From 4165303aa44fc017952669e317c5d489909ede5f Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 10:32:37 +0900 Subject: [PATCH 1/9] feat(template): add agent-describe dependency and typed response structs - Add agent-describe (path dep) and schemars to template/Cargo.toml - Create template/src/response.rs with result types for all commands - Export response module from template/src/lib.rs Co-Authored-By: Claude Opus 4.6 --- template/Cargo.toml | 2 ++ template/src/lib.rs | 1 + template/src/response.rs | 52 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 template/src/response.rs diff --git a/template/Cargo.toml b/template/Cargo.toml index 8bcc14c..f92d0e7 100644 --- a/template/Cargo.toml +++ b/template/Cargo.toml @@ -15,12 +15,14 @@ nix = { version = "0.30", features = ["signal"] } reqwest = { version = "0.13", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +schemars = "1" snafu = "0.9" tempfile = "3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "time", "sync"] } toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +agent-describe = { path = "/Users/ryan/code/rararulab/agent-describe/agent-describe" } [dev-dependencies] assert_cmd = "2" diff --git a/template/src/lib.rs b/template/src/lib.rs index 16a81cc..90b9fcd 100644 --- a/template/src/lib.rs +++ b/template/src/lib.rs @@ -4,3 +4,4 @@ pub mod cli; pub mod error; pub mod http; pub mod paths; +pub mod response; diff --git a/template/src/response.rs b/template/src/response.rs new file mode 100644 index 0000000..de4ff94 --- /dev/null +++ b/template/src/response.rs @@ -0,0 +1,52 @@ +//! Typed response structs for each CLI command. +//! +//! Naming convention: command `Foo` → `FooResult`. +//! Each type derives `Serialize` + `JsonSchema` for agent-describe schema generation. + +use schemars::JsonSchema; +use serde::Serialize; + +/// Result of the `hello` command. +#[derive(Debug, Serialize, JsonSchema)] +pub struct HelloResult { + /// The greeting message. + pub greeting: String, +} + +/// Result of `config set`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ConfigSetResult { + /// The key that was set. + pub key: String, + /// The value that was set. + pub value: String, +} + +/// Result of `config get`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ConfigGetResult { + /// The config key. + pub key: String, + /// The config value, or null if not set. + pub value: Option, +} + +/// Result of `config list`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ConfigListResult { + /// All config entries as key-value pairs. + pub entries: std::collections::BTreeMap, +} + +/// Result of the `agent` command. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AgentRunResult { + /// Whether the agent execution succeeded. + pub success: bool, + /// Process exit code. + pub exit_code: Option, + /// Whether the agent timed out. + pub timed_out: bool, + /// Agent output text. + pub output: String, +} From 372e84b8d68e215198eceaf5a7e357b51d6d10f9 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 10:37:30 +0900 Subject: [PATCH 2/9] feat(template): integrate agent-describe into template with typed responses - Add AgentDescribe derive and #[agent(cli = Cli)] to Command enum - Add hidden --agent-describe flag to Cli struct - Replace all ad-hoc serde_json::json!() output with typed AgentResponse - Wire #[agent(output = ...)] annotations for Hello and Agent variants Co-Authored-By: Claude Opus 4.6 --- template/src/cli/mod.rs | 12 +++++-- template/src/main.rs | 71 ++++++++++++++++++++--------------------- 2 files changed, 44 insertions(+), 39 deletions(-) diff --git a/template/src/cli/mod.rs b/template/src/cli/mod.rs index c505ce0..b1dbd1f 100644 --- a/template/src/cli/mod.rs +++ b/template/src/cli/mod.rs @@ -1,13 +1,18 @@ //! CLI command definitions and subcommand modules. +use agent_describe::AgentDescribe; use clap::{Parser, Subcommand}; /// Your CLI application — update this doc comment. #[derive(Parser)] #[command(name = "{{project-name}}", version)] pub struct Cli { + /// Output agent-describe schema (for AI agent discovery) + #[arg(long, hide = true)] + pub agent_describe: bool, + #[command(subcommand)] - pub command: Command, + pub command: Option, } /// Available subcommands. @@ -16,13 +21,15 @@ pub struct Cli { /// - JSON on stdout, human text on stderr /// - Every error includes a `suggestion` field /// - All parameters passable via flags (non-interactive) -#[derive(Subcommand)] +#[derive(Subcommand, AgentDescribe)] +#[agent(cli = Cli)] pub enum Command { /// Say hello (example command — replace with your own) #[command(after_help = "\ EXAMPLES: {{project-name}} hello {{project-name}} hello Alice")] + #[agent(output = crate::response::HelloResult)] Hello { /// Name to greet #[arg(default_value = "world")] @@ -45,6 +52,7 @@ EXAMPLES: EXAMPLES: {{project-name}} agent \"explain this codebase\" {{project-name}} agent --backend codex \"refactor main.rs\"")] + #[agent(output = crate::response::AgentRunResult)] Agent { /// The prompt to send to the agent prompt: String, diff --git a/template/src/main.rs b/template/src/main.rs index ceee2ac..6aee4ec 100644 --- a/template/src/main.rs +++ b/template/src/main.rs @@ -1,9 +1,11 @@ +use agent_describe::AgentResponse; use clap::Parser; use snafu::ResultExt; use {{crate_name}}::app_config; use {{crate_name}}::cli::{Cli, Command, ConfigAction}; use {{crate_name}}::error::{self, AgentBackendSnafu, AgentExecutionSnafu, ConfigSnafu, IoSnafu}; +use {{crate_name}}::response::{AgentRunResult, ConfigGetResult, ConfigListResult, ConfigSetResult, HelloResult}; #[tokio::main] async fn main() { @@ -16,10 +18,11 @@ async fn main() { if let Err(e) = run().await { eprintln!("Error: {e}"); - println!( - "{}", - serde_json::json!({"ok": false, "error": e.to_string(), "suggestion": "check --help for usage"}) - ); + AgentResponse::<()>::err( + e.to_string(), + Some("check --help or --agent-describe for usage"), + ) + .print(); std::process::exit(1); } } @@ -27,47 +30,44 @@ async fn main() { async fn run() -> error::Result<()> { let cli = Cli::parse(); - match cli.command { + if cli.agent_describe { + let schema = Command::agent_schema(); + println!("{}", serde_json::to_string_pretty(&schema).expect("schema serialization should not fail")); + return Ok(()); + } + + let command = cli.command.ok_or_else(|| { + ConfigSnafu { + message: "no command specified — try --help or --agent-describe".to_string(), + } + .build() + })?; + + match command { Command::Config { action } => match action { ConfigAction::Set { key, value } => { let mut cfg = app_config::load().clone(); set_config_field(&mut cfg, &key, &value)?; app_config::save(&cfg).context(IoSnafu)?; eprintln!("set {key} = {value}"); - println!( - "{}", - serde_json::json!({"ok": true, "action": "config_set", "key": key, "value": value}) - ); + AgentResponse::ok(ConfigSetResult { key, value }).print(); } ConfigAction::Get { key } => { let cfg = app_config::load(); let value = get_config_field(cfg, &key)?; - let display_value = value.as_deref().unwrap_or("(not set)"); - println!( - "{}", - serde_json::json!({"ok": true, "action": "config_get", "key": key, "value": display_value}) - ); + AgentResponse::ok(ConfigGetResult { key, value }).print(); } ConfigAction::List => { let cfg = app_config::load(); - let entries = config_as_map(cfg); - let map: serde_json::Map = entries - .into_iter() - .map(|(k, v)| (k, serde_json::Value::String(v))) - .collect(); - println!( - "{}", - serde_json::json!({"ok": true, "action": "config_list", "entries": map}) - ); + let entries: std::collections::BTreeMap = + config_as_map(cfg).into_iter().collect(); + AgentResponse::ok(ConfigListResult { entries }).print(); } }, Command::Hello { name } => { let greeting = format!("Hello, {name}!"); eprintln!("{greeting}"); - println!( - "{}", - serde_json::json!({"ok": true, "action": "hello", "greeting": greeting}) - ); + AgentResponse::ok(HelloResult { greeting }).print(); } Command::Agent { prompt, backend } => { use {{crate_name}}::agent::{CliBackend, CliExecutor}; @@ -99,16 +99,13 @@ async fn run() -> error::Result<()> { eprint!("{}", result.stderr); } - println!( - "{}", - serde_json::json!({ - "ok": result.success, - "action": "agent_run", - "exit_code": result.exit_code, - "timed_out": result.timed_out, - "output": result.output, - }) - ); + AgentResponse::ok(AgentRunResult { + success: result.success, + exit_code: result.exit_code, + timed_out: result.timed_out, + output: result.output, + }) + .print(); } } From 0169200de07772c4e4c10f2eeebb605acfa3d20f Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 10:38:32 +0900 Subject: [PATCH 3/9] feat: update post-setup prompt to mention agent-describe Co-Authored-By: Claude Opus 4.6 --- src/post_setup.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/post_setup.rs b/src/post_setup.rs index 69db449..02c0111 100644 --- a/src/post_setup.rs +++ b/src/post_setup.rs @@ -86,6 +86,8 @@ fn print_agent_prompt(project_name: &str, project_dir: &Path) { eprintln!("1. Update CLAUDE.md with the project description"); eprintln!("2. Replace the Hello example command with actual CLI commands"); eprintln!("3. Customize ExampleConfig in src/app_config.rs"); - eprintln!("4. Run `just pre-commit` to verify everything passes"); + eprintln!("4. Test agent discovery: `{project_name} --agent-describe`"); + eprintln!("5. Add new commands: define in cli/mod.rs, add {{Name}}Result in response.rs"); + eprintln!("6. Run `just pre-commit` to verify everything passes"); eprintln!("---"); } From 85f2b789d079bfdd3b002e16aedfce0c7c9be1db Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 10:38:44 +0900 Subject: [PATCH 4/9] test: add integration test for --agent-describe output Co-Authored-By: Claude Opus 4.6 --- template/Cargo.toml | 1 + template/tests/cli_test.rs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/template/Cargo.toml b/template/Cargo.toml index f92d0e7..7787510 100644 --- a/template/Cargo.toml +++ b/template/Cargo.toml @@ -27,6 +27,7 @@ agent-describe = { path = "/Users/ryan/code/rararulab/agent-describe/agent-descr [dev-dependencies] assert_cmd = "2" predicates = "3" +serde_json = "1" [lints.rust] unsafe_code = "deny" diff --git a/template/tests/cli_test.rs b/template/tests/cli_test.rs index f15f013..15b622c 100644 --- a/template/tests/cli_test.rs +++ b/template/tests/cli_test.rs @@ -80,3 +80,19 @@ fn config_get_unknown_key_fails() { .failure() .stdout(predicate::str::contains(r#""ok":false"#)); } + +#[test] +fn agent_describe_outputs_valid_schema() { + let assert = cmd() + .arg("--agent-describe") + .assert() + .success(); + + let output = assert.get_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + let schema: serde_json::Value = serde_json::from_str(&stdout) + .expect("--agent-describe should output valid JSON"); + + assert_eq!(schema["protocol"], "agent-cli/1"); + assert!(schema["commands"].as_array().unwrap().len() > 0); +} From 2dce6e2403e006ae8fc99406d39b550a6a1d9a3d Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 10:38:51 +0900 Subject: [PATCH 5/9] docs: document agent-cli/1 protocol in template Co-Authored-By: Claude Opus 4.6 --- template/CLAUDE.md | 8 ++++++++ template/README.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/template/CLAUDE.md b/template/CLAUDE.md index cae8364..7626dd0 100644 --- a/template/CLAUDE.md +++ b/template/CLAUDE.md @@ -23,6 +23,14 @@ All changes — no matter how small — follow the issue → worktree → PR → @docs/guides/anti-patterns.md +## Agent Protocol + +This CLI implements `agent-cli/1`. Run `{{project-name}} --agent-describe` to get the full schema. +- All command outputs are typed structs with `#[derive(Serialize, JsonSchema)]` +- Response wrapper: `AgentResponse::ok(data)` / `AgentResponse::err(msg, suggestion)` +- Naming convention: command `Foo` → response type `FooResult` in `src/response.rs` +- Add `#[agent(skip)]` to exclude commands, `#[agent(output = T)]` to override convention + ## Agent Quickstart How to initialize a project from this template and add features to it. diff --git a/template/README.md b/template/README.md index 6497e2b..51da966 100644 --- a/template/README.md +++ b/template/README.md @@ -120,6 +120,38 @@ This template follows [rararulab agent-friendly CLI standards](https://github.co - **Non-interactive** — every parameter passable via flags - **Example-driven help** — each subcommand shows runnable examples in `--help` +## Agent Integration + +This CLI implements the `agent-cli/1` protocol, making it self-describing for AI agents. + +### Discovery + +```bash +{{project-name}} --agent-describe +``` + +Outputs a JSON schema describing all commands, their arguments, and response formats. Any AI agent can use this to learn the CLI's capabilities without reading documentation. + +### Response Format + +All commands output structured JSON to stdout: + +```json +{"ok": true, "data": {"field": "value"}} +``` + +Errors include self-correction hints: + +```json +{"ok": false, "error": "message", "suggestion": "try this instead"} +``` + +### Adding New Commands + +1. Add the command variant to `src/cli/mod.rs` +2. Create `{CommandName}Result` struct in `src/response.rs` with `#[derive(Serialize, JsonSchema)]` +3. Use `AgentResponse::ok(result).print()` in the handler + ## Project Structure ``` From d7bb17cd87b6fe885b3940cd120a82a93b6e6884 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 10:40:59 +0900 Subject: [PATCH 6/9] fix(test): update config_list assertion for AgentResponse format --- template/tests/cli_test.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/template/tests/cli_test.rs b/template/tests/cli_test.rs index 15b622c..42652b5 100644 --- a/template/tests/cli_test.rs +++ b/template/tests/cli_test.rs @@ -34,7 +34,8 @@ fn config_list() { .args(["config", "list"]) .assert() .success() - .stdout(predicate::str::contains(r#""action":"config_list"#)); + .stdout(predicate::str::contains(r#""ok":true"#)) + .stdout(predicate::str::contains(r#""entries""#)); } #[test] From e248d8370427316d52c56d186686850bcc6cf3d3 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 10:50:03 +0900 Subject: [PATCH 7/9] refactor: move agent-describe into workspace as crate members Add agent-describe and agent-describe-derive under crates/ as workspace members. Configure release-plz to publish the library crates while keeping the binary crate unpublished. Update template/Cargo.toml to use a version dependency instead of an absolute path. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 83 +++++ Cargo.toml | 4 + crates/agent-describe-derive/Cargo.toml | 15 + crates/agent-describe-derive/src/lib.rs | 327 +++++++++++++++++++ crates/agent-describe/Cargo.toml | 14 + crates/agent-describe/examples/demo_cli.rs | 93 ++++++ crates/agent-describe/src/lib.rs | 7 + crates/agent-describe/src/response.rs | 45 +++ crates/agent-describe/src/schema.rs | 81 +++++ crates/agent-describe/tests/derive_test.rs | 197 +++++++++++ crates/agent-describe/tests/response_test.rs | 35 ++ release-plz.toml | 11 +- template/Cargo.toml | 2 +- 13 files changed, 912 insertions(+), 2 deletions(-) create mode 100644 crates/agent-describe-derive/Cargo.toml create mode 100644 crates/agent-describe-derive/src/lib.rs create mode 100644 crates/agent-describe/Cargo.toml create mode 100644 crates/agent-describe/examples/demo_cli.rs create mode 100644 crates/agent-describe/src/lib.rs create mode 100644 crates/agent-describe/src/response.rs create mode 100644 crates/agent-describe/src/schema.rs create mode 100644 crates/agent-describe/tests/derive_test.rs create mode 100644 crates/agent-describe/tests/response_test.rs diff --git a/Cargo.lock b/Cargo.lock index 2a25ee7..b1f4322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "agent-describe" +version = "0.1.0" +dependencies = [ + "agent-describe-derive", + "clap", + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "agent-describe-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -169,6 +189,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "equivalent" version = "1.0.2" @@ -440,6 +466,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -482,6 +528,31 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "semver" version = "1.0.27" @@ -495,6 +566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -517,6 +589,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/Cargo.toml b/Cargo.toml index 020c418..effa492 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +[workspace] +members = ["crates/agent-describe", "crates/agent-describe-derive"] +resolver = "2" + [package] name = "rara-cli-template" version = "0.1.0" diff --git a/crates/agent-describe-derive/Cargo.toml b/crates/agent-describe-derive/Cargo.toml new file mode 100644 index 0000000..88b43c6 --- /dev/null +++ b/crates/agent-describe-derive/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "agent-describe-derive" +version = "0.1.0" +edition = "2024" +description = "Derive macro for agent-describe" +license = "MIT" +repository = "https://github.com/rararulab/agent-describe" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } diff --git a/crates/agent-describe-derive/src/lib.rs b/crates/agent-describe-derive/src/lib.rs new file mode 100644 index 0000000..b7a5f30 --- /dev/null +++ b/crates/agent-describe-derive/src/lib.rs @@ -0,0 +1,327 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, DeriveInput, Data, Fields, Type, Attribute, Meta, Expr, ExprLit, Lit}; + +/// Derive `AgentDescribe` for a Clap `Subcommand` enum. +/// +/// Generates `fn agent_schema() -> serde_json::Value` that introspects +/// enum variants and produces an agent-cli/1 protocol schema. +#[proc_macro_derive(AgentDescribe, attributes(agent))] +pub fn derive_agent_describe(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let enum_name = &input.ident; + + let cli_type = extract_cli_type(&input.attrs) + .expect("#[agent(cli = CliType)] is required on the enum"); + + let data = match &input.data { + Data::Enum(data) => data, + _ => panic!("AgentDescribe can only be derived for enums"), + }; + + let mut command_tokens = Vec::new(); + + for variant in &data.variants { + if has_agent_attr(&variant.attrs, "skip") { + continue; + } + + let variant_name = &variant.ident; + let kebab_name = to_kebab_case(&variant_name.to_string()); + let doc = extract_doc_comment(&variant.attrs); + + // Check if this variant has a #[command(subcommand)] field + if has_clap_subcommand(&variant.fields) { + // Subcommand variant: flatten via Clap runtime introspection + command_tokens.push(quote! { + { + let root_cmd = <#cli_type as ::clap::CommandFactory>::command(); + if let Some(parent) = root_cmd.get_subcommands() + .find(|c| c.get_name() == #kebab_name) + { + for sub in parent.get_subcommands() { + let sub_name = sub.get_name().to_string(); + let sub_desc = sub.get_about() + .map(|s| s.to_string()) + .unwrap_or_default(); + let sub_args = ::agent_describe::args_from_clap_command(sub); + commands.push(::agent_describe::CommandSchema { + name: format!("{} {}", #kebab_name, sub_name), + description: sub_desc, + args: sub_args, + output: None, + }); + } + } + } + }); + } else { + // Regular variant: extract fields as args from macro analysis + let output_type = extract_output_type(&variant.attrs).unwrap_or_else(|| { + let result_name = syn::Ident::new( + &format!("{}Result", variant_name), + variant_name.span(), + ); + quote! { #result_name } + }); + + let mut arg_tokens = Vec::new(); + + if let Fields::Named(fields) = &variant.fields { + for field in &fields.named { + let field_name = field.ident.as_ref().unwrap().to_string(); + let field_doc = extract_doc_comment(&field.attrs); + let is_long = has_clap_long(&field.attrs); + let is_bool = is_bool_type(&field.ty); + let is_option = is_option_type(&field.ty); + + let display_name = if is_long { + let kebab = to_kebab_case(&field_name); + format!("--{}", kebab) + } else { + field_name.clone() + }; + + let type_str = if is_bool { "bool" } else { "string" }; + let required = !is_bool && !is_option; + + arg_tokens.push(quote! { + ::agent_describe::ArgSchema { + name: #display_name.to_string(), + r#type: #type_str.to_string(), + required: #required, + description: #field_doc.to_string(), + r#enum: None, + } + }); + } + } + + command_tokens.push(quote! { + { + let output_schema = { + let schema = ::schemars::generate::SchemaSettings::draft2020_12() + .into_generator() + .into_root_schema_for::<#output_type>(); + Some(::serde_json::to_value(schema).unwrap()) + }; + commands.push(::agent_describe::CommandSchema { + name: #kebab_name.to_string(), + description: #doc.to_string(), + args: vec![#(#arg_tokens),*], + output: output_schema, + }); + } + }); + } + } + + let expanded = quote! { + impl #enum_name { + /// Generate the agent-cli/1 protocol schema for this command enum. + pub fn agent_schema() -> ::serde_json::Value { + let root_cmd = <#cli_type as ::clap::CommandFactory>::command(); + let name = root_cmd.get_name().to_string(); + let version = root_cmd.get_version() + .map(|s| s.to_string()) + .unwrap_or_default(); + let description = root_cmd.get_about() + .map(|s| s.to_string()) + .unwrap_or_default(); + + let mut commands: Vec<::agent_describe::CommandSchema> = Vec::new(); + #(#command_tokens)* + + let schema = ::agent_describe::AgentSchema { + protocol: "agent-cli/1", + name, + version, + description, + commands, + error_format: ::agent_describe::AgentSchema::default_error_format(), + }; + + ::serde_json::to_value(schema).unwrap() + } + } + }; + + TokenStream::from(expanded) +} + +/// Collect `#[doc = "..."]` attributes into a single trimmed string. +fn extract_doc_comment(attrs: &[Attribute]) -> String { + let lines: Vec = attrs + .iter() + .filter_map(|attr| { + if !attr.path().is_ident("doc") { + return None; + } + if let Meta::NameValue(nv) = &attr.meta + && let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = &nv.value + { + return Some(s.value().trim().to_string()); + } + None + }) + .collect(); + lines.join(" ") +} + +/// Convert PascalCase to kebab-case (e.g., "DeployApp" → "deploy-app"). +fn to_kebab_case(s: &str) -> String { + let mut result = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + // Only add hyphen if previous char was lowercase or next is lowercase + result.push('-'); + } + result.push(c.to_lowercase().next().unwrap()); + } else { + // Convert underscores to hyphens + if c == '_' { + result.push('-'); + } else { + result.push(c); + } + } + } + result +} + +/// Check whether `#[agent(name)]` is present in attributes. +fn has_agent_attr(attrs: &[Attribute], name: &str) -> bool { + attrs.iter().any(|attr| { + if !attr.path().is_ident("agent") { + return false; + } + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident(name) { + found = true; + } + Ok(()) + }); + found + }) +} + +/// Extract `Type` from `#[agent(cli = Type)]`. +fn extract_cli_type(attrs: &[Attribute]) -> Option { + for attr in attrs { + if !attr.path().is_ident("agent") { + continue; + } + let mut result = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("cli") { + let value = meta.value()?; + let ty: Type = value.parse()?; + result = Some(quote! { #ty }); + } + Ok(()) + }); + if result.is_some() { + return result; + } + } + None +} + +/// Extract `Type` from `#[agent(output = Type)]`. +fn extract_output_type(attrs: &[Attribute]) -> Option { + for attr in attrs { + if !attr.path().is_ident("agent") { + continue; + } + let mut result = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("output") { + let value = meta.value()?; + let ty: Type = value.parse()?; + result = Some(quote! { #ty }); + } + Ok(()) + }); + if result.is_some() { + return result; + } + } + None +} + +/// Check whether any field attribute has `#[arg(long)]`. +fn has_clap_long(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| { + if !attr.path().is_ident("arg") { + return false; + } + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("long") { + found = true; + } + Ok(()) + }); + found + }) +} + +/// Check if a type is `bool`. +fn is_bool_type(ty: &Type) -> bool { + if let Type::Path(tp) = ty + && let Some(seg) = tp.path.segments.last() + { + return seg.ident == "bool"; + } + false +} + +/// Check if a type is `Option`. +fn is_option_type(ty: &Type) -> bool { + if let Type::Path(tp) = ty + && let Some(seg) = tp.path.segments.last() + { + return seg.ident == "Option"; + } + false +} + +/// Check if any field in the variant has `#[command(subcommand)]`. +fn has_clap_subcommand(fields: &Fields) -> bool { + match fields { + Fields::Named(named) => named.named.iter().any(|f| { + f.attrs.iter().any(|attr| { + if !attr.path().is_ident("command") { + return false; + } + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("subcommand") { + found = true; + } + Ok(()) + }); + found + }) + }), + Fields::Unnamed(unnamed) => unnamed.unnamed.iter().any(|f| { + f.attrs.iter().any(|attr| { + if !attr.path().is_ident("command") { + return false; + } + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("subcommand") { + found = true; + } + Ok(()) + }); + found + }) + }), + Fields::Unit => false, + } +} diff --git a/crates/agent-describe/Cargo.toml b/crates/agent-describe/Cargo.toml new file mode 100644 index 0000000..a1694fe --- /dev/null +++ b/crates/agent-describe/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "agent-describe" +version = "0.1.0" +edition = "2024" +description = "Self-describing CLI protocol for AI agents" +license = "MIT" +repository = "https://github.com/rararulab/agent-describe" + +[dependencies] +agent-describe-derive = { path = "../agent-describe-derive", version = "0.1.0" } +clap = { version = "4", features = ["derive"] } +schemars = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/agent-describe/examples/demo_cli.rs b/crates/agent-describe/examples/demo_cli.rs new file mode 100644 index 0000000..59fb627 --- /dev/null +++ b/crates/agent-describe/examples/demo_cli.rs @@ -0,0 +1,93 @@ +use agent_describe::{AgentDescribe, AgentResponse}; +use clap::{Parser, Subcommand, ValueEnum}; +use schemars::JsonSchema; +use serde::Serialize; + +#[derive(Parser)] +#[command(name = "demo", version = "0.1.0", about = "Demo agent-friendly CLI")] +struct Cli { + /// Output agent-describe schema + #[arg(long)] + agent_describe: bool, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, AgentDescribe)] +#[agent(cli = Cli)] +enum DemoCommand { + /// Deploy application + Deploy { + /// Target environment + #[arg(value_enum)] + env: Environment, + /// Dry run + #[arg(long)] + dry_run: bool, + }, + + /// Manage configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, +} + +#[derive(Subcommand)] +enum ConfigAction { + /// Set a config value + Set { + /// Config key + key: String, + /// Config value + value: String, + }, + /// Get a config value + Get { + /// Config key + key: String, + }, +} + +#[derive(Clone, ValueEnum)] +enum Environment { + Staging, + Production, +} + +/// Convention: Deploy -> DeployResult +#[derive(Serialize, JsonSchema)] +struct DeployResult { + /// Deployment URL + url: String, + /// Time taken in seconds + took_secs: f64, +} + +fn main() { + let cli = Cli::parse(); + + if cli.agent_describe { + let schema = DemoCommand::agent_schema(); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); + return; + } + + match cli.command { + Some(DemoCommand::Deploy { env: _, dry_run: _ }) => { + AgentResponse::ok(DeployResult { + url: "https://app.example.com".into(), + took_secs: 3.2, + }) + .print(); + } + Some(DemoCommand::Config { .. }) => { + AgentResponse::ok(serde_json::json!({"key": "example", "value": "test"})).print(); + } + None => { + eprintln!("No command specified. Try --help or --agent-describe"); + std::process::exit(1); + } + } +} diff --git a/crates/agent-describe/src/lib.rs b/crates/agent-describe/src/lib.rs new file mode 100644 index 0000000..7d4bc23 --- /dev/null +++ b/crates/agent-describe/src/lib.rs @@ -0,0 +1,7 @@ +pub use agent_describe_derive::AgentDescribe; + +pub mod response; +pub mod schema; + +pub use response::AgentResponse; +pub use schema::{args_from_clap_command, AgentSchema, ArgSchema, CommandSchema}; diff --git a/crates/agent-describe/src/response.rs b/crates/agent-describe/src/response.rs new file mode 100644 index 0000000..b2c8866 --- /dev/null +++ b/crates/agent-describe/src/response.rs @@ -0,0 +1,45 @@ +use serde::Serialize; + +/// Unified response wrapper for agent-friendly CLI output. +/// +/// All commands return either `Ok { data }` or `Err { error, suggestion }`. +/// Serialized to JSON on stdout; human-readable text goes to stderr. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum AgentResponse { + Ok { + ok: bool, + data: T, + }, + Err { + ok: bool, + error: String, + suggestion: Option, + }, +} + +impl AgentResponse { + /// Create a success response wrapping the given data. + pub fn ok(data: T) -> Self { + Self::Ok { ok: true, data } + } + + /// Create an error response with an optional suggestion for self-correction. + pub fn err(error: impl Into, suggestion: Option>) -> Self { + Self::Err { + ok: false, + error: error.into(), + suggestion: suggestion.map(Into::into), + } + } + + /// Serialize to JSON string. + pub fn to_json(&self) -> String { + serde_json::to_string(self).expect("AgentResponse serialization should not fail") + } + + /// Print JSON to stdout. + pub fn print(&self) { + println!("{}", self.to_json()); + } +} diff --git a/crates/agent-describe/src/schema.rs b/crates/agent-describe/src/schema.rs new file mode 100644 index 0000000..6b7b813 --- /dev/null +++ b/crates/agent-describe/src/schema.rs @@ -0,0 +1,81 @@ +use serde::Serialize; + +/// Top-level schema for `--agent-describe` output. +#[derive(Debug, Serialize)] +pub struct AgentSchema { + pub protocol: &'static str, + pub name: String, + pub version: String, + pub description: String, + pub commands: Vec, + pub error_format: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct CommandSchema { + pub name: String, + pub description: String, + pub args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, +} + +#[derive(Debug, Serialize)] +pub struct ArgSchema { + pub name: String, + pub r#type: String, + pub required: bool, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#enum: Option>, +} + +impl AgentSchema { + /// Standard error format included in every schema. + pub fn default_error_format() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "ok": { "const": false }, + "error": { "type": "string" }, + "suggestion": { "type": "string" } + }, + "required": ["ok", "error"] + }) + } +} + +/// Extract argument schemas from a Clap `Command` at runtime. +/// +/// Used by the derive macro to flatten subcommand args. +pub fn args_from_clap_command(cmd: &clap::Command) -> Vec { + cmd.get_arguments() + .filter(|a| a.get_id() != "help" && a.get_id() != "version") + .map(|arg| { + let name = if arg.get_long().is_some() { + format!("--{}", arg.get_id()) + } else { + arg.get_id().to_string() + }; + let type_str = if arg.get_action().takes_values() { + "string" + } else { + "bool" + }; + let possible: Vec = arg.get_possible_values() + .iter() + .filter_map(|v| v.get_name_and_aliases().next().map(String::from)) + .collect(); + + ArgSchema { + name, + r#type: type_str.to_string(), + required: arg.is_required_set(), + description: arg.get_help() + .map(|s| s.to_string()) + .unwrap_or_default(), + r#enum: if possible.is_empty() { None } else { Some(possible) }, + } + }) + .collect() +} diff --git a/crates/agent-describe/tests/derive_test.rs b/crates/agent-describe/tests/derive_test.rs new file mode 100644 index 0000000..32f0581 --- /dev/null +++ b/crates/agent-describe/tests/derive_test.rs @@ -0,0 +1,197 @@ +use agent_describe::AgentDescribe; +use clap::{Parser, Subcommand}; +use schemars::JsonSchema; +use serde::Serialize; + +#[derive(Parser)] +#[command(name = "testcli", version = "1.0.0", about = "A test CLI")] +struct TestCli { + #[command(subcommand)] + command: TestCommand, +} + +#[derive(Subcommand, AgentDescribe)] +#[agent(cli = TestCli)] +enum TestCommand { + /// Deploy to an environment + Deploy { + /// Target environment + env: String, + /// Skip actual deployment + #[arg(long)] + dry_run: bool, + }, + + /// Show greeting + #[agent(skip)] + Hello { + name: String, + }, + + /// Check system status + #[agent(output = CustomStatusOutput)] + Status { + /// Component to check + component: String, + }, + + /// Manage config + Config { + #[command(subcommand)] + action: ConfigAction, + }, +} + +#[derive(Subcommand)] +enum ConfigAction { + /// Set a value + Set { + /// Config key + key: String, + /// Config value + value: String, + }, + /// Get a value + Get { + /// Config key + key: String, + }, +} + +#[derive(Serialize, JsonSchema)] +struct DeployResult { + url: String, + took_secs: f64, +} + +#[derive(Serialize, JsonSchema)] +struct CustomStatusOutput { + healthy: bool, + uptime_secs: u64, +} + +#[test] +fn schema_includes_deploy_but_not_hello() { + let schema = TestCommand::agent_schema(); + + assert_eq!(schema["protocol"], "agent-cli/1"); + assert_eq!(schema["name"], "testcli"); + + let commands = schema["commands"].as_array().unwrap(); + let names: Vec<&str> = commands.iter().map(|c| c["name"].as_str().unwrap()).collect(); + + assert!(names.contains(&"deploy"), "deploy should be in schema"); + assert!(!names.contains(&"hello"), "hello should be skipped"); +} + +#[test] +fn deploy_has_args_and_output() { + let schema = TestCommand::agent_schema(); + + let commands = schema["commands"].as_array().unwrap(); + let deploy = commands.iter().find(|c| c["name"] == "deploy").unwrap(); + + assert_eq!(deploy["description"], "Deploy to an environment"); + + let args = deploy["args"].as_array().unwrap(); + assert!(args.iter().any(|a| a["name"] == "env"), "should have env arg"); + assert!( + args.iter() + .any(|a| a["name"] == "--dry-run" && a["type"] == "bool"), + "should have --dry-run flag" + ); + + // Output schema should exist and have DeployResult properties + let output = &deploy["output"]; + assert!(output.is_object(), "output schema should be present"); +} + +#[test] +fn schema_has_error_format() { + let schema = TestCommand::agent_schema(); + assert!(schema["error_format"].is_object()); + assert!(schema["error_format"]["properties"]["error"].is_object()); +} + +#[test] +fn output_override_uses_custom_type() { + let schema = TestCommand::agent_schema(); + let commands = schema["commands"].as_array().unwrap(); + let status = commands.iter().find(|c| c["name"] == "status").unwrap(); + + assert_eq!(status["description"], "Check system status"); + + // Output schema should reflect CustomStatusOutput, not StatusResult + let output = &status["output"]; + assert!(output.is_object(), "output schema should be present"); + + // Verify the schema title matches the custom type, not the default StatusResult + assert_eq!( + output["title"], "CustomStatusOutput", + "output type should be CustomStatusOutput, not StatusResult" + ); + + let props = &output["properties"]; + assert!( + props["healthy"].is_object(), + "should have 'healthy' from CustomStatusOutput" + ); + assert!( + props["uptime_secs"].is_object(), + "should have 'uptime_secs' from CustomStatusOutput" + ); +} + +#[test] +fn subcommand_flattening_produces_separate_commands() { + let schema = TestCommand::agent_schema(); + let commands = schema["commands"].as_array().unwrap(); + let names: Vec<&str> = commands.iter().map(|c| c["name"].as_str().unwrap()).collect(); + + // Flattened subcommands should appear as "config set" and "config get" + assert!( + names.contains(&"config set"), + "should have flattened 'config set', got: {:?}", + names + ); + assert!( + names.contains(&"config get"), + "should have flattened 'config get', got: {:?}", + names + ); + + // "config" alone should NOT appear + assert!( + !names.contains(&"config"), + "'config' should not appear as standalone command" + ); + + // Verify args on "config set" + let config_set = commands + .iter() + .find(|c| c["name"] == "config set") + .unwrap(); + let args = config_set["args"].as_array().unwrap(); + let arg_names: Vec<&str> = args.iter().map(|a| a["name"].as_str().unwrap()).collect(); + assert!( + arg_names.contains(&"key"), + "config set should have 'key' arg" + ); + assert!( + arg_names.contains(&"value"), + "config set should have 'value' arg" + ); + + // Verify args on "config get" + let config_get = commands + .iter() + .find(|c| c["name"] == "config get") + .unwrap(); + let args = config_get["args"].as_array().unwrap(); + let arg_names: Vec<&str> = args.iter().map(|a| a["name"].as_str().unwrap()).collect(); + assert!( + arg_names.contains(&"key"), + "config get should have 'key' arg" + ); + assert_eq!(args.len(), 1, "config get should have exactly 1 arg"); +} diff --git a/crates/agent-describe/tests/response_test.rs b/crates/agent-describe/tests/response_test.rs new file mode 100644 index 0000000..0714a82 --- /dev/null +++ b/crates/agent-describe/tests/response_test.rs @@ -0,0 +1,35 @@ +use agent_describe::AgentResponse; +use serde_json::Value; + +#[test] +fn ok_response_serializes_correctly() { + #[derive(serde::Serialize)] + struct MyResult { url: String } + + let resp = AgentResponse::ok(MyResult { url: "https://example.com".into() }); + let json: Value = serde_json::from_str(&resp.to_json()).unwrap(); + + assert_eq!(json["ok"], true); + assert_eq!(json["data"]["url"], "https://example.com"); + assert!(json.get("error").is_none()); +} + +#[test] +fn err_response_serializes_correctly() { + let resp = AgentResponse::<()>::err("not found", Some("try `mycli list`")); + let json: Value = serde_json::from_str(&resp.to_json()).unwrap(); + + assert_eq!(json["ok"], false); + assert_eq!(json["error"], "not found"); + assert_eq!(json["suggestion"], "try `mycli list`"); +} + +#[test] +fn err_response_without_suggestion() { + let resp = AgentResponse::<()>::err("failed", None::); + let json: Value = serde_json::from_str(&resp.to_json()).unwrap(); + + assert_eq!(json["ok"], false); + assert_eq!(json["error"], "failed"); + assert!(json["suggestion"].is_null()); +} diff --git a/release-plz.toml b/release-plz.toml index 5e44242..56c4b7b 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -26,5 +26,14 @@ publish_allow_dirty = false # disable running `cargo-semver-checks` semver_check = false -# disable cargo publish - not published to crates.io +# disable cargo publish - not published to crates.io (default for binary crate) publish = false + +# Publish derive crate first (agent-describe depends on it) +[[package]] +name = "agent-describe-derive" +publish = true + +[[package]] +name = "agent-describe" +publish = true diff --git a/template/Cargo.toml b/template/Cargo.toml index 7787510..d51774e 100644 --- a/template/Cargo.toml +++ b/template/Cargo.toml @@ -22,7 +22,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -agent-describe = { path = "/Users/ryan/code/rararulab/agent-describe/agent-describe" } +agent-describe = "0.1" [dev-dependencies] assert_cmd = "2" From 9cdc6a4cc446c0f373de6922aa3c2cac83d6afc5 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 10:50:59 +0900 Subject: [PATCH 8/9] docs: add agent-describe implementation plan --- docs/plans/2026-03-27-agent-describe.md | 1274 +++++++++++++++++++++++ 1 file changed, 1274 insertions(+) create mode 100644 docs/plans/2026-03-27-agent-describe.md diff --git a/docs/plans/2026-03-27-agent-describe.md b/docs/plans/2026-03-27-agent-describe.md new file mode 100644 index 0000000..08af350 --- /dev/null +++ b/docs/plans/2026-03-27-agent-describe.md @@ -0,0 +1,1274 @@ +# Agent-Friendly CLI Protocol (`agent-describe`) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make every CLI generated by rara-cli-template self-describing to AI agents — a CLI can tell any agent what commands it has, what arguments they take, and what output they produce, via `mycli --agent-describe`. + +**Architecture:** Two new crates (`agent-describe` runtime + `agent-describe-derive` proc macro) published to crates.io. The proc macro derives from the Clap `Command` enum, combining Clap runtime introspection for input schemas with schemars `JsonSchema` derive for output schemas. Naming convention: variant `Deploy` → looks for `DeployResult`. The template integrates both crates and replaces ad-hoc `json!()` with typed `AgentResponse`. + +**Tech Stack:** `syn`/`quote`/`proc-macro2` for the derive macro, `schemars` for JSON Schema generation, `clap::CommandFactory` for input introspection, `serde_json` for output. + +--- + +## Phase 1: `agent-describe` Crate (Runtime Library) + +### Task 1: Scaffold the workspace + +**Files:** +- Create: `agent-describe/Cargo.toml` +- Create: `agent-describe/src/lib.rs` +- Create: `agent-describe-derive/Cargo.toml` +- Create: `agent-describe-derive/src/lib.rs` + +We'll build these as a separate workspace outside the template, to be published to crates.io independently. + +**Step 1: Create the workspace root** + +```bash +mkdir -p /Users/ryan/code/rararulab/agent-describe +``` + +**Step 2: Create workspace Cargo.toml** + +Create `/Users/ryan/code/rararulab/agent-describe/Cargo.toml`: +```toml +[workspace] +members = ["agent-describe", "agent-describe-derive"] +resolver = "2" +``` + +**Step 3: Create runtime crate** + +Create `/Users/ryan/code/rararulab/agent-describe/agent-describe/Cargo.toml`: +```toml +[package] +name = "agent-describe" +version = "0.1.0" +edition = "2024" +description = "Self-describing CLI protocol for AI agents" +license = "MIT" +repository = "https://github.com/rararulab/agent-describe" + +[dependencies] +agent-describe-derive = { path = "../agent-describe-derive", version = "0.1.0" } +clap = { version = "4", features = ["derive"] } +schemars = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +``` + +Create `/Users/ryan/code/rararulab/agent-describe/agent-describe/src/lib.rs`: +```rust +pub use agent_describe_derive::AgentDescribe; + +pub mod response; +pub mod schema; + +pub use response::AgentResponse; +pub use schema::{AgentSchema, CommandSchema, ArgSchema}; +``` + +**Step 4: Create derive crate (stub)** + +Create `/Users/ryan/code/rararulab/agent-describe/agent-describe-derive/Cargo.toml`: +```toml +[package] +name = "agent-describe-derive" +version = "0.1.0" +edition = "2024" +description = "Derive macro for agent-describe" +license = "MIT" +repository = "https://github.com/rararulab/agent-describe" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } +``` + +Create `/Users/ryan/code/rararulab/agent-describe/agent-describe-derive/src/lib.rs`: +```rust +use proc_macro::TokenStream; + +/// Derive `AgentDescribe` for a Clap `Command` enum. +/// +/// Generates a `fn agent_schema()` that combines Clap input metadata +/// with schemars output schemas using naming convention: +/// variant `Foo` → looks for type `FooResult` implementing `JsonSchema`. +/// +/// Attributes: +/// - `#[agent(skip)]` — exclude a variant from the schema +/// - `#[agent(output = CustomType)]` — override the default `{Variant}Result` convention +#[proc_macro_derive(AgentDescribe, attributes(agent))] +pub fn derive_agent_describe(_input: TokenStream) -> TokenStream { + TokenStream::new() // stub — implemented in Task 3 +} +``` + +**Step 5: Init git and commit** + +```bash +cd /Users/ryan/code/rararulab/agent-describe +git init --initial-branch=main +git add -A +git commit -m "chore: scaffold agent-describe workspace" +``` + +--- + +### Task 2: Implement `AgentResponse` and schema types + +**Files:** +- Create: `agent-describe/agent-describe/src/response.rs` +- Create: `agent-describe/agent-describe/src/schema.rs` + +**Step 1: Write tests for AgentResponse** + +Create `agent-describe/agent-describe/tests/response_test.rs`: +```rust +use agent_describe::AgentResponse; +use serde_json::Value; + +#[test] +fn ok_response_serializes_correctly() { + #[derive(serde::Serialize)] + struct MyResult { url: String } + + let resp = AgentResponse::ok(MyResult { url: "https://example.com".into() }); + let json: Value = serde_json::from_str(&resp.to_json()).unwrap(); + + assert_eq!(json["ok"], true); + assert_eq!(json["data"]["url"], "https://example.com"); + assert!(json.get("error").is_none()); +} + +#[test] +fn err_response_serializes_correctly() { + let resp = AgentResponse::<()>::err("not found", Some("try `mycli list`")); + let json: Value = serde_json::from_str(&resp.to_json()).unwrap(); + + assert_eq!(json["ok"], false); + assert_eq!(json["error"], "not found"); + assert_eq!(json["suggestion"], "try `mycli list`"); +} + +#[test] +fn err_response_without_suggestion() { + let resp = AgentResponse::<()>::err("failed", None); + let json: Value = serde_json::from_str(&resp.to_json()).unwrap(); + + assert_eq!(json["ok"], false); + assert_eq!(json["error"], "failed"); + assert!(json["suggestion"].is_null()); +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd /Users/ryan/code/rararulab/agent-describe +cargo test +``` +Expected: FAIL — `response` module not implemented yet. + +**Step 3: Implement AgentResponse** + +Create `agent-describe/agent-describe/src/response.rs`: +```rust +use serde::Serialize; + +/// Unified response wrapper for agent-friendly CLI output. +/// +/// All commands return either `Ok { data }` or `Err { error, suggestion }`. +/// Serialized to JSON on stdout; human-readable text goes to stderr. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum AgentResponse { + Ok { + ok: bool, + data: T, + }, + Err { + ok: bool, + error: String, + suggestion: Option, + }, +} + +impl AgentResponse { + /// Create a success response wrapping the given data. + pub fn ok(data: T) -> Self { + Self::Ok { ok: true, data } + } + + /// Create an error response with an optional suggestion for self-correction. + pub fn err(error: impl Into, suggestion: Option>) -> Self { + Self::Err { + ok: false, + error: error.into(), + suggestion: suggestion.map(Into::into), + } + } + + /// Serialize to JSON string. + pub fn to_json(&self) -> String { + serde_json::to_string(self).expect("AgentResponse serialization should not fail") + } + + /// Print JSON to stdout. + pub fn print(&self) { + println!("{}", self.to_json()); + } +} +``` + +**Step 4: Implement schema types** + +Create `agent-describe/agent-describe/src/schema.rs`: +```rust +use serde::Serialize; + +/// Top-level schema for `--agent-describe` output. +#[derive(Debug, Serialize)] +pub struct AgentSchema { + /// Protocol identifier and version. + pub protocol: &'static str, + /// CLI binary name (from `CARGO_PKG_NAME`). + pub name: String, + /// CLI version (from `CARGO_PKG_VERSION`). + pub version: String, + /// One-line description (from `CARGO_PKG_DESCRIPTION`). + pub description: String, + /// Flattened list of all commands. + pub commands: Vec, + /// Standard error response format. + pub error_format: serde_json::Value, +} + +/// Schema for a single CLI command (flattened — subcommands become `parent.child`). +#[derive(Debug, Serialize)] +pub struct CommandSchema { + /// Dotted command name, e.g. `"deploy"` or `"config.set"`. + pub name: String, + /// Human-readable description (from doc comment / clap `about`). + pub description: String, + /// Positional and named arguments. + pub args: Vec, + /// Output JSON Schema (from schemars), if available. + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, +} + +/// Schema for a single argument. +#[derive(Debug, Serialize)] +pub struct ArgSchema { + /// Argument name. Positional: bare name. Flag: `--name`. + pub name: String, + /// Value type: `"string"`, `"bool"`, `"integer"`, `"number"`. + pub r#type: String, + /// Whether this argument is required. + pub required: bool, + /// Human-readable description. + pub description: String, + /// Possible values for enums. + #[serde(skip_serializing_if = "Option::is_none")] + pub r#enum: Option>, +} + +impl AgentSchema { + /// Standard error format included in every schema. + pub fn default_error_format() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "ok": { "const": false }, + "error": { "type": "string" }, + "suggestion": { "type": "string" } + }, + "required": ["ok", "error"] + }) + } +} +``` + +**Step 5: Run tests** + +```bash +cd /Users/ryan/code/rararulab/agent-describe +cargo test +``` +Expected: ALL PASS + +**Step 6: Commit** + +```bash +git add -A +git commit -m "feat: add AgentResponse and schema types" +``` + +--- + +### Task 3: Implement the `AgentDescribe` proc macro + +**Files:** +- Modify: `agent-describe/agent-describe-derive/src/lib.rs` + +This is the core of the project. The macro: +1. Parses the `Command` enum variants +2. Extracts doc comments as descriptions +3. For each variant, generates code that: + a. Calls `Cli::command()` (Clap introspection) for input args + b. References `{Variant}Result` type for output schema (naming convention) +4. Generates `impl AgentDescribe for Command` + +**Step 1: Write integration test** + +Create `agent-describe/agent-describe/tests/derive_test.rs`: +```rust +use agent_describe::AgentDescribe; +use clap::{Parser, Subcommand}; +use schemars::JsonSchema; +use serde::Serialize; + +#[derive(Parser)] +#[command(name = "testcli", version = "1.0.0", about = "A test CLI")] +struct Cli { + #[command(subcommand)] + command: TestCommand, +} + +#[derive(Subcommand, AgentDescribe)] +#[agent(cli = Cli)] +enum TestCommand { + /// Deploy to an environment + Deploy { + /// Target environment + env: String, + /// Skip actual deployment + #[arg(long)] + dry_run: bool, + }, + + /// Show greeting + #[agent(skip)] + Hello { + name: String, + }, +} + +#[derive(Serialize, JsonSchema)] +struct DeployResult { + url: String, + took_secs: f64, +} + +#[test] +fn schema_includes_deploy_but_not_hello() { + let schema = TestCommand::agent_schema(); + let json: serde_json::Value = serde_json::from_str(&schema.to_string()).unwrap(); + + assert_eq!(json["protocol"], "agent-cli/1"); + assert_eq!(json["name"], "testcli"); + + let commands = json["commands"].as_array().unwrap(); + let names: Vec<&str> = commands.iter().map(|c| c["name"].as_str().unwrap()).collect(); + + assert!(names.contains(&"deploy"), "deploy should be in schema"); + assert!(!names.contains(&"hello"), "hello should be skipped"); +} + +#[test] +fn deploy_has_args_and_output() { + let schema = TestCommand::agent_schema(); + let json: serde_json::Value = serde_json::from_str(&schema.to_string()).unwrap(); + + let deploy = &json["commands"][0]; + assert_eq!(deploy["name"], "deploy"); + assert_eq!(deploy["description"], "Deploy to an environment"); + + let args = deploy["args"].as_array().unwrap(); + assert!(args.iter().any(|a| a["name"] == "env")); + assert!(args.iter().any(|a| a["name"] == "--dry-run" && a["type"] == "bool")); + + // Output schema should reference DeployResult fields + let output = &deploy["output"]; + assert!(output.get("properties").is_some() || output.get("$ref").is_some(), + "output schema should have properties or $ref"); +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cargo test derive_test +``` +Expected: FAIL — macro is a stub. + +**Step 3: Implement the proc macro** + +Replace `agent-describe/agent-describe-derive/src/lib.rs` with: + +```rust +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, Attribute, DeriveInput, Data, Fields, Lit, Meta, Expr}; + +/// Derive `AgentDescribe` for a Clap `Subcommand` enum. +#[proc_macro_derive(AgentDescribe, attributes(agent))] +pub fn derive_agent_describe(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + + let variants = match &input.data { + Data::Enum(data) => &data.variants, + _ => panic!("AgentDescribe can only be derived for enums"), + }; + + // Extract #[agent(cli = CliType)] from enum-level attributes + let cli_type = extract_cli_type(&input.attrs) + .expect("AgentDescribe requires #[agent(cli = YourCliType)] on the enum"); + + let mut command_builders = Vec::new(); + + for variant in variants { + // Check for #[agent(skip)] + if has_agent_attr(&variant.attrs, "skip") { + continue; + } + + let variant_name = &variant.ident; + let command_name = to_kebab_case(&variant_name.to_string()); + let description = extract_doc_comment(&variant.attrs); + + // Check for #[agent(output = CustomType)] override + let output_type = extract_output_type(&variant.attrs); + + // Default convention: {Variant}Result + let result_type = match output_type { + Some(ty) => ty, + None => { + let result_name = syn::Ident::new( + &format!("{variant_name}Result"), + variant_name.span(), + ); + quote! { #result_name } + } + }; + + // Extract args from struct fields + let args_code = match &variant.fields { + Fields::Named(fields) => { + let arg_builders: Vec<_> = fields.named.iter().map(|field| { + let field_name = field.ident.as_ref().unwrap().to_string(); + let field_doc = extract_doc_comment(&field.attrs); + let is_flag = has_clap_long(&field.attrs); + let is_bool = is_bool_type(&field.ty); + let is_optional = is_option_type(&field.ty); + let display_name = if is_flag { + format!("--{}", field_name.replace('_', "-")) + } else { + field_name.clone() + }; + let type_str = if is_bool { "bool" } else { "string" }; + let required = !is_optional && !is_bool; + + // Check for value_enum + let has_value_enum = has_clap_value_enum(&field.attrs); + + quote! { + { + let mut arg = ::agent_describe::ArgSchema { + name: #display_name.to_string(), + r#type: #type_str.to_string(), + required: #required, + description: #field_doc.to_string(), + r#enum: None, + }; + if #has_value_enum { + // Value enum possible values extracted at runtime via Clap + // (handled in the runtime schema builder) + } + arg + } + } + }).collect(); + + quote! { vec![#(#arg_builders),*] } + } + _ => quote! { vec![] }, + }; + + // Check if variant has #[command(subcommand)] field → flatten + let has_subcommand = has_clap_subcommand(&variant.fields); + + if has_subcommand { + // For subcommand variants, we need runtime flattening + // Generate a marker that the runtime builder handles + command_builders.push(quote! { + // Subcommand flattening handled at runtime via Clap introspection + { + let clap_cmd = <#cli_type as ::clap::CommandFactory>::command(); + if let Some(parent) = clap_cmd.get_subcommands() + .find(|c| c.get_name() == #command_name) + { + for sub in parent.get_subcommands() { + let sub_name = format!("{}.{}", #command_name, sub.get_name()); + let sub_desc = sub.get_about() + .map(|s| s.to_string()) + .unwrap_or_default(); + let sub_args = ::agent_describe::schema::args_from_clap_command(sub); + commands.push(::agent_describe::CommandSchema { + name: sub_name, + description: sub_desc, + args: sub_args, + output: None, // Subcommand outputs handled separately + }); + } + } + } + }); + } else { + command_builders.push(quote! { + { + let output_schema = { + let gen = &mut ::schemars::generate::SchemaSettings::draft2020_12() + .into_generator(); + let schema = gen.into_root_schema_for::<#result_type>(); + Some(::serde_json::to_value(schema).unwrap()) + }; + commands.push(::agent_describe::CommandSchema { + name: #command_name.to_string(), + description: #description.to_string(), + args: #args_code, + output: output_schema, + }); + } + }); + } + } + + let expanded = quote! { + impl #name { + /// Generate the full agent-cli/1 schema for this CLI. + pub fn agent_schema() -> ::serde_json::Value { + let mut commands = Vec::<::agent_describe::CommandSchema>::new(); + + #(#command_builders)* + + let schema = ::agent_describe::AgentSchema { + protocol: "agent-cli/1", + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: env!("CARGO_PKG_DESCRIPTION").to_string(), + commands, + error_format: ::agent_describe::AgentSchema::default_error_format(), + }; + + ::serde_json::to_value(&schema).unwrap() + } + } + }; + + TokenStream::from(expanded) +} + +fn extract_doc_comment(attrs: &[Attribute]) -> String { + attrs.iter() + .filter_map(|attr| { + if attr.path().is_ident("doc") { + if let Meta::NameValue(nv) = &attr.meta { + if let Expr::Lit(lit) = &nv.value { + if let Lit::Str(s) = &lit.lit { + return Some(s.value().trim().to_string()); + } + } + } + } + None + }) + .collect::>() + .join(" ") +} + +fn to_kebab_case(s: &str) -> String { + let mut result = String::new(); + for (i, ch) in s.chars().enumerate() { + if ch.is_uppercase() { + if i > 0 { + result.push('-'); + } + result.push(ch.to_ascii_lowercase()); + } else { + result.push(ch); + } + } + result +} + +fn has_agent_attr(attrs: &[Attribute], name: &str) -> bool { + attrs.iter().any(|attr| { + if attr.path().is_ident("agent") { + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident(name) { + found = true; + } + Ok(()) + }); + found + } else { + false + } + }) +} + +fn extract_cli_type(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("agent") { + let mut cli_type = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("cli") { + let value = meta.value()?; + let ty: syn::Type = value.parse()?; + cli_type = Some(quote! { #ty }); + } + Ok(()) + }); + if cli_type.is_some() { + return cli_type; + } + } + } + None +} + +fn extract_output_type(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("agent") { + let mut output = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("output") { + let value = meta.value()?; + let ty: syn::Type = value.parse()?; + output = Some(quote! { #ty }); + } + Ok(()) + }); + if output.is_some() { + return output; + } + } + } + None +} + +fn has_clap_long(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| { + if attr.path().is_ident("arg") { + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("long") { + found = true; + } + Ok(()) + }); + found + } else { + false + } + }) +} + +fn has_clap_value_enum(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| { + if attr.path().is_ident("arg") { + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("value_enum") { + found = true; + } + Ok(()) + }); + found + } else { + false + } + }) +} + +fn is_bool_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(path) = ty { + path.path.segments.last() + .map(|s| s.ident == "bool") + .unwrap_or(false) + } else { + false + } +} + +fn is_option_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(path) = ty { + path.path.segments.last() + .map(|s| s.ident == "Option") + .unwrap_or(false) + } else { + false + } +} + +fn has_clap_subcommand(fields: &Fields) -> bool { + match fields { + Fields::Named(named) => { + named.named.iter().any(|f| { + f.attrs.iter().any(|attr| { + if attr.path().is_ident("command") { + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("subcommand") { + found = true; + } + Ok(()) + }); + found + } else { + false + } + }) + }) + } + _ => false, + } +} +``` + +**Step 4: Add runtime helper for Clap arg extraction** + +Add to `agent-describe/agent-describe/src/schema.rs`: +```rust +/// Extract argument schemas from a Clap `Command` at runtime. +/// +/// Used by the derive macro to flatten subcommand args. +pub fn args_from_clap_command(cmd: &clap::Command) -> Vec { + cmd.get_arguments() + .filter(|a| a.get_id() != "help" && a.get_id() != "version") + .map(|arg| { + let name = if arg.get_long().is_some() { + format!("--{}", arg.get_id()) + } else { + arg.get_id().to_string() + }; + let type_str = if arg.get_action().takes_values() { + "string" + } else { + "bool" + }; + let possible: Vec = arg.get_possible_values() + .iter() + .filter_map(|v| v.get_name_and_aliases().next().map(String::from)) + .collect(); + + ArgSchema { + name, + r#type: type_str.to_string(), + required: arg.is_required_set(), + description: arg.get_help() + .map(|s| s.to_string()) + .unwrap_or_default(), + r#enum: if possible.is_empty() { None } else { Some(possible) }, + } + }) + .collect() +} +``` + +**Step 5: Run tests** + +```bash +cargo test +``` +Expected: ALL PASS + +**Step 6: Commit** + +```bash +git add -A +git commit -m "feat: implement AgentDescribe derive macro with naming convention" +``` + +--- + +### Task 4: Add an example CLI to validate end-to-end + +**Files:** +- Create: `agent-describe/examples/demo-cli.rs` + +**Step 1: Create the example** + +Create `agent-describe/agent-describe/examples/demo-cli.rs`: +```rust +use agent_describe::{AgentDescribe, AgentResponse}; +use clap::{Parser, Subcommand, ValueEnum}; +use schemars::JsonSchema; +use serde::Serialize; + +#[derive(Parser)] +#[command(name = "demo", version = "0.1.0", about = "Demo agent-friendly CLI")] +struct Cli { + /// Output agent-describe schema + #[arg(long)] + agent_describe: bool, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, AgentDescribe)] +#[agent(cli = Cli)] +enum DemoCommand { + /// Deploy application + Deploy { + /// Target environment + #[arg(value_enum)] + env: Environment, + /// Dry run + #[arg(long)] + dry_run: bool, + }, + + /// Manage configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, +} + +#[derive(Subcommand)] +enum ConfigAction { + /// Set a config value + Set { + /// Config key + key: String, + /// Config value + value: String, + }, + /// Get a config value + Get { + /// Config key + key: String, + }, +} + +#[derive(Clone, ValueEnum)] +enum Environment { + Staging, + Production, +} + +// Convention: Deploy → DeployResult +#[derive(Serialize, JsonSchema)] +struct DeployResult { + /// Deployment URL + url: String, + /// Time taken in seconds + took_secs: f64, +} + +fn main() { + let cli = Cli::parse(); + + if cli.agent_describe { + let schema = DemoCommand::agent_schema(); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); + return; + } + + match cli.command { + Some(DemoCommand::Deploy { env: _, dry_run: _ }) => { + AgentResponse::ok(DeployResult { + url: "https://app.example.com".into(), + took_secs: 3.2, + }).print(); + } + Some(DemoCommand::Config { action: _ }) => { + AgentResponse::ok(serde_json::json!({"key": "example", "value": "test"})).print(); + } + None => { + eprintln!("No command specified. Try --help or --agent-describe"); + } + } +} +``` + +**Step 2: Run the example** + +```bash +cargo run --example demo-cli -- --agent-describe +``` +Expected: Pretty-printed JSON schema with protocol "agent-cli/1", commands for "deploy", "config.set", "config.get". + +```bash +cargo run --example demo-cli -- deploy staging +``` +Expected: `{"ok":true,"data":{"url":"https://app.example.com","took_secs":3.2}}` + +**Step 3: Commit** + +```bash +git add -A +git commit -m "feat: add demo-cli example for end-to-end validation" +``` + +--- + +## Phase 2: Template Integration + +### Task 5: Add `agent-describe` and `schemars` to the template + +**Files:** +- Modify: `template/Cargo.toml` — add dependencies +- Create: `template/src/response.rs` — re-export or thin wrapper + +**Step 1: Update template/Cargo.toml** + +Add to `[dependencies]`: +```toml +agent-describe = "0.1" +schemars = "1" +``` + +**Step 2: Create response types for each command** + +Create `template/src/response.rs`: +```rust +//! Typed response structs for each CLI command. +//! +//! Naming convention: command `Foo` → `FooResult`. +//! Each type derives `Serialize` + `JsonSchema` for agent-describe schema generation. + +use schemars::JsonSchema; +use serde::Serialize; + +/// Result of the `hello` command. +#[derive(Debug, Serialize, JsonSchema)] +pub struct HelloResult { + /// The greeting message. + pub greeting: String, +} + +/// Result of `config set`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ConfigSetResult { + /// The key that was set. + pub key: String, + /// The value that was set. + pub value: String, +} + +/// Result of `config get`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ConfigGetResult { + /// The config key. + pub key: String, + /// The config value, or null if not set. + pub value: Option, +} + +/// Result of `config list`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ConfigListResult { + /// All config entries as key-value pairs. + pub entries: std::collections::BTreeMap, +} + +/// Result of the `agent` command. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AgentRunResult { + /// Whether the agent execution succeeded. + pub success: bool, + /// Process exit code. + pub exit_code: Option, + /// Whether the agent timed out. + pub timed_out: bool, + /// Agent output text. + pub output: String, +} +``` + +**Step 3: Commit** + +```bash +git add -A +git commit -m "feat: add agent-describe dependency and typed response structs" +``` + +--- + +### Task 6: Add `AgentDescribe` derive to the template's Command enum + +**Files:** +- Modify: `template/src/cli/mod.rs` — add derive + attributes +- Modify: `template/src/main.rs` — add `--agent-describe` flag + replace ad-hoc json + +**Step 1: Update cli/mod.rs** + +Add the derive and attributes: +```rust +use agent_describe::AgentDescribe; + +#[derive(Parser)] +#[command(name = "{{project-name}}", version)] +pub struct Cli { + /// Output agent-describe schema (for AI agent discovery) + #[arg(long, hide = true)] + pub agent_describe: bool, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, AgentDescribe)] +#[agent(cli = Cli)] +pub enum Command { + /// Say hello (example command — replace with your own) + Hello { + #[arg(default_value = "world")] + name: String, + }, + + /// Manage config values + Config { + #[command(subcommand)] + action: ConfigAction, + }, + + /// Run a prompt through the configured agent backend + #[agent(output = AgentRunResult)] + Agent { + prompt: String, + #[arg(long)] + backend: Option, + }, +} +``` + +Note: `Hello` → convention finds `HelloResult`. `Config` → subcommand flattening. `Agent` → explicit `#[agent(output = AgentRunResult)]` because "AgentResult" would be ambiguous. + +**Step 2: Update main.rs — add --agent-describe handling** + +At the top of `run()`: +```rust +async fn run() -> Result<()> { + let cli = Cli::parse(); + + if cli.agent_describe { + let schema = Command::agent_schema(); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); + return Ok(()); + } + + let command = cli.command.ok_or_else(|| { + ConfigSnafu { message: "no command specified — try --help or --agent-describe".to_string() }.build() + })?; + + match command { + // ... existing dispatch but using AgentResponse + } +} +``` + +**Step 3: Replace ad-hoc json!() with AgentResponse** + +Replace each command's output: +```rust +// Before: +println!("{}", serde_json::json!({"ok": true, "action": "hello", "greeting": greeting})); + +// After: +use agent_describe::AgentResponse; +use crate::response::HelloResult; +AgentResponse::ok(HelloResult { greeting }).print(); +``` + +Apply same pattern to all commands: `config_set`, `config_get`, `config_list`, `agent_run`. + +**Step 4: Update error handler in main()** + +```rust +// Before: +println!("{}", serde_json::json!({"ok": false, "error": e.to_string(), "suggestion": "check --help for usage"})); + +// After: +AgentResponse::<()>::err(e.to_string(), Some("check --help or --agent-describe for usage")).print(); +``` + +**Step 5: Update lib.rs to export response module** + +Add `pub mod response;` to `template/src/lib.rs`. + +**Step 6: Run cargo check on the template (manual validation)** + +```bash +# Generate a test project and verify it compiles +cd /tmp +rara-cli-template setup --name test-agent-cli --org testorg +cd test-agent-cli +cargo check +cargo run -- --agent-describe +``` + +**Step 7: Commit** + +```bash +git add -A +git commit -m "feat: integrate agent-describe into template with typed responses" +``` + +--- + +### Task 7: Update template documentation + +**Files:** +- Modify: `template/CLAUDE.md` — mention agent-describe protocol +- Modify: `template/README.md` — add agent-friendly section +- Modify: `template/docs/guides/agent-quickstart.md` — add schema section + +**Step 1: Add to template/CLAUDE.md** + +Add a section: +```markdown +## Agent Protocol +This CLI implements `agent-cli/1`. Run `{{project-name}} --agent-describe` to get the full schema. +- All command outputs are typed structs with `#[derive(Serialize, JsonSchema)]` +- Response wrapper: `AgentResponse::ok(data)` / `AgentResponse::err(msg, suggestion)` +- Naming convention: command `Foo` → response type `FooResult` in `src/response.rs` +- Add `#[agent(skip)]` to exclude commands, `#[agent(output = T)]` to override convention +``` + +**Step 2: Add to template/README.md** + +Add an "Agent Integration" section explaining the protocol and how agents can discover/use the CLI. + +**Step 3: Commit** + +```bash +git add -A +git commit -m "docs: document agent-cli/1 protocol in template" +``` + +--- + +### Task 8: Update the scaffolding CLI's post-setup prompt + +**Files:** +- Modify: `src/post_setup.rs` — update agent prompt to mention --agent-describe + +**Step 1: Update print_agent_prompt** + +Add to the printed next-steps: +``` +4. Test agent discovery: `{{project-name}} --agent-describe` +5. Add new commands: define in cli/mod.rs, add {Name}Result in response.rs +``` + +**Step 2: Commit** + +```bash +git add -A +git commit -m "feat: update post-setup prompt to mention agent-describe" +``` + +--- + +### Task 9: Add integration tests + +**Files:** +- Modify: `template/tests/cli_test.rs` — add agent-describe test + +**Step 1: Write test** + +Add to `template/tests/cli_test.rs`: +```rust +#[test] +fn agent_describe_outputs_valid_schema() { + let cmd = Command::cargo_bin("{{project-name}}").unwrap() + .arg("--agent-describe") + .assert() + .success(); + + let output = cmd.get_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + let schema: serde_json::Value = serde_json::from_str(&stdout) + .expect("--agent-describe should output valid JSON"); + + assert_eq!(schema["protocol"], "agent-cli/1"); + assert!(schema["commands"].as_array().unwrap().len() > 0); +} +``` + +**Step 2: Run tests** + +```bash +cargo test +``` +Expected: ALL PASS + +**Step 3: Commit** + +```bash +git add -A +git commit -m "test: add integration test for --agent-describe output" +``` + +--- + +## Phase 3: Publish and Finalize + +### Task 10: Publish `agent-describe` to crates.io + +**Step 1: Final review of crate metadata** + +Verify `Cargo.toml` has: name, version, description, license, repository, keywords, categories. + +**Step 2: Dry-run publish** + +```bash +cd /Users/ryan/code/rararulab/agent-describe +cargo publish -p agent-describe-derive --dry-run +cargo publish -p agent-describe --dry-run +``` + +**Step 3: Publish (derive first, then runtime)** + +```bash +cargo publish -p agent-describe-derive +cargo publish -p agent-describe +``` + +**Step 4: Update template/Cargo.toml to use crates.io version** + +Change from path dependency to version: +```toml +agent-describe = "0.1" +``` + +**Step 5: Commit** + +```bash +git add -A +git commit -m "chore: publish agent-describe 0.1.0 to crates.io" +``` + +--- + +## Summary + +| Phase | Tasks | What it delivers | +|-------|-------|-----------------| +| 1: Crate | Tasks 1-4 | `agent-describe` + `agent-describe-derive` on crates.io | +| 2: Template | Tasks 5-9 | Template generates agent-friendly CLIs with `--agent-describe` | +| 3: Publish | Task 10 | Everything published and wired together | + +**Total estimated effort:** ~2 hours CC / ~1 week human team From af6c9415fc22cd281d2d5498237f7c615a17aaea Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 10:54:17 +0900 Subject: [PATCH 9/9] style: apply nightly rustfmt formatting --- crates/agent-describe-derive/src/lib.rs | 18 +++++----- crates/agent-describe/examples/demo_cli.rs | 8 ++--- crates/agent-describe/src/lib.rs | 2 +- crates/agent-describe/src/response.rs | 17 +++++---- crates/agent-describe/src/schema.rs | 37 +++++++++++--------- crates/agent-describe/tests/derive_test.rs | 37 ++++++++++---------- crates/agent-describe/tests/response_test.rs | 8 +++-- src/main.rs | 1 - src/post_setup.rs | 7 ++-- src/setup.rs | 20 +++++------ src/template.rs | 15 ++++++-- tests/setup_test.rs | 4 +-- 12 files changed, 95 insertions(+), 79 deletions(-) diff --git a/crates/agent-describe-derive/src/lib.rs b/crates/agent-describe-derive/src/lib.rs index b7a5f30..80ea48c 100644 --- a/crates/agent-describe-derive/src/lib.rs +++ b/crates/agent-describe-derive/src/lib.rs @@ -1,6 +1,8 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{parse_macro_input, DeriveInput, Data, Fields, Type, Attribute, Meta, Expr, ExprLit, Lit}; +use syn::{ + Attribute, Data, DeriveInput, Expr, ExprLit, Fields, Lit, Meta, Type, parse_macro_input, +}; /// Derive `AgentDescribe` for a Clap `Subcommand` enum. /// @@ -12,8 +14,8 @@ pub fn derive_agent_describe(input: TokenStream) -> TokenStream { let enum_name = &input.ident; - let cli_type = extract_cli_type(&input.attrs) - .expect("#[agent(cli = CliType)] is required on the enum"); + let cli_type = + extract_cli_type(&input.attrs).expect("#[agent(cli = CliType)] is required on the enum"); let data = match &input.data { Data::Enum(data) => data, @@ -59,10 +61,8 @@ pub fn derive_agent_describe(input: TokenStream) -> TokenStream { } else { // Regular variant: extract fields as args from macro analysis let output_type = extract_output_type(&variant.attrs).unwrap_or_else(|| { - let result_name = syn::Ident::new( - &format!("{}Result", variant_name), - variant_name.span(), - ); + let result_name = + syn::Ident::new(&format!("{}Result", variant_name), variant_name.span()); quote! { #result_name } }); @@ -159,7 +159,9 @@ fn extract_doc_comment(attrs: &[Attribute]) -> String { return None; } if let Meta::NameValue(nv) = &attr.meta - && let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = &nv.value + && let Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) = &nv.value { return Some(s.value().trim().to_string()); } diff --git a/crates/agent-describe/examples/demo_cli.rs b/crates/agent-describe/examples/demo_cli.rs index 59fb627..48823d7 100644 --- a/crates/agent-describe/examples/demo_cli.rs +++ b/crates/agent-describe/examples/demo_cli.rs @@ -21,7 +21,7 @@ enum DemoCommand { Deploy { /// Target environment #[arg(value_enum)] - env: Environment, + env: Environment, /// Dry run #[arg(long)] dry_run: bool, @@ -39,7 +39,7 @@ enum ConfigAction { /// Set a config value Set { /// Config key - key: String, + key: String, /// Config value value: String, }, @@ -60,7 +60,7 @@ enum Environment { #[derive(Serialize, JsonSchema)] struct DeployResult { /// Deployment URL - url: String, + url: String, /// Time taken in seconds took_secs: f64, } @@ -77,7 +77,7 @@ fn main() { match cli.command { Some(DemoCommand::Deploy { env: _, dry_run: _ }) => { AgentResponse::ok(DeployResult { - url: "https://app.example.com".into(), + url: "https://app.example.com".into(), took_secs: 3.2, }) .print(); diff --git a/crates/agent-describe/src/lib.rs b/crates/agent-describe/src/lib.rs index 7d4bc23..db0cfb0 100644 --- a/crates/agent-describe/src/lib.rs +++ b/crates/agent-describe/src/lib.rs @@ -4,4 +4,4 @@ pub mod response; pub mod schema; pub use response::AgentResponse; -pub use schema::{args_from_clap_command, AgentSchema, ArgSchema, CommandSchema}; +pub use schema::{AgentSchema, ArgSchema, CommandSchema, args_from_clap_command}; diff --git a/crates/agent-describe/src/response.rs b/crates/agent-describe/src/response.rs index b2c8866..65e448f 100644 --- a/crates/agent-describe/src/response.rs +++ b/crates/agent-describe/src/response.rs @@ -8,27 +8,26 @@ use serde::Serialize; #[serde(untagged)] pub enum AgentResponse { Ok { - ok: bool, + ok: bool, data: T, }, Err { - ok: bool, - error: String, + ok: bool, + error: String, suggestion: Option, }, } impl AgentResponse { /// Create a success response wrapping the given data. - pub fn ok(data: T) -> Self { - Self::Ok { ok: true, data } - } + pub fn ok(data: T) -> Self { Self::Ok { ok: true, data } } - /// Create an error response with an optional suggestion for self-correction. + /// Create an error response with an optional suggestion for + /// self-correction. pub fn err(error: impl Into, suggestion: Option>) -> Self { Self::Err { - ok: false, - error: error.into(), + ok: false, + error: error.into(), suggestion: suggestion.map(Into::into), } } diff --git a/crates/agent-describe/src/schema.rs b/crates/agent-describe/src/schema.rs index 6b7b813..4dc6b86 100644 --- a/crates/agent-describe/src/schema.rs +++ b/crates/agent-describe/src/schema.rs @@ -3,31 +3,31 @@ use serde::Serialize; /// Top-level schema for `--agent-describe` output. #[derive(Debug, Serialize)] pub struct AgentSchema { - pub protocol: &'static str, - pub name: String, - pub version: String, - pub description: String, - pub commands: Vec, + pub protocol: &'static str, + pub name: String, + pub version: String, + pub description: String, + pub commands: Vec, pub error_format: serde_json::Value, } #[derive(Debug, Serialize)] pub struct CommandSchema { - pub name: String, + pub name: String, pub description: String, - pub args: Vec, + pub args: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub output: Option, + pub output: Option, } #[derive(Debug, Serialize)] pub struct ArgSchema { - pub name: String, - pub r#type: String, - pub required: bool, + pub name: String, + pub r#type: String, + pub required: bool, pub description: String, #[serde(skip_serializing_if = "Option::is_none")] - pub r#enum: Option>, + pub r#enum: Option>, } impl AgentSchema { @@ -62,7 +62,8 @@ pub fn args_from_clap_command(cmd: &clap::Command) -> Vec { } else { "bool" }; - let possible: Vec = arg.get_possible_values() + let possible: Vec = arg + .get_possible_values() .iter() .filter_map(|v| v.get_name_and_aliases().next().map(String::from)) .collect(); @@ -71,10 +72,12 @@ pub fn args_from_clap_command(cmd: &clap::Command) -> Vec { name, r#type: type_str.to_string(), required: arg.is_required_set(), - description: arg.get_help() - .map(|s| s.to_string()) - .unwrap_or_default(), - r#enum: if possible.is_empty() { None } else { Some(possible) }, + description: arg.get_help().map(|s| s.to_string()).unwrap_or_default(), + r#enum: if possible.is_empty() { + None + } else { + Some(possible) + }, } }) .collect() diff --git a/crates/agent-describe/tests/derive_test.rs b/crates/agent-describe/tests/derive_test.rs index 32f0581..e476546 100644 --- a/crates/agent-describe/tests/derive_test.rs +++ b/crates/agent-describe/tests/derive_test.rs @@ -16,7 +16,7 @@ enum TestCommand { /// Deploy to an environment Deploy { /// Target environment - env: String, + env: String, /// Skip actual deployment #[arg(long)] dry_run: bool, @@ -24,9 +24,7 @@ enum TestCommand { /// Show greeting #[agent(skip)] - Hello { - name: String, - }, + Hello { name: String }, /// Check system status #[agent(output = CustomStatusOutput)] @@ -47,7 +45,7 @@ enum ConfigAction { /// Set a value Set { /// Config key - key: String, + key: String, /// Config value value: String, }, @@ -60,13 +58,13 @@ enum ConfigAction { #[derive(Serialize, JsonSchema)] struct DeployResult { - url: String, + url: String, took_secs: f64, } #[derive(Serialize, JsonSchema)] struct CustomStatusOutput { - healthy: bool, + healthy: bool, uptime_secs: u64, } @@ -78,7 +76,10 @@ fn schema_includes_deploy_but_not_hello() { assert_eq!(schema["name"], "testcli"); let commands = schema["commands"].as_array().unwrap(); - let names: Vec<&str> = commands.iter().map(|c| c["name"].as_str().unwrap()).collect(); + let names: Vec<&str> = commands + .iter() + .map(|c| c["name"].as_str().unwrap()) + .collect(); assert!(names.contains(&"deploy"), "deploy should be in schema"); assert!(!names.contains(&"hello"), "hello should be skipped"); @@ -94,7 +95,10 @@ fn deploy_has_args_and_output() { assert_eq!(deploy["description"], "Deploy to an environment"); let args = deploy["args"].as_array().unwrap(); - assert!(args.iter().any(|a| a["name"] == "env"), "should have env arg"); + assert!( + args.iter().any(|a| a["name"] == "env"), + "should have env arg" + ); assert!( args.iter() .any(|a| a["name"] == "--dry-run" && a["type"] == "bool"), @@ -146,7 +150,10 @@ fn output_override_uses_custom_type() { fn subcommand_flattening_produces_separate_commands() { let schema = TestCommand::agent_schema(); let commands = schema["commands"].as_array().unwrap(); - let names: Vec<&str> = commands.iter().map(|c| c["name"].as_str().unwrap()).collect(); + let names: Vec<&str> = commands + .iter() + .map(|c| c["name"].as_str().unwrap()) + .collect(); // Flattened subcommands should appear as "config set" and "config get" assert!( @@ -167,10 +174,7 @@ fn subcommand_flattening_produces_separate_commands() { ); // Verify args on "config set" - let config_set = commands - .iter() - .find(|c| c["name"] == "config set") - .unwrap(); + let config_set = commands.iter().find(|c| c["name"] == "config set").unwrap(); let args = config_set["args"].as_array().unwrap(); let arg_names: Vec<&str> = args.iter().map(|a| a["name"].as_str().unwrap()).collect(); assert!( @@ -183,10 +187,7 @@ fn subcommand_flattening_produces_separate_commands() { ); // Verify args on "config get" - let config_get = commands - .iter() - .find(|c| c["name"] == "config get") - .unwrap(); + let config_get = commands.iter().find(|c| c["name"] == "config get").unwrap(); let args = config_get["args"].as_array().unwrap(); let arg_names: Vec<&str> = args.iter().map(|a| a["name"].as_str().unwrap()).collect(); assert!( diff --git a/crates/agent-describe/tests/response_test.rs b/crates/agent-describe/tests/response_test.rs index 0714a82..b1a03bd 100644 --- a/crates/agent-describe/tests/response_test.rs +++ b/crates/agent-describe/tests/response_test.rs @@ -4,9 +4,13 @@ use serde_json::Value; #[test] fn ok_response_serializes_correctly() { #[derive(serde::Serialize)] - struct MyResult { url: String } + struct MyResult { + url: String, + } - let resp = AgentResponse::ok(MyResult { url: "https://example.com".into() }); + let resp = AgentResponse::ok(MyResult { + url: "https://example.com".into(), + }); let json: Value = serde_json::from_str(&resp.to_json()).unwrap(); assert_eq!(json["ok"], true); diff --git a/src/main.rs b/src/main.rs index 3e64472..2a29cea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ use clap::Parser; - use rara_cli_template::cli::{Cli, Command}; #[tokio::main] diff --git a/src/post_setup.rs b/src/post_setup.rs index 02c0111..4775311 100644 --- a/src/post_setup.rs +++ b/src/post_setup.rs @@ -1,7 +1,6 @@ //! Post-setup steps: git init, cargo check, and agent prompt output. -use std::path::Path; -use std::process::Stdio; +use std::{path::Path, process::Stdio}; /// Run all post-setup steps in the generated project directory. pub async fn run(project_dir: &Path, project_name: &str) { @@ -79,7 +78,9 @@ fn print_agent_prompt(project_name: &str, project_dir: &Path) { eprintln!("To start developing with an AI agent, copy the prompt below:"); eprintln!(); eprintln!("---"); - eprintln!("I have a new Rust CLI project \"{project_name}\" initialized from rara-cli-template."); + eprintln!( + "I have a new Rust CLI project \"{project_name}\" initialized from rara-cli-template." + ); eprintln!("The project is at {dir_display} with git already initialized."); eprintln!(); eprintln!("Read CLAUDE.md and docs/guides/agent-quickstart.md first, then:"); diff --git a/src/setup.rs b/src/setup.rs index f5ab094..da6d29b 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,7 +1,10 @@ -//! Setup command: collect parameters, validate, render template, run post-setup. +//! Setup command: collect parameters, validate, render template, run +//! post-setup. -use std::io::{self, BufRead, Write}; -use std::path::PathBuf; +use std::{ + io::{self, BufRead, Write}, + path::PathBuf, +}; use snafu::ResultExt; @@ -10,9 +13,9 @@ use crate::error::{self, IoSnafu, ValidationSnafu}; /// Collected setup parameters. pub struct SetupParams { pub project_name: String, - pub crate_name: String, - pub github_org: String, - pub output_dir: PathBuf, + pub crate_name: String, + pub github_org: String, + pub output_dir: PathBuf, } /// Collect setup parameters from CLI args and interactive prompts. @@ -43,10 +46,7 @@ pub fn collect_params( let crate_name = project_name.replace('-', "_"); - let output_dir = path.map_or_else( - || PathBuf::from(&project_name), - |p| p.join(&project_name), - ); + let output_dir = path.map_or_else(|| PathBuf::from(&project_name), |p| p.join(&project_name)); if output_dir.exists() { return ValidationSnafu { diff --git a/src/template.rs b/src/template.rs index 826ff02..e441be8 100644 --- a/src/template.rs +++ b/src/template.rs @@ -2,7 +2,7 @@ use std::path::Path; -use include_dir::{include_dir, Dir}; +use include_dir::{Dir, include_dir}; use snafu::ResultExt; use crate::error::{self, IoSnafu}; @@ -35,7 +35,11 @@ fn render_dir( github_org: &str, ) -> error::Result<()> { for entry in dir.dirs() { - let dir_name = entry.path().file_name().and_then(|n| n.to_str()).unwrap_or(""); + let dir_name = entry + .path() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); if SKIP_PATTERNS.contains(&dir_name) { continue; } @@ -93,7 +97,12 @@ fn is_text_file(path: &Path) -> bool { ) } -fn replace_placeholders(text: &str, project_name: &str, crate_name: &str, github_org: &str) -> String { +fn replace_placeholders( + text: &str, + project_name: &str, + crate_name: &str, + github_org: &str, +) -> String { text.replace("{{project-name}}", project_name) .replace("{{crate_name}}", crate_name) .replace("{{github-org}}", github_org) diff --git a/tests/setup_test.rs b/tests/setup_test.rs index 10cd472..b093142 100644 --- a/tests/setup_test.rs +++ b/tests/setup_test.rs @@ -2,9 +2,7 @@ use assert_cmd::Command; use predicates::prelude::predicate; use tempfile::TempDir; -fn cmd() -> Command { - Command::cargo_bin("rara-cli-template").expect("binary should exist") -} +fn cmd() -> Command { Command::cargo_bin("rara-cli-template").expect("binary should exist") } #[test] fn setup_creates_project_with_all_flags() {