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:
108
apps/coder/src/services/claude-command-discovery.ts
Normal file
108
apps/coder/src/services/claude-command-discovery.ts
Normal 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)));
|
||||
}
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)))}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user