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:
Hunter Bown
2026-05-10 10:51:57 -05:00
parent 3abd4dd988
commit c87196835c
4 changed files with 154 additions and 8 deletions
+8
View File
@@ -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.
+1 -1
View File
@@ -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 {
+139 -3
View File
@@ -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();
+6 -4
View File
@@ -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)"