feat: add agent skills tab and local dev helpers
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,3 +47,4 @@ tmp/
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
.superset/
|
||||
.claude/worktrees/
|
||||
|
||||
71
scripts/kill-dev.sh
Executable file
71
scripts/kill-dev.sh
Executable file
@@ -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."
|
||||
@@ -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<string>();
|
||||
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: [
|
||||
|
||||
@@ -144,4 +144,12 @@ export const agentsApi = {
|
||||
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
|
||||
loginWithClaude: (id: string, companyId?: string) =>
|
||||
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
||||
availableSkills: () =>
|
||||
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
|
||||
};
|
||||
|
||||
export interface AvailableSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
isPaperclipManaged: boolean;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +365,8 @@ export function AgentDetail() {
|
||||
const canonicalTab =
|
||||
activeView === "configuration"
|
||||
? "configuration"
|
||||
: activeView === "skills"
|
||||
? "skills"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: activeView === "budget"
|
||||
@@ -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" && (
|
||||
<SkillsTab
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "runs" && (
|
||||
<RunsTab
|
||||
runs={heartbeats ?? []}
|
||||
@@ -1200,6 +1213,36 @@ function ConfigurationTab({
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsTab({ agent }: { agent: Agent; companyId?: string }) {
|
||||
const instructionsPath =
|
||||
typeof agent.adapterConfig?.instructionsFilePath === "string" && agent.adapterConfig.instructionsFilePath.trim().length > 0
|
||||
? agent.adapterConfig.instructionsFilePath
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-sm font-medium">Skills</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Agent: <span className="font-mono">{agent.name}</span>
|
||||
</p>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground mb-1">
|
||||
Instructions file
|
||||
</div>
|
||||
<div className="font-mono break-all">
|
||||
{instructionsPath ?? "No instructions file configured for this agent."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Runs Tab ---- */
|
||||
|
||||
function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {
|
||||
|
||||
Reference in New Issue
Block a user