web+coder: discover Claude's enabled commands + plugin skills; icon-split commands vs skills

claude is PTY (no ACP discovery), so claude-command-discovery.ts reads its enabled set from disk (user-global): ~/.claude/commands/*.md + every enabled plugin's skills/<name>/SKILL.md (kind=skill) and commands/*.md (kind=command), from ~/.claude/settings.json:enabledPlugins + installed_plugins.json install paths, frontmatter-parsed, bare names, deduped. The snapshot claude branch discovers these live (snapshot cache rate-limits the reads). The coder / menu now shows up to three icon'd groups: <agent> commands (Terminal), <agent> skills (Puzzle), BooCoder skills (Sparkles) via a new optional icon on SlashCommandGroup. AgentCommand gains a kind field in both coder + web copies (parity test enforces); mergeCommandsByName made generic to preserve it. Invocation unchanged (literal /name -> claude). Project-local plugins deferred. BooChat unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 16:21:32 +00:00
parent dc3859975d
commit 2d997ecb6c
8 changed files with 154 additions and 11 deletions

View File

@@ -0,0 +1,108 @@
/**
* v2.5.11: discover Claude Code's real, enabled commands + plugin skills from
* disk so the coder slash menu shows them (claude is PTY — no ACP discovery).
*
* Scope (v1): user-global only — `~/.claude/commands/*.md` plus the enabled
* plugins listed in `~/.claude/settings.json:enabledPlugins` (user-scope install
* paths from `~/.claude/plugins/.../installed_plugins.json`). Project-local
* plugins and `<cwd>/.claude/commands` are deferred. Names are bare.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { AgentCommand } from './provider-types.js';
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
function frontmatterField(content: string, field: string): string | undefined {
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!block?.[1]) return undefined;
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
}
function readCommandDir(dir: string): AgentCommand[] {
if (!existsSync(dir)) return [];
let files: string[];
try {
files = readdirSync(dir);
} catch {
return [];
}
const out: AgentCommand[] = [];
for (const f of files) {
if (!f.endsWith('.md')) continue;
let description: string | undefined;
try {
description = frontmatterField(readFileSync(join(dir, f), 'utf8'), 'description');
} catch {
/* unreadable — still list the command by name */
}
out.push({ name: f.slice(0, -3), kind: 'command', ...(description ? { description } : {}) });
}
return out;
}
function readSkillDir(dir: string): AgentCommand[] {
if (!existsSync(dir)) return [];
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return [];
}
const out: AgentCommand[] = [];
for (const sub of entries) {
const skillMd = join(dir, sub, 'SKILL.md');
if (!existsSync(skillMd)) continue;
let content: string;
try {
content = readFileSync(skillMd, 'utf8');
} catch {
continue;
}
out.push({
name: frontmatterField(content, 'name') ?? sub,
kind: 'skill',
...(() => {
const d = frontmatterField(content, 'description');
return d ? { description: d } : {};
})(),
});
}
return out;
}
export function discoverClaudeCommands(): AgentCommand[] {
const root = join(homedir(), '.claude');
const out: AgentCommand[] = [];
// User custom commands.
out.push(...readCommandDir(join(root, 'commands')));
// Enabled plugins (user-scope installs).
try {
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
enabledPlugins?: Record<string, boolean>;
};
const installed = JSON.parse(
readFileSync(join(root, 'plugins', 'installed_plugins.json'), 'utf8'),
) as { plugins?: Record<string, Array<{ scope?: string; installPath?: string }>> };
const enabled = settings.enabledPlugins ?? {};
const plugins = installed.plugins ?? {};
for (const [key, on] of Object.entries(enabled)) {
if (!on) continue;
const installs = plugins[key] ?? [];
const installPath = (installs.find((i) => i.scope === 'user') ?? installs[0])?.installPath;
if (!installPath || !existsSync(installPath)) continue;
out.push(...readSkillDir(join(installPath, 'skills')));
out.push(...readCommandDir(join(installPath, 'commands')));
}
} catch {
/* missing/unreadable plugin config → user commands only */
}
// Dedupe by name (first wins).
const seen = new Set<string>();
return out.filter((c) => (seen.has(c.name) ? false : (seen.add(c.name), true)));
}

View File

@@ -16,6 +16,7 @@ import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { isCommandAvailable } from './command-availability.js';
import { discoverClaudeCommands } from './claude-command-discovery.js';
interface AgentRow {
name: string;
@@ -149,10 +150,12 @@ async function buildProviderEntry(
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
if (name === 'claude') {
// claude is PTY (no ACP discovery) — read its enabled commands + plugin
// skills from disk live (the snapshot cache rate-limits the fs reads).
return {
name, label, transport, status: 'ready', enabled: true, installed: true,
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
commands: manifestCommands,
commands: mergeCommands(manifestCommands, discoverClaudeCommands()),
};
}

View File

@@ -30,6 +30,9 @@ export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'erro
export interface AgentCommand {
name: string;
description?: string;
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
// Drives the icon split in the coder slash menu. Undefined → command.
kind?: 'command' | 'skill';
}
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is

View File

@@ -273,6 +273,9 @@ export interface PermissionPrompt {
export interface AgentCommand {
name: string;
description?: string;
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
// Drives the icon split in the coder slash menu. Undefined → command.
kind?: 'command' | 'skill';
}
export interface CoderSendMessageBody {

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, RefObject } from 'react';
import type { CSSProperties, ReactNode, RefObject } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
@@ -11,6 +11,7 @@ export interface SlashCommandItem {
export interface SlashCommandGroup {
label: string;
items: SlashCommandItem[];
icon?: ReactNode;
}
interface Props {
@@ -50,7 +51,7 @@ export function SlashCommandPicker({
() =>
groups
? groups
.map((g) => ({ label: g.label, items: filterByPrefix(g.items, query) }))
.map((g) => ({ label: g.label, icon: g.icon, items: filterByPrefix(g.items, query) }))
.filter((g) => g.items.length > 0)
: null,
[groups, query],
@@ -203,7 +204,8 @@ export function SlashCommandPicker({
{filteredGroups
? filteredGroups.map((g) => (
<div key={g.label}>
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70 flex items-center gap-1.5">
{g.icon}
{g.label}
</div>
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}

View File

@@ -4,10 +4,11 @@
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, Check, X, RefreshCw } from 'lucide-react';
import { Code, Check, X, RefreshCw, Terminal, Puzzle, Sparkles } from 'lucide-react';
import { AgentComposerBar } from '@/components/AgentComposerBar';
import { PermissionCard } from '@/components/PermissionCard';
import { ChatInput } from '@/components/ChatInput';
import type { SlashCommandGroup } from '@/components/SlashCommandPicker';
import { api } from '@/api/client';
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
import { useSkills } from '@/hooks/useSkills';
@@ -525,12 +526,31 @@ export function CoderPane({
[skills],
);
const slashGroups = useMemo(() => {
const groups: Array<{ label: string; items: Array<{ name: string; description?: string }> }> = [];
if (agentCommands.length > 0) {
groups.push({ label: `${agentConfig.provider} commands`, items: agentCommands });
const groups: SlashCommandGroup[] = [];
// Split the active agent's set: native/CLI commands vs plugin skills, each
// with its own icon. BooCoder skills always come last.
const agentCmds = agentCommands.filter((c) => c.kind !== 'skill');
const agentSkills = agentCommands.filter((c) => c.kind === 'skill');
if (agentCmds.length > 0) {
groups.push({
label: `${agentConfig.provider} commands`,
items: agentCmds,
icon: <Terminal className="size-3 shrink-0" />,
});
}
if (agentSkills.length > 0) {
groups.push({
label: `${agentConfig.provider} skills`,
items: agentSkills,
icon: <Puzzle className="size-3 shrink-0" />,
});
}
if (skillItems.length > 0) {
groups.push({ label: 'Skills', items: skillItems });
groups.push({
label: 'BooCoder skills',
items: skillItems,
icon: <Sparkles className="size-3 shrink-0" />,
});
}
return groups;
}, [agentCommands, skillItems, agentConfig.provider]);

View File

@@ -18,8 +18,8 @@ export function parseSlashInput(text: string): { cmdName: string; args: string }
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
}
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] {
const byName = new Map<string, SlashCommandItem>();
export function mergeCommandsByName<T extends SlashCommandItem>(...lists: T[][]): T[] {
const byName = new Map<string, T>();
for (const list of lists) {
for (const cmd of list) {
byName.set(cmd.name, cmd);