@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,277 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { defineCommand } from '../../kernel/command.js';
4
+ import { setCompilerOption } from '../../kernel/edit.js';
5
+ import { CliError, EXIT } from '../../kernel/errors.js';
6
+ import { installPackages } from '../../kernel/install.js';
7
+ import {
8
+ MODES,
9
+ SCRIPTS,
10
+ VITE_SCRIPTS,
11
+ appComponent,
12
+ integrationFor,
13
+ octaneConfig,
14
+ tsconfig,
15
+ viteConfig,
16
+ } from './templates.js';
17
+
18
+ /**
19
+ * @typedef {Object} Change
20
+ * @property {string} file relative to the project root
21
+ * @property {string} summary
22
+ * @property {() => void} apply
23
+ */
24
+
25
+ /**
26
+ * Refuse to rewrite a project with uncommitted work in it, so `git diff` stays
27
+ * a usable way to review what init did.
28
+ *
29
+ * @param {import('../../kernel/context.js').Ctx} ctx
30
+ * @returns {Promise<boolean>} true when the tree is dirty
31
+ */
32
+ async function isDirty(ctx) {
33
+ if (!ctx.exec.which('git')) return false;
34
+ const result = await ctx.exec.run('git', ['status', '--porcelain'], { cwd: ctx.cwd });
35
+ return result.code === 0 && result.stdout.trim() !== '';
36
+ }
37
+
38
+ /**
39
+ * @param {string} file
40
+ * @param {string} body
41
+ * @returns {() => void}
42
+ */
43
+ const writeFile = (file, body) => () => {
44
+ mkdirSync(path.dirname(file), { recursive: true });
45
+ writeFileSync(file, body);
46
+ };
47
+
48
+ /**
49
+ * @param {import('../../kernel/project.js').Project} project
50
+ * @param {keyof typeof MODES} mode
51
+ * @param {ReturnType<typeof integrationFor>} integration
52
+ * @returns {{ changes: Change[], manual: string[] }}
53
+ */
54
+ function plan(project, mode, integration) {
55
+ const { root } = project;
56
+ /** @type {Change[]} */
57
+ const changes = [];
58
+ /** @type {string[]} */
59
+ const manual = [];
60
+ const at = (/** @type {string} */ file) => path.join(root, file);
61
+
62
+ if (!project.tsconfig) {
63
+ changes.push({
64
+ file: 'tsconfig.json',
65
+ summary: 'create, configured for .tsrx',
66
+ apply: writeFile(at('tsconfig.json'), `${JSON.stringify(tsconfig, null, 2)}\n`),
67
+ });
68
+ } else {
69
+ const file = project.tsconfig.path;
70
+ const options = project.tsconfig.config.compilerOptions ?? {};
71
+ /** @type {[string, unknown][]} */
72
+ const needed = [];
73
+ if (options.jsxImportSource !== 'octane') needed.push(['jsxImportSource', 'octane']);
74
+ if (!Array.isArray(options.plugins))
75
+ needed.push(['plugins', [{ name: '@tsrx/typescript-plugin' }]]);
76
+ else if (
77
+ !options.plugins.some((/** @type {any} */ p) => p?.name === '@tsrx/typescript-plugin')
78
+ ) {
79
+ manual.push('Add { "name": "@tsrx/typescript-plugin" } to compilerOptions.plugins.');
80
+ }
81
+
82
+ if (needed.length > 0) {
83
+ changes.push({
84
+ file: path.relative(root, file),
85
+ summary: `set ${needed.map(([key]) => key).join(', ')}`,
86
+ apply() {
87
+ let text = readFileSync(file, 'utf8');
88
+ for (const [key, value] of needed) {
89
+ const edited = setCompilerOption(text, key, value);
90
+ if (!edited) throw new CliError(`Could not edit ${path.basename(file)} safely.`);
91
+ text = edited.text;
92
+ }
93
+ writeFileSync(file, text);
94
+ },
95
+ });
96
+ }
97
+ }
98
+
99
+ const bundlerConfigPath = project.bundlerConfigPath ?? at('vite.config.ts');
100
+ if (!project.bundlerConfigPath) {
101
+ changes.push({
102
+ file: 'vite.config.ts',
103
+ summary: `create, registering ${integration.specifier}`,
104
+ apply: writeFile(bundlerConfigPath, viteConfig(mode)),
105
+ });
106
+ } else if (!readFileSync(bundlerConfigPath, 'utf8').includes(integration.specifier)) {
107
+ // Rewriting an arbitrary bundler config is guesswork; state the exact
108
+ // edit instead of attempting it. The specifier is the one for THIS
109
+ // project's bundler, not the Vite plugin init would otherwise scaffold.
110
+ manual.push(
111
+ `In ${path.relative(root, bundlerConfigPath)}, add: import { octane } from '${integration.specifier}';`,
112
+ 'then include octane() in the plugins array.',
113
+ );
114
+ }
115
+
116
+ // init only knows how to scaffold Vite. On another bundler it still does the
117
+ // parts that are correct everywhere (tsconfig, the right plugin, the right
118
+ // dependency) and states the rest, rather than writing an octane.config.ts
119
+ // whose import does not resolve or `vite` scripts with no vite installed.
120
+ // @octanejs/rspack-plugin in particular exports no defineConfig/RenderRoute:
121
+ // it is the low-level integration where the app owns its own shell.
122
+ const scaffoldsVite = project.bundler === null || project.bundler === 'vite';
123
+
124
+ if (mode === 'fullstack' && !scaffoldsVite) {
125
+ manual.push(
126
+ `Add an octane.config.ts for ${project.bundler}: see https://octanejs.dev/docs/build-tools`,
127
+ );
128
+ }
129
+
130
+ if (mode === 'fullstack' && scaffoldsVite) {
131
+ if (!project.octaneConfigPath) {
132
+ changes.push({
133
+ file: 'octane.config.ts',
134
+ summary: 'create, with one route at /',
135
+ apply: writeFile(at('octane.config.ts'), octaneConfig),
136
+ });
137
+ }
138
+ if (!existsSync(at('src/App.tsrx'))) {
139
+ changes.push({
140
+ file: 'src/App.tsrx',
141
+ summary: 'create the route entry referenced by octane.config.ts',
142
+ apply: writeFile(at('src/App.tsrx'), appComponent),
143
+ });
144
+ }
145
+ }
146
+
147
+ const scripts = project.manifest.scripts ?? {};
148
+ const applicable = scaffoldsVite ? { ...VITE_SCRIPTS, ...SCRIPTS } : SCRIPTS;
149
+ const missing = Object.entries(applicable).filter(([name]) => !scripts[name]);
150
+ if (missing.length > 0) {
151
+ changes.push({
152
+ file: 'package.json',
153
+ summary: `add scripts: ${missing.map(([name]) => name).join(', ')}`,
154
+ apply() {
155
+ const manifest = JSON.parse(readFileSync(at('package.json'), 'utf8'));
156
+ manifest.scripts = { ...manifest.scripts, ...Object.fromEntries(missing) };
157
+ writeFileSync(at('package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
158
+ },
159
+ });
160
+ }
161
+
162
+ return { changes, manual };
163
+ }
164
+
165
+ export default defineCommand({
166
+ requiresProject: true,
167
+ description:
168
+ 'Wire Octane into the project in this directory: the bundler plugin, the TypeScript\n' +
169
+ 'settings .tsrx needs, the scripts, and the dependencies.',
170
+ flags: {
171
+ mode: {
172
+ type: 'string',
173
+ choices: Object.keys(MODES),
174
+ placeholder: '<mode>',
175
+ description: 'spa for a client-only app, fullstack for routing and SSR.',
176
+ },
177
+ install: {
178
+ type: 'boolean',
179
+ default: true,
180
+ description: 'Install dependencies (--no-install skips).',
181
+ },
182
+ force: { type: 'boolean', description: 'Proceed even with uncommitted changes.' },
183
+ },
184
+
185
+ async run(ctx, input) {
186
+ const project = ctx.project();
187
+
188
+ if (!input.flags.force && !ctx.dryRun && (await isDirty(ctx))) {
189
+ throw new CliError('This project has uncommitted changes.', {
190
+ hint: 'Commit or stash first so you can review what init writes, or pass --force.',
191
+ });
192
+ }
193
+
194
+ const mode = /** @type {keyof typeof MODES} */ (
195
+ input.flags.mode ??
196
+ (await ctx.ui.select({
197
+ message: 'What are you building?',
198
+ flag: '--mode',
199
+ initial: 'spa',
200
+ options: Object.entries(MODES).map(([value, entry]) => ({
201
+ value,
202
+ label: entry.label,
203
+ hint: entry.hint,
204
+ })),
205
+ }))
206
+ );
207
+
208
+ const integration = integrationFor(project.bundler, mode);
209
+ const { changes, manual } = plan(project, mode, integration);
210
+ const declared = project.declaredDependencies;
211
+ const dependencies = integration.dependencies.filter((name) => !declared[name]);
212
+ const devDependencies = integration.devDependencies.filter((name) => !declared[name]);
213
+
214
+ ctx.ui.intro('octane init');
215
+
216
+ if (changes.length === 0 && dependencies.length === 0 && devDependencies.length === 0) {
217
+ ctx.ui.outro('Already set up. Run `octane doctor` to confirm.');
218
+ return { json: { ok: true, mode, changes: [], installed: [] } };
219
+ }
220
+
221
+ ctx.ui.note(
222
+ ctx.dryRun ? 'Would change' : 'Will change',
223
+ changes.length > 0
224
+ ? changes.map((c) => `${c.file} ${ctx.ui.colors.dim(c.summary)}`)
225
+ : ['nothing'],
226
+ );
227
+ if (dependencies.length + devDependencies.length > 0) {
228
+ ctx.ui.note('Will install', [
229
+ ...dependencies,
230
+ ...devDependencies.map((name) => `${name} (dev)`),
231
+ ]);
232
+ }
233
+
234
+ if (ctx.dryRun) {
235
+ return {
236
+ json: { ok: true, dryRun: true, mode, changes: changes.map((c) => c.file), manual },
237
+ };
238
+ }
239
+
240
+ const confirmed = await ctx.ui.confirm({ message: 'Apply?', flag: '--yes', initial: true });
241
+ if (!confirmed)
242
+ return { exitCode: EXIT.OK, json: { ok: true, mode, changes: [], installed: [] } };
243
+
244
+ for (const change of changes) change.apply();
245
+
246
+ /** @type {string[]} */
247
+ const installed = [];
248
+ if (input.flags.install && dependencies.length + devDependencies.length > 0) {
249
+ const spinner = ctx.ui.spinner(`Installing with ${project.packageManager ?? 'npm'}`);
250
+ try {
251
+ for (const [names, dev] of [
252
+ [dependencies, false],
253
+ [devDependencies, true],
254
+ ]) {
255
+ if (/** @type {string[]} */ (names).length === 0) continue;
256
+ await installPackages(ctx, project, /** @type {string[]} */ (names), {
257
+ dev: Boolean(dev),
258
+ });
259
+ installed.push(.../** @type {string[]} */ (names));
260
+ }
261
+ } catch (error) {
262
+ spinner.stop('Install failed');
263
+ throw error;
264
+ }
265
+ spinner.stop(`Installed ${installed.length} package(s)`);
266
+ } else if (dependencies.length + devDependencies.length > 0) {
267
+ manual.push(`Install: ${[...dependencies, ...devDependencies].join(' ')}`);
268
+ }
269
+
270
+ if (manual.length > 0) ctx.ui.note('Do this by hand', manual);
271
+ ctx.ui.outro('Run `octane doctor` to verify the result.');
272
+
273
+ return {
274
+ json: { ok: true, mode, changes: changes.map((c) => c.file), installed, manual },
275
+ };
276
+ },
277
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The two supported ways to compile Octane.
3
+ *
4
+ * `spa` wires the compiler plugin only, which is all a client-rendered app
5
+ * needs. `fullstack` wires the metaframework plugin, which adds routing,
6
+ * streaming SSR, hydration, and the production build.
7
+ */
8
+ export const MODES = {
9
+ spa: {
10
+ label: 'Client-only app',
11
+ hint: "compiles .tsrx, keeps the bundler's standard HTML handling",
12
+ dependencies: [],
13
+ },
14
+ fullstack: {
15
+ label: 'Routing and SSR',
16
+ hint: 'adds octane.config.ts, streaming SSR, hydration, production build',
17
+ dependencies: [],
18
+ },
19
+ };
20
+
21
+ /**
22
+ * Which package supplies the Octane plugin, per bundler and mode.
23
+ *
24
+ * `init` only scaffolds a Vite project, but it also advises projects that
25
+ * already have a bundler config, and naming the Vite plugin at an Rspack
26
+ * project would wire the wrong plugin into the wrong bundler.
27
+ */
28
+ const INTEGRATIONS = {
29
+ vite: {
30
+ spa: { specifier: 'octane/compiler/vite', packages: [] },
31
+ fullstack: { specifier: '@octanejs/vite-plugin', packages: ['@octanejs/vite-plugin'] },
32
+ },
33
+ rspack: {
34
+ spa: { specifier: 'octane/compiler/bundler', packages: [] },
35
+ fullstack: { specifier: '@octanejs/rspack-plugin', packages: ['@octanejs/rspack-plugin'] },
36
+ },
37
+ rsbuild: {
38
+ spa: { specifier: 'octane/compiler/bundler', packages: [] },
39
+ fullstack: { specifier: '@octanejs/rsbuild-plugin', packages: ['@octanejs/rsbuild-plugin'] },
40
+ },
41
+ rspeedy: {
42
+ spa: { specifier: 'octane/compiler/bundler', packages: [] },
43
+ fullstack: { specifier: '@octanejs/rspeedy-plugin', packages: ['@octanejs/rspeedy-plugin'] },
44
+ },
45
+ };
46
+
47
+ /**
48
+ * @param {keyof typeof INTEGRATIONS | null} bundler defaults to vite, which is
49
+ * what `init` scaffolds when a project has no bundler config yet
50
+ * @param {keyof typeof MODES} mode
51
+ * @returns {{ specifier: string, dependencies: string[], devDependencies: string[] }}
52
+ */
53
+ export function integrationFor(bundler, mode) {
54
+ const target = INTEGRATIONS[bundler ?? 'vite'] ?? INTEGRATIONS.vite;
55
+ return {
56
+ specifier: target[mode].specifier,
57
+ dependencies: ['octane', ...target[mode].packages],
58
+ // The bundler itself is only ours to install when we are the ones
59
+ // creating its config.
60
+ devDependencies: ['@tsrx/typescript-plugin', ...(bundler === null ? ['vite'] : [])],
61
+ };
62
+ }
63
+
64
+ /**
65
+ * @param {keyof typeof MODES} mode
66
+ * @returns {string}
67
+ */
68
+ export const viteConfig = (mode) => `import { defineConfig } from 'vite';
69
+ import { octane } from '${integrationFor('vite', mode).specifier}';
70
+
71
+ export default defineConfig({
72
+ \tplugins: [octane()],
73
+ \tbuild: { target: 'esnext' },
74
+ });
75
+ `;
76
+
77
+ export const octaneConfig = `import { defineConfig, RenderRoute } from '@octanejs/vite-plugin';
78
+
79
+ export default defineConfig({
80
+ \trouter: {
81
+ \t\troutes: [new RenderRoute({ path: '/', entry: ['App', '/src/App.tsrx'] })],
82
+ \t},
83
+ });
84
+ `;
85
+
86
+ export const appComponent = `export function App() @{
87
+ \t<div class="app">
88
+ \t\t<h1>{'Hello from Octane' as string}</h1>
89
+ \t</div>
90
+ }
91
+ `;
92
+
93
+ export const tsconfig = {
94
+ compilerOptions: {
95
+ target: 'esnext',
96
+ module: 'esnext',
97
+ moduleResolution: 'bundler',
98
+ lib: ['esnext', 'dom', 'dom.iterable'],
99
+ strict: true,
100
+ noEmit: true,
101
+ allowImportingTsExtensions: true,
102
+ isolatedModules: true,
103
+ esModuleInterop: true,
104
+ skipLibCheck: true,
105
+ jsx: 'react-jsx',
106
+ jsxImportSource: 'octane',
107
+ plugins: [{ name: '@tsrx/typescript-plugin' }],
108
+ },
109
+ include: ['src/**/*', 'vite.config.ts'],
110
+ };
111
+
112
+ /**
113
+ * Scripts that are correct whatever the bundler is. `tsrx-tsc` is the only
114
+ * typechecker that reads `.tsrx`, regardless of how the project builds.
115
+ */
116
+ export const SCRIPTS = {
117
+ typecheck: 'tsrx-tsc --noEmit -p tsconfig.json',
118
+ };
119
+
120
+ /** Added only when init is the one creating the Vite config. */
121
+ export const VITE_SCRIPTS = {
122
+ dev: 'vite',
123
+ build: 'vite build',
124
+ };
@@ -0,0 +1,241 @@
1
+ import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { defineCommand } from '../../kernel/command.js';
4
+ import { CliError, EXIT, usageError } from '../../kernel/errors.js';
5
+ import { CLIENTS, findClient, readConfigText } from './clients.js';
6
+ import { detectClients, isPresent } from './detect.js';
7
+ import { SERVER_NAME, SERVER_PACKAGE, buildServerEntry, findRepoRoot } from './server.js';
8
+
9
+ /**
10
+ * @typedef {Object} Outcome
11
+ * @property {string} client
12
+ * @property {string} scope
13
+ * @property {'cli' | 'file'} method
14
+ * @property {string} target
15
+ * @property {'added' | 'updated' | 'unchanged' | 'skipped' | 'failed'} status
16
+ * @property {string} message
17
+ */
18
+
19
+ /**
20
+ * Install by handing the work to the client's own CLI, which owns its config
21
+ * schema and can change it without telling us.
22
+ *
23
+ * @param {import('../../kernel/context.js').Ctx} ctx
24
+ * @param {import('./detect.js').ClientState} state
25
+ * @param {import('./server.js').ServerEntry} entry
26
+ * @returns {Promise<Outcome | null>} null when the CLI route is unavailable
27
+ */
28
+ async function installViaCli(ctx, state, entry) {
29
+ const { client } = state;
30
+ const args =
31
+ client.cli && state.cliAvailable ? client.cliArgs(SERVER_NAME, entry, state.scope) : null;
32
+ if (!client.cli || !args) return null;
33
+
34
+ const result = await ctx.exec.run(client.cli, args, { cwd: ctx.cwd });
35
+ const base = {
36
+ client: client.id,
37
+ scope: state.scope,
38
+ method: /** @type {const} */ ('cli'),
39
+ target: client.cli,
40
+ };
41
+
42
+ if (result.code !== 0) {
43
+ return {
44
+ ...base,
45
+ status: 'failed',
46
+ message:
47
+ (result.stderr || result.stdout).trim() || `${client.cli} exited with ${result.code}`,
48
+ };
49
+ }
50
+ return {
51
+ ...base,
52
+ status: state.server ? 'updated' : 'added',
53
+ message: `${client.cli} ${args.join(' ')}`,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * @param {import('../../kernel/context.js').Ctx} ctx
59
+ * @param {import('./detect.js').ClientState} state
60
+ * @param {import('./server.js').ServerEntry} entry
61
+ * @returns {Outcome}
62
+ */
63
+ function installViaFile(ctx, state, entry) {
64
+ const { client, configPath } = state;
65
+ const current = readConfigText(configPath);
66
+ const next = client.write(current, SERVER_NAME, entry);
67
+ const base = {
68
+ client: client.id,
69
+ scope: state.scope,
70
+ method: /** @type {const} */ ('file'),
71
+ target: configPath,
72
+ };
73
+
74
+ if (next === current) return { ...base, status: 'unchanged', message: 'Already up to date' };
75
+
76
+ mkdirSync(path.dirname(configPath), { recursive: true });
77
+ // Back up anything that was already there. These files hold the user's other
78
+ // servers and, for Claude Code, unrelated state.
79
+ if (current !== '') copyFileSync(configPath, `${configPath}.octane-backup`);
80
+ writeFileSync(configPath, next);
81
+
82
+ return {
83
+ ...base,
84
+ status: state.server ? 'updated' : 'added',
85
+ message:
86
+ current === '' ? 'Created' : `Merged (backup at ${path.basename(configPath)}.octane-backup)`,
87
+ };
88
+ }
89
+
90
+ export default defineCommand({
91
+ description:
92
+ `Register the Octane MCP server (${SERVER_PACKAGE}) with a coding agent, so it can\n` +
93
+ 'reach the Octane skills, bindings data, and React-to-Octane bridging tools.',
94
+ positionals: [
95
+ {
96
+ name: 'client',
97
+ description: `Clients to configure: ${CLIENTS.map((c) => c.id).join(', ')}. Defaults to every detected client.`,
98
+ variadic: true,
99
+ },
100
+ ],
101
+ flags: {
102
+ scope: {
103
+ type: 'string',
104
+ choices: ['user', 'project'],
105
+ default: 'user',
106
+ placeholder: '<scope>',
107
+ description: 'Install for this user, or into this project so the team shares it.',
108
+ },
109
+ command: {
110
+ type: 'string',
111
+ placeholder: '<bin>',
112
+ description: `Run the server this way instead of \`npx -y ${SERVER_PACKAGE}\`.`,
113
+ },
114
+ force: { type: 'boolean', description: 'Overwrite an existing octane entry without asking.' },
115
+ },
116
+
117
+ async run(ctx, input) {
118
+ const scope = /** @type {import('./clients.js').Scope} */ (input.flags.scope);
119
+ const project = ctx.project();
120
+ const detected = detectClients(ctx, scope, project.root);
121
+
122
+ const selected =
123
+ input.positionals.length > 0
124
+ ? resolveRequested(input.positionals, detected)
125
+ : await chooseClients(ctx, detected);
126
+
127
+ if (selected.length === 0) throw new CliError('No clients selected.');
128
+
129
+ const repoRoot = findRepoRoot(ctx.cwd);
130
+ const entry = buildServerEntry({ command: input.flags.command, repoRoot });
131
+
132
+ ctx.ui.intro('octane mcp add');
133
+ if (repoRoot)
134
+ ctx.ui.log(ctx.ui.colors.dim(`Detected an Octane checkout: OCTANE_REPO_ROOT=${repoRoot}`));
135
+
136
+ if (ctx.dryRun) {
137
+ for (const state of selected) {
138
+ ctx.ui.note(`${state.client.label} (${scope})`, [
139
+ state.cliAvailable && state.client.cli
140
+ ? `would run: ${state.client.cli} ${state.client.cliArgs(SERVER_NAME, entry, scope)?.join(' ')}`
141
+ : `would write: ${state.configPath}`,
142
+ ...JSON.stringify({ [SERVER_NAME]: entry }, null, 2).split('\n'),
143
+ ]);
144
+ }
145
+ return { json: { ok: true, dryRun: true, clients: selected.map((s) => s.client.id) } };
146
+ }
147
+
148
+ /** @type {Outcome[]} */
149
+ const outcomes = [];
150
+ for (const state of selected) {
151
+ const existing = state.server && !input.flags.force;
152
+ if (existing) {
153
+ const replace = await ctx.ui.confirm({
154
+ message: `${state.client.label} already has an "${SERVER_NAME}" server. Replace it?`,
155
+ flag: '--force',
156
+ initial: false,
157
+ });
158
+ if (!replace) {
159
+ const viaCli = state.cliAvailable && state.client.cli;
160
+ outcomes.push({
161
+ client: state.client.id,
162
+ scope,
163
+ method: viaCli ? 'cli' : 'file',
164
+ target: viaCli ? /** @type {string} */ (state.client.cli) : state.configPath,
165
+ status: 'skipped',
166
+ message: 'Left the existing entry in place',
167
+ });
168
+ continue;
169
+ }
170
+ }
171
+ outcomes.push((await installViaCli(ctx, state, entry)) ?? installViaFile(ctx, state, entry));
172
+ }
173
+
174
+ for (const outcome of outcomes) {
175
+ const failed = outcome.status === 'failed';
176
+ const mark = failed ? ctx.ui.colors.red('✖') : ctx.ui.colors.green('✔');
177
+ ctx.ui.log(
178
+ ` ${mark} ${outcome.client} (${outcome.scope}) ${ctx.ui.colors.dim(outcome.message)}`,
179
+ );
180
+ }
181
+ ctx.ui.outro('Restart the agent to pick up the new server.');
182
+
183
+ const ok = outcomes.every((outcome) => outcome.status !== 'failed');
184
+ return { exitCode: ok ? EXIT.OK : EXIT.FAILURE, json: { ok, server: entry, outcomes } };
185
+ },
186
+ });
187
+
188
+ /**
189
+ * @param {string[]} ids
190
+ * @param {import('./detect.js').ClientState[]} detected
191
+ * @returns {import('./detect.js').ClientState[]}
192
+ */
193
+ function resolveRequested(ids, detected) {
194
+ return ids.map((id) => {
195
+ if (!findClient(id)) {
196
+ throw usageError(
197
+ `Unknown client: ${id}`,
198
+ `Known clients: ${CLIENTS.map((c) => c.id).join(', ')}.`,
199
+ );
200
+ }
201
+ const state = detected.find((candidate) => candidate.client.id === id);
202
+ if (!state) {
203
+ throw usageError(
204
+ `${id} does not support --scope ${detected[0]?.scope ?? 'this scope'}.`,
205
+ `${id} supports: ${findClient(id)?.scopes.join(', ')}.`,
206
+ );
207
+ }
208
+ return state;
209
+ });
210
+ }
211
+
212
+ /**
213
+ * @param {import('../../kernel/context.js').Ctx} ctx
214
+ * @param {import('./detect.js').ClientState[]} detected
215
+ * @returns {Promise<import('./detect.js').ClientState[]>}
216
+ */
217
+ async function chooseClients(ctx, detected) {
218
+ const present = detected.filter(isPresent);
219
+ if (present.length === 0) {
220
+ throw new CliError('No supported agent found on this machine.', {
221
+ hint: `Name one explicitly: octane mcp add ${CLIENTS.map((c) => c.id).join('|')}`,
222
+ });
223
+ }
224
+
225
+ const chosen = await ctx.ui.multiselect({
226
+ message: 'Which agents should get the Octane MCP server?',
227
+ flag: '<client>',
228
+ initial: present.map((state) => state.client.id),
229
+ options: present.map((state) => ({
230
+ value: state.client.id,
231
+ label: state.client.label,
232
+ hint: state.server
233
+ ? 'already configured'
234
+ : state.cliAvailable
235
+ ? 'via its CLI'
236
+ : state.configPath,
237
+ })),
238
+ });
239
+
240
+ return present.filter((state) => chosen.includes(state.client.id));
241
+ }