@reticlehq/init 3.4.0 → 3.6.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.
@@ -194,6 +194,14 @@ export declare const ANGULAR_WORKSPACE_FILE = "angular.json";
194
194
  interface FrameworkSignals {
195
195
  /** `package.json` dependency names that name this framework outright. */
196
196
  readonly deps: readonly string[];
197
+ /**
198
+ * Dependencies that disprove this framework's dependency signal, without affecting an explicit
199
+ * config-file signal.
200
+ *
201
+ * Vinext carries `next` for API compatibility but builds through Vite, so treating `next` as
202
+ * conclusive there would install wiring that Vinext never evaluates.
203
+ */
204
+ readonly depsUnless?: readonly string[];
197
205
  /** Root config-file basenames that name it when the dependency is absent. */
198
206
  readonly configs: readonly string[];
199
207
  /**
@@ -164,7 +164,7 @@ const VITE_INDEX_HTML = 'index.html';
164
164
  * asserted in `framework-adapter.test.ts`.
165
165
  */
166
166
  export const FRAMEWORK_SIGNALS = {
167
- [Framework.NEXT]: { deps: ['next'], configs: NEXT_CONFIGS },
167
+ [Framework.NEXT]: { deps: ['next'], depsUnless: ['vinext'], configs: NEXT_CONFIGS },
168
168
  [Framework.NUXT]: { deps: ['nuxt'], configs: NUXT_CONFIGS },
169
169
  [Framework.SVELTEKIT]: {
170
170
  deps: ['@sveltejs/kit'],
@@ -201,7 +201,7 @@ export const FRAMEWORK_SIGNALS = {
201
201
  deps: ['@tanstack/react-start', '@tanstack/start'],
202
202
  configs: [],
203
203
  },
204
- [Framework.VITE]: { deps: ['vite'], configs: VITE_CONFIGS },
204
+ [Framework.VITE]: { deps: ['vite', 'vinext'], configs: VITE_CONFIGS },
205
205
  /** CRA has no config file at all, so the dependency is the only signal. */
206
206
  [Framework.CRA]: { deps: ['react-scripts'], configs: [] },
207
207
  /** Nothing identifies plain HTML; it is where the chain below ends. */
@@ -325,8 +325,11 @@ function declaredDependencies(pkg) {
325
325
  function detectFramework(input) {
326
326
  for (const framework of DETECTION_ORDER) {
327
327
  const signals = FRAMEWORK_SIGNALS[framework];
328
- if (signals.deps.some((d) => depVersion(input.pkg, d) !== undefined))
328
+ const hasDisqualifyingDependency = true === signals.depsUnless?.some((d) => depVersion(input.pkg, d) !== undefined);
329
+ if (signals.deps.some((d) => depVersion(input.pkg, d) !== undefined) &&
330
+ !hasDisqualifyingDependency) {
329
331
  return framework;
332
+ }
330
333
  if (hasAnyConfig(input.configFiles, signals.configs) &&
331
334
  !hasAnyConfig(input.configFiles, signals.configsUnless ?? [])) {
332
335
  return framework;
@@ -34,6 +34,22 @@ export declare function detectDjangoProject(exists: (file: string) => boolean, r
34
34
  export declare function djangoSetupMessage(): string;
35
35
  /** The ecosystem this directory looks like, or undefined when nothing says. */
36
36
  export declare function detectNonJsEcosystem(exists: (file: string) => boolean): string | undefined;
37
+ /** Which answer `init` gives when there is no package.json. */
38
+ export declare const NoPackageJsonCase: {
39
+ /** No ecosystem marker and no page: the only honest answer is "run `init` somewhere else". */
40
+ readonly WRONG_DIRECTORY: "wrong_directory";
41
+ /** A page with no build step, which takes the script-tag snippet. */
42
+ readonly STATIC_PAGE: "static_page";
43
+ /** Flutter web paints into a canvas, so there is nothing to instrument. */
44
+ readonly FLUTTER: "flutter";
45
+ /** A recognised non-JS ecosystem, which can load the SDK from a URL. */
46
+ readonly NON_JS: "non_js";
47
+ };
48
+ export type NoPackageJsonCase = (typeof NoPackageJsonCase)[keyof typeof NoPackageJsonCase];
49
+ export interface NoPackageJsonAnswer {
50
+ kind: NoPackageJsonCase;
51
+ message: string;
52
+ }
37
53
  /**
38
54
  * What `init` should say when there is no package.json.
39
55
  *
@@ -42,3 +58,10 @@ export declare function detectNonJsEcosystem(exists: (file: string) => boolean):
42
58
  * second needs to know that no path exists and why.
43
59
  */
44
60
  export declare function noPackageJsonMessage(exists: (file: string) => boolean, rootFiles?: readonly string[]): string;
61
+ /**
62
+ * {@link noPackageJsonMessage}, plus which case it picked.
63
+ *
64
+ * `init` acts on the case (it writes nothing for {@link NoPackageJsonCase.WRONG_DIRECTORY}), so the
65
+ * choice is made here once rather than re-derived beside the message and kept in sync by hand.
66
+ */
67
+ export declare function noPackageJsonAnswer(exists: (file: string) => boolean, rootFiles?: readonly string[]): NoPackageJsonAnswer;
@@ -97,6 +97,17 @@ export function detectNonJsEcosystem(exists) {
97
97
  }
98
98
  return undefined;
99
99
  }
100
+ /** Which answer `init` gives when there is no package.json. */
101
+ export const NoPackageJsonCase = {
102
+ /** No ecosystem marker and no page: the only honest answer is "run `init` somewhere else". */
103
+ WRONG_DIRECTORY: 'wrong_directory',
104
+ /** A page with no build step, which takes the script-tag snippet. */
105
+ STATIC_PAGE: 'static_page',
106
+ /** Flutter web paints into a canvas, so there is nothing to instrument. */
107
+ FLUTTER: 'flutter',
108
+ /** A recognised non-JS ecosystem, which can load the SDK from a URL. */
109
+ NON_JS: 'non_js',
110
+ };
100
111
  /**
101
112
  * What `init` should say when there is no package.json.
102
113
  *
@@ -105,38 +116,59 @@ export function detectNonJsEcosystem(exists) {
105
116
  * second needs to know that no path exists and why.
106
117
  */
107
118
  export function noPackageJsonMessage(exists, rootFiles = []) {
119
+ return noPackageJsonAnswer(exists, rootFiles).message;
120
+ }
121
+ /**
122
+ * {@link noPackageJsonMessage}, plus which case it picked.
123
+ *
124
+ * `init` acts on the case (it writes nothing for {@link NoPackageJsonCase.WRONG_DIRECTORY}), so the
125
+ * choice is made here once rather than re-derived beside the message and kept in sync by hand.
126
+ */
127
+ export function noPackageJsonAnswer(exists, rootFiles = []) {
108
128
  const ecosystem = detectNonJsEcosystem(exists);
109
129
  if (ecosystem === undefined) {
110
130
  if (exists('index.html') || rootFiles.some((name) => name.endsWith('.html'))) {
111
- return ('This is a static page with no build step, so there is nothing for `reticle init` to wire. ' +
112
- 'Add the script-tag snippet below to the page while you develop, then reload it. Remove the ' +
113
- 'snippet before publishing: a static file has no development build to keep it out of production.');
131
+ return {
132
+ kind: NoPackageJsonCase.STATIC_PAGE,
133
+ message: 'This is a static page with no build step, so there is nothing for `reticle init` to wire. ' +
134
+ 'Add the script-tag snippet below to the page while you develop, then reload it. Remove the ' +
135
+ 'snippet before publishing: a static file has no development build to keep it out of production.',
136
+ };
114
137
  }
115
- return ('No package.json found here, and no app directory beneath it either. Run `reticle init` from ' +
116
- "your app's directory, or from a repo root that contains it.");
138
+ return {
139
+ kind: NoPackageJsonCase.WRONG_DIRECTORY,
140
+ message: 'No package.json found here, and no app directory beneath it either. Run `reticle init` from ' +
141
+ "your app's directory, or from a repo root that contains it.",
142
+ };
117
143
  }
118
144
  if (ecosystem === FLUTTER) {
119
145
  // An agent on a Flutter workspace asked us to "support Flutter, or clearly identify it as
120
146
  // unsupported during init". The second is the truthful one, and saying it by name in one line
121
147
  // is the difference between an agent that stops and an agent that spends an hour looking for
122
148
  // the package.json we told it was missing.
123
- return ('This is a Flutter project. Reticle cannot instrument it, and no directory or flag will ' +
124
- 'change that: Reticle reads the DOM of a running page, and Flutter web paints its entire UI ' +
125
- 'into a single canvas element with no DOM behind it. Nothing here is missing or misconfigured. ' +
126
- 'If this repo also contains an ordinary web app with its own package.json, run `reticle init` ' +
127
- 'inside that directory instead.');
149
+ return {
150
+ kind: NoPackageJsonCase.FLUTTER,
151
+ message: 'This is a Flutter project. Reticle cannot instrument it, and no directory or flag will ' +
152
+ 'change that: Reticle reads the DOM of a running page, and Flutter web paints its entire UI ' +
153
+ 'into a single canvas element with no DOM behind it. Nothing here is missing or misconfigured. ' +
154
+ 'If this repo also contains an ordinary web app with its own package.json, run `reticle init` ' +
155
+ 'inside that directory instead.',
156
+ };
128
157
  }
129
- return (`This looks like a ${ecosystem} project, and there is no package.json here or in any app ` +
130
- 'directory beneath it. That is not a blocker: Reticle needs its SDK loaded by the page, not ' +
131
- 'built by npm, and a page with no build step can load it from a URL. ' +
132
- // The old message ended at "there is no directory you could run this from that would change
133
- // that", which read as a refusal and was acted on as one — agents went looking for a bundler,
134
- // or for a standalone build to inject by hand, or gave up and used a different tool. The
135
- // capability was there the whole time; only the sentence was missing. Proven end to end on a
136
- // page served by `python3 -m http.server` before this line was written.
137
- 'Add the script-tag snippet below to a template you only serve in development, then reload the ' +
138
- 'page. ' +
139
- 'If this project ALSO has a JS front end (a `frontend/`, `client/` or `assets/` directory with ' +
140
- 'its own package.json), running `reticle init` there instead gives you source mapping and ' +
141
- 'framework state as well: point at it with `--app <dir>`.');
158
+ return {
159
+ kind: NoPackageJsonCase.NON_JS,
160
+ message: `This looks like a ${ecosystem} project, and there is no package.json here or in any app ` +
161
+ 'directory beneath it. That is not a blocker: Reticle needs its SDK loaded by the page, not ' +
162
+ 'built by npm, and a page with no build step can load it from a URL. ' +
163
+ // The old message ended at "there is no directory you could run this from that would change
164
+ // that", which read as a refusal and was acted on as one — agents went looking for a bundler,
165
+ // or for a standalone build to inject by hand, or gave up and used a different tool. The
166
+ // capability was there the whole time; only the sentence was missing. Proven end to end on a
167
+ // page served by `python3 -m http.server` before this line was written.
168
+ 'Add the script-tag snippet below to a template you only serve in development, then reload the ' +
169
+ 'page. ' +
170
+ 'If this project ALSO has a JS front end (a `frontend/`, `client/` or `assets/` directory with ' +
171
+ 'its own package.json), running `reticle init` there instead gives you source mapping and ' +
172
+ 'framework state as well: point at it with `--app <dir>`.',
173
+ };
142
174
  }
@@ -26,6 +26,11 @@ export declare function workspaceParents(sources: WorkspaceSources): string[];
26
26
  */
27
27
  /** Deps that mark a directory as a runnable web app even when it has no bundler config file. */
28
28
  export declare const APP_DEPS: readonly ["next", "vite", "electron-vite"];
29
+ /**
30
+ * Whether `dir` is itself an app with a page: what the daemon asks before wiring a folder unasked,
31
+ * so a library or API package it happens to run in is left alone.
32
+ */
33
+ export declare function isWebApp(dir: string, io: Pick<InitIo, 'exists' | 'readFile'>): boolean;
29
34
  /**
30
35
  * App directories under a workspace root.
31
36
  *
@@ -179,6 +179,13 @@ function appSignal(dir, io) {
179
179
  // backend. A package with a dev script and no page is only a candidate when nothing else is.
180
180
  return hasPage(dir, pkgRaw, io) ? AppSignal.UI : AppSignal.SERVED;
181
181
  }
182
+ /**
183
+ * Whether `dir` is itself an app with a page: what the daemon asks before wiring a folder unasked,
184
+ * so a library or API package it happens to run in is left alone.
185
+ */
186
+ export function isWebApp(dir, io) {
187
+ return AppSignal.UI === appSignal(dir, io);
188
+ }
182
189
  /**
183
190
  * App directories under a workspace root.
184
191
  *
@@ -11,6 +11,18 @@
11
11
  import type { InitIo, InitOptions, InitResult } from '../run-types.js';
12
12
  /** Re-enter `init`, scoped to one directory of the workspace. */
13
13
  type RunInit = (options: InitOptions, io: InitIo) => InitResult;
14
+ declare const POWERSHELL_ONLY_LABEL: "PowerShell only:";
15
+ interface DisplayedWorkspaceAppCommand {
16
+ command: string;
17
+ label?: typeof POWERSHELL_ONLY_LABEL;
18
+ }
19
+ /**
20
+ * Render one discovered workspace app as a command the user can paste back into a shell.
21
+ *
22
+ * Plain paths need no quoting. Complex Windows paths are explicitly PowerShell-only because
23
+ * cmd.exe and PowerShell use different quoting grammars.
24
+ */
25
+ export declare function displayedWorkspaceAppCommand(app: string, platform: NodeJS.Platform): DisplayedWorkspaceAppCommand;
14
26
  /**
15
27
  * When the current directory is a workspace root with no app of its own, wire the app instead of the
16
28
  * root. One candidate is wired silently (there is nothing to ask about); several are listed, because
@@ -29,7 +29,36 @@ function appNamedByRootConfig(io) {
29
29
  const named = workspacePackageDirs(io).filter((dir) => projectIdOf(io.readFile(`${dir}/${RETICLE_CONFIG_FILE}`)) === wanted);
30
30
  return 1 === named.length ? named[0] : undefined;
31
31
  }
32
- const AMBIGUOUS_HEADER = 'Several apps found in this workspace. Re-run `reticle init` inside the one you want:';
32
+ const AMBIGUOUS_HEADER = 'Several apps found in this workspace. Re-run `reticle init` for the one you want:';
33
+ const WORKSPACE_APP_COMMAND = 'reticle init --app';
34
+ const POWERSHELL_ONLY_LABEL = 'PowerShell only:';
35
+ const PLAIN_SHELL_ARG = /^[A-Za-z0-9_./-]+$/;
36
+ function posixShellArg(arg) {
37
+ return `'${arg.replaceAll("'", "'\"'\"'")}'`;
38
+ }
39
+ function powerShellArg(arg) {
40
+ return `'${arg.replaceAll("'", "''")}'`;
41
+ }
42
+ /**
43
+ * Render one discovered workspace app as a command the user can paste back into a shell.
44
+ *
45
+ * Plain paths need no quoting. Complex Windows paths are explicitly PowerShell-only because
46
+ * cmd.exe and PowerShell use different quoting grammars.
47
+ */
48
+ export function displayedWorkspaceAppCommand(app, platform) {
49
+ if (PLAIN_SHELL_ARG.test(app)) {
50
+ return { command: `${WORKSPACE_APP_COMMAND} ${app}` };
51
+ }
52
+ if ('win32' === platform) {
53
+ return {
54
+ label: POWERSHELL_ONLY_LABEL,
55
+ command: `${WORKSPACE_APP_COMMAND} ${powerShellArg(app)}`,
56
+ };
57
+ }
58
+ return {
59
+ command: `${WORKSPACE_APP_COMMAND} ${posixShellArg(app)}`,
60
+ };
61
+ }
33
62
  /**
34
63
  * When the current directory is a workspace root with no app of its own, wire the app instead of the
35
64
  * root. One candidate is wired silently (there is nothing to ask about); several are listed, because
@@ -89,10 +118,12 @@ export function redirectToWorkspaceApp(options, io, pkg, runInit) {
89
118
  if (0 === apps.length)
90
119
  return null; // not a workspace — fall through to the normal HTML plan
91
120
  io.print(AMBIGUOUS_HEADER);
92
- for (const a of apps)
93
- io.print(` ${a}`);
94
- io.print('');
95
- io.print(`Or name one without changing directory: reticle init --app ${apps[0] ?? '<dir>'}`);
121
+ for (const a of apps) {
122
+ const displayed = displayedWorkspaceAppCommand(a, process.platform);
123
+ if (displayed.label !== undefined)
124
+ io.print(` ${displayed.label}`);
125
+ io.print(` ${displayed.command}`);
126
+ }
96
127
  return { ok: false, applied: 0, manual: apps.length };
97
128
  }
98
129
  return enterApp(options, io, target, 'No app in this directory — wiring', runInit);
@@ -31,4 +31,10 @@ export declare const FEEDBACK_HINT: string;
31
31
  */
32
32
  export declare function restartHint(mcpStatus: StepStatus | undefined,
33
33
  /** The project's own dev command, when package.json named one. */
34
- devCommand?: string): string;
34
+ devCommand?: string,
35
+ /**
36
+ * Titles of other clients' registration steps this run left for the reader (the `⚠` rows). The
37
+ * Claude Code row says nothing about them, so without these the closing line contradicts the
38
+ * report above it.
39
+ */
40
+ manualClients?: readonly string[]): string;
@@ -21,6 +21,24 @@ export const FEEDBACK_HINT = 'Anything wrong, missing, or awkward — in this se
21
21
  'one thing that decides what gets fixed:\n' +
22
22
  ` ${CLI} feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened" (agents)\n` +
23
23
  ` ${CLI} feedback "what worked, what didn't" (humans)`;
24
+ /** User-facing closing copy for `restartHint` — one named home per sentence block. */
25
+ const RestartHintCopy = {
26
+ DEMO: 'Then drive one real flow of your app end to end and report the verdict — that is the install ' +
27
+ "proving itself, and it is the agent's job, not a separate command.",
28
+ PROVE: 'Then run `npx @reticlehq/server status` — it confirms the app connected, or says exactly why ' +
29
+ 'it has not.',
30
+ ALREADY_REGISTERED_ALL: 'The Reticle MCP server was already registered on this machine, so there is no restart to do ' +
31
+ 'and the tools are available now.',
32
+ ALREADY_REGISTERED_CLAUDE_HEAD: 'The Reticle MCP server was already registered with Claude Code, so there is no restart to ' +
33
+ 'do and the tools are available there now. They are NOT available in the clients whose ' +
34
+ 'step above still needs a hand edit (',
35
+ ALREADY_REGISTERED_CLAUDE_TAIL: ') until that is done.',
36
+ CLOSING_ALREADY_NO_MANUAL: 'Once it shows a session, drive a flow — that is the install finished. Carry straight on; ' +
37
+ 'nothing here is waiting on a human.',
38
+ CLOSING_ALREADY_WITH_MANUAL: 'Once it shows a session, drive a flow where the tools are already available — that is the ' +
39
+ 'install finished on that client. The hand-edit step above still needs a human for the other ' +
40
+ 'clients named there.',
41
+ };
24
42
  /**
25
43
  * What to say about the dev server.
26
44
  *
@@ -53,7 +71,13 @@ function devServerRestart(devCommand) {
53
71
  */
54
72
  export function restartHint(mcpStatus,
55
73
  /** The project's own dev command, when package.json named one. */
56
- devCommand) {
74
+ devCommand,
75
+ /**
76
+ * Titles of other clients' registration steps this run left for the reader (the `⚠` rows). The
77
+ * Claude Code row says nothing about them, so without these the closing line contradicts the
78
+ * report above it.
79
+ */
80
+ manualClients = []) {
57
81
  const dev = `${devServerRestart(devCommand)}.`;
58
82
  // NAME THE COMMAND THAT PROVES IT, not one that merely asks.
59
83
  //
@@ -77,10 +101,8 @@ devCommand) {
77
101
  // So the close hands the agent the sequence instead. It is deliberately one drive of one real
78
102
  // flow ending in a verdict, because an install that ends in "the tools are available" is an
79
103
  // install that ends in nothing.
80
- const demo = 'Then drive one real flow of your app end to end and report the verdict — that is the install ' +
81
- "proving itself, and it is the agent's job, not a separate command.";
82
- const prove = 'Then run `npx @reticlehq/server status` — it confirms the app connected, or says exactly why ' +
83
- 'it has not.';
104
+ const demo = RestartHintCopy.DEMO;
105
+ const prove = RestartHintCopy.PROVE;
84
106
  // Nothing registered this run (`--no-mcp`, or a client that needs a manual edit): a restart picks
85
107
  // up nothing, so advice about one would be advice about something we did not do.
86
108
  if (!wasMcpRegistered(mcpStatus))
@@ -92,13 +114,18 @@ devCommand) {
92
114
  // agent, the tools only appear after that" — and an agent believes the output in front of it over
93
115
  // whatever its skill file said, so it stops with the project wired and nothing driven.
94
116
  if (mcpStatus === StepStatus.ALREADY) {
95
- return (`${dev}\n` +
96
- `${demo}\n` +
97
- 'The Reticle MCP server was already registered on this machine, so there is no restart to do ' +
98
- 'and the tools are available now.\n' +
99
- `${prove}\n` +
100
- 'Once it shows a session, drive a flow — that is the install finished. Carry straight on; ' +
101
- 'nothing here is waiting on a human.');
117
+ // Only Claude Code's row was read, so "available now" is true for Claude Code and nothing else.
118
+ // Name the registrations still left, or a Codex user reads two contradicting lines and believes
119
+ // the last one.
120
+ const availability = 0 === manualClients.length
121
+ ? RestartHintCopy.ALREADY_REGISTERED_ALL
122
+ : RestartHintCopy.ALREADY_REGISTERED_CLAUDE_HEAD +
123
+ manualClients.join(', ') +
124
+ RestartHintCopy.ALREADY_REGISTERED_CLAUDE_TAIL;
125
+ const closing = 0 === manualClients.length
126
+ ? RestartHintCopy.CLOSING_ALREADY_NO_MANUAL
127
+ : RestartHintCopy.CLOSING_ALREADY_WITH_MANUAL;
128
+ return `${dev}\n${demo}\n${availability}\n${prove}\n${closing}`;
102
129
  }
103
130
  return (`${dev}\n` +
104
131
  `${demo}\n` +
@@ -24,23 +24,26 @@ const WILDCARDS = ['*', 'ws:', 'wss:', 'ws://*', 'https:'];
24
24
  function bridgeOrigins(port) {
25
25
  return [`ws://localhost:${String(port)}`, `ws://127.0.0.1:${String(port)}`];
26
26
  }
27
- /**
28
- * The `connect-src` directive's source list, or undefined when the text declares none.
29
- *
30
- * Matches to the end of the directive (`;`) or the end of the enclosing quoted string, which is how
31
- * these are written in both a Next `headers()` value and a `<meta content="...">`.
32
- */
27
+ const TERNARY_GAP = /^"\s*:\s*"$/;
28
+ const EXPRESSION_EDGE = /^["'`})\]]+|["'`})\]]+$/g;
33
29
  function directiveSources(text, directive) {
34
30
  // Stops at the directive separator or the closing double quote of the enclosing string. Single
35
31
  // quotes are NOT terminators: `'self'` is a source, not the end of the list.
36
- const match = new RegExp(`${directive}([^;"]*)`, 'i').exec(text);
37
- const list = match?.[1];
38
- if (list === undefined)
32
+ const matches = [...text.matchAll(new RegExp(`${directive}([^;"]*)`, 'gi'))];
33
+ if (0 === matches.length)
39
34
  return undefined;
40
- return list
41
- .split(/\s+/)
42
- .map((source) => source.trim())
43
- .filter((source) => source.length > 0);
35
+ return matches.map((match, index) => {
36
+ const previous = matches[index - 1];
37
+ const previousEnd = previous === undefined ? undefined : (previous.index ?? 0) + previous[0].length;
38
+ return {
39
+ sources: (match[1] ?? '')
40
+ .split(/\s+/)
41
+ .map((source) => source.trim())
42
+ .filter((source) => source.length > 0),
43
+ alternativeWithPrevious: previousEnd !== undefined &&
44
+ TERNARY_GAP.test(text.slice(previousEnd, match.index ?? previousEnd)),
45
+ };
46
+ });
44
47
  }
45
48
  /**
46
49
  * What this policy allows a WebSocket to reach — `connect-src`, or `default-src` when it is absent.
@@ -55,7 +58,11 @@ function directiveSources(text, directive) {
55
58
  * Still undefined when NEITHER is present — a policy that constrains neither is not blocking us.
56
59
  */
57
60
  function connectSrcSources(text) {
58
- return directiveSources(text, 'connect-src') ?? directiveSources(text, 'default-src');
61
+ const sources = directiveSources(text, 'connect-src') ?? directiveSources(text, 'default-src');
62
+ return sources?.map(({ sources: sourceList, alternativeWithPrevious }) => ({
63
+ sources: sourceList.map((source) => source.replace(EXPRESSION_EDGE, '')),
64
+ alternativeWithPrevious,
65
+ }));
59
66
  }
60
67
  /**
61
68
  * The problem with this app's `connect-src`, or undefined if there is not one.
@@ -67,15 +74,41 @@ export function cspConnectSrcProblem(text, port) {
67
74
  const sources = connectSrcSources(text);
68
75
  if (sources === undefined || 0 === sources.length)
69
76
  return undefined;
70
- if (sources.some((source) => WILDCARDS.includes(source)))
71
- return undefined;
72
77
  const wanted = bridgeOrigins(port);
78
+ // Separate CSP policies are all enforced, so every group must admit the bridge. Two occurrences
79
+ // that are alternate branches of one source-level ternary are not enforced together: either
80
+ // branch admitting the bridge is enough for that group.
81
+ const missingByPolicyGroup = [];
82
+ for (const { sources: sourceList, alternativeWithPrevious } of sources) {
83
+ const missingFromSourceList = sourceList.some((source) => WILDCARDS.includes(source))
84
+ ? []
85
+ : wanted.filter((origin) => !sourceList.includes(origin));
86
+ if (alternativeWithPrevious) {
87
+ const previousIndex = missingByPolicyGroup.length - 1;
88
+ const previousMissing = missingByPolicyGroup[previousIndex];
89
+ if (previousMissing === undefined) {
90
+ missingByPolicyGroup.push(missingFromSourceList);
91
+ }
92
+ else {
93
+ // Either complete branch makes this conditional group safe. If both branches block, retain
94
+ // every origin needed to make either possible policy admit the bridge.
95
+ missingByPolicyGroup[previousIndex] =
96
+ 0 === previousMissing.length || 0 === missingFromSourceList.length
97
+ ? []
98
+ : wanted.filter((origin) => previousMissing.includes(origin) || missingFromSourceList.includes(origin));
99
+ }
100
+ }
101
+ else {
102
+ missingByPolicyGroup.push(missingFromSourceList);
103
+ }
104
+ }
73
105
  // BOTH, not either: the SDK picks its host from how the page was served, and a policy that admits
74
106
  // one is a coin flip. A coin flip that fails is indistinguishable from every other silent
75
107
  // non-connect, which is the whole cost being avoided here.
76
- if (wanted.every((origin) => sources.includes(origin)))
108
+ const blockingGroups = missingByPolicyGroup.filter((missing) => 0 < missing.length);
109
+ if (0 === blockingGroups.length)
77
110
  return undefined;
78
- const missing = wanted.filter((origin) => !sources.includes(origin));
111
+ const missing = wanted.filter((origin) => blockingGroups.some((group) => group.includes(origin)));
79
112
  return (`this app declares a Content-Security-Policy whose \`connect-src\` does not admit the Reticle ` +
80
113
  `bridge: ${missing.join(' and ')} ${1 === missing.length ? 'is' : 'are'} missing. The browser ` +
81
114
  `will block the WebSocket and report it in ITS console only — every check on the Reticle side ` +
@@ -125,7 +158,19 @@ export function cspInlineScriptProblem(text, port) {
125
158
  const sources = scriptSrcSources(text);
126
159
  if (sources === undefined || 0 === sources.length)
127
160
  return undefined;
128
- if (!blocksInlineScript(sources))
161
+ // The same CSP composition rule applies here: any independent blocking policy wins, while a
162
+ // source-level conditional is safe when either of its mutually exclusive branches permits inline.
163
+ const policiesThatBlockInlineScript = [];
164
+ for (const { sources: sourceList, alternativeWithPrevious } of sources) {
165
+ const blocksInline = blocksInlineScript(sourceList);
166
+ if (alternativeWithPrevious) {
167
+ policiesThatBlockInlineScript[policiesThatBlockInlineScript.length - 1] &&= blocksInline;
168
+ }
169
+ else {
170
+ policiesThatBlockInlineScript.push(blocksInline);
171
+ }
172
+ }
173
+ if (!policiesThatBlockInlineScript.some(Boolean))
129
174
  return undefined;
130
175
  return (`this app declares a Content-Security-Policy whose \`script-src\` does not admit an inline ` +
131
176
  `script, so the connect snippet never executes. Nothing on the Reticle side can see this: with ` +
@@ -182,7 +227,7 @@ function withBridgeOrigins(policy, port) {
182
227
  }
183
228
  // No connect-src: the socket falls back to default-src, so the new directive starts from what
184
229
  // default-src already allowed — adding it bare would take 'self' away from every fetch the app does.
185
- const fallback = directiveSources(policy, 'default-src') ?? [];
230
+ const fallback = directiveSources(policy, 'default-src')?.[0]?.sources ?? [];
186
231
  const added = ` connect-src ${[...fallback, ...origins].join(' ')}`;
187
232
  const trimmed = policy.trimEnd();
188
233
  return trimmed.endsWith(';') ? `${trimmed}${added};` : `${trimmed};${added}`;
@@ -13,6 +13,13 @@ export declare const InitFailure: {
13
13
  readonly DEPENDENCY_INSTALL: "dependency_install";
14
14
  /** The `claude mcp add` step failed — the CLI is missing or refused. Reticle installs but is unreachable. */
15
15
  readonly MCP_REGISTRATION: "mcp_registration";
16
+ /** The checkout or required tooling failed preflight. */
17
+ readonly PREFLIGHT: "preflight";
18
+ /** Runtime continuation could not start the app or connect its SDK. */
19
+ readonly DEV_SERVER: "dev_server";
20
+ readonly APP_CONNECTION: "app_connection";
21
+ readonly BRIDGE_OCCUPIED: "bridge_occupied";
22
+ readonly RUNTIME_ERROR: "runtime_error";
16
23
  readonly OTHER: "other";
17
24
  };
18
25
  export type InitFailure = (typeof InitFailure)[keyof typeof InitFailure];
@@ -13,5 +13,12 @@ export const InitFailure = {
13
13
  DEPENDENCY_INSTALL: 'dependency_install',
14
14
  /** The `claude mcp add` step failed — the CLI is missing or refused. Reticle installs but is unreachable. */
15
15
  MCP_REGISTRATION: 'mcp_registration',
16
+ /** The checkout or required tooling failed preflight. */
17
+ PREFLIGHT: 'preflight',
18
+ /** Runtime continuation could not start the app or connect its SDK. */
19
+ DEV_SERVER: 'dev_server',
20
+ APP_CONNECTION: 'app_connection',
21
+ BRIDGE_OCCUPIED: 'bridge_occupied',
22
+ RUNTIME_ERROR: 'runtime_error',
16
23
  OTHER: 'other',
17
24
  };
package/dist/index.d.ts CHANGED
@@ -29,8 +29,9 @@ export { DEV_SERVER_PORTS, isLikelyDevServerPort, devServerPortWarning, } from '
29
29
  export { configWithInstallSource } from './project/install-source-config.js';
30
30
  export { projectIdOf, rememberProjectOnDisk, type RegistryIo } from './project/remember-project.js';
31
31
  export { detectPackageManager, Framework, installCommandParts, parseMajor, PackageManager, } from './detect/detect.js';
32
- export { findWorkspaceApps } from './detect/workspace-apps.js';
32
+ export { findWorkspaceApps, isWebApp, workspacePackageDirs } from './detect/workspace-apps.js';
33
33
  export { desktopLaunch, type DesktopLaunch } from './detect/dev-script.js';
34
+ export { detectNonJsEcosystem, noPackageJsonMessage } from './detect/non-js-project.js';
34
35
  export { deriveProjectId, packageName } from './project/project-id.js';
35
36
  export { refreshAgentRules } from './project/refresh-rules.js';
36
37
  export { diagnoseDesktop, isDesktopProject } from './diagnose/desktop-doctor.js';
package/dist/index.js CHANGED
@@ -29,8 +29,9 @@ export { DEV_SERVER_PORTS, isLikelyDevServerPort, devServerPortWarning, } from '
29
29
  export { configWithInstallSource } from './project/install-source-config.js';
30
30
  export { projectIdOf, rememberProjectOnDisk } from './project/remember-project.js';
31
31
  export { detectPackageManager, Framework, installCommandParts, parseMajor, PackageManager, } from './detect/detect.js';
32
- export { findWorkspaceApps } from './detect/workspace-apps.js';
32
+ export { findWorkspaceApps, isWebApp, workspacePackageDirs } from './detect/workspace-apps.js';
33
33
  export { desktopLaunch } from './detect/dev-script.js';
34
+ export { detectNonJsEcosystem, noPackageJsonMessage } from './detect/non-js-project.js';
34
35
  export { deriveProjectId, packageName } from './project/project-id.js';
35
36
  export { refreshAgentRules } from './project/refresh-rules.js';
36
37
  export { diagnoseDesktop, isDesktopProject } from './diagnose/desktop-doctor.js';
@@ -6,9 +6,8 @@
6
6
  * static page it can see whole, or prints the snippet for the stack it recognised (Streamlit,
7
7
  * Django, several pages) and stops.
8
8
  */
9
- import { join } from 'node:path';
10
9
  import { PackageManager, Framework } from './detect/detect.js';
11
- import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, noPackageJsonMessage, streamlitSetupMessage, } from './detect/non-js-project.js';
10
+ import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, NoPackageJsonCase, noPackageJsonAnswer, streamlitSetupMessage, } from './detect/non-js-project.js';
12
11
  import { InitFailure } from './diagnose/init-failure.js';
13
12
  import { HTML_INDEX_PATH, STATIC_TOKEN_MODULE, staticTokenFiles, withStaticSnippet, } from './patch/static-page.js';
14
13
  import { connectArg, connectArgWithToken, djangoMiddlewareSnippet, reticleConfigContent, staticPageSnippet, streamlitPageSnippet, } from './patch/snippets.js';
@@ -21,6 +20,7 @@ const SNIPPET_WRITTEN = `Wrote the dev-only connect snippet into ${HTML_INDEX_PA
21
20
  'gitignored — keep it out of anything you publish, and let each teammate run `reticle init` ' +
22
21
  'for their own.';
23
22
  const SNIPPET_ALREADY = `${HTML_INDEX_PATH} already loads the Reticle SDK — nothing to change.`;
23
+ const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
24
24
  /**
25
25
  * Write the snippet into `index.html`, and hand over to setup as a success.
26
26
  *
@@ -59,9 +59,23 @@ function wireStaticPage(options, io, connect) {
59
59
  };
60
60
  }
61
61
  export function initWithoutPackageJson(options, io) {
62
+ // Relative to the IO's own root, like every other read here (`readFile` below, `HTML_INDEX_PATH`).
63
+ // The real IO resolves it to the same file; joining `options.cwd` onto it only made these checks
64
+ // miss in the in-memory IO the tests use, so no test could tell the branches below apart.
65
+ const existsHere = (file) => io.exists(file);
62
66
  const streamlit = detectStreamlitProject((file) => io.readFile(file), io.rootFiles());
63
67
  // Asked only when Streamlit already said no, so the two can never both claim the page.
64
- const django = !streamlit && detectDjangoProject((file) => io.exists(join(options.cwd, file)), io.rootFiles());
68
+ const django = !streamlit && detectDjangoProject(existsHere, io.rootFiles());
69
+ // "Run init from your app's directory" is the whole answer, so stop at it. This used to fall
70
+ // through to the shared tail below: it wrote `.reticle.json` into the directory it had just called
71
+ // wrong, then printed a script-tag snippet under a message that asks for none (#1366).
72
+ const answer = noPackageJsonAnswer(existsHere, io.rootFiles());
73
+ if (!streamlit && !django && answer.kind === NoPackageJsonCase.WRONG_DIRECTORY) {
74
+ io.print(answer.message);
75
+ if (!options.dryRun)
76
+ io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
77
+ return { ok: false, applied: 0, manual: 0 };
78
+ }
65
79
  // Scope the project on disk before anything else, so the daemon can serve this page.
66
80
  //
67
81
  // This path used to print and exit, leaving no `.reticle.json` at all — and the reporter who
@@ -74,6 +88,10 @@ export function initWithoutPackageJson(options, io) {
74
88
  // Written only when absent. A config a user or an earlier run already placed here is theirs.
75
89
  const nonJsProjectId = deriveProjectId(undefined, io.cwd());
76
90
  const writeConfig = () => {
91
+ if (options.dryRun) {
92
+ io.print('Dry run: no files written; the snippet uses a placeholder pairing token.');
93
+ return;
94
+ }
77
95
  // Except its port. A re-run with a new `--port` starts the daemon there, and a config still
78
96
  // naming the old one sends the agent to a daemon that is no longer running.
79
97
  const existingConfig = io.readFile(RETICLE_CONFIG_FILE);
@@ -88,10 +106,14 @@ export function initWithoutPackageJson(options, io) {
88
106
  rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
89
107
  io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
90
108
  };
91
- const connect = connectArgWithToken(options.port, nonJsProjectId, io.host.pairingToken());
109
+ const connect = connectArgWithToken(options.port, nonJsProjectId, options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken());
92
110
  // One page at the root is a page we can see whole. Several, or none, is a choice that is not ours.
93
111
  if (!streamlit && !django && io.exists(HTML_INDEX_PATH)) {
94
112
  writeConfig();
113
+ if (options.dryRun) {
114
+ io.print(staticPageSnippet(connect));
115
+ return { ok: true, applied: 0, manual: 0 };
116
+ }
95
117
  // Without the token: the page is the deployable artifact, so the token goes beside it instead.
96
118
  return wireStaticPage(options, io, connectArg(options.port, nonJsProjectId));
97
119
  }
@@ -101,11 +123,7 @@ export function initWithoutPackageJson(options, io) {
101
123
  // a path problem and goes looking for a directory that cannot exist — reported from a
102
124
  // Streamlit app, where the search continued into hunting for a browser bundle to inject by
103
125
  // hand before the real answer surfaced.
104
- streamlit
105
- ? streamlitSetupMessage()
106
- : django
107
- ? djangoSetupMessage()
108
- : noPackageJsonMessage((file) => io.exists(join(options.cwd, file)), io.rootFiles()));
126
+ streamlit ? streamlitSetupMessage() : django ? djangoSetupMessage() : answer.message);
109
127
  writeConfig();
110
128
  // The message says "add the snippet below". Print the snippet, or the message is the same broken
111
129
  // promise in the other direction.
@@ -116,6 +134,7 @@ export function initWithoutPackageJson(options, io) {
116
134
  : staticPageSnippet(connect));
117
135
  // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
118
136
  // from someone who never ran the command — the two failure modes with the most different fixes.
119
- io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
137
+ if (!options.dryRun)
138
+ io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
120
139
  return { ok: false, applied: 0, manual: 0 };
121
140
  }
package/dist/node-io.d.ts CHANGED
@@ -34,4 +34,6 @@ export declare const RUNNABLE_COMMANDS: readonly string[];
34
34
  * reason a second, broken copy existed.
35
35
  */
36
36
  export declare function probeCli(command: string, args: readonly string[]): boolean;
37
- export declare function buildNodeIo(cwd: string, host: InitHost): InitIo;
37
+ export declare function buildNodeIo(cwd: string, host: InitHost, output?: {
38
+ stderr?: boolean;
39
+ }): InitIo;
package/dist/node-io.js CHANGED
@@ -102,7 +102,7 @@ export function probeCli(command, args) {
102
102
  return false;
103
103
  }
104
104
  }
105
- export function buildNodeIo(cwd, host) {
105
+ export function buildNodeIo(cwd, host, output = {}) {
106
106
  // Project-relative by default; absolute paths (e.g. ~/.cursor/mcp.json) pass through unchanged.
107
107
  const abs = (rel) => (isAbsolute(rel) ? rel : join(cwd, rel));
108
108
  return {
@@ -161,11 +161,14 @@ export function buildNodeIo(cwd, host) {
161
161
  scoped(rel) {
162
162
  // The host travels with the re-rooted IO: a monorepo redirect re-enters `runInit` through
163
163
  // this, and an untraced, unreported inner run is the half of init that actually did the work.
164
- return buildNodeIo(abs(rel), host);
164
+ return buildNodeIo(abs(rel), host, output);
165
165
  },
166
166
  exec(command, args) {
167
- // Inherit stdio so the install's own progress is visible to the user.
168
- const result = spawnAllowed(command, args, { cwd, stdio: 'inherit' });
167
+ // JSON callers reserve stdout for the result, including across package-manager subprocesses.
168
+ const result = spawnAllowed(command, args, {
169
+ cwd,
170
+ stdio: true === output.stderr ? ['inherit', 2, 2] : 'inherit',
171
+ });
169
172
  return 0 === result.status;
170
173
  },
171
174
  capture(command, args) {
@@ -189,7 +192,8 @@ export function buildNodeIo(cwd, host) {
189
192
  return 0 === result.status;
190
193
  },
191
194
  print(line) {
192
- process.stdout.write(`${wrapForTerminal(line, terminalWidth())}\n`);
195
+ const stream = true === output.stderr ? process.stderr : process.stdout;
196
+ stream.write(`${wrapForTerminal(line, terminalWidth())}\n`);
193
197
  },
194
198
  host,
195
199
  };
@@ -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
  }
package/dist/run.js CHANGED
@@ -13,10 +13,11 @@ import { preflight } from './plan/preflight.js';
13
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 { ANGULAR_WORKSPACE_FILE, detect, FORGE_RENDERER_CONFIGS, Framework, UiLibrary, } from './detect/detect.js';
16
+ import { ANGULAR_WORKSPACE_FILE, detect, FORGE_RENDERER_CONFIGS, Framework, } from './detect/detect.js';
17
17
  import { ANGULAR_PROXY_PATH, angularEntry } from './patch/angular.js';
18
18
  import { HTML_INDEX_PATH } from './patch/static-page.js';
19
19
  import { initWithoutPackageJson } from './no-package-json.js';
20
+ import { sdkPackagesDeclared, sdkPackagesPresent } from './sdk-packages.js';
20
21
  import { wasMcpRegistered } from './register/mcp-registered.js';
21
22
  import { pickAstroHost } from './patch/astro-host.js';
22
23
  import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, PACKAGE_JSON, VITE_CONFIG_CANDIDATES, } from './detect/workspace-apps.js';
@@ -32,6 +33,8 @@ import { tanstackStartConnectPath } from './patch/tanstack-start.js';
32
33
  import { installedViteMajor } from './patch/vite-owning-config.js';
33
34
  /** CRA's bundled entry, in the order create-react-app itself generates them. */
34
35
  const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
36
+ /** Preview snippets must not mint a real machine credential or imply this token is usable. */
37
+ const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
35
38
  function craEntryOf(io) {
36
39
  for (const path of CRA_ENTRY_CANDIDATES) {
37
40
  const source = io.readFile(path);
@@ -40,7 +43,7 @@ function craEntryOf(io) {
40
43
  }
41
44
  return null;
42
45
  }
43
- import { DEPS_TARGET, RETICLE_CONFIG_FILE, frameworkPackages, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
46
+ import { DEPS_TARGET, RETICLE_CONFIG_FILE, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
44
47
  import { claudeAvailableProbe, claudeHasReticle } from './register/mcp.js';
45
48
  import { reticleDevLocation } from './patch/next-patch.js';
46
49
  import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
@@ -54,7 +57,6 @@ import { InitFailure } from './diagnose/init-failure.js';
54
57
  import { resolveLockfiles } from './detect/lockfiles.js';
55
58
  // Re-exported: it moved to its own module, and every existing importer says `run.js`.
56
59
  export { resolveLockfiles };
57
- const NODE_MODULES_DIR = 'node_modules';
58
60
  const SVELTEKIT_HOOKS = 'src/hooks.client.ts';
59
61
  const REACT_ROUTER_ENTRY = 'app/entry.client.tsx';
60
62
  const TANSTACK_START_ROOT_CANDIDATES = [
@@ -193,6 +195,8 @@ function resolvedStatus(plan, target, failed, skipped) {
193
195
  return StepStatus.MANUAL;
194
196
  return step.status;
195
197
  }
198
+ /** Every client registration row is titled `MCP server (<client>)`; the closing hint names the unfinished ones. */
199
+ const MCP_CLIENT_TITLE_PREFIX = 'MCP server (';
196
200
  const STATUS_SYMBOL = {
197
201
  [StepStatus.APPLY]: '✓',
198
202
  [StepStatus.MANUAL]: '⚠',
@@ -395,7 +399,7 @@ function gatherPlanInput(options, io, pkg) {
395
399
  : {}),
396
400
  craEntry: craEntryOf(io),
397
401
  craEnv: io.readFile(CRA_ENV_PATH),
398
- pairingToken: io.host.pairingToken(),
402
+ pairingToken: options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken(),
399
403
  installSource: io.host.installSource(),
400
404
  reticleConfigExists: io.exists(RETICLE_CONFIG_FILE),
401
405
  // The CONTENT, so a config that exists can be checked rather than trusted — a `"port"` set to
@@ -475,6 +479,8 @@ continuesToRuntime = false) {
475
479
  // the app never dials the daemon and every tool answers "no browser session connected". `ok` was
476
480
  // hardcoded true, so a run that could not possibly work reported success.
477
481
  let connectPending = false;
482
+ // Other clients' registrations the run left for the reader, named for the closing hint.
483
+ const manualClients = [];
478
484
  for (const s of plan.steps) {
479
485
  // A side effect that failed to apply is reported as a manual step with its fallback command.
480
486
  const note = degraded.get(s.target);
@@ -502,6 +508,9 @@ continuesToRuntime = false) {
502
508
  manual++;
503
509
  if (isConnectStep(s.title))
504
510
  connectPending = true;
511
+ if (s.target !== MCP_TARGET && s.title.startsWith(MCP_CLIENT_TITLE_PREFIX)) {
512
+ manualClients.push(s.title);
513
+ }
505
514
  }
506
515
  for (const line of detail.split('\n'))
507
516
  io.print(` ${line}`);
@@ -523,7 +532,7 @@ continuesToRuntime = false) {
523
532
  }
524
533
  const mcpStatus = resolvedStatus(plan, MCP_TARGET, failed, skipped);
525
534
  if (!continuesToRuntime) {
526
- io.print(restartHint(mcpStatus, devCommand));
535
+ io.print(restartHint(mcpStatus, devCommand, manualClients));
527
536
  }
528
537
  // Carried out even when the hint above was printed, because the RUNTIME path needs the same fact
529
538
  // and could not reach it: `restartHint` is only printed when this run stops at the files, and the
@@ -545,25 +554,6 @@ continuesToRuntime = false) {
545
554
  * MODULE_NOT_FOUND, so the app stops booting *because* Reticle was installed. A skipped step is a
546
555
  * message; a half-wired app is a broken project.
547
556
  */
548
- /**
549
- * Are the SDK packages actually on disk, whatever the install step reported?
550
- *
551
- * `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
552
- * not there — that took a dev server down once, and installing Reticle must never be why an app
553
- * stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
554
- * install subprocess exited 0", and those come apart in exactly the situation the failure creates:
555
- * init tells the user to install by hand, they do, they re-run init, the install step fails again
556
- * (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
557
- * a Next 16 app where npm had already installed both packages successfully — leaving an init that
558
- * could not be retried into working, which is the shape this guard was written to prevent.
559
- *
560
- * Reading node_modules answers the real question and costs one `exists` call per package.
561
- */
562
- function sdkPackagesPresent(framework, uiLibrary, io) {
563
- const packages = frameworkPackages(framework, uiLibrary);
564
- return (packages.length > 0 &&
565
- packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
566
- }
567
557
  function applyEffects(plan, io, packageManager) {
568
558
  const why = new Map();
569
559
  const failed = new Set();
@@ -633,14 +623,17 @@ function applyEffects(plan, io, packageManager) {
633
623
  continue;
634
624
  }
635
625
  // Verify, don't re-run: give the install step itself the same sdkPackagesPresent benefit
636
- // already given to the wiring it gates below (#683).
637
- if (s.target === DEPS_TARGET && sdkPackagesPresent(plan.framework, plan.uiLibrary, io))
626
+ // already given to the wiring it gates below (#683) — but only when package.json DECLARES
627
+ // them. Reported from a Tauri + Next app: `pnpm add` died on ERR_PNPM_UNEXPECTED_STORE, the
628
+ // packages happened to resolve, init printed success, and the manifest never gained them.
629
+ const present = s.target === DEPS_TARGET && sdkPackagesPresent(plan.framework, plan.uiLibrary, io);
630
+ if (present && sdkPackagesDeclared(plan.framework, plan.uiLibrary, io))
638
631
  continue;
639
632
  failed.add(s.target);
640
633
  // A failed install only blocks the wiring when the packages are genuinely ABSENT. See
641
634
  // sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
642
635
  if (s.target === DEPS_TARGET) {
643
- installFailed = true;
636
+ installFailed = !present;
644
637
  const explained = explainInstallFailure(io, packageManager, exec, s.detail);
645
638
  if (explained !== undefined)
646
639
  why.set(s.target, explained);
@@ -737,7 +730,8 @@ function runInitSteps(options, io) {
737
730
  io.print(`${PACKAGE_JSON} is not valid JSON (${manifest.error}). Fix it and re-run — init reads the ` +
738
731
  'framework, the dev script and the package manager from it, and will not guess at any of ' +
739
732
  'them from a file it cannot read.');
740
- io.host.reportOutcome({ ok: false, reason: InitFailure.MALFORMED_PACKAGE_JSON });
733
+ if (!options.dryRun)
734
+ io.host.reportOutcome({ ok: false, reason: InitFailure.MALFORMED_PACKAGE_JSON });
741
735
  return { ok: false, applied: 0, manual: 0 };
742
736
  }
743
737
  const pkgRaw = manifest.pkg;
@@ -777,6 +771,8 @@ function runInitSteps(options, io) {
777
771
  });
778
772
  if (resolved.refusal !== undefined) {
779
773
  io.print(resolved.refusal);
774
+ if (!options.dryRun)
775
+ io.host.reportOutcome({ ok: false, reason: InitFailure.PREFLIGHT });
780
776
  return { ok: false, applied: 0, manual: 1 };
781
777
  }
782
778
  // The prefix that can actually run it on THIS machine — `pnpm`, or `corepack pnpm` where preflight
@@ -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.4.0",
3
+ "version": "3.6.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.4.0"
16
+ "@reticlehq/core": "3.6.0"
17
17
  },
18
18
  "license": "FSL-1.1-ALv2",
19
19
  "author": "Reticle contributors",