ndomo 0.1.0 → 0.2.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.
Files changed (65) hide show
  1. package/.env.example +4 -0
  2. package/README.es.md +29 -23
  3. package/README.md +64 -24
  4. package/bun.lock +447 -0
  5. package/docs/configuration.md +4 -4
  6. package/docs/installation.md +53 -34
  7. package/docs/installer.md +164 -0
  8. package/docs/integrations.md +1 -1
  9. package/docs/web-ui.md +124 -0
  10. package/package.json +43 -4
  11. package/scripts/install.sh +28 -0
  12. package/scripts/smoke-install.sh +47 -0
  13. package/scripts/smoke-web.sh +335 -0
  14. package/src/cli/__tests__/install.test.ts +733 -0
  15. package/src/cli/index.ts +8 -0
  16. package/src/cli/install.ts +1292 -0
  17. package/src/config/__tests__/schema.test.ts +223 -0
  18. package/src/config/schema.ts +129 -16
  19. package/src/http/__tests__/auth.test.ts +10 -10
  20. package/src/http/__tests__/spa.test.ts +296 -0
  21. package/src/http/auth.ts +8 -1
  22. package/src/http/server.ts +71 -2
  23. package/.bun-version +0 -1
  24. package/.dockerignore +0 -79
  25. package/.editorconfig +0 -18
  26. package/.github/CODEOWNERS +0 -8
  27. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
  28. package/.github/ISSUE_TEMPLATE/config.yml +0 -2
  29. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
  30. package/.github/dependabot.yml +0 -36
  31. package/.github/pull_request_template.md +0 -24
  32. package/.github/release.yml +0 -30
  33. package/.github/workflows/gitleaks.yml +0 -28
  34. package/.github/workflows/release-please.yml +0 -27
  35. package/.github/workflows/smoke.yml +0 -29
  36. package/.husky/commit-msg +0 -1
  37. package/CHANGELOG.md +0 -114
  38. package/Dockerfile +0 -32
  39. package/bin/ndomo-analyses.ts +0 -4
  40. package/bin/ndomo-status.ts +0 -4
  41. package/biome.json +0 -57
  42. package/commitlint.config.js +0 -3
  43. package/opencode.json +0 -5
  44. package/release-please-config.json +0 -11
  45. package/scripts/dev-bust-cache.sh +0 -164
  46. package/scripts/smoke-e2e.ts +0 -704
  47. package/scripts/smoke-hot.ts +0 -417
  48. package/scripts/smoke-v4.ts +0 -256
  49. package/scripts/smoke-v5.ts +0 -397
  50. package/scripts/uninstall.sh +0 -224
  51. package/src/index.ts +0 -37
  52. package/src/lib.ts +0 -65
  53. package/src/mem/scoped.ts +0 -65
  54. package/src/orchestrator/background.test.ts +0 -268
  55. package/src/orchestrator/background.ts +0 -293
  56. package/src/orchestrator/memory-hook.ts +0 -182
  57. package/src/orchestrator/reconciler.ts +0 -123
  58. package/src/orchestrator/scheduler.test.ts +0 -300
  59. package/src/orchestrator/scheduler.ts +0 -243
  60. package/src/plugin.test.ts +0 -2574
  61. package/src/plugin.ts +0 -1690
  62. package/src/worktrees/manager.ts +0 -236
  63. package/src/worktrees/state.ts +0 -87
  64. package/tests/integration/ranger-flow.test.ts +0 -257
  65. package/tsconfig.json +0 -31
@@ -0,0 +1,1292 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * ndomo install — TypeScript port of scripts/install.sh
4
+ *
5
+ * Installs agents, skills, and config into ~/.config/opencode/.
6
+ * Supports preset application, plugin registration, 3-strategy package install,
7
+ * HTTP auto-prompt, and DCP opt-in.
8
+ *
9
+ * @example
10
+ * bun run src/cli/install.ts
11
+ * bun run src/cli/install.ts --preset=budget --enable-http
12
+ * bun run src/cli/install.ts --dry-run --skip-deps
13
+ */
14
+
15
+ import { existsSync, mkdirSync, copyFileSync, symlinkSync, lstatSync, readFileSync, writeFileSync, rmSync } from "node:fs";
16
+ import { join, basename, dirname } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { resolveConfigDir, type HttpConfig, type NdomoConfig } from "../config/schema.ts";
19
+
20
+ // ─── ANSI colors (no external deps) ──────────────────────────────────────────
21
+ const RED = "\x1b[0;31m";
22
+ const GREEN = "\x1b[0;32m";
23
+ const YELLOW = "\x1b[1;33m";
24
+ const BLUE = "\x1b[0;34m";
25
+ const BOLD = "\x1b[1m";
26
+ const NC = "\x1b[0m";
27
+
28
+ const info = (msg: string): void => console.log(`${BLUE}[info]${NC} ${msg}`);
29
+ const ok = (msg: string): void => console.log(`${GREEN}[ok]${NC} ${msg}`);
30
+ const warn = (msg: string): void => console.error(`${YELLOW}[warn]${NC} ${msg}`);
31
+ const err = (msg: string): void => console.error(`${RED}[error]${NC} ${msg}`);
32
+ const die = (msg: string): never => {
33
+ err(msg);
34
+ process.exit(1);
35
+ };
36
+
37
+ // ─── Path traversal protection ────────────────────────────────────────────────
38
+ const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
39
+
40
+ function assertSafeFilename(name: string): boolean {
41
+ return SAFE_NAME_RE.test(name);
42
+ }
43
+
44
+ // ─── Type definitions ─────────────────────────────────────────────────────────
45
+ export type InstallFlags = {
46
+ preset: string;
47
+ provider: string;
48
+ noProviderPrompt: boolean;
49
+ withDcp: boolean;
50
+ dryRun: boolean;
51
+ skipDeps: boolean;
52
+ enableHttp: boolean;
53
+ disableHttp: boolean;
54
+ corsOrigins: string;
55
+ port: number;
56
+ authRequired: boolean;
57
+ uninstall: boolean;
58
+ help: boolean;
59
+ };
60
+
61
+ export type PresetEntry = {
62
+ model?: string;
63
+ temperature?: number;
64
+ reasoning_effort?: string;
65
+ };
66
+
67
+ // ─── Flag parsing ─────────────────────────────────────────────────────────────
68
+ export function parseFlags(args: string[]): InstallFlags {
69
+ const flags: InstallFlags = {
70
+ preset: "default",
71
+ provider: "",
72
+ noProviderPrompt: false,
73
+ withDcp: false,
74
+ dryRun: false,
75
+ skipDeps: false,
76
+ enableHttp: false,
77
+ disableHttp: false,
78
+ corsOrigins: "*",
79
+ port: 4097,
80
+ authRequired: true,
81
+ uninstall: false,
82
+ help: false,
83
+ };
84
+
85
+ for (const arg of args) {
86
+ if (arg === "--help" || arg === "-h") {
87
+ flags.help = true;
88
+ } else if (arg === "--with-dcp") {
89
+ flags.withDcp = true;
90
+ } else if (arg === "--dry-run") {
91
+ flags.dryRun = true;
92
+ } else if (arg === "--skip-deps") {
93
+ flags.skipDeps = true;
94
+ } else if (arg === "--enable-http") {
95
+ flags.enableHttp = true;
96
+ } else if (arg === "--disable-http") {
97
+ flags.disableHttp = true;
98
+ } else if (arg === "--no-provider-prompt") {
99
+ flags.noProviderPrompt = true;
100
+ } else if (arg.startsWith("--preset=")) {
101
+ flags.preset = arg.slice("--preset=".length);
102
+ } else if (arg.startsWith("--provider=")) {
103
+ flags.provider = arg.slice("--provider=".length);
104
+ } else if (arg.startsWith("--cors-origins=")) {
105
+ flags.corsOrigins = arg.slice("--cors-origins=".length);
106
+ } else if (arg.startsWith("--port=")) {
107
+ const val = Number(arg.slice("--port=".length));
108
+ if (!Number.isNaN(val) && val > 0 && val < 65536) {
109
+ flags.port = val;
110
+ }
111
+ } else if (arg.startsWith("--auth-required=")) {
112
+ flags.authRequired = arg.slice("--auth-required=".length) !== "false";
113
+ } else if (arg === "--uninstall") {
114
+ flags.uninstall = true;
115
+ } else if (arg.startsWith("-")) {
116
+ throw new Error(`Unknown option: ${arg} (try --help)`);
117
+ }
118
+ }
119
+
120
+ return flags;
121
+ }
122
+
123
+ // ─── Help text ────────────────────────────────────────────────────────────────
124
+ export function printHelp(): void {
125
+ console.log(`${BOLD}ndomo installer — agent preset & provider tool${NC}
126
+
127
+ ${BOLD}Usage:${NC}
128
+ bun run src/cli/install.ts [OPTIONS]
129
+ ndomo install [OPTIONS]
130
+
131
+ ${BOLD}Options:${NC}
132
+ --preset=NAME Use preset from ndomo.config.json (default: "default")
133
+ --provider=ID Override provider prefix (e.g., opencode, anthropic)
134
+ --no-provider-prompt Skip interactive provider override prompt
135
+ --with-dcp Also install @tarquinen/opencode-dcp
136
+ --dry-run Print planned changes, do not write
137
+ --skip-deps Skip 'bun install' step
138
+ --enable-http Auto-enable HTTP server (writes http block to ndomo.config.json)
139
+ --disable-http Skip HTTP auto-prompt (default in non-TTY)
140
+ --cors-origins=CSV Override CORS origins (default: *)
141
+ --port=N Override HTTP port (default: 4097)
142
+ --auth-required=BOOL Override auth requirement (default: true)
143
+ --uninstall Run uninstaller (compat, execs scripts/uninstall.sh)
144
+ --help, -h Show this help
145
+
146
+ ${BOLD}Environment:${NC}
147
+ NDOMO_SKIP_PACKAGE_INSTALL=1 Skip ndomo package installation
148
+ XDG_CONFIG_HOME Override config directory (default: ~/.config)
149
+
150
+ ${BOLD}Examples:${NC}
151
+ bun run src/cli/install.ts # apply default preset
152
+ bun run src/cli/install.ts --preset=budget # apply budget preset
153
+ bun run src/cli/install.ts --provider=opencode # swap provider prefix
154
+ bun run src/cli/install.ts --enable-http # enable HTTP server
155
+ bun run src/cli/install.ts --dry-run # preview changes`);
156
+ }
157
+
158
+ // ─── Project root detection ───────────────────────────────────────────────────
159
+ export function detectProjectRoot(): string {
160
+ // Walk up from __dirname to find package.json
161
+ let dir = import.meta.dir;
162
+ while (dir !== "/" && dir !== homedir()) {
163
+ if (existsSync(join(dir, "package.json"))) {
164
+ // Verify it's ndomo (has agents/ dir or src/cli/)
165
+ if (existsSync(join(dir, "agents")) || existsSync(join(dir, "src", "cli"))) {
166
+ return dir;
167
+ }
168
+ }
169
+ dir = dirname(dir);
170
+ }
171
+ // Fallback: two levels up from src/cli/
172
+ return join(import.meta.dir, "..", "..");
173
+ }
174
+
175
+ // ─── Step helpers ─────────────────────────────────────────────────────────────
176
+
177
+ /** Step 1: Install dependencies. */
178
+ export async function stepInstallDeps(projectRoot: string, dryRun: boolean): Promise<void> {
179
+ info("Installing dependencies...");
180
+ if (dryRun) {
181
+ info("[dry-run] would run: bun install --frozen-lockfile");
182
+ return;
183
+ }
184
+ const proc = Bun.spawn(["bun", "install", "--frozen-lockfile"], {
185
+ cwd: projectRoot,
186
+ stdout: "pipe",
187
+ stderr: "pipe",
188
+ });
189
+ await proc.exited;
190
+ if (proc.exitCode !== 0) {
191
+ // Fallback to non-frozen
192
+ warn("frozen lockfile failed, retrying without --frozen-lockfile...");
193
+ const proc2 = Bun.spawn(["bun", "install"], {
194
+ cwd: projectRoot,
195
+ stdout: "pipe",
196
+ stderr: "pipe",
197
+ });
198
+ await proc2.exited;
199
+ if (proc2.exitCode !== 0) {
200
+ die("bun install failed");
201
+ }
202
+ }
203
+ ok("Dependencies installed");
204
+ }
205
+
206
+ /** Step 2: Build TypeScript. */
207
+ export async function stepBuild(projectRoot: string, dryRun: boolean): Promise<void> {
208
+ info("Building TypeScript...");
209
+ if (dryRun) {
210
+ info("[dry-run] would run: bun run build (or tsc)");
211
+ return;
212
+ }
213
+ // Check for build script in package.json
214
+ const pkgPath = join(projectRoot, "package.json");
215
+ let hasBuild = false;
216
+ try {
217
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
218
+ hasBuild = typeof pkg.scripts?.build === "string";
219
+ } catch {
220
+ // no package.json — try tsc
221
+ }
222
+
223
+ const cmd = hasBuild ? ["bun", "run", "build"] : ["bun", "run", "--bun", "tsc"];
224
+ const proc = Bun.spawn(cmd, {
225
+ cwd: projectRoot,
226
+ stdout: "pipe",
227
+ stderr: "pipe",
228
+ });
229
+ await proc.exited;
230
+ if (proc.exitCode !== 0) {
231
+ die(`Build failed (exit ${proc.exitCode})`);
232
+ }
233
+ ok("Build complete");
234
+ }
235
+
236
+ /** Step 3+4: Copy agents with timestamped backup. */
237
+ export function stepCopyAgents(
238
+ projectRoot: string,
239
+ configDir: string,
240
+ backupDir: string,
241
+ dryRun: boolean,
242
+ ): number {
243
+ const agentSrc = join(projectRoot, "agents");
244
+ const agentDst = join(configDir, "agent");
245
+
246
+ if (!existsSync(agentSrc)) {
247
+ warn("No agents/ directory found in project root");
248
+ return 0;
249
+ }
250
+
251
+ mkdirSync(agentDst, { recursive: true });
252
+
253
+ const files = readdirSafe(agentSrc).filter((f) => f.endsWith(".md"));
254
+ let backedUp = 0;
255
+ let copied = 0;
256
+
257
+ for (const file of files) {
258
+ const srcFile = join(agentSrc, file);
259
+ const dstFile = join(agentDst, file);
260
+
261
+ // Backup existing
262
+ if (existsSync(dstFile)) {
263
+ if (backedUp === 0) {
264
+ mkdirSync(backupDir, { recursive: true });
265
+ info(`Backing up existing agents to ${backupDir}`);
266
+ }
267
+ if (!dryRun) {
268
+ copyFileSync(dstFile, join(backupDir, file));
269
+ }
270
+ backedUp++;
271
+ }
272
+
273
+ if (dryRun) {
274
+ info(`[dry-run] would copy agent: ${file}`);
275
+ } else {
276
+ copyFileSync(srcFile, dstFile);
277
+ }
278
+ copied++;
279
+ }
280
+
281
+ if (backedUp > 0) {
282
+ ok(`Backed up ${backedUp} existing agent(s)`);
283
+ }
284
+ if (copied > 0) {
285
+ ok(`Copied ${copied} agent(s) to ${agentDst}`);
286
+ } else {
287
+ warn("No agent .md files found");
288
+ }
289
+ return copied;
290
+ }
291
+
292
+ /** Step 5: Copy skills with timestamped backup. */
293
+ export function stepCopySkills(
294
+ projectRoot: string,
295
+ configDir: string,
296
+ backupDir: string,
297
+ dryRun: boolean,
298
+ ): number {
299
+ const skillSrc = join(projectRoot, "skills");
300
+ const skillDst = join(configDir, "skills");
301
+
302
+ if (!existsSync(skillSrc)) {
303
+ warn("No skills/ directory found in project root");
304
+ return 0;
305
+ }
306
+
307
+ mkdirSync(skillDst, { recursive: true });
308
+
309
+ const dirs = readdirSafe(skillSrc).filter((d) => {
310
+ const full = join(skillSrc, d);
311
+ return existsSync(full) && lstatSync(full).isDirectory();
312
+ });
313
+
314
+ let backedUp = 0;
315
+ let copied = 0;
316
+
317
+ for (const name of dirs) {
318
+ const srcDir = join(skillSrc, name);
319
+ const dstDir = join(skillDst, name);
320
+
321
+ // Backup existing
322
+ if (existsSync(dstDir)) {
323
+ if (backedUp === 0) {
324
+ mkdirSync(join(backupDir, "skills"), { recursive: true });
325
+ info(`Backing up existing skills to ${backupDir}/skills`);
326
+ }
327
+ if (!dryRun) {
328
+ cpSyncRecursive(dstDir, join(backupDir, "skills", name));
329
+ }
330
+ backedUp++;
331
+ // Remove existing before copy (bash: rm -rf then cp -r)
332
+ if (!dryRun) {
333
+ rmSync(dstDir, { recursive: true, force: true });
334
+ }
335
+ }
336
+
337
+ if (dryRun) {
338
+ info(`[dry-run] would copy skill: ${name}/`);
339
+ } else {
340
+ cpSyncRecursive(srcDir, dstDir);
341
+ }
342
+ copied++;
343
+ }
344
+
345
+ if (backedUp > 0) {
346
+ ok(`Backed up ${backedUp} existing skill(s)`);
347
+ }
348
+ if (copied > 0) {
349
+ ok(`Copied ${copied} skill(s) to ${skillDst}`);
350
+ }
351
+ return copied;
352
+ }
353
+
354
+ // ─── Preset application ──────────────────────────────────────────────────────
355
+
356
+ /**
357
+ * Parse YAML frontmatter from an agent .md file.
358
+ * Returns { frontmatter: Record<string, string>, body: string, raw: string }.
359
+ * Frontmatter is between the first two '---' lines.
360
+ */
361
+ export function parseFrontmatter(content: string): {
362
+ frontmatter: Record<string, string>;
363
+ body: string;
364
+ startIdx: number;
365
+ endIdx: number;
366
+ } {
367
+ const lines = content.split("\n");
368
+ let startIdx = -1;
369
+ let endIdx = -1;
370
+
371
+ for (let i = 0; i < lines.length; i++) {
372
+ const line = lines[i];
373
+ if (line !== undefined && line.trim() === "---") {
374
+ if (startIdx === -1) {
375
+ startIdx = i;
376
+ } else {
377
+ endIdx = i;
378
+ break;
379
+ }
380
+ }
381
+ }
382
+
383
+ if (startIdx === -1 || endIdx === -1) {
384
+ return { frontmatter: {}, body: content, startIdx: -1, endIdx: -1 };
385
+ }
386
+
387
+ const fm: Record<string, string> = {};
388
+ for (let i = startIdx + 1; i < endIdx; i++) {
389
+ const line = lines[i];
390
+ if (line === undefined) continue;
391
+ const colonIdx = line.indexOf(":");
392
+ if (colonIdx > 0) {
393
+ const key = line.slice(0, colonIdx).trim();
394
+ const value = line.slice(colonIdx + 1).trim();
395
+ fm[key] = value;
396
+ }
397
+ }
398
+
399
+ const body = lines.slice(endIdx + 1).join("\n");
400
+ return { frontmatter: fm, body, startIdx, endIdx };
401
+ }
402
+
403
+ /**
404
+ * Serialize frontmatter + body back to a string.
405
+ */
406
+ export function serializeFrontmatter(frontmatter: Record<string, string>, body: string): string {
407
+ const fmLines = Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`);
408
+ return `---\n${fmLines.join("\n")}\n---\n${body}`;
409
+ }
410
+
411
+ /**
412
+ * Apply preset values to an agent .md file's frontmatter.
413
+ * Handles reasoningEffort 3-tier insert fallback:
414
+ * 1. Update existing reasoningEffort line
415
+ * 2. Insert after temperature (if present)
416
+ * 3. Insert after model (if present)
417
+ * 4. Insert after opening ---
418
+ */
419
+ export function applyPresetToFile(
420
+ filePath: string,
421
+ preset: PresetEntry,
422
+ dryRun: boolean,
423
+ ): "updated" | "skipped" {
424
+ const content = readFileSync(filePath, "utf-8");
425
+ const { frontmatter, body, startIdx, endIdx } = parseFrontmatter(content);
426
+
427
+ if (startIdx === -1 || endIdx === -1) {
428
+ warn(`No frontmatter found in ${basename(filePath)}, skipping`);
429
+ return "skipped";
430
+ }
431
+
432
+ let changed = false;
433
+
434
+ // Apply model
435
+ if (preset.model) {
436
+ frontmatter["model"] = preset.model;
437
+ changed = true;
438
+ }
439
+
440
+ // Apply temperature
441
+ if (preset.temperature !== undefined) {
442
+ frontmatter["temperature"] = String(preset.temperature);
443
+ changed = true;
444
+ }
445
+
446
+ // Apply reasoningEffort (snake_case → camelCase)
447
+ if (preset.reasoning_effort) {
448
+ frontmatter["reasoningEffort"] = preset.reasoning_effort;
449
+ changed = true;
450
+ }
451
+
452
+ if (!changed) {
453
+ return "skipped";
454
+ }
455
+
456
+ // Serialize back preserving order: model, temperature, reasoningEffort, then others
457
+ const ordered: Record<string, string> = {};
458
+ const priority = ["model", "temperature", "reasoningEffort"];
459
+ for (const key of priority) {
460
+ if (frontmatter[key] !== undefined) {
461
+ ordered[key] = frontmatter[key];
462
+ }
463
+ }
464
+ for (const [key, value] of Object.entries(frontmatter)) {
465
+ if (!(key in ordered)) {
466
+ ordered[key] = value;
467
+ }
468
+ }
469
+
470
+ if (dryRun) {
471
+ info(`[dry-run] would update ${basename(filePath)}: model=${preset.model ?? "(keep)"}, temp=${preset.temperature ?? "(keep)"}, effort=${preset.reasoning_effort ?? "(keep)"}`);
472
+ } else {
473
+ writeFileSync(filePath, serializeFrontmatter(ordered, body));
474
+ }
475
+
476
+ return "updated";
477
+ }
478
+
479
+ /**
480
+ * Apply provider prefix to model lines in agent .md files.
481
+ * Replaces the provider/ prefix on model: lines (e.g., "opencode/gpt-4" → "anthropic/gpt-4").
482
+ */
483
+ export function applyProviderPrefix(
484
+ agentDir: string,
485
+ provider: string,
486
+ dryRun: boolean,
487
+ ): number {
488
+ const files = readdirSafe(agentDir).filter((f) => f.endsWith(".md"));
489
+ let updated = 0;
490
+
491
+ for (const file of files) {
492
+ const filePath = join(agentDir, file);
493
+ const content = readFileSync(filePath, "utf-8");
494
+ const lines = content.split("\n");
495
+ let changed = false;
496
+
497
+ for (let i = 0; i < lines.length; i++) {
498
+ const line = lines[i];
499
+ if (line === undefined) continue;
500
+ if (line.startsWith("model: ")) {
501
+ const value = line.slice("model: ".length);
502
+ const slashIdx = value.indexOf("/");
503
+ if (slashIdx > 0) {
504
+ const newValue = `${provider}/${value.slice(slashIdx + 1)}`;
505
+ lines[i] = `model: ${newValue}`;
506
+ changed = true;
507
+ break; // Only first model line
508
+ }
509
+ }
510
+ }
511
+
512
+ if (changed) {
513
+ if (dryRun) {
514
+ info(`[dry-run] would apply provider prefix '${provider}/' to ${file}`);
515
+ } else {
516
+ writeFileSync(filePath, lines.join("\n"));
517
+ }
518
+ updated++;
519
+ }
520
+ }
521
+
522
+ return updated;
523
+ }
524
+
525
+ /** Step 5.5: Apply preset + optional provider prefix override. */
526
+ export function stepApplyPreset(
527
+ configDir: string,
528
+ configJson: NdomoConfig,
529
+ preset: string,
530
+ provider: string,
531
+ dryRun: boolean,
532
+ ): void {
533
+ const agentDir = join(configDir, "agent");
534
+
535
+ if (!existsSync(agentDir)) {
536
+ warn("No agent directory found, skipping preset application");
537
+ return;
538
+ }
539
+
540
+ const presets = configJson.presets;
541
+ if (!presets || !presets[preset]) {
542
+ warn(`Preset '${preset}' not found in ndomo.config.json, skipping`);
543
+ return;
544
+ }
545
+
546
+ const presetData = presets[preset];
547
+ const files = readdirSafe(agentDir).filter((f) => f.endsWith(".md"));
548
+ let updated = 0;
549
+ let skipped = 0;
550
+
551
+ for (const file of files) {
552
+ const name = file.replace(/\.md$/, "");
553
+ if (!assertSafeFilename(name)) {
554
+ warn(`Skipping invalid agent name: '${name}'`);
555
+ skipped++;
556
+ continue;
557
+ }
558
+
559
+ const entry = presetData[name];
560
+ if (!entry) {
561
+ warn(`Agent '${name}' has no entry in preset '${preset}', skipping`);
562
+ skipped++;
563
+ continue;
564
+ }
565
+
566
+ const result = applyPresetToFile(join(agentDir, file), entry, dryRun);
567
+ if (result === "updated") {
568
+ updated++;
569
+ } else {
570
+ skipped++;
571
+ }
572
+ }
573
+
574
+ ok(`Applied preset '${preset}' — ${updated} agent(s) updated`);
575
+ if (skipped > 0) {
576
+ warn(`${skipped} agent(s) skipped`);
577
+ }
578
+
579
+ // Apply provider prefix if requested
580
+ if (provider) {
581
+ const updatedP = applyProviderPrefix(agentDir, provider, dryRun);
582
+ ok(`Provider prefix override '${provider}/' applied to ${updatedP} agent(s)`);
583
+ }
584
+ }
585
+
586
+ // ─── Step 6: Copy config files ───────────────────────────────────────────────
587
+ export function stepCopyConfig(
588
+ projectRoot: string,
589
+ configDir: string,
590
+ backupDir: string,
591
+ dryRun: boolean,
592
+ ): void {
593
+ const configJson = join(projectRoot, "config", "ndomo.config.json");
594
+ const schemaJson = join(projectRoot, "config", "ndomo.schema.json");
595
+
596
+ if (existsSync(configJson)) {
597
+ const dst = join(configDir, "ndomo.json");
598
+ if (existsSync(dst)) {
599
+ mkdirSync(backupDir, { recursive: true });
600
+ if (!dryRun) {
601
+ copyFileSync(dst, join(backupDir, "ndomo.json"));
602
+ }
603
+ info("Backed up existing ndomo.json");
604
+ }
605
+ if (dryRun) {
606
+ info("[dry-run] would copy ndomo.config.json -> ndomo.json");
607
+ } else {
608
+ copyFileSync(configJson, dst);
609
+ }
610
+ ok("Copied config.json -> ndomo.json");
611
+ } else {
612
+ warn("No config/ndomo.config.json found");
613
+ }
614
+
615
+ if (existsSync(schemaJson)) {
616
+ const dst = join(configDir, "ndomo.schema.json");
617
+ if (existsSync(dst)) {
618
+ mkdirSync(backupDir, { recursive: true });
619
+ if (!dryRun) {
620
+ copyFileSync(dst, join(backupDir, "ndomo.schema.json"));
621
+ }
622
+ info("Backed up existing ndomo.schema.json");
623
+ }
624
+ if (dryRun) {
625
+ info("[dry-run] would copy ndomo.schema.json");
626
+ } else {
627
+ copyFileSync(schemaJson, dst);
628
+ }
629
+ ok("Copied ndomo.schema.json");
630
+ } else {
631
+ warn("No config/ndomo.schema.json found");
632
+ }
633
+ }
634
+
635
+ // ─── Step 6.5: Register plugins in opencode.json ─────────────────────────────
636
+ export function stepRegisterPlugins(
637
+ configDir: string,
638
+ configJson: NdomoConfig,
639
+ backupDir: string,
640
+ dryRun: boolean,
641
+ ): void {
642
+ const opencodeJsonPath = join(configDir, "opencode.json");
643
+
644
+ // Create opencode.json if missing
645
+ if (!existsSync(opencodeJsonPath)) {
646
+ if (!dryRun) {
647
+ writeFileSync(opencodeJsonPath, "{}");
648
+ }
649
+ }
650
+
651
+ // Backup
652
+ if (existsSync(opencodeJsonPath)) {
653
+ const backupPath = join(backupDir, "opencode.json");
654
+ if (!existsSync(backupPath)) {
655
+ mkdirSync(backupDir, { recursive: true });
656
+ if (!dryRun) {
657
+ copyFileSync(opencodeJsonPath, backupPath);
658
+ }
659
+ info("Backed up opencode.json");
660
+ }
661
+ }
662
+
663
+ // Extract deduped union of plugins + optionalPlugins
664
+ const plugins = configJson.plugins ?? [];
665
+ const optionalPlugins = configJson.optionalPlugins ?? [];
666
+ const allPlugins = [...new Set([...plugins, ...optionalPlugins])].filter(
667
+ (p) => typeof p === "string" && p.length > 0,
668
+ );
669
+
670
+ if (allPlugins.length === 0) {
671
+ info("No ndomo plugins found to register");
672
+ return;
673
+ }
674
+
675
+ if (dryRun) {
676
+ info(`[dry-run] would register ${allPlugins.length} plugin(s): ${allPlugins.join(", ")}`);
677
+ return;
678
+ }
679
+
680
+ // Read existing opencode.json
681
+ let opencode: Record<string, unknown> = {};
682
+ try {
683
+ opencode = JSON.parse(readFileSync(opencodeJsonPath, "utf-8"));
684
+ } catch {
685
+ opencode = {};
686
+ }
687
+
688
+ // Merge with dedup
689
+ const existingPlugins: string[] = Array.isArray(opencode.plugin) ? opencode.plugin : [];
690
+ const merged = [...new Set([...existingPlugins, ...allPlugins])];
691
+ opencode.plugin = merged;
692
+
693
+ writeFileSync(opencodeJsonPath, JSON.stringify(opencode, null, 2) + "\n");
694
+ ok(`Registered ${allPlugins.length} ndomo plugin(s) in opencode.json`);
695
+ }
696
+
697
+ // ─── Step 6.6: Install ndomo package (3 strategies) ──────────────────────────
698
+
699
+ function isSymlink(p: string): boolean {
700
+ try {
701
+ return lstatSync(p).isSymbolicLink();
702
+ } catch {
703
+ return false;
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Strategy 1: file: dep + bun install → real copy (no symlink).
709
+ * Mutates package.json to add ndomo as file: dep, then runs bun install.
710
+ */
711
+ async function strategyFileDep(
712
+ projectRoot: string,
713
+ configDir: string,
714
+ ): Promise<boolean> {
715
+ const pkgJsonPath = join(configDir, "package.json");
716
+ const nmNdomo = join(configDir, "node_modules", "ndomo");
717
+
718
+ info(`Adding ndomo file: dep to ${pkgJsonPath}`);
719
+
720
+ try {
721
+ let pkg: Record<string, unknown> = {};
722
+ try {
723
+ pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
724
+ } catch {
725
+ return false;
726
+ }
727
+
728
+ // Mutate dependencies
729
+ const deps = (pkg.dependencies as Record<string, string>) ?? {};
730
+ deps.ndomo = `file://${projectRoot}`;
731
+ pkg.dependencies = deps;
732
+ writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n");
733
+
734
+ // Run bun install
735
+ const proc = Bun.spawn(["bun", "install", "--no-frozen-lockfile"], {
736
+ cwd: configDir,
737
+ stdout: "pipe",
738
+ stderr: "pipe",
739
+ });
740
+ await proc.exited;
741
+
742
+ if (proc.exitCode === 0 && existsSync(nmNdomo) && !isSymlink(nmNdomo)) {
743
+ ok("ndomo installed via bun (file: dep) — real copy, no symlink");
744
+ return true;
745
+ }
746
+ } catch {
747
+ // fall through to strategy 2
748
+ }
749
+
750
+ warn("bun install did not materialize ndomo as real copy, trying bun link...");
751
+ return false;
752
+ }
753
+
754
+ /**
755
+ * Strategy 2: bun link → managed symlink (bun-tracked).
756
+ */
757
+ async function strategyBunLink(projectRoot: string, configDir: string): Promise<boolean> {
758
+ info("Trying bun link...");
759
+
760
+ try {
761
+ // bun link in project root (registers package)
762
+ const proc1 = Bun.spawn(["bun", "link"], {
763
+ cwd: projectRoot,
764
+ stdout: "pipe",
765
+ stderr: "pipe",
766
+ });
767
+ await proc1.exited;
768
+
769
+ if (proc1.exitCode !== 0) {
770
+ warn("bun link in project root failed");
771
+ return false;
772
+ }
773
+
774
+ // bun link ndomo in config dir (links package)
775
+ const proc2 = Bun.spawn(["bun", "link", "ndomo"], {
776
+ cwd: configDir,
777
+ stdout: "pipe",
778
+ stderr: "pipe",
779
+ });
780
+ await proc2.exited;
781
+
782
+ const nmNdomo = join(configDir, "node_modules", "ndomo");
783
+ if (proc2.exitCode === 0 && existsSync(nmNdomo)) {
784
+ ok("ndomo linked via bun link (managed symlink)");
785
+ warn("bun link uses symlinks — run 'bun run dev:bust' if cache goes stale");
786
+ return true;
787
+ }
788
+ } catch {
789
+ // fall through to strategy 3
790
+ }
791
+
792
+ warn("bun link failed, falling back to manual symlink");
793
+ return false;
794
+ }
795
+
796
+ /**
797
+ * Strategy 3: manual symlink (last resort).
798
+ */
799
+ function strategyManualSymlink(projectRoot: string, configDir: string): boolean {
800
+ const nmNdomo = join(configDir, "node_modules", "ndomo");
801
+
802
+ info(`Creating manual symlink: ${nmNdomo} -> ${projectRoot}`);
803
+ mkdirSync(join(configDir, "node_modules"), { recursive: true });
804
+
805
+ try {
806
+ symlinkSync(projectRoot, nmNdomo, "dir");
807
+ ok(`ndomo symlinked at ${nmNdomo} (last resort)`);
808
+ warn("manual symlink may cause Bun cache stale — run 'bun run dev:bust' to recover");
809
+ return true;
810
+ } catch {
811
+ err("Failed to install ndomo package");
812
+ return false;
813
+ }
814
+ }
815
+
816
+ export async function stepInstallPackage(
817
+ projectRoot: string,
818
+ configDir: string,
819
+ dryRun: boolean,
820
+ ): Promise<void> {
821
+ // Skip if user opted out
822
+ if (process.env.NDOMO_SKIP_PACKAGE_INSTALL === "1") {
823
+ info("Skipping ndomo package install (NDOMO_SKIP_PACKAGE_INSTALL=1)");
824
+ return;
825
+ }
826
+
827
+ const nmNdomo = join(configDir, "node_modules", "ndomo");
828
+
829
+ // If existing install is a symlink, remove it
830
+ if (isSymlink(nmNdomo)) {
831
+ warn("Existing ndomo install is a symlink (causes Bun cache stale in dev)");
832
+ info("Removing symlink, will reinstall as real copy...");
833
+ if (!dryRun) {
834
+ rmSync(nmNdomo, { force: true });
835
+ }
836
+ } else if (existsSync(nmNdomo)) {
837
+ info(`ndomo already installed at ${nmNdomo}`);
838
+ return;
839
+ }
840
+
841
+ if (dryRun) {
842
+ info("[dry-run] would install ndomo package via 3-strategy cascade");
843
+ return;
844
+ }
845
+
846
+ // Need package.json for strategy 1+2
847
+ const pkgJsonPath = join(configDir, "package.json");
848
+ if (!existsSync(pkgJsonPath)) {
849
+ warn(`${pkgJsonPath} not found, falling back to manual symlink`);
850
+ strategyManualSymlink(projectRoot, configDir);
851
+ return;
852
+ }
853
+
854
+ // Strategy 1: file: dep + bun install
855
+ if (await strategyFileDep(projectRoot, configDir)) {
856
+ return;
857
+ }
858
+
859
+ // Strategy 2: bun link
860
+ if (await strategyBunLink(projectRoot, configDir)) {
861
+ return;
862
+ }
863
+
864
+ // Strategy 3: manual symlink (last resort)
865
+ if (!strategyManualSymlink(projectRoot, configDir)) {
866
+ die("Failed to install ndomo package");
867
+ }
868
+ }
869
+
870
+ // ─── Step 6.7: Copy custom tools ─────────────────────────────────────────────
871
+ // npm distribution: tools live inside the installed ndomo package, so symlink
872
+ // dance (used in old repo-based install) is obsolete. Copy .ts files directly.
873
+ export function stepCopyTools(
874
+ projectRoot: string,
875
+ configDir: string,
876
+ dryRun: boolean,
877
+ ): number {
878
+ const src = join(projectRoot, "tools");
879
+ const dst = join(configDir, "tools");
880
+
881
+ if (!existsSync(src)) {
882
+ warn(`No tools/ directory found at ${src} — skipping`);
883
+ return 0;
884
+ }
885
+
886
+ if (dryRun) {
887
+ info(`[dry-run] would copy tools from ${src} to ${dst}`);
888
+ return 0;
889
+ }
890
+
891
+ mkdirSync(configDir, { recursive: true });
892
+ mkdirSync(dst, { recursive: true });
893
+ let copied = 0;
894
+ const entries = readdirSafe(src);
895
+ for (const entry of entries) {
896
+ const srcPath = join(src, entry);
897
+ const dstPath = join(dst, entry);
898
+ const stat = lstatSync(srcPath);
899
+ if (!stat.isFile() || !srcPath.endsWith(".ts")) {
900
+ // Skip non-.ts files (subdirs, etc.)
901
+ continue;
902
+ }
903
+ if (existsSync(dstPath)) {
904
+ // Idempotent: skip if content matches
905
+ const srcContent = readFileSync(srcPath, "utf-8");
906
+ const dstContent = readFileSync(dstPath, "utf-8");
907
+ if (srcContent === dstContent) {
908
+ continue;
909
+ }
910
+ // Backup changed file
911
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
912
+ const backupPath = join(configDir, `.backup-${ts}`, "tools", entry);
913
+ mkdirSync(dirname(backupPath), { recursive: true });
914
+ copyFileSync(dstPath, backupPath);
915
+ }
916
+ copyFileSync(srcPath, dstPath);
917
+ copied++;
918
+ }
919
+ if (copied > 0) {
920
+ ok(`Copied ${copied} tool file(s) to ${dst}`);
921
+ } else {
922
+ info(`Tool files already up to date at ${dst}`);
923
+ }
924
+ return copied;
925
+ }
926
+
927
+ // ─── Step 7: Inject preset name into ndomo.json ──────────────────────────────
928
+ export function stepInjectPreset(configDir: string, preset: string, dryRun: boolean): void {
929
+ const ndomoJsonPath = join(configDir, "ndomo.json");
930
+
931
+ if (!existsSync(ndomoJsonPath)) {
932
+ return;
933
+ }
934
+
935
+ if (dryRun) {
936
+ info(`[dry-run] would inject preset '${preset}' into ndomo.json`);
937
+ return;
938
+ }
939
+
940
+ try {
941
+ const ndomo: Record<string, unknown> = JSON.parse(readFileSync(ndomoJsonPath, "utf-8"));
942
+ ndomo.preset = preset;
943
+ writeFileSync(ndomoJsonPath, JSON.stringify(ndomo, null, 2) + "\n");
944
+ ok(`Preset '${preset}' written to ndomo.json`);
945
+ } catch (e) {
946
+ warn(`Failed to inject preset into ndomo.json: ${e}`);
947
+ }
948
+ }
949
+
950
+ // ─── Step 8: Optional DCP install ────────────────────────────────────────────
951
+ export async function stepInstallDcp(dryRun: boolean): Promise<void> {
952
+ info("Installing @tarquinen/opencode-dcp (AGPL-3.0)...");
953
+ if (dryRun) {
954
+ info("[dry-run] would run: opencode plugin @tarquinen/opencode-dcp --global");
955
+ return;
956
+ }
957
+
958
+ const proc = Bun.spawn(["opencode", "plugin", "@tarquinen/opencode-dcp", "--global"], {
959
+ stdout: "pipe",
960
+ stderr: "pipe",
961
+ });
962
+ await proc.exited;
963
+ if (proc.exitCode === 0) {
964
+ ok("DCP plugin installed");
965
+ } else {
966
+ warn("DCP plugin install failed (non-fatal)");
967
+ }
968
+ }
969
+
970
+ // ─── HTTP auto-prompt ─────────────────────────────────────────────────────────
971
+ /**
972
+ * Build HttpConfig from flags + defaults.
973
+ */
974
+ export function buildHttpConfig(flags: InstallFlags): HttpConfig {
975
+ return {
976
+ enabled: true,
977
+ port: flags.port,
978
+ cors: {
979
+ origins: flags.corsOrigins?.split(",").map((s: string) => s.trim()) ?? ["*"],
980
+ },
981
+ auth: {
982
+ required: flags.authRequired,
983
+ },
984
+ };
985
+ }
986
+
987
+ /**
988
+ * Write http block to ndomo.config.json (source-of-truth in project, not config dir).
989
+ */
990
+ export function writeHttpBlock(projectRoot: string, httpConfig: HttpConfig, dryRun: boolean): void {
991
+ const configPath = join(projectRoot, "config", "ndomo.config.json");
992
+ if (!existsSync(configPath)) {
993
+ warn("config/ndomo.config.json not found, cannot write http block");
994
+ return;
995
+ }
996
+
997
+ try {
998
+ const config: Record<string, unknown> = JSON.parse(readFileSync(configPath, "utf-8"));
999
+ config.http = httpConfig;
1000
+
1001
+ if (dryRun) {
1002
+ info(`[dry-run] would write http block to ${configPath}:`);
1003
+ console.log(JSON.stringify(httpConfig, null, 2));
1004
+ } else {
1005
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
1006
+ ok("HTTP server config written to config/ndomo.config.json");
1007
+ info(` port: ${httpConfig.port}, cors: ${httpConfig.cors.origins.join(",")}, auth: ${httpConfig.auth.required}`);
1008
+ }
1009
+ } catch (e) {
1010
+ warn(`Failed to write http block: ${e}`);
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Prompt user interactively to enable HTTP server (TTY only).
1016
+ * Returns true if user accepts, false otherwise.
1017
+ */
1018
+ export async function promptHttpEnable(): Promise<boolean> {
1019
+ console.log("");
1020
+ console.log("[?] Enable ndomo HTTP server? Allows programmatic plan/task control via API.");
1021
+ console.log(" Recommended for users integrating ndomo with other tools (port 4097, auth required).");
1022
+ process.stdout.write(" Enable now? [Y/n]: ");
1023
+
1024
+ return new Promise((resolve) => {
1025
+ process.stdin.setEncoding("utf-8");
1026
+ process.stdin.resume();
1027
+
1028
+ let input = "";
1029
+ const onData = (chunk: string) => {
1030
+ input += chunk;
1031
+ if (input.includes("\n")) {
1032
+ cleanup();
1033
+ const answer = input.trim().toLowerCase();
1034
+ resolve(answer === "" || answer === "y" || answer === "yes");
1035
+ }
1036
+ };
1037
+
1038
+ const cleanup = () => {
1039
+ process.stdin.removeListener("data", onData);
1040
+ process.stdin.pause();
1041
+ };
1042
+
1043
+ process.stdin.on("data", onData);
1044
+
1045
+ // Timeout after 30s
1046
+ setTimeout(() => {
1047
+ cleanup();
1048
+ console.log("\n(timeout — skipping HTTP enable)");
1049
+ resolve(false);
1050
+ }, 30_000);
1051
+ });
1052
+ }
1053
+
1054
+ /**
1055
+ * Handle HTTP auto-prompt logic:
1056
+ * - --enable-http → write block immediately
1057
+ * - --disable-http → skip entirely
1058
+ * - Otherwise → prompt in TTY, skip in non-TTY
1059
+ */
1060
+ export async function stepHttpPrompt(
1061
+ flags: InstallFlags,
1062
+ projectRoot: string,
1063
+ dryRun: boolean,
1064
+ ): Promise<void> {
1065
+ if (flags.enableHttp) {
1066
+ const httpConfig = buildHttpConfig(flags);
1067
+ writeHttpBlock(projectRoot, httpConfig, dryRun);
1068
+ return;
1069
+ }
1070
+
1071
+ if (flags.disableHttp) {
1072
+ info("HTTP auto-prompt disabled (--disable-http)");
1073
+ return;
1074
+ }
1075
+
1076
+ // Interactive prompt only in TTY
1077
+ if (!process.stdin.isTTY) {
1078
+ info("Non-TTY mode — skipping HTTP prompt (use --enable-http to enable)");
1079
+ return;
1080
+ }
1081
+
1082
+ const accepted = await promptHttpEnable();
1083
+ if (accepted) {
1084
+ const httpConfig = buildHttpConfig(flags);
1085
+ writeHttpBlock(projectRoot, httpConfig, dryRun);
1086
+ } else {
1087
+ info("HTTP server not enabled (can be enabled later with --enable-http)");
1088
+ }
1089
+ }
1090
+
1091
+ // ─── Summary ─────────────────────────────────────────────────────────────────
1092
+ function printSummary(configDir: string, preset: string, provider: string, withDcp: boolean): void {
1093
+ const agentDir = join(configDir, "agent");
1094
+ const skillDir = join(configDir, "skills");
1095
+
1096
+ console.log("");
1097
+ console.log(`${GREEN}${BOLD}════════════════════════════════════════${NC}`);
1098
+ console.log(`${GREEN}${BOLD} ndomo installed successfully!${NC}`);
1099
+ console.log(`${GREEN}${BOLD}════════════════════════════════════════${NC}`);
1100
+ console.log("");
1101
+ console.log(`${BOLD}Installed agents:${NC}`);
1102
+
1103
+ if (existsSync(agentDir)) {
1104
+ const agents = readdirSafe(agentDir).filter((f) => f.endsWith(".md"));
1105
+ for (const a of agents) {
1106
+ console.log(` ${a.replace(/\.md$/, "").padEnd(20)} ${a}`);
1107
+ }
1108
+ }
1109
+
1110
+ console.log("");
1111
+ if (existsSync(skillDir)) {
1112
+ const skills = readdirSafe(skillDir).filter((d) => {
1113
+ const full = join(skillDir, d);
1114
+ return existsSync(full) && lstatSync(full).isDirectory();
1115
+ });
1116
+ console.log(`${BOLD}Installed skills:${NC} ${skills.join(", ")}`);
1117
+ }
1118
+
1119
+ console.log("");
1120
+ console.log(`${BOLD}Config:${NC} ${configDir}/ndomo.json`);
1121
+ console.log(`${BOLD}OpenCode config:${NC} ${configDir}/opencode.json (ndomo registered)`);
1122
+ console.log(`${BOLD}Preset:${NC} ${preset}`);
1123
+ if (provider) {
1124
+ console.log(`${BOLD}Provider:${NC} ${provider}`);
1125
+ }
1126
+ if (withDcp) {
1127
+ console.log(`${BOLD}DCP:${NC} installed`);
1128
+ }
1129
+ console.log("");
1130
+ console.log(`${BOLD}Next steps:${NC}`);
1131
+ console.log(` Run ${BLUE}opencode${NC} then ${BLUE}ping all agents${NC} to verify.`);
1132
+ console.log("");
1133
+ }
1134
+
1135
+ // ─── Uninstall shim ──────────────────────────────────────────────────────────
1136
+ function runUninstall(projectRoot: string): void {
1137
+ const uninstallScript = join(projectRoot, "scripts", "uninstall.sh");
1138
+ if (!existsSync(uninstallScript)) {
1139
+ die("scripts/uninstall.sh not found");
1140
+ }
1141
+ info("Running uninstaller...");
1142
+ const proc = Bun.spawn(["bash", uninstallScript], {
1143
+ cwd: projectRoot,
1144
+ stdout: "inherit",
1145
+ stderr: "inherit",
1146
+ });
1147
+ // Wait for process to finish
1148
+ proc.exited.then((code) => {
1149
+ process.exit(code ?? 0);
1150
+ });
1151
+ }
1152
+
1153
+ // ─── Safe directory listing ──────────────────────────────────────────────────
1154
+ function readdirSafe(dir: string): string[] {
1155
+ try {
1156
+ const { readdirSync } = require("node:fs");
1157
+ return readdirSync(dir) as string[];
1158
+ } catch {
1159
+ return [];
1160
+ }
1161
+ }
1162
+
1163
+ // ─── Recursive copy helper ───────────────────────────────────────────────────
1164
+ function cpSyncRecursive(src: string, dst: string): void {
1165
+ mkdirSync(dst, { recursive: true });
1166
+ const entries = readdirSafe(src);
1167
+ for (const entry of entries) {
1168
+ const srcPath = join(src, entry);
1169
+ const dstPath = join(dst, entry);
1170
+ const stat = lstatSync(srcPath);
1171
+ if (stat.isDirectory()) {
1172
+ cpSyncRecursive(srcPath, dstPath);
1173
+ } else {
1174
+ copyFileSync(srcPath, dstPath);
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ // ─── Main ────────────────────────────────────────────────────────────────────
1180
+ export async function runInstall(args: string[]): Promise<void> {
1181
+ const flags = parseFlags(args);
1182
+
1183
+ if (flags.help) {
1184
+ printHelp();
1185
+ return;
1186
+ }
1187
+
1188
+ // Uninstall shortcut
1189
+ if (flags.uninstall) {
1190
+ const projectRoot = detectProjectRoot();
1191
+ runUninstall(projectRoot);
1192
+ return;
1193
+ }
1194
+
1195
+ // Detect paths
1196
+ const projectRoot = detectProjectRoot();
1197
+ const configDir = resolveConfigDir();
1198
+ const timestamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 15);
1199
+ const backupDir = join(configDir, `.backup-${timestamp}`);
1200
+
1201
+ console.log("");
1202
+ console.log(`${BOLD}ndomo installer${NC} — preset: ${flags.preset}, config: ${configDir}`);
1203
+ console.log("");
1204
+
1205
+ // Load config for preset validation
1206
+ const configJsonPath = join(projectRoot, "config", "ndomo.config.json");
1207
+ let configJson: NdomoConfig = {};
1208
+ try {
1209
+ configJson = JSON.parse(readFileSync(configJsonPath, "utf-8"));
1210
+ } catch {
1211
+ warn("Could not read config/ndomo.config.json — preset application may fail");
1212
+ }
1213
+
1214
+ // Validate preset
1215
+ const presets = configJson.presets;
1216
+ if (presets && !presets[flags.preset]) {
1217
+ const available = Object.keys(presets).join(", ");
1218
+ die(`Unknown preset: '${flags.preset}' (available: ${available})`);
1219
+ }
1220
+
1221
+ // Dry-run banner
1222
+ if (flags.dryRun) {
1223
+ console.log(`${YELLOW}${BOLD}[DRY-RUN] No files will be written${NC}`);
1224
+ console.log("");
1225
+ }
1226
+
1227
+ // Step 1: Install deps
1228
+ if (!flags.skipDeps) {
1229
+ await stepInstallDeps(projectRoot, flags.dryRun);
1230
+ } else {
1231
+ info("Skipping dependency installation (--skip-deps)");
1232
+ }
1233
+
1234
+ // Step 2: Build
1235
+ await stepBuild(projectRoot, flags.dryRun);
1236
+
1237
+ // Step 3+4: Copy agents
1238
+ mkdirSync(join(configDir, "agent"), { recursive: true });
1239
+ mkdirSync(join(configDir, "skills"), { recursive: true });
1240
+ stepCopyAgents(projectRoot, configDir, backupDir, flags.dryRun);
1241
+
1242
+ // Step 5: Copy skills
1243
+ stepCopySkills(projectRoot, configDir, backupDir, flags.dryRun);
1244
+
1245
+ // Step 5.5: Apply preset
1246
+ stepApplyPreset(configDir, configJson, flags.preset, flags.provider, flags.dryRun);
1247
+
1248
+ // Step 6: Copy config
1249
+ stepCopyConfig(projectRoot, configDir, backupDir, flags.dryRun);
1250
+
1251
+ // Step 6.5: Register plugins
1252
+ // Reload config from configDir (just copied)
1253
+ let installedConfig: NdomoConfig = {};
1254
+ const ndomoJsonPath = join(configDir, "ndomo.json");
1255
+ try {
1256
+ installedConfig = JSON.parse(readFileSync(ndomoJsonPath, "utf-8"));
1257
+ } catch {
1258
+ // Use original
1259
+ installedConfig = configJson;
1260
+ }
1261
+ stepRegisterPlugins(configDir, installedConfig, backupDir, flags.dryRun);
1262
+
1263
+ // Step 6.6: Install package
1264
+ await stepInstallPackage(projectRoot, configDir, flags.dryRun);
1265
+
1266
+ // Step 6.7: Copy tools (npm distribution — no symlink)
1267
+ stepCopyTools(projectRoot, configDir, flags.dryRun);
1268
+
1269
+ // Step 7: Inject preset
1270
+ stepInjectPreset(configDir, flags.preset, flags.dryRun);
1271
+
1272
+ // Step 8: Optional DCP
1273
+ if (flags.withDcp) {
1274
+ await stepInstallDcp(flags.dryRun);
1275
+ }
1276
+
1277
+ // HTTP auto-prompt (closes Phase-1 gap)
1278
+ await stepHttpPrompt(flags, projectRoot, flags.dryRun);
1279
+
1280
+ // Summary
1281
+ if (!flags.dryRun) {
1282
+ printSummary(configDir, flags.preset, flags.provider, flags.withDcp);
1283
+ } else {
1284
+ console.log("");
1285
+ console.log(`${YELLOW}[dry-run] Complete. No files were modified.${NC}`);
1286
+ }
1287
+ }
1288
+
1289
+ // Direct execution
1290
+ if (import.meta.main) {
1291
+ await runInstall(process.argv.slice(2));
1292
+ }