1use super::*;
2use super::template::expand_command_template;
3
4#[derive(Clone)]
5pub struct CommandRegistry {
6 commands: Arc<HashMap<String, CommandRecord>>,
7}
8
9impl CommandRegistry {
10 pub fn load(workspace_dir: Option<&Path>) -> Option<Arc<Self>> {
11 let sources = discover_command_sources(workspace_dir);
12 let mut commands = HashMap::new();
13
14 for source in sources {
15 let files = discover_command_files(&source.root);
16
17 for (name, path) in files {
18 if commands.contains_key(&name) {
19 tracing::debug!(
20 command = %name,
21 path = %path.display(),
22 "command shadowed by higher-precedence definition"
23 );
24 continue;
25 }
26
27 match parse_command_file(&path) {
28 Ok(Some(parsed)) => {
29 if parsed.model.is_some() {
30 tracing::debug!(
31 command = %name,
32 model = ?parsed.model,
33 "command specifies model override (ignored in sac)"
34 );
35 }
36 commands.insert(
37 name.clone(),
38 CommandRecord {
39 name: name.clone(),
40 description: parsed.description,
41 agent: parsed.agent,
42 model: parsed.model,
43 subtask: parsed.subtask,
44 template: parsed.template,
45 source_path: path,
46 },
47 );
48 }
49 Ok(None) => {
50 tracing::debug!(
51 path = %path.display(),
52 "command file skipped (empty or invalid)"
53 );
54 }
55 Err(error) => {
56 eprintln!(
57 "Command file '{}' has errors and will be skipped: {:#}",
58 path.display(),
59 error
60 );
61 }
62 }
63 }
64 }
65
66 if commands.is_empty() {
67 return None;
68 }
69
70 tracing::info!(count = commands.len(), "loaded custom commands");
71 Some(Arc::new(Self {
72 commands: Arc::new(commands),
73 }))
74 }
75
76 pub fn has_command(&self, name: &str) -> bool {
77 self.commands.contains_key(name)
78 }
79
80 pub fn get(&self, name: &str) -> Option<&CommandRecord> {
81 self.commands.get(name)
82 }
83
84 pub fn catalog_entries(&self) -> Vec<CommandCatalogEntry> {
85 let mut entries: Vec<_> = self
86 .commands
87 .values()
88 .map(|cmd| CommandCatalogEntry {
89 name: cmd.name.clone(),
90 description: cmd.description.clone(),
91 })
92 .collect();
93 entries.sort_by(|a, b| a.name.cmp(&b.name));
94 entries
95 }
96
97 pub fn expand(
98 &self,
99 name: &str,
100 args: &str,
101 working_directory: &Path,
102 ) -> Option<String> {
103 let record = self.commands.get(name)?;
104
105 let expanded = expand_command_template(&record.template, args, working_directory);
106
107 let mut prompt = format!(
109 "# /{}: Custom Command\n\nArguments:\n{}\n\n",
110 name,
111 if args.is_empty() { "(none)" } else { args }
112 );
113
114 if let Some(agent) = &record.agent {
115 prompt.push_str(&format!(
116 "[Skill hint: consider activating the \"{}\" skill if available.]\n\n",
117 agent
118 ));
119 }
120
121 prompt.push_str(&expanded);
122 Some(prompt)
123 }
124}