singleton-pipeline 0.4.0-beta.13 → 0.4.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/packages/cli/src/assets/singleton-logo.txt +10 -0
  2. package/dist/packages/cli/src/commands/new.js +763 -0
  3. package/dist/packages/cli/src/commands/repl.js +557 -0
  4. package/dist/packages/cli/src/commands/usage.js +49 -0
  5. package/dist/packages/cli/src/executor/debug-loop.js +525 -0
  6. package/dist/packages/cli/src/executor/inputs.js +226 -0
  7. package/dist/packages/cli/src/executor/outputs.js +134 -0
  8. package/dist/packages/cli/src/executor/preflight.js +605 -0
  9. package/dist/packages/cli/src/executor/replay-loop.js +120 -0
  10. package/dist/packages/cli/src/executor/run-report.js +209 -0
  11. package/dist/packages/cli/src/executor/run-setup.js +114 -0
  12. package/dist/packages/cli/src/executor/security-review.js +97 -0
  13. package/dist/packages/cli/src/executor/snapshot-manager.js +349 -0
  14. package/dist/packages/cli/src/executor/step-runner.js +241 -0
  15. package/dist/packages/cli/src/executor.js +584 -0
  16. package/dist/packages/cli/src/index.js +107 -0
  17. package/dist/packages/cli/src/parser.js +89 -0
  18. package/dist/packages/cli/src/runners/_shared.js +96 -0
  19. package/dist/packages/cli/src/runners/claude.js +103 -0
  20. package/dist/packages/cli/src/runners/codex-instructions.js +69 -0
  21. package/dist/packages/cli/src/runners/codex.js +141 -0
  22. package/dist/packages/cli/src/runners/copilot.js +209 -0
  23. package/dist/packages/cli/src/runners/index.js +18 -0
  24. package/dist/packages/cli/src/runners/opencode.js +240 -0
  25. package/dist/packages/cli/src/scanner.js +43 -0
  26. package/dist/packages/cli/src/security/policy.js +115 -0
  27. package/dist/packages/cli/src/sentinels.js +1 -0
  28. package/dist/packages/cli/src/shell.js +753 -0
  29. package/dist/packages/cli/src/theme.js +39 -0
  30. package/dist/packages/cli/src/timeline.js +238 -0
  31. package/dist/packages/cli/src/types.js +1 -0
  32. package/dist/packages/cli/src/usage/aggregator.js +44 -0
  33. package/dist/packages/cli/src/usage/reader.js +30 -0
  34. package/dist/packages/cli/src/usage/types.js +1 -0
  35. package/dist/packages/server/src/index.js +36 -0
  36. package/dist/packages/server/src/routes/agents.js +31 -0
  37. package/dist/packages/server/src/routes/files.js +45 -0
  38. package/dist/packages/server/src/routes/pipelines.js +74 -0
  39. package/docs/reference.md +28 -0
  40. package/package.json +15 -14
  41. package/packages/web/dist/assets/{index-CnKytBly.js → index-9S0goZlQ.js} +1 -1
  42. package/packages/web/dist/assets/{index-CCFWfCA2.css → index-iV4UtXoN.css} +1 -1
  43. package/packages/web/dist/assets/logo-COSyZmgk.png +0 -0
  44. package/packages/web/dist/index.html +2 -2
  45. package/packages/cli/package.json +0 -18
  46. package/packages/cli/src/commands/new.js +0 -786
  47. package/packages/cli/src/commands/repl.js +0 -548
  48. package/packages/cli/src/executor/debug-loop.js +0 -587
  49. package/packages/cli/src/executor/inputs.js +0 -202
  50. package/packages/cli/src/executor/outputs.js +0 -140
  51. package/packages/cli/src/executor/preflight.js +0 -459
  52. package/packages/cli/src/executor/replay-loop.js +0 -172
  53. package/packages/cli/src/executor/run-report.js +0 -189
  54. package/packages/cli/src/executor/run-setup.js +0 -93
  55. package/packages/cli/src/executor/security-review.js +0 -108
  56. package/packages/cli/src/executor/snapshot-manager.js +0 -335
  57. package/packages/cli/src/executor/step-runner.js +0 -266
  58. package/packages/cli/src/executor.js +0 -652
  59. package/packages/cli/src/index.js +0 -107
  60. package/packages/cli/src/parser.js +0 -78
  61. package/packages/cli/src/runners/_shared.js +0 -83
  62. package/packages/cli/src/runners/claude.js +0 -122
  63. package/packages/cli/src/runners/codex-instructions.js +0 -75
  64. package/packages/cli/src/runners/codex.js +0 -165
  65. package/packages/cli/src/runners/copilot.js +0 -224
  66. package/packages/cli/src/runners/index.js +0 -20
  67. package/packages/cli/src/runners/opencode.js +0 -265
  68. package/packages/cli/src/scanner.js +0 -47
  69. package/packages/cli/src/security/policy.js +0 -126
  70. package/packages/cli/src/shell.js +0 -732
  71. package/packages/cli/src/theme.js +0 -46
  72. package/packages/cli/src/timeline.js +0 -180
  73. package/packages/server/package.json +0 -11
  74. package/packages/server/src/index.js +0 -43
  75. package/packages/server/src/routes/agents.js +0 -32
  76. package/packages/server/src/routes/files.js +0 -42
  77. package/packages/server/src/routes/pipelines.js +0 -74
@@ -0,0 +1,557 @@
1
+ import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawn } from 'node:child_process';
6
+ import { createShell, S } from '../shell.js';
7
+ import { scanAgents } from '../scanner.js';
8
+ import { runPipeline } from '../executor.js';
9
+ import { newAgentShellCommand } from './new.js';
10
+ import { loadProjectSecurityConfig } from '../security/policy.js';
11
+ import { resolveLatestRunDir } from '../executor/run-report.js';
12
+ import { usageCommand } from "./usage.js";
13
+ const PIPELINES_DIRS = ['.singleton/pipelines'];
14
+ const HELP = [
15
+ '',
16
+ `{bold}Commands{/}`,
17
+ '',
18
+ ` {${S.accent}-fg}{bold}/run <name>{/} run a pipeline`,
19
+ ` {${S.accent}-fg}{bold}/run <name> --dry{/} dry-run (plan without API calls)`,
20
+ ` {${S.accent}-fg}{bold}/run <name> --verbose{/} show prompts and outputs`,
21
+ ` {${S.accent}-fg}{bold}/run <name> --debug{/} pause before each step`,
22
+ ` {${S.accent}-fg}{bold}/scan{/} scan .md agents`,
23
+ ` {${S.accent}-fg}{bold}/new{/} create a new agent`,
24
+ ` {${S.accent}-fg}{bold}/serve{/} start the web server`,
25
+ ` {${S.accent}-fg}{bold}/stop{/} stop the web server`,
26
+ ` {${S.accent}-fg}{bold}/commit-last{/} commit deliverables from the last run`,
27
+ ` {${S.accent}-fg}{bold}/ls{/} list pipelines`,
28
+ ` {${S.accent}-fg}{bold}/help{/} show help`,
29
+ ` {${S.accent}-fg}{bold}/quit{/} quit {${S.muted}-fg}(or Ctrl+C){/}`,
30
+ ` {${S.accent}-fg}{bold}/usage <path>{/} Show usage summary actual project path by default`,
31
+ ` {${S.accent}-fg}{bold}/usage --today | this-month | last-month | all-time {/} Show usage summary `,
32
+ '',
33
+ ].join('\n');
34
+ const COMMANDS = [
35
+ { label: '/run', value: '/run ', description: 'run a pipeline' },
36
+ { label: '/scan', value: '/scan ', description: 'scan .md agents' },
37
+ { label: '/new', value: '/new ', description: 'create a new agent' },
38
+ { label: '/serve', value: '/serve ', description: 'start the web server' },
39
+ { label: '/stop', value: '/stop', description: 'stop the web server' },
40
+ { label: '/commit-last', value: '/commit-last', description: 'commit the last run' },
41
+ { label: '/ls', value: '/ls', description: 'list pipelines' },
42
+ { label: '/help', value: '/help', description: 'show help' },
43
+ { label: '/usage', value: '/usage', description: 'show usage' },
44
+ { label: '/quit', value: '/quit', description: 'quit' },
45
+ ];
46
+ const RUN_FLAGS = [
47
+ { label: '--dry', description: 'plan without API calls' },
48
+ { label: '--verbose', description: 'show prompts and outputs' },
49
+ { label: '--debug', description: 'pause before each step' },
50
+ { label: '-v', description: 'alias for --verbose' },
51
+ ];
52
+ const USAGE_FLAGS = [
53
+ { label: '--today', description: 'show today\'s usage', bucketValue: 'today' },
54
+ { label: '--this-month', description: 'show this month usage', bucketValue: 'this-month' },
55
+ { label: '--last-month', description: 'show last month usage', bucketValue: 'last-month' },
56
+ { label: '--all-time', description: 'show all time usage', bucketValue: 'all-time' },
57
+ ];
58
+ async function listPipelines(root) {
59
+ const names = [];
60
+ for (const dir of PIPELINES_DIRS) {
61
+ try {
62
+ const files = (await fs.readdir(path.resolve(root, dir)))
63
+ .filter((f) => f.endsWith('.json') && f !== 'agents.json');
64
+ names.push(...files.map((f) => f.replace(/\.json$/, '')));
65
+ }
66
+ catch { /* dir doesn't exist */ }
67
+ }
68
+ return [...new Set(names)];
69
+ }
70
+ async function resolvePipelinePath(name, root) {
71
+ const candidates = [
72
+ ...PIPELINES_DIRS.map((d) => path.resolve(root, d, `${name}.json`)),
73
+ path.resolve(root, `${name}.json`),
74
+ path.resolve(name),
75
+ ];
76
+ for (const c of candidates) {
77
+ try {
78
+ await fs.access(c);
79
+ return c;
80
+ }
81
+ catch { /* skip */ }
82
+ }
83
+ return null;
84
+ }
85
+ function runCommand(cmd, args, { cwd }) {
86
+ return new Promise((resolve, reject) => {
87
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
88
+ let stdout = '';
89
+ let stderr = '';
90
+ child.stdout.on('data', (d) => (stdout += d.toString()));
91
+ child.stderr.on('data', (d) => (stderr += d.toString()));
92
+ child.on('error', reject);
93
+ child.on('close', (code) => {
94
+ if (code !== 0) {
95
+ reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
96
+ return;
97
+ }
98
+ resolve({ stdout, stderr });
99
+ });
100
+ });
101
+ }
102
+ async function loadLastRunManifest(root) {
103
+ const latestDir = await resolveLatestRunDir(root);
104
+ const manifestPath = path.join(latestDir, 'run-manifest.json');
105
+ const raw = await fs.readFile(manifestPath, 'utf8');
106
+ return JSON.parse(raw);
107
+ }
108
+ function splitInput(buffer) {
109
+ const leadingTrimmed = buffer.trimStart();
110
+ const parts = leadingTrimmed.length ? leadingTrimmed.split(/\s+/) : [];
111
+ const endsWithSpace = /\s$/.test(buffer);
112
+ const current = endsWithSpace ? '' : (parts.at(-1) || '');
113
+ return { parts, current, endsWithSpace };
114
+ }
115
+ function replaceCurrentToken(buffer, replacement) {
116
+ if (/\s$/.test(buffer))
117
+ return `${buffer}${replacement}`;
118
+ const idx = buffer.search(/\S+$/);
119
+ if (idx === -1)
120
+ return replacement;
121
+ return `${buffer.slice(0, idx)}${replacement}`;
122
+ }
123
+ function matchesPrefix(value, prefix) {
124
+ return value.toLowerCase().startsWith(prefix.toLowerCase());
125
+ }
126
+ function matchesCommitExclude(relPath, pattern) {
127
+ const rel = String(relPath || '').replaceAll('\\', '/').replace(/^\/+/, '');
128
+ const pat = String(pattern || '').replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '');
129
+ return Boolean(pat && (rel === pat || rel.startsWith(`${pat}/`)));
130
+ }
131
+ async function completeRepl(buffer, root) {
132
+ const { parts, current } = splitInput(buffer);
133
+ if (parts.length <= 1 && !/\s$/.test(buffer)) {
134
+ return COMMANDS
135
+ .filter((cmd) => matchesPrefix(cmd.label, current || buffer.trim()))
136
+ .map((cmd) => ({ ...cmd, value: cmd.value }));
137
+ }
138
+ const cmd = parts[0];
139
+ if (cmd === '/run') {
140
+ const args = parts.slice(1).filter(Boolean);
141
+ const used = new Set(args);
142
+ const hasPipelineArg = args.some((arg) => !arg.startsWith('-'));
143
+ const pipelines = await listPipelines(root);
144
+ const pipelineItems = pipelines
145
+ .filter((name) => !current || matchesPrefix(name, current))
146
+ .map((name) => ({
147
+ label: name,
148
+ value: replaceCurrentToken(buffer, name),
149
+ description: 'pipeline',
150
+ }));
151
+ const flagItems = RUN_FLAGS
152
+ .filter((flag) => !used.has(flag.label))
153
+ .filter((flag) => !current || matchesPrefix(flag.label, current))
154
+ .map((flag) => ({
155
+ label: flag.label,
156
+ value: replaceCurrentToken(buffer, flag.label),
157
+ description: flag.description,
158
+ }));
159
+ if (hasPipelineArg && current === '')
160
+ return flagItems;
161
+ return current.startsWith('--') || current.startsWith('-')
162
+ ? flagItems
163
+ : [...pipelineItems, ...flagItems];
164
+ }
165
+ return [];
166
+ }
167
+ // Strip blessed tags to get the visible string length.
168
+ function tw(s) { return s.replace(/\{[^}]+\}/g, '').length; }
169
+ function layoutRow(left, right, width) {
170
+ const spaces = Math.max(2, width - tw(left) - tw(right));
171
+ return left + ' '.repeat(spaces) + right;
172
+ }
173
+ async function countAgents(root) {
174
+ try {
175
+ const raw = await fs.readFile(path.resolve(root, '.singleton', 'agents.json'), 'utf8');
176
+ return JSON.parse(raw).agents?.length ?? 0;
177
+ }
178
+ catch {
179
+ return 0;
180
+ }
181
+ }
182
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
183
+ const LOGO_PATH = path.join(__dirname, '..', 'assets', 'singleton-logo.txt');
184
+ function loadLogoLines() {
185
+ try {
186
+ const raw = fsSync.readFileSync(LOGO_PATH, 'utf8');
187
+ return raw
188
+ .replace(/\x1b\[\?25[lh]/g, '')
189
+ .split('\n')
190
+ .filter((line) => line.length > 0);
191
+ }
192
+ catch {
193
+ return [];
194
+ }
195
+ }
196
+ const SINGLETON_LOGO = loadLogoLines();
197
+ const APP_VERSION = 'v0.4.0-beta.12';
198
+ async function showWelcome(root, shell) {
199
+ const now = new Date();
200
+ const dateStr = now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
201
+ const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
202
+ const [pipelines, agentCount] = await Promise.all([
203
+ listPipelines(root),
204
+ countAgents(root),
205
+ ]);
206
+ const CONTENT_PAD_LEFT = 2;
207
+ const CONTENT_PAD_TOP = 1;
208
+ const contentHeight = Math.max(12, (shell.screen.height ?? 24) - 4);
209
+ const headerLines = [
210
+ '',
211
+ ' '.repeat(tw('Welcome back')),
212
+ `${dateStr} ${timeStr} {${S.muted}-fg}${APP_VERSION}{/}`,
213
+ '',
214
+ `${pipelines.length} pipeline${pipelines.length !== 1 ? 's' : ''}`,
215
+ `${agentCount} agent${agentCount !== 1 ? 's' : ''}`,
216
+ '',
217
+ `{${S.warning}-fg}{bold}New{/} {${S.text}-fg}redesigned /new agent flow{/}`,
218
+ `{${S.subtle}-fg}·{/} {${S.keyword}-fg}sectioned form{/} {${S.subtle}-fg}·{/} {${S.keyword}-fg}field autocomplete{/} {${S.subtle}-fg}·{/} {${S.keyword}-fg}review + :back{/}`,
219
+ `{${S.warning}-fg}{bold}New{/} {${S.text}-fg}safer replay and prompt inputs{/}`,
220
+ `{${S.subtle}-fg}·{/} {${S.keyword}-fg}snapshot restore{/} {${S.subtle}-fg}·{/} {${S.keyword}-fg}escaped user XML{/} {${S.subtle}-fg}·{/} {${S.keyword}-fg}loud rollback limits{/}`,
221
+ `{${S.warning}-fg}{bold}New{/} {${S.text}-fg}cleaner run UI and output{/}`,
222
+ `{${S.subtle}-fg}·{/} {${S.keyword}-fg}framed logs{/} {${S.subtle}-fg}·{/} {${S.keyword}-fg}step reviews{/} {${S.subtle}-fg}·{/} {${S.keyword}-fg}final Copilot output{/}`,
223
+ '',
224
+ ];
225
+ const TAGLINE = 'one to rule them all';
226
+ const CREDIT = 'Developed by Romain LENTZ';
227
+ const bottomBlockHeight = 3 + SINGLETON_LOGO.length;
228
+ const spacerLines = Math.max(0, contentHeight - headerLines.length - bottomBlockHeight);
229
+ // Track shimmer positions.
230
+ const welcomeRow = CONTENT_PAD_TOP + 2;
231
+ const creditRow = CONTENT_PAD_TOP + headerLines.length + spacerLines + SINGLETON_LOGO.length;
232
+ const taglineRow = creditRow + 1;
233
+ for (const line of headerLines) {
234
+ shell.log(line);
235
+ }
236
+ for (let i = 0; i < spacerLines; i += 1) {
237
+ shell.log('');
238
+ }
239
+ for (const logoLine of SINGLETON_LOGO) {
240
+ shell.log(logoLine);
241
+ }
242
+ shell.log(`{${S.muted}-fg}${CREDIT}{/}`);
243
+ shell.log(' '.repeat(TAGLINE.length));
244
+ shell.log('');
245
+ const stopWelcome = shell.createShimmer('Welcome back', welcomeRow, CONTENT_PAD_LEFT);
246
+ const stopTagline = shell.createShimmer(TAGLINE, taglineRow, CONTENT_PAD_LEFT);
247
+ return () => { stopWelcome(); stopTagline(); };
248
+ }
249
+ async function refreshFooter(root, shell) {
250
+ const [pipelines, agentCount] = await Promise.all([
251
+ listPipelines(root),
252
+ countAgents(root),
253
+ ]);
254
+ shell.setFooter(`${agentCount} agent${agentCount !== 1 ? 's' : ''} /scan to refresh`, `${pipelines.length} pipeline${pipelines.length !== 1 ? 's' : ''}`);
255
+ }
256
+ function createServeState(shell) {
257
+ return {
258
+ server: null,
259
+ url: '',
260
+ suppressCloseLog: false,
261
+ clear() {
262
+ this.server = null;
263
+ this.url = '';
264
+ this.suppressCloseLog = false;
265
+ shell.setFooterCenter('');
266
+ },
267
+ };
268
+ }
269
+ export async function replCommand(opts = {}) {
270
+ const root = path.resolve(opts.root || process.cwd());
271
+ const shell = createShell();
272
+ const serveState = createServeState(shell);
273
+ let stopShimmer = await showWelcome(root, shell);
274
+ await refreshFooter(root, shell);
275
+ shell.setCompleter(({ buffer }) => completeRepl(buffer, root));
276
+ shell.onCommand(async (raw) => {
277
+ if (stopShimmer) {
278
+ stopShimmer();
279
+ stopShimmer = null;
280
+ shell.clear();
281
+ }
282
+ const [cmd, ...args] = raw.trim().split(/\s+/);
283
+ shell.disableInput();
284
+ try {
285
+ switch (cmd) {
286
+ case '/run':
287
+ await cmdRun(args, root, shell);
288
+ break;
289
+ case '/ls':
290
+ await cmdLs(root, shell);
291
+ break;
292
+ case '/scan':
293
+ await cmdScan(root, shell);
294
+ await refreshFooter(root, shell);
295
+ break;
296
+ case '/new':
297
+ await cmdNew(root, shell);
298
+ await refreshFooter(root, shell);
299
+ break;
300
+ case '/serve':
301
+ await cmdServe(root, shell, serveState);
302
+ break;
303
+ case '/stop':
304
+ await cmdStop(shell, serveState);
305
+ break;
306
+ case '/commit-last':
307
+ await cmdCommitLast(root, shell);
308
+ break;
309
+ case '/usage':
310
+ await cmdUsage(args, root, shell);
311
+ break;
312
+ case '/help':
313
+ shell.log(HELP);
314
+ break;
315
+ case '/quit':
316
+ case '/exit':
317
+ if (serveState.server) {
318
+ await closeServer(serveState.server);
319
+ serveState.clear();
320
+ }
321
+ shell.log(`{${S.muted}-fg}See you soon.{/}`);
322
+ setTimeout(() => { shell.destroy(); process.exit(0); }, 300);
323
+ return;
324
+ default:
325
+ shell.log(`{${S.warning}-fg}!{/} Unknown command: {bold}${cmd}{/} — type /help`);
326
+ }
327
+ }
328
+ catch (err) {
329
+ // Safety: if a command (typically /run) threw before resetting the border, reflect the error.
330
+ shell.setMode?.('error');
331
+ shell.log(`{${S.error}-fg}✕{/} ${err instanceof Error ? err.message : String(err)}`);
332
+ }
333
+ shell.enableInput();
334
+ });
335
+ }
336
+ async function cmdRun(args, root, shell) {
337
+ const dry = args.includes('--dry');
338
+ const verbose = args.includes('--verbose') || args.includes('-v');
339
+ const debug = args.includes('--debug');
340
+ const name = args.filter((a) => !['--dry', '--verbose', '--debug', '-v'].includes(a))[0];
341
+ if (!name) {
342
+ const pipelines = await listPipelines(root);
343
+ if (pipelines.length === 0) {
344
+ shell.log(`{${S.warning}-fg}!{/} No pipelines found.`);
345
+ return;
346
+ }
347
+ shell.log(`{${S.muted}-fg} Pipelines: ${pipelines.join(', ')}{/}`);
348
+ shell.log(`{${S.muted}-fg} Usage: /run <name> [--dry] [--verbose] [--debug]{/}`);
349
+ return;
350
+ }
351
+ const filePath = await resolvePipelinePath(name, root);
352
+ if (!filePath) {
353
+ shell.log(`{${S.error}-fg}✕{/} Pipeline "{bold}${name}{/}" not found.`);
354
+ const pipelines = await listPipelines(root);
355
+ if (pipelines.length)
356
+ shell.log(`{${S.muted}-fg} Available: ${pipelines.join(', ')}{/}`);
357
+ return;
358
+ }
359
+ await runPipeline(filePath, { dryRun: dry, verbose, debug, shell });
360
+ }
361
+ async function cmdLs(root, shell) {
362
+ const pipelines = await listPipelines(root);
363
+ if (pipelines.length === 0) {
364
+ shell.log(`{${S.warning}-fg}!{/} No pipelines found.`);
365
+ return;
366
+ }
367
+ shell.log(`{bold}Pipelines (${pipelines.length}){/}`);
368
+ shell.log('');
369
+ for (const p of pipelines)
370
+ shell.log(` {${S.subtle}-fg}·{/} {${S.string}-fg}${p}{/}`);
371
+ shell.log('');
372
+ }
373
+ function groupAgentsByProvider(agents) {
374
+ return agents.reduce((groups, agent) => {
375
+ const provider = agent.provider || 'unknown';
376
+ if (!groups.has(provider))
377
+ groups.set(provider, []);
378
+ groups.get(provider).push(agent);
379
+ return groups;
380
+ }, new Map());
381
+ }
382
+ async function cmdScan(root, shell) {
383
+ shell.log(`{${S.muted}-fg}Scanning ${root}…{/}`);
384
+ const agents = await scanAgents(root);
385
+ if (agents.length === 0) {
386
+ shell.log(`{${S.warning}-fg}!{/} No agents found (no .md files with ## Config).`);
387
+ return;
388
+ }
389
+ shell.log(`{bold}Agents (${agents.length}){/}`);
390
+ shell.log('');
391
+ const groups = groupAgentsByProvider(agents);
392
+ [...groups.entries()].forEach(([provider, providerAgents]) => {
393
+ shell.log(` {${S.muted}-fg}════════════════════════════════════════{/}`);
394
+ shell.log(` {bold}${provider}{/} {${S.muted}-fg}(${providerAgents.length}){/}`);
395
+ shell.log(` {${S.muted}-fg}════════════════════════════════════════{/}`);
396
+ shell.log('');
397
+ providerAgents.forEach((a, index) => {
398
+ shell.log(` {${S.accent}-fg}{bold}${a.id}{/} {${S.muted}-fg}${a.description || '(no description)'}{/}`);
399
+ shell.log(` {${S.keyword}-fg}{bold}source{/}: {${S.text}-fg}${a.source || 'repo'}{/}${a.permission_mode ? ` {${S.keyword}-fg}{bold}permission{/}: {${S.text}-fg}${a.permission_mode}{/}` : ''}`);
400
+ shell.log(` {${S.keyword}-fg}{bold}in{/}: {${S.text}-fg}${a.inputs.join(', ') || '—'}{/} {${S.keyword}-fg}{bold}out{/}: {${S.text}-fg}${a.outputs.join(', ') || '—'}{/}`);
401
+ if (index < providerAgents.length - 1)
402
+ shell.log(` {${S.muted}-fg}──────────────────────────────────────{/}`);
403
+ });
404
+ shell.log('');
405
+ });
406
+ const outPath = path.resolve(root, '.singleton', 'agents.json');
407
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
408
+ await fs.writeFile(outPath, JSON.stringify({ scannedAt: new Date().toISOString(), root, agents }, null, 2));
409
+ shell.log('');
410
+ shell.log(`{${S.success}-fg}✓{/} Cache → {${S.string}-fg}.singleton/agents.json{/}`);
411
+ }
412
+ async function cmdNew(root, shell) {
413
+ await newAgentShellCommand({ root, shell });
414
+ }
415
+ function closeServer(server) {
416
+ return new Promise((resolve, reject) => {
417
+ server.close((err) => {
418
+ if (err) {
419
+ reject(err);
420
+ return;
421
+ }
422
+ resolve(undefined);
423
+ });
424
+ });
425
+ }
426
+ async function cmdServe(root, shell, serveState) {
427
+ if (serveState.server) {
428
+ shell.log(`{${S.warning}-fg}!{/} The server is already running on {${S.string}-fg}${serveState.url}{/}.`);
429
+ return;
430
+ }
431
+ const { startServer } = await import('../../../server/src/index.js');
432
+ const serverUrl = 'http://localhost:4317';
433
+ shell.log(`{${S.muted}-fg}Starting server… (/stop to stop){/}`);
434
+ shell.enableInput();
435
+ const server = await startServer({
436
+ port: 4317,
437
+ root,
438
+ logger: (message) => {
439
+ const urlMatch = String(message).match(/https?:\/\/\S+/);
440
+ if (urlMatch) {
441
+ const url = urlMatch[0];
442
+ const urlIndex = urlMatch.index ?? 0;
443
+ const prefix = message.slice(0, urlIndex);
444
+ const suffix = message.slice(urlIndex + url.length);
445
+ shell.log(`{${S.text}-fg}{bold}${prefix}{/}{${S.string}-fg}${url}{/}{${S.muted}-fg}${suffix}{/}`);
446
+ return;
447
+ }
448
+ shell.log(`{${S.muted}-fg}${message}{/}`);
449
+ },
450
+ });
451
+ serveState.server = server;
452
+ serveState.url = serverUrl;
453
+ server.on('close', () => {
454
+ const shouldLog = !serveState.suppressCloseLog;
455
+ serveState.clear();
456
+ if (shouldLog)
457
+ shell.log(`{${S.muted}-fg}Serve stopped.{/}`);
458
+ });
459
+ shell.setFooterCenter(`{${S.muted}-fg}serve running{/} {${S.string}-fg}${serverUrl}{/}`);
460
+ }
461
+ async function cmdUsage(args, root, shell) {
462
+ shell.log(`{${S.muted}-fg}════════════════════════════════════════{/}`);
463
+ if (!args[0]) {
464
+ const o = await usageCommand({ root }, "");
465
+ shell.log(o);
466
+ return;
467
+ }
468
+ const b = USAGE_FLAGS.find(flag => flag.label === args[0]);
469
+ if (!b) {
470
+ shell.log(`{${S.error}-fg}✕{/} "${args[0]}" is not a valid flag. Use one of: --today, --this-month, --last-month, --all-time.`);
471
+ return;
472
+ }
473
+ const o = await usageCommand({ root }, b.bucketValue);
474
+ shell.log(o);
475
+ }
476
+ async function cmdStop(shell, serveState) {
477
+ if (!serveState.server) {
478
+ shell.log(`{${S.warning}-fg}!{/} No running server.`);
479
+ return;
480
+ }
481
+ const url = serveState.url;
482
+ const server = serveState.server;
483
+ serveState.suppressCloseLog = true;
484
+ serveState.server = null;
485
+ serveState.url = '';
486
+ shell.setFooterCenter('');
487
+ await closeServer(server);
488
+ shell.log(`{${S.success}-fg}✓{/} Serve stopped {${S.string}-fg}${url}{/}`);
489
+ }
490
+ async function cmdCommitLast(root, shell) {
491
+ let manifest;
492
+ try {
493
+ manifest = await loadLastRunManifest(root);
494
+ }
495
+ catch {
496
+ shell.log(`{${S.warning}-fg}!{/} No usable latest run found in .singleton/runs/latest.`);
497
+ return;
498
+ }
499
+ let securityConfig;
500
+ try {
501
+ securityConfig = await loadProjectSecurityConfig(root);
502
+ }
503
+ catch (err) {
504
+ shell.log(`{${S.error}-fg}✕{/} ${err instanceof Error ? err.message : String(err)}`);
505
+ return;
506
+ }
507
+ const excluded = [];
508
+ const files = (Array.isArray(manifest.deliverables) ? manifest.deliverables : []).filter((file) => {
509
+ const excludedBy = securityConfig.commit.excludePaths.find((pattern) => matchesCommitExclude(file.path, pattern));
510
+ if (excludedBy) {
511
+ excluded.push({ file, excludedBy });
512
+ return false;
513
+ }
514
+ return true;
515
+ });
516
+ if (files.length === 0) {
517
+ shell.log(`{${S.warning}-fg}!{/} The last run produced no deliverables to commit.`);
518
+ if (excluded.length) {
519
+ shell.log(`{${S.muted}-fg}All deliverables were excluded by .singleton/security.json commit rules.{/}`);
520
+ }
521
+ return;
522
+ }
523
+ try {
524
+ await runCommand('git', ['rev-parse', '--show-toplevel'], { cwd: root });
525
+ }
526
+ catch {
527
+ shell.log(`{${S.error}-fg}✕{/} This project is not inside a Git repository.`);
528
+ return;
529
+ }
530
+ shell.log(`{bold}Commit last preview{/} {${S.muted}-fg}${manifest.pipeline || 'unknown pipeline'}{/}`);
531
+ shell.log('');
532
+ shell.log(`{${S.keyword}-fg}Files to stage{/}`);
533
+ for (const file of files)
534
+ shell.log(` {${S.subtle}-fg}·{/} {${S.string}-fg}${file.path}{/}`);
535
+ if (excluded.length) {
536
+ shell.log('');
537
+ shell.log(`{${S.warning}-fg}Excluded by security config{/}`);
538
+ for (const { file, excludedBy } of excluded)
539
+ shell.log(` {${S.subtle}-fg}·{/} {${S.string}-fg}${file.path}{/} {${S.muted}-fg}(${excludedBy}){/}`);
540
+ }
541
+ shell.log('');
542
+ shell.log(`{${S.muted}-fg}.singleton artifacts are not committed by /commit-last.{/}`);
543
+ shell.log('');
544
+ if (securityConfig.commit.requireConfirmation) {
545
+ const confirmation = (await shell.prompt('Stage and commit these files? (y/N)')).trim().toLowerCase();
546
+ if (confirmation !== 'y' && confirmation !== 'yes') {
547
+ shell.log(`{${S.warning}-fg}!{/} Commit cancelled.`);
548
+ return;
549
+ }
550
+ }
551
+ const defaultMessage = `Update files from ${manifest.pipeline || 'last pipeline'}`;
552
+ const message = (await shell.prompt(`Commit message (default: ${defaultMessage})`)).trim() || defaultMessage;
553
+ const relFiles = files.map((file) => file.path);
554
+ await runCommand('git', ['add', '--', ...relFiles], { cwd: root });
555
+ await runCommand('git', ['commit', '-m', message], { cwd: root });
556
+ shell.log(`{${S.success}-fg}✓{/} Commit created: {${S.string}-fg}${message}{/}`);
557
+ }
@@ -0,0 +1,49 @@
1
+ import path from 'node:path';
2
+ import { scanRuns } from '../usage/reader.js';
3
+ import { aggregate } from '../usage/aggregator.js';
4
+ import { S } from "../shell.js";
5
+ export async function usageCommand(opts, bucket) {
6
+ const root = path.resolve(opts.root);
7
+ const runs = await scanRuns(root);
8
+ const result = aggregate(runs, new Date());
9
+ const buckets = {
10
+ "today": () => renderBucketWithProvider("Today", result.today),
11
+ "this-month": () => renderBucketWithProvider("This month", result.thisMonth),
12
+ "last-month": () => renderBucketTotalOnly("Last month", result.lastMonth),
13
+ "all-time": () => renderBucketWithProvider("All time", result.allTime),
14
+ };
15
+ if (isBucketKey(bucket)) {
16
+ return buckets[bucket]();
17
+ }
18
+ else if (!bucket) {
19
+ return "All costs summary:" + Object.values(buckets).map(r => r()).join('\n');
20
+ }
21
+ return `"${bucket}" is not a valid bucket name. Use one of: today, this-month, last-month, all-time.`;
22
+ }
23
+ const LABEL_COL = 22;
24
+ const RUNS_COL = 12;
25
+ function renderBucketWithProvider(label, bucket) {
26
+ let msg = `\n{${S.muted}-fg}════════════════════════════════════════{/}\n{${S.keyword}-fg}{bold}${label}{/}\n`;
27
+ let totalRuns = 0;
28
+ for (const provider of Object.keys(bucket.byProvider)) {
29
+ const prefix = ` ${provider}`;
30
+ const runCount = bucket.byProvider[provider].runCount;
31
+ const runs = `${runCount} runs`;
32
+ const cost = `$${bucket.byProvider[provider].totalCost}`;
33
+ totalRuns += runCount;
34
+ msg += `{${S.text}-fg}{bold}${prefix.padEnd(LABEL_COL)}{/}${runs.padEnd(RUNS_COL)}${cost}\n`;
35
+ }
36
+ const totalPrefix = `${label} total:`;
37
+ const totalRunsStr = `${totalRuns} runs`;
38
+ const totalCost = `$${bucket.total}`;
39
+ msg += `{${S.accent}-fg}{bold}${totalPrefix.padEnd(LABEL_COL)}${totalRunsStr.padEnd(RUNS_COL)}${totalCost}{/}`;
40
+ return msg;
41
+ }
42
+ function renderBucketTotalOnly(label, bucket) {
43
+ const totalPrefix = `${label} total:`;
44
+ const totalCost = `$${bucket.total}`;
45
+ return `\n{${S.muted}-fg}════════════════════════════════════════{/}\n{${S.keyword}-fg}{bold}${label}{/}\n{${S.accent}-fg}{bold}${totalPrefix.padEnd(LABEL_COL)}${totalCost}{/}`;
46
+ }
47
+ function isBucketKey(value) {
48
+ return value === 'today' || value === 'this-month' || value === 'last-month' || value === 'all-time';
49
+ }