specrails-hub 0.1.0
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/LICENSE +21 -0
- package/README.md +255 -0
- package/cli/dist/srm.js +895 -0
- package/client/dist/assets/index-BEc7DzgE.css +1 -0
- package/client/dist/assets/index-DoIYcnfd.js +486 -0
- package/client/dist/index.html +13 -0
- package/package.json +57 -0
- package/server/analytics.test.ts +166 -0
- package/server/analytics.ts +318 -0
- package/server/chat-manager.test.ts +216 -0
- package/server/chat-manager.ts +289 -0
- package/server/command-grid-logic.test.ts +480 -0
- package/server/command-resolver.test.ts +136 -0
- package/server/command-resolver.ts +29 -0
- package/server/config.test.ts +193 -0
- package/server/config.ts +321 -0
- package/server/db.test.ts +409 -0
- package/server/db.ts +514 -0
- package/server/hooks.test.ts +196 -0
- package/server/hooks.ts +117 -0
- package/server/hub-db.ts +141 -0
- package/server/hub-router.ts +137 -0
- package/server/index.test.ts +538 -0
- package/server/index.ts +539 -0
- package/server/project-registry.ts +130 -0
- package/server/project-router.ts +451 -0
- package/server/proposal-manager.test.ts +410 -0
- package/server/proposal-manager.ts +285 -0
- package/server/proposal-routes.test.ts +424 -0
- package/server/queue-manager.test.ts +400 -0
- package/server/queue-manager.ts +545 -0
- package/server/setup-manager.ts +526 -0
- package/server/types.ts +360 -0
package/cli/dist/srm.js
ADDED
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* srm — specrails CLI bridge
|
|
5
|
+
*
|
|
6
|
+
* Routes commands to the manager when running, or falls back to invoking
|
|
7
|
+
* claude directly when the manager is not reachable.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* srm implement #42 → /sr:implement #42 (via manager or direct)
|
|
11
|
+
* srm "any raw prompt" → raw prompt (no /sr: prefix)
|
|
12
|
+
* srm --status → print manager state
|
|
13
|
+
* srm --jobs → print job history table
|
|
14
|
+
* srm --port 5000 <command> → use port 5000 instead of 4200
|
|
15
|
+
* srm --help → print usage and exit 0
|
|
16
|
+
*/
|
|
17
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
18
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
19
|
+
};
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.parseArgs = parseArgs;
|
|
22
|
+
exports.detectWebManager = detectWebManager;
|
|
23
|
+
exports.formatDuration = formatDuration;
|
|
24
|
+
exports.formatTokens = formatTokens;
|
|
25
|
+
exports.printSummary = printSummary;
|
|
26
|
+
const http_1 = __importDefault(require("http"));
|
|
27
|
+
const child_process_1 = require("child_process");
|
|
28
|
+
const child_process_2 = require("child_process");
|
|
29
|
+
const readline_1 = require("readline");
|
|
30
|
+
const ws_1 = __importDefault(require("ws"));
|
|
31
|
+
const path_1 = __importDefault(require("path"));
|
|
32
|
+
const os_1 = __importDefault(require("os"));
|
|
33
|
+
const fs_1 = __importDefault(require("fs"));
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
const DEFAULT_PORT = 4200;
|
|
38
|
+
const DETECTION_TIMEOUT_MS = 500;
|
|
39
|
+
const KNOWN_VERBS = new Set([
|
|
40
|
+
'implement',
|
|
41
|
+
'batch-implement',
|
|
42
|
+
'why',
|
|
43
|
+
'product-backlog',
|
|
44
|
+
'update-product-driven-backlog',
|
|
45
|
+
'refactor-recommender',
|
|
46
|
+
'health-check',
|
|
47
|
+
'compat-check',
|
|
48
|
+
]);
|
|
49
|
+
const EXIT_PATTERN = /\[process exited with code (\d+)/;
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// ANSI helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
const isTTY = process.stdout.isTTY === true;
|
|
54
|
+
function ansi(code, text) {
|
|
55
|
+
if (!isTTY)
|
|
56
|
+
return text;
|
|
57
|
+
return `\x1b[${code}m${text}\x1b[0m`;
|
|
58
|
+
}
|
|
59
|
+
const dim = (t) => ansi('2', t);
|
|
60
|
+
const red = (t) => ansi('31', t);
|
|
61
|
+
const bold = (t) => ansi('1', t);
|
|
62
|
+
const dimCyan = (t) => ansi('2;36', t);
|
|
63
|
+
function srmPrefix() {
|
|
64
|
+
return dim('[srm]');
|
|
65
|
+
}
|
|
66
|
+
function srmLog(msg) {
|
|
67
|
+
process.stdout.write(`${srmPrefix()} ${msg}\n`);
|
|
68
|
+
}
|
|
69
|
+
function srmError(msg) {
|
|
70
|
+
process.stderr.write(`${srmPrefix()} ${red(`error: ${msg}`)}\n`);
|
|
71
|
+
}
|
|
72
|
+
function srmWarn(msg) {
|
|
73
|
+
process.stderr.write(`${srmPrefix()} ${dim(`warning: ${msg}`)}\n`);
|
|
74
|
+
}
|
|
75
|
+
function parseArgs(argv) {
|
|
76
|
+
// argv is process.argv.slice(2)
|
|
77
|
+
let port = DEFAULT_PORT;
|
|
78
|
+
const args = [...argv];
|
|
79
|
+
// Extract --port <n> from any position
|
|
80
|
+
for (let i = 0; i < args.length; i++) {
|
|
81
|
+
if (args[i] === '--port' && i + 1 < args.length) {
|
|
82
|
+
const parsed = parseInt(args[i + 1], 10);
|
|
83
|
+
if (!isNaN(parsed)) {
|
|
84
|
+
port = parsed;
|
|
85
|
+
}
|
|
86
|
+
args.splice(i, 2);
|
|
87
|
+
i--;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
91
|
+
return { mode: 'help' };
|
|
92
|
+
}
|
|
93
|
+
if (args[0] === '--status') {
|
|
94
|
+
return { mode: 'status', port };
|
|
95
|
+
}
|
|
96
|
+
if (args[0] === '--jobs') {
|
|
97
|
+
return { mode: 'jobs', port };
|
|
98
|
+
}
|
|
99
|
+
if (args[0] === 'hub') {
|
|
100
|
+
return { mode: 'hub', subArgs: args.slice(1), port };
|
|
101
|
+
}
|
|
102
|
+
const first = args[0];
|
|
103
|
+
// Slash-prefixed command: pass through unchanged
|
|
104
|
+
if (first.startsWith('/')) {
|
|
105
|
+
const resolved = args.join(' ');
|
|
106
|
+
return { mode: 'raw', resolved, port };
|
|
107
|
+
}
|
|
108
|
+
// Known verb: inject /sr: prefix
|
|
109
|
+
if (KNOWN_VERBS.has(first)) {
|
|
110
|
+
const resolved = `/sr:${args.join(' ')}`;
|
|
111
|
+
return { mode: 'command', resolved, port };
|
|
112
|
+
}
|
|
113
|
+
// Unknown first token: treat as raw prompt
|
|
114
|
+
const resolved = args.join(' ');
|
|
115
|
+
return { mode: 'raw', resolved, port };
|
|
116
|
+
}
|
|
117
|
+
function printHelp() {
|
|
118
|
+
process.stdout.write(`
|
|
119
|
+
${bold('srm')} — specrails CLI bridge
|
|
120
|
+
|
|
121
|
+
${bold('Usage:')}
|
|
122
|
+
srm implement #42 Run a known specrails verb (prepends /sr:)
|
|
123
|
+
srm batch-implement #40 #41 Known verbs: ${[...KNOWN_VERBS].join(', ')}
|
|
124
|
+
srm "any raw prompt" Pass a raw prompt directly to claude
|
|
125
|
+
srm --status Print manager status and exit
|
|
126
|
+
srm --jobs Print recent job history and exit
|
|
127
|
+
srm hub <subcommand> Manage the hub (multi-project mode)
|
|
128
|
+
srm --port <n> Override default port (${DEFAULT_PORT})
|
|
129
|
+
srm --help Show this help text
|
|
130
|
+
|
|
131
|
+
${bold('Execution paths:')}
|
|
132
|
+
Manager running → POST /api/spawn + stream logs via WebSocket
|
|
133
|
+
Manager not running → spawn claude directly with stream-json output
|
|
134
|
+
`.trimStart());
|
|
135
|
+
}
|
|
136
|
+
function detectWebManager(port) {
|
|
137
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
req.destroy();
|
|
141
|
+
resolve({ running: false, baseUrl });
|
|
142
|
+
}, DETECTION_TIMEOUT_MS);
|
|
143
|
+
const req = http_1.default.get(`${baseUrl}/api/state`, { timeout: DETECTION_TIMEOUT_MS }, (res) => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
res.resume(); // drain the response
|
|
146
|
+
if (res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300) {
|
|
147
|
+
resolve({ running: true, baseUrl });
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
resolve({ running: false, baseUrl });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
req.on('error', () => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
resolve({ running: false, baseUrl });
|
|
156
|
+
});
|
|
157
|
+
req.on('timeout', () => {
|
|
158
|
+
req.destroy();
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
resolve({ running: false, baseUrl });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// HTTP helpers
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
function httpGet(url) {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const req = http_1.default.get(url, (res) => {
|
|
170
|
+
let body = '';
|
|
171
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
172
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
|
173
|
+
});
|
|
174
|
+
req.on('error', reject);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function httpPost(url, payload) {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const data = JSON.stringify(payload);
|
|
180
|
+
const urlObj = new URL(url);
|
|
181
|
+
const options = {
|
|
182
|
+
hostname: urlObj.hostname,
|
|
183
|
+
port: urlObj.port,
|
|
184
|
+
path: urlObj.pathname,
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: {
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
'Content-Length': Buffer.byteLength(data),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const req = http_1.default.request(options, (res) => {
|
|
192
|
+
let body = '';
|
|
193
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
194
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
|
195
|
+
});
|
|
196
|
+
req.on('error', reject);
|
|
197
|
+
req.write(data);
|
|
198
|
+
req.end();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Duration formatting
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
function formatDuration(ms) {
|
|
205
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
206
|
+
if (totalSeconds < 60) {
|
|
207
|
+
return `${totalSeconds}s`;
|
|
208
|
+
}
|
|
209
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
210
|
+
const seconds = totalSeconds % 60;
|
|
211
|
+
return `${minutes}m ${seconds}s`;
|
|
212
|
+
}
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Token formatting
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
function formatTokens(n) {
|
|
217
|
+
return new Intl.NumberFormat('en-US', { useGrouping: true })
|
|
218
|
+
.format(n)
|
|
219
|
+
.replace(/,/g, ' ');
|
|
220
|
+
}
|
|
221
|
+
function printSummary(data) {
|
|
222
|
+
const doneLabel = isTTY ? bold('[srm] done') : '[srm] done';
|
|
223
|
+
const durationPart = `duration: ${formatDuration(data.durationMs)}`;
|
|
224
|
+
const costPart = data.costUsd != null ? ` cost: $${data.costUsd.toFixed(2)}` : '';
|
|
225
|
+
const tokenPart = data.totalTokens != null ? ` tokens: ${formatTokens(data.totalTokens)}` : '';
|
|
226
|
+
const exitPart = ` exit: ${data.exitCode}`;
|
|
227
|
+
process.stdout.write(`${doneLabel} ${durationPart}${costPart}${tokenPart}${exitPart}\n`);
|
|
228
|
+
}
|
|
229
|
+
async function resolveProjectFromCwd(baseUrl) {
|
|
230
|
+
try {
|
|
231
|
+
const cwd = process.cwd();
|
|
232
|
+
const res = await httpGet(`${baseUrl}/api/hub/resolve?path=${encodeURIComponent(cwd)}`);
|
|
233
|
+
if (res.status === 200) {
|
|
234
|
+
const data = JSON.parse(res.body);
|
|
235
|
+
return data.project ?? null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Hub endpoint not available — not in hub mode
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
async function runViaWebManager(command, baseUrl) {
|
|
244
|
+
// Detect hub mode: check if /api/hub/state is reachable
|
|
245
|
+
let spawnUrl = `${baseUrl}/api/spawn`;
|
|
246
|
+
let jobApiBase = `${baseUrl}/api`;
|
|
247
|
+
try {
|
|
248
|
+
const hubCheck = await httpGet(`${baseUrl}/api/hub/state`);
|
|
249
|
+
if (hubCheck.status === 200) {
|
|
250
|
+
// Hub mode: resolve project from CWD
|
|
251
|
+
const project = await resolveProjectFromCwd(baseUrl);
|
|
252
|
+
if (!project) {
|
|
253
|
+
srmError('hub is running but no project registered for the current directory.\n' +
|
|
254
|
+
` Run: srm hub add ${process.cwd()}`);
|
|
255
|
+
return 1;
|
|
256
|
+
}
|
|
257
|
+
spawnUrl = `${baseUrl}/api/projects/${project.id}/spawn`;
|
|
258
|
+
jobApiBase = `${baseUrl}/api/projects/${project.id}`;
|
|
259
|
+
srmLog(`project: ${project.name}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Single-project mode — use default paths
|
|
264
|
+
}
|
|
265
|
+
// Spawn the job
|
|
266
|
+
let spawnRes;
|
|
267
|
+
try {
|
|
268
|
+
spawnRes = await httpPost(spawnUrl, { command });
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
srmError('failed to connect to manager');
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
if (spawnRes.status === 409) {
|
|
275
|
+
srmError('manager is busy (another job is running)');
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
if (spawnRes.status >= 400) {
|
|
279
|
+
let errMsg = `spawn failed with HTTP ${spawnRes.status}`;
|
|
280
|
+
try {
|
|
281
|
+
const parsed = JSON.parse(spawnRes.body);
|
|
282
|
+
if (parsed.error)
|
|
283
|
+
errMsg = parsed.error;
|
|
284
|
+
}
|
|
285
|
+
catch { /* use default */ }
|
|
286
|
+
srmError(errMsg);
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
let processId;
|
|
290
|
+
try {
|
|
291
|
+
const parsed = JSON.parse(spawnRes.body);
|
|
292
|
+
// Server returns jobId; processId is the legacy field name used in LogMessage
|
|
293
|
+
processId = (parsed.jobId ?? parsed.processId) ?? '';
|
|
294
|
+
if (!processId)
|
|
295
|
+
throw new Error('missing jobId');
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
srmError('invalid response from /api/spawn');
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
const startTime = Date.now();
|
|
302
|
+
// Connect WebSocket and stream logs
|
|
303
|
+
const wsUrl = baseUrl.replace(/^http/, 'ws');
|
|
304
|
+
let exitCode = 1;
|
|
305
|
+
let resolved = false;
|
|
306
|
+
await new Promise((resolve) => {
|
|
307
|
+
const ws = new ws_1.default(wsUrl);
|
|
308
|
+
ws.on('message', (data) => {
|
|
309
|
+
let msg;
|
|
310
|
+
try {
|
|
311
|
+
msg = JSON.parse(data.toString());
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (msg.type === 'init') {
|
|
317
|
+
// Replay only log lines from our processId
|
|
318
|
+
const initMsg = msg;
|
|
319
|
+
for (const logLine of initMsg.logBuffer) {
|
|
320
|
+
if (logLine.processId === processId) {
|
|
321
|
+
handleLogLine(logLine);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (msg.type === 'log') {
|
|
327
|
+
const logMsg = msg;
|
|
328
|
+
if (logMsg.processId !== processId)
|
|
329
|
+
return;
|
|
330
|
+
handleLogLine(logMsg);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (msg.type === 'phase') {
|
|
334
|
+
const phaseMsg = msg;
|
|
335
|
+
process.stdout.write(` ${dimCyan(`→ [${phaseMsg.phase}] ${phaseMsg.state}`)}\n`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
function handleLogLine(logMsg) {
|
|
340
|
+
if (resolved)
|
|
341
|
+
return;
|
|
342
|
+
// Check for exit signal
|
|
343
|
+
const match = EXIT_PATTERN.exec(logMsg.line);
|
|
344
|
+
if (match) {
|
|
345
|
+
exitCode = parseInt(match[1], 10);
|
|
346
|
+
resolved = true;
|
|
347
|
+
ws.close();
|
|
348
|
+
resolve();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Print to appropriate stream, preserving ANSI
|
|
352
|
+
if (logMsg.source === 'stderr') {
|
|
353
|
+
process.stderr.write(`${logMsg.line}\n`);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
process.stdout.write(`${logMsg.line}\n`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
ws.on('close', () => {
|
|
360
|
+
if (!resolved) {
|
|
361
|
+
srmWarn('lost connection to manager');
|
|
362
|
+
resolved = true;
|
|
363
|
+
resolve();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
ws.on('error', (err) => {
|
|
367
|
+
if (!resolved) {
|
|
368
|
+
srmWarn(`WebSocket error: ${err.message}`);
|
|
369
|
+
resolved = true;
|
|
370
|
+
resolve();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
const durationMs = Date.now() - startTime;
|
|
375
|
+
// Fetch job metadata for cost/tokens
|
|
376
|
+
let costUsd;
|
|
377
|
+
let totalTokens;
|
|
378
|
+
try {
|
|
379
|
+
const jobRes = await httpGet(`${jobApiBase}/jobs/${processId}`);
|
|
380
|
+
if (jobRes.status === 200) {
|
|
381
|
+
const parsed = JSON.parse(jobRes.body);
|
|
382
|
+
if (parsed.job) {
|
|
383
|
+
if (parsed.job.total_cost_usd != null)
|
|
384
|
+
costUsd = parsed.job.total_cost_usd;
|
|
385
|
+
const tokensIn = parsed.job.tokens_in ?? 0;
|
|
386
|
+
const tokensOut = parsed.job.tokens_out ?? 0;
|
|
387
|
+
if (parsed.job.tokens_in != null || parsed.job.tokens_out != null) {
|
|
388
|
+
totalTokens = tokensIn + tokensOut;
|
|
389
|
+
}
|
|
390
|
+
// Prefer server-side duration when available
|
|
391
|
+
if (parsed.job.duration_ms != null) {
|
|
392
|
+
printSummary({ durationMs: parsed.job.duration_ms, costUsd, totalTokens, exitCode });
|
|
393
|
+
return exitCode;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch { /* fall through to duration-only summary */ }
|
|
399
|
+
printSummary({ durationMs, costUsd, totalTokens, exitCode });
|
|
400
|
+
return exitCode;
|
|
401
|
+
}
|
|
402
|
+
async function runDirect(command) {
|
|
403
|
+
const startTime = Date.now();
|
|
404
|
+
const args = [
|
|
405
|
+
'--dangerously-skip-permissions',
|
|
406
|
+
'-p',
|
|
407
|
+
...command.trim().split(/\s+/),
|
|
408
|
+
'--output-format', 'stream-json',
|
|
409
|
+
'--verbose',
|
|
410
|
+
];
|
|
411
|
+
let child;
|
|
412
|
+
try {
|
|
413
|
+
child = (0, child_process_1.spawn)('claude', args, {
|
|
414
|
+
env: process.env,
|
|
415
|
+
shell: false,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
const code = err.code;
|
|
420
|
+
if (code === 'ENOENT') {
|
|
421
|
+
srmError('claude binary not found');
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
srmError(`failed to spawn claude: ${err.message}`);
|
|
425
|
+
}
|
|
426
|
+
return 1;
|
|
427
|
+
}
|
|
428
|
+
let resultData;
|
|
429
|
+
// Stderr: pass through unchanged
|
|
430
|
+
child.stderr?.pipe(process.stderr);
|
|
431
|
+
// Stdout: parse NDJSON line by line
|
|
432
|
+
const rl = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
|
|
433
|
+
rl.on('line', (line) => {
|
|
434
|
+
if (!line.trim())
|
|
435
|
+
return;
|
|
436
|
+
let parsed = null;
|
|
437
|
+
try {
|
|
438
|
+
parsed = JSON.parse(line);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// Non-JSON line: print as-is
|
|
442
|
+
process.stdout.write(`${line}\n`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (parsed.type === 'text') {
|
|
446
|
+
const content = parsed.content ?? '';
|
|
447
|
+
if (content)
|
|
448
|
+
process.stdout.write(`${content}\n`);
|
|
449
|
+
}
|
|
450
|
+
else if (parsed.type === 'result') {
|
|
451
|
+
resultData = parsed;
|
|
452
|
+
}
|
|
453
|
+
// All other types: silently ignore
|
|
454
|
+
});
|
|
455
|
+
const exitCode = await new Promise((resolve) => {
|
|
456
|
+
child.on('close', (code) => {
|
|
457
|
+
resolve(code ?? 1);
|
|
458
|
+
});
|
|
459
|
+
child.on('error', (err) => {
|
|
460
|
+
if (err.code === 'ENOENT') {
|
|
461
|
+
srmError('claude binary not found');
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
srmError(`claude process error: ${err.message}`);
|
|
465
|
+
}
|
|
466
|
+
resolve(1);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
const durationMs = Date.now() - startTime;
|
|
470
|
+
let costUsd;
|
|
471
|
+
let totalTokens;
|
|
472
|
+
if (resultData) {
|
|
473
|
+
if (resultData.cost_usd != null)
|
|
474
|
+
costUsd = resultData.cost_usd;
|
|
475
|
+
const tokensIn = resultData.input_tokens ?? 0;
|
|
476
|
+
const tokensOut = resultData.output_tokens ?? 0;
|
|
477
|
+
if (resultData.input_tokens != null || resultData.output_tokens != null) {
|
|
478
|
+
totalTokens = tokensIn + tokensOut;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
printSummary({ durationMs, costUsd, totalTokens, exitCode });
|
|
482
|
+
return exitCode;
|
|
483
|
+
}
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// --status handler
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
async function handleStatus(port) {
|
|
488
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
489
|
+
const detection = await detectWebManager(port);
|
|
490
|
+
if (!detection.running) {
|
|
491
|
+
process.stdout.write(`manager: not running (${baseUrl})\n`);
|
|
492
|
+
return 1;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
const res = await httpGet(`${baseUrl}/api/state`);
|
|
496
|
+
if (res.status !== 200) {
|
|
497
|
+
process.stdout.write(`manager: not running (${baseUrl})\n`);
|
|
498
|
+
return 1;
|
|
499
|
+
}
|
|
500
|
+
const state = JSON.parse(res.body);
|
|
501
|
+
const version = state.version ? ` (v${state.version})` : '';
|
|
502
|
+
process.stdout.write(`manager: running${version}\n`);
|
|
503
|
+
process.stdout.write(`project: ${state.projectName ?? 'unknown'}\n`);
|
|
504
|
+
process.stdout.write(`busy: ${state.busy ? 'true' : 'false'}\n`);
|
|
505
|
+
if (state.phases) {
|
|
506
|
+
const phaseStr = Object.entries(state.phases)
|
|
507
|
+
.map(([phase, st]) => `${phase}=${st}`)
|
|
508
|
+
.join(' ');
|
|
509
|
+
process.stdout.write(`phases: ${phaseStr}\n`);
|
|
510
|
+
}
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
process.stdout.write(`manager: not running (${baseUrl})\n`);
|
|
515
|
+
return 1;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function formatJobDuration(ms) {
|
|
519
|
+
if (ms == null)
|
|
520
|
+
return '-';
|
|
521
|
+
return formatDuration(ms);
|
|
522
|
+
}
|
|
523
|
+
function formatJobStarted(isoStr) {
|
|
524
|
+
try {
|
|
525
|
+
const d = new Date(isoStr);
|
|
526
|
+
const year = d.getFullYear();
|
|
527
|
+
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
528
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
529
|
+
const hour = String(d.getHours()).padStart(2, '0');
|
|
530
|
+
const min = String(d.getMinutes()).padStart(2, '0');
|
|
531
|
+
return `${year}-${month}-${day} ${hour}:${min}`;
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
return isoStr.slice(0, 16);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async function handleJobs(port) {
|
|
538
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
539
|
+
const detection = await detectWebManager(port);
|
|
540
|
+
if (!detection.running) {
|
|
541
|
+
srmError(`manager is not running (${baseUrl})`);
|
|
542
|
+
return 1;
|
|
543
|
+
}
|
|
544
|
+
let res;
|
|
545
|
+
try {
|
|
546
|
+
res = await httpGet(`${baseUrl}/api/jobs`);
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
srmError('failed to fetch job list');
|
|
550
|
+
return 1;
|
|
551
|
+
}
|
|
552
|
+
if (res.status === 501 || res.status === 404) {
|
|
553
|
+
srmLog('jobs history requires manager with SQLite persistence (#57)');
|
|
554
|
+
return 1;
|
|
555
|
+
}
|
|
556
|
+
if (res.status !== 200) {
|
|
557
|
+
srmError(`unexpected response from /api/jobs: HTTP ${res.status}`);
|
|
558
|
+
return 1;
|
|
559
|
+
}
|
|
560
|
+
let data;
|
|
561
|
+
try {
|
|
562
|
+
data = JSON.parse(res.body);
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
srmError('invalid response from /api/jobs');
|
|
566
|
+
return 1;
|
|
567
|
+
}
|
|
568
|
+
if (!data.jobs || data.jobs.length === 0) {
|
|
569
|
+
srmLog('no jobs recorded yet');
|
|
570
|
+
return 0;
|
|
571
|
+
}
|
|
572
|
+
// Column widths
|
|
573
|
+
const idW = 8;
|
|
574
|
+
const cmdW = 30;
|
|
575
|
+
const startW = 18;
|
|
576
|
+
const durW = 8;
|
|
577
|
+
const exitW = 4;
|
|
578
|
+
const header = [
|
|
579
|
+
'ID'.padEnd(idW),
|
|
580
|
+
'COMMAND'.padEnd(cmdW),
|
|
581
|
+
'STARTED'.padEnd(startW),
|
|
582
|
+
'DURATION'.padEnd(durW),
|
|
583
|
+
'EXIT'.padEnd(exitW),
|
|
584
|
+
].join(' ');
|
|
585
|
+
process.stdout.write(`${bold(header)}\n`);
|
|
586
|
+
for (const job of data.jobs) {
|
|
587
|
+
const idCell = job.id.slice(0, idW).padEnd(idW);
|
|
588
|
+
const cmdCell = job.command.slice(0, cmdW).padEnd(cmdW);
|
|
589
|
+
const startCell = formatJobStarted(job.started_at).padEnd(startW);
|
|
590
|
+
const durCell = formatJobDuration(job.duration_ms).padEnd(durW);
|
|
591
|
+
const exitCell = (job.exit_code ?? '-').toString().padEnd(exitW);
|
|
592
|
+
process.stdout.write(`${idCell} ${cmdCell} ${startCell} ${durCell} ${exitCell}\n`);
|
|
593
|
+
}
|
|
594
|
+
return 0;
|
|
595
|
+
}
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
// Hub subcommand group
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
const HUB_PID_FILE = path_1.default.join(os_1.default.homedir(), '.specrails', 'manager.pid');
|
|
600
|
+
function readPid() {
|
|
601
|
+
try {
|
|
602
|
+
const raw = fs_1.default.readFileSync(HUB_PID_FILE, 'utf-8').trim();
|
|
603
|
+
const pid = parseInt(raw, 10);
|
|
604
|
+
return isNaN(pid) ? null : pid;
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function isProcessRunning(pid) {
|
|
611
|
+
try {
|
|
612
|
+
process.kill(pid, 0);
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function hubServerPath() {
|
|
620
|
+
// srm lives at cli/dist/srm.js; server is at ../server/index.js (built)
|
|
621
|
+
// In dev mode with tsx, we resolve from __filename
|
|
622
|
+
const base = path_1.default.resolve(__dirname, '..');
|
|
623
|
+
// Try compiled JS first, then fall back to tsx dev path
|
|
624
|
+
const compiled = path_1.default.join(base, 'server', 'index.js');
|
|
625
|
+
const devTs = path_1.default.join(base, 'server', 'index.ts');
|
|
626
|
+
if (fs_1.default.existsSync(compiled))
|
|
627
|
+
return compiled;
|
|
628
|
+
if (fs_1.default.existsSync(devTs))
|
|
629
|
+
return devTs;
|
|
630
|
+
return compiled;
|
|
631
|
+
}
|
|
632
|
+
async function hubStart(port) {
|
|
633
|
+
const pid = readPid();
|
|
634
|
+
if (pid !== null && isProcessRunning(pid)) {
|
|
635
|
+
srmLog(`hub already running (pid ${pid}) on port ${port}`);
|
|
636
|
+
return 0;
|
|
637
|
+
}
|
|
638
|
+
const serverPath = hubServerPath();
|
|
639
|
+
const isTs = serverPath.endsWith('.ts');
|
|
640
|
+
const args = isTs
|
|
641
|
+
? ['tsx', serverPath, '--port', String(port)]
|
|
642
|
+
: ['node', serverPath, '--port', String(port)];
|
|
643
|
+
const child = (0, child_process_2.spawn)(args[0], args.slice(1), {
|
|
644
|
+
detached: true,
|
|
645
|
+
stdio: 'ignore',
|
|
646
|
+
env: { ...process.env },
|
|
647
|
+
});
|
|
648
|
+
child.unref();
|
|
649
|
+
// Wait briefly and confirm it started
|
|
650
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
651
|
+
const detection = await detectWebManager(port);
|
|
652
|
+
if (detection.running) {
|
|
653
|
+
srmLog(`hub started on http://127.0.0.1:${port}`);
|
|
654
|
+
return 0;
|
|
655
|
+
}
|
|
656
|
+
srmError('hub failed to start (check logs)');
|
|
657
|
+
return 1;
|
|
658
|
+
}
|
|
659
|
+
async function hubStop() {
|
|
660
|
+
const pid = readPid();
|
|
661
|
+
if (pid === null) {
|
|
662
|
+
srmLog('hub is not running (no pid file)');
|
|
663
|
+
return 0;
|
|
664
|
+
}
|
|
665
|
+
if (!isProcessRunning(pid)) {
|
|
666
|
+
srmLog('hub is not running (stale pid file)');
|
|
667
|
+
try {
|
|
668
|
+
fs_1.default.unlinkSync(HUB_PID_FILE);
|
|
669
|
+
}
|
|
670
|
+
catch { /* ignore */ }
|
|
671
|
+
return 0;
|
|
672
|
+
}
|
|
673
|
+
try {
|
|
674
|
+
process.kill(pid, 'SIGTERM');
|
|
675
|
+
srmLog(`hub stopped (pid ${pid})`);
|
|
676
|
+
return 0;
|
|
677
|
+
}
|
|
678
|
+
catch (err) {
|
|
679
|
+
srmError(`failed to stop hub: ${err.message}`);
|
|
680
|
+
return 1;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async function hubStatus(port) {
|
|
684
|
+
const pid = readPid();
|
|
685
|
+
const detection = await detectWebManager(port);
|
|
686
|
+
if (!detection.running) {
|
|
687
|
+
process.stdout.write(`hub: not running\n`);
|
|
688
|
+
return 1;
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
const res = await httpGet(`${detection.baseUrl}/api/hub/state`);
|
|
692
|
+
const state = JSON.parse(res.body);
|
|
693
|
+
process.stdout.write(`hub: running (pid ${pid ?? '?'}) on ${detection.baseUrl}\n`);
|
|
694
|
+
process.stdout.write(`projects: ${state.projectCount ?? 0}\n`);
|
|
695
|
+
if (state.projects) {
|
|
696
|
+
for (const p of state.projects) {
|
|
697
|
+
process.stdout.write(` - ${p.name}\n`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return 0;
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
process.stdout.write(`hub: running on ${detection.baseUrl}\n`);
|
|
704
|
+
return 0;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async function hubAdd(projectPath, port) {
|
|
708
|
+
const detection = await detectWebManager(port);
|
|
709
|
+
if (!detection.running) {
|
|
710
|
+
srmError('hub is not running. Start it first with: srm hub start');
|
|
711
|
+
return 1;
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
const res = await httpPost(`${detection.baseUrl}/api/hub/projects`, {
|
|
715
|
+
path: path_1.default.resolve(projectPath),
|
|
716
|
+
});
|
|
717
|
+
if (res.status === 201) {
|
|
718
|
+
const data = JSON.parse(res.body);
|
|
719
|
+
srmLog(`added project: ${data.project?.name ?? projectPath}`);
|
|
720
|
+
return 0;
|
|
721
|
+
}
|
|
722
|
+
else if (res.status === 409) {
|
|
723
|
+
srmLog('project already registered');
|
|
724
|
+
return 0;
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
let errMsg = `HTTP ${res.status}`;
|
|
728
|
+
try {
|
|
729
|
+
errMsg = JSON.parse(res.body).error ?? errMsg;
|
|
730
|
+
}
|
|
731
|
+
catch { /* use default */ }
|
|
732
|
+
srmError(`failed to add project: ${errMsg}`);
|
|
733
|
+
return 1;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
srmError(`failed to connect to hub: ${err.message}`);
|
|
738
|
+
return 1;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async function hubRemove(projectId, port) {
|
|
742
|
+
const detection = await detectWebManager(port);
|
|
743
|
+
if (!detection.running) {
|
|
744
|
+
srmError('hub is not running');
|
|
745
|
+
return 1;
|
|
746
|
+
}
|
|
747
|
+
try {
|
|
748
|
+
const deleteRes = await new Promise((resolve, reject) => {
|
|
749
|
+
const urlObj = new URL(`${detection.baseUrl}/api/hub/projects/${projectId}`);
|
|
750
|
+
const options = {
|
|
751
|
+
hostname: urlObj.hostname,
|
|
752
|
+
port: urlObj.port,
|
|
753
|
+
path: urlObj.pathname,
|
|
754
|
+
method: 'DELETE',
|
|
755
|
+
};
|
|
756
|
+
const req = http_1.default.request(options, (res) => {
|
|
757
|
+
let body = '';
|
|
758
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
759
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
|
760
|
+
});
|
|
761
|
+
req.on('error', reject);
|
|
762
|
+
req.end();
|
|
763
|
+
});
|
|
764
|
+
if (deleteRes.status === 200) {
|
|
765
|
+
srmLog(`project removed`);
|
|
766
|
+
return 0;
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
srmError(`failed to remove project: HTTP ${deleteRes.status}`);
|
|
770
|
+
return 1;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
srmError(`failed to connect to hub: ${err.message}`);
|
|
775
|
+
return 1;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
async function hubList(port) {
|
|
779
|
+
const detection = await detectWebManager(port);
|
|
780
|
+
if (!detection.running) {
|
|
781
|
+
srmError('hub is not running');
|
|
782
|
+
return 1;
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
const res = await httpGet(`${detection.baseUrl}/api/hub/projects`);
|
|
786
|
+
const data = JSON.parse(res.body);
|
|
787
|
+
if (!data.projects || data.projects.length === 0) {
|
|
788
|
+
srmLog('no projects registered');
|
|
789
|
+
return 0;
|
|
790
|
+
}
|
|
791
|
+
const idW = 36;
|
|
792
|
+
const nameW = 24;
|
|
793
|
+
process.stdout.write(`${bold('ID'.padEnd(idW))} ${bold('NAME'.padEnd(nameW))} ${bold('PATH')}\n`);
|
|
794
|
+
for (const p of data.projects) {
|
|
795
|
+
process.stdout.write(`${p.id.padEnd(idW)} ${p.name.padEnd(nameW)} ${p.path}\n`);
|
|
796
|
+
}
|
|
797
|
+
return 0;
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
srmError(`failed to fetch projects: ${err.message}`);
|
|
801
|
+
return 1;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async function handleHub(subArgs, port) {
|
|
805
|
+
const sub = subArgs[0];
|
|
806
|
+
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
|
|
807
|
+
process.stdout.write(`
|
|
808
|
+
${bold('srm hub')} — manage the specrails hub (multi-project mode)
|
|
809
|
+
|
|
810
|
+
${bold('Usage:')}
|
|
811
|
+
srm hub start Start the hub server
|
|
812
|
+
srm hub stop Stop the hub server
|
|
813
|
+
srm hub status Show hub status and registered projects
|
|
814
|
+
srm hub add <path> Register a project by path
|
|
815
|
+
srm hub remove <id> Unregister a project by ID
|
|
816
|
+
srm hub list List all registered projects
|
|
817
|
+
`.trimStart());
|
|
818
|
+
return 0;
|
|
819
|
+
}
|
|
820
|
+
if (sub === 'start') {
|
|
821
|
+
return hubStart(port);
|
|
822
|
+
}
|
|
823
|
+
if (sub === 'stop') {
|
|
824
|
+
return hubStop();
|
|
825
|
+
}
|
|
826
|
+
if (sub === 'status') {
|
|
827
|
+
return hubStatus(port);
|
|
828
|
+
}
|
|
829
|
+
if (sub === 'add') {
|
|
830
|
+
const projectPath = subArgs[1];
|
|
831
|
+
if (!projectPath) {
|
|
832
|
+
srmError('usage: srm hub add <path>');
|
|
833
|
+
return 1;
|
|
834
|
+
}
|
|
835
|
+
return hubAdd(projectPath, port);
|
|
836
|
+
}
|
|
837
|
+
if (sub === 'remove') {
|
|
838
|
+
const projectId = subArgs[1];
|
|
839
|
+
if (!projectId) {
|
|
840
|
+
srmError('usage: srm hub remove <id>');
|
|
841
|
+
return 1;
|
|
842
|
+
}
|
|
843
|
+
return hubRemove(projectId, port);
|
|
844
|
+
}
|
|
845
|
+
if (sub === 'list') {
|
|
846
|
+
return hubList(port);
|
|
847
|
+
}
|
|
848
|
+
srmError(`unknown hub subcommand: ${sub}`);
|
|
849
|
+
return 1;
|
|
850
|
+
}
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
// Main entry point
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
async function main() {
|
|
855
|
+
const argv = process.argv.slice(2);
|
|
856
|
+
const parsed = parseArgs(argv);
|
|
857
|
+
if (parsed.mode === 'help') {
|
|
858
|
+
printHelp();
|
|
859
|
+
process.exit(0);
|
|
860
|
+
}
|
|
861
|
+
if (parsed.mode === 'status') {
|
|
862
|
+
const code = await handleStatus(parsed.port);
|
|
863
|
+
process.exit(code);
|
|
864
|
+
}
|
|
865
|
+
if (parsed.mode === 'jobs') {
|
|
866
|
+
const code = await handleJobs(parsed.port);
|
|
867
|
+
process.exit(code);
|
|
868
|
+
}
|
|
869
|
+
if (parsed.mode === 'hub') {
|
|
870
|
+
const code = await handleHub(parsed.subArgs, parsed.port);
|
|
871
|
+
process.exit(code);
|
|
872
|
+
}
|
|
873
|
+
// Command or raw: resolve command string
|
|
874
|
+
const command = parsed.resolved;
|
|
875
|
+
const port = parsed.port;
|
|
876
|
+
srmLog(`running: ${command}`);
|
|
877
|
+
const detection = await detectWebManager(port);
|
|
878
|
+
let exitCode;
|
|
879
|
+
if (detection.running) {
|
|
880
|
+
srmLog(`routing via manager at ${detection.baseUrl}`);
|
|
881
|
+
exitCode = await runViaWebManager(command, detection.baseUrl);
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
srmLog('manager not running — invoking claude directly');
|
|
885
|
+
exitCode = await runDirect(command);
|
|
886
|
+
}
|
|
887
|
+
process.exit(exitCode);
|
|
888
|
+
}
|
|
889
|
+
// Only run main() when this file is executed directly (not when imported in tests)
|
|
890
|
+
if (require.main === module) {
|
|
891
|
+
main().catch((err) => {
|
|
892
|
+
srmError(err.message ?? String(err));
|
|
893
|
+
process.exit(1);
|
|
894
|
+
});
|
|
895
|
+
}
|