@yemi33/minions 0.1.2094 → 0.1.2095

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,718 @@
1
+ /**
2
+ * bin/cli-api-client.js — Minions CLI ↔ dashboard HTTP API bridge.
3
+ *
4
+ * Two layers:
5
+ * 1. Generic: `minions api <METHOD> <PATH> [--body json|--body-file f]`
6
+ * — direct passthrough to http://localhost:7331/api/* so every endpoint
7
+ * that the dashboard exposes is also reachable from the CLI without
8
+ * hand-writing a subcommand for each one. Surfaces gaps and lets power
9
+ * users script anything the dashboard can do.
10
+ * 2. Ergonomic per-resource subcommands (wi/plans/watch/schedule/feature/
11
+ * settings) — thin wrappers over the generic call with human-friendly
12
+ * output. Both layers share one HTTP code path so the auth/CSRF/origin
13
+ * contract is identical.
14
+ *
15
+ * The dashboard's Origin gate allows requests with no Origin/Referer header
16
+ * (curl + CLI tooling) — see dashboard.js comment "When both headers are
17
+ * absent ... we allow the request to preserve existing local automation."
18
+ * The CLI deliberately sends no Origin so it goes through that path.
19
+ */
20
+
21
+ const http = require('http');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const DEFAULT_BASE_URL = process.env.MINIONS_DASHBOARD_URL || 'http://localhost:7331';
26
+
27
+ // ── HTTP core ───────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Make an HTTP request to the dashboard. Returns `{ status, body, raw }` where
31
+ * `body` is the parsed JSON (or string if not JSON) and `raw` is the original
32
+ * response text. Does not throw on non-2xx — callers decide.
33
+ *
34
+ * Errors surfaced via thrown Error are connection-level: ECONNREFUSED when
35
+ * the dashboard isn't running, ETIMEDOUT, etc. Distinct from API-level errors
36
+ * which come back in the response body.
37
+ */
38
+ /**
39
+ * Normalize a URL path that may have been mangled by a shell.
40
+ *
41
+ * On Windows Git Bash / MSYS, a leading `/api/...` argument is auto-translated
42
+ * to `C:/Program Files/Git/api/...` BEFORE node sees argv. That makes
43
+ * `minions api GET /api/status` blow up with "Invalid URL". This function:
44
+ * - strips known Git-Bash prefixes ('C:/Program Files/Git', '/c/Program Files/Git', ...)
45
+ * - re-extracts the path starting at the first `/api/` occurrence
46
+ * - accepts paths without a leading slash (`api/status` → `/api/status`)
47
+ *
48
+ * Pure / no side effects so unit-testable.
49
+ */
50
+ function _normalizeApiPath(input) {
51
+ if (!input) return input;
52
+ let s = String(input);
53
+ // Already an absolute http(s) URL — leave alone.
54
+ if (/^https?:\/\//i.test(s)) return s;
55
+ // Git Bash / MSYS translation: extract from the FIRST `/api/` we find.
56
+ const apiIdx = s.indexOf('/api/');
57
+ if (apiIdx > 0) return s.slice(apiIdx);
58
+ // Missing leading slash: `minions api GET api/status` → `/api/status`.
59
+ if (s.startsWith('api/')) return '/' + s;
60
+ return s;
61
+ }
62
+
63
+ function apiCall(method, urlPath, bodyJson, { baseUrl = DEFAULT_BASE_URL, timeoutMs = 30000 } = {}) {
64
+ return new Promise((resolve, reject) => {
65
+ const normalized = _normalizeApiPath(urlPath);
66
+ let u;
67
+ try { u = new URL(normalized.startsWith('http') ? normalized : baseUrl + normalized); }
68
+ catch (e) { return reject(new Error(`Invalid URL: ${urlPath} (normalized: ${normalized})`)); }
69
+
70
+ const bodyStr = bodyJson != null ? (typeof bodyJson === 'string' ? bodyJson : JSON.stringify(bodyJson)) : null;
71
+ const headers = { 'Accept': 'application/json' };
72
+ if (bodyStr) {
73
+ headers['Content-Type'] = 'application/json';
74
+ headers['Content-Length'] = Buffer.byteLength(bodyStr);
75
+ }
76
+
77
+ const req = http.request({
78
+ method: method.toUpperCase(),
79
+ hostname: u.hostname,
80
+ port: u.port || 80,
81
+ path: u.pathname + u.search,
82
+ headers,
83
+ timeout: timeoutMs,
84
+ }, (res) => {
85
+ let buf = '';
86
+ res.setEncoding('utf8');
87
+ res.on('data', (chunk) => { buf += chunk; });
88
+ res.on('end', () => {
89
+ let parsed = buf;
90
+ if (res.headers['content-type'] && res.headers['content-type'].includes('json')) {
91
+ try { parsed = JSON.parse(buf); } catch { /* keep raw */ }
92
+ }
93
+ resolve({ status: res.statusCode || 0, body: parsed, raw: buf });
94
+ });
95
+ });
96
+
97
+ req.on('error', (err) => {
98
+ if (err.code === 'ECONNREFUSED') {
99
+ reject(new Error(`Dashboard not reachable at ${baseUrl}. Is minions running? Try \`minions restart\`.`));
100
+ } else {
101
+ reject(err);
102
+ }
103
+ });
104
+ req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out after ${timeoutMs}ms`)); });
105
+ if (bodyStr) req.write(bodyStr);
106
+ req.end();
107
+ });
108
+ }
109
+
110
+ // ── Argv parsing helpers ────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Pull a flag value out of an argv array. Returns the value (string) or null.
114
+ * Supports both `--flag value` and `--flag=value`. Removes the consumed
115
+ * tokens so the caller can pass the remainder as positional args.
116
+ */
117
+ function takeFlag(argv, flag) {
118
+ for (let i = 0; i < argv.length; i++) {
119
+ const a = argv[i];
120
+ if (a === flag) {
121
+ const v = argv[i + 1];
122
+ argv.splice(i, 2);
123
+ return v;
124
+ }
125
+ if (a.startsWith(flag + '=')) {
126
+ const v = a.slice(flag.length + 1);
127
+ argv.splice(i, 1);
128
+ return v;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function hasFlag(argv, flag) {
135
+ const i = argv.indexOf(flag);
136
+ if (i === -1) return false;
137
+ argv.splice(i, 1);
138
+ return true;
139
+ }
140
+
141
+ // ── Output formatting ───────────────────────────────────────────────────────
142
+
143
+ function jsonOut(obj) {
144
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
145
+ }
146
+
147
+ function lineOut(s) { process.stdout.write(String(s) + '\n'); }
148
+
149
+ // One-line summary of a work item — used by `minions wi list`.
150
+ function _formatWiOneLine(wi) {
151
+ const id = wi.id || '';
152
+ const status = (wi.status || '').padEnd(11);
153
+ const agent = (wi.agent || '-').padEnd(10);
154
+ const title = (wi.title || '').slice(0, 70);
155
+ return ` ${id.padEnd(28)} ${status} ${agent} ${title}`;
156
+ }
157
+
158
+ function _formatPlanOneLine(p) {
159
+ const slug = (p.slug || p.file || p.id || '').padEnd(36);
160
+ const status = (p.status || '').padEnd(14);
161
+ const title = (p.title || '').slice(0, 60);
162
+ return ` ${slug} ${status} ${title}`;
163
+ }
164
+
165
+ // ── Generic passthrough: `minions api <METHOD> <PATH> [...]` ────────────────
166
+
167
+ async function cliApi(argv) {
168
+ const help = `Usage: minions api <METHOD> <PATH> [--body JSON | --body-file FILE] [--base-url URL] [--quiet]
169
+
170
+ Direct HTTP passthrough to the dashboard API at ${DEFAULT_BASE_URL}.
171
+
172
+ Examples:
173
+ minions api GET /api/status
174
+ minions api GET /api/work-items
175
+ minions api POST /api/work-items/cancel --body '{"id":"W-abc"}'
176
+ minions api POST /api/work-items --body-file new-item.json
177
+
178
+ Flags:
179
+ --body <json> Inline JSON request body
180
+ --body-file <f> Read JSON body from a file
181
+ --base-url <url> Override default dashboard URL (env MINIONS_DASHBOARD_URL also works)
182
+ --quiet Suppress non-zero status logging; just print the body
183
+
184
+ Exit codes: 0 = 2xx, 1 = 4xx/5xx, 2 = connection failure / bad usage`;
185
+
186
+ if (argv.length === 0 || hasFlag(argv, '--help') || hasFlag(argv, '-h')) {
187
+ lineOut(help);
188
+ return 2;
189
+ }
190
+ const baseUrl = takeFlag(argv, '--base-url') || DEFAULT_BASE_URL;
191
+ const bodyInline = takeFlag(argv, '--body');
192
+ const bodyFile = takeFlag(argv, '--body-file');
193
+ const quiet = hasFlag(argv, '--quiet');
194
+ if (argv.length < 2) {
195
+ lineOut('Error: METHOD and PATH required\n');
196
+ lineOut(help);
197
+ return 2;
198
+ }
199
+ const [method, urlPath] = argv;
200
+
201
+ let body = null;
202
+ if (bodyInline) {
203
+ try { body = JSON.parse(bodyInline); }
204
+ catch (e) { lineOut(`Error: --body is not valid JSON: ${e.message}`); return 2; }
205
+ } else if (bodyFile) {
206
+ try { body = JSON.parse(fs.readFileSync(bodyFile, 'utf8')); }
207
+ catch (e) { lineOut(`Error: cannot read/parse --body-file: ${e.message}`); return 2; }
208
+ }
209
+
210
+ try {
211
+ const res = await apiCall(method, urlPath, body, { baseUrl });
212
+ if (typeof res.body === 'string') process.stdout.write(res.body + (res.body.endsWith('\n') ? '' : '\n'));
213
+ else jsonOut(res.body);
214
+ if (!quiet && (res.status < 200 || res.status >= 300)) {
215
+ process.stderr.write(`HTTP ${res.status}\n`);
216
+ }
217
+ return (res.status >= 200 && res.status < 300) ? 0 : 1;
218
+ } catch (e) {
219
+ process.stderr.write(`${e.message}\n`);
220
+ return 2;
221
+ }
222
+ }
223
+
224
+ // ── work-items (wi) ─────────────────────────────────────────────────────────
225
+
226
+ async function cliWi(argv) {
227
+ const sub = argv.shift();
228
+ const help = `Usage: minions wi <subcommand> [args]
229
+
230
+ Subcommands:
231
+ list [--status S] [--agent A] [--project P] [--json] List work items
232
+ show <id> [--json] Show one work item
233
+ cancel <id> [--reason R] Cancel a pending/active item
234
+ retry <id> Reset a failed item to pending
235
+ delete <id> Remove a work item (and kill agent)
236
+ archive <id> Move completed/failed item to archive
237
+ reopen <id> [--description D] Reopen a done/failed item to pending
238
+ feedback <id> --rating up|down [--comment C] Add human feedback`;
239
+
240
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }
241
+
242
+ if (sub === 'list') {
243
+ const status = takeFlag(argv, '--status');
244
+ const agent = takeFlag(argv, '--agent');
245
+ const project = takeFlag(argv, '--project');
246
+ const json = hasFlag(argv, '--json');
247
+ const res = await apiCall('GET', '/api/work-items');
248
+ if (res.status !== 200) { jsonOut(res.body); return 1; }
249
+ let items = Array.isArray(res.body) ? res.body : (res.body.items || []);
250
+ if (status) items = items.filter(i => i.status === status);
251
+ if (agent) items = items.filter(i => i.agent === agent);
252
+ if (project) items = items.filter(i => i.project === project || i._project === project);
253
+ if (json) { jsonOut(items); return 0; }
254
+ lineOut(`${items.length} work item(s)`);
255
+ for (const i of items) lineOut(_formatWiOneLine(i));
256
+ return 0;
257
+ }
258
+
259
+ if (sub === 'show') {
260
+ const id = argv.shift();
261
+ if (!id) { lineOut('Error: work item id required'); return 2; }
262
+ const json = hasFlag(argv, '--json');
263
+ const res = await apiCall('GET', `/api/work-items/${encodeURIComponent(id)}`);
264
+ if (res.status === 404) { lineOut(`Not found: ${id}`); return 1; }
265
+ if (res.status !== 200) { jsonOut(res.body); return 1; }
266
+ if (json) { jsonOut(res.body); return 0; }
267
+ const wi = res.body;
268
+ lineOut(`${wi.id} [${wi.status}] agent=${wi.agent || '-'} type=${wi.type || '-'}`);
269
+ if (wi.title) lineOut(` title: ${wi.title}`);
270
+ if (wi.description) lineOut(` description: ${String(wi.description).slice(0, 300)}`);
271
+ if (wi.created_at) lineOut(` created: ${wi.created_at}`);
272
+ return 0;
273
+ }
274
+
275
+ if (['cancel', 'retry', 'delete', 'archive', 'reopen'].includes(sub)) {
276
+ const id = argv.shift();
277
+ if (!id) { lineOut(`Error: work item id required`); return 2; }
278
+ const body = { id };
279
+ if (sub === 'cancel') {
280
+ const reason = takeFlag(argv, '--reason');
281
+ if (reason) body.reason = reason;
282
+ }
283
+ if (sub === 'reopen') {
284
+ const description = takeFlag(argv, '--description');
285
+ if (description) body.description = description;
286
+ }
287
+ const res = await apiCall('POST', `/api/work-items/${sub}`, body);
288
+ if (res.status === 200 || res.status === 202) {
289
+ lineOut(`${sub}: ${id}`);
290
+ return 0;
291
+ }
292
+ process.stderr.write(`HTTP ${res.status}\n`);
293
+ jsonOut(res.body);
294
+ return 1;
295
+ }
296
+
297
+ if (sub === 'feedback') {
298
+ const id = argv.shift();
299
+ if (!id) { lineOut('Error: work item id required'); return 2; }
300
+ const rating = takeFlag(argv, '--rating');
301
+ if (!rating || !['up', 'down'].includes(rating)) {
302
+ lineOut('Error: --rating up|down required'); return 2;
303
+ }
304
+ const comment = takeFlag(argv, '--comment');
305
+ const body = { id, rating };
306
+ if (comment) body.comment = comment;
307
+ const res = await apiCall('POST', '/api/work-items/feedback', body);
308
+ if (res.status === 200) { lineOut(`feedback recorded for ${id}`); return 0; }
309
+ process.stderr.write(`HTTP ${res.status}\n`);
310
+ jsonOut(res.body);
311
+ return 1;
312
+ }
313
+
314
+ lineOut(`Unknown wi subcommand: ${sub}\n`);
315
+ lineOut(help);
316
+ return 2;
317
+ }
318
+
319
+ // ── plans ───────────────────────────────────────────────────────────────────
320
+
321
+ async function cliPlans(argv) {
322
+ const sub = argv.shift();
323
+ const help = `Usage: minions plans <subcommand> [args]
324
+
325
+ Subcommands:
326
+ list [--status S] [--json] List plans (and their PRDs)
327
+ show <slug-or-file> [--json] Show a plan/PRD's content
328
+ approve <slug-or-file> Approve a plan for execution
329
+ pause <slug-or-file> Pause materialization + reset active items
330
+ reject <slug-or-file> Reject a plan
331
+ archive <slug-or-file> Move plan/PRD to archive
332
+ unarchive <slug-or-file> Restore from archive
333
+ regenerate <slug-or-file> Reset pending/failed items so they re-materialize`;
334
+
335
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }
336
+
337
+ if (sub === 'list') {
338
+ const status = takeFlag(argv, '--status');
339
+ const json = hasFlag(argv, '--json');
340
+ const res = await apiCall('GET', '/api/plans');
341
+ if (res.status !== 200) { jsonOut(res.body); return 1; }
342
+ let items = Array.isArray(res.body) ? res.body : (res.body.plans || []);
343
+ if (status) items = items.filter(p => p.status === status);
344
+ if (json) { jsonOut(items); return 0; }
345
+ lineOut(`${items.length} plan(s)`);
346
+ for (const p of items) lineOut(_formatPlanOneLine(p));
347
+ return 0;
348
+ }
349
+
350
+ if (sub === 'show') {
351
+ const id = argv.shift();
352
+ if (!id) { lineOut('Error: plan slug or file required'); return 2; }
353
+ const json = hasFlag(argv, '--json');
354
+ const res = await apiCall('GET', `/api/plans/${encodeURIComponent(id)}`);
355
+ if (res.status !== 200) { jsonOut(res.body); return 1; }
356
+ if (json) { jsonOut(res.body); return 0; }
357
+ if (typeof res.body === 'string') lineOut(res.body);
358
+ else jsonOut(res.body);
359
+ return 0;
360
+ }
361
+
362
+ if (['approve', 'pause', 'reject', 'archive', 'unarchive', 'regenerate'].includes(sub)) {
363
+ const id = argv.shift();
364
+ if (!id) { lineOut('Error: plan slug or file required'); return 2; }
365
+ // The plans API expects either `slug` or `file` in the body. We try `slug`
366
+ // first; servers that want `file` accept either based on lookup.
367
+ const body = { slug: id, file: id };
368
+ const res = await apiCall('POST', `/api/plans/${sub}`, body);
369
+ if (res.status === 200 || res.status === 202) {
370
+ lineOut(`${sub}: ${id}`);
371
+ return 0;
372
+ }
373
+ process.stderr.write(`HTTP ${res.status}\n`);
374
+ jsonOut(res.body);
375
+ return 1;
376
+ }
377
+
378
+ lineOut(`Unknown plans subcommand: ${sub}\n`);
379
+ lineOut(help);
380
+ return 2;
381
+ }
382
+
383
+ // ── schedules ───────────────────────────────────────────────────────────────
384
+
385
+ async function cliSchedule(argv) {
386
+ const sub = argv.shift();
387
+ const help = `Usage: minions schedule <subcommand> [args]
388
+
389
+ Subcommands:
390
+ list [--json] List all schedules with last-run state
391
+ show <id> [--json] Show one schedule
392
+ run-now <id> Manually enqueue the work item for a schedule
393
+ delete <id> Delete a schedule
394
+ add --body <json> | --body-file <f> Create a schedule from JSON spec
395
+ update --body <json> | --body-file f Update a schedule
396
+ parse <natural-language-text> Convert "every weekday at 9am" → cron
397
+
398
+ Note: \`add\` and \`update\` take a full schedule spec — see /api/routes for the
399
+ shape, or run \`minions api GET /api/schedules\` and adapt an existing entry.`;
400
+
401
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }
402
+
403
+ if (sub === 'list') {
404
+ const json = hasFlag(argv, '--json');
405
+ const res = await apiCall('GET', '/api/schedules');
406
+ if (res.status !== 200) { jsonOut(res.body); return 1; }
407
+ let items = Array.isArray(res.body) ? res.body : (res.body.schedules || []);
408
+ if (json) { jsonOut(items); return 0; }
409
+ lineOut(`${items.length} schedule(s)`);
410
+ for (const s of items) {
411
+ const lastRun = s._lastRun || s.lastRunAt || '(never)';
412
+ const enabled = s.enabled === false ? '[disabled]' : ' ';
413
+ lineOut(` ${(s.id || '').padEnd(28)} ${enabled} cron="${s.cron || ''}" last=${lastRun}`);
414
+ }
415
+ return 0;
416
+ }
417
+
418
+ if (sub === 'show') {
419
+ const id = argv.shift();
420
+ if (!id) { lineOut('Error: schedule id required'); return 2; }
421
+ const res = await apiCall('GET', '/api/schedules');
422
+ if (res.status !== 200) { jsonOut(res.body); return 1; }
423
+ const items = Array.isArray(res.body) ? res.body : (res.body.schedules || []);
424
+ const found = items.find(s => s.id === id);
425
+ if (!found) { lineOut(`Not found: ${id}`); return 1; }
426
+ jsonOut(found);
427
+ return 0;
428
+ }
429
+
430
+ if (sub === 'run-now' || sub === 'delete') {
431
+ const id = argv.shift();
432
+ if (!id) { lineOut('Error: schedule id required'); return 2; }
433
+ const res = await apiCall('POST', `/api/schedules/${sub}`, { id });
434
+ if (res.status === 200) { lineOut(`${sub}: ${id}`); return 0; }
435
+ process.stderr.write(`HTTP ${res.status}\n`);
436
+ jsonOut(res.body);
437
+ return 1;
438
+ }
439
+
440
+ if (sub === 'add' || sub === 'update') {
441
+ const bodyInline = takeFlag(argv, '--body');
442
+ const bodyFile = takeFlag(argv, '--body-file');
443
+ let body = null;
444
+ if (bodyInline) {
445
+ try { body = JSON.parse(bodyInline); }
446
+ catch (e) { lineOut(`Error: --body invalid JSON: ${e.message}`); return 2; }
447
+ } else if (bodyFile) {
448
+ try { body = JSON.parse(fs.readFileSync(bodyFile, 'utf8')); }
449
+ catch (e) { lineOut(`Error: cannot read --body-file: ${e.message}`); return 2; }
450
+ } else { lineOut('Error: --body or --body-file required'); return 2; }
451
+ const apiPath = sub === 'add' ? '/api/schedules' : '/api/schedules/update';
452
+ const res = await apiCall('POST', apiPath, body);
453
+ if (res.status === 200 || res.status === 201) { jsonOut(res.body); return 0; }
454
+ process.stderr.write(`HTTP ${res.status}\n`);
455
+ jsonOut(res.body);
456
+ return 1;
457
+ }
458
+
459
+ if (sub === 'parse') {
460
+ const text = argv.join(' ').trim();
461
+ if (!text) { lineOut('Error: natural language text required'); return 2; }
462
+ const res = await apiCall('POST', '/api/schedules/parse-natural', { text });
463
+ jsonOut(res.body);
464
+ return res.status === 200 ? 0 : 1;
465
+ }
466
+
467
+ lineOut(`Unknown schedule subcommand: ${sub}\n`);
468
+ lineOut(help);
469
+ return 2;
470
+ }
471
+
472
+ // ── watches ─────────────────────────────────────────────────────────────────
473
+
474
+ async function cliWatch(argv) {
475
+ const sub = argv.shift();
476
+ const help = `Usage: minions watch <subcommand> [args]
477
+
478
+ Subcommands:
479
+ list [--json] List all watches with current status
480
+ show <id> [--json] Show one watch with full history
481
+ delete <id> Delete a watch
482
+ add --body <json> | --body-file <f> Create a watch from JSON spec
483
+ update --body <json> | --body-file f Update a watch (pause/resume/modify)
484
+ target-types List registered target types + conditions
485
+ action-types List registered follow-up action types
486
+
487
+ Note: \`add\` and \`update\` take a full watch spec — see /api/routes for the
488
+ shape, or \`minions api GET /api/watches\` for examples.`;
489
+
490
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }
491
+
492
+ if (sub === 'list') {
493
+ const json = hasFlag(argv, '--json');
494
+ const res = await apiCall('GET', '/api/watches');
495
+ if (res.status !== 200) { jsonOut(res.body); return 1; }
496
+ const items = Array.isArray(res.body) ? res.body : (res.body.watches || []);
497
+ if (json) { jsonOut(items); return 0; }
498
+ lineOut(`${items.length} watch(es)`);
499
+ for (const w of items) {
500
+ const status = (w.status || 'unknown').padEnd(8);
501
+ const target = `${w.targetType || '-'}:${w.target || '-'}`.padEnd(30);
502
+ lineOut(` ${(w.id || '').padEnd(28)} ${status} ${target} ${w.condition || ''}`);
503
+ }
504
+ return 0;
505
+ }
506
+
507
+ if (sub === 'show') {
508
+ const id = argv.shift();
509
+ if (!id) { lineOut('Error: watch id required'); return 2; }
510
+ const [main, history] = await Promise.all([
511
+ apiCall('GET', '/api/watches'),
512
+ apiCall('GET', `/api/watches/${encodeURIComponent(id)}/history`),
513
+ ]);
514
+ const items = Array.isArray(main.body) ? main.body : (main.body.watches || []);
515
+ const w = items.find(x => x.id === id);
516
+ if (!w) { lineOut(`Not found: ${id}`); return 1; }
517
+ jsonOut({ watch: w, history: history.body });
518
+ return 0;
519
+ }
520
+
521
+ if (sub === 'target-types' || sub === 'action-types') {
522
+ const res = await apiCall('GET', `/api/watches/${sub}`);
523
+ jsonOut(res.body);
524
+ return res.status === 200 ? 0 : 1;
525
+ }
526
+
527
+ if (sub === 'delete') {
528
+ const id = argv.shift();
529
+ if (!id) { lineOut('Error: watch id required'); return 2; }
530
+ const res = await apiCall('POST', '/api/watches/delete', { id });
531
+ if (res.status === 200) { lineOut(`delete: ${id}`); return 0; }
532
+ process.stderr.write(`HTTP ${res.status}\n`);
533
+ jsonOut(res.body);
534
+ return 1;
535
+ }
536
+
537
+ if (sub === 'add' || sub === 'update') {
538
+ const bodyInline = takeFlag(argv, '--body');
539
+ const bodyFile = takeFlag(argv, '--body-file');
540
+ let body = null;
541
+ if (bodyInline) {
542
+ try { body = JSON.parse(bodyInline); }
543
+ catch (e) { lineOut(`Error: --body invalid JSON: ${e.message}`); return 2; }
544
+ } else if (bodyFile) {
545
+ try { body = JSON.parse(fs.readFileSync(bodyFile, 'utf8')); }
546
+ catch (e) { lineOut(`Error: cannot read --body-file: ${e.message}`); return 2; }
547
+ } else { lineOut('Error: --body or --body-file required'); return 2; }
548
+ const apiPath = sub === 'add' ? '/api/watches' : '/api/watches/update';
549
+ const res = await apiCall('POST', apiPath, body);
550
+ if (res.status === 200 || res.status === 201) { jsonOut(res.body); return 0; }
551
+ process.stderr.write(`HTTP ${res.status}\n`);
552
+ jsonOut(res.body);
553
+ return 1;
554
+ }
555
+
556
+ lineOut(`Unknown watch subcommand: ${sub}\n`);
557
+ lineOut(help);
558
+ return 2;
559
+ }
560
+
561
+ // ── feature flags ──────────────────────────────────────────────────────────
562
+
563
+ async function cliFeature(argv) {
564
+ const sub = argv.shift();
565
+ const help = `Usage: minions feature <subcommand> [args]
566
+
567
+ Subcommands:
568
+ list [--json] List registered feature flags
569
+ toggle <id> [--on | --off] Enable/disable a feature flag (omit for explicit toggle)`;
570
+
571
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }
572
+
573
+ if (sub === 'list') {
574
+ const json = hasFlag(argv, '--json');
575
+ const res = await apiCall('GET', '/api/features');
576
+ if (res.status !== 200) { jsonOut(res.body); return 1; }
577
+ const items = Array.isArray(res.body) ? res.body : (res.body.features || []);
578
+ if (json) { jsonOut(items); return 0; }
579
+ lineOut(`${items.length} feature flag(s)`);
580
+ for (const f of items) {
581
+ const on = f.enabled === true ? 'ON ' : (f.enabled === false ? 'off' : ' ');
582
+ lineOut(` [${on}] ${(f.id || '').padEnd(28)} ${(f.description || '').slice(0, 70)}`);
583
+ }
584
+ return 0;
585
+ }
586
+
587
+ if (sub === 'toggle') {
588
+ const id = argv.shift();
589
+ if (!id) { lineOut('Error: feature id required'); return 2; }
590
+ const on = hasFlag(argv, '--on');
591
+ const off = hasFlag(argv, '--off');
592
+ const body = { id };
593
+ if (on) body.enabled = true;
594
+ else if (off) body.enabled = false;
595
+ const res = await apiCall('POST', '/api/features/toggle', body);
596
+ if (res.status === 200) { jsonOut(res.body); return 0; }
597
+ process.stderr.write(`HTTP ${res.status}\n`);
598
+ jsonOut(res.body);
599
+ return 1;
600
+ }
601
+
602
+ lineOut(`Unknown feature subcommand: ${sub}\n`);
603
+ lineOut(help);
604
+ return 2;
605
+ }
606
+
607
+ // ── settings ───────────────────────────────────────────────────────────────
608
+
609
+ async function cliSettings(argv) {
610
+ const sub = argv.shift();
611
+ const help = `Usage: minions settings <subcommand> [args]
612
+
613
+ Subcommands:
614
+ get [<dotted.path>] [--json] Get full config or a single dotted path
615
+ set <dotted.path> <value> Set a config value (auto-parsed: numbers/booleans/JSON)
616
+ reset Reset engine + claude + agent settings to defaults
617
+
618
+ Examples:
619
+ minions settings get engine.maxConcurrent
620
+ minions settings set engine.maxConcurrent 8
621
+ minions settings set engine.evalLoop true
622
+ minions settings set engine.ccTurnTimeoutMs 600000`;
623
+
624
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }
625
+
626
+ if (sub === 'get') {
627
+ const dottedPath = argv.shift();
628
+ const json = hasFlag(argv, '--json');
629
+ const res = await apiCall('GET', '/api/settings');
630
+ if (res.status !== 200) { jsonOut(res.body); return 1; }
631
+ let value = res.body;
632
+ if (dottedPath) {
633
+ for (const part of dottedPath.split('.')) {
634
+ if (value == null) break;
635
+ value = value[part];
636
+ }
637
+ if (value === undefined) { lineOut(`(not set)`); return 1; }
638
+ }
639
+ if (json || typeof value === 'object') { jsonOut(value); }
640
+ else { lineOut(String(value)); }
641
+ return 0;
642
+ }
643
+
644
+ if (sub === 'set') {
645
+ const dottedPath = argv.shift();
646
+ const rawValue = argv.shift();
647
+ if (!dottedPath || rawValue === undefined) {
648
+ lineOut('Error: dotted.path and value required'); return 2;
649
+ }
650
+ // Coerce value: try JSON parse first (catches numbers, booleans, JSON objects), fall back to string.
651
+ let value;
652
+ try { value = JSON.parse(rawValue); }
653
+ catch { value = rawValue; }
654
+ // Build a nested patch object from the dotted path. /api/settings accepts
655
+ // a partial config that gets merged onto the existing one.
656
+ const patch = {};
657
+ const parts = dottedPath.split('.');
658
+ let cursor = patch;
659
+ for (let i = 0; i < parts.length - 1; i++) { cursor[parts[i]] = {}; cursor = cursor[parts[i]]; }
660
+ cursor[parts[parts.length - 1]] = value;
661
+ const res = await apiCall('POST', '/api/settings', patch);
662
+ if (res.status === 200) { lineOut(`set ${dottedPath} = ${JSON.stringify(value)}`); return 0; }
663
+ process.stderr.write(`HTTP ${res.status}\n`);
664
+ jsonOut(res.body);
665
+ return 1;
666
+ }
667
+
668
+ if (sub === 'reset') {
669
+ const res = await apiCall('POST', '/api/settings/reset', {});
670
+ if (res.status === 200) { lineOut('settings reset to defaults'); return 0; }
671
+ process.stderr.write(`HTTP ${res.status}\n`);
672
+ jsonOut(res.body);
673
+ return 1;
674
+ }
675
+
676
+ lineOut(`Unknown settings subcommand: ${sub}\n`);
677
+ lineOut(help);
678
+ return 2;
679
+ }
680
+
681
+ // ── Top-level dispatcher ────────────────────────────────────────────────────
682
+ //
683
+ // `bin/minions.js` calls `dispatch(cmd, argv)`; we return either an exit code
684
+ // promise (handled command) or null (not ours — let minions.js continue).
685
+
686
+ const HANDLERS = {
687
+ 'api': cliApi,
688
+ 'wi': cliWi,
689
+ 'plans': cliPlans,
690
+ 'schedule': cliSchedule,
691
+ 'schedules': cliSchedule, // accept both singular and plural
692
+ 'watch': cliWatch,
693
+ 'watches': cliWatch,
694
+ 'feature': cliFeature,
695
+ 'features': cliFeature,
696
+ 'settings': cliSettings,
697
+ };
698
+
699
+ function isHandled(cmd) { return Object.prototype.hasOwnProperty.call(HANDLERS, cmd); }
700
+
701
+ async function dispatch(cmd, argv) {
702
+ const fn = HANDLERS[cmd];
703
+ if (!fn) return null;
704
+ return await fn(argv);
705
+ }
706
+
707
+ module.exports = {
708
+ apiCall,
709
+ takeFlag,
710
+ hasFlag,
711
+ isHandled,
712
+ dispatch,
713
+ DEFAULT_BASE_URL,
714
+ // Per-command handlers exposed for unit tests that want to drive them
715
+ // directly without going through process.argv.
716
+ _handlers: { cliApi, cliWi, cliPlans, cliSchedule, cliWatch, cliFeature, cliSettings },
717
+ _normalizeApiPath,
718
+ };
package/bin/minions.js CHANGED
@@ -848,6 +848,15 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
848
848
  minions nuke --confirm Factory reset (delete state, reset config to defaults)
849
849
  minions uninstall --confirm Remove everything + uninstall npm package
850
850
 
851
+ Dashboard API (HTTP passthrough to localhost:7331):
852
+ minions api <METHOD> <PATH> Generic API call (any endpoint, see /api/routes)
853
+ minions wi <subcmd> Work-items: list/show/cancel/retry/delete/archive/reopen/feedback
854
+ minions plans <subcmd> Plans: list/show/approve/pause/reject/archive/unarchive/regenerate
855
+ minions schedule <subcmd> Schedules: list/show/add/update/delete/run-now/parse
856
+ minions watch <subcmd> Watches: list/show/add/update/delete/target-types/action-types
857
+ minions feature <subcmd> Feature flags: list/toggle
858
+ minions settings <subcmd> Settings: get [path]/set <path> <value>/reset
859
+
851
860
  Dashboard:
852
861
  minions dash Start web dashboard (default :7331)
853
862
 
@@ -1163,6 +1172,14 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1163
1172
  sock.connect(DASH_PORT, '127.0.0.1');
1164
1173
  } else if (engineCmds.has(cmd)) {
1165
1174
  delegate('engine.js', [cmd, ...rest]);
1175
+ } else if (require('./cli-api-client').isHandled(cmd)) {
1176
+ // Generic `minions api` passthrough + ergonomic per-resource subcommands
1177
+ // (wi/plans/schedule/watch/feature/settings). All route through the
1178
+ // dashboard's HTTP API at localhost:7331 so the auth/CSRF/origin contract
1179
+ // is identical to dashboard UI calls — no parallel state-mutation paths.
1180
+ ensureInstalled();
1181
+ require('./cli-api-client').dispatch(cmd, rest).then(code => process.exit(code || 0))
1182
+ .catch(err => { console.error(err && err.message || err); process.exit(2); });
1166
1183
  } else {
1167
1184
  console.log(` Unknown command: ${cmd}`);
1168
1185
  console.log(' Run "minions help" for usage.\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2094",
3
+ "version": "0.1.2095",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"