@reticlehq/init 3.5.0 → 3.7.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 (52) hide show
  1. package/dist/detect/detect.d.ts +8 -0
  2. package/dist/detect/detect.js +6 -3
  3. package/dist/detect/dev-script.d.ts +17 -0
  4. package/dist/detect/dev-script.js +128 -0
  5. package/dist/detect/non-js-project.d.ts +23 -0
  6. package/dist/detect/non-js-project.js +55 -23
  7. package/dist/detect/workspace-apps.d.ts +16 -0
  8. package/dist/detect/workspace-apps.js +70 -0
  9. package/dist/detect/workspace-redirect.d.ts +12 -0
  10. package/dist/detect/workspace-redirect.js +84 -9
  11. package/dist/diagnose/closing-hint.d.ts +7 -1
  12. package/dist/diagnose/closing-hint.js +39 -12
  13. package/dist/diagnose/csp-check.js +65 -20
  14. package/dist/diagnose/init-failure.d.ts +2 -0
  15. package/dist/diagnose/init-failure.js +2 -0
  16. package/dist/gather-plan-input.d.ts +11 -0
  17. package/dist/gather-plan-input.js +416 -0
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.js +7 -2
  20. package/dist/no-package-json.js +17 -8
  21. package/dist/node-io.d.ts +1 -1
  22. package/dist/node-io.js +11 -2
  23. package/dist/patch/next-patch.d.ts +28 -0
  24. package/dist/patch/next-patch.js +66 -0
  25. package/dist/patch/nuxt-snippets.js +1 -1
  26. package/dist/patch/snippets.d.ts +1 -1
  27. package/dist/patch/snippets.js +2 -2
  28. package/dist/patch/static-page.d.ts +1 -0
  29. package/dist/patch/static-page.js +3 -3
  30. package/dist/plan/connect-steps.d.ts +1 -0
  31. package/dist/plan/connect-steps.js +5 -0
  32. package/dist/plan/framework-adapter.js +2 -2
  33. package/dist/plan/plan-angular.js +1 -1
  34. package/dist/plan/plan-framework.d.ts +1 -1
  35. package/dist/plan/plan-framework.js +47 -13
  36. package/dist/plan/plan-types.d.ts +23 -2
  37. package/dist/plan/plan-vite.js +15 -0
  38. package/dist/plan/plan.js +68 -10
  39. package/dist/plan/stop-hook-step.d.ts +19 -0
  40. package/dist/plan/stop-hook-step.js +74 -1
  41. package/dist/project/agent-rules.d.ts +1 -1
  42. package/dist/project/agent-rules.js +16 -11
  43. package/dist/register/codex-toml.d.ts +12 -0
  44. package/dist/register/codex-toml.js +44 -0
  45. package/dist/register/mcp.d.ts +15 -2
  46. package/dist/register/mcp.js +16 -0
  47. package/dist/run-types.d.ts +6 -0
  48. package/dist/run.d.ts +1 -1
  49. package/dist/run.js +27 -414
  50. package/dist/sdk-packages.d.ts +19 -0
  51. package/dist/sdk-packages.js +34 -0
  52. package/package.json +2 -2
package/dist/run.js CHANGED
@@ -4,177 +4,25 @@
4
4
  * access goes through `InitIo` so the orchestration is unit-testable with an in-memory IO.
5
5
  */
6
6
  import { OnboardingPhase, OnboardingStepStatus } from '@reticlehq/core/telemetry';
7
- import { detectMcpClients } from './register/detect-clients.js';
8
- import { dirname, join } from 'node:path';
9
- import { CONTAINER_MARKERS, runsDevServer } from './diagnose/containerised-dev-server.js';
10
7
  import { explainInstallFailure } from './diagnose/install-retries.js';
11
- import { CSP_FILES } from './diagnose/csp-doctor.js';
12
8
  import { preflight } from './plan/preflight.js';
13
- import { devCommandFrom, devScriptBody } from './detect/dev-script.js';
9
+ import { devCommandFrom } from './detect/dev-script.js';
14
10
  import { restartHint, FEEDBACK_HINT } from './diagnose/closing-hint.js';
15
11
  import { projectIdOf, rememberProjectOnDisk } from './project/remember-project.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
12
  import { initWithoutPackageJson } from './no-package-json.js';
13
+ import { sdkPackagesDeclared, sdkPackagesPresent } from './sdk-packages.js';
20
14
  import { wasMcpRegistered } from './register/mcp-registered.js';
21
- import { pickAstroHost } from './patch/astro-host.js';
22
- import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, PACKAGE_JSON, VITE_CONFIG_CANDIDATES, } from './detect/workspace-apps.js';
15
+ import { PACKAGE_JSON } from './detect/workspace-apps.js';
23
16
  import { redirectToWorkspaceApp } from './detect/workspace-redirect.js';
24
- import { enclosingWorkspaceRoot } from './detect/workspace-apps.js';
17
+ import { appsAt, enclosingWorkspaceRoot } from './detect/workspace-apps.js';
25
18
  import { isConnectStep } from './plan/connect-steps.js';
26
- import { CURSOR_RULE_PATH, RETICLE_MD_PATH } from './project/agent-rules.js';
27
- import { CLAUDE_SETTINGS_PATH } from './plan/stop-hook-step.js';
28
- import { CRA_ENV_PATH } from './patch/cra.js';
29
- import { NEXT_LAYOUT_CANDIDATES, NEXT_PAGES_APP_CANDIDATES } from './patch/next-patch.js';
30
19
  import { formatGeneratedSource } from './patch/format-generated.js';
31
- import { tanstackStartConnectPath } from './patch/tanstack-start.js';
32
- import { installedViteMajor } from './patch/vite-owning-config.js';
33
- /** CRA's bundled entry, in the order create-react-app itself generates them. */
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';
37
- function craEntryOf(io) {
38
- for (const path of CRA_ENTRY_CANDIDATES) {
39
- const source = io.readFile(path);
40
- if (source !== null)
41
- return { path, source };
42
- }
43
- return null;
44
- }
45
- import { DEPS_TARGET, RETICLE_CONFIG_FILE, frameworkPackages, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
46
- import { claudeAvailableProbe, claudeHasReticle } from './register/mcp.js';
47
- import { reticleDevLocation } from './patch/next-patch.js';
48
- import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
49
- import { CLAUDE_PROJECT_CONFIG, CURSOR_PROJECT_MARKER } from './register/mcp-clients.js';
50
- import { deriveProjectId, packageName } from './project/project-id.js';
51
- import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, nuxtPluginPath, } from './patch/snippets.js';
52
- import { NUXT_CONFIG_CANDIDATES } from './patch/nuxt-patch.js';
53
- import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH } from './register/slash-command.js';
54
- import { RETICLE_VERSION } from './version.js';
20
+ import { DEPS_TARGET, RETICLE_CONFIG_FILE, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
55
21
  import { InitFailure } from './diagnose/init-failure.js';
56
22
  import { resolveLockfiles } from './detect/lockfiles.js';
23
+ import { agentRootOf, gatherPlanInput } from './gather-plan-input.js';
57
24
  // Re-exported: it moved to its own module, and every existing importer says `run.js`.
58
25
  export { resolveLockfiles };
59
- const NODE_MODULES_DIR = 'node_modules';
60
- const SVELTEKIT_HOOKS = 'src/hooks.client.ts';
61
- const REACT_ROUTER_ENTRY = 'app/entry.client.tsx';
62
- const TANSTACK_START_ROOT_CANDIDATES = [
63
- 'src/routes/__root.tsx',
64
- 'src/routes/__root.jsx',
65
- 'app/routes/__root.tsx',
66
- 'app/routes/__root.jsx',
67
- ];
68
- const SOURCE_FILE = /\.(tsx|jsx|ts|js|svelte|vue|astro)$/;
69
- /** Files read for the testid scan. A capabilities block is a hint; reading a whole repo for it is not. */
70
- const MAX_SCANNED_FILES = 200;
71
- /** Directories that never hold the app's own source, and are expensive or misleading to read. */
72
- const NOT_SOURCE_DIRS = new Set([
73
- 'node_modules',
74
- 'dist',
75
- 'build',
76
- 'out',
77
- 'coverage',
78
- 'target',
79
- 'public',
80
- 'static',
81
- '.next',
82
- '.svelte-kit',
83
- '.nuxt',
84
- ]);
85
- /** How far below the app root to look. Deep enough for `modules/users/UserList.tsx`, not a repo crawl. */
86
- const MAX_SCAN_DEPTH = 5;
87
- /**
88
- * Read a bounded set of the app's source files, for the `data-testid` scan.
89
- *
90
- * This used to walk the fixed list `src, src/components, src/pages, app, components`. Reported from
91
- * the field (#318) by a repo whose app is at `src/admin`: the scan read directories that were not
92
- * the app's and correctly reported finding nothing in them, so `init` said "no data-testid values
93
- * yet" about an app with several — which makes an agent go and write the ones already there. That
94
- * list had already grown once for a `frontend/` app, and a third report of the same shape is what
95
- * says the answer is not another name in a list. So: walk the app root, bounded by depth and by a
96
- * file count.
97
- */
98
- function readSourceFiles(io) {
99
- const out = [];
100
- const read = (path) => {
101
- const content = io.readFile(path);
102
- if (content !== null)
103
- out.push({ path, source: content });
104
- };
105
- for (const name of io.rootFiles()) {
106
- if (out.length >= MAX_SCANNED_FILES)
107
- return out;
108
- if (SOURCE_FILE.test(name))
109
- read(name);
110
- }
111
- const walk = (dir, depth) => {
112
- for (const name of io.listFiles(dir)) {
113
- if (out.length >= MAX_SCANNED_FILES)
114
- return;
115
- if (SOURCE_FILE.test(name))
116
- read(`${dir}/${name}`);
117
- }
118
- if (depth >= MAX_SCAN_DEPTH)
119
- return;
120
- for (const sub of io.listDirs(dir)) {
121
- if (out.length >= MAX_SCANNED_FILES)
122
- return;
123
- if (sub.startsWith('.') || NOT_SOURCE_DIRS.has(sub))
124
- continue;
125
- walk(`${dir}/${sub}`, depth + 1);
126
- }
127
- };
128
- for (const dir of io.listDirs('.')) {
129
- if (out.length >= MAX_SCANNED_FILES)
130
- return out;
131
- if (dir.startsWith('.') || NOT_SOURCE_DIRS.has(dir))
132
- continue;
133
- walk(dir, 1);
134
- }
135
- return out;
136
- }
137
- /** Direct dependency names, for naming the state libraries an app actually has. */
138
- function dependencyNames(pkg) {
139
- const p = (pkg ?? {});
140
- return new Set([
141
- ...Object.keys(p['dependencies'] ?? {}),
142
- ...Object.keys(p['devDependencies'] ?? {}),
143
- ]);
144
- }
145
- /** electron-vite and the unbundled Electron layouts this repo already ships. */
146
- const ELECTRON_MAIN_SOURCES = [
147
- 'src/main/index.ts',
148
- 'src/main/index.js',
149
- 'src/main/index.mts',
150
- 'src/main/index.mjs',
151
- 'electron/main.cjs',
152
- 'electron/main.js',
153
- 'electron/main.ts',
154
- 'src/main.ts',
155
- 'src/main.js',
156
- ];
157
- const ELECTRON_PRELOAD_SOURCES = [
158
- 'src/preload/index.ts',
159
- 'src/preload/index.js',
160
- 'src/preload/index.mts',
161
- 'src/preload/index.mjs',
162
- 'electron/preload.cjs',
163
- 'electron/preload.js',
164
- 'electron/preload.ts',
165
- 'src/preload.ts',
166
- 'src/preload.js',
167
- ];
168
- const ASTRO_CONFIG_CANDIDATES = [
169
- 'astro.config.mjs',
170
- 'astro.config.js',
171
- 'astro.config.ts',
172
- 'astro.config.cjs',
173
- ];
174
- /** Where a conventional Astro app keeps the layout every page wraps itself in. */
175
- const ASTRO_LAYOUTS_DIR = 'src/layouts';
176
- /** Also searched: an app with no layouts directory renders the document straight from a page. */
177
- const ASTRO_PAGES_DIR = 'src/pages';
178
26
  /**
179
27
  * `⚠` means WORK LEFT TO DO and nothing else — it is what an agent (and the release gate) counts to
180
28
  * decide whether the install finished. A notice gets its own mark so "steps remaining" can reach zero
@@ -195,6 +43,8 @@ function resolvedStatus(plan, target, failed, skipped) {
195
43
  return StepStatus.MANUAL;
196
44
  return step.status;
197
45
  }
46
+ /** Every client registration row is titled `MCP server (<client>)`; the closing hint names the unfinished ones. */
47
+ const MCP_CLIENT_TITLE_PREFIX = 'MCP server (';
198
48
  const STATUS_SYMBOL = {
199
49
  [StepStatus.APPLY]: '✓',
200
50
  [StepStatus.MANUAL]: '⚠',
@@ -202,234 +52,6 @@ const STATUS_SYMBOL = {
202
52
  [StepStatus.SKIP]: '–',
203
53
  [StepStatus.NOTICE]: 'ℹ',
204
54
  };
205
- function firstPresent(files, candidates) {
206
- for (const c of candidates)
207
- if (files.has(c))
208
- return c;
209
- return null;
210
- }
211
- function firstReadable(io, candidates) {
212
- for (const path of candidates) {
213
- const source = io.readFile(path);
214
- if (source !== null)
215
- return { path, source };
216
- }
217
- return null;
218
- }
219
- /**
220
- * The directory the human's agent runs in, when it is not the app's directory.
221
- *
222
- * `undefined` for a single-package repo, which keeps every agent-file path project-relative exactly
223
- * as it was — the two roots are the same there, which is why writing them beside the app went
224
- * unnoticed until a repo with its app at `src/admin` reported it.
225
- */
226
- function agentRootOf(options) {
227
- const root = options.agentRoot;
228
- return root === undefined || root === options.cwd ? undefined : root;
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
- }
241
- function gatherPlanInput(options, io, pkg) {
242
- // Stable identity derived from the app's package.json name + root, so it survives port changes.
243
- const projectId = deriveProjectId(packageName(pkg), options.cwd);
244
- const rootFiles = new Set(io.rootFiles());
245
- const nodeModulesMarkers = new Set(io.listFiles('node_modules'));
246
- const detectInput = {
247
- pkg: 'object' === typeof pkg && pkg !== null ? pkg : {},
248
- configFiles: rootFiles,
249
- // Walk up for the lockfile so a monorepo sub-package picks the workspace's package manager —
250
- // unless this package's own installed tree already answers it, which outranks an inherited one.
251
- lockfiles: resolveLockfiles(rootFiles, options.cwd, io, nodeModulesMarkers),
252
- // An already-installed tree names its own manager, which matters when no lockfile is committed.
253
- nodeModulesMarkers,
254
- };
255
- const detection = detect(detectInput);
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);
261
- const viteSource = null === vitePath ? null : io.readFile(vitePath);
262
- const viteConfig = vitePath !== null && viteSource !== null ? { path: vitePath, source: viteSource } : null;
263
- const electronVitePath = firstPresent(rootFiles, ELECTRON_VITE_CONFIG_CANDIDATES);
264
- const electronViteSource = null === electronVitePath ? null : io.readFile(electronVitePath);
265
- const electronViteConfig = electronVitePath !== null && electronViteSource !== null
266
- ? { path: electronVitePath, source: electronViteSource }
267
- : null;
268
- const electronMain = firstReadable(io, ELECTRON_MAIN_SOURCES);
269
- const electronPreload = firstReadable(io, ELECTRON_PRELOAD_SOURCES);
270
- // Global MCP registration targets each agent that's present: Claude via its CLI, Cursor via its
271
- // global config file. Only probe when the MCP step is in play.
272
- const availableProbe = claudeAvailableProbe();
273
- const claudeCli = options.mcp ? io.probe(availableProbe.command, availableProbe.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;
276
- // Every MCP client this machine shows evidence of. Conservative and one-directional: we write
277
- // into a config a client ALREADY has, and never create ~/.gemini or ~/.codeium for somebody who
278
- // does not use them.
279
- const detectedClients = options.mcp ? detectMcpClients(io) : [];
280
- const astroPath = firstPresent(rootFiles, ASTRO_CONFIG_CANDIDATES);
281
- const astroSource = null === astroPath ? null : io.readFile(astroPath);
282
- // Which file owns the DOCUMENT, not how many files sit in a directory. The old rule ("exactly one
283
- // .astro in src/layouts") fired on neither real Astro app: one has no layouts directory and
284
- // renders from src/pages/index.astro, the other has three files there of which two are partials.
285
- // `</body>` is the discriminator — see astro-host.
286
- const astroCandidates = [ASTRO_LAYOUTS_DIR, ASTRO_PAGES_DIR].flatMap((dir) => io
287
- .listFiles(dir)
288
- .filter((f) => f.endsWith('.astro'))
289
- .map((f) => ({ path: `${dir}/${f}`, source: io.readFile(`${dir}/${f}`) }))
290
- .filter((c) => c.source !== null));
291
- const astroHost = pickAstroHost(astroCandidates);
292
- const layoutRelPath = astroHost?.path ?? null;
293
- const astroLayoutSource = astroHost?.source ?? null;
294
- const nuxtConfigPath = firstPresent(rootFiles, NUXT_CONFIG_CANDIDATES);
295
- const nuxtConfigSource = null === nuxtConfigPath ? null : io.readFile(nuxtConfigPath);
296
- // Nuxt 4's default srcDir is `app/`, Nuxt 3's is the root — and a plugin written into the
297
- // directory Nuxt does not scan is never registered, silently.
298
- const nuxtHasAppDir = io.listDirs('.').includes('app');
299
- const nextConfigFile = firstPresent(rootFiles, NEXT_CONFIG_CANDIDATES);
300
- // App Router first; a Pages Router app has no layout, and its mount point is pages/_app.
301
- const layoutPath = NEXT_LAYOUT_CANDIDATES.find((p) => io.exists(p)) ??
302
- NEXT_PAGES_APP_CANDIDATES.find((p) => io.exists(p)) ??
303
- null;
304
- const layoutSource = null === layoutPath ? null : io.readFile(layoutPath);
305
- // Where the component goes depends on WHICH router mounts it: `pages/` routes on presence, so a
306
- // component there becomes a broken route; `app/` routes on filename, so a sibling is inert.
307
- const devLocation = reticleDevLocation(layoutPath ?? 'app/layout.tsx', detection.typescript);
308
- // Read once: both the testid scan and the store scan want the same bounded set of files.
309
- const sourceFiles = readSourceFiles(io);
310
- const agentRoot = agentRootOf(options);
311
- const agentFile = (relPath) => agentRoot === undefined ? relPath : join(agentRoot, relPath);
312
- const cspSources = {};
313
- for (const file of CSP_FILES) {
314
- const source = io.readFile(file);
315
- if (null !== source)
316
- cspSources[file] = source;
317
- }
318
- return {
319
- detection,
320
- captureBodies: options.captureBodies,
321
- hooks: options.hooks,
322
- cspSources,
323
- claudeCli,
324
- mcpExists,
325
- insideClaudeCode: io.host.insideClaudeCode(),
326
- claudeProjectConfig: options.mcp ? io.readFile(agentFile(CLAUDE_PROJECT_CONFIG)) : undefined,
327
- platform: process.platform,
328
- detectedClients,
329
- cursorProjectPresent: io.exists(CURSOR_PROJECT_MARKER),
330
- // Looked for beside the app AND one level up, because the app is routinely a subdirectory of the
331
- // repo that containerises it — `frontend/` under a root `docker-compose.yml` is the shape this
332
- // came from. Only the first match is reported; the note is the same whichever file found it.
333
- ...(() => {
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
- }));
341
- return marker === undefined ? {} : { containerMarker: marker };
342
- })(),
343
- viteConfig,
344
- electronViteConfig,
345
- electronPreload,
346
- electronMain,
347
- astroConfig: astroPath !== null && astroSource !== null ? { path: astroPath, source: astroSource } : null,
348
- astroLayout: layoutRelPath !== null && astroLayoutSource !== null
349
- ? { path: layoutRelPath, source: astroLayoutSource }
350
- : null,
351
- astroEnvDts: io.readFile('src/env.d.ts'),
352
- astroReticleDev: io.readFile('src/components/ReticleDev.ts'),
353
- nextConfigFile,
354
- nextConfigSource: null === nextConfigFile ? null : io.readFile(nextConfigFile),
355
- nextLayout: layoutPath !== null && layoutSource !== null
356
- ? { path: layoutPath, source: layoutSource }
357
- : null,
358
- // Capabilities: scanned, never asked for. Bounded — a hint for the agent, not a repo index.
359
- testids: scanTestids(sourceFiles.map((f) => f.source)),
360
- storeHints: storeHints(dependencyNames(pkg), sourceFiles),
361
- foundStores: scanStores(sourceFiles, dependencyNames(pkg)),
362
- nextFoundStores: scanStores(sourceFiles, dependencyNames(pkg), dirname(devLocation.path)),
363
- viteDevModuleExists: io.exists(detection.framework === Framework.ELECTRON_VITE
364
- ? ELECTRON_VITE_DEV_MODULE_PATH
365
- : VITE_DEV_MODULE_PATH),
366
- nextReticleDevPath: devLocation.path,
367
- nextReticleDevImport: devLocation.importSpecifier,
368
- nextReticleDevExists: io.exists(devLocation.path),
369
- nextReticleDevSource: io.readFile(devLocation.path),
370
- svelteKitHooksExists: io.exists(SVELTEKIT_HOOKS),
371
- svelteKitHooksSource: io.readFile(SVELTEKIT_HOOKS),
372
- reactRouterEntryExists: io.exists(REACT_ROUTER_ENTRY),
373
- // The SOURCE, so an entry the app already owns is ADDED TO rather than replaced — it is an
374
- // override of React Router's default, and everything in it is load-bearing.
375
- reactRouterEntrySource: io.readFile(REACT_ROUTER_ENTRY),
376
- nuxtConfig: nuxtConfigPath !== null && nuxtConfigSource !== null
377
- ? { path: nuxtConfigPath, source: nuxtConfigSource }
378
- : null,
379
- nuxtHasAppDir,
380
- viteMajor: installedViteMajor(io, detectInput.pkg),
381
- nuxtPluginExists: io.exists(nuxtPluginPath(nuxtHasAppDir)),
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
- : {}),
398
- craEntry: craEntryOf(io),
399
- craEnv: io.readFile(CRA_ENV_PATH),
400
- pairingToken: options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken(),
401
- installSource: io.host.installSource(),
402
- reticleConfigExists: io.exists(RETICLE_CONFIG_FILE),
403
- // The CONTENT, so a config that exists can be checked rather than trusted — a `"port"` set to
404
- // the app's own dev-server port used to survive every re-run of `init`.
405
- reticleConfigSource: io.readFile(RETICLE_CONFIG_FILE),
406
- // Read the agent instruction files so the rule merge stays idempotent across re-runs — from the
407
- // agent's own root, or the merge would idempotently check a file it is not going to write.
408
- claudeMdContent: io.readFile(agentFile('CLAUDE.md')),
409
- agentsMdContent: io.readFile(agentFile('AGENTS.md')),
410
- reticleMdContent: io.readFile(agentFile(RETICLE_MD_PATH)),
411
- cursorRuleContent: io.readFile(agentFile(CURSOR_RULE_PATH)),
412
- claudeCommandContent: io.readFile(agentFile(CLAUDE_COMMAND_PATH)),
413
- cursorCommandContent: io.readFile(agentFile(CURSOR_COMMAND_PATH)),
414
- // Read only to MERGE into: the settings file is the user's, and other tools write in it too.
415
- claudeSettingsContent: true === options.hooks ? io.readFile(agentFile(CLAUDE_SETTINGS_PATH)) : undefined,
416
- ...(agentRoot === undefined
417
- ? {}
418
- : {
419
- agentFileRoot: agentRoot,
420
- // The config the AGENT's cwd already has, if any — see agentRootConfigStep.
421
- agentRootConfigSource: io.readFile(agentFile(RETICLE_CONFIG_FILE)),
422
- }),
423
- options: {
424
- port: options.port,
425
- mcp: options.mcp,
426
- install: options.install,
427
- projectId,
428
- // The SDK must match the CLI asking for it — see pinnedPackages.
429
- sdkVersion: RETICLE_VERSION,
430
- },
431
- };
432
- }
433
55
  /** Where workspace tooling conventionally puts packages. */
434
56
  /** pnpm's workspace declaration, read when present — it is authoritative about where packages live. */
435
57
  const SKIPPED_DETAIL = 'skipped — the dependency install above failed, and wiring the app to a package that is not ' +
@@ -477,6 +99,8 @@ continuesToRuntime = false) {
477
99
  // the app never dials the daemon and every tool answers "no browser session connected". `ok` was
478
100
  // hardcoded true, so a run that could not possibly work reported success.
479
101
  let connectPending = false;
102
+ // Other clients' registrations the run left for the reader, named for the closing hint.
103
+ const manualClients = [];
480
104
  for (const s of plan.steps) {
481
105
  // A side effect that failed to apply is reported as a manual step with its fallback command.
482
106
  const note = degraded.get(s.target);
@@ -504,6 +128,9 @@ continuesToRuntime = false) {
504
128
  manual++;
505
129
  if (isConnectStep(s.title))
506
130
  connectPending = true;
131
+ if (s.target !== MCP_TARGET && s.title.startsWith(MCP_CLIENT_TITLE_PREFIX)) {
132
+ manualClients.push(s.title);
133
+ }
507
134
  }
508
135
  for (const line of detail.split('\n'))
509
136
  io.print(` ${line}`);
@@ -525,7 +152,7 @@ continuesToRuntime = false) {
525
152
  }
526
153
  const mcpStatus = resolvedStatus(plan, MCP_TARGET, failed, skipped);
527
154
  if (!continuesToRuntime) {
528
- io.print(restartHint(mcpStatus, devCommand));
155
+ io.print(restartHint(mcpStatus, devCommand, manualClients));
529
156
  }
530
157
  // Carried out even when the hint above was printed, because the RUNTIME path needs the same fact
531
158
  // and could not reach it: `restartHint` is only printed when this run stops at the files, and the
@@ -547,25 +174,6 @@ continuesToRuntime = false) {
547
174
  * MODULE_NOT_FOUND, so the app stops booting *because* Reticle was installed. A skipped step is a
548
175
  * message; a half-wired app is a broken project.
549
176
  */
550
- /**
551
- * Are the SDK packages actually on disk, whatever the install step reported?
552
- *
553
- * `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
554
- * not there — that took a dev server down once, and installing Reticle must never be why an app
555
- * stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
556
- * install subprocess exited 0", and those come apart in exactly the situation the failure creates:
557
- * init tells the user to install by hand, they do, they re-run init, the install step fails again
558
- * (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
559
- * a Next 16 app where npm had already installed both packages successfully — leaving an init that
560
- * could not be retried into working, which is the shape this guard was written to prevent.
561
- *
562
- * Reading node_modules answers the real question and costs one `exists` call per package.
563
- */
564
- function sdkPackagesPresent(framework, uiLibrary, io) {
565
- const packages = frameworkPackages(framework, uiLibrary);
566
- return (packages.length > 0 &&
567
- packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
568
- }
569
177
  function applyEffects(plan, io, packageManager) {
570
178
  const why = new Map();
571
179
  const failed = new Set();
@@ -635,14 +243,17 @@ function applyEffects(plan, io, packageManager) {
635
243
  continue;
636
244
  }
637
245
  // Verify, don't re-run: give the install step itself the same sdkPackagesPresent benefit
638
- // already given to the wiring it gates below (#683).
639
- if (s.target === DEPS_TARGET && sdkPackagesPresent(plan.framework, plan.uiLibrary, io))
246
+ // already given to the wiring it gates below (#683) — but only when package.json DECLARES
247
+ // them. Reported from a Tauri + Next app: `pnpm add` died on ERR_PNPM_UNEXPECTED_STORE, the
248
+ // packages happened to resolve, init printed success, and the manifest never gained them.
249
+ const present = s.target === DEPS_TARGET && sdkPackagesPresent(plan.framework, plan.uiLibrary, io);
250
+ if (present && sdkPackagesDeclared(plan.framework, plan.uiLibrary, io))
640
251
  continue;
641
252
  failed.add(s.target);
642
253
  // A failed install only blocks the wiring when the packages are genuinely ABSENT. See
643
254
  // sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
644
255
  if (s.target === DEPS_TARGET) {
645
- installFailed = true;
256
+ installFailed = !present;
646
257
  const explained = explainInstallFailure(io, packageManager, exec, s.detail);
647
258
  if (explained !== undefined)
648
259
  why.set(s.target, explained);
@@ -687,7 +298,9 @@ function withWorkspaceRoot(options, io) {
687
298
  if (options.agentRoot !== undefined || true === options.redirected)
688
299
  return options;
689
300
  const root = enclosingWorkspaceRoot(options.cwd, io);
690
- return root === undefined ? options : { ...options, agentRoot: root };
301
+ if (root === undefined)
302
+ return options;
303
+ return { ...options, agentRoot: root, severalApps: 1 < appsAt(root, io).length };
691
304
  }
692
305
  export function runInit(options, io) {
693
306
  const result = runInitSteps(withWorkspaceRoot(options, io), io);
@@ -752,11 +365,11 @@ function runInitSteps(options, io) {
752
365
  // `--app frontend`, the flag that exists for exactly this, was never read because the bail came
753
366
  // first. Reported twice by the same user, who tried the documented workaround and hit the same
754
367
  // wall. Discovery already handles the case (it scans top-level directories, not just declared
755
- // workspaces); it was simply unreachable.
756
- //
757
- // `'{}'` because the redirect only needs the manifest to ask "is THIS directory the app", and a
758
- // directory with no package.json is definitively not.
368
+ // workspaces); it was simply unreachable. `'{}'`: a directory with no manifest is not the app.
759
369
  const redirectedEarly = redirectToWorkspaceApp(options, io, pkgRaw ?? {}, runInit);
370
+ // Listing the apps below still counts as "no app here", in the same series as before.
371
+ if (true === redirectedEarly?.listedAppsBelow && !options.dryRun)
372
+ io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
760
373
  if (redirectedEarly !== null)
761
374
  return redirectedEarly;
762
375
  if (null === pkgRaw)
@@ -0,0 +1,19 @@
1
+ import type { Framework, UiLibrary } from './detect/detect.js';
2
+ import type { InitIo } from './run-types.js';
3
+ /**
4
+ * Are the SDK packages actually on disk, whatever the install step reported?
5
+ *
6
+ * `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
7
+ * not there — that took a dev server down once, and installing Reticle must never be why an app
8
+ * stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
9
+ * install subprocess exited 0", and those come apart in exactly the situation the failure creates:
10
+ * init tells the user to install by hand, they do, they re-run init, the install step fails again
11
+ * (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
12
+ * a Next 16 app where npm had already installed both packages successfully — leaving an init that
13
+ * could not be retried into working, which is the shape this guard was written to prevent.
14
+ *
15
+ * Reading node_modules answers the real question and costs one `exists` call per package.
16
+ */
17
+ export declare function sdkPackagesPresent(framework: Framework, uiLibrary: UiLibrary, io: Pick<InitIo, 'exists'>): boolean;
18
+ /** Does package.json list every SDK package? On disk without this, the next clean install drops them. */
19
+ export declare function sdkPackagesDeclared(framework: Framework, uiLibrary: UiLibrary, io: Pick<InitIo, 'readFile'>): boolean;
@@ -0,0 +1,34 @@
1
+ import { PACKAGE_JSON } from './detect/workspace-apps.js';
2
+ import { frameworkPackages } from './plan/plan.js';
3
+ const NODE_MODULES_DIR = 'node_modules';
4
+ /**
5
+ * Are the SDK packages actually on disk, whatever the install step reported?
6
+ *
7
+ * `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
8
+ * not there — that took a dev server down once, and installing Reticle must never be why an app
9
+ * stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
10
+ * install subprocess exited 0", and those come apart in exactly the situation the failure creates:
11
+ * init tells the user to install by hand, they do, they re-run init, the install step fails again
12
+ * (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
13
+ * a Next 16 app where npm had already installed both packages successfully — leaving an init that
14
+ * could not be retried into working, which is the shape this guard was written to prevent.
15
+ *
16
+ * Reading node_modules answers the real question and costs one `exists` call per package.
17
+ */
18
+ export function sdkPackagesPresent(framework, uiLibrary, io) {
19
+ const packages = frameworkPackages(framework, uiLibrary);
20
+ return (packages.length > 0 &&
21
+ packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
22
+ }
23
+ /** Does package.json list every SDK package? On disk without this, the next clean install drops them. */
24
+ export function sdkPackagesDeclared(framework, uiLibrary, io) {
25
+ let manifest;
26
+ try {
27
+ manifest = JSON.parse(io.readFile(PACKAGE_JSON) ?? '{}');
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ const m = (manifest ?? {});
33
+ return frameworkPackages(framework, uiLibrary).every((p) => m['dependencies']?.[p] !== undefined || m['devDependencies']?.[p] !== undefined);
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/init",
3
- "version": "3.5.0",
3
+ "version": "3.7.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.5.0"
16
+ "@reticlehq/core": "3.7.0"
17
17
  },
18
18
  "license": "FSL-1.1-ALv2",
19
19
  "author": "Reticle contributors",