paperclip-task.mjs
paperclip-task.mjsBrowse 2 files
3,159 tokens
12,091 bytes
Token encoding: o200k_base
Snapshot 5b913e7
← Back to SKILL.md
1#!/usr/bin/env node2 3import fs from "node:fs/promises";4 5const STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);6const PRIORITIES = new Set(["critical", "high", "medium", "low"]);7const WORK_MODES = new Set(["standard", "ask", "planning"]);8const 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;9 10const HELP = `Paperclip task bridge for Hermes11 12Usage:13 paperclip-task.mjs list-assigned [--status todo,in_progress,in_review,blocked] [--limit 20]14 paperclip-task.mjs create-task --title <title> [--description <text>|--description-file <path|->] [options]15 paperclip-task.mjs comment --issue <id|identifier> (--body <text>|--body-file <path|->) [--resume|--reopen]16 paperclip-task.mjs update-status --issue <id|identifier> --status <status> [--comment <text>|--comment-file <path|->]17 18Environment:19 PAPERCLIP_API_URL Paperclip base URL, with or without /api.20 PAPERCLIP_BRIDGE_API_KEY21 Task-bridge Paperclip API key with kind=task_bridge scope.22 PAPERCLIP_API_KEY Fallback bridge key env var. Do not use a full agent key.23 PAPERCLIP_COMPANY_ID Optional company id override.24 PAPERCLIP_AGENT_ID Optional agent id override.25 PAPERCLIP_RUN_ID Optional run id for X-Paperclip-Run-Id on mutations.26 27create-task options:28 --assignee-agent-id <uuid|self> Assign to an agent. Defaults to self.29 --unassigned Create backlog/unassigned work.30 --parent-id <uuid> Parent issue id.31 --goal-id <uuid> Goal id.32 --project-id <uuid> Project id.33 --priority <critical|high|medium|low>34 --status <backlog|todo|in_progress|in_review|done|blocked|cancelled>35 --work-mode <standard|ask|planning>36 37Output is JSON and never includes credentials.`;38 39class UsageError extends Error {40 constructor(message) {41 super(message);42 this.name = "UsageError";43 }44}45 46class ApiError extends Error {47 constructor(status, body) {48 const message = typeof body?.error === "string" ? body.error : `Paperclip API request failed with status ${status}`;49 super(message);50 this.name = "ApiError";51 this.status = status;52 this.body = body;53 }54}55 56function parseArgs(argv) {57 const out = { _: [] };58 for (let i = 0; i < argv.length; i += 1) {59 const arg = argv[i];60 if (!arg.startsWith("--")) {61 out._.push(arg);62 continue;63 }64 const eq = arg.indexOf("=");65 if (eq !== -1) {66 out[arg.slice(2, eq)] = arg.slice(eq + 1);67 continue;68 }69 const key = arg.slice(2);70 const next = argv[i + 1];71 if (!next || next.startsWith("--")) {72 out[key] = true;73 continue;74 }75 out[key] = next;76 i += 1;77 }78 return out;79}80 81function readStringFlag(args, name) {82 const value = args[name];83 if (typeof value !== "string" || value.trim().length === 0) return null;84 return value;85}86 87function requireStringFlag(args, name) {88 const value = readStringFlag(args, name);89 if (!value) throw new UsageError(`Missing required --${name}`);90 return value;91}92 93function boolFlag(args, name) {94 return args[name] === true;95}96 97function normalizeApiBaseUrl(raw) {98 if (!raw || typeof raw !== "string" || raw.trim().length === 0) {99 throw new UsageError("PAPERCLIP_API_URL is required");100 }101 const trimmed = raw.trim().replace(/\/+$/, "");102 return trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`;103}104 105function getConfig() {106 const apiKey = process.env.PAPERCLIP_BRIDGE_API_KEY?.trim() || process.env.PAPERCLIP_API_KEY?.trim();107 if (!apiKey) throw new UsageError("PAPERCLIP_BRIDGE_API_KEY is required");108 return {109 apiBaseUrl: normalizeApiBaseUrl(process.env.PAPERCLIP_API_URL),110 apiKey,111 runId: process.env.PAPERCLIP_RUN_ID?.trim() || null,112 companyId: process.env.PAPERCLIP_COMPANY_ID?.trim() || null,113 agentId: process.env.PAPERCLIP_AGENT_ID?.trim() || null,114 };115}116 117async function readBody(args, textFlag, fileFlag) {118 const direct = readStringFlag(args, textFlag);119 const file = readStringFlag(args, fileFlag);120 if (direct && file) throw new UsageError(`Use either --${textFlag} or --${fileFlag}, not both`);121 if (direct) return direct;122 if (!file) return null;123 if (file === "-") {124 return await new Promise((resolve, reject) => {125 let data = "";126 process.stdin.setEncoding("utf8");127 process.stdin.on("data", (chunk) => {128 data += chunk;129 });130 process.stdin.on("end", () => resolve(data));131 process.stdin.on("error", reject);132 });133 }134 return fs.readFile(file, "utf8");135}136 137async function apiFetch(config, path, options = {}) {138 const headers = {139 Authorization: `Bearer ${config.apiKey}`,140 Accept: "application/json",141 ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),142 ...(options.mutating && config.runId ? { "X-Paperclip-Run-Id": config.runId } : {}),143 };144 const response = await fetch(`${config.apiBaseUrl}${path}`, {145 method: options.method ?? "GET",146 headers,147 body: options.body === undefined ? undefined : JSON.stringify(options.body),148 });149 const text = await response.text();150 let body = null;151 if (text) {152 try {153 body = JSON.parse(text);154 } catch {155 body = { error: text.slice(0, 1000) };156 }157 }158 if (!response.ok) throw new ApiError(response.status, body);159 return body;160}161 162async function resolveIdentity(config) {163 if (config.companyId && config.agentId) {164 return { companyId: config.companyId, agentId: config.agentId, agent: null };165 }166 const agent = await apiFetch(config, "/agents/me");167 const companyId = config.companyId || agent.companyId;168 const agentId = config.agentId || agent.id;169 if (!companyId || !agentId) throw new ApiError(500, { error: "Paperclip identity response did not include companyId and agent id" });170 return { companyId, agentId, agent };171}172 173function issueSummary(issue) {174 if (!issue || typeof issue !== "object") return issue;175 return {176 id: issue.id ?? null,177 identifier: issue.identifier ?? null,178 title: issue.title ?? null,179 status: issue.status ?? null,180 priority: issue.priority ?? null,181 assigneeAgentId: issue.assigneeAgentId ?? null,182 assigneeUserId: issue.assigneeUserId ?? null,183 projectId: issue.projectId ?? null,184 goalId: issue.goalId ?? null,185 parentId: issue.parentId ?? null,186 updatedAt: issue.updatedAt ?? null,187 };188}189 190function commentSummary(comment) {191 if (!comment || typeof comment !== "object") return comment;192 return {193 id: comment.id ?? null,194 issueId: comment.issueId ?? null,195 authorType: comment.authorType ?? null,196 authorAgentId: comment.authorAgentId ?? null,197 createdAt: comment.createdAt ?? null,198 };199}200 201function printJson(value) {202 process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);203}204 205function validateEnum(value, allowed, label) {206 if (!allowed.has(value)) {207 throw new UsageError(`Invalid ${label}: ${value}`);208 }209 return value;210}211 212function parseLimit(args) {213 const raw = readStringFlag(args, "limit");214 if (!raw) return 20;215 const value = Number.parseInt(raw, 10);216 if (!Number.isInteger(value) || value <= 0 || value > 100) {217 throw new UsageError("--limit must be an integer from 1 to 100");218 }219 return value;220}221 222async function listAssigned(config, args) {223 const identity = await resolveIdentity(config);224 const status = readStringFlag(args, "status") || "todo,in_progress,in_review,blocked";225 const limit = parseLimit(args);226 const issues = await apiFetch(config, "/agents/me/inbox-lite");227 const allowedStatuses = new Set(status.split(",").map((entry) => entry.trim()).filter(Boolean));228 const filteredIssues = Array.isArray(issues)229 ? issues.filter((issue) => !allowedStatuses.size || allowedStatuses.has(issue?.status)).slice(0, limit)230 : [];231 printJson({232 command: "list-assigned",233 companyId: identity.companyId,234 agentId: identity.agentId,235 count: filteredIssues.length,236 issues: filteredIssues.map(issueSummary),237 });238}239 240async function createTask(config, args) {241 const identity = await resolveIdentity(config);242 const title = requireStringFlag(args, "title");243 const description = await readBody(args, "description", "description-file");244 const unassigned = boolFlag(args, "unassigned");245 const assigneeRaw = readStringFlag(args, "assignee-agent-id");246 const assigneeAgentId = unassigned247 ? undefined248 : !assigneeRaw || assigneeRaw === "self"249 ? identity.agentId250 : assigneeRaw;251 if (assigneeAgentId !== undefined && !UUID_RE.test(assigneeAgentId)) {252 throw new UsageError("--assignee-agent-id must be a UUID, self, or omitted");253 }254 const priority = readStringFlag(args, "priority") ?? "medium";255 const workMode = readStringFlag(args, "work-mode") ?? "standard";256 validateEnum(priority, PRIORITIES, "priority");257 validateEnum(workMode, WORK_MODES, "work mode");258 const body = {259 title,260 description,261 priority,262 workMode,263 ...(assigneeAgentId !== undefined ? { assigneeAgentId } : {}),264 };265 for (const [flag, field] of [266 ["parent-id", "parentId"],267 ["goal-id", "goalId"],268 ["project-id", "projectId"],269 ]) {270 const value = readStringFlag(args, flag);271 if (value) body[field] = value;272 }273 const status = readStringFlag(args, "status");274 if (status) body.status = validateEnum(status, STATUSES, "status");275 276 const issue = await apiFetch(config, `/companies/${encodeURIComponent(identity.companyId)}/issues`, {277 method: "POST",278 mutating: true,279 body,280 });281 printJson({ command: "create-task", issue: issueSummary(issue) });282}283 284async function comment(config, args) {285 const issueRef = requireStringFlag(args, "issue");286 const bodyText = await readBody(args, "body", "body-file");287 if (!bodyText || bodyText.trim().length === 0) throw new UsageError("comment requires --body or --body-file");288 const commentBody = {289 body: bodyText,290 ...(boolFlag(args, "resume") ? { resume: true } : {}),291 ...(boolFlag(args, "reopen") ? { reopen: true } : {}),292 };293 const created = await apiFetch(config, `/issues/${encodeURIComponent(issueRef)}/comments`, {294 method: "POST",295 mutating: true,296 body: commentBody,297 });298 printJson({ command: "comment", issue: issueRef, comment: commentSummary(created) });299}300 301async function updateStatus(config, args) {302 const issueRef = requireStringFlag(args, "issue");303 const status = validateEnum(requireStringFlag(args, "status"), STATUSES, "status");304 const commentText = await readBody(args, "comment", "comment-file");305 const body = {306 status,307 ...(commentText && commentText.trim().length > 0 ? { comment: commentText } : {}),308 ...(boolFlag(args, "resume") ? { resume: true } : {}),309 ...(boolFlag(args, "reopen") ? { reopen: true } : {}),310 };311 const issue = await apiFetch(config, `/issues/${encodeURIComponent(issueRef)}`, {312 method: "PATCH",313 mutating: true,314 body,315 });316 printJson({ command: "update-status", issue: issueSummary(issue) });317}318 319async function main() {320 const args = parseArgs(process.argv.slice(2));321 const command = args._[0];322 if (!command || command === "help" || command === "--help" || boolFlag(args, "help")) {323 process.stdout.write(`${HELP}\n`);324 return;325 }326 const config = getConfig();327 if (command === "list-assigned") return listAssigned(config, args);328 if (command === "create-task") return createTask(config, args);329 if (command === "comment") return comment(config, args);330 if (command === "update-status") return updateStatus(config, args);331 throw new UsageError(`Unknown command: ${command}`);332}333 334main().catch((err) => {335 if (err instanceof UsageError) {336 process.stderr.write(`Usage error: ${err.message}\n\n${HELP}\n`);337 process.exitCode = 2;338 return;339 }340 if (err instanceof ApiError) {341 printJson({342 error: err.message,343 status: err.status,344 details: err.body?.details ?? null,345 });346 process.exitCode = 1;347 return;348 }349 process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);350 process.exitCode = 1;351});352 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 49.SKILL.mdView in source ↗49```sh50node ./paperclip-task.mjs --help51```
Source excerpt starting at line 55.SKILL.mdView in source ↗55```sh56node ./paperclip-task.mjs list-assigned57node ./paperclip-task.mjs create-task --parent-id "00000000-0000-4000-8000-000000000000" --title "Investigate checkout failures" --description "Capture failing request and root cause."58node ./paperclip-task.mjs comment --issue PAP-123 --body "Found the failing request path."59node ./paperclip-task.mjs update-status --issue PAP-123 --status in_review --comment "Ready for review."60```
Source excerpt starting at line 66.66```sh67node ./paperclip-task.mjs create-task --title "Write rollout note" --description-file ./task.md68node ./paperclip-task.mjs comment --issue PAP-123 --body-file -69```