diff --git a/CHANGELOG.md b/CHANGELOG.md index 60876a0..db784a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## 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//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: **` commands`** (Terminal), **` 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 + `/.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 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. diff --git a/apps/coder/src/services/claude-command-discovery.ts b/apps/coder/src/services/claude-command-discovery.ts new file mode 100644 index 0000000..3ac45ab --- /dev/null +++ b/apps/coder/src/services/claude-command-discovery.ts @@ -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 `/.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; + }; + const installed = JSON.parse( + readFileSync(join(root, 'plugins', 'installed_plugins.json'), 'utf8'), + ) as { plugins?: Record> }; + + 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(); + return out.filter((c) => (seen.has(c.name) ? false : (seen.add(c.name), true))); +} diff --git a/apps/coder/src/services/provider-snapshot.ts b/apps/coder/src/services/provider-snapshot.ts index 459cff1..17f52ce 100644 --- a/apps/coder/src/services/provider-snapshot.ts +++ b/apps/coder/src/services/provider-snapshot.ts @@ -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()), }; } diff --git a/apps/coder/src/services/provider-types.ts b/apps/coder/src/services/provider-types.ts index 5e73d0e..5496181 100644 --- a/apps/coder/src/services/provider-types.ts +++ b/apps/coder/src/services/provider-types.ts @@ -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 diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 2d0b983..5b71861 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -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 { diff --git a/apps/web/src/components/SlashCommandPicker.tsx b/apps/web/src/components/SlashCommandPicker.tsx index fde77b1..aa380b4 100644 --- a/apps/web/src/components/SlashCommandPicker.tsx +++ b/apps/web/src/components/SlashCommandPicker.tsx @@ -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) => (
-
+
+ {g.icon} {g.label}
{g.items.map((item) => renderItem(item, (runningIndex += 1)))} diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index 4a9415b..aaca1cb 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -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: , + }); + } + if (agentSkills.length > 0) { + groups.push({ + label: `${agentConfig.provider} skills`, + items: agentSkills, + icon: , + }); } if (skillItems.length > 0) { - groups.push({ label: 'Skills', items: skillItems }); + groups.push({ + label: 'BooCoder skills', + items: skillItems, + icon: , + }); } return groups; }, [agentCommands, skillItems, agentConfig.provider]); diff --git a/apps/web/src/lib/slash-command.ts b/apps/web/src/lib/slash-command.ts index d53ef6f..917a5b5 100644 --- a/apps/web/src/lib/slash-command.ts +++ b/apps/web/src/lib/slash-command.ts @@ -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(); +export function mergeCommandsByName(...lists: T[][]): T[] { + const byName = new Map(); for (const list of lists) { for (const cmd of list) { byName.set(cmd.name, cmd);