@0dai-dev/cli 3.10.1 → 4.1.0

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,92 @@
1
+ "use strict";
2
+ const shared = require("../shared");
3
+ const { log, T, R, D, fs, path, apiCall } = shared;
4
+
5
+ async function cmdFeedbackPush(target) {
6
+ const fbDir = path.join(target, "ai", "feedback");
7
+ const items = [];
8
+
9
+ // Collect from report JSON files
10
+ try {
11
+ for (const f of fs.readdirSync(fbDir)) {
12
+ if (f.endsWith("-report.json") || (f.endsWith(".json") && f.match(/^\d{8}/))) {
13
+ try {
14
+ const d = JSON.parse(fs.readFileSync(path.join(fbDir, f), "utf8"));
15
+ if (d.project || d.verdict) items.push({ type: "report", data: d, file: f });
16
+ } catch {}
17
+ }
18
+ }
19
+ } catch {}
20
+
21
+ // Collect from operational.jsonl (feedback log entries)
22
+ const jsonlPath = path.join(fbDir, "operational.jsonl");
23
+ try {
24
+ if (fs.existsSync(jsonlPath)) {
25
+ const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n").filter(Boolean);
26
+ for (const line of lines) {
27
+ try { items.push({ type: "log", data: JSON.parse(line) }); } catch {}
28
+ }
29
+ }
30
+ } catch {}
31
+
32
+ if (!items.length) {
33
+ log("no feedback found");
34
+ console.log(` ${D}Log feedback first: 0dai feedback log --type suggestion --detail '...'${R}`);
35
+ return;
36
+ }
37
+
38
+ // Push all items
39
+ const report = {
40
+ project: path.basename(target),
41
+ entries: items.map(i => i.data),
42
+ count: items.length,
43
+ submitted_at: new Date().toISOString(),
44
+ };
45
+ log(`pushing ${items.length} feedback item(s)...`);
46
+ const result = await apiCall("/v1/feedback", { report });
47
+ if (result.received) {
48
+ log(`received${result.issue ? `: ${result.issue}` : ""}`);
49
+ if (result.bonus) log(`${T}bonus:${R} ${result.bonus}`);
50
+ // Archive pushed entries
51
+ if (fs.existsSync(jsonlPath)) {
52
+ const archivePath = path.join(fbDir, `pushed-${Date.now()}.jsonl`);
53
+ fs.renameSync(jsonlPath, archivePath);
54
+ }
55
+ } else {
56
+ log(`error: ${result.error || "unknown"}`);
57
+ }
58
+ }
59
+
60
+ async function cmdFeedback(target, sub, args) {
61
+ const fbDir = path.join(target, "ai", "feedback");
62
+
63
+ if (sub === "push") {
64
+ return cmdFeedbackPush(target);
65
+ }
66
+ if (sub === "log") {
67
+ const type = args.find((_, i) => args[i - 1] === "--type") || "suggestion";
68
+ const detail = args.find((_, i) => args[i - 1] === "--detail") || "";
69
+ if (!detail) { console.log("Usage: 0dai feedback log --type bug|suggestion|friction|positive --detail '...'"); return; }
70
+ fs.mkdirSync(fbDir, { recursive: true });
71
+ const entry = JSON.stringify({ ts: new Date().toISOString(), type, detail, agent: "cli" });
72
+ fs.appendFileSync(path.join(fbDir, "operational.jsonl"), entry + "\n");
73
+ log(`logged: [${type}] ${detail.slice(0, 60)}`);
74
+ return;
75
+ }
76
+ if (sub === "list") {
77
+ try {
78
+ const files = fs.readdirSync(fbDir).filter(f => f.endsWith("-report.json"));
79
+ if (!files.length) { log("no reports"); return; }
80
+ for (const f of files) {
81
+ try {
82
+ const d = JSON.parse(fs.readFileSync(path.join(fbDir, f), "utf8"));
83
+ console.log(` ${f}: ${d.verdict || "?"} (${d.project || "?"})`);
84
+ } catch {}
85
+ }
86
+ } catch { log("no feedback directory"); }
87
+ return;
88
+ }
89
+ console.log("Usage: 0dai feedback [push|log|list] [--type ...] [--detail '...']");
90
+ }
91
+
92
+ module.exports = { cmdFeedbackPush, cmdFeedback };
@@ -0,0 +1,270 @@
1
+ "use strict";
2
+ const shared = require("../shared");
3
+ const { log, T, R, D, fs, path, apiCall, AUTH_FILE, buildProjectIdentity, collectMetadata, recordExperienceEvent } = shared;
4
+
5
+ async function cmdGraph(target, sub, args) {
6
+ const graphFile = path.join(target, "ai", "manifest", "project_graph.json");
7
+
8
+ if (sub === "push") {
9
+ // Check auth
10
+ let auth;
11
+ try { auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8")); } catch {}
12
+ if (!auth || !auth.access_token) {
13
+ log("Graph push requires an account. Run: 0dai auth login");
14
+ return;
15
+ }
16
+
17
+ if (!fs.existsSync(graphFile)) {
18
+ log("No local graph found. Run: 0dai graph init or create ai/manifest/project_graph.json");
19
+ return;
20
+ }
21
+
22
+ let localGraph;
23
+ try { localGraph = JSON.parse(fs.readFileSync(graphFile, "utf8")); } catch (e) {
24
+ log(`Error reading graph file: ${e.message}`);
25
+ return;
26
+ }
27
+
28
+ const nodes = localGraph.nodes || {};
29
+ const edges = localGraph.edges || [];
30
+ const identity = buildProjectIdentity(target, collectMetadata(target));
31
+
32
+ log(`Pushing graph (${Object.keys(nodes).length} nodes, ${edges.length} edges)...`);
33
+ const result = await apiCall("/v1/graph/sync", {
34
+ project_id: identity.project_id,
35
+ nodes,
36
+ edges,
37
+ });
38
+
39
+ if (result.error) {
40
+ log(`Error: ${result.error}`);
41
+ return;
42
+ }
43
+
44
+ const parts = [];
45
+ if (result.added_nodes) parts.push(`${result.added_nodes} nodes added`);
46
+ if (result.updated_nodes) parts.push(`${result.updated_nodes} nodes updated`);
47
+ if (result.deleted_nodes) parts.push(`${result.deleted_nodes} nodes deleted`);
48
+ if (result.added_edges) parts.push(`${result.added_edges} edges added`);
49
+ if (result.deleted_edges) parts.push(`${result.deleted_edges} edges deleted`);
50
+
51
+ log(`Sync: ${parts.join(", ") || "up to date"}`);
52
+ if (result.edges_rejected) {
53
+ log(`${D}Edges rejected: upgrade to Pro for edge sync (0dai upgrade)${R}`);
54
+ }
55
+ recordExperienceEvent(target, {
56
+ event_type: "graph_synced",
57
+ agent: "cli",
58
+ model: "0dai-cli",
59
+ effort: "medium",
60
+ task: { goal: "push graph to server", task_type: "feat", result: "success", elapsed_seconds: 0, cost_usd: 0 },
61
+ context: { stack: "unknown", files_touched: 1, tests_passed: true, graph_nodes_used: Object.keys(nodes).length, graph_edges_used: edges.length },
62
+ });
63
+ return;
64
+ }
65
+
66
+ if (sub === "pull") {
67
+ let auth;
68
+ try { auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8")); } catch {}
69
+ if (!auth || !auth.access_token) {
70
+ log("Graph pull requires an account. Run: 0dai auth login");
71
+ return;
72
+ }
73
+
74
+ const identity = buildProjectIdentity(target, collectMetadata(target));
75
+ log("Pulling graph from server...");
76
+ const result = await apiCall(`/v1/graph/pull?project_id=${identity.project_id}`);
77
+
78
+ if (result.error) {
79
+ log(`Error: ${result.error}`);
80
+ if (result.hint) log(`Hint: ${result.hint}`);
81
+ return;
82
+ }
83
+
84
+ // Merge into local graph
85
+ let localGraph = { nodes: {}, edges: [], meta: {} };
86
+ if (fs.existsSync(graphFile)) {
87
+ try { localGraph = JSON.parse(fs.readFileSync(graphFile, "utf8")); } catch {}
88
+ }
89
+
90
+ // Merge nodes (server wins on conflicts)
91
+ const serverNodes = result.nodes || {};
92
+ const localNodes = localGraph.nodes || {};
93
+ const mergedNodes = { ...localNodes, ...serverNodes };
94
+
95
+ // Merge edges (server wins for Pro users)
96
+ const serverEdges = result.edges || [];
97
+ const localEdges = localGraph.edges || [];
98
+ const mergedEdges = [...localEdges, ...serverEdges];
99
+
100
+ // Deduplicate edges by (from, to, type)
101
+ const seen = new Set();
102
+ const uniqueEdges = [];
103
+ for (const e of mergedEdges) {
104
+ const key = `${e.from}|${e.to}|${e.type}`;
105
+ if (!seen.has(key)) {
106
+ seen.add(key);
107
+ uniqueEdges.push(e);
108
+ }
109
+ }
110
+
111
+ const merged = {
112
+ nodes: mergedNodes,
113
+ edges: uniqueEdges,
114
+ meta: {
115
+ ...localGraph.meta,
116
+ ...result.meta,
117
+ node_count: Object.keys(mergedNodes).length,
118
+ edge_count: uniqueEdges.length,
119
+ },
120
+ };
121
+
122
+ fs.mkdirSync(path.dirname(graphFile), { recursive: true });
123
+ fs.writeFileSync(graphFile, JSON.stringify(merged, null, 2) + "\n", "utf8");
124
+
125
+ log(`Pulled: ${Object.keys(mergedNodes).length} nodes, ${uniqueEdges.length} edges`);
126
+ if (result.meta && result.meta.edges_stripped) {
127
+ log(`${D}Edges not included: upgrade to Pro for full graph sync (0dai upgrade)${R}`);
128
+ }
129
+ recordExperienceEvent(target, {
130
+ event_type: "graph_synced",
131
+ agent: "cli",
132
+ model: "0dai-cli",
133
+ effort: "medium",
134
+ task: { goal: "pull graph from server", task_type: "feat", result: "success", elapsed_seconds: 0, cost_usd: 0 },
135
+ context: { stack: "unknown", files_touched: 1, tests_passed: true, graph_nodes_used: Object.keys(mergedNodes).length, graph_edges_used: uniqueEdges.length },
136
+ });
137
+ return;
138
+ }
139
+
140
+ if (sub === "status") {
141
+ let localNodes = 0, localEdges = 0;
142
+ if (fs.existsSync(graphFile)) {
143
+ try {
144
+ const g = JSON.parse(fs.readFileSync(graphFile, "utf8"));
145
+ localNodes = Object.keys(g.nodes || {}).length;
146
+ localEdges = (g.edges || []).length;
147
+ } catch {}
148
+ }
149
+
150
+ log(`Local graph: ${localNodes} nodes, ${localEdges} edges`);
151
+
152
+ // Check plan for edge sync status
153
+ let auth;
154
+ try { auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8")); } catch {}
155
+ if (auth && auth.access_token) {
156
+ const plan = (auth.plan || "free").toLowerCase();
157
+ const edgeStatus = plan === "free" ? `${D}nodes only (Pro for edges)${R}` : `${T}full sync${R}`;
158
+ log(`Server sync: ${edgeStatus}`);
159
+ } else {
160
+ log(`${D}Not authenticated — graph sync unavailable${R}`);
161
+ }
162
+ return;
163
+ }
164
+
165
+ if (sub === "history") {
166
+ const authFile = path.join(require("os").homedir(), ".0dai", "auth.json");
167
+ let auth = {};
168
+ try { auth = JSON.parse(fs.readFileSync(authFile, "utf8")); } catch {}
169
+ const plan = (auth.plan || "free").toLowerCase();
170
+ const limitIdx = args.indexOf("--limit");
171
+ const limit = limitIdx >= 0 && args[limitIdx + 1] ? parseInt(args[limitIdx + 1], 10) || 50 : 50;
172
+ const sinceIdx = args.indexOf("--since");
173
+ const since = sinceIdx >= 0 ? args[sinceIdx + 1] : null;
174
+ const nodeIdx = args.indexOf("--node");
175
+ const nodeId = nodeIdx >= 0 ? args[nodeIdx + 1] : null;
176
+
177
+ if (["pro", "team", "enterprise"].includes(plan) && auth.access_token) {
178
+ const identity = buildProjectIdentity(target, collectMetadata(target));
179
+ const params = new URLSearchParams({
180
+ project_id: identity.project_id,
181
+ limit: String(Math.max(1, limit)),
182
+ });
183
+ if (since) params.set("since", since);
184
+ if (nodeId) params.set("target_id", nodeId);
185
+ const result = await apiCall(`/v1/graph/history?${params.toString()}`);
186
+ if (result.error) {
187
+ log(`Error: ${result.error}`);
188
+ if (result.hint) log(result.hint);
189
+ return;
190
+ }
191
+ const entries = result.mutations || [];
192
+ if (!entries.length) { console.log(" No graph history yet."); return; }
193
+ for (const entry of entries) {
194
+ const actor = (entry.agent || "unknown").padEnd(7);
195
+ const context = entry.context ? ` | "${entry.context}"` : "";
196
+ console.log(` ${entry.timestamp || "?"} | ${actor} | ${entry.action.padEnd(13)} | ${entry.target_id}${context}`);
197
+ }
198
+ return;
199
+ }
200
+ log(`${D}Graph history requires Pro plan. Upgrade: 0dai upgrade${R}`);
201
+ return;
202
+ }
203
+
204
+ if (sub === "context") {
205
+ if (!fs.existsSync(graphFile)) {
206
+ log("No local graph found. Run: 0dai graph init or create ai/manifest/project_graph.json");
207
+ return;
208
+ }
209
+ const slicerScript = shared.findRepoScript(target, "graph_slicer_cli.py");
210
+ if (!slicerScript) {
211
+ log("graph slicer unavailable in this environment");
212
+ console.log(` ${D}Expected scripts/graph_slicer_cli.py in repo checkout${R}`);
213
+ return;
214
+ }
215
+ const forwarded = [slicerScript, "--target", target];
216
+ let i = 0;
217
+ while (i < args.length) {
218
+ if (args[i] === "--scope" && args[i + 1]) { forwarded.push("--scope", args[i + 1]); i += 2; }
219
+ else if (args[i] === "--task" && args[i + 1]) { forwarded.push("--task", args[i + 1]); i += 2; }
220
+ else if (args[i] === "--depth" && args[i + 1]) { forwarded.push("--depth", args[i + 1]); i += 2; }
221
+ else if (args[i] === "--budget" && args[i + 1]) { forwarded.push("--budget", args[i + 1]); i += 2; }
222
+ else if (args[i] === "--json") { forwarded.push("--json"); i += 1; }
223
+ else { i += 1; }
224
+ }
225
+ const result = shared.spawnSync("python3", forwarded, { stdio: "inherit" });
226
+ if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
227
+ return;
228
+ }
229
+
230
+ if (sub === "outcomes") {
231
+ const authFile = path.join(require("os").homedir(), ".0dai", "auth.json");
232
+ let auth = {};
233
+ try { auth = JSON.parse(fs.readFileSync(authFile, "utf8")); } catch {}
234
+ const plan = (auth.plan || "free").toLowerCase();
235
+ if (!["pro", "team", "enterprise"].includes(plan)) {
236
+ log(`${D}Outcomes require Pro plan. Upgrade: 0dai upgrade${R}`);
237
+ return;
238
+ }
239
+ if (!auth.access_token) {
240
+ log("Graph outcomes requires an account. Run: 0dai auth login");
241
+ return;
242
+ }
243
+ const identity = buildProjectIdentity(target, collectMetadata(target));
244
+ const params = new URLSearchParams({ project_id: identity.project_id });
245
+ const limitIdx = args.indexOf("--limit");
246
+ if (limitIdx >= 0 && args[limitIdx + 1]) params.set("limit", args[limitIdx + 1]);
247
+ const sinceIdx = args.indexOf("--since");
248
+ if (sinceIdx >= 0 && args[sinceIdx + 1]) params.set("since", args[sinceIdx + 1]);
249
+ const result = await apiCall(`/v1/outcomes?${params.toString()}`);
250
+ if (result.error) { log(`Error: ${result.error}`); return; }
251
+ const stats = result.stats || {};
252
+ log(`Outcomes: ${stats.total || 0} total, ${stats.success_rate || 0}% success rate`);
253
+ if (result.outcomes && result.outcomes.length) {
254
+ for (const o of result.outcomes.slice(0, 10)) {
255
+ console.log(` ${o.result || "?"} | ${o.agent || "?"} | ${o.title || o.task_id || "?"}`);
256
+ }
257
+ }
258
+ return;
259
+ }
260
+
261
+ console.log("Usage: 0dai graph [push|pull|status|history|context|outcomes]");
262
+ console.log(" push Upload local graph to server (Pro: edges, Free: nodes only)");
263
+ console.log(" pull Download server graph and merge locally");
264
+ console.log(" status Show local graph stats and sync state");
265
+ console.log(" history Show graph mutation timeline [--since 7d] [--node ID] [--limit 50]");
266
+ console.log(" context Get relevant graph context for a task [--scope FILE] [--task TYPE]");
267
+ console.log(" outcomes Show outcome analytics (Pro only)");
268
+ }
269
+
270
+ module.exports = { cmdGraph };
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+ const shared = require("../shared");
3
+ const {
4
+ T, R, D, log,
5
+ fs, path,
6
+ VERSION, SUPPORTED_CLIS,
7
+ apiCall, makeEnsureAuthenticated, ensureLicenseActivation,
8
+ collectMetadata, buildProjectIdentity, registerProject,
9
+ writeFiles, writeManagedFiles, sendProjectHeartbeat, recordExperienceEvent,
10
+ } = shared;
11
+ const { cmdAuthLogin } = require("./auth");
12
+
13
+ const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
14
+
15
+ // bindProjectForCloud — binds project to cloud via /v1/projects/bind
16
+ async function bindProjectForCloud(target, metadata, identity) {
17
+ const result = await apiCall("/v1/projects/bind", {
18
+ ...identity,
19
+ stack: identity.stack || "unknown",
20
+ });
21
+ if (result.error) {
22
+ log(`error: ${result.error}`);
23
+ if (result.hint) console.log(` ${result.hint}`);
24
+ process.exit(1);
25
+ }
26
+ return result.project || {
27
+ project_id: identity.project_id,
28
+ name: identity.project_name,
29
+ stack: identity.stack || "unknown",
30
+ binding_status: "bound",
31
+ };
32
+ }
33
+
34
+ async function cmdInit(target, args = []) {
35
+ const dryRun = args.includes("--dry-run");
36
+ const minimal = args.includes("--minimal");
37
+ const noWizard = args.includes("--no-wizard");
38
+
39
+ if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
40
+ const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
41
+ log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
42
+ return;
43
+ }
44
+
45
+ // First-run wizard (unless --no-wizard or non-interactive)
46
+ if (!noWizard && !dryRun && !minimal) {
47
+ try {
48
+ const { runWizard, isInteractive } = require("../wizard");
49
+ if (isInteractive()) {
50
+ const result = await runWizard(target);
51
+ if (result.completed) {
52
+ try {
53
+ const ob = require("../onboarding");
54
+ ob.trackFirstInit(target);
55
+ ob.showWhatsNext(result.mode || "local", false);
56
+ } catch {}
57
+ return;
58
+ }
59
+ }
60
+ } catch {}
61
+ }
62
+
63
+ const isTTY = process.stdout.isTTY;
64
+ let spinner = null;
65
+ if (isTTY) {
66
+ try { spinner = require("@clack/prompts").spinner(); } catch {}
67
+ }
68
+
69
+ const metadata = collectMetadata(target);
70
+ const { projectFiles, manifestContents, clis } = metadata;
71
+ const authStatus = await ensureAuthenticated("init");
72
+ const license = await ensureLicenseActivation();
73
+ const identity = buildProjectIdentity(target, metadata);
74
+ const boundProject = await bindProjectForCloud(target, metadata, identity);
75
+ if (dryRun) log(`${D}dry-run: would generate ai/ layer (${projectFiles.length} files, ${clis.length} CLIs)${R}`);
76
+ if (spinner) spinner.start(`${dryRun ? "[dry-run] " : ""}Generating ai/ layer (${projectFiles.length} files, ${clis.length} CLIs)...`);
77
+ else if (!dryRun) log(`sending to API (${projectFiles.length} files, ${clis.length} CLIs)...`);
78
+ const result = await apiCall("/v1/init", {
79
+ project_files: projectFiles,
80
+ manifest_contents: manifestContents,
81
+ available_clis: clis,
82
+ dry_run: dryRun,
83
+ minimal: minimal,
84
+ project_name: identity.project_name,
85
+ project_id: boundProject.project_id || identity.project_id,
86
+ remote_origin: identity.remote_origin,
87
+ origin: identity.origin,
88
+ binding_source: "init",
89
+ });
90
+
91
+ if (result.error) {
92
+ if (result.hint) {
93
+ log(`${result.message || result.error}`);
94
+ console.log(` ${result.hint}\n`);
95
+ } else {
96
+ log(`error: ${result.error}`);
97
+ }
98
+ process.exit(1);
99
+ }
100
+
101
+ if (spinner) spinner.stop(`${dryRun ? "[dry-run] " : ""}Detected: ${result.stack || "?"}`);
102
+ else log(`detected: ${result.stack || "?"}`);
103
+ if (dryRun) {
104
+ const files = Object.keys(result.files || {});
105
+ log(`${D}dry-run: would write ${files.length} files:${R}`);
106
+ for (const f of files.slice(0, 20)) console.log(` ${D}+ ${f}${R}`);
107
+ if (files.length > 20) console.log(` ${D}… and ${files.length - 20} more${R}`);
108
+ return;
109
+ }
110
+ writeFiles(target, result.files || {});
111
+
112
+ // Ensure ai/VERSION matches CLI version
113
+ const versionFile = path.join(target, "ai", "VERSION");
114
+ fs.mkdirSync(path.dirname(versionFile), { recursive: true });
115
+ fs.writeFileSync(versionFile, VERSION + "\n", "utf8");
116
+
117
+ // Add to .gitignore
118
+ const gi = path.join(target, ".gitignore");
119
+ try {
120
+ const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
121
+ if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
122
+ } catch {}
123
+
124
+ // Register in global portfolio
125
+ registerProject(target, path.basename(target), result.stack);
126
+
127
+ log(`initialized (${result.file_count || "?"} files)`);
128
+ console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
129
+ console.log(` project: ${boundProject.project_id || identity.project_id}`);
130
+ console.log(" skills: /build /review /status /feedback /bugfix /delegate");
131
+
132
+ // Detect agent auth status for smart onboarding hints
133
+ const { execFileSync: _ef } = require("child_process");
134
+ const agents = [];
135
+ try { _ef("claude", ["--version"], { timeout: 8000 }); agents.push("claude"); } catch {}
136
+ try { _ef("codex", ["--version"], { timeout: 8000 }); agents.push("codex"); } catch {}
137
+ try { _ef("gemini", ["--version"], { timeout: 8000 }); agents.push("gemini"); } catch {}
138
+
139
+ // Next steps — guide user to first value
140
+ console.log(`\n ${T}Next steps:${R}`);
141
+ console.log(` ${D}1.${R} Check health: ${D}0dai doctor${R}`);
142
+ if (agents.length > 0) {
143
+ const a = agents[0];
144
+ console.log(` ${D}2.${R} Try delegation: ${D}0dai run "write tests for auth"${R}`);
145
+ console.log(` ${D}(${agents.join(", ")} detected — delegation will use ${a} by default)${R}`);
146
+ } else {
147
+ console.log(` ${D}2.${R} Install an agent CLI to enable delegation:`);
148
+ console.log(` ${D}claude:${R} npm i -g @anthropic-ai/claude-code ${D}(or Pro subscription)${R}`);
149
+ console.log(` ${D}codex:${R} npm i -g @openai/codex ${D}(or ChatGPT Pro)${R}`);
150
+ }
151
+ console.log(` ${D}3.${R} Open dashboard: ${D}https://0dai.dev/dashboard${R}`);
152
+
153
+ await sendProjectHeartbeat(identity, result, {
154
+ project_id: boundProject.project_id || identity.project_id,
155
+ }).catch(() => {});
156
+ recordExperienceEvent(target, {
157
+ event_type: "config_generated",
158
+ agent: "cli",
159
+ model: "0dai-cli",
160
+ effort: "medium",
161
+ task: { goal: "initialize ai layer", task_type: "feat", result: "success", elapsed_seconds: 0, cost_usd: 0 },
162
+ context: { stack: result.stack || identity.stack || "unknown", files_touched: Number(result.file_count || 0), tests_passed: true },
163
+ });
164
+
165
+ // Send anonymous usage ping
166
+ apiCall("/v1/feedback", { report: {
167
+ stack_detected: result.stack || "?", _auto: true, _plan: result.plan || "trial",
168
+ _cli_version: VERSION, _files_generated: result.file_count || 0,
169
+ }}).catch(() => {});
170
+ }
171
+
172
+ async function cmdSync(target, args = []) {
173
+ const dryRun = args.includes("--dry-run");
174
+ const quiet = args.includes("--quiet") || args.includes("-q");
175
+ const force = args.includes("--force");
176
+ const updateTemplates = args.includes("--update-templates");
177
+
178
+ // Quick local check: skip API if already at current version (unless dry-run or force)
179
+ let version = "unknown";
180
+ try { version = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim(); } catch {}
181
+
182
+ const metadata = collectMetadata(target);
183
+ const { manifestContents, clis } = metadata;
184
+ const authStatus = await ensureAuthenticated("sync");
185
+ const license = await ensureLicenseActivation();
186
+ let stack = "generic", agents = [];
187
+ try {
188
+ const d = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
189
+ stack = d.stack || "generic";
190
+ agents = d.selected_agents || [];
191
+ } catch {}
192
+ const identity = buildProjectIdentity(target, metadata, stack);
193
+ const boundProject = await bindProjectForCloud(target, metadata, identity);
194
+
195
+ // Collect current ai/ files
196
+ const currentFiles = {};
197
+ const aiDir = path.join(target, "ai");
198
+ if (fs.existsSync(aiDir)) {
199
+ const walk = (dir) => {
200
+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
201
+ const p = path.join(dir, f.name);
202
+ if (f.isDirectory()) walk(p);
203
+ else {
204
+ try {
205
+ const stat = fs.statSync(p);
206
+ if (stat.size < 10000) currentFiles[path.relative(target, p)] = fs.readFileSync(p, "utf8");
207
+ } catch {}
208
+ }
209
+ }
210
+ };
211
+ walk(aiDir);
212
+ }
213
+
214
+ if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
215
+ if (force && !dryRun) log(`${T}force mode: will overwrite native configs from ai/ source${R}`);
216
+ if (updateTemplates && !dryRun) log(`${T}template update mode: will refresh managed native configs from latest templates${R}`);
217
+
218
+ const result = await apiCall("/v1/sync", {
219
+ ai_version: version, stack, agents: agents.length ? agents : clis,
220
+ current_files: currentFiles, manifest_contents: manifestContents,
221
+ dry_run: dryRun, quiet, force, update_templates: updateTemplates,
222
+ project_name: identity.project_name,
223
+ project_id: boundProject.project_id || identity.project_id,
224
+ remote_origin: identity.remote_origin,
225
+ origin: identity.origin,
226
+ binding_source: "sync",
227
+ });
228
+
229
+ if (result.error) {
230
+ log(`error: ${result.error}`);
231
+ if (result.hint) console.log(` ${result.hint}`);
232
+ process.exit(1);
233
+ }
234
+
235
+ const updated = result.files_updated || {};
236
+ if (dryRun) {
237
+ const files = Object.keys(updated);
238
+ if (files.length) {
239
+ log(`${D}dry-run: would update ${files.length} file(s):${R}`);
240
+ for (const f of files) console.log(` ${D}~ ${f}${R}`);
241
+ } else {
242
+ log(`${D}dry-run: nothing to update${R}`);
243
+ }
244
+ if (result.template_update_available) {
245
+ console.log(` ${D}template update available: run 0dai sync --update-templates${R}`);
246
+ }
247
+ return;
248
+ }
249
+ const changedCount = Object.keys(updated).length;
250
+ if (changedCount) {
251
+ writeFiles(target, updated);
252
+ if (!quiet) {
253
+ for (const f of Object.keys(updated)) console.log(` ~ ${f}`);
254
+ }
255
+ log(`sync: ${changedCount} file(s) updated`);
256
+ console.log(` ${D}Run: 0dai doctor to verify project health${R}`);
257
+ } else {
258
+ log("already up to date");
259
+ }
260
+
261
+ if (result.template_update_available && !updateTemplates && !quiet) {
262
+ console.log(` ${D}Template update available: run 0dai sync --update-templates to refresh managed native configs${R}`);
263
+ }
264
+
265
+ // --force: also overwrite native configs (CLAUDE.md, AGENTS.md, etc.) from ai/ source
266
+ if (force && result.native_configs) {
267
+ let overwritten = 0;
268
+ for (const [name, content] of Object.entries(result.native_configs)) {
269
+ const targetPath = path.join(target, name);
270
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
271
+ if (content) {
272
+ fs.writeFileSync(targetPath, content, "utf8");
273
+ overwritten++;
274
+ if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
275
+ }
276
+ }
277
+ if (overwritten && !quiet) {
278
+ log(`force: ${overwritten} native config file(s) overwritten`);
279
+ }
280
+ } else if (updateTemplates && result.native_configs) {
281
+ writeManagedFiles(target, result.native_configs);
282
+ if (!quiet) {
283
+ log("template update: managed native configs refreshed");
284
+ }
285
+ }
286
+
287
+ // --force: update drift baseline hashes so drift clears after regeneration
288
+ if (force) {
289
+ try {
290
+ const { spawnSync } = require("child_process");
291
+ const driftScript = path.join(target, "scripts", "drift_detector.py");
292
+ if (fs.existsSync(driftScript)) {
293
+ spawnSync("python3", [driftScript, "record", "--target", target], { stdio: "inherit" });
294
+ }
295
+ } catch {}
296
+ }
297
+
298
+ if (!quiet) {
299
+ console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
300
+ console.log(` project: ${boundProject.project_id || identity.project_id}`);
301
+ }
302
+
303
+ // Ensure ai/VERSION matches CLI version after successful sync
304
+ const versionFile = path.join(target, "ai", "VERSION");
305
+ try {
306
+ const current = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, "utf8").trim() : "";
307
+ if (current !== VERSION) {
308
+ fs.writeFileSync(versionFile, VERSION + "\n", "utf8");
309
+ }
310
+ } catch {}
311
+
312
+ // Update portfolio registry
313
+ registerProject(target, path.basename(target), stack);
314
+ await sendProjectHeartbeat(identity, result, {
315
+ project_id: boundProject.project_id || identity.project_id,
316
+ }).catch(() => {});
317
+ recordExperienceEvent(target, {
318
+ event_type: "config_generated",
319
+ agent: "cli",
320
+ model: "0dai-cli",
321
+ effort: "medium",
322
+ task: { goal: "sync ai layer", task_type: "feat", result: "success", elapsed_seconds: 0, cost_usd: 0 },
323
+ context: { stack: stack || identity.stack || "unknown", files_touched: changedCount, tests_passed: true },
324
+ });
325
+ }
326
+
327
+ module.exports = { cmdInit, cmdSync };