@0dai-dev/cli 4.0.0 → 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.
package/bin/0dai.js CHANGED
@@ -32,6 +32,7 @@ const { cmdFeedback, cmdFeedbackPush } = require("../lib/commands/feedback");
32
32
  const { cmdGraph } = require("../lib/commands/graph");
33
33
  const { cmdReport } = require("../lib/commands/report");
34
34
  const { cmdExperience } = require("../lib/commands/experience");
35
+ const { cmdWorkspace } = require("../lib/commands/workspace");
35
36
 
36
37
  async function main() {
37
38
  const args = process.argv.slice(2);
@@ -67,6 +68,29 @@ async function main() {
67
68
  case "watch": cmdWatch(target, args.slice(1)); break;
68
69
  case "audit": cmdAudit(target); break;
69
70
  case "security": {
71
+ const subSec = args[1] || "";
72
+ if (subSec === "install-hook") {
73
+ const hooksDir = path.join(target, ".git", "hooks");
74
+ if (!fs.existsSync(hooksDir)) { log("not a git repo"); break; }
75
+ const hookPath = path.join(hooksDir, "pre-commit");
76
+ const hookSource = path.join(__dirname, "..", "..", "..", "scripts", "hooks", "pre-commit.sh");
77
+ if (fs.existsSync(hookSource)) {
78
+ fs.copyFileSync(hookSource, hookPath);
79
+ fs.chmodSync(hookPath, 0o755);
80
+ log("pre-commit hook installed: " + hookPath);
81
+ } else {
82
+ // Inline install: copy from repo scripts if available
83
+ const repoHook = findRepoScript(target, "hooks/pre-commit.sh");
84
+ if (repoHook) {
85
+ fs.copyFileSync(repoHook, hookPath);
86
+ fs.chmodSync(hookPath, 0o755);
87
+ log("pre-commit hook installed: " + hookPath);
88
+ } else {
89
+ log("hook source not found — run: 0dai security install-hook from repo root");
90
+ }
91
+ }
92
+ break;
93
+ }
70
94
  const secScript = findRepoScript(target, "scan_secrets.py");
71
95
  if (!secScript) { log("secret scanner unavailable"); break; }
72
96
  const fwd = [secScript, "--target", target];
@@ -117,11 +141,38 @@ async function main() {
117
141
  break;
118
142
  case "session": cmdSession(target, sub, args); break;
119
143
  case "swarm": cmdSwarm(target, sub, args); break;
144
+ case "workspace": cmdWorkspace(target, sub, args.slice(2)); break;
120
145
  case "feedback": await cmdFeedback(target, sub, args); break;
121
146
  case "report": cmdReport(target, sub, args); break;
122
147
  case "experience": cmdExperience(target, sub, args); break;
123
148
  case "graph": await cmdGraph(target, sub, args); break;
124
149
  case "models": cmdModels(sub || args[1]); break;
150
+ case "delegate": case "delegation": {
151
+ const deScript = findRepoScript(target, "delegation_engine.py");
152
+ if (!deScript) { log("delegation engine unavailable"); break; }
153
+ const deCmd = cmd === "delegate" ? "delegate" : (sub || "show");
154
+ const fwd = [deScript, deCmd, "--target", target];
155
+ if (deCmd === "delegate") {
156
+ // Find goal: first non-flag arg after delegate, or --goal value
157
+ let goal = "";
158
+ for (let i = 0; i < args.length; i++) {
159
+ if (args[i] === "--goal" && args[i + 1]) { goal = args[i + 1]; i++; }
160
+ else if (!args[i].startsWith("-") && !goal) goal = args[i];
161
+ }
162
+ if (sub && !sub.startsWith("-")) goal = goal || sub;
163
+ if (goal) fwd.push(goal);
164
+ for (let i = 0; i < args.length; i++) {
165
+ if (args[i] === "--agent" && args[i + 1]) { fwd.push("--agent", args[i + 1]); i++; }
166
+ else if (args[i] === "--model" && args[i + 1]) { fwd.push("--model", args[i + 1]); i++; }
167
+ else if (args[i] === "--task-type" && args[i + 1]) { fwd.push("--task-type", args[i + 1]); i++; }
168
+ }
169
+ if (args.includes("--dry-run")) fwd.push("--dry-run");
170
+ }
171
+ if (args.includes("--json")) fwd.push("--json");
172
+ const result = spawnSync("python3", fwd, { stdio: "inherit", timeout: 15000 });
173
+ if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
174
+ break;
175
+ }
125
176
  case "redeem": await cmdRedeem(sub || args[1]); break;
126
177
  case "terminal": case "term":
127
178
  try {
@@ -192,7 +243,7 @@ async function main() {
192
243
  console.log(" watch Live task monitor: queue, active, recently done [--interval N]");
193
244
  console.log(" audit Scan ai/ and agent configs for leaked secrets");
194
245
  console.log(" init Initialize ai/ layer (via API) [--dry-run] [--minimal]");
195
- console.log(" sync Update ai/ layer (via API) [--dry-run] [--quiet]");
246
+ console.log(" sync Update ai/ layer (via API) [--dry-run] [--quiet] [--force]");
196
247
  console.log(" detect Show detected stack");
197
248
  console.log(" doctor Check health + credentials checklist");
198
249
  console.log(" update Update all installed agent CLIs to latest [--dry-run]");
@@ -206,6 +257,9 @@ async function main() {
206
257
  console.log(" swarm webhook add Register webhook (fires on task done/failed)");
207
258
  console.log(" swarm webhook list Show registered webhooks");
208
259
  console.log(" swarm webhook test Send test ping to a webhook URL");
260
+ console.log(" workspace init Create tmux workspace config (auto-detect services)");
261
+ console.log(" workspace up Start all workspace sessions");
262
+ console.log(" workspace status Show session status table");
209
263
  console.log(" feedback push Send feedback to 0dai");
210
264
  console.log(" report preview Preview privacy-safe project report");
211
265
  console.log(" report push Send report to 0dai (with offline queue)");
@@ -216,6 +270,8 @@ async function main() {
216
270
  console.log(" graph pull Download server graph and merge locally");
217
271
  console.log(" graph status Show local graph stats and sync state");
218
272
  console.log(" models Show model ratings (--fast/--balanced/--deep/--available)");
273
+ console.log(" delegate Auto-route task to best agent/model (0dai delegate 'goal')");
274
+ console.log(" delegation show Show current delegation policy");
219
275
  console.log(" terminal Launch interactive agent session");
220
276
  console.log(" auth login Authenticate (device code flow)");
221
277
  console.log(" auth logout Remove credentials");
@@ -162,10 +162,109 @@ async function cmdGraph(target, sub, args) {
162
162
  return;
163
163
  }
164
164
 
165
- console.log("Usage: 0dai graph [push|pull|status]");
166
- console.log(" push Upload local graph to server (Pro: edges, Free: nodes only)");
167
- console.log(" pull Download server graph and merge locally");
168
- console.log(" status Show local graph stats and sync state");
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)");
169
268
  }
170
269
 
171
270
  module.exports = { cmdGraph };
@@ -6,7 +6,7 @@ const {
6
6
  VERSION, SUPPORTED_CLIS,
7
7
  apiCall, makeEnsureAuthenticated, ensureLicenseActivation,
8
8
  collectMetadata, buildProjectIdentity, registerProject,
9
- writeFiles, sendProjectHeartbeat, recordExperienceEvent,
9
+ writeFiles, writeManagedFiles, sendProjectHeartbeat, recordExperienceEvent,
10
10
  } = shared;
11
11
  const { cmdAuthLogin } = require("./auth");
12
12
 
@@ -172,8 +172,10 @@ async function cmdInit(target, args = []) {
172
172
  async function cmdSync(target, args = []) {
173
173
  const dryRun = args.includes("--dry-run");
174
174
  const quiet = args.includes("--quiet") || args.includes("-q");
175
+ const force = args.includes("--force");
176
+ const updateTemplates = args.includes("--update-templates");
175
177
 
176
- // Quick local check: skip API if already at current version (unless dry-run)
178
+ // Quick local check: skip API if already at current version (unless dry-run or force)
177
179
  let version = "unknown";
178
180
  try { version = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim(); } catch {}
179
181
 
@@ -210,11 +212,13 @@ async function cmdSync(target, args = []) {
210
212
  }
211
213
 
212
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}`);
213
217
 
214
218
  const result = await apiCall("/v1/sync", {
215
219
  ai_version: version, stack, agents: agents.length ? agents : clis,
216
220
  current_files: currentFiles, manifest_contents: manifestContents,
217
- dry_run: dryRun, quiet,
221
+ dry_run: dryRun, quiet, force, update_templates: updateTemplates,
218
222
  project_name: identity.project_name,
219
223
  project_id: boundProject.project_id || identity.project_id,
220
224
  remote_origin: identity.remote_origin,
@@ -237,6 +241,9 @@ async function cmdSync(target, args = []) {
237
241
  } else {
238
242
  log(`${D}dry-run: nothing to update${R}`);
239
243
  }
244
+ if (result.template_update_available) {
245
+ console.log(` ${D}template update available: run 0dai sync --update-templates${R}`);
246
+ }
240
247
  return;
241
248
  }
242
249
  const changedCount = Object.keys(updated).length;
@@ -250,6 +257,44 @@ async function cmdSync(target, args = []) {
250
257
  } else {
251
258
  log("already up to date");
252
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
+
253
298
  if (!quiet) {
254
299
  console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
255
300
  console.log(` project: ${boundProject.project_id || identity.project_id}`);
@@ -54,4 +54,37 @@ function cmdModels(filter) {
54
54
  console.log(` ${DIM}Full table: https://0dai.dev/models${R}\n`);
55
55
  }
56
56
 
57
- module.exports = { cmdModels };
57
+ async function cmdModelsRecommend(target, args) {
58
+ const shared = require("../shared");
59
+ const { log, T, R, D, findRepoScript, spawnSync, requirePlan } = shared;
60
+
61
+ const gate = requirePlan("pro", "Model Recommend", target);
62
+ if (gate) { log(gate.error); log(gate.hint); return; }
63
+
64
+ const taskType = args.find((_, i) => args[i - 1] === "--task") || "";
65
+ const goal = args.find((_, i) => args[i - 1] === "--goal") || "";
66
+ const maxCost = parseFloat(args.find((_, i) => args[i - 1] === "--max-cost") || "0");
67
+ const minQuality = parseFloat(args.find((_, i) => args[i - 1] === "--min-quality") || "0");
68
+ const asJson = args.includes("--json");
69
+
70
+ if (!taskType && !goal) {
71
+ console.log("Usage: 0dai models recommend --task TYPE [--goal '...'] [--max-cost N] [--min-quality N] [--json]");
72
+ console.log(" TYPE: feat, fix, refactor, test, docs");
73
+ return;
74
+ }
75
+
76
+ const recScript = findRepoScript(target, "model_router.py");
77
+ if (!recScript) { log("model router unavailable"); return; }
78
+
79
+ const fwd = [recScript, "recommend", "--target", target];
80
+ if (taskType) fwd.push("--task", taskType);
81
+ if (goal) fwd.push("--goal", goal);
82
+ if (maxCost > 0) fwd.push("--max-cost", String(maxCost));
83
+ if (minQuality > 0) fwd.push("--min-quality", String(minQuality));
84
+ if (asJson) fwd.push("--json");
85
+
86
+ const result = spawnSync("python3", fwd, { stdio: "inherit" });
87
+ if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
88
+ }
89
+
90
+ module.exports = { cmdModels, cmdModelsRecommend };
@@ -155,7 +155,45 @@ function cmdSwarm(target, sub, args) {
155
155
  console.log();
156
156
  return;
157
157
  }
158
- console.log("Usage: 0dai swarm [status|add|delegate|budget] [--task '...'] [--to agent]");
158
+ if (sub === "estimate") {
159
+ const gate = requirePlan("pro", "Swarm Estimate", target);
160
+ if (gate) { log(gate.error); log(gate.hint); return; }
161
+ const goal = args.find((_, i) => args[i - 1] === "--goal") || "";
162
+ if (!goal) { console.log("Usage: 0dai swarm estimate --goal '...' [--agent claude|codex] [--model tier] [--json]"); return; }
163
+ const agent = args.find((_, i) => args[i - 1] === "--agent") || "";
164
+ const model = args.find((_, i) => args[i - 1] === "--model") || "";
165
+ const asJson = args.includes("--json");
166
+ // Call API for cost estimate
167
+ const identity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
168
+ shared.apiCall("/v1/swarm/estimate", { goal, agent, model_tier: model, project_id: identity.project_id }).then((result) => {
169
+ if (result.error) { log(`error: ${result.error}`); return; }
170
+ if (asJson) { console.log(JSON.stringify(result, null, 2)); return; }
171
+ log(`Cost estimate for: ${goal}`);
172
+ console.log(` ${D}Estimated: $${(result.estimated_cost_usd || 0).toFixed(4)} · ${result.estimated_tokens || "?"} tokens · ${result.estimated_time_s || "?"}s${R}`);
173
+ if (result.model_recommendation) console.log(` ${T}Recommended: ${result.model_recommendation}${R}`);
174
+ if (result.tier_breakdown) {
175
+ for (const [tier, cost] of Object.entries(result.tier_breakdown)) {
176
+ console.log(` ${tier}: $${cost.toFixed(4)}`);
177
+ }
178
+ }
179
+ });
180
+ return;
181
+ }
182
+ if (sub === "quality") {
183
+ const scorerScript = findRepoScript(target, "quality_scorer.py");
184
+ if (!scorerScript) { log("quality scorer unavailable"); return; }
185
+ const fwd = [scorerScript, "--target", target];
186
+ if (args.includes("--json")) fwd.push("--json");
187
+ for (let i = 2; i < args.length; i++) {
188
+ if (args[i] === "--last" && args[i + 1]) { fwd.push("--last", args[i + 1]); i++; }
189
+ else if (args[i] === "--details" && args[i + 1]) { fwd.push("--details", args[i + 1]); i++; }
190
+ }
191
+ const result = spawnSync("python3", fwd, { stdio: "inherit", timeout: 15000 });
192
+ if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
193
+ return;
194
+ }
195
+ console.log("Usage: 0dai swarm [status|add|delegate|budget|estimate|quality] [--task '...'] [--to agent]");
196
+ console.log(" quality Show quality scores for recent tasks (--last N / --details TASK_ID)");
159
197
  }
160
198
 
161
199
  module.exports = { cmdSwarm };
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ const shared = require("../shared");
3
+ const { log, T, R, D, fs, path, spawnSync } = shared;
4
+ const os = require("os");
5
+
6
+ const CONFIG_NAME = "workspace.json";
7
+
8
+ function findConfig(target) {
9
+ const local = path.join(target, ".0dai", CONFIG_NAME);
10
+ if (fs.existsSync(local)) return { file: local, scope: "local" };
11
+ const globalFile = path.join(os.homedir(), ".0dai", CONFIG_NAME);
12
+ if (fs.existsSync(globalFile)) return { file: globalFile, scope: "global" };
13
+ return null;
14
+ }
15
+
16
+ function loadConfig(target) {
17
+ const found = findConfig(target);
18
+ if (!found) return null;
19
+ try { return JSON.parse(fs.readFileSync(found.file, "utf8")); } catch { return null; }
20
+ }
21
+
22
+ function saveConfig(target, ws, globalFlag) {
23
+ const configFile = globalFlag
24
+ ? path.join(os.homedir(), ".0dai", CONFIG_NAME)
25
+ : path.join(target, ".0dai", CONFIG_NAME);
26
+ fs.mkdirSync(path.dirname(configFile), { recursive: true, mode: 0o700 });
27
+ fs.writeFileSync(configFile, JSON.stringify(ws, null, 2) + "\n", { mode: 0o600 });
28
+ return configFile;
29
+ }
30
+
31
+ function tmux(args) {
32
+ try {
33
+ return spawnSync("tmux", args, { encoding: "utf8", timeout: 5000 });
34
+ } catch { return { status: 1, stderr: "tmux not found" }; }
35
+ }
36
+
37
+ function sessionName(name) {
38
+ return "0dai-" + name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
39
+ }
40
+
41
+ function listTmuxSessions() {
42
+ const r = tmux(["list-sessions", "-F", "#{session_name}\t#{session_created}\t#{pane_pid}"]);
43
+ if (r.status !== 0) return [];
44
+ return r.stdout.trim().split("\n").filter(Boolean).map(line => {
45
+ const [name, created, pid] = line.split("\t");
46
+ return { name, created, pid };
47
+ });
48
+ }
49
+
50
+ // --- Auto-detection ---
51
+
52
+ function detectSessions(target) {
53
+ const sessions = [];
54
+
55
+ // API server
56
+ if (fs.existsSync(path.join(target, "scripts", "api_server.py"))) {
57
+ sessions.push({
58
+ name: "api",
59
+ cmd: "python3 api_server.py --public --port 8440",
60
+ dir: "scripts",
61
+ auto_start: true,
62
+ });
63
+ }
64
+
65
+ // Web dev (Next.js, etc.)
66
+ const pkgPath = path.join(target, "web", "package.json");
67
+ if (fs.existsSync(pkgPath)) {
68
+ try {
69
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
70
+ if (pkg.scripts && pkg.scripts.dev) {
71
+ sessions.push({ name: "web", cmd: "npm run dev", dir: "web", auto_start: true });
72
+ }
73
+ } catch {}
74
+ }
75
+
76
+ // Uptime monitor
77
+ if (fs.existsSync(path.join(target, "scripts", "uptime_monitor.py"))) {
78
+ sessions.push({
79
+ name: "monitor",
80
+ cmd: "python3 uptime_monitor.py --url http://localhost:8440",
81
+ dir: "scripts",
82
+ auto_start: false,
83
+ });
84
+ }
85
+
86
+ // Procfile support
87
+ const procfile = path.join(target, "Procfile");
88
+ if (fs.existsSync(procfile)) {
89
+ try {
90
+ const lines = fs.readFileSync(procfile, "utf8").split("\n");
91
+ for (const line of lines) {
92
+ const m = line.match(/^(\w+):\s*(.+)$/);
93
+ if (m) {
94
+ const existing = sessions.find(s => s.name === m[1]);
95
+ if (!existing) {
96
+ sessions.push({ name: m[1], cmd: m[2], dir: ".", auto_start: true });
97
+ }
98
+ }
99
+ }
100
+ } catch {}
101
+ }
102
+
103
+ return sessions;
104
+ }
105
+
106
+ // --- Commands ---
107
+
108
+ function cmdWorkspaceInit(target, args) {
109
+ const globalFlag = args.includes("--global");
110
+
111
+ if (sessions.length === 0) {
112
+ log("no services detected. Create workspace config manually with: 0dai workspace add");
113
+ return;
114
+ }
115
+
116
+ const ws = {
117
+ name: path.basename(target),
118
+ root: target,
119
+ sessions,
120
+ };
121
+
122
+ const configFile = saveConfig(target, ws, globalFlag);
123
+ log(`workspace config created: ${T}${configFile}${R}`);
124
+ log(`${sessions.length} session(s) detected: ${sessions.map(s => s.name).join(", ")}`);
125
+ log(`run: ${T}0dai workspace up${R}`);
126
+ }
127
+
128
+ function cmdWorkspaceAdd(target, args) {
129
+ const nameIdx = args.indexOf("add");
130
+ const name = nameIdx >= 0 ? args[nameIdx + 1] : args[0];
131
+ if (!name) { log("usage: 0dai workspace add <name> --cmd '...' [--dir scripts] [--auto]"); return; }
132
+
133
+ const ws = loadConfig(target) || { name: path.basename(target), sessions: [] };
134
+ const cmdIdx = args.indexOf("--cmd");
135
+ const dirIdx = args.indexOf("--dir");
136
+ const cmd = cmdIdx >= 0 && args[cmdIdx + 1] ? args[cmdIdx + 1] : name;
137
+ const dir = dirIdx >= 0 && args[dirIdx + 1] ? args[dirIdx + 1] : ".";
138
+ const auto = args.includes("--auto");
139
+
140
+ const existing = ws.sessions.findIndex(s => s.name === name);
141
+ const entry = { name, cmd, dir, auto_start: auto };
142
+ if (existing >= 0) ws.sessions[existing] = entry;
143
+ else ws.sessions.push(entry);
144
+
145
+ const found = findConfig(target);
146
+ const configFile = found ? found.file : saveConfig(target, ws, false);
147
+ fs.writeFileSync(configFile, JSON.stringify(ws, null, 2) + "\n", { mode: 0o600 });
148
+ log(`session "${T}${name}${R}" ${existing >= 0 ? "updated" : "added"}`);
149
+ }
150
+
151
+ function cmdWorkspaceRm(target, name) {
152
+ if (!name) { log("usage: 0dai workspace rm <name>"); return; }
153
+ const ws = loadConfig(target);
154
+ if (!ws) { log("no workspace config"); return; }
155
+ ws.sessions = ws.sessions.filter(s => s.name !== name);
156
+ const found = findConfig(target);
157
+ fs.writeFileSync(found.file, JSON.stringify(ws, null, 2) + "\n", { mode: 0o600 });
158
+ log(`session "${T}${name}${R}" removed`);
159
+ }
160
+
161
+ function cmdWorkspaceUp(target, args) {
162
+ const ws = loadConfig(target);
163
+ if (!ws) { log("no workspace config. Run: 0dai workspace init"); return; }
164
+
165
+ // Filter by name if specified
166
+ const filterNames = args.filter(a => !a.startsWith("-"));
167
+ const running = listTmuxSessions();
168
+
169
+ log(`starting workspace "${T}${ws.name}${R}"`);
170
+
171
+ for (const s of ws.sessions) {
172
+ if (!s.auto_start) continue;
173
+ if (filterNames.length && !filterNames.includes(s.name)) continue;
174
+
175
+ const sname = sessionName(s.name);
176
+ if (running.find(x => x.name === sname)) {
177
+ log(`${D}${s.name} already running (pid ${running.find(x => x.name === sname).pid})${R}`);
178
+ continue;
179
+ }
180
+
181
+ const dir = s.dir ? path.join(ws.root || target, s.dir) : (ws.root || target);
182
+ const cmd = `cd ${dir} && ${s.cmd}`;
183
+ const r = tmux(["new-session", "-d", "-s", sname, "bash", "-lc", cmd]);
184
+ if (r.status === 0) log(`${T}✓${R} ${s.name} started`);
185
+ else log(`${R}✗${R} ${s.name} failed: ${r.stderr?.trim() || r.error || "unknown"}`);
186
+ }
187
+
188
+ if (filterNames.length === 0) {
189
+ log(`workspace ready. ${D}Status: 0dai workspace status${R}`);
190
+ }
191
+ }
192
+
193
+ function cmdWorkspaceDown(target, args) {
194
+ const ws = loadConfig(target);
195
+ if (!ws) { log("no workspace config"); return; }
196
+
197
+ const filterNames = args.filter(a => !a.startsWith("-"));
198
+ log(`stopping workspace "${T}${ws.name}${R}"`);
199
+
200
+ for (const s of ws.sessions) {
201
+ if (filterNames.length && !filterNames.includes(s.name)) continue;
202
+ const sname = sessionName(s.name);
203
+ tmux(["kill-session", "-t", sname]);
204
+ log(`${D}${s.name} stopped${R}`);
205
+ }
206
+ }
207
+
208
+ function cmdWorkspaceStatus(target) {
209
+ const ws = loadConfig(target);
210
+ if (!ws) { log("no workspace config. Run: 0dai workspace init"); return; }
211
+
212
+ const running = listTmuxSessions();
213
+ const isTTY = process.stdout.isTTY;
214
+ const G = isTTY ? "\x1b[32m" : "";
215
+ const RD = isTTY ? "\x1b[31m" : "";
216
+ const DIM = isTTY ? "\x1b[2m" : "";
217
+
218
+ console.log(`\n ${T}Workspace: ${ws.name}${R}\n`);
219
+ console.log(` ${"SESSION".padEnd(14)} ${"STATUS".padEnd(10)} ${"PID".padEnd(8)} DIR`);
220
+ console.log(` ${"-".repeat(60)}`);
221
+
222
+ for (const s of ws.sessions) {
223
+ const sname = sessionName(s.name);
224
+ const match = running.find(x => x.name === sname);
225
+ const status = match ? `${G}running${R}` : `${RD}stopped${R}`;
226
+ const pid = match ? match.pid : "—";
227
+ const dir = s.dir || ".";
228
+ console.log(` ${s.name.padEnd(14)} ${status.padEnd(10)} ${String(pid).padEnd(8)} ${DIM}${dir}${R}`);
229
+ }
230
+ console.log();
231
+ }
232
+
233
+ function cmdWorkspaceAttach(target, name) {
234
+ if (!name) { log("usage: 0dai workspace attach <name>"); return; }
235
+ const sname = sessionName(name);
236
+ const running = listTmuxSessions();
237
+ if (!running.find(x => x.name === sname)) {
238
+ log(`session "${name}" not running. Run: 0dai workspace up`);
239
+ return;
240
+ }
241
+ log(`attaching to ${T}${name}${R}`);
242
+ try {
243
+ const { execSync } = require("child_process");
244
+ execSync(`tmux attach -t ${sname}`, { stdio: "inherit" });
245
+ } catch (e) { log(`error: ${e.message}`); }
246
+ }
247
+
248
+ function cmdWorkspaceLogs(target, name, args) {
249
+ if (!name) { log("usage: 0dai workspace logs <name> [-f]"); return; }
250
+ const sname = sessionName(name);
251
+ const running = listTmuxSessions();
252
+ if (!running.find(x => x.name === sname)) { log(`session "${name}" not running`); return; }
253
+
254
+ const follow = args.includes("-f") || args.includes("--follow");
255
+ if (follow) {
256
+ // Live follow mode
257
+ log(`following ${T}${name}${R} logs (Ctrl+C to exit)`);
258
+ try {
259
+ const { execSync } = require("child_process");
260
+ execSync(`tmux capture-pane -t ${sname} -p -e`, { stdio: "inherit" });
261
+ } catch (e) { log(`error: ${e.message}`); }
262
+ } else {
263
+ const r = tmux(["capture-pane", "-t", sname, "-p"]);
264
+ if (r.stdout) {
265
+ const lines = r.stdout.trim().split("\n").slice(-50);
266
+ console.log(lines.join("\n"));
267
+ }
268
+ }
269
+ }
270
+
271
+ function cmdWorkspace(target, sub, args) {
272
+ switch (sub) {
273
+ case "init": cmdWorkspaceInit(target, args); break;
274
+ case "up": cmdWorkspaceUp(target, args); break;
275
+ case "down": cmdWorkspaceDown(target, args); break;
276
+ case "status": cmdWorkspaceStatus(target); break;
277
+ case "attach": cmdWorkspaceAttach(target, args[0]); break;
278
+ case "logs": cmdWorkspaceLogs(target, args[0], args); break;
279
+ case "add": cmdWorkspaceAdd(target, args); break;
280
+ case "rm": cmdWorkspaceRm(target, args[0]); break;
281
+ default:
282
+ console.log(`\n ${T}0dai workspace${R} — tmux session manager\n`);
283
+ console.log("Commands:");
284
+ console.log(" workspace init [--global] Create config (auto-detect services)");
285
+ console.log(" workspace up [name...] Start auto_start sessions (filter by name)");
286
+ console.log(" workspace down [name...] Stop sessions (filter by name)");
287
+ console.log(" workspace status Show session status table");
288
+ console.log(" workspace attach <name> Attach to a running session");
289
+ console.log(" workspace logs <name> [-f] Show session output (-f = follow)");
290
+ console.log(" workspace add <name> --cmd '...' [--dir X] [--auto]");
291
+ console.log(" workspace rm <name> Remove session from config");
292
+ console.log();
293
+ }
294
+ }
295
+
296
+ module.exports = { cmdWorkspace };
package/lib/shared.js CHANGED
@@ -48,6 +48,10 @@ const CONFIG_DIR = path.join(os.homedir(), ".0dai");
48
48
  const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
49
49
  const VERSION_CHECK_FILE = path.join(CONFIG_DIR, ".version_check");
50
50
  const PROJECTS_FILE = path.join(CONFIG_DIR, "projects.json");
51
+ const MANAGED_BEGIN = "<!-- 0dai:managed:begin -->";
52
+ const MANAGED_END = "<!-- 0dai:managed:end -->";
53
+ const LEGACY_MANAGED_BEGIN = "<!-- zerodayai:managed:begin -->";
54
+ const LEGACY_MANAGED_END = "<!-- zerodayai:managed:end -->";
51
55
 
52
56
  // --- API ---
53
57
  function apiCall(endpoint, data) {
@@ -180,6 +184,31 @@ function mergeSettingsJson(existing, incoming) {
180
184
  } catch { return incoming; }
181
185
  }
182
186
 
187
+ function mergeManagedMarkdown(existing, incoming) {
188
+ let src = incoming;
189
+ if (src.startsWith("# managed: true")) {
190
+ src = src.split("\n").slice(1).join("\n").trimStart();
191
+ }
192
+ const managedBody = `${MANAGED_BEGIN}\n${src.trim()}\n${MANAGED_END}\n`;
193
+ if (existing.includes(MANAGED_BEGIN) && existing.includes(MANAGED_END)) {
194
+ const start = existing.indexOf(MANAGED_BEGIN);
195
+ const finish = existing.indexOf(MANAGED_END) + MANAGED_END.length;
196
+ return existing.slice(0, start) + managedBody + existing.slice(finish);
197
+ }
198
+ if (existing.includes(LEGACY_MANAGED_BEGIN) && existing.includes(LEGACY_MANAGED_END)) {
199
+ const finish = existing.indexOf(LEGACY_MANAGED_END) + LEGACY_MANAGED_END.length;
200
+ const rest = existing.slice(finish).trimStart();
201
+ return rest ? `${managedBody}\n${rest}` : managedBody;
202
+ }
203
+ return `${managedBody}\n${existing}`;
204
+ }
205
+
206
+ function contentLooksManaged(existing) {
207
+ return existing.includes("managed: true") || existing.includes('"managed": true') ||
208
+ (existing.includes(MANAGED_BEGIN) && existing.includes(MANAGED_END)) ||
209
+ (existing.includes(LEGACY_MANAGED_BEGIN) && existing.includes(LEGACY_MANAGED_END));
210
+ }
211
+
183
212
  function writeFiles(target, files) {
184
213
  let created = 0, updated = 0, unchanged = 0, merged = 0, skipped = 0;
185
214
  const targetResolved = path.resolve(target);
@@ -212,6 +241,54 @@ function writeFiles(target, files) {
212
241
  return created + updated;
213
242
  }
214
243
 
244
+ function writeManagedFiles(target, files) {
245
+ let created = 0, updated = 0, unchanged = 0, merged = 0, staged = 0, skipped = 0;
246
+ const targetResolved = path.resolve(target);
247
+ for (const [rel, content] of Object.entries(files)) {
248
+ if (typeof rel !== "string" || !rel || path.isAbsolute(rel) || rel.split(/[/\\]/).includes("..")) {
249
+ skipped++; continue;
250
+ }
251
+ const p = path.resolve(targetResolved, rel);
252
+ if (!p.startsWith(targetResolved + path.sep) && p !== targetResolved) { skipped++; continue; }
253
+ fs.mkdirSync(path.dirname(p), { recursive: true });
254
+ if (!fs.existsSync(p)) {
255
+ fs.writeFileSync(p, content, "utf8");
256
+ created++;
257
+ continue;
258
+ }
259
+ const existing = fs.readFileSync(p, "utf8");
260
+ if (existing === content) {
261
+ unchanged++;
262
+ continue;
263
+ }
264
+
265
+ if (rel.endsWith("settings.json")) {
266
+ fs.writeFileSync(p, mergeSettingsJson(existing, content), "utf8");
267
+ merged++;
268
+ continue;
269
+ }
270
+ if (rel === "AGENTS.md" || rel.endsWith("/CLAUDE.md")) {
271
+ fs.writeFileSync(p, mergeManagedMarkdown(existing, content), "utf8");
272
+ merged++;
273
+ continue;
274
+ }
275
+ if (!contentLooksManaged(existing)) {
276
+ fs.writeFileSync(`${p}.generated`, content, "utf8");
277
+ staged++;
278
+ continue;
279
+ }
280
+
281
+ fs.writeFileSync(p, content, "utf8");
282
+ updated++;
283
+ }
284
+ const parts = [`${created} created`, `${updated} updated`, `${unchanged} unchanged`];
285
+ if (merged) parts.push(`${merged} merged`);
286
+ if (staged) parts.push(`${staged} staged`);
287
+ if (skipped) parts.push(`${skipped} skipped (unsafe path)`);
288
+ log(parts.join(", "));
289
+ return created + updated + merged;
290
+ }
291
+
215
292
  // --- Repo Script Lookup ---
216
293
  function findRepoScript(target, scriptName) {
217
294
  const candidates = [
@@ -275,7 +352,7 @@ module.exports = {
275
352
  // Project
276
353
  sendProjectHeartbeat, recordExperienceEvent,
277
354
  // Files
278
- mergeSettingsJson, writeFiles, findRepoScript,
355
+ mergeSettingsJson, mergeManagedMarkdown, writeFiles, writeManagedFiles, findRepoScript,
279
356
  // Version
280
357
  checkVersion,
281
358
  // Re-exports for convenience
package/lib/wizard.js CHANGED
@@ -69,7 +69,7 @@ async function stepAuth(rl) {
69
69
  console.log(" 0dai works in two modes:");
70
70
  console.log("");
71
71
  console.log(" Local mode — generate configs offline, no account needed");
72
- console.log(" Cloud mode — full graph, swarm, roaming, 55 MCP tools");
72
+ console.log(" Cloud mode — full graph, swarm, roaming, 56 MCP tools");
73
73
  console.log("");
74
74
 
75
75
  const answer = await ask(rl, " Sign in now? [Y/n]: ", "y");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@0dai-dev/cli",
3
- "version": "4.0.0",
4
- "description": "One config layer for 5 AI agent CLIs — Claude Code, Codex, OpenCode, Gemini, Aider",
3
+ "version": "4.1.0",
4
+ "description": "One config layer for seven AI coding agents — Claude Code, Codex, OpenCode, Gemini, Aider, Qoder",
5
5
  "bin": {
6
6
  "0dai": "./bin/0dai.js"
7
7
  },
@@ -12,8 +12,11 @@
12
12
  "codex",
13
13
  "gemini",
14
14
  "aider",
15
+ "opencode",
16
+ "qoder",
15
17
  "developer-tools",
16
- "mcp"
18
+ "mcp",
19
+ "cli"
17
20
  ],
18
21
  "author": "0dai-dev <dev@0dai.dev>",
19
22
  "license": "MIT",
@@ -35,7 +38,9 @@
35
38
  "README.md"
36
39
  ],
37
40
  "dependencies": {
38
- "@clack/prompts": "^1.2.0",
41
+ "@clack/prompts": "^1.2.0"
42
+ },
43
+ "optionalDependencies": {
39
44
  "node-pty": "^1.0.0"
40
45
  }
41
46
  }