Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions crates/cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
57 changes: 57 additions & 0 deletions crates/cli/src/commands/run.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
1 change: 1 addition & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 69 additions & 2 deletions crates/cli/src/ui/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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) {
Expand Down
107 changes: 107 additions & 0 deletions crates/sigit-si-api/src/agent_runs.rs
Original file line number Diff line number Diff line change
@@ -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<AgentRun>,
}

impl Client {
/// `GET /api/v1/agent/runs` — your runs, newest first.
pub async fn agent_runs(&self) -> Result<Vec<AgentRun>> {
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<AgentRun> {
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<AgentRun> {
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<AgentRun> {
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<serde_json::Value> {
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"));
}
}
2 changes: 2 additions & 0 deletions crates/sigit-si-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions crates/sigit-si-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,34 @@ pub struct CombinedStatus {
pub statuses: Vec<CommitStatus>,
}

// ── 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<String>,
pub repo_full_name: String,
pub trigger_kind: String,
pub status: String,
#[serde(default)]
pub cancel_requested: bool,
pub exit_reason: Option<String>,
pub base_branch: Option<String>,
pub head_branch: Option<String>,
pub pull_request_url: Option<String>,
pub issue_number: Option<i64>,
pub pr_number: Option<i64>,
#[serde(default)]
pub inference_requests_used: i64,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transcript_tail: Option<String>,
}

// ── MCP-backed resources ────────────────────────────────────────────────────
//
// Issues, pull requests, and code search have no REST surface; they are served
Expand Down
Loading