mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: expose skills and $ autocomplete (#92)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user