Skip to main content

claude_code_cli_acp/config/
commands.rs

1use 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}