@reticlehq/init 3.5.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/detect/detect.d.ts +8 -0
- package/dist/detect/detect.js +6 -3
- package/dist/detect/dev-script.d.ts +17 -0
- package/dist/detect/dev-script.js +128 -0
- package/dist/detect/non-js-project.d.ts +23 -0
- package/dist/detect/non-js-project.js +55 -23
- package/dist/detect/workspace-apps.d.ts +16 -0
- package/dist/detect/workspace-apps.js +70 -0
- package/dist/detect/workspace-redirect.d.ts +12 -0
- package/dist/detect/workspace-redirect.js +84 -9
- package/dist/diagnose/closing-hint.d.ts +7 -1
- package/dist/diagnose/closing-hint.js +39 -12
- package/dist/diagnose/csp-check.js +65 -20
- package/dist/diagnose/init-failure.d.ts +2 -0
- package/dist/diagnose/init-failure.js +2 -0
- package/dist/gather-plan-input.d.ts +11 -0
- package/dist/gather-plan-input.js +416 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -2
- package/dist/no-package-json.js +17 -8
- package/dist/node-io.d.ts +1 -1
- package/dist/node-io.js +11 -2
- package/dist/patch/next-patch.d.ts +28 -0
- package/dist/patch/next-patch.js +66 -0
- package/dist/patch/nuxt-snippets.js +1 -1
- package/dist/patch/snippets.d.ts +1 -1
- package/dist/patch/snippets.js +2 -2
- package/dist/patch/static-page.d.ts +1 -0
- package/dist/patch/static-page.js +3 -3
- package/dist/plan/connect-steps.d.ts +1 -0
- package/dist/plan/connect-steps.js +5 -0
- package/dist/plan/framework-adapter.js +2 -2
- package/dist/plan/plan-angular.js +1 -1
- package/dist/plan/plan-framework.d.ts +1 -1
- package/dist/plan/plan-framework.js +47 -13
- package/dist/plan/plan-types.d.ts +23 -2
- package/dist/plan/plan-vite.js +15 -0
- package/dist/plan/plan.js +68 -10
- package/dist/plan/stop-hook-step.d.ts +19 -0
- package/dist/plan/stop-hook-step.js +74 -1
- package/dist/project/agent-rules.d.ts +1 -1
- package/dist/project/agent-rules.js +16 -11
- package/dist/register/codex-toml.d.ts +12 -0
- package/dist/register/codex-toml.js +44 -0
- package/dist/register/mcp.d.ts +15 -2
- package/dist/register/mcp.js +16 -0
- package/dist/run-types.d.ts +6 -0
- package/dist/run.d.ts +1 -1
- package/dist/run.js +27 -414
- package/dist/sdk-packages.d.ts +19 -0
- package/dist/sdk-packages.js +34 -0
- package/package.json +2 -2
|
@@ -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 =
|
|
81
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
if (list === undefined)
|
|
32
|
+
const matches = [...text.matchAll(new RegExp(`${directive}([^;"]*)`, 'gi'))];
|
|
33
|
+
if (0 === matches.length)
|
|
39
34
|
return undefined;
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
.
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
const blockingGroups = missingByPolicyGroup.filter((missing) => 0 < missing.length);
|
|
109
|
+
if (0 === blockingGroups.length)
|
|
77
110
|
return undefined;
|
|
78
|
-
const missing = wanted.filter((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
|
-
|
|
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}`;
|
|
@@ -20,6 +20,8 @@ export declare const InitFailure: {
|
|
|
20
20
|
readonly APP_CONNECTION: "app_connection";
|
|
21
21
|
readonly BRIDGE_OCCUPIED: "bridge_occupied";
|
|
22
22
|
readonly RUNTIME_ERROR: "runtime_error";
|
|
23
|
+
/** Ctrl-C (or a SIGTERM) during the runtime phase, before it could say how setup went. */
|
|
24
|
+
readonly INTERRUPTED: "interrupted";
|
|
23
25
|
readonly OTHER: "other";
|
|
24
26
|
};
|
|
25
27
|
export type InitFailure = (typeof InitFailure)[keyof typeof InitFailure];
|
|
@@ -20,5 +20,7 @@ export const InitFailure = {
|
|
|
20
20
|
APP_CONNECTION: 'app_connection',
|
|
21
21
|
BRIDGE_OCCUPIED: 'bridge_occupied',
|
|
22
22
|
RUNTIME_ERROR: 'runtime_error',
|
|
23
|
+
/** Ctrl-C (or a SIGTERM) during the runtime phase, before it could say how setup went. */
|
|
24
|
+
INTERRUPTED: 'interrupted',
|
|
23
25
|
OTHER: 'other',
|
|
24
26
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { InitOptions, InitIo } from './run-types.js';
|
|
2
|
+
import { type PlanInput } from './plan/plan.js';
|
|
3
|
+
/**
|
|
4
|
+
* The directory the human's agent runs in, when it is not the app's directory.
|
|
5
|
+
*
|
|
6
|
+
* `undefined` for a single-package repo, which keeps every agent-file path project-relative exactly
|
|
7
|
+
* as it was — the two roots are the same there, which is why writing them beside the app went
|
|
8
|
+
* unnoticed until a repo with its app at `src/admin` reported it.
|
|
9
|
+
*/
|
|
10
|
+
export declare function agentRootOf(options: InitOptions): string | undefined;
|
|
11
|
+
export declare function gatherPlanInput(options: InitOptions, io: InitIo, pkg: unknown): PlanInput;
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `reticle init` reads before it plans: every project file, probe and host answer the pure
|
|
3
|
+
* planner takes, gathered through the injected `InitIo`.
|
|
4
|
+
*
|
|
5
|
+
* Split from `run.ts`, which orchestrates the run (plan, apply, report). This is only the read side,
|
|
6
|
+
* and it writes nothing.
|
|
7
|
+
*/
|
|
8
|
+
import { detectMcpClients } from './register/detect-clients.js';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { CONTAINER_MARKERS, runsDevServer } from './diagnose/containerised-dev-server.js';
|
|
11
|
+
import { CSP_FILES } from './diagnose/csp-doctor.js';
|
|
12
|
+
import { devScriptBody, discoverPlainViteConfig } from './detect/dev-script.js';
|
|
13
|
+
import { ANGULAR_WORKSPACE_FILE, detect, FORGE_RENDERER_CONFIGS, Framework, } from './detect/detect.js';
|
|
14
|
+
import { ANGULAR_PROXY_PATH, angularEntry } from './patch/angular.js';
|
|
15
|
+
import { HTML_INDEX_PATH, STATIC_GITIGNORE_PATH, STATIC_TOKEN_MODULE, } from './patch/static-page.js';
|
|
16
|
+
import { pickAstroHost } from './patch/astro-host.js';
|
|
17
|
+
import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, VITE_CONFIG_CANDIDATES, } from './detect/workspace-apps.js';
|
|
18
|
+
import { CURSOR_RULE_PATH, RETICLE_MD_PATH } from './project/agent-rules.js';
|
|
19
|
+
import { CLAUDE_SETTINGS_PATH } from './plan/stop-hook-step.js';
|
|
20
|
+
import { CRA_ENV_PATH } from './patch/cra.js';
|
|
21
|
+
import { findNextMountFile } from './patch/next-patch.js';
|
|
22
|
+
import { tanstackStartConnectPath } from './patch/tanstack-start.js';
|
|
23
|
+
import { installedViteMajor } from './patch/vite-owning-config.js';
|
|
24
|
+
import { RETICLE_CONFIG_FILE } from './plan/plan.js';
|
|
25
|
+
import { claudeAvailableProbe, claudeHasReticle, codexAvailableProbe } from './register/mcp.js';
|
|
26
|
+
import { reticleDevLocation } from './patch/next-patch.js';
|
|
27
|
+
import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
|
|
28
|
+
import { CLAUDE_PROJECT_CONFIG, CURSOR_PROJECT_MARKER, McpClient } from './register/mcp-clients.js';
|
|
29
|
+
import { deriveProjectId, packageName } from './project/project-id.js';
|
|
30
|
+
import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, nuxtPluginPath, } from './patch/snippets.js';
|
|
31
|
+
import { NUXT_CONFIG_CANDIDATES } from './patch/nuxt-patch.js';
|
|
32
|
+
import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH } from './register/slash-command.js';
|
|
33
|
+
import { RETICLE_VERSION } from './version.js';
|
|
34
|
+
import { resolveLockfiles } from './detect/lockfiles.js';
|
|
35
|
+
/** CRA's bundled entry, in the order create-react-app itself generates them. */
|
|
36
|
+
const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
|
|
37
|
+
/** Preview snippets must not mint a real machine credential or imply this token is usable. */
|
|
38
|
+
const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
|
|
39
|
+
function craEntryOf(io) {
|
|
40
|
+
for (const path of CRA_ENTRY_CANDIDATES) {
|
|
41
|
+
const source = io.readFile(path);
|
|
42
|
+
if (source !== null)
|
|
43
|
+
return { path, source };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const SVELTEKIT_HOOKS = 'src/hooks.client.ts';
|
|
48
|
+
const REACT_ROUTER_ENTRY = 'app/entry.client.tsx';
|
|
49
|
+
const TANSTACK_START_ROOT_CANDIDATES = [
|
|
50
|
+
'src/routes/__root.tsx',
|
|
51
|
+
'src/routes/__root.jsx',
|
|
52
|
+
'app/routes/__root.tsx',
|
|
53
|
+
'app/routes/__root.jsx',
|
|
54
|
+
];
|
|
55
|
+
const SOURCE_FILE = /\.(tsx|jsx|ts|js|svelte|vue|astro)$/;
|
|
56
|
+
/** Files read for the testid scan. A capabilities block is a hint; reading a whole repo for it is not. */
|
|
57
|
+
const MAX_SCANNED_FILES = 200;
|
|
58
|
+
/** Directories that never hold the app's own source, and are expensive or misleading to read. */
|
|
59
|
+
const NOT_SOURCE_DIRS = new Set([
|
|
60
|
+
'node_modules',
|
|
61
|
+
'dist',
|
|
62
|
+
'build',
|
|
63
|
+
'out',
|
|
64
|
+
'coverage',
|
|
65
|
+
'target',
|
|
66
|
+
'public',
|
|
67
|
+
'static',
|
|
68
|
+
'.next',
|
|
69
|
+
'.svelte-kit',
|
|
70
|
+
'.nuxt',
|
|
71
|
+
]);
|
|
72
|
+
/** How far below the app root to look. Deep enough for `modules/users/UserList.tsx`, not a repo crawl. */
|
|
73
|
+
const MAX_SCAN_DEPTH = 5;
|
|
74
|
+
/**
|
|
75
|
+
* Read a bounded set of the app's source files, for the `data-testid` scan.
|
|
76
|
+
*
|
|
77
|
+
* This used to walk the fixed list `src, src/components, src/pages, app, components`. Reported from
|
|
78
|
+
* the field (#318) by a repo whose app is at `src/admin`: the scan read directories that were not
|
|
79
|
+
* the app's and correctly reported finding nothing in them, so `init` said "no data-testid values
|
|
80
|
+
* yet" about an app with several — which makes an agent go and write the ones already there. That
|
|
81
|
+
* list had already grown once for a `frontend/` app, and a third report of the same shape is what
|
|
82
|
+
* says the answer is not another name in a list. So: walk the app root, bounded by depth and by a
|
|
83
|
+
* file count.
|
|
84
|
+
*/
|
|
85
|
+
function readSourceFiles(io) {
|
|
86
|
+
const out = [];
|
|
87
|
+
const read = (path) => {
|
|
88
|
+
const content = io.readFile(path);
|
|
89
|
+
if (content !== null)
|
|
90
|
+
out.push({ path, source: content });
|
|
91
|
+
};
|
|
92
|
+
for (const name of io.rootFiles()) {
|
|
93
|
+
if (out.length >= MAX_SCANNED_FILES)
|
|
94
|
+
return out;
|
|
95
|
+
if (SOURCE_FILE.test(name))
|
|
96
|
+
read(name);
|
|
97
|
+
}
|
|
98
|
+
const walk = (dir, depth) => {
|
|
99
|
+
for (const name of io.listFiles(dir)) {
|
|
100
|
+
if (out.length >= MAX_SCANNED_FILES)
|
|
101
|
+
return;
|
|
102
|
+
if (SOURCE_FILE.test(name))
|
|
103
|
+
read(`${dir}/${name}`);
|
|
104
|
+
}
|
|
105
|
+
if (depth >= MAX_SCAN_DEPTH)
|
|
106
|
+
return;
|
|
107
|
+
for (const sub of io.listDirs(dir)) {
|
|
108
|
+
if (out.length >= MAX_SCANNED_FILES)
|
|
109
|
+
return;
|
|
110
|
+
if (sub.startsWith('.') || NOT_SOURCE_DIRS.has(sub))
|
|
111
|
+
continue;
|
|
112
|
+
walk(`${dir}/${sub}`, depth + 1);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
for (const dir of io.listDirs('.')) {
|
|
116
|
+
if (out.length >= MAX_SCANNED_FILES)
|
|
117
|
+
return out;
|
|
118
|
+
if (dir.startsWith('.') || NOT_SOURCE_DIRS.has(dir))
|
|
119
|
+
continue;
|
|
120
|
+
walk(dir, 1);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/** Direct dependency names, for naming the state libraries an app actually has. */
|
|
125
|
+
function dependencyNames(pkg) {
|
|
126
|
+
const p = (pkg ?? {});
|
|
127
|
+
return new Set([
|
|
128
|
+
...Object.keys(p['dependencies'] ?? {}),
|
|
129
|
+
...Object.keys(p['devDependencies'] ?? {}),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
/** electron-vite and the unbundled Electron layouts this repo already ships. */
|
|
133
|
+
const ELECTRON_MAIN_SOURCES = [
|
|
134
|
+
'src/main/index.ts',
|
|
135
|
+
'src/main/index.js',
|
|
136
|
+
'src/main/index.mts',
|
|
137
|
+
'src/main/index.mjs',
|
|
138
|
+
'electron/main.cjs',
|
|
139
|
+
'electron/main.js',
|
|
140
|
+
'electron/main.ts',
|
|
141
|
+
'src/main.ts',
|
|
142
|
+
'src/main.js',
|
|
143
|
+
];
|
|
144
|
+
const ELECTRON_PRELOAD_SOURCES = [
|
|
145
|
+
'src/preload/index.ts',
|
|
146
|
+
'src/preload/index.js',
|
|
147
|
+
'src/preload/index.mts',
|
|
148
|
+
'src/preload/index.mjs',
|
|
149
|
+
'electron/preload.cjs',
|
|
150
|
+
'electron/preload.js',
|
|
151
|
+
'electron/preload.ts',
|
|
152
|
+
'src/preload.ts',
|
|
153
|
+
'src/preload.js',
|
|
154
|
+
];
|
|
155
|
+
const ASTRO_CONFIG_CANDIDATES = [
|
|
156
|
+
'astro.config.mjs',
|
|
157
|
+
'astro.config.js',
|
|
158
|
+
'astro.config.ts',
|
|
159
|
+
'astro.config.cjs',
|
|
160
|
+
];
|
|
161
|
+
/** Where a conventional Astro app keeps the layout every page wraps itself in. */
|
|
162
|
+
const ASTRO_LAYOUTS_DIR = 'src/layouts';
|
|
163
|
+
/** Also searched: an app with no layouts directory renders the document straight from a page. */
|
|
164
|
+
const ASTRO_PAGES_DIR = 'src/pages';
|
|
165
|
+
function firstPresent(files, candidates) {
|
|
166
|
+
for (const c of candidates)
|
|
167
|
+
if (files.has(c))
|
|
168
|
+
return c;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
function firstReadable(io, candidates) {
|
|
172
|
+
for (const path of candidates) {
|
|
173
|
+
const source = io.readFile(path);
|
|
174
|
+
if (source !== null)
|
|
175
|
+
return { path, source };
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The directory the human's agent runs in, when it is not the app's directory.
|
|
181
|
+
*
|
|
182
|
+
* `undefined` for a single-package repo, which keeps every agent-file path project-relative exactly
|
|
183
|
+
* as it was — the two roots are the same there, which is why writing them beside the app went
|
|
184
|
+
* unnoticed until a repo with its app at `src/admin` reported it.
|
|
185
|
+
*/
|
|
186
|
+
export function agentRootOf(options) {
|
|
187
|
+
const root = options.agentRoot;
|
|
188
|
+
return root === undefined || root === options.cwd ? undefined : root;
|
|
189
|
+
}
|
|
190
|
+
/** What the Angular steps read: the workspace file, the entry it names, and our proxy module. */
|
|
191
|
+
function angularInputs(io) {
|
|
192
|
+
const workspace = io.readFile(ANGULAR_WORKSPACE_FILE);
|
|
193
|
+
const entryPath = angularEntry(workspace);
|
|
194
|
+
const entry = io.readFile(entryPath);
|
|
195
|
+
return {
|
|
196
|
+
angularWorkspace: null === workspace ? null : { path: ANGULAR_WORKSPACE_FILE, source: workspace },
|
|
197
|
+
angularEntry: null === entry ? null : { path: entryPath, source: entry },
|
|
198
|
+
angularProxySource: io.readFile(ANGULAR_PROXY_PATH),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
export function gatherPlanInput(options, io, pkg) {
|
|
202
|
+
// Stable identity derived from the app's package.json name + root, so it survives port changes.
|
|
203
|
+
const projectId = deriveProjectId(packageName(pkg), options.cwd);
|
|
204
|
+
const rootFiles = new Set(io.rootFiles());
|
|
205
|
+
const nodeModulesMarkers = new Set(io.listFiles('node_modules'));
|
|
206
|
+
const detectInput = {
|
|
207
|
+
pkg: 'object' === typeof pkg && pkg !== null ? pkg : {},
|
|
208
|
+
configFiles: rootFiles,
|
|
209
|
+
// Walk up for the lockfile so a monorepo sub-package picks the workspace's package manager —
|
|
210
|
+
// unless this package's own installed tree already answers it, which outranks an inherited one.
|
|
211
|
+
lockfiles: resolveLockfiles(rootFiles, options.cwd, io, nodeModulesMarkers),
|
|
212
|
+
// An already-installed tree names its own manager, which matters when no lockfile is committed.
|
|
213
|
+
nodeModulesMarkers,
|
|
214
|
+
};
|
|
215
|
+
const detection = detect(detectInput);
|
|
216
|
+
const nodeModulesLinked = true === io.isSymlink?.('node_modules');
|
|
217
|
+
// Forge has no `vite.config.*`; its renderer config IS the Vite config the plugin belongs in, and
|
|
218
|
+
// handing it over under that name is what lets the ordinary Vite patcher do the work.
|
|
219
|
+
const plainViteConfig = Framework.VITE === detection.framework
|
|
220
|
+
? discoverPlainViteConfig(io, pkg, rootFiles, VITE_CONFIG_CANDIDATES)
|
|
221
|
+
: undefined;
|
|
222
|
+
const vitePath = firstPresent(rootFiles, Framework.ELECTRON_FORGE === detection.framework
|
|
223
|
+
? FORGE_RENDERER_CONFIGS
|
|
224
|
+
: VITE_CONFIG_CANDIDATES);
|
|
225
|
+
const viteSource = null === vitePath ? null : io.readFile(vitePath);
|
|
226
|
+
const viteConfig = plainViteConfig === undefined
|
|
227
|
+
? vitePath !== null && viteSource !== null
|
|
228
|
+
? { path: vitePath, source: viteSource }
|
|
229
|
+
: null
|
|
230
|
+
: plainViteConfig.config;
|
|
231
|
+
const electronVitePath = firstPresent(rootFiles, ELECTRON_VITE_CONFIG_CANDIDATES);
|
|
232
|
+
const electronViteSource = null === electronVitePath ? null : io.readFile(electronVitePath);
|
|
233
|
+
const electronViteConfig = electronVitePath !== null && electronViteSource !== null
|
|
234
|
+
? { path: electronVitePath, source: electronViteSource }
|
|
235
|
+
: null;
|
|
236
|
+
const electronMain = firstReadable(io, ELECTRON_MAIN_SOURCES);
|
|
237
|
+
const electronPreload = firstReadable(io, ELECTRON_PRELOAD_SOURCES);
|
|
238
|
+
// Global MCP registration targets each agent that's present: Claude via its CLI, Cursor via its
|
|
239
|
+
// global config file. Only probe when the MCP step is in play.
|
|
240
|
+
const availableProbe = claudeAvailableProbe();
|
|
241
|
+
const claudeCli = options.mcp ? io.probe(availableProbe.command, availableProbe.args) : false;
|
|
242
|
+
// Read from Claude's config, never `claude mcp get`: that launches the server — see claudeHasReticle.
|
|
243
|
+
const mcpExists = claudeCli ? claudeHasReticle(io, io.cwd()) : false;
|
|
244
|
+
// Every MCP client this machine shows evidence of. Conservative and one-directional: we write
|
|
245
|
+
// into a config a client ALREADY has, and never create ~/.gemini or ~/.codeium for somebody who
|
|
246
|
+
// does not use them.
|
|
247
|
+
const detectedClients = options.mcp ? detectMcpClients(io) : [];
|
|
248
|
+
// Codex registers through its own CLI when there is one. Asked only after Codex was FOUND: the
|
|
249
|
+
// probe runs a binary, and a machine with no Codex has no business running one.
|
|
250
|
+
const codexProbe = codexAvailableProbe();
|
|
251
|
+
const codexCli = detectedClients.some((client) => McpClient.CODEX === client.id)
|
|
252
|
+
? io.probe(codexProbe.command, codexProbe.args)
|
|
253
|
+
: false;
|
|
254
|
+
const astroPath = firstPresent(rootFiles, ASTRO_CONFIG_CANDIDATES);
|
|
255
|
+
const astroSource = null === astroPath ? null : io.readFile(astroPath);
|
|
256
|
+
// Which file owns the DOCUMENT, not how many files sit in a directory. The old rule ("exactly one
|
|
257
|
+
// .astro in src/layouts") fired on neither real Astro app: one has no layouts directory and
|
|
258
|
+
// renders from src/pages/index.astro, the other has three files there of which two are partials.
|
|
259
|
+
// `</body>` is the discriminator — see astro-host.
|
|
260
|
+
const astroCandidates = [ASTRO_LAYOUTS_DIR, ASTRO_PAGES_DIR].flatMap((dir) => io
|
|
261
|
+
.listFiles(dir)
|
|
262
|
+
.filter((f) => f.endsWith('.astro'))
|
|
263
|
+
.map((f) => ({ path: `${dir}/${f}`, source: io.readFile(`${dir}/${f}`) }))
|
|
264
|
+
.filter((c) => c.source !== null));
|
|
265
|
+
const astroHost = pickAstroHost(astroCandidates);
|
|
266
|
+
const layoutRelPath = astroHost?.path ?? null;
|
|
267
|
+
const astroLayoutSource = astroHost?.source ?? null;
|
|
268
|
+
const nuxtConfigPath = firstPresent(rootFiles, NUXT_CONFIG_CANDIDATES);
|
|
269
|
+
const nuxtConfigSource = null === nuxtConfigPath ? null : io.readFile(nuxtConfigPath);
|
|
270
|
+
// Nuxt 4's default srcDir is `app/`, Nuxt 3's is the root — and a plugin written into the
|
|
271
|
+
// directory Nuxt does not scan is never registered, silently.
|
|
272
|
+
const nuxtHasAppDir = io.listDirs('.').includes('app');
|
|
273
|
+
const nextConfigFile = firstPresent(rootFiles, NEXT_CONFIG_CANDIDATES);
|
|
274
|
+
// App Router first, route groups included (#1538); a Pages Router app mounts in pages/_app.
|
|
275
|
+
const nextMount = findNextMountFile(io);
|
|
276
|
+
const layoutPath = nextMount.path;
|
|
277
|
+
const layoutSource = null === layoutPath ? null : io.readFile(layoutPath);
|
|
278
|
+
// Where the component goes depends on WHICH router mounts it: `pages/` routes on presence, so a
|
|
279
|
+
// component there becomes a broken route; `app/` routes on filename, so a sibling is inert.
|
|
280
|
+
const devLocation = reticleDevLocation(layoutPath ?? 'app/layout.tsx', detection.typescript);
|
|
281
|
+
// Read once: both the testid scan and the store scan want the same bounded set of files.
|
|
282
|
+
const sourceFiles = readSourceFiles(io);
|
|
283
|
+
const agentRoot = agentRootOf(options);
|
|
284
|
+
const agentFile = (relPath) => agentRoot === undefined ? relPath : join(agentRoot, relPath);
|
|
285
|
+
const cspSources = {};
|
|
286
|
+
for (const file of CSP_FILES) {
|
|
287
|
+
const source = io.readFile(file);
|
|
288
|
+
if (null !== source)
|
|
289
|
+
cspSources[file] = source;
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
detection,
|
|
293
|
+
...(nodeModulesLinked ? { nodeModulesLinked } : {}),
|
|
294
|
+
captureBodies: options.captureBodies,
|
|
295
|
+
hooks: options.hooks,
|
|
296
|
+
cspSources,
|
|
297
|
+
claudeCli,
|
|
298
|
+
mcpExists,
|
|
299
|
+
insideClaudeCode: io.host.insideClaudeCode(),
|
|
300
|
+
claudeProjectConfig: options.mcp ? io.readFile(agentFile(CLAUDE_PROJECT_CONFIG)) : undefined,
|
|
301
|
+
platform: process.platform,
|
|
302
|
+
detectedClients,
|
|
303
|
+
codexCli,
|
|
304
|
+
cursorProjectPresent: io.exists(CURSOR_PROJECT_MARKER),
|
|
305
|
+
// Looked for beside the app AND one level up, because the app is routinely a subdirectory of the
|
|
306
|
+
// repo that containerises it — `frontend/` under a root `docker-compose.yml` is the shape this
|
|
307
|
+
// came from. Only the first match is reported; the note is the same whichever file found it.
|
|
308
|
+
...(() => {
|
|
309
|
+
// Only a file that RUNS the dev server: a production-only image is the common case, and
|
|
310
|
+
// React Router's template ships one. See runsDevServer.
|
|
311
|
+
const devScript = devScriptBody(pkg);
|
|
312
|
+
const marker = CONTAINER_MARKERS.find((name) => [name, join('..', name)].some((path) => {
|
|
313
|
+
const content = io.readFile(path);
|
|
314
|
+
return null !== content && runsDevServer(name, content, devScript);
|
|
315
|
+
}));
|
|
316
|
+
return marker === undefined ? {} : { containerMarker: marker };
|
|
317
|
+
})(),
|
|
318
|
+
viteConfig,
|
|
319
|
+
viteConfigCandidates: plainViteConfig?.candidates,
|
|
320
|
+
electronViteConfig,
|
|
321
|
+
electronPreload,
|
|
322
|
+
electronMain,
|
|
323
|
+
astroConfig: astroPath !== null && astroSource !== null ? { path: astroPath, source: astroSource } : null,
|
|
324
|
+
astroLayout: layoutRelPath !== null && astroLayoutSource !== null
|
|
325
|
+
? { path: layoutRelPath, source: astroLayoutSource }
|
|
326
|
+
: null,
|
|
327
|
+
astroEnvDts: io.readFile('src/env.d.ts'),
|
|
328
|
+
astroReticleDev: io.readFile('src/components/ReticleDev.ts'),
|
|
329
|
+
nextConfigFile,
|
|
330
|
+
nextConfigSource: null === nextConfigFile ? null : io.readFile(nextConfigFile),
|
|
331
|
+
nextLayout: layoutPath !== null && layoutSource !== null
|
|
332
|
+
? { path: layoutPath, source: layoutSource }
|
|
333
|
+
: null,
|
|
334
|
+
// Capabilities: scanned, never asked for. Bounded — a hint for the agent, not a repo index.
|
|
335
|
+
nextLayoutCandidates: nextMount.candidates,
|
|
336
|
+
testids: scanTestids(sourceFiles.map((f) => f.source)),
|
|
337
|
+
storeHints: storeHints(dependencyNames(pkg), sourceFiles),
|
|
338
|
+
foundStores: scanStores(sourceFiles, dependencyNames(pkg)),
|
|
339
|
+
nextFoundStores: scanStores(sourceFiles, dependencyNames(pkg), dirname(devLocation.path)),
|
|
340
|
+
viteDevModuleExists: io.exists(detection.framework === Framework.ELECTRON_VITE
|
|
341
|
+
? ELECTRON_VITE_DEV_MODULE_PATH
|
|
342
|
+
: VITE_DEV_MODULE_PATH),
|
|
343
|
+
nextReticleDevPath: devLocation.path,
|
|
344
|
+
nextReticleDevImport: devLocation.importSpecifier,
|
|
345
|
+
nextReticleDevExists: io.exists(devLocation.path),
|
|
346
|
+
nextReticleDevSource: io.readFile(devLocation.path),
|
|
347
|
+
svelteKitHooksExists: io.exists(SVELTEKIT_HOOKS),
|
|
348
|
+
svelteKitHooksSource: io.readFile(SVELTEKIT_HOOKS),
|
|
349
|
+
reactRouterEntryExists: io.exists(REACT_ROUTER_ENTRY),
|
|
350
|
+
// The SOURCE, so an entry the app already owns is ADDED TO rather than replaced — it is an
|
|
351
|
+
// override of React Router's default, and everything in it is load-bearing.
|
|
352
|
+
reactRouterEntrySource: io.readFile(REACT_ROUTER_ENTRY),
|
|
353
|
+
nuxtConfig: nuxtConfigPath !== null && nuxtConfigSource !== null
|
|
354
|
+
? { path: nuxtConfigPath, source: nuxtConfigSource }
|
|
355
|
+
: null,
|
|
356
|
+
nuxtHasAppDir,
|
|
357
|
+
viteMajor: installedViteMajor(io, detectInput.pkg),
|
|
358
|
+
nuxtPluginExists: io.exists(nuxtPluginPath(nuxtHasAppDir)),
|
|
359
|
+
nuxtPluginSource: io.readFile(nuxtPluginPath(nuxtHasAppDir)),
|
|
360
|
+
...(() => {
|
|
361
|
+
const root = TANSTACK_START_ROOT_CANDIDATES.find((file) => io.exists(file));
|
|
362
|
+
return root === undefined
|
|
363
|
+
? {}
|
|
364
|
+
: {
|
|
365
|
+
tanstackStartRoot: root,
|
|
366
|
+
tanstackStartRootSource: io.readFile(root),
|
|
367
|
+
tanstackStartConnectExists: io.exists(tanstackStartConnectPath(root)),
|
|
368
|
+
tanstackStartConnectSource: io.readFile(tanstackStartConnectPath(root)),
|
|
369
|
+
};
|
|
370
|
+
})(),
|
|
371
|
+
...(Framework.ANGULAR === detection.framework ? angularInputs(io) : {}),
|
|
372
|
+
...(Framework.HTML === detection.framework
|
|
373
|
+
? {
|
|
374
|
+
htmlIndexSource: io.readFile(HTML_INDEX_PATH),
|
|
375
|
+
htmlLocalSources: {
|
|
376
|
+
[STATIC_TOKEN_MODULE]: io.readFile(STATIC_TOKEN_MODULE),
|
|
377
|
+
[STATIC_GITIGNORE_PATH]: io.readFile(STATIC_GITIGNORE_PATH),
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
: {}),
|
|
381
|
+
craEntry: craEntryOf(io),
|
|
382
|
+
craEnv: io.readFile(CRA_ENV_PATH),
|
|
383
|
+
pairingToken: options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken(),
|
|
384
|
+
installSource: io.host.installSource(),
|
|
385
|
+
reticleConfigExists: io.exists(RETICLE_CONFIG_FILE),
|
|
386
|
+
// The CONTENT, so a config that exists can be checked rather than trusted — a `"port"` set to
|
|
387
|
+
// the app's own dev-server port used to survive every re-run of `init`.
|
|
388
|
+
reticleConfigSource: io.readFile(RETICLE_CONFIG_FILE),
|
|
389
|
+
// Read the agent instruction files so the rule merge stays idempotent across re-runs — from the
|
|
390
|
+
// agent's own root, or the merge would idempotently check a file it is not going to write.
|
|
391
|
+
claudeMdContent: io.readFile(agentFile('CLAUDE.md')),
|
|
392
|
+
agentsMdContent: io.readFile(agentFile('AGENTS.md')),
|
|
393
|
+
reticleMdContent: io.readFile(agentFile(RETICLE_MD_PATH)),
|
|
394
|
+
cursorRuleContent: io.readFile(agentFile(CURSOR_RULE_PATH)),
|
|
395
|
+
claudeCommandContent: io.readFile(agentFile(CLAUDE_COMMAND_PATH)),
|
|
396
|
+
cursorCommandContent: io.readFile(agentFile(CURSOR_COMMAND_PATH)),
|
|
397
|
+
// Read only to MERGE into: the settings file is the user's, and other tools write in it too.
|
|
398
|
+
claudeSettingsContent: io.readFile(agentFile(CLAUDE_SETTINGS_PATH)),
|
|
399
|
+
...(agentRoot === undefined
|
|
400
|
+
? {}
|
|
401
|
+
: {
|
|
402
|
+
agentFileRoot: agentRoot,
|
|
403
|
+
// The config the AGENT's cwd already has, if any — see agentRootConfigStep.
|
|
404
|
+
agentRootConfigSource: io.readFile(agentFile(RETICLE_CONFIG_FILE)),
|
|
405
|
+
agentRootSeveralApps: true === options.severalApps,
|
|
406
|
+
}),
|
|
407
|
+
options: {
|
|
408
|
+
port: options.port,
|
|
409
|
+
mcp: options.mcp,
|
|
410
|
+
install: options.install,
|
|
411
|
+
projectId,
|
|
412
|
+
// The SDK must match the CLI asking for it — see pinnedPackages.
|
|
413
|
+
sdkVersion: RETICLE_VERSION,
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|