feat(cli): add client commands and home-based local runtime defaults
This commit is contained in:
71
cli/src/commands/client/activity.ts
Normal file
71
cli/src/commands/client/activity.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
74
cli/src/commands/client/agent.ts
Normal file
74
cli/src/commands/client/agent.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
259
cli/src/commands/client/approval.ts
Normal file
259
cli/src/commands/client/approval.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
185
cli/src/commands/client/common.ts
Normal file
185
cli/src/commands/client/common.ts
Normal 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);
|
||||
}
|
||||
67
cli/src/commands/client/company.ts
Normal file
67
cli/src/commands/client/company.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
120
cli/src/commands/client/context.ts
Normal file
120
cli/src/commands/client/context.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
34
cli/src/commands/client/dashboard.ts
Normal file
34
cli/src/commands/client/dashboard.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
313
cli/src/commands/client/issue.ts
Normal file
313
cli/src/commands/client/issue.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user