1use crate::permission::JcliConfig;
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7const COMMAND_MENTION_PREFIX: &str = "@command:";
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CommandSource {
17 User,
19 Project,
21}
22
23impl CommandSource {
24 pub fn label(&self) -> &'static str {
26 match self {
27 CommandSource::User => "用户",
28 CommandSource::Project => "项目",
29 }
30 }
31}
32
33#[derive(Debug, Clone, Deserialize)]
35pub struct CommandFrontmatter {
36 pub name: String,
37 pub description: String,
38}
39
40#[derive(Debug, Clone)]
42pub struct CustomCommand {
43 pub frontmatter: CommandFrontmatter,
44 pub body: String,
46 pub source: CommandSource,
48}
49
50pub fn commands_dir() -> PathBuf {
54 let dir = crate::constants::data_root().join("agent").join("commands");
55 let _ = fs::create_dir_all(&dir);
56 dir
57}
58
59pub fn project_commands_dir() -> Option<PathBuf> {
61 let config_dir = JcliConfig::find_config_dir()?;
62 let dir = config_dir.join("commands");
63 if dir.is_dir() { Some(dir) } else { None }
64}
65
66fn parse_command_md(path: &Path, source: CommandSource) -> Option<CustomCommand> {
68 let content = fs::read_to_string(path).ok()?;
69 let (fm_str, body) = super::skill::split_frontmatter(&content)?;
70 let frontmatter: CommandFrontmatter = serde_yaml::from_str(&fm_str).ok()?;
71
72 if frontmatter.name.is_empty() {
73 return None;
74 }
75
76 Some(CustomCommand {
77 frontmatter,
78 body: body.trim().to_string(),
79 source,
80 })
81}
82
83fn load_commands_from_dir(dir: &Path, source: CommandSource) -> Vec<CustomCommand> {
85 let mut commands = Vec::new();
86 let entries = match fs::read_dir(dir) {
87 Ok(e) => e,
88 Err(_) => return commands,
89 };
90 for entry in entries.flatten() {
91 let path = entry.path();
92 if path.is_dir() {
93 let cmd_md = path.join("COMMAND.md");
95 if cmd_md.exists()
96 && let Some(cmd) = parse_command_md(&cmd_md, source)
97 {
98 commands.push(cmd);
99 }
100 } else if path.extension().is_some_and(|ext| ext == "md") {
101 if let Some(cmd) = parse_command_md(&path, source) {
103 commands.push(cmd);
104 }
105 }
106 }
107 commands
108}
109
110pub fn load_all_commands() -> Vec<CustomCommand> {
112 let mut map: HashMap<String, CustomCommand> = HashMap::new();
113
114 for cmd in load_commands_from_dir(&commands_dir(), CommandSource::User) {
116 map.insert(cmd.frontmatter.name.clone(), cmd);
117 }
118
119 if let Some(dir) = project_commands_dir() {
121 for cmd in load_commands_from_dir(&dir, CommandSource::Project) {
122 map.insert(cmd.frontmatter.name.clone(), cmd);
123 }
124 }
125
126 let mut commands: Vec<CustomCommand> = map.into_values().collect();
127 commands.sort_by(|a, b| a.frontmatter.name.cmp(&b.frontmatter.name));
128 commands
129}
130
131pub fn expand_command_mentions(
133 text: &str,
134 commands: &[CustomCommand],
135 disabled: &[String],
136) -> String {
137 let mut result = text.to_string();
138 while let Some(start) = result.find(COMMAND_MENTION_PREFIX) {
140 let name_start = start + COMMAND_MENTION_PREFIX.len();
141 let name_end = result[name_start..]
143 .find(|c: char| c.is_whitespace())
144 .map(|i| name_start + i)
145 .unwrap_or(result.len());
146 let name = &result[name_start..name_end];
147
148 if let Some(cmd) = commands
149 .iter()
150 .find(|c| c.frontmatter.name == name && !disabled.iter().any(|d| d == name))
151 {
152 result.replace_range(start..name_end, &cmd.body);
153 } else {
154 break;
156 }
157 }
158 result
159}
160
161pub fn save_new_command(
168 source: CommandSource,
169 content: &str,
170) -> std::io::Result<(PathBuf, String)> {
171 let (fm_str, _body) = super::skill::split_frontmatter(content).ok_or_else(|| {
173 std::io::Error::new(std::io::ErrorKind::InvalidData, "缺少 YAML frontmatter")
174 })?;
175 let frontmatter: CommandFrontmatter = serde_yaml::from_str(&fm_str).map_err(|e| {
176 std::io::Error::new(
177 std::io::ErrorKind::InvalidData,
178 format!("解析 frontmatter 失败: {}", e),
179 )
180 })?;
181
182 if frontmatter.name.is_empty() {
183 return Err(std::io::Error::new(
184 std::io::ErrorKind::InvalidData,
185 "命令 name 不能为空",
186 ));
187 }
188
189 let name = &frontmatter.name;
191 if !name
192 .chars()
193 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
194 {
195 return Err(std::io::Error::new(
196 std::io::ErrorKind::InvalidData,
197 "命令 name 只允许字母、数字、下划线、连字符",
198 ));
199 }
200
201 let dir = commands_dir_for_source(source);
203 let _ = fs::create_dir_all(&dir);
204
205 let path = dir.join(format!("{}.md", name));
207 fs::write(&path, content)?;
208
209 Ok((path, name.clone()))
210}
211
212pub fn commands_dir_for_source(source: CommandSource) -> PathBuf {
214 match source {
215 CommandSource::User => commands_dir(),
216 CommandSource::Project => {
217 let config_dir =
219 JcliConfig::find_config_dir().unwrap_or_else(|| PathBuf::from(".jcli"));
220 config_dir.join("commands")
221 }
222 }
223}