mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-03 10:04:47 +08:00
2512 lines
85 KiB
Rust
2512 lines
85 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use plugins::{PluginError, PluginManager, PluginSummary};
|
|
use runtime::{compact_session, CompactionConfig, Session};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CommandManifestEntry {
|
|
pub name: String,
|
|
pub source: CommandSource,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CommandSource {
|
|
Builtin,
|
|
InternalOnly,
|
|
FeatureGated,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct CommandRegistry {
|
|
entries: Vec<CommandManifestEntry>,
|
|
}
|
|
|
|
impl CommandRegistry {
|
|
#[must_use]
|
|
pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
|
|
Self { entries }
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn entries(&self) -> &[CommandManifestEntry] {
|
|
&self.entries
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct SlashCommandSpec {
|
|
pub name: &'static str,
|
|
pub aliases: &'static [&'static str],
|
|
pub summary: &'static str,
|
|
pub argument_hint: Option<&'static str>,
|
|
pub resume_supported: bool,
|
|
}
|
|
|
|
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|
SlashCommandSpec {
|
|
name: "help",
|
|
aliases: &[],
|
|
summary: "Show available slash commands",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "status",
|
|
aliases: &[],
|
|
summary: "Show current session status",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "compact",
|
|
aliases: &[],
|
|
summary: "Compact local session history",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "model",
|
|
aliases: &[],
|
|
summary: "Show or switch the active model",
|
|
argument_hint: Some("[model]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "permissions",
|
|
aliases: &[],
|
|
summary: "Show or switch the active permission mode",
|
|
argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "clear",
|
|
aliases: &[],
|
|
summary: "Start a fresh local session",
|
|
argument_hint: Some("[--confirm]"),
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "cost",
|
|
aliases: &[],
|
|
summary: "Show cumulative token usage for this session",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "resume",
|
|
aliases: &[],
|
|
summary: "Load a saved session into the REPL",
|
|
argument_hint: Some("<session-path>"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "config",
|
|
aliases: &[],
|
|
summary: "Inspect Claw config files or merged sections",
|
|
argument_hint: Some("[env|hooks|model|plugins]"),
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "memory",
|
|
aliases: &[],
|
|
summary: "Inspect loaded Claw instruction memory files",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "init",
|
|
aliases: &[],
|
|
summary: "Create a starter CLAW.md for this repo",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "diff",
|
|
aliases: &[],
|
|
summary: "Show git diff for current workspace changes",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "version",
|
|
aliases: &[],
|
|
summary: "Show CLI version and build information",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "bughunter",
|
|
aliases: &[],
|
|
summary: "Inspect the codebase for likely bugs",
|
|
argument_hint: Some("[scope]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "branch",
|
|
aliases: &[],
|
|
summary: "List, create, or switch git branches",
|
|
argument_hint: Some("[list|create <name>|switch <name>]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "worktree",
|
|
aliases: &[],
|
|
summary: "List, add, remove, or prune git worktrees",
|
|
argument_hint: Some("[list|add <path> [branch]|remove <path>|prune]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "commit",
|
|
aliases: &[],
|
|
summary: "Generate a commit message and create a git commit",
|
|
argument_hint: None,
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "commit-push-pr",
|
|
aliases: &[],
|
|
summary: "Commit workspace changes, push the branch, and open a PR",
|
|
argument_hint: Some("[context]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "pr",
|
|
aliases: &[],
|
|
summary: "Draft or create a pull request from the conversation",
|
|
argument_hint: Some("[context]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "issue",
|
|
aliases: &[],
|
|
summary: "Draft or create a GitHub issue from the conversation",
|
|
argument_hint: Some("[context]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "ultraplan",
|
|
aliases: &[],
|
|
summary: "Run a deep planning prompt with multi-step reasoning",
|
|
argument_hint: Some("[task]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "teleport",
|
|
aliases: &[],
|
|
summary: "Jump to a file or symbol by searching the workspace",
|
|
argument_hint: Some("<symbol-or-path>"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "debug-tool-call",
|
|
aliases: &[],
|
|
summary: "Replay the last tool call with debug details",
|
|
argument_hint: None,
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "export",
|
|
aliases: &[],
|
|
summary: "Export the current conversation to a file",
|
|
argument_hint: Some("[file]"),
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "session",
|
|
aliases: &[],
|
|
summary: "List or switch managed local sessions",
|
|
argument_hint: Some("[list|switch <session-id>]"),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "plugin",
|
|
aliases: &["plugins", "marketplace"],
|
|
summary: "Manage Claw Code plugins",
|
|
argument_hint: Some(
|
|
"[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
|
|
),
|
|
resume_supported: false,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "agents",
|
|
aliases: &[],
|
|
summary: "List configured agents",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "skills",
|
|
aliases: &[],
|
|
summary: "List available skills",
|
|
argument_hint: None,
|
|
resume_supported: true,
|
|
},
|
|
];
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SlashCommand {
|
|
Help,
|
|
Status,
|
|
Compact,
|
|
Branch {
|
|
action: Option<String>,
|
|
target: Option<String>,
|
|
},
|
|
Bughunter {
|
|
scope: Option<String>,
|
|
},
|
|
Worktree {
|
|
action: Option<String>,
|
|
path: Option<String>,
|
|
branch: Option<String>,
|
|
},
|
|
Commit,
|
|
CommitPushPr {
|
|
context: Option<String>,
|
|
},
|
|
Pr {
|
|
context: Option<String>,
|
|
},
|
|
Issue {
|
|
context: Option<String>,
|
|
},
|
|
Ultraplan {
|
|
task: Option<String>,
|
|
},
|
|
Teleport {
|
|
target: Option<String>,
|
|
},
|
|
DebugToolCall,
|
|
Model {
|
|
model: Option<String>,
|
|
},
|
|
Permissions {
|
|
mode: Option<String>,
|
|
},
|
|
Clear {
|
|
confirm: bool,
|
|
},
|
|
Cost,
|
|
Resume {
|
|
session_path: Option<String>,
|
|
},
|
|
Config {
|
|
section: Option<String>,
|
|
},
|
|
Memory,
|
|
Init,
|
|
Diff,
|
|
Version,
|
|
Export {
|
|
path: Option<String>,
|
|
},
|
|
Session {
|
|
action: Option<String>,
|
|
target: Option<String>,
|
|
},
|
|
Plugins {
|
|
action: Option<String>,
|
|
target: Option<String>,
|
|
},
|
|
Agents {
|
|
args: Option<String>,
|
|
},
|
|
Skills {
|
|
args: Option<String>,
|
|
},
|
|
Unknown(String),
|
|
}
|
|
|
|
impl SlashCommand {
|
|
#[must_use]
|
|
pub fn parse(input: &str) -> Option<Self> {
|
|
let trimmed = input.trim();
|
|
if !trimmed.starts_with('/') {
|
|
return None;
|
|
}
|
|
|
|
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
|
|
let command = parts.next().unwrap_or_default();
|
|
Some(match command {
|
|
"help" => Self::Help,
|
|
"status" => Self::Status,
|
|
"compact" => Self::Compact,
|
|
"branch" => Self::Branch {
|
|
action: parts.next().map(ToOwned::to_owned),
|
|
target: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"bughunter" => Self::Bughunter {
|
|
scope: remainder_after_command(trimmed, command),
|
|
},
|
|
"worktree" => Self::Worktree {
|
|
action: parts.next().map(ToOwned::to_owned),
|
|
path: parts.next().map(ToOwned::to_owned),
|
|
branch: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"commit" => Self::Commit,
|
|
"commit-push-pr" => Self::CommitPushPr {
|
|
context: remainder_after_command(trimmed, command),
|
|
},
|
|
"pr" => Self::Pr {
|
|
context: remainder_after_command(trimmed, command),
|
|
},
|
|
"issue" => Self::Issue {
|
|
context: remainder_after_command(trimmed, command),
|
|
},
|
|
"ultraplan" => Self::Ultraplan {
|
|
task: remainder_after_command(trimmed, command),
|
|
},
|
|
"teleport" => Self::Teleport {
|
|
target: remainder_after_command(trimmed, command),
|
|
},
|
|
"debug-tool-call" => Self::DebugToolCall,
|
|
"model" => Self::Model {
|
|
model: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"permissions" => Self::Permissions {
|
|
mode: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"clear" => Self::Clear {
|
|
confirm: parts.next() == Some("--confirm"),
|
|
},
|
|
"cost" => Self::Cost,
|
|
"resume" => Self::Resume {
|
|
session_path: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"config" => Self::Config {
|
|
section: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"memory" => Self::Memory,
|
|
"init" => Self::Init,
|
|
"diff" => Self::Diff,
|
|
"version" => Self::Version,
|
|
"export" => Self::Export {
|
|
path: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"session" => Self::Session {
|
|
action: parts.next().map(ToOwned::to_owned),
|
|
target: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"plugin" | "plugins" | "marketplace" => Self::Plugins {
|
|
action: parts.next().map(ToOwned::to_owned),
|
|
target: {
|
|
let remainder = parts.collect::<Vec<_>>().join(" ");
|
|
(!remainder.is_empty()).then_some(remainder)
|
|
},
|
|
},
|
|
"agents" => Self::Agents {
|
|
args: remainder_after_command(trimmed, command),
|
|
},
|
|
"skills" => Self::Skills {
|
|
args: remainder_after_command(trimmed, command),
|
|
},
|
|
other => Self::Unknown(other.to_string()),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn remainder_after_command(input: &str, command: &str) -> Option<String> {
|
|
input
|
|
.trim()
|
|
.strip_prefix(&format!("/{command}"))
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
|
|
SLASH_COMMAND_SPECS
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
|
|
slash_command_specs()
|
|
.iter()
|
|
.filter(|spec| spec.resume_supported)
|
|
.collect()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn render_slash_command_help() -> String {
|
|
let mut lines = vec![
|
|
"Slash commands".to_string(),
|
|
" [resume] means the command also works with --resume SESSION.json".to_string(),
|
|
];
|
|
for spec in slash_command_specs() {
|
|
let name = match spec.argument_hint {
|
|
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
|
None => format!("/{}", spec.name),
|
|
};
|
|
let alias_suffix = if spec.aliases.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
" (aliases: {})",
|
|
spec.aliases
|
|
.iter()
|
|
.map(|alias| format!("/{alias}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
)
|
|
};
|
|
let resume = if spec.resume_supported {
|
|
" [resume]"
|
|
} else {
|
|
""
|
|
};
|
|
lines.push(format!(
|
|
" {name:<20} {}{alias_suffix}{resume}",
|
|
spec.summary
|
|
));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SlashCommandResult {
|
|
pub message: String,
|
|
pub session: Session,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PluginsCommandResult {
|
|
pub message: String,
|
|
pub reload_runtime: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
enum DefinitionSource {
|
|
ProjectCodex,
|
|
ProjectClaw,
|
|
UserCodexHome,
|
|
UserCodex,
|
|
UserClaw,
|
|
}
|
|
|
|
impl DefinitionSource {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::ProjectCodex => "Project (.codex)",
|
|
Self::ProjectClaw => "Project (.claw)",
|
|
Self::UserCodexHome => "User ($CODEX_HOME)",
|
|
Self::UserCodex => "User (~/.codex)",
|
|
Self::UserClaw => "User (~/.claw)",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct AgentSummary {
|
|
name: String,
|
|
description: Option<String>,
|
|
model: Option<String>,
|
|
reasoning_effort: Option<String>,
|
|
source: DefinitionSource,
|
|
shadowed_by: Option<DefinitionSource>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct SkillSummary {
|
|
name: String,
|
|
description: Option<String>,
|
|
source: DefinitionSource,
|
|
shadowed_by: Option<DefinitionSource>,
|
|
origin: SkillOrigin,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SkillOrigin {
|
|
SkillsDir,
|
|
LegacyCommandsDir,
|
|
}
|
|
|
|
impl SkillOrigin {
|
|
fn detail_label(self) -> Option<&'static str> {
|
|
match self {
|
|
Self::SkillsDir => None,
|
|
Self::LegacyCommandsDir => Some("legacy /commands"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct SkillRoot {
|
|
source: DefinitionSource,
|
|
path: PathBuf,
|
|
origin: SkillOrigin,
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
pub fn handle_plugins_slash_command(
|
|
action: Option<&str>,
|
|
target: Option<&str>,
|
|
manager: &mut PluginManager,
|
|
) -> Result<PluginsCommandResult, PluginError> {
|
|
match action {
|
|
None | Some("list") => Ok(PluginsCommandResult {
|
|
message: render_plugins_report(&manager.list_installed_plugins()?),
|
|
reload_runtime: false,
|
|
}),
|
|
Some("install") => {
|
|
let Some(target) = target else {
|
|
return Ok(PluginsCommandResult {
|
|
message: "Usage: /plugins install <path>".to_string(),
|
|
reload_runtime: false,
|
|
});
|
|
};
|
|
let install = manager.install(target)?;
|
|
let plugin = manager
|
|
.list_installed_plugins()?
|
|
.into_iter()
|
|
.find(|plugin| plugin.metadata.id == install.plugin_id);
|
|
Ok(PluginsCommandResult {
|
|
message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
|
|
reload_runtime: true,
|
|
})
|
|
}
|
|
Some("enable") => {
|
|
let Some(target) = target else {
|
|
return Ok(PluginsCommandResult {
|
|
message: "Usage: /plugins enable <name>".to_string(),
|
|
reload_runtime: false,
|
|
});
|
|
};
|
|
let plugin = resolve_plugin_target(manager, target)?;
|
|
manager.enable(&plugin.metadata.id)?;
|
|
Ok(PluginsCommandResult {
|
|
message: format!(
|
|
"Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
|
|
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
|
|
),
|
|
reload_runtime: true,
|
|
})
|
|
}
|
|
Some("disable") => {
|
|
let Some(target) = target else {
|
|
return Ok(PluginsCommandResult {
|
|
message: "Usage: /plugins disable <name>".to_string(),
|
|
reload_runtime: false,
|
|
});
|
|
};
|
|
let plugin = resolve_plugin_target(manager, target)?;
|
|
manager.disable(&plugin.metadata.id)?;
|
|
Ok(PluginsCommandResult {
|
|
message: format!(
|
|
"Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
|
|
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
|
|
),
|
|
reload_runtime: true,
|
|
})
|
|
}
|
|
Some("uninstall") => {
|
|
let Some(target) = target else {
|
|
return Ok(PluginsCommandResult {
|
|
message: "Usage: /plugins uninstall <plugin-id>".to_string(),
|
|
reload_runtime: false,
|
|
});
|
|
};
|
|
manager.uninstall(target)?;
|
|
Ok(PluginsCommandResult {
|
|
message: format!("Plugins\n Result uninstalled {target}"),
|
|
reload_runtime: true,
|
|
})
|
|
}
|
|
Some("update") => {
|
|
let Some(target) = target else {
|
|
return Ok(PluginsCommandResult {
|
|
message: "Usage: /plugins update <plugin-id>".to_string(),
|
|
reload_runtime: false,
|
|
});
|
|
};
|
|
let update = manager.update(target)?;
|
|
let plugin = manager
|
|
.list_installed_plugins()?
|
|
.into_iter()
|
|
.find(|plugin| plugin.metadata.id == update.plugin_id);
|
|
Ok(PluginsCommandResult {
|
|
message: format!(
|
|
"Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
|
|
update.plugin_id,
|
|
plugin
|
|
.as_ref()
|
|
.map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
|
|
update.old_version,
|
|
update.new_version,
|
|
plugin
|
|
.as_ref()
|
|
.map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
|
|
),
|
|
reload_runtime: true,
|
|
})
|
|
}
|
|
Some(other) => Ok(PluginsCommandResult {
|
|
message: format!(
|
|
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
|
|
),
|
|
reload_runtime: false,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
|
match normalize_optional_args(args) {
|
|
None | Some("list") => {
|
|
let roots = discover_definition_roots(cwd, "agents");
|
|
let agents = load_agents_from_roots(&roots)?;
|
|
Ok(render_agents_report(&agents))
|
|
}
|
|
Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
|
|
Some(args) => Ok(render_agents_usage(Some(args))),
|
|
}
|
|
}
|
|
|
|
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
|
match normalize_optional_args(args) {
|
|
None | Some("list") => {
|
|
let roots = discover_skill_roots(cwd);
|
|
let skills = load_skills_from_roots(&roots)?;
|
|
Ok(render_skills_report(&skills))
|
|
}
|
|
Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
|
|
Some(args) => Ok(render_skills_usage(Some(args))),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CommitPushPrRequest {
|
|
pub commit_message: Option<String>,
|
|
pub pr_title: String,
|
|
pub pr_body: String,
|
|
pub branch_name_hint: String,
|
|
}
|
|
|
|
pub fn handle_branch_slash_command(
|
|
action: Option<&str>,
|
|
target: Option<&str>,
|
|
cwd: &Path,
|
|
) -> io::Result<String> {
|
|
match normalize_optional_args(action) {
|
|
None | Some("list") => {
|
|
let branches = git_stdout(cwd, &["branch", "--list", "--verbose"])?;
|
|
let trimmed = branches.trim();
|
|
Ok(if trimmed.is_empty() {
|
|
"Branch\n Result no branches found".to_string()
|
|
} else {
|
|
format!("Branch\n Result listed\n\n{}", trimmed)
|
|
})
|
|
}
|
|
Some("create") => {
|
|
let Some(target) = target.filter(|value| !value.trim().is_empty()) else {
|
|
return Ok("Usage: /branch create <name>".to_string());
|
|
};
|
|
git_status_ok(cwd, &["switch", "-c", target])?;
|
|
Ok(format!(
|
|
"Branch\n Result created and switched\n Branch {target}"
|
|
))
|
|
}
|
|
Some("switch") => {
|
|
let Some(target) = target.filter(|value| !value.trim().is_empty()) else {
|
|
return Ok("Usage: /branch switch <name>".to_string());
|
|
};
|
|
git_status_ok(cwd, &["switch", target])?;
|
|
Ok(format!(
|
|
"Branch\n Result switched\n Branch {target}"
|
|
))
|
|
}
|
|
Some(other) => Ok(format!(
|
|
"Unknown /branch action '{other}'. Use /branch list, /branch create <name>, or /branch switch <name>."
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub fn handle_worktree_slash_command(
|
|
action: Option<&str>,
|
|
path: Option<&str>,
|
|
branch: Option<&str>,
|
|
cwd: &Path,
|
|
) -> io::Result<String> {
|
|
match normalize_optional_args(action) {
|
|
None | Some("list") => {
|
|
let worktrees = git_stdout(cwd, &["worktree", "list"])?;
|
|
let trimmed = worktrees.trim();
|
|
Ok(if trimmed.is_empty() {
|
|
"Worktree\n Result no worktrees found".to_string()
|
|
} else {
|
|
format!("Worktree\n Result listed\n\n{}", trimmed)
|
|
})
|
|
}
|
|
Some("add") => {
|
|
let Some(path) = path.filter(|value| !value.trim().is_empty()) else {
|
|
return Ok("Usage: /worktree add <path> [branch]".to_string());
|
|
};
|
|
if let Some(branch) = branch.filter(|value| !value.trim().is_empty()) {
|
|
if branch_exists(cwd, branch) {
|
|
git_status_ok(cwd, &["worktree", "add", path, branch])?;
|
|
} else {
|
|
git_status_ok(cwd, &["worktree", "add", path, "-b", branch])?;
|
|
}
|
|
Ok(format!(
|
|
"Worktree\n Result added\n Path {path}\n Branch {branch}"
|
|
))
|
|
} else {
|
|
git_status_ok(cwd, &["worktree", "add", path])?;
|
|
Ok(format!(
|
|
"Worktree\n Result added\n Path {path}"
|
|
))
|
|
}
|
|
}
|
|
Some("remove") => {
|
|
let Some(path) = path.filter(|value| !value.trim().is_empty()) else {
|
|
return Ok("Usage: /worktree remove <path>".to_string());
|
|
};
|
|
git_status_ok(cwd, &["worktree", "remove", path])?;
|
|
Ok(format!(
|
|
"Worktree\n Result removed\n Path {path}"
|
|
))
|
|
}
|
|
Some("prune") => {
|
|
git_status_ok(cwd, &["worktree", "prune"])?;
|
|
Ok("Worktree\n Result pruned".to_string())
|
|
}
|
|
Some(other) => Ok(format!(
|
|
"Unknown /worktree action '{other}'. Use /worktree list, /worktree add <path> [branch], /worktree remove <path>, or /worktree prune."
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub fn handle_commit_slash_command(message: &str, cwd: &Path) -> io::Result<String> {
|
|
let status = git_stdout(cwd, &["status", "--short"])?;
|
|
if status.trim().is_empty() {
|
|
return Ok(
|
|
"Commit\n Result skipped\n Reason no workspace changes"
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
let message = message.trim();
|
|
if message.is_empty() {
|
|
return Err(io::Error::other("generated commit message was empty"));
|
|
}
|
|
|
|
git_status_ok(cwd, &["add", "-A"])?;
|
|
let path = write_temp_text_file("claw-commit-message", "txt", message)?;
|
|
let path_string = path.to_string_lossy().into_owned();
|
|
git_status_ok(cwd, &["commit", "--file", path_string.as_str()])?;
|
|
|
|
Ok(format!(
|
|
"Commit\n Result created\n Message file {}\n\n{}",
|
|
path.display(),
|
|
message
|
|
))
|
|
}
|
|
|
|
pub fn handle_commit_push_pr_slash_command(
|
|
request: &CommitPushPrRequest,
|
|
cwd: &Path,
|
|
) -> io::Result<String> {
|
|
if !command_exists("gh") {
|
|
return Err(io::Error::other("gh CLI is required for /commit-push-pr"));
|
|
}
|
|
|
|
let default_branch = detect_default_branch(cwd)?;
|
|
let mut branch = current_branch(cwd)?;
|
|
let mut created_branch = false;
|
|
if branch == default_branch {
|
|
let hint = if request.branch_name_hint.trim().is_empty() {
|
|
request.pr_title.as_str()
|
|
} else {
|
|
request.branch_name_hint.as_str()
|
|
};
|
|
let next_branch = build_branch_name(hint);
|
|
git_status_ok(cwd, &["switch", "-c", next_branch.as_str()])?;
|
|
branch = next_branch;
|
|
created_branch = true;
|
|
}
|
|
|
|
let workspace_has_changes = !git_stdout(cwd, &["status", "--short"])?.trim().is_empty();
|
|
let commit_report = if workspace_has_changes {
|
|
let Some(message) = request.commit_message.as_deref() else {
|
|
return Err(io::Error::other(
|
|
"commit message is required when workspace changes are present",
|
|
));
|
|
};
|
|
Some(handle_commit_slash_command(message, cwd)?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let branch_diff = git_stdout(
|
|
cwd,
|
|
&["diff", "--stat", &format!("{default_branch}...HEAD")],
|
|
)?;
|
|
if branch_diff.trim().is_empty() {
|
|
return Ok(
|
|
"Commit/Push/PR\n Result skipped\n Reason no branch changes to push or open as a pull request"
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
git_status_ok(cwd, &["push", "--set-upstream", "origin", branch.as_str()])?;
|
|
|
|
let body_path = write_temp_text_file("claw-pr-body", "md", request.pr_body.trim())?;
|
|
let body_path_string = body_path.to_string_lossy().into_owned();
|
|
let create = Command::new("gh")
|
|
.args([
|
|
"pr",
|
|
"create",
|
|
"--title",
|
|
request.pr_title.as_str(),
|
|
"--body-file",
|
|
body_path_string.as_str(),
|
|
"--base",
|
|
default_branch.as_str(),
|
|
])
|
|
.current_dir(cwd)
|
|
.output()?;
|
|
|
|
let (result, url) = if create.status.success() {
|
|
(
|
|
"created",
|
|
parse_pr_url(&String::from_utf8_lossy(&create.stdout))
|
|
.unwrap_or_else(|| "<unknown>".to_string()),
|
|
)
|
|
} else {
|
|
let view = Command::new("gh")
|
|
.args(["pr", "view", "--json", "url"])
|
|
.current_dir(cwd)
|
|
.output()?;
|
|
if !view.status.success() {
|
|
return Err(io::Error::other(command_failure(
|
|
"gh",
|
|
&["pr", "create"],
|
|
&create,
|
|
)));
|
|
}
|
|
(
|
|
"existing",
|
|
parse_pr_json_url(&String::from_utf8_lossy(&view.stdout))
|
|
.unwrap_or_else(|| "<unknown>".to_string()),
|
|
)
|
|
};
|
|
|
|
let mut lines = vec![
|
|
"Commit/Push/PR".to_string(),
|
|
format!(" Result {result}"),
|
|
format!(" Branch {branch}"),
|
|
format!(" Base {default_branch}"),
|
|
format!(" Body file {}", body_path.display()),
|
|
format!(" URL {url}"),
|
|
];
|
|
if created_branch {
|
|
lines.insert(2, " Branch action created and switched".to_string());
|
|
}
|
|
if let Some(report) = commit_report {
|
|
lines.push(String::new());
|
|
lines.push(report);
|
|
}
|
|
Ok(lines.join("\n"))
|
|
}
|
|
|
|
pub fn detect_default_branch(cwd: &Path) -> io::Result<String> {
|
|
if let Ok(reference) = git_stdout(cwd, &["symbolic-ref", "refs/remotes/origin/HEAD"]) {
|
|
if let Some(branch) = reference
|
|
.trim()
|
|
.rsplit('/')
|
|
.next()
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
return Ok(branch.to_string());
|
|
}
|
|
}
|
|
|
|
for branch in ["main", "master"] {
|
|
if branch_exists(cwd, branch) {
|
|
return Ok(branch.to_string());
|
|
}
|
|
}
|
|
|
|
current_branch(cwd)
|
|
}
|
|
|
|
fn git_stdout(cwd: &Path, args: &[&str]) -> io::Result<String> {
|
|
run_command_stdout("git", args, cwd)
|
|
}
|
|
|
|
fn git_status_ok(cwd: &Path, args: &[&str]) -> io::Result<()> {
|
|
run_command_success("git", args, cwd)
|
|
}
|
|
|
|
fn run_command_stdout(program: &str, args: &[&str], cwd: &Path) -> io::Result<String> {
|
|
let output = Command::new(program).args(args).current_dir(cwd).output()?;
|
|
if !output.status.success() {
|
|
return Err(io::Error::other(command_failure(program, args, &output)));
|
|
}
|
|
String::from_utf8(output.stdout)
|
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
|
}
|
|
|
|
fn run_command_success(program: &str, args: &[&str], cwd: &Path) -> io::Result<()> {
|
|
let output = Command::new(program).args(args).current_dir(cwd).output()?;
|
|
if !output.status.success() {
|
|
return Err(io::Error::other(command_failure(program, args, &output)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn command_failure(program: &str, args: &[&str], output: &std::process::Output) -> String {
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
let detail = if stderr.is_empty() { stdout } else { stderr };
|
|
if detail.is_empty() {
|
|
format!("{program} {} failed", args.join(" "))
|
|
} else {
|
|
format!("{program} {} failed: {detail}", args.join(" "))
|
|
}
|
|
}
|
|
|
|
fn branch_exists(cwd: &Path, branch: &str) -> bool {
|
|
Command::new("git")
|
|
.args([
|
|
"show-ref",
|
|
"--verify",
|
|
"--quiet",
|
|
&format!("refs/heads/{branch}"),
|
|
])
|
|
.current_dir(cwd)
|
|
.output()
|
|
.map(|output| output.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn current_branch(cwd: &Path) -> io::Result<String> {
|
|
let branch = git_stdout(cwd, &["branch", "--show-current"])?;
|
|
let branch = branch.trim();
|
|
if branch.is_empty() {
|
|
Err(io::Error::other("unable to determine current git branch"))
|
|
} else {
|
|
Ok(branch.to_string())
|
|
}
|
|
}
|
|
|
|
fn command_exists(name: &str) -> bool {
|
|
Command::new(name)
|
|
.arg("--version")
|
|
.output()
|
|
.map(|output| output.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn write_temp_text_file(prefix: &str, extension: &str, contents: &str) -> io::Result<PathBuf> {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_nanos())
|
|
.unwrap_or_default();
|
|
let path = env::temp_dir().join(format!("{prefix}-{nanos}.{extension}"));
|
|
fs::write(&path, contents)?;
|
|
Ok(path)
|
|
}
|
|
|
|
fn build_branch_name(hint: &str) -> String {
|
|
let slug = slugify(hint);
|
|
let owner = env::var("SAFEUSER")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.or_else(|| {
|
|
env::var("USER")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
});
|
|
match owner {
|
|
Some(owner) => format!("{owner}/{slug}"),
|
|
None => slug,
|
|
}
|
|
}
|
|
|
|
fn slugify(value: &str) -> String {
|
|
let mut slug = String::new();
|
|
let mut last_was_dash = false;
|
|
for ch in value.chars() {
|
|
if ch.is_ascii_alphanumeric() {
|
|
slug.push(ch.to_ascii_lowercase());
|
|
last_was_dash = false;
|
|
} else if !last_was_dash {
|
|
slug.push('-');
|
|
last_was_dash = true;
|
|
}
|
|
}
|
|
let slug = slug.trim_matches('-').to_string();
|
|
if slug.is_empty() {
|
|
"change".to_string()
|
|
} else {
|
|
slug
|
|
}
|
|
}
|
|
|
|
fn parse_pr_url(stdout: &str) -> Option<String> {
|
|
stdout
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|line| line.starts_with("http://") || line.starts_with("https://"))
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn parse_pr_json_url(stdout: &str) -> Option<String> {
|
|
serde_json::from_str::<serde_json::Value>(stdout)
|
|
.ok()?
|
|
.get("url")?
|
|
.as_str()
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
|
let mut lines = vec!["Plugins".to_string()];
|
|
if plugins.is_empty() {
|
|
lines.push(" No plugins installed.".to_string());
|
|
return lines.join("\n");
|
|
}
|
|
for plugin in plugins {
|
|
let enabled = if plugin.enabled {
|
|
"enabled"
|
|
} else {
|
|
"disabled"
|
|
};
|
|
lines.push(format!(
|
|
" {name:<20} v{version:<10} {enabled}",
|
|
name = plugin.metadata.name,
|
|
version = plugin.metadata.version,
|
|
));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
|
|
let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
|
|
let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
|
|
let enabled = plugin.is_some_and(|plugin| plugin.enabled);
|
|
format!(
|
|
"Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
|
|
if enabled { "enabled" } else { "disabled" }
|
|
)
|
|
}
|
|
|
|
fn resolve_plugin_target(
|
|
manager: &PluginManager,
|
|
target: &str,
|
|
) -> Result<PluginSummary, PluginError> {
|
|
let mut matches = manager
|
|
.list_installed_plugins()?
|
|
.into_iter()
|
|
.filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
|
|
.collect::<Vec<_>>();
|
|
match matches.len() {
|
|
1 => Ok(matches.remove(0)),
|
|
0 => Err(PluginError::NotFound(format!(
|
|
"plugin `{target}` is not installed or discoverable"
|
|
))),
|
|
_ => Err(PluginError::InvalidManifest(format!(
|
|
"plugin name `{target}` is ambiguous; use the full plugin id"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
|
|
let mut roots = Vec::new();
|
|
|
|
for ancestor in cwd.ancestors() {
|
|
push_unique_root(
|
|
&mut roots,
|
|
DefinitionSource::ProjectCodex,
|
|
ancestor.join(".codex").join(leaf),
|
|
);
|
|
push_unique_root(
|
|
&mut roots,
|
|
DefinitionSource::ProjectClaw,
|
|
ancestor.join(".claw").join(leaf),
|
|
);
|
|
}
|
|
|
|
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
|
push_unique_root(
|
|
&mut roots,
|
|
DefinitionSource::UserCodexHome,
|
|
PathBuf::from(codex_home).join(leaf),
|
|
);
|
|
}
|
|
|
|
if let Some(home) = env::var_os("HOME") {
|
|
let home = PathBuf::from(home);
|
|
push_unique_root(
|
|
&mut roots,
|
|
DefinitionSource::UserCodex,
|
|
home.join(".codex").join(leaf),
|
|
);
|
|
push_unique_root(
|
|
&mut roots,
|
|
DefinitionSource::UserClaw,
|
|
home.join(".claw").join(leaf),
|
|
);
|
|
}
|
|
|
|
roots
|
|
}
|
|
|
|
fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|
let mut roots = Vec::new();
|
|
|
|
for ancestor in cwd.ancestors() {
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::ProjectCodex,
|
|
ancestor.join(".codex").join("skills"),
|
|
SkillOrigin::SkillsDir,
|
|
);
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::ProjectClaw,
|
|
ancestor.join(".claw").join("skills"),
|
|
SkillOrigin::SkillsDir,
|
|
);
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::ProjectCodex,
|
|
ancestor.join(".codex").join("commands"),
|
|
SkillOrigin::LegacyCommandsDir,
|
|
);
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::ProjectClaw,
|
|
ancestor.join(".claw").join("commands"),
|
|
SkillOrigin::LegacyCommandsDir,
|
|
);
|
|
}
|
|
|
|
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
|
let codex_home = PathBuf::from(codex_home);
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::UserCodexHome,
|
|
codex_home.join("skills"),
|
|
SkillOrigin::SkillsDir,
|
|
);
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::UserCodexHome,
|
|
codex_home.join("commands"),
|
|
SkillOrigin::LegacyCommandsDir,
|
|
);
|
|
}
|
|
|
|
if let Some(home) = env::var_os("HOME") {
|
|
let home = PathBuf::from(home);
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::UserCodex,
|
|
home.join(".codex").join("skills"),
|
|
SkillOrigin::SkillsDir,
|
|
);
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::UserCodex,
|
|
home.join(".codex").join("commands"),
|
|
SkillOrigin::LegacyCommandsDir,
|
|
);
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::UserClaw,
|
|
home.join(".claw").join("skills"),
|
|
SkillOrigin::SkillsDir,
|
|
);
|
|
push_unique_skill_root(
|
|
&mut roots,
|
|
DefinitionSource::UserClaw,
|
|
home.join(".claw").join("commands"),
|
|
SkillOrigin::LegacyCommandsDir,
|
|
);
|
|
}
|
|
|
|
roots
|
|
}
|
|
|
|
fn push_unique_root(
|
|
roots: &mut Vec<(DefinitionSource, PathBuf)>,
|
|
source: DefinitionSource,
|
|
path: PathBuf,
|
|
) {
|
|
if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
|
|
roots.push((source, path));
|
|
}
|
|
}
|
|
|
|
fn push_unique_skill_root(
|
|
roots: &mut Vec<SkillRoot>,
|
|
source: DefinitionSource,
|
|
path: PathBuf,
|
|
origin: SkillOrigin,
|
|
) {
|
|
if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
|
|
roots.push(SkillRoot {
|
|
source,
|
|
path,
|
|
origin,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn load_agents_from_roots(
|
|
roots: &[(DefinitionSource, PathBuf)],
|
|
) -> std::io::Result<Vec<AgentSummary>> {
|
|
let mut agents = Vec::new();
|
|
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
|
|
|
|
for (source, root) in roots {
|
|
let mut root_agents = Vec::new();
|
|
for entry in fs::read_dir(root)? {
|
|
let entry = entry?;
|
|
if entry.path().extension().is_none_or(|ext| ext != "toml") {
|
|
continue;
|
|
}
|
|
let contents = fs::read_to_string(entry.path())?;
|
|
let fallback_name = entry.path().file_stem().map_or_else(
|
|
|| entry.file_name().to_string_lossy().to_string(),
|
|
|stem| stem.to_string_lossy().to_string(),
|
|
);
|
|
root_agents.push(AgentSummary {
|
|
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
|
|
description: parse_toml_string(&contents, "description"),
|
|
model: parse_toml_string(&contents, "model"),
|
|
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
|
|
source: *source,
|
|
shadowed_by: None,
|
|
});
|
|
}
|
|
root_agents.sort_by(|left, right| left.name.cmp(&right.name));
|
|
|
|
for mut agent in root_agents {
|
|
let key = agent.name.to_ascii_lowercase();
|
|
if let Some(existing) = active_sources.get(&key) {
|
|
agent.shadowed_by = Some(*existing);
|
|
} else {
|
|
active_sources.insert(key, agent.source);
|
|
}
|
|
agents.push(agent);
|
|
}
|
|
}
|
|
|
|
Ok(agents)
|
|
}
|
|
|
|
fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
|
|
let mut skills = Vec::new();
|
|
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
|
|
|
|
for root in roots {
|
|
let mut root_skills = Vec::new();
|
|
for entry in fs::read_dir(&root.path)? {
|
|
let entry = entry?;
|
|
match root.origin {
|
|
SkillOrigin::SkillsDir => {
|
|
if !entry.path().is_dir() {
|
|
continue;
|
|
}
|
|
let skill_path = entry.path().join("SKILL.md");
|
|
if !skill_path.is_file() {
|
|
continue;
|
|
}
|
|
let contents = fs::read_to_string(skill_path)?;
|
|
let (name, description) = parse_skill_frontmatter(&contents);
|
|
root_skills.push(SkillSummary {
|
|
name: name
|
|
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
|
|
description,
|
|
source: root.source,
|
|
shadowed_by: None,
|
|
origin: root.origin,
|
|
});
|
|
}
|
|
SkillOrigin::LegacyCommandsDir => {
|
|
let path = entry.path();
|
|
let markdown_path = if path.is_dir() {
|
|
let skill_path = path.join("SKILL.md");
|
|
if !skill_path.is_file() {
|
|
continue;
|
|
}
|
|
skill_path
|
|
} else if path
|
|
.extension()
|
|
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
|
|
{
|
|
path
|
|
} else {
|
|
continue;
|
|
};
|
|
|
|
let contents = fs::read_to_string(&markdown_path)?;
|
|
let fallback_name = markdown_path.file_stem().map_or_else(
|
|
|| entry.file_name().to_string_lossy().to_string(),
|
|
|stem| stem.to_string_lossy().to_string(),
|
|
);
|
|
let (name, description) = parse_skill_frontmatter(&contents);
|
|
root_skills.push(SkillSummary {
|
|
name: name.unwrap_or(fallback_name),
|
|
description,
|
|
source: root.source,
|
|
shadowed_by: None,
|
|
origin: root.origin,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
root_skills.sort_by(|left, right| left.name.cmp(&right.name));
|
|
|
|
for mut skill in root_skills {
|
|
let key = skill.name.to_ascii_lowercase();
|
|
if let Some(existing) = active_sources.get(&key) {
|
|
skill.shadowed_by = Some(*existing);
|
|
} else {
|
|
active_sources.insert(key, skill.source);
|
|
}
|
|
skills.push(skill);
|
|
}
|
|
}
|
|
|
|
Ok(skills)
|
|
}
|
|
|
|
fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
|
|
let prefix = format!("{key} =");
|
|
for line in contents.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with('#') {
|
|
continue;
|
|
}
|
|
let Some(value) = trimmed.strip_prefix(&prefix) else {
|
|
continue;
|
|
};
|
|
let value = value.trim();
|
|
let Some(value) = value
|
|
.strip_prefix('"')
|
|
.and_then(|value| value.strip_suffix('"'))
|
|
else {
|
|
continue;
|
|
};
|
|
if !value.is_empty() {
|
|
return Some(value.to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
|
|
let mut lines = contents.lines();
|
|
if lines.next().map(str::trim) != Some("---") {
|
|
return (None, None);
|
|
}
|
|
|
|
let mut name = None;
|
|
let mut description = None;
|
|
for line in lines {
|
|
let trimmed = line.trim();
|
|
if trimmed == "---" {
|
|
break;
|
|
}
|
|
if let Some(value) = trimmed.strip_prefix("name:") {
|
|
let value = unquote_frontmatter_value(value.trim());
|
|
if !value.is_empty() {
|
|
name = Some(value);
|
|
}
|
|
continue;
|
|
}
|
|
if let Some(value) = trimmed.strip_prefix("description:") {
|
|
let value = unquote_frontmatter_value(value.trim());
|
|
if !value.is_empty() {
|
|
description = Some(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
(name, description)
|
|
}
|
|
|
|
fn unquote_frontmatter_value(value: &str) -> String {
|
|
value
|
|
.strip_prefix('"')
|
|
.and_then(|trimmed| trimmed.strip_suffix('"'))
|
|
.or_else(|| {
|
|
value
|
|
.strip_prefix('\'')
|
|
.and_then(|trimmed| trimmed.strip_suffix('\''))
|
|
})
|
|
.unwrap_or(value)
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
fn render_agents_report(agents: &[AgentSummary]) -> String {
|
|
if agents.is_empty() {
|
|
return "No agents found.".to_string();
|
|
}
|
|
|
|
let total_active = agents
|
|
.iter()
|
|
.filter(|agent| agent.shadowed_by.is_none())
|
|
.count();
|
|
let mut lines = vec![
|
|
"Agents".to_string(),
|
|
format!(" {total_active} active agents"),
|
|
String::new(),
|
|
];
|
|
|
|
for source in [
|
|
DefinitionSource::ProjectCodex,
|
|
DefinitionSource::ProjectClaw,
|
|
DefinitionSource::UserCodexHome,
|
|
DefinitionSource::UserCodex,
|
|
DefinitionSource::UserClaw,
|
|
] {
|
|
let group = agents
|
|
.iter()
|
|
.filter(|agent| agent.source == source)
|
|
.collect::<Vec<_>>();
|
|
if group.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
lines.push(format!("{}:", source.label()));
|
|
for agent in group {
|
|
let detail = agent_detail(agent);
|
|
match agent.shadowed_by {
|
|
Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
|
|
None => lines.push(format!(" {detail}")),
|
|
}
|
|
}
|
|
lines.push(String::new());
|
|
}
|
|
|
|
lines.join("\n").trim_end().to_string()
|
|
}
|
|
|
|
fn agent_detail(agent: &AgentSummary) -> String {
|
|
let mut parts = vec![agent.name.clone()];
|
|
if let Some(description) = &agent.description {
|
|
parts.push(description.clone());
|
|
}
|
|
if let Some(model) = &agent.model {
|
|
parts.push(model.clone());
|
|
}
|
|
if let Some(reasoning) = &agent.reasoning_effort {
|
|
parts.push(reasoning.clone());
|
|
}
|
|
parts.join(" · ")
|
|
}
|
|
|
|
fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|
if skills.is_empty() {
|
|
return "No skills found.".to_string();
|
|
}
|
|
|
|
let total_active = skills
|
|
.iter()
|
|
.filter(|skill| skill.shadowed_by.is_none())
|
|
.count();
|
|
let mut lines = vec![
|
|
"Skills".to_string(),
|
|
format!(" {total_active} available skills"),
|
|
String::new(),
|
|
];
|
|
|
|
for source in [
|
|
DefinitionSource::ProjectCodex,
|
|
DefinitionSource::ProjectClaw,
|
|
DefinitionSource::UserCodexHome,
|
|
DefinitionSource::UserCodex,
|
|
DefinitionSource::UserClaw,
|
|
] {
|
|
let group = skills
|
|
.iter()
|
|
.filter(|skill| skill.source == source)
|
|
.collect::<Vec<_>>();
|
|
if group.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
lines.push(format!("{}:", source.label()));
|
|
for skill in group {
|
|
let mut parts = vec![skill.name.clone()];
|
|
if let Some(description) = &skill.description {
|
|
parts.push(description.clone());
|
|
}
|
|
if let Some(detail) = skill.origin.detail_label() {
|
|
parts.push(detail.to_string());
|
|
}
|
|
let detail = parts.join(" · ");
|
|
match skill.shadowed_by {
|
|
Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
|
|
None => lines.push(format!(" {detail}")),
|
|
}
|
|
}
|
|
lines.push(String::new());
|
|
}
|
|
|
|
lines.join("\n").trim_end().to_string()
|
|
}
|
|
|
|
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
|
args.map(str::trim).filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn render_agents_usage(unexpected: Option<&str>) -> String {
|
|
let mut lines = vec![
|
|
"Agents".to_string(),
|
|
" Usage /agents".to_string(),
|
|
" Direct CLI claw agents".to_string(),
|
|
" Sources .codex/agents, .claw/agents, $CODEX_HOME/agents".to_string(),
|
|
];
|
|
if let Some(args) = unexpected {
|
|
lines.push(format!(" Unexpected {args}"));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn render_skills_usage(unexpected: Option<&str>) -> String {
|
|
let mut lines = vec![
|
|
"Skills".to_string(),
|
|
" Usage /skills".to_string(),
|
|
" Direct CLI claw skills".to_string(),
|
|
" Sources .codex/skills, .claw/skills, legacy /commands".to_string(),
|
|
];
|
|
if let Some(args) = unexpected {
|
|
lines.push(format!(" Unexpected {args}"));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn handle_slash_command(
|
|
input: &str,
|
|
session: &Session,
|
|
compaction: CompactionConfig,
|
|
) -> Option<SlashCommandResult> {
|
|
match SlashCommand::parse(input)? {
|
|
SlashCommand::Compact => {
|
|
let result = compact_session(session, compaction);
|
|
let message = if result.removed_message_count == 0 {
|
|
"Compaction skipped: session is below the compaction threshold.".to_string()
|
|
} else {
|
|
format!(
|
|
"Compacted {} messages into a resumable system summary.",
|
|
result.removed_message_count
|
|
)
|
|
};
|
|
Some(SlashCommandResult {
|
|
message,
|
|
session: result.compacted_session,
|
|
})
|
|
}
|
|
SlashCommand::Help => Some(SlashCommandResult {
|
|
message: render_slash_command_help(),
|
|
session: session.clone(),
|
|
}),
|
|
SlashCommand::Status
|
|
| SlashCommand::Branch { .. }
|
|
| SlashCommand::Bughunter { .. }
|
|
| SlashCommand::Worktree { .. }
|
|
| SlashCommand::Commit
|
|
| SlashCommand::CommitPushPr { .. }
|
|
| SlashCommand::Pr { .. }
|
|
| SlashCommand::Issue { .. }
|
|
| SlashCommand::Ultraplan { .. }
|
|
| SlashCommand::Teleport { .. }
|
|
| SlashCommand::DebugToolCall
|
|
| SlashCommand::Model { .. }
|
|
| SlashCommand::Permissions { .. }
|
|
| SlashCommand::Clear { .. }
|
|
| SlashCommand::Cost
|
|
| SlashCommand::Resume { .. }
|
|
| SlashCommand::Config { .. }
|
|
| SlashCommand::Memory
|
|
| SlashCommand::Init
|
|
| SlashCommand::Diff
|
|
| SlashCommand::Version
|
|
| SlashCommand::Export { .. }
|
|
| SlashCommand::Session { .. }
|
|
| SlashCommand::Plugins { .. }
|
|
| SlashCommand::Agents { .. }
|
|
| SlashCommand::Skills { .. }
|
|
| SlashCommand::Unknown(_) => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
handle_branch_slash_command, handle_commit_push_pr_slash_command,
|
|
handle_commit_slash_command, handle_plugins_slash_command, handle_slash_command,
|
|
handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots,
|
|
render_agents_report, render_plugins_report, render_skills_report,
|
|
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
|
CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
|
};
|
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
|
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
fn temp_dir(label: &str) -> PathBuf {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("time should be after epoch")
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
|
|
}
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
LOCK.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.expect("env lock")
|
|
}
|
|
|
|
fn run_command(cwd: &Path, program: &str, args: &[&str]) -> String {
|
|
let output = Command::new(program)
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.output()
|
|
.expect("command should run");
|
|
assert!(
|
|
output.status.success(),
|
|
"{} {} failed: {}",
|
|
program,
|
|
args.join(" "),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
String::from_utf8(output.stdout).expect("stdout should be utf8")
|
|
}
|
|
|
|
fn init_git_repo(label: &str) -> PathBuf {
|
|
let root = temp_dir(label);
|
|
fs::create_dir_all(&root).expect("repo root");
|
|
|
|
let init = Command::new("git")
|
|
.args(["init", "-b", "main"])
|
|
.current_dir(&root)
|
|
.output()
|
|
.expect("git init should run");
|
|
if !init.status.success() {
|
|
let fallback = Command::new("git")
|
|
.arg("init")
|
|
.current_dir(&root)
|
|
.output()
|
|
.expect("fallback git init should run");
|
|
assert!(
|
|
fallback.status.success(),
|
|
"fallback git init should succeed"
|
|
);
|
|
let rename = Command::new("git")
|
|
.args(["branch", "-m", "main"])
|
|
.current_dir(&root)
|
|
.output()
|
|
.expect("git branch -m should run");
|
|
assert!(rename.status.success(), "git branch -m main should succeed");
|
|
}
|
|
|
|
run_command(&root, "git", &["config", "user.name", "Claw Tests"]);
|
|
run_command(&root, "git", &["config", "user.email", "claw@example.com"]);
|
|
fs::write(root.join("README.md"), "seed\n").expect("seed file");
|
|
run_command(&root, "git", &["add", "README.md"]);
|
|
run_command(&root, "git", &["commit", "-m", "chore: seed repo"]);
|
|
root
|
|
}
|
|
|
|
fn init_bare_repo(label: &str) -> PathBuf {
|
|
let root = temp_dir(label);
|
|
let output = Command::new("git")
|
|
.args(["init", "--bare"])
|
|
.arg(&root)
|
|
.output()
|
|
.expect("bare repo should initialize");
|
|
assert!(output.status.success(), "git init --bare should succeed");
|
|
root
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn write_fake_gh(bin_dir: &Path, log_path: &Path, url: &str) {
|
|
fs::create_dir_all(bin_dir).expect("bin dir");
|
|
let script = format!(
|
|
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'gh 1.0.0'\n exit 0\nfi\nprintf '%s\\n' \"$*\" >> \"{}\"\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"create\" ]; then\n echo '{}'\n exit 0\nfi\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n echo '{{\"url\":\"{}\"}}'\n exit 0\nfi\nexit 0\n",
|
|
log_path.display(),
|
|
url,
|
|
url,
|
|
);
|
|
let path = bin_dir.join("gh");
|
|
fs::write(&path, script).expect("gh stub");
|
|
let mut permissions = fs::metadata(&path).expect("metadata").permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(&path, permissions).expect("chmod");
|
|
}
|
|
|
|
fn write_external_plugin(root: &Path, name: &str, version: &str) {
|
|
fs::create_dir_all(root.join(".claw-plugin")).expect("manifest dir");
|
|
fs::write(
|
|
root.join(".claw-plugin").join("plugin.json"),
|
|
format!(
|
|
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
|
|
),
|
|
)
|
|
.expect("write manifest");
|
|
}
|
|
|
|
fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
|
|
fs::create_dir_all(root.join(".claw-plugin")).expect("manifest dir");
|
|
fs::write(
|
|
root.join(".claw-plugin").join("plugin.json"),
|
|
format!(
|
|
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
|
|
if default_enabled { "true" } else { "false" }
|
|
),
|
|
)
|
|
.expect("write bundled manifest");
|
|
}
|
|
|
|
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
|
|
fs::create_dir_all(root).expect("agent root");
|
|
fs::write(
|
|
root.join(format!("{name}.toml")),
|
|
format!(
|
|
"name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
|
|
),
|
|
)
|
|
.expect("write agent");
|
|
}
|
|
|
|
fn write_skill(root: &Path, name: &str, description: &str) {
|
|
let skill_root = root.join(name);
|
|
fs::create_dir_all(&skill_root).expect("skill root");
|
|
fs::write(
|
|
skill_root.join("SKILL.md"),
|
|
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
|
|
)
|
|
.expect("write skill");
|
|
}
|
|
|
|
fn write_legacy_command(root: &Path, name: &str, description: &str) {
|
|
fs::create_dir_all(root).expect("commands root");
|
|
fs::write(
|
|
root.join(format!("{name}.md")),
|
|
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
|
|
)
|
|
.expect("write command");
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
#[test]
|
|
fn parses_supported_slash_commands() {
|
|
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
|
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
|
|
assert_eq!(
|
|
SlashCommand::parse("/bughunter runtime"),
|
|
Some(SlashCommand::Bughunter {
|
|
scope: Some("runtime".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/branch create feature/demo"),
|
|
Some(SlashCommand::Branch {
|
|
action: Some("create".to_string()),
|
|
target: Some("feature/demo".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/worktree add ../demo wt-demo"),
|
|
Some(SlashCommand::Worktree {
|
|
action: Some("add".to_string()),
|
|
path: Some("../demo".to_string()),
|
|
branch: Some("wt-demo".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
|
|
assert_eq!(
|
|
SlashCommand::parse("/commit-push-pr ready for review"),
|
|
Some(SlashCommand::CommitPushPr {
|
|
context: Some("ready for review".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/pr ready for review"),
|
|
Some(SlashCommand::Pr {
|
|
context: Some("ready for review".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/issue flaky test"),
|
|
Some(SlashCommand::Issue {
|
|
context: Some("flaky test".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/ultraplan ship both features"),
|
|
Some(SlashCommand::Ultraplan {
|
|
task: Some("ship both features".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/teleport conversation.rs"),
|
|
Some(SlashCommand::Teleport {
|
|
target: Some("conversation.rs".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/debug-tool-call"),
|
|
Some(SlashCommand::DebugToolCall)
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/model opus"),
|
|
Some(SlashCommand::Model {
|
|
model: Some("opus".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/model"),
|
|
Some(SlashCommand::Model { model: None })
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/permissions read-only"),
|
|
Some(SlashCommand::Permissions {
|
|
mode: Some("read-only".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/clear"),
|
|
Some(SlashCommand::Clear { confirm: false })
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/clear --confirm"),
|
|
Some(SlashCommand::Clear { confirm: true })
|
|
);
|
|
assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
|
|
assert_eq!(
|
|
SlashCommand::parse("/resume session.json"),
|
|
Some(SlashCommand::Resume {
|
|
session_path: Some("session.json".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/config"),
|
|
Some(SlashCommand::Config { section: None })
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/config env"),
|
|
Some(SlashCommand::Config {
|
|
section: Some("env".to_string())
|
|
})
|
|
);
|
|
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
|
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
|
assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
|
|
assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
|
|
assert_eq!(
|
|
SlashCommand::parse("/export notes.txt"),
|
|
Some(SlashCommand::Export {
|
|
path: Some("notes.txt".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/session switch abc123"),
|
|
Some(SlashCommand::Session {
|
|
action: Some("switch".to_string()),
|
|
target: Some("abc123".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/plugins install demo"),
|
|
Some(SlashCommand::Plugins {
|
|
action: Some("install".to_string()),
|
|
target: Some("demo".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/plugins list"),
|
|
Some(SlashCommand::Plugins {
|
|
action: Some("list".to_string()),
|
|
target: None
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/plugins enable demo"),
|
|
Some(SlashCommand::Plugins {
|
|
action: Some("enable".to_string()),
|
|
target: Some("demo".to_string())
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/plugins disable demo"),
|
|
Some(SlashCommand::Plugins {
|
|
action: Some("disable".to_string()),
|
|
target: Some("demo".to_string())
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn renders_help_from_shared_specs() {
|
|
let help = render_slash_command_help();
|
|
assert!(help.contains("works with --resume SESSION.json"));
|
|
assert!(help.contains("/help"));
|
|
assert!(help.contains("/status"));
|
|
assert!(help.contains("/compact"));
|
|
assert!(help.contains("/bughunter [scope]"));
|
|
assert!(help.contains("/branch [list|create <name>|switch <name>]"));
|
|
assert!(help.contains("/worktree [list|add <path> [branch]|remove <path>|prune]"));
|
|
assert!(help.contains("/commit"));
|
|
assert!(help.contains("/commit-push-pr [context]"));
|
|
assert!(help.contains("/pr [context]"));
|
|
assert!(help.contains("/issue [context]"));
|
|
assert!(help.contains("/ultraplan [task]"));
|
|
assert!(help.contains("/teleport <symbol-or-path>"));
|
|
assert!(help.contains("/debug-tool-call"));
|
|
assert!(help.contains("/model [model]"));
|
|
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
|
assert!(help.contains("/clear [--confirm]"));
|
|
assert!(help.contains("/cost"));
|
|
assert!(help.contains("/resume <session-path>"));
|
|
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
|
assert!(help.contains("/memory"));
|
|
assert!(help.contains("/init"));
|
|
assert!(help.contains("/diff"));
|
|
assert!(help.contains("/version"));
|
|
assert!(help.contains("/export [file]"));
|
|
assert!(help.contains("/session [list|switch <session-id>]"));
|
|
assert!(help.contains(
|
|
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
|
));
|
|
assert!(help.contains("aliases: /plugins, /marketplace"));
|
|
assert!(help.contains("/agents"));
|
|
assert!(help.contains("/skills"));
|
|
assert_eq!(slash_command_specs().len(), 28);
|
|
assert_eq!(resume_supported_slash_commands().len(), 13);
|
|
}
|
|
|
|
#[test]
|
|
fn compacts_sessions_via_slash_command() {
|
|
let session = Session {
|
|
version: 1,
|
|
messages: vec![
|
|
ConversationMessage::user_text("a ".repeat(200)),
|
|
ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "b ".repeat(200),
|
|
}]),
|
|
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
|
|
ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "recent".to_string(),
|
|
}]),
|
|
],
|
|
};
|
|
|
|
let result = handle_slash_command(
|
|
"/compact",
|
|
&session,
|
|
CompactionConfig {
|
|
preserve_recent_messages: 2,
|
|
max_estimated_tokens: 1,
|
|
},
|
|
)
|
|
.expect("slash command should be handled");
|
|
|
|
assert!(result.message.contains("Compacted 2 messages"));
|
|
assert_eq!(result.session.messages[0].role, MessageRole::System);
|
|
}
|
|
|
|
#[test]
|
|
fn help_command_is_non_mutating() {
|
|
let session = Session::new();
|
|
let result = handle_slash_command("/help", &session, CompactionConfig::default())
|
|
.expect("help command should be handled");
|
|
assert_eq!(result.session, session);
|
|
assert!(result.message.contains("Slash commands"));
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
|
let session = Session::new();
|
|
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
|
assert!(
|
|
handle_slash_command("/branch list", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
assert!(
|
|
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
assert!(
|
|
handle_slash_command("/worktree list", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command(
|
|
"/commit-push-pr review notes",
|
|
&session,
|
|
CompactionConfig::default()
|
|
)
|
|
.is_none());
|
|
assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
|
|
assert!(
|
|
handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
assert!(
|
|
handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
assert!(
|
|
handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
handle_slash_command("/model sonnet", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
assert!(handle_slash_command(
|
|
"/permissions read-only",
|
|
&session,
|
|
CompactionConfig::default()
|
|
)
|
|
.is_none());
|
|
assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
|
|
assert!(
|
|
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
|
|
.is_none()
|
|
);
|
|
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command(
|
|
"/resume session.json",
|
|
&session,
|
|
CompactionConfig::default()
|
|
)
|
|
.is_none());
|
|
assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
|
|
assert!(
|
|
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
|
assert!(
|
|
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
assert!(
|
|
handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn renders_plugins_report_with_name_version_and_status() {
|
|
let rendered = render_plugins_report(&[
|
|
PluginSummary {
|
|
metadata: PluginMetadata {
|
|
id: "demo@external".to_string(),
|
|
name: "demo".to_string(),
|
|
version: "1.2.3".to_string(),
|
|
description: "demo plugin".to_string(),
|
|
kind: PluginKind::External,
|
|
source: "demo".to_string(),
|
|
default_enabled: false,
|
|
root: None,
|
|
},
|
|
enabled: true,
|
|
},
|
|
PluginSummary {
|
|
metadata: PluginMetadata {
|
|
id: "sample@external".to_string(),
|
|
name: "sample".to_string(),
|
|
version: "0.9.0".to_string(),
|
|
description: "sample plugin".to_string(),
|
|
kind: PluginKind::External,
|
|
source: "sample".to_string(),
|
|
default_enabled: false,
|
|
root: None,
|
|
},
|
|
enabled: false,
|
|
},
|
|
]);
|
|
|
|
assert!(rendered.contains("demo"));
|
|
assert!(rendered.contains("v1.2.3"));
|
|
assert!(rendered.contains("enabled"));
|
|
assert!(rendered.contains("sample"));
|
|
assert!(rendered.contains("v0.9.0"));
|
|
assert!(rendered.contains("disabled"));
|
|
}
|
|
|
|
#[test]
|
|
fn lists_agents_from_project_and_user_roots() {
|
|
let workspace = temp_dir("agents-workspace");
|
|
let project_agents = workspace.join(".codex").join("agents");
|
|
let user_home = temp_dir("agents-home");
|
|
let user_agents = user_home.join(".codex").join("agents");
|
|
|
|
write_agent(
|
|
&project_agents,
|
|
"planner",
|
|
"Project planner",
|
|
"gpt-5.4",
|
|
"medium",
|
|
);
|
|
write_agent(
|
|
&user_agents,
|
|
"planner",
|
|
"User planner",
|
|
"gpt-5.4-mini",
|
|
"high",
|
|
);
|
|
write_agent(
|
|
&user_agents,
|
|
"verifier",
|
|
"Verification agent",
|
|
"gpt-5.4-mini",
|
|
"high",
|
|
);
|
|
|
|
let roots = vec![
|
|
(DefinitionSource::ProjectCodex, project_agents),
|
|
(DefinitionSource::UserCodex, user_agents),
|
|
];
|
|
let report =
|
|
render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
|
|
|
|
assert!(report.contains("Agents"));
|
|
assert!(report.contains("2 active agents"));
|
|
assert!(report.contains("Project (.codex):"));
|
|
assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
|
|
assert!(report.contains("User (~/.codex):"));
|
|
assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
|
|
assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
|
|
|
|
let _ = fs::remove_dir_all(workspace);
|
|
let _ = fs::remove_dir_all(user_home);
|
|
}
|
|
|
|
#[test]
|
|
fn lists_skills_from_project_and_user_roots() {
|
|
let workspace = temp_dir("skills-workspace");
|
|
let project_skills = workspace.join(".codex").join("skills");
|
|
let project_commands = workspace.join(".claw").join("commands");
|
|
let user_home = temp_dir("skills-home");
|
|
let user_skills = user_home.join(".codex").join("skills");
|
|
|
|
write_skill(&project_skills, "plan", "Project planning guidance");
|
|
write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance");
|
|
write_skill(&user_skills, "plan", "User planning guidance");
|
|
write_skill(&user_skills, "help", "Help guidance");
|
|
|
|
let roots = vec![
|
|
SkillRoot {
|
|
source: DefinitionSource::ProjectCodex,
|
|
path: project_skills,
|
|
origin: SkillOrigin::SkillsDir,
|
|
},
|
|
SkillRoot {
|
|
source: DefinitionSource::ProjectClaw,
|
|
path: project_commands,
|
|
origin: SkillOrigin::LegacyCommandsDir,
|
|
},
|
|
SkillRoot {
|
|
source: DefinitionSource::UserCodex,
|
|
path: user_skills,
|
|
origin: SkillOrigin::SkillsDir,
|
|
},
|
|
];
|
|
let report =
|
|
render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
|
|
|
|
assert!(report.contains("Skills"));
|
|
assert!(report.contains("3 available skills"));
|
|
assert!(report.contains("Project (.codex):"));
|
|
assert!(report.contains("plan · Project planning guidance"));
|
|
assert!(report.contains("Project (.claw):"));
|
|
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
|
|
assert!(report.contains("User (~/.codex):"));
|
|
assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
|
|
assert!(report.contains("help · Help guidance"));
|
|
|
|
let _ = fs::remove_dir_all(workspace);
|
|
let _ = fs::remove_dir_all(user_home);
|
|
}
|
|
|
|
#[test]
|
|
fn agents_and_skills_usage_support_help_and_unexpected_args() {
|
|
let cwd = temp_dir("slash-usage");
|
|
|
|
let agents_help =
|
|
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
|
|
assert!(agents_help.contains("Usage /agents"));
|
|
assert!(agents_help.contains("Direct CLI claw agents"));
|
|
|
|
let agents_unexpected =
|
|
super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
|
|
assert!(agents_unexpected.contains("Unexpected show planner"));
|
|
|
|
let skills_help =
|
|
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
|
assert!(skills_help.contains("Usage /skills"));
|
|
assert!(skills_help.contains("legacy /commands"));
|
|
|
|
let skills_unexpected =
|
|
super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
|
|
assert!(skills_unexpected.contains("Unexpected show help"));
|
|
|
|
let _ = fs::remove_dir_all(cwd);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_quoted_skill_frontmatter_values() {
|
|
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
|
let (name, description) = super::parse_skill_frontmatter(contents);
|
|
assert_eq!(name.as_deref(), Some("hud"));
|
|
assert_eq!(description.as_deref(), Some("Quoted description"));
|
|
}
|
|
|
|
#[test]
|
|
fn installs_plugin_from_path_and_lists_it() {
|
|
let config_home = temp_dir("home");
|
|
let source_root = temp_dir("source");
|
|
write_external_plugin(&source_root, "demo", "1.0.0");
|
|
|
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
|
let install = handle_plugins_slash_command(
|
|
Some("install"),
|
|
Some(source_root.to_str().expect("utf8 path")),
|
|
&mut manager,
|
|
)
|
|
.expect("install command should succeed");
|
|
assert!(install.reload_runtime);
|
|
assert!(install.message.contains("installed demo@external"));
|
|
assert!(install.message.contains("Name demo"));
|
|
assert!(install.message.contains("Version 1.0.0"));
|
|
assert!(install.message.contains("Status enabled"));
|
|
|
|
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
|
.expect("list command should succeed");
|
|
assert!(!list.reload_runtime);
|
|
assert!(list.message.contains("demo"));
|
|
assert!(list.message.contains("v1.0.0"));
|
|
assert!(list.message.contains("enabled"));
|
|
|
|
let _ = fs::remove_dir_all(config_home);
|
|
let _ = fs::remove_dir_all(source_root);
|
|
}
|
|
|
|
#[test]
|
|
fn enables_and_disables_plugin_by_name() {
|
|
let config_home = temp_dir("toggle-home");
|
|
let source_root = temp_dir("toggle-source");
|
|
write_external_plugin(&source_root, "demo", "1.0.0");
|
|
|
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
|
handle_plugins_slash_command(
|
|
Some("install"),
|
|
Some(source_root.to_str().expect("utf8 path")),
|
|
&mut manager,
|
|
)
|
|
.expect("install command should succeed");
|
|
|
|
let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
|
|
.expect("disable command should succeed");
|
|
assert!(disable.reload_runtime);
|
|
assert!(disable.message.contains("disabled demo@external"));
|
|
assert!(disable.message.contains("Name demo"));
|
|
assert!(disable.message.contains("Status disabled"));
|
|
|
|
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
|
.expect("list command should succeed");
|
|
assert!(list.message.contains("demo"));
|
|
assert!(list.message.contains("disabled"));
|
|
|
|
let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
|
|
.expect("enable command should succeed");
|
|
assert!(enable.reload_runtime);
|
|
assert!(enable.message.contains("enabled demo@external"));
|
|
assert!(enable.message.contains("Name demo"));
|
|
assert!(enable.message.contains("Status enabled"));
|
|
|
|
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
|
.expect("list command should succeed");
|
|
assert!(list.message.contains("demo"));
|
|
assert!(list.message.contains("enabled"));
|
|
|
|
let _ = fs::remove_dir_all(config_home);
|
|
let _ = fs::remove_dir_all(source_root);
|
|
}
|
|
|
|
#[test]
|
|
fn lists_auto_installed_bundled_plugins_with_status() {
|
|
let config_home = temp_dir("bundled-home");
|
|
let bundled_root = temp_dir("bundled-root");
|
|
let bundled_plugin = bundled_root.join("starter");
|
|
write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
|
|
|
|
let mut config = PluginManagerConfig::new(&config_home);
|
|
config.bundled_root = Some(bundled_root.clone());
|
|
let mut manager = PluginManager::new(config);
|
|
|
|
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
|
.expect("list command should succeed");
|
|
assert!(!list.reload_runtime);
|
|
assert!(list.message.contains("starter"));
|
|
assert!(list.message.contains("v0.1.0"));
|
|
assert!(list.message.contains("disabled"));
|
|
|
|
let _ = fs::remove_dir_all(config_home);
|
|
let _ = fs::remove_dir_all(bundled_root);
|
|
}
|
|
|
|
#[test]
|
|
fn branch_and_worktree_commands_manage_git_state() {
|
|
// given
|
|
let repo = init_git_repo("branch-worktree");
|
|
let worktree_path = repo
|
|
.parent()
|
|
.expect("repo should have parent")
|
|
.join("branch-worktree-linked");
|
|
|
|
// when
|
|
let branch_list =
|
|
handle_branch_slash_command(Some("list"), None, &repo).expect("branch list succeeds");
|
|
let created = handle_branch_slash_command(Some("create"), Some("feature/demo"), &repo)
|
|
.expect("branch create succeeds");
|
|
let switched = handle_branch_slash_command(Some("switch"), Some("main"), &repo)
|
|
.expect("branch switch succeeds");
|
|
let added = handle_worktree_slash_command(
|
|
Some("add"),
|
|
Some(worktree_path.to_str().expect("utf8 path")),
|
|
Some("wt-demo"),
|
|
&repo,
|
|
)
|
|
.expect("worktree add succeeds");
|
|
let listed_worktrees =
|
|
handle_worktree_slash_command(Some("list"), None, None, &repo).expect("list succeeds");
|
|
let removed = handle_worktree_slash_command(
|
|
Some("remove"),
|
|
Some(worktree_path.to_str().expect("utf8 path")),
|
|
None,
|
|
&repo,
|
|
)
|
|
.expect("remove succeeds");
|
|
|
|
// then
|
|
assert!(branch_list.contains("main"));
|
|
assert!(created.contains("feature/demo"));
|
|
assert!(switched.contains("main"));
|
|
assert!(added.contains("wt-demo"));
|
|
assert!(listed_worktrees.contains(worktree_path.to_str().expect("utf8 path")));
|
|
assert!(removed.contains("Result removed"));
|
|
|
|
let _ = fs::remove_dir_all(repo);
|
|
let _ = fs::remove_dir_all(worktree_path);
|
|
}
|
|
|
|
#[test]
|
|
fn commit_command_stages_and_commits_changes() {
|
|
// given
|
|
let repo = init_git_repo("commit-command");
|
|
fs::write(repo.join("notes.txt"), "hello\n").expect("write notes");
|
|
|
|
// when
|
|
let report =
|
|
handle_commit_slash_command("feat: add notes", &repo).expect("commit succeeds");
|
|
let status = run_command(&repo, "git", &["status", "--short"]);
|
|
let message = run_command(&repo, "git", &["log", "-1", "--pretty=%B"]);
|
|
|
|
// then
|
|
assert!(report.contains("Result created"));
|
|
assert!(status.trim().is_empty());
|
|
assert_eq!(message.trim(), "feat: add notes");
|
|
|
|
let _ = fs::remove_dir_all(repo);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn commit_push_pr_command_commits_pushes_and_creates_pr() {
|
|
// given
|
|
let _guard = env_lock();
|
|
let repo = init_git_repo("commit-push-pr");
|
|
let remote = init_bare_repo("commit-push-pr-remote");
|
|
run_command(
|
|
&repo,
|
|
"git",
|
|
&[
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
remote.to_str().expect("utf8 remote"),
|
|
],
|
|
);
|
|
run_command(&repo, "git", &["push", "-u", "origin", "main"]);
|
|
fs::write(repo.join("feature.txt"), "feature\n").expect("write feature file");
|
|
|
|
let fake_bin = temp_dir("fake-gh-bin");
|
|
let gh_log = fake_bin.join("gh.log");
|
|
write_fake_gh(&fake_bin, &gh_log, "https://example.com/pr/123");
|
|
|
|
let previous_path = env::var_os("PATH");
|
|
let mut new_path = fake_bin.display().to_string();
|
|
if let Some(path) = &previous_path {
|
|
new_path.push(':');
|
|
new_path.push_str(&path.to_string_lossy());
|
|
}
|
|
env::set_var("PATH", &new_path);
|
|
let previous_safeuser = env::var_os("SAFEUSER");
|
|
env::set_var("SAFEUSER", "tester");
|
|
|
|
let request = CommitPushPrRequest {
|
|
commit_message: Some("feat: add feature file".to_string()),
|
|
pr_title: "Add feature file".to_string(),
|
|
pr_body: "## Summary\n- add feature file".to_string(),
|
|
branch_name_hint: "Add feature file".to_string(),
|
|
};
|
|
|
|
// when
|
|
let report =
|
|
handle_commit_push_pr_slash_command(&request, &repo).expect("commit-push-pr succeeds");
|
|
let branch = run_command(&repo, "git", &["branch", "--show-current"]);
|
|
let message = run_command(&repo, "git", &["log", "-1", "--pretty=%B"]);
|
|
let gh_invocations = fs::read_to_string(&gh_log).expect("gh log should exist");
|
|
|
|
// then
|
|
assert!(report.contains("Result created"));
|
|
assert!(report.contains("URL https://example.com/pr/123"));
|
|
assert_eq!(branch.trim(), "tester/add-feature-file");
|
|
assert_eq!(message.trim(), "feat: add feature file");
|
|
assert!(gh_invocations.contains("pr create"));
|
|
assert!(gh_invocations.contains("--base main"));
|
|
|
|
if let Some(path) = previous_path {
|
|
env::set_var("PATH", path);
|
|
} else {
|
|
env::remove_var("PATH");
|
|
}
|
|
if let Some(safeuser) = previous_safeuser {
|
|
env::set_var("SAFEUSER", safeuser);
|
|
} else {
|
|
env::remove_var("SAFEUSER");
|
|
}
|
|
|
|
let _ = fs::remove_dir_all(repo);
|
|
let _ = fs::remove_dir_all(remote);
|
|
let _ = fs::remove_dir_all(fake_bin);
|
|
}
|
|
}
|