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:
@@ -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);
|
||||
|
||||
@@ -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/...`,
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user