From 2cb8727874c3fbbb5aacc31ad7b70cc9236eba57 Mon Sep 17 00:00:00 2001 From: freespace8 Date: Wed, 21 Jan 2026 16:28:15 +0800 Subject: [PATCH] feat: expose skills and $ autocomplete (#92) --- .gitignore | 3 + bun.lock | 2 + cli/src/modules/common/handlers/skills.ts | 19 +++ .../modules/common/registerCommonHandlers.ts | 2 + cli/src/modules/common/skills.test.ts | 90 ++++++++++++++ cli/src/modules/common/skills.ts | 112 ++++++++++++++++++ server/src/sync/rpcGateway.ts | 12 ++ server/src/sync/syncEngine.ts | 8 ++ server/src/web/routes/sessions.ts | 23 ++++ web/src/api/client.ts | 7 ++ .../AssistantChat/HappyComposer.tsx | 6 +- web/src/components/ChatInput/Autocomplete.tsx | 6 +- web/src/hooks/queries/useSkills.ts | 107 +++++++++++++++++ web/src/lib/query-keys.ts | 1 + web/src/lib/recent-skills.ts | 54 +++++++++ web/src/router.tsx | 13 +- web/src/types/api.ts | 11 ++ 17 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 cli/src/modules/common/handlers/skills.ts create mode 100644 cli/src/modules/common/skills.test.ts create mode 100644 cli/src/modules/common/skills.ts create mode 100644 web/src/hooks/queries/useSkills.ts create mode 100644 web/src/lib/recent-skills.ts diff --git a/.gitignore b/.gitignore index 9c2717e2..96c9be9e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ coverage/ .claude/settings.local.json localdocs/ execplan/ + +# Generated npm bundle output (local) +cli/npm/main/ diff --git a/bun.lock b/bun.lock index cd70c2c0..1a2176c9 100644 --- a/bun.lock +++ b/bun.lock @@ -858,6 +858,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.11.0", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-vI9ECr1baVxKT/71+QqzZHoPSJRMTWwoORRooD472y8VtdhGqLqLQ4tyWEL+P5zFgVNJfhWtfTg4TJ3FmXZd6g=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.11.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-kwPVKW81JM2npGjnczJOupFu58lTj/3UYHN2rLkxkWitrSogPR5aQeQE1f7txPXl5xc8H7LxqO8i7i/WBsHDHg=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], diff --git a/cli/src/modules/common/handlers/skills.ts b/cli/src/modules/common/handlers/skills.ts new file mode 100644 index 00000000..ea719a76 --- /dev/null +++ b/cli/src/modules/common/handlers/skills.ts @@ -0,0 +1,19 @@ +import { logger } from '@/ui/logger' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { listSkills, type ListSkillsRequest, type ListSkillsResponse } from '../skills' +import { getErrorMessage, rpcError } from '../rpcResponses' + +export function registerSkillsHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler('listSkills', async () => { + logger.debug('List skills request') + + try { + const skills = await listSkills() + return { success: true, skills } + } catch (error) { + logger.debug('Failed to list skills:', error) + return rpcError(getErrorMessage(error, 'Failed to list skills')) + } + }) +} + diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 8b815d42..01d23cce 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -6,6 +6,7 @@ import { registerFileHandlers } from './handlers/files' import { registerGitHandlers } from './handlers/git' import { registerRipgrepHandlers } from './handlers/ripgrep' import { registerSlashCommandHandlers } from './handlers/slashCommands' +import { registerSkillsHandlers } from './handlers/skills' import { registerUploadHandlers } from './handlers/uploads' export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { @@ -15,6 +16,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor registerRipgrepHandlers(rpcHandlerManager, workingDirectory) registerDifftasticHandlers(rpcHandlerManager, workingDirectory) registerSlashCommandHandlers(rpcHandlerManager) + registerSkillsHandlers(rpcHandlerManager) registerGitHandlers(rpcHandlerManager, workingDirectory) registerUploadHandlers(rpcHandlerManager) } diff --git a/cli/src/modules/common/skills.test.ts b/cli/src/modules/common/skills.test.ts new file mode 100644 index 00000000..365b2487 --- /dev/null +++ b/cli/src/modules/common/skills.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { listSkills } from './skills'; + +describe('skills', () => { + const originalCodexHome = process.env.CODEX_HOME; + let codexHome: string; + + beforeEach(async () => { + codexHome = await mkdtemp(join(tmpdir(), 'hapi-skills-')); + process.env.CODEX_HOME = codexHome; + }); + + afterEach(async () => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + await rm(codexHome, { recursive: true, force: true }); + }); + + it('returns empty list when skills directory is missing', async () => { + const skills = await listSkills(); + expect(skills).toEqual([]); + }); + + it('lists only top-level skills and .system children', async () => { + const skillsRoot = join(codexHome, 'skills'); + await mkdir(skillsRoot, { recursive: true }); + + const amisDir = join(skillsRoot, 'amis'); + await mkdir(amisDir, { recursive: true }); + await writeFile(join(amisDir, 'SKILL.md'), [ + '---', + 'name: amis', + 'description: AMIS guide', + '---', + '', + '# AMIS', + ].join('\n')); + + const helloAgentsDir = join(skillsRoot, 'hello-agents'); + await mkdir(join(helloAgentsDir, 'analyze'), { recursive: true }); + await writeFile(join(helloAgentsDir, 'SKILL.md'), [ + '---', + 'name: helloagents', + 'description: Main skill', + '---', + '', + '# HelloAGENTS', + ].join('\n')); + await writeFile(join(helloAgentsDir, 'analyze', 'SKILL.md'), [ + '---', + 'name: analyze', + 'description: Sub skill', + '---', + '', + '# Analyze', + ].join('\n')); + + const systemRoot = join(skillsRoot, '.system'); + const systemSkillDir = join(systemRoot, 'skill-creator'); + await mkdir(systemSkillDir, { recursive: true }); + await writeFile(join(systemSkillDir, 'SKILL.md'), [ + '---', + 'name: skill-creator', + 'description: Create skills', + '---', + '', + '# Skill Creator', + ].join('\n')); + + const skills = await listSkills(); + expect(skills.map((s) => s.name)).toEqual(['amis', 'helloagents', 'skill-creator']); + }); + + it('falls back to directory name when frontmatter is missing', async () => { + const skillsRoot = join(codexHome, 'skills'); + const fallbackDir = join(skillsRoot, 'no-frontmatter'); + await mkdir(fallbackDir, { recursive: true }); + await writeFile(join(fallbackDir, 'SKILL.md'), '# No Frontmatter\n'); + + const skills = await listSkills(); + expect(skills).toEqual([{ name: 'no-frontmatter', description: undefined }]); + }); +}); + diff --git a/cli/src/modules/common/skills.ts b/cli/src/modules/common/skills.ts new file mode 100644 index 00000000..613e9eeb --- /dev/null +++ b/cli/src/modules/common/skills.ts @@ -0,0 +1,112 @@ +import { readdir, readFile } from 'fs/promises'; +import { join, basename } from 'path'; +import { homedir } from 'os'; +import { parse as parseYaml } from 'yaml'; + +export interface SkillSummary { + name: string; + description?: string; +} + +export interface ListSkillsRequest { +} + +export interface ListSkillsResponse { + success: boolean; + skills?: SkillSummary[]; + error?: string; +} + +function getSkillsRoot(): string { + const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex'); + return join(codexHome, 'skills'); +} + +function parseFrontmatter(fileContent: string): { frontmatter?: Record; body: string } { + const match = fileContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) { + return { body: fileContent.trim() }; + } + + const yamlContent = match[1]; + const body = match[2].trim(); + try { + const parsed = parseYaml(yamlContent) as Record | null; + return { frontmatter: parsed ?? undefined, body }; + } catch { + return { body: fileContent.trim() }; + } +} + +function extractSkillSummary(skillDir: string, fileContent: string): SkillSummary | null { + const parsed = parseFrontmatter(fileContent); + const nameFromFrontmatter = typeof parsed.frontmatter?.name === 'string' ? parsed.frontmatter.name.trim() : ''; + const name = nameFromFrontmatter || basename(skillDir); + if (!name) { + return null; + } + + const description = typeof parsed.frontmatter?.description === 'string' + ? parsed.frontmatter.description.trim() + : undefined; + + return { name, description }; +} + +async function listTopLevelSkillDirs(skillsRoot: string): Promise { + try { + const entries = await readdir(skillsRoot, { withFileTypes: true }); + const result: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + if (entry.name === '.system') { + const systemRoot = join(skillsRoot, entry.name); + try { + const systemEntries = await readdir(systemRoot, { withFileTypes: true }); + for (const systemEntry of systemEntries) { + if (!systemEntry.isDirectory()) { + continue; + } + result.push(join(systemRoot, systemEntry.name)); + } + } catch { + // ignore unreadable .system + } + continue; + } + + result.push(join(skillsRoot, entry.name)); + } + + return result; + } catch { + return []; + } +} + +export async function listSkills(): Promise { + const skillsRoot = getSkillsRoot(); + const skillDirs = await listTopLevelSkillDirs(skillsRoot); + if (skillDirs.length === 0) { + return []; + } + + const skills = await Promise.all(skillDirs.map(async (dir): Promise => { + const filePath = join(dir, 'SKILL.md'); + try { + const fileContent = await readFile(filePath, 'utf-8'); + return extractSkillSummary(dir, fileContent); + } catch { + return null; + } + })); + + return skills + .filter((skill): skill is SkillSummary => skill !== null) + .sort((a, b) => a.name.localeCompare(b.name)); +} + diff --git a/server/src/sync/rpcGateway.ts b/server/src/sync/rpcGateway.ts index 00adfc35..097125d8 100644 --- a/server/src/sync/rpcGateway.ts +++ b/server/src/sync/rpcGateway.ts @@ -177,6 +177,18 @@ export class RpcGateway { } } + async listSkills(sessionId: string): Promise<{ + success: boolean + skills?: Array<{ name: string; description?: string }> + error?: string + }> { + return await this.sessionRpc(sessionId, 'listSkills', {}) as { + success: boolean + skills?: Array<{ name: string; description?: string }> + error?: string + } + } + private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index bdab76c6..505660bb 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -316,4 +316,12 @@ export class SyncEngine { }> { return await this.rpcGateway.listSlashCommands(sessionId, agent) } + + async listSkills(sessionId: string): Promise<{ + success: boolean + skills?: Array<{ name: string; description?: string }> + error?: string + }> { + return await this.rpcGateway.listSkills(sessionId) + } } diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index 96d93a5a..4d3f0b14 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -347,5 +347,28 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/sessions/:id/skills', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + // Session must exist but doesn't need to be active + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + try { + const result = await engine.listSkills(sessionResult.sessionId) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list skills' + }) + } + }) + return app } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 79f20b56..545ba0c9 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -14,6 +14,7 @@ import type { PushUnsubscribePayload, PushVapidPublicKeyResponse, SlashCommandsResponse, + SkillsResponse, SpawnResponse, UploadFileResponse, VisibilityPayload, @@ -367,6 +368,12 @@ export class ApiClient { ) } + async getSkills(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/skills` + ) + } + async renameSession(sessionId: string, name: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'PATCH', diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 7eb0ddbd..16383d85 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -19,6 +19,7 @@ import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' import { applySuggestion } from '@/utils/applySuggestion' import { usePlatform } from '@/hooks/usePlatform' import { usePWAInstall } from '@/hooks/usePWAInstall' +import { markSkillUsed } from '@/lib/recent-skills' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { Autocomplete } from '@/components/ChatInput/Autocomplete' import { StatusBar } from '@/components/AssistantChat/StatusBar' @@ -70,7 +71,7 @@ export function HappyComposer(props: { onModelModeChange, onSwitchToRemote, onTerminal, - autocompletePrefixes = ['@', '/'], + autocompletePrefixes = ['@', '/', '$'], autocompleteSuggestions = defaultSuggestionHandler, voiceStatus = 'disconnected', voiceMicMuted = false, @@ -161,6 +162,9 @@ export function HappyComposer(props: { const handleSuggestionSelect = useCallback((index: number) => { const suggestion = suggestions[index] if (!suggestion || !textareaRef.current) return + if (suggestion.text.startsWith('$')) { + markSkillUsed(suggestion.text.slice(1)) + } // For Codex user prompts with content, expand the content instead of command name let textToInsert = suggestion.text diff --git a/web/src/components/ChatInput/Autocomplete.tsx b/web/src/components/ChatInput/Autocomplete.tsx index 8acb517b..07fe9968 100644 --- a/web/src/components/ChatInput/Autocomplete.tsx +++ b/web/src/components/ChatInput/Autocomplete.tsx @@ -35,7 +35,7 @@ export const Autocomplete = memo(function Autocomplete(props: AutocompleteProps) key={suggestion.key} type="button" data-suggestion-index={index} - className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${ + className={`flex w-full cursor-pointer flex-col items-start gap-0.5 px-3 py-2 text-left text-sm transition-colors ${ index === selectedIndex ? 'bg-[var(--app-button)] text-[var(--app-button-text)]' : 'text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)]' @@ -43,9 +43,9 @@ export const Autocomplete = memo(function Autocomplete(props: AutocompleteProps) onClick={() => onSelect(index)} onMouseDown={(e) => e.preventDefault()} // Prevent blur on textarea > - {suggestion.label} + {suggestion.label} {suggestion.description && ( - Promise +} { + const resolvedSessionId = sessionId ?? 'unknown' + + const query = useQuery({ + queryKey: queryKeys.skills(resolvedSessionId), + queryFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + return await api.getSkills(sessionId) + }, + enabled: Boolean(api && sessionId), + staleTime: Infinity, + gcTime: 30 * 60 * 1000, + retry: false, + }) + + const skills = useMemo(() => { + if (query.data?.success && query.data.skills) { + return query.data.skills + } + return [] + }, [query.data]) + + const getSuggestions = useCallback(async (queryText: string): Promise => { + const recent = getRecentSkills() + const getRecency = (name: string) => recent[name] ?? 0 + const searchTerm = queryText.startsWith('$') + ? queryText.slice(1).toLowerCase() + : queryText.toLowerCase() + + if (!searchTerm) { + return [...skills] + .sort((a, b) => getRecency(b.name) - getRecency(a.name) || a.name.localeCompare(b.name)) + .map((skill) => ({ + key: `$${skill.name}`, + text: `$${skill.name}`, + label: `$${skill.name}`, + description: skill.description, + source: 'builtin' + })) + } + + const maxDistance = Math.max(2, Math.floor(searchTerm.length / 2)) + return skills + .map(skill => { + const name = skill.name.toLowerCase() + let score: number + if (name === searchTerm) score = 0 + else if (name.startsWith(searchTerm)) score = 1 + else if (name.includes(searchTerm)) score = 2 + else { + const dist = levenshteinDistance(searchTerm, name) + score = dist <= maxDistance ? 3 + dist : Infinity + } + return { skill, score, recency: getRecency(skill.name) } + }) + .filter(item => item.score < Infinity) + .sort((a, b) => a.score - b.score || b.recency - a.recency || a.skill.name.localeCompare(b.skill.name)) + .map(({ skill }) => ({ + key: `$${skill.name}`, + text: `$${skill.name}`, + label: `$${skill.name}`, + description: skill.description, + source: 'builtin' + })) + }, [skills]) + + return { + skills, + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load skills' : null, + getSuggestions, + } +} diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index ef6ce1be..4b12ef84 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -13,4 +13,5 @@ export const queryKeys = { staged ? 'staged' : 'unstaged' ] as const, slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const, + skills: (sessionId: string) => ['skills', sessionId] as const, } diff --git a/web/src/lib/recent-skills.ts b/web/src/lib/recent-skills.ts new file mode 100644 index 00000000..73c52961 --- /dev/null +++ b/web/src/lib/recent-skills.ts @@ -0,0 +1,54 @@ +const RECENT_SKILLS_KEY = 'hapi-recent-skills' +const MAX_RECENT_SKILLS = 200 + +type RecentSkillsMap = Record + +function safeParseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown + } catch { + return null + } +} + +export function getRecentSkills(): RecentSkillsMap { + if (typeof window === 'undefined') return {} + try { + const raw = localStorage.getItem(RECENT_SKILLS_KEY) + if (!raw) return {} + const parsed = safeParseJson(raw) + if (!parsed || typeof parsed !== 'object') return {} + + const record = parsed as Record + const result: RecentSkillsMap = {} + for (const [key, value] of Object.entries(record)) { + if (typeof key !== 'string' || key.trim().length === 0) continue + if (typeof value !== 'number' || !Number.isFinite(value)) continue + result[key] = value + } + return result + } catch { + return {} + } +} + +export function markSkillUsed(skillName: string): void { + const name = skillName.trim() + if (!name) return + if (typeof window === 'undefined') return + + try { + const recent = getRecentSkills() + recent[name] = Date.now() + + const entries = Object.entries(recent) + .sort((a, b) => b[1] - a[1]) + .slice(0, MAX_RECENT_SKILLS) + + const next: RecentSkillsMap = Object.fromEntries(entries) + localStorage.setItem(RECENT_SKILLS_KEY, JSON.stringify(next)) + } catch { + // Ignore storage errors + } +} + diff --git a/web/src/router.tsx b/web/src/router.tsx index 200c704e..3ff3f309 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -21,6 +21,7 @@ import { useMachines } from '@/hooks/queries/useMachines' import { useSession } from '@/hooks/queries/useSession' import { useSessions } from '@/hooks/queries/useSessions' import { useSlashCommands } from '@/hooks/queries/useSlashCommands' +import { useSkills } from '@/hooks/queries/useSkills' import { useSendMessage } from '@/hooks/mutations/useSendMessage' import { queryKeys } from '@/lib/query-keys' import { useTranslation } from '@/lib/use-translation' @@ -182,6 +183,16 @@ function SessionPage() { const { getSuggestions: getSlashSuggestions, } = useSlashCommands(api, sessionId, agentType) + const { + getSuggestions: getSkillSuggestions, + } = useSkills(api, sessionId) + + const getAutocompleteSuggestions = useCallback(async (query: string) => { + if (query.startsWith('$')) { + return await getSkillSuggestions(query) + } + return await getSlashSuggestions(query) + }, [getSkillSuggestions, getSlashSuggestions]) const refreshSelectedSession = useCallback(() => { void refetchSession() @@ -215,7 +226,7 @@ function SessionPage() { onFlushPending={flushPending} onAtBottomChange={setAtBottom} onRetryMessage={retryMessage} - autocompleteSuggestions={getSlashSuggestions} + autocompleteSuggestions={getAutocompleteSuggestions} /> ) } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 56dc106e..c20a552a 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -147,6 +147,17 @@ export type SlashCommandsResponse = { error?: string } +export type SkillSummary = { + name: string + description?: string +} + +export type SkillsResponse = { + success: boolean + skills?: SkillSummary[] + error?: string +} + export type PushSubscriptionKeys = { p256dh: string auth: string