feat(ui): add skills pane in left nav and wire skills api/service

This commit is contained in:
“BeeRad”
2026-03-07 11:53:55 +11:00
parent 2cdd685c04
commit b67a789740
9 changed files with 563 additions and 202 deletions
+12 -195
View File
@@ -1,196 +1,13 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import matter from 'gray-matter';
export {
listSkills as listGuides,
readSkill as readGuide,
writeSkill as writeGuide,
deleteSkill as deleteGuide,
getUserSkillCount as getUserGuideCount,
getSkillStats as getGuideStats,
} from '@/services/skills/skillService';
export interface GuideMeta {
name: string;
description: string;
immutable: boolean;
}
export interface Guide extends GuideMeta {
content: string;
}
const MAX_USER_GUIDES = 10;
const GUIDES_DIR = path.join(
os.homedir(),
'Library/Application Support/RA-H/guides'
);
const SYSTEM_GUIDES_DIR = path.join(
process.cwd(),
'src/config/guides/system'
);
const USER_GUIDES_DIR = path.join(
process.cwd(),
'src/config/guides'
);
// System guide names (immutable, always re-seeded)
const SYSTEM_GUIDE_NAMES = new Set([
'schema',
'creating-nodes',
'edges',
'dimensions',
'extract',
]);
function ensureGuidesDir(): void {
if (!fs.existsSync(GUIDES_DIR)) {
fs.mkdirSync(GUIDES_DIR, { recursive: true });
}
}
/**
* Seed system guides — always overwritten on app start to stay current.
* User guides are only seeded if they don't already exist.
*/
function seedGuides(): void {
// Always re-seed system guides (immutable, kept up to date)
if (fs.existsSync(SYSTEM_GUIDES_DIR)) {
const systemFiles = fs.readdirSync(SYSTEM_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of systemFiles) {
const dest = path.join(GUIDES_DIR, file);
fs.copyFileSync(path.join(SYSTEM_GUIDES_DIR, file), dest);
}
}
// Seed default user guides only if they don't exist
if (fs.existsSync(USER_GUIDES_DIR)) {
const userFiles = fs.readdirSync(USER_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of userFiles) {
const dest = path.join(GUIDES_DIR, file);
if (!fs.existsSync(dest)) {
fs.copyFileSync(path.join(USER_GUIDES_DIR, file), dest);
}
}
}
}
let initialized = false;
function init(): void {
if (initialized) return;
ensureGuidesDir();
seedGuides();
initialized = true;
}
function isSystemGuide(filename: string): boolean {
const name = filename.replace('.md', '');
return SYSTEM_GUIDE_NAMES.has(name);
}
export function listGuides(): GuideMeta[] {
init();
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
const guides = files.map(file => {
const raw = fs.readFileSync(path.join(GUIDES_DIR, file), 'utf-8');
const { data } = matter(raw);
const immutable = isSystemGuide(file) || data.immutable === true;
return {
name: data.name || file.replace('.md', ''),
description: data.description || '',
immutable,
};
});
// System guides first, then user guides alphabetically
return guides.sort((a, b) => {
if (a.immutable && !b.immutable) return -1;
if (!a.immutable && b.immutable) return 1;
return a.name.localeCompare(b.name);
});
}
export function readGuide(name: string): Guide | null {
init();
const candidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
];
for (const filename of candidates) {
const filepath = path.join(GUIDES_DIR, filename);
if (fs.existsSync(filepath)) {
const raw = fs.readFileSync(filepath, 'utf-8');
const { data, content } = matter(raw);
const immutable = isSystemGuide(filename) || data.immutable === true;
return {
name: data.name || name,
description: data.description || '',
immutable,
content: content.trim(),
};
}
}
return null;
}
export function writeGuide(name: string, content: string): { success: boolean; error?: string } {
init();
const filename = `${name.toLowerCase()}.md`;
// Reject writes to immutable guides
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be modified.` };
}
// Check user guide cap for new guides
const filepath = path.join(GUIDES_DIR, filename);
if (!fs.existsSync(filepath)) {
const userGuideCount = getUserGuideCount();
if (userGuideCount >= MAX_USER_GUIDES) {
return { success: false, error: `Maximum of ${MAX_USER_GUIDES} custom guides reached. Delete a guide first.` };
}
}
fs.writeFileSync(filepath, content, 'utf-8');
return { success: true };
}
export function deleteGuide(name: string): { success: boolean; error?: string } {
init();
const candidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
];
for (const filename of candidates) {
// Reject deletes of immutable guides
if (isSystemGuide(filename)) {
return { success: false, error: `Guide "${name}" is a system guide and cannot be deleted.` };
}
const filepath = path.join(GUIDES_DIR, filename);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
return { success: true };
}
}
return { success: false, error: `Guide "${name}" not found.` };
}
export function getUserGuideCount(): number {
init();
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
return files.filter(f => !isSystemGuide(f)).length;
}
export function getGuideStats(): { userGuides: number; maxUserGuides: number; systemGuides: number } {
const guides = listGuides();
const systemCount = guides.filter(g => g.immutable).length;
const userCount = guides.filter(g => !g.immutable).length;
return {
userGuides: userCount,
maxUserGuides: MAX_USER_GUIDES,
systemGuides: systemCount,
};
}
export type {
SkillMeta as GuideMeta,
Skill as Guide,
} from '@/services/skills/skillService';
+231
View File
@@ -0,0 +1,231 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import matter from 'gray-matter';
export interface SkillMeta {
name: string;
description: string;
immutable: boolean;
}
export interface Skill extends SkillMeta {
content: string;
}
const SKILLS_DIR = path.join(
os.homedir(),
'Library/Application Support/RA-H/skills'
);
const LEGACY_GUIDES_DIR = path.join(
os.homedir(),
'Library/Application Support/RA-H/guides'
);
const BUNDLED_SKILLS_DIR = path.join(
process.cwd(),
'src/config/skills'
);
const SEEDED_SKILL_IDS = new Set([
'db-operations',
'create-skill',
'audit',
'traverse',
'onboarding',
'persona',
'calibration',
'connect',
]);
function ensureSkillsDir(): void {
if (!fs.existsSync(SKILLS_DIR)) {
fs.mkdirSync(SKILLS_DIR, { recursive: true });
}
}
function stripMdExtension(value: string): string {
return value.replace(/\.md$/i, '');
}
function normalizeSkillId(value: string): string {
return stripMdExtension(value)
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function listMarkdownFiles(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
}
function migrateLegacyGuides(): void {
const guideFiles = listMarkdownFiles(LEGACY_GUIDES_DIR);
for (const file of guideFiles) {
const target = path.join(SKILLS_DIR, file);
if (!fs.existsSync(target)) {
fs.copyFileSync(path.join(LEGACY_GUIDES_DIR, file), target);
}
}
}
function seedSkills(): void {
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map<string, string>();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
if (!fs.existsSync(dest)) {
fs.copyFileSync(source, dest);
}
}
}
function resolveSkillFilename(name: string): string | null {
const files = listMarkdownFiles(SKILLS_DIR);
const normalizedInput = normalizeSkillId(name);
const directCandidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
normalizedInput ? `${normalizedInput}.md` : '',
].filter(Boolean);
for (const candidate of directCandidates) {
if (files.includes(candidate)) {
return candidate;
}
}
for (const file of files) {
if (normalizeSkillId(file) === normalizedInput) {
return file;
}
const raw = fs.readFileSync(path.join(SKILLS_DIR, file), 'utf-8');
const { data } = matter(raw);
if (typeof data.name === 'string' && normalizeSkillId(data.name) === normalizedInput) {
return file;
}
}
return null;
}
let initialized = false;
function init(): void {
if (initialized) return;
ensureSkillsDir();
migrateLegacyGuides();
seedSkills();
initialized = true;
}
export function listSkills(): SkillMeta[] {
init();
const files = listMarkdownFiles(SKILLS_DIR);
const skills = files.map((file) => {
const raw = fs.readFileSync(path.join(SKILLS_DIR, file), 'utf-8');
const { data } = matter(raw);
return {
name: data.name || file.replace('.md', ''),
description: data.description || '',
immutable: false,
};
});
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
export function readSkill(name: string): Skill | null {
init();
const filename = resolveSkillFilename(name);
if (!filename) return null;
const filepath = path.join(SKILLS_DIR, filename);
const raw = fs.readFileSync(filepath, 'utf-8');
const { data, content } = matter(raw);
return {
name: data.name || stripMdExtension(filename),
description: data.description || '',
immutable: false,
content: content.trim(),
};
}
export function writeSkill(name: string, content: string): { success: boolean; error?: string } {
init();
const normalizedName = normalizeSkillId(name);
const existingFilename = resolveSkillFilename(name);
const filename = existingFilename || `${normalizedName || name.toLowerCase()}.md`;
const filepath = path.join(SKILLS_DIR, filename);
fs.writeFileSync(filepath, content, 'utf-8');
return { success: true };
}
export function deleteSkill(name: string): { success: boolean; error?: string } {
init();
const filename = resolveSkillFilename(name);
if (!filename) {
return { success: false, error: `Skill "${name}" not found.` };
}
const filepath = path.join(SKILLS_DIR, filename);
fs.unlinkSync(filepath);
return { success: true };
}
export function getUserSkillCount(): number {
init();
return listMarkdownFiles(SKILLS_DIR).length;
}
export function getSkillStats(): { userSkills: number; maxUserSkills: number; systemSkills: number } {
const count = getUserSkillCount();
return {
userSkills: count,
maxUserSkills: 9999,
systemSkills: 0,
};
}
// Compatibility aliases for legacy guide-first code paths.
export type GuideMeta = SkillMeta;
export type Guide = Skill;
export const listGuides = listSkills;
export const readGuide = readSkill;
export const writeGuide = writeSkill;
export const deleteGuide = deleteSkill;
export function getUserGuideCount(): number {
return getUserSkillCount();
}
export function getGuideStats(): { userGuides: number; maxUserGuides: number; systemGuides: number } {
const stats = getSkillStats();
return {
userGuides: stats.userSkills,
maxUserGuides: stats.maxUserSkills,
systemGuides: stats.systemSkills,
};
}