fix(skill): resolve multi-word skill names in slash invocations (fixes #619) (#628)

Resolve slash invocations against registered skill names longest-first, while preserving the existing identifier guard and unknown-skill fallback. Wire the resolver into local TUI, remote TUI, and headless REPL paths, with base and end-to-end TUI coverage.

Co-authored-by: 陈朱衡 <wsadwsadqe@hotmail.com>
This commit is contained in:
Jeremy Huang
2026-07-28 01:27:56 -07:00
committed by GitHub
parent 524032dfd6
commit 268913473f
7 changed files with 196 additions and 24 deletions
@@ -770,8 +770,11 @@ impl Agent {
continue;
}
// Check for skill invocation
if let Some(invocation) = SkillRegistry::parse_invocation(input) {
// Check for skill invocation. Resolve against the registry (not
// the bare tokenizer) so a `SKILL.md` `name:` field containing
// spaces, e.g. "My Custom Skill", can still be matched: the
// bare parse always stops at the first whitespace.
if let Some(invocation) = skills.resolve_invocation(input) {
if let Some(skill) = skills.get(invocation.name) {
println!("Activating skill: {}", skill.name);
println!("{}\n", skill.description);
+3 -8
View File
@@ -8,6 +8,9 @@ use std::sync::Arc;
use std::sync::OnceLock;
use tokio::sync::RwLock;
mod invocation;
pub use invocation::SkillInvocation;
/// A skill definition from SKILL.md
#[derive(Debug, Clone)]
pub struct Skill {
@@ -33,14 +36,6 @@ pub struct SkillRegistry {
skills: HashMap<String, Skill>,
}
/// A slash-command skill invocation, optionally followed by a prompt that
/// should be submitted immediately after activating the skill.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SkillInvocation<'a> {
pub name: &'a str,
pub prompt: Option<&'a str>,
}
/// Maximum directory depth scanned under a Claude Code plugin root when
/// looking for `skills/<name>/SKILL.md` entries. Plugin layouts vary across
/// Claude Code versions (`cache/<marketplace>/<plugin>/<version>/skills/...`,
+144
View File
@@ -0,0 +1,144 @@
use super::SkillRegistry;
/// A slash-command skill invocation with an optional trailing prompt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SkillInvocation<'a> {
pub name: &'a str,
pub prompt: Option<&'a str>,
}
impl SkillRegistry {
/// Resolve a slash invocation against registered names, including names
/// containing spaces. The longest registered name wins; unknown names fall
/// back to the identifier-shaped result from [`Self::parse_invocation`].
pub fn resolve_invocation<'a>(&self, input: &'a str) -> Option<SkillInvocation<'a>> {
let fallback = Self::parse_invocation(input)?;
let invocation = input.trim().strip_prefix('/')?;
let boundaries = std::iter::once(invocation.len()).chain(
invocation
.char_indices()
.rev()
.filter_map(|(index, ch)| ch.is_whitespace().then_some(index)),
);
for end in boundaries {
let name = &invocation[..end];
if name.is_empty() || !self.skills.contains_key(name) {
continue;
}
let prompt = invocation[end..].trim();
let prompt = match prompt.as_bytes() {
[b'"', .., b'"'] | [b'\'', .., b'\''] if prompt.len() >= 2 => {
&prompt[1..prompt.len() - 1]
}
_ => prompt,
};
return Some(SkillInvocation {
name,
prompt: (!prompt.is_empty()).then_some(prompt),
});
}
Some(fallback)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skill::{Skill, build_skill_search_text};
use std::path::PathBuf;
fn registry(names: &[&str]) -> SkillRegistry {
let mut registry = SkillRegistry::default();
for name in names {
registry.skills.insert(
(*name).to_string(),
Skill {
name: (*name).to_string(),
description: "Test skill".to_string(),
allowed_tools: None,
content: "content".to_string(),
path: PathBuf::from(format!("/tmp/{name}/SKILL.md")),
search_text: build_skill_search_text(name, "Test skill", "content"),
},
);
}
registry
}
#[test]
fn matches_multi_word_skill_names() {
let registry = registry(&["My Custom Skill"]);
assert_eq!(
registry.resolve_invocation("/My Custom Skill"),
Some(SkillInvocation {
name: "My Custom Skill",
prompt: None,
})
);
}
#[test]
fn keeps_trailing_prompt_after_multi_word_name() {
let registry = registry(&["My Custom Skill"]);
assert_eq!(
registry.resolve_invocation("/My Custom Skill only the staged diff"),
Some(SkillInvocation {
name: "My Custom Skill",
prompt: Some("only the staged diff"),
})
);
assert_eq!(
registry.resolve_invocation("/My Custom Skill 'only the staged diff'"),
registry.resolve_invocation("/My Custom Skill only the staged diff")
);
}
#[test]
fn prefers_longest_registered_match() {
let registry = registry(&["My", "My Custom Skill"]);
assert_eq!(
registry.resolve_invocation("/My Custom Skill please"),
Some(SkillInvocation {
name: "My Custom Skill",
prompt: Some("please"),
})
);
}
#[test]
fn falls_back_to_single_token_when_no_registry_match_exists() {
let registry = SkillRegistry::default();
assert_eq!(
registry.resolve_invocation("/not-a-real-skill with a prompt"),
Some(SkillInvocation {
name: "not-a-real-skill",
prompt: Some("with a prompt"),
})
);
}
#[test]
fn does_not_bypass_terminal_file_drop_guard() {
let registry = registry(&["tmp/screenshot.png"]);
assert_eq!(registry.resolve_invocation("/tmp/screenshot.png"), None);
assert_eq!(
registry.resolve_invocation("/tmp/screenshot.png describe this"),
None
);
}
#[test]
fn preserves_single_word_skill_invocations() {
let registry = registry(&["frontend-design"]);
assert_eq!(
registry.resolve_invocation("/frontend-design build a settings page"),
Some(SkillInvocation {
name: "frontend-design",
prompt: Some("build a settings page"),
})
);
}
}
+8 -8
View File
@@ -1,9 +1,9 @@
#![cfg_attr(test, allow(clippy::items_after_test_module))]
use super::{
App, ContentBlock, DisplayMessage, Message, ProcessingStatus, Role, SendAction, SkillRegistry,
commands, ctrl_bracket_fallback_to_esc, is_context_limit_error,
is_request_payload_too_large_error, remote,
App, ContentBlock, DisplayMessage, Message, ProcessingStatus, Role, SendAction, commands,
ctrl_bracket_fallback_to_esc, is_context_limit_error, is_request_payload_too_large_error,
remote,
};
use crate::bus::{
Bus, BusEvent, ClipboardPasteCompleted, ClipboardPasteContent, ClipboardPasteKind,
@@ -3655,19 +3655,19 @@ impl App {
return;
}
// A terminal file drop is user input even when its absolute path starts
// with `/`. Check the filesystem-aware drop parser before slash routing
// so a real file can never collide with a skill name.
// File drops remain ordinary input. Registry-aware resolution supports
// multi-word skill names without weakening that guard.
let initial_snapshot = self.current_skills_snapshot();
let skill_invocation = parse_dropped_paths(&input)
.is_none()
.then(|| SkillRegistry::parse_invocation(&input))
.then(|| initial_snapshot.resolve_invocation(&input))
.flatten();
// Check for skill invocation.
if let Some(invocation) = skill_invocation {
let skill_name = invocation.name.to_string();
let trailing_prompt = invocation.prompt.map(str::to_string);
let mut skill = self.current_skills_snapshot().get(&skill_name).cloned();
let mut skill = initial_snapshot.get(&skill_name).cloned();
// Remote/minimal TUI clients may start with an empty skill snapshot, and
// daemon-side `skill_manage reload_all` can update a different process.
@@ -1,6 +1,5 @@
use super::super::{PendingRemoteMessage, PendingSplitPrompt};
use super::*;
use crate::skill::SkillRegistry;
#[expect(
clippy::too_many_arguments,
@@ -161,15 +160,18 @@ pub(in crate::tui::app) async fn submit_remote_slash_input(
//
// `/?` is the one builtin whose token is not identifier-shaped, so it is
// allowed through explicitly.
// Resolve registered multi-word skill names before falling back to the
// existing single-token command handling.
let snapshot = app.current_skills_snapshot();
let trimmed = raw_input.trim();
let is_command_shaped = trimmed == "/?"
|| (input::parse_dropped_paths(&raw_input).is_none()
&& SkillRegistry::parse_invocation(&raw_input).is_some());
&& snapshot.resolve_invocation(&raw_input).is_some());
if !is_command_shaped {
return submit_prepared_remote_input(app, remote, prepared).await;
}
let Some(invocation) = SkillRegistry::parse_invocation(&raw_input) else {
let Some(invocation) = snapshot.resolve_invocation(&raw_input) else {
app.input = raw_input;
app.cursor_pos = app.input.len();
app.submit_input();
@@ -184,7 +186,7 @@ pub(in crate::tui::app) async fn submit_remote_slash_input(
};
let skill_name = invocation.name.to_string();
let mut skill = app.current_skills_snapshot().get(&skill_name).cloned();
let mut skill = snapshot.get(&skill_name).cloned();
if skill.is_none() {
app.refresh_skills_snapshot();
skill = app.current_skills_snapshot().get(&skill_name).cloned();
@@ -207,7 +209,9 @@ pub(in crate::tui::app) async fn submit_remote_slash_input(
app.pending_images.clear();
app.submit_input();
let expanded_prompt = SkillRegistry::parse_invocation(&prepared.expanded)
let expanded_prompt = app
.current_skills_snapshot()
.resolve_invocation(&prepared.expanded)
.and_then(|invocation| invocation.prompt)
.unwrap_or(trailing_prompt)
.to_string();
+1 -1
View File
@@ -47,7 +47,7 @@ include!("tests/issue_544_paste_enter.rs");
include!("tests/issue_497_copy_ctrl_c.rs");
include!("tests/spinner_slash_commands.rs");
include!("tests/command_suggestions_cache.rs");
include!("tests/skill_invocation_multi_word.rs");
#[test]
fn kv_cache_signature_prefix_match_allows_appended_messages() {
let baseline_messages = vec![
@@ -0,0 +1,26 @@
#[test]
fn skill_invocation_matches_a_multi_word_skill_name() {
let mut app = create_test_app();
let temp = tempfile::tempdir().expect("tempdir");
let skill_dir = temp.path().join(".jcode/skills/my-custom-skill");
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: My Custom Skill\ndescription: Commit staged changes\n---\nUse it.\n",
)
.expect("write skill");
app.session.working_dir = Some(temp.path().to_string_lossy().to_string());
app.input = "/My Custom Skill".to_string();
app.cursor_pos = app.input.len();
app.submit_input();
assert_eq!(app.active_skill.as_deref(), Some("My Custom Skill"));
let last = app.display_messages().last().expect("activation message");
assert!(
last.content.contains("Activated skill: My Custom Skill"),
"{}",
last.content
);
assert!(!last.content.contains("Unknown skill"), "{}", last.content);
}