runsignal 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +20 -3
  2. package/bin/runsignal.js +285 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -19,9 +19,12 @@ npx runsignal --help
19
19
  - `runsignal login`
20
20
  - `runsignal init`
21
21
  - `runsignal wrap -- <command>`
22
- - `runsignal send --message "..."`
23
- - `runsignal status <eventId>`
24
- - `runsignal logout`
22
+ - `runsignal send --message "..."`
23
+ - `runsignal status <eventId>`
24
+ - `runsignal logout`
25
+ - `runsignal hooks install --shell powershell`
26
+ - `runsignal hooks doctor --shell powershell`
27
+ - `runsignal hooks uninstall --shell powershell`
25
28
 
26
29
  ## Quickstart
27
30
 
@@ -34,4 +37,18 @@ npx runsignal@latest wrap -- claude "refactor auth flow"
34
37
  ## Environment
35
38
 
36
39
  - `RUNSIGNAL_APP_URL` to override app URL (default: `https://runsignal.dev`)
40
+
41
+ ## Automatic Wrapping
42
+
43
+ To keep using `claude ...` / `codex ...` normally while routing through RunSignal:
44
+
45
+ ```bash
46
+ runsignal hooks install --shell powershell
47
+ ```
48
+
49
+ Then restart terminal and verify:
50
+
51
+ ```bash
52
+ runsignal hooks doctor --shell powershell
53
+ ```
37
54
 
package/bin/runsignal.js CHANGED
@@ -40,6 +40,8 @@ function toUserMessage(errorCode) {
40
40
  if (code.includes('message_required')) return 'Message is required. Use --message "..."';
41
41
  if (code.includes('event_id_required')) return 'Event id is required. Usage: runsignal status <eventId>';
42
42
  if (code.includes('init_failed')) return 'Initialization failed. Check token/provider config and try again.';
43
+ if (code.includes('hooks_shell_not_supported'))
44
+ return 'Hooks shell not supported. Use --shell powershell|bash|zsh|all.';
43
45
  return code || 'Unknown error';
44
46
  }
45
47
 
@@ -98,6 +100,226 @@ function sleep(ms) {
98
100
  return new Promise((resolve) => setTimeout(resolve, ms));
99
101
  }
100
102
 
103
+ const HOOK_START = '# >>> RunSignal hooks start >>>';
104
+ const HOOK_END = '# <<< RunSignal hooks end <<<';
105
+
106
+ function stripHookBlock(content) {
107
+ const start = content.indexOf(HOOK_START);
108
+ const end = content.indexOf(HOOK_END);
109
+ if (start === -1 || end === -1 || end < start) {
110
+ return content;
111
+ }
112
+ const before = content.slice(0, start).trimEnd();
113
+ const after = content.slice(end + HOOK_END.length).trimStart();
114
+ if (!before && !after) return '';
115
+ if (!before) return `${after}\n`;
116
+ if (!after) return `${before}\n`;
117
+ return `${before}\n\n${after}\n`;
118
+ }
119
+
120
+ function buildHookBlock(shellKind) {
121
+ if (shellKind === 'powershell') {
122
+ return `${HOOK_START}
123
+ function RunSignal-Resolve-NativeCommand {
124
+ param([string]$Name)
125
+ $cmd = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
126
+ if (-not $cmd) { return $null }
127
+ return $cmd.Source
128
+ }
129
+ function claude {
130
+ $native = RunSignal-Resolve-NativeCommand "claude"
131
+ if (-not $native) { Write-Error "RunSignal hook: native claude executable not found."; return }
132
+ if ($args.Count -eq 0) { & $native; return }
133
+ runsignal wrap -- $native @args
134
+ }
135
+ function codex {
136
+ $native = RunSignal-Resolve-NativeCommand "codex"
137
+ if (-not $native) { Write-Error "RunSignal hook: native codex executable not found."; return }
138
+ if ($args.Count -eq 0) { & $native; return }
139
+ runsignal wrap -- $native @args
140
+ }
141
+ function cursor {
142
+ $native = RunSignal-Resolve-NativeCommand "cursor"
143
+ if (-not $native) { Write-Error "RunSignal hook: native cursor executable not found."; return }
144
+ if ($args.Count -eq 0) { & $native; return }
145
+ runsignal wrap -- $native @args
146
+ }
147
+ function gemini {
148
+ $native = RunSignal-Resolve-NativeCommand "gemini"
149
+ if (-not $native) { Write-Error "RunSignal hook: native gemini executable not found."; return }
150
+ if ($args.Count -eq 0) { & $native; return }
151
+ runsignal wrap -- $native @args
152
+ }
153
+ function aider {
154
+ $native = RunSignal-Resolve-NativeCommand "aider"
155
+ if (-not $native) { Write-Error "RunSignal hook: native aider executable not found."; return }
156
+ if ($args.Count -eq 0) { & $native; return }
157
+ runsignal wrap -- $native @args
158
+ }
159
+ ${HOOK_END}
160
+ `;
161
+ }
162
+
163
+ return `${HOOK_START}
164
+ runsignal_resolve_native() {
165
+ type -P "$1" 2>/dev/null
166
+ }
167
+ claude() {
168
+ local native
169
+ native="$(runsignal_resolve_native claude)"
170
+ if [ -z "$native" ]; then echo "RunSignal hook: native claude executable not found." >&2; return 127; fi
171
+ if [ "$#" -eq 0 ]; then "$native"; return $?; fi
172
+ runsignal wrap -- "$native" "$@"
173
+ }
174
+ codex() {
175
+ local native
176
+ native="$(runsignal_resolve_native codex)"
177
+ if [ -z "$native" ]; then echo "RunSignal hook: native codex executable not found." >&2; return 127; fi
178
+ if [ "$#" -eq 0 ]; then "$native"; return $?; fi
179
+ runsignal wrap -- "$native" "$@"
180
+ }
181
+ cursor() {
182
+ local native
183
+ native="$(runsignal_resolve_native cursor)"
184
+ if [ -z "$native" ]; then echo "RunSignal hook: native cursor executable not found." >&2; return 127; fi
185
+ if [ "$#" -eq 0 ]; then "$native"; return $?; fi
186
+ runsignal wrap -- "$native" "$@"
187
+ }
188
+ gemini() {
189
+ local native
190
+ native="$(runsignal_resolve_native gemini)"
191
+ if [ -z "$native" ]; then echo "RunSignal hook: native gemini executable not found." >&2; return 127; fi
192
+ if [ "$#" -eq 0 ]; then "$native"; return $?; fi
193
+ runsignal wrap -- "$native" "$@"
194
+ }
195
+ aider() {
196
+ local native
197
+ native="$(runsignal_resolve_native aider)"
198
+ if [ -z "$native" ]; then echo "RunSignal hook: native aider executable not found." >&2; return 127; fi
199
+ if [ "$#" -eq 0 ]; then "$native"; return $?; fi
200
+ runsignal wrap -- "$native" "$@"
201
+ }
202
+ ${HOOK_END}
203
+ `;
204
+ }
205
+
206
+ function resolveHookTargets(shellOption) {
207
+ const shell = String(shellOption || 'auto').toLowerCase();
208
+ const home = os.homedir();
209
+
210
+ if (process.platform === 'win32') {
211
+ const powershellTargets = [
212
+ {
213
+ path: path.join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
214
+ kind: 'powershell'
215
+ },
216
+ {
217
+ path: path.join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),
218
+ kind: 'powershell'
219
+ }
220
+ ];
221
+ if (shell === 'auto' || shell === 'powershell' || shell === 'all') {
222
+ return powershellTargets;
223
+ }
224
+ return [];
225
+ }
226
+
227
+ if (shell === 'zsh') return [{path: path.join(home, '.zshrc'), kind: 'posix'}];
228
+ if (shell === 'bash') return [{path: path.join(home, '.bashrc'), kind: 'posix'}];
229
+ if (shell === 'all') {
230
+ return [
231
+ {path: path.join(home, '.bashrc'), kind: 'posix'},
232
+ {path: path.join(home, '.zshrc'), kind: 'posix'}
233
+ ];
234
+ }
235
+ if (shell === 'auto') {
236
+ const shellEnv = String(process.env.SHELL || '').toLowerCase();
237
+ if (shellEnv.includes('zsh')) return [{path: path.join(home, '.zshrc'), kind: 'posix'}];
238
+ return [{path: path.join(home, '.bashrc'), kind: 'posix'}];
239
+ }
240
+
241
+ return [];
242
+ }
243
+
244
+ async function readTextFile(filePath) {
245
+ try {
246
+ return await fs.readFile(filePath, 'utf8');
247
+ } catch {
248
+ return '';
249
+ }
250
+ }
251
+
252
+ async function runHooksInstall(options) {
253
+ const targets = resolveHookTargets(options.shell);
254
+ if (!targets.length) {
255
+ throw new Error('hooks_shell_not_supported');
256
+ }
257
+
258
+ for (const target of targets) {
259
+ const existing = await readTextFile(target.path);
260
+ const cleaned = stripHookBlock(existing).trimEnd();
261
+ const block = buildHookBlock(target.kind);
262
+ const next = cleaned ? `${cleaned}\n\n${block}` : block;
263
+ await fs.mkdir(path.dirname(target.path), {recursive: true});
264
+ await fs.writeFile(target.path, next, 'utf8');
265
+ console.log(`Hooks installed: ${target.path}`);
266
+ }
267
+
268
+ console.log('Restart your terminal to apply hooks.');
269
+ }
270
+
271
+ async function runHooksUninstall(options) {
272
+ const targets = resolveHookTargets(options.shell);
273
+ if (!targets.length) {
274
+ throw new Error('hooks_shell_not_supported');
275
+ }
276
+
277
+ for (const target of targets) {
278
+ const existing = await readTextFile(target.path);
279
+ if (!existing) {
280
+ console.log(`Hooks not present: ${target.path}`);
281
+ continue;
282
+ }
283
+ const next = stripHookBlock(existing);
284
+ if (next === existing) {
285
+ console.log(`Hooks not present: ${target.path}`);
286
+ continue;
287
+ }
288
+ await fs.writeFile(target.path, next, 'utf8');
289
+ console.log(`Hooks removed: ${target.path}`);
290
+ }
291
+ }
292
+
293
+ async function runHooksDoctor(options) {
294
+ const targets = resolveHookTargets(options.shell);
295
+ if (!targets.length) {
296
+ throw new Error('hooks_shell_not_supported');
297
+ }
298
+
299
+ let healthy = true;
300
+ for (const target of targets) {
301
+ const existing = await readTextFile(target.path);
302
+ const hasHooks = existing.includes(HOOK_START) && existing.includes(HOOK_END);
303
+ if (!hasHooks) {
304
+ healthy = false;
305
+ }
306
+ console.log(
307
+ JSON.stringify(
308
+ {
309
+ path: target.path,
310
+ installed: hasHooks
311
+ },
312
+ null,
313
+ 2
314
+ )
315
+ );
316
+ }
317
+
318
+ if (!healthy) {
319
+ process.exitCode = 1;
320
+ }
321
+ }
322
+
101
323
  function getMachineName() {
102
324
  return process.env.COMPUTERNAME || os.hostname() || 'machine';
103
325
  }
@@ -171,6 +393,18 @@ function extractInterestingLine(chunk) {
171
393
  return null;
172
394
  }
173
395
 
396
+ function quoteForShell(arg) {
397
+ const value = String(arg ?? '');
398
+ if (value.length === 0) {
399
+ return '""';
400
+ }
401
+ if (!/[\s"]/g.test(value)) {
402
+ return value;
403
+ }
404
+ const escaped = value.replace(/(["\\$`])/g, '\\$1');
405
+ return `"${escaped}"`;
406
+ }
407
+
174
408
  async function runLogin(options) {
175
409
  const config = await readConfig();
176
410
  const baseUrl = String(options.appUrl || config.app_url || DEFAULT_APP_URL).replace(/\/$/, '');
@@ -325,9 +559,14 @@ async function runWrap(commandParts) {
325
559
  agent_label: agentLabel
326
560
  };
327
561
 
328
- const commandString = commandParts.join(' ');
562
+ const commandString =
563
+ commandParts.length === 1
564
+ ? String(commandParts[0])
565
+ : commandParts.map((part) => quoteForShell(part)).join(' ');
566
+ const interactivePassthrough =
567
+ commandParts.length === 1 && !/[\s"'`]/.test(String(commandParts[0]));
329
568
  const child = spawn(commandString, {
330
- stdio: ['inherit', 'pipe', 'pipe'],
569
+ stdio: interactivePassthrough ? 'inherit' : ['inherit', 'pipe', 'pipe'],
331
570
  shell: true,
332
571
  env: process.env
333
572
  });
@@ -335,6 +574,12 @@ async function runWrap(commandParts) {
335
574
  let sawApproval = false;
336
575
  let sawQuestion = false;
337
576
  let lastInterestingLine = '';
577
+ const pendingTasks = new Set();
578
+
579
+ const queueTask = (promise) => {
580
+ pendingTasks.add(promise);
581
+ promise.finally(() => pendingTasks.delete(promise));
582
+ };
338
583
 
339
584
  const handleChunk = async (chunkRaw) => {
340
585
  const chunk = chunkRaw.toString('utf8');
@@ -412,18 +657,21 @@ async function runWrap(commandParts) {
412
657
  }
413
658
  };
414
659
 
415
- child.stdout.on('data', (chunk) => {
416
- process.stdout.write(chunk);
417
- void handleChunk(chunk);
418
- });
419
- child.stderr.on('data', (chunk) => {
420
- process.stderr.write(chunk);
421
- void handleChunk(chunk);
422
- });
660
+ if (!interactivePassthrough) {
661
+ child.stdout.on('data', (chunk) => {
662
+ process.stdout.write(chunk);
663
+ queueTask(handleChunk(chunk));
664
+ });
665
+ child.stderr.on('data', (chunk) => {
666
+ process.stderr.write(chunk);
667
+ queueTask(handleChunk(chunk));
668
+ });
669
+ }
423
670
 
424
671
  const exitCode = await new Promise((resolve) => {
425
672
  child.on('close', (code) => resolve(code ?? 1));
426
673
  });
674
+ await Promise.allSettled(Array.from(pendingTasks));
427
675
 
428
676
  const completionMessage =
429
677
  exitCode === 0
@@ -449,7 +697,9 @@ async function runWrap(commandParts) {
449
697
  // no-op
450
698
  }
451
699
 
452
- process.exit(exitCode);
700
+ // Avoid hard process exit on Windows: let Node close handles cleanly.
701
+ process.exitCode = exitCode;
702
+ return;
453
703
  }
454
704
 
455
705
  async function runTest() {
@@ -609,7 +859,7 @@ async function runLogout() {
609
859
 
610
860
  const program = new Command();
611
861
 
612
- program.name('runsignal').description('RunSignal CLI').version('0.1.2');
862
+ program.name('runsignal').description('RunSignal CLI').version('0.1.4');
613
863
 
614
864
  program
615
865
  .command('login')
@@ -666,6 +916,29 @@ program.command('logout').action(async () => {
666
916
  await runLogout();
667
917
  });
668
918
 
919
+ const hooks = program.command('hooks').description('Manage shell hooks for automatic wrapping');
920
+
921
+ hooks
922
+ .command('install')
923
+ .option('--shell <shell>', 'auto|powershell|bash|zsh|all', 'auto')
924
+ .action(async (options) => {
925
+ await runHooksInstall(options);
926
+ });
927
+
928
+ hooks
929
+ .command('uninstall')
930
+ .option('--shell <shell>', 'auto|powershell|bash|zsh|all', 'auto')
931
+ .action(async (options) => {
932
+ await runHooksUninstall(options);
933
+ });
934
+
935
+ hooks
936
+ .command('doctor')
937
+ .option('--shell <shell>', 'auto|powershell|bash|zsh|all', 'auto')
938
+ .action(async (options) => {
939
+ await runHooksDoctor(options);
940
+ });
941
+
669
942
  program.parseAsync(process.argv).catch((error) => {
670
943
  const message = error instanceof Error ? error.message : String(error);
671
944
  console.error(`RunSignal error: ${toUserMessage(message)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runsignal",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "RunSignal CLI for wrapping AI agent runs and handling approvals",
5
5
  "type": "module",
6
6
  "license": "MIT",