From b67a789740398e9d991142eb38f156ee5572a5b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Sat, 7 Mar 2026 11:53:55 +1100 Subject: [PATCH] feat(ui): add skills pane in left nav and wire skills api/service --- app/api/skills/[name]/route.ts | 52 +++++ app/api/skills/route.ts | 17 ++ src/components/layout/LeftToolbar.tsx | 17 +- src/components/layout/ThreePanelLayout.tsx | 16 +- src/components/panes/SkillsPane.tsx | 219 +++++++++++++++++++ src/components/panes/index.ts | 1 + src/components/panes/types.ts | 5 +- src/services/guides/guideService.ts | 207 ++---------------- src/services/skills/skillService.ts | 231 +++++++++++++++++++++ 9 files changed, 563 insertions(+), 202 deletions(-) create mode 100644 app/api/skills/[name]/route.ts create mode 100644 app/api/skills/route.ts create mode 100644 src/components/panes/SkillsPane.tsx create mode 100644 src/services/skills/skillService.ts diff --git a/app/api/skills/[name]/route.ts b/app/api/skills/[name]/route.ts new file mode 100644 index 0000000..5a5d05e --- /dev/null +++ b/app/api/skills/[name]/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { readSkill, deleteSkill } from '@/services/skills/skillService'; + +export const runtime = 'nodejs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + try { + const { name } = await params; + const skill = readSkill(name); + if (!skill) { + return NextResponse.json( + { success: false, error: `Skill "${name}" not found` }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true, data: skill }); + } catch (error) { + console.error('[API /skills/[name]] error:', error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Failed to read skill' }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + try { + const { name } = await params; + const result = deleteSkill(name); + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true, message: `Skill "${name}" deleted` }); + } catch (error) { + console.error('[API /skills/[name] DELETE] error:', error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Failed to delete skill' }, + { status: 500 } + ); + } +} diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts new file mode 100644 index 0000000..cb261c6 --- /dev/null +++ b/app/api/skills/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { listSkills } from '@/services/skills/skillService'; + +export const runtime = 'nodejs'; + +export async function GET() { + try { + const skills = listSkills(); + return NextResponse.json({ success: true, data: skills }); + } catch (error) { + console.error('[API /skills] error:', error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Failed to list skills' }, + { status: 500 } + ); + } +} diff --git a/src/components/layout/LeftToolbar.tsx b/src/components/layout/LeftToolbar.tsx index 6f945db..5e255c6 100644 --- a/src/components/layout/LeftToolbar.tsx +++ b/src/components/layout/LeftToolbar.tsx @@ -9,6 +9,7 @@ import { Map, Folder, Table2, + BookOpen, Settings, } from 'lucide-react'; import type { PaneType } from '../panes/types'; @@ -30,6 +31,7 @@ const PANE_TYPE_ICONS: Record = { map: Map, dimensions: Folder, table: Table2, + skills: BookOpen, }; const PANE_TYPE_LABELS: Record = { @@ -37,9 +39,10 @@ const PANE_TYPE_LABELS: Record = { map: 'Map', dimensions: 'Dimensions', table: 'Table', + skills: 'Skills', }; -// Pane types shown in the toolbar (excludes 'node', 'chat', and 'guides' which is in settings) +// Pane types shown in the toolbar center section (skills is pinned above settings) const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'table']; interface ToolbarButtonProps { @@ -224,8 +227,16 @@ export default function LeftToolbar({ })} - {/* Bottom section - Settings */} -
+ {/* Bottom section - Skills + Settings */} +
+ onPaneTypeClick('skills')} + /> ); + case 'skills': + return ( + + ); + default: return null; } diff --git a/src/components/panes/SkillsPane.tsx b/src/components/panes/SkillsPane.tsx new file mode 100644 index 0000000..55a7677 --- /dev/null +++ b/src/components/panes/SkillsPane.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { ArrowLeft, Trash2 } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import PaneHeader from './PaneHeader'; +import type { BasePaneProps } from './types'; + +interface SkillMeta { + name: string; + description: string; + immutable: boolean; +} + +interface Skill extends SkillMeta { + content: string; +} + +export default function SkillsPane({ + slot, + onCollapse, + onSwapPanes, + tabBar, +}: BasePaneProps) { + const [skills, setSkills] = useState([]); + const [selectedSkill, setSelectedSkill] = useState(null); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + + useEffect(() => { + fetchSkills(); + + const handleSkillUpdated = () => { + fetchSkills(); + }; + window.addEventListener('skills:updated', handleSkillUpdated); + window.addEventListener('guides:updated', handleSkillUpdated); + + return () => { + window.removeEventListener('skills:updated', handleSkillUpdated); + window.removeEventListener('guides:updated', handleSkillUpdated); + }; + }, []); + + const fetchSkills = async () => { + try { + const res = await fetch('/api/skills'); + const data = await res.json(); + if (data.success) { + setSkills(data.data); + } + } catch (err) { + console.error('[SkillsPane] Failed to fetch skills:', err); + } finally { + setLoading(false); + } + }; + + const handleSelectSkill = async (name: string) => { + try { + const res = await fetch(`/api/skills/${encodeURIComponent(name)}`); + const data = await res.json(); + if (data.success) { + setSelectedSkill(data.data); + } + } catch (err) { + console.error('[SkillsPane] Failed to fetch skill:', err); + } + }; + + const handleDeleteSkill = async (name: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (!confirm(`Delete skill "${name}"?`)) return; + + setDeleting(name); + try { + const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + fetchSkills(); + if (selectedSkill?.name === name) { + setSelectedSkill(null); + } + } + } catch (err) { + console.error('[SkillsPane] Failed to delete skill:', err); + } finally { + setDeleting(null); + } + }; + + return ( +
+ + {selectedSkill ? ( +
+ + {selectedSkill.name} +
+ ) : ( + {skills.length} skills + )} +
+ +
+ {loading ? ( +
Loading...
+ ) : selectedSkill ? ( +
+ {selectedSkill.content} +
+ ) : ( +
+ {skills.length === 0 ? ( +
+ No skills found +
+ ) : ( + skills.map((skill) => ( + + + )) + )} +
+ )} +
+
+ ); +} diff --git a/src/components/panes/index.ts b/src/components/panes/index.ts index 7f54eed..4d3ca36 100644 --- a/src/components/panes/index.ts +++ b/src/components/panes/index.ts @@ -3,5 +3,6 @@ export { default as DimensionsPane } from './DimensionsPane'; export { default as MapPane } from './MapPane'; export { default as ViewsPane } from './ViewsPane'; export { default as TablePane } from './TablePane'; +export { default as SkillsPane } from './SkillsPane'; export { default as PaneHeader } from './PaneHeader'; export * from './types'; diff --git a/src/components/panes/types.ts b/src/components/panes/types.ts index b17a13f..b88ca93 100644 --- a/src/components/panes/types.ts +++ b/src/components/panes/types.ts @@ -14,8 +14,8 @@ export type AgentDelegation = { updatedAt: string; }; -// The four pane types (chat removed in rah-light, guides moved to settings) -export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table'; +// Pane types (chat removed in rah-light) +export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table' | 'skills'; // State for each slot export interface SlotState { @@ -111,6 +111,7 @@ export const PANE_LABELS: Record = { map: 'Map', views: 'Feed', table: 'Table', + skills: 'Skills', }; // Default slot states diff --git a/src/services/guides/guideService.ts b/src/services/guides/guideService.ts index 53b46dc..0decd8e 100644 --- a/src/services/guides/guideService.ts +++ b/src/services/guides/guideService.ts @@ -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'; diff --git a/src/services/skills/skillService.ts b/src/services/skills/skillService.ts new file mode 100644 index 0000000..94ad2f1 --- /dev/null +++ b/src/services/skills/skillService.ts @@ -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(); + + 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, + }; +}