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