Skip to main content

atd_cli/
cli.rs

1//! CLI surface: every clap-derive struct in one place.
2
3use clap::{Parser, Subcommand};
4use std::path::PathBuf;
5
6#[derive(Debug, Parser)]
7#[command(
8    name = "atd",
9    version,
10    about = "Reference client for the Agent Tool Dispatch (ATD) protocol."
11)]
12pub struct Cli {
13    /// Override the Unix socket path. Default: $HOME/.anos/anos.sock
14    #[arg(long, global = true)]
15    pub sock: Option<PathBuf>,
16
17    #[command(subcommand)]
18    pub command: Command,
19}
20
21#[derive(Debug, Subcommand)]
22pub enum Command {
23    /// List available tools (wraps the ATD `discover` API).
24    List(ListArgs),
25    /// Show a tool's full schema (wraps `describe`).
26    Schema(SchemaArgs),
27    /// Invoke a tool (wraps `call`).
28    Call(CallArgs),
29    /// Check connectivity to the ATD server.
30    Doctor(DoctorArgs),
31    /// Pull skill files from a connected ATD server (`<x>.skills.list/get`)
32    /// and write them to per-platform install paths.
33    Skills(SkillsCmd),
34}
35
36#[derive(Debug, clap::Args)]
37pub struct SkillsCmd {
38    #[command(subcommand)]
39    pub action: SkillsAction,
40}
41
42#[derive(Debug, Subcommand)]
43pub enum SkillsAction {
44    /// Sync skills from the connected ATD server to a per-platform directory.
45    Sync(SkillsSyncArgs),
46}
47
48#[derive(Debug, clap::Args)]
49pub struct SkillsSyncArgs {
50    /// Where to write the synced skills.
51    #[arg(long, value_enum)]
52    pub target: SyncTarget,
53    /// Override the target's default install directory (incompatible with stdout).
54    #[arg(long)]
55    pub out_dir: Option<PathBuf>,
56    /// List what would be written without writing.
57    #[arg(long)]
58    pub dry_run: bool,
59}
60
61#[derive(Debug, Clone, Copy, clap::ValueEnum)]
62pub enum SyncTarget {
63    Hermes,
64    ClaudeCode,
65    Stdout,
66}
67
68impl SyncTarget {
69    pub fn default_out_dir(&self) -> Option<PathBuf> {
70        let home = std::env::var_os("HOME").map(PathBuf::from)?;
71        match self {
72            SyncTarget::Hermes => Some(home.join(".hermes/skills")),
73            SyncTarget::ClaudeCode => Some(home.join(".claude/skills")),
74            SyncTarget::Stdout => None,
75        }
76    }
77}
78
79#[derive(Debug, clap::Args)]
80pub struct ListArgs {
81    /// Substring match against id/name/description.
82    #[arg(short, long)]
83    pub query: Option<String>,
84    /// Filter by domain (e.g. "fs", "web").
85    #[arg(short, long)]
86    pub domain: Option<String>,
87    /// Filter by tier.
88    #[arg(long, value_parser = ["hot", "warm", "cold"])]
89    pub tier: Option<String>,
90    /// Filter by visibility.
91    #[arg(long, value_parser = ["read", "write", "dangerous", "system"])]
92    pub visibility: Option<String>,
93    /// Cap the number of results.
94    #[arg(short, long)]
95    pub limit: Option<usize>,
96    /// Emit structured JSON instead of human output.
97    #[arg(long)]
98    pub json: bool,
99}
100
101#[derive(Debug, clap::Args)]
102pub struct SchemaArgs {
103    /// Tool id, e.g. "anos:fs.read".
104    pub tool_id: String,
105    /// Emit raw JSON instead of pretty-printed JSON.
106    #[arg(long)]
107    pub json: bool,
108}
109
110#[derive(Debug, clap::Args)]
111pub struct CallArgs {
112    /// Tool id, e.g. "anos:fs.read".
113    pub tool_id: String,
114    /// JSON arguments object, e.g. '{"path":"/tmp/x"}'. Defaults to `{}`.
115    #[arg(long, default_value = "{}")]
116    pub args: String,
117    /// Run in dry-run mode (server may return a preview).
118    #[arg(long)]
119    pub dry_run: bool,
120    /// Emit the result as raw JSON instead of pretty-printed.
121    #[arg(long)]
122    pub json: bool,
123}
124
125#[derive(Debug, clap::Args)]
126pub struct DoctorArgs {
127    /// Emit structured JSON instead of human output.
128    #[arg(long)]
129    pub json: bool,
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use clap::CommandFactory;
136
137    #[test]
138    fn cli_parses_list_with_flags() {
139        let cli = Cli::try_parse_from(["atd", "list", "--query", "fs", "--limit", "5", "--json"])
140            .unwrap();
141        match cli.command {
142            Command::List(args) => {
143                assert_eq!(args.query.as_deref(), Some("fs"));
144                assert_eq!(args.limit, Some(5));
145                assert!(args.json);
146            }
147            _ => panic!("expected List variant"),
148        }
149    }
150
151    #[test]
152    fn cli_parses_schema_with_positional_tool_id() {
153        let cli = Cli::try_parse_from(["atd", "schema", "anos:fs.read"]).unwrap();
154        match cli.command {
155            Command::Schema(args) => assert_eq!(args.tool_id, "anos:fs.read"),
156            _ => panic!("expected Schema variant"),
157        }
158    }
159
160    #[test]
161    fn cli_parses_call_with_args_and_dry_run() {
162        let cli = Cli::try_parse_from([
163            "atd",
164            "call",
165            "anos:fs.read",
166            "--args",
167            r#"{"path":"/tmp/x"}"#,
168            "--dry-run",
169        ])
170        .unwrap();
171        match cli.command {
172            Command::Call(args) => {
173                assert_eq!(args.tool_id, "anos:fs.read");
174                assert_eq!(args.args, r#"{"path":"/tmp/x"}"#);
175                assert!(args.dry_run);
176            }
177            _ => panic!("expected Call variant"),
178        }
179    }
180
181    #[test]
182    fn sock_flag_is_global_and_parses_before_subcommand() {
183        let cli = Cli::try_parse_from(["atd", "--sock", "/tmp/x.sock", "list"]).unwrap();
184        assert_eq!(
185            cli.sock
186                .as_deref()
187                .map(|p| p.to_string_lossy().into_owned()),
188            Some("/tmp/x.sock".to_string())
189        );
190    }
191
192    #[test]
193    fn invalid_tier_value_is_rejected() {
194        let err = Cli::try_parse_from(["atd", "list", "--tier", "lukewarm"]).unwrap_err();
195        let s = err.to_string();
196        assert!(
197            s.contains("lukewarm"),
198            "error should mention bad value, got: {s}"
199        );
200    }
201
202    #[test]
203    fn cli_parses_skills_sync_with_target_and_out_dir() {
204        let cli = Cli::try_parse_from([
205            "atd",
206            "skills",
207            "sync",
208            "--target",
209            "hermes",
210            "--out-dir",
211            "/tmp/out",
212        ])
213        .unwrap();
214        match cli.command {
215            Command::Skills(SkillsCmd {
216                action: SkillsAction::Sync(args),
217            }) => {
218                assert!(matches!(args.target, SyncTarget::Hermes));
219                assert_eq!(
220                    args.out_dir
221                        .as_deref()
222                        .map(|p| p.to_string_lossy().into_owned()),
223                    Some("/tmp/out".into())
224                );
225                assert!(!args.dry_run);
226            }
227            _ => panic!("expected Skills(Sync) variant"),
228        }
229    }
230
231    #[test]
232    fn cli_parses_skills_sync_stdout_target() {
233        let cli = Cli::try_parse_from(["atd", "skills", "sync", "--target", "stdout"]).unwrap();
234        match cli.command {
235            Command::Skills(SkillsCmd {
236                action: SkillsAction::Sync(args),
237            }) => assert!(matches!(args.target, SyncTarget::Stdout)),
238            _ => panic!("expected Skills(Sync) variant"),
239        }
240    }
241
242    #[test]
243    fn cli_is_wellformed() {
244        // Fails at compile / factory time if the derive macros produce an
245        // invalid command tree (e.g. overlapping short flags).
246        Cli::command().debug_assert();
247    }
248}