@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,42 @@
1
+ /**
2
+ * Exit codes are part of the CLI's contract: CI and agents branch on them, so
3
+ * they are fixed here rather than invented per command.
4
+ *
5
+ * - `OK` nothing to report
6
+ * - `FAILURE` the command ran and failed
7
+ * - `USAGE` the invocation was wrong (unknown flag, missing input)
8
+ * - `DIAGNOSTIC` the command ran fine and reported problems (doctor findings)
9
+ */
10
+ export const EXIT = {
11
+ OK: 0,
12
+ FAILURE: 1,
13
+ USAGE: 2,
14
+ DIAGNOSTIC: 3,
15
+ };
16
+
17
+ /**
18
+ * An error with a presentation. Anything thrown as a `CliError` is rendered as
19
+ * a message the caller can act on; anything else is an internal bug and is
20
+ * reported with its stack.
21
+ */
22
+ export class CliError extends Error {
23
+ /**
24
+ * @param {string} message
25
+ * @param {{ exitCode?: number, hint?: string }} [options]
26
+ */
27
+ constructor(message, options = {}) {
28
+ super(message);
29
+ this.name = 'CliError';
30
+ this.exitCode = options.exitCode ?? EXIT.FAILURE;
31
+ this.hint = options.hint;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {string} message
37
+ * @param {string} [hint]
38
+ * @returns {CliError}
39
+ */
40
+ export function usageError(message, hint) {
41
+ return new CliError(message, { exitCode: EXIT.USAGE, hint });
42
+ }
@@ -0,0 +1,62 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { accessSync, constants } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * @typedef {Object} ExecResult
7
+ * @property {number} code
8
+ * @property {string} stdout
9
+ * @property {string} stderr
10
+ */
11
+
12
+ /**
13
+ * @typedef {Object} Exec
14
+ * @property {(bin: string) => string | null} which
15
+ * @property {(file: string, args: string[], options?: { cwd?: string }) => Promise<ExecResult>} run
16
+ */
17
+
18
+ /**
19
+ * Process access, isolated behind one small interface. This is the only such
20
+ * seam in the kernel: it exists so that commands which shell out to another
21
+ * tool (`claude mcp add`, a package manager install) stay testable without
22
+ * spawning anything. Filesystem work deliberately does *not* go through an
23
+ * adapter, because fixture directories on disk test it more honestly than a
24
+ * mock would.
25
+ *
26
+ * @param {NodeJS.ProcessEnv} [env]
27
+ * @returns {Exec}
28
+ */
29
+ export function createExec(env = process.env) {
30
+ const extensions =
31
+ process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
32
+
33
+ return {
34
+ which(bin) {
35
+ for (const dir of (env.PATH ?? '').split(path.delimiter)) {
36
+ if (!dir) continue;
37
+ for (const ext of extensions) {
38
+ const candidate = path.join(dir, bin + ext);
39
+ try {
40
+ accessSync(candidate, constants.X_OK);
41
+ return candidate;
42
+ } catch {
43
+ // Not here; keep walking PATH.
44
+ }
45
+ }
46
+ }
47
+ return null;
48
+ },
49
+
50
+ run(file, args, options = {}) {
51
+ return new Promise((resolve) => {
52
+ execFile(file, args, { cwd: options.cwd, env }, (error, stdout, stderr) => {
53
+ resolve({
54
+ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
55
+ stdout: String(stdout),
56
+ stderr: String(stderr),
57
+ });
58
+ });
59
+ });
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,97 @@
1
+ import { GLOBAL_FLAGS } from './args.js';
2
+
3
+ /**
4
+ * @param {string} name
5
+ * @param {import('./args.js').FlagSpec} spec
6
+ * @returns {string}
7
+ */
8
+ function flagLabel(name, spec) {
9
+ const short = spec.short ? `-${spec.short}, ` : ' ';
10
+ const value = spec.type === 'boolean' ? '' : ` ${spec.placeholder ?? '<value>'}`;
11
+ return `${short}--${name}${value}`;
12
+ }
13
+
14
+ /**
15
+ * @param {[string, string][]} rows
16
+ * @param {(text: string) => string} accent
17
+ * @returns {string[]}
18
+ */
19
+ function table(rows, accent) {
20
+ const width = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
21
+ return rows.map(([label, description]) => ` ${accent(label.padEnd(width))} ${description}`);
22
+ }
23
+
24
+ /**
25
+ * Render help entirely from the command spec, so usage text cannot drift from
26
+ * what the parser actually accepts.
27
+ *
28
+ * @param {{
29
+ * path: string[],
30
+ * module: import('./command.js').CommandModule | null,
31
+ * entries: import('./command.js').CommandEntry[],
32
+ * colors: { bold: (s: string) => string, dim: (s: string) => string, cyan: (s: string) => string },
33
+ * }} input
34
+ * @returns {string}
35
+ */
36
+ export function renderHelp({ path, module, entries, colors }) {
37
+ const invocation = ['octane', ...path].join(' ');
38
+ const subcommands = module?.subcommands ?? (path.length === 0 ? entries : []);
39
+ const positionals = module?.positionals ?? [];
40
+
41
+ /** @type {string[]} */
42
+ const lines = [];
43
+
44
+ const argsUsage = positionals
45
+ .map((p) => (p.required ? `<${p.name}>` : `[${p.name}]`) + (p.variadic ? '...' : ''))
46
+ .join(' ');
47
+
48
+ lines.push(colors.bold('Usage'));
49
+ lines.push(
50
+ ` ${invocation}${subcommands.length > 0 ? ' <command>' : ''}${argsUsage ? ` ${argsUsage}` : ''} [options]`,
51
+ );
52
+
53
+ if (module?.description) {
54
+ lines.push('', module.description);
55
+ }
56
+
57
+ if (subcommands.length > 0) {
58
+ lines.push('', colors.bold('Commands'));
59
+ lines.push(
60
+ ...table(
61
+ subcommands.map((entry) => [entry.name, entry.summary]),
62
+ colors.cyan,
63
+ ),
64
+ );
65
+ }
66
+
67
+ if (positionals.length > 0) {
68
+ lines.push('', colors.bold('Arguments'));
69
+ lines.push(
70
+ ...table(
71
+ positionals.map((p) => [p.name, p.description]),
72
+ colors.cyan,
73
+ ),
74
+ );
75
+ }
76
+
77
+ const own = Object.entries(module?.flags ?? {});
78
+ if (own.length > 0) {
79
+ lines.push('', colors.bold('Options'));
80
+ lines.push(
81
+ ...table(
82
+ own.map(([name, spec]) => [flagLabel(name, spec), spec.description]),
83
+ colors.cyan,
84
+ ),
85
+ );
86
+ }
87
+
88
+ lines.push('', colors.bold('Global options'));
89
+ lines.push(
90
+ ...table(
91
+ Object.entries(GLOBAL_FLAGS).map(([name, spec]) => [flagLabel(name, spec), spec.description]),
92
+ colors.dim,
93
+ ),
94
+ );
95
+
96
+ return lines.join('\n');
97
+ }
@@ -0,0 +1,43 @@
1
+ import { CliError } from './errors.js';
2
+
3
+ /** How each package manager spells "add a dependency". */
4
+ const MANAGERS = {
5
+ pnpm: { add: ['add'], dev: '-D' },
6
+ npm: { add: ['install'], dev: '--save-dev' },
7
+ yarn: { add: ['add'], dev: '-D' },
8
+ bun: { add: ['add'], dev: '-d' },
9
+ };
10
+
11
+ /**
12
+ * @param {import('./project.js').Project} project
13
+ * @param {string[]} names
14
+ * @param {boolean} dev
15
+ * @returns {{ manager: string, args: string[] }}
16
+ */
17
+ export function installCommand(project, names, dev = false) {
18
+ const manager = project.packageManager ?? 'npm';
19
+ const recipe = MANAGERS[/** @type {keyof typeof MANAGERS} */ (manager)] ?? MANAGERS.npm;
20
+ return { manager, args: [...recipe.add, ...(dev ? [recipe.dev] : []), ...names] };
21
+ }
22
+
23
+ /**
24
+ * Install through the project's own package manager, which owns version
25
+ * resolution. The CLI never invents a version range.
26
+ *
27
+ * @param {import('./context.js').Ctx} ctx
28
+ * @param {import('./project.js').Project} project
29
+ * @param {string[]} names
30
+ * @param {{ dev?: boolean }} [options]
31
+ * @returns {Promise<string>} the command that ran
32
+ */
33
+ export async function installPackages(ctx, project, names, { dev = false } = {}) {
34
+ const { manager, args } = installCommand(project, names, dev);
35
+ const result = await ctx.exec.run(manager, args, { cwd: project.root });
36
+
37
+ if (result.code !== 0) {
38
+ throw new CliError(`\`${manager} ${args.join(' ')}\` failed.`, {
39
+ hint: (result.stderr || result.stdout).trim() || 'Install the packages by hand.',
40
+ });
41
+ }
42
+ return `${manager} ${args.join(' ')}`;
43
+ }
@@ -0,0 +1,91 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Parse JSON with comments and trailing commas, the dialect `tsconfig.json` and
5
+ * every editor MCP config are actually written in. Comments and commas are
6
+ * handled in one pass so that a `,` or `//` inside a string literal is never
7
+ * mistaken for syntax.
8
+ *
9
+ * @param {string} text
10
+ * @returns {unknown}
11
+ */
12
+ export function parseJsonc(text) {
13
+ let out = '';
14
+ let lastMeaningful = -1;
15
+ let i = 0;
16
+
17
+ const push = (/** @type {string} */ ch) => {
18
+ if ((ch === '}' || ch === ']') && lastMeaningful >= 0 && out[lastMeaningful] === ',') {
19
+ out = out.slice(0, lastMeaningful) + out.slice(lastMeaningful + 1);
20
+ lastMeaningful = -1;
21
+ for (let j = out.length - 1; j >= 0; j--) {
22
+ if (!/\s/.test(out[j])) {
23
+ lastMeaningful = j;
24
+ break;
25
+ }
26
+ }
27
+ }
28
+ out += ch;
29
+ if (!/\s/.test(ch)) lastMeaningful = out.length - 1;
30
+ };
31
+
32
+ while (i < text.length) {
33
+ const ch = text[i];
34
+
35
+ if (ch === '"') {
36
+ // Emit the whole literal verbatim. Nothing inside it is syntax, so a
37
+ // `//`, a `]`, or a `,` in a string value must not reach `push`.
38
+ const start = i++;
39
+ while (i < text.length) {
40
+ if (text[i] === '\\') {
41
+ i += 2;
42
+ continue;
43
+ }
44
+ if (text[i] === '"') {
45
+ i++;
46
+ break;
47
+ }
48
+ i++;
49
+ }
50
+ out += text.slice(start, i);
51
+ lastMeaningful = out.length - 1;
52
+ continue;
53
+ }
54
+
55
+ if (ch === '/' && text[i + 1] === '/') {
56
+ while (i < text.length && text[i] !== '\n') i++;
57
+ continue;
58
+ }
59
+
60
+ if (ch === '/' && text[i + 1] === '*') {
61
+ i += 2;
62
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
63
+ i += 2;
64
+ continue;
65
+ }
66
+
67
+ push(ch);
68
+ i++;
69
+ }
70
+
71
+ return JSON.parse(out);
72
+ }
73
+
74
+ /**
75
+ * @param {string} file
76
+ * @returns {any | null} `null` when the file is missing, empty, or unparseable
77
+ */
78
+ export function readJsonc(file) {
79
+ let text;
80
+ try {
81
+ text = readFileSync(file, 'utf8');
82
+ } catch {
83
+ return null;
84
+ }
85
+ if (text.trim() === '') return null;
86
+ try {
87
+ return parseJsonc(text);
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
@@ -0,0 +1,166 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parseArgs } from './args.js';
3
+ import { renderBanner } from './banner.js';
4
+ import { resolveCommand } from './command.js';
5
+ import { createContext } from './context.js';
6
+ import { CliError, EXIT, usageError } from './errors.js';
7
+ import { renderHelp } from './help.js';
8
+ import { COMMANDS } from './registry.js';
9
+
10
+ export const VERSION = JSON.parse(
11
+ readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
12
+ ).version;
13
+
14
+ /**
15
+ * @typedef {Object} RunOptions
16
+ * @property {NodeJS.ProcessEnv} [env]
17
+ * @property {NodeJS.WritableStream} [stdout]
18
+ * @property {NodeJS.WritableStream} [stderr]
19
+ * @property {boolean} [tty]
20
+ * @property {import('./exec.js').Exec} [exec]
21
+ */
22
+
23
+ /**
24
+ * Offer the commands available at `path` and dispatch the chosen one.
25
+ *
26
+ * Recursive by construction: choosing a group re-enters `dispatch`, which finds
27
+ * a module with subcommands and no `run` and calls back here. So `octane` then
28
+ * `mcp` walks into add/status/remove without the caller knowing how deep the
29
+ * tree goes.
30
+ *
31
+ * @param {import('./context.js').Ctx} ctx
32
+ * @param {RunOptions} options the original invocation options, so the chosen
33
+ * command runs against the same streams and injected process access
34
+ * @param {string[]} path commands already chosen
35
+ * @param {import('./command.js').CommandEntry[]} entries what is available here
36
+ * @returns {Promise<number>}
37
+ */
38
+ async function runMenu(ctx, options, path, entries) {
39
+ const name = await ctx.ui.select({
40
+ message: path.length === 0 ? 'What would you like to do?' : `octane ${path.join(' ')}`,
41
+ flag: '<command>',
42
+ options: entries.map((entry) => ({
43
+ value: entry.name,
44
+ label: entry.name,
45
+ hint: entry.summary,
46
+ })),
47
+ });
48
+ return dispatch([...path, name], options);
49
+ }
50
+
51
+ /**
52
+ * @param {string[]} argv
53
+ * @param {RunOptions} options
54
+ * @returns {Promise<number>}
55
+ */
56
+ async function dispatch(argv, options) {
57
+ const stdout = options.stdout ?? process.stdout;
58
+ const { path, module, argv: rest } = await resolveCommand(COMMANDS, argv);
59
+
60
+ if (!module && rest.length > 0 && !rest[0].startsWith('-')) {
61
+ throw usageError(`Unknown command: ${rest[0]}`, 'Run `octane --help` to list commands.');
62
+ }
63
+
64
+ const parsed = parseArgs(rest, module ?? {});
65
+ const ctx = createContext({
66
+ flags: parsed.flags,
67
+ version: VERSION,
68
+ env: options.env,
69
+ stdout,
70
+ tty: options.tty,
71
+ exec: options.exec,
72
+ });
73
+
74
+ // `--version` is documented as a global flag, so it answers everywhere rather
75
+ // than only at the root.
76
+ if (parsed.flags.version) {
77
+ if (ctx.json) stdout.write(`${JSON.stringify({ version: VERSION }, null, 2)}\n`);
78
+ else ctx.ui.log(VERSION);
79
+ return EXIT.OK;
80
+ }
81
+
82
+ if (!module?.run || parsed.flags.help) {
83
+ if (ctx.json) {
84
+ const listed = module?.subcommands ?? COMMANDS;
85
+ stdout.write(
86
+ `${JSON.stringify(
87
+ {
88
+ command: path.join(' ') || null,
89
+ commands: listed.map((e) => ({ name: e.name, summary: e.summary })),
90
+ },
91
+ null,
92
+ 2,
93
+ )}\n`,
94
+ );
95
+ return EXIT.OK;
96
+ }
97
+ // Root invocation only: the wordmark heads `octane` and `octane --help`,
98
+ // not every subcommand's help.
99
+ if (path.length === 0) renderBanner(ctx);
100
+
101
+ // Nothing runnable was named, so offer what is available here instead of
102
+ // printing help at someone who has not asked for it. `--help` is an
103
+ // explicit request for the text, so it still wins.
104
+ const choices = module?.subcommands ?? (module ? [] : COMMANDS);
105
+ if (!parsed.flags.help && ctx.ui.canPrompt && choices.length > 0) {
106
+ return runMenu(ctx, options, path, choices);
107
+ }
108
+ ctx.ui.log(renderHelp({ path, module, entries: COMMANDS, colors: ctx.ui.colors }));
109
+ return EXIT.OK;
110
+ }
111
+
112
+ // Commands that write into a project need one to exist. Without this they
113
+ // fail deep inside an fs call with a raw ENOENT stack.
114
+ if (module.requiresProject && ctx.project().manifestPath === null) {
115
+ throw new CliError(`No package.json found in ${ctx.cwd} or any parent directory.`, {
116
+ hint: 'Run this inside a project, or create one first with `npm init -y`.',
117
+ });
118
+ }
119
+
120
+ const result = await module.run(ctx, {
121
+ flags: parsed.flags,
122
+ positionals: parsed.positionals,
123
+ rest: parsed.rest,
124
+ });
125
+
126
+ if (typeof result === 'number') return result;
127
+ if (result && typeof result === 'object') {
128
+ if (ctx.json && result.json !== undefined) {
129
+ stdout.write(`${JSON.stringify(result.json, null, 2)}\n`);
130
+ }
131
+ return result.exitCode ?? EXIT.OK;
132
+ }
133
+ return EXIT.OK;
134
+ }
135
+
136
+ /**
137
+ * Entry point. Never throws: every failure is rendered and turned into an exit
138
+ * code, in the caller's chosen output format.
139
+ *
140
+ * @param {string[]} argv
141
+ * @param {RunOptions} [options]
142
+ * @returns {Promise<number>}
143
+ */
144
+ export async function main(argv, options = {}) {
145
+ const stderr = options.stderr ?? process.stderr;
146
+ // Read from raw argv: a failure can happen before the flags are parsed, and
147
+ // the parser accepts `--json=true` as well as the bare form.
148
+ const json = argv.some((token) => token === '--json' || token.startsWith('--json='));
149
+
150
+ try {
151
+ return await dispatch(argv, options);
152
+ } catch (error) {
153
+ const known = error instanceof CliError;
154
+ const message = error instanceof Error ? error.message : String(error);
155
+
156
+ if (json) {
157
+ stderr.write(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
158
+ } else {
159
+ stderr.write(`\n${message}\n`);
160
+ if (known && error.hint) stderr.write(`${error.hint}\n`);
161
+ if (!known && error instanceof Error && error.stack) stderr.write(`\n${error.stack}\n`);
162
+ }
163
+
164
+ return known ? error.exitCode : EXIT.FAILURE;
165
+ }
166
+ }