@reticlehq/init 3.2.0 → 3.4.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 (93) hide show
  1. package/dist/detect/capabilities.d.ts +4 -1
  2. package/dist/detect/capabilities.js +45 -9
  3. package/dist/detect/detect.d.ts +58 -4
  4. package/dist/detect/detect.js +117 -7
  5. package/dist/detect/dev-script.d.ts +26 -2
  6. package/dist/detect/dev-script.js +80 -7
  7. package/dist/detect/non-js-project.d.ts +1 -1
  8. package/dist/detect/non-js-project.js +6 -1
  9. package/dist/detect/workspace-apps.d.ts +21 -0
  10. package/dist/detect/workspace-apps.js +111 -12
  11. package/dist/detect/workspace-redirect.d.ts +2 -1
  12. package/dist/detect/workspace-redirect.js +28 -2
  13. package/dist/diagnose/containerised-dev-server.d.ts +12 -4
  14. package/dist/diagnose/containerised-dev-server.js +36 -4
  15. package/dist/diagnose/csp-check.d.ts +12 -6
  16. package/dist/diagnose/csp-check.js +61 -13
  17. package/dist/diagnose/csp-doctor.d.ts +33 -2
  18. package/dist/diagnose/csp-doctor.js +103 -6
  19. package/dist/diagnose/csp-step.d.ts +27 -0
  20. package/dist/diagnose/csp-step.js +86 -0
  21. package/dist/diagnose/desktop-doctor.d.ts +1 -1
  22. package/dist/diagnose/desktop-doctor.js +8 -2
  23. package/dist/diagnose/install-hint.d.ts +9 -1
  24. package/dist/diagnose/install-hint.js +17 -7
  25. package/dist/diagnose/install-retries.d.ts +18 -2
  26. package/dist/diagnose/install-retries.js +50 -3
  27. package/dist/diagnose/terminal-wrap.d.ts +18 -0
  28. package/dist/diagnose/terminal-wrap.js +63 -0
  29. package/dist/host.d.ts +6 -0
  30. package/dist/host.js +3 -0
  31. package/dist/index.d.ts +8 -3
  32. package/dist/index.js +8 -3
  33. package/dist/no-package-json.d.ts +10 -0
  34. package/dist/no-package-json.js +121 -0
  35. package/dist/node-io.d.ts +9 -5
  36. package/dist/node-io.js +25 -7
  37. package/dist/patch/angular.d.ts +68 -0
  38. package/dist/patch/angular.js +216 -0
  39. package/dist/patch/astro-patch.d.ts +48 -13
  40. package/dist/patch/astro-patch.js +151 -43
  41. package/dist/patch/cra.d.ts +0 -2
  42. package/dist/patch/cra.js +0 -2
  43. package/dist/patch/electron-patch.d.ts +10 -1
  44. package/dist/patch/electron-patch.js +79 -27
  45. package/dist/patch/electron-vite-patch.js +1 -1
  46. package/dist/patch/nuxt-patch.d.ts +1 -1
  47. package/dist/patch/nuxt-patch.js +2 -2
  48. package/dist/patch/nuxt-snippets.d.ts +44 -0
  49. package/dist/patch/nuxt-snippets.js +135 -0
  50. package/dist/patch/remix.d.ts +24 -0
  51. package/dist/patch/remix.js +59 -0
  52. package/dist/patch/snippets.d.ts +58 -57
  53. package/dist/patch/snippets.js +181 -202
  54. package/dist/patch/static-page.d.ts +39 -0
  55. package/dist/patch/static-page.js +102 -0
  56. package/dist/patch/tanstack-start.d.ts +36 -8
  57. package/dist/patch/tanstack-start.js +118 -29
  58. package/dist/patch/vite-config.d.ts +5 -0
  59. package/dist/patch/vite-config.js +8 -0
  60. package/dist/patch/vite-env-types.d.ts +9 -0
  61. package/dist/patch/vite-env-types.js +19 -0
  62. package/dist/patch/vite-owning-config.d.ts +47 -0
  63. package/dist/patch/vite-owning-config.js +104 -19
  64. package/dist/plan/connect-steps.d.ts +10 -3
  65. package/dist/plan/connect-steps.js +22 -3
  66. package/dist/plan/framework-adapter.d.ts +5 -6
  67. package/dist/plan/framework-adapter.js +105 -11
  68. package/dist/plan/notices.js +4 -3
  69. package/dist/plan/plan-angular.d.ts +6 -0
  70. package/dist/plan/plan-angular.js +89 -0
  71. package/dist/plan/plan-electron-vite.d.ts +6 -3
  72. package/dist/plan/plan-electron-vite.js +39 -8
  73. package/dist/plan/plan-framework.d.ts +9 -47
  74. package/dist/plan/plan-framework.js +114 -323
  75. package/dist/plan/plan-types.d.ts +47 -2
  76. package/dist/plan/plan-vite.d.ts +67 -0
  77. package/dist/plan/plan-vite.js +256 -0
  78. package/dist/plan/plan.js +155 -69
  79. package/dist/plan/port-steps.d.ts +45 -0
  80. package/dist/plan/port-steps.js +119 -0
  81. package/dist/plan/preflight.d.ts +17 -3
  82. package/dist/plan/preflight.js +53 -23
  83. package/dist/plan/stop-hook-step.d.ts +4 -0
  84. package/dist/plan/stop-hook-step.js +49 -0
  85. package/dist/project/agent-rules.js +2 -0
  86. package/dist/register/mcp-clients.d.ts +13 -0
  87. package/dist/register/mcp-clients.js +22 -0
  88. package/dist/register/mcp.d.ts +26 -5
  89. package/dist/register/mcp.js +64 -8
  90. package/dist/run-types.d.ts +10 -0
  91. package/dist/run.js +132 -78
  92. package/package.json +3 -4
  93. package/NOTICE +0 -5
package/dist/plan/plan.js CHANGED
@@ -3,23 +3,25 @@
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 { Framework, UiLibrary, installCommand, installCommandParts, } from '../detect/detect.js';
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
- import { cspStep } from './plan-framework.js';
13
+ import { cspStep } from '../diagnose/csp-step.js';
14
14
  import { frameworkSteps } from './framework-adapter.js';
15
15
  import { FRAMEWORK_ADAPTERS, RETICLE_BROWSER_SDK, RETICLE_REACT_KIT } from './framework-adapter.js';
16
- import { MCP_TARGET, StepStatus, } from './plan-types.js';
16
+ import { MCP_TARGET, StepStatus } from './plan-types.js';
17
17
  export { DEPS_TARGET, MCP_TARGET, StepStatus, } from './plan-types.js';
18
18
  import { join } from 'node:path';
19
- import { reticleConfigContent } from '../patch/snippets.js';
19
+ import { reticleConfigContent, usesReactKit } from '../patch/snippets.js';
20
20
  import { configWithInstallSource } from '../project/install-source-config.js';
21
- import { containerisedStep, uiLibraryStep, webGlCanvasStep, windowsMcpNoteStep } from './notices.js';
22
- import { existingConfigProblem, projectIdOf, RETICLE_CONFIG_FILE } from '../detect/existing-config.js';
21
+ import { portMoveDetail, retargetConfigPort } from './port-steps.js';
22
+ import { containerisedStep, uiLibraryStep, webGlCanvasStep, windowsMcpNoteStep, } from './notices.js';
23
+ import { existingConfigProblem, projectIdOf, RETICLE_CONFIG_FILE, } from '../detect/existing-config.js';
24
+ import { CLAUDE_SETTINGS_PATH, stopHookStep } from './stop-hook-step.js';
23
25
  // Re-exported: it moved to the module that reads it, and every existing importer says `plan.js`.
24
26
  export { RETICLE_CONFIG_FILE };
25
27
  /**
@@ -36,20 +38,6 @@ function pinnedPackages(packages, version) {
36
38
  return packages;
37
39
  return packages.map((p) => `${p}@${version}`);
38
40
  }
39
- /**
40
- * Does this codebase want the React kit, or the framework-neutral sensor?
41
- *
42
- * The kit is what adds component identity — component names and stacks — and it is worth having
43
- * wherever React or Preact is rendering (the adapter reaches Preact through `preact/compat`).
44
- * Everywhere else it is a package named `@reticlehq/react`, carrying `react` in its peer
45
- * dependencies, being installed into a codebase that has no React in it.
46
- *
47
- * UNKNOWN keeps the kit deliberately. Absence of evidence is not evidence of Vue, and guessing
48
- * "sensor" on no information silently drops component identity from apps that should have it.
49
- */
50
- function wantsReactKit(ui) {
51
- return ui !== UiLibrary.VUE && ui !== UiLibrary.SVELTE;
52
- }
53
41
  /**
54
42
  * The dev-dependencies `reticle init` installs — kit (or sensor) first, build plugin next.
55
43
  *
@@ -59,7 +47,7 @@ function wantsReactKit(ui) {
59
47
  * right after `init` had detected the real UI library and said so on screen.
60
48
  */
61
49
  export function frameworkPackages(framework, uiLibrary = UiLibrary.UNKNOWN) {
62
- const kit = wantsReactKit(uiLibrary) ? RETICLE_REACT_KIT : RETICLE_BROWSER_SDK;
50
+ const kit = usesReactKit(uiLibrary, framework) ? RETICLE_REACT_KIT : RETICLE_BROWSER_SDK;
63
51
  return FRAMEWORK_ADAPTERS[framework].packages(kit);
64
52
  }
65
53
  /**
@@ -104,40 +92,49 @@ function claudeMcpStep(input) {
104
92
  * parse is reported with a paste-able block rather than overwritten, and everything else is written.
105
93
  */
106
94
  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({
95
+ return (input.detectedClients ?? []).map((detected) => clientStep(clientSpec(detected.id), detected.configPath, detected.existing));
96
+ }
97
+ /** One client's step: already-correct is left alone, an unparseable file gets a paste-able block. */
98
+ function clientStep(spec, configPath, existing) {
99
+ const merged = mergeClientConfig(spec, existing);
100
+ const title = `MCP server (${spec.label})`;
101
+ if (merged.status === ClientMergeStatus.ALREADY) {
102
+ return {
133
103
  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
- });
104
+ target: configPath,
105
+ status: StepStatus.ALREADY,
106
+ detail: `reticle already registered with ${spec.label}`,
107
+ };
139
108
  }
140
- return steps;
109
+ if (merged.status === ClientMergeStatus.MANUAL) {
110
+ // Either the file did not parse, or the format is one we refuse to edit blind (TOML). Both
111
+ // end the same way: say so, and hand over the exact block.
112
+ return {
113
+ title,
114
+ target: configPath,
115
+ status: StepStatus.MANUAL,
116
+ detail: `add this to ${configPath} by hand:\n${clientSnippet(spec)}`,
117
+ };
118
+ }
119
+ return {
120
+ title,
121
+ target: configPath,
122
+ status: StepStatus.APPLY,
123
+ detail: `register reticle with ${spec.label}`,
124
+ write: { path: configPath, content: merged.content },
125
+ };
126
+ }
127
+ /**
128
+ * Claude Code's project `.mcp.json`, written only when `init` runs inside Claude Code with no CLI on
129
+ * PATH (a VS Code extension session). There the reporter had to write this file by hand (#1071,
130
+ * #1078); anywhere else a `.mcp.json` is a file in somebody's repo for a client they may not use, so
131
+ * they get the notice instead.
132
+ */
133
+ function claudeProjectStep(input) {
134
+ if (input.claudeCli || true !== input.insideClaudeCode)
135
+ return null;
136
+ const path = agentFile(input, CLAUDE_PROJECT_CONFIG);
137
+ return clientStep(CLAUDE_PROJECT_SPEC, path, input.claudeProjectConfig ?? null);
141
138
  }
142
139
  /** One global registration per detected agent (Claude + Cursor). Falls back to a manual note. */
143
140
  function mcpSteps(input) {
@@ -157,7 +154,12 @@ function mcpSteps(input) {
157
154
  // Claude and Cursor first (they have their own registration paths), then every other detected
158
155
  // client. A machine with Cursor AND Windsurf gets both — registering only the first one found is
159
156
  // how a user ends up with Reticle in the editor they were not using.
160
- const steps = [...stepsForAgents(input, (a) => a.mcpStep), ...otherClientSteps(input)];
157
+ const claudeProject = claudeProjectStep(input);
158
+ const steps = [
159
+ ...stepsForAgents(input, (a) => a.mcpStep),
160
+ ...(null === claudeProject ? [] : [claudeProject]),
161
+ ...otherClientSteps(input),
162
+ ];
161
163
  if (0 === steps.length) {
162
164
  // No agent detected. mcpManual already carries the Windows cmd fallback — do not append it again.
163
165
  return [
@@ -169,6 +171,32 @@ function mcpSteps(input) {
169
171
  },
170
172
  ];
171
173
  }
174
+ /*
175
+ * Claude Code never leaves the plan without a word (#1071).
176
+ *
177
+ * It is the only client detected by CLI-on-PATH rather than by its config, so inside a Claude Code
178
+ * VS Code extension session — where `claude` is not on PATH — it reads as absent while the user is
179
+ * sitting in it. `claudeMcpStep` then returned null, and the manual fallback below only fires when
180
+ * NO agent at all was found. With Gemini and Codex present, Claude Code vanished from the plan
181
+ * entirely and nothing said so. A step that disappears is exactly what the install gate's baseline
182
+ * diff exists to catch, and the plan is the one artifact a person reads to learn what init did.
183
+ *
184
+ * Only when something else WAS found. With nothing found, the generic note below already says how
185
+ * to register, so nothing has silently vanished — and leaving that path untouched keeps every
186
+ * pristine scaffold in the install baseline reading exactly as it did.
187
+ */
188
+ if (!input.claudeCli && null === claudeProject && steps.length > 0) {
189
+ steps.push({
190
+ title: CLAUDE_MCP_TITLE,
191
+ target: MCP_TARGET,
192
+ // NOTICE, not MANUAL. Nothing here FAILED and the reader may not use Claude Code at all, so
193
+ // this is something to know rather than work owed — and the install gate asserts zero `⚠`,
194
+ // which would turn an informational line into a gate failure on any machine that happens to
195
+ // have another agent's config. A notice prints in full exactly like a manual step.
196
+ status: StepStatus.NOTICE,
197
+ 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()}`,
198
+ });
199
+ }
172
200
  const windowsNote = windowsMcpNoteStep(input);
173
201
  if (windowsNote !== null)
174
202
  steps.push(windowsNote);
@@ -415,10 +443,51 @@ function agentRuleSteps(input) {
415
443
  * with nothing naming a version. Pinning turns that into this loud failure, which is the better
416
444
  * trade — but only if the message says what to do about it.
417
445
  */
446
+ /**
447
+ * Is this package already declared at the version we would pin?
448
+ *
449
+ * The caret is the point. `npm i -D pkg@3.2.0` WRITES `^3.2.0`, so on every later run the pinned
450
+ * `3.2.0` failed a string comparison against the range npm had just created, and init re-installed
451
+ * for ever. Only the two forms a package manager writes by itself are read - an exact version, and
452
+ * `^`/`~` over that same version.
453
+ *
454
+ * Everything else installs. A false "already" leaves somebody with no SDK and an install that
455
+ * claims to have run; a redundant install only costs time, so the unreadable cases go the safe way.
456
+ */
457
+ function alreadyDeclared(declared, pinned) {
458
+ if (declared === undefined)
459
+ return false;
460
+ if (pinned === undefined || 0 === pinned.length)
461
+ return true;
462
+ return declared === pinned || `^${pinned}` === declared || `~${pinned}` === declared;
463
+ }
418
464
  function installStep(input) {
419
465
  const pm = input.detection.packageManager;
466
+ // `Detection.packageManagerCommand` (see `preflight.ts`, #1149). Falling back to `pm` covers every
467
+ // caller that never went through preflight (tests, and any future one).
468
+ const pmCommand = input.detection.packageManagerCommand ?? pm;
420
469
  const packages = pinnedPackages(frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion);
421
- const command = installCommand(pm, packages);
470
+ const command = installCommand(pm, packages, pmCommand);
471
+ /*
472
+ * A re-run over an already-wired project does NO dependency work.
473
+ *
474
+ * Reported from the field: `init` on an instrumented npm-workspaces project ran a pnpm dependency
475
+ * migration, which moved the existing `node_modules` aside and broke the dev server. Whatever
476
+ * manager gets chosen, the redundant install is the step that touches the tree — so the fix that
477
+ * matters is not running one that nothing needs. `init` is documented as idempotent; this is the
478
+ * step that was not.
479
+ */
480
+ const declared = input.detection.dependencies ?? {};
481
+ const version = input.options.sdkVersion;
482
+ const names = frameworkPackages(input.detection.framework, input.detection.uiLibrary);
483
+ if (names.length > 0 && names.every((n) => alreadyDeclared(declared[n], version))) {
484
+ return {
485
+ title: 'Install dependencies',
486
+ target: 'package.json',
487
+ status: StepStatus.ALREADY,
488
+ detail: `${names.join(', ')} already declared${version === undefined || 0 === version.length ? '' : ` at ${version}`}`,
489
+ };
490
+ }
422
491
  if (!input.options.install) {
423
492
  return {
424
493
  title: 'Install dependencies',
@@ -427,7 +496,7 @@ function installStep(input) {
427
496
  detail: command,
428
497
  };
429
498
  }
430
- const parts = installCommandParts(pm, packages);
499
+ const parts = installCommandParts(pm, packages, [], pmCommand);
431
500
  return {
432
501
  title: 'Install dependencies',
433
502
  target: 'package.json',
@@ -436,9 +505,9 @@ function installStep(input) {
436
505
  exec: {
437
506
  command: parts.command,
438
507
  args: parts.args,
439
- fallback: `${command}\n\n${installFailureHint(pm)}`,
508
+ fallback: `${command}\n\n${installFailureHint(pm, pmCommand)}`,
440
509
  },
441
- retries: installRetries(pm, packages, frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion),
510
+ retries: installRetries(pm, packages, frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion, pmCommand),
442
511
  };
443
512
  }
444
513
  const RETICLE_CONFIG_TITLE = 'Reticle config';
@@ -513,9 +582,9 @@ function agentRootConfigStep(input, content) {
513
582
  },
514
583
  ];
515
584
  }
516
- function reticleConfigStep(input, content) {
585
+ function reticleConfigStep(input, content, onDisk, movedFrom) {
517
586
  if (true === input.reticleConfigExists) {
518
- const problem = existingConfigProblem(input.reticleConfigSource);
587
+ const problem = existingConfigProblem(onDisk);
519
588
  if (problem !== undefined) {
520
589
  // `ℹ`, not `·`: the step is done and something about the result still stops things working,
521
590
  // which is the one mark that says "there is something here to read".
@@ -528,14 +597,17 @@ function reticleConfigStep(input, content) {
528
597
  }
529
598
  // The one thing a re-run can still learn: which channel the user actually arrived through.
530
599
  // See configWithInstallSource — it only ever ADDS a field that is absent.
531
- const backfilled = configWithInstallSource(input.reticleConfigSource, input.installSource);
532
- if (backfilled !== undefined) {
600
+ const backfilled = configWithInstallSource(onDisk, input.installSource);
601
+ if (backfilled !== undefined || movedFrom !== undefined) {
602
+ const port = input.options.port;
533
603
  return {
534
604
  title: RETICLE_CONFIG_TITLE,
535
605
  target: RETICLE_CONFIG_FILE,
536
606
  status: StepStatus.APPLY,
537
- detail: 'record which install route this project came through',
538
- write: { path: RETICLE_CONFIG_FILE, content: backfilled },
607
+ detail: movedFrom !== undefined && port !== undefined
608
+ ? portMoveDetail(movedFrom, port)
609
+ : 'record which install route this project came through',
610
+ write: { path: RETICLE_CONFIG_FILE, content: backfilled ?? onDisk ?? content },
539
611
  };
540
612
  }
541
613
  return {
@@ -557,16 +629,27 @@ function reticleConfigStep(input, content) {
557
629
  * The project config, in every directory that has to read it: the app's, and — after a redirect —
558
630
  * the one the agent runs from. One content string for both, so they cannot disagree; an existing
559
631
  * app-side config wins over a freshly derived one, or a re-run would copy a DIFFERENT identity up.
632
+ *
633
+ * Except its port, when a re-run asked for a different one: the daemon is about to start on the
634
+ * requested port, and a config still naming the old one is half of a split brain (see
635
+ * port-steps.ts). Only the port moves; the identity stays the one already on disk.
560
636
  */
561
637
  function reticleConfigSteps(input) {
562
638
  const existing = input.reticleConfigSource;
563
- const content = true === input.reticleConfigExists && null !== existing && existing !== undefined
639
+ const onDisk = true === input.reticleConfigExists && null !== existing && existing !== undefined
564
640
  ? existing
565
- : reticleConfigContent(input.detection.framework, input.options.port, input.options.projectId,
641
+ : undefined;
642
+ const moved = onDisk === undefined ? null : retargetConfigPort(onDisk, input.options.port);
643
+ const content = moved?.code ??
644
+ onDisk ??
645
+ reticleConfigContent(input.detection.framework, input.options.port, input.options.projectId,
566
646
  // Only when it is actually known. Writing `unknown` would be indistinguishable from a
567
647
  // config written before this field existed, and the two mean different things.
568
648
  input.installSource);
569
- return [reticleConfigStep(input, content), ...agentRootConfigStep(input, content)];
649
+ return [
650
+ reticleConfigStep(input, content, moved?.code ?? existing, moved?.from),
651
+ ...agentRootConfigStep(input, content),
652
+ ];
570
653
  }
571
654
  export function buildPlan(input) {
572
655
  const steps = [
@@ -574,6 +657,9 @@ export function buildPlan(input) {
574
657
  ...mcpSteps(input),
575
658
  ...agentRuleSteps(input),
576
659
  ...slashCommandSteps(input),
660
+ ...(true === input.hooks
661
+ ? [stopHookStep(agentFile(input, CLAUDE_SETTINGS_PATH), input.claudeSettingsContent)]
662
+ : []),
577
663
  ...uiLibraryStep(input),
578
664
  ...webGlCanvasStep(input),
579
665
  installStep(input),
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Moving a project that `init` already wired onto a different bridge port.
3
+ *
4
+ * Re-running `init --port 4689` over a project wired at 4688 used to be a silent no-op that
5
+ * reported success: every step read "already", nothing was rewritten, setup started the daemon on
6
+ * 4689, and the page kept dialling 4688 — "never dialled the bridge", with every file green. Each
7
+ * of those files had an "exists → done" check that never looked at the port inside it.
8
+ *
9
+ * So the port is compared wherever `init` wrote one, and rewritten IN PLACE: the rest of each file
10
+ * is the user's (registered stores, their own plugins), so only the number moves. With no `--port`
11
+ * on the re-run there is nothing requested to converge on, and every file is left exactly as it is —
12
+ * a re-run without the flag must not quietly reset a project to the default port.
13
+ */
14
+ import { type Step } from './plan-types.js';
15
+ /** A rewritten source, and the port it was moved off (for the "port A → B" line). */
16
+ export interface Retargeted {
17
+ code: string;
18
+ from: number;
19
+ }
20
+ /** The one line both rewrites print, so every file reports a move the same way. */
21
+ export declare function portMoveDetail(from: number, to: number): string;
22
+ /**
23
+ * Rewrite every port `init` baked into a generated source file, or null when none differs.
24
+ *
25
+ * Covers the two shapes `init` writes: the bridge URL literal (Next's component, the SvelteKit hook,
26
+ * the Nuxt plugin) and the plugin option (`reticle({ port })` in a Vite config). A Vite config with
27
+ * a bare `reticle()` is null: the plugin reads the port from `.reticle.json`, which moves with it.
28
+ */
29
+ export declare function retargetPort(source: string, to: number | undefined): Retargeted | null;
30
+ /**
31
+ * The same, for `.reticle.json`: the `port` field set to the requested one, every other field kept.
32
+ *
33
+ * An absent `port` means the default, so moving onto the default REMOVES the field rather than
34
+ * writing `4400` — the same shape a fresh install writes (see `reticleConfigContent`). Unreadable
35
+ * JSON is null: `existingConfigProblem` already reports it, and rewriting a file we cannot parse
36
+ * would drop whatever the user meant by it.
37
+ */
38
+ export declare function retargetConfigPort(source: string, to: number | undefined): Retargeted | null;
39
+ /**
40
+ * The step for a file `init` already wrote, once a re-run's `--port` has been compared with it.
41
+ *
42
+ * Every "file exists" branch used to return ALREADY without reading the port inside the file; this is
43
+ * the one place that turns the rewrite above into a step.
44
+ */
45
+ export declare function alreadyOrMovedPort(already: Step, source: string | null | undefined, port: number | undefined): Step;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Moving a project that `init` already wired onto a different bridge port.
3
+ *
4
+ * Re-running `init --port 4689` over a project wired at 4688 used to be a silent no-op that
5
+ * reported success: every step read "already", nothing was rewritten, setup started the daemon on
6
+ * 4689, and the page kept dialling 4688 — "never dialled the bridge", with every file green. Each
7
+ * of those files had an "exists → done" check that never looked at the port inside it.
8
+ *
9
+ * So the port is compared wherever `init` wrote one, and rewritten IN PLACE: the rest of each file
10
+ * is the user's (registered stores, their own plugins), so only the number moves. With no `--port`
11
+ * on the re-run there is nothing requested to converge on, and every file is left exactly as it is —
12
+ * a re-run without the flag must not quietly reset a project to the default port.
13
+ */
14
+ import { RETICLE_CLIENT_HOST, RETICLE_DEFAULT_PORT, RETICLE_WS_PATH, bridgeWsUrl, } from '@reticlehq/core';
15
+ import { StepStatus } from './plan-types.js';
16
+ /** The bridge URL every generated connect bakes (`bridgeWsUrl`), with its port captured. */
17
+ const BRIDGE_URL = new RegExp(`(ws://${RETICLE_CLIENT_HOST}:)(\\d+)(${RETICLE_WS_PATH})\\b`, 'g');
18
+ /** The opening of the connect call every generated connect file makes. */
19
+ const CONNECT_CALL = 'reticle.connect({';
20
+ /** `port: <n>` inside the options object of a `reticle({ … })` plugin call. */
21
+ const PLUGIN_PORT = /(\breticle\(\s*\{[^}]*?\bport\s*:\s*)(\d+)/g;
22
+ /** The one line both rewrites print, so every file reports a move the same way. */
23
+ export function portMoveDetail(from, to) {
24
+ return `port ${String(from)} → ${String(to)}`;
25
+ }
26
+ /**
27
+ * Rewrite every port `init` baked into a generated source file, or null when none differs.
28
+ *
29
+ * Covers the two shapes `init` writes: the bridge URL literal (Next's component, the SvelteKit hook,
30
+ * the Nuxt plugin) and the plugin option (`reticle({ port })` in a Vite config). A Vite config with
31
+ * a bare `reticle()` is null: the plugin reads the port from `.reticle.json`, which moves with it.
32
+ */
33
+ export function retargetPort(source, to) {
34
+ if (to === undefined)
35
+ return null;
36
+ let from;
37
+ let baked = false;
38
+ /** True when this literal is the one to move; remembers the first port moved off. */
39
+ const moves = (port) => {
40
+ baked = true;
41
+ const current = Number(port);
42
+ if (current === to)
43
+ return false;
44
+ from ??= current;
45
+ return true;
46
+ };
47
+ const target = String(to);
48
+ const code = source
49
+ .replace(BRIDGE_URL, (match, head, port, tail) => moves(port) ? `${head}${target}${tail}` : match)
50
+ .replace(PLUGIN_PORT, (match, head, port) => moves(port) ? `${head}${target}` : match);
51
+ if (from !== undefined)
52
+ return { code, from };
53
+ // A port was baked and it is already the requested one: nothing to do. Falling through here used
54
+ // to insert a SECOND `url:` beside the one already there on every same-port re-run —
55
+ // `TS1117: An object literal cannot have multiple properties with the same name`, and the app's
56
+ // build failed.
57
+ if (baked)
58
+ return null;
59
+ // A default-port install bakes no URL at all — the SDK's default IS 4400 — so there is nothing to
60
+ // rewrite, and the page would go on dialling 4400. Give the connect call the URL it now needs.
61
+ if (RETICLE_DEFAULT_PORT === to || !source.includes(CONNECT_CALL))
62
+ return null;
63
+ return {
64
+ code: source.replace(CONNECT_CALL, `${CONNECT_CALL} url: '${bridgeWsUrl(to)}',`),
65
+ from: RETICLE_DEFAULT_PORT,
66
+ };
67
+ }
68
+ /**
69
+ * The same, for `.reticle.json`: the `port` field set to the requested one, every other field kept.
70
+ *
71
+ * An absent `port` means the default, so moving onto the default REMOVES the field rather than
72
+ * writing `4400` — the same shape a fresh install writes (see `reticleConfigContent`). Unreadable
73
+ * JSON is null: `existingConfigProblem` already reports it, and rewriting a file we cannot parse
74
+ * would drop whatever the user meant by it.
75
+ */
76
+ export function retargetConfigPort(source, to) {
77
+ if (to === undefined)
78
+ return null;
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(source);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed))
87
+ return null;
88
+ const fields = { ...parsed };
89
+ const declared = fields['port'];
90
+ const from = 'number' === typeof declared ? declared : RETICLE_DEFAULT_PORT;
91
+ if (from === to)
92
+ return null;
93
+ if (RETICLE_DEFAULT_PORT === to)
94
+ delete fields['port'];
95
+ else
96
+ fields['port'] = to;
97
+ return { code: `${JSON.stringify(fields, null, 2)}\n`, from };
98
+ }
99
+ /**
100
+ * The step for a file `init` already wrote, once a re-run's `--port` has been compared with it.
101
+ *
102
+ * Every "file exists" branch used to return ALREADY without reading the port inside the file; this is
103
+ * the one place that turns the rewrite above into a step.
104
+ */
105
+ export function alreadyOrMovedPort(already, source, port) {
106
+ if (StepStatus.ALREADY !== already.status)
107
+ return already;
108
+ if ('string' !== typeof source || port === undefined)
109
+ return already;
110
+ const moved = retargetPort(source, port);
111
+ if (null === moved)
112
+ return already;
113
+ return {
114
+ ...already,
115
+ status: StepStatus.APPLY,
116
+ detail: portMoveDetail(moved.from, port),
117
+ write: { path: already.target, content: moved.code },
118
+ };
119
+ }
@@ -16,8 +16,22 @@ export interface PreflightIo {
16
16
  /** Runs a command quietly for a yes/no check; true on exit code 0. */
17
17
  probe(command: string, args: readonly string[]): boolean;
18
18
  }
19
+ /** What `preflight` decided: run through `command`, or refuse with this message. */
20
+ export type PreflightResult = {
21
+ refusal: string;
22
+ command?: undefined;
23
+ } | {
24
+ refusal?: undefined;
25
+ command: string;
26
+ };
19
27
  /**
20
- * The refusal to print, or undefined when this machine can run the install.
28
+ * The one place this run decides whether — and how — it can invoke the package manager.
29
+ *
30
+ * Resolves the invocation ONCE: earlier, `preflightRefusal` probed the machine to decide whether to
31
+ * refuse, and callers that got past it probed AGAIN, separately, to learn what the resolved command
32
+ * actually was — two subprocesses (the bare binary, then corepack) doing the same test twice on every
33
+ * ordinary run. This is the single call site: it returns either the command every later step should
34
+ * invoke, or the refusal to print, never both and never neither.
21
35
  *
22
36
  * `packageManager` is the one init RESOLVED, never a raw lockfile check. An inherited
23
37
  * `pnpm-lock.yaml` at a monorepo root does not mean the app in `frontend/` uses pnpm — that app's own
@@ -25,6 +39,6 @@ export interface PreflightIo {
25
39
  * here from `exists('pnpm-lock.yaml')` refused an npm app sitting under a pnpm monorepo on a machine
26
40
  * with no pnpm, which the install gate proves must succeed.
27
41
  */
28
- export declare function preflightRefusal(io: PreflightIo, packageManager: string, options?: {
42
+ export declare function preflight(io: PreflightIo, packageManager: string, options?: {
29
43
  alreadyServed?: boolean;
30
- }): string | undefined;
44
+ }): PreflightResult;
@@ -11,8 +11,38 @@
11
11
  * npm ships with node, so refusing for its absence would refuse on a machine that is fine.
12
12
  */
13
13
  const ALWAYS_PRESENT = 'npm';
14
+ /** The one program allowed to run a package manager it cannot find on PATH itself. */
15
+ const COREPACK = 'corepack';
14
16
  /**
15
- * The refusal to print, or undefined when this machine can run the install.
17
+ * The prefix this run can actually invoke `packageManager` through, or undefined when neither the
18
+ * bare binary nor corepack can.
19
+ *
20
+ * Reported from the field (#1149): a Windows machine with a corepack-managed pnpm — no `pnpm` on
21
+ * PATH, only `corepack`, which resolves it from the project's `packageManager` field — failed the
22
+ * bare `pnpm --version` probe and refused, even though `corepack pnpm --version` succeeds and every
23
+ * later step could have run through it. `corepack <pm>` is not a fallback guess: corepack shipped
24
+ * with node and does exactly what the bare binary would have, so a machine that can only reach it
25
+ * that way is not missing the tool, only the shim. Every other place in this codebase that needs to
26
+ * talk about a corepack-only machine (`Detection.packageManagerCommand` and its readers) points back
27
+ * here rather than re-explaining it.
28
+ */
29
+ function resolvedPmCommand(io, packageManager) {
30
+ if (ALWAYS_PRESENT === packageManager)
31
+ return packageManager;
32
+ if (io.probe(packageManager, ['--version']))
33
+ return packageManager;
34
+ if (io.probe(COREPACK, [packageManager, '--version']))
35
+ return `${COREPACK} ${packageManager}`;
36
+ return undefined;
37
+ }
38
+ /**
39
+ * The one place this run decides whether — and how — it can invoke the package manager.
40
+ *
41
+ * Resolves the invocation ONCE: earlier, `preflightRefusal` probed the machine to decide whether to
42
+ * refuse, and callers that got past it probed AGAIN, separately, to learn what the resolved command
43
+ * actually was — two subprocesses (the bare binary, then corepack) doing the same test twice on every
44
+ * ordinary run. This is the single call site: it returns either the command every later step should
45
+ * invoke, or the refusal to print, never both and never neither.
16
46
  *
17
47
  * `packageManager` is the one init RESOLVED, never a raw lockfile check. An inherited
18
48
  * `pnpm-lock.yaml` at a monorepo root does not mean the app in `frontend/` uses pnpm — that app's own
@@ -20,33 +50,33 @@ const ALWAYS_PRESENT = 'npm';
20
50
  * here from `exists('pnpm-lock.yaml')` refused an npm app sitting under a pnpm monorepo on a machine
21
51
  * with no pnpm, which the install gate proves must succeed.
22
52
  */
23
- export function preflightRefusal(io, packageManager, options = {}) {
53
+ export function preflight(io, packageManager, options = {}) {
24
54
  // First: on a read-only checkout nothing else matters, and one access check is cheaper and
25
55
  // quieter than spawning a subprocess to discover the same thing.
26
56
  if (!io.canWrite()) {
27
- return (`${io.cwd()} is not writable, and init has to write into it (.reticle.json, the build config, ` +
28
- 'a capabilities file). Fix the permissions, or run init from a checkout you own.');
57
+ return {
58
+ refusal: `${io.cwd()} is not writable, and init has to write into it (.reticle.json, the build config, ` +
59
+ 'a capabilities file). Fix the permissions, or run init from a checkout you own.',
60
+ };
29
61
  }
62
+ const resolved = resolvedPmCommand(io, packageManager);
63
+ if (resolved !== undefined)
64
+ return { command: resolved };
30
65
  // What the project resolves to says nothing about what the machine HAS, and a project committed to
31
66
  // pnpm on an npm-only box is an ordinary Monday.
32
67
  //
33
- // `alreadyServed` is `--url`, and skipping the check for it is the message keeping its own promise.
34
- // This guard exists for the DEV SERVER — to stop `spawn pnpm ENOENT` surfacing inside "the dev
35
- // server exited" — and `--url` says the app is already up, so init starts nothing and the failure
36
- // it protects against cannot happen. Reported from the field as a circle: the refusal named `--url`
37
- // as the way past it while `--url` was being passed, because the flag was parsed and then never
38
- // handed to init at all.
39
- //
40
- // The dependency install can still fail without the package manager. That is one step reporting ⚠,
41
- // which is what a step that cannot complete is for, and is a better outcome than writing nothing:
42
- // the config, the connect module and `.reticle.json` all land, and the user is told the one thing
43
- // left to do rather than being sent to install a package manager they were trying to avoid.
44
- if (true !== options.alreadyServed &&
45
- ALWAYS_PRESENT !== packageManager &&
46
- !io.probe(packageManager, ['--version'])) {
47
- return (`this project uses ${packageManager} (its lockfile says so) and ${packageManager} is not ` +
48
- `installed on this machine. Install it (npm i -g ${packageManager}, or corepack enable), or ` +
49
- 'pass --url with the address the app already serves.');
50
- }
51
- return undefined;
68
+ // `alreadyServed` is `--url`. This guard exists for the DEV SERVER — to stop `spawn pnpm ENOENT`
69
+ // surfacing inside "the dev server exited" — and `--url` says the app is already up, so init starts
70
+ // nothing and the failure it protects against cannot happen. The dependency install can still fail
71
+ // without the package manager (one step reporting ⚠, a better outcome than writing nothing), so a
72
+ // command is still returned here — the bare name, since neither probe above found one — rather than
73
+ // refusing a run that `--url` already made safe.
74
+ if (true === options.alreadyServed)
75
+ return { command: packageManager };
76
+ return {
77
+ refusal: `this project uses ${packageManager} (its lockfile says so) and ${packageManager} is not ` +
78
+ `installed on this machine — and corepack ${packageManager} could not run it either. Install ` +
79
+ `it (npm i -g ${packageManager}), or enable corepack for it (corepack enable — on Windows this ` +
80
+ 'needs an elevated shell), or pass --url with the address the app already serves.',
81
+ };
52
82
  }
@@ -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;