feat(cli): add client commands and home-based local runtime defaults

This commit is contained in:
Forgotten
2026-02-20 07:10:58 -06:00
parent 8e3c2fae35
commit 8f3fc077fa
40 changed files with 2284 additions and 138 deletions

View File

@@ -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",

View File

@@ -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/);
});
});

View File

@@ -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({});
});
});

View File

@@ -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"));
});
});

View File

@@ -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<string, string>;
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<ApiRequestError>);
});
});

175
cli/src/client/context.ts Normal file
View File

@@ -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<string, ClientContextProfile>;
}
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<string, unknown>;
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<string, unknown>;
const version = record.version === 1 ? 1 : 1;
const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE;
const rawProfiles = record.profiles;
const profiles: Record<string, ClientContextProfile> = {};
if (typeof rawProfiles === "object" && rawProfiles !== null && !Array.isArray(rawProfiles)) {
for (const [name, profile] of Object.entries(rawProfiles as Record<string, unknown>)) {
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<ClientContextProfile>,
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 };
}

148
cli/src/client/http.ts Normal file
View File

@@ -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<T>(path: string, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, { method: "GET" }, opts);
}
post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, {
method: "POST",
body: body === undefined ? undefined : JSON.stringify(body),
}, opts);
}
patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, {
method: "PATCH",
body: body === undefined ? undefined : JSON.stringify(body),
}, opts);
}
delete<T>(path: string, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, { method: "DELETE" }, opts);
}
private async request<T>(path: string, init: RequestInit, opts?: RequestOptions): Promise<T | null> {
const url = buildUrl(this.apiBase, path);
const headers: Record<string, string> = {
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<ApiRequestError> {
const text = await response.text();
const parsed = safeParseJson(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const body = parsed as Record<string, unknown>;
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<string, string> {
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)]),
);
}

View File

@@ -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 <id>", "Company ID")
.option("--agent-id <id>", "Filter by agent ID")
.option("--entity-type <type>", "Filter by entity type")
.option("--entity-id <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<ActivityEvent[]>(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 },
);
}

View File

@@ -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 <id>", "Company ID")
.action(async (opts: AgentListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = (await ctx.api.get<Agent[]>(`/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("<agentId>", "Agent ID")
.action(async (agentId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Agent>(`/api/agents/${agentId}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}

View File

@@ -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 <id>", "Company ID")
.option("--status <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<Approval[]>(
`/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("<approvalId>", "Approval ID")
.action(async (approvalId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Approval>(`/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 <id>", "Company ID")
.requiredOption("--type <type>", "Approval type (hire_agent|approve_ceo_strategy)")
.requiredOption("--payload <json>", "Approval payload as JSON object")
.option("--requested-by-agent-id <id>", "Requesting agent ID")
.option("--issue-ids <csv>", "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<Approval>(`/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("<approvalId>", "Approval ID")
.option("--decision-note <text>", "Decision note")
.option("--decided-by-user-id <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<Approval>(`/api/approvals/${approvalId}/approve`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("reject")
.description("Reject an approval request")
.argument("<approvalId>", "Approval ID")
.option("--decision-note <text>", "Decision note")
.option("--decided-by-user-id <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<Approval>(`/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("<approvalId>", "Approval ID")
.option("--decision-note <text>", "Decision note")
.option("--decided-by-user-id <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<Approval>(`/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("<approvalId>", "Approval ID")
.option("--payload <json>", "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<Approval>(`/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("<approvalId>", "Approval ID")
.requiredOption("--body <text>", "Comment body")
.action(async (approvalId: string, opts: ApprovalCommentOptions) => {
try {
const ctx = resolveCommandContext(opts);
const created = await ctx.api.post<ApprovalComment>(`/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<string, unknown> {
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<string, unknown>;
} catch (err) {
throw new Error(`Invalid ${name} JSON: ${err instanceof Error ? err.message : String(err)}`);
}
}

View File

@@ -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>", "Path to Paperclip 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 API")
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
.option("--json", "Output raw JSON");
if (opts?.includeCompany) {
command.option("-C, --company-id <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<string, unknown>));
} 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, unknown>): string {
const keyOrder = ["identifier", "id", "name", "status", "priority", "title", "action"];
const seen = new Set<string>();
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);
}

View File

@@ -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<Company[]>("/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("<companyId>", "Company ID")
.action(async (companyId: string, opts: CompanyCommandOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Company>(`/api/companies/${companyId}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}

View File

@@ -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>", "Path to CLI context file")
.option("--profile <name>", "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>", "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>", "Profile name")
.option("--context <path>", "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>", "Path to CLI context file")
.option("--profile <name>", "Profile name (default: current profile)")
.option("--api-base <url>", "Default API base URL")
.option("--company-id <id>", "Default company ID")
.option("--api-key-env-var-name <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 });
});
}

View File

@@ -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 <id>", "Company ID")
.action(async (opts: DashboardGetOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const row = await ctx.api.get<DashboardSummary>(`/api/companies/${ctx.companyId}/dashboard`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}

View File

@@ -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 <id>", "Company ID")
.option("--status <csv>", "Comma-separated statuses")
.option("--assignee-agent-id <id>", "Filter by assignee agent ID")
.option("--project-id <id>", "Filter by project ID")
.option("--match <text>", "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<Issue[]>(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("<idOrIdentifier>", "Issue ID or identifier")
.action(async (idOrIdentifier: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Issue>(`/api/issues/${idOrIdentifier}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
issue
.command("create")
.description("Create an issue")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("--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")
.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);
});
}

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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[] = [
{

View File

@@ -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,
}),
},
);
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 "";
}
}

View File

@@ -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)) {

104
cli/src/commands/run.ts Normal file
View File

@@ -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"),
);
}

View File

@@ -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 });

66
cli/src/config/home.ts Normal file
View File

@@ -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),
};
}

View File

@@ -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 {

View File

@@ -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);
});

View File

@@ -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"),
};
}

View File

@@ -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 };
}

View File

@@ -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";
},

View File

@@ -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];

7
cli/vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});

128
doc/CLI.md Normal file
View File

@@ -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
```

View File

@@ -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:

View File

@@ -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`.

View File

@@ -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

View File

@@ -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",
},
}),
});

3
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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:
resolveHomeAwarePath(
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ??
fileSecrets?.localEncrypted.keyFilePath ??
"./data/secrets/master.key",
resolveDefaultSecretsKeyFilePath(),
),
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
};

49
server/src/home-paths.ts Normal file
View File

@@ -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));
}

View File

@@ -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 {

View File

@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
projects: ["packages/db", "server", "ui"],
projects: ["packages/db", "server", "ui", "cli"],
},
});