diff --git a/app/api/projects/[project_id]/skills/[name]/route.ts b/app/api/projects/[project_id]/skills/[name]/route.ts new file mode 100644 index 00000000..f7a3ebed --- /dev/null +++ b/app/api/projects/[project_id]/skills/[name]/route.ts @@ -0,0 +1,45 @@ +/** + * Single skill API + * GET /api/projects/[project_id]/skills/[name] - read a skill + * DELETE /api/projects/[project_id]/skills/[name] - delete a skill + */ + +import { NextRequest } from 'next/server'; +import { getSkill, deleteSkill, SkillError } from '@/lib/services/skills'; +import { createSuccessResponse, createErrorResponse, handleApiError } from '@/lib/utils/api-response'; + +interface RouteContext { + params: Promise<{ project_id: string; name: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteContext) { + try { + const { project_id, name } = await params; + const skill = await getSkill(project_id, name); + if (!skill) { + return createErrorResponse('Skill not found', undefined, 404); + } + return createSuccessResponse(skill); + } catch (error) { + if (error instanceof SkillError) { + return createErrorResponse(error.message, undefined, error.status); + } + return handleApiError(error, 'API', 'Failed to read skill'); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteContext) { + try { + const { project_id, name } = await params; + const ok = await deleteSkill(project_id, name); + return createSuccessResponse({ deleted: ok, name }); + } catch (error) { + if (error instanceof SkillError) { + return createErrorResponse(error.message, undefined, error.status); + } + return handleApiError(error, 'API', 'Failed to delete skill'); + } +} + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; diff --git a/app/api/projects/[project_id]/skills/route.ts b/app/api/projects/[project_id]/skills/route.ts new file mode 100644 index 00000000..e048c514 --- /dev/null +++ b/app/api/projects/[project_id]/skills/route.ts @@ -0,0 +1,51 @@ +/** + * Per-project Skills API + * GET /api/projects/[project_id]/skills - list skills + * POST /api/projects/[project_id]/skills - create/update a skill + */ + +import { NextRequest } from 'next/server'; +import { listAllSkills, saveSkill, SkillError } from '@/lib/services/skills'; +import { createSuccessResponse, createErrorResponse, handleApiError } from '@/lib/utils/api-response'; + +interface RouteContext { + params: Promise<{ project_id: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteContext) { + try { + const { project_id } = await params; + const skills = await listAllSkills(project_id); + return createSuccessResponse(skills); + } catch (error) { + if (error instanceof SkillError) { + return createErrorResponse(error.message, undefined, error.status); + } + return handleApiError(error, 'API', 'Failed to list skills'); + } +} + +export async function POST(request: NextRequest, { params }: RouteContext) { + try { + const { project_id } = await params; + const body = await request.json().catch(() => ({})); + if (!body || typeof body.name !== 'string' || body.name.trim().length === 0) { + return createErrorResponse('name is required', undefined, 400); + } + const skill = await saveSkill(project_id, { + name: body.name, + description: typeof body.description === 'string' ? body.description : '', + content: typeof body.content === 'string' ? body.content : '', + raw: typeof body.raw === 'string' ? body.raw : undefined, + }); + return createSuccessResponse(skill, 201); + } catch (error) { + if (error instanceof SkillError) { + return createErrorResponse(error.message, undefined, error.status); + } + return handleApiError(error, 'API', 'Failed to save skill'); + } +} + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; diff --git a/components/settings/ProjectSettings.tsx b/components/settings/ProjectSettings.tsx index 0c6dee0d..f3726080 100644 --- a/components/settings/ProjectSettings.tsx +++ b/components/settings/ProjectSettings.tsx @@ -9,6 +9,7 @@ import { GeneralSettings } from './GeneralSettings'; import { AIAssistantSettings } from './AIAssistantSettings'; import { EnvironmentSettings } from './EnvironmentSettings'; import { ServiceSettings } from './ServiceSettings'; +import { SkillsSettings } from './SkillsSettings'; import GlobalSettings from './GlobalSettings'; interface ProjectSettingsProps { @@ -21,7 +22,7 @@ interface ProjectSettingsProps { onProjectUpdated?: (update: { name: string; description?: string | null }) => void; } -type SettingsTab = 'general' | 'ai-assistant' | 'environment' | 'services'; +type SettingsTab = 'general' | 'ai-assistant' | 'environment' | 'services' | 'skills'; export function ProjectSettings({ isOpen, @@ -58,6 +59,12 @@ export function ProjectSettings({ label: 'Services', icon: , }, + { + id: 'skills' as SettingsTab, + label: 'Skills', + icon: , + hidden: !isProjectScoped, + }, ].filter(tab => !('hidden' in tab) || !tab.hidden), [isProjectScoped] ); @@ -140,8 +147,8 @@ export function ProjectSettings({ )} {activeTab === 'services' && ( - { // Open Global Settings with services tab setShowGlobalSettings(true); @@ -149,6 +156,10 @@ export function ProjectSettings({ }} /> )} + + {activeTab === 'skills' && ( + + )} diff --git a/components/settings/SkillsSettings.tsx b/components/settings/SkillsSettings.tsx new file mode 100644 index 00000000..3635d8dd --- /dev/null +++ b/components/settings/SkillsSettings.tsx @@ -0,0 +1,314 @@ +/** + * Skills Settings — manage per-project Agent Skills and view global (shared) skills. + * Skills are auto-loaded by the agent (settingSources: ['project','user']). + */ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? ''; + +interface Skill { + name: string; + description: string; + content: string; + raw: string; + scope: 'project' | 'global'; +} + +interface SkillsSettingsProps { + projectId: string; +} + +const EXAMPLE = `When asked to do X, follow these steps: +1. ... +2. ... + +Reference any conventions or helper files the agent should follow.`; + +function SkillCard({ + skill, + onEdit, + onDelete, +}: { + skill: Skill; + onEdit?: () => void; + onDelete?: () => void; +}) { + const [open, setOpen] = useState(false); + const isGlobal = skill.scope === 'global'; + return ( +
+
+
+ ✦ +
+
+
+ {skill.name} + + {isGlobal ? 'Global' : 'Project'} + +
+

+ {skill.description || 'No description'} +

+
+ + {onEdit && ( + + )} + {onDelete && ( + + )} +
+ {open && ( +
+              {skill.content || '(no body)'}
+            
+ )} +
+
+
+ ); +} + +export function SkillsSettings({ projectId }: SkillsSettingsProps) { + const [project, setProject] = useState([]); + const [global, setGlobal] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [query, setQuery] = useState(''); + + const [editing, setEditing] = useState(null); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [content, setContent] = useState(''); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + setIsLoading(true); + try { + const res = await fetch(`${API_BASE}/api/projects/${projectId}/skills`); + const json = await res.json(); + const data = json?.data ?? {}; + setProject(Array.isArray(data.project) ? data.project : []); + setGlobal(Array.isArray(data.global) ? data.global : []); + } catch (e) { + console.error('Failed to load skills:', e); + } finally { + setIsLoading(false); + } + }, [projectId]); + + useEffect(() => { + load(); + }, [load]); + + const resetForm = () => { + setEditing(null); + setName(''); + setDescription(''); + setContent(''); + setError(null); + }; + const startNew = () => { + resetForm(); + setEditing('__new__'); + }; + const startEdit = (s: Skill) => { + setEditing(s.name); + setName(s.name); + setDescription(s.description); + setContent(s.content); + setError(null); + }; + + const save = async () => { + if (!name.trim()) { + setError('Name is required'); + return; + } + setSaving(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/api/projects/${projectId}/skills`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description, content }), + }); + const json = await res.json(); + if (!res.ok || json?.success === false) { + setError(json?.error || 'Failed to save skill'); + return; + } + resetForm(); + await load(); + } catch { + setError('Failed to save skill'); + } finally { + setSaving(false); + } + }; + + const remove = async (skillName: string) => { + try { + await fetch(`${API_BASE}/api/projects/${projectId}/skills/${encodeURIComponent(skillName)}`, { + method: 'DELETE', + }); + if (editing === skillName) resetForm(); + await load(); + } catch (e) { + console.error('Failed to delete skill:', e); + } + }; + + const filteredGlobal = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return global; + return global.filter( + (s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q) + ); + }, [global, query]); + + return ( +
+
+

Skills

+

+ Reusable instructions the agent loads automatically. Project skills live in this project; global + skills are shared across all projects. +

+
+ + {/* Project skills */} +
+
+

+ Project skills + + {project.length} + +

+ {editing === null && ( + + )} +
+ + {editing !== null && ( +
+
+ {editing === '__new__' ? 'New skill' : `Edit: ${editing}`} +
+ setName(e.target.value)} + disabled={editing !== '__new__'} + placeholder="skill-name (e.g. brand-voice)" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-100" + /> + setDescription(e.target.value)} + placeholder="Description — when should the agent use this?" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> +