feat(skills): /skills <prefix> filters the local list (#1318)
On top of v0.8.26's inter-row spacing for /skills (#1328 from @reidliu41), the list now also accepts an optional name-prefix argument so users with crowded skill folders can narrow the view without scrolling. /skills → full list (unchanged) /skills git → only skills whose name starts with "git" /skills GIT → same (case-insensitive) /skills nope → "No skills match prefix `nope` (out of 12 …)" /skills --remote → unchanged /skills sync → unchanged /skills --bogus → "Usage: …" error (rejected so future flags don't silently turn into no-match prefixes) The match-count header reflects both the matched count and the registry total, so the user can see at a glance how aggressive the filter is. Empty match sets explicitly say so and point back at unfiltered `/skills`. Skill names that start with `-` aren't allowed by the loader, so reserving the dash prefix for flags is safe. Plus the matching usage / description updates in the command metadata + all four shipping locales (en / ja / zh-Hans / pt-BR) so /help shows the new argument. Closes #1318. Thanks @simuusang for the report. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,14 @@ Big thanks to every contributor below.
|
||||
localization table, so once you pick your language the rest of
|
||||
the flow is in that language. Particularly nice for users on
|
||||
CJK input methods who want to avoid IME juggling during setup.
|
||||
- **`/skills <prefix>` filters the local skills list** (#1318) — on
|
||||
top of the v0.8.26 inter-row spacing (#1328 from @reidliu41), the
|
||||
list now narrows to skills whose names start with the typed
|
||||
prefix. Case-insensitive. The header reflects matched count vs
|
||||
registry total; an empty match set says so explicitly and points
|
||||
back at unfiltered `/skills`. `--remote` and `sync` stay
|
||||
reserved as subcommands; any `--`-prefixed argument is rejected
|
||||
rather than being silently treated as a no-match prefix.
|
||||
- **HTTP 400 quota errors retried** (#1203) — some OpenAI-compatible
|
||||
gateways return quota/rate-limit errors as HTTP 400 instead of 429.
|
||||
These are now classified as retryable `RateLimited` errors.
|
||||
|
||||
@@ -444,7 +444,7 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "skills",
|
||||
aliases: &[],
|
||||
usage: "/skills [--remote|sync]",
|
||||
usage: "/skills [--remote|sync|<prefix>]",
|
||||
description_id: MessageId::CmdSkillsDescription,
|
||||
},
|
||||
CommandInfo {
|
||||
|
||||
@@ -35,6 +35,7 @@ fn render_skill_warnings(registry: &SkillRegistry) -> String {
|
||||
/// Pass `sync` to pull the registry index and download all skills to the
|
||||
/// local cache (`~/.deepseek/cache/skills/`).
|
||||
pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let mut prefix: Option<String> = None;
|
||||
if let Some(arg) = arg {
|
||||
let trimmed = arg.trim();
|
||||
if trimmed == "--remote" || trimmed == "remote" {
|
||||
@@ -44,7 +45,15 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
return sync_skills(app);
|
||||
}
|
||||
if !trimmed.is_empty() {
|
||||
return CommandResult::error("Usage: /skills [--remote|sync]");
|
||||
// Anything else is treated as a name-prefix filter (#1318).
|
||||
// Reject obviously malformed args (whitespace inside the
|
||||
// prefix, leading dash) so future flag additions don't
|
||||
// collide with skill names. Skill names that start with
|
||||
// `-` aren't allowed by the loader so this is safe.
|
||||
if trimmed.starts_with('-') || trimmed.split_whitespace().count() > 1 {
|
||||
return CommandResult::error("Usage: /skills [--remote|sync|<name-prefix>]");
|
||||
}
|
||||
prefix = Some(trimmed.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
@@ -70,9 +79,38 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
return CommandResult::message(msg);
|
||||
}
|
||||
|
||||
let mut output = format!("Available skills ({}):\n", registry.len());
|
||||
let filtered: Vec<&crate::skills::Skill> = if let Some(p) = prefix.as_deref() {
|
||||
registry
|
||||
.list()
|
||||
.iter()
|
||||
.filter(|s| s.name.to_ascii_lowercase().starts_with(p))
|
||||
.collect()
|
||||
} else {
|
||||
registry.list().iter().collect()
|
||||
};
|
||||
|
||||
if filtered.is_empty() {
|
||||
// The user typed a prefix that matched nothing. Surface what
|
||||
// they typed plus the full count so they can decide whether
|
||||
// to adjust the prefix or run `/skills` for the whole list.
|
||||
let p = prefix.as_deref().unwrap_or("");
|
||||
return CommandResult::message(format!(
|
||||
"No skills match prefix `{p}` (out of {} available).\n\nRun /skills to see them all.{warnings}",
|
||||
registry.len()
|
||||
));
|
||||
}
|
||||
|
||||
let mut output = if let Some(p) = prefix.as_deref() {
|
||||
format!(
|
||||
"Available skills matching `{p}` ({} of {}):\n",
|
||||
filtered.len(),
|
||||
registry.len()
|
||||
)
|
||||
} else {
|
||||
format!("Available skills ({}):\n", registry.len())
|
||||
};
|
||||
output.push_str("─────────────────────────────\n");
|
||||
for (idx, skill) in registry.list().iter().enumerate() {
|
||||
for (idx, skill) in filtered.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
output.push('\n');
|
||||
}
|
||||
@@ -680,6 +718,104 @@ mod tests {
|
||||
assert!(msg.contains("/test-skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_filters_by_name_prefix() {
|
||||
// #1318: a `/skills <prefix>` argument should narrow the list to
|
||||
// skills whose names start with the prefix. The header reflects
|
||||
// both the matched count and the registry total so the user
|
||||
// knows what they're looking at.
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = IsolatedHome::new(&tmpdir);
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"alpha-skill",
|
||||
"---\nname: alpha-skill\ndescription: First\n---\nbody",
|
||||
);
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"alphabet-helper",
|
||||
"---\nname: alphabet-helper\ndescription: Helper\n---\nbody",
|
||||
);
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"beta-skill",
|
||||
"---\nname: beta-skill\ndescription: Second\n---\nbody",
|
||||
);
|
||||
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app, Some("alph"));
|
||||
let msg = result.message.expect("filter result has message");
|
||||
|
||||
assert!(msg.contains("/alpha-skill"));
|
||||
assert!(msg.contains("/alphabet-helper"));
|
||||
assert!(
|
||||
!msg.contains("/beta-skill"),
|
||||
"beta-skill must be filtered out"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("matching `alph`") && msg.contains("2 of 3"),
|
||||
"header should show count + total, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_filter_is_case_insensitive() {
|
||||
// Prefix matching is case-insensitive — typing `Alph` finds
|
||||
// `alpha-skill` the same as `alph` does.
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = IsolatedHome::new(&tmpdir);
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"alpha-skill",
|
||||
"---\nname: alpha-skill\ndescription: First\n---\nbody",
|
||||
);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app, Some("ALPH"));
|
||||
let msg = result.message.expect("case-insensitive filter has message");
|
||||
assert!(msg.contains("/alpha-skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_filter_with_zero_matches_says_so() {
|
||||
// When the prefix matches nothing, the message must say so
|
||||
// explicitly (rather than printing an empty list) and point
|
||||
// the user back at the unfiltered command.
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = IsolatedHome::new(&tmpdir);
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"alpha-skill",
|
||||
"---\nname: alpha-skill\ndescription: First\n---\nbody",
|
||||
);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app, Some("nonexistent"));
|
||||
let msg = result.message.expect("zero-match filter still has message");
|
||||
assert!(msg.contains("No skills match prefix `nonexistent`"));
|
||||
assert!(msg.contains("Run /skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_rejects_flag_like_prefix() {
|
||||
// `--remote` and `sync` stay reserved as subcommands; any other
|
||||
// dash-prefixed argument is rejected so we don't silently turn
|
||||
// a future flag into a no-match filter.
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = IsolatedHome::new(&tmpdir);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app, Some("--bogus"));
|
||||
assert!(
|
||||
result.is_error,
|
||||
"expected usage error for --bogus, got: {result:?}"
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.is_some_and(|m| m.contains("name-prefix")),
|
||||
"expected --bogus error message to mention name-prefix, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_separates_entries_with_blank_line() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
|
||||
@@ -841,7 +841,7 @@ fn english(id: MessageId) -> &'static str {
|
||||
"Activate a skill, or install/update/uninstall/trust a community skill"
|
||||
}
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"List local skills (or --remote to browse the curated registry)"
|
||||
"List local skills (filter by `/skills <prefix>`; --remote browses the curated registry)"
|
||||
}
|
||||
MessageId::CmdStashDescription => {
|
||||
"Park or restore a composer draft (Ctrl+S to push, /stash list/pop)"
|
||||
@@ -1182,7 +1182,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
"スキルを有効化、またはコミュニティスキルをインストール/更新/アンインストール/信頼"
|
||||
}
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"ローカルスキルを一覧表示(--remote で精選レジストリを参照)"
|
||||
"ローカルスキルを一覧表示(`/skills <prefix>` で絞り込み、--remote で精選レジストリを参照)"
|
||||
}
|
||||
MessageId::CmdStashDescription => {
|
||||
"コンポーザーの下書きを退避/復元(Ctrl+S で退避、/stash list|pop)"
|
||||
@@ -1496,7 +1496,9 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdSessionsDescription => "打开会话选择器",
|
||||
MessageId::CmdSettingsDescription => "显示持久化设置",
|
||||
MessageId::CmdSkillDescription => "激活技能,或安装/更新/卸载/信任社区技能",
|
||||
MessageId::CmdSkillsDescription => "列出本地技能(或使用 --remote 浏览精选注册表)",
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"列出本地技能(用 `/skills <prefix>` 按名称前缀过滤,--remote 浏览精选注册表)"
|
||||
}
|
||||
MessageId::CmdStashDescription => "暂存或恢复输入草稿(Ctrl+S 暂存,/stash list|pop)",
|
||||
MessageId::CmdStatusDescription => "显示当前运行状态",
|
||||
MessageId::CmdStatuslineDescription => "配置底栏要显示哪些条目",
|
||||
@@ -1808,7 +1810,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
|
||||
"Ativar uma skill, ou instalar/atualizar/desinstalar/confiar em uma skill da comunidade"
|
||||
}
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"Listar skills locais (ou --remote para navegar pelo registro curado)"
|
||||
"Listar skills locais (filtre com `/skills <prefixo>`; --remote navega pelo registro curado)"
|
||||
}
|
||||
MessageId::CmdStashDescription => {
|
||||
"Estacionar ou restaurar rascunho do compositor (Ctrl+S estaciona, /stash list|pop)"
|
||||
|
||||
Reference in New Issue
Block a user