import { Command } from "commander"; import { addIssueCommentSchema, checkoutIssueSchema, createIssueSchema, updateIssueSchema, type Issue, type IssueComment, } from "@paperclip/shared"; import { addCommonClientOptions, formatInlineRecord, handleCommandError, printOutput, resolveCommandContext, type BaseClientOptions, } from "./common.js"; interface IssueBaseOptions extends BaseClientOptions { status?: string; assigneeAgentId?: string; projectId?: string; match?: string; } interface IssueCreateOptions extends BaseClientOptions { title: string; description?: string; status?: string; priority?: string; assigneeAgentId?: string; projectId?: string; goalId?: string; parentId?: string; requestDepth?: string; billingCode?: string; } interface IssueUpdateOptions extends BaseClientOptions { title?: string; description?: string; status?: string; priority?: string; assigneeAgentId?: string; projectId?: string; goalId?: string; parentId?: string; requestDepth?: string; billingCode?: string; comment?: string; hiddenAt?: string; } interface IssueCommentOptions extends BaseClientOptions { body: string; reopen?: boolean; } interface IssueCheckoutOptions extends BaseClientOptions { agentId: string; expectedStatuses?: string; } export function registerIssueCommands(program: Command): void { const issue = program.command("issue").description("Issue operations"); addCommonClientOptions( issue .command("list") .description("List issues for a company") .option("-C, --company-id ", "Company ID") .option("--status ", "Comma-separated statuses") .option("--assignee-agent-id ", "Filter by assignee agent ID") .option("--project-id ", "Filter by project ID") .option("--match ", "Local text match on identifier/title/description") .action(async (opts: IssueBaseOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const params = new URLSearchParams(); if (opts.status) params.set("status", opts.status); if (opts.assigneeAgentId) params.set("assigneeAgentId", opts.assigneeAgentId); if (opts.projectId) params.set("projectId", opts.projectId); const query = params.toString(); const path = `/api/companies/${ctx.companyId}/issues${query ? `?${query}` : ""}`; const rows = (await ctx.api.get(path)) ?? []; const filtered = filterIssueRows(rows, opts.match); if (ctx.json) { printOutput(filtered, { json: true }); return; } if (filtered.length === 0) { printOutput([], { json: false }); return; } for (const item of filtered) { console.log( formatInlineRecord({ identifier: item.identifier, id: item.id, status: item.status, priority: item.priority, assigneeAgentId: item.assigneeAgentId, title: item.title, projectId: item.projectId, }), ); } } catch (err) { handleCommandError(err); } }), { includeCompany: false }, ); addCommonClientOptions( issue .command("get") .description("Get an issue by UUID or identifier (e.g. PC-12)") .argument("", "Issue ID or identifier") .action(async (idOrIdentifier: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); const row = await ctx.api.get(`/api/issues/${idOrIdentifier}`); printOutput(row, { json: ctx.json }); } catch (err) { handleCommandError(err); } }), ); addCommonClientOptions( issue .command("create") .description("Create an issue") .requiredOption("-C, --company-id ", "Company ID") .requiredOption("--title ", "Issue title") .option("--description <text>", "Issue description") .option("--status <status>", "Issue status") .option("--priority <priority>", "Issue priority") .option("--assignee-agent-id <id>", "Assignee agent ID") .option("--project-id <id>", "Project ID") .option("--goal-id <id>", "Goal ID") .option("--parent-id <id>", "Parent issue ID") .option("--request-depth <n>", "Request depth integer") .option("--billing-code <code>", "Billing code") .action(async (opts: IssueCreateOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const payload = createIssueSchema.parse({ title: opts.title, description: opts.description, status: opts.status, priority: opts.priority, assigneeAgentId: opts.assigneeAgentId, projectId: opts.projectId, goalId: opts.goalId, parentId: opts.parentId, requestDepth: parseOptionalInt(opts.requestDepth), billingCode: opts.billingCode, }); const created = await ctx.api.post<Issue>(`/api/companies/${ctx.companyId}/issues`, payload); printOutput(created, { json: ctx.json }); } catch (err) { handleCommandError(err); } }), { includeCompany: false }, ); addCommonClientOptions( issue .command("update") .description("Update an issue") .argument("<issueId>", "Issue ID") .option("--title <title>", "Issue title") .option("--description <text>", "Issue description") .option("--status <status>", "Issue status") .option("--priority <priority>", "Issue priority") .option("--assignee-agent-id <id>", "Assignee agent ID") .option("--project-id <id>", "Project ID") .option("--goal-id <id>", "Goal ID") .option("--parent-id <id>", "Parent issue ID") .option("--request-depth <n>", "Request depth integer") .option("--billing-code <code>", "Billing code") .option("--comment <text>", "Optional comment to add with update") .option("--hidden-at <iso8601|null>", "Set hiddenAt timestamp or literal 'null'") .action(async (issueId: string, opts: IssueUpdateOptions) => { try { const ctx = resolveCommandContext(opts); const payload = updateIssueSchema.parse({ title: opts.title, description: opts.description, status: opts.status, priority: opts.priority, assigneeAgentId: opts.assigneeAgentId, projectId: opts.projectId, goalId: opts.goalId, parentId: opts.parentId, requestDepth: parseOptionalInt(opts.requestDepth), billingCode: opts.billingCode, comment: opts.comment, hiddenAt: parseHiddenAt(opts.hiddenAt), }); const updated = await ctx.api.patch<Issue & { comment?: IssueComment | null }>(`/api/issues/${issueId}`, payload); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); } }), ); addCommonClientOptions( issue .command("comment") .description("Add comment to issue") .argument("<issueId>", "Issue ID") .requiredOption("--body <text>", "Comment body") .option("--reopen", "Reopen if issue is done/cancelled") .action(async (issueId: string, opts: IssueCommentOptions) => { try { const ctx = resolveCommandContext(opts); const payload = addIssueCommentSchema.parse({ body: opts.body, reopen: opts.reopen, }); const comment = await ctx.api.post<IssueComment>(`/api/issues/${issueId}/comments`, payload); printOutput(comment, { json: ctx.json }); } catch (err) { handleCommandError(err); } }), ); addCommonClientOptions( issue .command("checkout") .description("Checkout issue for an agent") .argument("<issueId>", "Issue ID") .requiredOption("--agent-id <id>", "Agent ID") .option( "--expected-statuses <csv>", "Expected current statuses", "todo,backlog,blocked", ) .action(async (issueId: string, opts: IssueCheckoutOptions) => { try { const ctx = resolveCommandContext(opts); const payload = checkoutIssueSchema.parse({ agentId: opts.agentId, expectedStatuses: parseCsv(opts.expectedStatuses), }); const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/checkout`, payload); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); } }), ); addCommonClientOptions( issue .command("release") .description("Release issue back to todo and clear assignee") .argument("<issueId>", "Issue ID") .action(async (issueId: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/release`, {}); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); } }), ); } function parseCsv(value: string | undefined): string[] { if (!value) return []; return value.split(",").map((v) => v.trim()).filter(Boolean); } function parseOptionalInt(value: string | undefined): number | undefined { if (value === undefined) return undefined; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) { throw new Error(`Invalid integer value: ${value}`); } return parsed; } function parseHiddenAt(value: string | undefined): string | null | undefined { if (value === undefined) return undefined; if (value.trim().toLowerCase() === "null") return null; return value; } function filterIssueRows(rows: Issue[], match: string | undefined): Issue[] { if (!match?.trim()) return rows; const needle = match.trim().toLowerCase(); return rows.filter((row) => { const text = [row.identifier, row.title, row.description] .filter((part): part is string => Boolean(part)) .join("\n") .toLowerCase(); return text.includes(needle); }); }