From f565280dff28acaaa1f26722f1b3a476eea23934 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 26 Jun 2026 03:02:26 +0000 Subject: [PATCH] Add Hermes Paperclip task bridge Co-Authored-By: Paperclip --- packages/adapters/hermes-local/README.md | 33 ++ packages/adapters/hermes-local/package.json | 1 + .../skills/paperclip-task-bridge/SKILL.md | 57 +++ .../paperclip-task-bridge/paperclip-task.mjs | 371 ++++++++++++++++++ .../adapters/hermes-local/src/index.test.ts | 12 + packages/adapters/hermes-local/src/index.ts | 18 + .../src/server/paperclip-task-bridge.test.ts | 184 +++++++++ 7 files changed, 676 insertions(+) create mode 100644 packages/adapters/hermes-local/skills/paperclip-task-bridge/SKILL.md create mode 100755 packages/adapters/hermes-local/skills/paperclip-task-bridge/paperclip-task.mjs create mode 100644 packages/adapters/hermes-local/src/server/paperclip-task-bridge.test.ts diff --git a/packages/adapters/hermes-local/README.md b/packages/adapters/hermes-local/README.md index 90ff7e4b1e5..1279a66f532 100644 --- a/packages/adapters/hermes-local/README.md +++ b/packages/adapters/hermes-local/README.md @@ -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= +``` + +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: diff --git a/packages/adapters/hermes-local/package.json b/packages/adapters/hermes-local/package.json index 29d0ff6f059..9f247119633 100644 --- a/packages/adapters/hermes-local/package.json +++ b/packages/adapters/hermes-local/package.json @@ -59,6 +59,7 @@ }, "files": [ "dist", + "skills", "ui-parser.cjs", "README.md", "LICENSE" diff --git a/packages/adapters/hermes-local/skills/paperclip-task-bridge/SKILL.md b/packages/adapters/hermes-local/skills/paperclip-task-bridge/SKILL.md new file mode 100644 index 00000000000..96b29e98d6e --- /dev/null +++ b/packages/adapters/hermes-local/skills/paperclip-task-bridge/SKILL.md @@ -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 ` 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. diff --git a/packages/adapters/hermes-local/skills/paperclip-task-bridge/paperclip-task.mjs b/packages/adapters/hermes-local/skills/paperclip-task-bridge/paperclip-task.mjs new file mode 100755 index 00000000000..d7b4da9b5df --- /dev/null +++ b/packages/adapters/hermes-local/skills/paperclip-task-bridge/paperclip-task.mjs @@ -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 [--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; +}); diff --git a/packages/adapters/hermes-local/src/index.test.ts b/packages/adapters/hermes-local/src/index.test.ts index f26e9a51ee1..659e5cca32e 100644 --- a/packages/adapters/hermes-local/src/index.test.ts +++ b/packages/adapters/hermes-local/src/index.test.ts @@ -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); +}); diff --git a/packages/adapters/hermes-local/src/index.ts b/packages/adapters/hermes-local/src/index.ts index 93945289a3d..7cf1298f1ef 100644 --- a/packages/adapters/hermes-local/src/index.ts +++ b/packages/adapters/hermes-local/src/index.ts @@ -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 diff --git a/packages/adapters/hermes-local/src/server/paperclip-task-bridge.test.ts b/packages/adapters/hermes-local/src/server/paperclip-task-bridge.test.ts new file mode 100644 index 00000000000..8d64cd72585 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/paperclip-task-bridge.test.ts @@ -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); + }); +});