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,39 @@
1
+ import chalk from 'chalk';
2
+ // ============================================================
3
+ // CLI theme — semantic tokens. Change palette here.
4
+ // ============================================================
5
+ const accent = chalk.hex('#AF87FF');
6
+ // -- Semantic styles ----------------------------------------
7
+ export const style = {
8
+ // Informational
9
+ title: (text) => accent.bold(text),
10
+ heading: (text) => chalk.bold(text),
11
+ muted: (text) => chalk.hex('#676498')(text),
12
+ dim: (text) => chalk.gray.italic(text),
13
+ // Status
14
+ success: (text) => chalk.green(text),
15
+ warn: (text) => chalk.yellow(text),
16
+ error: (text) => chalk.red(text),
17
+ info: (text) => accent(text),
18
+ // Data accents
19
+ accent: (text) => accent(text),
20
+ id: (text) => accent.bold(text),
21
+ path: (text) => chalk.gray(text),
22
+ value: (text) => chalk.white(text),
23
+ code: (text) => chalk.magenta(text)
24
+ };
25
+ // -- Semantic markers (prefix glyphs) -----------------------
26
+ export const mark = {
27
+ success: '✓',
28
+ error: '✕',
29
+ warn: '!',
30
+ info: '›',
31
+ bullet: '·'
32
+ };
33
+ // -- Pre-composed line helpers ------------------------------
34
+ export const line = {
35
+ success: (message) => `${style.success(mark.success)} ${message}`,
36
+ error: (message) => `${style.error(mark.error)} ${message}`,
37
+ warn: (message) => `${style.warn(mark.warn)} ${message}`,
38
+ info: (message) => `${style.info(mark.info)} ${message}`
39
+ };
@@ -0,0 +1,238 @@
1
+ import blessed from 'blessed';
2
+ import { ASCII_MODE, G, S } from './shell.js';
3
+ const FRAMES = ASCII_MODE ? ['|', '/', '-', '\\'] : ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
4
+ function stripBlessedTags(s) {
5
+ return String(s || '').replace(/\{[^}]+\}/g, '');
6
+ }
7
+ export function createPlainTimeline(stepNames, { log = console.log } = {}) {
8
+ const statuses = stepNames.map(() => 'pending');
9
+ const meta = stepNames.map(() => '');
10
+ function line(index, status, info = '') {
11
+ const label = stepNames[index] || `step ${index + 1}`;
12
+ const suffix = info ? ` - ${info}` : '';
13
+ return `[${status}] ${label}${suffix}`;
14
+ }
15
+ return {
16
+ log(text) { log(stripBlessedTags(text)); },
17
+ logMuted(text) { log(stripBlessedTags(text)); },
18
+ logSuccess(text) { log(stripBlessedTags(text)); },
19
+ logError(text) { log(stripBlessedTags(text)); },
20
+ logDiffLine(raw) { log(String(raw ?? '')); },
21
+ setRunning(i, info = '') {
22
+ statuses[i] = 'running';
23
+ meta[i] = info;
24
+ log(line(i, 'running', info));
25
+ },
26
+ setPaused(i, info = '') {
27
+ statuses[i] = 'paused';
28
+ meta[i] = info;
29
+ log(line(i, 'paused', info));
30
+ },
31
+ setDone(i, info = '') {
32
+ statuses[i] = 'done';
33
+ meta[i] = info;
34
+ log(line(i, 'done', info));
35
+ },
36
+ setError(i, info = '') {
37
+ statuses[i] = 'error';
38
+ meta[i] = info;
39
+ log(line(i, 'error', info));
40
+ },
41
+ end() { },
42
+ };
43
+ }
44
+ // widgets: { screen, logPanel, statusBox } — provided by shell when running inside the TUI.
45
+ // If omitted, a standalone blessed screen is created.
46
+ export function createTimeline(stepNames, widgets = null) {
47
+ const N = stepNames.length;
48
+ const statuses = stepNames.map(() => 'pending');
49
+ const meta = stepNames.map(() => '');
50
+ let runningIdx = -1;
51
+ let spinnerInterval = null;
52
+ let spinnerFrame = 0;
53
+ let screen;
54
+ let logPanel;
55
+ let statusBox;
56
+ let setLabel = null;
57
+ let mirror = null;
58
+ let ownScreen = false;
59
+ if (widgets) {
60
+ ({ screen, logPanel, statusBox, setLabel = null, mirror = null } = widgets);
61
+ }
62
+ else {
63
+ ownScreen = true;
64
+ screen = blessed.screen({ smartCSR: true, title: 'Singleton' });
65
+ logPanel = blessed.log({
66
+ top: 0, left: 0,
67
+ width: '100%', height: '100%-6',
68
+ tags: true,
69
+ scrollable: true,
70
+ alwaysScroll: true,
71
+ padding: { left: 2, top: 1, right: 2 }
72
+ });
73
+ const separator = blessed.line({
74
+ orientation: 'horizontal',
75
+ bottom: 5, left: 0,
76
+ width: '100%',
77
+ style: { fg: S.border }
78
+ });
79
+ statusBox = blessed.box({
80
+ bottom: 1, left: 0,
81
+ width: '100%', height: 4,
82
+ tags: true,
83
+ padding: { left: 2, right: 2 }
84
+ });
85
+ screen.append(logPanel);
86
+ screen.append(separator);
87
+ screen.append(statusBox);
88
+ screen.key(['C-c'], () => { screen.destroy(); process.exit(0); });
89
+ }
90
+ function dot(status, frame = 0) {
91
+ if (status === 'done')
92
+ return `{${S.success}-fg}${G.running}{/}`;
93
+ if (status === 'running')
94
+ return `{${S.text}-fg}${FRAMES[frame % FRAMES.length]}{/}`;
95
+ if (status === 'paused')
96
+ return `{${S.warning}-fg}${G.running}{/}`;
97
+ if (status === 'error')
98
+ return `{${S.error}-fg}${G.running}{/}`;
99
+ return `{${S.subtle}-fg}${G.pending}{/}`;
100
+ }
101
+ function shimmerName(text, frame = 0) {
102
+ text = text || '';
103
+ const peak = frame % (text.length + 6);
104
+ return text.split('').map((ch, i) => {
105
+ const dist = Math.abs(i - peak);
106
+ let color = S.accent;
107
+ if (dist === 0)
108
+ color = S.text;
109
+ else if (dist === 1)
110
+ color = '#EDD9FF';
111
+ else if (dist === 2)
112
+ color = '#D4B0FE';
113
+ return ch === ' ' ? ch : `{${color}-fg}{bold}${ch}{/}`;
114
+ }).join('');
115
+ }
116
+ function compactDots(frame = 0) {
117
+ return stepNames.map((_name, i) => dot(statuses[i], frame)).join(` {${S.subtle}-fg}${G.dash}{/} `);
118
+ }
119
+ function renderTimeline(frame = 0) {
120
+ const currentMeta = runningIdx >= 0 && meta[runningIdx]
121
+ ? ` {${S.muted}-fg}${meta[runningIdx]}{/}`
122
+ : '';
123
+ const isPaused = runningIdx >= 0 && statuses[runningIdx] === 'paused';
124
+ const activityLabel = isPaused
125
+ ? `{${S.warning}-fg}{bold}Paused{/}`
126
+ : `{bold}Running{/}`;
127
+ const activityIcon = isPaused
128
+ ? `{${S.warning}-fg}${G.running}{/}`
129
+ : `{${S.text}-fg}${FRAMES[frame % FRAMES.length]}{/}`;
130
+ const runningLabel = runningIdx >= 0
131
+ ? `${activityLabel} ${activityIcon} ${shimmerName(stepNames[runningIdx], frame)}${currentMeta}`
132
+ : `{bold}Running:{/} {${S.muted}-fg}idle{/}`;
133
+ const statusLines = [
134
+ '',
135
+ runningLabel,
136
+ '',
137
+ compactDots(frame)
138
+ ];
139
+ statusBox.setContent(statusLines.join('\n'));
140
+ if (setLabel) {
141
+ if (runningIdx < 0) {
142
+ setLabel('');
143
+ }
144
+ else if (runningIdx === 0) {
145
+ // Index 0 is always the preflight pseudo-step — it's not a "real" pipeline step,
146
+ // so show "preflight" rather than fold it into the X/N count.
147
+ const labelText = isPaused ? 'preflight - paused' : 'preflight';
148
+ setLabel(`{${S.text}-fg}{bold}${labelText}{/}`);
149
+ }
150
+ else {
151
+ // Real steps: 1..(N-1). Subtract 1 from N to exclude preflight from the total.
152
+ const labelText = isPaused
153
+ ? `step ${runningIdx}/${N - 1} - paused`
154
+ : `step ${runningIdx}/${N - 1}`;
155
+ setLabel(`{${S.text}-fg}{bold}${labelText}{/}`);
156
+ }
157
+ }
158
+ screen.render();
159
+ }
160
+ renderTimeline();
161
+ return {
162
+ log(text) { const s = `{${S.keyword}-fg}${text}{/}`; logPanel.log(s); mirror?.(s); screen.render(); },
163
+ logMuted(text) { const s = `{${S.muted}-fg}${text}{/}`; logPanel.log(s); mirror?.(s); screen.render(); },
164
+ logSuccess(text) { const s = `{${S.success}-fg}${text}{/}`; logPanel.log(s); mirror?.(s); screen.render(); },
165
+ logError(text) { const s = `{${S.error}-fg}${text}{/}`; logPanel.log(s); mirror?.(s); screen.render(); },
166
+ logDiffLine(raw) {
167
+ const text = String(raw ?? '');
168
+ const body = text.replace(/^\s+/, '');
169
+ // Default to S.subtle for all non-signal lines (meta git, context, untracked previews) —
170
+ // one consistent gray instead of two slightly different ones. Check git meta starts before
171
+ // +/- because +++ and --- would otherwise match the body coloring.
172
+ let color = S.muted;
173
+ if (/^(diff --git|index |--- |\+\+\+ )/.test(body))
174
+ color = S.muted;
175
+ else if (body.startsWith('@@'))
176
+ color = S.keyword;
177
+ else if (body.startsWith('+'))
178
+ color = S.success;
179
+ else if (body.startsWith('-'))
180
+ color = S.error;
181
+ const s = `{${color}-fg}${text}{/}`;
182
+ logPanel.log(s);
183
+ mirror?.(s);
184
+ screen.render();
185
+ },
186
+ setRunning(i, info = '') {
187
+ if (spinnerInterval) {
188
+ clearInterval(spinnerInterval);
189
+ spinnerInterval = null;
190
+ }
191
+ runningIdx = i;
192
+ statuses[i] = 'running';
193
+ meta[i] = info;
194
+ renderTimeline();
195
+ spinnerInterval = setInterval(() => { spinnerFrame++; renderTimeline(spinnerFrame); }, 80);
196
+ },
197
+ setPaused(i, info = '') {
198
+ if (spinnerInterval) {
199
+ clearInterval(spinnerInterval);
200
+ spinnerInterval = null;
201
+ }
202
+ runningIdx = i;
203
+ statuses[i] = 'paused';
204
+ meta[i] = info;
205
+ renderTimeline();
206
+ },
207
+ setDone(i, info = '') {
208
+ if (spinnerInterval) {
209
+ clearInterval(spinnerInterval);
210
+ spinnerInterval = null;
211
+ }
212
+ statuses[i] = 'done';
213
+ meta[i] = info;
214
+ if (runningIdx === i)
215
+ runningIdx = -1;
216
+ renderTimeline();
217
+ },
218
+ setError(i, info = '') {
219
+ if (spinnerInterval) {
220
+ clearInterval(spinnerInterval);
221
+ spinnerInterval = null;
222
+ }
223
+ statuses[i] = 'error';
224
+ meta[i] = info;
225
+ if (runningIdx === i)
226
+ runningIdx = -1;
227
+ renderTimeline();
228
+ },
229
+ end() {
230
+ if (spinnerInterval) {
231
+ clearInterval(spinnerInterval);
232
+ spinnerInterval = null;
233
+ }
234
+ if (ownScreen)
235
+ screen.destroy();
236
+ }
237
+ };
238
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ export function aggregate(runs, now) {
2
+ const result = {
3
+ today: emptyBucketWithProviders(),
4
+ thisMonth: emptyBucketWithProviders(),
5
+ lastMonth: { total: 0 },
6
+ allTime: emptyBucketWithProviders()
7
+ };
8
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
9
+ const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
10
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
11
+ const buckets = [
12
+ { match: (d) => d >= todayStart, apply: (run) => addToBucketWithProvider(result.today, run) },
13
+ { match: (d) => d >= thisMonthStart, apply: (run) => addToBucketWithProvider(result.thisMonth, run) },
14
+ { match: (d) => d >= lastMonthStart && d <= thisMonthStart, apply: (run) => addToBucketTotalOnly(result.lastMonth, run) },
15
+ { match: () => true, apply: (run) => addToBucketWithProvider(result.allTime, run) },
16
+ ];
17
+ for (const run of runs) {
18
+ const runDate = new Date(run.createdAt);
19
+ for (const b of buckets) {
20
+ if (b.match(runDate))
21
+ b.apply(run);
22
+ }
23
+ }
24
+ return result;
25
+ }
26
+ function emptyBucketWithProviders() {
27
+ return {
28
+ byProvider: {
29
+ claude: { totalCost: 0, runCount: 0, reported: true },
30
+ codex: { totalCost: 0, runCount: 0, reported: false },
31
+ copilot: { totalCost: 0, runCount: 0, reported: true },
32
+ opencode: { totalCost: 0, runCount: 0, reported: true },
33
+ },
34
+ total: 0,
35
+ };
36
+ }
37
+ function addToBucketWithProvider(bucketTarget, run) {
38
+ bucketTarget.total += run.cost;
39
+ bucketTarget.byProvider[run.provider].totalCost += run.cost;
40
+ bucketTarget.byProvider[run.provider].runCount += 1;
41
+ }
42
+ function addToBucketTotalOnly(bucketTarget, run) {
43
+ bucketTarget.total += run.cost;
44
+ }
@@ -0,0 +1,30 @@
1
+ import path from "node:path";
2
+ import fs from 'node:fs/promises';
3
+ const PROVIDERS = ['claude', 'codex', 'copilot', 'opencode'];
4
+ export async function scanRuns(rootPath) {
5
+ const runsPath = path.join(rootPath, '.singleton', "runs");
6
+ const rundDirectories = await fs.readdir(runsPath, { withFileTypes: true });
7
+ const runs = [];
8
+ for (const runDirectory of rundDirectories) {
9
+ if (runDirectory.isDirectory()) {
10
+ const runPath = path.join(runsPath, runDirectory.name);
11
+ const directory = await fs.readdir(runPath, { withFileTypes: true });
12
+ for (const file of directory) {
13
+ if (file.isFile() && file.name == "run-manifest.json") {
14
+ const runManifestFile = path.join(runPath, file.name);
15
+ const runManifest = await fs.readFile(runManifestFile, 'utf8');
16
+ const runManifestJson = JSON.parse(runManifest);
17
+ for (const stats of runManifestJson["stats"]) {
18
+ if (!isProvider(stats.provider))
19
+ continue;
20
+ runs.push({ createdAt: runManifestJson.createdAt, provider: stats.provider, cost: stats.cost });
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
26
+ return runs;
27
+ }
28
+ function isProvider(value) {
29
+ return value === 'claude' || value === 'codex' || value === 'copilot' || value === 'opencode';
30
+ }
@@ -0,0 +1 @@
1
+ export const BUCKET_KEYS = ["today", "this-month", "last-month", "all-time"];
@@ -0,0 +1,36 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const WEB_DIST = path.resolve(__dirname, '../../web/dist');
8
+ import { agentsRouter } from './routes/agents.js';
9
+ import { pipelinesRouter } from './routes/pipelines.js';
10
+ import { filesRouter } from './routes/files.js';
11
+ export async function startServer({ port = 4317, root = process.cwd(), logger = console.log } = {}) {
12
+ const app = express();
13
+ app.use(cors());
14
+ app.use(express.json({ limit: '2mb' }));
15
+ const ctx = {
16
+ root,
17
+ pipelinesDir: path.join(root, '.singleton', 'pipelines'),
18
+ agentsCacheFile: path.join(root, '.singleton', 'agents.json')
19
+ };
20
+ app.get('/api/health', (_req, res) => res.json({ ok: true, root }));
21
+ app.use('/api/agents', agentsRouter(ctx));
22
+ app.use('/api/pipelines', pipelinesRouter(ctx));
23
+ app.use('/api/files', filesRouter(ctx));
24
+ app.use(express.static(WEB_DIST));
25
+ app.get('*', (_req, res) => res.sendFile(path.join(WEB_DIST, 'index.html')));
26
+ return new Promise((resolve) => {
27
+ const server = app.listen(port, () => {
28
+ logger(`Singleton server listening on http://localhost:${port}`);
29
+ logger(`Project root: ${root}`);
30
+ resolve(server);
31
+ });
32
+ });
33
+ }
34
+ if (import.meta.url === `file://${process.argv[1]}`) {
35
+ startServer({ port: Number(process.env.PORT) || 4317, root: process.cwd() });
36
+ }
@@ -0,0 +1,31 @@
1
+ import { Router } from 'express';
2
+ import fs from 'node:fs/promises';
3
+ import { scanAgents } from '../../../cli/src/scanner.js';
4
+ export function agentsRouter(ctx) {
5
+ const r = Router();
6
+ r.get('/', async (_req, res) => {
7
+ try {
8
+ try {
9
+ const raw = await fs.readFile(ctx.agentsCacheFile, 'utf8');
10
+ return res.json(JSON.parse(raw));
11
+ }
12
+ catch {
13
+ const agents = await scanAgents(ctx.root);
14
+ return res.json({ scannedAt: new Date().toISOString(), root: ctx.root, agents });
15
+ }
16
+ }
17
+ catch (err) {
18
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
19
+ }
20
+ });
21
+ r.post('/rescan', async (_req, res) => {
22
+ try {
23
+ const agents = await scanAgents(ctx.root);
24
+ res.json({ scannedAt: new Date().toISOString(), root: ctx.root, agents });
25
+ }
26
+ catch (err) {
27
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
28
+ }
29
+ });
30
+ return r;
31
+ }
@@ -0,0 +1,45 @@
1
+ import { Router } from 'express';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.singleton', 'dist', 'build', '.next', '.cache']);
5
+ export function toApiFilePath(relPath) {
6
+ return String(relPath || '').replace(/\\/g, '/');
7
+ }
8
+ async function walk(root, rel = '', out = []) {
9
+ const abs = path.join(root, rel);
10
+ const entries = await fs.readdir(abs, { withFileTypes: true });
11
+ for (const e of entries) {
12
+ if (e.name.startsWith('.') && e.name !== '.singleton') {
13
+ if (e.isDirectory())
14
+ continue;
15
+ }
16
+ if (e.isDirectory()) {
17
+ if (SKIP_DIRS.has(e.name))
18
+ continue;
19
+ await walk(root, path.join(rel, e.name), out);
20
+ }
21
+ else if (e.isFile()) {
22
+ out.push(toApiFilePath(path.join(rel, e.name)));
23
+ }
24
+ }
25
+ return out;
26
+ }
27
+ export function filesRouter(ctx) {
28
+ const r = Router();
29
+ r.get('/', async (req, res) => {
30
+ try {
31
+ const ext = String(req.query.ext || '').replace(/^\./, '').toLowerCase();
32
+ const all = await walk(ctx.root);
33
+ const filtered = ext
34
+ ? all.filter((f) => f.toLowerCase().endsWith(`.${ext}`))
35
+ : all;
36
+ filtered.sort();
37
+ res.json({ root: ctx.root, files: filtered });
38
+ }
39
+ catch (err) {
40
+ const message = err instanceof Error ? err.message : String(err);
41
+ res.status(500).json({ error: message });
42
+ }
43
+ });
44
+ return r;
45
+ }
@@ -0,0 +1,74 @@
1
+ import { Router } from 'express';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ function safeName(name) {
5
+ return String(name).replace(/[^a-zA-Z0-9_-]+/g, '-').slice(0, 80) || 'pipeline';
6
+ }
7
+ export function pipelinesRouter(ctx) {
8
+ const r = Router();
9
+ r.get('/', async (_req, res) => {
10
+ try {
11
+ await fs.mkdir(ctx.pipelinesDir, { recursive: true });
12
+ const files = (await fs.readdir(ctx.pipelinesDir)).filter((f) => f.endsWith('.json'));
13
+ const items = (await Promise.all(files.map(async (f) => {
14
+ try {
15
+ const raw = await fs.readFile(path.join(ctx.pipelinesDir, f), 'utf8');
16
+ const parsed = JSON.parse(raw);
17
+ if (!parsed.name)
18
+ return null;
19
+ return { file: f, ...parsed };
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }))).filter(Boolean);
25
+ res.json({ pipelines: items });
26
+ }
27
+ catch (err) {
28
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
29
+ }
30
+ });
31
+ r.get('/:name', async (req, res) => {
32
+ try {
33
+ const file = path.join(ctx.pipelinesDir, `${safeName(req.params.name)}.json`);
34
+ const raw = await fs.readFile(file, 'utf8');
35
+ res.json(JSON.parse(raw));
36
+ }
37
+ catch (err) {
38
+ res.status(404).json({ error: 'not found' });
39
+ }
40
+ });
41
+ r.post('/', async (req, res) => {
42
+ try {
43
+ const { name, steps = [], nodes, edges } = req.body || {};
44
+ if (!name)
45
+ return res.status(400).json({ error: 'name required' });
46
+ await fs.mkdir(ctx.pipelinesDir, { recursive: true });
47
+ const safe = safeName(name);
48
+ const file = path.join(ctx.pipelinesDir, `${safe}.json`);
49
+ const payload = {
50
+ name: safe,
51
+ created: new Date().toISOString(),
52
+ steps,
53
+ nodes,
54
+ edges
55
+ };
56
+ await fs.writeFile(file, JSON.stringify(payload, null, 2));
57
+ res.json({ ok: true, file: `${safe}.json`, ...payload });
58
+ }
59
+ catch (err) {
60
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
61
+ }
62
+ });
63
+ r.delete('/:name', async (req, res) => {
64
+ try {
65
+ const file = path.join(ctx.pipelinesDir, `${safeName(req.params.name)}.json`);
66
+ await fs.unlink(file);
67
+ res.json({ ok: true });
68
+ }
69
+ catch (err) {
70
+ res.status(404).json({ error: 'not found' });
71
+ }
72
+ });
73
+ return r;
74
+ }
package/docs/reference.md CHANGED
@@ -941,6 +941,33 @@ Start the interactive shell.
941
941
  singleton
942
942
  ```
943
943
 
944
+ ### `usage`
945
+
946
+ Show cost and run usage across providers, broken down by time bucket.
947
+
948
+ ```bash
949
+ singleton usage # full summary (today, this-month, last-month, all-time)
950
+ singleton usage today # only today's bucket
951
+ singleton usage this-month # only the current month
952
+ singleton usage last-month # only the previous month
953
+ singleton usage all-time # cumulative total since the first run
954
+ ```
955
+
956
+ Options:
957
+
958
+ - `--root <path>` — project root, defaults to the current directory
959
+
960
+ What it does:
961
+
962
+ - reads every `run-manifest.json` under `.singleton/runs/`
963
+ - aggregates cost and run count per provider, per time bucket
964
+ - prints aligned columns with per-provider totals and bucket totals
965
+
966
+ Notes:
967
+
968
+ - `codex` runs report a cost of `0` because Codex usage is not surfaced in its manifest. The aggregator still counts the runs, but the cost column will read `$0`.
969
+ - An unknown bucket name prints an error listing the valid values.
970
+
944
971
  ## REPL commands
945
972
 
946
973
  ```txt
@@ -951,6 +978,7 @@ singleton
951
978
  /stop
952
979
  /commit-last
953
980
  /ls
981
+ /usage [--today | --this-month | --last-month | --all-time]
954
982
  /help
955
983
  /quit
956
984
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "singleton-pipeline",
3
- "version": "0.4.0-beta.13",
3
+ "version": "0.4.0-beta.14",
4
4
  "description": "Visual pipeline builder for multi-agent AI workflows. Orchestrates Claude Code, Codex, Copilot, and OpenCode under a unified security policy.",
5
5
  "keywords": [
6
6
  "ai",
@@ -29,15 +29,12 @@
29
29
  "node": ">=20"
30
30
  },
31
31
  "bin": {
32
- "singleton": "./packages/cli/src/index.js"
32
+ "singleton": "./dist/packages/cli/src/index.js"
33
33
  },
34
34
  "files": [
35
- "packages/cli/src/**/*.js",
36
- "!packages/cli/src/**/*.test.js",
37
- "!packages/cli/src/__fixtures__",
38
- "packages/cli/package.json",
39
- "packages/server/src/**/*.js",
40
- "packages/server/package.json",
35
+ "dist/packages/cli/src/**/*.js",
36
+ "dist/packages/cli/src/assets/**",
37
+ "dist/packages/server/src/**/*.js",
41
38
  "packages/web/dist/**",
42
39
  "packages/web/package.json",
43
40
  "docs/reference.md",
@@ -61,17 +58,21 @@
61
58
  "kleur": "^4.1.5"
62
59
  },
63
60
  "devDependencies": {
61
+ "typescript": "^6.0.3",
64
62
  "vitest": "^3.2.4"
65
63
  },
66
64
  "scripts": {
67
- "singleton": "node packages/cli/src/index.js",
68
- "scan": "node packages/cli/src/index.js scan",
69
- "serve": "node packages/cli/src/index.js serve",
70
- "dev:server": "node packages/server/src/index.js",
65
+ "singleton": "npm run build:ts --silent && node dist/packages/cli/src/index.js",
66
+ "scan": "npm run build:ts --silent && node dist/packages/cli/src/index.js scan",
67
+ "serve": "npm run build:ts --silent && node dist/packages/cli/src/index.js serve",
68
+ "dev:server": "npm run build:ts --silent && node dist/packages/server/src/index.js",
71
69
  "test": "vitest run",
72
70
  "test:watch": "vitest",
71
+ "typecheck": "tsc -p tsconfig.typecheck.json",
72
+ "build:ts": "tsc -p tsconfig.build.json && node -e \"require('fs').cpSync('packages/cli/src/assets', 'dist/packages/cli/src/assets', {recursive: true})\"",
73
+ "build": "npm run build:ts && npm run build:web",
73
74
  "build:web": "npm run build -w @singleton/web",
74
- "prepublishOnly": "npm run build:web && npm test",
75
- "pack:smoke": "npm pack && tar -tzf singleton-pipeline-*.tgz | head -60"
75
+ "prepublishOnly": "npm run build && npm test",
76
+ "pack:smoke": "npm run build && npm pack --cache .npm-cache && tar -tzf singleton-pipeline-*.tgz | head -60"
76
77
  }
77
78
  }