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.
- package/dist/packages/cli/src/assets/singleton-logo.txt +10 -0
- package/dist/packages/cli/src/commands/new.js +763 -0
- package/dist/packages/cli/src/commands/repl.js +557 -0
- package/dist/packages/cli/src/commands/usage.js +49 -0
- package/dist/packages/cli/src/executor/debug-loop.js +525 -0
- package/dist/packages/cli/src/executor/inputs.js +226 -0
- package/dist/packages/cli/src/executor/outputs.js +134 -0
- package/dist/packages/cli/src/executor/preflight.js +605 -0
- package/dist/packages/cli/src/executor/replay-loop.js +120 -0
- package/dist/packages/cli/src/executor/run-report.js +209 -0
- package/dist/packages/cli/src/executor/run-setup.js +114 -0
- package/dist/packages/cli/src/executor/security-review.js +97 -0
- package/dist/packages/cli/src/executor/snapshot-manager.js +349 -0
- package/dist/packages/cli/src/executor/step-runner.js +241 -0
- package/dist/packages/cli/src/executor.js +584 -0
- package/dist/packages/cli/src/index.js +107 -0
- package/dist/packages/cli/src/parser.js +89 -0
- package/dist/packages/cli/src/runners/_shared.js +96 -0
- package/dist/packages/cli/src/runners/claude.js +103 -0
- package/dist/packages/cli/src/runners/codex-instructions.js +69 -0
- package/dist/packages/cli/src/runners/codex.js +141 -0
- package/dist/packages/cli/src/runners/copilot.js +209 -0
- package/dist/packages/cli/src/runners/index.js +18 -0
- package/dist/packages/cli/src/runners/opencode.js +240 -0
- package/dist/packages/cli/src/scanner.js +43 -0
- package/dist/packages/cli/src/security/policy.js +115 -0
- package/dist/packages/cli/src/sentinels.js +1 -0
- package/dist/packages/cli/src/shell.js +753 -0
- package/dist/packages/cli/src/theme.js +39 -0
- package/dist/packages/cli/src/timeline.js +238 -0
- package/dist/packages/cli/src/types.js +1 -0
- package/dist/packages/cli/src/usage/aggregator.js +44 -0
- package/dist/packages/cli/src/usage/reader.js +30 -0
- package/dist/packages/cli/src/usage/types.js +1 -0
- package/dist/packages/server/src/index.js +36 -0
- package/dist/packages/server/src/routes/agents.js +31 -0
- package/dist/packages/server/src/routes/files.js +45 -0
- package/dist/packages/server/src/routes/pipelines.js +74 -0
- package/docs/reference.md +28 -0
- package/package.json +15 -14
- package/packages/web/dist/assets/{index-CnKytBly.js → index-9S0goZlQ.js} +1 -1
- package/packages/web/dist/assets/{index-CCFWfCA2.css → index-iV4UtXoN.css} +1 -1
- package/packages/web/dist/assets/logo-COSyZmgk.png +0 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/cli/package.json +0 -18
- package/packages/cli/src/commands/new.js +0 -786
- package/packages/cli/src/commands/repl.js +0 -548
- package/packages/cli/src/executor/debug-loop.js +0 -587
- package/packages/cli/src/executor/inputs.js +0 -202
- package/packages/cli/src/executor/outputs.js +0 -140
- package/packages/cli/src/executor/preflight.js +0 -459
- package/packages/cli/src/executor/replay-loop.js +0 -172
- package/packages/cli/src/executor/run-report.js +0 -189
- package/packages/cli/src/executor/run-setup.js +0 -93
- package/packages/cli/src/executor/security-review.js +0 -108
- package/packages/cli/src/executor/snapshot-manager.js +0 -335
- package/packages/cli/src/executor/step-runner.js +0 -266
- package/packages/cli/src/executor.js +0 -652
- package/packages/cli/src/index.js +0 -107
- package/packages/cli/src/parser.js +0 -78
- package/packages/cli/src/runners/_shared.js +0 -83
- package/packages/cli/src/runners/claude.js +0 -122
- package/packages/cli/src/runners/codex-instructions.js +0 -75
- package/packages/cli/src/runners/codex.js +0 -165
- package/packages/cli/src/runners/copilot.js +0 -224
- package/packages/cli/src/runners/index.js +0 -20
- package/packages/cli/src/runners/opencode.js +0 -265
- package/packages/cli/src/scanner.js +0 -47
- package/packages/cli/src/security/policy.js +0 -126
- package/packages/cli/src/shell.js +0 -732
- package/packages/cli/src/theme.js +0 -46
- package/packages/cli/src/timeline.js +0 -180
- package/packages/server/package.json +0 -11
- package/packages/server/src/index.js +0 -43
- package/packages/server/src/routes/agents.js +0 -32
- package/packages/server/src/routes/files.js +0 -42
- 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.
|
|
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
|
-
"
|
|
37
|
-
"
|
|
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
|
|
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
|
}
|