@yemi33/minions 0.1.2094 → 0.1.2096
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli-api-client.js +718 -0
- package/bin/minions.js +112 -42
- package/dashboard/js/refresh.js +17 -17
- package/dashboard/js/render-prs.js +61 -2
- package/dashboard.js +39 -0
- package/engine/cleanup.js +125 -0
- package/engine/lifecycle.js +53 -0
- package/engine/queries.js +8 -0
- package/engine/shared.js +19 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|