runsignal 0.1.2 → 0.1.3

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 +221 -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,162 @@ 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 claude { runsignal wrap -- claude @args }
124
+ function codex { runsignal wrap -- codex @args }
125
+ function cursor { runsignal wrap -- cursor @args }
126
+ function gemini { runsignal wrap -- gemini @args }
127
+ function aider { runsignal wrap -- aider @args }
128
+ ${HOOK_END}
129
+ `;
130
+ }
131
+
132
+ return `${HOOK_START}
133
+ claude() { runsignal wrap -- claude "$@"; }
134
+ codex() { runsignal wrap -- codex "$@"; }
135
+ cursor() { runsignal wrap -- cursor "$@"; }
136
+ gemini() { runsignal wrap -- gemini "$@"; }
137
+ aider() { runsignal wrap -- aider "$@"; }
138
+ ${HOOK_END}
139
+ `;
140
+ }
141
+
142
+ function resolveHookTargets(shellOption) {
143
+ const shell = String(shellOption || 'auto').toLowerCase();
144
+ const home = os.homedir();
145
+
146
+ if (process.platform === 'win32') {
147
+ const powershellTargets = [
148
+ {
149
+ path: path.join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
150
+ kind: 'powershell'
151
+ },
152
+ {
153
+ path: path.join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),
154
+ kind: 'powershell'
155
+ }
156
+ ];
157
+ if (shell === 'auto' || shell === 'powershell' || shell === 'all') {
158
+ return powershellTargets;
159
+ }
160
+ return [];
161
+ }
162
+
163
+ if (shell === 'zsh') return [{path: path.join(home, '.zshrc'), kind: 'posix'}];
164
+ if (shell === 'bash') return [{path: path.join(home, '.bashrc'), kind: 'posix'}];
165
+ if (shell === 'all') {
166
+ return [
167
+ {path: path.join(home, '.bashrc'), kind: 'posix'},
168
+ {path: path.join(home, '.zshrc'), kind: 'posix'}
169
+ ];
170
+ }
171
+ if (shell === 'auto') {
172
+ const shellEnv = String(process.env.SHELL || '').toLowerCase();
173
+ if (shellEnv.includes('zsh')) return [{path: path.join(home, '.zshrc'), kind: 'posix'}];
174
+ return [{path: path.join(home, '.bashrc'), kind: 'posix'}];
175
+ }
176
+
177
+ return [];
178
+ }
179
+
180
+ async function readTextFile(filePath) {
181
+ try {
182
+ return await fs.readFile(filePath, 'utf8');
183
+ } catch {
184
+ return '';
185
+ }
186
+ }
187
+
188
+ async function runHooksInstall(options) {
189
+ const targets = resolveHookTargets(options.shell);
190
+ if (!targets.length) {
191
+ throw new Error('hooks_shell_not_supported');
192
+ }
193
+
194
+ for (const target of targets) {
195
+ const existing = await readTextFile(target.path);
196
+ const cleaned = stripHookBlock(existing).trimEnd();
197
+ const block = buildHookBlock(target.kind);
198
+ const next = cleaned ? `${cleaned}\n\n${block}` : block;
199
+ await fs.mkdir(path.dirname(target.path), {recursive: true});
200
+ await fs.writeFile(target.path, next, 'utf8');
201
+ console.log(`Hooks installed: ${target.path}`);
202
+ }
203
+
204
+ console.log('Restart your terminal to apply hooks.');
205
+ }
206
+
207
+ async function runHooksUninstall(options) {
208
+ const targets = resolveHookTargets(options.shell);
209
+ if (!targets.length) {
210
+ throw new Error('hooks_shell_not_supported');
211
+ }
212
+
213
+ for (const target of targets) {
214
+ const existing = await readTextFile(target.path);
215
+ if (!existing) {
216
+ console.log(`Hooks not present: ${target.path}`);
217
+ continue;
218
+ }
219
+ const next = stripHookBlock(existing);
220
+ if (next === existing) {
221
+ console.log(`Hooks not present: ${target.path}`);
222
+ continue;
223
+ }
224
+ await fs.writeFile(target.path, next, 'utf8');
225
+ console.log(`Hooks removed: ${target.path}`);
226
+ }
227
+ }
228
+
229
+ async function runHooksDoctor(options) {
230
+ const targets = resolveHookTargets(options.shell);
231
+ if (!targets.length) {
232
+ throw new Error('hooks_shell_not_supported');
233
+ }
234
+
235
+ let healthy = true;
236
+ for (const target of targets) {
237
+ const existing = await readTextFile(target.path);
238
+ const hasHooks = existing.includes(HOOK_START) && existing.includes(HOOK_END);
239
+ if (!hasHooks) {
240
+ healthy = false;
241
+ }
242
+ console.log(
243
+ JSON.stringify(
244
+ {
245
+ path: target.path,
246
+ installed: hasHooks
247
+ },
248
+ null,
249
+ 2
250
+ )
251
+ );
252
+ }
253
+
254
+ if (!healthy) {
255
+ process.exitCode = 1;
256
+ }
257
+ }
258
+
101
259
  function getMachineName() {
102
260
  return process.env.COMPUTERNAME || os.hostname() || 'machine';
103
261
  }
@@ -171,6 +329,18 @@ function extractInterestingLine(chunk) {
171
329
  return null;
172
330
  }
173
331
 
332
+ function quoteForShell(arg) {
333
+ const value = String(arg ?? '');
334
+ if (value.length === 0) {
335
+ return '""';
336
+ }
337
+ if (!/[\s"]/g.test(value)) {
338
+ return value;
339
+ }
340
+ const escaped = value.replace(/(["\\$`])/g, '\\$1');
341
+ return `"${escaped}"`;
342
+ }
343
+
174
344
  async function runLogin(options) {
175
345
  const config = await readConfig();
176
346
  const baseUrl = String(options.appUrl || config.app_url || DEFAULT_APP_URL).replace(/\/$/, '');
@@ -325,9 +495,14 @@ async function runWrap(commandParts) {
325
495
  agent_label: agentLabel
326
496
  };
327
497
 
328
- const commandString = commandParts.join(' ');
498
+ const commandString =
499
+ commandParts.length === 1
500
+ ? String(commandParts[0])
501
+ : commandParts.map((part) => quoteForShell(part)).join(' ');
502
+ const interactivePassthrough =
503
+ commandParts.length === 1 && !/[\s"'`]/.test(String(commandParts[0]));
329
504
  const child = spawn(commandString, {
330
- stdio: ['inherit', 'pipe', 'pipe'],
505
+ stdio: interactivePassthrough ? 'inherit' : ['inherit', 'pipe', 'pipe'],
331
506
  shell: true,
332
507
  env: process.env
333
508
  });
@@ -335,6 +510,12 @@ async function runWrap(commandParts) {
335
510
  let sawApproval = false;
336
511
  let sawQuestion = false;
337
512
  let lastInterestingLine = '';
513
+ const pendingTasks = new Set();
514
+
515
+ const queueTask = (promise) => {
516
+ pendingTasks.add(promise);
517
+ promise.finally(() => pendingTasks.delete(promise));
518
+ };
338
519
 
339
520
  const handleChunk = async (chunkRaw) => {
340
521
  const chunk = chunkRaw.toString('utf8');
@@ -412,18 +593,21 @@ async function runWrap(commandParts) {
412
593
  }
413
594
  };
414
595
 
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
- });
596
+ if (!interactivePassthrough) {
597
+ child.stdout.on('data', (chunk) => {
598
+ process.stdout.write(chunk);
599
+ queueTask(handleChunk(chunk));
600
+ });
601
+ child.stderr.on('data', (chunk) => {
602
+ process.stderr.write(chunk);
603
+ queueTask(handleChunk(chunk));
604
+ });
605
+ }
423
606
 
424
607
  const exitCode = await new Promise((resolve) => {
425
608
  child.on('close', (code) => resolve(code ?? 1));
426
609
  });
610
+ await Promise.allSettled(Array.from(pendingTasks));
427
611
 
428
612
  const completionMessage =
429
613
  exitCode === 0
@@ -449,7 +633,9 @@ async function runWrap(commandParts) {
449
633
  // no-op
450
634
  }
451
635
 
452
- process.exit(exitCode);
636
+ // Avoid hard process exit on Windows: let Node close handles cleanly.
637
+ process.exitCode = exitCode;
638
+ return;
453
639
  }
454
640
 
455
641
  async function runTest() {
@@ -609,7 +795,7 @@ async function runLogout() {
609
795
 
610
796
  const program = new Command();
611
797
 
612
- program.name('runsignal').description('RunSignal CLI').version('0.1.2');
798
+ program.name('runsignal').description('RunSignal CLI').version('0.1.3');
613
799
 
614
800
  program
615
801
  .command('login')
@@ -666,6 +852,29 @@ program.command('logout').action(async () => {
666
852
  await runLogout();
667
853
  });
668
854
 
855
+ const hooks = program.command('hooks').description('Manage shell hooks for automatic wrapping');
856
+
857
+ hooks
858
+ .command('install')
859
+ .option('--shell <shell>', 'auto|powershell|bash|zsh|all', 'auto')
860
+ .action(async (options) => {
861
+ await runHooksInstall(options);
862
+ });
863
+
864
+ hooks
865
+ .command('uninstall')
866
+ .option('--shell <shell>', 'auto|powershell|bash|zsh|all', 'auto')
867
+ .action(async (options) => {
868
+ await runHooksUninstall(options);
869
+ });
870
+
871
+ hooks
872
+ .command('doctor')
873
+ .option('--shell <shell>', 'auto|powershell|bash|zsh|all', 'auto')
874
+ .action(async (options) => {
875
+ await runHooksDoctor(options);
876
+ });
877
+
669
878
  program.parseAsync(process.argv).catch((error) => {
670
879
  const message = error instanceof Error ? error.message : String(error);
671
880
  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.3",
4
4
  "description": "RunSignal CLI for wrapping AI agent runs and handling approvals",
5
5
  "type": "module",
6
6
  "license": "MIT",