@clue-ai/cli 0.0.17 → 0.0.19

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/bin/clue-cli.mjs CHANGED
@@ -20,6 +20,7 @@ import { runSemanticCi, runSemanticInventory } from "../src/semantic-ci.mjs";
20
20
  import { runSetupCheck } from "../src/setup-check.mjs";
21
21
  import { runSetupDetect } from "../src/setup-detect.mjs";
22
22
  import { runSetupDoctor } from "../src/setup-doctor.mjs";
23
+ import { runSetupAgent } from "../src/setup-agent.mjs";
23
24
  import { buildAiSetupHelp } from "../src/setup-help.mjs";
24
25
  import { runSetupPrepare } from "../src/setup-prepare.mjs";
25
26
  import { installSetupSkills } from "../src/setup-tool.mjs";
@@ -850,6 +851,7 @@ const usage = () =>
850
851
  "Usage:",
851
852
  " /clue-init",
852
853
  ` ${clueCliCommand("setup --clue-api-key <key> --clue-api-base-url <url> --project-key <key> --environment dev --documents-url <url>")}`,
854
+ ` ${clueCliCommand("setup-agent --repo . [--ai-provider openai|anthropic --ai-provider-api-key <key> --ai-model <model>]")}`,
853
855
  ` ${clueCliCommand("setup-detect --repo .")}`,
854
856
  ` ${clueCliCommand("semantic-inventory --framework fastapi --backend-root-path backend --repo .")}`,
855
857
  ` ${clueCliCommand("semantic-agent-skills --output .clue/semantic-agent-skills.json")}`,
@@ -930,6 +932,19 @@ const main = async () => {
930
932
  return;
931
933
  }
932
934
 
935
+ if (command === "setup-agent") {
936
+ const report = await runSetupAgent({
937
+ env: process.env,
938
+ flags,
939
+ repoRoot,
940
+ });
941
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
942
+ if (report.status === "blocked") {
943
+ process.exitCode = 1;
944
+ }
945
+ return;
946
+ }
947
+
933
948
  if (command === "setup") {
934
949
  const report = await installSetupSkills({
935
950
  repoRoot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clue-ai/cli",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "clue-ai": "bin/clue-cli.mjs"
@@ -25,8 +25,7 @@ const providerBaseUrl = ({ provider, env = {}, request = {} }) => {
25
25
  };
26
26
 
27
27
  const providerModel = ({ env = {}, request = {} }) => {
28
- const configuredModel =
29
- env.CLUE_INIT_AI_MODEL || env.CLUE_AI_MODEL || request.ai_model;
28
+ const configuredModel = env.CLUE_AI_MODEL || request.ai_model;
30
29
  const model = String(configuredModel || "").trim();
31
30
  if (!model) {
32
31
  throw new Error("CLUE_AI_MODEL is required");
@@ -59,6 +58,27 @@ const parseOpenAiJson = async ({ response, emptyMessage }) => {
59
58
  return JSON.parse(content);
60
59
  };
61
60
 
61
+ const parseOpenAiStrictToolJson = async ({ response, toolName, emptyMessage }) => {
62
+ const body = await response.json();
63
+ const output = Array.isArray(body?.output) ? body.output : [];
64
+ const functionCalls = output.flatMap((entry) => {
65
+ if (entry?.type === "function_call") return [entry];
66
+ if (Array.isArray(entry?.content)) {
67
+ return entry.content.filter((item) => item?.type === "function_call");
68
+ }
69
+ return [];
70
+ });
71
+ const toolCall = functionCalls.find((entry) => entry?.name === toolName);
72
+ const rawArguments = toolCall?.arguments ?? toolCall?.input;
73
+ if (typeof rawArguments === "string" && rawArguments.trim()) {
74
+ return JSON.parse(rawArguments);
75
+ }
76
+ if (rawArguments && typeof rawArguments === "object") {
77
+ return rawArguments;
78
+ }
79
+ throw new Error(emptyMessage);
80
+ };
81
+
62
82
  const parseAnthropicJson = async ({ response, toolName, emptyMessage }) => {
63
83
  const body = await response.json();
64
84
  const toolUse = Array.isArray(body?.content)
@@ -84,6 +104,76 @@ const parseAnthropicJson = async ({ response, toolName, emptyMessage }) => {
84
104
  return JSON.parse(text);
85
105
  };
86
106
 
107
+ export const callStrictToolAiProvider = async ({
108
+ config,
109
+ system,
110
+ user,
111
+ toolName,
112
+ toolDescription,
113
+ parameters,
114
+ failureMessage = "AI provider failed",
115
+ emptyMessage = "AI provider returned empty tool call",
116
+ }) => {
117
+ const response =
118
+ config.provider === "anthropic"
119
+ ? await fetch(`${config.baseUrl}/messages`, {
120
+ method: "POST",
121
+ headers: {
122
+ "content-type": "application/json",
123
+ "x-api-key": config.apiKey,
124
+ "anthropic-version": "2023-06-01",
125
+ },
126
+ body: JSON.stringify({
127
+ model: config.model,
128
+ max_tokens: 4096,
129
+ temperature: 0,
130
+ system,
131
+ messages: [{ role: "user", content: user }],
132
+ tools: [
133
+ {
134
+ name: toolName,
135
+ description: toolDescription,
136
+ input_schema: parameters,
137
+ },
138
+ ],
139
+ tool_choice: { type: "tool", name: toolName },
140
+ }),
141
+ })
142
+ : await fetch(`${config.baseUrl}/responses`, {
143
+ method: "POST",
144
+ headers: {
145
+ "content-type": "application/json",
146
+ authorization: `Bearer ${config.apiKey}`,
147
+ },
148
+ body: JSON.stringify({
149
+ model: config.model,
150
+ input: [
151
+ { role: "system", content: system },
152
+ { role: "user", content: user },
153
+ ],
154
+ tools: [
155
+ {
156
+ type: "function",
157
+ name: toolName,
158
+ description: toolDescription,
159
+ strict: true,
160
+ parameters,
161
+ },
162
+ ],
163
+ tool_choice: { type: "function", name: toolName },
164
+ parallel_tool_calls: false,
165
+ temperature: 0,
166
+ }),
167
+ });
168
+
169
+ if (!response.ok) {
170
+ throw new Error(`${failureMessage}: ${response.status}`);
171
+ }
172
+ return config.provider === "anthropic"
173
+ ? parseAnthropicJson({ response, toolName, emptyMessage })
174
+ : parseOpenAiStrictToolJson({ response, toolName, emptyMessage });
175
+ };
176
+
87
177
  export const callJsonAiProvider = async ({
88
178
  config,
89
179
  system,
@@ -11,6 +11,7 @@ const ROUTE_DECORATOR = new RegExp(
11
11
  const API_ROUTE_DECORATOR = /@(?<router>[A-Za-z_][A-Za-z0-9_]*)\.api_route\(\s*["'](?<path>[^"']+)["'][\s\S]*?methods\s*=\s*\[(?<methods>[^\]]+)\]/gi;
12
12
  const FUNCTION_PATTERN = /(?:async\s+def|def)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
13
13
  const ROUTER_ASSIGNMENT = /\b(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*APIRouter\s*\(/g;
14
+ const ROUTER_ALIAS_ASSIGNMENT = /^\s*(?<alias>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?<owner>[A-Za-z_][A-Za-z0-9_]*)\.(?<routerName>[A-Za-z_][A-Za-z0-9_]*)\s*$/gm;
14
15
  const INCLUDE_ROUTER_CALL = /\b(?<owner>[A-Za-z_][A-Za-z0-9_]*)\.include_router\s*\(/g;
15
16
  const FROM_IMPORT = /^\s*from\s+(?<module>[A-Za-z0-9_.]+|\.+[A-Za-z0-9_.]*)\s+import\s+(?<names>[A-Za-z0-9_,\s]+)$/gm;
16
17
  const IMPORT_MODULE = /^\s*import\s+(?<modules>[A-Za-z0-9_.,\s]+)$/gm;
@@ -214,9 +215,35 @@ const parseImports = ({ source, currentRelativePath, filesByRelativePath }) => {
214
215
 
215
216
  const routerKey = (file, routerName) => `${file}::${routerName}`;
216
217
 
217
- const resolveRouterTarget = ({ target, relativePath, imports }) => {
218
+ const parseRouterAliases = ({ source, relativePath, imports }) => {
219
+ const aliases = new Map();
220
+ for (const match of source.matchAll(ROUTER_ALIAS_ASSIGNMENT)) {
221
+ const importedOwner = imports.get(match.groups.owner);
222
+ if (importedOwner?.file && !importedOwner.name) {
223
+ aliases.set(match.groups.alias, {
224
+ file: importedOwner.file,
225
+ name: match.groups.routerName,
226
+ });
227
+ continue;
228
+ }
229
+ aliases.set(match.groups.alias, {
230
+ file: relativePath,
231
+ name: match.groups.routerName,
232
+ });
233
+ }
234
+ return aliases;
235
+ };
236
+
237
+ const resolveRouterTarget = ({ target, relativePath, imports, routerAliases }) => {
218
238
  const parts = target.split(".");
219
239
  if (parts.length === 1) {
240
+ const aliased = routerAliases.get(target);
241
+ if (aliased) {
242
+ return {
243
+ file: aliased.file,
244
+ routerName: aliased.name,
245
+ };
246
+ }
220
247
  const imported = imports.get(target);
221
248
  return {
222
249
  file: imported?.file ?? relativePath,
@@ -236,7 +263,7 @@ const resolveRouterTarget = ({ target, relativePath, imports }) => {
236
263
  };
237
264
  };
238
265
 
239
- const parseIncludeRouters = ({ source, relativePath, imports }) => {
266
+ const parseIncludeRouters = ({ source, relativePath, imports, routerAliases }) => {
240
267
  const includes = [];
241
268
  for (const match of source.matchAll(INCLUDE_ROUTER_CALL)) {
242
269
  const openParenIndex = match.index + match[0].length - 1;
@@ -249,6 +276,7 @@ const parseIncludeRouters = ({ source, relativePath, imports }) => {
249
276
  target: routerMatch.groups.router,
250
277
  relativePath,
251
278
  imports,
279
+ routerAliases,
252
280
  });
253
281
  includes.push({
254
282
  ownerName: match.groups.owner,
@@ -359,6 +387,11 @@ export const analyzeFastApiRoutes = async ({ repoRoot, files }) => {
359
387
  currentRelativePath: record.relativePath,
360
388
  filesByRelativePath,
361
389
  });
390
+ record.routerAliases = parseRouterAliases({
391
+ source: record.source,
392
+ relativePath: record.relativePath,
393
+ imports: record.imports,
394
+ });
362
395
  }
363
396
 
364
397
  const routerPrefixesByKey = new Map();
@@ -375,6 +408,7 @@ export const analyzeFastApiRoutes = async ({ repoRoot, files }) => {
375
408
  source: record.source,
376
409
  relativePath: record.relativePath,
377
410
  imports: record.imports,
411
+ routerAliases: record.routerAliases,
378
412
  })) {
379
413
  const parentKey = record.routerPrefixes.has(include.ownerName)
380
414
  ? routerKey(record.relativePath, include.ownerName)
@@ -1,6 +1,10 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
- import { callJsonAiProvider, resolveAiProviderConfig } from "./ai-provider.mjs";
3
+ import {
4
+ callJsonAiProvider,
5
+ callStrictToolAiProvider,
6
+ resolveAiProviderConfig,
7
+ } from "./ai-provider.mjs";
4
8
  import {
5
9
  extractExecutableModuleStatements,
6
10
  findLifecycleCallApiNames,
@@ -22,6 +26,13 @@ const API_NAMES = new Set([
22
26
  ]);
23
27
 
24
28
  const SOURCE_EXTENSIONS = [".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
29
+ const DEPENDENCY_CONTEXT_FILE_CANDIDATES = [
30
+ "package.json",
31
+ "requirements.txt",
32
+ "requirements-dev.txt",
33
+ "pyproject.toml",
34
+ "Pipfile",
35
+ ];
25
36
  const MAX_CONTEXT_FILES = 180;
26
37
  const MAX_FILE_CHARS = 12_000;
27
38
  const MAX_TOTAL_CHARS = 360_000;
@@ -29,6 +40,71 @@ const FRONTEND_SDK_PACKAGE = "@clue-ai/browser-sdk";
29
40
  const WRONG_FRONTEND_SDK_PACKAGES = ["clue-js-sdk", "@clue/browser-sdk"];
30
41
  const CLUE_SETUP_ADDITION_PATTERN =
31
42
  /\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;
43
+ const LIFECYCLE_PLAN_TOOL_SCHEMA = {
44
+ type: "object",
45
+ properties: {
46
+ status: { type: "string", enum: ["ready", "blocked"] },
47
+ edits: {
48
+ type: "array",
49
+ items: {
50
+ type: "object",
51
+ properties: {
52
+ file_path: { type: "string" },
53
+ find: { type: "string" },
54
+ replace: { type: "string" },
55
+ },
56
+ required: ["file_path", "find", "replace"],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ lifecycle_insertions: {
61
+ type: "array",
62
+ items: {
63
+ type: "object",
64
+ properties: {
65
+ api_name: {
66
+ type: "string",
67
+ enum: [
68
+ "ClueInit",
69
+ "ClueIdentify",
70
+ "ClueSetAccount",
71
+ "ClueLogout",
72
+ ],
73
+ },
74
+ file_path: { type: "string" },
75
+ confidence: { type: "number" },
76
+ reason: { type: "string" },
77
+ },
78
+ required: ["api_name", "file_path", "confidence", "reason"],
79
+ additionalProperties: false,
80
+ },
81
+ },
82
+ blockers: {
83
+ type: "array",
84
+ items: {
85
+ type: "object",
86
+ properties: {
87
+ reason: { type: "string" },
88
+ evidence: { type: ["string", "null"] },
89
+ },
90
+ required: ["reason", "evidence"],
91
+ additionalProperties: false,
92
+ },
93
+ },
94
+ warnings: {
95
+ type: "array",
96
+ items: { type: "string" },
97
+ },
98
+ },
99
+ required: [
100
+ "status",
101
+ "edits",
102
+ "lifecycle_insertions",
103
+ "blockers",
104
+ "warnings",
105
+ ],
106
+ additionalProperties: false,
107
+ };
32
108
 
33
109
  const nonEmpty = (value, field) => {
34
110
  if (typeof value !== "string" || value.trim() === "") {
@@ -47,6 +123,33 @@ const safeRelativePath = (repoRoot, filePath) => {
47
123
  return { absolutePath, relativePath };
48
124
  };
49
125
 
126
+ const startsWithAllowedPath = (filePath, allowedPath) => {
127
+ const normalizedFilePath = filePath.replace(/\\/g, "/").replace(/^\/+/, "");
128
+ const normalizedAllowedPath = String(allowedPath ?? "")
129
+ .replace(/\\/g, "/")
130
+ .replace(/^\/+/, "")
131
+ .replace(/\/+$/, "");
132
+ return (
133
+ normalizedAllowedPath !== "" &&
134
+ (normalizedFilePath === normalizedAllowedPath ||
135
+ normalizedFilePath.startsWith(`${normalizedAllowedPath}/`))
136
+ );
137
+ };
138
+
139
+ const assertAllowedWritePath = ({ allowedWritePaths, filePath }) => {
140
+ if (!Array.isArray(allowedWritePaths) || allowedWritePaths.length === 0) {
141
+ return;
142
+ }
143
+ if (
144
+ allowedWritePaths.some((allowedPath) =>
145
+ startsWithAllowedPath(filePath, allowedPath),
146
+ )
147
+ ) {
148
+ return;
149
+ }
150
+ throw new Error(`setup-agent cannot edit outside allowed setup paths: ${filePath}`);
151
+ };
152
+
50
153
  const assertApiName = (apiName) => {
51
154
  if (!API_NAMES.has(apiName)) {
52
155
  throw new Error(`unsupported lifecycle API: ${apiName}`);
@@ -458,7 +561,11 @@ const buildLifecycleEvidenceSourceMap = async ({
458
561
  return sourceByPath;
459
562
  };
460
563
 
461
- export const applyLifecyclePlan = async ({ repoRoot, plan: rawPlan }) => {
564
+ export const applyLifecyclePlan = async ({
565
+ repoRoot,
566
+ plan: rawPlan,
567
+ allowedWritePaths,
568
+ }) => {
462
569
  const plan = normalizePlan(rawPlan);
463
570
  if (plan.status === "blocked") {
464
571
  throw new Error(
@@ -478,6 +585,7 @@ export const applyLifecyclePlan = async ({ repoRoot, plan: rawPlan }) => {
478
585
  repoRoot,
479
586
  edit.file_path,
480
587
  );
588
+ assertAllowedWritePath({ allowedWritePaths, filePath: relativePath });
481
589
  assertNoForbiddenInstrumentation(edit.replace);
482
590
  const current =
483
591
  sourceByPath.get(relativePath)?.text ??
@@ -499,6 +607,10 @@ export const applyLifecyclePlan = async ({ repoRoot, plan: rawPlan }) => {
499
607
  }
500
608
  await buildLifecycleEvidenceSourceMap({ repoRoot, plan, sourceByPath });
501
609
  for (const insertion of plan.lifecycleInsertions) {
610
+ assertAllowedWritePath({
611
+ allowedWritePaths,
612
+ filePath: insertion.file_path,
613
+ });
502
614
  const source = sourceByPath.get(insertion.file_path);
503
615
  if (!source) {
504
616
  throw new Error(
@@ -520,6 +632,42 @@ export const applyLifecyclePlan = async ({ repoRoot, plan: rawPlan }) => {
520
632
  };
521
633
  };
522
634
 
635
+ const dependencyContextCandidatePaths = (allowedSourcePaths) => [
636
+ ...DEPENDENCY_CONTEXT_FILE_CANDIDATES,
637
+ ...allowedSourcePaths.flatMap((root) => {
638
+ const normalized = root.replace(/\/+$/, "");
639
+ const roots =
640
+ normalized.endsWith("/src") || normalized.endsWith("/app")
641
+ ? [normalized, dirname(normalized)]
642
+ : [normalized];
643
+ return roots.flatMap((candidateRoot) =>
644
+ DEPENDENCY_CONTEXT_FILE_CANDIDATES.map((file) =>
645
+ join(candidateRoot, file),
646
+ ),
647
+ );
648
+ }),
649
+ ];
650
+
651
+ const addContextFile = async ({
652
+ absolutePath,
653
+ context,
654
+ repoRoot,
655
+ totalChars,
656
+ }) => {
657
+ const text = await readFile(absolutePath, "utf8");
658
+ const snippet = text.slice(0, MAX_FILE_CHARS);
659
+ const nextTotalChars = totalChars + snippet.length;
660
+ if (nextTotalChars > MAX_TOTAL_CHARS) {
661
+ return { totalChars, added: false };
662
+ }
663
+ context.push({
664
+ file_path: relative(resolve(repoRoot), absolutePath),
665
+ source: snippet,
666
+ truncated: text.length > snippet.length,
667
+ });
668
+ return { totalChars: nextTotalChars, added: true };
669
+ };
670
+
523
671
  const readContextFiles = async ({ repoRoot, request }) => {
524
672
  const files = await listAllowedSourceFiles({
525
673
  repoRoot,
@@ -530,17 +678,38 @@ const readContextFiles = async ({ repoRoot, request }) => {
530
678
  const context = [];
531
679
  let totalChars = 0;
532
680
  for (const absolutePath of files.slice(0, MAX_CONTEXT_FILES)) {
533
- const text = await readFile(absolutePath, "utf8");
534
- const snippet = text.slice(0, MAX_FILE_CHARS);
535
- totalChars += snippet.length;
536
- if (totalChars > MAX_TOTAL_CHARS) {
681
+ const result = await addContextFile({
682
+ absolutePath,
683
+ context,
684
+ repoRoot,
685
+ totalChars,
686
+ });
687
+ totalChars = result.totalChars;
688
+ if (!result.added) {
537
689
  break;
538
690
  }
539
- context.push({
540
- file_path: relative(resolve(repoRoot), absolutePath),
541
- source: snippet,
542
- truncated: text.length > snippet.length,
543
- });
691
+ }
692
+ const seen = new Set(context.map((entry) => entry.file_path));
693
+ for (const candidatePath of dependencyContextCandidatePaths(
694
+ request.allowed_source_paths,
695
+ )) {
696
+ const { absolutePath, relativePath } = safeRelativePath(
697
+ repoRoot,
698
+ candidatePath,
699
+ );
700
+ if (seen.has(relativePath)) continue;
701
+ try {
702
+ const result = await addContextFile({
703
+ absolutePath,
704
+ context,
705
+ repoRoot,
706
+ totalChars,
707
+ });
708
+ totalChars = result.totalChars;
709
+ if (result.added) seen.add(relativePath);
710
+ } catch (error) {
711
+ if (error?.code !== "ENOENT") throw error;
712
+ }
544
713
  }
545
714
  return context;
546
715
  };
@@ -567,8 +736,8 @@ const buildLifecyclePrompt = ({ request, files }) =>
567
736
  "Find all clear account, workspace, organization, or tenant resolution paths and add ClueSetAccount to every one of them.",
568
737
  "Find all clear logout or session reset paths and add ClueLogout to every one of them.",
569
738
  "Inspect backend lifecycle points as carefully as frontend lifecycle points. Backend login/session/account code is especially important.",
570
- "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.",
571
- "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.",
739
+ "For frontend code, add or use the real @clue-ai/browser-sdk dependency through the latest channel (@clue-ai/browser-sdk@latest). Do not invent clue-js-sdk, @clue/browser-sdk, local SDK modules, global window.Clue APIs, or dynamic imports that hide a missing SDK.",
740
+ "For FastAPI backends, add the unpinned clue-fastapi-sdk dependency when missing so pip resolves the latest release, import clue_init_fastapi plus ClueIdentify/ClueSetAccount/ClueLogout where needed, and initialize the SDK at FastAPI app creation.",
572
741
  "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.",
573
742
  "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.",
574
743
  "Do not add broad ClueTrack instrumentation.",
@@ -580,20 +749,25 @@ const buildLifecyclePrompt = ({ request, files }) =>
580
749
  "Use environment variable names for Clue configuration values.",
581
750
  "For Python/FastAPI code, read CLUE_PROJECT_KEY, CLUE_ENVIRONMENT, CLUE_API_KEY, and CLUE_INGEST_ENDPOINT from the backend env block.",
582
751
  "CLUE_API_BASE_URL is not part of backend SDK initialization. Use it only when the application backend owns a browser-token proxy for a frontend service.",
583
- "For Next.js browser/client code, read NEXT_PUBLIC_CLUE_PROJECT_KEY, NEXT_PUBLIC_CLUE_ENVIRONMENT, NEXT_PUBLIC_CLUE_SERVICE_KEY, and NEXT_PUBLIC_CLUE_INGEST_ENDPOINT from the frontend .env.local block.",
752
+ "For Next.js browser/client code, read NEXT_PUBLIC_CLUE_PROJECT_KEY, NEXT_PUBLIC_CLUE_ENVIRONMENT, NEXT_PUBLIC_CLUE_SERVICE_KEY, NEXT_PUBLIC_CLUE_INGEST_ENDPOINT, and NEXT_PUBLIC_CLUE_BROWSER_TOKEN_ENDPOINT from the frontend .env.local block.",
584
753
  "Do not read process.env.CLUE_PROJECT_KEY, process.env.CLUE_ENVIRONMENT, process.env.CLUE_SERVICE_KEY, or process.env.CLUE_INGEST_ENDPOINT in Next.js browser/client code, and do not add non-public CLUE_* fallbacks there.",
754
+ "Frontend SDK adapter code is contract-owned Clue setup wiring. The AI may choose the existing import/mount point, but must not invent token URL, env, or initialization semantics.",
755
+ "For Next.js frontend adapters, read the full customer-backend browser-token proxy URL from NEXT_PUBLIC_CLUE_BROWSER_TOKEN_ENDPOINT. Do not derive it from NEXT_PUBLIC_API_URL, generic app API env names, detected backend ports, or relative frontend-origin paths.",
756
+ "Do not mix stale browser-token paths such as /api/clue/browser-token, /clue/browser-tokens, or /browser-tokens with the canonical /api/v1/clue/browser-tokens path.",
757
+ "Do not call ClueInit with empty-string fallbacks for required NEXT_PUBLIC_CLUE_* values. If required Clue env is absent, skip initialization and report the missing env names.",
758
+ "If a singleton guard is used, do not set initialized = true before ClueInit has actually been called with required config present.",
585
759
  "For non-Next.js browser code, use the exact frontend env names written in .env.clue for that service instead of inventing a framework-specific prefix.",
586
760
  "Never place CLUE_API_KEY in frontend code, frontend env files, browser bundles, or client-readable config.",
587
761
  "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.",
588
762
  "Configure frontend ClueInit with browserTokenProvider that calls the local backend token endpoint and returns the token string.",
589
763
  "Keep the four setup API hops distinct: customer frontend -> customer backend /api/v1/clue/browser-tokens, customer backend -> Clue /api/v1/ingest/browser-tokens, customer frontend -> Clue /api/v1/ingest/browser, and customer backend -> Clue /api/v1/ingest/backend.",
590
764
  "The local backend token endpoint is part of the customer app, not the Clue API. Place it under a Clue-reserved local route such as /api/v1/clue/browser-tokens; do not use a generic path such as /browser-tokens that could be confused with product behavior. It must call Clue server-side at /api/v1/ingest/browser-tokens.",
591
- "The frontend browserTokenProvider must send the same service key used by ClueInit to the customer backend token endpoint. For Next.js this value comes from NEXT_PUBLIC_CLUE_SERVICE_KEY.",
765
+ "The frontend browserTokenProvider must call NEXT_PUBLIC_CLUE_BROWSER_TOKEN_ENDPOINT and send the same service key used by ClueInit to the customer backend token endpoint. For Next.js this value comes from NEXT_PUBLIC_CLUE_SERVICE_KEY.",
592
766
  "The browser token request must include the frontend service key used by ClueInit. Project key and environment may be included only as public consistency hints; the backend must use server configuration or validate them against server configuration before calling Clue.",
593
767
  "The backend browser token proxy must derive request origin from trusted request headers or server request metadata. Do not trust origin, projectKey, or environment from JSON/body payload fields when calling Clue with server CLUE_API_KEY.",
594
768
  "For browser token proxy code, the service key sent to Clue must be the frontend ClueInit serviceKey from the browser request, not the backend service's CLUE_SERVICE_KEY.",
595
769
  "If a backend-owned browser token endpoint is implemented, read CLUE_API_BASE_URL from the backend env block and normalize it so values with or without a trailing /api/v1 do not produce duplicate paths.",
596
- "Do not add @clue-ai/browser-sdk or backend SDK dependencies with * or latest; use a concrete published version or package-manager-resolved semver range and update the repository lockfile when one exists.",
770
+ "Install Clue SDK dependencies through the latest channel. Frontend package managers must use @clue-ai/browser-sdk@latest; Python backend dependency declarations must not pin clue-fastapi-sdk or clue-django-sdk to a fixed version.",
597
771
  "Prefer minimal edits that engineers can review in one PR.",
598
772
  "Do not run broad formatters, import sorters, cleanup tools, or style-only edits. Whitespace-only changes are allowed only on lines directly changed for Clue SDK wiring.",
599
773
  "If a lifecycle point is unclear, skip that edit and include a warning.",
@@ -614,6 +788,8 @@ const buildLifecyclePrompt = ({ request, files }) =>
614
788
  browser_ingest_endpoint_env: "framework_specific",
615
789
  nextjs_browser_ingest_endpoint_env: "NEXT_PUBLIC_CLUE_INGEST_ENDPOINT",
616
790
  client_backend_browser_token_proxy_path: "/api/v1/clue/browser-tokens",
791
+ nextjs_browser_token_endpoint_env:
792
+ "NEXT_PUBLIC_CLUE_BROWSER_TOKEN_ENDPOINT",
617
793
  clue_backend_browser_token_issue_path: "/api/v1/ingest/browser-tokens",
618
794
  nextjs_browser_service_key_env: "NEXT_PUBLIC_CLUE_SERVICE_KEY",
619
795
  service_key: request.service_key,
@@ -660,3 +836,45 @@ export const planLifecycleInsertions = async ({ repoRoot, request, env }) => {
660
836
  emptyMessage: "AI provider returned empty lifecycle plan",
661
837
  });
662
838
  };
839
+
840
+ export const planLifecycleInsertionsStrict = async ({
841
+ repoRoot,
842
+ request,
843
+ env,
844
+ failureContext = null,
845
+ }) => {
846
+ const apiKey = env.CLUE_AI_PROVIDER_API_KEY;
847
+ if (!apiKey) {
848
+ throw new Error(
849
+ "CLUE_AI_PROVIDER_API_KEY is required for setup-agent lifecycle planning",
850
+ );
851
+ }
852
+ const setupAgentEnv = {
853
+ ...env,
854
+ CLUE_AI_PROVIDER_BASE_URL: undefined,
855
+ };
856
+ const files = await readContextFiles({ repoRoot, request });
857
+ const prompt = JSON.parse(buildLifecyclePrompt({ request, files }));
858
+ const user = JSON.stringify({
859
+ ...prompt,
860
+ setup_agent_mode: {
861
+ execution_owner: "clue_cli",
862
+ model_role: "lifecycle_plan_only",
863
+ direct_file_writes_allowed: false,
864
+ setup_watch_allowed: false,
865
+ retry_failure_context: failureContext,
866
+ },
867
+ });
868
+ return callStrictToolAiProvider({
869
+ config: resolveAiProviderConfig({ env: setupAgentEnv, apiKey }),
870
+ system:
871
+ "You are a safe code-edit planner for Clue SDK setup. Call the provided function with a schema-valid lifecycle plan only.",
872
+ user,
873
+ toolName: "propose_clue_lifecycle_plan",
874
+ toolDescription:
875
+ "Return the exact Clue SDK lifecycle insertion plan for the local CLI to validate and apply.",
876
+ parameters: LIFECYCLE_PLAN_TOOL_SCHEMA,
877
+ failureMessage: "AI provider failed during setup-agent lifecycle planning",
878
+ emptyMessage: "AI provider returned empty setup-agent lifecycle plan",
879
+ });
880
+ };
@@ -185,6 +185,7 @@ const semanticSnapshotPurposeChangeStateValues = [
185
185
  const semanticSnapshotActiveSemanticSourceValues = [
186
186
  "previous_reused",
187
187
  "new_confirmed",
188
+ "new_unconfirmed",
188
189
  "previous_kept_pending_review",
189
190
  ];
190
191
  const targetObjectKeySchema = nonEmptyStringSchema.regex(snakeSegmentPattern, {