feat(ui): add skills pane in left nav and wire skills api/service
This commit is contained in:
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Map,
|
Map,
|
||||||
Folder,
|
Folder,
|
||||||
Table2,
|
Table2,
|
||||||
|
BookOpen,
|
||||||
Settings,
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { PaneType } from '../panes/types';
|
import type { PaneType } from '../panes/types';
|
||||||
@@ -30,6 +31,7 @@ const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
|
|||||||
map: Map,
|
map: Map,
|
||||||
dimensions: Folder,
|
dimensions: Folder,
|
||||||
table: Table2,
|
table: Table2,
|
||||||
|
skills: BookOpen,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PANE_TYPE_LABELS: Record<string, string> = {
|
const PANE_TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -37,9 +39,10 @@ const PANE_TYPE_LABELS: Record<string, string> = {
|
|||||||
map: 'Map',
|
map: 'Map',
|
||||||
dimensions: 'Dimensions',
|
dimensions: 'Dimensions',
|
||||||
table: 'Table',
|
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'];
|
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'table'];
|
||||||
|
|
||||||
interface ToolbarButtonProps {
|
interface ToolbarButtonProps {
|
||||||
@@ -224,8 +227,16 @@ export default function LeftToolbar({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom section - Settings */}
|
{/* Bottom section - Skills + Settings */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<PaneTypeButton
|
||||||
|
icon={BookOpen}
|
||||||
|
label="Skills"
|
||||||
|
paneType="skills"
|
||||||
|
isOpen={openPaneTypes.has('skills')}
|
||||||
|
isActivePane={activePaneType === 'skills'}
|
||||||
|
onClick={() => onPaneTypeClick('skills')}
|
||||||
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={Settings}
|
icon={Settings}
|
||||||
label="Settings"
|
label="Settings"
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export interface PendingNode {
|
|||||||
import LeftToolbar from './LeftToolbar';
|
import LeftToolbar from './LeftToolbar';
|
||||||
import SplitHandle from './SplitHandle';
|
import SplitHandle from './SplitHandle';
|
||||||
|
|
||||||
// Pane components (ChatPane removed in rah-light, GuidesPane moved to settings)
|
// Pane components (ChatPane removed in rah-light)
|
||||||
import { NodePane, DimensionsPane, MapPane, ViewsPane, TablePane } from '../panes';
|
import { NodePane, DimensionsPane, MapPane, ViewsPane, TablePane, SkillsPane } from '../panes';
|
||||||
import QuickAddInput from '../agents/QuickAddInput';
|
import QuickAddInput from '../agents/QuickAddInput';
|
||||||
import type { PaneType, SlotState, PaneAction } from '../panes/types';
|
import type { PaneType, SlotState, PaneAction } from '../panes/types';
|
||||||
|
|
||||||
@@ -295,6 +295,7 @@ export default function ThreePanelLayout() {
|
|||||||
case 'GUIDE_UPDATED':
|
case 'GUIDE_UPDATED':
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.dispatchEvent(new CustomEvent('guides:updated', { detail: data.data }));
|
window.dispatchEvent(new CustomEvent('guides:updated', { detail: data.data }));
|
||||||
|
window.dispatchEvent(new CustomEvent('skills:updated', { detail: data.data }));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -960,6 +961,17 @@ export default function ThreePanelLayout() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'skills':
|
||||||
|
return (
|
||||||
|
<SkillsPane
|
||||||
|
slot={slot}
|
||||||
|
isActive={isActive}
|
||||||
|
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
|
||||||
|
onCollapse={onCollapse}
|
||||||
|
onSwapPanes={slotB ? handleSwapPanes : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SkillMeta[]>([]);
|
||||||
|
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'transparent',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
|
||||||
|
{selectedSkill ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSkill(null)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#888',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.color = '#ccc';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.color = '#888';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span style={{ color: '#ccc', fontSize: '13px', fontWeight: 500 }}>{selectedSkill.name}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#666', fontSize: '11px' }}>{skills.length} skills</span>
|
||||||
|
)}
|
||||||
|
</PaneHeader>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>Loading...</div>
|
||||||
|
) : selectedSkill ? (
|
||||||
|
<div className="skill-content" style={{ color: '#ccc', fontSize: '13px', lineHeight: '1.6' }}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{selectedSkill.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
|
||||||
|
No skills found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
skills.map((skill) => (
|
||||||
|
<button
|
||||||
|
key={skill.name}
|
||||||
|
onClick={() => handleSelectSkill(skill.name)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#161616',
|
||||||
|
border: '1px solid #222',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.background = '#1a1a1a';
|
||||||
|
e.currentTarget.style.borderColor = '#333';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = '#161616';
|
||||||
|
e.currentTarget.style.borderColor = '#222';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ color: '#ddd', fontSize: '13px', fontWeight: 500 }}>{skill.name}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: '#777',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
display: 'block',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={e => handleDeleteSkill(skill.name, e)}
|
||||||
|
disabled={deleting === skill.name}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#555',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: deleting === skill.name ? 0.3 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.color = '#ef4444';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.color = '#555';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@ export { default as DimensionsPane } from './DimensionsPane';
|
|||||||
export { default as MapPane } from './MapPane';
|
export { default as MapPane } from './MapPane';
|
||||||
export { default as ViewsPane } from './ViewsPane';
|
export { default as ViewsPane } from './ViewsPane';
|
||||||
export { default as TablePane } from './TablePane';
|
export { default as TablePane } from './TablePane';
|
||||||
|
export { default as SkillsPane } from './SkillsPane';
|
||||||
export { default as PaneHeader } from './PaneHeader';
|
export { default as PaneHeader } from './PaneHeader';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export type AgentDelegation = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The four pane types (chat removed in rah-light, guides moved to settings)
|
// Pane types (chat removed in rah-light)
|
||||||
export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table';
|
export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
|
||||||
|
|
||||||
// State for each slot
|
// State for each slot
|
||||||
export interface SlotState {
|
export interface SlotState {
|
||||||
@@ -111,6 +111,7 @@ export const PANE_LABELS: Record<PaneType, string> = {
|
|||||||
map: 'Map',
|
map: 'Map',
|
||||||
views: 'Feed',
|
views: 'Feed',
|
||||||
table: 'Table',
|
table: 'Table',
|
||||||
|
skills: 'Skills',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default slot states
|
// Default slot states
|
||||||
|
|||||||
@@ -1,196 +1,13 @@
|
|||||||
import fs from 'fs';
|
export {
|
||||||
import path from 'path';
|
listSkills as listGuides,
|
||||||
import os from 'os';
|
readSkill as readGuide,
|
||||||
import matter from 'gray-matter';
|
writeSkill as writeGuide,
|
||||||
|
deleteSkill as deleteGuide,
|
||||||
|
getUserSkillCount as getUserGuideCount,
|
||||||
|
getSkillStats as getGuideStats,
|
||||||
|
} from '@/services/skills/skillService';
|
||||||
|
|
||||||
export interface GuideMeta {
|
export type {
|
||||||
name: string;
|
SkillMeta as GuideMeta,
|
||||||
description: string;
|
Skill as Guide,
|
||||||
immutable: boolean;
|
} from '@/services/skills/skillService';
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user