mod expansion;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::debug;
use expansion::parse_frontmatter;
#[derive(Debug, Clone)]
pub struct CustomCommand {
pub name: String,
pub template: String,
pub source: String,
pub description: String,
pub model: Option<String>,
pub agent: Option<String>,
pub subtask: bool,
}
#[derive(Debug, Clone)]
pub struct CommandInfo {
pub name: String,
pub description: String,
pub source: String,
pub model: Option<String>,
pub agent: Option<String>,
}
pub struct CustomCommandLoader {
working_dir: PathBuf,
commands: Option<HashMap<String, CustomCommand>>,
}
impl CustomCommandLoader {
pub fn new(working_dir: &Path) -> Self {
Self {
working_dir: working_dir.to_path_buf(),
commands: None,
}
}
pub fn load_commands(&mut self) -> &HashMap<String, CustomCommand> {
if let Some(ref cmds) = self.commands {
return cmds;
}
let mut commands = HashMap::new();
let dirs = self.get_command_dirs();
for (cmd_dir, source) in dirs {
if let Ok(entries) = fs::read_dir(&cmd_dir) {
let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
paths.sort();
for path in paths {
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
match ext {
Some("md") | Some("txt") | None => {}
_ => continue,
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if stem.starts_with('.') || stem.starts_with('_') {
continue;
}
match fs::read_to_string(&path) {
Ok(raw_content) => {
let (frontmatter, template) = parse_frontmatter(&raw_content);
let description = frontmatter
.get("description")
.cloned()
.or_else(|| {
template
.trim()
.lines()
.next()
.filter(|line| line.starts_with('#'))
.map(|line| line.trim_start_matches('#').trim().to_string())
})
.unwrap_or_default();
let model = frontmatter.get("model").cloned();
let agent = frontmatter.get("agent").cloned();
let subtask = frontmatter.get("subtask").is_some_and(|v| v == "true");
let file_name =
path.file_name().and_then(|n| n.to_str()).unwrap_or(&stem);
let source_label = format!("{}:{}", source, file_name);
commands.entry(stem.clone()).or_insert(CustomCommand {
name: stem,
template,
source: source_label,
description,
model,
agent,
subtask,
});
}
Err(e) => {
debug!("Failed to load command {:?}: {}", path, e);
}
}
}
}
}
if !commands.is_empty() {
let names: Vec<&str> = commands.keys().map(|s| s.as_str()).collect();
debug!("Loaded {} custom commands: {:?}", commands.len(), names);
}
self.commands = Some(commands);
self.commands
.as_ref()
.expect("commands was just set to Some")
}
pub fn get_command(&mut self, name: &str) -> Option<&CustomCommand> {
self.load_commands().get(name)
}
pub fn list_commands(&mut self) -> Vec<CommandInfo> {
self.load_commands()
.values()
.map(|cmd| CommandInfo {
name: cmd.name.clone(),
description: cmd.description.clone(),
source: cmd.source.clone(),
model: cmd.model.clone(),
agent: cmd.agent.clone(),
})
.collect()
}
pub fn reload(&mut self) {
self.commands = None;
}
fn get_command_dirs(&self) -> Vec<(PathBuf, &'static str)> {
let mut dirs = Vec::new();
let local = self.working_dir.join(".opendev/commands");
if local.is_dir() {
dirs.push((local, "project"));
}
if let Some(home) = dirs_next::home_dir() {
let global = home.join(".opendev/commands");
if global.is_dir() {
dirs.push((global, "global"));
}
}
dirs
}
}
#[cfg(test)]
mod tests;