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

@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.5.11-claude-skill-discovery — 2026-05-29
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
## v2.5.10-opencode-live-commands — 2026-05-29 ## v2.5.10-opencode-live-commands — 2026-05-29
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred. Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.

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 { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js'; import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { isCommandAvailable } from './command-availability.js'; import { isCommandAvailable } from './command-availability.js';
import { discoverClaudeCommands } from './claude-command-discovery.js';
interface AgentRow { interface AgentRow {
name: string; name: string;
@@ -149,10 +150,12 @@ async function buildProviderEntry(
// claude: static models + thinking options, no ACP probe (unchanged from v2.2). // claude: static models + thinking options, no ACP probe (unchanged from v2.2).
if (name === 'claude') { 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 { return {
name, label, transport, status: 'ready', enabled: true, installed: true, name, label, transport, status: 'ready', enabled: true, installed: true,
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId, 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 { export interface AgentCommand {
name: string; name: string;
description?: 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 // 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 { export interface AgentCommand {
name: string; name: string;
description?: 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 { export interface CoderSendMessageBody {

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; 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 { createPortal } from 'react-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -11,6 +11,7 @@ export interface SlashCommandItem {
export interface SlashCommandGroup { export interface SlashCommandGroup {
label: string; label: string;
items: SlashCommandItem[]; items: SlashCommandItem[];
icon?: ReactNode;
} }
interface Props { interface Props {
@@ -50,7 +51,7 @@ export function SlashCommandPicker({
() => () =>
groups groups
? 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) .filter((g) => g.items.length > 0)
: null, : null,
[groups, query], [groups, query],
@@ -203,7 +204,8 @@ export function SlashCommandPicker({
{filteredGroups {filteredGroups
? filteredGroups.map((g) => ( ? filteredGroups.map((g) => (
<div key={g.label}> <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} {g.label}
</div> </div>
{g.items.map((item) => renderItem(item, (runningIndex += 1)))} {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). // WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { AgentComposerBar } from '@/components/AgentComposerBar';
import { PermissionCard } from '@/components/PermissionCard'; import { PermissionCard } from '@/components/PermissionCard';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import type { SlashCommandGroup } from '@/components/SlashCommandPicker';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types'; import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
import { useSkills } from '@/hooks/useSkills'; import { useSkills } from '@/hooks/useSkills';
@@ -525,12 +526,31 @@ export function CoderPane({
[skills], [skills],
); );
const slashGroups = useMemo(() => { const slashGroups = useMemo(() => {
const groups: Array<{ label: string; items: Array<{ name: string; description?: string }> }> = []; const groups: SlashCommandGroup[] = [];
if (agentCommands.length > 0) { // Split the active agent's set: native/CLI commands vs plugin skills, each
groups.push({ label: `${agentConfig.provider} commands`, items: agentCommands }); // 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) { 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; return groups;
}, [agentCommands, skillItems, agentConfig.provider]); }, [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() }; return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
} }
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] { export function mergeCommandsByName<T extends SlashCommandItem>(...lists: T[][]): T[] {
const byName = new Map<string, SlashCommandItem>(); const byName = new Map<string, T>();
for (const list of lists) { for (const list of lists) {
for (const cmd of list) { for (const cmd of list) {
byName.set(cmd.name, cmd); byName.set(cmd.name, cmd);