@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/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # @capacms/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `capa`: Capa content from a terminal, a script or an AI agent, and one command
4
+ to plug Capa into Claude Code, Codex or Cursor.
5
+
6
+ ```bash
7
+ npm install -g @capacms/cli
8
+ capa login # paste a key from Developers > Keys; it goes to the OS keychain
9
+ capa init claude # or codex, or cursor: registers `capa mcp`, with no key in the file
10
+ capa entries list articles --json
11
+ capa entries create articles --data '{"title":"Hello"}'
12
+ ```
13
+
14
+ Without installing, name the package: `npx @capacms/cli login`. Never
15
+ `npx capa`, which fetches an unrelated npm package of that name. `@capacms/sdk`
16
+ also ships a `capa` command, so npm refuses to install both globally
17
+ (`EEXIST`): install this one globally and keep the SDK in your project.
18
+
19
+ Every content command is one tool of [`@capacms/mcp`](../mcp/README.md), run
20
+ by name. The CLI and the MCP server take the same arguments and refuse the
21
+ same way. `capa tool <name>` runs any of them.
22
+
23
+ Every command takes `--json` and `--profile NAME`. None prompts when stdin is
24
+ not a terminal. The exit codes are documented: 0 done, 1 refused, 2 usage,
25
+ 3 no key, 4 not offered for this key, 5 Capa did not answer.
26
+
27
+ The full reference, with what each key reads and writes, is
28
+ [docs/cli.md](../../docs/cli.md).
29
+
30
+ ## Layout
31
+
32
+ | File | What it holds |
33
+ |---|---|
34
+ | `bin/capa.mjs` | the Node check, then `lib/main.mjs` |
35
+ | `lib/main.mjs` | the command table, help, login, whoami, projects, tools, mcp, init |
36
+ | `lib/commands.mjs` | the content commands: each one a tool, its flags typed from the tool's input schema |
37
+ | `lib/session.mjs` | which key (`CAPA_KEY`, else the profile), `connect`, `runTool`, exit codes |
38
+ | `lib/credentials.mjs` | profiles in `config.json`; the key in the keychain (`security`, `secret-tool`) or a 0600 file |
39
+ | `lib/init.mjs`, `lib/toml.mjs`, `lib/diff.mjs` | `capa init`: the clients' config formats, the Codex TOML section, the diff it shows |
40
+ | `lib/sdk.mjs` | `capa codegen` and `capa persist`, run from the project's `@capacms/sdk` |
41
+
42
+ ## Tests
43
+
44
+ ```bash
45
+ pnpm -F @capacms/cli test
46
+ ```
47
+
48
+ They run the real commands in-process against `packages/mcp`'s stand-in
49
+ deployment and check what it stored. One test creates a throwaway macOS
50
+ keychain (`CAPA_KEYCHAIN`) and never touches the login keychain. The Codex
51
+ format test runs `codex mcp get` where Codex is installed and is skipped
52
+ elsewhere.
package/bin/capa.mjs ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * capa: the Capa command line. lib/main.mjs has the commands; this file
4
+ * checks Node first, so an old Node is told what it needs instead of failing
5
+ * on syntax it cannot read, and exits only once stdout has drained (a piped
6
+ * answer is never cut short).
7
+ */
8
+ const [major, minor] = process.versions.node.split(".").map(Number);
9
+ if (major < 20 || (major === 20 && minor < 3)) {
10
+ process.stderr.write(`capa needs Node 20.3 or later; this is ${process.versions.node}.\n`);
11
+ process.exit(2);
12
+ }
13
+
14
+ import("../lib/main.mjs")
15
+ .then(({ main }) => main(process.argv.slice(2)))
16
+ .then(
17
+ (code) => {
18
+ // null: `capa mcp`, which exits on its own when the client goes away.
19
+ if (code === null || code === undefined) return;
20
+ process.stdout.write("", () => process.exit(code));
21
+ },
22
+ (error) => {
23
+ process.stderr.write(`capa: ${error?.message ?? error}\n`);
24
+ process.stdout.write("", () => process.exit(1));
25
+ },
26
+ );
package/lib/argv.mjs ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * argv.mjs — the command line, read without a library.
3
+ *
4
+ * --name value --name=value --flag (a boolean) --no-flag (false)
5
+ * -h -y the two short forms: --help and --yes
6
+ * -- everything after it is positional
7
+ *
8
+ * A flag is a boolean only when the command says so, which is how
9
+ * `--sdl articles` can never swallow `articles`. Flag names are read in
10
+ * kebab-case or camelCase and stored camelCase, the case tool arguments use:
11
+ * `--version-id` and `--versionId` are both `versionId`.
12
+ */
13
+ import { CliError, EXIT } from "./output.mjs";
14
+
15
+ /** The flags every command takes, wherever they appear. */
16
+ export const GLOBAL_BOOLEANS = new Set(["json", "help", "version"]);
17
+ export const GLOBAL_VALUES = new Set(["profile"]);
18
+
19
+ export const camel = (name) => name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
20
+ export const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
21
+
22
+ /**
23
+ * `{ positionals, flags }`. `booleans` are the flags that take no value.
24
+ * A flag given twice keeps the last value.
25
+ */
26
+ export function parseArgv(argv, { booleans = new Set() } = {}) {
27
+ const positionals = [];
28
+ const flags = {};
29
+ const isBoolean = (name) => GLOBAL_BOOLEANS.has(name) || booleans.has(name);
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const token = argv[i];
32
+ if (token === "--") {
33
+ positionals.push(...argv.slice(i + 1));
34
+ break;
35
+ }
36
+ if (token === "-h") {
37
+ flags.help = true;
38
+ continue;
39
+ }
40
+ if (token === "-y") {
41
+ flags.yes = true;
42
+ continue;
43
+ }
44
+ if (token === "-V") {
45
+ flags.version = true;
46
+ continue;
47
+ }
48
+ if (!token.startsWith("--") || token === "-") {
49
+ positionals.push(token);
50
+ continue;
51
+ }
52
+ const eq = token.indexOf("=");
53
+ const raw = eq === -1 ? token.slice(2) : token.slice(2, eq);
54
+ if (!raw) throw new CliError(EXIT.usage, `${token} is not a flag.`);
55
+ let name = camel(raw);
56
+ if (eq !== -1) {
57
+ flags[name] = token.slice(eq + 1);
58
+ continue;
59
+ }
60
+ if (name.startsWith("no") && /^no[A-Z]/.test(name) && isBoolean(name[2].toLowerCase() + name.slice(3))) {
61
+ flags[name[2].toLowerCase() + name.slice(3)] = false;
62
+ continue;
63
+ }
64
+ if (isBoolean(name)) {
65
+ flags[name] = true;
66
+ continue;
67
+ }
68
+ const next = argv[i + 1];
69
+ if (next === undefined || (next.startsWith("--") && next !== "--")) {
70
+ throw new CliError(EXIT.usage, `--${kebab(name)} needs a value.`);
71
+ }
72
+ flags[name] = next;
73
+ i++;
74
+ }
75
+ return { positionals, flags };
76
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * commands.mjs — the content commands, each one an MCP tool by name.
3
+ *
4
+ * A command names its tool and which positionals fill which arguments; every
5
+ * other argument of the tool is a flag of the same name (`--max-chars` or
6
+ * `--maxChars` for `maxChars`), typed from the tool's own input schema. So a
7
+ * tool that gains an argument gains the flag with no change here, and a
8
+ * misspelt flag reaches the tool's argument check and gets its answer:
9
+ * "Unknown argument modle. Did you mean model?".
10
+ *
11
+ * Where two tools answer one question for different keys (a `cap_` key reads
12
+ * entries with capa_read_entries, a legacy key on a GraphQL deployment with
13
+ * capa_list_content), the command takes the first one this key is offered.
14
+ * `capa tool <name>` runs any tool directly, so nothing the MCP server offers
15
+ * is out of reach of the CLI.
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { TOOLS_BY_NAME } from "@capacms/mcp";
20
+ import { kebab } from "./argv.mjs";
21
+ import { CliError, EXIT } from "./output.mjs";
22
+
23
+ /**
24
+ * Each command: its words, a one-line summary, the positionals it takes, and
25
+ * `pick(offered)` → `{ tool, args }` from the positionals. `offered(name)`
26
+ * says whether the session offers a tool.
27
+ */
28
+ export const COMMANDS = [
29
+ {
30
+ words: ["models"],
31
+ usage: "capa models [<model>]",
32
+ summary: "List the models, or show one model's fields (the names entries are written with).",
33
+ positionals: ["model?"],
34
+ tools: ["capa_get_model", "capa_graphql_schema", "capa_list_models"],
35
+ pick: ([model], offered) => {
36
+ if (model) return offered("capa_get_model") ? { tool: "capa_get_model", args: { namespace: model } } : { tool: "capa_graphql_schema", args: { model } };
37
+ return !offered("capa_graphql_schema") && offered("capa_list_models") ? { tool: "capa_list_models", args: {} } : { tool: "capa_graphql_schema", args: {} };
38
+ },
39
+ },
40
+ {
41
+ words: ["schema"],
42
+ usage: "capa schema [<model>] [--sdl]",
43
+ summary: "What this key can read: models, fields, relations, filters and sorts; --sdl for the GraphQL SDL.",
44
+ positionals: ["model?"],
45
+ tools: ["capa_graphql_schema"],
46
+ pick: ([model]) => ({ tool: "capa_graphql_schema", args: model ? { model } : {} }),
47
+ },
48
+ {
49
+ words: ["entries", "list"],
50
+ usage: "capa entries list <model> [--where JSON] [--sort field] [--limit n] [--select fields]",
51
+ summary: "List a model's entries.",
52
+ positionals: ["model"],
53
+ tools: ["capa_read_entries", "capa_list_content"],
54
+ pick: ([model], offered) =>
55
+ !offered("capa_read_entries") && offered("capa_list_content")
56
+ ? { tool: "capa_list_content", args: { namespace: model } }
57
+ : { tool: "capa_read_entries", args: { model } },
58
+ },
59
+ {
60
+ words: ["entries", "get"],
61
+ usage: "capa entries get <model> <id>",
62
+ summary: "Read one entry.",
63
+ positionals: ["model", "id"],
64
+ tools: ["capa_read_entries", "capa_get_content"],
65
+ pick: ([model, id], offered) =>
66
+ !offered("capa_read_entries") && offered("capa_get_content")
67
+ ? { tool: "capa_get_content", args: { namespace: model, id } }
68
+ : { tool: "capa_read_entries", args: { model, id } },
69
+ },
70
+ {
71
+ words: ["entries", "create"],
72
+ usage: "capa entries create <model> --data JSON|@file|-",
73
+ summary: "Create an entry, saved as a draft.",
74
+ positionals: ["model"],
75
+ tools: ["capa_create_entry"],
76
+ pick: ([model]) => ({ tool: "capa_create_entry", args: { model } }),
77
+ },
78
+ {
79
+ words: ["entries", "update"],
80
+ usage: "capa entries update <id> --data JSON|@file|-",
81
+ summary: "Change fields of an entry, saved as a new draft version. Fields left out keep their values.",
82
+ positionals: ["id"],
83
+ tools: ["capa_update_entry"],
84
+ pick: ([id]) => ({ tool: "capa_update_entry", args: { id } }),
85
+ },
86
+ {
87
+ words: ["entries", "publish"],
88
+ usage: "capa entries publish <id> [--version-id id]",
89
+ summary: "Publish an entry: its newest draft becomes what sites read.",
90
+ positionals: ["id"],
91
+ tools: ["capa_publish_entry"],
92
+ pick: ([id]) => ({ tool: "capa_publish_entry", args: { id } }),
93
+ },
94
+ {
95
+ words: ["entries", "unpublish"],
96
+ usage: "capa entries unpublish <id>",
97
+ summary: "Unpublish an entry: sites stop reading it, and it stays as a draft.",
98
+ positionals: ["id"],
99
+ tools: ["capa_unpublish_entry"],
100
+ pick: ([id]) => ({ tool: "capa_unpublish_entry", args: { id } }),
101
+ },
102
+ {
103
+ words: ["media", "list"],
104
+ usage: "capa media list [--search text] [--type image|video|file|audio|document|pdf] [--page n] [--size n]",
105
+ summary: "List the project's files, newest first, as the values image, video and file fields take.",
106
+ positionals: [],
107
+ tools: ["capa_list_media"],
108
+ pick: () => ({ tool: "capa_list_media", args: {} }),
109
+ },
110
+ {
111
+ words: ["media", "upload"],
112
+ usage: "capa media upload <path> [--folder-id id] [--is-public]",
113
+ summary: "Upload a file from this folder; answers the value an image, video or file field takes.",
114
+ positionals: ["path"],
115
+ tools: ["capa_upload_media"],
116
+ pick: ([file]) => ({ tool: "capa_upload_media", args: { path: file } }),
117
+ },
118
+ ];
119
+
120
+ /** The command whose words start `positionals`, longest first, and the positionals left after them. */
121
+ export function findCommand(positionals) {
122
+ const matches = COMMANDS.filter((c) => c.words.every((w, i) => positionals[i] === w)).sort((a, b) => b.words.length - a.words.length);
123
+ return matches[0] ? { command: matches[0], rest: positionals.slice(matches[0].words.length) } : null;
124
+ }
125
+
126
+ /** The boolean flags of these tools, which take no value on the command line. */
127
+ export function booleanFlags(toolNames) {
128
+ const out = new Set();
129
+ for (const name of toolNames) {
130
+ for (const [prop, schema] of Object.entries(TOOLS_BY_NAME.get(name)?.inputSchema?.properties ?? {})) {
131
+ if (schema.type === "boolean") out.add(prop);
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ const typesOf = (schema) => (Array.isArray(schema?.type) ? schema.type : schema?.type ? [schema.type] : []);
138
+
139
+ /** `@file` reads a file, `-` reads stdin; anything else is the value itself. */
140
+ function readValue(flag, value, io) {
141
+ if (value === "-") {
142
+ if (io.stdinUsed) throw new CliError(EXIT.usage, `--${flag} - reads stdin, and another flag already read it.`);
143
+ io.stdinUsed = true;
144
+ return io.readStdin();
145
+ }
146
+ if (typeof value === "string" && value.startsWith("@") && value.length > 1) {
147
+ const file = path.resolve(io.cwd, value.slice(1));
148
+ try {
149
+ return fs.readFileSync(file, "utf8");
150
+ } catch (error) {
151
+ throw new CliError(EXIT.usage, `--${flag} ${value}: ${error.code === "ENOENT" ? "no such file" : error.message}.`);
152
+ }
153
+ }
154
+ return value;
155
+ }
156
+
157
+ /**
158
+ * A flag's value as the tool's schema types it. Objects and arrays are JSON
159
+ * (inline, `@file` or `-` for stdin); numbers and booleans are parsed; a string
160
+ * stays a string. A value that does not parse is passed through as given, so
161
+ * the tool's own check refuses it with the tool's own words.
162
+ */
163
+ async function coerce(flag, value, schema, io) {
164
+ const types = typesOf(schema);
165
+ if (value === true || value === false) return value;
166
+ if (types.includes("object") || types.includes("array") || types.includes("null")) {
167
+ const text = await readValue(flag, value, io);
168
+ const trimmed = String(text).trim();
169
+ const looksJson = /^[[{"]/.test(trimmed) || trimmed === "null" || /^-?\d/.test(trimmed) || trimmed === "true" || trimmed === "false";
170
+ if (looksJson) {
171
+ try {
172
+ return JSON.parse(trimmed);
173
+ } catch (error) {
174
+ if (!types.includes("string")) throw new CliError(EXIT.usage, `--${flag} is not JSON: ${error.message}.`);
175
+ }
176
+ }
177
+ if (!types.includes("string") && (types.includes("object") || types.includes("array"))) {
178
+ // Not JSON at all: hand it over as a string, and the tool says what it wanted.
179
+ return trimmed;
180
+ }
181
+ return text;
182
+ }
183
+ if ((types.includes("integer") || types.includes("number")) && /^-?\d+(\.\d+)?$/.test(String(value))) return Number(value);
184
+ if (types.includes("boolean") && (value === "true" || value === "false")) return value === "true";
185
+ return value;
186
+ }
187
+
188
+ /** Tool arguments from the command's own arguments, then every flag that is not one of the CLI's. */
189
+ export async function toolArgs(toolName, base, flags, io, { reserved = new Set() } = {}) {
190
+ const tool = TOOLS_BY_NAME.get(toolName);
191
+ const props = tool?.inputSchema?.properties ?? {};
192
+ const args = { ...base };
193
+ for (const [name, value] of Object.entries(flags)) {
194
+ if (reserved.has(name)) continue;
195
+ // An alias (`--namespace` for `model`) is typed as what it stands for and
196
+ // passed under its own name: the tool resolves it, as it does for MCP.
197
+ args[name] = await coerce(kebab(name), value, props[name] ?? props[tool?.aliases?.[name]], io);
198
+ }
199
+ return args;
200
+ }
201
+
202
+ /** The flags a tool takes, for help: `--name <type> description`. */
203
+ export function flagHelp(toolName, skip = []) {
204
+ const tool = TOOLS_BY_NAME.get(toolName);
205
+ const props = tool?.inputSchema?.properties ?? {};
206
+ const required = new Set(tool?.inputSchema?.required ?? []);
207
+ return Object.entries(props)
208
+ .filter(([name]) => !skip.includes(name))
209
+ .map(([name, schema]) => {
210
+ const types = typesOf(schema);
211
+ const shape = types.includes("boolean") && types.length === 1 ? "" : types.some((t) => t === "object" || t === "array") ? " <json|@file|->" : types.includes("integer") || types.includes("number") ? " <n>" : " <text>";
212
+ const first = String(schema.description ?? "").split(/(?<=\.)\s(?=[A-Z])/)[0];
213
+ return ` --${kebab(name)}${shape}${required.has(name) ? " (required)" : ""}\n ${first}`;
214
+ });
215
+ }