diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index d0b215b..e75809a 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -42,6 +42,10 @@ pub enum Command { #[command(subcommand)] Code(CodeCommand), + /// Start and follow siGit Code Cloud Agent runs. + #[command(subcommand)] + Run(RunCommand), + /// Report and read commit statuses, the checks CI shows on a commit. #[command(subcommand)] Status(StatusCommand), @@ -222,6 +226,28 @@ pub enum CodeCommand { }, } +#[derive(clap::Subcommand)] +pub enum RunCommand { + /// List your agent runs, newest first. + List, + /// Show a run's status and the end of its transcript. + View { id: i64 }, + /// Start a run on a GitHub repository where the siGit Code app is + /// installed and your connected GitHub account can write. + Start { + /// OWNER/NAME of the GitHub repository. + #[arg(long)] + repo: String, + /// What the agent should do. + prompt: String, + }, + /// Ask a running agent to stop. + Cancel { id: i64 }, + /// Print the branch, prompt and command for continuing a run on your own + /// machine. + Handoff { id: i64 }, +} + #[derive(clap::Subcommand)] pub enum StatusCommand { /// Report a status on a commit. diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 6302aa3..6791be9 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -8,5 +8,6 @@ pub mod issue; pub mod me; pub mod pr; pub mod repo; +pub mod run; pub mod session; pub mod status; diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs new file mode 100644 index 0000000..b1368b2 --- /dev/null +++ b/crates/cli/src/commands/run.rs @@ -0,0 +1,57 @@ +use { + crate::{cli::RunCommand, context::Ctx, ui}, + anyhow::Result, +}; + +pub async fn run(ctx: &Ctx, command: RunCommand) -> Result<()> { + ctx.require_token()?; + + match command { + RunCommand::List => list(ctx).await, + RunCommand::View { id } => view(ctx, id).await, + RunCommand::Start { repo, prompt } => start(ctx, &repo, &prompt).await, + RunCommand::Cancel { id } => cancel(ctx, id).await, + RunCommand::Handoff { id } => handoff(ctx, id).await, + } +} + +async fn list(ctx: &Ctx) -> Result<()> { + let runs = ctx.client.agent_runs().await?; + ui::emit(ctx.json, &runs, |runs| ui::render::agent_runs(runs)) +} + +async fn view(ctx: &Ctx, id: i64) -> Result<()> { + let run = ctx.client.agent_run(id).await?; + ui::emit(ctx.json, &run, ui::render::agent_run_detail) +} + +async fn start(ctx: &Ctx, repo: &str, prompt: &str) -> Result<()> { + let slug = crate::git::RepoSlug::parse(repo)?; + let run = ctx + .client + .create_agent_run(&slug.to_string(), prompt) + .await?; + ui::emit(ctx.json, &run, |run| { + ui::success(format!( + "Started run #{} on {}.", + run.id, + ui::highlight(&run.repo_full_name) + )); + ui::note(format!("Follow it with `si run view {}`.", run.id)); + }) +} + +async fn cancel(ctx: &Ctx, id: i64) -> Result<()> { + let run = ctx.client.cancel_agent_run(id).await?; + ui::emit(ctx.json, &run, |run| { + ui::success(format!("Asked run #{} to stop.", run.id)); + }) +} + +/// The context is already JSON, so it prints the same with or without +/// `--json`. +async fn handoff(ctx: &Ctx, id: i64) -> Result<()> { + let context = ctx.client.agent_run_handoff(id).await?; + println!("{}", serde_json::to_string_pretty(&context)?); + Ok(()) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 857064c..7126295 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -46,6 +46,7 @@ async fn run() -> Result<()> { Command::Pr(command) => commands::pr::run(&ctx, command).await, Command::Issue(command) => commands::issue::run(&ctx, command).await, Command::Code(command) => commands::code::run(&ctx, command).await, + Command::Run(command) => commands::run::run(&ctx, command).await, Command::Status(command) => commands::status::run(&ctx, command).await, Command::Hook(command) => commands::hook::run(&ctx, command).await, Command::Session(command) => commands::session::run(&ctx, command).await, diff --git a/crates/cli/src/ui/render.rs b/crates/cli/src/ui/render.rs index 352db1e..9b503af 100644 --- a/crates/cli/src/ui/render.rs +++ b/crates/cli/src/ui/render.rs @@ -5,8 +5,8 @@ use { super::{bold, dim, highlight, relative_time, state_badge}, console::style, sigit_si_api::models::{ - Billing, CloudSession, CodeHit, CombinedStatus, Comment, Hook, Issue, IssueSummary, - PullRequest, PullRequestSummary, Repository, RepositorySummary, User, + AgentRun, Billing, CloudSession, CodeHit, CombinedStatus, Comment, Hook, Issue, + IssueSummary, PullRequest, PullRequestSummary, Repository, RepositorySummary, User, }, tabled::{builder::Builder, settings::Style}, }; @@ -401,6 +401,73 @@ pub fn combined_status(combined: &CombinedStatus) { ); } +// ── Agent runs ────────────────────────────────────────────────────────────── + +fn run_badge(status: &str) -> String { + match status { + "succeeded" | "completed" => style(status).green().to_string(), + "failed" | "errored" | "timed_out" => style(status).red().to_string(), + "running" | "queued" | "pending" => style(status).yellow().to_string(), + other => style(other).dim().to_string(), + } +} + +pub fn agent_runs(runs: &[AgentRun]) { + let rows = runs + .iter() + .map(|run| { + vec![ + run.id.to_string(), + truncate(run.title.as_deref().unwrap_or("(untitled)"), 40), + run.repo_full_name.clone(), + run_badge(&run.status), + relative_time(run.created_at), + ] + }) + .collect(); + + table( + &["ID", "Title", "Repository", "Status", "Started"], + rows, + "You have no agent runs yet. Start one with `si run start`.", + ); +} + +pub fn agent_run_detail(run: &AgentRun) { + println!(); + println!( + " {} {}", + highlight(run.title.as_deref().unwrap_or("(untitled run)")), + run_badge(&run.status) + ); + field("ID", Some(&run.id.to_string())); + field("Repository", Some(&run.repo_full_name)); + field("Triggered by", Some(&run.trigger_kind)); + field("Base", run.base_branch.as_deref()); + field("Branch", run.head_branch.as_deref()); + field("Pull request", run.pull_request_url.as_deref()); + field("Exit reason", run.exit_reason.as_deref()); + field("Requests", Some(&run.inference_requests_used.to_string())); + if run.cancel_requested { + field("Cancel", Some("requested")); + } + field("Created", Some(&relative_time(run.created_at))); + field("Finished", run.finished_at.map(relative_time).as_deref()); + println!(); + + if let Some(tail) = run + .transcript_tail + .as_deref() + .filter(|t| !t.trim().is_empty()) + { + println!(" {}", dim("Transcript (end)")); + for line in tail.lines() { + println!(" {line}"); + } + println!(); + } +} + // ── Billing ───────────────────────────────────────────────────────────────── pub fn billing(billing: &Billing) { diff --git a/crates/sigit-si-api/src/agent_runs.rs b/crates/sigit-si-api/src/agent_runs.rs new file mode 100644 index 0000000..bbdcb6c --- /dev/null +++ b/crates/sigit-si-api/src/agent_runs.rs @@ -0,0 +1,107 @@ +//! siGit Code Cloud Agent runs. + +use { + crate::{client::Client, error::Result, models::AgentRun}, + reqwest::{Method, RequestBuilder}, + serde::Deserialize, +}; + +#[derive(Deserialize)] +struct AgentRuns { + agent_runs: Vec, +} + +impl Client { + /// `GET /api/v1/agent/runs` — your runs, newest first. + pub async fn agent_runs(&self) -> Result> { + let runs: AgentRuns = self.get("agent/runs").await?; + Ok(runs.agent_runs) + } + + /// `GET /api/v1/agent/runs/:id` — one run, with the tail of its + /// transcript in `transcript_tail`. + pub async fn agent_run(&self, id: i64) -> Result { + self.get(&format!("agent/runs/{id}")).await + } + + /// `POST /api/v1/agent/runs` — start a run on a GitHub repository that has + /// the siGit Code GitHub App installed and that your connected GitHub + /// account can write to. + pub async fn create_agent_run(&self, repo_full_name: &str, prompt: &str) -> Result { + self.send(self.create_agent_run_request(repo_full_name, prompt)) + .await + } + + /// `POST /api/v1/agent/runs/:id/cancel` + pub async fn cancel_agent_run(&self, id: i64) -> Result { + self.send(self.request(Method::POST, &format!("agent/runs/{id}/cancel"))) + .await + } + + /// `GET /api/v1/agent/runs/:id/handoff` — the branch, prompt and command to + /// carry on with a run on your own machine. Returned as the server sent it. + pub async fn agent_run_handoff(&self, id: i64) -> Result { + self.get(&format!("agent/runs/{id}/handoff")).await + } + + fn create_agent_run_request(&self, repo_full_name: &str, prompt: &str) -> RequestBuilder { + self.request(Method::POST, "agent/runs") + .json(&serde_json::json!({ + "repo_full_name": repo_full_name, + "prompt": prompt, + })) + } +} + +#[cfg(test)] +mod tests { + use {super::*, crate::Environment}; + + fn client() -> Client { + Client::builder(Environment::Production) + .base_url("https://sigit.si") + .token("tok") + .build() + .unwrap() + } + + #[test] + fn a_run_is_started_with_the_repo_and_prompt() { + let request = client() + .create_agent_run_request("seto/demo", "Fix the build") + .build() + .unwrap(); + + assert_eq!(request.method(), Method::POST); + assert_eq!(request.url().as_str(), "https://sigit.si/api/v1/agent/runs"); + let body = request.body().and_then(|b| b.as_bytes()).unwrap(); + let body: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(body["repo_full_name"], "seto/demo"); + assert_eq!(body["prompt"], "Fix the build"); + } + + #[test] + fn the_list_is_unwrapped() { + let body = r#"{"agent_runs":[{"id":7,"title":null,"repo_full_name":"seto/demo", + "trigger_kind":"api","status":"running","cancel_requested":false, + "exit_reason":null,"base_branch":"main","head_branch":"sigit/x", + "pull_request_url":null,"issue_number":null,"pr_number":null, + "inference_requests_used":3,"created_at":"2026-10-01T10:00:00Z", + "started_at":null,"finished_at":null}]}"#; + let runs: AgentRuns = serde_json::from_str(body).unwrap(); + assert_eq!(runs.agent_runs[0].id, 7); + assert!(runs.agent_runs[0].transcript_tail.is_none()); + } + + #[test] + fn a_single_run_carries_its_transcript_tail() { + let body = r#"{"id":7,"title":"t","repo_full_name":"seto/demo","trigger_kind":"api", + "status":"succeeded","cancel_requested":false,"exit_reason":null, + "base_branch":null,"head_branch":null,"pull_request_url":null, + "issue_number":null,"pr_number":null,"inference_requests_used":0, + "created_at":"2026-10-01T10:00:00Z","started_at":null,"finished_at":null, + "transcript_tail":"line 1\nline 2\n"}"#; + let run: AgentRun = serde_json::from_str(body).unwrap(); + assert_eq!(run.transcript_tail.as_deref(), Some("line 1\nline 2\n")); + } +} diff --git a/crates/sigit-si-api/src/lib.rs b/crates/sigit-si-api/src/lib.rs index 1286c59..ff5062e 100644 --- a/crates/sigit-si-api/src/lib.rs +++ b/crates/sigit-si-api/src/lib.rs @@ -35,6 +35,8 @@ #[cfg(feature = "client")] pub mod account; #[cfg(feature = "client")] +pub mod agent_runs; +#[cfg(feature = "client")] pub mod billing; #[cfg(feature = "client")] pub mod client; diff --git a/crates/sigit-si-api/src/models.rs b/crates/sigit-si-api/src/models.rs index e9befa8..b7767aa 100644 --- a/crates/sigit-si-api/src/models.rs +++ b/crates/sigit-si-api/src/models.rs @@ -166,6 +166,34 @@ pub struct CombinedStatus { pub statuses: Vec, } +// ── Agent runs ────────────────────────────────────────────────────────────── + +/// A siGit Code Cloud Agent run. `transcript_tail` is only present on the +/// single-run read. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AgentRun { + pub id: i64, + pub title: Option, + pub repo_full_name: String, + pub trigger_kind: String, + pub status: String, + #[serde(default)] + pub cancel_requested: bool, + pub exit_reason: Option, + pub base_branch: Option, + pub head_branch: Option, + pub pull_request_url: Option, + pub issue_number: Option, + pub pr_number: Option, + #[serde(default)] + pub inference_requests_used: i64, + pub created_at: DateTime, + pub started_at: Option>, + pub finished_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transcript_tail: Option, +} + // ── MCP-backed resources ──────────────────────────────────────────────────── // // Issues, pull requests, and code search have no REST surface; they are served