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