mirror of
https://github.com/instructkr/claude-code.git
synced 2026-04-03 12:38:53 +03:00
feat: expand slash command surface
This commit is contained in:
@@ -278,6 +278,12 @@ pub enum SlashCommand {
|
|||||||
action: Option<String>,
|
action: Option<String>,
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
},
|
},
|
||||||
|
Agents {
|
||||||
|
args: Option<String>,
|
||||||
|
},
|
||||||
|
Skills {
|
||||||
|
args: Option<String>,
|
||||||
|
},
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,13 +345,19 @@ impl SlashCommand {
|
|||||||
action: parts.next().map(ToOwned::to_owned),
|
action: parts.next().map(ToOwned::to_owned),
|
||||||
target: parts.next().map(ToOwned::to_owned),
|
target: parts.next().map(ToOwned::to_owned),
|
||||||
},
|
},
|
||||||
"plugins" => Self::Plugins {
|
"plugin" | "plugins" | "marketplace" => Self::Plugins {
|
||||||
action: parts.next().map(ToOwned::to_owned),
|
action: parts.next().map(ToOwned::to_owned),
|
||||||
target: {
|
target: {
|
||||||
let remainder = parts.collect::<Vec<_>>().join(" ");
|
let remainder = parts.collect::<Vec<_>>().join(" ");
|
||||||
(!remainder.is_empty()).then_some(remainder)
|
(!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()),
|
other => Self::Unknown(other.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -384,12 +396,27 @@ pub fn render_slash_command_help() -> String {
|
|||||||
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
||||||
None => format!("/{}", spec.name),
|
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 {
|
let resume = if spec.resume_supported {
|
||||||
" [resume]"
|
" [resume]"
|
||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
lines.push(format!(" {name:<20} {}{}", spec.summary, resume));
|
lines.push(format!(
|
||||||
|
" {name:<20} {}{alias_suffix}{resume}",
|
||||||
|
spec.summary
|
||||||
|
));
|
||||||
}
|
}
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
@@ -406,6 +433,45 @@ pub struct PluginsCommandResult {
|
|||||||
pub reload_runtime: bool,
|
pub reload_runtime: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum DefinitionSource {
|
||||||
|
ProjectCodex,
|
||||||
|
ProjectClaude,
|
||||||
|
UserCodexHome,
|
||||||
|
UserCodex,
|
||||||
|
UserClaude,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefinitionSource {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ProjectCodex => "Project (.codex)",
|
||||||
|
Self::ProjectClaude => "Project (.claude)",
|
||||||
|
Self::UserCodexHome => "User ($CODEX_HOME)",
|
||||||
|
Self::UserCodex => "User (~/.codex)",
|
||||||
|
Self::UserClaude => "User (~/.claude)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn handle_plugins_slash_command(
|
pub fn handle_plugins_slash_command(
|
||||||
action: Option<&str>,
|
action: Option<&str>,
|
||||||
@@ -518,6 +584,26 @@ pub fn handle_plugins_slash_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
||||||
|
if let Some(args) = args.filter(|value| !value.trim().is_empty()) {
|
||||||
|
return Ok(format!("Usage: /agents\nUnexpected arguments: {args}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let roots = discover_definition_roots(cwd, "agents");
|
||||||
|
let agents = load_agents_from_roots(&roots)?;
|
||||||
|
Ok(render_agents_report(&agents))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
||||||
|
if let Some(args) = args.filter(|value| !value.trim().is_empty()) {
|
||||||
|
return Ok(format!("Usage: /skills\nUnexpected arguments: {args}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let roots = discover_definition_roots(cwd, "skills");
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
Ok(render_skills_report(&skills))
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
||||||
let mut lines = vec!["Plugins".to_string()];
|
let mut lines = vec!["Plugins".to_string()];
|
||||||
@@ -570,6 +656,303 @@ fn resolve_plugin_target(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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::ProjectClaude,
|
||||||
|
ancestor.join(".claude").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::UserClaude,
|
||||||
|
home.join(".claude").join(leaf),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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(|stem| stem.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| entry.file_name().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: &[(DefinitionSource, PathBuf)],
|
||||||
|
) -> std::io::Result<Vec<SkillSummary>> {
|
||||||
|
let mut skills = Vec::new();
|
||||||
|
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
|
||||||
|
|
||||||
|
for (source, root) in roots {
|
||||||
|
let mut root_skills = Vec::new();
|
||||||
|
for entry in fs::read_dir(root)? {
|
||||||
|
let entry = entry?;
|
||||||
|
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: *source,
|
||||||
|
shadowed_by: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 = value.trim();
|
||||||
|
if !value.is_empty() {
|
||||||
|
name = Some(value.to_string());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(value) = trimmed.strip_prefix("description:") {
|
||||||
|
let value = value.trim();
|
||||||
|
if !value.is_empty() {
|
||||||
|
description = Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(name, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
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::ProjectClaude,
|
||||||
|
DefinitionSource::UserCodexHome,
|
||||||
|
DefinitionSource::UserCodex,
|
||||||
|
DefinitionSource::UserClaude,
|
||||||
|
] {
|
||||||
|
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::ProjectClaude,
|
||||||
|
DefinitionSource::UserCodexHome,
|
||||||
|
DefinitionSource::UserCodex,
|
||||||
|
DefinitionSource::UserClaude,
|
||||||
|
] {
|
||||||
|
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 detail = match &skill.description {
|
||||||
|
Some(description) => format!("{} · {}", skill.name, description),
|
||||||
|
None => skill.name.clone(),
|
||||||
|
};
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn handle_slash_command(
|
pub fn handle_slash_command(
|
||||||
input: &str,
|
input: &str,
|
||||||
@@ -617,6 +1000,8 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Export { .. }
|
| SlashCommand::Export { .. }
|
||||||
| SlashCommand::Session { .. }
|
| SlashCommand::Session { .. }
|
||||||
| SlashCommand::Plugins { .. }
|
| SlashCommand::Plugins { .. }
|
||||||
|
| SlashCommand::Agents { .. }
|
||||||
|
| SlashCommand::Skills { .. }
|
||||||
| SlashCommand::Unknown(_) => None,
|
| SlashCommand::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user