@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 +51 -2
- package/bin/capa.mjs +26 -0
- package/lib/argv.mjs +76 -0
- package/lib/commands.mjs +215 -0
- package/lib/credentials.mjs +450 -0
- package/lib/diff.mjs +45 -0
- package/lib/init.mjs +365 -0
- package/lib/main.mjs +477 -0
- package/lib/output.mjs +59 -0
- package/lib/sdk.mjs +47 -0
- package/lib/session.mjs +216 -0
- package/lib/toml.mjs +100 -0
- package/package.json +24 -3
package/lib/session.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session.mjs — from "which key" to a tool's answer and an exit code.
|
|
3
|
+
*
|
|
4
|
+
* Which key: CAPA_KEY (or CAPA_API_KEY) in the environment wins, so CI and
|
|
5
|
+
* an agent's own env work with nothing saved. Otherwise the profile named by
|
|
6
|
+
* --profile, else CAPA_PROFILE, else the one `capa login` made current. The
|
|
7
|
+
* API address is CAPA_API_URL when set, else the profile's.
|
|
8
|
+
*
|
|
9
|
+
* Every content command then does what the MCP server does at startup
|
|
10
|
+
* (`connect`: `/api/me` and the deployment probes) and runs ONE tool through
|
|
11
|
+
* `runTool`, the function the server's `tools/call` runs. So a command
|
|
12
|
+
* answers what the tool answers, with the same argument checks and the same
|
|
13
|
+
* refusals; only the exit code is the CLI's own.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
CapaApiError,
|
|
17
|
+
CapaTimeout,
|
|
18
|
+
CapaUnreachable,
|
|
19
|
+
TOOLS_BY_NAME,
|
|
20
|
+
apiNextGet,
|
|
21
|
+
connect,
|
|
22
|
+
keyFamily,
|
|
23
|
+
loadConfig,
|
|
24
|
+
probeUploads,
|
|
25
|
+
runTool,
|
|
26
|
+
} from "@capacms/mcp";
|
|
27
|
+
import { CliError, EXIT } from "./output.mjs";
|
|
28
|
+
|
|
29
|
+
/** Where `capa login` sends a key when no --api-url names another host: the API, which serves reads and the agent writes. */
|
|
30
|
+
export const DEFAULT_API_URL = "https://api.capacms.com";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The environment `loadConfig` reads, built from the stored profile and the
|
|
34
|
+
* real environment. `{ env, source, profile }`; throws a CliError (exit 3)
|
|
35
|
+
* when there is no key at all.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveEnv(io, credentials, profileName) {
|
|
38
|
+
const real = io.env;
|
|
39
|
+
const envKey = (real.CAPA_KEY || real.CAPA_API_KEY || "").trim();
|
|
40
|
+
let profile = null;
|
|
41
|
+
try {
|
|
42
|
+
profile = credentials.active(envKey ? null : profileName);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new CliError(error.exitCode ?? EXIT.config, error.message);
|
|
45
|
+
}
|
|
46
|
+
if (envKey) {
|
|
47
|
+
const url = real.CAPA_API_URL || real.CAPA_BASE_URL || profile?.meta.apiUrl || DEFAULT_API_URL;
|
|
48
|
+
return {
|
|
49
|
+
source: "env",
|
|
50
|
+
profile: null,
|
|
51
|
+
env: {
|
|
52
|
+
CAPA_API_URL: url,
|
|
53
|
+
CAPA_KEY: envKey,
|
|
54
|
+
CAPA_TENANT_ID: real.CAPA_TENANT_ID ?? "",
|
|
55
|
+
CAPA_API_VERSION: real.CAPA_API_VERSION ?? "",
|
|
56
|
+
...uploadEnv(io, profile),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (!profile) {
|
|
61
|
+
throw new CliError(EXIT.config, "Not logged in. Run capa login, or set CAPA_KEY (and CAPA_API_URL) in the environment.");
|
|
62
|
+
}
|
|
63
|
+
let secret;
|
|
64
|
+
try {
|
|
65
|
+
secret = credentials.secretFor(profile);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new CliError(error.exitCode ?? EXIT.config, error.message);
|
|
68
|
+
}
|
|
69
|
+
if (!secret) {
|
|
70
|
+
throw new CliError(EXIT.config, `The key for profile ${profile.name} is gone from ${profile.meta.store === "file" ? "its file" : "the keychain"}. Run capa login again.`);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
source: "profile",
|
|
74
|
+
profile,
|
|
75
|
+
env: {
|
|
76
|
+
CAPA_API_URL: real.CAPA_API_URL || real.CAPA_BASE_URL || profile.meta.apiUrl,
|
|
77
|
+
CAPA_KEY: secret,
|
|
78
|
+
CAPA_TENANT_ID: profile.meta.tenantId ?? "",
|
|
79
|
+
CAPA_API_VERSION: real.CAPA_API_VERSION || profile.meta.apiVersion || "",
|
|
80
|
+
...uploadEnv(io, profile),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Where files go and which folder they may be read from: the uploader's
|
|
87
|
+
* address from the environment, else the one `capa login --upload-url` kept.
|
|
88
|
+
* The folder is the person's own choice only (CAPA_UPLOAD_ROOT), else the
|
|
89
|
+
* project Claude Code names (CLAUDE_PROJECT_DIR), else the folder capa runs
|
|
90
|
+
* in, which `configFrom` passes as `cwd` and which the upload tool refuses
|
|
91
|
+
* when it is the home folder or holds it (@capacms/mcp media-tools.mjs).
|
|
92
|
+
*/
|
|
93
|
+
function uploadEnv(io, profile) {
|
|
94
|
+
return {
|
|
95
|
+
CAPA_UPLOAD_URL: io.env.CAPA_UPLOAD_URL || profile?.meta.uploadUrl || "",
|
|
96
|
+
CAPA_UPLOAD_ROOT: io.env.CAPA_UPLOAD_ROOT || "",
|
|
97
|
+
CAPA_UPLOAD_ALLOW: io.env.CAPA_UPLOAD_ALLOW || "",
|
|
98
|
+
CLAUDE_PROJECT_DIR: io.env.CLAUDE_PROJECT_DIR || "",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `loadConfig`, with its refusals as the CLI's, and with `io` the folder capa runs in and the home folder. */
|
|
103
|
+
export function configFrom(env, io = null) {
|
|
104
|
+
let config;
|
|
105
|
+
try {
|
|
106
|
+
config = loadConfig(env);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new CliError(EXIT.config, String(error.message).replace(/^capa-mcp: /, ""));
|
|
109
|
+
}
|
|
110
|
+
if (!io) return config;
|
|
111
|
+
return { ...config, cwd: io.cwd, ...(io.env.HOME ? { home: io.env.HOME } : {}) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The exit code and the CLI's message for a transport or API failure. */
|
|
115
|
+
export function failureOf(error) {
|
|
116
|
+
if (error instanceof CapaUnreachable || error instanceof CapaTimeout || error?.name === "TimeoutError") return EXIT.unreachable;
|
|
117
|
+
if (error instanceof CapaApiError && error.status === 401) return EXIT.config;
|
|
118
|
+
return EXIT.refused;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `whoAmI`, and whether this deployment takes uploads from the key
|
|
123
|
+
* (`probeUploads`), so what `keySees` says about uploading is what the MCP
|
|
124
|
+
* server will offer.
|
|
125
|
+
*/
|
|
126
|
+
export async function whoAmIAndUploads(config) {
|
|
127
|
+
const [who, media] = await Promise.all([whoAmI(config), probeUploads(config)]);
|
|
128
|
+
return { ...who, uploads: media.uploads };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `GET /api/me` for a config: `{ me, meta }`, or a CliError naming why not. */
|
|
132
|
+
export async function whoAmI(config) {
|
|
133
|
+
try {
|
|
134
|
+
const body = await apiNextGet(config, "/api/me");
|
|
135
|
+
return { me: body?.data ?? null, meta: body?.meta ?? null };
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const exitCode = failureOf(error);
|
|
138
|
+
const why =
|
|
139
|
+
exitCode === EXIT.config
|
|
140
|
+
? `The API refused the key (401${error.code ? ` ${error.code}` : ""}). It may be mistyped, deactivated or expired.`
|
|
141
|
+
: error.message;
|
|
142
|
+
throw new CliError(exitCode, why, error instanceof CapaApiError ? { status: error.status, code: error.code ?? null } : {});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What a key reads and may write, in one or two sentences a person acts on.
|
|
148
|
+
* Uploading is named only where the deployment said it takes uploads from
|
|
149
|
+
* this key (`uploads`, from `whoAmIAndUploads`); a production key holding
|
|
150
|
+
* media:create may, as the API has it since #349.
|
|
151
|
+
*/
|
|
152
|
+
export function keySees(me, { uploads = null } = {}) {
|
|
153
|
+
if (!me) return null;
|
|
154
|
+
const scopes = Array.isArray(me.scopes) ? me.scopes : [];
|
|
155
|
+
const holds = (scope) => scopes.some((s) => s === scope || s.startsWith(`${scope}:`) || s === "*:*" || s === `${scope.split(":")[0]}:*`);
|
|
156
|
+
const reads =
|
|
157
|
+
me.environment === "production"
|
|
158
|
+
? "A production key reads published entries only, exactly what visitors see; a draft it writes shows up in its reads once the entry is published."
|
|
159
|
+
: `A ${me.environment ?? "development"} key reads drafts and unpublished changes too.`;
|
|
160
|
+
const writes = [
|
|
161
|
+
holds("instance:create") && "create drafts",
|
|
162
|
+
holds("instance:update") && "update drafts",
|
|
163
|
+
holds("instance:publish") && "publish and unpublish",
|
|
164
|
+
uploads === true && holds("media:create") && "upload media",
|
|
165
|
+
].filter(Boolean);
|
|
166
|
+
const agent = Array.isArray(me.surfaces) && me.surfaces.includes("/v2/agent");
|
|
167
|
+
const may = !writes.length
|
|
168
|
+
? "It reads only."
|
|
169
|
+
: agent
|
|
170
|
+
? `It may ${writes.join(", ").replace(/, ([^,]*)$/, " and $1")}.`
|
|
171
|
+
: `Its scopes would let it ${writes.join(", ").replace(/, ([^,]*)$/, " and $1")}, but this deployment does not accept cap_ keys on /v2/agent yet, so the write commands are not offered.`;
|
|
172
|
+
return `${reads} ${may}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A connected session: `{ ctx, me, notes, config, resolved }`. `ctx` is what
|
|
177
|
+
* `runTool` takes.
|
|
178
|
+
*/
|
|
179
|
+
export async function openSession(io, credentials, profileName) {
|
|
180
|
+
const resolved = resolveEnv(io, credentials, profileName);
|
|
181
|
+
const config = configFrom(resolved.env, io);
|
|
182
|
+
const { ctx, me, notes } = await connect(config);
|
|
183
|
+
return { ctx, me, notes, config, resolved };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Is `name` offered in this session? */
|
|
187
|
+
export const offered = (session, name) => session.ctx.tools.some((tool) => tool.name === name);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Runs one tool and prints its answer. Returns the exit code:
|
|
191
|
+
* 0 an answer, 1 an in-band `{ error }` or an API refusal, 2 arguments the
|
|
192
|
+
* tool refused, 3 a key the API does not know, 4 a tool not offered here,
|
|
193
|
+
* 5 no answer from Capa.
|
|
194
|
+
*/
|
|
195
|
+
export async function runAndPrint(out, session, name, args) {
|
|
196
|
+
if (!TOOLS_BY_NAME.has(name)) throw new CliError(EXIT.usage, `No tool ${name}. capa tools lists the ones this key is offered.`);
|
|
197
|
+
const run = await runTool(session.ctx, name, args);
|
|
198
|
+
if (run.ok) {
|
|
199
|
+
out.answer(run.answer);
|
|
200
|
+
const inBand = run.answer && typeof run.answer === "object" && !Array.isArray(run.answer) && "error" in run.answer && run.answer.error;
|
|
201
|
+
return inBand ? EXIT.refused : EXIT.ok;
|
|
202
|
+
}
|
|
203
|
+
if (run.reason === "unknown_tool") {
|
|
204
|
+
// The startup notes that name this tool say why; the rest are about other tools.
|
|
205
|
+
const notes = session.notes.map((n) => n.replace(/^capa-mcp: /, "")).map((n) => n[0].toUpperCase() + n.slice(1));
|
|
206
|
+
const naming = notes.filter((n) => n.includes(name));
|
|
207
|
+
const why = (naming.length ? naming : notes).map((n) => ` ${n}`).join("");
|
|
208
|
+
throw new CliError(EXIT.notOffered, `${name} is not offered for this key here.${why}`, { tool: name, family: keyFamily(session.config.apiKey) });
|
|
209
|
+
}
|
|
210
|
+
if (run.reason === "invalid_arguments") throw new CliError(EXIT.usage, run.text, { tool: name });
|
|
211
|
+
const error = run.error;
|
|
212
|
+
throw new CliError(failureOf(error), run.text, {
|
|
213
|
+
tool: name,
|
|
214
|
+
...(error instanceof CapaApiError ? { status: error.status, code: error.code ?? null } : {}),
|
|
215
|
+
});
|
|
216
|
+
}
|
package/lib/toml.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toml.mjs — replacing one `[mcp_servers.<name>]` section of a Codex
|
|
3
|
+
* config.toml, and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Not a TOML parser. Codex's config is a person's file, with their comments,
|
|
6
|
+
* their other servers and their own order, and a parse-and-print round trip
|
|
7
|
+
* would rewrite all of it. So this works on lines: the section is its header
|
|
8
|
+
* and every line up to the next header that is not one of its own subtables
|
|
9
|
+
* (`[mcp_servers.capa.env]`, `[mcp_servers.capa.tools.x]`). An existing
|
|
10
|
+
* section is replaced where it stands; a new one is appended. Headers inside a
|
|
11
|
+
* multi-line string are not headers.
|
|
12
|
+
*
|
|
13
|
+
* A server defined any other way (a dotted key `mcp_servers.capa.command = `,
|
|
14
|
+
* or `capa = { ... }` under `[mcp_servers]`) is refused, not guessed at.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The dotted path of a header's key: `mcp_servers."capa".env` → ["mcp_servers", "capa", "env"]. */
|
|
18
|
+
export function keyPath(key) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
let current = "";
|
|
21
|
+
let quote = null;
|
|
22
|
+
for (const char of key.trim()) {
|
|
23
|
+
if (quote) {
|
|
24
|
+
if (char === quote) quote = null;
|
|
25
|
+
else current += char;
|
|
26
|
+
} else if (char === '"' || char === "'") {
|
|
27
|
+
quote = char;
|
|
28
|
+
} else if (char === ".") {
|
|
29
|
+
parts.push(current.trim());
|
|
30
|
+
current = "";
|
|
31
|
+
} else {
|
|
32
|
+
current += char;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
parts.push(current.trim());
|
|
36
|
+
return parts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HEADER = /^\s*\[(\[)?\s*(.+?)\s*\]?\]\s*(#.*)?$/;
|
|
40
|
+
|
|
41
|
+
/** Each line, with the table it belongs to (`null` before the first header) and whether it is a header. */
|
|
42
|
+
function scan(lines) {
|
|
43
|
+
let table = null;
|
|
44
|
+
let multiline = null;
|
|
45
|
+
return lines.map((line) => {
|
|
46
|
+
if (multiline) {
|
|
47
|
+
if (line.includes(multiline)) multiline = null;
|
|
48
|
+
return { line, table, header: false };
|
|
49
|
+
}
|
|
50
|
+
const header = HEADER.exec(line);
|
|
51
|
+
if (header && !line.trim().startsWith("#")) {
|
|
52
|
+
table = keyPath(header[2]);
|
|
53
|
+
return { line, table, header: true };
|
|
54
|
+
}
|
|
55
|
+
for (const fence of ['"""', "'''"]) {
|
|
56
|
+
const count = line.split(fence).length - 1;
|
|
57
|
+
if (count % 2 === 1) multiline = fence;
|
|
58
|
+
}
|
|
59
|
+
return { line, table, header: false };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const within = (table, name) => Array.isArray(table) && table[0] === "mcp_servers" && table[1] === name;
|
|
64
|
+
|
|
65
|
+
/** A TOML basic string. JSON's escapes are TOML's for every character a path or a command holds. */
|
|
66
|
+
export const tomlString = (value) => JSON.stringify(String(value));
|
|
67
|
+
export const tomlArray = (values) => `[${values.map(tomlString).join(", ")}]`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `text` with the section for server `name` set to `block` (its header and
|
|
71
|
+
* body, ending in a newline). `{ text, replaced }`; throws when the server is
|
|
72
|
+
* defined in a form this cannot edit safely.
|
|
73
|
+
*/
|
|
74
|
+
export function setServerSection(text, name, block) {
|
|
75
|
+
const lines = text.split("\n");
|
|
76
|
+
const rows = scan(lines);
|
|
77
|
+
for (const row of rows) {
|
|
78
|
+
if (row.header || /^\s*(#|$)/.test(row.line)) continue;
|
|
79
|
+
const dotted = new RegExp(`^\\s*mcp_servers\\s*\\.\\s*["']?${name}["']?\\s*[.=]`);
|
|
80
|
+
const inline = new RegExp(`^\\s*["']?${name}["']?\\s*=`);
|
|
81
|
+
const atRoot = row.table === null || row.table.length === 0;
|
|
82
|
+
if ((atRoot && dotted.test(row.line)) || (row.table?.length === 1 && row.table[0] === "mcp_servers" && inline.test(row.line))) {
|
|
83
|
+
throw new Error(`mcp_servers.${name} is defined inline in this file. Remove that definition by hand (or rename it), then run capa init codex again.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const start = rows.findIndex((row) => row.header && within(row.table, name));
|
|
87
|
+
if (start === -1) {
|
|
88
|
+
const body = text.replace(/\s*$/, "");
|
|
89
|
+
return { text: `${body}${body ? "\n\n" : ""}${block}`, replaced: false };
|
|
90
|
+
}
|
|
91
|
+
let end = rows.findIndex((row, i) => i > start && row.header && !within(row.table, name));
|
|
92
|
+
if (end === -1) end = rows.length;
|
|
93
|
+
// Blank lines and comments right above the next header belong to it, not to the section being replaced.
|
|
94
|
+
let cut = end;
|
|
95
|
+
while (cut > start + 1 && /^\s*(#.*)?$/.test(lines[cut - 1]) && end !== rows.length) cut--;
|
|
96
|
+
const before = lines.slice(0, start).join("\n");
|
|
97
|
+
const after = lines.slice(cut).join("\n");
|
|
98
|
+
const joined = `${before}${before ? "\n" : ""}${block}${after ? `\n${after.replace(/^\n+/, "")}` : ""}`;
|
|
99
|
+
return { text: joined.endsWith("\n") ? joined : `${joined}\n`, replaced: true };
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capacms/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The capa command line: Capa content, models and keys from a terminal, a script or an agent, and capa init for Claude Code, Codex and Cursor.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"homepage": "https://docs.capacms.com/cli",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"capa": "bin/capa.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"lib"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20.3"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@capacms/mcp": "0.3.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/argv.test.mjs test/credentials.test.mjs test/init.test.mjs test/commands.test.mjs test/media.test.mjs test/sdk.test.mjs"
|
|
26
|
+
}
|
|
6
27
|
}
|