@clue-ai/cli 0.0.4 → 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
@@ -1,9 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
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";
4
6
  import { commandSpecs } from "../src/command-spec.mjs";
5
- import { runInitTool } from "../src/init-tool.mjs";
6
- import { runSemanticCi } from "../src/semantic-ci.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";
7
17
  import { installSetupSkills } from "../src/setup-tool.mjs";
8
18
 
9
19
  const parseArgs = (argv) => {
@@ -26,13 +36,676 @@ const parseArgs = (argv) => {
26
36
 
27
37
  const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
28
38
 
29
- const usage = () => [
30
- "Usage:",
31
- " /clue-init",
32
- " clue-ai setup",
33
- " clue-ai init --request clue-init-request.json --repo .",
34
- " clue-ai semantic-ci --request clue-semantic-request.json --repo .",
35
- ].join("\n");
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(
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
+ };
195
+ };
196
+
197
+ const readSetupManifest = async ({ repoRoot, manifestPath }) => {
198
+ const resolvedPath = resolve(repoRoot, manifestPath);
199
+ return readJson(resolvedPath);
200
+ };
201
+
202
+ const manifestWatchTargets = (manifest) => {
203
+ const targets = manifest?.lifecycle_verification?.watch_targets;
204
+ if (!Array.isArray(targets) || targets.length === 0) {
205
+ return [];
206
+ }
207
+ return targets.map(normalizeManifestWatchTarget);
208
+ };
209
+
210
+ const resolveTargetUrlFromEnv = ({ target, env }) => {
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;
217
+ };
218
+
219
+ const shouldAskQuestions = ({ flags }) =>
220
+ !flags.has("yes") && process.stdin.isTTY && process.stdout.isTTY;
221
+
222
+ const confirmTargetUrls = async ({ flags, watchTargets, env }) => {
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
+ }
250
+ };
251
+
252
+ const uniqueCsv = (values) => [...new Set(values.filter(Boolean))].join(",");
253
+
254
+ const buildWatchProducerIds = ({ explicitProducerIds, watchTargets }) =>
255
+ uniqueCsv([
256
+ ...splitCsv(explicitProducerIds),
257
+ ...watchTargets.map((target) => target.producerId),
258
+ ]);
259
+
260
+ const checkTargetUrl = async (url) => {
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
+ }
272
+ };
273
+
274
+ const evaluateWatchTargets = async ({ latest, watchTargets }) => {
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
+ );
319
+ };
320
+
321
+ const lifecycleStatusFromEvents = (events) => {
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
+ };
334
+ };
335
+
336
+ const localSetupCheckSnapshot = ({ receivedBatches }) => {
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 };
389
+ };
390
+
391
+ const readRequestJson = async (request) => {
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);
399
+ };
400
+
401
+ const startLocalSetupReceiver = async ({ host, port }) => {
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
+ };
468
+ };
469
+
470
+ const renderWatchTargets = (targetChecks) =>
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");
488
+
489
+ const setupCheckUrl = ({
490
+ clueApiBaseUrl,
491
+ environment,
492
+ limit,
493
+ producerIds,
494
+ projectId,
495
+ projectKey,
496
+ startedAt,
497
+ }) => {
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;
511
+ };
512
+
513
+ const runSetupWatch = async ({ flags, repoRoot = ".", env = process.env }) => {
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");
664
+ };
665
+
666
+ const usage = () =>
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
+ };
36
709
 
37
710
  const main = async () => {
38
711
  const { command, flags } = parseArgs(process.argv.slice(2));
@@ -42,25 +715,158 @@ const main = async () => {
42
715
  }
43
716
 
44
717
  if (command === "commands") {
45
- process.stdout.write(`${JSON.stringify({ commands: commandSpecs }, null, 2)}\n`);
718
+ process.stdout.write(
719
+ `${JSON.stringify({ commands: commandSpecs }, null, 2)}\n`,
720
+ );
46
721
  return;
47
722
  }
48
723
 
49
724
  const requestPath = flags.get("request");
50
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
+
51
733
  if (command === "setup") {
52
734
  const report = await installSetupSkills({
53
735
  repoRoot,
54
- target: typeof flags.get("target") === "string" ? flags.get("target") : undefined,
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,
55
816
  });
56
817
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
57
818
  return;
58
819
  }
59
820
 
60
- if (typeof requestPath !== "string") {
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") {
61
864
  throw new Error("--request is required");
62
865
  }
63
- const request = await readJson(resolve(requestPath));
866
+ const request =
867
+ typeof requestEnvName === "string"
868
+ ? JSON.parse(process.env[requestEnvName] ?? "")
869
+ : await readJson(resolve(requestPath));
64
870
 
65
871
  if (command === "init") {
66
872
  const report = await runInitTool({ repoRoot, request });
@@ -69,7 +875,18 @@ const main = async () => {
69
875
  }
70
876
 
71
877
  if (command === "semantic-ci") {
72
- const result = await runSemanticCi({ repoRoot, request, env: process.env });
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
+ });
73
890
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
74
891
  return;
75
892
  }
@@ -78,6 +895,8 @@ const main = async () => {
78
895
  };
79
896
 
80
897
  main().catch((error) => {
81
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
898
+ process.stderr.write(
899
+ `${error instanceof Error ? error.message : String(error)}\n`,
900
+ );
82
901
  process.exitCode = 1;
83
902
  });