@reticlehq/init 3.8.2 → 3.10.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.
@@ -125,6 +125,12 @@ export interface PlanInput {
125
125
  claudeCli: boolean;
126
126
  /** Whether an `reticle` MCP server is already registered with Claude (any scope) — idempotency. */
127
127
  mcpExists: boolean;
128
+ /** The Reticle Claude Code plugin is installed, and it brings its own `reticle` server. */
129
+ claudePlugin?: boolean | undefined;
130
+ /** A user-scope `reticle` entry exists too. Next to the plugin, that is a duplicate. */
131
+ claudeUserEntry?: boolean | undefined;
132
+ /** The user-scope `reticle` entry is the floating npx form. Reported, never replaced. */
133
+ claudeUserEntryFloating?: boolean | undefined;
128
134
  /**
129
135
  * Whether `init` is running inside Claude Code itself, which is the one case where writing its
130
136
  * project-scope `.mcp.json` is known to be wanted. See `CLAUDE_PROJECT_SPEC`.
@@ -372,6 +378,8 @@ export interface PlanInput {
372
378
  promptHooks?: readonly PromptHookTarget[] | undefined;
373
379
  /** See `InitHost.hookBin`. */
374
380
  hookBin?: string | undefined;
381
+ /** See `InitHost.mcpLauncher`. Absent: the MCP registration uses the pinned npx command. */
382
+ mcpLauncher?: string | undefined;
375
383
  /**
376
384
  * Absolute directory the AGENT runs in, when that is not the app's directory.
377
385
  *
@@ -7,7 +7,7 @@
7
7
  * Start, the Electron renderers) composes these steps, while the rest of `plan-framework.ts` is one
8
8
  * builder per framework that has its own files to write.
9
9
  */
10
- import { newViteConfig, patchViteConfig, VitePatchKind, VITE_IMPORT } from '../patch/vite-config.js';
10
+ import { DefineConfigSource, newViteConfig, patchViteConfig, VitePatchKind, VITE_IMPORT, } from '../patch/vite-config.js';
11
11
  import { viteManual, viteDevModuleFile, VITE_DEV_MODULE_PATH } from '../patch/snippets.js';
12
12
  import { hasOptOut, OPT_OUT_MARKER } from '../detect/declared/init-opt-out.js';
13
13
  import { Framework } from '../detect/detect.js';
@@ -228,7 +228,9 @@ function viteConfigSteps(input, detail, inject = true) {
228
228
  detail,
229
229
  write: {
230
230
  path,
231
- content: newViteConfig(port, true === input.captureBodies, inject, stampSource),
231
+ content: newViteConfig(port, true === input.captureBodies, inject, stampSource, undefined === input.detection.dependencies?.[DefineConfigSource.VITE_PLUS]
232
+ ? DefineConfigSource.VITE
233
+ : DefineConfigSource.VITE_PLUS),
232
234
  expect: [VITE_IMPORT, 'reticle('],
233
235
  },
234
236
  dependsOnInstall: true,
package/dist/plan/plan.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * contents, produce an ordered list of steps — each marked apply / manual / already / skip. The
4
4
  * runner performs the `write` side-effects; this module decides *what* should happen.
5
5
  */
6
- import { claudeAddCommand, claudeProjectMcpJson, codexAddCommand, mcpManual, } from '../register/mcp.js';
6
+ import { claudeAddCommand, claudeProjectMcpJson, claudeRemoveUserCommand, claudeStaleEntryNote, codexAddCommand, mcpManual, } from '../register/mcp.js';
7
7
  import { codexNamesOurServer } from '../register/codex-toml.js';
8
8
  import { mergeClientConfig, ClientMergeStatus, clientSnippet, clientSpec, CLAUDE_PROJECT_CONFIG, CLAUDE_PROJECT_SPEC, McpClient, } from '../register/mcp-clients.js';
9
9
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH, SLASH_COMMAND_BODY, SLASH_COMMAND_SIGNATURE, } from '../register/slash-command.js';
@@ -39,7 +39,47 @@ function agentFile(input, relPath) {
39
39
  return root === undefined || 0 === root.length ? relPath : join(root, relPath);
40
40
  }
41
41
  const CLAUDE_MCP_TITLE = 'MCP server (Claude, global)';
42
+ /**
43
+ * The Reticle plugin brings its own `reticle` server. A user-scope entry next to it gives Claude
44
+ * Code two servers with the same tools, so none is added. An old one is named, not removed.
45
+ */
46
+ function claudePluginStep(input) {
47
+ if (true === input.claudeUserEntry) {
48
+ return {
49
+ title: CLAUDE_MCP_TITLE,
50
+ target: MCP_TARGET,
51
+ status: StepStatus.MANUAL,
52
+ detail: `the Reticle plugin is active and a user-scope reticle server is registered too, so Claude Code has two. Remove the user-scope one: ${claudeRemoveUserCommand()}`,
53
+ };
54
+ }
55
+ return {
56
+ title: CLAUDE_MCP_TITLE,
57
+ target: MCP_TARGET,
58
+ status: StepStatus.ALREADY,
59
+ detail: 'the Reticle plugin is active and gives Claude Code its server; no user-scope entry added',
60
+ };
61
+ }
62
+ /**
63
+ * The floating npx entry, kept and named. A NOTICE after the Claude Code step, so the step that
64
+ * decides the closing hint still reads ALREADY.
65
+ */
66
+ function claudeFloatingSteps(input) {
67
+ if (true !== input.claudeUserEntryFloating) {
68
+ return [];
69
+ }
70
+ return [
71
+ {
72
+ title: 'Old MCP entry (Claude, global)',
73
+ target: MCP_TARGET,
74
+ status: StepStatus.NOTICE,
75
+ detail: claudeStaleEntryNote(input.mcpLauncher),
76
+ },
77
+ ];
78
+ }
42
79
  function claudeMcpStep(input) {
80
+ if (true === input.claudePlugin) {
81
+ return claudePluginStep(input);
82
+ }
43
83
  if (!input.claudeCli) {
44
84
  return null;
45
85
  }
@@ -51,7 +91,7 @@ function claudeMcpStep(input) {
51
91
  detail: 'reticle already registered (install once, used by every project)',
52
92
  };
53
93
  }
54
- const cmd = claudeAddCommand();
94
+ const cmd = claudeAddCommand(input.mcpLauncher);
55
95
  return {
56
96
  title: CLAUDE_MCP_TITLE,
57
97
  target: MCP_TARGET,
@@ -71,7 +111,7 @@ function otherClientSteps(input) {
71
111
  return (input.detectedClients ?? []).map((detected) => {
72
112
  const spec = clientSpec(detected.id);
73
113
  return (codexCliStep(spec, detected.configPath, detected.existing, input) ??
74
- clientStep(spec, detected.configPath, detected.existing));
114
+ clientStep(spec, detected.configPath, detected.existing, input.mcpLauncher));
75
115
  });
76
116
  }
77
117
  /**
@@ -90,7 +130,7 @@ function codexCliStep(spec, configPath, existing, input) {
90
130
  if (codexNamesOurServer(existing, spec.serversKey)) {
91
131
  return null;
92
132
  }
93
- const cmd = codexAddCommand();
133
+ const cmd = codexAddCommand(input.mcpLauncher);
94
134
  return {
95
135
  title: `MCP server (${spec.label})`,
96
136
  target: configPath,
@@ -100,8 +140,8 @@ function codexCliStep(spec, configPath, existing, input) {
100
140
  };
101
141
  }
102
142
  /** One client's step: already-correct is left alone, an unparseable file gets a paste-able block. */
103
- function clientStep(spec, configPath, existing) {
104
- const merged = mergeClientConfig(spec, existing);
143
+ function clientStep(spec, configPath, existing, launcher) {
144
+ const merged = mergeClientConfig(spec, existing, launcher);
105
145
  const title = `MCP server (${spec.label})`;
106
146
  if (merged.status === ClientMergeStatus.ALREADY) {
107
147
  return {
@@ -118,7 +158,7 @@ function clientStep(spec, configPath, existing) {
118
158
  title,
119
159
  target: configPath,
120
160
  status: StepStatus.MANUAL,
121
- detail: `add this to ${configPath} by hand:\n${clientSnippet(spec)}`,
161
+ detail: `add this to ${configPath} by hand:\n${clientSnippet(spec, launcher)}`,
122
162
  };
123
163
  }
124
164
  return {
@@ -136,11 +176,11 @@ function clientStep(spec, configPath, existing) {
136
176
  * they get the notice instead.
137
177
  */
138
178
  function claudeProjectStep(input) {
139
- if (input.claudeCli || true !== input.insideClaudeCode) {
179
+ if (input.claudeCli || true !== input.insideClaudeCode || true === input.claudePlugin) {
140
180
  return null;
141
181
  }
142
182
  const path = agentFile(input, CLAUDE_PROJECT_CONFIG);
143
- return clientStep(CLAUDE_PROJECT_SPEC, path, input.claudeProjectConfig ?? null);
183
+ return clientStep(CLAUDE_PROJECT_SPEC, path, input.claudeProjectConfig ?? null, input.mcpLauncher);
144
184
  }
145
185
  /** One global registration per detected agent (Claude + Cursor). Falls back to a manual note. */
146
186
  function mcpSteps(input) {
@@ -163,6 +203,7 @@ function mcpSteps(input) {
163
203
  const claudeProject = claudeProjectStep(input);
164
204
  const steps = [
165
205
  ...stepsForAgents(input, (a) => a.mcpStep),
206
+ ...claudeFloatingSteps(input),
166
207
  ...(null === claudeProject ? [] : [claudeProject]),
167
208
  ...otherClientSteps(input),
168
209
  ];
@@ -173,7 +214,7 @@ function mcpSteps(input) {
173
214
  title: 'MCP server (global)',
174
215
  target: MCP_TARGET,
175
216
  status: StepStatus.MANUAL,
176
- detail: mcpManual(),
217
+ detail: mcpManual(input.mcpLauncher),
177
218
  },
178
219
  ];
179
220
  }
@@ -191,7 +232,10 @@ function mcpSteps(input) {
191
232
  * to register, so nothing has silently vanished — and leaving that path untouched keeps every
192
233
  * pristine scaffold in the install baseline reading exactly as it did.
193
234
  */
194
- if (!input.claudeCli && null === claudeProject && steps.length > 0) {
235
+ if (!input.claudeCli &&
236
+ true !== input.claudePlugin &&
237
+ null === claudeProject &&
238
+ steps.length > 0) {
195
239
  steps.push({
196
240
  title: CLAUDE_MCP_TITLE,
197
241
  target: MCP_TARGET,
@@ -200,7 +244,7 @@ function mcpSteps(input) {
200
244
  // which would turn an informational line into a gate failure on any machine that happens to
201
245
  // have another agent's config. A notice prints in full exactly like a manual step.
202
246
  status: StepStatus.NOTICE,
203
- detail: `no \`claude\` on PATH, so it could not be registered from here. Inside a Claude Code VS Code extension session the CLI is genuinely absent while the editor is not — add ${claudeProjectMcpJson()}`,
247
+ detail: `no \`claude\` on PATH, so it could not be registered from here. Inside a Claude Code VS Code extension session the CLI is genuinely absent while the editor is not — add ${claudeProjectMcpJson(input.mcpLauncher)}`,
204
248
  });
205
249
  }
206
250
  const windowsNote = windowsMcpNoteStep(input);
@@ -32,8 +32,8 @@ export interface ClientSpec {
32
32
  format: ConfigFormat;
33
33
  /** The object key holding the server map — `mcpServers`, `servers`, or `mcp`. */
34
34
  serversKey: string;
35
- /** Our entry, in THIS client's shape. */
36
- entry: () => unknown;
35
+ /** Our entry, in THIS client's shape. `launcher`: see `serverInvocation`. */
36
+ entry: (launcher?: string) => unknown;
37
37
  /** Where the shape came from, so the next person can re-check it rather than trust this file. */
38
38
  docs: string;
39
39
  }
@@ -74,14 +74,14 @@ interface ClientMergeResult {
74
74
  * already has the table is a warning they must not act on, and the setup contract makes a `⚠`
75
75
  * blocking (#681). Detection is read-only and cannot corrupt anything.
76
76
  */
77
- export declare function mergeClientConfig(spec: ClientSpec, existing: string | null): ClientMergeResult;
77
+ export declare function mergeClientConfig(spec: ClientSpec, existing: string | null, launcher?: string): ClientMergeResult;
78
78
  /**
79
79
  * A block the user can paste, for a client we could not write.
80
80
  *
81
81
  * Every client gets one, including the ones we CAN write: when a merge comes back MANUAL because the
82
82
  * file could not be parsed, the snippet is the only actionable thing left to give.
83
83
  */
84
- export declare function clientSnippet(spec: ClientSpec): string;
84
+ export declare function clientSnippet(spec: ClientSpec, launcher?: string): string;
85
85
  /**
86
86
  * The path whose existence means "this user has this client".
87
87
  *
@@ -18,7 +18,7 @@
18
18
  * an install that reports success and registers nothing, which is the exact failure class this
19
19
  * repo's install gate exists to catch.
20
20
  */
21
- import { NPX, MCP_SERVER_NAME, npxServerArgs, isReticleRegistration } from './mcp.js';
21
+ import { MCP_SERVER_NAME, isReticleRegistration, serverInvocation } from './mcp.js';
22
22
  import { codexServerTokens } from './codex-toml.js';
23
23
  export const McpClient = {
24
24
  CLAUDE_CODE: 'claude-code',
@@ -43,15 +43,16 @@ export const ConfigFormat = {
43
43
  CLI: 'cli',
44
44
  };
45
45
  /** The `{ command, args }` entry Cursor, Windsurf, Gemini and VS Code all take. */
46
- function commandArgsEntry() {
47
- return { command: NPX, args: npxServerArgs() };
46
+ function commandArgsEntry(launcher) {
47
+ const [command, ...args] = serverInvocation(launcher);
48
+ return { command, args };
48
49
  }
49
50
  /**
50
51
  * OpenCode's entry. `command` is a single ARRAY containing the executable and its arguments, and
51
52
  * `enabled` is explicit — a server registered but disabled is the quietest possible failure.
52
53
  */
53
- function openCodeEntry() {
54
- return { type: 'local', command: [NPX, ...npxServerArgs()], enabled: true };
54
+ function openCodeEntry(launcher) {
55
+ return { type: 'local', command: serverInvocation(launcher), enabled: true };
55
56
  }
56
57
  /**
57
58
  * Claude Code's project-scope MCP config, which needs no CLI on PATH.
@@ -189,8 +190,9 @@ export const MCP_CLIENTS = [
189
190
  ];
190
191
  export function clientSpec(id) {
191
192
  const found = MCP_CLIENTS.find((spec) => spec.id === id);
192
- if (found === undefined)
193
+ if (found === undefined) {
193
194
  throw new Error(`no MCP client spec for '${id}'`);
195
+ }
194
196
  return found;
195
197
  }
196
198
  export const ClientMergeStatus = {
@@ -199,15 +201,17 @@ export const ClientMergeStatus = {
199
201
  MANUAL: 'manual',
200
202
  };
201
203
  function parseConfig(existing) {
202
- if (null === existing || 0 === existing.trim().length)
204
+ if (null === existing || 0 === existing.trim().length) {
203
205
  return { ok: true, config: {} };
206
+ }
204
207
  try {
205
208
  const parsed = JSON.parse(existing);
206
209
  // Valid JSON that is not a PLAIN OBJECT — `[]`, `3`, `"x"`, `null` — must not fall through to an
207
210
  // empty config, because the file then gets rewritten wholesale. Unparseable JSON was always
208
211
  // handled conservatively; this adjacent case destroyed the file instead.
209
- if (typeof parsed !== 'object' || null === parsed || Array.isArray(parsed))
212
+ if (typeof parsed !== 'object' || null === parsed || Array.isArray(parsed)) {
210
213
  return { ok: false };
214
+ }
211
215
  return { ok: true, config: parsed };
212
216
  }
213
217
  catch {
@@ -228,11 +232,13 @@ function parseConfig(existing) {
228
232
  * not repair the thing an upgrade exists to repair — and a WRONG one, naming a Reticle package that
229
233
  * has no MCP server in it (see isReticleRegistration).
230
234
  */
231
- function leaveEntryAlone(existing, spec) {
232
- if (JSON.stringify(existing) === JSON.stringify(spec.entry()))
235
+ function leaveEntryAlone(existing, spec, launcher) {
236
+ if (JSON.stringify(existing) === JSON.stringify(spec.entry(launcher))) {
233
237
  return true;
234
- if (typeof existing !== 'object' || null === existing)
238
+ }
239
+ if (typeof existing !== 'object' || null === existing) {
235
240
  return true;
241
+ }
236
242
  const record = existing;
237
243
  // Both shapes carry the package name somewhere in a string; find it without assuming which field.
238
244
  const tokens = [record.command, record.args].flat().filter((v) => 'string' === typeof v);
@@ -246,14 +252,23 @@ function leaveEntryAlone(existing, spec) {
246
252
  * drifted from what we would write, and when the shape was not one the scanner reads — every one of
247
253
  * those keeps today's MANUAL answer, and the snippet is then the actionable thing to print.
248
254
  */
249
- function tomlEntryIsSettled(spec, existing) {
255
+ /** `@reticlehq/server@3.8.2` → `@reticlehq/server`. Every other string is returned as it is. */
256
+ const unpinned = (token) => token.replace(/^(@reticlehq\/server)@.*$/, '$1');
257
+ function tomlEntryIsSettled(spec, existing, launcher) {
250
258
  const tokens = codexServerTokens(existing, spec.serversKey);
251
- if (0 === tokens.length)
259
+ if (0 === tokens.length) {
252
260
  return false;
253
- const expected = [NPX, ...npxServerArgs()];
261
+ }
254
262
  // Same strings, and no others: an entry carrying an extra flag is not the one we would write.
255
- if (tokens.length === expected.length && expected.every((t) => tokens.includes(t)))
263
+ const same = (a, b) => a.length === b.length && b.every((t) => a.includes(t));
264
+ if (same(tokens, serverInvocation(launcher))) {
256
265
  return true;
266
+ }
267
+ // An npx entry of an older version still starts Reticle. TOML is never written by us, so asking
268
+ // for a hand edit at each release is a warning with no defect behind it.
269
+ if (same(tokens.map(unpinned), serverInvocation().map(unpinned))) {
270
+ return true;
271
+ }
257
272
  // Not ours to touch. Somebody pointing `reticle` at their own local build made a deliberate
258
273
  // choice, and telling them to hand-edit it back is the same wrong answer as overwriting it.
259
274
  return !isReticleRegistration(tokens);
@@ -269,29 +284,30 @@ function tomlEntryIsSettled(spec, existing) {
269
284
  * already has the table is a warning they must not act on, and the setup contract makes a `⚠`
270
285
  * blocking (#681). Detection is read-only and cannot corrupt anything.
271
286
  */
272
- export function mergeClientConfig(spec, existing) {
287
+ export function mergeClientConfig(spec, existing, launcher) {
273
288
  if (spec.format !== ConfigFormat.JSON) {
274
- if (ConfigFormat.TOML === spec.format && tomlEntryIsSettled(spec, existing)) {
289
+ if (ConfigFormat.TOML === spec.format && tomlEntryIsSettled(spec, existing, launcher)) {
275
290
  return { status: ClientMergeStatus.ALREADY, content: existing ?? '' };
276
291
  }
277
292
  return { status: ClientMergeStatus.MANUAL, content: existing ?? '' };
278
293
  }
279
294
  const parsed = parseConfig(existing);
280
- if (!parsed.ok)
295
+ if (!parsed.ok) {
281
296
  return { status: ClientMergeStatus.MANUAL, content: existing ?? '' };
297
+ }
282
298
  const config = parsed.config;
283
299
  const current = config[spec.serversKey];
284
300
  const servers = 'object' === typeof current && null !== current && !Array.isArray(current)
285
301
  ? current
286
302
  : {};
287
303
  if (Object.prototype.hasOwnProperty.call(servers, MCP_SERVER_NAME) &&
288
- leaveEntryAlone(servers[MCP_SERVER_NAME], spec)) {
304
+ leaveEntryAlone(servers[MCP_SERVER_NAME], spec, launcher)) {
289
305
  return { status: ClientMergeStatus.ALREADY, content: existing ?? '' };
290
306
  }
291
307
  // Only OUR key is replaced; every other server and top-level key is carried through untouched.
292
308
  const merged = {
293
309
  ...config,
294
- [spec.serversKey]: { ...servers, [MCP_SERVER_NAME]: spec.entry() },
310
+ [spec.serversKey]: { ...servers, [MCP_SERVER_NAME]: spec.entry(launcher) },
295
311
  };
296
312
  return { status: ClientMergeStatus.APPLY, content: `${JSON.stringify(merged, null, 2)}\n` };
297
313
  }
@@ -301,17 +317,17 @@ export function mergeClientConfig(spec, existing) {
301
317
  * Every client gets one, including the ones we CAN write: when a merge comes back MANUAL because the
302
318
  * file could not be parsed, the snippet is the only actionable thing left to give.
303
319
  */
304
- export function clientSnippet(spec) {
320
+ export function clientSnippet(spec, launcher) {
321
+ const [command, ...rest] = serverInvocation(launcher);
305
322
  if (spec.format === ConfigFormat.TOML) {
306
- const args = npxServerArgs()
307
- .map((a) => `"${a}"`)
308
- .join(', ');
309
- return `[mcp_servers.${MCP_SERVER_NAME}]\ncommand = "${NPX}"\nargs = [${args}]\n`;
323
+ // JSON string escapes are valid TOML basic-string escapes, so a Windows path stays valid.
324
+ const args = rest.map((a) => JSON.stringify(a)).join(', ');
325
+ return `[mcp_servers.${MCP_SERVER_NAME}]\ncommand = ${JSON.stringify(command)}\nargs = [${args}]\n`;
310
326
  }
311
327
  if (spec.format === ConfigFormat.CLI) {
312
- return `claude mcp add -s user ${MCP_SERVER_NAME} -- ${NPX} ${npxServerArgs().join(' ')}\n`;
328
+ return `claude mcp add -s user ${MCP_SERVER_NAME} -- ${[command, ...rest].join(' ')}\n`;
313
329
  }
314
- return `${JSON.stringify({ [spec.serversKey]: { [MCP_SERVER_NAME]: spec.entry() } }, null, 2)}\n`;
330
+ return `${JSON.stringify({ [spec.serversKey]: { [MCP_SERVER_NAME]: spec.entry(launcher) } }, null, 2)}\n`;
315
331
  }
316
332
  /**
317
333
  * The path whose existence means "this user has this client".
@@ -326,11 +342,13 @@ export function clientSnippet(spec) {
326
342
  * because a fresh Cursor profile has not written `~/.cursor` yet.
327
343
  */
328
344
  export function clientMarkerRelPath(spec) {
329
- if (spec.scope === ConfigScope.CLI)
345
+ if (spec.scope === ConfigScope.CLI) {
330
346
  return '';
347
+ }
331
348
  // Antigravity also creates ~/.gemini. The shared parent is not evidence of Gemini CLI.
332
- if (spec.id === McpClient.GEMINI)
349
+ if (spec.id === McpClient.GEMINI) {
333
350
  return spec.relPath;
351
+ }
334
352
  const parts = spec.relPath.split('/');
335
353
  return parts.length > 1 ? parts.slice(0, -1).join('/') : spec.relPath;
336
354
  }
@@ -5,7 +5,7 @@
5
5
  * hand-editing `~/.claude.json` (a large stateful file). When the `claude` CLI is absent we print a
6
6
  * manual instruction instead.
7
7
  *
8
- * The registration is intentionally PORTLESS — `npx @reticlehq/server mcp`, never `--port N`. A single
8
+ * The registration is intentionally PORTLESS — it never carries `--port N`. A single
9
9
  * global entry serves every project, so baking a port into it would pin every project to one port
10
10
  * and defeat per-project isolation. Instead `reticle mcp` resolves the port at runtime from the
11
11
  * project's `.reticle.json` in its CWD (see cli-port.ts). The port belongs to the project, not the
@@ -26,23 +26,26 @@ export { MCP_SERVER_NAME } from '@reticlehq/core';
26
26
  */
27
27
  export declare const NPX = "npx";
28
28
  /**
29
- * Args after `npx` that launch the bridge: `@reticlehq/server mcp`. Portless — the port comes from
30
- * the project's `.reticle.json` at runtime, so one global entry works for every project.
29
+ * Args after `npx` that launch the bridge: `@reticlehq/server@<this version> mcp`. Portless — the
30
+ * port comes from the project's `.reticle.json` at runtime, so one global entry works for every
31
+ * project.
31
32
  *
32
- * DELIBERATELY UNPINNED, and the trade is worth stating. `reticle init` pins the SDK to the CLI's
33
- * exact version, so on release day the app can be on the new one while npx serves a cached older
34
- * build — a real skew, reported from the field. Pinning this entry would remove that window.
35
- *
36
- * It would also freeze the agent's MCP server at whatever version was installed the day `init` ran,
37
- * for as long as that entry survives — and `reticle update` upgrades the CLI, not a global agent
38
- * config. A permanent pin trades a window that lasts until the next npx cache miss for one that
39
- * lasts forever.
40
- *
41
- * So it stays unpinned, and the skew is handled where it actually shows up: the contract fingerprint
42
- * makes a real mismatch loud on the next tool result (see version-skew), and a stale entry of our own
43
- * shape is now repaired on re-run rather than reported "already registered" (see mcp-clients.ts).
33
+ * PINNED to one exact version. With a floating `@reticlehq/server`, npx asks the registry on every
34
+ * start. A fresh sandbox (an empty npm cache) downloaded about 131 MB before the MCP server
35
+ * answered: 3.9 s to 5.6 s on each boot, against 0.6 s warm. With an exact version, an image that
36
+ * ran it once uses its cache again. A re-run of `init` or `reticle setup mcp` writes the new
37
+ * version into a stale entry of our own shape (see mcp-clients.ts), and the contract fingerprint
38
+ * makes a real mismatch loud on the next tool result (see version-skew).
44
39
  */
45
40
  export declare function npxServerArgs(): string[];
41
+ /** Is this string the bin of an installed `@reticlehq/server`? */
42
+ export declare function isReticleLauncher(token: string): boolean;
43
+ /**
44
+ * The bin to register, when the CLI that runs now is an install that stays on disk: a global
45
+ * install, or one in a project's `node_modules`. `undefined` for a run-once copy and for a source
46
+ * checkout. Registration then uses the pinned npx command.
47
+ */
48
+ export declare function installedLauncher(bin: string | undefined): string | undefined;
46
49
  /**
47
50
  * Is this existing registration one WE are responsible for?
48
51
  *
@@ -58,6 +61,11 @@ export declare function npxServerArgs(): string[];
58
61
  * @param tokens every string in the entry's command + args, in any of the client shapes.
59
62
  */
60
63
  export declare function isReticleRegistration(tokens: readonly string[]): boolean;
64
+ /**
65
+ * The command that starts the MCP server: `node <launcher> mcp` for an installed copy, else the
66
+ * pinned npx command. `node` is a real executable on Windows too, so it needs no `cmd /c`.
67
+ */
68
+ export declare function serverInvocation(launcher?: string): string[];
61
69
  interface CliAddCommand {
62
70
  command: string;
63
71
  args: string[];
@@ -65,7 +73,7 @@ interface CliAddCommand {
65
73
  display: string;
66
74
  }
67
75
  /** `claude mcp add reticle -s user -- npx @reticlehq/server mcp` — registers globally for all projects (portless). */
68
- export declare function claudeAddCommand(): CliAddCommand;
76
+ export declare function claudeAddCommand(launcher?: string): CliAddCommand;
69
77
  /**
70
78
  * Is a `reticle` server already registered with Claude Code, in any scope this project sees?
71
79
  *
@@ -84,6 +92,33 @@ export declare function claudeHasReticle(io: {
84
92
  readFile(path: string): string | null;
85
93
  homeDir(): string;
86
94
  }, projectDir: string | undefined, configDir?: string | undefined): boolean;
95
+ /** The `reticle` entries Claude Code has, in the scopes `claudeHasReticle` reads. */
96
+ export declare function claudeReticleEntries(io: {
97
+ readFile(path: string): string | null;
98
+ homeDir(): string;
99
+ }, projectDir: string | undefined, configDir?: string | undefined): unknown[];
100
+ /**
101
+ * Is the Reticle Claude Code plugin installed? The plugin brings its own `reticle` MCP server, so a
102
+ * `claude mcp add` next to it gives Claude Code two servers with the same tools. An unreadable
103
+ * record reads as "not installed".
104
+ */
105
+ export declare function claudePluginInstalled(io: {
106
+ readFile(path: string): string | null;
107
+ homeDir(): string;
108
+ }, configDir?: string | undefined): boolean;
109
+ /** The one command that removes a user-scope `reticle` entry left next to the plugin. */
110
+ export declare function claudeRemoveUserCommand(): string;
111
+ /**
112
+ * Claude Code's user-scope `reticle` entry is the floating npx form that registration wrote before
113
+ * it pinned a version or used the installed launcher. `claudeHasReticle` reads it as registered, so
114
+ * nothing replaced it. Reported only: a Claude Code entry is changed only by the person.
115
+ */
116
+ export declare function claudeUserEntryIsFloating(io: {
117
+ readFile(path: string): string | null;
118
+ homeDir(): string;
119
+ }, configDir?: string | undefined): boolean;
120
+ /** The one sentence about a floating entry, and the commands that replace it. */
121
+ export declare function claudeStaleEntryNote(launcher?: string): string;
87
122
  /** Probe args for whether the `claude` CLI is installed at all. */
88
123
  export declare function claudeAvailableProbe(): {
89
124
  command: string;
@@ -96,7 +131,7 @@ export declare function claudeAvailableProbe(): {
96
131
  * It REPLACES an existing entry of the same name and still exits 0, so callers run it only when the
97
132
  * config names no `reticle` server at all (see `codexNamesOurServer`).
98
133
  */
99
- export declare function codexAddCommand(): CliAddCommand;
134
+ export declare function codexAddCommand(launcher?: string): CliAddCommand;
100
135
  /** Probe args for whether the `codex` CLI is installed at all. */
101
136
  export declare function codexAvailableProbe(): {
102
137
  command: string;
@@ -116,5 +151,5 @@ export declare function mcpWindowsNote(): string;
116
151
  * session the `claude` binary is not on PATH, so every CLI-based route is shut and `.mcp.json` in
117
152
  * the project root is the one that still works.
118
153
  */
119
- export declare function claudeProjectMcpJson(): string;
120
- export declare function mcpManual(): string;
154
+ export declare function claudeProjectMcpJson(launcher?: string): string;
155
+ export declare function mcpManual(launcher?: string): string;