@octanejs/cli 0.0.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/package.json +50 -0
  4. package/src/bin/octane.js +4 -0
  5. package/src/commands/add.js +138 -0
  6. package/src/commands/analyze.js +271 -0
  7. package/src/commands/bindings.js +55 -0
  8. package/src/commands/doctor/check.js +43 -0
  9. package/src/commands/doctor/checks/bundler.js +104 -0
  10. package/src/commands/doctor/checks/config.js +184 -0
  11. package/src/commands/doctor/checks/dependencies.js +120 -0
  12. package/src/commands/doctor/checks/environment.js +38 -0
  13. package/src/commands/doctor/checks/source.js +108 -0
  14. package/src/commands/doctor/checks/typescript.js +183 -0
  15. package/src/commands/doctor/index.js +118 -0
  16. package/src/commands/doctor/registry.js +32 -0
  17. package/src/commands/doctor/report.js +158 -0
  18. package/src/commands/explain.js +95 -0
  19. package/src/commands/info.js +54 -0
  20. package/src/commands/init/index.js +277 -0
  21. package/src/commands/init/templates.js +124 -0
  22. package/src/commands/mcp/add.js +241 -0
  23. package/src/commands/mcp/clients.js +281 -0
  24. package/src/commands/mcp/detect.js +58 -0
  25. package/src/commands/mcp/index.js +23 -0
  26. package/src/commands/mcp/remove.js +105 -0
  27. package/src/commands/mcp/server.js +46 -0
  28. package/src/commands/mcp/status.js +48 -0
  29. package/src/data/index.js +74 -0
  30. package/src/data/octane-data.json +953 -0
  31. package/src/index.js +4 -0
  32. package/src/kernel/args.js +181 -0
  33. package/src/kernel/banner.js +98 -0
  34. package/src/kernel/command.js +84 -0
  35. package/src/kernel/context.js +78 -0
  36. package/src/kernel/edit.js +238 -0
  37. package/src/kernel/errors.js +42 -0
  38. package/src/kernel/exec.js +62 -0
  39. package/src/kernel/help.js +97 -0
  40. package/src/kernel/install.js +43 -0
  41. package/src/kernel/jsonc.js +91 -0
  42. package/src/kernel/main.js +166 -0
  43. package/src/kernel/project.js +376 -0
  44. package/src/kernel/registry.js +52 -0
  45. package/src/kernel/semver.js +111 -0
  46. package/src/kernel/ui.js +155 -0
@@ -0,0 +1,281 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseJsonc } from '../../kernel/jsonc.js';
4
+
5
+ /**
6
+ * @typedef {'user' | 'project'} Scope
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} Paths
11
+ * @property {string} projectRoot
12
+ * @property {string} home resolved from the environment, so tests never touch a
13
+ * real `~/.claude.json`
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} Client
18
+ * @property {string} id
19
+ * @property {string} label
20
+ * @property {string | null} cli binary that owns this client's config, if any
21
+ * @property {Scope[]} scopes
22
+ * @property {(scope: Scope, paths: Paths) => string} configPath
23
+ * @property {(name: string, entry: import('./server.js').ServerEntry, scope: Scope) => string[] | null} cliArgs
24
+ * @property {(name: string, scope: Scope) => string[] | null} cliRemoveArgs
25
+ * @property {(text: string, name: string) => unknown | null} readServer current entry, or null
26
+ * @property {(text: string, name: string, entry: import('./server.js').ServerEntry) => string} write
27
+ * @property {(text: string, name: string) => string} remove
28
+ */
29
+
30
+ /**
31
+ * Read a config file that may be missing, empty, or corrupt.
32
+ *
33
+ * All three happen in the wild: `~/.cursor/mcp.json` in particular is commonly
34
+ * present but not valid JSON. Treating those as an empty document is what keeps
35
+ * the merge from either crashing or silently discarding a real config.
36
+ *
37
+ * @param {string} file
38
+ * @returns {string}
39
+ */
40
+ export function readConfigText(file) {
41
+ if (!existsSync(file)) return '';
42
+ try {
43
+ return readFileSync(file, 'utf8');
44
+ } catch {
45
+ return '';
46
+ }
47
+ }
48
+
49
+ /**
50
+ * @param {string} text
51
+ * @returns {Record<string, any>}
52
+ */
53
+ function parseObject(text) {
54
+ if (text.trim() === '') return {};
55
+ try {
56
+ const parsed = parseJsonc(text);
57
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
58
+ ? /** @type {Record<string, any>} */ (parsed)
59
+ : {};
60
+ } catch {
61
+ return {};
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Most clients store MCP servers in a JSON object under a single key. The only
67
+ * differences are the file path, the key name, and whether servers can be
68
+ * nested per project.
69
+ *
70
+ * @param {{
71
+ * id: string,
72
+ * label: string,
73
+ * cli?: string | null,
74
+ * scopes?: Scope[],
75
+ * key?: string,
76
+ * entry?: (entry: import('./server.js').ServerEntry) => object,
77
+ * configPath: (scope: Scope, paths: Paths) => string,
78
+ * cliArgs?: Client['cliArgs'],
79
+ * cliRemoveArgs?: Client['cliRemoveArgs'],
80
+ * }} spec
81
+ * @returns {Client}
82
+ */
83
+ function jsonClient(spec) {
84
+ const key = spec.key ?? 'mcpServers';
85
+ const shape = spec.entry ?? ((entry) => entry);
86
+
87
+ return {
88
+ id: spec.id,
89
+ label: spec.label,
90
+ cli: spec.cli ?? null,
91
+ scopes: spec.scopes ?? ['user', 'project'],
92
+ configPath: spec.configPath,
93
+ cliArgs: spec.cliArgs ?? (() => null),
94
+ cliRemoveArgs: spec.cliRemoveArgs ?? (() => null),
95
+
96
+ readServer(text, name) {
97
+ return parseObject(text)[key]?.[name] ?? null;
98
+ },
99
+
100
+ write(text, name, entry) {
101
+ const config = parseObject(text);
102
+ config[key] = { ...config[key], [name]: shape(entry) };
103
+ return `${JSON.stringify(config, null, 2)}\n`;
104
+ },
105
+
106
+ remove(text, name) {
107
+ const config = parseObject(text);
108
+ if (config[key]) delete config[key][name];
109
+ return `${JSON.stringify(config, null, 2)}\n`;
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Replace or append a TOML table, without a TOML parser.
116
+ *
117
+ * The section boundary is unambiguous (a line starting with `[`), so a splice
118
+ * preserves every unrelated key, comment, and ordering choice in the file. A
119
+ * parse-and-reserialize round trip would not.
120
+ *
121
+ * @param {string} text
122
+ * @param {string} header
123
+ * @param {string[] | null} body null removes the section
124
+ * @returns {string}
125
+ */
126
+ export function spliceTomlSection(text, header, body) {
127
+ const lines = text.split('\n');
128
+ const start = lines.findIndex((line) => line.trim() === header);
129
+
130
+ if (start === -1) {
131
+ if (!body) return text;
132
+ const prefix = text.trim() === '' ? '' : `${text.replace(/\n+$/, '')}\n\n`;
133
+ return `${prefix}${header}\n${body.join('\n')}\n`;
134
+ }
135
+
136
+ const end = sectionEnd(lines, start);
137
+ const before = lines.slice(0, start);
138
+ const after = lines.slice(end);
139
+
140
+ // Blank lines are trimmed only at the splice boundary. A global whitespace
141
+ // pass would also reach inside a multi-line TOML string elsewhere in the
142
+ // file, which is exactly the unrelated user data this splice exists to
143
+ // leave alone.
144
+ while (before.length > 0 && before[before.length - 1].trim() === '') before.pop();
145
+
146
+ const separator = before.length > 0 ? [''] : [];
147
+ const replacement = body ? [...separator, header, ...body] : [];
148
+ const tail = after.length > 0 ? ['', ...after] : [''];
149
+
150
+ return [...before, ...replacement, ...tail].join('\n');
151
+ }
152
+
153
+ /**
154
+ * Index of the first line after the section starting at `start`.
155
+ *
156
+ * @param {string[]} lines
157
+ * @param {number} start
158
+ * @returns {number}
159
+ */
160
+ function sectionEnd(lines, start) {
161
+ let end = start + 1;
162
+ while (end < lines.length && !/^\s*\[/.test(lines[end])) end++;
163
+ return end;
164
+ }
165
+
166
+ /**
167
+ * @param {string} value
168
+ * @returns {string}
169
+ */
170
+ const toml = (value) => JSON.stringify(String(value));
171
+
172
+ /** @type {Client} */
173
+ const codex = {
174
+ id: 'codex',
175
+ label: 'Codex',
176
+ cli: 'codex',
177
+ // Codex keeps MCP servers in one user-level config; it has no project scope.
178
+ scopes: ['user'],
179
+ configPath: (scope, paths) => path.join(paths.home, '.codex', 'config.toml'),
180
+
181
+ cliArgs: (name, entry) => [
182
+ 'mcp',
183
+ 'add',
184
+ ...Object.entries(entry.env ?? {}).flatMap(([key, value]) => ['--env', `${key}=${value}`]),
185
+ name,
186
+ '--',
187
+ entry.command,
188
+ ...entry.args,
189
+ ],
190
+ cliRemoveArgs: (name) => ['mcp', 'remove', name],
191
+
192
+ readServer(text, name) {
193
+ const lines = text.split('\n');
194
+ const start = lines.findIndex((line) => line.trim() === `[mcp_servers.${name}]`);
195
+ if (start === -1) return null;
196
+ // Bounded to this section: an unbounded search would report a later
197
+ // server's command as if it were ours.
198
+ const body = lines.slice(start + 1, sectionEnd(lines, start)).join('\n');
199
+ return { command: /^\s*command\s*=\s*"([^"]*)"/m.exec(body)?.[1] ?? null };
200
+ },
201
+
202
+ write(text, name, entry) {
203
+ const body = [
204
+ `command = ${toml(entry.command)}`,
205
+ `args = [${entry.args.map(toml).join(', ')}]`,
206
+ ];
207
+ const env = Object.entries(entry.env ?? {});
208
+ if (env.length > 0) {
209
+ body.push(`env = { ${env.map(([key, value]) => `${key} = ${toml(value)}`).join(', ')} }`);
210
+ }
211
+ return spliceTomlSection(text, `[mcp_servers.${name}]`, body);
212
+ },
213
+
214
+ remove: (text, name) => spliceTomlSection(text, `[mcp_servers.${name}]`, null),
215
+ };
216
+
217
+ /**
218
+ * Every client the CLI knows how to configure.
219
+ *
220
+ * Adding one is a single entry here: the add/status/remove commands are generic
221
+ * over this table.
222
+ *
223
+ * @type {Client[]}
224
+ */
225
+ export const CLIENTS = [
226
+ jsonClient({
227
+ id: 'claude',
228
+ label: 'Claude Code',
229
+ cli: 'claude',
230
+ // `project` is the committed .mcp.json the whole team shares; `user`
231
+ // is the personal config. This mirrors `claude mcp add --scope`.
232
+ configPath: (scope, paths) =>
233
+ scope === 'project'
234
+ ? path.join(paths.projectRoot, '.mcp.json')
235
+ : path.join(paths.home, '.claude.json'),
236
+ // The name goes before `-e`: Claude declares `--env <env...>` as variadic,
237
+ // so an `-e` ahead of the positional swallows the server name and the CLI
238
+ // rejects it as a malformed environment variable.
239
+ cliArgs: (name, entry, scope) => [
240
+ 'mcp',
241
+ 'add',
242
+ '--scope',
243
+ scope,
244
+ name,
245
+ ...Object.entries(entry.env ?? {}).flatMap(([key, value]) => ['-e', `${key}=${value}`]),
246
+ '--',
247
+ entry.command,
248
+ ...entry.args,
249
+ ],
250
+ // Scope is forwarded: `claude mcp remove` without it removes from
251
+ // whichever scope the entry happens to exist in, which need not be the
252
+ // one `mcp remove --scope` just reported.
253
+ cliRemoveArgs: (name, scope) => ['mcp', 'remove', '--scope', scope, name],
254
+ }),
255
+ codex,
256
+ jsonClient({
257
+ id: 'cursor',
258
+ label: 'Cursor',
259
+ configPath: (scope, paths) =>
260
+ scope === 'project'
261
+ ? path.join(paths.projectRoot, '.cursor', 'mcp.json')
262
+ : path.join(paths.home, '.cursor', 'mcp.json'),
263
+ }),
264
+ jsonClient({
265
+ id: 'vscode',
266
+ label: 'VS Code',
267
+ // VS Code names the map `servers`, not `mcpServers`, and its schema
268
+ // discriminates transports on `type`; without it the entry is rejected
269
+ // and the server silently never registers.
270
+ key: 'servers',
271
+ entry: (entry) => ({ type: 'stdio', ...entry }),
272
+ scopes: ['project'],
273
+ configPath: (scope, paths) => path.join(paths.projectRoot, '.vscode', 'mcp.json'),
274
+ }),
275
+ ];
276
+
277
+ /**
278
+ * @param {string} id
279
+ * @returns {Client | undefined}
280
+ */
281
+ export const findClient = (id) => CLIENTS.find((client) => client.id === id);
@@ -0,0 +1,58 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { CLIENTS, readConfigText } from './clients.js';
4
+ import { SERVER_NAME } from './server.js';
5
+
6
+ /**
7
+ * Honour an overridden HOME so a test run can never read or write the real
8
+ * `~/.claude.json`.
9
+ *
10
+ * @param {import('../../kernel/context.js').Ctx} ctx
11
+ * @returns {string}
12
+ */
13
+ export const resolveHome = (ctx) => ctx.env.HOME ?? ctx.env.USERPROFILE ?? homedir();
14
+
15
+ /**
16
+ * @typedef {Object} ClientState
17
+ * @property {import('./clients.js').Client} client
18
+ * @property {import('./clients.js').Scope} scope
19
+ * @property {string} configPath
20
+ * @property {boolean} configExists
21
+ * @property {boolean} cliAvailable
22
+ * @property {unknown | null} server the octane entry already registered, if any
23
+ */
24
+
25
+ /**
26
+ * Inspect every known client for a given scope.
27
+ *
28
+ * A client counts as present when either its config file exists or its CLI is
29
+ * on PATH, so a fresh install of a tool that has not written its config yet is
30
+ * still offered.
31
+ *
32
+ * @param {import('../../kernel/context.js').Ctx} ctx
33
+ * @param {import('./clients.js').Scope} scope
34
+ * @param {string} projectRoot
35
+ * @returns {ClientState[]}
36
+ */
37
+ export function detectClients(ctx, scope, projectRoot) {
38
+ const paths = { projectRoot, home: resolveHome(ctx) };
39
+
40
+ return CLIENTS.filter((client) => client.scopes.includes(scope)).map((client) => {
41
+ const configPath = client.configPath(scope, paths);
42
+ const configExists = existsSync(configPath);
43
+ return {
44
+ client,
45
+ scope,
46
+ configPath,
47
+ configExists,
48
+ cliAvailable: Boolean(client.cli && ctx.exec.which(client.cli)),
49
+ server: configExists ? client.readServer(readConfigText(configPath), SERVER_NAME) : null,
50
+ };
51
+ });
52
+ }
53
+
54
+ /**
55
+ * @param {ClientState} state
56
+ * @returns {boolean}
57
+ */
58
+ export const isPresent = (state) => state.configExists || state.cliAvailable;
@@ -0,0 +1,23 @@
1
+ import { defineCommand } from '../../kernel/command.js';
2
+ import { SERVER_PACKAGE } from './server.js';
3
+
4
+ export default defineCommand({
5
+ description: `Manage the Octane MCP server (${SERVER_PACKAGE}) across coding agents.`,
6
+ subcommands: [
7
+ {
8
+ name: 'add',
9
+ summary: 'Register the Octane MCP server with an agent.',
10
+ load: () => import('./add.js'),
11
+ },
12
+ {
13
+ name: 'status',
14
+ summary: 'Show where the Octane MCP server is registered.',
15
+ load: () => import('./status.js'),
16
+ },
17
+ {
18
+ name: 'remove',
19
+ summary: 'Remove the Octane MCP server from an agent.',
20
+ load: () => import('./remove.js'),
21
+ },
22
+ ],
23
+ });
@@ -0,0 +1,105 @@
1
+ import { copyFileSync, writeFileSync } from 'node:fs';
2
+ import { defineCommand } from '../../kernel/command.js';
3
+ import { EXIT } from '../../kernel/errors.js';
4
+ import { CLIENTS, readConfigText } from './clients.js';
5
+ import { detectClients } from './detect.js';
6
+ import { SERVER_NAME } from './server.js';
7
+
8
+ export default defineCommand({
9
+ description: `Remove the "${SERVER_NAME}" MCP server from one or more agents.`,
10
+ positionals: [
11
+ {
12
+ name: 'client',
13
+ description: `Clients to clean up: ${CLIENTS.map((c) => c.id).join(', ')}. Defaults to every configured client.`,
14
+ variadic: true,
15
+ },
16
+ ],
17
+ flags: {
18
+ scope: {
19
+ type: 'string',
20
+ choices: ['user', 'project'],
21
+ default: 'user',
22
+ placeholder: '<scope>',
23
+ description: 'Which installation to remove.',
24
+ },
25
+ },
26
+
27
+ async run(ctx, input) {
28
+ const scope = /** @type {import('./clients.js').Scope} */ (input.flags.scope);
29
+ const project = ctx.project();
30
+
31
+ const configured = detectClients(ctx, scope, project.root).filter(
32
+ (state) =>
33
+ state.server !== null &&
34
+ (input.positionals.length === 0 || input.positionals.includes(state.client.id)),
35
+ );
36
+
37
+ ctx.ui.intro('octane mcp remove');
38
+
39
+ if (configured.length === 0) {
40
+ ctx.ui.outro(`No ${scope}-scope client has an "${SERVER_NAME}" server.`);
41
+ return { json: { ok: true, removed: [] } };
42
+ }
43
+
44
+ if (ctx.dryRun) {
45
+ for (const state of configured) {
46
+ const args = state.cliAvailable ? state.client.cliRemoveArgs(SERVER_NAME, scope) : null;
47
+ ctx.ui.note(`${state.client.label} (${scope})`, [
48
+ args
49
+ ? `would run: ${state.client.cli} ${args.join(' ')}`
50
+ : `would rewrite: ${state.configPath}`,
51
+ ]);
52
+ }
53
+ return {
54
+ json: { ok: true, dryRun: true, removed: configured.map((state) => state.client.id) },
55
+ };
56
+ }
57
+
58
+ const confirmed = await ctx.ui.confirm({
59
+ message: `Remove "${SERVER_NAME}" from ${configured.map((s) => s.client.label).join(', ')}?`,
60
+ flag: '--yes',
61
+ initial: true,
62
+ });
63
+ if (!confirmed) return { exitCode: EXIT.OK, json: { ok: true, removed: [] } };
64
+
65
+ /** @type {{ client: string, method: string, target: string, ok: boolean, message: string }[]} */
66
+ const removed = [];
67
+
68
+ for (const state of configured) {
69
+ const { client } = state;
70
+ const args =
71
+ client.cli && state.cliAvailable ? client.cliRemoveArgs(SERVER_NAME, scope) : null;
72
+
73
+ if (client.cli && args) {
74
+ const result = await ctx.exec.run(client.cli, args, { cwd: ctx.cwd });
75
+ removed.push({
76
+ client: client.id,
77
+ method: 'cli',
78
+ target: client.cli,
79
+ ok: result.code === 0,
80
+ message: result.code === 0 ? 'Removed' : (result.stderr || result.stdout).trim(),
81
+ });
82
+ continue;
83
+ }
84
+
85
+ const current = readConfigText(state.configPath);
86
+ copyFileSync(state.configPath, `${state.configPath}.octane-backup`);
87
+ writeFileSync(state.configPath, client.remove(current, SERVER_NAME));
88
+ removed.push({
89
+ client: client.id,
90
+ method: 'file',
91
+ target: state.configPath,
92
+ ok: true,
93
+ message: 'Removed',
94
+ });
95
+ }
96
+
97
+ for (const entry of removed) {
98
+ const mark = entry.ok ? ctx.ui.colors.green('✔') : ctx.ui.colors.red('✖');
99
+ ctx.ui.log(` ${mark} ${entry.client} ${ctx.ui.colors.dim(entry.message)}`);
100
+ }
101
+
102
+ const ok = removed.every((entry) => entry.ok);
103
+ return { exitCode: ok ? EXIT.OK : EXIT.FAILURE, json: { ok, removed } };
104
+ },
105
+ });
@@ -0,0 +1,46 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readJsonc } from '../../kernel/jsonc.js';
4
+
5
+ export const SERVER_NAME = 'octane';
6
+ export const SERVER_PACKAGE = '@octanejs/mcp-server';
7
+
8
+ /**
9
+ * @typedef {Object} ServerEntry
10
+ * @property {string} command
11
+ * @property {string[]} args
12
+ * @property {Record<string, string>} [env]
13
+ */
14
+
15
+ /**
16
+ * Walk up for an octane monorepo checkout.
17
+ *
18
+ * The MCP server registers its maintainer tools only when `OCTANE_REPO_ROOT`
19
+ * points at a checkout, so installing from inside one should wire that up
20
+ * rather than silently offering half the toolset.
21
+ *
22
+ * @param {string} from
23
+ * @returns {string | null}
24
+ */
25
+ export function findRepoRoot(from) {
26
+ let dir = path.resolve(from);
27
+ for (;;) {
28
+ const manifest = path.join(dir, 'packages', 'octane', 'package.json');
29
+ if (existsSync(manifest) && readJsonc(manifest)?.name === 'octane') return dir;
30
+ const parent = path.dirname(dir);
31
+ if (parent === dir) return null;
32
+ dir = parent;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {{ command?: string, repoRoot?: string | null }} [options]
38
+ * @returns {ServerEntry}
39
+ */
40
+ export function buildServerEntry({ command, repoRoot } = {}) {
41
+ /** @type {ServerEntry} */
42
+ const entry = command ? { command, args: [] } : { command: 'npx', args: ['-y', SERVER_PACKAGE] };
43
+
44
+ if (repoRoot) entry.env = { OCTANE_REPO_ROOT: repoRoot };
45
+ return entry;
46
+ }
@@ -0,0 +1,48 @@
1
+ import path from 'node:path';
2
+ import { defineCommand } from '../../kernel/command.js';
3
+ import { detectClients } from './detect.js';
4
+ import { SERVER_NAME } from './server.js';
5
+
6
+ /** @type {import('./clients.js').Scope[]} */
7
+ const SCOPES = ['user', 'project'];
8
+
9
+ export default defineCommand({
10
+ description: `Show which agents have the "${SERVER_NAME}" MCP server registered.`,
11
+
12
+ async run(ctx) {
13
+ const project = ctx.project();
14
+
15
+ const rows = SCOPES.flatMap((scope) =>
16
+ detectClients(ctx, scope, project.root).map((state) => ({
17
+ client: state.client.id,
18
+ label: state.client.label,
19
+ scope,
20
+ configured: state.server !== null,
21
+ cliAvailable: state.cliAvailable,
22
+ configPath: state.configPath,
23
+ configExists: state.configExists,
24
+ })),
25
+ );
26
+
27
+ ctx.ui.intro('octane mcp status');
28
+ const width = rows.reduce((max, row) => Math.max(max, row.label.length), 0);
29
+
30
+ for (const row of rows) {
31
+ const mark = row.configured ? ctx.ui.colors.green('✔') : ctx.ui.colors.dim('○');
32
+ const where = row.configExists ? path.normalize(row.configPath) : 'no config file';
33
+ const detail = row.configured ? where : row.cliAvailable ? `installed, ${where}` : where;
34
+ ctx.ui.log(
35
+ ` ${mark} ${row.label.padEnd(width)} ${row.scope.padEnd(7)} ${ctx.ui.colors.dim(detail)}`,
36
+ );
37
+ }
38
+
39
+ const configured = rows.filter((row) => row.configured).length;
40
+ ctx.ui.outro(
41
+ configured === 0
42
+ ? 'Not registered anywhere yet. Run `octane mcp add`.'
43
+ : `Registered in ${configured} place(s).`,
44
+ );
45
+
46
+ return { json: { server: SERVER_NAME, clients: rows } };
47
+ },
48
+ });
@@ -0,0 +1,74 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * The binding and error-code catalogs, snapshotted from the octane repository
5
+ * at publish time by `scripts/generate-cli-data.mjs`. Read from disk rather
6
+ * than imported so the module works identically under Node, vitest, and any
7
+ * bundler.
8
+ */
9
+ export const DATA = JSON.parse(
10
+ readFileSync(new URL('./octane-data.json', import.meta.url), 'utf8'),
11
+ );
12
+
13
+ /** Matches `formatProdErrorMessage` in packages/octane/src/error-message.ts. */
14
+ export const ERROR_DOCS_URL = 'https://octanejs.dev/errors/';
15
+
16
+ /**
17
+ * @typedef {Object} Binding
18
+ * @property {string} name
19
+ * @property {string} version
20
+ * @property {string} category
21
+ * @property {{ package: string, version: string } | null} upstream
22
+ * @property {string} surface
23
+ * @property {string[]} divergences
24
+ * @property {string} ssr
25
+ * @property {string | null} verified
26
+ */
27
+
28
+ /** @type {Binding[]} */
29
+ export const BINDINGS = DATA.bindings;
30
+
31
+ /**
32
+ * Resolve what a user asked for to the binding that provides it.
33
+ *
34
+ * Accepts the binding's own name, the React package it ports, or any upstream
35
+ * package recorded in that binding's status.json.
36
+ *
37
+ * @param {string} name
38
+ * @returns {{ binding: Binding, via: 'binding' | 'react-package' } | null}
39
+ */
40
+ export function resolveBinding(name) {
41
+ const direct = BINDINGS.find((binding) => binding.name === name);
42
+ if (direct) return { binding: direct, via: 'binding' };
43
+
44
+ const mapped = DATA.reactPackages[name];
45
+ const fromMap = mapped && BINDINGS.find((binding) => binding.name === mapped);
46
+ if (fromMap) return { binding: fromMap, via: 'react-package' };
47
+
48
+ const upstream = BINDINGS.find((binding) => binding.upstream?.package === name);
49
+ return upstream ? { binding: upstream, via: 'react-package' } : null;
50
+ }
51
+
52
+ /**
53
+ * @param {string} query
54
+ * @param {{ surface?: boolean }} [options] search the prose too. Useful for an
55
+ * explicit `octane bindings <query>`, but too loose for "did you mean",
56
+ * where matching a word buried in a paragraph suggests unrelated packages.
57
+ * @returns {Binding[]}
58
+ */
59
+ export function searchBindings(query, { surface = true } = {}) {
60
+ const needle = query.toLowerCase();
61
+ if (needle === '') return [];
62
+
63
+ return BINDINGS.filter((binding) =>
64
+ [
65
+ binding.name,
66
+ binding.category,
67
+ binding.upstream?.package ?? '',
68
+ surface ? binding.surface : '',
69
+ ]
70
+ .join(' ')
71
+ .toLowerCase()
72
+ .includes(needle),
73
+ );
74
+ }