@bankung/agent-teams 0.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/cli/index.js ADDED
@@ -0,0 +1,665 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // cli/index.js — agent-teams CLI entrypoint.
4
+ //
5
+ // Subcommands:
6
+ // up Full install / bring-up (idempotent). Works in-repo OR standalone.
7
+ // down Stop containers without wiping data.
8
+ // status Show container health + API probe.
9
+ // reset Destructive wipe + rebuild (requires confirmation).
10
+ // help Print usage.
11
+ // --help, -h, --version, -v Aliases handled below.
12
+ //
13
+ // Zero runtime dependencies — only Node built-ins.
14
+
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+ const { spawnSync, spawn } = require('child_process');
18
+ const { checkGit, checkDocker, compose } = require('./lib/docker');
19
+ const { ensureEnv, readEnvPort } = require('./lib/env');
20
+ const { waitForHealthy } = require('./lib/health');
21
+ const { openUrl } = require('./lib/open-url');
22
+ const { requireConfirmation } = require('./lib/confirm');
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants / defaults
26
+ // ---------------------------------------------------------------------------
27
+ const VERSION = '0.1.0';
28
+ const PROJECT_NAME = 'agent-teams';
29
+ const REPO_URL = 'https://github.com/bankung/agent-teams.git';
30
+
31
+ // The CLI is published from the repo root, so __dirname = <repo>/cli.
32
+ // In IN-REPO mode the package root IS the repo root — docker-compose.yml sits
33
+ // one level up from __dirname. In STANDALONE (npx from empty dir) mode the
34
+ // package ships only cli/ + README so that file is absent; resolveRepoRoot()
35
+ // handles the distinction at runtime.
36
+ const PKG_PARENT = path.resolve(__dirname, '..');
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Help text
40
+ // ---------------------------------------------------------------------------
41
+ const HELP = `
42
+ Usage: npx @bankung/agent-teams <command> [options]
43
+
44
+ Commands:
45
+ up [targetDir] Build and start all services (Docker Compose). Idempotent.
46
+ In standalone mode (run outside a cloned repo), clones the
47
+ repository first. Optional targetDir overrides the default
48
+ clone destination (<cwd>/agent-teams).
49
+ down Stop all services (no data loss — volumes are kept).
50
+ status Show container health and probe the API on :8456.
51
+ reset DESTRUCTIVE: wipe all data (Postgres volume) and rebuild from
52
+ scratch. Requires typing 'WIPE' to confirm, or pass --yes.
53
+ help Print this message.
54
+
55
+ Options:
56
+ --help, -h Print this message.
57
+ --version, -v Print version.
58
+ --yes Skip interactive confirmation for reset.
59
+ --images, --pull (up only) Pull pre-built images from GHCR instead of
60
+ building from source. Requires no git clone — the CLI ships
61
+ docker-compose.images.yml and .env.example. Images must be
62
+ published to GHCR by the release CI first. Set
63
+ AGENT_TEAMS_VERSION in .env to pin a specific release tag
64
+ (default: latest).
65
+
66
+ Prerequisites:
67
+ Docker Desktop (or Docker Engine) must be installed and running.
68
+ git must be installed and on PATH (required for standalone/clone mode only;
69
+ NOT required when using --images).
70
+ Neither Docker nor git is installed by this CLI.
71
+ Docker: https://docs.docker.com/get-docker/
72
+ git: https://git-scm.com/downloads
73
+
74
+ Examples:
75
+ npx @bankung/agent-teams up # clone + build from source
76
+ npx @bankung/agent-teams up --images # pull pre-built GHCR images
77
+ npx @bankung/agent-teams status
78
+ npx @bankung/agent-teams down
79
+ npx @bankung/agent-teams reset --yes
80
+ npx @bankung/agent-teams up ~/my-agent-teams
81
+
82
+ Bin alias: the package exposes the \`agent-teams\` bin alias.
83
+ After a global install (\`npm install -g @bankung/agent-teams\`) you can run:
84
+ agent-teams up
85
+ agent-teams up --images
86
+ `.trim();
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Utilities
90
+ // ---------------------------------------------------------------------------
91
+ function die(msg, code = 1) {
92
+ process.stderr.write(`ERROR: ${msg}\n`);
93
+ process.exit(code);
94
+ }
95
+
96
+ function log(msg) {
97
+ process.stdout.write(`==> ${msg}\n`);
98
+ }
99
+
100
+ function banner() {
101
+ console.log(`
102
+ =========================================================================
103
+ agent-teams is installed and running.
104
+
105
+ Next steps:
106
+ 1. Open http://localhost:5431 in your browser.
107
+ 2. Click the 'demo-tour' project. Try a task. (5 min walkthrough.)
108
+ 3. Read QUICKSTART.md (at the repo root) for the full intro.
109
+
110
+ Need help? See README.md or run: npx @bankung/agent-teams --help
111
+ =========================================================================
112
+ `);
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Images-compose resolution (--images / --pull mode)
117
+ //
118
+ // docker-compose.images.yml is shipped inside the npm package (alongside
119
+ // .env.example) so `npx @bankung/agent-teams up --images` works from an
120
+ // EMPTY directory — no git clone needed.
121
+ //
122
+ // Search order:
123
+ // 1. PKG_PARENT/docker-compose.images.yml (in-repo or package-root mode)
124
+ // 2. process.cwd()/docker-compose.images.yml (user ran from repo dir)
125
+ //
126
+ // Returns the absolute path to the compose file, or throws if not found.
127
+ // ---------------------------------------------------------------------------
128
+ function resolveImagesCompose() {
129
+ const candidates = [
130
+ path.join(PKG_PARENT, 'docker-compose.images.yml'),
131
+ path.join(process.cwd(), 'docker-compose.images.yml'),
132
+ ];
133
+ for (const candidate of candidates) {
134
+ if (fs.existsSync(candidate)) return candidate;
135
+ }
136
+ throw new Error(
137
+ 'docker-compose.images.yml not found.\n' +
138
+ ' Searched:\n' +
139
+ candidates.map((c) => ` ${c}`).join('\n') + '\n' +
140
+ ' The file ships with the npm package — try reinstalling:\n' +
141
+ ' npm install -g @bankung/agent-teams'
142
+ );
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Repo-root resolution (NEW FEATURE — clone capability)
147
+ //
148
+ // Returns the absolute path to the repo root.
149
+ //
150
+ // IN-REPO mode: docker-compose.yml exists at PKG_PARENT (the package root,
151
+ // one level above this file's __dirname). Use PKG_PARENT as-is.
152
+ //
153
+ // STANDALONE mode: docker-compose.yml is absent. Clone or reuse a clone in
154
+ // `targetDir` (default: <cwd>/agent-teams).
155
+ // ---------------------------------------------------------------------------
156
+ async function resolveRepoRoot(targetDir) {
157
+ const composeFile = path.join(PKG_PARENT, 'docker-compose.yml');
158
+
159
+ if (fs.existsSync(composeFile)) {
160
+ log(`IN-REPO mode — using repo root: ${PKG_PARENT}`);
161
+ return PKG_PARENT;
162
+ }
163
+
164
+ // STANDALONE mode — need git
165
+ const git = checkGit();
166
+ if (!git.ok) die(git.message, 1);
167
+
168
+ const cloneDir = targetDir
169
+ ? path.resolve(targetDir)
170
+ : path.join(process.cwd(), 'agent-teams');
171
+
172
+ // Guard: refuse if cloneDir resolves to a system directory.
173
+ // Covers POSIX (/etc, /usr, /bin, /sbin, /lib, /boot) and Windows (C:\Windows).
174
+ const normalClone = cloneDir.replace(/\\/g, '/');
175
+ const SYSTEM_DIRS_RE = /^(\/etc|\/usr|\/bin|\/sbin|\/lib|\/boot|[A-Za-z]:\/Windows)(\/|$)/i;
176
+ if (SYSTEM_DIRS_RE.test(normalClone)) {
177
+ die(`Refusing to clone into a system directory: ${cloneDir}`, 1);
178
+ }
179
+
180
+ log(`STANDALONE mode — resolved clone directory: ${cloneDir}`);
181
+
182
+ if (fs.existsSync(cloneDir)) {
183
+ // Directory exists — check whether it already contains the repo
184
+ const cloneCompose = path.join(cloneDir, 'docker-compose.yml');
185
+ if (fs.existsSync(cloneCompose)) {
186
+ log(`Clone directory already contains a repo — reusing: ${cloneDir}`);
187
+ // Optional: git pull --ff-only (non-fatal on failure)
188
+ log('Attempting git pull --ff-only to get latest changes (non-fatal if it fails)...');
189
+ const pullResult = spawnSync('git', ['pull', '--ff-only'], {
190
+ cwd: cloneDir,
191
+ stdio: 'inherit',
192
+ shell: false,
193
+ timeout: 300_000,
194
+ });
195
+ if (pullResult.status !== 0) {
196
+ process.stderr.write('WARN: git pull --ff-only failed — continuing with existing clone.\n');
197
+ }
198
+ return cloneDir;
199
+ } else {
200
+ die(
201
+ `Directory "${cloneDir}" already exists but does not contain agent-teams (docker-compose.yml missing).\n` +
202
+ ` Choose an empty or clean target directory:\n` +
203
+ ` npx @bankung/agent-teams up /path/to/empty-dir`,
204
+ 1
205
+ );
206
+ }
207
+ }
208
+
209
+ // Clone the repo
210
+ log(`Cloning ${REPO_URL} into ${cloneDir} ...`);
211
+ log('(First run builds Docker images from source — this may take several minutes.)');
212
+ const cloneResult = spawnSync('git', ['clone', REPO_URL, cloneDir], {
213
+ stdio: 'inherit',
214
+ shell: false,
215
+ timeout: 300_000,
216
+ });
217
+ if (cloneResult.status !== 0) {
218
+ die('git clone failed. Check the error above. Ensure the repo URL is public and git is on PATH.', 1);
219
+ }
220
+
221
+ return cloneDir;
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Tier-preset step (BLOCKER-1)
226
+ //
227
+ // Mirrors install.sh step 5 / install.ps1 step 5.
228
+ // Prompts for Claude Code plan, runs the tier-set script when Pro is chosen.
229
+ // Non-interactive (no TTY or NON_INTERACTIVE env var set) → skip silently.
230
+ // ---------------------------------------------------------------------------
231
+ async function runTierStep(repoRoot) {
232
+ const isInteractive =
233
+ process.stdin.isTTY &&
234
+ !process.env.NON_INTERACTIVE;
235
+
236
+ if (!isInteractive) {
237
+ log('Non-interactive mode — defaulting to TIER MAX.');
238
+ return;
239
+ }
240
+
241
+ // Prompt (mirrors install.sh exactly)
242
+ process.stdout.write('\nClaude Code plan? [m]ax / [p]ro (default: max, Enter to skip): ');
243
+
244
+ const planInput = await new Promise((resolve) => {
245
+ const readline = require('readline');
246
+ // terminal:false intentional — we wrote the prompt manually above (process.stdout.write)
247
+ // to avoid readline's built-in prompt echoing; terminal:false suppresses the duplicate.
248
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
249
+ // We already wrote the prompt above; just read one line.
250
+ rl.once('line', (line) => { rl.close(); resolve(line.trim()); });
251
+ rl.once('close', () => resolve(''));
252
+ });
253
+
254
+ const isProPlan = /^(p|pro)$/i.test(planInput);
255
+ const tierChoice = isProPlan ? 'l2' : 'max';
256
+
257
+ if (tierChoice === 'l2') {
258
+ log('Pro plan selected — applying TIER L2 preset...');
259
+ const isWindows = process.platform === 'win32';
260
+ if (isWindows) {
261
+ const tierScript = path.join(repoRoot, 'bin', 'agent-teams-tier-set.ps1');
262
+ if (fs.existsSync(tierScript)) {
263
+ const result = spawnSync('powershell.exe', ['-NonInteractive', '-File', tierScript, 'l2'], {
264
+ stdio: 'inherit',
265
+ shell: false,
266
+ cwd: repoRoot,
267
+ });
268
+ if (result.status !== 0) {
269
+ process.stderr.write('WARN: agent-teams-tier-set.ps1 exited non-zero — tier may not be applied.\n');
270
+ }
271
+ } else {
272
+ process.stderr.write(`WARN: bin\\agent-teams-tier-set.ps1 not found — skipping tier apply. Run it manually.\n`);
273
+ }
274
+ } else {
275
+ const tierScript = path.join(repoRoot, 'bin', 'agent-teams-tier-set.sh');
276
+ if (fs.existsSync(tierScript)) {
277
+ const result = spawnSync('bash', [tierScript, 'l2'], {
278
+ stdio: 'inherit',
279
+ shell: false,
280
+ cwd: repoRoot,
281
+ });
282
+ if (result.status !== 0) {
283
+ process.stderr.write('WARN: agent-teams-tier-set.sh exited non-zero — tier may not be applied.\n');
284
+ }
285
+ } else {
286
+ process.stderr.write(`WARN: bin/agent-teams-tier-set.sh not found — skipping tier apply. Run it manually.\n`);
287
+ }
288
+ }
289
+ log('TIER L2 active. Restart your Claude Code session to pick up new model defaults.');
290
+ } else {
291
+ log('TIER MAX active (operator default — no agent file changes).');
292
+ }
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // SEC-4: Production-readiness warnings (shared by cmdUp and cmdUpImages)
297
+ //
298
+ // Non-fatal — operator must decide. Never throws.
299
+ // ---------------------------------------------------------------------------
300
+ function warnIfInsecure(envRoot) {
301
+ const envFile = path.join(envRoot, '.env');
302
+ if (!fs.existsSync(envFile)) return;
303
+ try {
304
+ const raw = fs.readFileSync(envFile, 'utf8');
305
+ const get = (key) => {
306
+ // Escape key to prevent regex injection (hardcoded callers, but defensive).
307
+ const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
308
+ const m = raw.match(new RegExp(`^${safeKey}=(.*)$`, 'm'));
309
+ return m ? m[1].trim() : '';
310
+ };
311
+ const secretKey = get('SECRET_KEY');
312
+ const appEnv = get('APP_ENV');
313
+ const appDebug = get('APP_DEBUG');
314
+ const warns = [];
315
+ if (!secretKey || secretKey.includes('dev-secret') || secretKey.length < 32) {
316
+ warns.push(' - SECRET_KEY is empty or weak. Generate a strong random value before going live.');
317
+ }
318
+ if (appEnv === 'development') {
319
+ warns.push(' - APP_ENV=development is set. Change to "production" for public deployments.');
320
+ }
321
+ if (appDebug === 'true') {
322
+ warns.push(' - APP_DEBUG=true is set. Disable before exposing the stack to the internet.');
323
+ }
324
+ if (warns.length) {
325
+ process.stderr.write(
326
+ '\nWARN: Production-readiness issues detected in .env:\n' +
327
+ warns.join('\n') + '\n' +
328
+ 'Set secure values in .env before exposing this stack publicly.\n\n'
329
+ );
330
+ }
331
+ } catch (_) { /* best-effort; never fatal */ }
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Subcommand: up
336
+ // ---------------------------------------------------------------------------
337
+ async function cmdUp(argv) {
338
+ // Flags
339
+ const useImages = argv.includes('--images') || argv.includes('--pull');
340
+
341
+ // Optional positional argument: targetDir for standalone clone (ignored in --images mode)
342
+ const positional = argv.filter((a) => !a.startsWith('-'));
343
+ const targetDir = positional[0] || null;
344
+
345
+ // 1. Docker daemon check
346
+ const docker = checkDocker();
347
+ if (!docker.ok) die(docker.message, 1);
348
+ log('Docker daemon OK.');
349
+
350
+ if (useImages) {
351
+ // --images / --pull mode: pull pre-built GHCR images — no clone needed.
352
+ await cmdUpImages(argv);
353
+ return;
354
+ }
355
+
356
+ // 2. Resolve repo root (IN-REPO or STANDALONE/clone)
357
+ const repoRoot = await resolveRepoRoot(targetDir);
358
+
359
+ // 3. .env scaffold + CREDENTIALS_MASTER_KEY
360
+ ensureEnv(repoRoot);
361
+ warnIfInsecure(repoRoot);
362
+
363
+ // 4. docker compose up -d --build
364
+ log('Building and starting services (docker compose up -d --build)...');
365
+ const upExit = await compose(['up', '-d', '--build'], {}, { cwd: repoRoot });
366
+ if (upExit !== 0) die('docker compose up failed. Inspect the output above.', 2);
367
+
368
+ // 5. Schema migration (bypasses the L10 live-DB guard — safe on fresh install)
369
+ log('Running schema migration...');
370
+ log(' (MIGRATION_TARGET=live bypasses the live-DB guard — safe on fresh or idempotent re-run)');
371
+ const migrateExit = await compose(
372
+ ['exec', '-T', '-e', 'MIGRATION_TARGET=live', 'api', 'alembic', 'upgrade', 'head'],
373
+ {},
374
+ { cwd: repoRoot }
375
+ );
376
+ if (migrateExit !== 0) die('Schema migration failed. Check logs: docker compose logs api', 5);
377
+
378
+ // 6. Wait for API healthy
379
+ const apiPort = readEnvPort(repoRoot, 'API_PORT', '8456');
380
+ const healthUrl = `http://localhost:${apiPort}/api/projects`;
381
+ log(`Waiting for API at ${healthUrl} (cap 60s)...`);
382
+ const healthy = await waitForHealthy(healthUrl, { timeoutMs: 60000, intervalMs: 5000 });
383
+ if (!healthy) die('API did not become healthy within 60s. Check logs: docker compose logs api', 3);
384
+ log('API healthy.');
385
+
386
+ // 7. Seed (idempotent — re-runs are no-ops)
387
+ log('Running seed (docker compose exec -T api python -m scripts.seed)...');
388
+ log(' (SEED_TARGET=production bypasses the L11 guard — safe on fresh or idempotent re-run)');
389
+ const seedExit = await compose(
390
+ ['exec', '-T', '-e', 'SEED_TARGET=production', 'api', 'python', '-m', 'scripts.seed'],
391
+ {},
392
+ { cwd: repoRoot }
393
+ );
394
+ if (seedExit !== 0) die('Seed failed. Check logs: docker compose logs api', 4);
395
+
396
+ // 8. Tier-preset step (BLOCKER-1)
397
+ await runTierStep(repoRoot);
398
+
399
+ // 9. Banner + open browser
400
+ banner();
401
+ const webPort = readEnvPort(repoRoot, 'WEB_PORT', '5431');
402
+ openUrl(`http://localhost:${webPort}/p/agent-teams`);
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Subcommand: up --images (pull mode)
407
+ //
408
+ // Pulls pre-built images from GHCR and starts the stack using
409
+ // docker-compose.images.yml. No git clone required. The compose file and
410
+ // .env.example are shipped inside the npm package.
411
+ // ---------------------------------------------------------------------------
412
+ async function cmdUpImages(_argv) {
413
+ // Resolve the bundled images-compose file (shipped with the npm package).
414
+ let imagesCompose;
415
+ try {
416
+ imagesCompose = resolveImagesCompose();
417
+ } catch (err) {
418
+ die(err.message, 1);
419
+ }
420
+ log(`Using images compose: ${imagesCompose}`);
421
+
422
+ // The env file lives next to the compose file (package root) in standalone
423
+ // mode, or at the repo root in in-repo mode. Both are the same directory.
424
+ const envRoot = path.dirname(imagesCompose);
425
+
426
+ // Scaffold .env from .env.example (ships with the package).
427
+ ensureEnv(envRoot);
428
+
429
+ // SEC-4: Non-fatal production-readiness WARN (shared warnIfInsecure — also called from cmdUp).
430
+ warnIfInsecure(envRoot);
431
+
432
+ // Pull images first.
433
+ log('Pulling images from GHCR (docker compose pull)...');
434
+ const pullExit = await compose(['pull'], {}, { cwd: envRoot }, imagesCompose);
435
+ if (pullExit !== 0) die('docker compose pull failed. Check the output above.', 2);
436
+
437
+ // Start stack (no --build — images already pulled).
438
+ log('Starting services (docker compose up -d)...');
439
+ const upExit = await compose(['up', '-d'], {}, { cwd: envRoot }, imagesCompose);
440
+ if (upExit !== 0) die('docker compose up failed. Inspect the output above.', 2);
441
+
442
+ // Schema migration.
443
+ log('Running schema migration...');
444
+ log(' (MIGRATION_TARGET=live bypasses the live-DB guard — safe on fresh or idempotent re-run)');
445
+ const migrateExit = await compose(
446
+ ['exec', '-T', '-e', 'MIGRATION_TARGET=live', 'api', 'alembic', 'upgrade', 'head'],
447
+ {},
448
+ { cwd: envRoot },
449
+ imagesCompose
450
+ );
451
+ if (migrateExit !== 0) die('Schema migration failed. Check logs: docker compose -f docker-compose.images.yml logs api', 5);
452
+
453
+ // Wait for API healthy.
454
+ const apiPort = readEnvPort(envRoot, 'API_PORT', '8456');
455
+ const healthUrl = `http://localhost:${apiPort}/api/projects`;
456
+ log(`Waiting for API at ${healthUrl} (cap 60s)...`);
457
+ const healthy = await waitForHealthy(healthUrl, { timeoutMs: 60000, intervalMs: 5000 });
458
+ if (!healthy) die('API did not become healthy within 60s. Check logs: docker compose -f docker-compose.images.yml logs api', 3);
459
+ log('API healthy.');
460
+
461
+ // Seed (idempotent).
462
+ log('Running seed...');
463
+ log(' (SEED_TARGET=production bypasses the L11 guard — safe on fresh or idempotent re-run)');
464
+ const seedExit = await compose(
465
+ ['exec', '-T', '-e', 'SEED_TARGET=production', 'api', 'python', '-m', 'scripts.seed'],
466
+ {},
467
+ { cwd: envRoot },
468
+ imagesCompose
469
+ );
470
+ if (seedExit !== 0) die('Seed failed. Check logs: docker compose -f docker-compose.images.yml logs api', 4);
471
+
472
+ // Tier preset — only fires when bin/ is present (not shipped in npm package).
473
+ if (fs.existsSync(path.join(envRoot, 'bin'))) {
474
+ await runTierStep(envRoot);
475
+ } else {
476
+ log('Tier setup scripts (bin/) not present in this install — skipping tier step.');
477
+ log(' To configure Claude Code tiers, clone the repo: https://github.com/bankung/agent-teams');
478
+ }
479
+
480
+ // Banner + open browser.
481
+ banner();
482
+ const webPort = readEnvPort(envRoot, 'WEB_PORT', '5431');
483
+ openUrl(`http://localhost:${webPort}/p/agent-teams`);
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Subcommand: down
488
+ // ---------------------------------------------------------------------------
489
+ async function cmdDown() {
490
+ const docker = checkDocker();
491
+ if (!docker.ok) die(docker.message, 1);
492
+
493
+ // Resolution order:
494
+ // 1. docker-compose.yml at PKG_PARENT → in-repo mode (normal dev build)
495
+ // 2. docker-compose.images.yml → standalone --images mode
496
+ // 3. docker-compose.yml at cwd → fallback
497
+ // 4. neither found → graceful exit
498
+ const devCompose = path.join(PKG_PARENT, 'docker-compose.yml');
499
+ let imagesComposePath = null;
500
+ try { imagesComposePath = resolveImagesCompose(); } catch (_) { /* not found */ }
501
+
502
+ let repoRoot = null;
503
+ let composeArg = null; // null = use default docker-compose.yml discovery
504
+
505
+ if (fs.existsSync(devCompose)) {
506
+ repoRoot = PKG_PARENT;
507
+ // composeArg stays null — docker compose will pick docker-compose.yml
508
+ } else if (imagesComposePath) {
509
+ repoRoot = path.dirname(imagesComposePath);
510
+ composeArg = imagesComposePath;
511
+ } else {
512
+ const cwdCompose = path.join(process.cwd(), 'docker-compose.yml');
513
+ if (fs.existsSync(cwdCompose)) {
514
+ repoRoot = process.cwd();
515
+ } else {
516
+ log('Nothing to stop — no compose file found.');
517
+ process.exit(0);
518
+ }
519
+ }
520
+
521
+ log('Stopping services (volumes preserved)...');
522
+ const code = await compose(['down'], {}, { cwd: repoRoot }, composeArg);
523
+ process.exit(code);
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // Subcommand: status
528
+ // ---------------------------------------------------------------------------
529
+ async function cmdStatus() {
530
+ const docker = checkDocker();
531
+ if (!docker.ok) die(docker.message, 1);
532
+
533
+ // Mirror cmdDown resolution: prefer dev compose, fall back to images compose.
534
+ const devCompose = path.join(PKG_PARENT, 'docker-compose.yml');
535
+ let imagesComposePath = null;
536
+ try { imagesComposePath = resolveImagesCompose(); } catch (_) { /* not found */ }
537
+
538
+ let repoRoot = null;
539
+ let composeArg = null;
540
+
541
+ if (fs.existsSync(devCompose)) {
542
+ repoRoot = PKG_PARENT;
543
+ } else if (imagesComposePath) {
544
+ repoRoot = path.dirname(imagesComposePath);
545
+ composeArg = imagesComposePath;
546
+ } else {
547
+ const cwdCompose = path.join(process.cwd(), 'docker-compose.yml');
548
+ repoRoot = fs.existsSync(cwdCompose) ? process.cwd() : PKG_PARENT;
549
+ }
550
+
551
+ // Show `docker compose ps` output
552
+ log('Container status:');
553
+ await compose(['ps'], {}, { cwd: repoRoot }, composeArg);
554
+
555
+ // Probe API
556
+ const apiPort = readEnvPort(repoRoot, 'API_PORT', '8456');
557
+ const healthUrl = `http://localhost:${apiPort}/api/projects`;
558
+ log(`Probing API at ${healthUrl}...`);
559
+ const healthy = await waitForHealthy(healthUrl, { timeoutMs: 5000, intervalMs: 1000 });
560
+ if (healthy) {
561
+ log(`API is reachable at ${healthUrl}`);
562
+ } else {
563
+ process.stderr.write(`WARN: API at ${healthUrl} did not respond. Stack may be starting.\n`);
564
+ process.exit(1);
565
+ }
566
+ }
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Subcommand: reset
570
+ // ---------------------------------------------------------------------------
571
+ async function cmdReset(argv) {
572
+ const docker = checkDocker();
573
+ if (!docker.ok) die(docker.message, 1);
574
+
575
+ // BLOCKER-2 guard 1: refuse to run from a worktree.
576
+ // Check the resolved repo root (PKG_PARENT in in-repo mode) and cwd.
577
+ const repoRootForReset = fs.existsSync(path.join(PKG_PARENT, 'docker-compose.yml'))
578
+ ? PKG_PARENT
579
+ : process.cwd();
580
+
581
+ // Normalise separators for the pattern check (handle both / and \)
582
+ const repoRootNorm = repoRootForReset.replace(/\\/g, '/');
583
+ if (repoRootNorm.includes('.claude/worktrees')) {
584
+ die(
585
+ `Refusing to reset from a worktree path: ${repoRootForReset}\n` +
586
+ ` cd to the main repo checkout first.`,
587
+ 1
588
+ );
589
+ }
590
+
591
+ // BLOCKER-2 guard 2: docker-compose.yml must exist.
592
+ const composeFilePath = path.join(repoRootForReset, 'docker-compose.yml');
593
+ if (!fs.existsSync(composeFilePath)) {
594
+ die(
595
+ `docker-compose.yml not found in ${repoRootForReset}.\n` +
596
+ ` Reset requires the full repo. Run 'up' first to clone it, then re-run 'reset' from that directory.`,
597
+ 1
598
+ );
599
+ }
600
+
601
+ const hasYes = argv.includes('--yes') || process.env.AGENT_TEAMS_RESET_YES === '1';
602
+
603
+ if (!hasYes) {
604
+ const confirmed = await requireConfirmation(
605
+ `\nThis will:\n` +
606
+ ` - Stop all agent-teams containers (compose project: ${PROJECT_NAME}).\n` +
607
+ ` - DELETE the Postgres volume (every project, task, and history row is gone).\n` +
608
+ ` - Re-build and re-seed from scratch.\n`
609
+ );
610
+ if (!confirmed) {
611
+ console.log('Aborted.');
612
+ process.exit(0);
613
+ }
614
+ }
615
+
616
+ log(`docker compose -p ${PROJECT_NAME} down -v`);
617
+ const downCode = await compose(['down', '-v'], {}, { cwd: repoRootForReset });
618
+ if (downCode !== 0) die('docker compose down -v failed.', downCode);
619
+
620
+ log('Re-running full install...');
621
+ // F-01: pass the original flags (e.g. --images) so `reset --images` rebuilds via images.
622
+ await cmdUp(argv);
623
+ }
624
+
625
+ // ---------------------------------------------------------------------------
626
+ // Dispatch
627
+ // ---------------------------------------------------------------------------
628
+ async function main() {
629
+ const [,, cmd, ...rest] = process.argv;
630
+
631
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
632
+ console.log(HELP);
633
+ process.exit(0);
634
+ }
635
+
636
+ if (cmd === '--version' || cmd === '-v') {
637
+ console.log(`agent-teams ${VERSION}`);
638
+ process.exit(0);
639
+ }
640
+
641
+ switch (cmd) {
642
+ case 'up':
643
+ await cmdUp(rest);
644
+ break;
645
+ case 'down':
646
+ await cmdDown();
647
+ break;
648
+ case 'status':
649
+ await cmdStatus();
650
+ break;
651
+ case 'reset':
652
+ // F-02: pass only `rest` — omit the literal "reset" string that was incorrectly injected.
653
+ await cmdReset(rest);
654
+ break;
655
+ default:
656
+ process.stderr.write(`Unknown command: ${cmd}\n\n`);
657
+ console.log(HELP);
658
+ process.exit(1);
659
+ }
660
+ }
661
+
662
+ main().catch((err) => {
663
+ process.stderr.write(`Unhandled error: ${err.message}\n`);
664
+ process.exit(1);
665
+ });
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ // cli/lib/confirm.js — Interactive confirmation prompt via readline.
3
+
4
+ const readline = require('readline');
5
+
6
+ /**
7
+ * Prompt the user to type a specific confirmation word.
8
+ * Resolves true if the user types the expected word, false otherwise.
9
+ *
10
+ * @param {string} promptText The text to print before the prompt.
11
+ * @param {string} expectedWord The exact word the user must type (default: 'WIPE').
12
+ */
13
+ function requireConfirmation(promptText, expectedWord = 'WIPE') {
14
+ return new Promise((resolve) => {
15
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
+ let answered = false;
17
+ // F-05: EOF or closed stdin (e.g. piped input that ends without the expected word)
18
+ // must default to "not confirmed" rather than hanging or resolving true.
19
+ // Guard: only resolve false here if the question callback has NOT already resolved.
20
+ rl.once('close', () => { if (!answered) resolve(false); });
21
+ process.stdout.write(promptText + '\n');
22
+ rl.question(`Type '${expectedWord}' to continue, anything else to abort: `, (answer) => {
23
+ answered = true;
24
+ rl.close();
25
+ resolve(answer.trim() === expectedWord);
26
+ });
27
+ });
28
+ }
29
+
30
+ module.exports = { requireConfirmation };