mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Add Hermes Paperclip task bridge
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
802245c94b
commit
f565280dff
7 files changed
+676
No files matched your search
@@ -108,6 +108,39 @@ secret values, so do not copy printed tokens into comments or config. Use
|
||||
multiline comments or status updates, preserve newlines with a heredoc plus
|
||||
`jq --arg`.
|
||||
|
||||
### Hermes-originated Paperclip tasks
|
||||
|
||||
The package includes a Hermes skill/helper for the reverse direction: a user
|
||||
starts in Hermes and asks Hermes to create or update Paperclip work. This is not
|
||||
the same as Paperclip waking Hermes through `hermes_local` or `hermes_gateway`.
|
||||
|
||||
Configure Paperclip access in Hermes env/profile secrets, not prompt text:
|
||||
|
||||
```bash
|
||||
PAPERCLIP_API_URL=http://127.0.0.1:3100/api
|
||||
PAPERCLIP_API_KEY=<scoped-agent-api-key>
|
||||
```
|
||||
|
||||
Optional env values:
|
||||
|
||||
- `PAPERCLIP_COMPANY_ID`
|
||||
- `PAPERCLIP_AGENT_ID`
|
||||
- `PAPERCLIP_RUN_ID`
|
||||
|
||||
The bundled `paperclip-task-bridge` skill provides deterministic helper
|
||||
commands:
|
||||
|
||||
```bash
|
||||
node ./paperclip-task.mjs list-assigned
|
||||
node ./paperclip-task.mjs create-task --title "Investigate checkout failures" --description "Capture failing request and root cause."
|
||||
node ./paperclip-task.mjs comment --issue PAP-123 --body "Found the failing request path."
|
||||
node ./paperclip-task.mjs update-status --issue PAP-123 --status in_review --comment "Ready for review."
|
||||
```
|
||||
|
||||
The helper reads credentials from environment variables and prints only JSON
|
||||
summaries. It supports `create-task`, `comment`, `update-status`, and
|
||||
`list-assigned`.
|
||||
|
||||
### 3. Assign work
|
||||
|
||||
Create issues in Paperclip and assign them to your Hermes agent. On each heartbeat, Hermes will:
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills",
|
||||
"ui-parser.cjs",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: paperclip-task-bridge
|
||||
description: Create, comment on, update, and list Paperclip tasks from Hermes using scoped Paperclip API credentials.
|
||||
---
|
||||
|
||||
# Paperclip Task Bridge
|
||||
|
||||
Use this skill when a Hermes-originated request needs to create or update Paperclip work directly. This is the Hermes-to-Paperclip direction, separate from Paperclip waking Hermes through the `hermes_local` or `hermes_gateway` adapter.
|
||||
|
||||
## Required Environment
|
||||
|
||||
Configure these in Hermes env/profile secrets, not in prompt text:
|
||||
|
||||
- `PAPERCLIP_API_URL` - Paperclip base URL, with or without `/api`.
|
||||
- `PAPERCLIP_API_KEY` - scoped Paperclip agent API key for the Hermes actor.
|
||||
|
||||
Optional:
|
||||
|
||||
- `PAPERCLIP_COMPANY_ID` - skips one identity lookup when set.
|
||||
- `PAPERCLIP_AGENT_ID` - skips one identity lookup when set.
|
||||
- `PAPERCLIP_RUN_ID` - sent as `X-Paperclip-Run-Id` on mutating requests when Hermes is running inside a Paperclip heartbeat.
|
||||
|
||||
Never print or paste API keys. The helper reads credentials from environment variables and only prints response summaries.
|
||||
|
||||
## Helper
|
||||
|
||||
Run the helper from this skill directory:
|
||||
|
||||
```sh
|
||||
node ./paperclip-task.mjs --help
|
||||
```
|
||||
|
||||
Commands:
|
||||
|
||||
```sh
|
||||
node ./paperclip-task.mjs list-assigned
|
||||
node ./paperclip-task.mjs create-task --title "Investigate checkout failures" --description "Capture failing request and root cause."
|
||||
node ./paperclip-task.mjs comment --issue PAP-123 --body "Found the failing request path."
|
||||
node ./paperclip-task.mjs update-status --issue PAP-123 --status in_review --comment "Ready for review."
|
||||
```
|
||||
|
||||
`create-task` defaults to assigning the task to the authenticated Hermes agent so the work is immediately actionable. Use `--unassigned` to create backlog work instead. Use `--assignee-agent-id <uuid>` only when the Paperclip API key has permission to assign work to that agent.
|
||||
|
||||
For multiline bodies, prefer files or stdin:
|
||||
|
||||
```sh
|
||||
node ./paperclip-task.mjs create-task --title "Write rollout note" --description-file ./task.md
|
||||
node ./paperclip-task.mjs comment --issue PAP-123 --body-file -
|
||||
```
|
||||
|
||||
## Workflow Expectations
|
||||
|
||||
- Keep tasks company-scoped by using the company resolved from the scoped agent key.
|
||||
- Let Paperclip activity logging come from the normal API endpoints; do not write local logs that include credentials.
|
||||
- Use comments for durable progress.
|
||||
- Use `update-status` only when the issue has a real disposition: `done`, `in_review`, `blocked`, `todo`, `in_progress`, `backlog`, or `cancelled`.
|
||||
- Use `list-assigned` before creating duplicate work when the user asks about current Paperclip assignments.
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);
|
||||
const PRIORITIES = new Set(["critical", "high", "medium", "low"]);
|
||||
const WORK_MODES = new Set(["standard", "ask", "planning"]);
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
const HELP = `Paperclip task bridge for Hermes
|
||||
|
||||
Usage:
|
||||
paperclip-task.mjs list-assigned [--status todo,in_progress,in_review,blocked] [--limit 20]
|
||||
paperclip-task.mjs create-task --title <title> [--description <text>|--description-file <path|->] [options]
|
||||
paperclip-task.mjs comment --issue <id|identifier> (--body <text>|--body-file <path|->) [--resume|--reopen]
|
||||
paperclip-task.mjs update-status --issue <id|identifier> --status <status> [--comment <text>|--comment-file <path|->]
|
||||
|
||||
Environment:
|
||||
PAPERCLIP_API_URL Paperclip base URL, with or without /api.
|
||||
PAPERCLIP_API_KEY Scoped Paperclip agent API key.
|
||||
PAPERCLIP_COMPANY_ID Optional company id override.
|
||||
PAPERCLIP_AGENT_ID Optional agent id override.
|
||||
PAPERCLIP_RUN_ID Optional run id for X-Paperclip-Run-Id on mutations.
|
||||
|
||||
create-task options:
|
||||
--assignee-agent-id <uuid|self> Assign to an agent. Defaults to self.
|
||||
--unassigned Create backlog/unassigned work.
|
||||
--parent-id <uuid> Parent issue id.
|
||||
--goal-id <uuid> Goal id.
|
||||
--project-id <uuid> Project id.
|
||||
--priority <critical|high|medium|low>
|
||||
--status <backlog|todo|in_progress|in_review|done|blocked|cancelled>
|
||||
--work-mode <standard|ask|planning>
|
||||
|
||||
Output is JSON and never includes credentials.`;
|
||||
|
||||
class UsageError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "UsageError";
|
||||
}
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, body) {
|
||||
const message = typeof body?.error === "string" ? body.error : `Paperclip API request failed with status ${status}`;
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith("--")) {
|
||||
out._.push(arg);
|
||||
continue;
|
||||
}
|
||||
const eq = arg.indexOf("=");
|
||||
if (eq !== -1) {
|
||||
out[arg.slice(2, eq)] = arg.slice(eq + 1);
|
||||
continue;
|
||||
}
|
||||
const key = arg.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
out[key] = true;
|
||||
continue;
|
||||
}
|
||||
out[key] = next;
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readStringFlag(args, name) {
|
||||
const value = args[name];
|
||||
if (typeof value !== "string" || value.trim().length === 0) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireStringFlag(args, name) {
|
||||
const value = readStringFlag(args, name);
|
||||
if (!value) throw new UsageError(`Missing required --${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolFlag(args, name) {
|
||||
return args[name] === true;
|
||||
}
|
||||
|
||||
function normalizeApiBaseUrl(raw) {
|
||||
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
|
||||
throw new UsageError("PAPERCLIP_API_URL is required");
|
||||
}
|
||||
const trimmed = raw.trim().replace(/\/+$/, "");
|
||||
return trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`;
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const apiKey = process.env.PAPERCLIP_API_KEY?.trim();
|
||||
if (!apiKey) throw new UsageError("PAPERCLIP_API_KEY is required");
|
||||
return {
|
||||
apiBaseUrl: normalizeApiBaseUrl(process.env.PAPERCLIP_API_URL),
|
||||
apiKey,
|
||||
runId: process.env.PAPERCLIP_RUN_ID?.trim() || null,
|
||||
companyId: process.env.PAPERCLIP_COMPANY_ID?.trim() || null,
|
||||
agentId: process.env.PAPERCLIP_AGENT_ID?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(args, textFlag, fileFlag) {
|
||||
const direct = readStringFlag(args, textFlag);
|
||||
const file = readStringFlag(args, fileFlag);
|
||||
if (direct && file) throw new UsageError(`Use either --${textFlag} or --${fileFlag}, not both`);
|
||||
if (direct) return direct;
|
||||
if (!file) return null;
|
||||
if (file === "-") {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
process.stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
return fs.readFile(file, "utf8");
|
||||
}
|
||||
|
||||
async function apiFetch(config, path, options = {}) {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
Accept: "application/json",
|
||||
...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.mutating && config.runId ? { "X-Paperclip-Run-Id": config.runId } : {}),
|
||||
};
|
||||
const response = await fetch(`${config.apiBaseUrl}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = { error: text.slice(0, 1000) };
|
||||
}
|
||||
}
|
||||
if (!response.ok) throw new ApiError(response.status, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function resolveIdentity(config) {
|
||||
if (config.companyId && config.agentId) {
|
||||
return { companyId: config.companyId, agentId: config.agentId, agent: null };
|
||||
}
|
||||
const agent = await apiFetch(config, "/agents/me");
|
||||
const companyId = config.companyId || agent.companyId;
|
||||
const agentId = config.agentId || agent.id;
|
||||
if (!companyId || !agentId) throw new ApiError(500, { error: "Paperclip identity response did not include companyId and agent id" });
|
||||
return { companyId, agentId, agent };
|
||||
}
|
||||
|
||||
function issueSummary(issue) {
|
||||
if (!issue || typeof issue !== "object") return issue;
|
||||
return {
|
||||
id: issue.id ?? null,
|
||||
identifier: issue.identifier ?? null,
|
||||
title: issue.title ?? null,
|
||||
status: issue.status ?? null,
|
||||
priority: issue.priority ?? null,
|
||||
assigneeAgentId: issue.assigneeAgentId ?? null,
|
||||
assigneeUserId: issue.assigneeUserId ?? null,
|
||||
projectId: issue.projectId ?? null,
|
||||
goalId: issue.goalId ?? null,
|
||||
parentId: issue.parentId ?? null,
|
||||
updatedAt: issue.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function commentSummary(comment) {
|
||||
if (!comment || typeof comment !== "object") return comment;
|
||||
return {
|
||||
id: comment.id ?? null,
|
||||
issueId: comment.issueId ?? null,
|
||||
authorType: comment.authorType ?? null,
|
||||
authorAgentId: comment.authorAgentId ?? null,
|
||||
createdAt: comment.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function printJson(value) {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function validateEnum(value, allowed, label) {
|
||||
if (!allowed.has(value)) {
|
||||
throw new UsageError(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLimit(args) {
|
||||
const raw = readStringFlag(args, "limit");
|
||||
if (!raw) return 20;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 100) {
|
||||
throw new UsageError("--limit must be an integer from 1 to 100");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function resolveIssueId(config, companyId, ref) {
|
||||
if (UUID_RE.test(ref)) return ref;
|
||||
const query = new URLSearchParams({
|
||||
q: ref,
|
||||
limit: "10",
|
||||
});
|
||||
const issues = await apiFetch(config, `/companies/${encodeURIComponent(companyId)}/issues?${query}`);
|
||||
const match = Array.isArray(issues)
|
||||
? issues.find((issue) => issue?.identifier === ref || issue?.id === ref)
|
||||
: null;
|
||||
if (!match?.id) throw new ApiError(404, { error: `Issue not found for reference ${ref}` });
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async function listAssigned(config, args) {
|
||||
const identity = await resolveIdentity(config);
|
||||
const status = readStringFlag(args, "status") || "todo,in_progress,in_review,blocked";
|
||||
const limit = parseLimit(args);
|
||||
const query = new URLSearchParams({
|
||||
assigneeAgentId: identity.agentId,
|
||||
status,
|
||||
limit: String(limit),
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
includeBlockedBy: "true",
|
||||
});
|
||||
const issues = await apiFetch(config, `/companies/${encodeURIComponent(identity.companyId)}/issues?${query}`);
|
||||
printJson({
|
||||
command: "list-assigned",
|
||||
companyId: identity.companyId,
|
||||
agentId: identity.agentId,
|
||||
count: Array.isArray(issues) ? issues.length : 0,
|
||||
issues: Array.isArray(issues) ? issues.map(issueSummary) : [],
|
||||
});
|
||||
}
|
||||
|
||||
async function createTask(config, args) {
|
||||
const identity = await resolveIdentity(config);
|
||||
const title = requireStringFlag(args, "title");
|
||||
const description = await readBody(args, "description", "description-file");
|
||||
const unassigned = boolFlag(args, "unassigned");
|
||||
const assigneeRaw = readStringFlag(args, "assignee-agent-id");
|
||||
const assigneeAgentId = unassigned
|
||||
? undefined
|
||||
: !assigneeRaw || assigneeRaw === "self"
|
||||
? identity.agentId
|
||||
: assigneeRaw;
|
||||
if (assigneeAgentId !== undefined && !UUID_RE.test(assigneeAgentId)) {
|
||||
throw new UsageError("--assignee-agent-id must be a UUID, self, or omitted");
|
||||
}
|
||||
const priority = readStringFlag(args, "priority") ?? "medium";
|
||||
const workMode = readStringFlag(args, "work-mode") ?? "standard";
|
||||
validateEnum(priority, PRIORITIES, "priority");
|
||||
validateEnum(workMode, WORK_MODES, "work mode");
|
||||
const body = {
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
workMode,
|
||||
...(assigneeAgentId !== undefined ? { assigneeAgentId } : {}),
|
||||
};
|
||||
for (const [flag, field] of [
|
||||
["parent-id", "parentId"],
|
||||
["goal-id", "goalId"],
|
||||
["project-id", "projectId"],
|
||||
]) {
|
||||
const value = readStringFlag(args, flag);
|
||||
if (value) body[field] = value;
|
||||
}
|
||||
const status = readStringFlag(args, "status");
|
||||
if (status) body.status = validateEnum(status, STATUSES, "status");
|
||||
|
||||
const issue = await apiFetch(config, `/companies/${encodeURIComponent(identity.companyId)}/issues`, {
|
||||
method: "POST",
|
||||
mutating: true,
|
||||
body,
|
||||
});
|
||||
printJson({ command: "create-task", issue: issueSummary(issue) });
|
||||
}
|
||||
|
||||
async function comment(config, args) {
|
||||
const identity = await resolveIdentity(config);
|
||||
const issueRef = requireStringFlag(args, "issue");
|
||||
const bodyText = await readBody(args, "body", "body-file");
|
||||
if (!bodyText || bodyText.trim().length === 0) throw new UsageError("comment requires --body or --body-file");
|
||||
const issueId = await resolveIssueId(config, identity.companyId, issueRef);
|
||||
const commentBody = {
|
||||
body: bodyText,
|
||||
...(boolFlag(args, "resume") ? { resume: true } : {}),
|
||||
...(boolFlag(args, "reopen") ? { reopen: true } : {}),
|
||||
};
|
||||
const created = await apiFetch(config, `/issues/${encodeURIComponent(issueId)}/comments`, {
|
||||
method: "POST",
|
||||
mutating: true,
|
||||
body: commentBody,
|
||||
});
|
||||
printJson({ command: "comment", issueId, comment: commentSummary(created) });
|
||||
}
|
||||
|
||||
async function updateStatus(config, args) {
|
||||
const identity = await resolveIdentity(config);
|
||||
const issueRef = requireStringFlag(args, "issue");
|
||||
const status = validateEnum(requireStringFlag(args, "status"), STATUSES, "status");
|
||||
const commentText = await readBody(args, "comment", "comment-file");
|
||||
const issueId = await resolveIssueId(config, identity.companyId, issueRef);
|
||||
const body = {
|
||||
status,
|
||||
...(commentText && commentText.trim().length > 0 ? { comment: commentText } : {}),
|
||||
...(boolFlag(args, "resume") ? { resume: true } : {}),
|
||||
...(boolFlag(args, "reopen") ? { reopen: true } : {}),
|
||||
};
|
||||
const issue = await apiFetch(config, `/issues/${encodeURIComponent(issueId)}`, {
|
||||
method: "PATCH",
|
||||
mutating: true,
|
||||
body,
|
||||
});
|
||||
printJson({ command: "update-status", issue: issueSummary(issue) });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const command = args._[0];
|
||||
if (!command || command === "help" || command === "--help" || boolFlag(args, "help")) {
|
||||
process.stdout.write(`${HELP}\n`);
|
||||
return;
|
||||
}
|
||||
const config = getConfig();
|
||||
if (command === "list-assigned") return listAssigned(config, args);
|
||||
if (command === "create-task") return createTask(config, args);
|
||||
if (command === "comment") return comment(config, args);
|
||||
if (command === "update-status") return updateStatus(config, args);
|
||||
throw new UsageError(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
if (err instanceof UsageError) {
|
||||
process.stderr.write(`Usage error: ${err.message}\n\n${HELP}\n`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (err instanceof ApiError) {
|
||||
printJson({
|
||||
error: err.message,
|
||||
status: err.status,
|
||||
details: err.body?.details ?? null,
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -21,3 +21,15 @@ test("root package export exposes Paperclip external adapter entrypoint", () =>
|
||||
expect(typeof adapter.detectModel).toBe("function");
|
||||
expect(typeof adapter.getConfigSchema).toBe("function");
|
||||
});
|
||||
|
||||
test("Hermes adapter exposes bundled Paperclip task bridge skill", async () => {
|
||||
const adapter = createServerAdapter();
|
||||
const snapshot = await adapter.listSkills?.({
|
||||
adapterType: "hermes_local",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(snapshot?.entries.some((entry) => entry.runtimeName === "paperclip-task-bridge")).toBe(true);
|
||||
});
|
||||
@@ -106,6 +106,24 @@ tools, persistent memory, session persistence, skills, and MCP support.
|
||||
| env | object | {} | Extra environment variables |
|
||||
| promptTemplate | string | (default) | Custom prompt template with {{variable}} placeholders |
|
||||
|
||||
## Hermes-Originated Paperclip Tasks
|
||||
|
||||
This adapter package also ships a Hermes-facing Paperclip task bridge skill:
|
||||
\`paperclip-task-bridge\`. Use it when a user starts in Hermes and asks Hermes
|
||||
to create, comment on, update, or list Paperclip tasks.
|
||||
|
||||
Configure credentials through Hermes env/profile secrets, never in prompt text:
|
||||
|
||||
- \`PAPERCLIP_API_URL\` - Paperclip base URL, with or without \`/api\`
|
||||
- \`PAPERCLIP_API_KEY\` - scoped Paperclip agent API key
|
||||
- optional \`PAPERCLIP_COMPANY_ID\`, \`PAPERCLIP_AGENT_ID\`, and \`PAPERCLIP_RUN_ID\`
|
||||
|
||||
The bridge is separate from adapter execution:
|
||||
|
||||
- \`hermes_local\` means Paperclip shells out to local \`hermes chat\`.
|
||||
- \`hermes_gateway\` means Paperclip wakes remote Hermes through Hermes's API server.
|
||||
- \`paperclip-task-bridge\` means Hermes calls Paperclip's REST API to manage tasks.
|
||||
|
||||
## Available Template Variables
|
||||
|
||||
- \`{{agentId}}\` — Paperclip agent ID
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const helperPath = path.resolve(__dirname, "../../skills/paperclip-task-bridge/paperclip-task.mjs");
|
||||
const apiKey = "pc_test_secret_should_not_print";
|
||||
|
||||
type RequestRecord = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
function runHelper(args: string[], env: Record<string, string>) {
|
||||
return new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [helperPath, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("paperclip-task-bridge helper", () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
let requests: RequestRecord[];
|
||||
|
||||
beforeEach(async () => {
|
||||
requests = [];
|
||||
server = http.createServer(async (req, res) => {
|
||||
let raw = "";
|
||||
req.setEncoding("utf8");
|
||||
for await (const chunk of req) raw += chunk;
|
||||
const body = raw ? JSON.parse(raw) : null;
|
||||
requests.push({
|
||||
method: req.method ?? "GET",
|
||||
url: req.url ?? "/",
|
||||
headers: req.headers,
|
||||
body,
|
||||
});
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.headers.authorization !== `Bearer ${apiKey}`) {
|
||||
res.statusCode = 401;
|
||||
res.end(JSON.stringify({ error: "bad auth" }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/api/agents/me") {
|
||||
res.end(JSON.stringify({ id: "11111111-1111-4111-8111-111111111111", companyId: "22222222-2222-4222-8222-222222222222" }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url?.startsWith("/api/companies/22222222-2222-4222-8222-222222222222/issues")) {
|
||||
res.end(JSON.stringify([
|
||||
{
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
identifier: "PAP-123",
|
||||
title: "Existing task",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
updatedAt: "2026-06-26T00:00:00.000Z",
|
||||
},
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/api/companies/22222222-2222-4222-8222-222222222222/issues") {
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify({
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
identifier: "PAP-124",
|
||||
title: body.title,
|
||||
status: body.status ?? "todo",
|
||||
priority: body.priority,
|
||||
assigneeAgentId: body.assigneeAgentId ?? null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/api/issues/33333333-3333-4333-8333-333333333333/comments") {
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
issueId: "33333333-3333-4333-8333-333333333333",
|
||||
authorType: "agent",
|
||||
authorAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
createdAt: "2026-06-26T00:00:00.000Z",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.method === "PATCH" && req.url === "/api/issues/33333333-3333-4333-8333-333333333333") {
|
||||
res.end(JSON.stringify({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
identifier: "PAP-123",
|
||||
title: "Existing task",
|
||||
status: body.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("missing server address");
|
||||
baseUrl = `http://127.0.0.1:${address.port}/api`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
function env() {
|
||||
return {
|
||||
PAPERCLIP_API_URL: baseUrl,
|
||||
PAPERCLIP_API_KEY: apiKey,
|
||||
PAPERCLIP_COMPANY_ID: "22222222-2222-4222-8222-222222222222",
|
||||
PAPERCLIP_AGENT_ID: "11111111-1111-4111-8111-111111111111",
|
||||
PAPERCLIP_RUN_ID: "66666666-6666-4666-8666-666666666666",
|
||||
};
|
||||
}
|
||||
|
||||
it("lists assigned tasks without printing credentials", async () => {
|
||||
const result = await runHelper(["list-assigned"], env());
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('"command": "list-assigned"');
|
||||
expect(result.stdout).toContain('"identifier": "PAP-123"');
|
||||
expect(result.stdout).not.toContain(apiKey);
|
||||
expect(result.stderr).not.toContain(apiKey);
|
||||
expect(requests.some((request) => request.url.includes("assigneeAgentId=11111111-1111-4111-8111-111111111111"))).toBe(true);
|
||||
});
|
||||
|
||||
it("creates tasks assigned to the authenticated agent by default", async () => {
|
||||
const result = await runHelper(["create-task", "--title", "Bridge task", "--description", "Created from Hermes"], env());
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('"identifier": "PAP-124"');
|
||||
const createRequest = requests.find((request) => request.method === "POST" && request.url.includes("/companies/"));
|
||||
expect(createRequest?.headers["x-paperclip-run-id"]).toBe("66666666-6666-4666-8666-666666666666");
|
||||
expect(createRequest?.body).toMatchObject({
|
||||
title: "Bridge task",
|
||||
description: "Created from Hermes",
|
||||
priority: "medium",
|
||||
workMode: "standard",
|
||||
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
expect(result.stdout).not.toContain(apiKey);
|
||||
});
|
||||
|
||||
it("comments and updates status by resolving issue identifiers", async () => {
|
||||
const comment = await runHelper(["comment", "--issue", "PAP-123", "--body", "Progress from Hermes"], env());
|
||||
const update = await runHelper(["update-status", "--issue", "PAP-123", "--status", "in_review", "--comment", "Ready"], env());
|
||||
|
||||
expect(comment.code).toBe(0);
|
||||
expect(update.code).toBe(0);
|
||||
const commentRequest = requests.find((request) => request.method === "POST" && request.url.includes("/comments"));
|
||||
const patchRequest = requests.find((request) => request.method === "PATCH");
|
||||
expect(commentRequest?.body).toMatchObject({ body: "Progress from Hermes" });
|
||||
expect(patchRequest?.body).toMatchObject({ status: "in_review", comment: "Ready" });
|
||||
expect(comment.stdout + update.stdout).not.toContain(apiKey);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user