runsignal 0.0.1 → 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 +22 -0
- package/README.md +37 -0
- package/bin/runsignal.js +671 -3
- package/package.json +42 -4
- package/src/api-client.js +107 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RunSignal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# RunSignal CLI
|
|
2
|
+
|
|
3
|
+
RunSignal CLI lets you initialize a machine, wrap agent commands, and inspect event decisions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -g runsignal
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run directly:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx runsignal --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
- `runsignal login`
|
|
20
|
+
- `runsignal init --provider telegram|slack`
|
|
21
|
+
- `runsignal wrap -- <command>`
|
|
22
|
+
- `runsignal send --message "..."`
|
|
23
|
+
- `runsignal status <eventId>`
|
|
24
|
+
- `runsignal logout`
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
runsignal login
|
|
30
|
+
runsignal init --provider telegram
|
|
31
|
+
runsignal wrap -- claude "refactor auth flow"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Environment
|
|
35
|
+
|
|
36
|
+
- `RUNSIGNAL_APP_URL` to override app URL (default: `http://localhost:3000`)
|
|
37
|
+
|
package/bin/runsignal.js
CHANGED
|
@@ -1,3 +1,671 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {Command} from 'commander';
|
|
4
|
+
import {spawn, execSync} from 'node:child_process';
|
|
5
|
+
import {promises as fs} from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import readline from 'node:readline/promises';
|
|
9
|
+
import {stdin as input, stdout as output} from 'node:process';
|
|
10
|
+
import {
|
|
11
|
+
getEventAnswer,
|
|
12
|
+
getEventDecision,
|
|
13
|
+
pollAnswer,
|
|
14
|
+
pollDecision,
|
|
15
|
+
sendEvent
|
|
16
|
+
} from '../src/api-client.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_APP_URL = process.env.RUNSIGNAL_APP_URL || 'http://localhost:3000';
|
|
19
|
+
const CONFIG_DIR = path.join(os.homedir(), '.runsignal');
|
|
20
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
21
|
+
|
|
22
|
+
function toUserMessage(errorCode) {
|
|
23
|
+
const code = String(errorCode || '');
|
|
24
|
+
if (code.includes('not_logged_in')) return 'You are not logged in. Run: runsignal login';
|
|
25
|
+
if (code.includes('machine_not_initialized')) return 'Machine is not initialized. Run: runsignal init';
|
|
26
|
+
if (code.includes('missing_wrapped_command')) return 'Missing wrapped command. Usage: runsignal wrap -- <command>';
|
|
27
|
+
if (code.includes('provider_invalid')) return 'Invalid provider. Use telegram or slack.';
|
|
28
|
+
if (code.includes('telegram_config_invalid'))
|
|
29
|
+
return 'Telegram config invalid. bot_token and chat_id are required.';
|
|
30
|
+
if (code.includes('slack_webhook_invalid')) return 'Slack webhook URL is invalid.';
|
|
31
|
+
if (code.includes('slack_config_invalid'))
|
|
32
|
+
return 'Slack config invalid. Use webhook_url or bot_token + channel_id.';
|
|
33
|
+
if (code.includes('login_timeout')) return 'Login timed out. Retry runsignal login and validate quickly.';
|
|
34
|
+
if (code.includes('login_poll_failed'))
|
|
35
|
+
return 'Login polling failed. Check APP URL and server availability.';
|
|
36
|
+
if (code.includes('send_event_failed'))
|
|
37
|
+
return 'Event send failed. Check machine key, network and server logs.';
|
|
38
|
+
if (code.includes('status_failed')) return 'Status check failed. Verify event id and machine ownership.';
|
|
39
|
+
if (code.includes('event_type_invalid'))
|
|
40
|
+
return 'Invalid event type. Use approval_required, task_completed, error, quota_warning, question.';
|
|
41
|
+
if (code.includes('severity_invalid')) return 'Invalid severity. Use info, warning, or critical.';
|
|
42
|
+
if (code.includes('message_required')) return 'Message is required. Use --message "..."';
|
|
43
|
+
if (code.includes('event_id_required')) return 'Event id is required. Usage: runsignal status <eventId>';
|
|
44
|
+
if (code.includes('init_failed')) return 'Initialization failed. Check token/provider config and try again.';
|
|
45
|
+
return code || 'Unknown error';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function ensureConfigDir() {
|
|
49
|
+
await fs.mkdir(CONFIG_DIR, {recursive: true});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readConfig() {
|
|
53
|
+
try {
|
|
54
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
55
|
+
return JSON.parse(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function writeConfig(config) {
|
|
62
|
+
await ensureConfigDir();
|
|
63
|
+
const raw = `${JSON.stringify(config, null, 2)}\n`;
|
|
64
|
+
await fs.writeFile(CONFIG_PATH, raw, {encoding: 'utf8'});
|
|
65
|
+
|
|
66
|
+
if (process.platform !== 'win32') {
|
|
67
|
+
try {
|
|
68
|
+
await fs.chmod(CONFIG_PATH, 0o600);
|
|
69
|
+
} catch {
|
|
70
|
+
// noop
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mergeConfig(base, patch) {
|
|
76
|
+
return {...base, ...patch};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function apiFetch(baseUrl, route, options = {}) {
|
|
80
|
+
const response = await fetch(`${baseUrl}${route}`, {
|
|
81
|
+
...options,
|
|
82
|
+
headers: {
|
|
83
|
+
'content-type': 'application/json',
|
|
84
|
+
...(options.headers || {})
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const text = await response.text();
|
|
89
|
+
let payload = null;
|
|
90
|
+
try {
|
|
91
|
+
payload = text ? JSON.parse(text) : null;
|
|
92
|
+
} catch {
|
|
93
|
+
payload = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {ok: response.ok, status: response.status, payload};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sleep(ms) {
|
|
100
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function prompt(question) {
|
|
104
|
+
const rl = readline.createInterface({input, output});
|
|
105
|
+
try {
|
|
106
|
+
const value = await rl.question(question);
|
|
107
|
+
return value.trim();
|
|
108
|
+
} finally {
|
|
109
|
+
rl.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getMachineName() {
|
|
114
|
+
return process.env.COMPUTERNAME || os.hostname() || 'machine';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getPlatformLabel() {
|
|
118
|
+
return `${process.platform}-${process.arch}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getRepoFromGit() {
|
|
122
|
+
try {
|
|
123
|
+
const remote = execSync('git config --get remote.origin.url', {
|
|
124
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
125
|
+
})
|
|
126
|
+
.toString('utf8')
|
|
127
|
+
.trim();
|
|
128
|
+
|
|
129
|
+
if (!remote) {
|
|
130
|
+
return path.basename(process.cwd());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const normalized = remote.replace(/\.git$/i, '');
|
|
134
|
+
const slashParts = normalized.split('/');
|
|
135
|
+
const lastSlash = slashParts[slashParts.length - 1];
|
|
136
|
+
|
|
137
|
+
if (lastSlash.includes(':')) {
|
|
138
|
+
return lastSlash.split(':').pop() || path.basename(process.cwd());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return lastSlash || path.basename(process.cwd());
|
|
142
|
+
} catch {
|
|
143
|
+
return path.basename(process.cwd());
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getBranchFromGit() {
|
|
148
|
+
try {
|
|
149
|
+
return execSync('git rev-parse --abbrev-ref HEAD', {stdio: ['ignore', 'pipe', 'ignore']})
|
|
150
|
+
.toString('utf8')
|
|
151
|
+
.trim();
|
|
152
|
+
} catch {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function inferAgentLabel(commandParts) {
|
|
158
|
+
const first = (commandParts[0] || '').toLowerCase();
|
|
159
|
+
if (first.includes('claude')) return 'claude-code';
|
|
160
|
+
if (first.includes('codex')) return 'codex';
|
|
161
|
+
if (first.includes('cursor')) return 'cursor-cli';
|
|
162
|
+
if (first.includes('gemini')) return 'gemini-cli';
|
|
163
|
+
if (first.includes('aider')) return 'aider';
|
|
164
|
+
return first || 'manual-cli';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function extractInterestingLine(chunk) {
|
|
168
|
+
const lines = chunk
|
|
169
|
+
.split(/\r?\n/)
|
|
170
|
+
.map((line) => line.trim())
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
|
|
173
|
+
for (const line of lines) {
|
|
174
|
+
if (/approve|approval|confirm|allow/i.test(line)) {
|
|
175
|
+
return {kind: 'approval_required', line};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (/\?$/.test(line) || /choose|select|which option|reply/i.test(line)) {
|
|
179
|
+
return {kind: 'question', line};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runLogin(options) {
|
|
187
|
+
const config = await readConfig();
|
|
188
|
+
const baseUrl = String(options.appUrl || config.app_url || DEFAULT_APP_URL).replace(/\/$/, '');
|
|
189
|
+
|
|
190
|
+
const start = await apiFetch(baseUrl, '/api/cli/device/start', {method: 'POST', body: '{}'});
|
|
191
|
+
if (!start.ok) {
|
|
192
|
+
throw new Error('login_start_failed');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const deviceCode = start.payload?.device_code;
|
|
196
|
+
const userCode = start.payload?.user_code;
|
|
197
|
+
const verificationUrl = start.payload?.verification_url;
|
|
198
|
+
const interval = Number(start.payload?.interval || 4);
|
|
199
|
+
|
|
200
|
+
if (!deviceCode || !userCode || !verificationUrl) {
|
|
201
|
+
throw new Error('login_payload_invalid');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(`Open ${verificationUrl} and enter code: ${userCode}`);
|
|
205
|
+
|
|
206
|
+
let accessToken = null;
|
|
207
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
208
|
+
while (Date.now() < deadline) {
|
|
209
|
+
await sleep(interval * 1000);
|
|
210
|
+
const poll = await apiFetch(baseUrl, '/api/cli/device/poll', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
body: JSON.stringify({device_code: deviceCode})
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (poll.ok && poll.payload?.access_token) {
|
|
216
|
+
accessToken = poll.payload.access_token;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (poll.status !== 428) {
|
|
221
|
+
throw new Error('login_poll_failed');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!accessToken) {
|
|
226
|
+
throw new Error('login_timeout');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await writeConfig(mergeConfig(config, {app_url: baseUrl, access_token: accessToken}));
|
|
230
|
+
console.log('Login completed.');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function runInit(options) {
|
|
234
|
+
const config = await readConfig();
|
|
235
|
+
const baseUrl = String(config.app_url || options.appUrl || DEFAULT_APP_URL).replace(/\/$/, '');
|
|
236
|
+
const accessToken = config.access_token;
|
|
237
|
+
|
|
238
|
+
if (!accessToken) {
|
|
239
|
+
throw new Error('not_logged_in');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let provider = String(options.provider || '').toLowerCase();
|
|
243
|
+
if (provider !== 'telegram' && provider !== 'slack') {
|
|
244
|
+
const answer = await prompt('Provider (telegram/slack): ');
|
|
245
|
+
provider = answer.toLowerCase();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (provider !== 'telegram' && provider !== 'slack') {
|
|
249
|
+
throw new Error('provider_invalid');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const machineName = String(options.machineName || getMachineName());
|
|
253
|
+
const providerConfig = {};
|
|
254
|
+
|
|
255
|
+
if (provider === 'telegram') {
|
|
256
|
+
const botToken = String(options.botToken || (await prompt('Telegram bot token: ')));
|
|
257
|
+
const chatId = String(options.chatId || (await prompt('Telegram chat id: ')));
|
|
258
|
+
if (!botToken || !chatId) {
|
|
259
|
+
throw new Error('telegram_config_invalid');
|
|
260
|
+
}
|
|
261
|
+
providerConfig.bot_token = botToken;
|
|
262
|
+
providerConfig.chat_id = chatId;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (provider === 'slack') {
|
|
266
|
+
const webhookUrl = String(options.webhookUrl || (await prompt('Slack webhook URL: ')));
|
|
267
|
+
if (!webhookUrl.startsWith('https://')) {
|
|
268
|
+
throw new Error('slack_webhook_invalid');
|
|
269
|
+
}
|
|
270
|
+
providerConfig.webhook_url = webhookUrl;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const register = await apiFetch(baseUrl, '/api/cli/machines/register', {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: {authorization: `Bearer ${accessToken}`},
|
|
276
|
+
body: JSON.stringify({
|
|
277
|
+
machine_name: machineName,
|
|
278
|
+
platform: getPlatformLabel(),
|
|
279
|
+
provider,
|
|
280
|
+
provider_config: providerConfig
|
|
281
|
+
})
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (!register.ok) {
|
|
285
|
+
throw new Error(`init_failed:${register.payload?.error || register.status}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
await writeConfig(
|
|
289
|
+
mergeConfig(config, {
|
|
290
|
+
app_url: baseUrl,
|
|
291
|
+
machine_name: machineName,
|
|
292
|
+
machine_id: register.payload.machine_id,
|
|
293
|
+
machine_api_key: register.payload.machine_api_key,
|
|
294
|
+
provider,
|
|
295
|
+
provider_config: providerConfig
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
console.log(`Machine initialized: ${register.payload.machine_id}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function runWrap(commandParts) {
|
|
303
|
+
if (!commandParts.length) {
|
|
304
|
+
throw new Error('missing_wrapped_command');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const config = await readConfig();
|
|
308
|
+
const baseUrl = String(config.app_url || DEFAULT_APP_URL).replace(/\/$/, '');
|
|
309
|
+
const machineApiKey = config.machine_api_key;
|
|
310
|
+
if (!machineApiKey) {
|
|
311
|
+
throw new Error('machine_not_initialized');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const repo = getRepoFromGit();
|
|
315
|
+
const branch = getBranchFromGit();
|
|
316
|
+
const machineName = config.machine_name || getMachineName();
|
|
317
|
+
const agentLabel = inferAgentLabel(commandParts);
|
|
318
|
+
|
|
319
|
+
const context = {
|
|
320
|
+
cwd_name: path.basename(process.cwd()),
|
|
321
|
+
git_repo: repo,
|
|
322
|
+
machine_name: machineName,
|
|
323
|
+
agent_label: agentLabel
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const commandString = commandParts.join(' ');
|
|
327
|
+
const child = spawn(commandString, {
|
|
328
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
329
|
+
shell: true,
|
|
330
|
+
env: process.env
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
let sawApproval = false;
|
|
334
|
+
let sawQuestion = false;
|
|
335
|
+
let lastInterestingLine = '';
|
|
336
|
+
|
|
337
|
+
const handleChunk = async (chunkRaw) => {
|
|
338
|
+
const chunk = chunkRaw.toString('utf8');
|
|
339
|
+
const interesting = extractInterestingLine(chunk);
|
|
340
|
+
if (!interesting) return;
|
|
341
|
+
|
|
342
|
+
lastInterestingLine = interesting.line;
|
|
343
|
+
|
|
344
|
+
if (interesting.kind === 'approval_required' && !sawApproval) {
|
|
345
|
+
sawApproval = true;
|
|
346
|
+
try {
|
|
347
|
+
const event = await sendEvent({
|
|
348
|
+
apiFetch,
|
|
349
|
+
baseUrl,
|
|
350
|
+
machineApiKey,
|
|
351
|
+
event: {
|
|
352
|
+
message: interesting.line.slice(0, 500),
|
|
353
|
+
event_type: 'approval_required',
|
|
354
|
+
repo,
|
|
355
|
+
branch,
|
|
356
|
+
severity: 'critical',
|
|
357
|
+
context,
|
|
358
|
+
agent: agentLabel
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
if (event.eventId) {
|
|
362
|
+
const decision = await pollDecision({
|
|
363
|
+
apiFetch,
|
|
364
|
+
baseUrl,
|
|
365
|
+
machineApiKey,
|
|
366
|
+
eventId: event.eventId,
|
|
367
|
+
sleep
|
|
368
|
+
});
|
|
369
|
+
console.log(`\n[RunSignal] decision: ${decision}`);
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// keep wrapped command running
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (interesting.kind === 'question' && !sawQuestion) {
|
|
377
|
+
sawQuestion = true;
|
|
378
|
+
try {
|
|
379
|
+
const event = await sendEvent({
|
|
380
|
+
apiFetch,
|
|
381
|
+
baseUrl,
|
|
382
|
+
machineApiKey,
|
|
383
|
+
event: {
|
|
384
|
+
message: interesting.line.slice(0, 500),
|
|
385
|
+
event_type: 'question',
|
|
386
|
+
repo,
|
|
387
|
+
branch,
|
|
388
|
+
severity: 'warning',
|
|
389
|
+
question: interesting.line.slice(0, 500),
|
|
390
|
+
options: ['A', 'B', 'C', 'D'],
|
|
391
|
+
context,
|
|
392
|
+
agent: agentLabel
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
if (event.eventId) {
|
|
396
|
+
const answer = await pollAnswer({
|
|
397
|
+
apiFetch,
|
|
398
|
+
baseUrl,
|
|
399
|
+
machineApiKey,
|
|
400
|
+
eventId: event.eventId,
|
|
401
|
+
sleep
|
|
402
|
+
});
|
|
403
|
+
if (answer) {
|
|
404
|
+
console.log(`\n[RunSignal] reply: ${answer}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
// keep wrapped command running
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
child.stdout.on('data', (chunk) => {
|
|
414
|
+
process.stdout.write(chunk);
|
|
415
|
+
void handleChunk(chunk);
|
|
416
|
+
});
|
|
417
|
+
child.stderr.on('data', (chunk) => {
|
|
418
|
+
process.stderr.write(chunk);
|
|
419
|
+
void handleChunk(chunk);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const exitCode = await new Promise((resolve) => {
|
|
423
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const completionMessage =
|
|
427
|
+
exitCode === 0
|
|
428
|
+
? `Wrapped command completed: ${commandParts.join(' ')}`
|
|
429
|
+
: `Wrapped command failed (code ${exitCode}): ${lastInterestingLine || commandParts.join(' ')}`;
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
await sendEvent({
|
|
433
|
+
apiFetch,
|
|
434
|
+
baseUrl,
|
|
435
|
+
machineApiKey,
|
|
436
|
+
event: {
|
|
437
|
+
message: completionMessage.slice(0, 1000),
|
|
438
|
+
event_type: exitCode === 0 ? 'task_completed' : 'error',
|
|
439
|
+
repo,
|
|
440
|
+
branch,
|
|
441
|
+
severity: exitCode === 0 ? 'info' : 'critical',
|
|
442
|
+
context,
|
|
443
|
+
agent: agentLabel
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
} catch {
|
|
447
|
+
// no-op
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
process.exit(exitCode);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function runTest() {
|
|
454
|
+
const config = await readConfig();
|
|
455
|
+
const baseUrl = String(config.app_url || DEFAULT_APP_URL).replace(/\/$/, '');
|
|
456
|
+
const machineApiKey = config.machine_api_key;
|
|
457
|
+
|
|
458
|
+
if (!machineApiKey) {
|
|
459
|
+
throw new Error('machine_not_initialized');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const repo = getRepoFromGit();
|
|
463
|
+
const branch = getBranchFromGit();
|
|
464
|
+
|
|
465
|
+
const result = await sendEvent({
|
|
466
|
+
apiFetch,
|
|
467
|
+
baseUrl,
|
|
468
|
+
machineApiKey,
|
|
469
|
+
event: {
|
|
470
|
+
message: 'RunSignal test event',
|
|
471
|
+
event_type: 'quota_warning',
|
|
472
|
+
repo,
|
|
473
|
+
branch,
|
|
474
|
+
severity: 'warning',
|
|
475
|
+
context: {
|
|
476
|
+
cwd_name: path.basename(process.cwd()),
|
|
477
|
+
git_repo: repo,
|
|
478
|
+
machine_name: config.machine_name || getMachineName(),
|
|
479
|
+
agent_label: 'runsignal-test'
|
|
480
|
+
},
|
|
481
|
+
agent: 'runsignal-test'
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
console.log(`Test event sent: ${result.eventId || 'unknown'}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function normalizeEventType(value) {
|
|
489
|
+
const raw = String(value || '').trim().toLowerCase();
|
|
490
|
+
if (
|
|
491
|
+
raw === 'approval_required' ||
|
|
492
|
+
raw === 'task_completed' ||
|
|
493
|
+
raw === 'error' ||
|
|
494
|
+
raw === 'quota_warning' ||
|
|
495
|
+
raw === 'question'
|
|
496
|
+
) {
|
|
497
|
+
return raw;
|
|
498
|
+
}
|
|
499
|
+
throw new Error('event_type_invalid');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function defaultSeverityForType(type) {
|
|
503
|
+
if (type === 'error' || type === 'approval_required') return 'critical';
|
|
504
|
+
if (type === 'quota_warning' || type === 'question') return 'warning';
|
|
505
|
+
return 'info';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function normalizeSeverity(value, fallback) {
|
|
509
|
+
const raw = String(value || fallback || '').trim().toLowerCase();
|
|
510
|
+
if (raw === 'info' || raw === 'warning' || raw === 'critical') {
|
|
511
|
+
return raw;
|
|
512
|
+
}
|
|
513
|
+
throw new Error('severity_invalid');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function runSend(options) {
|
|
517
|
+
const config = await readConfig();
|
|
518
|
+
const baseUrl = String(config.app_url || options.appUrl || DEFAULT_APP_URL).replace(/\/$/, '');
|
|
519
|
+
const machineApiKey = config.machine_api_key;
|
|
520
|
+
if (!machineApiKey) {
|
|
521
|
+
throw new Error('machine_not_initialized');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const eventType = normalizeEventType(options.type || 'task_completed');
|
|
525
|
+
const severity = normalizeSeverity(options.severity, defaultSeverityForType(eventType));
|
|
526
|
+
const message = String(options.message || '').trim();
|
|
527
|
+
if (!message) {
|
|
528
|
+
throw new Error('message_required');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const repo = options.repo ? String(options.repo).trim() : getRepoFromGit();
|
|
532
|
+
const branch = options.branch ? String(options.branch).trim() : getBranchFromGit();
|
|
533
|
+
const agent = options.agent ? String(options.agent).trim() : 'manual-cli';
|
|
534
|
+
|
|
535
|
+
const result = await sendEvent({
|
|
536
|
+
apiFetch,
|
|
537
|
+
baseUrl,
|
|
538
|
+
machineApiKey,
|
|
539
|
+
event: {
|
|
540
|
+
message: message.slice(0, 2000),
|
|
541
|
+
event_type: eventType,
|
|
542
|
+
repo,
|
|
543
|
+
branch,
|
|
544
|
+
severity,
|
|
545
|
+
context: {
|
|
546
|
+
cwd_name: path.basename(process.cwd()),
|
|
547
|
+
git_repo: repo,
|
|
548
|
+
machine_name: config.machine_name || getMachineName(),
|
|
549
|
+
agent_label: agent
|
|
550
|
+
},
|
|
551
|
+
agent
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
console.log(
|
|
556
|
+
JSON.stringify(
|
|
557
|
+
{
|
|
558
|
+
event_id: result.eventId,
|
|
559
|
+
decision: result.decision
|
|
560
|
+
},
|
|
561
|
+
null,
|
|
562
|
+
2
|
|
563
|
+
)
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function runStatus(eventId, options) {
|
|
568
|
+
const config = await readConfig();
|
|
569
|
+
const baseUrl = String(config.app_url || options.appUrl || DEFAULT_APP_URL).replace(/\/$/, '');
|
|
570
|
+
const machineApiKey = config.machine_api_key;
|
|
571
|
+
if (!machineApiKey) {
|
|
572
|
+
throw new Error('machine_not_initialized');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const id = String(eventId || '').trim();
|
|
576
|
+
if (!id) {
|
|
577
|
+
throw new Error('event_id_required');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const decision = await getEventDecision({apiFetch, baseUrl, machineApiKey, eventId: id});
|
|
581
|
+
const answer = await getEventAnswer({apiFetch, baseUrl, machineApiKey, eventId: id});
|
|
582
|
+
|
|
583
|
+
console.log(
|
|
584
|
+
JSON.stringify(
|
|
585
|
+
{
|
|
586
|
+
event_id: id,
|
|
587
|
+
decision,
|
|
588
|
+
answer
|
|
589
|
+
},
|
|
590
|
+
null,
|
|
591
|
+
2
|
|
592
|
+
)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function runLogout() {
|
|
597
|
+
const config = await readConfig();
|
|
598
|
+
const next = {...config};
|
|
599
|
+
delete next.access_token;
|
|
600
|
+
delete next.machine_id;
|
|
601
|
+
delete next.machine_api_key;
|
|
602
|
+
delete next.provider;
|
|
603
|
+
delete next.provider_config;
|
|
604
|
+
await writeConfig(next);
|
|
605
|
+
console.log('Logged out.');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const program = new Command();
|
|
609
|
+
|
|
610
|
+
program.name('runsignal').description('RunSignal CLI').version('0.1.0');
|
|
611
|
+
|
|
612
|
+
program
|
|
613
|
+
.command('login')
|
|
614
|
+
.option('--app-url <url>', 'RunSignal app base URL')
|
|
615
|
+
.action(async (options) => {
|
|
616
|
+
await runLogin(options);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
program
|
|
620
|
+
.command('init')
|
|
621
|
+
.option('--provider <provider>', 'telegram or slack')
|
|
622
|
+
.option('--app-url <url>', 'RunSignal app base URL')
|
|
623
|
+
.option('--machine-name <name>', 'Machine name override')
|
|
624
|
+
.option('--bot-token <token>', 'Telegram bot token')
|
|
625
|
+
.option('--chat-id <id>', 'Telegram chat id')
|
|
626
|
+
.option('--webhook-url <url>', 'Slack webhook URL')
|
|
627
|
+
.action(async (options) => {
|
|
628
|
+
await runInit(options);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
program
|
|
632
|
+
.command('wrap')
|
|
633
|
+
.argument('<command...>', 'Command to wrap and monitor')
|
|
634
|
+
.action(async (commandParts) => {
|
|
635
|
+
await runWrap(commandParts);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
program.command('test').action(async () => {
|
|
639
|
+
await runTest();
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
program
|
|
643
|
+
.command('send')
|
|
644
|
+
.requiredOption('--message <message>', 'Event message')
|
|
645
|
+
.option('--type <type>', 'Event type', 'task_completed')
|
|
646
|
+
.option('--severity <severity>', 'info|warning|critical')
|
|
647
|
+
.option('--repo <repo>', 'Repository name override')
|
|
648
|
+
.option('--branch <branch>', 'Git branch override')
|
|
649
|
+
.option('--agent <agent>', 'Agent label override')
|
|
650
|
+
.option('--app-url <url>', 'RunSignal app base URL')
|
|
651
|
+
.action(async (options) => {
|
|
652
|
+
await runSend(options);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
program
|
|
656
|
+
.command('status')
|
|
657
|
+
.argument('<eventId>', 'Event identifier')
|
|
658
|
+
.option('--app-url <url>', 'RunSignal app base URL')
|
|
659
|
+
.action(async (eventId, options) => {
|
|
660
|
+
await runStatus(eventId, options);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
program.command('logout').action(async () => {
|
|
664
|
+
await runLogout();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
668
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
669
|
+
console.error(`RunSignal error: ${toUserMessage(message)}`);
|
|
670
|
+
process.exit(1);
|
|
671
|
+
});
|
package/package.json
CHANGED
|
@@ -1,10 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runsignal",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "RunSignal CLI agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RunSignal CLI for wrapping AI agent runs and handling approvals",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://runsignal.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/gdamien33/runsignal.git",
|
|
11
|
+
"directory": "apps/cli"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/gdamien33/runsignal/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"runsignal",
|
|
18
|
+
"cli",
|
|
19
|
+
"ai",
|
|
20
|
+
"agent",
|
|
21
|
+
"devtools",
|
|
22
|
+
"monitoring"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.18.0"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"src",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
5
36
|
"bin": {
|
|
6
37
|
"runsignal": "bin/runsignal.js"
|
|
7
38
|
},
|
|
8
|
-
"
|
|
9
|
-
|
|
39
|
+
"scripts": {
|
|
40
|
+
"start": "node bin/runsignal.js --help",
|
|
41
|
+
"test:smoke": "node test/e2e-smoke.mjs",
|
|
42
|
+
"pack:check": "npm pack --dry-run",
|
|
43
|
+
"prepublishOnly": "npm run test:smoke"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"commander": "^14.0.1"
|
|
47
|
+
}
|
|
10
48
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
function normalizeEventResponse(payload) {
|
|
2
|
+
if (!payload || typeof payload !== 'object') return { eventId: null, decision: 'pending' };
|
|
3
|
+
|
|
4
|
+
if (payload.data && typeof payload.data === 'object') {
|
|
5
|
+
return {
|
|
6
|
+
eventId: payload.data.event_id || null,
|
|
7
|
+
decision: payload.data.decision || 'pending'
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
eventId: payload.event_id || null,
|
|
13
|
+
decision: payload.decision || 'pending'
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function sendEvent({ apiFetch, baseUrl, machineApiKey, event }) {
|
|
18
|
+
const { ok, status, payload } = await apiFetch(baseUrl, '/api/events', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { authorization: `Bearer ${machineApiKey}` },
|
|
21
|
+
body: JSON.stringify(event)
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!ok) {
|
|
25
|
+
const errorMessage = payload?.error?.message || payload?.error || `http_${status}`;
|
|
26
|
+
throw new Error(`send_event_failed:${errorMessage}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return normalizeEventResponse(payload);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getEventDecision({ apiFetch, baseUrl, machineApiKey, eventId }) {
|
|
33
|
+
const response = await apiFetch(
|
|
34
|
+
baseUrl,
|
|
35
|
+
`/api/cli/events/${encodeURIComponent(eventId)}/decision`,
|
|
36
|
+
{
|
|
37
|
+
method: 'GET',
|
|
38
|
+
headers: { authorization: `Bearer ${machineApiKey}` }
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`status_failed:${response.payload?.error || response.status}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return response.payload?.decision || 'pending';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function getEventAnswer({ apiFetch, baseUrl, machineApiKey, eventId }) {
|
|
50
|
+
const response = await apiFetch(
|
|
51
|
+
baseUrl,
|
|
52
|
+
`/api/cli/events/${encodeURIComponent(eventId)}/answer`,
|
|
53
|
+
{
|
|
54
|
+
method: 'GET',
|
|
55
|
+
headers: { authorization: `Bearer ${machineApiKey}` }
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return response.payload?.answer ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function pollDecision({
|
|
66
|
+
apiFetch,
|
|
67
|
+
baseUrl,
|
|
68
|
+
machineApiKey,
|
|
69
|
+
eventId,
|
|
70
|
+
sleep,
|
|
71
|
+
timeoutMs = 5 * 60 * 1000
|
|
72
|
+
}) {
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
75
|
+
const decision = await getEventDecision({ apiFetch, baseUrl, machineApiKey, eventId }).catch(
|
|
76
|
+
() => 'pending'
|
|
77
|
+
);
|
|
78
|
+
if (decision !== 'pending') {
|
|
79
|
+
return decision;
|
|
80
|
+
}
|
|
81
|
+
await sleep(2000);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return 'pending';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function pollAnswer({
|
|
88
|
+
apiFetch,
|
|
89
|
+
baseUrl,
|
|
90
|
+
machineApiKey,
|
|
91
|
+
eventId,
|
|
92
|
+
sleep,
|
|
93
|
+
timeoutMs = 5 * 60 * 1000
|
|
94
|
+
}) {
|
|
95
|
+
const startedAt = Date.now();
|
|
96
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
97
|
+
const answer = await getEventAnswer({ apiFetch, baseUrl, machineApiKey, eventId }).catch(
|
|
98
|
+
() => null
|
|
99
|
+
);
|
|
100
|
+
if (typeof answer === 'string' && answer.trim()) {
|
|
101
|
+
return answer.trim();
|
|
102
|
+
}
|
|
103
|
+
await sleep(2000);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|