Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/app/api/workspaces/[id]/skills/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
10 changes: 10 additions & 0 deletions src/app/workspace/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -42,6 +43,15 @@ export default async function WorkspacePage({
</div>
</section>

<section>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-neutral-500">
Skills
</h2>
<div className="mt-3">
<SkillsGrid workspaceId={workspace.id} />
</div>
</section>

<section>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-neutral-500">
Agents
Expand Down
161 changes: 128 additions & 33 deletions src/components/CreateAgentForm.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(
new Set(),
)
const [availableSkills, setAvailableSkills] = useState<Skill[]>([])
const [skillsError, setSkillsError] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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)
Expand All @@ -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) {
Expand All @@ -41,7 +81,7 @@ export function CreateAgentForm({ workspaceId }: { workspaceId: string }) {
setName('')
setSystemPrompt('')
setModel('')
setSkills('')
setSelectedSkills(new Set())
startTransition(() => router.refresh())
}

Expand Down Expand Up @@ -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"
/>
</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-[0.16em] text-neutral-500">
Model (optional, e.g. opus / sonnet)
</span>
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="sonnet"
className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-sm focus:border-neutral-600 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-[0.16em] text-neutral-500">
Skills (comma-separated)
</span>
<input
type="text"
value={skills}
onChange={(e) => setSkills(e.target.value)}
placeholder="review, simplify"
className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-sm focus:border-neutral-600 focus:outline-none"
/>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-[0.16em] text-neutral-500">
Model (optional, e.g. opus / sonnet)
</span>
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="sonnet"
className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-sm focus:border-neutral-600 focus:outline-none"
/>
</label>

<fieldset className="flex flex-col gap-2">
<legend className="text-xs uppercase tracking-[0.16em] text-neutral-500">
Skills (pick from this workspace and your user-level set)
</legend>
{skillsError && (
<div className="text-xs text-red-400">{skillsError}</div>
)}
{availableSkills.length === 0 && !skillsError && (
<div className="rounded border border-dashed border-neutral-800 px-3 py-2 text-xs text-neutral-500">
no skills discovered yet.
</div>
)}
{availableSkills.length > 0 && (
<div className="grid grid-cols-1 gap-1 rounded border border-neutral-800 bg-neutral-950 p-2 sm:grid-cols-2">
{availableSkills.map((skill) => {
const checked = selectedSkills.has(skill.name)
const key = `${skill.source}:${skill.path}`
return (
<label
key={key}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs hover:bg-neutral-900"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSkill(skill.name)}
className="accent-amber-500"
/>
<span className="font-mono text-neutral-200">
{skill.name}
</span>
<span
className={`ml-auto rounded border px-1.5 py-0.5 font-mono text-[10px] ${
skill.source === 'project'
? 'border-amber-900 text-amber-500'
: 'border-neutral-700 text-neutral-500'
}`}
>
{skill.source}
</span>
</label>
)
})}
</div>
)}
{/* 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) => (
<label
key={`missing:${s}`}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs text-neutral-500"
>
<input
type="checkbox"
checked
onChange={() => toggleSkill(s)}
className="accent-amber-500"
/>
<span className="font-mono">{s}</span>
<span className="ml-auto rounded border border-red-900 px-1.5 py-0.5 font-mono text-[10px] text-red-400">
missing
</span>
</label>
))}
</fieldset>

{error && <div className="text-sm text-red-400">{error}</div>}
<button
type="submit"
Expand Down
120 changes: 120 additions & 0 deletions src/components/SkillsGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
'use client'

import { useCallback, useEffect, useState } from 'react'
import type { Skill } from '@/lib/skills'

/**
* Grid of skills discovered under the workspace's `.claude/skills/`
* and the user-level `~/.claude/skills/`. Read-only browsing surface.
* The agent form (CreateAgentForm) reuses the same fetch to let the
* user pick which ones to pin per agent.
*/
export function SkillsGrid({ workspaceId }: { workspaceId: string }) {
const [skills, setSkills] = useState<Skill[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)

const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/workspaces/${workspaceId}/skills`)
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string
}
setError(body.error || 'failed to load skills')
setSkills([])
return
}
const body = (await res.json()) as { skills: Skill[] }
setSkills(body.skills)
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to load skills')
setSkills([])
} finally {
setLoading(false)
}
}, [workspaceId])

useEffect(() => {
load()
}, [load])

return (
<div>
<div className="flex items-center justify-between">
<p className="text-xs text-neutral-500">
Discovered under <code className="font-mono">~/.claude/skills/</code>{' '}
and{' '}
<code className="font-mono">
&lt;workspace&gt;/.claude/skills/
</code>
.
</p>
<button
onClick={load}
disabled={loading}
className="text-xs text-neutral-500 hover:text-neutral-300 disabled:opacity-50"
>
{loading ? 'scanning...' : 'rescan'}
</button>
</div>

{error && <div className="mt-2 text-xs text-red-400">{error}</div>}

{skills && skills.length === 0 && !error && (
<div className="mt-3 rounded border border-dashed border-neutral-800 p-4 text-xs text-neutral-500">
no skills found. drop a SKILL.md under{' '}
<code className="font-mono">~/.claude/skills/&lt;name&gt;/</code> or{' '}
<code className="font-mono">
&lt;workspace&gt;/.claude/skills/&lt;name&gt;/
</code>
.
</div>
)}

{skills && skills.length > 0 && (
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{skills.map((skill) => (
<SkillCard key={`${skill.source}:${skill.path}`} skill={skill} />
))}
</div>
)}
</div>
)
}

function SkillCard({ skill }: { skill: Skill }) {
return (
<div className="flex flex-col gap-2 rounded border border-neutral-800 bg-neutral-900/40 p-3">
<div className="flex items-start justify-between gap-2">
<div className="font-mono text-sm font-medium text-neutral-100">
{skill.name}
</div>
<SourceBadge source={skill.source} />
</div>
{skill.description ? (
<p className="line-clamp-2 text-xs text-neutral-400">
{skill.description}
</p>
) : (
<p className="text-xs text-neutral-600 italic">no description</p>
)}
</div>
)
}

function SourceBadge({ source }: { source: 'user' | 'project' }) {
const classes =
source === 'project'
? 'border-amber-900 text-amber-500'
: 'border-neutral-700 text-neutral-400'
return (
<span
className={`shrink-0 rounded border px-1.5 py-0.5 font-mono text-[10px] ${classes}`}
>
{source}
</span>
)
}
Loading
Loading