@clue-ai/cli 0.0.3 → 0.0.5

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.
@@ -0,0 +1,859 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "node:http";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
6
+ import { commandSpecs } from "../src/command-spec.mjs";
7
+ import {
8
+ buildSemanticWorkflowRequestFromFlags,
9
+ runInitTool,
10
+ writeSemanticWorkflow,
11
+ } from "../src/init-tool.mjs";
12
+ import { applyLifecyclePlan } from "../src/lifecycle-init.mjs";
13
+ import { runSemanticCi, runSemanticInventory } from "../src/semantic-ci.mjs";
14
+ import { runSetupCheck } from "../src/setup-check.mjs";
15
+ import { runSetupDetect } from "../src/setup-detect.mjs";
16
+ import { runSetupPrepare } from "../src/setup-prepare.mjs";
17
+ import { installSetupSkills } from "../src/setup-tool.mjs";
18
+
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 };
35
+ };
36
+
37
+ const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
38
+
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;
54
+ };
55
+
56
+ const sleep = (ms) =>
57
+ new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
58
+
59
+ const splitCsv = (value) =>
60
+ typeof value === "string"
61
+ ? value
62
+ .split(",")
63
+ .map((entry) => entry.trim())
64
+ .filter(Boolean)
65
+ : [];
66
+
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;
84
+ };
85
+
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"],
101
+ ]);
102
+
103
+ const DEFAULT_WATCH_LIFECYCLE = [
104
+ "init",
105
+ "identify",
106
+ "set-account",
107
+ "logout",
108
+ "event-sent",
109
+ ];
110
+ const DEFAULT_SETUP_MANIFEST_PATH = ".clue/setup-manifest.json";
111
+
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)];
130
+ };
131
+
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
+ });
152
+
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
+ };
193
+ };
194
+
195
+ const readSetupManifest = async ({ repoRoot, manifestPath }) => {
196
+ const resolvedPath = resolve(repoRoot, manifestPath);
197
+ return readJson(resolvedPath);
198
+ };
199
+
200
+ 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);
206
+ };
207
+
208
+ 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;
215
+ };
216
+
217
+ const shouldAskQuestions = ({ flags }) =>
218
+ !flags.has("yes") && process.stdin.isTTY && process.stdout.isTTY;
219
+
220
+ 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
+ }
248
+ };
249
+
250
+ const uniqueCsv = (values) => [...new Set(values.filter(Boolean))].join(",");
251
+
252
+ const buildWatchProducerIds = ({ explicitProducerIds, watchTargets }) =>
253
+ uniqueCsv([
254
+ ...splitCsv(explicitProducerIds),
255
+ ...watchTargets.map((target) => target.producerId),
256
+ ]);
257
+
258
+ 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
+ }
270
+ };
271
+
272
+ 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
+ );
317
+ };
318
+
319
+ 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
+ };
332
+ };
333
+
334
+ 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 };
387
+ };
388
+
389
+ 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);
397
+ };
398
+
399
+ 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
+ };
462
+ };
463
+
464
+ 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");
482
+
483
+ const setupCheckUrl = ({
484
+ clueApiBaseUrl,
485
+ environment,
486
+ limit,
487
+ producerIds,
488
+ projectId,
489
+ projectKey,
490
+ startedAt,
491
+ }) => {
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;
505
+ };
506
+
507
+ 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");
660
+ };
661
+
662
+ 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");
678
+
679
+ 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()}`);
852
+ };
853
+
854
+ main().catch((error) => {
855
+ process.stderr.write(
856
+ `${error instanceof Error ? error.message : String(error)}\n`,
857
+ );
858
+ process.exitCode = 1;
859
+ });