@reticlehq/init 3.6.0 → 3.8.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 (64) hide show
  1. package/dist/detect/detect.d.ts +9 -0
  2. package/dist/detect/detect.js +5 -1
  3. package/dist/detect/dev-script.d.ts +17 -0
  4. package/dist/detect/dev-script.js +128 -0
  5. package/dist/detect/dev-server-ports.d.ts +2 -0
  6. package/dist/detect/dev-server-ports.js +20 -0
  7. package/dist/detect/prettier-config.d.ts +21 -0
  8. package/dist/detect/prettier-config.js +38 -0
  9. package/dist/detect/workspace-apps.d.ts +11 -0
  10. package/dist/detect/workspace-apps.js +63 -0
  11. package/dist/detect/workspace-redirect.js +69 -10
  12. package/dist/diagnose/closing-hint.d.ts +5 -0
  13. package/dist/diagnose/closing-hint.js +18 -11
  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 +468 -0
  18. package/dist/host.d.ts +6 -0
  19. package/dist/index.d.ts +11 -4
  20. package/dist/index.js +14 -4
  21. package/dist/no-package-json.js +62 -14
  22. package/dist/node-io.d.ts +1 -1
  23. package/dist/node-io.js +11 -2
  24. package/dist/patch/next-patch.d.ts +45 -9
  25. package/dist/patch/next-patch.js +93 -0
  26. package/dist/patch/nuxt-snippets.js +1 -1
  27. package/dist/patch/prettier-ignore.d.ts +34 -0
  28. package/dist/patch/prettier-ignore.js +49 -0
  29. package/dist/patch/python-snippets.d.ts +22 -0
  30. package/dist/patch/python-snippets.js +113 -0
  31. package/dist/patch/snippets.d.ts +10 -31
  32. package/dist/patch/snippets.js +42 -105
  33. package/dist/patch/static-page.d.ts +1 -0
  34. package/dist/patch/static-page.js +6 -8
  35. package/dist/patch/vite-config.js +11 -0
  36. package/dist/plan/connect-steps.d.ts +2 -0
  37. package/dist/plan/connect-steps.js +6 -0
  38. package/dist/plan/framework-adapter.d.ts +10 -0
  39. package/dist/plan/framework-adapter.js +122 -3
  40. package/dist/plan/notices.js +1 -1
  41. package/dist/plan/plan-angular.js +1 -1
  42. package/dist/plan/plan-framework.d.ts +1 -1
  43. package/dist/plan/plan-framework.js +140 -37
  44. package/dist/plan/plan-types.d.ts +50 -2
  45. package/dist/plan/plan-vite.js +15 -0
  46. package/dist/plan/plan.d.ts +1 -10
  47. package/dist/plan/plan.js +181 -126
  48. package/dist/plan/stop-hook-step.d.ts +30 -0
  49. package/dist/plan/stop-hook-step.js +154 -5
  50. package/dist/project/agent-rules.d.ts +1 -1
  51. package/dist/project/agent-rules.js +22 -17
  52. package/dist/project/refresh-rules.js +2 -1
  53. package/dist/register/codex-toml.d.ts +12 -0
  54. package/dist/register/codex-toml.js +44 -0
  55. package/dist/register/mcp.d.ts +15 -2
  56. package/dist/register/mcp.js +16 -0
  57. package/dist/register/prompt-hooks.d.ts +123 -0
  58. package/dist/register/prompt-hooks.js +449 -0
  59. package/dist/register/slash-command.d.ts +1 -1
  60. package/dist/register/slash-command.js +5 -4
  61. package/dist/run-types.d.ts +10 -0
  62. package/dist/run.d.ts +1 -1
  63. package/dist/run.js +15 -390
  64. package/package.json +2 -2
@@ -6,11 +6,14 @@
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 { dirname, join } from 'node:path';
9
10
  import { PackageManager, Framework } from './detect/detect.js';
11
+ import { PACKAGE_JSON } from './detect/workspace-apps.js';
10
12
  import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, NoPackageJsonCase, noPackageJsonAnswer, streamlitSetupMessage, } from './detect/non-js-project.js';
11
13
  import { InitFailure } from './diagnose/init-failure.js';
12
14
  import { HTML_INDEX_PATH, STATIC_TOKEN_MODULE, staticTokenFiles, withStaticSnippet, } from './patch/static-page.js';
13
- import { connectArg, connectArgWithToken, djangoMiddlewareSnippet, reticleConfigContent, staticPageSnippet, streamlitPageSnippet, } from './patch/snippets.js';
15
+ import { connectArg, reticleConfigContent, staticPageSnippetWithLocalToken, } from './patch/snippets.js';
16
+ import { djangoMiddlewareSnippet, pythonConnectArg, streamlitPageSnippet, } from './patch/python-snippets.js';
14
17
  import { RETICLE_CONFIG_FILE } from './detect/existing-config.js';
15
18
  import { deriveProjectId } from './project/project-id.js';
16
19
  import { portMoveDetail, retargetConfigPort, retargetPort } from './plan/port-steps.js';
@@ -19,8 +22,37 @@ const SNIPPET_WRITTEN = `Wrote the dev-only connect snippet into ${HTML_INDEX_PA
19
22
  `opened on localhost. This machine’s pairing token is in ${STATIC_TOKEN_MODULE}, which is ` +
20
23
  'gitignored — keep it out of anything you publish, and let each teammate run `reticle init` ' +
21
24
  'for their own.';
25
+ const TOKEN_MODULE_WRITTEN = `This machine’s pairing token is in ${STATIC_TOKEN_MODULE}, which is gitignored. Serve it beside ` +
26
+ 'the page: the snippet imports it. Keep it out of anything you publish.';
22
27
  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';
28
+ /** How far up `init` looks for the app it was run inside. The same bound as the workspace walk. */
29
+ const MAX_ANCESTOR_DEPTH = 8;
30
+ const HOME_FOLDER = 'This is your home folder, not a project. Go to the folder of your app (the one with its ' +
31
+ 'package.json) and run `reticle init` there.';
32
+ const insideApp = (dir) => `No package.json here, but this folder is inside the app at ${dir}. Run init from the app's ` +
33
+ `folder:\n cd "${dir}" && reticle init`;
34
+ /**
35
+ * The wrong-directory answer when the right directory can be named.
36
+ *
37
+ * Discovery looks DOWN for the app. Run from `src/` of that app, it found nothing and said "run
38
+ * `reticle init` from your app's directory" with no path, to a person one folder inside it. The
39
+ * walk up stops at the home folder: a package.json in the home folder is an accident, not an app.
40
+ */
41
+ function namedDirectory(io) {
42
+ const home = io.homeDir();
43
+ let dir = io.cwd();
44
+ if (dir === home)
45
+ return HOME_FOLDER;
46
+ for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth++) {
47
+ const parent = dirname(dir);
48
+ if (parent === dir || parent === home)
49
+ return undefined;
50
+ if (null !== io.readFile(join(parent, PACKAGE_JSON)))
51
+ return insideApp(parent);
52
+ dir = parent;
53
+ }
54
+ return undefined;
55
+ }
24
56
  /**
25
57
  * Write the snippet into `index.html`, and hand over to setup as a success.
26
58
  *
@@ -28,10 +60,24 @@ const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
28
60
  * print the snippet and exit 1 with `no_package_json`, which read to the funnel — and to the person —
29
61
  * as the setup failing on exactly the path that works.
30
62
  */
31
- function wireStaticPage(options, io, connect) {
63
+ /** Write the gitignored token module and its ignore line, only where they change. */
64
+ function writeTokenModule(io) {
32
65
  for (const [path, content] of Object.entries(staticTokenFiles(io.host.pairingToken(), (p) => io.readFile(p)))) {
33
66
  io.writeFile(path, content);
34
67
  }
68
+ }
69
+ /** The snippet for this kind of project. Each one reads the token at run time. */
70
+ function printedSnippet(streamlit, django, connect) {
71
+ if (streamlit) {
72
+ return streamlitPageSnippet(pythonConnectArg(connect));
73
+ }
74
+ if (django) {
75
+ return djangoMiddlewareSnippet(pythonConnectArg(connect));
76
+ }
77
+ return staticPageSnippetWithLocalToken(connect);
78
+ }
79
+ function wireStaticPage(options, io, connect) {
80
+ writeTokenModule(io);
35
81
  const html = io.readFile(HTML_INDEX_PATH) ?? '';
36
82
  // A page that already connects still has its port compared: the snippet bakes the daemon URL, and
37
83
  // a re-run with a new `--port` that answered "already" left the page dialling the old daemon.
@@ -71,7 +117,7 @@ export function initWithoutPackageJson(options, io) {
71
117
  // wrong, then printed a script-tag snippet under a message that asks for none (#1366).
72
118
  const answer = noPackageJsonAnswer(existsHere, io.rootFiles());
73
119
  if (!streamlit && !django && answer.kind === NoPackageJsonCase.WRONG_DIRECTORY) {
74
- io.print(answer.message);
120
+ io.print(namedDirectory(io) ?? answer.message);
75
121
  if (!options.dryRun)
76
122
  io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
77
123
  return { ok: false, applied: 0, manual: 0 };
@@ -89,7 +135,7 @@ export function initWithoutPackageJson(options, io) {
89
135
  const nonJsProjectId = deriveProjectId(undefined, io.cwd());
90
136
  const writeConfig = () => {
91
137
  if (options.dryRun) {
92
- io.print('Dry run: no files written; the snippet uses a placeholder pairing token.');
138
+ io.print('Dry run: no files written.');
93
139
  return;
94
140
  }
95
141
  // Except its port. A re-run with a new `--port` starts the daemon there, and a config still
@@ -106,16 +152,17 @@ export function initWithoutPackageJson(options, io) {
106
152
  rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
107
153
  io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
108
154
  };
109
- const connect = connectArgWithToken(options.port, nonJsProjectId, options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken());
155
+ // No snippet below holds the token. A printed token stays in terminal history and transcripts.
156
+ const connect = connectArg(options.port, nonJsProjectId);
110
157
  // One page at the root is a page we can see whole. Several, or none, is a choice that is not ours.
111
158
  if (!streamlit && !django && io.exists(HTML_INDEX_PATH)) {
112
159
  writeConfig();
113
160
  if (options.dryRun) {
114
- io.print(staticPageSnippet(connect));
161
+ io.print(staticPageSnippetWithLocalToken(connect));
115
162
  return { ok: true, applied: 0, manual: 0 };
116
163
  }
117
- // Without the token: the page is the deployable artifact, so the token goes beside it instead.
118
- return wireStaticPage(options, io, connectArg(options.port, nonJsProjectId));
164
+ // The page is the deployable artifact, so the token goes beside it instead.
165
+ return wireStaticPage(options, io, connect);
119
166
  }
120
167
  io.print(
121
168
  // Two genuinely different situations used to share one sentence: a JS developer in the wrong
@@ -127,11 +174,12 @@ export function initWithoutPackageJson(options, io) {
127
174
  writeConfig();
128
175
  // The message says "add the snippet below". Print the snippet, or the message is the same broken
129
176
  // promise in the other direction.
130
- io.print(streamlit
131
- ? streamlitPageSnippet(connect)
132
- : django
133
- ? djangoMiddlewareSnippet(connect)
134
- : staticPageSnippet(connect));
177
+ io.print(printedSnippet(streamlit, django, connect));
178
+ // The page snippet imports the token from this module. Python reads the token file instead.
179
+ if (!streamlit && !django && !options.dryRun) {
180
+ writeTokenModule(io);
181
+ io.print(TOKEN_MODULE_WRITTEN);
182
+ }
135
183
  // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
136
184
  // from someone who never ran the command — the two failure modes with the most different fixes.
137
185
  if (!options.dryRun)
package/dist/node-io.d.ts CHANGED
@@ -8,7 +8,7 @@ import type { InitHost } from './host.js';
8
8
  /**
9
9
  * Every program `init` is ever allowed to run.
10
10
  *
11
- * The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude CLI —
11
+ * The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude and Codex CLIs —
12
12
  * because those are the only things the plan can ask for. It is a named constant for the same reason
13
13
  * every other wire string here is one, and it is CHECKED because of the shell: on Windows these spawn
14
14
  * through a shell, and a shell turns the command name into something the shell parses rather than a
package/dist/node-io.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * command, not the MCP stdio transport.
5
5
  */
6
6
  import { terminalWidth, wrapForTerminal } from './diagnose/terminal-wrap.js';
7
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, accessSync, constants, } from 'node:fs';
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, accessSync, constants, lstatSync, } from 'node:fs';
8
8
  import { NodePlatform } from './detect/platform.js';
9
9
  import { join, dirname, isAbsolute } from 'node:path';
10
10
  import { homedir } from 'node:os';
@@ -24,7 +24,7 @@ function shellSafe(args) {
24
24
  /**
25
25
  * Every program `init` is ever allowed to run.
26
26
  *
27
- * The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude CLI —
27
+ * The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude and Codex CLIs —
28
28
  * because those are the only things the plan can ask for. It is a named constant for the same reason
29
29
  * every other wire string here is one, and it is CHECKED because of the shell: on Windows these spawn
30
30
  * through a shell, and a shell turns the command name into something the shell parses rather than a
@@ -46,6 +46,7 @@ export const RUNNABLE_COMMANDS = [
46
46
  'npx',
47
47
  'corepack',
48
48
  'claude',
49
+ 'codex',
49
50
  ];
50
51
  /** The command, proven to be one of `RUNNABLE_COMMANDS` — never the caller's string. */
51
52
  function runnable(command) {
@@ -158,6 +159,14 @@ export function buildNodeIo(cwd, host, output = {}) {
158
159
  return [];
159
160
  }
160
161
  },
162
+ isSymlink(rel) {
163
+ try {
164
+ return lstatSync(abs(rel)).isSymbolicLink();
165
+ }
166
+ catch {
167
+ return false;
168
+ }
169
+ },
161
170
  scoped(rel) {
162
171
  // The host travels with the re-rooted IO: a monorepo redirect re-enters `runInit` through
163
172
  // this, and an untraced, unreported inner run is the half of init that actually did the work.
@@ -5,6 +5,7 @@
5
5
  * half-edit. These must not be left manual: both edits fail silently when skipped, and one of them
6
6
  * is JSX.
7
7
  */
8
+ import type { InitIo } from '../run-types.js';
8
9
  import { type SourcePatch } from './patch-kind.js';
9
10
  /** Where the generated connect component goes, and how the mount file should import it. */
10
11
  interface ReticleDevLocation {
@@ -14,18 +15,17 @@ interface ReticleDevLocation {
14
15
  importSpecifier: string;
15
16
  }
16
17
  /**
17
- * Decide where the connect component lives, from the file that will mount it.
18
+ * Whether Next treats the project as TypeScript: Next's own rule, not the package list (#1527).
18
19
  *
19
- * Two things this gets wrong if hardcoded, both of which broke real apps:
20
+ * Next reads a `tsconfig.json`, and a `.ts`/`.tsx` source file. It does not read a `typescript`
21
+ * dependency, which a JavaScript app often has for editor types or ESLint. Choosing `.tsx` from that
22
+ * dependency wrote a TypeScript file into a `jsconfig.json` app. On the next `next dev`, Next then
23
+ * generated a `tsconfig.json`, ignored the `jsconfig.json` from then on, and every `@/` import broke.
20
24
  *
21
- * - **Pages Router routes on presence.** EVERY file under `pages/` is a route, so a component
22
- * dropped there has no default export: `/reticle-dev` 500s and `next build` fails. It has to go
23
- * somewhere that is not a route directory. App Router routes on FILENAME (`page`/`layout`/
24
- * `route`), so an extra file beside the layout is inert and can stay there.
25
- * - **The extension is not cosmetic.** A `.tsx` file in a JavaScript project makes Next auto-install
26
- * TypeScript on the next `next dev` — and on Next 13 that takes its require-hook down with it, so
27
- * the dev server never starts. The install then looks like it broke the app, because it did.
25
+ * `mountPath` is the layout or `_app` file the component is mounted from, or `null` when none was
26
+ * found. Its extension is the project's own answer for the directory being written into.
28
27
  */
28
+ export declare function nextUsesTypeScript(mountPath: string | null, hasTsconfig: boolean): boolean;
29
29
  export declare function reticleDevLocation(mountPath: string, typescript: boolean): ReticleDevLocation;
30
30
  export declare function patchNextConfig(source: string, sourceMapping?: boolean): SourcePatch;
31
31
  /**
@@ -41,9 +41,45 @@ export declare function patchRootLayout(source: string): SourcePatch;
41
41
  * ReticleDev component has to land NEXT TO the layout or the relative import it generates is dead.
42
42
  */
43
43
  export declare const NEXT_LAYOUT_CANDIDATES: string[];
44
+ /**
45
+ * What `findRouteGroupRootLayout` found: the layout to mount into, with the other group root layouts
46
+ * that do not mount `<ReticleDev />`, or the ones it could not choose between.
47
+ */
48
+ export type RouteGroupLayouts = {
49
+ path: string;
50
+ unmounted: string[];
51
+ } | {
52
+ candidates: string[];
53
+ };
54
+ type ListingIo = Pick<InitIo, 'listDirs' | 'listFiles' | 'readFile'>;
55
+ /**
56
+ * The root layout of an App Router app with no top-level `app/layout.*`: its root layouts then live
57
+ * in route groups (`app/(site)/layout.tsx`), one per group, which Next documents as "multiple root
58
+ * layouts" (#1538). `null` when the app has none, so the Pages Router candidates are still asked.
59
+ *
60
+ * One layout is chosen, because a step patches one file: a group layout that already mounts
61
+ * `<ReticleDev />` (so the patch reports it already wired), else the one group that renders `/`,
62
+ * else the only group layout there is. Several and none of those is returned as candidates, so the
63
+ * manual step can name them rather than say there is no root layout.
64
+ *
65
+ * Each group root layout renders its own routes, so the routes under the others render without
66
+ * `<ReticleDev />`. Those that do not mount it already come back as `unmounted`, for init to name.
67
+ */
68
+ export declare function findRouteGroupRootLayout(io: ListingIo): RouteGroupLayouts | null;
44
69
  /**
45
70
  * Pages Router mount points, checked only when there is no App Router layout. A Pages app has no
46
71
  * `app/` directory at all, so writing the component there produced a file nothing imported.
47
72
  */
48
73
  export declare const NEXT_PAGES_APP_CANDIDATES: string[];
74
+ /**
75
+ * The file `<ReticleDev />` is mounted in: the App Router's root layout, then a route-group root
76
+ * layout (#1538), then the Pages Router's `_app`. `candidates` is set when route groups hold several
77
+ * root layouts and none could be chosen, so the manual step can name them; `unmounted` names the
78
+ * other group root layouts when one was chosen, so init can say which routes go without it.
79
+ */
80
+ export declare function findNextMountFile(io: ListingIo & Pick<InitIo, 'exists'>): {
81
+ path: string | null;
82
+ candidates?: string[];
83
+ unmounted?: string[];
84
+ };
49
85
  export {};
@@ -28,6 +28,22 @@ const RETICLE_DEV_IMPORT = reticleDevImport(RETICLE_DEV_SIBLING);
28
28
  * TypeScript on the next `next dev` — and on Next 13 that takes its require-hook down with it, so
29
29
  * the dev server never starts. The install then looks like it broke the app, because it did.
30
30
  */
31
+ /** A TypeScript source file, as Next counts one. */
32
+ const TS_SOURCE = /\.tsx?$/;
33
+ /**
34
+ * Whether Next treats the project as TypeScript: Next's own rule, not the package list (#1527).
35
+ *
36
+ * Next reads a `tsconfig.json`, and a `.ts`/`.tsx` source file. It does not read a `typescript`
37
+ * dependency, which a JavaScript app often has for editor types or ESLint. Choosing `.tsx` from that
38
+ * dependency wrote a TypeScript file into a `jsconfig.json` app. On the next `next dev`, Next then
39
+ * generated a `tsconfig.json`, ignored the `jsconfig.json` from then on, and every `@/` import broke.
40
+ *
41
+ * `mountPath` is the layout or `_app` file the component is mounted from, or `null` when none was
42
+ * found. Its extension is the project's own answer for the directory being written into.
43
+ */
44
+ export function nextUsesTypeScript(mountPath, hasTsconfig) {
45
+ return hasTsconfig || (null !== mountPath && TS_SOURCE.test(mountPath));
46
+ }
31
47
  export function reticleDevLocation(mountPath, typescript) {
32
48
  const ext = typescript ? '.tsx' : '.jsx';
33
49
  const slash = mountPath.lastIndexOf('/');
@@ -223,6 +239,67 @@ export const NEXT_LAYOUT_CANDIDATES = [
223
239
  'src/app/layout.jsx',
224
240
  'src/app/layout.js',
225
241
  ];
242
+ /** The App Router roots, in the order `NEXT_LAYOUT_CANDIDATES` checks them. */
243
+ const NEXT_APP_DIRS = ['app', 'src/app'];
244
+ const LAYOUT_BASENAMES = ['layout.tsx', 'layout.jsx', 'layout.js'];
245
+ const PAGE_FILE = /^page\.(tsx|ts|jsx|js|mdx)$/;
246
+ /** `(marketing)`: a route group, which organises routes without adding a URL segment. */
247
+ const ROUTE_GROUP = /^\(.+\)$/;
248
+ /** Whether `dir` renders `/`: a page of its own, or one in a route group nested under it. */
249
+ function servesRoot(io, dir) {
250
+ if (io.listFiles(dir).some((name) => PAGE_FILE.test(name)))
251
+ return true;
252
+ return io
253
+ .listDirs(dir)
254
+ .filter((name) => ROUTE_GROUP.test(name))
255
+ .some((name) => servesRoot(io, `${dir}/${name}`));
256
+ }
257
+ /**
258
+ * The root layout of an App Router app with no top-level `app/layout.*`: its root layouts then live
259
+ * in route groups (`app/(site)/layout.tsx`), one per group, which Next documents as "multiple root
260
+ * layouts" (#1538). `null` when the app has none, so the Pages Router candidates are still asked.
261
+ *
262
+ * One layout is chosen, because a step patches one file: a group layout that already mounts
263
+ * `<ReticleDev />` (so the patch reports it already wired), else the one group that renders `/`,
264
+ * else the only group layout there is. Several and none of those is returned as candidates, so the
265
+ * manual step can name them rather than say there is no root layout.
266
+ *
267
+ * Each group root layout renders its own routes, so the routes under the others render without
268
+ * `<ReticleDev />`. Those that do not mount it already come back as `unmounted`, for init to name.
269
+ */
270
+ export function findRouteGroupRootLayout(io) {
271
+ for (const appDir of NEXT_APP_DIRS) {
272
+ const layouts = io
273
+ .listDirs(appDir)
274
+ .filter((name) => ROUTE_GROUP.test(name))
275
+ .sort()
276
+ .flatMap((group) => {
277
+ const dir = `${appDir}/${group}`;
278
+ const files = io.listFiles(dir);
279
+ const layout = LAYOUT_BASENAMES.find((name) => files.includes(name));
280
+ return layout === undefined ? [] : [{ dir, path: `${dir}/${layout}` }];
281
+ });
282
+ if (0 === layouts.length)
283
+ continue;
284
+ const mounted = new Set(layouts
285
+ .filter((l) => true === io.readFile(l.path)?.includes(RETICLE_DEV_COMPONENT))
286
+ .map((l) => l.path));
287
+ const chosen = (path) => ({
288
+ path,
289
+ unmounted: layouts.map((l) => l.path).filter((p) => p !== path && !mounted.has(p)),
290
+ });
291
+ const wired = layouts.find((l) => mounted.has(l.path));
292
+ if (wired !== undefined)
293
+ return chosen(wired.path);
294
+ const root = layouts.filter((l) => servesRoot(io, l.dir));
295
+ if (1 === root.length && root[0] !== undefined)
296
+ return chosen(root[0].path);
297
+ if (1 === layouts.length && layouts[0] !== undefined)
298
+ return chosen(layouts[0].path);
299
+ return { candidates: layouts.map((l) => l.path) };
300
+ }
301
+ return null;
302
+ }
226
303
  /**
227
304
  * Pages Router mount points, checked only when there is no App Router layout. A Pages app has no
228
305
  * `app/` directory at all, so writing the component there produced a file nothing imported.
@@ -235,3 +312,19 @@ export const NEXT_PAGES_APP_CANDIDATES = [
235
312
  'src/pages/_app.jsx',
236
313
  'src/pages/_app.js',
237
314
  ];
315
+ /**
316
+ * The file `<ReticleDev />` is mounted in: the App Router's root layout, then a route-group root
317
+ * layout (#1538), then the Pages Router's `_app`. `candidates` is set when route groups hold several
318
+ * root layouts and none could be chosen, so the manual step can name them; `unmounted` names the
319
+ * other group root layouts when one was chosen, so init can say which routes go without it.
320
+ */
321
+ export function findNextMountFile(io) {
322
+ const top = NEXT_LAYOUT_CANDIDATES.find((p) => io.exists(p));
323
+ if (top !== undefined)
324
+ return { path: top };
325
+ const groups = findRouteGroupRootLayout(io);
326
+ if (groups !== null && 'path' in groups)
327
+ return { path: groups.path, unmounted: groups.unmounted };
328
+ const pagesApp = NEXT_PAGES_APP_CANDIDATES.find((p) => io.exists(p)) ?? null;
329
+ return null === groups ? { path: pagesApp } : { path: pagesApp, candidates: groups.candidates };
330
+ }
@@ -130,6 +130,6 @@ and no index.html to inject into. Wire it with a dev-only CLIENT plugin, which i
130
130
 
131
131
  The package is @reticlehq/browser — the framework-neutral sensor. DOM, network, console, routing and
132
132
  source file:line all work in Vue. What you do not get is React component identity, which is the only
133
- thing the React adapter adds. The install gate scaffolds a Nuxt app on every release and requires a session, so this wiring
133
+ thing the React adapter adds. The install gate scaffolds a Nuxt app whenever the install path changes and requires a session, so this wiring
134
134
  is proven; if something does not work, please open an issue.`;
135
135
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Keep Reticle's local state out of the project's Prettier check.
3
+ *
4
+ * `reticle init` runs in the user's project, and every drive writes JSON under `.reticle/`
5
+ * (impact.json, runs/, intent/). A project's `prettier --check .` fails on those files —
6
+ * not just for missing trailing newlines, but because Prettier's JSON formatting differs from
7
+ * `JSON.stringify` (e.g. short arrays stay on one line). The nested `.reticle/.gitignore` does
8
+ * not help: Prettier only reads the ignore file of the directory it runs from.
9
+ *
10
+ * So when the project uses Prettier, `init` adds `.reticle/` to the project's `.prettierignore`.
11
+ * This is the root-cause fix: it covers every file Reticle writes, present and future,
12
+ * regardless of how each one is serialized.
13
+ *
14
+ * Idempotent: re-running `init` never duplicates the entry. Never removes anything the user
15
+ * had — it only appends the one line when it is absent.
16
+ */
17
+ export declare const PRETTIERIGNORE_PATH = ".prettierignore";
18
+ /** The entry `init` manages in the project's `.prettierignore`. */
19
+ export declare const RETICLE_IGNORE_ENTRY = ".reticle/";
20
+ /**
21
+ * True when the `.prettierignore` content already excludes `.reticle/`.
22
+ *
23
+ * Matches the entry with or without trailing slash, ignoring comments and blank lines —
24
+ * a user who wrote `.reticle` by hand has already solved this; we must not add a duplicate.
25
+ */
26
+ export declare function ignoresReticle(content: string): boolean;
27
+ /**
28
+ * The `.prettierignore` content after `init` ensures `.reticle/` is excluded.
29
+ *
30
+ * - No existing file: create one with the managed header and the entry.
31
+ * - Existing file without the entry: append the entry (keeping a single blank line separator).
32
+ * - Existing file with the entry: returned unchanged (caller treats this as ALREADY).
33
+ */
34
+ export declare function withReticleIgnored(existing: string | null): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Keep Reticle's local state out of the project's Prettier check.
3
+ *
4
+ * `reticle init` runs in the user's project, and every drive writes JSON under `.reticle/`
5
+ * (impact.json, runs/, intent/). A project's `prettier --check .` fails on those files —
6
+ * not just for missing trailing newlines, but because Prettier's JSON formatting differs from
7
+ * `JSON.stringify` (e.g. short arrays stay on one line). The nested `.reticle/.gitignore` does
8
+ * not help: Prettier only reads the ignore file of the directory it runs from.
9
+ *
10
+ * So when the project uses Prettier, `init` adds `.reticle/` to the project's `.prettierignore`.
11
+ * This is the root-cause fix: it covers every file Reticle writes, present and future,
12
+ * regardless of how each one is serialized.
13
+ *
14
+ * Idempotent: re-running `init` never duplicates the entry. Never removes anything the user
15
+ * had — it only appends the one line when it is absent.
16
+ */
17
+ export const PRETTIERIGNORE_PATH = '.prettierignore';
18
+ /** The entry `init` manages in the project's `.prettierignore`. */
19
+ export const RETICLE_IGNORE_ENTRY = '.reticle/';
20
+ /**
21
+ * True when the `.prettierignore` content already excludes `.reticle/`.
22
+ *
23
+ * Matches the entry with or without trailing slash, ignoring comments and blank lines —
24
+ * a user who wrote `.reticle` by hand has already solved this; we must not add a duplicate.
25
+ */
26
+ export function ignoresReticle(content) {
27
+ return content
28
+ .split('\n')
29
+ .map((line) => line.trim())
30
+ .filter((line) => '' !== line && !line.startsWith('#'))
31
+ .some((line) => RETICLE_IGNORE_ENTRY === line || '.reticle' === line);
32
+ }
33
+ const MANAGED_HEADER = "# Added by `reticle init`: Reticle's local state (.reticle/) must not break `prettier --check`.";
34
+ /**
35
+ * The `.prettierignore` content after `init` ensures `.reticle/` is excluded.
36
+ *
37
+ * - No existing file: create one with the managed header and the entry.
38
+ * - Existing file without the entry: append the entry (keeping a single blank line separator).
39
+ * - Existing file with the entry: returned unchanged (caller treats this as ALREADY).
40
+ */
41
+ export function withReticleIgnored(existing) {
42
+ if (null === existing) {
43
+ return `${MANAGED_HEADER}\n${RETICLE_IGNORE_ENTRY}\n`;
44
+ }
45
+ if (ignoresReticle(existing))
46
+ return existing;
47
+ const trimmed = existing.replace(/\n+$/, '');
48
+ return `${trimmed}\n\n${MANAGED_HEADER}\n${RETICLE_IGNORE_ENTRY}\n`;
49
+ }
@@ -0,0 +1,22 @@
1
+ /** The connect literal for a Python snippet: the token is substituted when the page is served. */
2
+ export declare function pythonConnectArg(connectArgLiteral: string): string;
3
+ /**
4
+ * Streamlit has no served HTML template, and `st.markdown` inserts script markup without executing
5
+ * it. Streamlit 1.63 added an explicit JavaScript boundary to `st.html`; use a classic script there
6
+ * so it can dynamically import the ESM SDK in the app document. A head marker makes reruns
7
+ * idempotent and is removed after an import failure so the next rerun can retry.
8
+ */
9
+ export declare function streamlitPageSnippet(connectArgLiteral: string): string;
10
+ /**
11
+ * DEBUG-only middleware that injects Reticle into every Django-rendered page.
12
+ *
13
+ * Django serves templates, so the generic script tag has no single home: an app has many templates
14
+ * and a base template it may not have. Middleware has exactly one, runs for every page, and is the
15
+ * idiomatic Django answer — a field reporter arrived at this same shape by hand after `init` printed
16
+ * a script tag and exited 1, and had to write the middleware and `.reticle.json` themselves.
17
+ *
18
+ * Guarded on `settings.DEBUG` so it can never reach production, and on the response being HTML so it
19
+ * does not corrupt a JSON API response or a file download. Injected before `</body>` rather than in
20
+ * `<head>`, so the SDK sees a parsed document.
21
+ */
22
+ export declare function djangoMiddlewareSnippet(connectArgLiteral: string): string;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The connect snippets for Python apps (Streamlit, Django), which have no JS build to inline the
3
+ * token. Each one reads this machine's pairing token from the token file when the page is served,
4
+ * so the token is never in `init` output or in the pasted code.
5
+ */
6
+ import { CDN_SDK_URL, withTokenExpression } from './snippets.js';
7
+ /** Where the Python snippets put the token, until the page is served. */
8
+ const PYTHON_TOKEN_PLACEHOLDER = '__RETICLE_PAIRING_TOKEN__';
9
+ /** Python that reads this machine's pairing token at run time, the way the daemon finds it. */
10
+ const PYTHON_TOKEN_READER = `def _reticle_pairing_token():
11
+ folder = os.environ.get("RETICLE_PAIRING_TOKEN_DIR") or str(Path.home() / ".reticle")
12
+ try:
13
+ return (Path(folder) / "pairing-token").read_text().strip()
14
+ except OSError:
15
+ return ""`;
16
+ /** The connect literal for a Python snippet: the token is substituted when the page is served. */
17
+ export function pythonConnectArg(connectArgLiteral) {
18
+ return withTokenExpression(connectArgLiteral, PYTHON_TOKEN_PLACEHOLDER);
19
+ }
20
+ /**
21
+ * Streamlit has no served HTML template, and `st.markdown` inserts script markup without executing
22
+ * it. Streamlit 1.63 added an explicit JavaScript boundary to `st.html`; use a classic script there
23
+ * so it can dynamically import the ESM SDK in the app document. A head marker makes reruns
24
+ * idempotent and is removed after an import failure so the next rerun can retry.
25
+ */
26
+ export function streamlitPageSnippet(connectArgLiteral) {
27
+ return `import json
28
+ import os
29
+ from pathlib import Path
30
+
31
+ import streamlit as st
32
+
33
+
34
+ ${PYTHON_TOKEN_READER}
35
+
36
+
37
+ st.html(
38
+ """
39
+ <script>
40
+ (() => {
41
+ if (document.getElementById('reticle-streamlit-connect')) return;
42
+ const marker = document.createElement('meta');
43
+ marker.id = 'reticle-streamlit-connect';
44
+ document.head.appendChild(marker);
45
+ void import('${CDN_SDK_URL}')
46
+ .then(({ reticle }) => reticle.connect(${connectArgLiteral}))
47
+ .catch((error) => {
48
+ marker.remove();
49
+ throw error;
50
+ });
51
+ })();
52
+ </script>
53
+ """.replace("${PYTHON_TOKEN_PLACEHOLDER}", json.dumps(_reticle_pairing_token())),
54
+ unsafe_allow_javascript=True,
55
+ )`;
56
+ }
57
+ /**
58
+ * DEBUG-only middleware that injects Reticle into every Django-rendered page.
59
+ *
60
+ * Django serves templates, so the generic script tag has no single home: an app has many templates
61
+ * and a base template it may not have. Middleware has exactly one, runs for every page, and is the
62
+ * idiomatic Django answer — a field reporter arrived at this same shape by hand after `init` printed
63
+ * a script tag and exited 1, and had to write the middleware and `.reticle.json` themselves.
64
+ *
65
+ * Guarded on `settings.DEBUG` so it can never reach production, and on the response being HTML so it
66
+ * does not corrupt a JSON API response or a file download. Injected before `</body>` rather than in
67
+ * `<head>`, so the SDK sees a parsed document.
68
+ */
69
+ export function djangoMiddlewareSnippet(connectArgLiteral) {
70
+ return `# reticle_dev.py: add to MIDDLEWARE while DEBUG is on:
71
+ # MIDDLEWARE = [..., "reticle_dev.ReticleDevMiddleware"]
72
+ import json
73
+ import os
74
+ from pathlib import Path
75
+
76
+ from django.conf import settings
77
+
78
+ _SNIPPET = """<script type="module">
79
+ import { reticle } from '${CDN_SDK_URL}';
80
+ reticle.connect(${connectArgLiteral});
81
+ </script>"""
82
+
83
+
84
+ ${PYTHON_TOKEN_READER}
85
+
86
+
87
+ class ReticleDevMiddleware:
88
+ """Inject the Reticle SDK into HTML responses during development only."""
89
+
90
+ def __init__(self, get_response):
91
+ self.get_response = get_response
92
+
93
+ def __call__(self, request):
94
+ response = self.get_response(request)
95
+ if not settings.DEBUG:
96
+ return response
97
+ if "text/html" not in response.get("Content-Type", ""):
98
+ return response
99
+ # Streaming responses have no .content to rewrite; leave them alone.
100
+ if getattr(response, "streaming", False):
101
+ return response
102
+ body = response.content.decode(response.charset)
103
+ if "</body>" not in body:
104
+ return response
105
+ snippet = _SNIPPET.replace("${PYTHON_TOKEN_PLACEHOLDER}", json.dumps(_reticle_pairing_token()))
106
+ response.content = body.replace("</body>", snippet + "</body>", 1).encode(
107
+ response.charset
108
+ )
109
+ if response.has_header("Content-Length"):
110
+ response["Content-Length"] = str(len(response.content))
111
+ return response
112
+ `;
113
+ }