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/test-results/
|
||||||
tests/e2e/playwright-report/
|
tests/e2e/playwright-report/
|
||||||
.superset/
|
.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 (
|
if (
|
||||||
normalized !== "paperclip" &&
|
normalized !== "paperclip" &&
|
||||||
normalized !== "paperclip-create-agent" &&
|
normalized !== "paperclip-create-agent" &&
|
||||||
|
normalized !== "paperclip-create-plugin" &&
|
||||||
normalized !== "para-memory-files"
|
normalized !== "para-memory-files"
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
@@ -119,6 +120,90 @@ function readSkillMarkdown(skillName: string): string | null {
|
|||||||
return 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) {
|
function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) {
|
||||||
const { claimSecretHash: _claimSecretHash, ...safe } = row;
|
const { claimSecretHash: _claimSecretHash, ...safe } = row;
|
||||||
return safe;
|
return safe;
|
||||||
@@ -1610,6 +1695,10 @@ export function accessRoutes(
|
|||||||
return { token, created, normalizedAgentMessage };
|
return { token, created, normalizedAgentMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.get("/skills/available", (_req, res) => {
|
||||||
|
res.json({ skills: listAvailableSkills() });
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/skills/index", (_req, res) => {
|
router.get("/skills/index", (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
skills: [
|
skills: [
|
||||||
|
|||||||
@@ -144,4 +144,12 @@ export const agentsApi = {
|
|||||||
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
|
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
|
||||||
loginWithClaude: (id: string, companyId?: string) =>
|
loginWithClaude: (id: string, companyId?: string) =>
|
||||||
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
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,
|
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
|
||||||
runIssues: (runId: string) => ["run-issues", runId] as const,
|
runIssues: (runId: string) => ["run-issues", runId] as const,
|
||||||
org: (companyId: string) => ["org", companyId] as const,
|
org: (companyId: string) => ["org", companyId] as const,
|
||||||
|
skills: {
|
||||||
|
available: ["skills", "available"] as const,
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
all: ["plugins"] as const,
|
all: ["plugins"] as const,
|
||||||
examples: ["plugins", "examples"] as const,
|
examples: ["plugins", "examples"] as const,
|
||||||
|
|||||||
@@ -185,11 +185,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
|||||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
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 {
|
function parseAgentDetailView(value: string | null): AgentDetailView {
|
||||||
if (value === "configure" || value === "configuration") return "configuration";
|
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;
|
if (value === "runs") return value;
|
||||||
return "dashboard";
|
return "dashboard";
|
||||||
}
|
}
|
||||||
@@ -364,10 +365,12 @@ export function AgentDetail() {
|
|||||||
const canonicalTab =
|
const canonicalTab =
|
||||||
activeView === "configuration"
|
activeView === "configuration"
|
||||||
? "configuration"
|
? "configuration"
|
||||||
: activeView === "runs"
|
: activeView === "skills"
|
||||||
? "runs"
|
? "skills"
|
||||||
: activeView === "budget"
|
: activeView === "runs"
|
||||||
? "budget"
|
? "runs"
|
||||||
|
: activeView === "budget"
|
||||||
|
? "budget"
|
||||||
: "dashboard";
|
: "dashboard";
|
||||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||||
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
||||||
@@ -483,6 +486,8 @@ export function AgentDetail() {
|
|||||||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||||||
} else if (activeView === "configuration") {
|
} else if (activeView === "configuration") {
|
||||||
crumbs.push({ label: "Configuration" });
|
crumbs.push({ label: "Configuration" });
|
||||||
|
} else if (activeView === "skills") {
|
||||||
|
crumbs.push({ label: "Skills" });
|
||||||
} else if (activeView === "runs") {
|
} else if (activeView === "runs") {
|
||||||
crumbs.push({ label: "Runs" });
|
crumbs.push({ label: "Runs" });
|
||||||
} else if (activeView === "budget") {
|
} else if (activeView === "budget") {
|
||||||
@@ -642,6 +647,7 @@ export function AgentDetail() {
|
|||||||
items={[
|
items={[
|
||||||
{ value: "dashboard", label: "Dashboard" },
|
{ value: "dashboard", label: "Dashboard" },
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
|
{ value: "skills", label: "Skills" },
|
||||||
{ value: "runs", label: "Runs" },
|
{ value: "runs", label: "Runs" },
|
||||||
{ value: "budget", label: "Budget" },
|
{ value: "budget", label: "Budget" },
|
||||||
]}
|
]}
|
||||||
@@ -734,6 +740,13 @@ export function AgentDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeView === "skills" && (
|
||||||
|
<SkillsTab
|
||||||
|
agent={agent}
|
||||||
|
companyId={resolvedCompanyId ?? undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeView === "runs" && (
|
{activeView === "runs" && (
|
||||||
<RunsTab
|
<RunsTab
|
||||||
runs={heartbeats ?? []}
|
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 ---- */
|
/* ---- Runs Tab ---- */
|
||||||
|
|
||||||
function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {
|
function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user