diff --git a/src/app/api/workspaces/[id]/skills/route.ts b/src/app/api/workspaces/[id]/skills/route.ts new file mode 100644 index 0000000..cfe641f --- /dev/null +++ b/src/app/api/workspaces/[id]/skills/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server' +import { db, schema } from '@/lib/db' +import { eq } from 'drizzle-orm' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { scanSkills } from '@/lib/skills' + +export const runtime = 'nodejs' + +/** + * GET /api/workspaces/[id]/skills + * + * Returns the union of skills under ~/.claude/skills/ and the + * workspace's own .claude/skills/. Always rescanned (cheap local FS). + */ +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const [workspace] = await db + .select() + .from(schema.workspaces) + .where(eq(schema.workspaces.id, id)) + if (!workspace) { + return NextResponse.json({ error: 'workspace not found' }, { status: 404 }) + } + if (!path.isAbsolute(workspace.cwd)) { + return NextResponse.json( + { error: 'workspace cwd is not an absolute path' }, + { status: 400 }, + ) + } + try { + const stat = await fs.stat(workspace.cwd) + if (!stat.isDirectory()) { + return NextResponse.json( + { error: 'workspace cwd is not a directory' }, + { status: 400 }, + ) + } + } catch { + return NextResponse.json( + { error: 'workspace cwd does not exist on disk' }, + { status: 400 }, + ) + } + + const skills = await scanSkills(workspace.cwd) + return NextResponse.json({ skills }) +} diff --git a/src/app/workspace/[id]/page.tsx b/src/app/workspace/[id]/page.tsx index 791d3be..a599337 100644 --- a/src/app/workspace/[id]/page.tsx +++ b/src/app/workspace/[id]/page.tsx @@ -4,6 +4,7 @@ import { db, schema } from '@/lib/db' import { desc, eq } from 'drizzle-orm' import { CreateAgentForm } from '@/components/CreateAgentForm' import { AgentPanel } from '@/components/AgentPanel' +import { SkillsGrid } from '@/components/SkillsGrid' export const dynamic = 'force-dynamic' @@ -42,6 +43,15 @@ export default async function WorkspacePage({ +
+

+ Skills +

+
+ +
+
+

Agents diff --git a/src/components/CreateAgentForm.tsx b/src/components/CreateAgentForm.tsx index 4d5da89..e71452a 100644 --- a/src/components/CreateAgentForm.tsx +++ b/src/components/CreateAgentForm.tsx @@ -1,17 +1,60 @@ 'use client' -import { useState, useTransition } from 'react' +import { useCallback, useEffect, useState, useTransition } from 'react' import { useRouter } from 'next/navigation' +import type { Skill } from '@/lib/skills' export function CreateAgentForm({ workspaceId }: { workspaceId: string }) { const [name, setName] = useState('') const [systemPrompt, setSystemPrompt] = useState('') const [model, setModel] = useState('') - const [skills, setSkills] = useState('') + // Selected skill names. Picked from a checkbox list populated by + // the workspace's discovered skills (user + project roots). + const [selectedSkills, setSelectedSkills] = useState>( + new Set(), + ) + const [availableSkills, setAvailableSkills] = useState([]) + const [skillsError, setSkillsError] = useState(null) const [error, setError] = useState(null) const [pending, startTransition] = useTransition() const router = useRouter() + const loadSkills = useCallback(async () => { + setSkillsError(null) + try { + const res = await fetch(`/api/workspaces/${workspaceId}/skills`) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: string + } + setSkillsError(body.error || 'failed to load skills') + setAvailableSkills([]) + return + } + const body = (await res.json()) as { skills: Skill[] } + setAvailableSkills(body.skills) + } catch (err) { + setSkillsError(err instanceof Error ? err.message : 'failed to load skills') + setAvailableSkills([]) + } + }, [workspaceId]) + + useEffect(() => { + loadSkills() + }, [loadSkills]) + + function toggleSkill(name: string) { + setSelectedSkills((cur) => { + const next = new Set(cur) + if (next.has(name)) { + next.delete(name) + } else { + next.add(name) + } + return next + }) + } + async function submit(e: React.FormEvent) { e.preventDefault() setError(null) @@ -27,10 +70,7 @@ export function CreateAgentForm({ workspaceId }: { workspaceId: string }) { name: name.trim(), systemPrompt: systemPrompt.trim(), model: model.trim() || null, - skills: skills - .split(',') - .map((s) => s.trim()) - .filter(Boolean), + skills: Array.from(selectedSkills), }), }) if (!res.ok) { @@ -41,7 +81,7 @@ export function CreateAgentForm({ workspaceId }: { workspaceId: string }) { setName('') setSystemPrompt('') setModel('') - setSkills('') + setSelectedSkills(new Set()) startTransition(() => router.refresh()) } @@ -71,32 +111,87 @@ export function CreateAgentForm({ workspaceId }: { workspaceId: string }) { className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm focus:border-neutral-600 focus:outline-none" /> -
- - -
+ + +
+ + Skills (pick from this workspace and your user-level set) + + {skillsError && ( +
{skillsError}
+ )} + {availableSkills.length === 0 && !skillsError && ( +
+ no skills discovered yet. +
+ )} + {availableSkills.length > 0 && ( +
+ {availableSkills.map((skill) => { + const checked = selectedSkills.has(skill.name) + const key = `${skill.source}:${skill.path}` + return ( + + ) + })} +
+ )} + {/* If a previously-pinned skill no longer exists on disk, show + it tagged "missing" so the user can see why it isn't loading. */} + {Array.from(selectedSkills) + .filter((s) => !availableSkills.some((a) => a.name === s)) + .map((s) => ( + + ))} +
+ {error &&
{error}
} + + + {error &&
{error}
} + + {skills && skills.length === 0 && !error && ( +
+ no skills found. drop a SKILL.md under{' '} + ~/.claude/skills/<name>/ or{' '} + + <workspace>/.claude/skills/<name>/ + + . +
+ )} + + {skills && skills.length > 0 && ( +
+ {skills.map((skill) => ( + + ))} +
+ )} + + ) +} + +function SkillCard({ skill }: { skill: Skill }) { + return ( +
+
+
+ {skill.name} +
+ +
+ {skill.description ? ( +

+ {skill.description} +

+ ) : ( +

no description

+ )} +
+ ) +} + +function SourceBadge({ source }: { source: 'user' | 'project' }) { + const classes = + source === 'project' + ? 'border-amber-900 text-amber-500' + : 'border-neutral-700 text-neutral-400' + return ( + + {source} + + ) +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 8010314..27fc6e8 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -23,11 +23,27 @@ declare global { const DB_PATH = process.env.ARGUS_DB_PATH || path.join(process.cwd(), 'argus.db') -const sqlite = globalThis.__argus_db ?? new Database(DB_PATH) -sqlite.pragma('journal_mode = WAL') -sqlite.pragma('foreign_keys = ON') +function openSqlite(file: string): Database.Database { + const d = new Database(file) + d.pragma('busy_timeout = 5000') + d.pragma('journal_mode = WAL') + d.pragma('foreign_keys = ON') + return d +} + +// During `next build`, Next.js spawns multiple worker processes that +// each import this module in parallel. Setting `journal_mode = WAL` +// requires an exclusive lock and does NOT honor busy_timeout, so the +// races produce SQLITE_BUSY on the file DB. Build never executes +// queries; it just imports modules for static analysis. So during the +// build phase we point at an in-memory DB instead, which is per-process +// and conflict-free. At `next start` and `next dev` we hit the real +// file as usual. +const isBuild = process.env.NEXT_PHASE === 'phase-production-build' +const sqlite = + globalThis.__argus_db ?? openSqlite(isBuild ? ':memory:' : DB_PATH) -if (process.env.NODE_ENV !== 'production') { +if (!isBuild && process.env.NODE_ENV !== 'production') { globalThis.__argus_db = sqlite } @@ -37,41 +53,51 @@ export const db = drizzle(sqlite, { schema }) * Run pending migrations at import time. Called once per process. * Cheap because better-sqlite3 keeps the migration tracker in the * same SQLite file. + * + * The schema is fully idempotent (CREATE ... IF NOT EXISTS), so a + * SQLITE_BUSY from a parallel build worker that already migrated is + * harmless: we swallow it and trust the peer's success. */ export function ensureMigrated() { - // Inline minimal migration: create tables if missing. Drizzle Kit - // can replace this with proper migration files later (`pnpm - // db:generate`), but for v0.1 we want zero-setup boot. - sqlite.exec(` - CREATE TABLE IF NOT EXISTS workspaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - cwd TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS agents ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - name TEXT NOT NULL, - system_prompt TEXT DEFAULT '', - model TEXT, - skills TEXT DEFAULT '[]', - created_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS runs ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, - prompt TEXT NOT NULL, - output TEXT DEFAULT '', - status TEXT NOT NULL DEFAULT 'running', - exit_code INTEGER, - started_at INTEGER NOT NULL, - ended_at INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_agents_workspace ON agents(workspace_id); - CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id); - CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at DESC); - `) + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + cwd TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + system_prompt TEXT DEFAULT '', + model TEXT, + skills TEXT DEFAULT '[]', + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + prompt TEXT NOT NULL, + output TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'running', + exit_code INTEGER, + started_at INTEGER NOT NULL, + ended_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_agents_workspace ON agents(workspace_id); + CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id); + CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at DESC); + `) + } catch (err) { + const code = + err && typeof err === 'object' && 'code' in err + ? (err as { code?: string }).code + : undefined + if (code === 'SQLITE_BUSY') return + throw err + } } ensureMigrated() diff --git a/src/lib/skills.ts b/src/lib/skills.ts new file mode 100644 index 0000000..9bd6559 --- /dev/null +++ b/src/lib/skills.ts @@ -0,0 +1,122 @@ +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +/** + * Discovery for Claude Code "skills" on the local machine. + * + * A skill is a directory containing a SKILL.md file with YAML + * frontmatter (at minimum a name, usually a description). Two roots + * matter: + * + * user: ~/.claude/skills//SKILL.md + * project: /.claude/skills//SKILL.md + * + * For v1 we scan one level deep under each root. If a frontmatter + * block is missing or malformed we fall back to the directory name + * and an empty description rather than throwing, so a single bad + * skill doesn't break the marketplace. + */ + +export type SkillSource = 'user' | 'project' + +export type Skill = { + /** Display name. From frontmatter `name`, falling back to dir name. */ + name: string + /** Free-text description. From frontmatter `description`, may be empty. */ + description: string + /** Where we found it. */ + source: SkillSource + /** Absolute path to the SKILL.md on disk. */ + path: string +} + +function userSkillsRoot(): string { + return path.join(os.homedir(), '.claude', 'skills') +} + +function projectSkillsRoot(workspaceCwd: string): string { + return path.join(workspaceCwd, '.claude', 'skills') +} + +/** + * List skills under a single root. Returns [] if the root does not + * exist. Skill directories without SKILL.md are skipped silently. + */ +async function scanRoot( + root: string, + source: SkillSource, +): Promise { + let entries: import('node:fs').Dirent[] + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch { + return [] + } + + const skills: Skill[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + const skillMd = path.join(root, entry.name, 'SKILL.md') + let raw: string + try { + raw = await fs.readFile(skillMd, 'utf8') + } catch { + // No SKILL.md in this directory, skip. + continue + } + const fm = parseFrontmatter(raw) + skills.push({ + name: typeof fm.name === 'string' && fm.name.trim() ? fm.name.trim() : entry.name, + description: + typeof fm.description === 'string' ? fm.description.trim() : '', + source, + path: skillMd, + }) + } + return skills +} + +/** + * Returns the union of user + project skills. Both roots may contain + * a skill with the same name; we keep both, distinguished by source. + */ +export async function scanSkills(workspaceCwd: string): Promise { + const [userSkills, projectSkills] = await Promise.all([ + scanRoot(userSkillsRoot(), 'user'), + scanRoot(projectSkillsRoot(workspaceCwd), 'project'), + ]) + return [...userSkills, ...projectSkills].sort((a, b) => { + if (a.source !== b.source) return a.source === 'project' ? -1 : 1 + return a.name.localeCompare(b.name) + }) +} + +/** + * Tiny YAML frontmatter parser. Handles only the shape Claude Code + * skills use: a leading `---` block, one `key: value` per line, no + * nested structures. Anything fancier falls back to {} so the caller + * uses sensible defaults. + */ +function parseFrontmatter(src: string): Record { + const trimmed = src.replace(/^/, '') + if (!trimmed.startsWith('---')) return {} + const end = trimmed.indexOf('\n---', 3) + if (end < 0) return {} + const block = trimmed.slice(3, end).trim() + const out: Record = {} + for (const line of block.split('\n')) { + const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/) + if (!m) continue + let value = m[2].trim() + // Strip surrounding quotes if present. + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + out[m[1]] = value + } + return out +}