Skip to main content

ggen_cli_lib/cmds/
agent.rs

1//! Agent noun — the AGI-facing CLI surface over `ggen_core::agent::PackAgent`.
2//!
3//! `ggen agent <verb>` is the third transport over the one authoritative pack
4//! lifecycle facade, alongside the Rust library API (`ggen_core::agent`) and the
5//! `ggen.packs.*` MCP/A2A tools (`ggen-a2a-mcp`). Every verb emits structured
6//! JSON an autonomous agent can parse and chain, covering the full
7//! project-bring-up lifecycle an AGI drives to complete a project:
8//!
9//! ```text
10//!   capabilities → search / list / show → resolve → compatibility
11//!       → install → status → verify        (and remove)
12//! ```
13//!
14//! Because every verb routes through the same `PackAgent` the MCP/A2A surface
15//! uses — not a parallel implementation — the three transports cannot drift, and
16//! the durable-state contract (lockfile entry with a non-empty digest, a signed
17//! provenance receipt) and the fail-closed error behaviour are identical
18//! everywhere.
19
20use clap_noun_verb::{NounVerbError, Result};
21use clap_noun_verb_macros::verb;
22
23use ggen_core::agent::{InstallRequest, PackAgent};
24
25// ── helpers ─────────────────────────────────────────────────────────────────
26
27/// Construct an agent rooted at the current working directory (the project root
28/// for a CLI invocation, matching where `install` writes the lockfile).
29fn agent() -> Result<PackAgent> {
30    PackAgent::new()
31        .map_err(|e| NounVerbError::execution_error(format!("agent init failed: {}", e)))
32}
33
34/// Construct an agent at an explicit `--root`, or the current directory if none
35/// is given. Used by the read-only `status` / `verify` verbs.
36fn agent_at(root: Option<String>) -> Result<PackAgent> {
37    match root {
38        Some(r) => Ok(PackAgent::at_root(r)),
39        None => agent(),
40    }
41}
42
43/// Lift a facade error into a CLI error, preserving the message.
44fn lift<T>(r: ggen_core::agent::AgentResult<T>) -> Result<T> {
45    r.map_err(|e| NounVerbError::execution_error(e.to_string()))
46}
47
48/// Serialize a facade outcome to JSON so every verb emits a uniform,
49/// agent-parseable result.
50fn json<T: serde::Serialize>(value: T) -> Result<serde_json::Value> {
51    serde_json::to_value(value)
52        .map_err(|e| NounVerbError::execution_error(format!("serialization failed: {}", e)))
53}
54
55// ── discovery (read-only) ───────────────────────────────────────────────────
56
57/// Describe the agent's operations and capability surfaces — the discovery entry
58/// point an agent calls first to learn the contract.
59#[verb]
60pub fn capabilities() -> Result<serde_json::Value> {
61    json(agent()?.capabilities())
62}
63
64/// Relevance-rank packs in the local registry by a text query.
65#[verb]
66pub fn search(#[arg(index = 1)] query: String, limit: Option<usize>) -> Result<serde_json::Value> {
67    json(lift(agent()?.search(&query, limit))?)
68}
69
70/// List all packs in the local registry, optionally filtered by category.
71#[verb]
72pub fn list(category: Option<String>) -> Result<serde_json::Value> {
73    json(lift(agent()?.list(category.as_deref()))?)
74}
75
76/// Full detail for one pack: metadata, packages, templates, dependencies, and
77/// the validation (quality-gate) result.
78#[verb]
79pub fn show(#[arg(index = 1)] pack_id: String) -> Result<serde_json::Value> {
80    json(lift(agent()?.show(&pack_id))?)
81}
82
83/// Resolve a capability surface (e.g. `mcp`, `web`) to concrete pack IDs,
84/// optionally narrowed by `--projection` and `--runtime`.
85#[verb]
86pub fn resolve(
87    #[arg(index = 1)] surface: String, projection: Option<String>, runtime: Option<String>,
88) -> Result<serde_json::Value> {
89    json(lift(agent()?.resolve_capability(
90        &surface,
91        projection.as_deref(),
92        runtime.as_deref(),
93    ))?)
94}
95
96/// Check whether a comma-separated set of packs composes without conflicts
97/// (overlapping packages or unloadable packs). The pre-flight before a
98/// multi-pack install.
99#[verb]
100pub fn compatibility(#[arg(index = 1)] packs: String) -> Result<serde_json::Value> {
101    let a = agent()?;
102    let ids: Vec<String> = packs
103        .split(',')
104        .map(|s| s.trim().to_string())
105        .filter(|s| !s.is_empty())
106        .collect();
107    let res = crate::runtime::block_on(a.check_compatibility(&ids))
108        .map_err(|e| NounVerbError::execution_error(e.to_string()))?;
109    json(lift(res)?)
110}
111
112// ── installed state / provenance (read-only) ────────────────────────────────
113
114/// Report installed packs from the project lockfile (`--root` to inspect another
115/// project; default is the current directory).
116#[verb]
117pub fn status(root: Option<String>) -> Result<serde_json::Value> {
118    json(lift(agent_at(root)?.status())?)
119}
120
121/// Verify a provenance receipt against its signing key. Fail-closed: a missing
122/// key, malformed receipt, or bad signature yields `is_valid: false`.
123#[verb]
124pub fn verify(
125    #[arg(index = 1)] receipt_path: String, root: Option<String>,
126) -> Result<serde_json::Value> {
127    json(agent_at(root)?.verify(&receipt_path))
128}
129
130// ── mutating lifecycle ──────────────────────────────────────────────────────
131
132/// Install a pack: write the lockfile with a non-empty digest and emit a signed
133/// provenance receipt. `--dry_run` previews without writing durable state.
134#[verb]
135pub fn install(
136    #[arg(index = 1)] pack_id: String, force: Option<bool>, dry_run: Option<bool>,
137) -> Result<serde_json::Value> {
138    let a = agent()?;
139    let req = InstallRequest {
140        pack_id,
141        force: force.unwrap_or(false),
142        dry_run: dry_run.unwrap_or(false),
143        emit_receipt: true,
144    };
145    let res = crate::runtime::block_on(a.install(req))
146        .map_err(|e| NounVerbError::execution_error(e.to_string()))?;
147    json(lift(res)?)
148}
149
150/// Remove a pack from the project lockfile. Fail-closed: a missing lockfile or
151/// an absent pack errors and leaves the lockfile intact.
152#[verb]
153pub fn remove(#[arg(index = 1)] pack_id: String) -> Result<serde_json::Value> {
154    json(lift(agent()?.remove(&pack_id))?)
155}