@reticlehq/init 3.1.0 → 3.3.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 (39) hide show
  1. package/dist/detect/detect.d.ts +13 -2
  2. package/dist/detect/detect.js +46 -3
  3. package/dist/detect/workspace-redirect.d.ts +2 -1
  4. package/dist/diagnose/csp-doctor.d.ts +20 -1
  5. package/dist/diagnose/csp-doctor.js +101 -4
  6. package/dist/diagnose/desktop-doctor.d.ts +1 -1
  7. package/dist/diagnose/install-retries.d.ts +2 -1
  8. package/dist/host.d.ts +6 -0
  9. package/dist/host.js +3 -0
  10. package/dist/index.d.ts +11 -1
  11. package/dist/index.js +11 -1
  12. package/dist/patch/astro-patch.d.ts +47 -12
  13. package/dist/patch/astro-patch.js +149 -41
  14. package/dist/patch/nuxt-snippets.d.ts +44 -0
  15. package/dist/patch/nuxt-snippets.js +135 -0
  16. package/dist/patch/snippets.d.ts +1 -48
  17. package/dist/patch/snippets.js +68 -178
  18. package/dist/patch/vite-env-types.d.ts +9 -0
  19. package/dist/patch/vite-env-types.js +19 -0
  20. package/dist/patch/vite-owning-config.d.ts +13 -0
  21. package/dist/patch/vite-owning-config.js +43 -16
  22. package/dist/plan/connect-steps.d.ts +3 -2
  23. package/dist/plan/connect-steps.js +4 -2
  24. package/dist/plan/framework-adapter.d.ts +2 -5
  25. package/dist/plan/framework-adapter.js +6 -5
  26. package/dist/plan/plan-framework.d.ts +1 -1
  27. package/dist/plan/plan-framework.js +33 -11
  28. package/dist/plan/plan-types.d.ts +18 -2
  29. package/dist/plan/plan.js +117 -35
  30. package/dist/plan/stop-hook-step.d.ts +4 -0
  31. package/dist/plan/stop-hook-step.js +49 -0
  32. package/dist/project/agent-rules.js +2 -0
  33. package/dist/register/mcp-clients.d.ts +13 -0
  34. package/dist/register/mcp-clients.js +22 -0
  35. package/dist/register/mcp.d.ts +8 -0
  36. package/dist/register/mcp.js +16 -0
  37. package/dist/run-types.d.ts +12 -0
  38. package/dist/run.js +42 -6
  39. package/package.json +2 -2
@@ -6,7 +6,7 @@
6
6
  import { bridgeWsUrl } from '@reticlehq/core';
7
7
  import { patchViteConfig, VitePatchKind, VITE_IMPORT } from '../patch/vite-config.js';
8
8
  import { patchNextConfig, patchRootLayout, patchPagesApp } from '../patch/next-patch.js';
9
- import { ASTRO_ENV_DTS_PATH, patchAstroConfig, patchAstroEnvDts, patchAstroLayout, } from '../patch/astro-patch.js';
9
+ import { ASTRO_ENV_DTS_PATH, ASTRO_RETICLE_DEV_PATH, astroReticleDevFile, patchAstroConfig, patchAstroEnvDts, patchAstroLayout, } from '../patch/astro-patch.js';
10
10
  import { CRA_DEV_MODULE_IMPORT, CRA_ENV_PATH, CRA_TOKEN_PER_MACHINE_NOTICE, TOKEN_VAR, craDevModuleFile, craDevModulePath, craEnvPatch, craImportPatch, } from '../patch/cra.js';
11
11
  import { PatchKind } from '../patch/patch-kind.js';
12
12
  import { viteManual, NEXT_LAYOUT_MANUAL, NEXT_LAYOUT_PATH, viteDevModuleFile, VITE_DEV_MODULE_PATH, nextReticleDevFile, NEXT_RETICLE_DEV_PATH, nextConfigManual, svelteKitHooksFile, SVELTEKIT_HOOKS_PATH, UNVERIFIED_FRAMEWORK_NOTE, astroManual, nuxtManual, nuxtPluginFile, nuxtPluginPath, NUXT_PLUGIN_NOTICE, webpack4TranspileNote, WEBPACK4_REACT_SCRIPTS_MAJOR, reactRouterEntryFile, reactRouterEntryPatch, REACT_ROUTER_ENTRY_PATH, UNVERIFIED_TANSTACK_START_NOTE, htmlManual, } from '../patch/snippets.js';
@@ -636,7 +636,7 @@ export function svelteKitSteps(input) {
636
636
  ];
637
637
  }
638
638
  /**
639
- * Astro: the config define + build target, and the connect script in ONE layout.
639
+ * Astro: the config build target, and the connect script (with the pairing token) in ONE layout.
640
640
  *
641
641
  * Astro was the last gated stack left printing a recipe it did not apply. It still falls back to the
642
642
  * printed one whenever the choice is not obvious — no config, no single layout, or a shape the
@@ -659,14 +659,15 @@ export function astroSteps(input) {
659
659
  },
660
660
  ];
661
661
  }
662
- // ATOMIC. The connect snippet is useless without the config: the token is inlined by the config,
663
- // so a layout patched on its own gives an app that dials the bridge and is refused. Measured on a
664
- // real fixture — config ⚠, layout ✓ — which reads as one step done and one caveat when it is
665
- // actually a guaranteed non-connection. If either half cannot be applied, BOTH go manual with the
666
- // single recipe that does the whole job.
662
+ // ATOMIC. The connect snippet is useless without the config: without `build.target: 'es2022'`
663
+ // Astro down-levels the SDK and the dynamic import dies, and without `optimizeDeps.include` the
664
+ // first load 404s the hashed module. The token itself now lives in the layout frontmatter (#1008),
665
+ // but a layout patched on its own still cannot connect. Measured on a real fixture — config ⚠,
666
+ // layout ✓ — which reads as one step done and one caveat when it is actually a guaranteed
667
+ // non-connection. If either half cannot be applied, BOTH go manual with the single recipe.
667
668
  const manualWithLayout = astroManual(input.options.port, input.options.projectId, layout.path);
668
669
  const configPatch = patchAstroConfig(config.source);
669
- const layoutPatch = patchAstroLayout(layout.source, input.options.port, input.options.projectId, input.detection.uiLibrary, input.testids ?? []);
670
+ const layoutPatch = patchAstroLayout(layout.source, layout.path);
670
671
  if (configPatch.kind === PatchKind.MANUAL || layoutPatch.kind === PatchKind.MANUAL) {
671
672
  return [
672
673
  {
@@ -678,13 +679,34 @@ export function astroSteps(input) {
678
679
  ];
679
680
  }
680
681
  const envPatch = patchAstroEnvDts(input.astroEnvDts ?? null);
682
+ const existingDev = input.astroReticleDev ?? null;
683
+ const devAlready = 'string' === typeof existingDev && existingDev.includes('reticle.connect');
684
+ const devStep = devAlready
685
+ ? {
686
+ title: StepTitle.ASTRO_RETICLE_DEV,
687
+ target: ASTRO_RETICLE_DEV_PATH,
688
+ status: StepStatus.ALREADY,
689
+ detail: 'file exists, left alone',
690
+ }
691
+ : {
692
+ title: StepTitle.ASTRO_RETICLE_DEV,
693
+ target: ASTRO_RETICLE_DEV_PATH,
694
+ status: StepStatus.APPLY,
695
+ detail: 'write the local connect module (static SDK import, token from <meta>)',
696
+ write: {
697
+ path: ASTRO_RETICLE_DEV_PATH,
698
+ content: astroReticleDevFile(input.options.port, input.options.projectId, input.detection.uiLibrary, input.testids ?? []),
699
+ },
700
+ dependsOnInstall: true,
701
+ };
681
702
  return [
682
- patchStep(StepTitle.ASTRO_CONFIG, config.path, configPatch, 'inline the pairing token and raise build.target to es2022', manualWithLayout),
683
- patchStep(StepTitle.CONNECT_SNIPPET_ASTRO, layout.path, layoutPatch, 'add the dev-only connect <script> before </body>', manualWithLayout),
703
+ patchStep(StepTitle.ASTRO_CONFIG, config.path, configPatch, 'raise build.target to es2022 and keep .reticle/ out of the watcher', manualWithLayout),
704
+ patchStep(StepTitle.CONNECT_SNIPPET_ASTRO, layout.path, layoutPatch, 'add the pairing-token <meta> and a script that statically imports ReticleDev', manualWithLayout),
705
+ devStep,
684
706
  // Declares the Vite define names so `astro check` (create-astro's default build) can see them
685
707
  // (#677). Independent of the two halves above: even an ALREADY config/layout still needs this
686
708
  // when the env file was never written.
687
- patchStep(StepTitle.ASTRO_ENV_DTS, ASTRO_ENV_DTS_PATH, envPatch, 'declare __RETICLE_TOKEN__ / __RETICLE_ROOT__ for astro check', manualWithLayout),
709
+ patchStep(StepTitle.ASTRO_ENV_DTS, ASTRO_ENV_DTS_PATH, envPatch, 'declare window.__RETICLE_TOKEN__ / __RETICLE_ROOT__ for astro check', manualWithLayout),
688
710
  ];
689
711
  }
690
712
  /**
@@ -114,6 +114,13 @@ export interface PlanInput {
114
114
  claudeCli: boolean;
115
115
  /** Whether an `reticle` MCP server is already registered with Claude (any scope) — idempotency. */
116
116
  mcpExists: boolean;
117
+ /**
118
+ * Whether `init` is running inside Claude Code itself, which is the one case where writing its
119
+ * project-scope `.mcp.json` is known to be wanted. See `CLAUDE_PROJECT_SPEC`.
120
+ */
121
+ insideClaudeCode?: boolean | undefined;
122
+ /** The project `.mcp.json` at the agent's root, read so the merge stays idempotent. */
123
+ claudeProjectConfig?: string | null | undefined;
117
124
  /** `process.platform`. Injected so this module stays pure. Windows is the only branch. */
118
125
  platform?: string;
119
126
  /**
@@ -175,10 +182,15 @@ export interface PlanInput {
175
182
  source: string;
176
183
  } | null | undefined;
177
184
  /**
178
- * Existing `src/env.d.ts` content, when present — where the Vite-define ambient declarations go
179
- * so `astro check` can see `__RETICLE_TOKEN__` / `__RETICLE_ROOT__` (#677).
185
+ * Existing `src/env.d.ts` content, when present — where the window ambient declarations go
186
+ * so `astro check` can see `__RETICLE_TOKEN__` / `__RETICLE_ROOT__` (#677, #1008).
180
187
  */
181
188
  astroEnvDts?: string | null | undefined;
189
+ /**
190
+ * Existing `src/components/ReticleDev.ts` content, when present — the local module whose
191
+ * static SDK import is what actually dials the daemon.
192
+ */
193
+ astroReticleDev?: string | null | undefined;
182
194
  /** Discovered Next config filename (e.g. 'next.config.mjs'), or null. */
183
195
  nextConfigFile: string | null;
184
196
  /** Source of that Next config, so the export can be wrapped in withReticle. */
@@ -283,6 +295,10 @@ export interface PlanInput {
283
295
  claudeCommandContent?: string | null | undefined;
284
296
  /** Current .cursor/commands/reticle.md content, or null when absent. */
285
297
  cursorCommandContent?: string | null | undefined;
298
+ /** `--hooks`: install the print-only Claude Code Stop hook. Off unless asked. */
299
+ hooks?: boolean | undefined;
300
+ /** Current .claude/settings.json content, or null when absent (the hook is MERGED into it). */
301
+ claudeSettingsContent?: string | null | undefined;
286
302
  /**
287
303
  * Absolute directory the AGENT runs in, when that is not the app's directory.
288
304
  *
package/dist/plan/plan.js CHANGED
@@ -6,8 +6,8 @@
6
6
  import { Framework, UiLibrary, installCommand, installCommandParts, } from '../detect/detect.js';
7
7
  import { installFailureHint } from '../diagnose/install-hint.js';
8
8
  import { installRetries } from '../diagnose/install-retries.js';
9
- import { claudeAddCommand, mcpManual } from '../register/mcp.js';
10
- import { mergeClientConfig, ClientMergeStatus, clientSnippet, clientSpec, McpClient, } from '../register/mcp-clients.js';
9
+ import { claudeAddCommand, claudeProjectMcpJson, mcpManual } from '../register/mcp.js';
10
+ import { mergeClientConfig, ClientMergeStatus, clientSnippet, clientSpec, CLAUDE_PROJECT_CONFIG, CLAUDE_PROJECT_SPEC, McpClient, } from '../register/mcp-clients.js';
11
11
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH, SLASH_COMMAND_BODY, SLASH_COMMAND_SIGNATURE, } from '../register/slash-command.js';
12
12
  import { mergeMarkedInstruction, reticleMdFile, cursorRuleFile, AgentRuleStatus, CLAUDE_MD_PATH, markedBlock, AGENTS_MD_PATH, RETICLE_MD_PATH, CURSOR_RULE_PATH, } from '../project/agent-rules.js';
13
13
  import { cspStep } from './plan-framework.js';
@@ -20,6 +20,7 @@ import { reticleConfigContent } from '../patch/snippets.js';
20
20
  import { configWithInstallSource } from '../project/install-source-config.js';
21
21
  import { containerisedStep, uiLibraryStep, webGlCanvasStep, windowsMcpNoteStep } from './notices.js';
22
22
  import { existingConfigProblem, projectIdOf, RETICLE_CONFIG_FILE } from '../detect/existing-config.js';
23
+ import { CLAUDE_SETTINGS_PATH, stopHookStep } from './stop-hook-step.js';
23
24
  // Re-exported: it moved to the module that reads it, and every existing importer says `plan.js`.
24
25
  export { RETICLE_CONFIG_FILE };
25
26
  /**
@@ -104,40 +105,49 @@ function claudeMcpStep(input) {
104
105
  * parse is reported with a paste-able block rather than overwritten, and everything else is written.
105
106
  */
106
107
  function otherClientSteps(input) {
107
- const steps = [];
108
- for (const detected of input.detectedClients ?? []) {
109
- const spec = clientSpec(detected.id);
110
- const merged = mergeClientConfig(spec, detected.existing);
111
- const title = `MCP server (${spec.label})`;
112
- if (merged.status === ClientMergeStatus.ALREADY) {
113
- steps.push({
114
- title,
115
- target: detected.configPath,
116
- status: StepStatus.ALREADY,
117
- detail: `reticle already registered with ${spec.label}`,
118
- });
119
- continue;
120
- }
121
- if (merged.status === ClientMergeStatus.MANUAL) {
122
- // Either the file did not parse, or the format is one we refuse to edit blind (TOML). Both
123
- // end the same way: say so, and hand over the exact block.
124
- steps.push({
125
- title,
126
- target: detected.configPath,
127
- status: StepStatus.MANUAL,
128
- detail: `add this to ${detected.configPath} by hand:\n${clientSnippet(spec)}`,
129
- });
130
- continue;
131
- }
132
- steps.push({
108
+ return (input.detectedClients ?? []).map((detected) => clientStep(clientSpec(detected.id), detected.configPath, detected.existing));
109
+ }
110
+ /** One client's step: already-correct is left alone, an unparseable file gets a paste-able block. */
111
+ function clientStep(spec, configPath, existing) {
112
+ const merged = mergeClientConfig(spec, existing);
113
+ const title = `MCP server (${spec.label})`;
114
+ if (merged.status === ClientMergeStatus.ALREADY) {
115
+ return {
133
116
  title,
134
- target: detected.configPath,
135
- status: StepStatus.APPLY,
136
- detail: `register reticle with ${spec.label}`,
137
- write: { path: detected.configPath, content: merged.content },
138
- });
117
+ target: configPath,
118
+ status: StepStatus.ALREADY,
119
+ detail: `reticle already registered with ${spec.label}`,
120
+ };
139
121
  }
140
- return steps;
122
+ if (merged.status === ClientMergeStatus.MANUAL) {
123
+ // Either the file did not parse, or the format is one we refuse to edit blind (TOML). Both
124
+ // end the same way: say so, and hand over the exact block.
125
+ return {
126
+ title,
127
+ target: configPath,
128
+ status: StepStatus.MANUAL,
129
+ detail: `add this to ${configPath} by hand:\n${clientSnippet(spec)}`,
130
+ };
131
+ }
132
+ return {
133
+ title,
134
+ target: configPath,
135
+ status: StepStatus.APPLY,
136
+ detail: `register reticle with ${spec.label}`,
137
+ write: { path: configPath, content: merged.content },
138
+ };
139
+ }
140
+ /**
141
+ * Claude Code's project `.mcp.json`, written only when `init` runs inside Claude Code with no CLI on
142
+ * PATH (a VS Code extension session). There the reporter had to write this file by hand (#1071,
143
+ * #1078); anywhere else a `.mcp.json` is a file in somebody's repo for a client they may not use, so
144
+ * they get the notice instead.
145
+ */
146
+ function claudeProjectStep(input) {
147
+ if (input.claudeCli || true !== input.insideClaudeCode)
148
+ return null;
149
+ const path = agentFile(input, CLAUDE_PROJECT_CONFIG);
150
+ return clientStep(CLAUDE_PROJECT_SPEC, path, input.claudeProjectConfig ?? null);
141
151
  }
142
152
  /** One global registration per detected agent (Claude + Cursor). Falls back to a manual note. */
143
153
  function mcpSteps(input) {
@@ -157,7 +167,12 @@ function mcpSteps(input) {
157
167
  // Claude and Cursor first (they have their own registration paths), then every other detected
158
168
  // client. A machine with Cursor AND Windsurf gets both — registering only the first one found is
159
169
  // how a user ends up with Reticle in the editor they were not using.
160
- const steps = [...stepsForAgents(input, (a) => a.mcpStep), ...otherClientSteps(input)];
170
+ const claudeProject = claudeProjectStep(input);
171
+ const steps = [
172
+ ...stepsForAgents(input, (a) => a.mcpStep),
173
+ ...(null === claudeProject ? [] : [claudeProject]),
174
+ ...otherClientSteps(input),
175
+ ];
161
176
  if (0 === steps.length) {
162
177
  // No agent detected. mcpManual already carries the Windows cmd fallback — do not append it again.
163
178
  return [
@@ -169,6 +184,32 @@ function mcpSteps(input) {
169
184
  },
170
185
  ];
171
186
  }
187
+ /*
188
+ * Claude Code never leaves the plan without a word (#1071).
189
+ *
190
+ * It is the only client detected by CLI-on-PATH rather than by its config, so inside a Claude Code
191
+ * VS Code extension session — where `claude` is not on PATH — it reads as absent while the user is
192
+ * sitting in it. `claudeMcpStep` then returned null, and the manual fallback below only fires when
193
+ * NO agent at all was found. With Gemini and Codex present, Claude Code vanished from the plan
194
+ * entirely and nothing said so. A step that disappears is exactly what the install gate's baseline
195
+ * diff exists to catch, and the plan is the one artifact a person reads to learn what init did.
196
+ *
197
+ * Only when something else WAS found. With nothing found, the generic note below already says how
198
+ * to register, so nothing has silently vanished — and leaving that path untouched keeps every
199
+ * pristine scaffold in the install baseline reading exactly as it did.
200
+ */
201
+ if (!input.claudeCli && null === claudeProject && steps.length > 0) {
202
+ steps.push({
203
+ title: CLAUDE_MCP_TITLE,
204
+ target: MCP_TARGET,
205
+ // NOTICE, not MANUAL. Nothing here FAILED and the reader may not use Claude Code at all, so
206
+ // this is something to know rather than work owed — and the install gate asserts zero `⚠`,
207
+ // which would turn an informational line into a gate failure on any machine that happens to
208
+ // have another agent's config. A notice prints in full exactly like a manual step.
209
+ status: StepStatus.NOTICE,
210
+ 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()}`,
211
+ });
212
+ }
172
213
  const windowsNote = windowsMcpNoteStep(input);
173
214
  if (windowsNote !== null)
174
215
  steps.push(windowsNote);
@@ -415,10 +456,48 @@ function agentRuleSteps(input) {
415
456
  * with nothing naming a version. Pinning turns that into this loud failure, which is the better
416
457
  * trade — but only if the message says what to do about it.
417
458
  */
459
+ /**
460
+ * Is this package already declared at the version we would pin?
461
+ *
462
+ * The caret is the point. `npm i -D pkg@3.2.0` WRITES `^3.2.0`, so on every later run the pinned
463
+ * `3.2.0` failed a string comparison against the range npm had just created, and init re-installed
464
+ * for ever. Only the two forms a package manager writes by itself are read - an exact version, and
465
+ * `^`/`~` over that same version.
466
+ *
467
+ * Everything else installs. A false "already" leaves somebody with no SDK and an install that
468
+ * claims to have run; a redundant install only costs time, so the unreadable cases go the safe way.
469
+ */
470
+ function alreadyDeclared(declared, pinned) {
471
+ if (declared === undefined)
472
+ return false;
473
+ if (pinned === undefined || 0 === pinned.length)
474
+ return true;
475
+ return declared === pinned || `^${pinned}` === declared || `~${pinned}` === declared;
476
+ }
418
477
  function installStep(input) {
419
478
  const pm = input.detection.packageManager;
420
479
  const packages = pinnedPackages(frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion);
421
480
  const command = installCommand(pm, packages);
481
+ /*
482
+ * A re-run over an already-wired project does NO dependency work.
483
+ *
484
+ * Reported from the field: `init` on an instrumented npm-workspaces project ran a pnpm dependency
485
+ * migration, which moved the existing `node_modules` aside and broke the dev server. Whatever
486
+ * manager gets chosen, the redundant install is the step that touches the tree — so the fix that
487
+ * matters is not running one that nothing needs. `init` is documented as idempotent; this is the
488
+ * step that was not.
489
+ */
490
+ const declared = input.detection.dependencies ?? {};
491
+ const version = input.options.sdkVersion;
492
+ const names = frameworkPackages(input.detection.framework, input.detection.uiLibrary);
493
+ if (names.length > 0 && names.every((n) => alreadyDeclared(declared[n], version))) {
494
+ return {
495
+ title: 'Install dependencies',
496
+ target: 'package.json',
497
+ status: StepStatus.ALREADY,
498
+ detail: `${names.join(', ')} already declared${version === undefined || 0 === version.length ? '' : ` at ${version}`}`,
499
+ };
500
+ }
422
501
  if (!input.options.install) {
423
502
  return {
424
503
  title: 'Install dependencies',
@@ -574,6 +653,9 @@ export function buildPlan(input) {
574
653
  ...mcpSteps(input),
575
654
  ...agentRuleSteps(input),
576
655
  ...slashCommandSteps(input),
656
+ ...(true === input.hooks
657
+ ? [stopHookStep(agentFile(input, CLAUDE_SETTINGS_PATH), input.claudeSettingsContent)]
658
+ : []),
577
659
  ...uiLibraryStep(input),
578
660
  ...webGlCanvasStep(input),
579
661
  installStep(input),
@@ -0,0 +1,4 @@
1
+ import { type Step } from './plan-types.js';
2
+ export declare const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
3
+ export declare const STOP_HOOK_COMMAND = "npx @reticlehq/server report --hook";
4
+ export declare function stopHookStep(path: string, existing: string | null | undefined): Step;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `init --hooks`: the print-only Claude Code Stop hook, merged into `.claude/settings.json`.
3
+ *
4
+ * Opt-in, because it writes into a file the user and other tools own. When the agent stops without a
5
+ * single claim having held, the hook prints one line saying so; it never blocks the agent. The line
6
+ * itself is `reticle report --hook`, which reads the session journal on disk.
7
+ */
8
+ import { RETICLE_NPM_PACKAGE } from '../version.js';
9
+ import { StepStatus } from './plan-types.js';
10
+ export const CLAUDE_SETTINGS_PATH = '.claude/settings.json';
11
+ export const STOP_HOOK_COMMAND = `npx ${RETICLE_NPM_PACKAGE} report --hook`;
12
+ const TITLE = 'Claude Code Stop hook';
13
+ const STOP_EVENT = 'Stop';
14
+ const HOOK_ENTRY = { hooks: [{ type: 'command', command: STOP_HOOK_COMMAND }] };
15
+ const isObject = (value) => 'object' === typeof value && null !== value && !Array.isArray(value);
16
+ /** The settings as an object, or undefined when the text is not one. */
17
+ function parseSettings(text) {
18
+ try {
19
+ const parsed = JSON.parse(text);
20
+ return isObject(parsed) ? parsed : undefined;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ export function stopHookStep(path, existing) {
27
+ const settings = null === existing || undefined === existing ? {} : parseSettings(existing);
28
+ if (settings === undefined) {
29
+ return {
30
+ title: TITLE,
31
+ target: path,
32
+ status: StepStatus.MANUAL,
33
+ detail: `${CLAUDE_SETTINGS_PATH} is not a JSON object, so it was left alone. Add a Stop hook running \`${STOP_HOOK_COMMAND}\``,
34
+ };
35
+ }
36
+ const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
37
+ const stop = Array.isArray(hooks[STOP_EVENT]) ? hooks[STOP_EVENT] : [];
38
+ if (JSON.stringify(stop).includes(STOP_HOOK_COMMAND)) {
39
+ return { title: TITLE, target: path, status: StepStatus.ALREADY, detail: 'hook already installed' };
40
+ }
41
+ const merged = { ...settings, hooks: { ...hooks, [STOP_EVENT]: [...stop, HOOK_ENTRY] } };
42
+ return {
43
+ title: TITLE,
44
+ target: path,
45
+ status: StepStatus.APPLY,
46
+ detail: 'print one line when the agent stops without a claim that held; never blocks it',
47
+ write: { path, content: `${JSON.stringify(merged, null, 2)}\n`, expect: [STOP_HOOK_COMMAND] },
48
+ };
49
+ }
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { NPX } from '../register/mcp.js';
21
21
  import { RETICLE_NPM_PACKAGE } from '../version.js';
22
+ import { DISCOVERY_CALL_URL, DISCOVERY_EMAIL_COMMAND } from '@reticlehq/core';
22
23
  /** Project-root instruction files the agent re-reads every session. */
23
24
  export const CLAUDE_MD_PATH = 'CLAUDE.md';
24
25
  export const AGENTS_MD_PATH = 'AGENTS.md';
@@ -206,6 +207,7 @@ Finish that file when you see it. It is what lets you check what the app BELIEVE
206
207
  - **Working around a Reticle defect in silence is the one thing not to do.** A silent workaround is how the same defect survives to the next user.
207
208
  - **Report defects in *Reticle*, not in the app under test.** A bug you find in this app is Reticle working as intended; that one belongs in your report to the user.
208
209
  - **Never include app source, secrets, user data, or customer records** in a report.
210
+ - **When the person you work with is stuck, or Reticle got in their way, offer them a call with the founder** (${DISCOVERY_CALL_URL}) or leaving an email with \`${DISCOVERY_EMAIL_COMMAND}\`. That conversation is where Reticle's direction comes from. Offer it; never book a call or submit an email for them.
209
211
 
210
212
  ## More
211
213
 
@@ -37,6 +37,19 @@ export interface ClientSpec {
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
  }
40
+ /**
41
+ * Claude Code's project-scope MCP config, which needs no CLI on PATH.
42
+ *
43
+ * Named here beside the client specs because it IS one of Claude Code's two config routes, and a
44
+ * free string for it is the reason nobody noticed the other route existed when the CLI one failed.
45
+ */
46
+ export declare const CLAUDE_PROJECT_CONFIG = ".mcp.json";
47
+ /**
48
+ * Claude Code's project-scope registration, as a client spec so it goes through the same merge
49
+ * (already / manual / apply) as every other client. Not in `MCP_CLIENTS`: that list is DETECTED by
50
+ * its config existing, and this one is only ever written from inside Claude Code (see plan.ts).
51
+ */
52
+ export declare const CLAUDE_PROJECT_SPEC: ClientSpec;
40
53
  export declare const MCP_CLIENTS: readonly ClientSpec[];
41
54
  export declare function clientSpec(id: McpClient): ClientSpec;
42
55
  export declare const ClientMergeStatus: {
@@ -53,6 +53,28 @@ function commandArgsEntry() {
53
53
  function openCodeEntry() {
54
54
  return { type: 'local', command: [NPX, ...npxServerArgs()], enabled: true };
55
55
  }
56
+ /**
57
+ * Claude Code's project-scope MCP config, which needs no CLI on PATH.
58
+ *
59
+ * Named here beside the client specs because it IS one of Claude Code's two config routes, and a
60
+ * free string for it is the reason nobody noticed the other route existed when the CLI one failed.
61
+ */
62
+ export const CLAUDE_PROJECT_CONFIG = '.mcp.json';
63
+ /**
64
+ * Claude Code's project-scope registration, as a client spec so it goes through the same merge
65
+ * (already / manual / apply) as every other client. Not in `MCP_CLIENTS`: that list is DETECTED by
66
+ * its config existing, and this one is only ever written from inside Claude Code (see plan.ts).
67
+ */
68
+ export const CLAUDE_PROJECT_SPEC = {
69
+ id: McpClient.CLAUDE_CODE,
70
+ label: 'Claude Code, project',
71
+ scope: ConfigScope.PROJECT,
72
+ relPath: CLAUDE_PROJECT_CONFIG,
73
+ format: ConfigFormat.JSON,
74
+ serversKey: 'mcpServers',
75
+ entry: commandArgsEntry,
76
+ docs: 'https://docs.claude.com/en/docs/claude-code/mcp: project scope, .mcp.json at the project root',
77
+ };
56
78
  export const MCP_CLIENTS = [
57
79
  {
58
80
  id: McpClient.CLAUDE_CODE,
@@ -83,4 +83,12 @@ export declare function claudeAvailableProbe(): {
83
83
  */
84
84
  export declare function mcpWindowsNote(): string;
85
85
  /** Printed when the `claude` CLI isn't available — register Reticle globally once, by hand. */
86
+ /**
87
+ * Claude Code's PROJECT-scope config, which needs no CLI on PATH.
88
+ *
89
+ * The remedy a reporter had to work out for themselves: inside a Claude Code VS Code extension
90
+ * session the `claude` binary is not on PATH, so every CLI-based route is shut and `.mcp.json` in
91
+ * the project root is the one that still works.
92
+ */
93
+ export declare function claudeProjectMcpJson(): string;
86
94
  export declare function mcpManual(): string;
@@ -16,6 +16,7 @@ import { RETICLE_NPM_PACKAGE } from '../version.js';
16
16
  // below (and their tests) keep reading it from the module that USES it.
17
17
  export { MCP_SERVER_NAME } from '@reticlehq/core';
18
18
  import { MCP_SERVER_NAME } from '@reticlehq/core';
19
+ import { CLAUDE_PROJECT_CONFIG } from './mcp-clients.js';
19
20
  /**
20
21
  * The registered command is STILL bare `npx` on every platform, deliberately.
21
22
  *
@@ -112,12 +113,27 @@ does not gate:
112
113
  "${MCP_SERVER_NAME}": { "command": "${WINDOWS_SHELL}", "args": ${JSON.stringify([WINDOWS_SHELL_C, NPX, ...serverInvocation().slice(1)])} }`;
113
114
  }
114
115
  /** Printed when the `claude` CLI isn't available — register Reticle globally once, by hand. */
116
+ /**
117
+ * Claude Code's PROJECT-scope config, which needs no CLI on PATH.
118
+ *
119
+ * The remedy a reporter had to work out for themselves: inside a Claude Code VS Code extension
120
+ * session the `claude` binary is not on PATH, so every CLI-based route is shut and `.mcp.json` in
121
+ * the project root is the one that still works.
122
+ */
123
+ export function claudeProjectMcpJson() {
124
+ const [command, ...args] = serverInvocation();
125
+ return `${CLAUDE_PROJECT_CONFIG} (project root):
126
+
127
+ { "mcpServers": { "${MCP_SERVER_NAME}": { "command": "${command ?? NPX}", "args": ${JSON.stringify(args)} } } }`;
128
+ }
115
129
  export function mcpManual() {
116
130
  const tail = serverInvocation().join(' ');
117
131
  return `Register the Reticle MCP server ONCE, globally (so every project gets it):
118
132
 
119
133
  ${CLAUDE_CLI} ${MCP_SUBCOMMAND} add ${MCP_SERVER_NAME} -s user -- ${tail}
120
134
 
135
+ Or, in Claude Code with no \`claude\` on PATH (a VS Code extension session), write ${claudeProjectMcpJson()}
136
+
121
137
  Or, for another agent, add this to its global MCP config (e.g. Cursor's ~/.cursor/mcp.json):
122
138
 
123
139
  "${MCP_SERVER_NAME}": { "command": "${NPX}", "args": ${JSON.stringify(serverInvocation().slice(1))} }
@@ -10,6 +10,8 @@ import type { InitOutcome } from '@reticlehq/core/telemetry';
10
10
  export interface InitOptions {
11
11
  /** `--capture-bodies`: write `captureNetworkBodies: true` into the app's config. Off by default (#705). */
12
12
  captureBodies?: boolean | undefined;
13
+ /** `--hooks`: also install the print-only Claude Code Stop hook. Off by default. */
14
+ hooks?: boolean | undefined;
13
15
  cwd: string;
14
16
  port: number | undefined;
15
17
  mcp: boolean;
@@ -111,6 +113,16 @@ export interface InitResult {
111
113
  ok: boolean;
112
114
  applied: number;
113
115
  manual: number;
116
+ /**
117
+ * The MCP server was registered by THIS run, rather than having been there already.
118
+ *
119
+ * `outcome.mcpRegistered` cannot answer this: it is true for both APPLY and ALREADY, and the two
120
+ * have opposite answers to the only question that matters here — must the agent reading this
121
+ * restart before it can call a `reticle_*` tool. An agent client reads its server list when it
122
+ * STARTS and never re-reads it, so on the run that first registers Reticle on a machine the tools
123
+ * are not in the session that just asked for them.
124
+ */
125
+ mcpNewlyRegistered?: boolean;
114
126
  /** What this run established. Absent only where init exits before establishing anything. */
115
127
  context?: InitContext;
116
128
  /**
package/dist/run.js CHANGED
@@ -20,6 +20,7 @@ import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, PACKAGE_JSON,
20
20
  import { redirectToWorkspaceApp } from './detect/workspace-redirect.js';
21
21
  import { isConnectStep } from './plan/connect-steps.js';
22
22
  import { CURSOR_RULE_PATH, RETICLE_MD_PATH } from './project/agent-rules.js';
23
+ import { CLAUDE_SETTINGS_PATH } from './plan/stop-hook-step.js';
23
24
  import { CRA_ENV_PATH } from './patch/cra.js';
24
25
  import { NEXT_LAYOUT_CANDIDATES, NEXT_PAGES_APP_CANDIDATES } from './patch/next-patch.js';
25
26
  import { formatGeneratedSource } from './patch/format-generated.js';
@@ -37,7 +38,7 @@ import { DEPS_TARGET, RETICLE_CONFIG_FILE, frameworkPackages, MCP_TARGET, buildP
37
38
  import { claudeAvailableProbe, claudeExistsProbe } from './register/mcp.js';
38
39
  import { reticleDevLocation } from './patch/next-patch.js';
39
40
  import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
40
- import { CURSOR_PROJECT_MARKER } from './register/mcp-clients.js';
41
+ import { CLAUDE_PROJECT_CONFIG, CURSOR_PROJECT_MARKER } from './register/mcp-clients.js';
41
42
  import { deriveProjectId, packageName } from './project/project-id.js';
42
43
  import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, connectArgWithToken, nuxtPluginPath, staticPageSnippet, djangoMiddlewareSnippet, reticleConfigContent, streamlitPageSnippet, } from './patch/snippets.js';
43
44
  import { NUXT_CONFIG_CANDIDATES } from './patch/nuxt-patch.js';
@@ -294,9 +295,12 @@ function gatherPlanInput(options, io, pkg) {
294
295
  return {
295
296
  detection,
296
297
  captureBodies: options.captureBodies,
298
+ hooks: options.hooks,
297
299
  cspSources,
298
300
  claudeCli,
299
301
  mcpExists,
302
+ insideClaudeCode: io.host.insideClaudeCode(),
303
+ claudeProjectConfig: options.mcp ? io.readFile(agentFile(CLAUDE_PROJECT_CONFIG)) : undefined,
300
304
  platform: process.platform,
301
305
  detectedClients,
302
306
  cursorProjectPresent: io.exists(CURSOR_PROJECT_MARKER),
@@ -316,6 +320,7 @@ function gatherPlanInput(options, io, pkg) {
316
320
  ? { path: layoutRelPath, source: astroLayoutSource }
317
321
  : null,
318
322
  astroEnvDts: io.readFile('src/env.d.ts'),
323
+ astroReticleDev: io.readFile('src/components/ReticleDev.ts'),
319
324
  nextConfigFile,
320
325
  nextConfigSource: null === nextConfigFile ? null : io.readFile(nextConfigFile),
321
326
  nextLayout: layoutPath !== null && layoutSource !== null
@@ -360,6 +365,8 @@ function gatherPlanInput(options, io, pkg) {
360
365
  cursorRuleContent: io.readFile(agentFile(CURSOR_RULE_PATH)),
361
366
  claudeCommandContent: io.readFile(agentFile(CLAUDE_COMMAND_PATH)),
362
367
  cursorCommandContent: io.readFile(agentFile(CURSOR_COMMAND_PATH)),
368
+ // Read only to MERGE into: the settings file is the user's, and other tools write in it too.
369
+ claudeSettingsContent: true === options.hooks ? io.readFile(agentFile(CLAUDE_SETTINGS_PATH)) : undefined,
363
370
  ...(agentRoot === undefined
364
371
  ? {}
365
372
  : {
@@ -393,6 +400,20 @@ devCommand,
393
400
  */
394
401
  continuesToRuntime = false) {
395
402
  io.print(dryRun ? 'reticle init (dry run, no files written)' : 'reticle init');
403
+ /*
404
+ * The escape hatches, named by the command that needs them, at the moment it needs them.
405
+ *
406
+ * `--dry-run`, `--app` and `--no-mcp` all exist and all work, and they were discoverable only
407
+ * from `--help`. A prospective user's agent refused to run this at all — correctly, since it was
408
+ * being asked to let a third-party package edit a build config and register itself with every
409
+ * agent on the machine, and nothing it could see offered a way to look first. It had already
410
+ * decided by the time `--help` would have told it.
411
+ *
412
+ * On the REAL run only. In a dry run the reader has already found the flag.
413
+ */
414
+ if (!dryRun) {
415
+ io.print(' --dry-run shows this plan and writes nothing · --app <dir> picks the app · --no-mcp skips agent registration');
416
+ }
396
417
  // Every path below is printed RELATIVE, and until now nothing said what to. Reported from the
397
418
  // field as "[✓] Reticle config → .reticle.json" followed by the file not being there: the app was
398
419
  // in `frontend/`, init redirected into it, and the report's `.reticle.json` was true about a
@@ -441,9 +462,14 @@ continuesToRuntime = false) {
441
462
  for (const line of detail.split('\n'))
442
463
  io.print(` ${line}`);
443
464
  }
444
- else if (detail.length > 0) {
445
- io.print(` ${detail}`);
446
- }
465
+ // Everything else gets its row and nothing more. The detail on a step that needs no decision
466
+ // restates its own title — "reticle already registered with Cursor" under
467
+ // `[·] MCP server (Cursor)` — and a measured Vite first run spent twelve of its forty-six lines
468
+ // that way, six tool names deep, before saying anything about the reader's own app.
469
+ //
470
+ // The ROW stays on every step: `apps/e2e/install-gate.mjs` reads `[mark] title → target` out of
471
+ // this report and diffs the shape against a recorded baseline, so a row that stopped printing
472
+ // would take that guard down with it while looking like tidying.
447
473
  }
448
474
  io.print('');
449
475
  if (connectPending) {
@@ -451,10 +477,20 @@ continuesToRuntime = false) {
451
477
  '"no browser session connected" until then. Everything else is already in place.');
452
478
  io.print('');
453
479
  }
480
+ const mcpStatus = resolvedStatus(plan, MCP_TARGET, failed, skipped);
454
481
  if (!continuesToRuntime) {
455
- io.print(restartHint(resolvedStatus(plan, MCP_TARGET, failed, skipped), devCommand));
482
+ io.print(restartHint(mcpStatus, devCommand));
456
483
  }
457
- return { ok: !connectPending, applied, manual };
484
+ // Carried out even when the hint above was printed, because the RUNTIME path needs the same fact
485
+ // and could not reach it: `restartHint` is only printed when this run stops at the files, and the
486
+ // full run -- the one the installer sends everybody to -- ended by telling an agent to call
487
+ // `reticle_act_and_wait` in a session whose tool list was read before Reticle existed.
488
+ return {
489
+ ok: !connectPending,
490
+ applied,
491
+ manual,
492
+ ...(StepStatus.APPLY === mcpStatus ? { mcpNewlyRegistered: true } : {}),
493
+ };
458
494
  }
459
495
  /**
460
496
  * Perform the apply-step side effects; return the targets whose side effect failed.