use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(
name = "atd",
version,
about = "Reference client for the Agent Tool Dispatch (ATD) protocol."
)]
pub struct Cli {
#[arg(long, global = true)]
pub sock: Option<PathBuf>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
List(ListArgs),
Schema(SchemaArgs),
Call(CallArgs),
Doctor(DoctorArgs),
Skills(SkillsCmd),
}
#[derive(Debug, clap::Args)]
pub struct SkillsCmd {
#[command(subcommand)]
pub action: SkillsAction,
}
#[derive(Debug, Subcommand)]
pub enum SkillsAction {
Sync(SkillsSyncArgs),
}
#[derive(Debug, clap::Args)]
pub struct SkillsSyncArgs {
#[arg(long, value_enum)]
pub target: SyncTarget,
#[arg(long)]
pub out_dir: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum SyncTarget {
Hermes,
ClaudeCode,
Stdout,
}
impl SyncTarget {
pub fn default_out_dir(&self) -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
match self {
SyncTarget::Hermes => Some(home.join(".hermes/skills")),
SyncTarget::ClaudeCode => Some(home.join(".claude/skills")),
SyncTarget::Stdout => None,
}
}
}
#[derive(Debug, clap::Args)]
pub struct ListArgs {
#[arg(short, long)]
pub query: Option<String>,
#[arg(short, long)]
pub domain: Option<String>,
#[arg(long, value_parser = ["hot", "warm", "cold"])]
pub tier: Option<String>,
#[arg(long, value_parser = ["read", "write", "dangerous", "system"])]
pub visibility: Option<String>,
#[arg(short, long)]
pub limit: Option<usize>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, clap::Args)]
pub struct SchemaArgs {
pub tool_id: String,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, clap::Args)]
pub struct CallArgs {
pub tool_id: String,
#[arg(long, default_value = "{}")]
pub args: String,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, clap::Args)]
pub struct DoctorArgs {
#[arg(long)]
pub json: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_parses_list_with_flags() {
let cli = Cli::try_parse_from(["atd", "list", "--query", "fs", "--limit", "5", "--json"])
.unwrap();
match cli.command {
Command::List(args) => {
assert_eq!(args.query.as_deref(), Some("fs"));
assert_eq!(args.limit, Some(5));
assert!(args.json);
}
_ => panic!("expected List variant"),
}
}
#[test]
fn cli_parses_schema_with_positional_tool_id() {
let cli = Cli::try_parse_from(["atd", "schema", "anos:fs.read"]).unwrap();
match cli.command {
Command::Schema(args) => assert_eq!(args.tool_id, "anos:fs.read"),
_ => panic!("expected Schema variant"),
}
}
#[test]
fn cli_parses_call_with_args_and_dry_run() {
let cli = Cli::try_parse_from([
"atd",
"call",
"anos:fs.read",
"--args",
r#"{"path":"/tmp/x"}"#,
"--dry-run",
])
.unwrap();
match cli.command {
Command::Call(args) => {
assert_eq!(args.tool_id, "anos:fs.read");
assert_eq!(args.args, r#"{"path":"/tmp/x"}"#);
assert!(args.dry_run);
}
_ => panic!("expected Call variant"),
}
}
#[test]
fn sock_flag_is_global_and_parses_before_subcommand() {
let cli = Cli::try_parse_from(["atd", "--sock", "/tmp/x.sock", "list"]).unwrap();
assert_eq!(
cli.sock
.as_deref()
.map(|p| p.to_string_lossy().into_owned()),
Some("/tmp/x.sock".to_string())
);
}
#[test]
fn invalid_tier_value_is_rejected() {
let err = Cli::try_parse_from(["atd", "list", "--tier", "lukewarm"]).unwrap_err();
let s = err.to_string();
assert!(
s.contains("lukewarm"),
"error should mention bad value, got: {s}"
);
}
#[test]
fn cli_parses_skills_sync_with_target_and_out_dir() {
let cli = Cli::try_parse_from([
"atd",
"skills",
"sync",
"--target",
"hermes",
"--out-dir",
"/tmp/out",
])
.unwrap();
match cli.command {
Command::Skills(SkillsCmd {
action: SkillsAction::Sync(args),
}) => {
assert!(matches!(args.target, SyncTarget::Hermes));
assert_eq!(
args.out_dir
.as_deref()
.map(|p| p.to_string_lossy().into_owned()),
Some("/tmp/out".into())
);
assert!(!args.dry_run);
}
_ => panic!("expected Skills(Sync) variant"),
}
}
#[test]
fn cli_parses_skills_sync_stdout_target() {
let cli = Cli::try_parse_from(["atd", "skills", "sync", "--target", "stdout"]).unwrap();
match cli.command {
Command::Skills(SkillsCmd {
action: SkillsAction::Sync(args),
}) => assert!(matches!(args.target, SyncTarget::Stdout)),
_ => panic!("expected Skills(Sync) variant"),
}
}
#[test]
fn cli_is_wellformed() {
Cli::command().debug_assert();
}
}