@reticlehq/init 3.7.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 (45) hide show
  1. package/dist/detect/detect.d.ts +9 -0
  2. package/dist/detect/detect.js +5 -1
  3. package/dist/detect/dev-server-ports.d.ts +2 -0
  4. package/dist/detect/dev-server-ports.js +20 -0
  5. package/dist/detect/prettier-config.d.ts +21 -0
  6. package/dist/detect/prettier-config.js +38 -0
  7. package/dist/detect/workspace-redirect.js +23 -8
  8. package/dist/diagnose/closing-hint.d.ts +5 -0
  9. package/dist/diagnose/closing-hint.js +18 -11
  10. package/dist/gather-plan-input.js +70 -18
  11. package/dist/host.d.ts +6 -0
  12. package/dist/index.d.ts +8 -2
  13. package/dist/index.js +8 -2
  14. package/dist/no-package-json.js +62 -14
  15. package/dist/patch/next-patch.d.ts +19 -11
  16. package/dist/patch/next-patch.js +33 -6
  17. package/dist/patch/prettier-ignore.d.ts +34 -0
  18. package/dist/patch/prettier-ignore.js +49 -0
  19. package/dist/patch/python-snippets.d.ts +22 -0
  20. package/dist/patch/python-snippets.js +113 -0
  21. package/dist/patch/snippets.d.ts +9 -30
  22. package/dist/patch/snippets.js +40 -103
  23. package/dist/patch/static-page.js +3 -5
  24. package/dist/patch/vite-config.js +11 -0
  25. package/dist/plan/connect-steps.d.ts +1 -0
  26. package/dist/plan/connect-steps.js +1 -0
  27. package/dist/plan/framework-adapter.d.ts +10 -0
  28. package/dist/plan/framework-adapter.js +120 -1
  29. package/dist/plan/notices.js +1 -1
  30. package/dist/plan/plan-framework.js +101 -32
  31. package/dist/plan/plan-types.d.ts +27 -0
  32. package/dist/plan/plan.d.ts +1 -10
  33. package/dist/plan/plan.js +128 -131
  34. package/dist/plan/stop-hook-step.d.ts +11 -0
  35. package/dist/plan/stop-hook-step.js +82 -6
  36. package/dist/project/agent-rules.d.ts +1 -1
  37. package/dist/project/agent-rules.js +8 -8
  38. package/dist/project/refresh-rules.js +2 -1
  39. package/dist/register/prompt-hooks.d.ts +123 -0
  40. package/dist/register/prompt-hooks.js +449 -0
  41. package/dist/register/slash-command.d.ts +1 -1
  42. package/dist/register/slash-command.js +5 -4
  43. package/dist/run-types.d.ts +4 -0
  44. package/dist/run.js +4 -1
  45. 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)
@@ -15,18 +15,17 @@ interface ReticleDevLocation {
15
15
  importSpecifier: string;
16
16
  }
17
17
  /**
18
- * 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).
19
19
  *
20
- * 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.
21
24
  *
22
- * - **Pages Router routes on presence.** EVERY file under `pages/` is a route, so a component
23
- * dropped there has no default export: `/reticle-dev` 500s and `next build` fails. It has to go
24
- * somewhere that is not a route directory. App Router routes on FILENAME (`page`/`layout`/
25
- * `route`), so an extra file beside the layout is inert and can stay there.
26
- * - **The extension is not cosmetic.** A `.tsx` file in a JavaScript project makes Next auto-install
27
- * TypeScript on the next `next dev` — and on Next 13 that takes its require-hook down with it, so
28
- * 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.
29
27
  */
28
+ export declare function nextUsesTypeScript(mountPath: string | null, hasTsconfig: boolean): boolean;
30
29
  export declare function reticleDevLocation(mountPath: string, typescript: boolean): ReticleDevLocation;
31
30
  export declare function patchNextConfig(source: string, sourceMapping?: boolean): SourcePatch;
32
31
  /**
@@ -42,9 +41,13 @@ export declare function patchRootLayout(source: string): SourcePatch;
42
41
  * ReticleDev component has to land NEXT TO the layout or the relative import it generates is dead.
43
42
  */
44
43
  export declare const NEXT_LAYOUT_CANDIDATES: string[];
45
- /** What `findRouteGroupRootLayout` found: the layout to mount into, or the ones it could not choose between. */
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
+ */
46
48
  export type RouteGroupLayouts = {
47
49
  path: string;
50
+ unmounted: string[];
48
51
  } | {
49
52
  candidates: string[];
50
53
  };
@@ -58,6 +61,9 @@ type ListingIo = Pick<InitIo, 'listDirs' | 'listFiles' | 'readFile'>;
58
61
  * `<ReticleDev />` (so the patch reports it already wired), else the one group that renders `/`,
59
62
  * else the only group layout there is. Several and none of those is returned as candidates, so the
60
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.
61
67
  */
62
68
  export declare function findRouteGroupRootLayout(io: ListingIo): RouteGroupLayouts | null;
63
69
  /**
@@ -68,10 +74,12 @@ export declare const NEXT_PAGES_APP_CANDIDATES: string[];
68
74
  /**
69
75
  * The file `<ReticleDev />` is mounted in: the App Router's root layout, then a route-group root
70
76
  * layout (#1538), then the Pages Router's `_app`. `candidates` is set when route groups hold several
71
- * root layouts and none could be chosen, so the manual step can name them.
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.
72
79
  */
73
80
  export declare function findNextMountFile(io: ListingIo & Pick<InitIo, 'exists'>): {
74
81
  path: string | null;
75
82
  candidates?: string[];
83
+ unmounted?: string[];
76
84
  };
77
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('/');
@@ -247,6 +263,9 @@ function servesRoot(io, dir) {
247
263
  * `<ReticleDev />` (so the patch reports it already wired), else the one group that renders `/`,
248
264
  * else the only group layout there is. Several and none of those is returned as candidates, so the
249
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.
250
269
  */
251
270
  export function findRouteGroupRootLayout(io) {
252
271
  for (const appDir of NEXT_APP_DIRS) {
@@ -262,14 +281,21 @@ export function findRouteGroupRootLayout(io) {
262
281
  });
263
282
  if (0 === layouts.length)
264
283
  continue;
265
- const wired = layouts.find((l) => true === io.readFile(l.path)?.includes(RETICLE_DEV_COMPONENT));
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));
266
292
  if (wired !== undefined)
267
- return { path: wired.path };
293
+ return chosen(wired.path);
268
294
  const root = layouts.filter((l) => servesRoot(io, l.dir));
269
295
  if (1 === root.length && root[0] !== undefined)
270
- return { path: root[0].path };
296
+ return chosen(root[0].path);
271
297
  if (1 === layouts.length && layouts[0] !== undefined)
272
- return { path: layouts[0].path };
298
+ return chosen(layouts[0].path);
273
299
  return { candidates: layouts.map((l) => l.path) };
274
300
  }
275
301
  return null;
@@ -289,7 +315,8 @@ export const NEXT_PAGES_APP_CANDIDATES = [
289
315
  /**
290
316
  * The file `<ReticleDev />` is mounted in: the App Router's root layout, then a route-group root
291
317
  * layout (#1538), then the Pages Router's `_app`. `candidates` is set when route groups hold several
292
- * root layouts and none could be chosen, so the manual step can name them.
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.
293
320
  */
294
321
  export function findNextMountFile(io) {
295
322
  const top = NEXT_LAYOUT_CANDIDATES.find((p) => io.exists(p));
@@ -297,7 +324,7 @@ export function findNextMountFile(io) {
297
324
  return { path: top };
298
325
  const groups = findRouteGroupRootLayout(io);
299
326
  if (groups !== null && 'path' in groups)
300
- return { path: groups.path };
327
+ return { path: groups.path, unmounted: groups.unmounted };
301
328
  const pagesApp = NEXT_PAGES_APP_CANDIDATES.find((p) => io.exists(p)) ?? null;
302
329
  return null === groups ? { path: pagesApp } : { path: pagesApp, candidates: groups.candidates };
303
330
  }
@@ -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
+ }
@@ -35,19 +35,16 @@ export declare const CDN_SDK_URL: string;
35
35
  * (so the app is identifiable across port changes). Empty string only when neither applies.
36
36
  */
37
37
  export declare function connectArg(port: number | undefined, projectId?: string): string;
38
+ /** The gitignored module beside the page that holds this machine's pairing token. */
39
+ export declare const LOCAL_TOKEN_MODULE = "reticle.local.js";
38
40
  /**
39
- * The same literal, with the pairing token folded in.
41
+ * A connect literal with `token` set to a JS expression, never to the token itself.
40
42
  *
41
- * The token belongs INSIDE the call the user pastes. Every other stack has a build step to inline
42
- * it (the Vite plugin's `define`, Next's NEXT_PUBLIC_*, Astro's config, CRA's .env); the hand-wired
43
- * paths have none, so `init` inlines the literal it already read. Without it the bridge closes the
44
- * socket with AUTH_FAILED and no session ever appears: see Bridge's hello handler.
45
- *
46
- * An empty token is omitted rather than emitted as `token: ''`. A daemon that could not write to
47
- * $HOME runs without auth and trusts loopback, and an empty string would fail the comparison against
48
- * one that does hold a token.
43
+ * The token used to be a literal in every printed snippet. It then stayed in terminal history,
44
+ * in agent transcripts, and in every file the snippet was pasted into. The snippets now read it at
45
+ * run time: from the gitignored module, or, in Python, from the token file.
49
46
  */
50
- export declare function connectArgWithToken(port: number | undefined, projectId: string | undefined, pairingToken: string | undefined): string;
47
+ export declare function withTokenExpression(connectArgLiteral: string, tokenExpression: string): string;
51
48
  export declare function usesReactKit(uiLibrary: UiLibrary, framework?: Framework): boolean;
52
49
  /**
53
50
  * Which SDK package the GENERATED code should import, and whether `install()` applies.
@@ -152,26 +149,8 @@ export declare const NEXT_LAYOUT_MANUAL = "Mount <ReticleDev /> in your root lay
152
149
  * opposite sign, so the two share a builder rather than a copy.
153
150
  */
154
151
  export declare function staticPageSnippet(connectArgLiteral: string): string;
155
- /**
156
- * Streamlit has no served HTML template, and `st.markdown` inserts script markup without executing
157
- * it. Streamlit 1.63 added an explicit JavaScript boundary to `st.html`; use a classic script there
158
- * so it can dynamically import the ESM SDK in the app document. A head marker makes reruns
159
- * idempotent and is removed after an import failure so the next rerun can retry.
160
- */
161
- export declare function streamlitPageSnippet(connectArgLiteral: string): string;
162
- /**
163
- * DEBUG-only middleware that injects Reticle into every Django-rendered page.
164
- *
165
- * Django serves templates, so the generic script tag has no single home: an app has many templates
166
- * and a base template it may not have. Middleware has exactly one, runs for every page, and is the
167
- * idiomatic Django answer — a field reporter arrived at this same shape by hand after `init` printed
168
- * a script tag and exited 1, and had to write the middleware and `.reticle.json` themselves.
169
- *
170
- * Guarded on `settings.DEBUG` so it can never reach production, and on the response being HTML so it
171
- * does not corrupt a JSON API response or a file download. Injected before `</body>` rather than in
172
- * `<head>`, so the SDK sees a parsed document.
173
- */
174
- export declare function djangoMiddlewareSnippet(connectArgLiteral: string): string;
152
+ /** The same snippet, with the token read from the gitignored module beside the page. */
153
+ export declare function staticPageSnippetWithLocalToken(connectArgLiteral: string): string;
175
154
  export declare function htmlManual(port: number | undefined, projectId?: string, pairingToken?: string, uiLibrary?: UiLibrary): string;
176
155
  export declare const NEXT_RETICLE_DEV_PATH = "app/reticle-dev.tsx";
177
156
  export declare const SVELTEKIT_HOOKS_PATH = "src/hooks.client.ts";