@reticlehq/init 3.3.0 → 3.5.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 (84) 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 +45 -2
  4. package/dist/detect/detect.js +76 -5
  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.js +28 -2
  12. package/dist/diagnose/containerised-dev-server.d.ts +12 -4
  13. package/dist/diagnose/containerised-dev-server.js +36 -4
  14. package/dist/diagnose/csp-check.d.ts +12 -6
  15. package/dist/diagnose/csp-check.js +61 -13
  16. package/dist/diagnose/csp-doctor.d.ts +13 -1
  17. package/dist/diagnose/csp-doctor.js +2 -2
  18. package/dist/diagnose/csp-step.d.ts +27 -0
  19. package/dist/diagnose/csp-step.js +86 -0
  20. package/dist/diagnose/desktop-doctor.js +8 -2
  21. package/dist/diagnose/init-failure.d.ts +7 -0
  22. package/dist/diagnose/init-failure.js +7 -0
  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 +16 -1
  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/index.d.ts +6 -2
  30. package/dist/index.js +6 -2
  31. package/dist/no-package-json.d.ts +10 -0
  32. package/dist/no-package-json.js +131 -0
  33. package/dist/node-io.d.ts +12 -6
  34. package/dist/node-io.js +33 -11
  35. package/dist/patch/angular.d.ts +68 -0
  36. package/dist/patch/angular.js +216 -0
  37. package/dist/patch/astro-patch.d.ts +1 -1
  38. package/dist/patch/astro-patch.js +2 -2
  39. package/dist/patch/cra.d.ts +0 -2
  40. package/dist/patch/cra.js +0 -2
  41. package/dist/patch/electron-patch.d.ts +10 -1
  42. package/dist/patch/electron-patch.js +79 -27
  43. package/dist/patch/electron-vite-patch.js +1 -1
  44. package/dist/patch/nuxt-patch.d.ts +1 -1
  45. package/dist/patch/nuxt-patch.js +2 -2
  46. package/dist/patch/nuxt-snippets.js +6 -6
  47. package/dist/patch/remix.d.ts +24 -0
  48. package/dist/patch/remix.js +59 -0
  49. package/dist/patch/snippets.d.ts +57 -9
  50. package/dist/patch/snippets.js +119 -28
  51. package/dist/patch/static-page.d.ts +39 -0
  52. package/dist/patch/static-page.js +102 -0
  53. package/dist/patch/tanstack-start.d.ts +36 -8
  54. package/dist/patch/tanstack-start.js +118 -29
  55. package/dist/patch/vite-config.d.ts +5 -0
  56. package/dist/patch/vite-config.js +8 -0
  57. package/dist/patch/vite-owning-config.d.ts +34 -0
  58. package/dist/patch/vite-owning-config.js +63 -5
  59. package/dist/plan/connect-steps.d.ts +7 -1
  60. package/dist/plan/connect-steps.js +18 -1
  61. package/dist/plan/framework-adapter.d.ts +3 -1
  62. package/dist/plan/framework-adapter.js +99 -6
  63. package/dist/plan/notices.js +4 -3
  64. package/dist/plan/plan-angular.d.ts +6 -0
  65. package/dist/plan/plan-angular.js +89 -0
  66. package/dist/plan/plan-electron-vite.d.ts +6 -3
  67. package/dist/plan/plan-electron-vite.js +39 -8
  68. package/dist/plan/plan-framework.d.ts +8 -46
  69. package/dist/plan/plan-framework.js +81 -312
  70. package/dist/plan/plan-types.d.ts +29 -0
  71. package/dist/plan/plan-vite.d.ts +67 -0
  72. package/dist/plan/plan-vite.js +256 -0
  73. package/dist/plan/plan.js +38 -34
  74. package/dist/plan/port-steps.d.ts +45 -0
  75. package/dist/plan/port-steps.js +119 -0
  76. package/dist/plan/preflight.d.ts +17 -3
  77. package/dist/plan/preflight.js +53 -23
  78. package/dist/register/mcp-clients.js +5 -3
  79. package/dist/register/mcp.d.ts +18 -5
  80. package/dist/register/mcp.js +48 -8
  81. package/dist/run-types.d.ts +8 -0
  82. package/dist/run.js +118 -80
  83. package/package.json +3 -4
  84. package/NOTICE +0 -5
@@ -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
  }
@@ -162,9 +162,8 @@ export const MCP_CLIENTS = [
162
162
  * `~/.gemini/config/mcp_config.json`, and one file serves the 2.0 IDE, the CLI and the SDK. Same
163
163
  * `command`/`args` object Cursor, Windsurf and Gemini CLI already use.
164
164
  *
165
- * It shares the `~/.gemini` tree with Gemini CLI, which is why the marker matters: registration is
166
- * gated on the marker DIRECTORY, and these resolve to `.gemini/config` and `.gemini` respectively,
167
- * so having one installed cannot make `init` write a config for the other.
165
+ * It shares the `~/.gemini` tree with Gemini CLI. Gemini detection requires its settings file:
166
+ * checking only the parent directory also matches an Antigravity-only installation.
168
167
  *
169
168
  * NOT verified against a running install. The path is documented, the merge is the shared JSON
170
169
  * path that every other client here uses, and an unparseable config still degrades to a printed
@@ -329,6 +328,9 @@ export function clientSnippet(spec) {
329
328
  export function clientMarkerRelPath(spec) {
330
329
  if (spec.scope === ConfigScope.CLI)
331
330
  return '';
331
+ // Antigravity also creates ~/.gemini. The shared parent is not evidence of Gemini CLI.
332
+ if (spec.id === McpClient.GEMINI)
333
+ return spec.relPath;
332
334
  const parts = spec.relPath.split('/');
333
335
  return parts.length > 1 ? parts.slice(0, -1).join('/') : spec.relPath;
334
336
  }
@@ -66,11 +66,24 @@ interface ClaudeAddCommand {
66
66
  }
67
67
  /** `claude mcp add reticle -s user -- npx @reticlehq/server mcp` — registers globally for all projects (portless). */
68
68
  export declare function claudeAddCommand(): ClaudeAddCommand;
69
- /** Probe args that tell us whether an `reticle` server already exists in any scope (exit 0 = exists). */
70
- export declare function claudeExistsProbe(): {
71
- command: string;
72
- args: string[];
73
- };
69
+ /**
70
+ * Is a `reticle` server already registered with Claude Code, in any scope this project sees?
71
+ *
72
+ * READ FROM CLAUDE'S OWN CONFIG, never asked of the CLI. `claude mcp get reticle` health-checks the
73
+ * entry by LAUNCHING it — `npx @reticlehq/server mcp` in the project — which starts a daemon on
74
+ * whatever `.reticle.json` says before `init` has rewritten it. A re-run moving the port brought the
75
+ * old-port daemon back about two seconds after it was stopped. `claude mcp list` health-checks
76
+ * every server the same way.
77
+ *
78
+ * The three scopes `claude mcp add` writes: user (`mcpServers` at the top of the state file), local
79
+ * (`projects[<dir>].mcpServers`, keyed by absolute path) and project (`<dir>/.mcp.json`). Without a
80
+ * project directory only user scope is looked at. An unreadable file answers "not registered",
81
+ * which costs at worst a `claude mcp add` that reports the entry already exists.
82
+ */
83
+ export declare function claudeHasReticle(io: {
84
+ readFile(path: string): string | null;
85
+ homeDir(): string;
86
+ }, projectDir: string | undefined, configDir?: string | undefined): boolean;
74
87
  /** Probe args for whether the `claude` CLI is installed at all. */
75
88
  export declare function claudeAvailableProbe(): {
76
89
  command: string;
@@ -11,6 +11,7 @@
11
11
  * project's `.reticle.json` in its CWD (see cli-port.ts). The port belongs to the project, not the
12
12
  * global agent config.
13
13
  */
14
+ import { join } from 'node:path';
14
15
  import { RETICLE_NPM_PACKAGE } from '../version.js';
15
16
  // The name is a wire identity and lives in the contract; re-exported here so the registration writers
16
17
  // below (and their tests) keep reading it from the module that USES it.
@@ -87,14 +88,53 @@ export function claudeAddCommand() {
87
88
  const args = [MCP_SUBCOMMAND, 'add', MCP_SERVER_NAME, '-s', 'user', '--', ...tail];
88
89
  return { command: CLAUDE_CLI, args, display: `${CLAUDE_CLI} ${args.join(' ')}` };
89
90
  }
90
- /** Probe args that tell us whether an `reticle` server already exists in any scope (exit 0 = exists). */
91
- export function claudeExistsProbe() {
92
- // NO `-s`: `claude mcp get` takes no options at all, so passing one exits 1 with "unknown option
93
- // '-s'" — the probe answered "not registered" on EVERY machine, init then ran `claude mcp add`,
94
- // which exits 1 with "already exists", and a re-run reported `[⚠] step failed` plus a manual
95
- // command that fails the same way. A false positive from a project-scoped entry costs one skipped
96
- // registration; the flag cost every re-run a failed step.
97
- return { command: CLAUDE_CLI, args: [MCP_SUBCOMMAND, 'get', MCP_SERVER_NAME] };
91
+ /** Where Claude Code keeps its state: `$CLAUDE_CONFIG_DIR/.claude.json`, else `~/.claude.json`. */
92
+ const CLAUDE_STATE_FILE = '.claude.json';
93
+ /** `mcpServers` holds a `reticle` entry. */
94
+ function namesReticle(scope) {
95
+ if ('object' !== typeof scope || null === scope)
96
+ return false;
97
+ const servers = scope.mcpServers;
98
+ return 'object' === typeof servers && null !== servers && MCP_SERVER_NAME in servers;
99
+ }
100
+ function parsed(source) {
101
+ if (null === source)
102
+ return undefined;
103
+ try {
104
+ return JSON.parse(source);
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ /**
111
+ * Is a `reticle` server already registered with Claude Code, in any scope this project sees?
112
+ *
113
+ * READ FROM CLAUDE'S OWN CONFIG, never asked of the CLI. `claude mcp get reticle` health-checks the
114
+ * entry by LAUNCHING it — `npx @reticlehq/server mcp` in the project — which starts a daemon on
115
+ * whatever `.reticle.json` says before `init` has rewritten it. A re-run moving the port brought the
116
+ * old-port daemon back about two seconds after it was stopped. `claude mcp list` health-checks
117
+ * every server the same way.
118
+ *
119
+ * The three scopes `claude mcp add` writes: user (`mcpServers` at the top of the state file), local
120
+ * (`projects[<dir>].mcpServers`, keyed by absolute path) and project (`<dir>/.mcp.json`). Without a
121
+ * project directory only user scope is looked at. An unreadable file answers "not registered",
122
+ * which costs at worst a `claude mcp add` that reports the entry already exists.
123
+ */
124
+ export function claudeHasReticle(io, projectDir, configDir = process.env['CLAUDE_CONFIG_DIR']) {
125
+ const state = parsed(io.readFile(join(configDir ?? io.homeDir(), CLAUDE_STATE_FILE)));
126
+ if (namesReticle(state))
127
+ return true;
128
+ if (projectDir === undefined)
129
+ return false;
130
+ const projects = 'object' === typeof state && null !== state
131
+ ? state.projects
132
+ : undefined;
133
+ if ('object' === typeof projects && null !== projects) {
134
+ if (namesReticle(projects[projectDir]))
135
+ return true;
136
+ }
137
+ return namesReticle(parsed(io.readFile(join(projectDir, CLAUDE_PROJECT_CONFIG))));
98
138
  }
99
139
  /** Probe args for whether the `claude` CLI is installed at all. */
100
140
  export function claudeAvailableProbe() {
@@ -78,6 +78,14 @@ export interface InitIo {
78
78
  scoped(relPath: string): InitIo;
79
79
  /** Runs a subprocess to completion (inherits stdio); returns true on exit code 0. */
80
80
  exec(command: string, args: readonly string[]): boolean;
81
+ /**
82
+ * Runs a subprocess quietly and returns its combined output — for a failure whose text names its
83
+ * cause (see explainInstallFailure). Optional: without it the generic failure text stands.
84
+ */
85
+ capture?(command: string, args: readonly string[]): {
86
+ ok: boolean;
87
+ output: string;
88
+ };
81
89
  /** Runs a subprocess quietly (no stdio) for a yes/no check; returns true on exit code 0. */
82
90
  probe(command: string, args: readonly string[]): boolean;
83
91
  /** Can this process write into the project root — see preflight.ts. */
package/dist/run.js CHANGED
@@ -6,26 +6,34 @@
6
6
  import { OnboardingPhase, OnboardingStepStatus } from '@reticlehq/core/telemetry';
7
7
  import { detectMcpClients } from './register/detect-clients.js';
8
8
  import { dirname, join } from 'node:path';
9
- import { CONTAINER_MARKERS } from './diagnose/containerised-dev-server.js';
9
+ import { CONTAINER_MARKERS, runsDevServer } from './diagnose/containerised-dev-server.js';
10
+ import { explainInstallFailure } from './diagnose/install-retries.js';
10
11
  import { CSP_FILES } from './diagnose/csp-doctor.js';
11
- import { preflightRefusal } from './plan/preflight.js';
12
- import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, noPackageJsonMessage, streamlitSetupMessage, } from './detect/non-js-project.js';
13
- import { devCommandFrom } from './detect/dev-script.js';
12
+ import { preflight } from './plan/preflight.js';
13
+ import { devCommandFrom, devScriptBody } from './detect/dev-script.js';
14
14
  import { restartHint, FEEDBACK_HINT } from './diagnose/closing-hint.js';
15
15
  import { projectIdOf, rememberProjectOnDisk } from './project/remember-project.js';
16
- import { detect, Framework, UiLibrary } from './detect/detect.js';
16
+ import { ANGULAR_WORKSPACE_FILE, detect, FORGE_RENDERER_CONFIGS, Framework, UiLibrary, } from './detect/detect.js';
17
+ import { ANGULAR_PROXY_PATH, angularEntry } from './patch/angular.js';
18
+ import { HTML_INDEX_PATH } from './patch/static-page.js';
19
+ import { initWithoutPackageJson } from './no-package-json.js';
17
20
  import { wasMcpRegistered } from './register/mcp-registered.js';
18
21
  import { pickAstroHost } from './patch/astro-host.js';
19
22
  import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, PACKAGE_JSON, VITE_CONFIG_CANDIDATES, } from './detect/workspace-apps.js';
20
23
  import { redirectToWorkspaceApp } from './detect/workspace-redirect.js';
24
+ import { enclosingWorkspaceRoot } from './detect/workspace-apps.js';
21
25
  import { isConnectStep } from './plan/connect-steps.js';
22
26
  import { CURSOR_RULE_PATH, RETICLE_MD_PATH } from './project/agent-rules.js';
23
27
  import { CLAUDE_SETTINGS_PATH } from './plan/stop-hook-step.js';
24
28
  import { CRA_ENV_PATH } from './patch/cra.js';
25
29
  import { NEXT_LAYOUT_CANDIDATES, NEXT_PAGES_APP_CANDIDATES } from './patch/next-patch.js';
26
30
  import { formatGeneratedSource } from './patch/format-generated.js';
31
+ import { tanstackStartConnectPath } from './patch/tanstack-start.js';
32
+ import { installedViteMajor } from './patch/vite-owning-config.js';
27
33
  /** CRA's bundled entry, in the order create-react-app itself generates them. */
28
34
  const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
35
+ /** Preview snippets must not mint a real machine credential or imply this token is usable. */
36
+ const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
29
37
  function craEntryOf(io) {
30
38
  for (const path of CRA_ENTRY_CANDIDATES) {
31
39
  const source = io.readFile(path);
@@ -35,12 +43,12 @@ function craEntryOf(io) {
35
43
  return null;
36
44
  }
37
45
  import { DEPS_TARGET, RETICLE_CONFIG_FILE, frameworkPackages, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
38
- import { claudeAvailableProbe, claudeExistsProbe } from './register/mcp.js';
46
+ import { claudeAvailableProbe, claudeHasReticle } from './register/mcp.js';
39
47
  import { reticleDevLocation } from './patch/next-patch.js';
40
48
  import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
41
49
  import { CLAUDE_PROJECT_CONFIG, CURSOR_PROJECT_MARKER } from './register/mcp-clients.js';
42
50
  import { deriveProjectId, packageName } from './project/project-id.js';
43
- import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, connectArgWithToken, nuxtPluginPath, staticPageSnippet, djangoMiddlewareSnippet, reticleConfigContent, streamlitPageSnippet, } from './patch/snippets.js';
51
+ import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, nuxtPluginPath, } from './patch/snippets.js';
44
52
  import { NUXT_CONFIG_CANDIDATES } from './patch/nuxt-patch.js';
45
53
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH } from './register/slash-command.js';
46
54
  import { RETICLE_VERSION } from './version.js';
@@ -219,6 +227,17 @@ function agentRootOf(options) {
219
227
  const root = options.agentRoot;
220
228
  return root === undefined || root === options.cwd ? undefined : root;
221
229
  }
230
+ /** What the Angular steps read: the workspace file, the entry it names, and our proxy module. */
231
+ function angularInputs(io) {
232
+ const workspace = io.readFile(ANGULAR_WORKSPACE_FILE);
233
+ const entryPath = angularEntry(workspace);
234
+ const entry = io.readFile(entryPath);
235
+ return {
236
+ angularWorkspace: null === workspace ? null : { path: ANGULAR_WORKSPACE_FILE, source: workspace },
237
+ angularEntry: null === entry ? null : { path: entryPath, source: entry },
238
+ angularProxySource: io.readFile(ANGULAR_PROXY_PATH),
239
+ };
240
+ }
222
241
  function gatherPlanInput(options, io, pkg) {
223
242
  // Stable identity derived from the app's package.json name + root, so it survives port changes.
224
243
  const projectId = deriveProjectId(packageName(pkg), options.cwd);
@@ -234,7 +253,11 @@ function gatherPlanInput(options, io, pkg) {
234
253
  nodeModulesMarkers,
235
254
  };
236
255
  const detection = detect(detectInput);
237
- const vitePath = firstPresent(rootFiles, VITE_CONFIG_CANDIDATES);
256
+ // Forge has no `vite.config.*`; its renderer config IS the Vite config the plugin belongs in, and
257
+ // handing it over under that name is what lets the ordinary Vite patcher do the work.
258
+ const vitePath = firstPresent(rootFiles, Framework.ELECTRON_FORGE === detection.framework
259
+ ? FORGE_RENDERER_CONFIGS
260
+ : VITE_CONFIG_CANDIDATES);
238
261
  const viteSource = null === vitePath ? null : io.readFile(vitePath);
239
262
  const viteConfig = vitePath !== null && viteSource !== null ? { path: vitePath, source: viteSource } : null;
240
263
  const electronVitePath = firstPresent(rootFiles, ELECTRON_VITE_CONFIG_CANDIDATES);
@@ -248,8 +271,8 @@ function gatherPlanInput(options, io, pkg) {
248
271
  // global config file. Only probe when the MCP step is in play.
249
272
  const availableProbe = claudeAvailableProbe();
250
273
  const claudeCli = options.mcp ? io.probe(availableProbe.command, availableProbe.args) : false;
251
- const existsProbe = claudeExistsProbe();
252
- const mcpExists = claudeCli ? io.probe(existsProbe.command, existsProbe.args) : false;
274
+ // Read from Claude's config, never `claude mcp get`: that launches the server — see claudeHasReticle.
275
+ const mcpExists = claudeCli ? claudeHasReticle(io, io.cwd()) : false;
253
276
  // Every MCP client this machine shows evidence of. Conservative and one-directional: we write
254
277
  // into a config a client ALREADY has, and never create ~/.gemini or ~/.codeium for somebody who
255
278
  // does not use them.
@@ -308,7 +331,13 @@ function gatherPlanInput(options, io, pkg) {
308
331
  // repo that containerises it — `frontend/` under a root `docker-compose.yml` is the shape this
309
332
  // came from. Only the first match is reported; the note is the same whichever file found it.
310
333
  ...(() => {
311
- const marker = CONTAINER_MARKERS.find((name) => io.exists(name) || io.exists(join('..', name)));
334
+ // Only a file that RUNS the dev server: a production-only image is the common case, and
335
+ // React Router's template ships one. See runsDevServer.
336
+ const devScript = devScriptBody(pkg);
337
+ const marker = CONTAINER_MARKERS.find((name) => [name, join('..', name)].some((path) => {
338
+ const content = io.readFile(path);
339
+ return null !== content && runsDevServer(name, content, devScript);
340
+ }));
312
341
  return marker === undefined ? {} : { containerMarker: marker };
313
342
  })(),
314
343
  viteConfig,
@@ -328,7 +357,7 @@ function gatherPlanInput(options, io, pkg) {
328
357
  : null,
329
358
  // Capabilities: scanned, never asked for. Bounded — a hint for the agent, not a repo index.
330
359
  testids: scanTestids(sourceFiles.map((f) => f.source)),
331
- storeHints: storeHints(dependencyNames(pkg)),
360
+ storeHints: storeHints(dependencyNames(pkg), sourceFiles),
332
361
  foundStores: scanStores(sourceFiles, dependencyNames(pkg)),
333
362
  nextFoundStores: scanStores(sourceFiles, dependencyNames(pkg), dirname(devLocation.path)),
334
363
  viteDevModuleExists: io.exists(detection.framework === Framework.ELECTRON_VITE
@@ -339,6 +368,7 @@ function gatherPlanInput(options, io, pkg) {
339
368
  nextReticleDevExists: io.exists(devLocation.path),
340
369
  nextReticleDevSource: io.readFile(devLocation.path),
341
370
  svelteKitHooksExists: io.exists(SVELTEKIT_HOOKS),
371
+ svelteKitHooksSource: io.readFile(SVELTEKIT_HOOKS),
342
372
  reactRouterEntryExists: io.exists(REACT_ROUTER_ENTRY),
343
373
  // The SOURCE, so an entry the app already owns is ADDED TO rather than replaced — it is an
344
374
  // override of React Router's default, and everything in it is load-bearing.
@@ -347,11 +377,27 @@ function gatherPlanInput(options, io, pkg) {
347
377
  ? { path: nuxtConfigPath, source: nuxtConfigSource }
348
378
  : null,
349
379
  nuxtHasAppDir,
380
+ viteMajor: installedViteMajor(io, detectInput.pkg),
350
381
  nuxtPluginExists: io.exists(nuxtPluginPath(nuxtHasAppDir)),
351
- tanstackStartRoot: TANSTACK_START_ROOT_CANDIDATES.find((file) => io.exists(file)),
382
+ nuxtPluginSource: io.readFile(nuxtPluginPath(nuxtHasAppDir)),
383
+ ...(() => {
384
+ const root = TANSTACK_START_ROOT_CANDIDATES.find((file) => io.exists(file));
385
+ return root === undefined
386
+ ? {}
387
+ : {
388
+ tanstackStartRoot: root,
389
+ tanstackStartRootSource: io.readFile(root),
390
+ tanstackStartConnectExists: io.exists(tanstackStartConnectPath(root)),
391
+ tanstackStartConnectSource: io.readFile(tanstackStartConnectPath(root)),
392
+ };
393
+ })(),
394
+ ...(Framework.ANGULAR === detection.framework ? angularInputs(io) : {}),
395
+ ...(Framework.HTML === detection.framework
396
+ ? { htmlIndexSource: io.readFile(HTML_INDEX_PATH) }
397
+ : {}),
352
398
  craEntry: craEntryOf(io),
353
399
  craEnv: io.readFile(CRA_ENV_PATH),
354
- pairingToken: io.host.pairingToken(),
400
+ pairingToken: options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken(),
355
401
  installSource: io.host.installSource(),
356
402
  reticleConfigExists: io.exists(RETICLE_CONFIG_FILE),
357
403
  // The CONTENT, so a config that exists can be checked rather than trusted — a `"port"` set to
@@ -388,7 +434,7 @@ function gatherPlanInput(options, io, pkg) {
388
434
  /** pnpm's workspace declaration, read when present — it is authoritative about where packages live. */
389
435
  const SKIPPED_DETAIL = 'skipped — the dependency install above failed, and wiring the app to a package that is not ' +
390
436
  'installed stops it booting. Run that install, then re-run `reticle init`.';
391
- function report(plan, dryRun, failed, skipped, degraded, io, projectDir, agentRoot,
437
+ function report(plan, dryRun, failed, skipped, degraded, why, io, projectDir, agentRoot,
392
438
  /** The project's own dev command, so the closing line names what a human would type. */
393
439
  devCommand,
394
440
  /**
@@ -412,7 +458,7 @@ continuesToRuntime = false) {
412
458
  * On the REAL run only. In a dry run the reader has already found the flag.
413
459
  */
414
460
  if (!dryRun) {
415
- io.print(' --dry-run shows this plan and writes nothing · --app <dir> picks the app · --no-mcp skips agent registration');
461
+ io.print(' --dry-run previews · --app <dir> picks the app · --no-mcp skips agent registration');
416
462
  }
417
463
  // Every path below is printed RELATIVE, and until now nothing said what to. Reported from the
418
464
  // field as "[✓] Reticle config → .reticle.json" followed by the file not being there: the app was
@@ -447,7 +493,7 @@ continuesToRuntime = false) {
447
493
  const detail = skipped.has(s.target)
448
494
  ? SKIPPED_DETAIL
449
495
  : downgraded && s.exec !== undefined
450
- ? `step failed — run manually: ${s.exec.fallback}`
496
+ ? (why.get(s.target) ?? `step failed — run manually: ${s.exec.fallback}`)
451
497
  : s.detail;
452
498
  io.print(` [${STATUS_SYMBOL[status]}] ${s.title} → ${s.target}`);
453
499
  if (status === StepStatus.APPLY)
@@ -520,7 +566,8 @@ function sdkPackagesPresent(framework, uiLibrary, io) {
520
566
  return (packages.length > 0 &&
521
567
  packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
522
568
  }
523
- function applyEffects(plan, io) {
569
+ function applyEffects(plan, io, packageManager) {
570
+ const why = new Map();
524
571
  const failed = new Set();
525
572
  const skipped = new Set();
526
573
  const degraded = new Map();
@@ -594,8 +641,12 @@ function applyEffects(plan, io) {
594
641
  failed.add(s.target);
595
642
  // A failed install only blocks the wiring when the packages are genuinely ABSENT. See
596
643
  // sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
597
- if (s.target === DEPS_TARGET)
644
+ if (s.target === DEPS_TARGET) {
598
645
  installFailed = true;
646
+ const explained = explainInstallFailure(io, packageManager, exec, s.detail);
647
+ if (explained !== undefined)
648
+ why.set(s.target, explained);
649
+ }
599
650
  }
600
651
  }
601
652
  // Where this project lives, remembered for a daemon that will be started somewhere else.
@@ -609,7 +660,7 @@ function applyEffects(plan, io) {
609
660
  if (projectId !== undefined) {
610
661
  rememberProjectOnDisk(io, projectId, io.cwd(), Date.now());
611
662
  }
612
- return { failed, skipped, degraded };
663
+ return { failed, skipped, degraded, why };
613
664
  }
614
665
  /**
615
666
  * Which STEP failed, from the step targets — not from an error string, which would carry paths.
@@ -626,8 +677,20 @@ function classifyInitFailure(failed) {
626
677
  return InitFailure.MCP_REGISTRATION;
627
678
  return InitFailure.OTHER;
628
679
  }
680
+ /**
681
+ * Where the agent stands when `init` runs inside a workspace package: the workspace root.
682
+ *
683
+ * The same answer `--app` from the root already gives (see enterApp), so both routes into one app
684
+ * leave the root in one state — a pointer config and the agent files an agent opened there reads.
685
+ */
686
+ function withWorkspaceRoot(options, io) {
687
+ if (options.agentRoot !== undefined || true === options.redirected)
688
+ return options;
689
+ const root = enclosingWorkspaceRoot(options.cwd, io);
690
+ return root === undefined ? options : { ...options, agentRoot: root };
691
+ }
629
692
  export function runInit(options, io) {
630
- const result = runInitSteps(options, io);
693
+ const result = runInitSteps(withWorkspaceRoot(options, io), io);
631
694
  // The ask goes here, not in report(): report() is only the success-shaped path, and the exits that
632
695
  // matter most are the ones that never reach it — no package.json, an ambiguous workspace — where
633
696
  // setup died before anything ran and the person holding the report has the least to go on. This
@@ -635,7 +698,10 @@ export function runInit(options, io) {
635
698
  //
636
699
  // `redirected` is what keeps it to ONE print: wiring an app in a monorepo re-enters runInit for the
637
700
  // chosen directory, and the inner call must not ask again.
638
- if (true !== options.redirected) {
701
+ //
702
+ // And not when the runtime follows: it asks once it has finished, as the last thing printed,
703
+ // rather than here in the middle, before the dev server has even started.
704
+ if (true !== options.redirected && true !== options.continuesToRuntime) {
639
705
  io.print('');
640
706
  io.print(FEEDBACK_HINT);
641
707
  }
@@ -673,7 +739,8 @@ function runInitSteps(options, io) {
673
739
  io.print(`${PACKAGE_JSON} is not valid JSON (${manifest.error}). Fix it and re-run — init reads the ` +
674
740
  'framework, the dev script and the package manager from it, and will not guess at any of ' +
675
741
  'them from a file it cannot read.');
676
- io.host.reportOutcome({ ok: false, reason: InitFailure.MALFORMED_PACKAGE_JSON });
742
+ if (!options.dryRun)
743
+ io.host.reportOutcome({ ok: false, reason: InitFailure.MALFORMED_PACKAGE_JSON });
677
744
  return { ok: false, applied: 0, manual: 0 };
678
745
  }
679
746
  const pkgRaw = manifest.pkg;
@@ -692,52 +759,8 @@ function runInitSteps(options, io) {
692
759
  const redirectedEarly = redirectToWorkspaceApp(options, io, pkgRaw ?? {}, runInit);
693
760
  if (redirectedEarly !== null)
694
761
  return redirectedEarly;
695
- if (null === pkgRaw) {
696
- const streamlit = detectStreamlitProject((file) => io.readFile(file), io.rootFiles());
697
- // Asked only when Streamlit already said no, so the two can never both claim the page.
698
- const django = !streamlit &&
699
- detectDjangoProject((file) => io.exists(join(options.cwd, file)), io.rootFiles());
700
- io.print(
701
- // Two genuinely different situations used to share one sentence: a JS developer in the wrong
702
- // directory, and a project that is not JavaScript at all. The second reads the old wording as
703
- // a path problem and goes looking for a directory that cannot exist — reported from a
704
- // Streamlit app, where the search continued into hunting for a browser bundle to inject by
705
- // hand before the real answer surfaced.
706
- streamlit
707
- ? streamlitSetupMessage()
708
- : django
709
- ? djangoSetupMessage()
710
- : noPackageJsonMessage((file) => io.exists(join(options.cwd, file))));
711
- // The message says "add the snippet below". Print the snippet, or the message is the same
712
- // broken promise in the other direction. `connectArg` carries the port; there is no projectId
713
- // to bake, because a projectId is derived from the package.json that does not exist here.
714
- // Scope the project on disk before printing anything, so the daemon can serve this page.
715
- //
716
- // This path used to print and exit, leaving no `.reticle.json` at all — and the reporter who
717
- // asked for the Django path had to hand-write one alongside the middleware. Without it the
718
- // daemon has no config in its own directory, refuses the page's dial, and every downstream
719
- // symptom points somewhere other than the cause (see #685). The projectId derivation already
720
- // handles the no-package case: it falls back to the root folder name, fingerprinted by the
721
- // absolute path so two checkouts stay distinct.
722
- //
723
- // Written only when absent. A config a user or an earlier run already placed here is theirs.
724
- const nonJsProjectId = deriveProjectId(undefined, io.cwd());
725
- if (!io.exists(RETICLE_CONFIG_FILE)) {
726
- io.writeFile(RETICLE_CONFIG_FILE, reticleConfigContent(Framework.HTML, options.port, nonJsProjectId, io.host.installSource()));
727
- rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
728
- io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
729
- }
730
- const connect = connectArgWithToken(options.port, nonJsProjectId, io.host.pairingToken());
731
- io.print(streamlit
732
- ? streamlitPageSnippet(connect)
733
- : django
734
- ? djangoMiddlewareSnippet(connect)
735
- : staticPageSnippet(connect));
736
- // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
737
- // from someone who never ran the command — the two failure modes with the most different fixes.
738
- io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
739
- return { ok: false, applied: 0, manual: 0 };
740
- }
762
+ if (null === pkgRaw)
763
+ return initWithoutPackageJson(options, io);
741
764
  // Init is the flow a user experiences the wait of personally, and the fixture gate measures it at
742
765
  // 1–6s per app with no explanation of the spread. These three spans split that number into detect
743
766
  // (filesystem probing), plan (pure), and apply (writes + package-manager and CLI subprocesses).
@@ -747,23 +770,34 @@ function runInitSteps(options, io) {
747
770
  // the app in frontend/ uses pnpm, and checking the file refused a scaffold the install gate proves
748
771
  // must succeed. Both conditions make every later phase fail, and each arrives far from its cause
749
772
  // when it is not checked here. See preflight.ts.
750
- const refusal = preflightRefusal({
773
+ const preflightIo = {
751
774
  cwd: () => io.cwd(),
752
775
  canWrite: () => io.canWrite(),
753
776
  probe: (command, args) => io.probe(command, args),
754
- }, planInput.detection.packageManager, { alreadyServed: options.url !== undefined && '' !== options.url });
755
- if (refusal !== undefined) {
756
- io.print(refusal);
777
+ };
778
+ const resolved = preflight(preflightIo, planInput.detection.packageManager, {
779
+ alreadyServed: options.url !== undefined && '' !== options.url,
780
+ });
781
+ if (resolved.refusal !== undefined) {
782
+ io.print(resolved.refusal);
783
+ if (!options.dryRun)
784
+ io.host.reportOutcome({ ok: false, reason: InitFailure.PREFLIGHT });
757
785
  return { ok: false, applied: 0, manual: 1 };
758
786
  }
787
+ // The prefix that can actually run it on THIS machine — `pnpm`, or `corepack pnpm` where preflight
788
+ // just proved only corepack can. Threaded onto detection (never recomputed by `detect`, which is
789
+ // pure and never probes) so the install step, its retries and the dev command all spawn and print
790
+ // the same thing preflight just confirmed works, instead of a bare binary it already ruled out.
791
+ const packageManagerCommand = resolved.command;
792
+ planInput.detection = { ...planInput.detection, packageManagerCommand };
759
793
  const plan = io.host.span('init.plan', {}, () => buildPlan(planInput));
760
794
  const effects = options.dryRun
761
- ? { failed: new Set(), skipped: new Set(), degraded: new Map() }
762
- : io.host.span('init.apply', { steps: plan.steps.length }, () => applyEffects(plan, io));
763
- const { failed, skipped, degraded } = effects;
795
+ ? { failed: new Set(), skipped: new Set(), degraded: new Map(), why: new Map() }
796
+ : io.host.span('init.apply', { steps: plan.steps.length }, () => applyEffects(plan, io, planInput.detection.packageManager));
797
+ const { failed, skipped, degraded, why } = effects;
764
798
  // The project's own dev command, so the closing line names what a human would actually type.
765
- const devCommand = devCommandFrom(pkgRaw, planInput.detection.packageManager);
766
- const result = report(plan, options.dryRun, failed, skipped, degraded, io, options.cwd, agentRootOf(options), devCommand, true === options.continuesToRuntime);
799
+ const devCommand = devCommandFrom(pkgRaw, packageManagerCommand);
800
+ const result = report(plan, options.dryRun, failed, skipped, degraded, why, io, options.cwd, agentRootOf(options), devCommand, true === options.continuesToRuntime);
767
801
  // A dry run is a preview, not an outcome — reporting it would inflate both success and failure.
768
802
  if (options.dryRun)
769
803
  return result;
@@ -831,8 +865,12 @@ function runInitSteps(options, io) {
831
865
  stack: plan.framework,
832
866
  ...(result.ok ? {} : { reason: classifyInitFailure(failed) }),
833
867
  });
868
+ // A failed dependency install skipped every wiring step, so there is no instrumented app to boot:
869
+ // without a context the runtime stops at this report, rather than starting the dev server and
870
+ // diagnosing an SDK that was never installed as a dev server that needs restarting.
871
+ const handover = failed.has(DEPS_TARGET) ? {} : { context };
834
872
  if (true === options.deferOutcome)
835
- return { ...result, context, outcome };
873
+ return { ...result, ...handover, outcome };
836
874
  io.host.reportOutcome(outcome);
837
- return { ...result, context };
875
+ return { ...result, ...handover };
838
876
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/init",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "Reticle project scaffolder — detects the framework, writes the config, patches the build and registers the MCP server. The build-time half of `reticle init`.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,7 +13,7 @@
13
13
  "./package.json": "./package.json"
14
14
  },
15
15
  "dependencies": {
16
- "@reticlehq/core": "3.3.0"
16
+ "@reticlehq/core": "3.5.0"
17
17
  },
18
18
  "license": "FSL-1.1-ALv2",
19
19
  "author": "Reticle contributors",
@@ -27,8 +27,7 @@
27
27
  "files": [
28
28
  "dist",
29
29
  "README.md",
30
- "LICENSE",
31
- "NOTICE"
30
+ "LICENSE"
32
31
  ],
33
32
  "sideEffects": false,
34
33
  "keywords": [