From 8f3fc077fac1fbd0b411c4e7f43f1563f61eec08 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Fri, 20 Feb 2026 07:10:58 -0600 Subject: [PATCH] feat(cli): add client commands and home-based local runtime defaults --- cli/package.json | 3 +- cli/src/__tests__/common.test.ts | 98 +++++++++ cli/src/__tests__/context.test.ts | 70 ++++++ cli/src/__tests__/home-paths.test.ts | 44 ++++ cli/src/__tests__/http.test.ts | 61 ++++++ cli/src/client/context.ts | 175 +++++++++++++++ cli/src/client/http.ts | 148 +++++++++++++ cli/src/commands/client/activity.ts | 71 ++++++ cli/src/commands/client/agent.ts | 74 +++++++ cli/src/commands/client/approval.ts | 259 ++++++++++++++++++++++ cli/src/commands/client/common.ts | 185 ++++++++++++++++ cli/src/commands/client/company.ts | 67 ++++++ cli/src/commands/client/context.ts | 120 ++++++++++ cli/src/commands/client/dashboard.ts | 34 +++ cli/src/commands/client/issue.ts | 313 +++++++++++++++++++++++++++ cli/src/commands/configure.ts | 10 +- cli/src/commands/doctor.ts | 14 +- cli/src/commands/env.ts | 10 +- cli/src/commands/heartbeat-run.ts | 104 +++------ cli/src/commands/onboard.ts | 7 + cli/src/commands/run.ts | 104 +++++++++ cli/src/config/env.ts | 10 +- cli/src/config/home.ts | 66 ++++++ cli/src/config/store.ts | 7 +- cli/src/index.ts | 38 +++- cli/src/prompts/database.ts | 11 +- cli/src/prompts/logging.ts | 10 +- cli/src/prompts/secrets.ts | 13 +- cli/src/utils/path-resolver.ts | 12 +- cli/vitest.config.ts | 7 + doc/CLI.md | 128 +++++++++++ doc/DATABASE.md | 10 +- doc/DEVELOPING.md | 53 ++++- doc/SPEC-implementation.md | 2 +- packages/shared/src/config-schema.ts | 10 +- pnpm-lock.yaml | 3 + server/src/config.ts | 17 +- server/src/home-paths.ts | 49 +++++ server/src/paths.ts | 3 +- vitest.config.ts | 2 +- 40 files changed, 2284 insertions(+), 138 deletions(-) create mode 100644 cli/src/__tests__/common.test.ts create mode 100644 cli/src/__tests__/context.test.ts create mode 100644 cli/src/__tests__/home-paths.test.ts create mode 100644 cli/src/__tests__/http.test.ts create mode 100644 cli/src/client/context.ts create mode 100644 cli/src/client/http.ts create mode 100644 cli/src/commands/client/activity.ts create mode 100644 cli/src/commands/client/agent.ts create mode 100644 cli/src/commands/client/approval.ts create mode 100644 cli/src/commands/client/common.ts create mode 100644 cli/src/commands/client/company.ts create mode 100644 cli/src/commands/client/context.ts create mode 100644 cli/src/commands/client/dashboard.ts create mode 100644 cli/src/commands/client/issue.ts create mode 100644 cli/src/commands/run.ts create mode 100644 cli/src/config/home.ts create mode 100644 cli/vitest.config.ts create mode 100644 doc/CLI.md create mode 100644 server/src/home-paths.ts diff --git a/cli/package.json b/cli/package.json index d2d4f1dc..6ea708a0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "bin": { - "paperclip": "./src/index.ts" + "paperclip": "./dist/index.js" }, "scripts": { "dev": "tsx src/index.ts", @@ -17,6 +17,7 @@ "@paperclip/adapter-codex-local": "workspace:*", "@paperclip/adapter-utils": "workspace:*", "@paperclip/db": "workspace:*", + "@paperclip/server": "workspace:*", "@paperclip/shared": "workspace:*", "dotenv": "^17.0.1", "commander": "^13.1.0", diff --git a/cli/src/__tests__/common.test.ts b/cli/src/__tests__/common.test.ts new file mode 100644 index 00000000..91be4b7c --- /dev/null +++ b/cli/src/__tests__/common.test.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { writeContext } from "../client/context.js"; +import { resolveCommandContext } from "../commands/client/common.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createTempPath(name: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-")); + return path.join(dir, name); +} + +describe("resolveCommandContext", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_API_URL; + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_COMPANY_ID; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("uses profile defaults when options/env are not provided", () => { + const contextPath = createTempPath("context.json"); + + writeContext( + { + version: 1, + currentProfile: "ops", + profiles: { + ops: { + apiBase: "http://127.0.0.1:9999", + companyId: "company-profile", + apiKeyEnvVarName: "AGENT_KEY", + }, + }, + }, + contextPath, + ); + process.env.AGENT_KEY = "key-from-env"; + + const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true }); + expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999"); + expect(resolved.companyId).toBe("company-profile"); + expect(resolved.api.apiKey).toBe("key-from-env"); + }); + + it("prefers explicit options over profile values", () => { + const contextPath = createTempPath("context.json"); + writeContext( + { + version: 1, + currentProfile: "default", + profiles: { + default: { + apiBase: "http://profile:3100", + companyId: "company-profile", + }, + }, + }, + contextPath, + ); + + const resolved = resolveCommandContext( + { + context: contextPath, + apiBase: "http://override:3200", + apiKey: "direct-token", + companyId: "company-override", + }, + { requireCompany: true }, + ); + + expect(resolved.api.apiBase).toBe("http://override:3200"); + expect(resolved.companyId).toBe("company-override"); + expect(resolved.api.apiKey).toBe("direct-token"); + }); + + it("throws when company is required but unresolved", () => { + const contextPath = createTempPath("context.json"); + writeContext( + { + version: 1, + currentProfile: "default", + profiles: { default: {} }, + }, + contextPath, + ); + + expect(() => + resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }), + ).toThrow(/Company ID is required/); + }); +}); diff --git a/cli/src/__tests__/context.test.ts b/cli/src/__tests__/context.test.ts new file mode 100644 index 00000000..f9b28597 --- /dev/null +++ b/cli/src/__tests__/context.test.ts @@ -0,0 +1,70 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + defaultClientContext, + readContext, + setCurrentProfile, + upsertProfile, + writeContext, +} from "../client/context.js"; + +function createTempContextPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-context-")); + return path.join(dir, "context.json"); +} + +describe("client context store", () => { + it("returns default context when file does not exist", () => { + const contextPath = createTempContextPath(); + const context = readContext(contextPath); + expect(context).toEqual(defaultClientContext()); + }); + + it("upserts profile values and switches current profile", () => { + const contextPath = createTempContextPath(); + + upsertProfile( + "work", + { + apiBase: "http://localhost:3100", + companyId: "company-123", + apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN", + }, + contextPath, + ); + + setCurrentProfile("work", contextPath); + const context = readContext(contextPath); + + expect(context.currentProfile).toBe("work"); + expect(context.profiles.work).toEqual({ + apiBase: "http://localhost:3100", + companyId: "company-123", + apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN", + }); + }); + + it("normalizes invalid file content to safe defaults", () => { + const contextPath = createTempContextPath(); + writeContext( + { + version: 1, + currentProfile: "x", + profiles: { + x: { + apiBase: " ", + companyId: " ", + apiKeyEnvVarName: " ", + }, + }, + }, + contextPath, + ); + + const context = readContext(contextPath); + expect(context.currentProfile).toBe("x"); + expect(context.profiles.x).toEqual({}); + }); +}); diff --git a/cli/src/__tests__/home-paths.test.ts b/cli/src/__tests__/home-paths.test.ts new file mode 100644 index 00000000..1d9c654e --- /dev/null +++ b/cli/src/__tests__/home-paths.test.ts @@ -0,0 +1,44 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + describeLocalInstancePaths, + expandHomePrefix, + resolvePaperclipHomeDir, + resolvePaperclipInstanceId, +} from "../config/home.js"; + +const ORIGINAL_ENV = { ...process.env }; + +describe("home path resolution", () => { + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("defaults to ~/.paperclip and default instance", () => { + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_INSTANCE_ID; + + const paths = describeLocalInstancePaths(); + expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip")); + expect(paths.instanceId).toBe("default"); + expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json")); + }); + + it("supports PAPERCLIP_HOME and explicit instance ids", () => { + process.env.PAPERCLIP_HOME = "~/paperclip-home"; + + const home = resolvePaperclipHomeDir(); + expect(home).toBe(path.resolve(os.homedir(), "paperclip-home")); + expect(resolvePaperclipInstanceId("dev_1")).toBe("dev_1"); + }); + + it("rejects invalid instance ids", () => { + expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/); + }); + + it("expands ~ prefixes", () => { + expect(expandHomePrefix("~")).toBe(os.homedir()); + expect(expandHomePrefix("~/x/y")).toBe(path.resolve(os.homedir(), "x/y")); + }); +}); diff --git a/cli/src/__tests__/http.test.ts b/cli/src/__tests__/http.test.ts new file mode 100644 index 00000000..3681d798 --- /dev/null +++ b/cli/src/__tests__/http.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiRequestError, PaperclipApiClient } from "../client/http.js"; + +describe("PaperclipApiClient", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("adds authorization and run-id headers", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = new PaperclipApiClient({ + apiBase: "http://localhost:3100", + apiKey: "token-123", + runId: "run-abc", + }); + + await client.post("/api/test", { hello: "world" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(call[0]).toContain("/api/test"); + + const headers = call[1].headers as Record; + expect(headers.authorization).toBe("Bearer token-123"); + expect(headers["x-paperclip-run-id"]).toBe("run-abc"); + expect(headers["content-type"]).toBe("application/json"); + }); + + it("returns null on ignoreNotFound", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Not found" }), { status: 404 }), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" }); + const result = await client.get("/api/missing", { ignoreNotFound: true }); + expect(result).toBeNull(); + }); + + it("throws ApiRequestError with details", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ error: "Issue checkout conflict", details: { issueId: "1" } }), + { status: 409 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" }); + + await expect(client.post("/api/issues/1/checkout", {})).rejects.toMatchObject({ + status: 409, + message: "Issue checkout conflict", + details: { issueId: "1" }, + } satisfies Partial); + }); +}); diff --git a/cli/src/client/context.ts b/cli/src/client/context.ts new file mode 100644 index 00000000..15fde597 --- /dev/null +++ b/cli/src/client/context.ts @@ -0,0 +1,175 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveDefaultContextPath } from "../config/home.js"; + +const DEFAULT_CONTEXT_BASENAME = "context.json"; +const DEFAULT_PROFILE = "default"; + +export interface ClientContextProfile { + apiBase?: string; + companyId?: string; + apiKeyEnvVarName?: string; +} + +export interface ClientContext { + version: 1; + currentProfile: string; + profiles: Record; +} + +function findContextFileFromAncestors(startDir: string): string | null { + const absoluteStartDir = path.resolve(startDir); + let currentDir = absoluteStartDir; + + while (true) { + const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONTEXT_BASENAME); + if (fs.existsSync(candidate)) { + return candidate; + } + + const nextDir = path.resolve(currentDir, ".."); + if (nextDir === currentDir) break; + currentDir = nextDir; + } + + return null; +} + +export function resolveContextPath(overridePath?: string): string { + if (overridePath) return path.resolve(overridePath); + if (process.env.PAPERCLIP_CONTEXT) return path.resolve(process.env.PAPERCLIP_CONTEXT); + return findContextFileFromAncestors(process.cwd()) ?? resolveDefaultContextPath(); +} + +export function defaultClientContext(): ClientContext { + return { + version: 1, + currentProfile: DEFAULT_PROFILE, + profiles: { + [DEFAULT_PROFILE]: {}, + }, + }; +} + +function parseJson(filePath: string): unknown { + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")); + } catch (err) { + throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function toStringOrUndefined(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizeProfile(value: unknown): ClientContextProfile { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + const profile = value as Record; + + return { + apiBase: toStringOrUndefined(profile.apiBase), + companyId: toStringOrUndefined(profile.companyId), + apiKeyEnvVarName: toStringOrUndefined(profile.apiKeyEnvVarName), + }; +} + +function normalizeContext(raw: unknown): ClientContext { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return defaultClientContext(); + } + + const record = raw as Record; + const version = record.version === 1 ? 1 : 1; + const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE; + + const rawProfiles = record.profiles; + const profiles: Record = {}; + + if (typeof rawProfiles === "object" && rawProfiles !== null && !Array.isArray(rawProfiles)) { + for (const [name, profile] of Object.entries(rawProfiles as Record)) { + if (!name.trim()) continue; + profiles[name] = normalizeProfile(profile); + } + } + + if (!profiles[currentProfile]) { + profiles[currentProfile] = {}; + } + + if (Object.keys(profiles).length === 0) { + profiles[DEFAULT_PROFILE] = {}; + } + + return { + version, + currentProfile, + profiles, + }; +} + +export function readContext(contextPath?: string): ClientContext { + const filePath = resolveContextPath(contextPath); + if (!fs.existsSync(filePath)) { + return defaultClientContext(); + } + + const raw = parseJson(filePath); + return normalizeContext(raw); +} + +export function writeContext(context: ClientContext, contextPath?: string): void { + const filePath = resolveContextPath(contextPath); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + const normalized = normalizeContext(context); + fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 }); +} + +export function upsertProfile( + profileName: string, + patch: Partial, + contextPath?: string, +): ClientContext { + const context = readContext(contextPath); + const existing = context.profiles[profileName] ?? {}; + const merged: ClientContextProfile = { + ...existing, + ...patch, + }; + + if (patch.apiBase !== undefined && patch.apiBase.trim().length === 0) { + delete merged.apiBase; + } + if (patch.companyId !== undefined && patch.companyId.trim().length === 0) { + delete merged.companyId; + } + if (patch.apiKeyEnvVarName !== undefined && patch.apiKeyEnvVarName.trim().length === 0) { + delete merged.apiKeyEnvVarName; + } + + context.profiles[profileName] = merged; + context.currentProfile = context.currentProfile || profileName; + writeContext(context, contextPath); + return context; +} + +export function setCurrentProfile(profileName: string, contextPath?: string): ClientContext { + const context = readContext(contextPath); + if (!context.profiles[profileName]) { + context.profiles[profileName] = {}; + } + context.currentProfile = profileName; + writeContext(context, contextPath); + return context; +} + +export function resolveProfile( + context: ClientContext, + profileName?: string, +): { name: string; profile: ClientContextProfile } { + const name = profileName?.trim() || context.currentProfile || DEFAULT_PROFILE; + const profile = context.profiles[name] ?? {}; + return { name, profile }; +} diff --git a/cli/src/client/http.ts b/cli/src/client/http.ts new file mode 100644 index 00000000..863249b7 --- /dev/null +++ b/cli/src/client/http.ts @@ -0,0 +1,148 @@ +import { URL } from "node:url"; + +export class ApiRequestError extends Error { + status: number; + details?: unknown; + body?: unknown; + + constructor(status: number, message: string, details?: unknown, body?: unknown) { + super(message); + this.status = status; + this.details = details; + this.body = body; + } +} + +interface RequestOptions { + ignoreNotFound?: boolean; +} + +interface ApiClientOptions { + apiBase: string; + apiKey?: string; + runId?: string; +} + +export class PaperclipApiClient { + readonly apiBase: string; + readonly apiKey?: string; + readonly runId?: string; + + constructor(opts: ApiClientOptions) { + this.apiBase = opts.apiBase.replace(/\/+$/, ""); + this.apiKey = opts.apiKey?.trim() || undefined; + this.runId = opts.runId?.trim() || undefined; + } + + get(path: string, opts?: RequestOptions): Promise { + return this.request(path, { method: "GET" }, opts); + } + + post(path: string, body?: unknown, opts?: RequestOptions): Promise { + return this.request(path, { + method: "POST", + body: body === undefined ? undefined : JSON.stringify(body), + }, opts); + } + + patch(path: string, body?: unknown, opts?: RequestOptions): Promise { + return this.request(path, { + method: "PATCH", + body: body === undefined ? undefined : JSON.stringify(body), + }, opts); + } + + delete(path: string, opts?: RequestOptions): Promise { + return this.request(path, { method: "DELETE" }, opts); + } + + private async request(path: string, init: RequestInit, opts?: RequestOptions): Promise { + const url = buildUrl(this.apiBase, path); + + const headers: Record = { + accept: "application/json", + ...toStringRecord(init.headers), + }; + + if (init.body !== undefined) { + headers["content-type"] = headers["content-type"] ?? "application/json"; + } + + if (this.apiKey) { + headers.authorization = `Bearer ${this.apiKey}`; + } + + if (this.runId) { + headers["x-paperclip-run-id"] = this.runId; + } + + const response = await fetch(url, { + ...init, + headers, + }); + + if (opts?.ignoreNotFound && response.status === 404) { + return null; + } + + if (!response.ok) { + throw await toApiError(response); + } + + if (response.status === 204) { + return null; + } + + const text = await response.text(); + if (!text.trim()) { + return null; + } + + return safeParseJson(text) as T; + } +} + +function buildUrl(apiBase: string, path: string): string { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = new URL(apiBase); + url.pathname = `${url.pathname.replace(/\/+$/, "")}${normalizedPath}`; + return url.toString(); +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function toApiError(response: Response): Promise { + const text = await response.text(); + const parsed = safeParseJson(text); + + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const body = parsed as Record; + const message = + (typeof body.error === "string" && body.error.trim()) || + (typeof body.message === "string" && body.message.trim()) || + `Request failed with status ${response.status}`; + + return new ApiRequestError(response.status, message, body.details, parsed); + } + + return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed); +} + +function toStringRecord(headers: HeadersInit | undefined): Record { + if (!headers) return {}; + if (Array.isArray(headers)) { + return Object.fromEntries(headers.map(([key, value]) => [key, String(value)])); + } + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, String(value)]), + ); +} diff --git a/cli/src/commands/client/activity.ts b/cli/src/commands/client/activity.ts new file mode 100644 index 00000000..82ff5bb5 --- /dev/null +++ b/cli/src/commands/client/activity.ts @@ -0,0 +1,71 @@ +import { Command } from "commander"; +import type { ActivityEvent } from "@paperclip/shared"; +import { + addCommonClientOptions, + formatInlineRecord, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface ActivityListOptions extends BaseClientOptions { + companyId?: string; + agentId?: string; + entityType?: string; + entityId?: string; +} + +export function registerActivityCommands(program: Command): void { + const activity = program.command("activity").description("Activity log operations"); + + addCommonClientOptions( + activity + .command("list") + .description("List company activity log entries") + .requiredOption("-C, --company-id ", "Company ID") + .option("--agent-id ", "Filter by agent ID") + .option("--entity-type ", "Filter by entity type") + .option("--entity-id ", "Filter by entity ID") + .action(async (opts: ActivityListOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const params = new URLSearchParams(); + if (opts.agentId) params.set("agentId", opts.agentId); + if (opts.entityType) params.set("entityType", opts.entityType); + if (opts.entityId) params.set("entityId", opts.entityId); + + const query = params.toString(); + const path = `/api/companies/${ctx.companyId}/activity${query ? `?${query}` : ""}`; + const rows = (await ctx.api.get(path)) ?? []; + + if (ctx.json) { + printOutput(rows, { json: true }); + return; + } + + if (rows.length === 0) { + printOutput([], { json: false }); + return; + } + + for (const row of rows) { + console.log( + formatInlineRecord({ + id: row.id, + action: row.action, + actorType: row.actorType, + actorId: row.actorId, + entityType: row.entityType, + entityId: row.entityId, + createdAt: String(row.createdAt), + }), + ); + } + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts new file mode 100644 index 00000000..efbdb8ea --- /dev/null +++ b/cli/src/commands/client/agent.ts @@ -0,0 +1,74 @@ +import { Command } from "commander"; +import type { Agent } from "@paperclip/shared"; +import { + addCommonClientOptions, + formatInlineRecord, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface AgentListOptions extends BaseClientOptions { + companyId?: string; +} + +export function registerAgentCommands(program: Command): void { + const agent = program.command("agent").description("Agent operations"); + + addCommonClientOptions( + agent + .command("list") + .description("List agents for a company") + .requiredOption("-C, --company-id ", "Company ID") + .action(async (opts: AgentListOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const rows = (await ctx.api.get(`/api/companies/${ctx.companyId}/agents`)) ?? []; + + if (ctx.json) { + printOutput(rows, { json: true }); + return; + } + + if (rows.length === 0) { + printOutput([], { json: false }); + return; + } + + for (const row of rows) { + console.log( + formatInlineRecord({ + id: row.id, + name: row.name, + role: row.role, + status: row.status, + reportsTo: row.reportsTo, + budgetMonthlyCents: row.budgetMonthlyCents, + spentMonthlyCents: row.spentMonthlyCents, + }), + ); + } + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + agent + .command("get") + .description("Get one agent") + .argument("", "Agent ID") + .action(async (agentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const row = await ctx.api.get(`/api/agents/${agentId}`); + printOutput(row, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} diff --git a/cli/src/commands/client/approval.ts b/cli/src/commands/client/approval.ts new file mode 100644 index 00000000..866b783b --- /dev/null +++ b/cli/src/commands/client/approval.ts @@ -0,0 +1,259 @@ +import { Command } from "commander"; +import { + createApprovalSchema, + requestApprovalRevisionSchema, + resolveApprovalSchema, + resubmitApprovalSchema, + type Approval, + type ApprovalComment, +} from "@paperclip/shared"; +import { + addCommonClientOptions, + formatInlineRecord, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface ApprovalListOptions extends BaseClientOptions { + companyId?: string; + status?: string; +} + +interface ApprovalDecisionOptions extends BaseClientOptions { + decisionNote?: string; + decidedByUserId?: string; +} + +interface ApprovalCreateOptions extends BaseClientOptions { + companyId?: string; + type: string; + requestedByAgentId?: string; + payload: string; + issueIds?: string; +} + +interface ApprovalResubmitOptions extends BaseClientOptions { + payload?: string; +} + +interface ApprovalCommentOptions extends BaseClientOptions { + body: string; +} + +export function registerApprovalCommands(program: Command): void { + const approval = program.command("approval").description("Approval operations"); + + addCommonClientOptions( + approval + .command("list") + .description("List approvals for a company") + .requiredOption("-C, --company-id ", "Company ID") + .option("--status ", "Status filter") + .action(async (opts: ApprovalListOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const params = new URLSearchParams(); + if (opts.status) params.set("status", opts.status); + const query = params.toString(); + const rows = + (await ctx.api.get( + `/api/companies/${ctx.companyId}/approvals${query ? `?${query}` : ""}`, + )) ?? []; + + if (ctx.json) { + printOutput(rows, { json: true }); + return; + } + + if (rows.length === 0) { + printOutput([], { json: false }); + return; + } + + for (const row of rows) { + console.log( + formatInlineRecord({ + id: row.id, + type: row.type, + status: row.status, + requestedByAgentId: row.requestedByAgentId, + requestedByUserId: row.requestedByUserId, + }), + ); + } + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + approval + .command("get") + .description("Get one approval") + .argument("", "Approval ID") + .action(async (approvalId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const row = await ctx.api.get(`/api/approvals/${approvalId}`); + printOutput(row, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + approval + .command("create") + .description("Create an approval request") + .requiredOption("-C, --company-id ", "Company ID") + .requiredOption("--type ", "Approval type (hire_agent|approve_ceo_strategy)") + .requiredOption("--payload ", "Approval payload as JSON object") + .option("--requested-by-agent-id ", "Requesting agent ID") + .option("--issue-ids ", "Comma-separated linked issue IDs") + .action(async (opts: ApprovalCreateOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const payloadJson = parseJsonObject(opts.payload, "payload"); + const payload = createApprovalSchema.parse({ + type: opts.type, + payload: payloadJson, + requestedByAgentId: opts.requestedByAgentId, + issueIds: parseCsv(opts.issueIds), + }); + const created = await ctx.api.post(`/api/companies/${ctx.companyId}/approvals`, payload); + printOutput(created, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + approval + .command("approve") + .description("Approve an approval request") + .argument("", "Approval ID") + .option("--decision-note ", "Decision note") + .option("--decided-by-user-id ", "Decision actor user ID") + .action(async (approvalId: string, opts: ApprovalDecisionOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = resolveApprovalSchema.parse({ + decisionNote: opts.decisionNote, + decidedByUserId: opts.decidedByUserId, + }); + const updated = await ctx.api.post(`/api/approvals/${approvalId}/approve`, payload); + printOutput(updated, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + approval + .command("reject") + .description("Reject an approval request") + .argument("", "Approval ID") + .option("--decision-note ", "Decision note") + .option("--decided-by-user-id ", "Decision actor user ID") + .action(async (approvalId: string, opts: ApprovalDecisionOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = resolveApprovalSchema.parse({ + decisionNote: opts.decisionNote, + decidedByUserId: opts.decidedByUserId, + }); + const updated = await ctx.api.post(`/api/approvals/${approvalId}/reject`, payload); + printOutput(updated, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + approval + .command("request-revision") + .description("Request revision for an approval") + .argument("", "Approval ID") + .option("--decision-note ", "Decision note") + .option("--decided-by-user-id ", "Decision actor user ID") + .action(async (approvalId: string, opts: ApprovalDecisionOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = requestApprovalRevisionSchema.parse({ + decisionNote: opts.decisionNote, + decidedByUserId: opts.decidedByUserId, + }); + const updated = await ctx.api.post(`/api/approvals/${approvalId}/request-revision`, payload); + printOutput(updated, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + approval + .command("resubmit") + .description("Resubmit an approval (optionally with new payload)") + .argument("", "Approval ID") + .option("--payload ", "Payload JSON object") + .action(async (approvalId: string, opts: ApprovalResubmitOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = resubmitApprovalSchema.parse({ + payload: opts.payload ? parseJsonObject(opts.payload, "payload") : undefined, + }); + const updated = await ctx.api.post(`/api/approvals/${approvalId}/resubmit`, payload); + printOutput(updated, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + approval + .command("comment") + .description("Add comment to an approval") + .argument("", "Approval ID") + .requiredOption("--body ", "Comment body") + .action(async (approvalId: string, opts: ApprovalCommentOptions) => { + try { + const ctx = resolveCommandContext(opts); + const created = await ctx.api.post(`/api/approvals/${approvalId}/comments`, { + body: opts.body, + }); + printOutput(created, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function parseCsv(value: string | undefined): string[] | undefined { + if (!value) return undefined; + const rows = value.split(",").map((v) => v.trim()).filter(Boolean); + return rows.length > 0 ? rows : undefined; +} + +function parseJsonObject(value: string, name: string): Record { + try { + const parsed = JSON.parse(value) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`${name} must be a JSON object`); + } + return parsed as Record; + } catch (err) { + throw new Error(`Invalid ${name} JSON: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/cli/src/commands/client/common.ts b/cli/src/commands/client/common.ts new file mode 100644 index 00000000..fad92a74 --- /dev/null +++ b/cli/src/commands/client/common.ts @@ -0,0 +1,185 @@ +import pc from "picocolors"; +import type { Command } from "commander"; +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; + context?: string; + profile?: string; + apiBase?: string; + apiKey?: string; + companyId?: string; + json?: boolean; +} + +export interface ResolvedClientContext { + api: PaperclipApiClient; + companyId?: string; + profileName: string; + profile: ClientContextProfile; + json: boolean; +} + +export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command { + command + .option("-c, --config ", "Path to Paperclip config file") + .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("--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 = + options.apiBase?.trim() || + process.env.PAPERCLIP_API_URL?.trim() || + profile.apiBase || + inferApiBaseFromConfig(options.config); + + const apiKey = + options.apiKey?.trim() || + process.env.PAPERCLIP_API_KEY?.trim() || + readKeyFromProfileEnv(profile); + + 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 `paperclip context set`.", + ); + } + + const api = new PaperclipApiClient({ apiBase, apiKey }); + return { + api, + companyId, + profileName, + profile, + json: Boolean(options.json), + }; +} + +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]"; +} + +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); +} diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts new file mode 100644 index 00000000..f6720c51 --- /dev/null +++ b/cli/src/commands/client/company.ts @@ -0,0 +1,67 @@ +import { Command } from "commander"; +import type { Company } from "@paperclip/shared"; +import { + addCommonClientOptions, + formatInlineRecord, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface CompanyCommandOptions extends BaseClientOptions {} + +export function registerCompanyCommands(program: Command): void { + const company = program.command("company").description("Company operations"); + + addCommonClientOptions( + company + .command("list") + .description("List companies") + .action(async (opts: CompanyCommandOptions) => { + try { + const ctx = resolveCommandContext(opts); + const rows = (await ctx.api.get("/api/companies")) ?? []; + if (ctx.json) { + printOutput(rows, { json: true }); + return; + } + + if (rows.length === 0) { + printOutput([], { json: false }); + return; + } + + const formatted = rows.map((row) => ({ + id: row.id, + name: row.name, + status: row.status, + budgetMonthlyCents: row.budgetMonthlyCents, + spentMonthlyCents: row.spentMonthlyCents, + requireBoardApprovalForNewAgents: row.requireBoardApprovalForNewAgents, + })); + for (const row of formatted) { + console.log(formatInlineRecord(row)); + } + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + company + .command("get") + .description("Get one company") + .argument("", "Company ID") + .action(async (companyId: string, opts: CompanyCommandOptions) => { + try { + const ctx = resolveCommandContext(opts); + const row = await ctx.api.get(`/api/companies/${companyId}`); + printOutput(row, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} diff --git a/cli/src/commands/client/context.ts b/cli/src/commands/client/context.ts new file mode 100644 index 00000000..a45ea845 --- /dev/null +++ b/cli/src/commands/client/context.ts @@ -0,0 +1,120 @@ +import { Command } from "commander"; +import pc from "picocolors"; +import { + readContext, + resolveContextPath, + resolveProfile, + setCurrentProfile, + upsertProfile, +} from "../../client/context.js"; +import { printOutput } from "./common.js"; + +interface ContextOptions { + context?: string; + profile?: string; + json?: boolean; +} + +interface ContextSetOptions extends ContextOptions { + apiBase?: string; + companyId?: string; + apiKeyEnvVarName?: string; + use?: boolean; +} + +export function registerContextCommands(program: Command): void { + const context = program.command("context").description("Manage CLI client context profiles"); + + context + .command("show") + .description("Show current context and active profile") + .option("--context ", "Path to CLI context file") + .option("--profile ", "Profile to inspect") + .option("--json", "Output raw JSON") + .action((opts: ContextOptions) => { + const contextPath = resolveContextPath(opts.context); + const store = readContext(opts.context); + const resolved = resolveProfile(store, opts.profile); + const payload = { + contextPath, + currentProfile: store.currentProfile, + profileName: resolved.name, + profile: resolved.profile, + profiles: store.profiles, + }; + printOutput(payload, { json: opts.json }); + }); + + context + .command("list") + .description("List available context profiles") + .option("--context ", "Path to CLI context file") + .option("--json", "Output raw JSON") + .action((opts: ContextOptions) => { + const store = readContext(opts.context); + const rows = Object.entries(store.profiles).map(([name, profile]) => ({ + name, + current: name === store.currentProfile, + apiBase: profile.apiBase ?? null, + companyId: profile.companyId ?? null, + apiKeyEnvVarName: profile.apiKeyEnvVarName ?? null, + })); + printOutput(rows, { json: opts.json }); + }); + + context + .command("use") + .description("Set active context profile") + .argument("", "Profile name") + .option("--context ", "Path to CLI context file") + .action((profile: string, opts: ContextOptions) => { + setCurrentProfile(profile, opts.context); + console.log(pc.green(`Active profile set to '${profile}'.`)); + }); + + context + .command("set") + .description("Set values on a profile") + .option("--context ", "Path to CLI context file") + .option("--profile ", "Profile name (default: current profile)") + .option("--api-base ", "Default API base URL") + .option("--company-id ", "Default company ID") + .option("--api-key-env-var-name ", "Env var containing API key (recommended)") + .option("--use", "Set this profile as active") + .option("--json", "Output raw JSON") + .action((opts: ContextSetOptions) => { + const existing = readContext(opts.context); + const targetProfile = opts.profile?.trim() || existing.currentProfile || "default"; + + upsertProfile( + targetProfile, + { + apiBase: opts.apiBase, + companyId: opts.companyId, + apiKeyEnvVarName: opts.apiKeyEnvVarName, + }, + opts.context, + ); + + if (opts.use) { + setCurrentProfile(targetProfile, opts.context); + } + + const updated = readContext(opts.context); + const resolved = resolveProfile(updated, targetProfile); + const payload = { + contextPath: resolveContextPath(opts.context), + currentProfile: updated.currentProfile, + profileName: resolved.name, + profile: resolved.profile, + }; + + if (!opts.json) { + console.log(pc.green(`Updated profile '${targetProfile}'.`)); + if (opts.use) { + console.log(pc.green(`Set '${targetProfile}' as active profile.`)); + } + } + printOutput(payload, { json: opts.json }); + }); +} diff --git a/cli/src/commands/client/dashboard.ts b/cli/src/commands/client/dashboard.ts new file mode 100644 index 00000000..b8216cda --- /dev/null +++ b/cli/src/commands/client/dashboard.ts @@ -0,0 +1,34 @@ +import { Command } from "commander"; +import type { DashboardSummary } from "@paperclip/shared"; +import { + addCommonClientOptions, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface DashboardGetOptions extends BaseClientOptions { + companyId?: string; +} + +export function registerDashboardCommands(program: Command): void { + const dashboard = program.command("dashboard").description("Dashboard summary operations"); + + addCommonClientOptions( + dashboard + .command("get") + .description("Get dashboard summary for a company") + .requiredOption("-C, --company-id ", "Company ID") + .action(async (opts: DashboardGetOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const row = await ctx.api.get(`/api/companies/${ctx.companyId}/dashboard`); + printOutput(row, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts new file mode 100644 index 00000000..3a54c447 --- /dev/null +++ b/cli/src/commands/client/issue.ts @@ -0,0 +1,313 @@ +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); + }); +} diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index 9354bbde..d076e6c6 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -8,6 +8,11 @@ import { promptLlm } from "../prompts/llm.js"; import { promptLogging } from "../prompts/logging.js"; import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js"; import { promptServer } from "../prompts/server.js"; +import { + resolveDefaultEmbeddedPostgresDir, + resolveDefaultLogsDir, + resolvePaperclipInstanceId, +} from "../config/home.js"; type Section = "llm" | "database" | "logging" | "server" | "secrets"; @@ -20,6 +25,7 @@ const SECTION_LABELS: Record<Section, string> = { }; function defaultConfig(): PaperclipConfig { + const instanceId = resolvePaperclipInstanceId(); return { $meta: { version: 1, @@ -28,12 +34,12 @@ function defaultConfig(): PaperclipConfig { }, database: { mode: "embedded-postgres", - embeddedPostgresDataDir: "./data/embedded-postgres", + embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId), embeddedPostgresPort: 54329, }, logging: { mode: "file", - logDir: "./data/logs", + logDir: resolveDefaultLogsDir(instanceId), }, server: { port: 3100, diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 678facef..210dfb31 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -23,7 +23,7 @@ export async function doctor(opts: { config?: string; repair?: boolean; yes?: boolean; -}): Promise<void> { +}): Promise<{ passed: number; warned: number; failed: number }> { p.intro(pc.bgCyan(pc.black(" paperclip doctor "))); const configPath = resolveConfigPath(opts.config); @@ -35,8 +35,7 @@ export async function doctor(opts: { printResult(cfgResult); if (cfgResult.status === "fail") { - printSummary(results); - return; + return printSummary(results); } let config: PaperclipConfig; @@ -52,8 +51,7 @@ export async function doctor(opts: { }; results.push(readResult); printResult(readResult); - printSummary(results); - return; + return printSummary(results); } // 2. Agent JWT check @@ -91,7 +89,7 @@ export async function doctor(opts: { printResult(portResult); // Summary - printSummary(results); + return printSummary(results); } function printResult(result: CheckResult): void { @@ -129,7 +127,7 @@ async function maybeRepair( } } -function printSummary(results: CheckResult[]): void { +function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } { const passed = results.filter((r) => r.status === "pass").length; const warned = results.filter((r) => r.status === "warn").length; const failed = results.filter((r) => r.status === "fail").length; @@ -148,4 +146,6 @@ function printSummary(results: CheckResult[]): void { } else { p.outro(pc.green("All checks passed!")); } + + return { passed, warned, failed }; } diff --git a/cli/src/commands/env.ts b/cli/src/commands/env.ts index 1b7de4a8..7f48383a 100644 --- a/cli/src/commands/env.ts +++ b/cli/src/commands/env.ts @@ -7,6 +7,10 @@ import { readAgentJwtSecretFromEnvFile, resolveAgentJwtEnvFile, } from "../config/env.js"; +import { + resolveDefaultSecretsKeyFilePath, + resolvePaperclipInstanceId, +} from "../config/home.js"; type EnvSource = "env" | "config" | "file" | "default" | "missing"; @@ -23,7 +27,9 @@ const DEFAULT_AGENT_JWT_ISSUER = "paperclip"; const DEFAULT_AGENT_JWT_AUDIENCE = "paperclip-api"; const DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS = "30000"; const DEFAULT_SECRETS_PROVIDER = "local_encrypted"; -const DEFAULT_SECRETS_KEY_FILE_PATH = "./data/secrets/master.key"; +function defaultSecretsKeyFilePath(): string { + return resolveDefaultSecretsKeyFilePath(resolvePaperclipInstanceId()); +} export async function envCommand(opts: { config?: string }): Promise<void> { p.intro(pc.bgCyan(pc.black(" paperclip env "))); @@ -120,7 +126,7 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st const secretsKeyFilePath = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ?? config?.secrets?.localEncrypted?.keyFilePath ?? - DEFAULT_SECRETS_KEY_FILE_PATH; + defaultSecretsKeyFilePath(); const rows: EnvVarRow[] = [ { diff --git a/cli/src/commands/heartbeat-run.ts b/cli/src/commands/heartbeat-run.ts index 288a42ba..76122a73 100644 --- a/cli/src/commands/heartbeat-run.ts +++ b/cli/src/commands/heartbeat-run.ts @@ -1,9 +1,8 @@ import { setTimeout as delay } from "node:timers/promises"; import pc from "picocolors"; import type { Agent, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatus } from "@paperclip/shared"; -import type { PaperclipConfig } from "../config/schema.js"; -import { readConfig } from "../config/store.js"; import { getCLIAdapter } from "../adapters/index.js"; +import { resolveCommandContext } from "./client/common.js"; const HEARTBEAT_SOURCES = ["timer", "assignment", "on_demand", "automation"] as const; const HEARTBEAT_TRIGGERS = ["manual", "ping", "callback", "system"] as const; @@ -19,12 +18,16 @@ interface HeartbeatRunEventRecord extends HeartbeatRunEvent { interface HeartbeatRunOptions { config?: string; + context?: string; + profile?: string; agentId: string; apiBase?: string; + apiKey?: string; source: string; trigger: string; timeoutMs: string; debug?: boolean; + json?: boolean; } function asRecord(value: unknown): Record<string, unknown> | null { @@ -63,35 +66,27 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> { ? (opts.trigger as HeartbeatTrigger) : "manual"; - let config: PaperclipConfig | null = null; - try { - config = readConfig(opts.config); - } catch (err) { - console.error( - pc.yellow( - `Config warning: ${err instanceof Error ? err.message : String(err)}\nContinuing with API base fallback settings.`, - ), - ); - } - const apiBase = getApiBase(config, opts.apiBase); - - const agent = await requestJson<Agent>(`${apiBase}/api/agents/${opts.agentId}`, { - method: "GET", + const ctx = resolveCommandContext({ + config: opts.config, + context: opts.context, + profile: opts.profile, + apiBase: opts.apiBase, + apiKey: opts.apiKey, + json: opts.json, }); + const api = ctx.api; + + const agent = await api.get<Agent>(`/api/agents/${opts.agentId}`); if (!agent || typeof agent !== "object" || !agent.id) { console.error(pc.red(`Agent not found: ${opts.agentId}`)); return; } - const invokeRes = await requestJson<InvokedHeartbeat>( - `${apiBase}/api/agents/${opts.agentId}/wakeup`, + const invokeRes = await api.post<InvokedHeartbeat>( + `/api/agents/${opts.agentId}/wakeup`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - source: source, - triggerDetail: triggerDetail, - }), + source: source, + triggerDetail: triggerDetail, }, ); if (!invokeRes) { @@ -221,16 +216,15 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> { } while (true) { - const events = await requestJson<HeartbeatRunEvent[]>( - `${apiBase}/api/heartbeat-runs/${activeRunId}/events?afterSeq=${lastEventSeq}&limit=100`, - { method: "GET" }, + const events = await api.get<HeartbeatRunEvent[]>( + `/api/heartbeat-runs/${activeRunId}/events?afterSeq=${lastEventSeq}&limit=100`, ); for (const event of Array.isArray(events) ? (events as HeartbeatRunEventRecord[]) : []) { handleEvent(event); } - const runList = (await requestJson<(HeartbeatRun | null)[]>( - `${apiBase}/api/companies/${agent.companyId}/heartbeat-runs?agentId=${agent.id}`, + const runList = (await api.get<(HeartbeatRun | null)[]>( + `/api/companies/${agent.companyId}/heartbeat-runs?agentId=${agent.id}`, )) || []; const currentRun = runList.find((r) => r && r.id === activeRunId) ?? null; @@ -259,9 +253,8 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> { break; } - const logResult = await requestJson<{ content: string; nextOffset?: number }>( - `${apiBase}/api/heartbeat-runs/${activeRunId}/log?offset=${logOffset}&limitBytes=16384`, - { method: "GET" }, + const logResult = await api.get<{ content: string; nextOffset?: number }>( + `/api/heartbeat-runs/${activeRunId}/log?offset=${logOffset}&limitBytes=16384`, { ignoreNotFound: true }, ); if (logResult && logResult.content) { @@ -349,50 +342,3 @@ function safeParseLogLine(line: string): { stream: "stdout" | "stderr" | "system return null; } } - -function getApiBase(config: PaperclipConfig | null, apiBaseOverride?: string): string { - if (apiBaseOverride?.trim()) return apiBaseOverride.trim(); - const envBase = process.env.PAPERCLIP_API_URL?.trim(); - if (envBase) return envBase; - const envHost = process.env.PAPERCLIP_SERVER_HOST?.trim() || "localhost"; - const envPort = Number(process.env.PAPERCLIP_SERVER_PORT || config?.server?.port || 3100); - return `http://${envHost}:${Number.isFinite(envPort) && envPort > 0 ? envPort : 3100}`; -} - -async function requestJson<T>( - url: string, - init?: RequestInit, - opts?: { ignoreNotFound?: boolean }, -): Promise<T | null> { - const res = await fetch(url, { - ...init, - headers: { - ...init?.headers, - accept: "application/json", - }, - }); - - if (!res.ok) { - if (opts?.ignoreNotFound && res.status === 404) { - return null; - } - const text = await safeReadText(res); - console.error(pc.red(`Request failed (${res.status}): ${text || res.statusText}`)); - return null; - } - - return (await res.json()) as T; -} - -async function safeReadText(res: Response): Promise<string> { - try { - const text = await res.text(); - if (!text) return ""; - const trimmed = text.trim(); - if (!trimmed) return ""; - if (trimmed[0] === "{" || trimmed[0] === "[") return trimmed; - return trimmed; - } catch (_err) { - return ""; - } -} diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index c26788f1..505741b7 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -9,9 +9,16 @@ import { promptLlm } from "../prompts/llm.js"; import { promptLogging } from "../prompts/logging.js"; import { defaultSecretsConfig } from "../prompts/secrets.js"; import { promptServer } from "../prompts/server.js"; +import { describeLocalInstancePaths, resolvePaperclipInstanceId } from "../config/home.js"; export async function onboard(opts: { config?: string }): Promise<void> { p.intro(pc.bgCyan(pc.black(" paperclip onboard "))); + const instance = describeLocalInstancePaths(resolvePaperclipInstanceId()); + p.log.message( + pc.dim( + `Local home: ${instance.homeDir} | instance: ${instance.instanceId} | config: ${resolveConfigPath(opts.config)}`, + ), + ); // Check for existing config if (configExists(opts.config)) { diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts new file mode 100644 index 00000000..d7baa27f --- /dev/null +++ b/cli/src/commands/run.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { onboard } from "./onboard.js"; +import { doctor } from "./doctor.js"; +import { configExists, resolveConfigPath } from "../config/store.js"; +import { + describeLocalInstancePaths, + resolvePaperclipHomeDir, + resolvePaperclipInstanceId, +} from "../config/home.js"; + +interface RunOptions { + config?: string; + instance?: string; + repair?: boolean; + yes?: boolean; +} + +export async function runCommand(opts: RunOptions): Promise<void> { + const instanceId = resolvePaperclipInstanceId(opts.instance); + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + + const homeDir = resolvePaperclipHomeDir(); + fs.mkdirSync(homeDir, { recursive: true }); + + const paths = describeLocalInstancePaths(instanceId); + fs.mkdirSync(paths.instanceRoot, { recursive: true }); + + const configPath = resolveConfigPath(opts.config); + process.env.PAPERCLIP_CONFIG = configPath; + + p.intro(pc.bgCyan(pc.black(" paperclip run "))); + p.log.message(pc.dim(`Home: ${paths.homeDir}`)); + p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); + p.log.message(pc.dim(`Config: ${configPath}`)); + + if (!configExists(configPath)) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + p.log.error("No config found and terminal is non-interactive."); + p.log.message(`Run ${pc.cyan("paperclip onboard")} once, then retry ${pc.cyan("paperclip run")}.`); + process.exit(1); + } + + p.log.step("No config found. Starting onboarding..."); + await onboard({ config: configPath }); + } + + p.log.step("Running doctor checks..."); + const summary = await doctor({ + config: configPath, + repair: opts.repair ?? true, + yes: opts.yes ?? true, + }); + + if (summary.failed > 0) { + p.log.error("Doctor found blocking issues. Not starting server."); + process.exit(1); + } + + p.log.step("Starting Paperclip server..."); + await importServerEntry(); +} + +async function importServerEntry(): Promise<void> { + const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + const fileCandidates = [ + path.resolve(projectRoot, "server/dist/index.js"), + path.resolve(projectRoot, "server/src/index.ts"), + ]; + + const specifierCandidates: string[] = [ + "@paperclip/server/dist/index.js", + "@paperclip/server/src/index.ts", + ]; + + const importErrors: string[] = []; + + for (const specifier of specifierCandidates) { + try { + await import(specifier); + return; + } catch (err) { + importErrors.push(`${specifier}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + for (const filePath of fileCandidates) { + if (!fs.existsSync(filePath)) continue; + try { + await import(pathToFileURL(filePath).href); + return; + } catch (err) { + importErrors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + throw new Error( + `Could not start Paperclip server entrypoint. Tried: ${[...specifierCandidates, ...fileCandidates].join(", ")}\n` + + importErrors.join("\n"), + ); +} diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 98b5938f..7a0fb1e8 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -8,8 +8,6 @@ const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET"; function resolveEnvFilePath() { return path.resolve(path.dirname(resolveConfigPath()), ".env"); } - -const ENV_FILE_PATH = resolveEnvFilePath(); const loadedEnvFiles = new Set<string>(); function isNonEmpty(value: unknown): value is string { @@ -35,10 +33,10 @@ function renderEnvFile(entries: Record<string, string>) { } export function resolveAgentJwtEnvFile(): string { - return ENV_FILE_PATH; + return resolveEnvFilePath(); } -export function loadAgentJwtEnvFile(filePath = ENV_FILE_PATH): void { +export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void { if (loadedEnvFiles.has(filePath)) return; if (!fs.existsSync(filePath)) return; @@ -52,7 +50,7 @@ export function readAgentJwtSecretFromEnv(): string | null { return isNonEmpty(raw) ? raw!.trim() : null; } -export function readAgentJwtSecretFromEnvFile(filePath = ENV_FILE_PATH): string | null { +export function readAgentJwtSecretFromEnvFile(filePath = resolveEnvFilePath()): string | null { if (!fs.existsSync(filePath)) return null; const raw = fs.readFileSync(filePath, "utf-8"); @@ -78,7 +76,7 @@ export function ensureAgentJwtSecret(): { secret: string; created: boolean } { return { secret, created }; } -export function writeAgentJwtEnv(secret: string, filePath = ENV_FILE_PATH): void { +export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); diff --git a/cli/src/config/home.ts b/cli/src/config/home.ts new file mode 100644 index 00000000..a341a73a --- /dev/null +++ b/cli/src/config/home.ts @@ -0,0 +1,66 @@ +import os from "node:os"; +import path from "node:path"; + +const DEFAULT_INSTANCE_ID = "default"; +const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/; + +export function resolvePaperclipHomeDir(): string { + const envHome = process.env.PAPERCLIP_HOME?.trim(); + if (envHome) return path.resolve(expandHomePrefix(envHome)); + return path.resolve(os.homedir(), ".paperclip"); +} + +export function resolvePaperclipInstanceId(override?: string): string { + const raw = override?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID; + if (!INSTANCE_ID_RE.test(raw)) { + throw new Error( + `Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`, + ); + } + return raw; +} + +export function resolvePaperclipInstanceRoot(instanceId?: string): string { + const id = resolvePaperclipInstanceId(instanceId); + return path.resolve(resolvePaperclipHomeDir(), "instances", id); +} + +export function resolveDefaultConfigPath(instanceId?: string): string { + return path.resolve(resolvePaperclipInstanceRoot(instanceId), "config.json"); +} + +export function resolveDefaultContextPath(): string { + return path.resolve(resolvePaperclipHomeDir(), "context.json"); +} + +export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string { + return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db"); +} + +export function resolveDefaultLogsDir(instanceId?: string): string { + return path.resolve(resolvePaperclipInstanceRoot(instanceId), "logs"); +} + +export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string { + return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key"); +} + +export function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +export function describeLocalInstancePaths(instanceId?: string) { + const resolvedInstanceId = resolvePaperclipInstanceId(instanceId); + const instanceRoot = resolvePaperclipInstanceRoot(resolvedInstanceId); + return { + homeDir: resolvePaperclipHomeDir(), + instanceId: resolvedInstanceId, + instanceRoot, + configPath: resolveDefaultConfigPath(resolvedInstanceId), + embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId), + logDir: resolveDefaultLogsDir(resolvedInstanceId), + secretsKeyFilePath: resolveDefaultSecretsKeyFilePath(resolvedInstanceId), + }; +} diff --git a/cli/src/config/store.ts b/cli/src/config/store.ts index 973244be..8dddc777 100644 --- a/cli/src/config/store.ts +++ b/cli/src/config/store.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import { paperclipConfigSchema, type PaperclipConfig } from "./schema.js"; +import { + resolveDefaultConfigPath, + resolvePaperclipInstanceId, +} from "./home.js"; -const DEFAULT_CONFIG_PATH = ".paperclip/config.json"; const DEFAULT_CONFIG_BASENAME = "config.json"; function findConfigFileFromAncestors(startDir: string): string | null { @@ -26,7 +29,7 @@ function findConfigFileFromAncestors(startDir: string): string | null { export function resolveConfigPath(overridePath?: string): string { if (overridePath) return path.resolve(overridePath); if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG); - return findConfigFileFromAncestors(process.cwd()) ?? path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); + return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(resolvePaperclipInstanceId()); } function parseJson(filePath: string): unknown { diff --git a/cli/src/index.ts b/cli/src/index.ts index fd997ec3..50021f27 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -5,6 +5,14 @@ import { doctor } from "./commands/doctor.js"; import { envCommand } from "./commands/env.js"; import { configure } from "./commands/configure.js"; import { heartbeatRun } from "./commands/heartbeat-run.js"; +import { runCommand } from "./commands/run.js"; +import { registerContextCommands } from "./commands/client/context.js"; +import { registerCompanyCommands } from "./commands/client/company.js"; +import { registerIssueCommands } from "./commands/client/issue.js"; +import { registerAgentCommands } from "./commands/client/agent.js"; +import { registerApprovalCommands } from "./commands/client/approval.js"; +import { registerActivityCommands } from "./commands/client/activity.js"; +import { registerDashboardCommands } from "./commands/client/dashboard.js"; const program = new Command(); @@ -26,7 +34,9 @@ program .option("--repair", "Attempt to repair issues automatically") .alias("--fix") .option("-y, --yes", "Skip repair confirmation prompts") - .action(doctor); + .action(async (opts) => { + await doctor(opts); + }); program .command("env") @@ -41,6 +51,15 @@ program .option("-s, --section <section>", "Section to configure (llm, database, logging, server, secrets)") .action(configure); +program + .command("run") + .description("Bootstrap local setup (onboard + doctor) and run Paperclip") + .option("-c, --config <path>", "Path to config file") + .option("-i, --instance <id>", "Local instance id (default: default)") + .option("--repair", "Attempt automatic repairs during doctor", true) + .option("--no-repair", "Disable automatic repairs during doctor") + .action(runCommand); + const heartbeat = program.command("heartbeat").description("Heartbeat utilities"); heartbeat @@ -48,7 +67,10 @@ heartbeat .description("Run one agent heartbeat and stream live logs") .requiredOption("-a, --agent-id <agentId>", "Agent ID to invoke") .option("-c, --config <path>", "Path to config file") + .option("--context <path>", "Path to CLI context file") + .option("--profile <name>", "CLI context profile name") .option("--api-base <url>", "Base URL for the Paperclip server API") + .option("--api-key <token>", "Bearer token for agent-authenticated calls") .option( "--source <source>", "Invocation source (timer | assignment | on_demand | automation)", @@ -56,7 +78,19 @@ heartbeat ) .option("--trigger <trigger>", "Trigger detail (manual | ping | callback | system)", "manual") .option("--timeout-ms <ms>", "Max time to wait before giving up", "0") + .option("--json", "Output raw JSON where applicable") .option("--debug", "Show raw adapter stdout/stderr JSON chunks") .action(heartbeatRun); -program.parse(); +registerContextCommands(program); +registerCompanyCommands(program); +registerIssueCommands(program); +registerAgentCommands(program); +registerApprovalCommands(program); +registerActivityCommands(program); +registerDashboardCommands(program); + +program.parseAsync().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/cli/src/prompts/database.ts b/cli/src/prompts/database.ts index 28c34c41..64dbf46c 100644 --- a/cli/src/prompts/database.ts +++ b/cli/src/prompts/database.ts @@ -1,7 +1,10 @@ import * as p from "@clack/prompts"; import type { DatabaseConfig } from "../config/schema.js"; +import { resolveDefaultEmbeddedPostgresDir, resolvePaperclipInstanceId } from "../config/home.js"; export async function promptDatabase(): Promise<DatabaseConfig> { + const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(resolvePaperclipInstanceId()); + const mode = await p.select({ message: "Database mode", options: [ @@ -33,15 +36,15 @@ export async function promptDatabase(): Promise<DatabaseConfig> { return { mode: "postgres", connectionString, - embeddedPostgresDataDir: "./data/embedded-postgres", + embeddedPostgresDataDir: defaultEmbeddedDir, embeddedPostgresPort: 54329, }; } const embeddedPostgresDataDir = await p.text({ message: "Embedded PostgreSQL data directory", - defaultValue: "./data/embedded-postgres", - placeholder: "./data/embedded-postgres", + defaultValue: defaultEmbeddedDir, + placeholder: defaultEmbeddedDir, }); if (p.isCancel(embeddedPostgresDataDir)) { @@ -66,7 +69,7 @@ export async function promptDatabase(): Promise<DatabaseConfig> { return { mode: "embedded-postgres", - embeddedPostgresDataDir: embeddedPostgresDataDir || "./data/embedded-postgres", + embeddedPostgresDataDir: embeddedPostgresDataDir || defaultEmbeddedDir, embeddedPostgresPort: Number(embeddedPostgresPort || "54329"), }; } diff --git a/cli/src/prompts/logging.ts b/cli/src/prompts/logging.ts index 734e2186..dddaaf3c 100644 --- a/cli/src/prompts/logging.ts +++ b/cli/src/prompts/logging.ts @@ -1,7 +1,9 @@ import * as p from "@clack/prompts"; import type { LoggingConfig } from "../config/schema.js"; +import { resolveDefaultLogsDir, resolvePaperclipInstanceId } from "../config/home.js"; export async function promptLogging(): Promise<LoggingConfig> { + const defaultLogDir = resolveDefaultLogsDir(resolvePaperclipInstanceId()); const mode = await p.select({ message: "Logging mode", options: [ @@ -18,8 +20,8 @@ export async function promptLogging(): Promise<LoggingConfig> { if (mode === "file") { const logDir = await p.text({ message: "Log directory", - defaultValue: "./data/logs", - placeholder: "./data/logs", + defaultValue: defaultLogDir, + placeholder: defaultLogDir, }); if (p.isCancel(logDir)) { @@ -27,9 +29,9 @@ export async function promptLogging(): Promise<LoggingConfig> { process.exit(0); } - return { mode: "file", logDir: logDir || "./data/logs" }; + return { mode: "file", logDir: logDir || defaultLogDir }; } p.note("Cloud logging is coming soon. Using file-based logging for now."); - return { mode: "file", logDir: "./data/logs" }; + return { mode: "file", logDir: defaultLogDir }; } diff --git a/cli/src/prompts/secrets.ts b/cli/src/prompts/secrets.ts index 748cdf33..56e0c67f 100644 --- a/cli/src/prompts/secrets.ts +++ b/cli/src/prompts/secrets.ts @@ -1,15 +1,19 @@ import * as p from "@clack/prompts"; import type { SecretProvider } from "@paperclip/shared"; import type { SecretsConfig } from "../config/schema.js"; +import { resolveDefaultSecretsKeyFilePath, resolvePaperclipInstanceId } from "../config/home.js"; -const DEFAULT_KEY_FILE_PATH = "./data/secrets/master.key"; +function defaultKeyFilePath(): string { + return resolveDefaultSecretsKeyFilePath(resolvePaperclipInstanceId()); +} export function defaultSecretsConfig(): SecretsConfig { + const keyFilePath = defaultKeyFilePath(); return { provider: "local_encrypted", strictMode: false, localEncrypted: { - keyFilePath: DEFAULT_KEY_FILE_PATH, + keyFilePath, }, }; } @@ -59,12 +63,13 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon process.exit(0); } - let keyFilePath = base.localEncrypted.keyFilePath || DEFAULT_KEY_FILE_PATH; + const fallbackDefault = defaultKeyFilePath(); + let keyFilePath = base.localEncrypted.keyFilePath || fallbackDefault; if (provider === "local_encrypted") { const keyPath = await p.text({ message: "Local encrypted key file path", defaultValue: keyFilePath, - placeholder: DEFAULT_KEY_FILE_PATH, + placeholder: fallbackDefault, validate: (value) => { if (!value || value.trim().length === 0) return "Key file path is required"; }, diff --git a/cli/src/utils/path-resolver.ts b/cli/src/utils/path-resolver.ts index daff57ac..d8ebbd07 100644 --- a/cli/src/utils/path-resolver.ts +++ b/cli/src/utils/path-resolver.ts @@ -1,22 +1,24 @@ import fs from "node:fs"; import path from "node:path"; +import { expandHomePrefix } from "../config/home.js"; function unique(items: string[]): string[] { return Array.from(new Set(items)); } export function resolveRuntimeLikePath(value: string, configPath?: string): string { - if (path.isAbsolute(value)) return value; + const expanded = expandHomePrefix(value); + if (path.isAbsolute(expanded)) return path.resolve(expanded); const cwd = process.cwd(); const configDir = configPath ? path.dirname(configPath) : null; const workspaceRoot = configDir ? path.resolve(configDir, "..") : cwd; const candidates = unique([ - path.resolve(workspaceRoot, "server", value), - path.resolve(workspaceRoot, value), - path.resolve(cwd, value), - ...(configDir ? [path.resolve(configDir, value)] : []), + ...(configDir ? [path.resolve(configDir, expanded)] : []), + path.resolve(workspaceRoot, "server", expanded), + path.resolve(workspaceRoot, expanded), + path.resolve(cwd, expanded), ]); return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]; diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/doc/CLI.md b/doc/CLI.md new file mode 100644 index 00000000..b2063c75 --- /dev/null +++ b/doc/CLI.md @@ -0,0 +1,128 @@ +# CLI Reference + +Paperclip CLI now supports both: + +- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`) +- control-plane client operations (issues, approvals, agents, activity, dashboard) + +## Base Usage + +Use repo script in development: + +```sh +pnpm paperclip --help +``` + +First-time local bootstrap + run: + +```sh +pnpm paperclip run +``` + +Choose local instance: + +```sh +pnpm paperclip run --instance dev +``` + +All client commands support: + +- `--api-base <url>` +- `--api-key <token>` +- `--context <path>` +- `--profile <name>` +- `--json` + +Company-scoped commands also support `--company-id <id>`. + +## Context Profiles + +Store local defaults in `~/.paperclip/context.json`: + +```sh +pnpm paperclip context set --api-base http://localhost:3100 --company-id <company-id> +pnpm paperclip context show +pnpm paperclip context list +pnpm paperclip context use default +``` + +To avoid storing secrets in context, set `apiKeyEnvVarName` and keep the key in env: + +```sh +pnpm paperclip context set --api-key-env-var-name PAPERCLIP_API_KEY +export PAPERCLIP_API_KEY=... +``` + +## Company Commands + +```sh +pnpm paperclip company list +pnpm paperclip company get <company-id> +``` + +## Issue Commands + +```sh +pnpm paperclip issue list --company-id <company-id> [--status todo,in_progress] [--assignee-agent-id <agent-id>] [--match text] +pnpm paperclip issue get <issue-id-or-identifier> +pnpm paperclip issue create --company-id <company-id> --title "..." [--description "..."] [--status todo] [--priority high] +pnpm paperclip issue update <issue-id> [--status in_progress] [--comment "..."] +pnpm paperclip issue comment <issue-id> --body "..." [--reopen] +pnpm paperclip issue checkout <issue-id> --agent-id <agent-id> [--expected-statuses todo,backlog,blocked] +pnpm paperclip issue release <issue-id> +``` + +## Agent Commands + +```sh +pnpm paperclip agent list --company-id <company-id> +pnpm paperclip agent get <agent-id> +``` + +## Approval Commands + +```sh +pnpm paperclip approval list --company-id <company-id> [--status pending] +pnpm paperclip approval get <approval-id> +pnpm paperclip approval create --company-id <company-id> --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>] +pnpm paperclip approval approve <approval-id> [--decision-note "..."] +pnpm paperclip approval reject <approval-id> [--decision-note "..."] +pnpm paperclip approval request-revision <approval-id> [--decision-note "..."] +pnpm paperclip approval resubmit <approval-id> [--payload '{"...":"..."}'] +pnpm paperclip approval comment <approval-id> --body "..." +``` + +## Activity Commands + +```sh +pnpm paperclip activity list --company-id <company-id> [--agent-id <agent-id>] [--entity-type issue] [--entity-id <id>] +``` + +## Dashboard Commands + +```sh +pnpm paperclip dashboard get --company-id <company-id> +``` + +## Heartbeat Command + +`heartbeat run` now also supports context/api-key options and uses the shared client stack: + +```sh +pnpm paperclip heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100] [--api-key <token>] +``` + +## Local Storage Defaults + +Default local instance root is `~/.paperclip/instances/default`: + +- config: `~/.paperclip/instances/default/config.json` +- embedded db: `~/.paperclip/instances/default/db` +- logs: `~/.paperclip/instances/default/logs` +- secrets key: `~/.paperclip/instances/default/secrets/master.key` + +Override base home or instance with env vars: + +```sh +PAPERCLIP_HOME=/custom/home PAPERCLIP_INSTANCE_ID=dev pnpm paperclip run +``` diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2406c318..08673c4f 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -12,12 +12,12 @@ pnpm dev That's it. On first start the server: -1. Creates a `./server/data/embedded-postgres/` directory for storage +1. Creates a `~/.paperclip/instances/default/db/` directory for storage 2. Ensures the `paperclip` database exists 3. Runs migrations automatically for empty databases 4. Starts serving requests -Data persists across restarts in `./server/data/embedded-postgres/`. To reset local dev data, delete that directory. +Data persists across restarts in `~/.paperclip/instances/default/db/`. To reset local dev data, delete that directory. This mode is ideal for local development and one-command installs. @@ -115,7 +115,7 @@ The database mode is controlled by `DATABASE_URL`: | `DATABASE_URL` | Mode | |---|---| -| Not set | Embedded PostgreSQL (`./server/data/embedded-postgres/`) | +| Not set | Embedded PostgreSQL (`~/.paperclip/instances/default/db/`) | | `postgres://...localhost...` | Local Docker PostgreSQL | | `postgres://...supabase.com...` | Hosted Supabase | @@ -131,8 +131,8 @@ Paperclip stores secret metadata and versions in: For local/default installs, the active provider is `local_encrypted`: - Secret material is encrypted at rest with a local master key. -- Default key file: `./data/secrets/master.key` (auto-created if missing). -- CLI config location: `.paperclip/config.json` under `secrets.localEncrypted.keyFilePath`. +- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing). +- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`. Optional overrides: diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 93f1cedd..76778950 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -21,12 +21,32 @@ This starts: - API server: `http://localhost:3100` - UI: served by the API server in dev middleware mode (same origin as API) +## One-Command Local Run + +For a first-time local install, you can bootstrap and run in one command: + +```sh +pnpm paperclip run +``` + +`paperclip run` does: + +1. auto-onboard if config is missing +2. `paperclip doctor` with repair enabled +3. starts the server when checks pass + ## Database in Dev (Auto-Handled) For local development, leave `DATABASE_URL` unset. The server will automatically use embedded PostgreSQL and persist data at: -- `./data/embedded-postgres` +- `~/.paperclip/instances/default/db` + +Override home and instance: + +```sh +PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclip run +``` No Docker or external database is required for this mode. @@ -49,7 +69,7 @@ Expected: To wipe local dev data and start fresh: ```sh -rm -rf server/data/embedded-postgres +rm -rf ~/.paperclip/instances/default/db pnpm dev ``` @@ -61,7 +81,7 @@ If you set `DATABASE_URL`, the server will use that instead of embedded PostgreS Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config. -- Default local key path: `./data/secrets/master.key` +- Default local key path: `~/.paperclip/instances/default/secrets/master.key` - Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY` - Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE` @@ -85,3 +105,30 @@ Migration helper for existing inline env secrets: pnpm secrets:migrate-inline-env # dry run pnpm secrets:migrate-inline-env --apply # apply migration ``` + +## CLI Client Operations + +Paperclip CLI now includes client-side control-plane commands in addition to setup commands. + +Quick examples: + +```sh +pnpm paperclip issue list --company-id <company-id> +pnpm paperclip issue create --company-id <company-id> --title "Investigate checkout conflict" +pnpm paperclip issue update <issue-id> --status in_progress --comment "Started triage" +``` + +Set defaults once with context profiles: + +```sh +pnpm paperclip context set --api-base http://localhost:3100 --company-id <company-id> +``` + +Then run commands without repeating flags: + +```sh +pnpm paperclip issue list +pnpm paperclip dashboard get +``` + +See full command reference in `doc/CLI.md`. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 7501e0be..1f9ef2df 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -92,7 +92,7 @@ V1 implementation extends this baseline into a company-centric, governance-aware ## 6.2 Data Stores - Primary: PostgreSQL -- Local default: embedded PostgreSQL at `./server/data/embedded-postgres` +- Local default: embedded PostgreSQL at `~/.paperclip/instances/default/db` - Optional local prod-like: Docker Postgres - Optional hosted: Supabase/Postgres-compatible diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index 22c5b4df..b338e485 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -15,13 +15,13 @@ export const llmConfigSchema = z.object({ export const databaseConfigSchema = z.object({ mode: z.enum(["embedded-postgres", "postgres"]).default("embedded-postgres"), connectionString: z.string().optional(), - embeddedPostgresDataDir: z.string().default("./data/embedded-postgres"), + embeddedPostgresDataDir: z.string().default("~/.paperclip/instances/default/db"), embeddedPostgresPort: z.number().int().min(1).max(65535).default(54329), }); export const loggingConfigSchema = z.object({ mode: z.enum(["file", "cloud"]), - logDir: z.string().default("./data/logs"), + logDir: z.string().default("~/.paperclip/instances/default/logs"), }); export const serverConfigSchema = z.object({ @@ -30,14 +30,14 @@ export const serverConfigSchema = z.object({ }); export const secretsLocalEncryptedConfigSchema = z.object({ - keyFilePath: z.string().default("./data/secrets/master.key"), + keyFilePath: z.string().default("~/.paperclip/instances/default/secrets/master.key"), }); export const secretsConfigSchema = z.object({ provider: z.enum(SECRET_PROVIDERS).default("local_encrypted"), strictMode: z.boolean().default(false), localEncrypted: secretsLocalEncryptedConfigSchema.default({ - keyFilePath: "./data/secrets/master.key", + keyFilePath: "~/.paperclip/instances/default/secrets/master.key", }), }); @@ -51,7 +51,7 @@ export const paperclipConfigSchema = z.object({ provider: "local_encrypted", strictMode: false, localEncrypted: { - keyFilePath: "./data/secrets/master.key", + keyFilePath: "~/.paperclip/instances/default/secrets/master.key", }, }), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a3b530e..1887fd7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@paperclip/db': specifier: workspace:* version: link:../packages/db + '@paperclip/server': + specifier: workspace:* + version: link:../server '@paperclip/shared': specifier: workspace:* version: link:../packages/shared diff --git a/server/src/config.ts b/server/src/config.ts index e022aeac..488a7678 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -3,6 +3,11 @@ import { existsSync } from "node:fs"; import { config as loadDotenv } from "dotenv"; import { resolvePaperclipEnvPath } from "./paths.js"; import { SECRET_PROVIDERS, type SecretProvider } from "@paperclip/shared"; +import { + resolveDefaultEmbeddedPostgresDir, + resolveDefaultSecretsKeyFilePath, + resolveHomeAwarePath, +} from "./home-paths.js"; const PAPERCLIP_ENV_FILE_PATH = resolvePaperclipEnvPath(); if (existsSync(PAPERCLIP_ENV_FILE_PATH)) { @@ -54,7 +59,9 @@ export function loadConfig(): Config { port: Number(process.env.PORT) || fileConfig?.server.port || 3100, databaseMode: fileDatabaseMode, databaseUrl: process.env.DATABASE_URL ?? fileDbUrl, - embeddedPostgresDataDir: fileConfig?.database.embeddedPostgresDataDir ?? "./data/embedded-postgres", + embeddedPostgresDataDir: resolveHomeAwarePath( + fileConfig?.database.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(), + ), embeddedPostgresPort: fileConfig?.database.embeddedPostgresPort ?? 54329, serveUi: process.env.SERVE_UI !== undefined @@ -64,9 +71,11 @@ export function loadConfig(): Config { secretsProvider, secretsStrictMode, secretsMasterKeyFilePath: - process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ?? - fileSecrets?.localEncrypted.keyFilePath ?? - "./data/secrets/master.key", + resolveHomeAwarePath( + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ?? + fileSecrets?.localEncrypted.keyFilePath ?? + resolveDefaultSecretsKeyFilePath(), + ), heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false", heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000), }; diff --git a/server/src/home-paths.ts b/server/src/home-paths.ts new file mode 100644 index 00000000..e8c179e1 --- /dev/null +++ b/server/src/home-paths.ts @@ -0,0 +1,49 @@ +import os from "node:os"; +import path from "node:path"; + +const DEFAULT_INSTANCE_ID = "default"; +const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/; + +function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +export function resolvePaperclipHomeDir(): string { + const envHome = process.env.PAPERCLIP_HOME?.trim(); + if (envHome) return path.resolve(expandHomePrefix(envHome)); + return path.resolve(os.homedir(), ".paperclip"); +} + +export function resolvePaperclipInstanceId(): string { + const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID; + if (!INSTANCE_ID_RE.test(raw)) { + throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`); + } + return raw; +} + +export function resolvePaperclipInstanceRoot(): string { + return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId()); +} + +export function resolveDefaultConfigPath(): string { + return path.resolve(resolvePaperclipInstanceRoot(), "config.json"); +} + +export function resolveDefaultEmbeddedPostgresDir(): string { + return path.resolve(resolvePaperclipInstanceRoot(), "db"); +} + +export function resolveDefaultLogsDir(): string { + return path.resolve(resolvePaperclipInstanceRoot(), "logs"); +} + +export function resolveDefaultSecretsKeyFilePath(): string { + return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "master.key"); +} + +export function resolveHomeAwarePath(value: string): string { + return path.resolve(expandHomePrefix(value)); +} diff --git a/server/src/paths.ts b/server/src/paths.ts index 07492c02..21856a69 100644 --- a/server/src/paths.ts +++ b/server/src/paths.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { resolveDefaultConfigPath } from "./home-paths.js"; const PAPERCLIP_CONFIG_BASENAME = "config.json"; const PAPERCLIP_ENV_FILENAME = ".env"; @@ -25,7 +26,7 @@ function findConfigFileFromAncestors(startDir: string): string | null { export function resolvePaperclipConfigPath(overridePath?: string): string { if (overridePath) return path.resolve(overridePath); if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG); - return findConfigFileFromAncestors(process.cwd()) ?? path.resolve(process.cwd(), ".paperclip", PAPERCLIP_CONFIG_BASENAME); + return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(); } export function resolvePaperclipEnvPath(overrideConfigPath?: string): string { diff --git a/vitest.config.ts b/vitest.config.ts index 5f01019f..9bcf27c4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - projects: ["packages/db", "server", "ui"], + projects: ["packages/db", "server", "ui", "cli"], }, });