claude_code_cli_acp/config/
commands.rs1use std::path::{Path, PathBuf};
2
3use agent_client_protocol::schema::{
4 AvailableCommand, AvailableCommandInput, UnstructuredCommandInput,
5};
6
7const UNSUPPORTED_COMMANDS: &[&str] = &[
8 "cost",
9 "keybindings-help",
10 "login",
11 "logout",
12 "output-style:new",
13 "release-notes",
14 "todos",
15];
16
17pub fn available_commands(cwd: &Path) -> Vec<AvailableCommand> {
18 let mut commands = builtin_commands();
19 commands.extend(discover_project_commands(cwd));
20 commands.extend(discover_project_skills(cwd));
21 commands.sort_by(|a, b| a.name.cmp(&b.name));
22 commands.dedup_by(|a, b| a.name == b.name);
23 commands
24 .into_iter()
25 .filter(|command| !UNSUPPORTED_COMMANDS.contains(&command.name.as_str()))
26 .collect()
27}
28
29fn builtin_commands() -> Vec<AvailableCommand> {
30 vec![
31 AvailableCommand::new("init", "Create or update project instructions"),
32 AvailableCommand::new(
33 "compact",
34 "Free up context by summarizing the conversation so far",
35 )
36 .input(AvailableCommandInput::Unstructured(
37 UnstructuredCommandInput::new("<optional custom summarization instructions>"),
38 )),
39 AvailableCommand::new("clear", "Clear the current Claude conversation"),
40 AvailableCommand::new("resume", "Resume a Claude session").input(
41 AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(
42 "Optional session selector or instructions",
43 )),
44 ),
45 ]
46}
47
48fn discover_project_commands(cwd: &Path) -> Vec<AvailableCommand> {
49 let root = cwd.join(".claude/commands");
50 markdown_files(&root)
51 .into_iter()
52 .filter_map(|path| {
53 let name = command_name(&root, &path)?;
54 let metadata = command_metadata(&path).ok()?;
55 Some(command_from_metadata(name, metadata))
56 })
57 .collect()
58}
59
60fn discover_project_skills(cwd: &Path) -> Vec<AvailableCommand> {
61 let root = cwd.join(".claude/skills");
62 let mut files = markdown_files(&root);
63 files.extend(skill_entrypoints(&root));
64 files.sort();
65 files.dedup();
66 files
67 .into_iter()
68 .filter_map(|path| {
69 let name = skill_name(&root, &path)?;
70 let metadata = command_metadata(&path).ok()?;
71 Some(command_from_metadata(name, metadata))
72 })
73 .collect()
74}
75
76fn markdown_files(root: &Path) -> Vec<PathBuf> {
77 let mut files = Vec::new();
78 let mut stack = vec![root.to_path_buf()];
79 while let Some(path) = stack.pop() {
80 let Ok(entries) = std::fs::read_dir(&path) else {
81 continue;
82 };
83 for entry in entries.flatten() {
84 let entry_path = entry.path();
85 let Ok(file_type) = entry.file_type() else {
86 continue;
87 };
88 if file_type.is_dir() {
89 stack.push(entry_path);
90 } else if file_type.is_file()
91 && entry_path.extension().and_then(|ext| ext.to_str()) == Some("md")
92 {
93 files.push(entry_path);
94 }
95 }
96 }
97 files
98}
99
100fn skill_entrypoints(root: &Path) -> Vec<PathBuf> {
101 let mut files = Vec::new();
102 let Ok(entries) = std::fs::read_dir(root) else {
103 return files;
104 };
105 for entry in entries.flatten() {
106 let path = entry.path().join("SKILL.md");
107 if path.is_file() {
108 files.push(path);
109 }
110 }
111 files
112}
113
114fn command_name(root: &Path, path: &Path) -> Option<String> {
115 let relative = path.strip_prefix(root).ok()?;
116 let mut parts = relative
117 .components()
118 .map(|component| component.as_os_str().to_string_lossy().to_string())
119 .collect::<Vec<_>>();
120 let file = parts.pop()?;
121 parts.push(file.strip_suffix(".md").unwrap_or(&file).to_string());
122 Some(parts.join(":"))
123}
124
125fn skill_name(root: &Path, path: &Path) -> Option<String> {
126 let relative = path.strip_prefix(root).ok()?;
127 let parts = relative
128 .components()
129 .map(|component| component.as_os_str().to_string_lossy().to_string())
130 .collect::<Vec<_>>();
131 match parts.as_slice() {
132 [file] => Some(file.strip_suffix(".md").unwrap_or(file).to_string()),
133 [dir, file] if file == "SKILL.md" => Some(dir.clone()),
134 _ => command_name(root, path),
135 }
136}
137
138#[derive(Clone, Debug, Default, PartialEq, Eq)]
139struct CommandMetadata {
140 description: String,
141 argument_hint: Option<String>,
142}
143
144fn command_metadata(path: &Path) -> anyhow::Result<CommandMetadata> {
145 let text = std::fs::read_to_string(path)?;
146 let frontmatter = frontmatter(&text);
147 let description = frontmatter
148 .as_ref()
149 .and_then(|metadata| metadata_value(metadata, "description"))
150 .or_else(|| first_heading_or_line(&text))
151 .unwrap_or_default();
152 let argument_hint = frontmatter
153 .as_ref()
154 .and_then(|metadata| metadata_value(metadata, "argument-hint"))
155 .or_else(|| {
156 frontmatter
157 .as_ref()
158 .and_then(|metadata| metadata_value(metadata, "argument_hint"))
159 });
160
161 Ok(CommandMetadata {
162 description,
163 argument_hint,
164 })
165}
166
167fn command_from_metadata(name: String, metadata: CommandMetadata) -> AvailableCommand {
168 let mut command = AvailableCommand::new(name, metadata.description);
169 if let Some(hint) = metadata
170 .argument_hint
171 .filter(|hint| !hint.trim().is_empty())
172 {
173 command = command.input(AvailableCommandInput::Unstructured(
174 UnstructuredCommandInput::new(hint),
175 ));
176 }
177 command
178}
179
180fn frontmatter(text: &str) -> Option<String> {
181 let mut lines = text.lines();
182 if lines.next()? != "---" {
183 return None;
184 }
185 let mut metadata = Vec::new();
186 for line in lines {
187 if line == "---" {
188 return Some(metadata.join("\n"));
189 }
190 metadata.push(line);
191 }
192 None
193}
194
195fn metadata_value(metadata: &str, key: &str) -> Option<String> {
196 for line in metadata.lines() {
197 if let Some((line_key, value)) = line.split_once(':')
198 && line_key.trim() == key
199 {
200 return Some(trim_yaml_scalar(value));
201 }
202 }
203 None
204}
205
206fn trim_yaml_scalar(value: &str) -> String {
207 value
208 .trim()
209 .trim_matches('"')
210 .trim_matches('\'')
211 .to_string()
212}
213
214fn first_heading_or_line(text: &str) -> Option<String> {
215 text.lines()
216 .map(str::trim)
217 .filter(|line| !line.is_empty() && *line != "---")
218 .find_map(|line| {
219 let line = line.strip_prefix('#').map(str::trim).unwrap_or(line);
220 (!line.is_empty()).then(|| line.to_string())
221 })
222}