@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.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/package.json +50 -0
- package/src/bin/octane.js +4 -0
- package/src/commands/add.js +138 -0
- package/src/commands/analyze.js +271 -0
- package/src/commands/bindings.js +55 -0
- package/src/commands/doctor/check.js +43 -0
- package/src/commands/doctor/checks/bundler.js +104 -0
- package/src/commands/doctor/checks/config.js +184 -0
- package/src/commands/doctor/checks/dependencies.js +120 -0
- package/src/commands/doctor/checks/environment.js +38 -0
- package/src/commands/doctor/checks/source.js +108 -0
- package/src/commands/doctor/checks/typescript.js +183 -0
- package/src/commands/doctor/index.js +118 -0
- package/src/commands/doctor/registry.js +32 -0
- package/src/commands/doctor/report.js +158 -0
- package/src/commands/explain.js +95 -0
- package/src/commands/info.js +54 -0
- package/src/commands/init/index.js +277 -0
- package/src/commands/init/templates.js +124 -0
- package/src/commands/mcp/add.js +241 -0
- package/src/commands/mcp/clients.js +281 -0
- package/src/commands/mcp/detect.js +58 -0
- package/src/commands/mcp/index.js +23 -0
- package/src/commands/mcp/remove.js +105 -0
- package/src/commands/mcp/server.js +46 -0
- package/src/commands/mcp/status.js +48 -0
- package/src/data/index.js +74 -0
- package/src/data/octane-data.json +953 -0
- package/src/index.js +4 -0
- package/src/kernel/args.js +181 -0
- package/src/kernel/banner.js +98 -0
- package/src/kernel/command.js +84 -0
- package/src/kernel/context.js +78 -0
- package/src/kernel/edit.js +238 -0
- package/src/kernel/errors.js +42 -0
- package/src/kernel/exec.js +62 -0
- package/src/kernel/help.js +97 -0
- package/src/kernel/install.js +43 -0
- package/src/kernel/jsonc.js +91 -0
- package/src/kernel/main.js +166 -0
- package/src/kernel/project.js +376 -0
- package/src/kernel/registry.js +52 -0
- package/src/kernel/semver.js +111 -0
- 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
|
+
}
|