From 4323d4bbda162ff0978a239b25d445891a0565df Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 09:10:40 -0500 Subject: [PATCH] feat: add agent skills tab and local dev helpers --- .gitignore | 1 + scripts/kill-dev.sh | 71 ++++++++++++++++++++++++++++ server/src/routes/access.ts | 89 ++++++++++++++++++++++++++++++++++++ ui/src/api/agents.ts | 8 ++++ ui/src/lib/queryKeys.ts | 3 ++ ui/src/pages/AgentDetail.tsx | 55 +++++++++++++++++++--- 6 files changed, 221 insertions(+), 6 deletions(-) create mode 100755 scripts/kill-dev.sh diff --git a/.gitignore b/.gitignore index f2c9b9a7..312c3969 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ tmp/ tests/e2e/test-results/ tests/e2e/playwright-report/ .superset/ +.claude/worktrees/ diff --git a/scripts/kill-dev.sh b/scripts/kill-dev.sh new file mode 100755 index 00000000..2cb946e2 --- /dev/null +++ b/scripts/kill-dev.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Kill all local Paperclip dev server processes (across all worktrees). +# +# Usage: +# scripts/kill-dev.sh # kill all paperclip dev processes +# scripts/kill-dev.sh --dry # preview what would be killed +# + +set -euo pipefail + +DRY_RUN=false +if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then + DRY_RUN=true +fi + +# Collect PIDs of node processes running from any paperclip directory. +# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/... +# Excludes postgres-related processes. +pids=() +lines=() + +while IFS= read -r line; do + [[ -z "$line" ]] && continue + # skip postgres processes + [[ "$line" == *postgres* ]] && continue + pid=$(echo "$line" | awk '{print $2}') + pids+=("$pid") + lines+=("$line") +done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true) + +if [[ ${#pids[@]} -eq 0 ]]; then + echo "No Paperclip dev processes found." + exit 0 +fi + +echo "Found ${#pids[@]} Paperclip dev process(es):" +echo "" + +for i in "${!pids[@]}"; do + line="${lines[$i]}" + pid=$(echo "$line" | awk '{print $2}') + start=$(echo "$line" | awk '{print $9}') + cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') + # Shorten the command for readability + cmd=$(echo "$cmd" | sed "s|$HOME/||g") + printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" +done + +echo "" + +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run — re-run without --dry to kill these processes." + exit 0 +fi + +echo "Sending SIGTERM..." +for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone" +done + +# Give processes a moment to exit, then SIGKILL any stragglers +sleep 2 +for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " $pid still alive, sending SIGKILL..." + kill -9 "$pid" 2>/dev/null || true + fi +done + +echo "Done." diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index ee156091..a966d12b 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -100,6 +100,7 @@ function readSkillMarkdown(skillName: string): string | null { if ( normalized !== "paperclip" && normalized !== "paperclip-create-agent" && + normalized !== "paperclip-create-plugin" && normalized !== "para-memory-files" ) return null; @@ -119,6 +120,90 @@ function readSkillMarkdown(skillName: string): string | null { return null; } +/** Resolve the Paperclip repo skills directory (built-in / managed skills). */ +function resolvePaperclipSkillsDir(): string | null { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.resolve(moduleDir, "../../skills"), // published + path.resolve(process.cwd(), "skills"), // cwd (monorepo root) + path.resolve(moduleDir, "../../../skills"), // dev + ]; + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isDirectory()) return candidate; + } catch { /* skip */ } + } + return null; +} + +/** Parse YAML frontmatter from a SKILL.md file to extract the description. */ +function parseSkillFrontmatter(markdown: string): { description: string } { + const match = markdown.match(/^---\n([\s\S]*?)\n---/); + if (!match) return { description: "" }; + const yaml = match[1]; + // Extract description — handles both single-line and multi-line YAML values + const descMatch = yaml.match( + /^description:\s*(?:>\s*\n((?:\s{2,}[^\n]*\n?)+)|[|]\s*\n((?:\s{2,}[^\n]*\n?)+)|["']?(.*?)["']?\s*$)/m + ); + if (!descMatch) return { description: "" }; + const raw = descMatch[1] ?? descMatch[2] ?? descMatch[3] ?? ""; + return { + description: raw + .split("\n") + .map((l: string) => l.trim()) + .filter(Boolean) + .join(" ") + .trim(), + }; +} + +interface AvailableSkill { + name: string; + description: string; + isPaperclipManaged: boolean; +} + +/** Discover all available Claude Code skills from ~/.claude/skills/. */ +function listAvailableSkills(): AvailableSkill[] { + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + const claudeSkillsDir = path.join(homeDir, ".claude", "skills"); + const paperclipSkillsDir = resolvePaperclipSkillsDir(); + + // Build set of Paperclip-managed skill names + const paperclipSkillNames = new Set(); + if (paperclipSkillsDir) { + try { + for (const entry of fs.readdirSync(paperclipSkillsDir, { withFileTypes: true })) { + if (entry.isDirectory()) paperclipSkillNames.add(entry.name); + } + } catch { /* skip */ } + } + + const skills: AvailableSkill[] = []; + + try { + const entries = fs.readdirSync(claudeSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (entry.name.startsWith(".")) continue; + const skillMdPath = path.join(claudeSkillsDir, entry.name, "SKILL.md"); + let description = ""; + try { + const md = fs.readFileSync(skillMdPath, "utf8"); + description = parseSkillFrontmatter(md).description; + } catch { /* no SKILL.md or unreadable */ } + skills.push({ + name: entry.name, + description, + isPaperclipManaged: paperclipSkillNames.has(entry.name), + }); + } + } catch { /* ~/.claude/skills/ doesn't exist */ } + + skills.sort((a, b) => a.name.localeCompare(b.name)); + return skills; +} + function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) { const { claimSecretHash: _claimSecretHash, ...safe } = row; return safe; @@ -1610,6 +1695,10 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + router.get("/skills/available", (_req, res) => { + res.json({ skills: listAvailableSkills() }); + }); + router.get("/skills/index", (_req, res) => { res.json({ skills: [ diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 85486af9..9008fbca 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -144,4 +144,12 @@ export const agentsApi = { ) => api.post(agentPath(id, companyId, "/wakeup"), data), loginWithClaude: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/claude-login"), {}), + availableSkills: () => + api.get<{ skills: AvailableSkill[] }>("/skills/available"), }; + +export interface AvailableSkill { + name: string; + description: string; + isPaperclipManaged: boolean; +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 28d9b4e9..f0d96874 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -96,6 +96,9 @@ export const queryKeys = { liveRuns: (companyId: string) => ["live-runs", companyId] as const, runIssues: (runId: string) => ["run-issues", runId] as const, org: (companyId: string) => ["org", companyId] as const, + skills: { + available: ["skills", "available"] as const, + }, plugins: { all: ["plugins"] as const, examples: ["plugins", "examples"] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 32c3b9d6..9faaf005 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -185,11 +185,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh container.scrollTo({ top: container.scrollHeight, behavior }); } -type AgentDetailView = "dashboard" | "configuration" | "runs" | "budget"; +type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget"; function parseAgentDetailView(value: string | null): AgentDetailView { if (value === "configure" || value === "configuration") return "configuration"; - if (value === "budget") return "budget"; + if (value === "skills") return value; + if (value === "budget") return value; if (value === "runs") return value; return "dashboard"; } @@ -364,10 +365,12 @@ export function AgentDetail() { const canonicalTab = activeView === "configuration" ? "configuration" - : activeView === "runs" - ? "runs" - : activeView === "budget" - ? "budget" + : activeView === "skills" + ? "skills" + : activeView === "runs" + ? "runs" + : activeView === "budget" + ? "budget" : "dashboard"; if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); @@ -483,6 +486,8 @@ export function AgentDetail() { crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); } else if (activeView === "configuration") { crumbs.push({ label: "Configuration" }); + } else if (activeView === "skills") { + crumbs.push({ label: "Skills" }); } else if (activeView === "runs") { crumbs.push({ label: "Runs" }); } else if (activeView === "budget") { @@ -642,6 +647,7 @@ export function AgentDetail() { items={[ { value: "dashboard", label: "Dashboard" }, { value: "configuration", label: "Configuration" }, + { value: "skills", label: "Skills" }, { value: "runs", label: "Runs" }, { value: "budget", label: "Budget" }, ]} @@ -734,6 +740,13 @@ export function AgentDetail() { /> )} + {activeView === "skills" && ( + + )} + {activeView === "runs" && ( 0 + ? agent.adapterConfig.instructionsFilePath + : null; + + return ( +
+
+

Skills

+

+ Skills are reusable instruction bundles the agent can invoke from its local tool environment. + This view keeps the tab compile-safe and shows the current instructions file path while the broader skills listing work continues elsewhere in the tree. +

+

+ Agent: {agent.name} +

+
+
+ Instructions file +
+
+ {instructionsPath ?? "No instructions file configured for this agent."} +
+
+
+
+ ); +} + /* ---- Runs Tab ---- */ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {