import pc from "picocolors"; import type { Command } from "commander"; import { getStoredBoardCredential, loginBoardCli } from "../../client/board-auth.js"; import { buildCliCommandLabel } from "../../client/command-label.js"; import { readConfig } from "../../config/store.js"; import { readContext, resolveProfile, type ClientContextProfile } from "../../client/context.js"; import { ApiRequestError, PaperclipApiClient } from "../../client/http.js"; export interface BaseClientOptions { config?: string; dataDir?: string; context?: string; profile?: string; apiBase?: string; apiKey?: string; runId?: string; companyId?: string; json?: boolean; } export interface ResolvedClientContext { api: PaperclipApiClient; companyId?: string; profileName: string; profile: ClientContextProfile; json: boolean; authSource: "explicit" | "env" | "profile_env" | "stored_board" | "none"; } export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command { command .option("-c, --config ", "Path to Paperclip config file") .option("-d, --data-dir ", "Paperclip data directory root (isolates state from ~/.paperclip)") .option("--context ", "Path to CLI context file") .option("--profile ", "CLI context profile name") .option("--api-base ", "Base URL for the Paperclip API") .option("--api-key ", "Bearer token for agent-authenticated calls") .option("--run-id ", "Heartbeat run id for agent-authenticated mutations (checkout/release/interactions/in-progress update); falls back to $PAPERCLIP_RUN_ID") .option("--json", "Output raw JSON"); if (opts?.includeCompany) { command.option("-C, --company-id ", "Company ID (overrides context default)"); } return command; } export function resolveCommandContext( options: BaseClientOptions, opts?: { requireCompany?: boolean }, ): ResolvedClientContext { const context = readContext(options.context); const { name: profileName, profile } = resolveProfile(context, options.profile); const apiBase = resolveApiBase(options, profile); const resolvedApiKey = resolveApiKey(options, profile); const explicitApiKey = resolvedApiKey.value; const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase); const apiKey = explicitApiKey || storedBoardCredential?.token; const companyId = options.companyId?.trim() || process.env.PAPERCLIP_COMPANY_ID?.trim() || profile.companyId; if (opts?.requireCompany && !companyId) { throw new Error( "Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or set context profile companyId via `paperclipai context set`.", ); } // Agent-authenticated mutations (checkout, release, interactions, PATCH of an // in-progress issue) require the X-Paperclip-Run-Id header (the server returns // "401 Agent run id required" without it). Source it from --run-id, else the // PAPERCLIP_RUN_ID env the adapter/embodiment context already exports. const runId = options.runId?.trim() || process.env.PAPERCLIP_RUN_ID?.trim() || undefined; const api = new PaperclipApiClient({ apiBase, apiKey, runId, recoverAuth: explicitApiKey || !canAttemptInteractiveBoardAuth() ? undefined : async ({ error }) => { const requestedAccess = error.message.includes("Instance admin required") ? "instance_admin_required" : "board"; if (!shouldRecoverBoardAuth(error)) { return null; } const login = await loginBoardCli({ apiBase, requestedAccess, requestedCompanyId: companyId ?? null, command: buildCliCommandLabel(), }); return login.token; }, }); return { api, companyId, profileName, profile, json: Boolean(options.json), authSource: explicitApiKey ? resolvedApiKey.source : storedBoardCredential ? "stored_board" : "none", }; } export function resolveApiBase(options: Pick, profile: ClientContextProfile = {}): string { return normalizeApiBase( options.apiBase?.trim() || process.env.PAPERCLIP_API_URL?.trim() || profile.apiBase || inferApiBaseFromConfig(options.config), ); } export function normalizeApiBase(apiBase: string): string { return apiBase.trim().replace(/\/+$/, ""); } export function apiPath(strings: TemplateStringsArray, ...values: Array): string { let path = strings[0] ?? ""; values.forEach((value, index) => { if (value === null || value === undefined || String(value).trim() === "") { throw new Error("Cannot build API path with an empty path segment."); } path += `${encodeURIComponent(String(value))}${strings[index + 1] ?? ""}`; }); return path; } export function inferContentTypeFromPath(filePath: string): string | undefined { const ext = filePath.split(/[\\/]/).pop()?.split(".").pop()?.toLowerCase(); if (!ext) return undefined; // These MIME strings are matched against the server's issue-attachment // allowlist (server/src/attachment-types.ts DEFAULT_ALLOWED_TYPES) by EXACT // string, so text types must carry no "; charset=..." parameter or the upload // is rejected with "422 Unsupported attachment content type". Keep this set in // sync with that allowlist (plus svg/avif, accepted by the asset routes). return { avif: "image/avif", csv: "text/csv", gif: "image/gif", htm: "text/html", html: "text/html", jpeg: "image/jpeg", jpg: "image/jpeg", json: "application/json", m4v: "video/x-m4v", md: "text/markdown", mov: "video/quicktime", mp4: "video/mp4", pdf: "application/pdf", png: "image/png", qt: "video/quicktime", svg: "image/svg+xml", txt: "text/plain", webm: "video/webm", webp: "image/webp", zip: "application/zip", }[ext]; } function resolveApiKey( options: Pick, profile: ClientContextProfile, ): { value: string | undefined; source: "explicit" | "env" | "profile_env" | "none" } { const optionValue = options.apiKey?.trim(); if (optionValue) return { value: optionValue, source: "explicit" }; const envValue = process.env.PAPERCLIP_API_KEY?.trim(); if (envValue) return { value: envValue, source: "env" }; const profileEnvValue = readKeyFromProfileEnv(profile); if (profileEnvValue) return { value: profileEnvValue, source: "profile_env" }; return { value: undefined, source: "none" }; } function shouldRecoverBoardAuth(error: ApiRequestError): boolean { if (error.status === 401) return true; if (error.status !== 403) return false; return error.message.includes("Board access required") || error.message.includes("Instance admin required"); } function canAttemptInteractiveBoardAuth(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } export function printOutput(data: unknown, opts: { json?: boolean; label?: string } = {}): void { if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; } if (opts.label) { console.log(pc.bold(opts.label)); } if (Array.isArray(data)) { if (data.length === 0) { console.log(pc.dim("(empty)")); return; } for (const item of data) { if (typeof item === "object" && item !== null) { console.log(formatInlineRecord(item as Record)); } else { console.log(String(item)); } } return; } if (typeof data === "object" && data !== null) { console.log(JSON.stringify(data, null, 2)); return; } if (data === undefined || data === null) { console.log(pc.dim("(null)")); return; } console.log(String(data)); } export function formatInlineRecord(record: Record): string { const keyOrder = ["identifier", "id", "name", "status", "priority", "title", "action"]; const seen = new Set(); const parts: string[] = []; for (const key of keyOrder) { if (!(key in record)) continue; parts.push(`${key}=${renderValue(record[key])}`); seen.add(key); } for (const [key, value] of Object.entries(record)) { if (seen.has(key)) continue; if (typeof value === "object") continue; parts.push(`${key}=${renderValue(value)}`); } return parts.join(" "); } function renderValue(value: unknown): string { if (value === null || value === undefined) return "-"; if (typeof value === "string") { const compact = value.replace(/\s+/g, " ").trim(); return compact.length > 90 ? `${compact.slice(0, 87)}...` : compact; } if (typeof value === "number" || typeof value === "boolean") { return String(value); } return "[object]"; } export function inferApiBaseFromConfig(configPath?: string): string { const envHost = process.env.PAPERCLIP_SERVER_HOST?.trim() || "localhost"; let port = Number(process.env.PAPERCLIP_SERVER_PORT || ""); if (!Number.isFinite(port) || port <= 0) { try { const config = readConfig(configPath); port = Number(config?.server?.port ?? 3100); } catch { port = 3100; } } if (!Number.isFinite(port) || port <= 0) { port = 3100; } return `http://${envHost}:${port}`; } function readKeyFromProfileEnv(profile: ClientContextProfile): string | undefined { if (!profile.apiKeyEnvVarName) return undefined; return process.env[profile.apiKeyEnvVarName]?.trim() || undefined; } export function handleCommandError(error: unknown): never { if (error instanceof ApiRequestError) { const detailSuffix = error.details !== undefined ? ` details=${JSON.stringify(error.details)}` : ""; console.error(pc.red(`API error ${error.status}: ${error.message}${detailSuffix}`)); process.exit(1); } const message = error instanceof Error ? error.message : String(error); console.error(pc.red(message)); process.exit(1); }