feat: expose skills and $ autocomplete (#92)

This commit is contained in:
freespace8
2026-01-21 16:28:15 +08:00
committed by GitHub
parent 8b368024e3
commit 2cb8727874
17 changed files with 471 additions and 5 deletions
+19
View File
@@ -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<ListSkillsRequest, ListSkillsResponse>('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'))
}
})
}
@@ -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)
}
+90
View File
@@ -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 }]);
});
});
+112
View File
@@ -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<string, unknown>; 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<string, unknown> | 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<string[]> {
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<SkillSummary[]> {
const skillsRoot = getSkillsRoot();
const skillDirs = await listTopLevelSkillDirs(skillsRoot);
if (skillDirs.length === 0) {
return [];
}
const skills = await Promise.all(skillDirs.map(async (dir): Promise<SkillSummary | null> => {
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));
}