From 94b8aaead36a84e2da4d765bd66207874bd018ca Mon Sep 17 00:00:00 2001 From: Bradley Hilton Date: Wed, 2 Sep 2026 15:11:08 -0500 Subject: [PATCH] feat(agent): machine-readable `atomic agent status --json` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The human output is a report; there was no way to get the same facts as data. A tool that needs to *decide* something — "does this agent have hooks installed, and should I install them before recording a run?" — had to scrape the ✓/○ lines, which are prose and free to change. Emits every agent in the registry with `detected` and `hooks_installed`, keyed by the registry's own name. Absent agents are listed too: a caller choosing whether to install must tell "Atomic does not know this agent" from "known, and not set up", and an omitted entry cannot say which. Sorted, so diffing two runs shows real changes only. Sessions and totals carry the same detail `--verbose` prints, plus `sessions_error` when the session store cannot be read — the human output prints that and continues, and dropping it would make a broken store indistinguishable from an empty one. Follows `agent lifecycle`'s `--json`: same flag, `serde_json::to_string`, snake_case fields. --- README.md | 2 +- atomic-cli/src/commands/agent/status.rs | 273 +++++++++++++++++++++++- 2 files changed, 272 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9ac2d79b..e6198870 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,7 @@ Every change stores two parallel representations: |---------|-------------| | `atomic agent enable` | Install agent hooks (auto-detect or `--agent claude-code`) | | `atomic agent disable` | Remove agent hooks | -| `atomic agent status` | Show active sessions and hook status | +| `atomic agent status` | Show active sessions and hook status (`--json` for tooling) | | `atomic agent explain ` | Generate AI reasoning summary for a session | | `atomic agent attest` | List and inspect attestations | diff --git a/atomic-cli/src/commands/agent/status.rs b/atomic-cli/src/commands/agent/status.rs index c4b260d0..4a9a2540 100644 --- a/atomic-cli/src/commands/agent/status.rs +++ b/atomic-cli/src/commands/agent/status.rs @@ -13,9 +13,13 @@ //! //! # Show verbose status with session details //! atomic agent status --verbose +//! +//! # Machine-readable, for tools that gate on integration state +//! atomic agent status --json //! ``` use clap::Args; +use serde::Serialize; use atomic_agent::hooks::AgentRegistry; use atomic_agent::turn::session::SessionStore; @@ -37,13 +41,134 @@ pub struct AgentStatus { /// files touched, duration, and first prompt. #[arg(short, long)] verbose: bool, + + /// Print JSON. + /// + /// The human output is a report; this is the same facts as data, for + /// callers that need to *decide* something — a tool asking "does this agent + /// have hooks installed, and should I install them before recording a run?" + /// had no option but to scrape the ✓/○ lines, which are prose and free to + /// change. `--verbose` is ignored here: JSON always carries the full detail. + #[arg(long)] + json: bool, +} + +/// One agent the registry knows about, and where it stands in this repository. +#[derive(Debug, Serialize)] +struct AgentEntry { + /// Registry name, e.g. `claude-code`. The stable key to match on. + name: String, + display_name: String, + /// The agent's config was found in this repository. + detected: bool, + /// Atomic's hooks are installed for it. Without this its turns are not + /// recorded, which is the question most callers are actually asking. + hooks_installed: bool, +} + +#[derive(Debug, Serialize)] +struct SessionEntry { + session_id: String, + agent_display_name: String, + view: String, + phase: String, + model: String, + agent_vendor: String, + turn_count: u32, + files_touched: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + first_prompt: Option, + /// Not ended. Mirrors the ●/○ split in the human output. + active: bool, + duration: String, +} + +#[derive(Debug, Serialize)] +struct Totals { + sessions: usize, + turns: u32, + files_touched: usize, +} + +#[derive(Debug, Serialize)] +struct StatusJson { + agents: Vec, + sessions: Vec, + totals: Totals, + /// Why the session list is empty, when it is empty for a reason. The human + /// output prints this and carries on; dropping it from JSON would turn a + /// broken session store into an indistinguishable "no sessions". + #[serde(skip_serializing_if = "Option::is_none")] + sessions_error: Option, } impl AgentStatus { /// Create a default instance for testing. #[cfg(test)] pub(crate) fn default_for_test() -> Self { - Self { verbose: false } + Self { + verbose: false, + json: false, + } + } + + /// Build the JSON view of the same state the human output describes. + /// + /// Every agent in the registry appears, including ones that are neither + /// detected nor installed: a caller deciding whether to install needs to + /// distinguish "this agent is unknown to Atomic" from "known, and not set + /// up", and an omitted entry cannot say which. + fn to_json(&self, repo_root: &std::path::Path, registry: &AgentRegistry) -> StatusJson { + let installed = registry.installed(repo_root); + let detected = registry.detect(repo_root); + + let mut agents: Vec = registry + .list() + .into_iter() + .map(|name| AgentEntry { + display_name: registry + .get(name) + .map_or_else(|| name.to_string(), |a| a.display_name().to_string()), + detected: detected.contains(&name), + hooks_installed: installed.contains(&name), + name: name.to_string(), + }) + .collect(); + agents.sort_by(|a, b| a.name.cmp(&b.name)); + + let (sessions, sessions_error) = + match SessionStore::for_repo(repo_root).and_then(|store| store.list()) { + Ok(list) => (list, None), + Err(e) => (Vec::new(), Some(e.to_string())), + }; + + let totals = Totals { + sessions: sessions.len(), + turns: sessions.iter().map(|s| s.turn_count).sum(), + files_touched: sessions.iter().map(|s| s.files_touched.len()).sum(), + }; + + StatusJson { + agents, + sessions: sessions + .into_iter() + .map(|s| SessionEntry { + active: !s.is_ended(), + duration: s.duration_display(), + session_id: s.session_id, + agent_display_name: s.agent_display_name, + view: s.view_name, + phase: s.phase.to_string(), + model: s.model, + agent_vendor: s.agent_vendor, + turn_count: s.turn_count, + files_touched: s.files_touched, + first_prompt: s.first_prompt, + }) + .collect(), + totals, + sessions_error, + } } } @@ -53,6 +178,15 @@ impl Command for AgentStatus { let registry = AgentRegistry::with_defaults(); + if self.json { + println!( + "{}", + serde_json::to_string(&self.to_json(&repo_root, ®istry)) + .expect("status is plain data and always serializes") + ); + return Ok(()); + } + // Installed agents println!("Agent Integration Status"); @@ -273,10 +407,145 @@ mod tests { #[test] fn test_status_verbose_flag() { - let cmd = AgentStatus { verbose: true }; + let cmd = AgentStatus { + verbose: true, + json: false, + }; assert!(cmd.verbose); } + fn json_cmd() -> AgentStatus { + AgentStatus { + verbose: false, + json: true, + } + } + + fn temp_repo() -> tempfile::TempDir { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".atomic")).unwrap(); + dir + } + + /// Every registry agent is listed, whatever its state. A caller deciding + /// whether to install has to tell "Atomic does not know this agent" from + /// "known, and not set up", and an omitted entry cannot say which. + /// + /// Deliberately does not assert that a bare repo detects nothing: several + /// agents are configured in `$HOME` rather than the repository, so what a + /// fixture detects depends on the machine running the test. + #[test] + fn json_lists_every_registry_agent() { + let dir = temp_repo(); + let registry = AgentRegistry::with_defaults(); + let out = json_cmd().to_json(dir.path(), ®istry); + + assert_eq!(out.agents.len(), registry.list().len()); + assert!( + out.agents.iter().any(|a| a.name == "claude-code"), + "the registry's own names must be the keys callers match on" + ); + + let mut names: Vec<&str> = out.agents.iter().map(|a| a.name.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), out.agents.len(), "no agent listed twice"); + } + + /// The two flags must say what the registry says — that is the whole + /// contract, since callers gate installs on `hooks_installed`. + #[test] + fn json_flags_agree_with_the_registry() { + let dir = temp_repo(); + let registry = AgentRegistry::with_defaults(); + let installed = registry.installed(dir.path()); + let detected = registry.detect(dir.path()); + + for entry in json_cmd().to_json(dir.path(), ®istry).agents { + assert_eq!( + entry.hooks_installed, + installed.contains(&entry.name.as_str()), + "hooks_installed disagrees for {}", + entry.name + ); + assert_eq!( + entry.detected, + detected.contains(&entry.name.as_str()), + "detected disagrees for {}", + entry.name + ); + } + } + + /// Stable order, so a caller diffing two runs sees real changes only. + #[test] + fn json_agents_are_sorted_by_name() { + let dir = temp_repo(); + let out = json_cmd().to_json(dir.path(), &AgentRegistry::with_defaults()); + + let names: Vec<&str> = out.agents.iter().map(|a| a.name.as_str()).collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted); + } + + #[test] + fn json_reports_sessions_totals_and_which_are_active() { + use atomic_agent::turn::session::AgentSession; + + let dir = temp_repo(); + let store = SessionStore::for_repo(dir.path()).unwrap(); + + store + .save(&AgentSession::new( + "sess-live", + "claude-code", + "Claude Code", + )) + .unwrap(); + + let mut ended = AgentSession::new("sess-done", "claude-code", "Claude Code"); + ended.phase = atomic_agent::turn::phase::Phase::Ended; + ended.ended_at = Some(chrono::Utc::now()); + ended.turn_count = 4; + store.save(&ended).unwrap(); + + let out = json_cmd().to_json(dir.path(), &AgentRegistry::with_defaults()); + + assert_eq!(out.totals.sessions, 2); + assert_eq!(out.totals.turns, 4); + assert!(out.sessions_error.is_none()); + + let live = out + .sessions + .iter() + .find(|s| s.session_id == "sess-live") + .expect("the active session is present"); + assert!(live.active); + let done = out + .sessions + .iter() + .find(|s| s.session_id == "sess-done") + .expect("the ended session is present"); + assert!(!done.active); + assert_eq!(done.turn_count, 4); + } + + /// The payload has to round-trip as JSON, since that is the only reason it + /// exists — and `to_string` is called with `expect` in the command. + #[test] + fn json_payload_serializes() { + let dir = temp_repo(); + let out = json_cmd().to_json(dir.path(), &AgentRegistry::with_defaults()); + let text = serde_json::to_string(&out).expect("serializes"); + + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert!(parsed["agents"].is_array()); + assert!(parsed["totals"]["sessions"].is_number()); + // Absent rather than null, so consumers can test for presence. + assert!(parsed.get("sessions_error").is_none()); + } + #[test] fn test_session_store_for_temp_repo() { let dir = tempfile::TempDir::new().unwrap();