diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 09139d92c7..bfd5f5726e 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -14,6 +14,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -32,6 +33,7 @@ import { ChatHeader } from './components/chat-header' import { FreebuffActiveSessionSummary } from './components/freebuff-active-session-summary' import { LoadPreviousButton } from './components/load-previous-button' import { QueuePanel } from './components/queue-panel' +import { SkillsPanel } from './components/skills-panel' import { ReviewScreen } from './components/review-screen' import { MessageWithAgents } from './components/message-with-agents' import { areCreditsRestored } from './components/out-of-credits-banner' @@ -46,6 +48,11 @@ import { import { TopBanner } from './components/top-banner' import { useChatRuntime } from './contexts/chat-runtime-context' import { getSlashCommandsWithSkills } from './data/slash-commands' +import { + getSkillsVersion, + refreshSkillRegistry, + subscribeToSkillsVersion, +} from './utils/skill-registry' import { useAskUserBridge } from './hooks/use-ask-user-bridge' import { useChatInput } from './hooks/use-chat-input' import { @@ -69,6 +76,7 @@ import { getProjectRoot } from './project-files' import { useChatHistoryStore } from './state/chat-history-store' import { useChatStore } from './state/chat-store' import { useQueuePanelStore } from './state/queue-panel-store' +import { useSkillsPanelStore } from './state/skills-panel-store' import { useReviewStore } from './state/review-store' import { useFeedbackStore } from './state/feedback-store' import { useMessageBlockStore } from './state/message-block-store' @@ -548,8 +556,19 @@ export const Chat = ({ const setInputMode = useChatStore((state) => state.setInputMode) const askUserState = useChatStore((state) => state.askUserState) - // Get loaded skills for slash commands - const loadedSkills = useMemo(() => getLoadedSkills(), []) + // Get loaded skills for slash commands. Keyed on the registry version so a + // mid-session change (delete via the panel, edit on disk + reopen) swaps the + // list without a restart — the registry object itself is mutated in place, + // which zustand and useMemo would never notice. + const skillsVersion = useSyncExternalStore( + subscribeToSkillsVersion, + getSkillsVersion, + getSkillsVersion, + ) + const loadedSkills = useMemo(() => { + void skillsVersion + return getLoadedSkills() + }, [skillsVersion]) // Filter slash commands based on current ads state - only show the option that changes state // Hide both ads commands entirely for subscribers @@ -995,6 +1014,13 @@ export const Chat = ({ })), ) + const { skillsPanelOpen, closeSkillsPanel } = useSkillsPanelStore( + useShallow((state) => ({ + skillsPanelOpen: state.skillsPanelOpen, + closeSkillsPanel: state.closeSkillsPanel, + })), + ) + // Review and ask_user take the composer's place too. Leaving the panel // flagged open behind them would keep chat's keyboard disabled with nothing // rendered to handle keys, so hand the surface back for real. @@ -1004,11 +1030,24 @@ export const Chat = ({ } }, [queuePanelOpen, reviewMode, askUserState, closeQueuePanel]) + // Same arbitration as the queue panel: review/ask-user own the surface and + // the keyboard, so the skills panel hands them back rather than linger + // invisibly under them. + useEffect(() => { + if (skillsPanelOpen && (reviewMode || askUserState !== null)) { + closeSkillsPanel() + } + }, [skillsPanelOpen, reviewMode, askUserState, closeSkillsPanel]) + // The panel store outlives this component and a Freebuff session can end on // its own, unmounting chat mid-edit. Without this, the next session would // open onto a panel for a queue that no longer exists. useEffect(() => () => useQueuePanelStore.getState().closeQueuePanel(), []) + // A Freebuff session can end on its own, unmounting chat mid-panel; without + // this the next session would open onto a stale skills panel. + useEffect(() => () => useSkillsPanelStore.getState().closeSkillsPanel(), []) + const publishMutation = usePublishMutation() const handleCommandResult = useCallback( @@ -1051,6 +1090,10 @@ export const Chat = ({ if (queuedCount > 0) useQueuePanelStore.getState().openQueuePanel() else setMessages((prev) => [...prev, getSystemMessage('Nothing queued.')]) } + + if (result.openSkillsPanel) { + useSkillsPanelStore.getState().openSkillsPanel() + } }, [ saveCurrentInput, @@ -1220,6 +1263,32 @@ export const Chat = ({ inputRef.current?.focus() }, [closeQueuePanel, setInputFocused, inputRef]) + const handleCloseSkillsPanel = useCallback(() => { + closeSkillsPanel() + setInputFocused(true) + inputRef.current?.focus() + }, [closeSkillsPanel, setInputFocused, inputRef]) + + // Refresh the registry when the /skills panel opens, so a skill installed + // or edited moments ago shows up without restarting the CLI. + useEffect(() => { + if (!skillsPanelOpen) return + void refreshSkillRegistry() + }, [skillsPanelOpen]) + + // Invoking from the panel closes it and drops into the existing skill input + // mode — the exact path /skill: takes, so the two entries cannot + // drift. Focus returns first so the composer receives what the user types. + const handleSkillsPanelInvoke = useCallback( + (name: string) => { + closeSkillsPanel() + setInputFocused(true) + inputRef.current?.focus() + useChatStore.getState().enterSkillMode(name) + }, + [closeSkillsPanel, setInputFocused, inputRef], + ) + const handleReviewCustom = useCallback(() => { closeReviewScreen() setInputMode('review') @@ -1594,6 +1663,7 @@ export const Chat = ({ askUserState !== null || reviewMode || queuePanelOpen || + skillsPanelOpen || sponsoredProposalMenuOpen, }) @@ -1771,6 +1841,7 @@ export const Chat = ({ askUserState !== null || reviewMode || queuePanelOpen || + skillsPanelOpen || sponsoredProposalMenuOpen || isFreebuffSessionOver useEffect(() => { @@ -1955,6 +2026,14 @@ export const Chat = ({ width={separatorWidth} maxVisibleRows={isCompactHeight ? 4 : 8} /> + ) : skillsPanelOpen && !askUserState ? ( + ) : isFreebuffSessionOver && !askUserState ? ( = {}): RouterParams => + ({ + agentMode: 'DEFAULT', + inputRef: { current: null }, + inputValue: '/skills', + isChainInProgressRef: { current: false }, + isStreaming: false, + logoutMutation: {} as RouterParams['logoutMutation'], + streamMessageIdRef: { current: null }, + addToQueue: mock(() => {}), + clearMessages: mock(() => {}), + saveToHistory: mock(() => {}), + scrollToLatest: mock(() => {}), + sendMessage: mock(async () => {}), + setCanProcessQueue: mock(() => {}), + setInputFocused: mock(() => {}), + setInputValue: mock(() => {}), + setIsAuthenticated: mock(() => {}), + setMessages: mock(() => {}), + setUser: mock(() => {}), + ...overrides, + }) as RouterParams + +const resetChatStore = () => { + useChatStore.getState().setInputMode('default') + useChatStore.getState().setPendingSkillName(null) +} + +beforeEach(() => { + __setSkillsForTests({ + [PROJECT_SKILL.name]: PROJECT_SKILL, + [GLOBAL_SKILL.name]: GLOBAL_SKILL, + }) +}) + +afterEach(() => { + __resetSkillRegistryForTests() +}) + +describe('/skills command', () => { + test('opens the panel when skills are loaded', async () => { + const command = findCommand('skills') + expect(command).toBeDefined() + + const params = createMockParams() + const result = await command!.handler(params, '') + + expect(result).toMatchObject({ openSkillsPanel: true }) + expect(params.sendMessage).not.toHaveBeenCalled() + }) + + test('reports install guidance instead of opening an empty panel', async () => { + __resetSkillRegistryForTests() + + const command = findCommand('skills') + const params = createMockParams() + const result = await command!.handler(params, '') + + expect(result).toBeUndefined() + expect(params.setMessages).toHaveBeenCalledTimes(1) + const [updater] = (params.setMessages as ReturnType).mock + .calls[0] as [(prev: unknown[]) => unknown[]] + const messages = updater([]) as { role: string; content: string }[] + const last = messages[messages.length - 1] + expect(last.content).toContain('No skills loaded') + expect(last.content).toContain('npx skills add') + }) + + test('is reachable through the skill alias', () => { + expect(findCommand('skill')).toBeDefined() + }) +}) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 8609655129..e0e07cf950 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -37,7 +37,7 @@ import { AGENT_MODES, END_SESSION_MESSAGE, IS_FREEBUFF } from '../utils/constant import { exitCliCleanly } from '../utils/exit-cleanly' import { getSystemMessage, getUserMessage } from '../utils/message-history' import { capturePendingAttachments } from '../utils/pending-attachments' -import { getSkillByName } from '../utils/skill-registry' +import { getSkillByName, getSkillCount } from '../utils/skill-registry' import type { MultilineInputHandle } from '../components/multiline-input' import type { InputValue, PendingAttachment } from '../types/store' @@ -81,6 +81,7 @@ export type CommandResult = { openChatHistory?: boolean openReviewScreen?: boolean openQueuePanel?: boolean + openSkillsPanel?: boolean preSelectAgents?: string[] } | void @@ -719,6 +720,27 @@ const ALL_COMMANDS: CommandDefinition[] = [ return { openQueuePanel: true } }, }), + defineCommand({ + name: 'skills', + aliases: ['skill'], + handler: (params) => { + if (getSkillCount() === 0) { + params.setMessages((prev) => [ + ...prev, + getUserMessage(params.inputValue.trim()), + getSystemMessage( + 'No skills loaded.\n\nSkills load from:\n - ~/.claude/skills/ (global, Claude Code compatible)\n - ~/.agents/skills/ (global)\n - .claude/skills/ (project, Claude Code compatible)\n - .agents/skills/ (project, overrides global)\n\nInstall some with: npx skills add \nNew and changed skills are picked up live — no restart needed.', + ), + ]) + params.saveToHistory(params.inputValue.trim()) + clearInput(params) + return + } + params.saveToHistory(params.inputValue.trim()) + clearInput(params) + return { openSkillsPanel: true } + }, + }), defineCommand({ name: 'theme:toggle', handler: (params) => { diff --git a/cli/src/components/skills-panel.tsx b/cli/src/components/skills-panel.tsx new file mode 100644 index 0000000000..f1c5deef94 --- /dev/null +++ b/cli/src/components/skills-panel.tsx @@ -0,0 +1,340 @@ +import { spawnSync } from 'child_process' +import { existsSync } from 'fs' +import { rm } from 'fs/promises' +import path from 'path' + +import { resolveSkillsDirs } from '@codebuff/sdk' +import { useKeyboard } from '@opentui/react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' + +import { Button } from './button' +import { ClickableTitleBox } from './clickable-title-box' +import { useTheme } from '../hooks/use-theme' +import { getProjectRoot, tryGetProjectRoot } from '../project-files' +import { refreshSkillRegistry } from '../utils/skill-registry' +import { truncateToSingleLinePreview } from '../utils/agent-display' +import { clamp } from '../utils/math' +import { + resolveSkillsPanelAction, +} from '../utils/skills-panel-actions' +import { + estimateTokens, + matchesSkillQuery, + renderTokens, +} from '../utils/skills-panel-format' +import { BORDER_CHARS } from '../utils/ui-constants' + +import type { SkillDefinition } from '@codebuff/common/types/skill' +import type { KeyEvent } from '@opentui/core' + +interface SkillsPanelProps { + skills: SkillDefinition[] + /** Invoke the named skill: enters skill input mode, like /skill:. */ + onInvoke: (name: string) => void + onClose: () => void + /** Width of the surrounding chat chrome, so rows truncate on the same + * column the composer wraps on. */ + width: number + /** Rows to show before the list starts scrolling around the selection. */ + maxVisibleRows?: number +} + +const DEFAULT_MAX_VISIBLE_ROWS = 8 + +/** Keep the selected row inside the window even when the list scrolls past it. */ +function windowStart( + selectedIndex: number, + total: number, + visible: number, +): number { + if (total <= visible) return 0 + return clamp(selectedIndex - Math.floor(visible / 2), 0, total - visible) +} + +/** Which skills directory a skill loaded from, for the row badge. */ +let projectSkillsDirs: Set | null = null + +function getProjectSkillsDirs(): Set { + // Lazily computed on first render: getProjectRoot() is only meaningful + // after the CLI finishes booting, and module-load order does not guarantee + // that. One Set, resolved once — the project root cannot change mid-session. + projectSkillsDirs ??= new Set( + resolveSkillsDirs({ cwd: tryGetProjectRoot() || process.cwd() }).map( + (dir) => path.resolve(dir), + ), + ) + return projectSkillsDirs +} + +function sourceOf(skill: SkillDefinition): 'project' | 'global' { + // //SKILL.md — the grandparent of the file is the + // skills directory it was discovered in. Resolved on both sides so mixed + // separators cannot break the comparison on Windows. + const skillsDir = path.resolve( + path.dirname(path.dirname(skill.filePath)), + ) + return getProjectSkillsDirs().has(skillsDir) ? 'project' : 'global' +} + +export const SkillsPanel: React.FC = ({ + skills, + onInvoke, + onClose, + width, + maxVisibleRows = DEFAULT_MAX_VISIBLE_ROWS, +}) => { + const theme = useTheme() + + // Selection tracks the skill name, so it stays put when the list re-sorts + // or a sibling is deleted. + const [selectedName, setSelectedName] = useState( + skills[0]?.name ?? null, + ) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const [notice, setNotice] = useState(null) + const [searching, setSearching] = useState(false) + const [query, setQuery] = useState('') + const filterActive = query.length > 0 + + const filtered = useMemo( + () => skills.filter((skill) => matchesSkillQuery(skill, query)), + [skills, query], + ) + + // Selection rides through the filter by name; when the current selection + // (or the whole list) filters away, snap to the top so Enter is never dead. + const selectedIndex = Math.max( + 0, + filtered.findIndex((skill) => skill.name === selectedName), + ) + const selected = filtered[selectedIndex] + useEffect(() => { + if (!selected) setSelectedName(filtered[0]?.name ?? null) + }, [selected, filtered]) + + // Nothing left to manage: hand the composer back rather than leave an empty + // box for the user to dismiss. + useEffect(() => { + if (skills.length === 0) onClose() + }, [skills.length, onClose]) + + // A pending confirmation for a skill that just vanished (deleted out from + // under us in another terminal) can no longer land on anything. + useEffect(() => { + if (confirmingDelete && !selected) setConfirmingDelete(false) + }, [confirmingDelete, selected]) + + const deleteSelected = useCallback(async () => { + if (!selected) return + // Claude Code semantics: removing a skill removes its DIRECTORY, not just + // SKILL.md — a skill can carry supporting files (reference docs, scripts) + // that would otherwise be orphaned. + const skillDir = path.dirname(selected.filePath) + if (!existsSync(skillDir)) { + setNotice(`Directory not found: ${skillDir}`) + return + } + try { + await rm(skillDir, { recursive: true }) + // Refresh the registry right away: the version bump re-renders the + // panel with the refreshed list instead of waiting for the + // refresh-on-open. + void refreshSkillRegistry() + // Dropping the row moves the cursor to whatever fills the vacancy. + const successor = filtered[selectedIndex + 1] ?? filtered[selectedIndex - 1] + setSelectedName(successor?.name ?? null) + setNotice(`Deleted ${skillDir}`) + } catch (error) { + setNotice( + `Could not delete: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }, [selected, filtered, selectedIndex]) + + const openInEditor = useCallback(() => { + if (!selected) return + const editor = + process.env.VISUAL ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'vi') + // Inherit stdio so the editor owns the terminal, matching how /bash runs + // interactive commands. + const result = spawnSync(editor, [selected.filePath], { stdio: 'inherit' }) + if (result.error || result.status !== 0) { + setNotice(`Could not open $EDITOR (${editor}). Set EDITOR and try again.`) + } + }, [selected]) + + const handleKey = useCallback( + (key: KeyEvent) => { + const action = resolveSkillsPanelAction(key, { + confirmingDelete, + searching, + filterActive, + }) + if (action.type === 'none') return + // Any deliberate action supersedes the last complaint. + if (action.type !== 'confirm') setNotice(null) + + switch (action.type) { + case 'close': + onClose() + return + case 'cancel': + setConfirmingDelete(false) + return + case 'search-start': + setSearching(true) + return + case 'search-exit': + setSearching(false) + return + case 'search-clear': + setQuery('') + return + case 'search-input': + setQuery((prev) => prev + action.char) + return + case 'search-backspace': + if (query.length > 0) setQuery(query.slice(0, -1)) + // Backspace on an empty query leaves search mode (common editor + // convention) instead of stranding the user in an empty search. + else setSearching(false) + return + case 'select': { + const to = clamp(selectedIndex + action.delta, 0, filtered.length - 1) + setSelectedName(filtered[to]?.name ?? null) + return + } + case 'invoke': + if (selected) onInvoke(selected.name) + return + case 'open': + openInEditor() + return + case 'delete': + if (selected) setConfirmingDelete(true) + return + case 'confirm': + setConfirmingDelete(false) + void deleteSelected() + return + } + }, + [ + confirmingDelete, + deleteSelected, + filtered, + filterActive, + onClose, + onInvoke, + openInEditor, + query, + searching, + selected, + selectedIndex, + ], + ) + + useKeyboard(handleKey) + + // A row must fit one line or it wraps and the list stops being scannable. + // Budget: two border columns, two padding columns, then "❯ " + badge + gap + // + right-aligned token estimate, so descriptions truncate where the + // composer would. + const promptWidth = Math.max(10, width - 22) + const rowLabel = useCallback( + (skill: SkillDefinition) => { + const badge = sourceOf(skill) === 'project' ? 'project' : 'global' + const tokens = renderTokens(estimateTokens(skill)) + const suffix = ` ${tokens}` + const body = + truncateToSingleLinePreview(skill.description, promptWidth - suffix.length) ?? '' + return `${badge.padEnd(7)} ${body.padEnd(Math.max(0, promptWidth - suffix.length))}${suffix}` + }, + [promptWidth], + ) + + const projectCount = useMemo( + () => skills.filter((skill) => sourceOf(skill) === 'project').length, + [skills], + ) + const globalCount = skills.length - projectCount + const hiddenBySearch = skills.length - filtered.length + + const start = windowStart(selectedIndex, filtered.length, maxVisibleRows) + const visible = filtered.slice(start, start + maxVisibleRows) + const hiddenBelow = filtered.length - (start + visible.length) + + return ( + + {start > 0 && {` ↑ ${start} more`}} + + {visible.map((skill, offset) => { + const index = start + offset + const isSelected = index === selectedIndex + + return ( + + ) + })} + + {hiddenBelow > 0 && ( + {` ↓ ${hiddenBelow} more`} + )} + + {hiddenBySearch > 0 && ( + + {`${hiddenBySearch} hidden by filter`} + + )} + + {notice && {notice}} + + {searching ? ( + + {`/${query}▏ type to filter · esc done`} + + ) : confirmingDelete ? ( + + {`Delete ${selected && path.dirname(selected.filePath)}/? Enter confirm · Esc cancel`} + + ) : ( + + {filterActive + ? '/ filter · esc clears filter · esc again closes' + : '/ filter · Enter run · o open · d delete · esc close'} + + )} + + ) +} diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index c9a1321c11..2470b962db 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -121,6 +121,12 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [ description: 'Edit, reorder, or delete the messages waiting to be sent', aliases: ['queued'], }, + { + id: 'skills', + label: 'skills', + description: 'Browse, run, open, or delete your loaded skills', + aliases: ['skill'], + }, { id: 'new', label: 'new', @@ -247,14 +253,20 @@ function truncateDescription(description: string): string { /** * Returns SLASH_COMMANDS merged with skill commands. * Skills become slash commands that users can invoke directly. + * + * Skills marked `user-invocable: false` (Claude Code frontmatter parity) are + * model-only knowledge: they are hidden from the / menu, the same way Claude + * Code hides them from its / menu. */ export function getSlashCommandsWithSkills(skills: SkillsMap): SlashCommand[] { - const skillCommands: SlashCommand[] = Object.values(skills).map((skill) => ({ - id: `skill:${skill.name}`, - label: `skill:${skill.name}`, - description: truncateDescription(skill.description), - insertText: `/skill:${skill.name} `, - })) + const skillCommands: SlashCommand[] = Object.values(skills) + .filter((skill) => skill.userInvocable !== false) + .map((skill) => ({ + id: `skill:${skill.name}`, + label: `skill:${skill.name}`, + description: truncateDescription(skill.description), + insertText: `/skill:${skill.name} `, + })) const commands = [...SLASH_COMMANDS, ...skillCommands] diff --git a/cli/src/state/skills-panel-store.ts b/cli/src/state/skills-panel-store.ts new file mode 100644 index 0000000000..da9a3e78b6 --- /dev/null +++ b/cli/src/state/skills-panel-store.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' + +interface SkillsPanelState { + /** The skills panel takes the composer's place while open, the way the + * queue panel and review screen do — the skill list is what the user is + * "typing about". */ + skillsPanelOpen: boolean + openSkillsPanel: () => void + closeSkillsPanel: () => void +} + +export const useSkillsPanelStore = create()( + immer((set) => ({ + skillsPanelOpen: false, + openSkillsPanel: () => { + set((state) => { + state.skillsPanelOpen = true + }) + }, + closeSkillsPanel: () => { + set((state) => { + state.skillsPanelOpen = false + }) + }, + })), +) diff --git a/cli/src/utils/__tests__/skill-registry.test.ts b/cli/src/utils/__tests__/skill-registry.test.ts new file mode 100644 index 0000000000..4c438ec39a --- /dev/null +++ b/cli/src/utils/__tests__/skill-registry.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { setProjectRoot } from '../../project-files' +import { + __resetSkillRegistryForTests, + getLoadedSkills, + getSkillsVersion, + initializeSkillRegistry, + refreshSkillRegistry, + subscribeToSkillsVersion, +} from '../skill-registry' + +let tmpRoot: string +let oldHome: string | undefined +let oldUserProfile: string | undefined + +const writeSkill = ( + kind: 'project' | 'global', + dirName: string, + frontmatter: Record, +) => { + const base = + kind === 'project' ? path.join(tmpRoot, '.agents', 'skills') : path.join(os.homedir(), '.claude', 'skills') + const skillDir = path.join(base, dirName) + fs.mkdirSync(skillDir, { recursive: true }) + const lines = Object.entries(frontmatter).map( + ([key, value]) => `${key}: ${value}`, + ) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `---\n${lines.join('\n')}\n---\nBody of ${dirName}.\n`, + ) + return skillDir +} + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-registry-')) + setProjectRoot(tmpRoot) + // Hermetic home: includeHomeSkills makes the SDK load real ~/.claude/skills + // too, so on a machine with skills installed for Claude Code (any + // maintainer's, in practice) the counts below would include those. Redirect + // the home directory so tests only see what they write themselves. + oldHome = process.env.HOME + oldUserProfile = process.env.USERPROFILE + process.env.HOME = tmpRoot + process.env.USERPROFILE = tmpRoot +}) + +afterEach(() => { + __resetSkillRegistryForTests() + if (oldHome !== undefined) process.env.HOME = oldHome + else delete process.env.HOME + if (oldUserProfile !== undefined) process.env.USERPROFILE = oldUserProfile + else delete process.env.USERPROFILE + fs.rmSync(tmpRoot, { recursive: true, force: true }) +}) + +describe('skill-registry refresh', () => { + test('initializeSkillRegistry loads project skills', async () => { + writeSkill('project', 'deploy', { + name: 'deploy', + description: 'Deploy the app', + }) + + await initializeSkillRegistry() + + expect(getLoadedSkills()['deploy']).toMatchObject({ + name: 'deploy', + description: 'Deploy the app', + }) + }) + + test('refreshSkillRegistry detects new, edited, and deleted skills', async () => { + const skillDir = writeSkill('project', 'deploy', { + name: 'deploy', + description: 'Deploy the app', + }) + await initializeSkillRegistry() + expect(getSkillCountForTest()).toBe(1) + + // New skill appears. + writeSkill('project', 'review', { + name: 'review', + description: 'Review changes', + }) + await expect(refreshSkillRegistry()).resolves.toBe(true) + expect(getSkillCountForTest()).toBe(2) + + // Content edit is detected (same name, new description). + writeSkill('project', 'deploy', { + name: 'deploy', + description: 'Deploy the app to production', + }) + await expect(refreshSkillRegistry()).resolves.toBe(true) + expect(getLoadedSkills()['deploy'].description).toBe( + 'Deploy the app to production', + ) + + // Deleting the whole directory removes the skill. + fs.rmSync(skillDir, { recursive: true }) + await expect(refreshSkillRegistry()).resolves.toBe(true) + expect(getSkillCountForTest()).toBe(1) + expect(getLoadedSkills()['deploy']).toBeUndefined() + + // Nothing changed: no new version. + const before = getSkillsVersion() + await expect(refreshSkillRegistry()).resolves.toBe(false) + expect(getSkillsVersion()).toBe(before) + }) + + test('version subscribers are notified on change', async () => { + writeSkill('project', 'deploy', { + name: 'deploy', + description: 'Deploy the app', + }) + await initializeSkillRegistry() + + const onChange = mock(() => {}) + const unsubscribe = subscribeToSkillsVersion(onChange) + const before = getSkillsVersion() + + writeSkill('project', 'review', { + name: 'review', + description: 'Review changes', + }) + await refreshSkillRegistry() + + expect(getSkillsVersion()).toBe(before + 1) + expect(onChange).toHaveBeenCalled() + + unsubscribe() + await refreshSkillRegistry() + expect(onChange).toHaveBeenCalledTimes(1) + }) +}) + +function getSkillCountForTest(): number { + return Object.keys(getLoadedSkills()).length +} diff --git a/cli/src/utils/__tests__/skills-panel-actions.test.ts b/cli/src/utils/__tests__/skills-panel-actions.test.ts new file mode 100644 index 0000000000..87d12ad480 --- /dev/null +++ b/cli/src/utils/__tests__/skills-panel-actions.test.ts @@ -0,0 +1,228 @@ +import { describe, test, expect } from 'bun:test' + +import { resolveSkillsPanelAction } from '../skills-panel-actions' + +import type { KeyEvent } from '@opentui/core' + +const createKey = (overrides: Partial = {}): KeyEvent => + ({ + name: '', + sequence: '', + ctrl: false, + meta: false, + shift: false, + option: false, + ...overrides, + }) as KeyEvent + +const browsing = { + confirmingDelete: false, + searching: false, + filterActive: false, +} +const confirming = { + confirmingDelete: true, + searching: false, + filterActive: false, +} +const searching = { + confirmingDelete: false, + searching: true, + filterActive: false, +} +const filtered = { + confirmingDelete: false, + searching: false, + filterActive: true, +} + +describe('resolveSkillsPanelAction', () => { + test('arrows and j/k move the selection', () => { + expect( + resolveSkillsPanelAction(createKey({ name: 'up' }), browsing), + ).toEqual({ type: 'select', delta: -1 }) + expect( + resolveSkillsPanelAction(createKey({ name: 'down' }), browsing), + ).toEqual({ type: 'select', delta: 1 }) + expect(resolveSkillsPanelAction(createKey({ name: 'k' }), browsing)).toEqual( + { type: 'select', delta: -1 }, + ) + expect(resolveSkillsPanelAction(createKey({ name: 'j' }), browsing)).toEqual( + { type: 'select', delta: 1 }, + ) + }) + + test('enter invokes, o opens, d deletes', () => { + expect( + resolveSkillsPanelAction(createKey({ name: 'return' }), browsing), + ).toEqual({ type: 'invoke' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'enter' }), browsing), + ).toEqual({ type: 'invoke' }) + expect(resolveSkillsPanelAction(createKey({ name: 'o' }), browsing)).toEqual( + { type: 'open' }, + ) + expect(resolveSkillsPanelAction(createKey({ name: 'd' }), browsing)).toEqual( + { type: 'delete' }, + ) + expect( + resolveSkillsPanelAction(createKey({ name: 'delete' }), browsing), + ).toEqual({ type: 'delete' }) + }) + + test('escape, ctrl+c, and q close while browsing', () => { + expect( + resolveSkillsPanelAction(createKey({ name: 'escape' }), browsing), + ).toEqual({ type: 'close' }) + expect( + resolveSkillsPanelAction( + createKey({ name: 'c', ctrl: true }), + browsing, + ), + ).toEqual({ type: 'close' }) + expect(resolveSkillsPanelAction(createKey({ name: 'q' }), browsing)).toEqual( + { type: 'close' }, + ) + }) + + test('a pending delete swallows stray keys so held keys cannot chain-delete', () => { + // The next row's `d`, a printable key, even enter-adjacent modifiers: + // nothing but confirm/cancel may act while the prompt is up. + expect( + resolveSkillsPanelAction(createKey({ name: 'd' }), confirming), + ).toEqual({ type: 'none' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'j' }), confirming), + ).toEqual({ type: 'none' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'o' }), confirming), + ).toEqual({ type: 'none' }) + }) + + test('a pending delete confirms on enter and cancels on escape/n/q', () => { + expect( + resolveSkillsPanelAction(createKey({ name: 'return' }), confirming), + ).toEqual({ type: 'confirm' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'enter' }), confirming), + ).toEqual({ type: 'confirm' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'escape' }), confirming), + ).toEqual({ type: 'cancel' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'n' }), confirming), + ).toEqual({ type: 'cancel' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'q' }), confirming), + ).toEqual({ type: 'cancel' }) + }) + + test('/ enters search mode', () => { + expect( + resolveSkillsPanelAction(createKey({ name: '/', sequence: '/' }), browsing), + ).toEqual({ type: 'search-start' }) + // Only a bare `/` — ctrl/meta variants stay unbound so a chord can never + // drop the user into a typing mode they did not ask for. + expect( + resolveSkillsPanelAction(createKey({ name: '/', sequence: '/', ctrl: true }), browsing), + ).toEqual({ type: 'none' }) + }) + + test('search mode: printable keys become query edits, not shortcuts', () => { + // `d` and `o` would delete/open while browsing; while searching they are + // just letters in the query. + expect( + resolveSkillsPanelAction(createKey({ name: 'd', sequence: 'd' }), searching), + ).toEqual({ type: 'search-input', char: 'd' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'o', sequence: 'o' }), searching), + ).toEqual({ type: 'search-input', char: 'o' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'q', sequence: 'q' }), searching), + ).toEqual({ type: 'search-input', char: 'q' }) + expect( + resolveSkillsPanelAction( + createKey({ name: 'space', sequence: ' ' }), + searching, + ), + ).toEqual({ type: 'search-input', char: ' ' }) + }) + + test('search mode: backspace deletes, escape exits, ctrl+c still closes', () => { + expect( + resolveSkillsPanelAction(createKey({ name: 'backspace' }), searching), + ).toEqual({ type: 'search-backspace' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'escape' }), searching), + ).toEqual({ type: 'search-exit' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'c', ctrl: true }), searching), + ).toEqual({ type: 'close' }) + }) + + test('search mode: navigation and invoke still work', () => { + // Named keys (arrows, enter) still navigate/invoke while typing. + expect( + resolveSkillsPanelAction(createKey({ name: 'up' }), searching), + ).toEqual({ type: 'select', delta: -1 }) + expect( + resolveSkillsPanelAction(createKey({ name: 'down' }), searching), + ).toEqual({ type: 'select', delta: 1 }) + expect( + resolveSkillsPanelAction(createKey({ name: 'return' }), searching), + ).toEqual({ type: 'invoke' }) + // But the letter forms of those shortcuts are query edits here — `j` + // types a j, it does not move the cursor. + expect( + resolveSkillsPanelAction(createKey({ name: 'j', sequence: 'j' }), searching), + ).toEqual({ type: 'search-input', char: 'j' }) + }) + + test('search mode: delete confirmation still outranks typing', () => { + // A stray `d` while a delete is pending must never edit a query — the + // confirm prompt only answers to enter/confirm or cancel. + expect( + resolveSkillsPanelAction( + createKey({ name: 'd', sequence: 'd' }), + { confirmingDelete: true, searching: true, filterActive: true }, + ), + ).toEqual({ type: 'none' }) + expect( + resolveSkillsPanelAction( + createKey({ name: 'return' }), + { confirmingDelete: true, searching: true, filterActive: true }, + ), + ).toEqual({ type: 'confirm' }) + }) + + test('escape unwinds one layer at a time: search, then filter, then close', () => { + // Browsing with an active filter: escape clears the filter, not the panel. + expect( + resolveSkillsPanelAction(createKey({ name: 'escape' }), filtered), + ).toEqual({ type: 'search-clear' }) + // With the filter gone, the same key closes. + expect( + resolveSkillsPanelAction(createKey({ name: 'escape' }), browsing), + ).toEqual({ type: 'close' }) + // And from inside search mode it only leaves the search. + expect( + resolveSkillsPanelAction(createKey({ name: 'escape' }), searching), + ).toEqual({ type: 'search-exit' }) + // q and ctrl+c still close even while a filter is active. + expect( + resolveSkillsPanelAction(createKey({ name: 'q' }), filtered), + ).toEqual({ type: 'close' }) + expect( + resolveSkillsPanelAction(createKey({ name: 'c', ctrl: true }), filtered), + ).toEqual({ type: 'close' }) + }) + + test('unbound keys do nothing', () => { + expect(resolveSkillsPanelAction(createKey({ name: 'x' }), browsing)).toEqual( + { type: 'none' }, + ) + expect( + resolveSkillsPanelAction(createKey({ name: 'tab' }), browsing), + ).toEqual({ type: 'none' }) + }) +}) diff --git a/cli/src/utils/__tests__/skills-panel-format.test.ts b/cli/src/utils/__tests__/skills-panel-format.test.ts new file mode 100644 index 0000000000..648dfd34b0 --- /dev/null +++ b/cli/src/utils/__tests__/skills-panel-format.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' + +import { + estimateTokens, + matchesSkillQuery, + renderTokens, +} from '../skills-panel-format' + +import type { SkillDefinition } from '@codebuff/common/types/skill' + +const skill = (overrides: Partial = {}): SkillDefinition => ({ + name: 'release-notes', + description: 'Draft release notes from recent commits', + content: 'x'.repeat(400), + filePath: '/project/.agents/skills/release-notes/SKILL.md', + ...overrides, +}) + +describe('estimateTokens', () => { + test('scales with content length at ~4 chars per token', () => { + // Both cases pass an empty description so content dominates the math. + expect( + estimateTokens(skill({ content: 'x'.repeat(400), description: '' })), + ).toBe(100) + expect( + estimateTokens(skill({ content: 'x'.repeat(4000), description: '' })), + ).toBe(1000) + }) + + test('counts the description and never returns zero', () => { + // 12 chars of description + empty content still rounds to 3. + expect(estimateTokens(skill({ content: '', description: '123456789012' }))).toBe( + 3, + ) + expect(estimateTokens(skill({ content: '', description: '' }))).toBe(1) + }) +}) + +describe('renderTokens', () => { + test('plain numbers below 1000, k notation above', () => { + expect(renderTokens(70)).toBe('70 tok') + expect(renderTokens(999)).toBe('999 tok') + expect(renderTokens(1000)).toBe('1k tok') + expect(renderTokens(2800)).toBe('2.8k tok') + }) +}) + +describe('matchesSkillQuery', () => { + const ponytail = skill({ + name: 'ponytail', + description: 'Forces the laziest solution that actually works', + }) + + test('empty or whitespace queries match everything', () => { + expect(matchesSkillQuery(ponytail, '')).toBe(true) + expect(matchesSkillQuery(ponytail, ' ')).toBe(true) + }) + + test('matches by name or description, case-insensitively', () => { + expect(matchesSkillQuery(ponytail, 'pony')).toBe(true) + expect(matchesSkillQuery(ponytail, 'PONY')).toBe(true) + expect(matchesSkillQuery(ponytail, 'laziest')).toBe(true) + expect(matchesSkillQuery(ponytail, 'git-helper')).toBe(false) + }) + + test('trims the query before matching', () => { + expect(matchesSkillQuery(ponytail, ' pony ')).toBe(true) + expect(matchesSkillQuery(ponytail, ' zzz ')).toBe(false) + }) +}) diff --git a/cli/src/utils/skill-registry.ts b/cli/src/utils/skill-registry.ts index 79942f4e99..c2080e78ba 100644 --- a/cli/src/utils/skill-registry.ts +++ b/cli/src/utils/skill-registry.ts @@ -1,6 +1,8 @@ +import os from 'os' + import { loadSkills as sdkLoadSkills } from '@codebuff/sdk' -import { getProjectRoot } from '../project-files' +import { getProjectRoot, tryGetProjectRoot } from '../project-files' import { logger } from './logger' import type { SkillDefinition, SkillsMap } from '@codebuff/common/types/skill' @@ -11,6 +13,91 @@ import type { SkillDefinition, SkillsMap } from '@codebuff/common/types/skill' let skillsCache: SkillsMap = {} +/** + * Bumped whenever a refresh changes the set of loaded skills. React surfaces + * (the /skills panel, the slash-command merge) subscribe to this number + * instead of to the mutable cache itself, which zustand would never see. + */ +let skillsVersion = 0 + +export function getSkillsVersion(): number { + return skillsVersion +} + +/** + * Subscribe to registry version changes. Written for React's + * `useSyncExternalStore`, which needs a stable function that returns an + * unsubscribe. + */ +export function subscribeToSkillsVersion(onChange: () => void): () => void { + versionSubscribers.add(onChange) + return () => { + versionSubscribers.delete(onChange) + } +} + +const versionSubscribers = new Set<() => void>() + +/** The working directory skills should be resolved against. */ +function skillsCwd(): string { + return tryGetProjectRoot() || process.cwd() +} + +/** + * True when the two maps describe the same skill set: same names, same + * invocability, same content. Metadata-only edits still count as a change so + * the /skills panel shows fresh descriptions after the user edits SKILL.md. + */ +function skillsEqual(a: SkillsMap, b: SkillsMap): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) return false + return aKeys.every((key) => { + const x = a[key] + const y = b[key] + if (!y) return false + return ( + x.content === y.content && + x.disableModelInvocation === y.disableModelInvocation && + x.userInvocable === y.userInvocable && + x.description === y.description + ) + }) +} + +/** + * Re-load skills from disk and swap the cache only when something actually + * changed. Every caller funnels through this function so there is exactly one + * notion of "the skills changed" — the version bump — for the UI to key on. + * + * Returns true when the skill set changed. + */ +export async function refreshSkillRegistry(): Promise { + const cwd = skillsCwd() + try { + const fresh = await sdkLoadSkills({ + cwd, + verbose: false, + includeHomeSkills: true, + }) + if (skillsEqual(fresh, skillsCache)) return false + skillsCache = fresh + skillsVersion += 1 + for (const notify of versionSubscribers) notify() + return true + } catch (error) { + logger.warn({ error }, 'Failed to refresh skills') + return false + } +} + +// ============================================================================ +// Live reload +// ============================================================================ +// Implemented on feat/skills-reload — deliberately not here. Watching skill +// directories is an independently reviewable feature (recursive watch +// semantics differ per platform) and lives in its own PR. + /** * Initialize the skill registry by loading skills via the SDK. * This must be called at CLI startup. @@ -97,11 +184,16 @@ export function getLoadedSkillsMessage(): string | null { */ export function __resetSkillRegistryForTests(): void { skillsCache = {} + skillsVersion = 0 } /** * Seed the cache without touching the filesystem. Intended for test scenarios. + * Bumps the version and notifies subscribers exactly like a real refresh, so + * React surfaces keyed on the version see the seeded set. */ export function __setSkillsForTests(skills: SkillsMap): void { skillsCache = skills + skillsVersion += 1 + for (const notify of versionSubscribers) notify() } diff --git a/cli/src/utils/skills-panel-actions.ts b/cli/src/utils/skills-panel-actions.ts new file mode 100644 index 0000000000..9ce59451c5 --- /dev/null +++ b/cli/src/utils/skills-panel-actions.ts @@ -0,0 +1,105 @@ +import type { KeyEvent } from '@opentui/core' + +/** + * What a keypress means inside the skills panel. Kept separate from the + * component so the shortcut table is testable without a renderer, matching + * how the queue panel's shortcuts are resolved. + */ +export type SkillsPanelAction = + | { type: 'close' } + /** Move the cursor by `delta` rows. */ + | { type: 'select'; delta: number } + /** Invoke the selected skill (enters skill input mode). */ + | { type: 'invoke' } + /** Open the selected skill's SKILL.md in $EDITOR. */ + | { type: 'open' } + /** Delete the selected skill's SKILL.md (with confirmation). */ + | { type: 'delete' } + | { type: 'confirm' } + | { type: 'cancel' } + /** Enter search mode: keystrokes filter the list instead of triggering actions. */ + | { type: 'search-start' } + /** Append `char` to the search query (search mode only). */ + | { type: 'search-input'; char: string } + /** Remove the last character of the search query (search mode only). */ + | { type: 'search-backspace' } + /** Leave search mode, keeping the current filter (search mode only). */ + | { type: 'search-exit' } + /** Clear the active filter (browsing with a non-empty query only). */ + | { type: 'search-clear' } + | { type: 'none' } + +export type SkillsPanelKeyboardState = { + /** While a delete is pending confirmation the panel only listens for + * confirm/cancel — a stray `d` must not chain-delete the next row. */ + confirmingDelete: boolean + /** While search mode is active, printable keys edit the query instead of + * firing single-letter shortcuts like o/d/j/k. */ + searching: boolean + /** True while a non-empty query filters the list: escape then clears the + * filter instead of closing the panel (press it again to close). */ + filterActive: boolean +} + +/** A printable character keypress (no ctrl/meta modifiers). */ +function printableChar(key: KeyEvent): string | null { + if (key.ctrl || key.meta || key.option) return null + if (key.sequence && key.sequence.length === 1 && key.sequence >= ' ') + return key.sequence + return null +} + +export function resolveSkillsPanelAction( + key: KeyEvent, + state: SkillsPanelKeyboardState, +): SkillsPanelAction { + const isEscape = key.name === 'escape' + const isCtrlC = key.ctrl && key.name === 'c' + + if (state.confirmingDelete) { + // Enter confirms; anything escape-shaped cancels; everything else is + // swallowed so a held key can't confirm or chain. Delete confirmation + // outranks search mode: it must never be dismissible by a query edit. + if (key.name === 'return' || key.name === 'enter') return { type: 'confirm' } + if (isEscape || isCtrlC || key.name === 'n' || key.name === 'q') + return { type: 'cancel' } + return { type: 'none' } + } + + // Escape unwinds one layer at a time: search mode → filter → close. + // (Ctrl+C always closes; `q` closes, mirroring the queue panel.) + if (isEscape && state.searching) return { type: 'search-exit' } + if (isEscape && state.filterActive) return { type: 'search-clear' } + if (isEscape || isCtrlC) return { type: 'close' } + if (key.name === 'q' && !state.searching) return { type: 'close' } + + if (state.searching) { + if (key.name === 'backspace') + return { type: 'search-backspace' } + // Arrows and enter keep working while typing — but only their named + // forms: letters (j/k/q/d/o) are query edits in search mode. + if (key.name === 'up') return { type: 'select', delta: -1 } + if (key.name === 'down') return { type: 'select', delta: 1 } + if (key.name === 'return' || key.name === 'enter') return { type: 'invoke' } + const char = printableChar(key) + if (char) return { type: 'search-input', char } + // Anything else (modifiers, chords, unnamed keys) does nothing rather + // than firing a single-letter shortcut underneath the typing. + return { type: 'none' } + } + + if (key.name === 'up' || key.name === 'k') return { type: 'select', delta: -1 } + if (key.name === 'down' || key.name === 'j') + return { type: 'select', delta: 1 } + + // Enter invokes; `o` opens the file in $EDITOR. + if (key.name === 'return' || key.name === 'enter') return { type: 'invoke' } + if (key.name === 'o') return { type: 'open' } + + if (key.name === 'd' || key.name === 'delete') return { type: 'delete' } + + // `/` enters search mode (matches the queue panel's slash-filter affordance). + if (printableChar(key) === '/') return { type: 'search-start' } + + return { type: 'none' } +} diff --git a/cli/src/utils/skills-panel-format.ts b/cli/src/utils/skills-panel-format.ts new file mode 100644 index 0000000000..9291203b8c --- /dev/null +++ b/cli/src/utils/skills-panel-format.ts @@ -0,0 +1,35 @@ +import type { SkillDefinition } from '@codebuff/common/types/skill' + +/** + * Panel display helpers, kept renderer-free so they are unit-testable — + * the same split as skills-panel-actions.ts. + */ + +/** + * Rough context cost of a skill, in tokens (~4 chars/token). Matches the + * order of magnitude of Claude Code's per-row estimates; the point is the + * relative weight, not an exact count. + */ +export function estimateTokens(skill: SkillDefinition): number { + const chars = skill.content.length + skill.description.length + return Math.max(1, Math.round(chars / 4)) +} + +/** Compact right-aligned token readout: 1–3 digits, then `k` past 999. */ +export function renderTokens(tokens: number): string { + const short = tokens > 999 ? `${Math.round(tokens / 100) / 10}k` : `${tokens}` + return `${short} tok` +} + +/** + * Case-insensitive substring match against name + description. Shared by the + * panel and its tests so the filter semantics have exactly one definition. + */ +export function matchesSkillQuery(skill: SkillDefinition, query: string): boolean { + const q = query.trim().toLowerCase() + if (!q) return true + return ( + skill.name.toLowerCase().includes(q) || + skill.description.toLowerCase().includes(q) + ) +} diff --git a/common/src/types/skill.ts b/common/src/types/skill.ts index 17c0082ea5..180f0f4137 100644 --- a/common/src/types/skill.ts +++ b/common/src/types/skill.ts @@ -36,6 +36,22 @@ export const SkillFrontmatterSchema = z.object({ .transform((d) => d.slice(0, SKILL_DESCRIPTION_MAX_LENGTH)), license: z.string().optional(), 'disable-model-invocation': z.boolean().optional(), + // Claude Code compatibility: `user-invocable: false` marks a skill the + // model may invoke but the user cannot (hidden from / menu). Absent = + // invocable by both, matching Claude Code's default. + 'user-invocable': z.boolean().optional(), + // Claude Code compatibility: hint shown during autocomplete, e.g. + // "[issue-number]". Freebuff does not substitute arguments yet, but the + // field is accepted so a Claude Code skill does not fail validation. + // Claude Code's own docs write the value unquoted, which YAML parses as a + // single-element array — coerce that back to the display string. + 'argument-hint': z + .union([z.string(), z.array(z.string())]) + .optional() + .transform((v) => (Array.isArray(v) ? v.join(' ') : v)), + // Claude Code compatibility: extra trigger context appended to the + // description in listings. Accepted and surfaced; never rejected. + 'when_to_use': z.string().optional(), metadata: SkillMetadataSchema.optional(), }) @@ -53,6 +69,12 @@ export const SkillDefinitionSchema = z.object({ license: z.string().optional(), /** Whether only the user may invoke this skill. */ disableModelInvocation: z.boolean().optional(), + /** Whether only the model may invoke this skill (hidden from the / menu). */ + userInvocable: z.boolean().optional(), + /** Autocomplete hint for expected arguments, e.g. "[issue-number]". */ + argumentHint: z.string().optional(), + /** Extra trigger context appended to the description in listings. */ + whenToUse: z.string().optional(), /** Optional key-value metadata */ metadata: SkillMetadataSchema.optional(), /** Full SKILL.md content (including frontmatter) */ @@ -74,6 +96,9 @@ export function createSkillDefinition(params: { description: frontmatter.description, license: frontmatter.license, disableModelInvocation: frontmatter['disable-model-invocation'], + userInvocable: frontmatter['user-invocable'], + argumentHint: frontmatter['argument-hint'], + whenToUse: frontmatter['when_to_use'], metadata: frontmatter.metadata, content, filePath, diff --git a/common/src/util/parse-skill.test.ts b/common/src/util/parse-skill.test.ts new file mode 100644 index 0000000000..ecc236f978 --- /dev/null +++ b/common/src/util/parse-skill.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test' + +import { parseSkillFileContent } from './parse-skill' + +describe('parseSkillFileContent', () => { + const baseOptions = { + directoryName: 'deploy', + filePath: '/skills/deploy/SKILL.md', + } + + test('parses name, description, and license', () => { + const skill = parseSkillFileContent( + [ + '---', + 'name: deploy', + 'description: Deploy the application', + 'license: MIT', + '---', + 'Run the deploy script.', + ].join('\n'), + baseOptions, + ) + + expect(skill).toMatchObject({ + name: 'deploy', + description: 'Deploy the application', + license: 'MIT', + filePath: '/skills/deploy/SKILL.md', + }) + expect(skill?.content).toContain('Run the deploy script.') + }) + + test('accepts Claude Code frontmatter fields', () => { + const skill = parseSkillFileContent( + [ + '---', + 'name: deploy', + 'description: Deploy the application', + 'user-invocable: false', + // Claude Code's docs write this unquoted: YAML parses it as a flow + // sequence, which we join back to the display string — brackets are + // lost to YAML either way, in Claude Code just as here. + 'argument-hint: [environment]', + 'when_to_use: When the user asks to ship or release', + 'disable-model-invocation: true', + '---', + 'Deploy steps.', + ].join('\n'), + baseOptions, + ) + + expect(skill).not.toBeNull() + expect(skill?.userInvocable).toBe(false) + expect(skill?.argumentHint).toBe('environment') + expect(skill?.whenToUse).toBe( + 'When the user asks to ship or release', + ) + expect(skill?.disableModelInvocation).toBe(true) + }) + + test('rejects a name that does not match the directory', () => { + const skill = parseSkillFileContent( + '---\nname: other\ndescription: Mismatched\n---\nbody', + baseOptions, + ) + + expect(skill).toBeNull() + }) + + test('returns null when there is no frontmatter', () => { + expect(parseSkillFileContent('Just some instructions.', baseOptions)).toBe( + null, + ) + }) + + test('returns null when frontmatter is invalid per the schema', () => { + // uppercase name violates SKILL_NAME_REGEX + const skill = parseSkillFileContent( + '---\nname: Deploy\ndescription: Bad name casing\n---\nbody', + baseOptions, + ) + + expect(skill).toBeNull() + }) +}) diff --git a/common/src/util/skills.test.ts b/common/src/util/skills.test.ts index deb31a3aeb..545cfca99e 100644 --- a/common/src/util/skills.test.ts +++ b/common/src/util/skills.test.ts @@ -28,6 +28,40 @@ describe('formatAvailableSkillsXml', () => { expect(xml).not.toContain('deploy') }) + test('keeps model-only skills in the model listing (user-invocable: false)', () => { + const skills: SkillsMap = { + legacy: { + name: 'legacy', + description: 'How the legacy system works', + content: 'legacy system context', + userInvocable: false, + filePath: '/skills/legacy/SKILL.md', + }, + } + + // Model-only means "hidden from the user", not "hidden from the model" — + // and this listing IS the model's, so the skill appears here. + const xml = formatAvailableSkillsXml(skills) + expect(xml).toContain('legacy') + }) + + test('appends when_to_use as trigger context', () => { + const skills: SkillsMap = { + deploy: { + name: 'deploy', + description: 'Deploy the application', + content: 'deployment instructions', + whenToUse: 'When the user asks to ship or release', + filePath: '/skills/deploy/SKILL.md', + }, + } + + const xml = formatAvailableSkillsXml(skills) + expect(xml).toContain( + 'When the user asks to ship or release', + ) + }) + test('returns an empty listing when every skill is user-only', () => { const skills: SkillsMap = { deploy: { diff --git a/common/src/util/skills.ts b/common/src/util/skills.ts index 7dc9de121c..a6b0ccdc0a 100644 --- a/common/src/util/skills.ts +++ b/common/src/util/skills.ts @@ -1,5 +1,11 @@ import type { SkillDefinition, SkillsMap } from '../types/skill' +/** + * Whether the agent may load this skill on its own. A skill is model-invocable + * unless it opted out via `disable-model-invocation: true` (user-only) or + * `user-invocable: false` (model-only skills ARE invocable by the model — the + * flag hides them from the user's / menu, not from the agent). + */ export function isSkillModelInvocable(skill: SkillDefinition): boolean { return skill.disableModelInvocation !== true } @@ -26,10 +32,20 @@ export function formatAvailableSkillsXml(skills: SkillsMap): string { } const skillsXml = skillEntries - .map( - (skill) => - ` \n ${skill.name}\n ${escapeXml(skill.description)}\n `, - ) + .map((skill) => { + const lines = [ + ' ', + ` ${skill.name}`, + ` ${escapeXml(skill.description)}`, + ] + // Claude Code parity: `when_to_use` carries extra trigger context that + // helps the model decide when the skill applies. + if (skill.whenToUse) { + lines.push(` ${escapeXml(skill.whenToUse)}`) + } + lines.push(' ') + return lines.join('\n') + }) .join('\n') return `\n${skillsXml}\n` diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 7e0a151b08..6d3418cee0 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -46,6 +46,7 @@ export { loadSkills, loadSkillsSync, parseSkillFileContent, + resolveSkillsDirs, } from './skills/load-skills' export { formatAvailableSkillsXml } from '@codebuff/common/util/skills' export type { LoadSkillsOptions } from './skills/load-skills' diff --git a/test/setup-scm-loader.ts b/test/setup-scm-loader.ts new file mode 100644 index 0000000000..20fa413834 --- /dev/null +++ b/test/setup-scm-loader.ts @@ -0,0 +1,29 @@ +/** + * Bun preload: teach bun to import .scm (tree-sitter query) files as text. + * + * packages/code-map/src/languages.ts imports .scm files directly, and the SDK + * barrel re-exports code-map — so any test importing @codebuff/sdk (or the CLI + * modules that reach it) needs this registered before those imports evaluate. + * + * cli/bunfig.toml lists this preload, but the file itself was not present in + * the public mirror, so sdk-touching tests died at import time ("Unknown file + * type" for the first .scm import), which bun reports as an unhandled error + * between tests rather than a test failure (see docs/testing.md). + * + * The bundled build handles .scm imports the same way: loader 'text', default + * export is the file's contents as a string. + */ +import { plugin } from 'bun' +import { readFileSync } from 'fs' + +plugin({ + name: 'scm-text-loader', + setup(build) { + build.onLoad({ filter: /\.scm$/ }, (args) => ({ + // Wrap in a JS module: bun's plugin API only accepts code loaders + // (js/json/...), and the import sites expect a default-exported string. + contents: `export default ${JSON.stringify(readFileSync(args.path, 'utf8'))}`, + loader: 'js' as const, + })) + }, +})