@clue-ai/cli 0.0.5 → 0.0.6

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
@@ -5,9 +5,9 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, relative, resolve } from "node:path";
6
6
  import { commandSpecs } from "../src/command-spec.mjs";
7
7
  import {
8
- buildSemanticWorkflowRequestFromFlags,
9
- runInitTool,
10
- writeSemanticWorkflow,
8
+ buildSemanticWorkflowRequestFromFlags,
9
+ runInitTool,
10
+ writeSemanticWorkflow,
11
11
  } from "../src/init-tool.mjs";
12
12
  import { applyLifecyclePlan } from "../src/lifecycle-init.mjs";
13
13
  import { runSemanticCi, runSemanticInventory } from "../src/semantic-ci.mjs";
@@ -17,843 +17,886 @@ import { runSetupPrepare } from "../src/setup-prepare.mjs";
17
17
  import { installSetupSkills } from "../src/setup-tool.mjs";
18
18
 
19
19
  const parseArgs = (argv) => {
20
- const [command = "help", ...tokens] = argv;
21
- const flags = new Map();
22
- for (let index = 0; index < tokens.length; index += 1) {
23
- const token = tokens[index];
24
- if (!token.startsWith("--")) continue;
25
- const key = token.slice(2);
26
- const next = tokens[index + 1];
27
- if (next && !next.startsWith("--")) {
28
- flags.set(key, next);
29
- index += 1;
30
- } else {
31
- flags.set(key, true);
32
- }
33
- }
34
- return { command, flags };
20
+ const [command = "help", ...tokens] = argv;
21
+ const flags = new Map();
22
+ for (let index = 0; index < tokens.length; index += 1) {
23
+ const token = tokens[index];
24
+ if (!token.startsWith("--")) continue;
25
+ const key = token.slice(2);
26
+ const next = tokens[index + 1];
27
+ if (next && !next.startsWith("--")) {
28
+ flags.set(key, next);
29
+ index += 1;
30
+ } else {
31
+ flags.set(key, true);
32
+ }
33
+ }
34
+ return { command, flags };
35
35
  };
36
36
 
37
37
  const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
38
38
 
39
39
  const writeJsonIfRequested = async ({ repoRoot, outputPath, value }) => {
40
- if (typeof outputPath !== "string") return false;
41
- const resolvedRepoRoot = resolve(repoRoot);
42
- const resolvedOutputPath = resolve(resolvedRepoRoot, outputPath);
43
- const relativeOutputPath = relative(resolvedRepoRoot, resolvedOutputPath);
44
- if (relativeOutputPath.startsWith("..") || isAbsolute(relativeOutputPath)) {
45
- throw new Error(`output path escapes repo root: ${outputPath}`);
46
- }
47
- await mkdir(dirname(resolvedOutputPath), { recursive: true });
48
- await writeFile(
49
- resolvedOutputPath,
50
- `${JSON.stringify(value, null, 2)}\n`,
51
- "utf8",
52
- );
53
- return true;
40
+ if (typeof outputPath !== "string") return false;
41
+ const resolvedRepoRoot = resolve(repoRoot);
42
+ const resolvedOutputPath = resolve(resolvedRepoRoot, outputPath);
43
+ const relativeOutputPath = relative(resolvedRepoRoot, resolvedOutputPath);
44
+ if (relativeOutputPath.startsWith("..") || isAbsolute(relativeOutputPath)) {
45
+ throw new Error(`output path escapes repo root: ${outputPath}`);
46
+ }
47
+ await mkdir(dirname(resolvedOutputPath), { recursive: true });
48
+ await writeFile(
49
+ resolvedOutputPath,
50
+ `${JSON.stringify(value, null, 2)}\n`,
51
+ "utf8",
52
+ );
53
+ return true;
54
54
  };
55
55
 
56
56
  const sleep = (ms) =>
57
- new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
57
+ new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
58
58
 
59
59
  const splitCsv = (value) =>
60
- typeof value === "string"
61
- ? value
62
- .split(",")
63
- .map((entry) => entry.trim())
64
- .filter(Boolean)
65
- : [];
60
+ typeof value === "string"
61
+ ? value
62
+ .split(",")
63
+ .map((entry) => entry.trim())
64
+ .filter(Boolean)
65
+ : [];
66
66
 
67
67
  const splitWatchTargetEntries = (value) => {
68
- if (typeof value !== "string") return [];
69
- const entries = [];
70
- let current = "";
71
- let bracketDepth = 0;
72
- for (const char of value) {
73
- if (char === "[") bracketDepth += 1;
74
- if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
75
- if (char === "," && bracketDepth === 0) {
76
- if (current.trim()) entries.push(current.trim());
77
- current = "";
78
- continue;
79
- }
80
- current += char;
81
- }
82
- if (current.trim()) entries.push(current.trim());
83
- return entries;
68
+ if (typeof value !== "string") return [];
69
+ const entries = [];
70
+ let current = "";
71
+ let bracketDepth = 0;
72
+ for (const char of value) {
73
+ if (char === "[") bracketDepth += 1;
74
+ if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
75
+ if (char === "," && bracketDepth === 0) {
76
+ if (current.trim()) entries.push(current.trim());
77
+ current = "";
78
+ continue;
79
+ }
80
+ current += char;
81
+ }
82
+ if (current.trim()) entries.push(current.trim());
83
+ return entries;
84
84
  };
85
85
 
86
86
  const WATCH_LIFECYCLE_ALIASES = new Map([
87
- ["clue-init", "init"],
88
- ["init", "init"],
89
- ["sdk-initialized", "init"],
90
- ["clue-identify", "identify"],
91
- ["identify", "identify"],
92
- ["identified", "identify"],
93
- ["clue-set-account", "set-account"],
94
- ["set-account", "set-account"],
95
- ["account", "set-account"],
96
- ["clue-logout", "logout"],
97
- ["logout", "logout"],
98
- ["event-sent", "event-sent"],
99
- ["event", "event-sent"],
100
- ["log", "event-sent"],
87
+ ["clue-init", "init"],
88
+ ["init", "init"],
89
+ ["sdk-initialized", "init"],
90
+ ["clue-identify", "identify"],
91
+ ["identify", "identify"],
92
+ ["identified", "identify"],
93
+ ["clue-set-account", "set-account"],
94
+ ["set-account", "set-account"],
95
+ ["account", "set-account"],
96
+ ["clue-logout", "logout"],
97
+ ["logout", "logout"],
98
+ ["event-sent", "event-sent"],
99
+ ["event", "event-sent"],
100
+ ["log", "event-sent"],
101
101
  ]);
102
102
 
103
103
  const DEFAULT_WATCH_LIFECYCLE = [
104
- "init",
105
- "identify",
106
- "set-account",
107
- "logout",
108
- "event-sent",
104
+ "init",
105
+ "identify",
106
+ "set-account",
107
+ "logout",
108
+ "event-sent",
109
109
  ];
110
110
  const DEFAULT_SETUP_MANIFEST_PATH = ".clue/setup-manifest.json";
111
111
 
112
112
  const parseExpectedLifecycle = (value) => {
113
- if (typeof value !== "string" || !value.trim()) {
114
- return DEFAULT_WATCH_LIFECYCLE;
115
- }
116
- const entries = value
117
- .split(/[,+]/)
118
- .map((entry) => entry.trim().toLowerCase())
119
- .filter(Boolean);
120
- const normalized = entries.map((entry) => {
121
- const lifecycle = WATCH_LIFECYCLE_ALIASES.get(entry);
122
- if (!lifecycle) {
123
- throw new Error(
124
- `invalid lifecycle check: ${entry}; expected init, identify, set-account, logout, or event-sent`,
125
- );
126
- }
127
- return lifecycle;
128
- });
129
- return [...new Set(normalized)];
113
+ if (typeof value !== "string" || !value.trim()) {
114
+ return DEFAULT_WATCH_LIFECYCLE;
115
+ }
116
+ const entries = value
117
+ .split(/[,+]/)
118
+ .map((entry) => entry.trim().toLowerCase())
119
+ .filter(Boolean);
120
+ const normalized = entries.map((entry) => {
121
+ const lifecycle = WATCH_LIFECYCLE_ALIASES.get(entry);
122
+ if (!lifecycle) {
123
+ throw new Error(
124
+ `invalid lifecycle check: ${entry}; expected init, identify, set-account, logout, or event-sent`,
125
+ );
126
+ }
127
+ return lifecycle;
128
+ });
129
+ return [...new Set(normalized)];
130
130
  };
131
131
 
132
132
  const parseWatchTargets = (value) =>
133
- splitWatchTargetEntries(value).map((entry) => {
134
- const match =
135
- /^(frontend|backend):([^[\]=\s]+)(?:\[([^\]]+)\])?(?:=(\S+))?$/.exec(
136
- entry,
137
- );
138
- if (!match) {
139
- throw new Error(
140
- `invalid --watch-targets entry: ${entry}; expected frontend:<service-key>[init,identify,set-account,logout,event-sent][=<url>] or backend:<service-key>[...][=<url>]`,
141
- );
142
- }
143
- const serviceKey = match[2];
144
- return {
145
- kind: match[1],
146
- serviceKey,
147
- producerId: serviceKey,
148
- expectedLifecycle: parseExpectedLifecycle(match[3]),
149
- url: match[4] ?? null,
150
- };
151
- });
133
+ splitWatchTargetEntries(value).map((entry) => {
134
+ const match =
135
+ /^(frontend|backend):([^[\]=\s]+)(?:\[([^\]]+)\])?(?:=(\S+))?$/.exec(
136
+ entry,
137
+ );
138
+ if (!match) {
139
+ throw new Error(
140
+ `invalid --watch-targets entry: ${entry}; expected frontend:<service-key>[init,identify,set-account,logout,event-sent][=<url>] or backend:<service-key>[...][=<url>]`,
141
+ );
142
+ }
143
+ const serviceKey = match[2];
144
+ return {
145
+ kind: match[1],
146
+ serviceKey,
147
+ producerId: serviceKey,
148
+ expectedLifecycle: parseExpectedLifecycle(match[3]),
149
+ url: match[4] ?? null,
150
+ };
151
+ });
152
152
 
153
153
  const normalizeManifestWatchTarget = (target) => {
154
- if (!target || typeof target !== "object") {
155
- throw new Error("invalid setup manifest watch target");
156
- }
157
- const kind = target.kind;
158
- const serviceKey = target.service_key ?? target.serviceKey;
159
- if (kind !== "frontend" && kind !== "backend") {
160
- throw new Error("setup manifest watch target kind must be frontend or backend");
161
- }
162
- if (typeof serviceKey !== "string" || !serviceKey.trim()) {
163
- throw new Error("setup manifest watch target service_key is required");
164
- }
165
- const localUrlCandidates = Array.isArray(target.local_url_candidates)
166
- ? target.local_url_candidates
167
- .map((entry) => String(entry).trim())
168
- .filter(Boolean)
169
- : [];
170
- const urlEnvName =
171
- typeof target.url_env_name === "string" && target.url_env_name.trim()
172
- ? target.url_env_name.trim()
173
- : null;
174
- const explicitUrl =
175
- typeof target.url === "string" && target.url.trim()
176
- ? target.url.trim()
177
- : null;
178
- const expectedLifecycle = Array.isArray(target.expected_lifecycle)
179
- ? parseExpectedLifecycle(target.expected_lifecycle.join(","))
180
- : DEFAULT_WATCH_LIFECYCLE;
181
- return {
182
- kind,
183
- serviceKey: serviceKey.trim(),
184
- producerId:
185
- typeof target.producer_id === "string" && target.producer_id.trim()
186
- ? target.producer_id.trim()
187
- : serviceKey.trim(),
188
- expectedLifecycle,
189
- url: explicitUrl,
190
- urlEnvName,
191
- localUrlCandidates,
192
- };
154
+ if (!target || typeof target !== "object") {
155
+ throw new Error("invalid setup manifest watch target");
156
+ }
157
+ const kind = target.kind;
158
+ const serviceKey = target.service_key ?? target.serviceKey;
159
+ if (kind !== "frontend" && kind !== "backend") {
160
+ throw new Error(
161
+ "setup manifest watch target kind must be frontend or backend",
162
+ );
163
+ }
164
+ if (typeof serviceKey !== "string" || !serviceKey.trim()) {
165
+ throw new Error("setup manifest watch target service_key is required");
166
+ }
167
+ const localUrlCandidates = Array.isArray(target.local_url_candidates)
168
+ ? target.local_url_candidates
169
+ .map((entry) => String(entry).trim())
170
+ .filter(Boolean)
171
+ : [];
172
+ const urlEnvName =
173
+ typeof target.url_env_name === "string" && target.url_env_name.trim()
174
+ ? target.url_env_name.trim()
175
+ : null;
176
+ const explicitUrl =
177
+ typeof target.url === "string" && target.url.trim()
178
+ ? target.url.trim()
179
+ : null;
180
+ const expectedLifecycle = Array.isArray(target.expected_lifecycle)
181
+ ? parseExpectedLifecycle(target.expected_lifecycle.join(","))
182
+ : DEFAULT_WATCH_LIFECYCLE;
183
+ return {
184
+ kind,
185
+ serviceKey: serviceKey.trim(),
186
+ producerId:
187
+ typeof target.producer_id === "string" && target.producer_id.trim()
188
+ ? target.producer_id.trim()
189
+ : serviceKey.trim(),
190
+ expectedLifecycle,
191
+ url: explicitUrl,
192
+ urlEnvName,
193
+ localUrlCandidates,
194
+ };
193
195
  };
194
196
 
195
197
  const readSetupManifest = async ({ repoRoot, manifestPath }) => {
196
- const resolvedPath = resolve(repoRoot, manifestPath);
197
- return readJson(resolvedPath);
198
+ const resolvedPath = resolve(repoRoot, manifestPath);
199
+ return readJson(resolvedPath);
198
200
  };
199
201
 
200
202
  const manifestWatchTargets = (manifest) => {
201
- const targets = manifest?.lifecycle_verification?.watch_targets;
202
- if (!Array.isArray(targets) || targets.length === 0) {
203
- return [];
204
- }
205
- return targets.map(normalizeManifestWatchTarget);
203
+ const targets = manifest?.lifecycle_verification?.watch_targets;
204
+ if (!Array.isArray(targets) || targets.length === 0) {
205
+ return [];
206
+ }
207
+ return targets.map(normalizeManifestWatchTarget);
206
208
  };
207
209
 
208
210
  const resolveTargetUrlFromEnv = ({ target, env }) => {
209
- if (target.url) return target.url;
210
- if (target.urlEnvName && typeof env[target.urlEnvName] === "string") {
211
- const value = env[target.urlEnvName].trim();
212
- if (value) return value;
213
- }
214
- return target.localUrlCandidates[0] ?? null;
211
+ if (target.url) return target.url;
212
+ if (target.urlEnvName && typeof env[target.urlEnvName] === "string") {
213
+ const value = env[target.urlEnvName].trim();
214
+ if (value) return value;
215
+ }
216
+ return target.localUrlCandidates[0] ?? null;
215
217
  };
216
218
 
217
219
  const shouldAskQuestions = ({ flags }) =>
218
- !flags.has("yes") && process.stdin.isTTY && process.stdout.isTTY;
220
+ !flags.has("yes") && process.stdin.isTTY && process.stdout.isTTY;
219
221
 
220
222
  const confirmTargetUrls = async ({ flags, watchTargets, env }) => {
221
- const initialTargets = watchTargets.map((target) => ({
222
- ...target,
223
- url: resolveTargetUrlFromEnv({ target, env }),
224
- }));
225
- if (!shouldAskQuestions({ flags })) {
226
- return initialTargets;
227
- }
228
- const readline = createInterface({
229
- input: process.stdin,
230
- output: process.stdout,
231
- });
232
- try {
233
- const confirmedTargets = [];
234
- for (const target of initialTargets) {
235
- const currentUrl = target.url ?? "";
236
- const answer = await readline.question(
237
- `${target.kind}のローカルURL(${target.serviceKey})はこちらで正しいですか? ${currentUrl || "(未設定)"}\nEnterで決定、違う場合はURLを入力: `,
238
- );
239
- confirmedTargets.push({
240
- ...target,
241
- url: answer.trim() || currentUrl || null,
242
- });
243
- }
244
- return confirmedTargets;
245
- } finally {
246
- readline.close();
247
- }
223
+ const initialTargets = watchTargets.map((target) => ({
224
+ ...target,
225
+ url: resolveTargetUrlFromEnv({ target, env }),
226
+ }));
227
+ if (!shouldAskQuestions({ flags })) {
228
+ return initialTargets;
229
+ }
230
+ const readline = createInterface({
231
+ input: process.stdin,
232
+ output: process.stdout,
233
+ });
234
+ try {
235
+ const confirmedTargets = [];
236
+ for (const target of initialTargets) {
237
+ const currentUrl = target.url ?? "";
238
+ const answer = await readline.question(
239
+ `${target.kind}のローカルURL(${target.serviceKey})はこちらで正しいですか? ${currentUrl || "(未設定)"}\nEnterで決定、違う場合はURLを入力: `,
240
+ );
241
+ confirmedTargets.push({
242
+ ...target,
243
+ url: answer.trim() || currentUrl || null,
244
+ });
245
+ }
246
+ return confirmedTargets;
247
+ } finally {
248
+ readline.close();
249
+ }
248
250
  };
249
251
 
250
252
  const uniqueCsv = (values) => [...new Set(values.filter(Boolean))].join(",");
251
253
 
252
254
  const buildWatchProducerIds = ({ explicitProducerIds, watchTargets }) =>
253
- uniqueCsv([
254
- ...splitCsv(explicitProducerIds),
255
- ...watchTargets.map((target) => target.producerId),
256
- ]);
255
+ uniqueCsv([
256
+ ...splitCsv(explicitProducerIds),
257
+ ...watchTargets.map((target) => target.producerId),
258
+ ]);
257
259
 
258
260
  const checkTargetUrl = async (url) => {
259
- if (!url) return { checked: false, reachable: true, status: null };
260
- try {
261
- const response = await fetch(url, { method: "GET" });
262
- return {
263
- checked: true,
264
- reachable: response.status < 500,
265
- status: response.status,
266
- };
267
- } catch {
268
- return { checked: true, reachable: false, status: null };
269
- }
261
+ if (!url) return { checked: false, reachable: true, status: null };
262
+ try {
263
+ const response = await fetch(url, { method: "GET" });
264
+ return {
265
+ checked: true,
266
+ reachable: response.status < 500,
267
+ status: response.status,
268
+ };
269
+ } catch {
270
+ return { checked: true, reachable: false, status: null };
271
+ }
270
272
  };
271
273
 
272
274
  const evaluateWatchTargets = async ({ latest, watchTargets }) => {
273
- const producers = Array.isArray(latest?.producers) ? latest.producers : [];
274
- return Promise.all(
275
- watchTargets.map(async (target) => {
276
- const producer = producers.find(
277
- (entry) => entry.id === target.producerId,
278
- );
279
- const urlHealth = await checkTargetUrl(target.url);
280
- const lifecycleStatus = {
281
- init: Boolean(producer?.clueInit ?? producer?.sdkInitialized),
282
- identify: Boolean(producer?.clueIdentify),
283
- "set-account": Boolean(producer?.clueSetAccount),
284
- logout: Boolean(producer?.clueLogout),
285
- "event-sent": Boolean(producer?.logStored),
286
- };
287
- const expectedLifecycle = target.expectedLifecycle;
288
- const unexpectedLifecycle = Object.entries(lifecycleStatus)
289
- .filter(
290
- ([name, passed]) =>
291
- passed &&
292
- name !== "event-sent" &&
293
- name !== "init" &&
294
- !expectedLifecycle.includes(name),
295
- )
296
- .map(([name]) => name);
297
- const producerPassed =
298
- expectedLifecycle.every((name) => lifecycleStatus[name]) &&
299
- unexpectedLifecycle.length === 0;
300
- return {
301
- ...target,
302
- expectedLifecycle,
303
- eventCount: producer?.eventCount ?? 0,
304
- latestOccurredAt: producer?.latestOccurredAt ?? null,
305
- logStored: Boolean(producer?.logStored),
306
- requestStored: Boolean(producer?.requestStored),
307
- sdkInitialized: Boolean(producer?.sdkInitialized),
308
- lifecycleStatus,
309
- unexpectedLifecycle,
310
- urlChecked: urlHealth.checked,
311
- urlReachable: urlHealth.reachable,
312
- urlStatus: urlHealth.status,
313
- passed: producerPassed && urlHealth.reachable,
314
- };
315
- }),
316
- );
275
+ const producers = Array.isArray(latest?.producers) ? latest.producers : [];
276
+ return Promise.all(
277
+ watchTargets.map(async (target) => {
278
+ const producer = producers.find(
279
+ (entry) => entry.id === target.producerId,
280
+ );
281
+ const urlHealth = await checkTargetUrl(target.url);
282
+ const lifecycleStatus = {
283
+ init: Boolean(producer?.clueInit ?? producer?.sdkInitialized),
284
+ identify: Boolean(producer?.clueIdentify),
285
+ "set-account": Boolean(producer?.clueSetAccount),
286
+ logout: Boolean(producer?.clueLogout),
287
+ "event-sent": Boolean(producer?.logStored),
288
+ };
289
+ const expectedLifecycle = target.expectedLifecycle;
290
+ const unexpectedLifecycle = Object.entries(lifecycleStatus)
291
+ .filter(
292
+ ([name, passed]) =>
293
+ passed &&
294
+ name !== "event-sent" &&
295
+ name !== "init" &&
296
+ !expectedLifecycle.includes(name),
297
+ )
298
+ .map(([name]) => name);
299
+ const producerPassed =
300
+ expectedLifecycle.every((name) => lifecycleStatus[name]) &&
301
+ unexpectedLifecycle.length === 0;
302
+ return {
303
+ ...target,
304
+ expectedLifecycle,
305
+ eventCount: producer?.eventCount ?? 0,
306
+ latestOccurredAt: producer?.latestOccurredAt ?? null,
307
+ logStored: Boolean(producer?.logStored),
308
+ requestStored: Boolean(producer?.requestStored),
309
+ sdkInitialized: Boolean(producer?.sdkInitialized),
310
+ lifecycleStatus,
311
+ unexpectedLifecycle,
312
+ urlChecked: urlHealth.checked,
313
+ urlReachable: urlHealth.reachable,
314
+ urlStatus: urlHealth.status,
315
+ passed: producerPassed && urlHealth.reachable,
316
+ };
317
+ }),
318
+ );
317
319
  };
318
320
 
319
321
  const lifecycleStatusFromEvents = (events) => {
320
- const customEventNames = new Set(
321
- events
322
- .map((event) => event.custom_event_name ?? event.customEventName)
323
- .filter((value) => typeof value === "string"),
324
- );
325
- return {
326
- init: customEventNames.has("sdk_initialized"),
327
- identify: customEventNames.has("identity_identified"),
328
- "set-account": customEventNames.has("account_associated"),
329
- logout: customEventNames.has("identity_logged_out"),
330
- "event-sent": events.length > 0,
331
- };
322
+ const customEventNames = new Set(
323
+ events
324
+ .map((event) => event.custom_event_name ?? event.customEventName)
325
+ .filter((value) => typeof value === "string"),
326
+ );
327
+ return {
328
+ init: customEventNames.has("sdk_initialized"),
329
+ identify: customEventNames.has("identity_identified"),
330
+ "set-account": customEventNames.has("account_associated"),
331
+ logout: customEventNames.has("identity_logged_out"),
332
+ "event-sent": events.length > 0,
333
+ };
332
334
  };
333
335
 
334
336
  const localSetupCheckSnapshot = ({ receivedBatches }) => {
335
- const producerEvents = new Map();
336
- for (const batch of receivedBatches) {
337
- const producerId =
338
- batch.producerId ||
339
- batch.payload?.producer_metadata?.producer_id ||
340
- batch.payload?.producer_id;
341
- if (typeof producerId !== "string" || !producerId.trim()) continue;
342
- const events = Array.isArray(batch.payload?.events)
343
- ? batch.payload.events
344
- : [];
345
- const existing = producerEvents.get(producerId) ?? [];
346
- producerEvents.set(producerId, [...existing, ...events]);
347
- }
348
- const producers = [...producerEvents.entries()].map(([id, events]) => {
349
- const lifecycleStatus = lifecycleStatusFromEvents(events);
350
- return {
351
- id,
352
- eventCount: events.length,
353
- clueInit: lifecycleStatus.init,
354
- clueIdentify: lifecycleStatus.identify,
355
- clueSetAccount: lifecycleStatus["set-account"],
356
- clueLogout: lifecycleStatus.logout,
357
- sdkInitialized: lifecycleStatus.init,
358
- logStored: lifecycleStatus["event-sent"],
359
- requestStored: events.some((event) => event.event_category === "request"),
360
- internalFlowStored: events.some(
361
- (event) => event.event_category === "internal_flow",
362
- ),
363
- latestOccurredAt:
364
- events
365
- .map((event) => event.occurred_at)
366
- .filter((value) => typeof value === "string")
367
- .sort()
368
- .at(-1) ?? null,
369
- };
370
- });
371
- const lifecycleChecks = producers.flatMap((producer) => [
372
- ["frontend_sdk_initialized", producer.sdkInitialized],
373
- ["backend_sdk_initialized", producer.sdkInitialized],
374
- ["frontend_log_sent", producer.logStored],
375
- ["backend_log_sent", producer.logStored],
376
- ["identified_event", producer.clueIdentify],
377
- ["account_event", producer.clueSetAccount],
378
- ["logout_reset", producer.clueLogout],
379
- ]);
380
- const checks = Object.fromEntries(
381
- lifecycleChecks.map(([name, passed]) => [
382
- name,
383
- passed ? "passed" : "waiting",
384
- ]),
385
- );
386
- return { checks, producers };
337
+ const producerEvents = new Map();
338
+ for (const batch of receivedBatches) {
339
+ const producerId =
340
+ batch.producerId ||
341
+ batch.payload?.producer_metadata?.producer_id ||
342
+ batch.payload?.producer_id;
343
+ if (typeof producerId !== "string" || !producerId.trim()) continue;
344
+ const events = Array.isArray(batch.payload?.events)
345
+ ? batch.payload.events
346
+ : [];
347
+ const existing = producerEvents.get(producerId) ?? [];
348
+ producerEvents.set(producerId, [...existing, ...events]);
349
+ }
350
+ const producers = [...producerEvents.entries()].map(([id, events]) => {
351
+ const lifecycleStatus = lifecycleStatusFromEvents(events);
352
+ return {
353
+ id,
354
+ eventCount: events.length,
355
+ clueInit: lifecycleStatus.init,
356
+ clueIdentify: lifecycleStatus.identify,
357
+ clueSetAccount: lifecycleStatus["set-account"],
358
+ clueLogout: lifecycleStatus.logout,
359
+ sdkInitialized: lifecycleStatus.init,
360
+ logStored: lifecycleStatus["event-sent"],
361
+ requestStored: events.some((event) => event.event_category === "request"),
362
+ internalFlowStored: events.some(
363
+ (event) => event.event_category === "internal_flow",
364
+ ),
365
+ latestOccurredAt:
366
+ events
367
+ .map((event) => event.occurred_at)
368
+ .filter((value) => typeof value === "string")
369
+ .sort()
370
+ .at(-1) ?? null,
371
+ };
372
+ });
373
+ const lifecycleChecks = producers.flatMap((producer) => [
374
+ ["frontend_sdk_initialized", producer.sdkInitialized],
375
+ ["backend_sdk_initialized", producer.sdkInitialized],
376
+ ["frontend_log_sent", producer.logStored],
377
+ ["backend_log_sent", producer.logStored],
378
+ ["identified_event", producer.clueIdentify],
379
+ ["account_event", producer.clueSetAccount],
380
+ ["logout_reset", producer.clueLogout],
381
+ ]);
382
+ const checks = Object.fromEntries(
383
+ lifecycleChecks.map(([name, passed]) => [
384
+ name,
385
+ passed ? "passed" : "waiting",
386
+ ]),
387
+ );
388
+ return { checks, producers };
387
389
  };
388
390
 
389
391
  const readRequestJson = async (request) => {
390
- const chunks = [];
391
- for await (const chunk of request) {
392
- chunks.push(chunk);
393
- }
394
- const body = Buffer.concat(chunks).toString("utf8");
395
- if (!body.trim()) return {};
396
- return JSON.parse(body);
392
+ const chunks = [];
393
+ for await (const chunk of request) {
394
+ chunks.push(chunk);
395
+ }
396
+ const body = Buffer.concat(chunks).toString("utf8");
397
+ if (!body.trim()) return {};
398
+ return JSON.parse(body);
397
399
  };
398
400
 
399
401
  const startLocalSetupReceiver = async ({ host, port }) => {
400
- const receivedBatches = [];
401
- const server = createServer(async (request, response) => {
402
- response.setHeader("access-control-allow-origin", "*");
403
- response.setHeader("access-control-allow-methods", "POST, OPTIONS");
404
- response.setHeader("access-control-allow-headers", "*");
405
- if (request.method === "OPTIONS") {
406
- response.writeHead(204);
407
- response.end();
408
- return;
409
- }
410
- if (request.method !== "POST") {
411
- response.writeHead(404, { "content-type": "application/json" });
412
- response.end(JSON.stringify({ accepted: false }));
413
- return;
414
- }
415
- try {
416
- const payload = await readRequestJson(request);
417
- receivedBatches.push({
418
- payload,
419
- producerId:
420
- payload?.producer_metadata?.producer_id ?? payload?.producer_id ?? null,
421
- path: request.url,
422
- });
423
- response.writeHead(202, { "content-type": "application/json" });
424
- response.end(
425
- JSON.stringify({
426
- accepted: true,
427
- status: "accepted",
428
- duplicate: false,
429
- eventCount: Array.isArray(payload?.events) ? payload.events.length : 0,
430
- }),
431
- );
432
- } catch (error) {
433
- response.writeHead(400, { "content-type": "application/json" });
434
- response.end(
435
- JSON.stringify({
436
- accepted: false,
437
- reason: error instanceof Error ? error.message : String(error),
438
- }),
439
- );
440
- }
441
- });
442
- await new Promise((resolveListen, rejectListen) => {
443
- server.once("error", rejectListen);
444
- server.listen(port, host, () => {
445
- server.off("error", rejectListen);
446
- resolveListen();
447
- });
448
- });
449
- const address = server.address();
450
- if (!address || typeof address === "string") {
451
- throw new Error("local setup receiver failed to bind");
452
- }
453
- const baseUrl = `http://${host}:${address.port}`;
454
- return {
455
- baseUrl,
456
- receivedBatches,
457
- close: () =>
458
- new Promise((resolveClose) => {
459
- server.close(() => resolveClose());
460
- }),
461
- };
402
+ const receivedBatches = [];
403
+ const server = createServer(async (request, response) => {
404
+ response.setHeader("access-control-allow-origin", "*");
405
+ response.setHeader("access-control-allow-methods", "POST, OPTIONS");
406
+ response.setHeader("access-control-allow-headers", "*");
407
+ if (request.method === "OPTIONS") {
408
+ response.writeHead(204);
409
+ response.end();
410
+ return;
411
+ }
412
+ if (request.method !== "POST") {
413
+ response.writeHead(404, { "content-type": "application/json" });
414
+ response.end(JSON.stringify({ accepted: false }));
415
+ return;
416
+ }
417
+ try {
418
+ const payload = await readRequestJson(request);
419
+ receivedBatches.push({
420
+ payload,
421
+ producerId:
422
+ payload?.producer_metadata?.producer_id ??
423
+ payload?.producer_id ??
424
+ null,
425
+ path: request.url,
426
+ });
427
+ response.writeHead(202, { "content-type": "application/json" });
428
+ response.end(
429
+ JSON.stringify({
430
+ accepted: true,
431
+ status: "accepted",
432
+ duplicate: false,
433
+ eventCount: Array.isArray(payload?.events)
434
+ ? payload.events.length
435
+ : 0,
436
+ }),
437
+ );
438
+ } catch (error) {
439
+ response.writeHead(400, { "content-type": "application/json" });
440
+ response.end(
441
+ JSON.stringify({
442
+ accepted: false,
443
+ reason: error instanceof Error ? error.message : String(error),
444
+ }),
445
+ );
446
+ }
447
+ });
448
+ await new Promise((resolveListen, rejectListen) => {
449
+ server.once("error", rejectListen);
450
+ server.listen(port, host, () => {
451
+ server.off("error", rejectListen);
452
+ resolveListen();
453
+ });
454
+ });
455
+ const address = server.address();
456
+ if (!address || typeof address === "string") {
457
+ throw new Error("local setup receiver failed to bind");
458
+ }
459
+ const baseUrl = `http://${host}:${address.port}`;
460
+ return {
461
+ baseUrl,
462
+ receivedBatches,
463
+ close: () =>
464
+ new Promise((resolveClose) => {
465
+ server.close(() => resolveClose());
466
+ }),
467
+ };
462
468
  };
463
469
 
464
470
  const renderWatchTargets = (targetChecks) =>
465
- targetChecks
466
- .map((target) => {
467
- const urlStatus = target.urlChecked
468
- ? ` url:${target.urlReachable ? target.urlStatus : "unreachable"}`
469
- : "";
470
- const lifecycle = target.expectedLifecycle
471
- .map(
472
- (name) =>
473
- `${name}:${target.lifecycleStatus[name] ? "ok" : "waiting"}`,
474
- )
475
- .join(" ");
476
- const unexpected = target.unexpectedLifecycle.length
477
- ? ` unexpected:${target.unexpectedLifecycle.join(",")}`
478
- : "";
479
- return `${target.passed ? "[x]" : "[ ]"} ${target.kind}:${target.serviceKey} ${lifecycle} events:${target.eventCount}${urlStatus}${unexpected}`;
480
- })
481
- .join("\n");
471
+ targetChecks
472
+ .map((target) => {
473
+ const urlStatus = target.urlChecked
474
+ ? ` url:${target.urlReachable ? target.urlStatus : "unreachable"}`
475
+ : "";
476
+ const lifecycle = target.expectedLifecycle
477
+ .map(
478
+ (name) =>
479
+ `${name}:${target.lifecycleStatus[name] ? "ok" : "waiting"}`,
480
+ )
481
+ .join(" ");
482
+ const unexpected = target.unexpectedLifecycle.length
483
+ ? ` unexpected:${target.unexpectedLifecycle.join(",")}`
484
+ : "";
485
+ return `${target.passed ? "[x]" : "[ ]"} ${target.kind}:${target.serviceKey} ${lifecycle} events:${target.eventCount}${urlStatus}${unexpected}`;
486
+ })
487
+ .join("\n");
482
488
 
483
489
  const setupCheckUrl = ({
484
- clueApiBaseUrl,
485
- environment,
486
- limit,
487
- producerIds,
488
- projectId,
489
- projectKey,
490
- startedAt,
490
+ clueApiBaseUrl,
491
+ environment,
492
+ limit,
493
+ producerIds,
494
+ projectId,
495
+ projectKey,
496
+ startedAt,
491
497
  }) => {
492
- const normalizedBaseUrl = String(clueApiBaseUrl).replace(/\/+$/, "");
493
- const url = new URL(`${normalizedBaseUrl}/api/v1/events/setup-check`);
494
- url.searchParams.set("projectKey", projectKey);
495
- url.searchParams.set("environment", environment);
496
- url.searchParams.set("startedAt", startedAt);
497
- url.searchParams.set("limit", String(limit));
498
- if (typeof projectId === "string" && projectId.trim()) {
499
- url.searchParams.set("project_id", projectId.trim());
500
- }
501
- if (typeof producerIds === "string" && producerIds.trim()) {
502
- url.searchParams.set("producerIds", producerIds.trim());
503
- }
504
- return url;
498
+ const normalizedBaseUrl = String(clueApiBaseUrl).replace(/\/+$/, "");
499
+ const url = new URL(`${normalizedBaseUrl}/api/v1/events/setup-check`);
500
+ url.searchParams.set("projectKey", projectKey);
501
+ url.searchParams.set("environment", environment);
502
+ url.searchParams.set("startedAt", startedAt);
503
+ url.searchParams.set("limit", String(limit));
504
+ if (typeof projectId === "string" && projectId.trim()) {
505
+ url.searchParams.set("project_id", projectId.trim());
506
+ }
507
+ if (typeof producerIds === "string" && producerIds.trim()) {
508
+ url.searchParams.set("producerIds", producerIds.trim());
509
+ }
510
+ return url;
505
511
  };
506
512
 
507
513
  const runSetupWatch = async ({ flags, repoRoot = ".", env = process.env }) => {
508
- const localMode = flags.has("local");
509
- const manifestPath = String(
510
- flags.get("manifest") || DEFAULT_SETUP_MANIFEST_PATH,
511
- );
512
- const manifest = localMode
513
- ? await readSetupManifest({ repoRoot, manifestPath })
514
- : null;
515
- if (localMode && manifest?.status !== "ready_for_ai") {
516
- throw new Error(
517
- `setup-watch --local requires a ready setup manifest at ${manifestPath}`,
518
- );
519
- }
520
- const projectKey = String(
521
- flags.get("project-key") ||
522
- env.CLUE_PROJECT_KEY ||
523
- manifest?.project_key ||
524
- "",
525
- ).trim();
526
- if (!projectKey && !localMode) {
527
- throw new Error("--project-key or CLUE_PROJECT_KEY is required");
528
- }
529
- const environment = String(
530
- flags.get("environment") ||
531
- env.CLUE_ENVIRONMENT ||
532
- manifest?.environment ||
533
- "dev",
534
- ).trim();
535
- const clueApiBaseUrl = String(
536
- flags.get("clue-api-base-url") || env.CLUE_API_BASE_URL || "",
537
- ).trim();
538
- if (!clueApiBaseUrl && !localMode) {
539
- throw new Error("--clue-api-base-url or CLUE_API_BASE_URL is required");
540
- }
541
- const startedAt = String(
542
- flags.get("started-at") || new Date().toISOString(),
543
- ).trim();
544
- const timeoutMs = Number(flags.get("timeout-ms") || 120_000);
545
- const pollIntervalMs = Number(flags.get("poll-interval-ms") || 3000);
546
- const limit = Number(flags.get("limit") || 200);
547
- const projectId = flags.get("project-id");
548
- const explicitWatchTargets = parseWatchTargets(flags.get("watch-targets"));
549
- const watchTargets = await confirmTargetUrls({
550
- flags,
551
- watchTargets:
552
- explicitWatchTargets.length > 0
553
- ? explicitWatchTargets
554
- : manifestWatchTargets(manifest),
555
- env,
556
- });
557
- const producerIds = buildWatchProducerIds({
558
- explicitProducerIds: flags.get("producer-ids"),
559
- watchTargets,
560
- });
561
- const started = Date.now();
562
- let latest = null;
563
-
564
- if (localMode) {
565
- const receiver = await startLocalSetupReceiver({
566
- host: String(flags.get("host") || "127.0.0.1"),
567
- port: Number(flags.get("port") || 0),
568
- });
569
- try {
570
- process.stdout.write(
571
- `Local Clue setup receiver: ${receiver.baseUrl}\n`,
572
- );
573
- process.stdout.write(
574
- `Frontend endpoint: ${receiver.baseUrl}/api/v1/ingest/browser\n`,
575
- );
576
- process.stdout.write(
577
- `Backend endpoint: ${receiver.baseUrl}/api/v1/ingest/backend\n`,
578
- );
579
- if (watchTargets.length > 0) {
580
- process.stdout.write(
581
- `Watching targets: ${watchTargets.map((target) => `${target.kind}:${target.serviceKey}`).join(", ")}\n`,
582
- );
583
- }
584
- while (Date.now() - started <= timeoutMs) {
585
- latest = localSetupCheckSnapshot({
586
- receivedBatches: receiver.receivedBatches,
587
- });
588
- const targetChecks = await evaluateWatchTargets({
589
- latest,
590
- watchTargets,
591
- });
592
- const targetChecksPassed =
593
- targetChecks.length > 0 &&
594
- targetChecks.every((target) => target.passed);
595
- const renderedTargets = renderWatchTargets(targetChecks);
596
- process.stdout.write(`${renderedTargets}\n\n`);
597
- if (targetChecksPassed) {
598
- process.stdout.write("Clue local setup checks passed.\n");
599
- return { ...latest, local: true, watchTargets: targetChecks };
600
- }
601
- await sleep(pollIntervalMs);
602
- }
603
- process.stdout.write(`${JSON.stringify(latest, null, 2)}\n`);
604
- throw new Error(
605
- "setup-watch --local timed out before all Clue setup checks passed",
606
- );
607
- } finally {
608
- await receiver.close();
609
- }
610
- }
611
-
612
- process.stdout.write(
613
- `Waiting for Clue setup checks since ${startedAt} (${environment})...\n`,
614
- );
615
- if (watchTargets.length > 0) {
616
- process.stdout.write(
617
- `Watching targets: ${watchTargets.map((target) => `${target.kind}:${target.serviceKey}`).join(", ")}\n`,
618
- );
619
- }
620
-
621
- while (Date.now() - started <= timeoutMs) {
622
- const response = await fetch(
623
- setupCheckUrl({
624
- clueApiBaseUrl,
625
- environment,
626
- limit,
627
- producerIds,
628
- projectId,
629
- projectKey,
630
- startedAt,
631
- }),
632
- );
633
- if (!response.ok) {
634
- throw new Error(`setup-check request failed: ${response.status}`);
635
- }
636
- latest = await response.json();
637
- const checks = latest?.checks ?? {};
638
- const entries = Object.entries(checks);
639
- const setupChecksPassed =
640
- entries.length > 0 && entries.every(([, status]) => status === "passed");
641
- const targetChecks = await evaluateWatchTargets({ latest, watchTargets });
642
- const targetChecksPassed = targetChecks.every((target) => target.passed);
643
- const passed = setupChecksPassed && targetChecksPassed;
644
- const rendered = entries
645
- .map(([id, status]) => `${status === "passed" ? "[x]" : "[ ]"} ${id}`)
646
- .join("\n");
647
- const renderedTargets = renderWatchTargets(targetChecks);
648
- process.stdout.write(
649
- `${rendered}${renderedTargets ? `\n${renderedTargets}` : ""}\n\n`,
650
- );
651
- if (passed) {
652
- process.stdout.write("Clue setup checks passed.\n");
653
- return { ...latest, watchTargets: targetChecks };
654
- }
655
- await sleep(pollIntervalMs);
656
- }
657
-
658
- process.stdout.write(`${JSON.stringify(latest, null, 2)}\n`);
659
- throw new Error("setup-watch timed out before all Clue setup checks passed");
514
+ const localMode = flags.has("local");
515
+ const manifestPath = String(
516
+ flags.get("manifest") || DEFAULT_SETUP_MANIFEST_PATH,
517
+ );
518
+ const manifest = localMode
519
+ ? await readSetupManifest({ repoRoot, manifestPath })
520
+ : null;
521
+ if (localMode && manifest?.status !== "ready_for_ai") {
522
+ throw new Error(
523
+ `setup-watch --local requires a ready setup manifest at ${manifestPath}`,
524
+ );
525
+ }
526
+ const projectKey = String(
527
+ flags.get("project-key") ||
528
+ env.CLUE_PROJECT_KEY ||
529
+ manifest?.project_key ||
530
+ "",
531
+ ).trim();
532
+ if (!projectKey && !localMode) {
533
+ throw new Error("--project-key or CLUE_PROJECT_KEY is required");
534
+ }
535
+ const environment = String(
536
+ flags.get("environment") ||
537
+ env.CLUE_ENVIRONMENT ||
538
+ manifest?.environment ||
539
+ "dev",
540
+ ).trim();
541
+ const clueApiBaseUrl = String(
542
+ flags.get("clue-api-base-url") || env.CLUE_API_BASE_URL || "",
543
+ ).trim();
544
+ if (!clueApiBaseUrl && !localMode) {
545
+ throw new Error("--clue-api-base-url or CLUE_API_BASE_URL is required");
546
+ }
547
+ const startedAt = String(
548
+ flags.get("started-at") || new Date().toISOString(),
549
+ ).trim();
550
+ const timeoutMs = Number(flags.get("timeout-ms") || 120_000);
551
+ const pollIntervalMs = Number(flags.get("poll-interval-ms") || 3000);
552
+ const limit = Number(flags.get("limit") || 200);
553
+ const projectId = flags.get("project-id");
554
+ const explicitWatchTargets = parseWatchTargets(flags.get("watch-targets"));
555
+ const watchTargets = await confirmTargetUrls({
556
+ flags,
557
+ watchTargets:
558
+ explicitWatchTargets.length > 0
559
+ ? explicitWatchTargets
560
+ : manifestWatchTargets(manifest),
561
+ env,
562
+ });
563
+ const producerIds = buildWatchProducerIds({
564
+ explicitProducerIds: flags.get("producer-ids"),
565
+ watchTargets,
566
+ });
567
+ const started = Date.now();
568
+ let latest = null;
569
+
570
+ if (localMode) {
571
+ const receiver = await startLocalSetupReceiver({
572
+ host: String(flags.get("host") || "127.0.0.1"),
573
+ port: Number(flags.get("port") || 0),
574
+ });
575
+ try {
576
+ process.stdout.write(`Local Clue setup receiver: ${receiver.baseUrl}\n`);
577
+ process.stdout.write(
578
+ `Frontend endpoint: ${receiver.baseUrl}/api/v1/ingest/browser\n`,
579
+ );
580
+ process.stdout.write(
581
+ `Backend endpoint: ${receiver.baseUrl}/api/v1/ingest/backend\n`,
582
+ );
583
+ if (watchTargets.length > 0) {
584
+ process.stdout.write(
585
+ `Watching targets: ${watchTargets.map((target) => `${target.kind}:${target.serviceKey}`).join(", ")}\n`,
586
+ );
587
+ }
588
+ while (Date.now() - started <= timeoutMs) {
589
+ latest = localSetupCheckSnapshot({
590
+ receivedBatches: receiver.receivedBatches,
591
+ });
592
+ const targetChecks = await evaluateWatchTargets({
593
+ latest,
594
+ watchTargets,
595
+ });
596
+ const targetChecksPassed =
597
+ targetChecks.length > 0 &&
598
+ targetChecks.every((target) => target.passed);
599
+ const renderedTargets = renderWatchTargets(targetChecks);
600
+ process.stdout.write(`${renderedTargets}\n\n`);
601
+ if (targetChecksPassed) {
602
+ process.stdout.write("Clue local setup checks passed.\n");
603
+ return { ...latest, local: true, watchTargets: targetChecks };
604
+ }
605
+ await sleep(pollIntervalMs);
606
+ }
607
+ process.stdout.write(`${JSON.stringify(latest, null, 2)}\n`);
608
+ throw new Error(
609
+ "setup-watch --local timed out before all Clue setup checks passed",
610
+ );
611
+ } finally {
612
+ await receiver.close();
613
+ }
614
+ }
615
+
616
+ process.stdout.write(
617
+ `Waiting for Clue setup checks since ${startedAt} (${environment})...\n`,
618
+ );
619
+ if (watchTargets.length > 0) {
620
+ process.stdout.write(
621
+ `Watching targets: ${watchTargets.map((target) => `${target.kind}:${target.serviceKey}`).join(", ")}\n`,
622
+ );
623
+ }
624
+
625
+ while (Date.now() - started <= timeoutMs) {
626
+ const response = await fetch(
627
+ setupCheckUrl({
628
+ clueApiBaseUrl,
629
+ environment,
630
+ limit,
631
+ producerIds,
632
+ projectId,
633
+ projectKey,
634
+ startedAt,
635
+ }),
636
+ );
637
+ if (!response.ok) {
638
+ throw new Error(`setup-check request failed: ${response.status}`);
639
+ }
640
+ latest = await response.json();
641
+ const checks = latest?.checks ?? {};
642
+ const entries = Object.entries(checks);
643
+ const setupChecksPassed =
644
+ entries.length > 0 && entries.every(([, status]) => status === "passed");
645
+ const targetChecks = await evaluateWatchTargets({ latest, watchTargets });
646
+ const targetChecksPassed = targetChecks.every((target) => target.passed);
647
+ const passed = setupChecksPassed && targetChecksPassed;
648
+ const rendered = entries
649
+ .map(([id, status]) => `${status === "passed" ? "[x]" : "[ ]"} ${id}`)
650
+ .join("\n");
651
+ const renderedTargets = renderWatchTargets(targetChecks);
652
+ process.stdout.write(
653
+ `${rendered}${renderedTargets ? `\n${renderedTargets}` : ""}\n\n`,
654
+ );
655
+ if (passed) {
656
+ process.stdout.write("Clue setup checks passed.\n");
657
+ return { ...latest, watchTargets: targetChecks };
658
+ }
659
+ await sleep(pollIntervalMs);
660
+ }
661
+
662
+ process.stdout.write(`${JSON.stringify(latest, null, 2)}\n`);
663
+ throw new Error("setup-watch timed out before all Clue setup checks passed");
660
664
  };
661
665
 
662
666
  const usage = () =>
663
- [
664
- "Usage:",
665
- " /clue-init",
666
- " clue-ai setup",
667
- " clue-ai setup-detect --repo .",
668
- " clue-ai semantic-inventory --framework fastapi --backend-root-path backend --repo . --output .clue/semantic-routes.json",
669
- " clue-ai semantic-workflow --framework fastapi --backend-root-path backend --repo .",
670
- " clue-ai lifecycle-apply --plan clue-lifecycle-plan.json --repo .",
671
- " clue-ai setup-check --framework fastapi --backend-root-path backend --repo .",
672
- " clue-ai setup-watch --local",
673
- " clue-ai setup-watch --project-key <key> --environment dev --clue-api-base-url <clue-api-base-url> --watch-targets frontend:web[init,identify,set-account,logout,event-sent]=<frontend-url>,backend:api[init,identify,set-account,logout,event-sent]=<backend-url>",
674
- " clue-ai init --request clue-init-request.json --repo .",
675
- " clue-ai semantic-ci --request clue-semantic-request.json --repo . [--previous-snapshot-file previous.json]",
676
- " clue-ai semantic-ci --request-env CLUE_SEMANTIC_REQUEST_JSON --repo .",
677
- ].join("\n");
667
+ [
668
+ "Usage:",
669
+ " /clue-init",
670
+ " clue-ai setup --clue-api-key <key> --clue-api-base-url <url> --project-key <key> --environment dev",
671
+ " clue-ai setup-detect --repo .",
672
+ " clue-ai semantic-inventory --framework fastapi --backend-root-path backend --repo . --output .clue/semantic-routes.json",
673
+ " clue-ai semantic-workflow --framework fastapi --backend-root-path backend --repo .",
674
+ " clue-ai lifecycle-apply --plan clue-lifecycle-plan.json --repo .",
675
+ " clue-ai setup-check --framework fastapi --backend-root-path backend --repo .",
676
+ " clue-ai setup-watch --local",
677
+ " clue-ai setup-watch --project-key <key> --environment dev --clue-api-base-url <clue-api-base-url> --watch-targets frontend:web[init,identify,set-account,logout,event-sent]=<frontend-url>,backend:api[init,identify,set-account,logout,event-sent]=<backend-url>",
678
+ " clue-ai init --request clue-init-request.json --repo .",
679
+ " clue-ai semantic-ci --request clue-semantic-request.json --repo . [--previous-snapshot-file previous.json]",
680
+ " clue-ai semantic-ci --request-env CLUE_SEMANTIC_REQUEST_JSON --repo .",
681
+ ].join("\n");
682
+
683
+ const renderEnvironmentInstructions = (instructions) => {
684
+ if (!instructions || instructions.status !== "ready") {
685
+ return "";
686
+ }
687
+ const lines = ["", instructions.message, ""];
688
+ for (const block of instructions.service_env_blocks) {
689
+ lines.push(
690
+ `[${block.kind}] ${block.root_path} (${block.env_file_candidates.join(" or ")})`,
691
+ block.env_block,
692
+ "",
693
+ );
694
+ }
695
+ lines.push(
696
+ "GitHub Secrets",
697
+ ...instructions.ci_github.secrets.map(
698
+ (entry) => `${entry.name}=${entry.value}`,
699
+ ),
700
+ "",
701
+ "GitHub Variables",
702
+ ...instructions.ci_github.variables.map(
703
+ (entry) => `${entry.name}=${entry.value}`,
704
+ ),
705
+ "",
706
+ );
707
+ return `${lines.join("\n")}\n`;
708
+ };
678
709
 
679
710
  const main = async () => {
680
- const { command, flags } = parseArgs(process.argv.slice(2));
681
- if (command === "help" || flags.has("help")) {
682
- process.stdout.write(`${usage()}\n`);
683
- return;
684
- }
685
-
686
- if (command === "commands") {
687
- process.stdout.write(
688
- `${JSON.stringify({ commands: commandSpecs }, null, 2)}\n`,
689
- );
690
- return;
691
- }
692
-
693
- const requestPath = flags.get("request");
694
- const repoRoot = resolve(String(flags.get("repo") || "."));
695
-
696
- if (command === "setup-watch") {
697
- const report = await runSetupWatch({ flags, repoRoot });
698
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
699
- return;
700
- }
701
-
702
- if (command === "setup") {
703
- const report = await installSetupSkills({
704
- repoRoot,
705
- target:
706
- typeof flags.get("target") === "string"
707
- ? flags.get("target")
708
- : undefined,
709
- });
710
- const preparation = flags.has("skills-only")
711
- ? {
712
- status: "skipped",
713
- reason: "skills-only flag was provided",
714
- }
715
- : await runSetupPrepare({
716
- repoRoot,
717
- target: report.target,
718
- skillRoot: report.skill_root,
719
- });
720
- process.stdout.write(
721
- `${JSON.stringify({ ...report, preparation }, null, 2)}\n`,
722
- );
723
- return;
724
- }
725
-
726
- if (command === "setup-detect") {
727
- const report = await runSetupDetect({
728
- repoRoot,
729
- excludedSourcePaths:
730
- typeof flags.get("excluded-source-paths") === "string"
731
- ? flags
732
- .get("excluded-source-paths")
733
- .split(",")
734
- .map((entry) => entry.trim())
735
- .filter(Boolean)
736
- : [],
737
- });
738
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
739
- if (!report.detected) {
740
- process.exitCode = 1;
741
- }
742
- return;
743
- }
744
-
745
- if (command === "semantic-workflow") {
746
- const request = buildSemanticWorkflowRequestFromFlags({
747
- framework: flags.get("framework"),
748
- backendRootPath: flags.get("backend-root-path"),
749
- allowedSourcePaths: flags.get("allowed-source-paths"),
750
- excludedSourcePaths: flags.get("excluded-source-paths"),
751
- serviceKey: flags.get("service-key"),
752
- workflowPath: flags.get("workflow-path"),
753
- });
754
- const report = await writeSemanticWorkflow({ repoRoot, request });
755
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
756
- return;
757
- }
758
-
759
- if (command === "semantic-inventory") {
760
- const request = buildSemanticWorkflowRequestFromFlags({
761
- framework: flags.get("framework"),
762
- backendRootPath: flags.get("backend-root-path"),
763
- allowedSourcePaths: flags.get("allowed-source-paths"),
764
- excludedSourcePaths: flags.get("excluded-source-paths"),
765
- serviceKey: flags.get("service-key"),
766
- workflowPath: flags.get("workflow-path"),
767
- });
768
- const report = await runSemanticInventory({ repoRoot, request });
769
- await writeJsonIfRequested({
770
- repoRoot,
771
- outputPath: flags.get("output"),
772
- value: report,
773
- });
774
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
775
- return;
776
- }
777
-
778
- if (command === "lifecycle-apply") {
779
- const planPath = flags.get("plan");
780
- if (typeof planPath !== "string") {
781
- throw new Error("--plan is required");
782
- }
783
- const plan = await readJson(resolve(planPath));
784
- const report = await applyLifecyclePlan({ repoRoot, plan });
785
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
786
- return;
787
- }
788
-
789
- if (command === "setup-check") {
790
- const hasInventoryFlags =
791
- typeof flags.get("framework") === "string" &&
792
- typeof flags.get("backend-root-path") === "string";
793
- const request = hasInventoryFlags
794
- ? buildSemanticWorkflowRequestFromFlags({
795
- framework: flags.get("framework"),
796
- backendRootPath: flags.get("backend-root-path"),
797
- allowedSourcePaths: flags.get("allowed-source-paths"),
798
- excludedSourcePaths: flags.get("excluded-source-paths"),
799
- serviceKey: flags.get("service-key"),
800
- workflowPath: flags.get("workflow-path"),
801
- })
802
- : undefined;
803
- const report = await runSetupCheck({
804
- repoRoot,
805
- request,
806
- target:
807
- typeof flags.get("target") === "string"
808
- ? flags.get("target")
809
- : undefined,
810
- requireSdkLifecycle: flags.has("require-sdk-lifecycle"),
811
- });
812
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
813
- if (!report.passed) {
814
- process.exitCode = 1;
815
- }
816
- return;
817
- }
818
-
819
- const requestEnvName = flags.get("request-env");
820
- if (typeof requestPath !== "string" && typeof requestEnvName !== "string") {
821
- throw new Error("--request is required");
822
- }
823
- const request =
824
- typeof requestEnvName === "string"
825
- ? JSON.parse(process.env[requestEnvName] ?? "")
826
- : await readJson(resolve(requestPath));
827
-
828
- if (command === "init") {
829
- const report = await runInitTool({ repoRoot, request });
830
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
831
- return;
832
- }
833
-
834
- if (command === "semantic-ci") {
835
- const previousSnapshotPath =
836
- flags.get("previous-snapshot") ?? flags.get("previous-snapshot-file");
837
- const previousSnapshot =
838
- typeof previousSnapshotPath === "string"
839
- ? await readJson(resolve(previousSnapshotPath))
840
- : undefined;
841
- const result = await runSemanticCi({
842
- repoRoot,
843
- request,
844
- env: process.env,
845
- previousSnapshot,
846
- });
847
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
848
- return;
849
- }
850
-
851
- throw new Error(`Unknown command: ${command}\n${usage()}`);
711
+ const { command, flags } = parseArgs(process.argv.slice(2));
712
+ if (command === "help" || flags.has("help")) {
713
+ process.stdout.write(`${usage()}\n`);
714
+ return;
715
+ }
716
+
717
+ if (command === "commands") {
718
+ process.stdout.write(
719
+ `${JSON.stringify({ commands: commandSpecs }, null, 2)}\n`,
720
+ );
721
+ return;
722
+ }
723
+
724
+ const requestPath = flags.get("request");
725
+ const repoRoot = resolve(String(flags.get("repo") || "."));
726
+
727
+ if (command === "setup-watch") {
728
+ const report = await runSetupWatch({ flags, repoRoot });
729
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
730
+ return;
731
+ }
732
+
733
+ if (command === "setup") {
734
+ const report = await installSetupSkills({
735
+ repoRoot,
736
+ target:
737
+ typeof flags.get("target") === "string"
738
+ ? flags.get("target")
739
+ : undefined,
740
+ });
741
+ const preparation = flags.has("skills-only")
742
+ ? {
743
+ status: "skipped",
744
+ reason: "skills-only flag was provided",
745
+ }
746
+ : await runSetupPrepare({
747
+ repoRoot,
748
+ target: report.target,
749
+ skillRoot: report.skill_root,
750
+ setupContext: {
751
+ clueApiKey: flags.get("clue-api-key"),
752
+ clueApiBaseUrl: flags.get("clue-api-base-url"),
753
+ projectKey: flags.get("project-key"),
754
+ environment: flags.get("environment"),
755
+ },
756
+ });
757
+ const environmentInstructions = renderEnvironmentInstructions(
758
+ preparation.environment_instructions,
759
+ );
760
+ if (environmentInstructions) {
761
+ process.stderr.write(environmentInstructions);
762
+ }
763
+ process.stdout.write(
764
+ `${JSON.stringify({ ...report, preparation }, null, 2)}\n`,
765
+ );
766
+ return;
767
+ }
768
+
769
+ if (command === "setup-detect") {
770
+ const report = await runSetupDetect({
771
+ repoRoot,
772
+ excludedSourcePaths:
773
+ typeof flags.get("excluded-source-paths") === "string"
774
+ ? flags
775
+ .get("excluded-source-paths")
776
+ .split(",")
777
+ .map((entry) => entry.trim())
778
+ .filter(Boolean)
779
+ : [],
780
+ });
781
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
782
+ if (!report.detected) {
783
+ process.exitCode = 1;
784
+ }
785
+ return;
786
+ }
787
+
788
+ if (command === "semantic-workflow") {
789
+ const request = buildSemanticWorkflowRequestFromFlags({
790
+ framework: flags.get("framework"),
791
+ backendRootPath: flags.get("backend-root-path"),
792
+ allowedSourcePaths: flags.get("allowed-source-paths"),
793
+ excludedSourcePaths: flags.get("excluded-source-paths"),
794
+ serviceKey: flags.get("service-key"),
795
+ workflowPath: flags.get("workflow-path"),
796
+ });
797
+ const report = await writeSemanticWorkflow({ repoRoot, request });
798
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
799
+ return;
800
+ }
801
+
802
+ if (command === "semantic-inventory") {
803
+ const request = buildSemanticWorkflowRequestFromFlags({
804
+ framework: flags.get("framework"),
805
+ backendRootPath: flags.get("backend-root-path"),
806
+ allowedSourcePaths: flags.get("allowed-source-paths"),
807
+ excludedSourcePaths: flags.get("excluded-source-paths"),
808
+ serviceKey: flags.get("service-key"),
809
+ workflowPath: flags.get("workflow-path"),
810
+ });
811
+ const report = await runSemanticInventory({ repoRoot, request });
812
+ await writeJsonIfRequested({
813
+ repoRoot,
814
+ outputPath: flags.get("output"),
815
+ value: report,
816
+ });
817
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
818
+ return;
819
+ }
820
+
821
+ if (command === "lifecycle-apply") {
822
+ const planPath = flags.get("plan");
823
+ if (typeof planPath !== "string") {
824
+ throw new Error("--plan is required");
825
+ }
826
+ const plan = await readJson(resolve(planPath));
827
+ const report = await applyLifecyclePlan({ repoRoot, plan });
828
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
829
+ return;
830
+ }
831
+
832
+ if (command === "setup-check") {
833
+ const hasInventoryFlags =
834
+ typeof flags.get("framework") === "string" &&
835
+ typeof flags.get("backend-root-path") === "string";
836
+ const request = hasInventoryFlags
837
+ ? buildSemanticWorkflowRequestFromFlags({
838
+ framework: flags.get("framework"),
839
+ backendRootPath: flags.get("backend-root-path"),
840
+ allowedSourcePaths: flags.get("allowed-source-paths"),
841
+ excludedSourcePaths: flags.get("excluded-source-paths"),
842
+ serviceKey: flags.get("service-key"),
843
+ workflowPath: flags.get("workflow-path"),
844
+ })
845
+ : undefined;
846
+ const report = await runSetupCheck({
847
+ repoRoot,
848
+ request,
849
+ target:
850
+ typeof flags.get("target") === "string"
851
+ ? flags.get("target")
852
+ : undefined,
853
+ requireSdkLifecycle: flags.has("require-sdk-lifecycle"),
854
+ });
855
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
856
+ if (!report.passed) {
857
+ process.exitCode = 1;
858
+ }
859
+ return;
860
+ }
861
+
862
+ const requestEnvName = flags.get("request-env");
863
+ if (typeof requestPath !== "string" && typeof requestEnvName !== "string") {
864
+ throw new Error("--request is required");
865
+ }
866
+ const request =
867
+ typeof requestEnvName === "string"
868
+ ? JSON.parse(process.env[requestEnvName] ?? "")
869
+ : await readJson(resolve(requestPath));
870
+
871
+ if (command === "init") {
872
+ const report = await runInitTool({ repoRoot, request });
873
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
874
+ return;
875
+ }
876
+
877
+ if (command === "semantic-ci") {
878
+ const previousSnapshotPath =
879
+ flags.get("previous-snapshot") ?? flags.get("previous-snapshot-file");
880
+ const previousSnapshot =
881
+ typeof previousSnapshotPath === "string"
882
+ ? await readJson(resolve(previousSnapshotPath))
883
+ : undefined;
884
+ const result = await runSemanticCi({
885
+ repoRoot,
886
+ request,
887
+ env: process.env,
888
+ previousSnapshot,
889
+ });
890
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
891
+ return;
892
+ }
893
+
894
+ throw new Error(`Unknown command: ${command}\n${usage()}`);
852
895
  };
853
896
 
854
897
  main().catch((error) => {
855
- process.stderr.write(
856
- `${error instanceof Error ? error.message : String(error)}\n`,
857
- );
858
- process.exitCode = 1;
898
+ process.stderr.write(
899
+ `${error instanceof Error ? error.message : String(error)}\n`,
900
+ );
901
+ process.exitCode = 1;
859
902
  });