Skip to main content

j_agent/infra/
command.rs

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
8// ========== 常量 ==========
9
10/// 消息中引用自定义命令的前缀标记
11const COMMAND_MENTION_PREFIX: &str = "@command:";
12
13// ========== 数据结构 ==========
14
15/// Command 来源层级
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum CommandSource {
18    /// 用户级: ~/.jdata/agent/commands/
19    User,
20    /// 项目级: .jcli/commands/
21    Project,
22}
23
24impl CommandSource {
25    /// 返回当前来源的中文标签
26    pub fn label(&self) -> &'static str {
27        match self {
28            CommandSource::User => "用户",
29            CommandSource::Project => "项目",
30        }
31    }
32}
33
34/// 自定义命令的 YAML frontmatter 元数据
35#[derive(Debug, Clone, Deserialize)]
36pub struct CommandFrontmatter {
37    pub name: String,
38    pub description: String,
39}
40
41/// 自定义命令,包含 frontmatter 元数据、提示词正文和来源层级
42#[derive(Debug, Clone)]
43pub struct CustomCommand {
44    pub frontmatter: CommandFrontmatter,
45    /// 提示词正文
46    pub body: String,
47    /// 来源层级
48    pub source: CommandSource,
49}
50
51// ========== 加载与解析 ==========
52
53/// 返回用户级 commands 目录: ~/.jdata/agent/commands/
54pub 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
60/// 返回项目级 commands 目录: .jcli/commands/(如果存在)
61pub 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
67/// 解析 COMMAND.md: YAML frontmatter + body
68fn 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
84/// 从指定目录加载 commands
85fn 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            // 目录制: commands/review/COMMAND.md
95            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            // 单文件制: commands/review.md
103            if let Some(cmd) = parse_command_md(&path, source) {
104                commands.push(cmd);
105            }
106        }
107    }
108    commands
109}
110
111/// 加载所有 commands(用户级 + 项目级,同名时项目级覆盖)
112pub fn load_all_commands() -> Vec<CustomCommand> {
113    let mut map: HashMap<String, CustomCommand> = HashMap::new();
114
115    // 1. 用户级
116    for cmd in load_commands_from_dir(&commands_dir(), CommandSource::User) {
117        map.insert(cmd.frontmatter.name.clone(), cmd);
118    }
119
120    // 2. 项目级(覆盖同名)
121    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
132/// 展开消息中的 @command:name 引用,替换为 command body
133pub fn expand_command_mentions(
134    text: &str,
135    commands: &[CustomCommand],
136    disabled: &[String],
137) -> String {
138    let mut result = text.to_string();
139    // 反复查找 @command: 模式
140    while let Some(start) = result.find(COMMAND_MENTION_PREFIX) {
141        let name_start = start + COMMAND_MENTION_PREFIX.len();
142        // 名称到空白字符或字符串末尾为止
143        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            // 未找到匹配的 command,跳过避免死循环
156            break;
157        }
158    }
159    result
160}
161
162// ========== 创建与保存 ==========
163
164/// 保存新命令到指定目录
165///
166/// 从 frontmatter 解析 name,保存到对应目录。
167/// 返回保存路径和命令名称。
168pub fn save_new_command(source: CommandSource, content: &str) -> io::Result<(PathBuf, String)> {
169    // 解析 frontmatter 获取 name
170    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    // 验证 name 格式:只允许字母、数字、下划线、连字符
184    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    // 确定保存目录
196    let dir = commands_dir_for_source(source);
197    let _ = fs::create_dir_all(&dir);
198
199    // 保存为 {name}.md
200    let path = dir.join(format!("{}.md", name));
201    fs::write(&path, content)?;
202
203    Ok((path, name.clone()))
204}
205
206/// 返回指定来源的 commands 目录路径
207pub fn commands_dir_for_source(source: CommandSource) -> PathBuf {
208    match source {
209        CommandSource::User => commands_dir(),
210        CommandSource::Project => {
211            // 项目级:需要确保 .jcli 目录存在
212            let config_dir =
213                JcliConfig::find_config_dir().unwrap_or_else(|| PathBuf::from(".jcli"));
214            config_dir.join("commands")
215        }
216    }
217}