1use 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 #[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(ListArgs),
25 Schema(SchemaArgs),
27 Call(CallArgs),
29 Doctor(DoctorArgs),
31 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(SkillsSyncArgs),
46}
47
48#[derive(Debug, clap::Args)]
49pub struct SkillsSyncArgs {
50 #[arg(long, value_enum)]
52 pub target: SyncTarget,
53 #[arg(long)]
55 pub out_dir: Option<PathBuf>,
56 #[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 #[arg(short, long)]
83 pub query: Option<String>,
84 #[arg(short, long)]
86 pub domain: Option<String>,
87 #[arg(long, value_parser = ["hot", "warm", "cold"])]
89 pub tier: Option<String>,
90 #[arg(long, value_parser = ["read", "write", "dangerous", "system"])]
92 pub visibility: Option<String>,
93 #[arg(short, long)]
95 pub limit: Option<usize>,
96 #[arg(long)]
98 pub json: bool,
99}
100
101#[derive(Debug, clap::Args)]
102pub struct SchemaArgs {
103 pub tool_id: String,
105 #[arg(long)]
107 pub json: bool,
108}
109
110#[derive(Debug, clap::Args)]
111pub struct CallArgs {
112 pub tool_id: String,
114 #[arg(long, default_value = "{}")]
116 pub args: String,
117 #[arg(long)]
119 pub dry_run: bool,
120 #[arg(long)]
122 pub json: bool,
123}
124
125#[derive(Debug, clap::Args)]
126pub struct DoctorArgs {
127 #[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 Cli::command().debug_assert();
247 }
248}