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

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