@0dai-dev/cli 4.2.0 → 4.3.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.
Files changed (52) hide show
  1. package/README.md +98 -10
  2. package/bin/0dai.js +298 -60
  3. package/lib/commands/audit.js +13 -0
  4. package/lib/commands/auth.js +344 -98
  5. package/lib/commands/boneyard.js +44 -0
  6. package/lib/commands/ci.js +329 -0
  7. package/lib/commands/compliance.js +20 -0
  8. package/lib/commands/doctor.js +39 -1
  9. package/lib/commands/experience.js +5 -1
  10. package/lib/commands/feedback.js +92 -5
  11. package/lib/commands/gh.js +506 -0
  12. package/lib/commands/graph.js +78 -10
  13. package/lib/commands/heatmap.js +17 -0
  14. package/lib/commands/import_claude_code_agents.js +367 -0
  15. package/lib/commands/init.js +504 -28
  16. package/lib/commands/loop.js +108 -0
  17. package/lib/commands/mcp.js +410 -0
  18. package/lib/commands/models.js +27 -3
  19. package/lib/commands/paste.js +114 -0
  20. package/lib/commands/play.js +173 -0
  21. package/lib/commands/provider.js +69 -0
  22. package/lib/commands/quota.js +76 -0
  23. package/lib/commands/receipt.js +53 -0
  24. package/lib/commands/report.js +29 -2
  25. package/lib/commands/run.js +104 -7
  26. package/lib/commands/runner.js +527 -0
  27. package/lib/commands/session.js +1 -7
  28. package/lib/commands/standup.js +40 -0
  29. package/lib/commands/status.js +30 -1
  30. package/lib/commands/swarm.js +97 -4
  31. package/lib/commands/tui.js +81 -13
  32. package/lib/commands/upgrade.js +58 -0
  33. package/lib/commands/usage.js +87 -0
  34. package/lib/commands/vault.js +246 -0
  35. package/lib/onboarding.js +9 -3
  36. package/lib/shared.js +29 -14
  37. package/lib/utils/activation_telemetry.js +156 -0
  38. package/lib/utils/auth.js +1 -0
  39. package/lib/utils/canonical-counts.js +54 -0
  40. package/lib/utils/constants.js +7 -0
  41. package/lib/utils/diff-preview.js +192 -0
  42. package/lib/utils/identity.js +76 -18
  43. package/lib/utils/mcp-auth.js +607 -0
  44. package/lib/utils/plan.js +47 -2
  45. package/lib/utils/run_cost.js +91 -0
  46. package/lib/vault/cipher.js +125 -0
  47. package/lib/vault/identity.js +122 -0
  48. package/lib/vault/index.js +184 -0
  49. package/lib/vault/storage.js +84 -0
  50. package/lib/wizard.js +19 -12
  51. package/package.json +8 -4
  52. package/lib/tui/index.mjs +0 -34610
package/lib/shared.js CHANGED
@@ -60,7 +60,7 @@ const DRIFT_TRACKED_CONFIGS = [
60
60
  ];
61
61
 
62
62
  // --- API ---
63
- function apiCall(endpoint, data) {
63
+ function apiCall(endpoint, data, options = {}) {
64
64
  return new Promise((resolve) => {
65
65
  const url = new URL(endpoint, API_URL);
66
66
  const mod = url.protocol === "https:" ? https : http;
@@ -71,11 +71,16 @@ function apiCall(endpoint, data) {
71
71
  "X-CLI-Version": VERSION,
72
72
  "X-Client-Channel": "npm",
73
73
  };
74
- try {
75
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
76
- const token = auth.api_key || auth.access_token || auth.token;
77
- if (token) headers["Authorization"] = `Bearer ${token}`;
78
- } catch {}
74
+ const accessToken = options && options.accessToken ? String(options.accessToken).trim() : "";
75
+ if (accessToken) {
76
+ headers["Authorization"] = `Bearer ${accessToken}`;
77
+ } else {
78
+ try {
79
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
80
+ const token = auth.api_key || auth.access_token || auth.token;
81
+ if (token) headers["Authorization"] = `Bearer ${token}`;
82
+ } catch {}
83
+ }
79
84
  const opts = {
80
85
  hostname: url.hostname,
81
86
  port: url.port || (url.protocol === "https:" ? 443 : 80),
@@ -116,12 +121,14 @@ function updateAuthState(patch) {
116
121
  saveAuthState({ ...current, ...patch });
117
122
  }
118
123
 
119
- async function fetchAuthStatus() {
120
- const status = await apiCall("/v1/auth/status");
121
- if (status && !status.error && status.email) {
124
+ async function fetchAuthStatus(accessToken = "") {
125
+ const token = String(accessToken || "").trim();
126
+ const status = await apiCall("/v1/auth/status", null, token ? { accessToken: token } : undefined);
127
+ if (status && !status.error && status.email && !token) {
122
128
  updateAuthState({
123
129
  email: status.email, plan: status.plan || "free",
124
130
  name: status.name || "", license: status.license || {},
131
+ plan_expires_at: status.plan_expires_at || "",
125
132
  });
126
133
  }
127
134
  return status;
@@ -302,6 +309,8 @@ function mergeSettingsJson(existing, incoming) {
302
309
  function writeFiles(target, files) {
303
310
  let created = 0, updated = 0, unchanged = 0, merged = 0, skipped = 0;
304
311
  const targetResolved = path.resolve(target);
312
+ let backupDir = null;
313
+ const backupConfigs = new Set(["CLAUDE.md", "AGENTS.md"]);
305
314
  for (const [rel, content] of Object.entries(files)) {
306
315
  if (typeof rel !== "string" || !rel || path.isAbsolute(rel) || rel.split(/[/\\]/).includes("..")) {
307
316
  skipped++; continue;
@@ -314,11 +323,16 @@ function writeFiles(target, files) {
314
323
  const existing = fs.readFileSync(p, "utf8");
315
324
  if (existing === content) { unchanged++; continue; }
316
325
  if (rel.endsWith("settings.json")) { finalContent = mergeSettingsJson(existing, content); merged++; }
317
- else if (rel === "AGENTS.md") {
318
- if (existing.includes("managed: false")) { unchanged++; continue; }
319
- const backupDir = path.join(target, "ai", ".backups");
320
- fs.mkdirSync(backupDir, { recursive: true });
321
- fs.writeFileSync(path.join(backupDir, "AGENTS.md.bak"), existing, "utf8");
326
+ else if (backupConfigs.has(rel)) {
327
+ if (rel === "AGENTS.md" && existing.includes("managed: false")) { unchanged++; continue; }
328
+ if (!backupDir) {
329
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
330
+ backupDir = path.join(targetResolved, "ai", ".backups", timestamp);
331
+ fs.mkdirSync(backupDir, { recursive: true });
332
+ }
333
+ const backupPath = path.join(backupDir, rel);
334
+ fs.writeFileSync(backupPath, existing, "utf8");
335
+ log(`backed up existing ${rel} to ${backupPath}`);
322
336
  updated++;
323
337
  } else { updated++; }
324
338
  } else { created++; }
@@ -337,6 +351,7 @@ function findRepoScript(target, scriptName) {
337
351
  path.join(target, "scripts", scriptName),
338
352
  path.join(process.cwd(), "scripts", scriptName),
339
353
  path.join(__dirname, "..", "..", "..", "scripts", scriptName),
354
+ path.join(__dirname, "..", "..", "..", "..", "scripts", scriptName),
340
355
  ];
341
356
  for (const c of candidates) { if (fs.existsSync(c)) return c; }
342
357
  return null;
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ function activationPath(target) {
7
+ return path.join(target, "ai", "meta", "telemetry", "activation.jsonl");
8
+ }
9
+
10
+ function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+
14
+ function ensureDir(filePath) {
15
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
16
+ }
17
+
18
+ function readEvents(target) {
19
+ const file = activationPath(target);
20
+ if (!fs.existsSync(file)) return [];
21
+ const lines = fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean);
22
+ const events = [];
23
+ for (const ln of lines) {
24
+ try {
25
+ const row = JSON.parse(ln);
26
+ if (row && row.event) events.push(row);
27
+ } catch { /* ignore malformed lines */ }
28
+ }
29
+ return events;
30
+ }
31
+
32
+ function appendEvent(target, entry) {
33
+ const file = activationPath(target);
34
+ try {
35
+ ensureDir(file);
36
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n", "utf8");
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function hasEvent(events, eventName, projectId) {
44
+ return events.some((e) => e.event === eventName && e.project_id === projectId);
45
+ }
46
+
47
+ function findInitTs(events, projectId) {
48
+ const init = events.find((e) => e.event === "init" && e.project_id === projectId);
49
+ return init && init.ts ? init.ts : null;
50
+ }
51
+
52
+ function parseTs(ts) {
53
+ const ms = Date.parse(ts);
54
+ return Number.isFinite(ms) ? ms : null;
55
+ }
56
+
57
+ // Idempotently append `{event:"init", ts, project_id}` on successful init.
58
+ function recordActivationInit(target, projectId) {
59
+ try {
60
+ if (!projectId) return { fired: false };
61
+ const events = readEvents(target);
62
+ if (hasEvent(events, "init", projectId)) return { fired: false };
63
+ const entry = { event: "init", ts: nowIso(), project_id: projectId };
64
+ return { fired: appendEvent(target, entry) };
65
+ } catch {
66
+ return { fired: false };
67
+ }
68
+ }
69
+
70
+ // Idempotently append first_task with duration_ms = first_task.ts - init.ts.
71
+ function recordActivationFirstTask(target, projectId) {
72
+ try {
73
+ if (!projectId) return { fired: false, durationMs: null };
74
+ const events = readEvents(target);
75
+ if (hasEvent(events, "first_task", projectId)) {
76
+ return { fired: false, durationMs: null };
77
+ }
78
+
79
+ const initTs = findInitTs(events, projectId);
80
+ const ts = nowIso();
81
+ let durationMs = null;
82
+ if (initTs) {
83
+ const initMs = parseTs(initTs);
84
+ const taskMs = parseTs(ts);
85
+ if (initMs != null && taskMs != null) {
86
+ durationMs = Math.max(0, taskMs - initMs);
87
+ }
88
+ }
89
+
90
+ const entry = {
91
+ event: "first_task",
92
+ ts,
93
+ project_id: projectId,
94
+ duration_ms: durationMs,
95
+ };
96
+ const fired = appendEvent(target, entry);
97
+ return { fired, durationMs };
98
+ } catch {
99
+ return { fired: false, durationMs: null };
100
+ }
101
+ }
102
+
103
+ function percentile(sorted, p) {
104
+ if (!sorted.length) return null;
105
+ if (sorted.length === 1) return sorted[0];
106
+ const idx = (sorted.length - 1) * p;
107
+ const lo = Math.floor(idx);
108
+ const hi = Math.ceil(idx);
109
+ if (lo === hi) return sorted[lo];
110
+ const weight = idx - lo;
111
+ return Math.round(sorted[lo] * (1 - weight) + sorted[hi] * weight);
112
+ }
113
+
114
+ function getActivationDurationStats(target) {
115
+ const events = readEvents(target);
116
+ const durations = events
117
+ .filter((e) => e.event === "first_task"
118
+ && typeof e.duration_ms === "number"
119
+ && Number.isFinite(e.duration_ms))
120
+ .map((e) => e.duration_ms)
121
+ .sort((a, b) => a - b);
122
+
123
+ return {
124
+ count: durations.length,
125
+ p50_ms: percentile(durations, 0.5),
126
+ p90_ms: percentile(durations, 0.9),
127
+ };
128
+ }
129
+
130
+ function formatDurationMs(ms) {
131
+ if (ms == null) return "?";
132
+ if (ms < 1000) return `${ms}ms`;
133
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
134
+ return `${(ms / 60000).toFixed(1)}m`;
135
+ }
136
+
137
+ function printActivationStats(target) {
138
+ const stats = getActivationDurationStats(target);
139
+ if (stats.count === 0) return stats;
140
+ console.log(
141
+ ` activation TTFV: p50 ${formatDurationMs(stats.p50_ms)}`
142
+ + ` / p90 ${formatDurationMs(stats.p90_ms)}`
143
+ + ` (${stats.count} sample${stats.count === 1 ? "" : "s"})`,
144
+ );
145
+ return stats;
146
+ }
147
+
148
+ module.exports = {
149
+ activationPath,
150
+ readEvents,
151
+ recordActivationInit,
152
+ recordActivationFirstTask,
153
+ getActivationDurationStats,
154
+ printActivationStats,
155
+ formatDurationMs,
156
+ };
package/lib/utils/auth.js CHANGED
@@ -85,6 +85,7 @@ async function fetchAuthStatus(apiCallFn, API_URL) {
85
85
  plan: status.plan || "free",
86
86
  name: status.name || "",
87
87
  license: status.license || {},
88
+ plan_expires_at: status.plan_expires_at || "",
88
89
  });
89
90
  }
90
91
  return status;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Runtime reader for generated canonical product counts.
3
+ */
4
+ "use strict";
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const { SUPPORTED_CLIS } = require("./constants");
9
+
10
+ const FALLBACK = Object.freeze({
11
+ schema_version: 0,
12
+ supported_agents: SUPPORTED_CLIS.map((cli) => cli.name),
13
+ supported_agents_total: SUPPORTED_CLIS.length,
14
+ supported_runtime_agents: SUPPORTED_CLIS.map((cli) => cli.name),
15
+ supported_runtime_agents_total: SUPPORTED_CLIS.length,
16
+ supported_agent_clis: SUPPORTED_CLIS.map((cli) => cli.name),
17
+ supported_agent_cli_display_names: SUPPORTED_CLIS.map((cli) => cli.name),
18
+ agent_clis_total: SUPPORTED_CLIS.length,
19
+ mcp_tools_total: null,
20
+ free_mcp_tools_total: null,
21
+ pro_mcp_tools_total: null,
22
+ cli_commands_total: null,
23
+ integrations_total: null,
24
+ });
25
+
26
+ function candidatePaths() {
27
+ const envPath = process.env.ODAI_CANONICAL_COUNTS_PATH;
28
+ return [
29
+ envPath,
30
+ path.resolve(__dirname, "../../../../ai/manifest/canonical-counts.json"),
31
+ path.resolve(process.cwd(), "ai/manifest/canonical-counts.json"),
32
+ ].filter(Boolean);
33
+ }
34
+
35
+ function loadCanonicalCounts() {
36
+ for (const candidate of candidatePaths()) {
37
+ try {
38
+ if (!fs.existsSync(candidate)) continue;
39
+ const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
40
+ if (parsed && typeof parsed === "object") {
41
+ return { ...FALLBACK, ...parsed };
42
+ }
43
+ } catch {}
44
+ }
45
+ return { ...FALLBACK };
46
+ }
47
+
48
+ function mcpToolsLabel(counts = loadCanonicalCounts()) {
49
+ return Number.isInteger(counts.mcp_tools_total)
50
+ ? `${counts.mcp_tools_total} MCP tools`
51
+ : "MCP tools";
52
+ }
53
+
54
+ module.exports = { loadCanonicalCounts, mcpToolsLabel };
@@ -46,6 +46,13 @@ const SUPPORTED_CLIS = [
46
46
  altAuth: null,
47
47
  agentFiles: [".qoder/settings.json"],
48
48
  },
49
+ {
50
+ name: "cursor", bin: "cursor-agent",
51
+ pkg: null, pkgType: "curl",
52
+ install: "curl https://cursor.com/install -fsS | bash",
53
+ altAuth: "Cursor Pro/Business subscription",
54
+ agentFiles: [".cursor/mcp.json", ".cursor/hooks.json"],
55
+ },
49
56
  ];
50
57
 
51
58
  const MANIFEST_FILES = [
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const readline = require("readline");
5
+
6
+ function _lines(text) {
7
+ if (text == null) return [];
8
+ const s = String(text);
9
+ return s.length === 0 ? [] : s.split(/\r?\n/);
10
+ }
11
+
12
+ function unifiedDiff(relPath, before, after, context = 3) {
13
+ const a = _lines(before);
14
+ const b = _lines(after);
15
+ if (a.join("\n") === b.join("\n")) return "";
16
+
17
+ const n = a.length;
18
+ const m = b.length;
19
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
20
+ for (let i = n - 1; i >= 0; i--) {
21
+ for (let j = m - 1; j >= 0; j--) {
22
+ if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1;
23
+ else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
24
+ }
25
+ }
26
+
27
+ const ops = [];
28
+ let i = 0, j = 0;
29
+ while (i < n && j < m) {
30
+ if (a[i] === b[j]) { ops.push(["=", a[i]]); i++; j++; }
31
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push(["-", a[i]]); i++; }
32
+ else { ops.push(["+", b[j]]); j++; }
33
+ }
34
+ while (i < n) { ops.push(["-", a[i++]]); }
35
+ while (j < m) { ops.push(["+", b[j++]]); }
36
+
37
+ const hunks = [];
38
+ let k = 0;
39
+ while (k < ops.length) {
40
+ if (ops[k][0] === "=") { k++; continue; }
41
+ let start = Math.max(0, k - context);
42
+ let end = k;
43
+ while (end < ops.length) {
44
+ if (ops[end][0] !== "=") { end++; continue; }
45
+ let run = 0;
46
+ let peek = end;
47
+ while (peek < ops.length && ops[peek][0] === "=") { peek++; run++; }
48
+ if (run > context * 2 || peek === ops.length) {
49
+ end += Math.min(run, context);
50
+ break;
51
+ }
52
+ end = peek;
53
+ }
54
+ hunks.push([start, end]);
55
+ k = end;
56
+ }
57
+
58
+ const out = [`--- a/${relPath}`, `+++ b/${relPath}`];
59
+ for (const [hs, he] of hunks) {
60
+ let oldStart = 0, oldLen = 0, newStart = 0, newLen = 0;
61
+ for (let p = 0; p < hs; p++) {
62
+ if (ops[p][0] !== "+") oldStart++;
63
+ if (ops[p][0] !== "-") newStart++;
64
+ }
65
+ for (let p = hs; p < he; p++) {
66
+ if (ops[p][0] !== "+") oldLen++;
67
+ if (ops[p][0] !== "-") newLen++;
68
+ }
69
+ out.push(`@@ -${oldStart + 1},${oldLen} +${newStart + 1},${newLen} @@`);
70
+ for (let p = hs; p < he; p++) {
71
+ const [tag, line] = ops[p];
72
+ const prefix = tag === "=" ? " " : tag;
73
+ out.push(prefix + line);
74
+ }
75
+ }
76
+ return out.join("\n");
77
+ }
78
+
79
+ function isHashOnlyFile(value) {
80
+ return !!(
81
+ value &&
82
+ typeof value === "object" &&
83
+ !Array.isArray(value) &&
84
+ Number.isFinite(Number(value.size)) &&
85
+ typeof value.sha256 === "string" &&
86
+ /^[a-f0-9]{64}$/i.test(value.sha256)
87
+ );
88
+ }
89
+
90
+ function formatFileSize(size) {
91
+ const bytes = Number(size) || 0;
92
+ if (bytes < 1000) return `${bytes}B`;
93
+ if (bytes < 1000 * 1000) return `${Math.round(bytes / 1000)}KB`;
94
+ return `${Math.round(bytes / 100000) / 10}MB`;
95
+ }
96
+
97
+ function shortSha256(sha256) {
98
+ const value = String(sha256 || "");
99
+ return value.length > 12 ? `${value.slice(0, 12)}...` : value;
100
+ }
101
+
102
+ function hashText(text) {
103
+ return crypto.createHash("sha256").update(String(text), "utf8").digest("hex");
104
+ }
105
+
106
+ function descriptorForText(text) {
107
+ const value = String(text);
108
+ return {
109
+ size: Buffer.byteLength(value, "utf8"),
110
+ sha256: hashText(value),
111
+ compare: "hash-only",
112
+ };
113
+ }
114
+
115
+ function formatHashOnlyFile(value) {
116
+ return `size: ${formatFileSize(value.size)} sha256: ${shortSha256(value.sha256)} [hash-only compare]`;
117
+ }
118
+
119
+ function fileValueEqual(a, b) {
120
+ if (a === b) return true;
121
+ if (a == null || b == null) return a == null && b == null;
122
+ const aHashOnly = isHashOnlyFile(a);
123
+ const bHashOnly = isHashOnlyFile(b);
124
+ if (!aHashOnly && !bHashOnly) return false;
125
+ const aDescriptor = aHashOnly ? a : descriptorForText(a);
126
+ const bDescriptor = bHashOnly ? b : descriptorForText(b);
127
+ return Number(aDescriptor.size) === Number(bDescriptor.size) &&
128
+ String(aDescriptor.sha256).toLowerCase() === String(bDescriptor.sha256).toLowerCase();
129
+ }
130
+
131
+ function valueForDiff(value, peerValue) {
132
+ if (isHashOnlyFile(value)) return formatHashOnlyFile(value);
133
+ if (value == null) return "";
134
+ if (isHashOnlyFile(peerValue)) return formatHashOnlyFile(descriptorForText(value));
135
+ return String(value);
136
+ }
137
+
138
+ function renderFileMapDiff(changes, currentFiles = {}) {
139
+ const parts = [];
140
+ const summary = { added: [], modified: [], removed: [] };
141
+ for (const rel of Object.keys(changes).sort()) {
142
+ const hasCurrent = Object.prototype.hasOwnProperty.call(currentFiles, rel);
143
+ const before = hasCurrent ? currentFiles[rel] : "";
144
+ const after = changes[rel];
145
+ if (fileValueEqual(before, after)) continue;
146
+ if (!hasCurrent) summary.added.push(rel);
147
+ else if (after == null) summary.removed.push(rel);
148
+ else summary.modified.push(rel);
149
+ const d = unifiedDiff(rel, valueForDiff(before, after), valueForDiff(after, before));
150
+ if (d) parts.push(d);
151
+ }
152
+ return { diff: parts.join("\n"), summary };
153
+ }
154
+
155
+ function shouldAutoYes(args = []) {
156
+ if (args.includes("--yes") || args.includes("-y")) return true;
157
+ if (process.env.ODAI_ASSUME_YES === "1") return true;
158
+ if (process.env.CI === "true") return true;
159
+ if (!process.stdin.isTTY) return true;
160
+ return false;
161
+ }
162
+
163
+ function promptConfirm(question, defaultYes = false) {
164
+ return new Promise((resolve) => {
165
+ if (!process.stdin.isTTY) { resolve(defaultYes); return; }
166
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
167
+ const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
168
+ rl.question(question + suffix, (ans) => {
169
+ rl.close();
170
+ const a = String(ans || "").trim().toLowerCase();
171
+ if (!a) return resolve(defaultYes);
172
+ resolve(a === "y" || a === "yes");
173
+ });
174
+ });
175
+ }
176
+
177
+ async function confirmOrExit({ args = [], quiet = false, message = "Apply these changes?", defaultYes = false } = {}) {
178
+ if (quiet) return true;
179
+ if (shouldAutoYes(args)) return true;
180
+ const ok = await promptConfirm(message, defaultYes);
181
+ return ok;
182
+ }
183
+
184
+ module.exports = {
185
+ unifiedDiff,
186
+ renderFileMapDiff,
187
+ isHashOnlyFile,
188
+ formatHashOnlyFile,
189
+ shouldAutoYes,
190
+ promptConfirm,
191
+ confirmOrExit,
192
+ };
@@ -30,31 +30,85 @@ function deviceFingerprint() {
30
30
  }
31
31
 
32
32
  function registerProject(projectPath, name, stack, CONFIG_DIR, PROJECTS_FILE) {
33
+ if (!CONFIG_DIR || !PROJECTS_FILE) {
34
+ throw new Error("registerProject requires CONFIG_DIR and PROJECTS_FILE");
35
+ }
36
+
37
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
38
+ let projects = [];
39
+ if (fs.existsSync(PROJECTS_FILE)) {
40
+ const parsed = JSON.parse(fs.readFileSync(PROJECTS_FILE, "utf8"));
41
+ projects = Array.isArray(parsed && parsed.projects) ? parsed.projects : [];
42
+ }
43
+
44
+ const abs = path.resolve(projectPath);
45
+ const idx = projects.findIndex(p => p.path === abs);
46
+ const entry = { path: abs, name: name || path.basename(abs), stack: stack || "?", last_seen: new Date().toISOString() };
47
+ if (idx >= 0) projects[idx] = entry;
48
+ else projects.unshift(entry);
49
+ fs.writeFileSync(PROJECTS_FILE, JSON.stringify({ projects: projects.slice(0, 50) }, null, 2));
50
+ return entry;
51
+ }
52
+
53
+ function scrubRemoteUrl(url) {
54
+ return String(url || "").replace(/^(https?:\/\/)[^@/\s]+@/, "$1");
55
+ }
56
+
57
+ function parseScalar(value) {
58
+ return String(value || "").trim().replace(/^["']|["']$/g, "");
59
+ }
60
+
61
+ function readProjectManifest(target) {
62
+ const file = path.join(target, "ai", "manifest", "project.yaml");
63
+ const result = {};
64
+ let inProject = false;
33
65
  try {
34
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
35
- let projects = [];
36
- try { projects = JSON.parse(fs.readFileSync(PROJECTS_FILE, "utf8")).projects || []; } catch {}
37
- const abs = path.resolve(projectPath);
38
- const idx = projects.findIndex(p => p.path === abs);
39
- const entry = { path: abs, name: name || path.basename(abs), stack: stack || "?", last_seen: new Date().toISOString() };
40
- if (idx >= 0) projects[idx] = entry;
41
- else projects.unshift(entry);
42
- fs.writeFileSync(PROJECTS_FILE, JSON.stringify({ projects: projects.slice(0, 50) }, null, 2));
66
+ const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
67
+ for (const raw of lines) {
68
+ const line = raw.trim();
69
+ if (!line || line.startsWith("#")) continue;
70
+ const topLevel = raw.length === raw.trimStart().length;
71
+ if (topLevel) inProject = false;
72
+ if (topLevel && line === "project:") {
73
+ inProject = true;
74
+ continue;
75
+ }
76
+ if (topLevel && line.startsWith("name:")) result.name = parseScalar(line.split(":", 2)[1]);
77
+ else if (topLevel && line.startsWith("stack:")) result.stack = parseScalar(line.split(":", 2)[1]);
78
+ else if (topLevel && line.startsWith("repo:")) result.repo = scrubRemoteUrl(parseScalar(line.split(":", 2)[1]));
79
+ else if (inProject && line.startsWith("name:") && !result.name) result.name = parseScalar(line.split(":", 2)[1]);
80
+ else if (inProject && line.startsWith("stack:") && !result.stack) result.stack = parseScalar(line.split(":", 2)[1]);
81
+ else if (inProject && line.startsWith("type:") && !result.stack) result.stack = parseScalar(line.split(":", 2)[1]);
82
+ else if (inProject && line.startsWith("repo:") && !result.repo) result.repo = scrubRemoteUrl(parseScalar(line.split(":", 2)[1]));
83
+ }
43
84
  } catch {}
85
+ return result;
86
+ }
87
+
88
+ function readDiscovery(target) {
89
+ try {
90
+ return JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
91
+ } catch {
92
+ return {};
93
+ }
44
94
  }
45
95
 
46
96
  function projectIdFor(target, projectName, remoteOrigin) {
47
97
  const crypto = require("crypto");
48
- const seed = JSON.stringify({ name: projectName, origin: remoteOrigin || path.resolve(target) });
98
+ const seed = projectIdSeed(projectName, remoteOrigin || path.resolve(target));
49
99
  return "prj_" + crypto.createHash("sha256").update(seed).digest("hex").slice(0, 16);
50
100
  }
51
101
 
102
+ function projectIdSeed(projectName, origin) {
103
+ return `{"name": ${JSON.stringify(projectName)}, "origin": ${JSON.stringify(origin)}}`;
104
+ }
105
+
52
106
  function getGitRemoteOrigin(target) {
53
107
  try {
54
108
  const { execFileSync } = require("child_process");
55
- return execFileSync("git", ["config", "--get", "remote.origin.url"], {
109
+ return scrubRemoteUrl(execFileSync("git", ["config", "--get", "remote.origin.url"], {
56
110
  cwd: target, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000,
57
- }).trim();
111
+ }).trim());
58
112
  } catch { return ""; }
59
113
  }
60
114
 
@@ -127,21 +181,25 @@ function collectMetadata(target) {
127
181
  }
128
182
 
129
183
  function buildProjectIdentity(target, metadata, detectedStack = "") {
130
- const projectName = inferProjectName(target, metadata.manifestContents);
184
+ const manifest = readProjectManifest(target);
185
+ const discovery = readDiscovery(target);
186
+ const projectName = manifest.name || inferProjectName(target, metadata.manifestContents);
131
187
  const remoteOrigin = getGitRemoteOrigin(target);
188
+ const originValue = remoteOrigin || manifest.repo || path.resolve(target);
132
189
  return {
133
190
  project_name: projectName,
134
- stack: detectedStack || detectStackHint(metadata.projectFiles, metadata.manifestContents),
135
- project_id: projectIdFor(target, projectName, remoteOrigin),
136
- origin: remoteOrigin ? "git" : "local",
137
- remote_origin: remoteOrigin,
191
+ stack: detectedStack || manifest.stack || discovery.stack || detectStackHint(metadata.projectFiles, metadata.manifestContents),
192
+ project_id: projectIdFor(target, projectName, originValue),
193
+ origin: remoteOrigin ? "git" : (manifest.repo ? "manifest" : "local"),
194
+ remote_origin: remoteOrigin || manifest.repo || "",
138
195
  binding_source: "npm-cli",
139
196
  };
140
197
  }
141
198
 
142
199
  module.exports = {
143
200
  MANIFEST_FILES, PROBE_DIRS,
144
- deviceFingerprint, registerProject, projectIdFor,
201
+ deviceFingerprint, registerProject, projectIdFor, projectIdSeed,
202
+ scrubRemoteUrl, readProjectManifest, readDiscovery,
145
203
  getGitRemoteOrigin, inferProjectName, detectStackHint,
146
204
  collectMetadata, buildProjectIdentity,
147
205
  };