@clue-ai/cli 0.0.9 → 0.0.11
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/README.md +37 -26
- package/bin/clue-cli.mjs +93 -54
- package/package.json +1 -1
- package/src/cli-invocation.mjs +34 -0
- package/src/command-spec.mjs +3 -0
- package/src/init-tool.mjs +2 -1
- package/src/lifecycle-guard.mjs +168 -103
- package/src/lifecycle-init.mjs +593 -187
- package/src/semantic-agent-runner.mjs +3 -1
- package/src/setup-check.mjs +641 -47
- package/src/setup-help.mjs +69 -0
- package/src/setup-prepare.mjs +78 -15
- package/src/setup-tool.mjs +421 -388
package/src/lifecycle-init.mjs
CHANGED
|
@@ -1,229 +1,635 @@
|
|
|
1
1
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { callJsonAiProvider, resolveAiProviderConfig } from "./ai-provider.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
extractExecutableModuleStatements,
|
|
6
|
+
findLifecycleCallApiNames,
|
|
7
|
+
findLifecycleGuardViolations,
|
|
8
|
+
stripSourceNoise,
|
|
9
|
+
} from "./lifecycle-guard.mjs";
|
|
5
10
|
import { listAllowedSourceFiles } from "./path-policy.mjs";
|
|
6
11
|
|
|
7
12
|
const API_NAMES = new Set([
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
"ClueInit",
|
|
14
|
+
"ClueIdentify",
|
|
15
|
+
"ClueSetAccount",
|
|
16
|
+
"ClueLogout",
|
|
12
17
|
]);
|
|
13
18
|
|
|
14
19
|
const SOURCE_EXTENSIONS = [".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
15
20
|
const MAX_CONTEXT_FILES = 180;
|
|
16
21
|
const MAX_FILE_CHARS = 12_000;
|
|
17
22
|
const MAX_TOTAL_CHARS = 360_000;
|
|
23
|
+
const FRONTEND_SDK_PACKAGE = "@clue-ai/browser-sdk";
|
|
24
|
+
const WRONG_FRONTEND_SDK_PACKAGES = ["clue-js-sdk", "@clue/browser-sdk"];
|
|
25
|
+
const CLUE_SETUP_ADDITION_PATTERN =
|
|
26
|
+
/\b(?:ClueInit|ClueIdentify|ClueSetAccount|ClueLogout|clue_init_fastapi|clue_init_django|clue-fastapi-sdk|clue-django-sdk|CLUE_[A-Z0-9_]+|browserTokenProvider|clue[-_])|@clue-ai\/browser-sdk/i;
|
|
18
27
|
|
|
19
28
|
const nonEmpty = (value, field) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
30
|
+
throw new Error(`${field} is required`);
|
|
31
|
+
}
|
|
32
|
+
return value.trim();
|
|
24
33
|
};
|
|
25
34
|
|
|
26
35
|
const safeRelativePath = (repoRoot, filePath) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
const root = resolve(repoRoot);
|
|
37
|
+
const absolutePath = resolve(root, filePath);
|
|
38
|
+
const relativePath = relative(root, absolutePath);
|
|
39
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
40
|
+
throw new Error(`edit path escapes repo root: ${filePath}`);
|
|
41
|
+
}
|
|
42
|
+
return { absolutePath, relativePath };
|
|
34
43
|
};
|
|
35
44
|
|
|
36
45
|
const assertApiName = (apiName) => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
if (!API_NAMES.has(apiName)) {
|
|
47
|
+
throw new Error(`unsupported lifecycle API: ${apiName}`);
|
|
48
|
+
}
|
|
49
|
+
return apiName;
|
|
41
50
|
};
|
|
42
51
|
|
|
43
52
|
const assertNoForbiddenInstrumentation = (replacement) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
if (replacement.includes("ClueTrack")) {
|
|
54
|
+
throw new Error("init tool must not add broad ClueTrack instrumentation");
|
|
55
|
+
}
|
|
56
|
+
if (/data-clue-(id|key)/i.test(replacement)) {
|
|
57
|
+
throw new Error("init tool must not add data-clue-id or data-clue-key");
|
|
58
|
+
}
|
|
59
|
+
const wrongSdkPackage = WRONG_FRONTEND_SDK_PACKAGES.find((packageName) =>
|
|
60
|
+
replacement.includes(packageName),
|
|
61
|
+
);
|
|
62
|
+
if (wrongSdkPackage) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`init tool must not use wrong frontend SDK package: ${wrongSdkPackage}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
50
67
|
};
|
|
51
68
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
const assertLifecycleCallsAreNonBlocking = (replacement) => {
|
|
70
|
+
const violations = findLifecycleGuardViolations(replacement);
|
|
71
|
+
if (violations.length > 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Clue lifecycle calls must not be awaited or block host service behavior: ${JSON.stringify(violations)}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const assertClueSetupOnlyEdit = (edit) => {
|
|
79
|
+
if (!edit.replace.includes(edit.find)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Clue lifecycle edits must be additive and preserve existing source in ${edit.file_path}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const addedText = edit.replace.split(edit.find).join("");
|
|
85
|
+
if (!CLUE_SETUP_ADDITION_PATTERN.test(addedText)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Clue lifecycle edits must add only Clue setup related code in ${edit.file_path}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
59
90
|
};
|
|
60
91
|
|
|
61
92
|
const normalizeLifecycleInsertion = (input) => ({
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
api_name: assertApiName(nonEmpty(input.api_name, "api_name")),
|
|
94
|
+
file_path: nonEmpty(input.file_path, "file_path"),
|
|
95
|
+
confidence: Math.max(0, Math.min(1, Number(input.confidence ?? 0))),
|
|
96
|
+
reason: nonEmpty(input.reason, "reason"),
|
|
66
97
|
});
|
|
67
98
|
|
|
99
|
+
const normalizeBlocker = (input) => {
|
|
100
|
+
if (typeof input === "string") {
|
|
101
|
+
return { reason: nonEmpty(input, "blocker.reason") };
|
|
102
|
+
}
|
|
103
|
+
if (!input || typeof input !== "object") {
|
|
104
|
+
throw new Error("blocker must be a string or object");
|
|
105
|
+
}
|
|
106
|
+
const blocker = {
|
|
107
|
+
reason: nonEmpty(input.reason, "blocker.reason"),
|
|
108
|
+
};
|
|
109
|
+
if (typeof input.evidence === "string" && input.evidence.trim()) {
|
|
110
|
+
blocker.evidence = input.evidence.trim();
|
|
111
|
+
}
|
|
112
|
+
return blocker;
|
|
113
|
+
};
|
|
114
|
+
|
|
68
115
|
const normalizePlan = (input) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
116
|
+
if (!input || typeof input !== "object") {
|
|
117
|
+
throw new Error("AI lifecycle plan must be an object");
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
input.status !== undefined &&
|
|
121
|
+
input.status !== "ready" &&
|
|
122
|
+
input.status !== "blocked"
|
|
123
|
+
) {
|
|
124
|
+
throw new Error("AI lifecycle plan status must be ready or blocked");
|
|
125
|
+
}
|
|
126
|
+
const status = input.status === "blocked" ? "blocked" : "ready";
|
|
127
|
+
if (!Array.isArray(input.edits)) {
|
|
128
|
+
throw new Error("AI lifecycle plan must include edits");
|
|
129
|
+
}
|
|
130
|
+
const edits = input.edits.map((edit) => ({
|
|
131
|
+
file_path: nonEmpty(edit.file_path, "edit.file_path"),
|
|
132
|
+
find: nonEmpty(edit.find, "edit.find"),
|
|
133
|
+
replace: nonEmpty(edit.replace, "edit.replace"),
|
|
134
|
+
}));
|
|
135
|
+
const lifecycleInsertions = Array.isArray(input.lifecycle_insertions)
|
|
136
|
+
? input.lifecycle_insertions.map(normalizeLifecycleInsertion)
|
|
137
|
+
: [];
|
|
138
|
+
const blockers = Array.isArray(input.blockers)
|
|
139
|
+
? input.blockers.map(normalizeBlocker)
|
|
140
|
+
: [];
|
|
141
|
+
if (status === "blocked") {
|
|
142
|
+
if (edits.length > 0 || lifecycleInsertions.length > 0) {
|
|
143
|
+
throw new Error("blocked lifecycle plan must not include edits");
|
|
144
|
+
}
|
|
145
|
+
if (blockers.length === 0) {
|
|
146
|
+
throw new Error("blocked lifecycle plan must include blockers");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (status === "ready" && blockers.length > 0) {
|
|
150
|
+
throw new Error("ready lifecycle plan must not include blockers");
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
status,
|
|
154
|
+
edits,
|
|
155
|
+
lifecycleInsertions,
|
|
156
|
+
blockers,
|
|
157
|
+
warnings: Array.isArray(input.warnings)
|
|
158
|
+
? input.warnings
|
|
159
|
+
.filter((warning) => typeof warning === "string" && warning.trim())
|
|
160
|
+
.map((warning) => warning.trim())
|
|
161
|
+
: [],
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
166
|
+
|
|
167
|
+
const sourceImportsFrontendSdk = (text) =>
|
|
168
|
+
extractExecutableModuleStatements(text).some((statement) =>
|
|
169
|
+
new RegExp(
|
|
170
|
+
`(?:from\\s*["']${escapeRegex(FRONTEND_SDK_PACKAGE)}["']|import\\s*["']${escapeRegex(FRONTEND_SDK_PACKAGE)}["'])`,
|
|
171
|
+
).test(statement),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const parseNamedSpecifiers = (specifiers) =>
|
|
175
|
+
specifiers
|
|
176
|
+
.split(",")
|
|
177
|
+
.map((specifier) => specifier.trim())
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
.map((specifier) => {
|
|
180
|
+
const match = /^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/.exec(
|
|
181
|
+
specifier,
|
|
182
|
+
);
|
|
183
|
+
if (!match) return null;
|
|
184
|
+
return {
|
|
185
|
+
imported: match[1],
|
|
186
|
+
local: match[2] ?? match[1],
|
|
187
|
+
exported: match[2] ?? match[1],
|
|
188
|
+
};
|
|
189
|
+
})
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
|
|
192
|
+
const sourceDirectlyProvidesApiFromFrontendSdk = (text, apiName) => {
|
|
193
|
+
const statements = extractExecutableModuleStatements(text).join("\n");
|
|
194
|
+
for (const match of statements.matchAll(
|
|
195
|
+
/import\s*{([^}]+)}\s*from\s*["']([^"']+)["']/g,
|
|
196
|
+
)) {
|
|
197
|
+
if (match[2] !== FRONTEND_SDK_PACKAGE) continue;
|
|
198
|
+
if (
|
|
199
|
+
parseNamedSpecifiers(match[1]).some(
|
|
200
|
+
(specifier) =>
|
|
201
|
+
specifier.imported === apiName && specifier.local === apiName,
|
|
202
|
+
)
|
|
203
|
+
) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const match of statements.matchAll(
|
|
208
|
+
/export\s*{([^}]+)}\s*from\s*["']([^"']+)["']/g,
|
|
209
|
+
)) {
|
|
210
|
+
if (match[2] !== FRONTEND_SDK_PACKAGE) continue;
|
|
211
|
+
if (
|
|
212
|
+
parseNamedSpecifiers(match[1]).some(
|
|
213
|
+
(specifier) =>
|
|
214
|
+
specifier.imported === apiName && specifier.exported === apiName,
|
|
215
|
+
)
|
|
216
|
+
) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const frontendSdkImportedLocalsForApi = (text, apiName) => {
|
|
224
|
+
const locals = [];
|
|
225
|
+
for (const match of extractExecutableModuleStatements(text)
|
|
226
|
+
.join("\n")
|
|
227
|
+
.matchAll(/import\s*{([^}]+)}\s*from\s*["']([^"']+)["']/g)) {
|
|
228
|
+
if (match[2] !== FRONTEND_SDK_PACKAGE) continue;
|
|
229
|
+
for (const specifier of parseNamedSpecifiers(match[1])) {
|
|
230
|
+
if (specifier.imported === apiName) {
|
|
231
|
+
locals.push(specifier.local);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return locals;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const frontendSdkNamespaces = (text) =>
|
|
239
|
+
[
|
|
240
|
+
...extractExecutableModuleStatements(text)
|
|
241
|
+
.join("\n")
|
|
242
|
+
.matchAll(
|
|
243
|
+
/import\s*\*\s*as\s*([A-Za-z_$][\w$]*)\s*from\s*["']([^"']+)["']/g,
|
|
244
|
+
),
|
|
245
|
+
]
|
|
246
|
+
.filter((match) => match[2] === FRONTEND_SDK_PACKAGE)
|
|
247
|
+
.map((match) => match[1]);
|
|
248
|
+
|
|
249
|
+
const sourceExportsLocalAsApi = (text, localName, apiName) => {
|
|
250
|
+
const statements = extractExecutableModuleStatements(text).join("\n");
|
|
251
|
+
for (const match of statements.matchAll(
|
|
252
|
+
/export\s*{([^}]+)}(?:\s*from\s*["'][^"']+["'])?/g,
|
|
253
|
+
)) {
|
|
254
|
+
if (match[0].includes(" from ")) continue;
|
|
255
|
+
if (
|
|
256
|
+
parseNamedSpecifiers(match[1]).some(
|
|
257
|
+
(specifier) =>
|
|
258
|
+
specifier.imported === localName && specifier.exported === apiName,
|
|
259
|
+
)
|
|
260
|
+
) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return new RegExp(
|
|
265
|
+
`export\\s+const\\s+${escapeRegex(apiName)}\\s*=\\s*${escapeRegex(localName)}\\b`,
|
|
266
|
+
).test(statements);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const sourceForwardsFrontendSdkApi = (source, apiName) => {
|
|
270
|
+
const text = source.text;
|
|
271
|
+
if (sourceDirectlyProvidesApiFromFrontendSdk(text, apiName)) return true;
|
|
272
|
+
const importedLocals = frontendSdkImportedLocalsForApi(text, apiName);
|
|
273
|
+
if (
|
|
274
|
+
importedLocals.some((localName) =>
|
|
275
|
+
sourceExportsLocalAsApi(text, localName, apiName),
|
|
276
|
+
)
|
|
277
|
+
) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
return frontendSdkNamespaces(text).some((namespaceName) =>
|
|
281
|
+
new RegExp(
|
|
282
|
+
`export\\s+const\\s+${escapeRegex(apiName)}\\s*=\\s*${escapeRegex(namespaceName)}\\.${escapeRegex(apiName)}\\b`,
|
|
283
|
+
).test(text),
|
|
284
|
+
);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const localImportSpecifiersForApi = (text, apiName) =>
|
|
288
|
+
[
|
|
289
|
+
...extractExecutableModuleStatements(text)
|
|
290
|
+
.join("\n")
|
|
291
|
+
.matchAll(/import\s*{([^}]+)}\s*from\s*["']([^"']+)["']/g),
|
|
292
|
+
]
|
|
293
|
+
.filter((match) => match[2].startsWith(".") || match[2].startsWith("@/"))
|
|
294
|
+
.flatMap((match) =>
|
|
295
|
+
parseNamedSpecifiers(match[1])
|
|
296
|
+
.filter(
|
|
297
|
+
(specifier) =>
|
|
298
|
+
specifier.imported === apiName && specifier.local === apiName,
|
|
299
|
+
)
|
|
300
|
+
.map(() => match[2]),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const importSpecifiers = (text) =>
|
|
304
|
+
[
|
|
305
|
+
...extractExecutableModuleStatements(text)
|
|
306
|
+
.join("\n")
|
|
307
|
+
.matchAll(/import\s+(?:[\s\S]*?\s+from\s+)?["']([^"']+)["']/g),
|
|
308
|
+
]
|
|
309
|
+
.map((match) => match[1] ?? match[2])
|
|
310
|
+
.filter(Boolean);
|
|
311
|
+
|
|
312
|
+
const candidateSourcePaths = (basePath) => [
|
|
313
|
+
basePath,
|
|
314
|
+
...SOURCE_EXTENSIONS.map((extension) => `${basePath}${extension}`),
|
|
315
|
+
...SOURCE_EXTENSIONS.map((extension) => join(basePath, `index${extension}`)),
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
const localImportCandidatePaths = ({ importerPath, specifier }) => {
|
|
319
|
+
const candidates = [];
|
|
320
|
+
if (specifier.startsWith(".")) {
|
|
321
|
+
candidates.push(
|
|
322
|
+
...candidateSourcePaths(join(dirname(importerPath), specifier)),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (specifier.startsWith("@/")) {
|
|
326
|
+
const srcIndex = importerPath.lastIndexOf("/src/");
|
|
327
|
+
if (srcIndex >= 0) {
|
|
328
|
+
candidates.push(
|
|
329
|
+
...candidateSourcePaths(
|
|
330
|
+
join(
|
|
331
|
+
importerPath.slice(0, srcIndex + "/src".length),
|
|
332
|
+
specifier.slice(2),
|
|
333
|
+
),
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
for (const root of [
|
|
338
|
+
"src",
|
|
339
|
+
"frontend/src",
|
|
340
|
+
"apps/web/src",
|
|
341
|
+
"apps/admin/src",
|
|
342
|
+
"apps/visitor/src",
|
|
343
|
+
]) {
|
|
344
|
+
candidates.push(...candidateSourcePaths(join(root, specifier.slice(2))));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return candidates;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const resolveLocalSource = ({ importerPath, sourceByPath, specifier }) => {
|
|
351
|
+
const candidates = localImportCandidatePaths({ importerPath, specifier });
|
|
352
|
+
return candidates
|
|
353
|
+
.map((candidate) => sourceByPath.get(candidate))
|
|
354
|
+
.find(Boolean);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const fileLooksFrontend = (filePath) =>
|
|
358
|
+
/\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(filePath);
|
|
359
|
+
|
|
360
|
+
const hasVerifiedFrontendSdkAccess = ({ apiName, source, sourceByPath }) => {
|
|
361
|
+
if (sourceDirectlyProvidesApiFromFrontendSdk(source.text, apiName))
|
|
362
|
+
return true;
|
|
363
|
+
return localImportSpecifiersForApi(source.text, apiName).some((specifier) => {
|
|
364
|
+
const importedSource = resolveLocalSource({
|
|
365
|
+
importerPath: source.file_path,
|
|
366
|
+
sourceByPath,
|
|
367
|
+
specifier,
|
|
368
|
+
});
|
|
369
|
+
return importedSource
|
|
370
|
+
? sourceForwardsFrontendSdkApi(importedSource, apiName)
|
|
371
|
+
: false;
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const assertLifecycleInsertionEvidence = async ({
|
|
376
|
+
insertion,
|
|
377
|
+
source,
|
|
378
|
+
sourceByPath,
|
|
379
|
+
}) => {
|
|
380
|
+
const executableSource = stripSourceNoise(source.text, {
|
|
381
|
+
stripStrings: true,
|
|
382
|
+
});
|
|
383
|
+
if (
|
|
384
|
+
!findLifecycleCallApiNames(executableSource).includes(insertion.api_name)
|
|
385
|
+
) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`lifecycle insertion evidence missing ${insertion.api_name} call in ${insertion.file_path}`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const blocking = findLifecycleGuardViolations(executableSource).filter(
|
|
391
|
+
(violation) => violation.api_name === insertion.api_name,
|
|
392
|
+
);
|
|
393
|
+
if (blocking.length > 0) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
`lifecycle insertion evidence contains blocking ${insertion.api_name} call in ${insertion.file_path}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (
|
|
399
|
+
fileLooksFrontend(insertion.file_path) &&
|
|
400
|
+
!hasVerifiedFrontendSdkAccess({
|
|
401
|
+
apiName: insertion.api_name,
|
|
402
|
+
source,
|
|
403
|
+
sourceByPath,
|
|
404
|
+
})
|
|
405
|
+
) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`lifecycle insertion evidence for ${insertion.api_name} in ${insertion.file_path} does not resolve to ${FRONTEND_SDK_PACKAGE}`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const loadSourceIntoMap = async ({ repoRoot, sourceByPath, filePath }) => {
|
|
413
|
+
if (sourceByPath.has(filePath)) return;
|
|
414
|
+
const { absolutePath, relativePath } = safeRelativePath(repoRoot, filePath);
|
|
415
|
+
sourceByPath.set(relativePath, {
|
|
416
|
+
file_path: relativePath,
|
|
417
|
+
text: await readFile(absolutePath, "utf8"),
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const buildLifecycleEvidenceSourceMap = async ({
|
|
422
|
+
repoRoot,
|
|
423
|
+
plan,
|
|
424
|
+
sourceByPath = new Map(),
|
|
425
|
+
}) => {
|
|
426
|
+
for (const filePath of [
|
|
427
|
+
...new Set([
|
|
428
|
+
...plan.edits.map((edit) => edit.file_path),
|
|
429
|
+
...plan.lifecycleInsertions.map((insertion) => insertion.file_path),
|
|
430
|
+
]),
|
|
431
|
+
]) {
|
|
432
|
+
await loadSourceIntoMap({ repoRoot, sourceByPath, filePath });
|
|
433
|
+
}
|
|
434
|
+
for (const source of [...sourceByPath.values()]) {
|
|
435
|
+
for (const specifier of importSpecifiers(source.text)) {
|
|
436
|
+
for (const candidate of localImportCandidatePaths({
|
|
437
|
+
importerPath: source.file_path,
|
|
438
|
+
specifier,
|
|
439
|
+
})) {
|
|
440
|
+
try {
|
|
441
|
+
await loadSourceIntoMap({
|
|
442
|
+
repoRoot,
|
|
443
|
+
sourceByPath,
|
|
444
|
+
filePath: candidate,
|
|
445
|
+
});
|
|
446
|
+
break;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (error?.code !== "ENOENT") throw error;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return sourceByPath;
|
|
92
454
|
};
|
|
93
455
|
|
|
94
456
|
export const applyLifecyclePlan = async ({ repoRoot, plan: rawPlan }) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
457
|
+
const plan = normalizePlan(rawPlan);
|
|
458
|
+
if (plan.status === "blocked") {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`AI lifecycle plan blocked: ${plan.blockers
|
|
461
|
+
.map((blocker) =>
|
|
462
|
+
blocker.evidence
|
|
463
|
+
? `${blocker.reason} (${blocker.evidence})`
|
|
464
|
+
: blocker.reason,
|
|
465
|
+
)
|
|
466
|
+
.join("; ")}`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const sourceByPath = new Map();
|
|
470
|
+
const pendingWrites = new Map();
|
|
471
|
+
for (const edit of plan.edits) {
|
|
472
|
+
const { absolutePath, relativePath } = safeRelativePath(
|
|
473
|
+
repoRoot,
|
|
474
|
+
edit.file_path,
|
|
475
|
+
);
|
|
476
|
+
assertNoForbiddenInstrumentation(edit.replace);
|
|
477
|
+
const current =
|
|
478
|
+
sourceByPath.get(relativePath)?.text ??
|
|
479
|
+
(await readFile(absolutePath, "utf8"));
|
|
480
|
+
const occurrences = current.split(edit.find).length - 1;
|
|
481
|
+
if (occurrences !== 1) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`edit.find must match exactly once in ${edit.file_path}; matched ${occurrences}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
assertClueSetupOnlyEdit(edit);
|
|
487
|
+
assertLifecycleCallsAreNonBlocking(edit.replace);
|
|
488
|
+
const next = current.replace(edit.find, edit.replace);
|
|
489
|
+
sourceByPath.set(relativePath, {
|
|
490
|
+
file_path: relativePath,
|
|
491
|
+
text: next,
|
|
492
|
+
});
|
|
493
|
+
pendingWrites.set(relativePath, { absolutePath, text: next });
|
|
494
|
+
}
|
|
495
|
+
await buildLifecycleEvidenceSourceMap({ repoRoot, plan, sourceByPath });
|
|
496
|
+
for (const insertion of plan.lifecycleInsertions) {
|
|
497
|
+
const source = sourceByPath.get(insertion.file_path);
|
|
498
|
+
if (!source) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`lifecycle insertion evidence file missing: ${insertion.file_path}`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
await assertLifecycleInsertionEvidence({
|
|
504
|
+
insertion,
|
|
505
|
+
source,
|
|
506
|
+
sourceByPath,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
for (const write of pendingWrites.values()) {
|
|
510
|
+
await writeFile(write.absolutePath, write.text, "utf8");
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
lifecycleInsertions: plan.lifecycleInsertions,
|
|
514
|
+
warnings: plan.warnings,
|
|
515
|
+
};
|
|
117
516
|
};
|
|
118
517
|
|
|
119
518
|
const readContextFiles = async ({ repoRoot, request }) => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
519
|
+
const files = await listAllowedSourceFiles({
|
|
520
|
+
repoRoot,
|
|
521
|
+
allowedSourcePaths: request.allowed_source_paths,
|
|
522
|
+
excludedSourcePaths: request.excluded_source_paths,
|
|
523
|
+
extensions: SOURCE_EXTENSIONS,
|
|
524
|
+
});
|
|
525
|
+
const context = [];
|
|
526
|
+
let totalChars = 0;
|
|
527
|
+
for (const absolutePath of files.slice(0, MAX_CONTEXT_FILES)) {
|
|
528
|
+
const text = await readFile(absolutePath, "utf8");
|
|
529
|
+
const snippet = text.slice(0, MAX_FILE_CHARS);
|
|
530
|
+
totalChars += snippet.length;
|
|
531
|
+
if (totalChars > MAX_TOTAL_CHARS) {
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
context.push({
|
|
535
|
+
file_path: relative(resolve(repoRoot), absolutePath),
|
|
536
|
+
source: snippet,
|
|
537
|
+
truncated: text.length > snippet.length,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return context;
|
|
142
541
|
};
|
|
143
542
|
|
|
144
543
|
const buildLifecyclePrompt = ({ request, files }) =>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
544
|
+
JSON.stringify({
|
|
545
|
+
task: "Add Clue SDK lifecycle API calls to this repository using exact text replacements.",
|
|
546
|
+
rules: [
|
|
547
|
+
"Return JSON only.",
|
|
548
|
+
"Use only exact replacements. Each find string must be copied exactly from source.",
|
|
549
|
+
"Add ClueInit, ClueIdentify, ClueSetAccount, and ClueLogout where repository code has clear lifecycle points.",
|
|
550
|
+
"Official Clue SDK public lifecycle APIs are no-throw and own SDK failure isolation.",
|
|
551
|
+
"Do not add per-call try/catch, try/except, .catch, or custom safe wrappers solely around official Clue SDK public lifecycle calls.",
|
|
552
|
+
"Do not create no-op wrappers, placeholder lifecycle functions, fake SDK modules, window.Clue* shims, or helpers that swallow calls without forwarding them to a real Clue SDK import.",
|
|
553
|
+
"Custom repository adapters are allowed only when they demonstrably forward to the real Clue SDK.",
|
|
554
|
+
"Never await a Clue lifecycle call in a way that can block login, logout, account selection, request handling, page rendering, or API responses.",
|
|
555
|
+
"Find all clear login success paths and add ClueIdentify to every one of them. Do not stop after the first login flow.",
|
|
556
|
+
"Find all clear account, workspace, organization, or tenant resolution paths and add ClueSetAccount to every one of them.",
|
|
557
|
+
"Find all clear logout or session reset paths and add ClueLogout to every one of them.",
|
|
558
|
+
"Inspect backend lifecycle points as carefully as frontend lifecycle points. Backend login/session/account code is especially important.",
|
|
559
|
+
"For frontend code, add or use the real @clue-ai/browser-sdk dependency. Do not invent clue-js-sdk, @clue/browser-sdk, local SDK modules, global window.Clue APIs, or dynamic imports that hide a missing SDK.",
|
|
560
|
+
"For FastAPI backends, add the clue-fastapi-sdk dependency when missing, import clue_init_fastapi plus ClueIdentify/ClueSetAccount/ClueLogout where needed, and initialize the SDK at FastAPI app creation.",
|
|
561
|
+
"For Django backends, use clue-django-sdk only after dependency or registry verification confirms it is installable. If it cannot be verified, report a blocker instead of adding guessed imports or dependencies.",
|
|
562
|
+
"For other backend frameworks, treat SDK existence as unverified unless present in dependency files or verified through package-manager/official documentation. If no backend SDK exists or verification is impossible, report a blocker instead of silently frontend-only setup.",
|
|
563
|
+
"Do not add broad ClueTrack instrumentation.",
|
|
564
|
+
"Do not add data-clue-id, data-clue-key, or similar DOM tags.",
|
|
565
|
+
"Do not create route semantics files or layer files.",
|
|
566
|
+
"Do not copy project keys, API keys, service secrets, or environment-specific values into code.",
|
|
567
|
+
"Do not send raw email, raw person names, tokens, workspace names, organization names, or tenant names as lifecycle traits unless the repository already has an explicit Clue privacy policy allowing them.",
|
|
568
|
+
"Prefer stable ids and non-PII booleans/counts for ClueIdentify and ClueSetAccount traits.",
|
|
569
|
+
"Use environment variable names for Clue configuration values.",
|
|
570
|
+
"For Python/FastAPI code, read CLUE_PROJECT_KEY, CLUE_ENVIRONMENT, CLUE_API_KEY, CLUE_API_BASE_URL, and CLUE_INGEST_ENDPOINT from environment variables.",
|
|
571
|
+
"For browser code, read CLUE_PROJECT_KEY, CLUE_ENVIRONMENT, CLUE_SERVICE_KEY, and CLUE_INGEST_ENDPOINT from environment variables through the target framework's safe client config mechanism. Do not hard-code a Next.js-only prefix.",
|
|
572
|
+
"Never place CLUE_API_KEY in frontend code, frontend env files, browser bundles, or client-readable config.",
|
|
573
|
+
"When browser SDK ingest is configured, implement a backend-owned browser token endpoint that reads server-side CLUE_API_KEY and requests POST /api/v1/ingest/browser-tokens from Clue.",
|
|
574
|
+
"Configure frontend ClueInit with browserTokenProvider that calls the local backend token endpoint and returns the token string.",
|
|
575
|
+
"The browser token request must include project key, environment, service key, and the current browser origin; the backend must attach x-clue-api-key server-side when calling Clue.",
|
|
576
|
+
"Prefer minimal edits that engineers can review in one PR.",
|
|
577
|
+
"If a lifecycle point is unclear, skip that edit and include a warning.",
|
|
578
|
+
"Return status ready only when edits are safe to apply. Return status blocked when required SDKs or lifecycle points cannot be verified.",
|
|
579
|
+
"If status is blocked, return no edits and no lifecycle_insertions, and list blockers. Do not encode blockers as warnings.",
|
|
580
|
+
],
|
|
581
|
+
repository_context: {
|
|
582
|
+
target_tool: request.target_tool,
|
|
583
|
+
framework: request.framework,
|
|
584
|
+
project_key_env: "CLUE_PROJECT_KEY",
|
|
585
|
+
browser_project_key_env: "CLUE_PROJECT_KEY",
|
|
586
|
+
environment_env: "CLUE_ENVIRONMENT",
|
|
587
|
+
browser_environment_env: "CLUE_ENVIRONMENT",
|
|
588
|
+
clue_api_base_url_env: "CLUE_API_BASE_URL",
|
|
589
|
+
clue_ingest_endpoint_env: "CLUE_INGEST_ENDPOINT",
|
|
590
|
+
browser_ingest_endpoint_env: "CLUE_INGEST_ENDPOINT",
|
|
591
|
+
browser_token_endpoint_path: "/api/v1/ingest/browser-tokens",
|
|
592
|
+
service_key: request.service_key,
|
|
593
|
+
},
|
|
594
|
+
output_shape: {
|
|
595
|
+
status: "ready",
|
|
596
|
+
blockers: [],
|
|
597
|
+
edits: [
|
|
598
|
+
{
|
|
599
|
+
file_path: "app/main.py",
|
|
600
|
+
find: "exact original text",
|
|
601
|
+
replace: "exact replacement text",
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
lifecycle_insertions: [
|
|
605
|
+
{
|
|
606
|
+
api_name: "ClueInit",
|
|
607
|
+
file_path: "app/main.py",
|
|
608
|
+
confidence: 0.8,
|
|
609
|
+
reason: "SDK initialized where FastAPI app is created.",
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
warnings: ["short engineer review note"],
|
|
613
|
+
},
|
|
614
|
+
files,
|
|
615
|
+
});
|
|
210
616
|
|
|
211
617
|
export const planLifecycleInsertions = async ({ repoRoot, request, env }) => {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
618
|
+
const apiKey = env.CLUE_AI_PROVIDER_API_KEY;
|
|
619
|
+
if (!apiKey) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
"CLUE_AI_PROVIDER_API_KEY is required for lifecycle API insertion",
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
const files = await readContextFiles({ repoRoot, request });
|
|
625
|
+
return callJsonAiProvider({
|
|
626
|
+
config: resolveAiProviderConfig({ env, apiKey }),
|
|
627
|
+
system:
|
|
628
|
+
"You are a safe code-edit planner for Clue SDK initialization. Return schema-valid JSON only.",
|
|
629
|
+
user: buildLifecyclePrompt({ request, files }),
|
|
630
|
+
toolName: "return_lifecycle_plan",
|
|
631
|
+
toolDescription: "Return the Clue SDK lifecycle insertion plan.",
|
|
632
|
+
failureMessage: "AI provider failed during lifecycle planning",
|
|
633
|
+
emptyMessage: "AI provider returned empty lifecycle plan",
|
|
634
|
+
});
|
|
229
635
|
};
|