@capacms/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/main.mjs ADDED
@@ -0,0 +1,477 @@
1
+ /**
2
+ * main.mjs — `capa`: read the command line, run one command, answer an exit
3
+ * code. Everything a command touches comes in through `io` (streams, env,
4
+ * cwd, the process runner), so the tests run the real commands in-process.
5
+ */
6
+ import fs from "node:fs";
7
+ import { createInterface } from "node:readline";
8
+ import { TOOLS, TOOLS_BY_NAME, connect, keyFamily, serveStdio } from "@capacms/mcp";
9
+ import { parseArgv } from "./argv.mjs";
10
+ import { COMMANDS, booleanFlags, findCommand, flagHelp, toolArgs } from "./commands.mjs";
11
+ import { createCredentials, systemRunner } from "./credentials.mjs";
12
+ import { CLIENTS, applyPlan, planInit, planInstructions, showPlan } from "./init.mjs";
13
+ import { CliError, EXIT, createOutput } from "./output.mjs";
14
+ import { SDK_COMMANDS, runSdk } from "./sdk.mjs";
15
+ import { DEFAULT_API_URL, configFrom, keySees, offered, openSession, resolveEnv, runAndPrint, whoAmIAndUploads } from "./session.mjs";
16
+
17
+ const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
18
+
19
+ /** Flags the CLI reads itself; never passed to a tool. */
20
+ const RESERVED = new Set(["json", "profile", "help", "version"]);
21
+
22
+ const LOCAL_COMMANDS = {
23
+ login: { usage: "capa login [--key-stdin] [--api-url URL] [--upload-url URL] [--profile NAME] [--tenant-id ID]", summary: "Check a key against Capa and keep it in the keychain.", booleans: ["keyStdin"] },
24
+ logout: { usage: "capa logout [--profile NAME] [--all]", summary: "Forget a key: the profile and its keychain entry.", booleans: ["all"] },
25
+ whoami: { usage: "capa whoami", summary: "The key in use: its project, environment, scopes, and what it reads." },
26
+ projects: { usage: "capa projects [use <profile>]", summary: "The saved profiles, one per key and project; `use` picks the current one." },
27
+ tools: { usage: "capa tools", summary: "The MCP tools this key is offered here, each one runnable with capa tool." },
28
+ tool: { usage: "capa tool <name> [--args JSON|@file|-] [--<argument> value ...]", summary: "Run any MCP tool by name, with its arguments as flags or one JSON object." },
29
+ mcp: { usage: "capa mcp", summary: "Serve the MCP server on stdio with the stored key (what capa init registers)." },
30
+ init: {
31
+ usage: "capa init claude|codex|cursor [--dry-run] [--yes] [--instructions] [--scope project|user|local] [--project] [--global] [--approve] [--command CMD]",
32
+ summary: "Register capa mcp with Claude Code, Codex or Cursor, showing the change first.",
33
+ booleans: ["yes", "dryRun", "instructions", "project", "global", "approve"],
34
+ },
35
+ codegen: { usage: "capa codegen [--graphql] [--out file]", summary: "Generate types (from @capacms/sdk, run as is)." },
36
+ persist: { usage: "capa persist [--documents src] [--manifest file]", summary: "Register persisted GraphQL documents (from @capacms/sdk, run as is)." },
37
+ };
38
+
39
+ const HELP = () =>
40
+ [
41
+ `capa ${VERSION}: Capa from a terminal, a script or an agent.`,
42
+ "",
43
+ "Account",
44
+ ...["login", "logout", "whoami", "projects"].map((c) => ` ${LOCAL_COMMANDS[c].usage}\n ${LOCAL_COMMANDS[c].summary}`),
45
+ "",
46
+ "Content (each runs one MCP tool, with the same arguments and errors)",
47
+ ...COMMANDS.map((c) => ` ${c.usage}\n ${c.summary}`),
48
+ ` ${LOCAL_COMMANDS.tools.usage}\n ${LOCAL_COMMANDS.tools.summary}`,
49
+ ` ${LOCAL_COMMANDS.tool.usage}\n ${LOCAL_COMMANDS.tool.summary}`,
50
+ "",
51
+ "Agents",
52
+ ` ${LOCAL_COMMANDS.init.usage}\n ${LOCAL_COMMANDS.init.summary}`,
53
+ ` ${LOCAL_COMMANDS.mcp.usage}\n ${LOCAL_COMMANDS.mcp.summary}`,
54
+ "",
55
+ "Code",
56
+ ` ${LOCAL_COMMANDS.codegen.usage}\n ${LOCAL_COMMANDS.codegen.summary}`,
57
+ ` ${LOCAL_COMMANDS.persist.usage}\n ${LOCAL_COMMANDS.persist.summary}`,
58
+ "",
59
+ "Every command takes --json (stdout is one JSON document, errors included) and --profile NAME.",
60
+ "Nothing prompts when stdin is not a terminal: pass the flag instead.",
61
+ "",
62
+ "The key: CAPA_KEY (and CAPA_API_URL) in the environment, else the profile capa login saved.",
63
+ "",
64
+ "Exit codes",
65
+ " 0 done",
66
+ " 1 the API refused, or answered with an error",
67
+ " 2 usage: unknown command or flag, a bad argument, or a write that needs --yes",
68
+ " 3 no key, a key the API does not know, or a config file capa cannot use",
69
+ " 4 the tool behind the command is not offered for this key here",
70
+ " 5 Capa did not answer",
71
+ "codegen and persist exit as @capacms/sdk documents.",
72
+ ].join("\n");
73
+
74
+ function commandHelp(name, command) {
75
+ if (command) {
76
+ const skip = command.pick(command.positionals.map((p) => p.replace("?", "")), () => true).args;
77
+ const flags = command.tools.flatMap((tool) => (TOOLS_BY_NAME.has(tool) ? flagHelp(tool, Object.keys(skip)) : []));
78
+ const seen = new Set();
79
+ const unique = flags.filter((line) => {
80
+ const flag = line.split("\n")[0].trim().split(" ")[0];
81
+ if (seen.has(flag)) return false;
82
+ seen.add(flag);
83
+ return true;
84
+ });
85
+ return [command.usage, ` ${command.summary}`, "", `Runs ${command.tools.join(", or else ")}.`, "", "Flags", ...unique, " --json\n Print one JSON document."].join("\n");
86
+ }
87
+ const local = LOCAL_COMMANDS[name];
88
+ return local ? `${local.usage}\n ${local.summary}` : HELP();
89
+ }
90
+
91
+ async function readAll(stream) {
92
+ let text = "";
93
+ stream.setEncoding?.("utf8");
94
+ for await (const chunk of stream) text += chunk;
95
+ return text;
96
+ }
97
+
98
+ /** A key typed at a terminal, not echoed. */
99
+ function promptHidden(io, question) {
100
+ return new Promise((resolve, reject) => {
101
+ const input = io.stdin;
102
+ io.stderr.write(question);
103
+ input.setRawMode?.(true);
104
+ input.setEncoding?.("utf8");
105
+ input.resume();
106
+ let value = "";
107
+ const finish = (error) => {
108
+ input.setRawMode?.(false);
109
+ input.pause();
110
+ input.off("data", onData);
111
+ io.stderr.write("\n");
112
+ if (error) reject(error);
113
+ else resolve(value);
114
+ };
115
+ const onData = (chunk) => {
116
+ for (const char of String(chunk)) {
117
+ if (char === "\r" || char === "\n") return finish();
118
+ if (char === "\u0003") return finish(new CliError(EXIT.usage, "Cancelled; nothing was saved."));
119
+ if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
120
+ else value += char;
121
+ }
122
+ };
123
+ input.on("data", onData);
124
+ });
125
+ }
126
+
127
+ /** y/N at a terminal. */
128
+ async function confirm(io, question) {
129
+ const rl = createInterface({ input: io.stdin, output: io.stderr });
130
+ const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve));
131
+ rl.close();
132
+ return /^y(es)?$/i.test(String(answer).trim());
133
+ }
134
+
135
+ const slug = (text) =>
136
+ String(text ?? "")
137
+ .toLowerCase()
138
+ .replace(/[^a-z0-9]+/g, "-")
139
+ .replace(/^-+|-+$/g, "")
140
+ .slice(0, 40);
141
+
142
+ export function defaultProfileName(me) {
143
+ const project = slug(me?.tenant?.name) || slug(me?.tenant?.id) || "capa";
144
+ return `${project}-${slug(me?.environment) || "key"}`;
145
+ }
146
+
147
+ const mask = (prefix) => (prefix ? `${prefix}...` : "prefix not reported");
148
+
149
+ async function login(io, out, credentials, flags) {
150
+ let key;
151
+ if (flags.keyStdin) key = await io.readStdin();
152
+ else if (io.isTTY) key = await promptHidden(io, "Paste a Capa key (Developers > Keys): ");
153
+ key = String(key ?? "").trim();
154
+ if (!key) {
155
+ throw new CliError(
156
+ EXIT.usage,
157
+ "No key given. Pipe it in (capa login --key-stdin < key.txt, or printf %s \"$CAPA_KEY\" | capa login --key-stdin), or run capa login in a terminal to paste it. A key is never taken as an argument, where the process list shows it.",
158
+ );
159
+ }
160
+ const apiUrl = (flags.apiUrl || io.env.CAPA_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
161
+ const family = keyFamily(key);
162
+ if (family === "legacy" && !flags.tenantId) {
163
+ throw new CliError(EXIT.usage, "That is a legacy key (pk_, sk_ or no prefix), which also needs --tenant-id <project id>. A cap_ key from Developers > Keys needs nothing else.");
164
+ }
165
+ const uploadUrl = typeof flags.uploadUrl === "string" ? flags.uploadUrl.trim().replace(/\/+$/, "") : "";
166
+ const config = configFrom({
167
+ CAPA_API_URL: apiUrl,
168
+ CAPA_KEY: key,
169
+ CAPA_TENANT_ID: flags.tenantId ?? "",
170
+ CAPA_API_VERSION: flags.apiVersion ?? "",
171
+ CAPA_UPLOAD_URL: uploadUrl,
172
+ });
173
+ const { me, uploads } = await whoAmIAndUploads(config);
174
+ if (!me) throw new CliError(EXIT.refused, `${apiUrl}/api/me answered without describing the key. Nothing was saved.`);
175
+ const name = typeof flags.profile === "string" && flags.profile ? flags.profile : defaultProfileName(me);
176
+ let saved;
177
+ try {
178
+ saved = credentials.add(
179
+ name,
180
+ {
181
+ apiUrl,
182
+ family,
183
+ project: me.tenant ? { id: me.tenant.id, name: me.tenant.name ?? null } : null,
184
+ environment: me.environment ?? null,
185
+ keyPrefix: me.keyPrefix ?? null,
186
+ ...(flags.tenantId ? { tenantId: flags.tenantId } : {}),
187
+ ...(flags.apiVersion ? { apiVersion: flags.apiVersion } : {}),
188
+ ...(uploadUrl ? { uploadUrl } : {}),
189
+ },
190
+ key,
191
+ );
192
+ } catch (error) {
193
+ throw new CliError(error.exitCode ?? EXIT.config, `${error.message} Nothing was saved.`);
194
+ }
195
+ const sees = keySees(me, { uploads });
196
+ out.answer(
197
+ out.json
198
+ ? { profile: name, project: me.tenant ?? null, environment: me.environment ?? null, keyPrefix: me.keyPrefix ?? null, apiUrl, store: saved.store, sees }
199
+ : [
200
+ `Logged in to ${me.tenant?.name ? `"${me.tenant.name}"` : "the project"} with a ${me.environment ?? ""} key (${mask(me.keyPrefix)}) as profile ${name}.`,
201
+ `The key is in ${saved.where}. ${credentials.file} holds the profile and nothing secret.`,
202
+ sees,
203
+ ]
204
+ .filter(Boolean)
205
+ .join("\n"),
206
+ );
207
+ return EXIT.ok;
208
+ }
209
+
210
+ async function logout(io, out, credentials, flags) {
211
+ const { current, profiles } = credentials.list();
212
+ const names = flags.all ? profiles.map((p) => p.name) : [flags.profile || io.env.CAPA_PROFILE || current].filter(Boolean);
213
+ if (!names.length) throw new CliError(EXIT.config, "Not logged in: there is nothing to forget.");
214
+ const removed = names.map((name) => credentials.remove(name)).filter(Boolean);
215
+ if (!removed.length) throw new CliError(EXIT.config, `No profile ${names.join(", ")}.`);
216
+ out.answer(out.json ? { removed: removed.map((r) => r.name) } : removed.map((r) => `Forgot profile ${r.name} and deleted its key.`).join("\n"));
217
+ return EXIT.ok;
218
+ }
219
+
220
+ async function whoami(io, out, credentials, flags) {
221
+ const resolved = resolveEnv(io, credentials, flags.profile);
222
+ const config = configFrom(resolved.env, io);
223
+ const { me, uploads } = await whoAmIAndUploads(config);
224
+ const sees = keySees(me, { uploads });
225
+ const doc = {
226
+ profile: resolved.profile?.name ?? null,
227
+ source: resolved.source === "env" ? "CAPA_KEY" : "profile",
228
+ apiUrl: config.baseUrl,
229
+ project: me?.tenant ?? null,
230
+ environment: me?.environment ?? null,
231
+ keyPrefix: me?.keyPrefix ?? null,
232
+ scopes: me?.scopes ?? [],
233
+ surfaces: me?.surfaces ?? [],
234
+ reads: me?.environment === "production" ? "published" : "drafts",
235
+ sees,
236
+ };
237
+ out.answer(
238
+ out.json
239
+ ? doc
240
+ : [
241
+ `${doc.project?.name ? `"${doc.project.name}"` : "Project"} (${doc.project?.id ?? "unknown id"}) at ${doc.apiUrl}`,
242
+ `A ${doc.environment} key (${mask(doc.keyPrefix)}), from ${doc.source === "CAPA_KEY" ? "CAPA_KEY in the environment" : `profile ${doc.profile}`}.`,
243
+ `Scopes: ${doc.scopes.join(", ") || "none listed"}.`,
244
+ `Surfaces: ${doc.surfaces.join(", ") || "not reported"}.`,
245
+ sees,
246
+ ].join("\n"),
247
+ );
248
+ return EXIT.ok;
249
+ }
250
+
251
+ async function projects(io, out, credentials, positionals) {
252
+ if (positionals[0] === "use") {
253
+ if (!positionals[1]) throw new CliError(EXIT.usage, "capa projects use <profile>: name the profile.");
254
+ try {
255
+ credentials.use(positionals[1]);
256
+ } catch (error) {
257
+ throw new CliError(error.exitCode ?? EXIT.config, error.message);
258
+ }
259
+ out.answer(out.json ? { current: positionals[1] } : `Now using profile ${positionals[1]}.`);
260
+ return EXIT.ok;
261
+ }
262
+ if (positionals.length) throw new CliError(EXIT.usage, `capa projects takes no argument but use <profile>, not ${positionals[0]}.`);
263
+ const { current, profiles } = credentials.list();
264
+ if (out.json) {
265
+ out.answer({ current, profiles: profiles.map(({ name, project, environment, keyPrefix, apiUrl, store }) => ({ name, current: name === current, project, environment, keyPrefix, apiUrl, store })) });
266
+ return EXIT.ok;
267
+ }
268
+ if (!profiles.length) {
269
+ out.answer("No profiles yet. capa login adds one.");
270
+ return EXIT.ok;
271
+ }
272
+ out.answer(
273
+ profiles
274
+ .map((p) => `${p.name === current ? "*" : " "} ${p.name} ${p.project?.name ?? p.project?.id ?? ""} ${p.environment ?? ""} ${mask(p.keyPrefix)} ${p.apiUrl} (${p.store})`)
275
+ .join("\n"),
276
+ );
277
+ return EXIT.ok;
278
+ }
279
+
280
+ async function listTools(io, out, credentials, flags) {
281
+ const session = await openSession(io, credentials, flags.profile);
282
+ for (const note of session.notes) out.note(note.replace(/^capa-mcp: /, "capa: "));
283
+ const tools = session.ctx.tools.map((t) => ({ name: t.name, title: t.annotations?.title ?? t.title ?? null, writes: t.annotations?.readOnlyHint === false }));
284
+ out.answer(out.json ? { tools } : tools.map((t) => `${t.name}${t.writes ? " (writes)" : ""} ${t.title ?? ""}`).join("\n"));
285
+ return EXIT.ok;
286
+ }
287
+
288
+ async function runToolCommand(io, out, credentials, positionals, flags) {
289
+ const [name, ...extra] = positionals;
290
+ if (!name) throw new CliError(EXIT.usage, "capa tool <name>: name the tool. capa tools lists them.");
291
+ if (extra.length) throw new CliError(EXIT.usage, `capa tool takes one tool name; ${extra.join(" ")} is extra. Pass arguments as --flags or --args.`);
292
+ if (!TOOLS_BY_NAME.has(name)) {
293
+ const near = TOOLS.map((t) => t.name).filter((n) => n.includes(name.replace(/^capa_/, "")) || name.includes(n.replace(/^capa_/, "")));
294
+ throw new CliError(EXIT.usage, `No tool ${name}.${near.length ? ` Did you mean ${near.join(" or ")}?` : ""} capa tools lists the ones this key is offered.`);
295
+ }
296
+ let base = {};
297
+ if (flags.args !== undefined) {
298
+ const text = flags.args === "-" ? await io.readStdin() : String(flags.args).startsWith("@") ? fs.readFileSync(String(flags.args).slice(1), "utf8") : flags.args;
299
+ try {
300
+ base = JSON.parse(text);
301
+ } catch (error) {
302
+ throw new CliError(EXIT.usage, `--args is not JSON: ${error.message}.`);
303
+ }
304
+ if (!base || typeof base !== "object" || Array.isArray(base)) throw new CliError(EXIT.usage, "--args is one JSON object of the tool's arguments.");
305
+ if (flags.args === "-") io.stdinUsed = true;
306
+ }
307
+ const args = await toolArgs(name, base, flags, io, { reserved: new Set([...RESERVED, "args"]) });
308
+ const session = await openSession(io, credentials, flags.profile);
309
+ return runAndPrint(out, session, name, args);
310
+ }
311
+
312
+ async function content(io, out, credentials, command, rest, flags) {
313
+ const required = command.positionals.filter((p) => !p.endsWith("?")).length;
314
+ if (rest.length < required || rest.length > command.positionals.length) {
315
+ throw new CliError(EXIT.usage, `usage: ${command.usage}`);
316
+ }
317
+ const session = await openSession(io, credentials, flags.profile);
318
+ const { tool, args: base } = command.pick(rest, (name) => offered(session, name));
319
+ const args = await toolArgs(tool, base, flags, io, { reserved: RESERVED });
320
+ return runAndPrint(out, session, tool, args);
321
+ }
322
+
323
+ async function mcp(io, out, credentials, flags) {
324
+ const resolved = resolveEnv(io, credentials, flags.profile);
325
+ const config = configFrom(resolved.env, io);
326
+ const { ctx, notes } = await connect(config);
327
+ for (const note of notes) io.stderr.write(`${note}\n`);
328
+ await serveStdio(ctx, { input: io.stdin, output: io.stdout, error: io.stderr, exit: io.exit });
329
+ return null;
330
+ }
331
+
332
+ async function init(io, out, credentials, positionals, flags) {
333
+ const [client, ...extra] = positionals;
334
+ if (!CLIENTS.includes(client) || extra.length) throw new CliError(EXIT.usage, `usage: ${LOCAL_COMMANDS.init.usage}`);
335
+ const plan = planInit(client, io, flags);
336
+ let me = null;
337
+ let uploads = null;
338
+ let whoNote = null;
339
+ try {
340
+ const resolved = resolveEnv(io, credentials, flags.profile);
341
+ ({ me, uploads } = await whoAmIAndUploads(configFrom(resolved.env, io)));
342
+ whoNote = `capa mcp will use ${resolved.source === "env" ? "CAPA_KEY from its environment" : `profile ${resolved.profile.name}`}: ${me?.tenant?.name ? `"${me.tenant.name}", ` : ""}a ${me?.environment} key. ${keySees(me, { uploads }) ?? ""}`;
343
+ } catch (error) {
344
+ if (flags.instructions) throw new CliError(error.exitCode ?? EXIT.config, `--instructions names the models, so it needs a key that works: ${error.message}`);
345
+ whoNote = `Run capa login before starting ${client}: capa mcp needs a key (${error.message})`;
346
+ }
347
+ const steps = [...plan.steps, ...(flags.instructions ? [planInstructions(client, io, me, keySees(me, { uploads }))] : [])];
348
+ const changes = showPlan(out, steps);
349
+ const report = (written) =>
350
+ out.answer(
351
+ out.json
352
+ ? { client, server: plan.server, written, steps: steps.map((s) => (s.run ? { run: s.run } : { file: s.file, ...(s.target ? { target: s.target } : {}), created: s.created, changed: s.before !== s.after })), note: whoNote }
353
+ : written
354
+ ? `Done. ${whoNote}`
355
+ : whoNote,
356
+ );
357
+ if (!changes) {
358
+ report(false);
359
+ return EXIT.ok;
360
+ }
361
+ if (flags.dryRun) {
362
+ out.note("Dry run: nothing was written.");
363
+ report(false);
364
+ return EXIT.ok;
365
+ }
366
+ if (!flags.yes) {
367
+ if (!io.isTTY) throw new CliError(EXIT.usage, "Nothing was written. Pass --yes to make this change, or --dry-run to only show it.");
368
+ if (!(await confirm(io, "Make this change?"))) {
369
+ out.note("Nothing was written.");
370
+ return EXIT.usage;
371
+ }
372
+ }
373
+ applyPlan(steps, io);
374
+ report(true);
375
+ return EXIT.ok;
376
+ }
377
+
378
+ /**
379
+ * Runs `capa <argv>`. Answers the exit code, or null when the command keeps
380
+ * the process (`capa mcp`) and exits on its own.
381
+ */
382
+ export async function main(argv, given = {}) {
383
+ const io = {
384
+ env: given.env ?? process.env,
385
+ stdin: given.stdin ?? process.stdin,
386
+ stdout: given.stdout ?? process.stdout,
387
+ stderr: given.stderr ?? process.stderr,
388
+ cwd: given.cwd ?? process.cwd(),
389
+ run: given.run ?? systemRunner,
390
+ platform: given.platform ?? process.platform,
391
+ exit: given.exit ?? ((code) => process.exit(code)),
392
+ sdkBin: given.sdkBin,
393
+ };
394
+ io.isTTY = given.isTTY ?? Boolean(io.stdin.isTTY);
395
+ io.readStdin = () => readAll(io.stdin);
396
+
397
+ // codegen and persist are the SDK's, and get their arguments exactly as typed.
398
+ if (SDK_COMMANDS.has(argv[0])) {
399
+ const out = createOutput(io, { json: false });
400
+ try {
401
+ return runSdk(argv[0], argv.slice(1), io);
402
+ } catch (error) {
403
+ return out.fail(error);
404
+ }
405
+ }
406
+
407
+ // The command's words decide which of its flags take no value, so find them first.
408
+ const words = [];
409
+ for (let i = 0; i < argv.length && words.length < 2; i++) {
410
+ if (argv[i] === "--profile") i++;
411
+ else if (!argv[i].startsWith("-")) words.push(argv[i]);
412
+ else if (words.length) break;
413
+ }
414
+ const found = findCommand(words);
415
+ const local = LOCAL_COMMANDS[words[0]];
416
+ let booleans = new Set(local?.booleans ?? []);
417
+ if (found) booleans = booleanFlags(found.command.tools);
418
+ if (words[0] === "tool" && words[1]) booleans = booleanFlags([words[1]]);
419
+
420
+ let parsed;
421
+ try {
422
+ parsed = parseArgv(argv, { booleans });
423
+ } catch (error) {
424
+ return createOutput(io, { json: argv.includes("--json") }).fail(error);
425
+ }
426
+ const { flags } = parsed;
427
+ const out = createOutput(io, { json: Boolean(flags.json) });
428
+ const credentials = createCredentials({ env: io.env, platform: io.platform, run: io.run });
429
+
430
+ try {
431
+ if (flags.version && !parsed.positionals.length) {
432
+ out.answer(out.json ? { version: VERSION } : VERSION);
433
+ return EXIT.ok;
434
+ }
435
+ const [name, ...rest] = parsed.positionals;
436
+ if (!name || name === "help") {
437
+ const topic = rest.length ? findCommand(rest)?.command ?? null : null;
438
+ out.answer(rest.length ? commandHelp(rest[0], topic) : HELP());
439
+ return name || flags.help ? EXIT.ok : EXIT.usage;
440
+ }
441
+ if (flags.help) {
442
+ out.answer(commandHelp(name, found?.command ?? null));
443
+ return EXIT.ok;
444
+ }
445
+ if (found) return await content(io, out, credentials, found.command, parsed.positionals.slice(found.command.words.length), flags);
446
+ switch (name) {
447
+ case "login":
448
+ return await login(io, out, credentials, flags);
449
+ case "logout":
450
+ return await logout(io, out, credentials, flags);
451
+ case "whoami":
452
+ return await whoami(io, out, credentials, flags);
453
+ case "projects":
454
+ return await projects(io, out, credentials, rest);
455
+ case "tools":
456
+ return await listTools(io, out, credentials, flags);
457
+ case "tool":
458
+ return await runToolCommand(io, out, credentials, rest, flags);
459
+ case "mcp":
460
+ return await mcp(io, out, credentials, flags);
461
+ case "init":
462
+ return await init(io, out, credentials, rest, flags);
463
+ case "entries":
464
+ case "media":
465
+ throw new CliError(EXIT.usage, `capa ${name} ${rest[0] ?? ""}: unknown. ${COMMANDS.filter((c) => c.words[0] === name).map((c) => c.usage.split(" ").slice(0, 3).join(" ")).join(", ") || "capa help lists the commands."}`);
466
+ default:
467
+ throw new CliError(EXIT.usage, `Unknown command ${name}. capa help lists the commands.`);
468
+ }
469
+ } catch (error) {
470
+ if (!(error instanceof CliError) && typeof error?.exitCode !== "number") {
471
+ // A bug, not a refusal: say so plainly, with the stack where a person can find it.
472
+ if (io.env.CAPA_DEBUG) io.stderr.write(`${error?.stack ?? error}\n`);
473
+ return out.fail(new CliError(EXIT.refused, `${error?.message ?? error}`));
474
+ }
475
+ return out.fail(error);
476
+ }
477
+ }
package/lib/output.mjs ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * output.mjs — exit codes, errors, and what goes to stdout and stderr.
3
+ *
4
+ * With --json, stdout carries exactly one JSON document and nothing else: the
5
+ * answer, or `{ "error": { "message", "exitCode", ... } }`. Without it, an
6
+ * answer is printed as indented JSON (answers are documents, and a table would
7
+ * drop fields), and errors and notes go to stderr. Either way the exit code
8
+ * says what happened, so a script never has to parse a sentence.
9
+ */
10
+
11
+ /** The documented exit codes (docs/cli.md, `capa help`). */
12
+ export const EXIT = Object.freeze({
13
+ ok: 0,
14
+ /** The API refused the request, or answered with an in-band `{ error }`. */
15
+ refused: 1,
16
+ /** A usage error: an unknown command or flag, a missing or invalid argument, or a write that needs --yes. */
17
+ usage: 2,
18
+ /** No credentials, a key the API does not know, or a config file that cannot be used. */
19
+ config: 3,
20
+ /** The tool behind the command is not offered for this key on this deployment. */
21
+ notOffered: 4,
22
+ /** Capa did not answer: unreachable, or no answer in time. */
23
+ unreachable: 5,
24
+ });
25
+
26
+ export class CliError extends Error {
27
+ constructor(exitCode, message, details = {}) {
28
+ super(message);
29
+ this.name = "CliError";
30
+ this.exitCode = exitCode;
31
+ this.details = details;
32
+ }
33
+ }
34
+
35
+ export function createOutput(io, { json }) {
36
+ const write = (stream, text) => stream.write(text.endsWith("\n") ? text : `${text}\n`);
37
+ return {
38
+ json,
39
+ /** The command's answer, on stdout. */
40
+ answer(value) {
41
+ write(io.stdout, json ? JSON.stringify(value) : typeof value === "string" ? value : JSON.stringify(value, null, 2));
42
+ },
43
+ /** A line for a person (never in --json mode, where stdout is the document). */
44
+ say(text) {
45
+ if (!json) write(io.stdout, text);
46
+ },
47
+ /** A line on stderr, in either mode: progress, notes, warnings. */
48
+ note(text) {
49
+ write(io.stderr, text);
50
+ },
51
+ fail(error) {
52
+ const exitCode = error instanceof CliError || typeof error?.exitCode === "number" ? error.exitCode : EXIT.refused;
53
+ const details = error instanceof CliError ? error.details : {};
54
+ if (json) write(io.stdout, JSON.stringify({ error: { message: error.message, exitCode, ...details } }));
55
+ else write(io.stderr, `capa: ${error.message}`);
56
+ return exitCode;
57
+ },
58
+ };
59
+ }
package/lib/sdk.mjs ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * sdk.mjs — `capa codegen` and `capa persist` are @capacms/sdk's, run as is.
3
+ *
4
+ * Both packages ship a `capa` bin. Rather than fight over the name, each
5
+ * hands the other's commands across: this CLI runs the project's own SDK bin
6
+ * for codegen and persist, with the same arguments, the same output and the
7
+ * same exit codes, and the SDK's bin hands every other command to this CLI
8
+ * when the project has it. So whichever `capa` a package manager links,
9
+ * every command does the same thing.
10
+ *
11
+ * The SDK is resolved from the working directory, the project's own
12
+ * node_modules, since codegen writes types for the SDK version the project
13
+ * builds with.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { createRequire } from "node:module";
18
+ import { spawnSync } from "node:child_process";
19
+ import { CliError, EXIT } from "./output.mjs";
20
+
21
+ export const SDK_COMMANDS = new Set(["codegen", "persist"]);
22
+
23
+ /** The project's @capacms/sdk `capa` bin, or null. */
24
+ export function sdkBin(cwd) {
25
+ try {
26
+ const require = createRequire(path.join(cwd, "package.json"));
27
+ const manifest = require.resolve("@capacms/sdk/package.json");
28
+ const bin = JSON.parse(fs.readFileSync(manifest, "utf8")).bin?.capa;
29
+ return bin ? path.join(path.dirname(manifest), bin) : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** Runs `capa <command> ...args` from the project's SDK. Answers its exit code. */
36
+ export function runSdk(command, args, io) {
37
+ const bin = (io.sdkBin ?? sdkBin)(io.cwd);
38
+ if (!bin) {
39
+ throw new CliError(
40
+ EXIT.config,
41
+ `capa ${command} comes from @capacms/sdk, which this project does not have. Install it (npm i @capacms/sdk), then run capa ${command} again.`,
42
+ );
43
+ }
44
+ const result = spawnSync(process.execPath, [bin, command, ...args], { cwd: io.cwd, env: io.env, stdio: "inherit" });
45
+ if (result.error) throw new CliError(EXIT.config, `could not run ${bin}: ${result.error.message}`);
46
+ return result.status ?? 1;
47
+ }