runsignal 0.1.4 → 0.1.6

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 +26 -10
  2. package/bin/runsignal.js +212 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,22 +16,31 @@ npx runsignal --help
16
16
 
17
17
  ## Commands
18
18
 
19
- - `runsignal login`
19
+ - `runsignal login`
20
20
  - `runsignal init`
21
- - `runsignal wrap -- <command>`
21
+ - `runsignal setup`
22
+ - `runsignal wrap -- <command>`
22
23
  - `runsignal send --message "..."`
23
24
  - `runsignal status <eventId>`
24
25
  - `runsignal logout`
25
- - `runsignal hooks install --shell powershell`
26
- - `runsignal hooks doctor --shell powershell`
27
- - `runsignal hooks uninstall --shell powershell`
26
+ - `runsignal hooks install`
27
+ - `runsignal hooks doctor`
28
+ - `runsignal hooks uninstall`
28
29
 
29
30
  ## Quickstart
30
31
 
32
+ Canonical zero-friction onboarding:
33
+
34
+ ```bash
35
+ npx --yes runsignal@latest setup
36
+ ```
37
+
38
+ Equivalent manual flow:
39
+
31
40
  ```bash
32
- npx runsignal@latest login
33
- npx runsignal@latest init
34
- npx runsignal@latest wrap -- claude "refactor auth flow"
41
+ npx --yes runsignal@latest login
42
+ npx --yes runsignal@latest init
43
+ npx --yes runsignal@latest hooks install
35
44
  ```
36
45
 
37
46
  ## Environment
@@ -43,12 +52,19 @@ npx runsignal@latest wrap -- claude "refactor auth flow"
43
52
  To keep using `claude ...` / `codex ...` normally while routing through RunSignal:
44
53
 
45
54
  ```bash
46
- runsignal hooks install --shell powershell
55
+ runsignal hooks install
47
56
  ```
48
57
 
49
58
  Then restart terminal and verify:
50
59
 
51
60
  ```bash
52
- runsignal hooks doctor --shell powershell
61
+ runsignal hooks doctor
62
+ ```
63
+
64
+ After that, use your CLI agents normally:
65
+
66
+ ```bash
67
+ claude
68
+ codex
53
69
  ```
54
70
 
package/bin/runsignal.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import {Command} from 'commander';
4
4
  import {spawn, execSync} from 'node:child_process';
5
5
  import {promises as fs} from 'node:fs';
6
+ import {existsSync} from 'node:fs';
6
7
  import os from 'node:os';
7
8
  import path from 'node:path';
8
9
  import {
@@ -122,38 +123,44 @@ function buildHookBlock(shellKind) {
122
123
  return `${HOOK_START}
123
124
  function RunSignal-Resolve-NativeCommand {
124
125
  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
126
+ $candidates = @(Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue)
127
+ if (-not $candidates -or $candidates.Count -eq 0) { return $null }
128
+ $preferred = $candidates | Where-Object { $_.Source -match '\\.exe$' } | Select-Object -First 1
129
+ if ($preferred) { return $preferred.Source }
130
+ $launcher = $candidates | Where-Object { $_.Source -match '\\.(cmd|bat)$' } | Select-Object -First 1
131
+ if ($launcher) { return $launcher.Source }
132
+ $secondary = $candidates | Where-Object { $_.Source -match '\\.[A-Za-z0-9]+$' } | Select-Object -First 1
133
+ if ($secondary) { return $secondary.Source }
134
+ return $candidates[0].Source
128
135
  }
129
136
  function claude {
130
137
  $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 }
138
+ if (-not $native) {
139
+ $userHome = [Environment]::GetFolderPath("UserProfile")
140
+ $fallback = Join-Path $userHome ".local\\bin\\claude.exe"
141
+ if (Test-Path $fallback) { $native = $fallback }
142
+ }
143
+ if (-not $native) { Write-Error "RunSignal hook: native claude executable not found. Add your native installer path to PATH (example: %USERPROFILE%\\.local\\bin)."; return }
133
144
  runsignal wrap -- $native @args
134
145
  }
135
146
  function codex {
136
147
  $native = RunSignal-Resolve-NativeCommand "codex"
137
148
  if (-not $native) { Write-Error "RunSignal hook: native codex executable not found."; return }
138
- if ($args.Count -eq 0) { & $native; return }
139
149
  runsignal wrap -- $native @args
140
150
  }
141
151
  function cursor {
142
152
  $native = RunSignal-Resolve-NativeCommand "cursor"
143
153
  if (-not $native) { Write-Error "RunSignal hook: native cursor executable not found."; return }
144
- if ($args.Count -eq 0) { & $native; return }
145
154
  runsignal wrap -- $native @args
146
155
  }
147
156
  function gemini {
148
157
  $native = RunSignal-Resolve-NativeCommand "gemini"
149
158
  if (-not $native) { Write-Error "RunSignal hook: native gemini executable not found."; return }
150
- if ($args.Count -eq 0) { & $native; return }
151
159
  runsignal wrap -- $native @args
152
160
  }
153
161
  function aider {
154
162
  $native = RunSignal-Resolve-NativeCommand "aider"
155
163
  if (-not $native) { Write-Error "RunSignal hook: native aider executable not found."; return }
156
- if ($args.Count -eq 0) { & $native; return }
157
164
  runsignal wrap -- $native @args
158
165
  }
159
166
  ${HOOK_END}
@@ -162,41 +169,46 @@ ${HOOK_END}
162
169
 
163
170
  return `${HOOK_START}
164
171
  runsignal_resolve_native() {
165
- type -P "$1" 2>/dev/null
172
+ local name
173
+ name="$1"
174
+ if type -P "$name" >/dev/null 2>&1; then
175
+ type -P "$name"
176
+ return 0
177
+ fi
178
+ if [ -x "$HOME/.local/bin/$name" ]; then
179
+ printf '%s\n' "$HOME/.local/bin/$name"
180
+ return 0
181
+ fi
182
+ return 1
166
183
  }
167
184
  claude() {
168
185
  local native
169
186
  native="$(runsignal_resolve_native claude)"
170
187
  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
188
  runsignal wrap -- "$native" "$@"
173
189
  }
174
190
  codex() {
175
191
  local native
176
192
  native="$(runsignal_resolve_native codex)"
177
193
  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
194
  runsignal wrap -- "$native" "$@"
180
195
  }
181
196
  cursor() {
182
197
  local native
183
198
  native="$(runsignal_resolve_native cursor)"
184
199
  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
200
  runsignal wrap -- "$native" "$@"
187
201
  }
188
202
  gemini() {
189
203
  local native
190
204
  native="$(runsignal_resolve_native gemini)"
191
205
  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
206
  runsignal wrap -- "$native" "$@"
194
207
  }
195
208
  aider() {
196
209
  local native
197
210
  native="$(runsignal_resolve_native aider)"
198
211
  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
212
  runsignal wrap -- "$native" "$@"
201
213
  }
202
214
  ${HOOK_END}
@@ -320,6 +332,21 @@ async function runHooksDoctor(options) {
320
332
  }
321
333
  }
322
334
 
335
+ async function areHooksInstalled(shellOption) {
336
+ const targets = resolveHookTargets(shellOption);
337
+ if (!targets.length) {
338
+ throw new Error('hooks_shell_not_supported');
339
+ }
340
+ for (const target of targets) {
341
+ const existing = await readTextFile(target.path);
342
+ const hasHooks = existing.includes(HOOK_START) && existing.includes(HOOK_END);
343
+ if (!hasHooks) {
344
+ return false;
345
+ }
346
+ }
347
+ return true;
348
+ }
349
+
323
350
  function getMachineName() {
324
351
  return process.env.COMPUTERNAME || os.hostname() || 'machine';
325
352
  }
@@ -405,6 +432,35 @@ function quoteForShell(arg) {
405
432
  return `"${escaped}"`;
406
433
  }
407
434
 
435
+ function stripWrappingQuotes(value) {
436
+ const raw = String(value || '').trim();
437
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
438
+ return raw.slice(1, -1);
439
+ }
440
+ return raw;
441
+ }
442
+
443
+ function isWindowsScriptLauncher(filePath) {
444
+ if (process.platform !== 'win32') return false;
445
+ const ext = path.extname(String(filePath || '')).toLowerCase();
446
+ return ext === '.cmd' || ext === '.bat' || ext === '.ps1';
447
+ }
448
+
449
+ function normalizeWindowsExecutablePath(filePath) {
450
+ const raw = String(filePath || '');
451
+ if (process.platform !== 'win32') return raw;
452
+ if (!raw) return raw;
453
+ const ext = path.extname(raw).toLowerCase();
454
+ if (ext) return raw;
455
+ const exe = `${raw}.exe`;
456
+ if (existsSync(exe)) return exe;
457
+ const cmd = `${raw}.cmd`;
458
+ if (existsSync(cmd)) return cmd;
459
+ const bat = `${raw}.bat`;
460
+ if (existsSync(bat)) return bat;
461
+ return raw;
462
+ }
463
+
408
464
  async function runLogin(options) {
409
465
  const config = await readConfig();
410
466
  const baseUrl = String(options.appUrl || config.app_url || DEFAULT_APP_URL).replace(/\/$/, '');
@@ -563,13 +619,71 @@ async function runWrap(commandParts) {
563
619
  commandParts.length === 1
564
620
  ? String(commandParts[0])
565
621
  : commandParts.map((part) => quoteForShell(part)).join(' ');
622
+ const singleCommand = String(commandParts[0] || '');
623
+ const singleExecutable = normalizeWindowsExecutablePath(stripWrappingQuotes(singleCommand));
566
624
  const interactivePassthrough =
567
- commandParts.length === 1 && !/[\s"'`]/.test(String(commandParts[0]));
568
- const child = spawn(commandString, {
569
- stdio: interactivePassthrough ? 'inherit' : ['inherit', 'pipe', 'pipe'],
570
- shell: true,
571
- env: process.env
572
- });
625
+ commandParts.length === 1 && (existsSync(singleExecutable) || !/\s/.test(singleExecutable));
626
+ const child = interactivePassthrough
627
+ ? isWindowsScriptLauncher(singleExecutable)
628
+ ? spawn(singleExecutable, [], {
629
+ stdio: 'inherit',
630
+ shell: true,
631
+ env: process.env
632
+ })
633
+ : spawn(singleExecutable, [], {
634
+ stdio: 'inherit',
635
+ shell: false,
636
+ env: process.env
637
+ })
638
+ : spawn(commandString, {
639
+ stdio: ['inherit', 'pipe', 'pipe'],
640
+ shell: true,
641
+ env: process.env
642
+ });
643
+
644
+ let heartbeatInterval = null;
645
+ if (interactivePassthrough) {
646
+ const startedAt = Date.now();
647
+ try {
648
+ await sendEvent({
649
+ apiFetch,
650
+ baseUrl,
651
+ machineApiKey,
652
+ event: {
653
+ message: `Session started: ${singleExecutable}`,
654
+ event_type: 'session_started',
655
+ repo,
656
+ branch,
657
+ severity: 'info',
658
+ context,
659
+ agent: agentLabel
660
+ }
661
+ });
662
+ } catch {
663
+ // non-blocking
664
+ }
665
+
666
+ heartbeatInterval = setInterval(() => {
667
+ const elapsedMin = Math.max(1, Math.floor((Date.now() - startedAt) / 60_000));
668
+ sendEvent({
669
+ apiFetch,
670
+ baseUrl,
671
+ machineApiKey,
672
+ event: {
673
+ message: `Session running (${elapsedMin} min): ${singleExecutable}`,
674
+ event_type: 'session_running',
675
+ repo,
676
+ branch,
677
+ severity: 'info',
678
+ context,
679
+ agent: agentLabel
680
+ }
681
+ }).catch(() => {
682
+ // non-blocking
683
+ });
684
+ }, 120_000);
685
+ heartbeatInterval.unref?.();
686
+ }
573
687
 
574
688
  let sawApproval = false;
575
689
  let sawQuestion = false;
@@ -669,8 +783,12 @@ async function runWrap(commandParts) {
669
783
  }
670
784
 
671
785
  const exitCode = await new Promise((resolve) => {
786
+ child.on('error', () => resolve(1));
672
787
  child.on('close', (code) => resolve(code ?? 1));
673
788
  });
789
+ if (heartbeatInterval) {
790
+ clearInterval(heartbeatInterval);
791
+ }
674
792
  await Promise.allSettled(Array.from(pendingTasks));
675
793
 
676
794
  const completionMessage =
@@ -744,7 +862,9 @@ function normalizeEventType(value) {
744
862
  raw === 'task_completed' ||
745
863
  raw === 'error' ||
746
864
  raw === 'quota_warning' ||
747
- raw === 'question'
865
+ raw === 'question' ||
866
+ raw === 'session_started' ||
867
+ raw === 'session_running'
748
868
  ) {
749
869
  return raw;
750
870
  }
@@ -857,9 +977,78 @@ async function runLogout() {
857
977
  console.log('Logged out.');
858
978
  }
859
979
 
980
+ async function runSetup(options) {
981
+ const shell = String(options.shell || 'auto');
982
+ let config = await readConfig();
983
+ const baseUrl = String(options.appUrl || config.app_url || DEFAULT_APP_URL).replace(/\/$/, '');
984
+
985
+ if (config.app_url !== baseUrl) {
986
+ await writeConfig(mergeConfig(config, {app_url: baseUrl}));
987
+ config = await readConfig();
988
+ }
989
+
990
+ const summary = {
991
+ login: 'skipped',
992
+ init: 'skipped',
993
+ hooks: 'skipped'
994
+ };
995
+
996
+ if (!config.access_token) {
997
+ await runLogin({appUrl: baseUrl});
998
+ summary.login = 'ok';
999
+ config = await readConfig();
1000
+ }
1001
+
1002
+ if (!config.machine_api_key) {
1003
+ await runInit({
1004
+ appUrl: baseUrl,
1005
+ machineName: options.machineName,
1006
+ provider: options.provider,
1007
+ botToken: options.botToken,
1008
+ chatId: options.chatId,
1009
+ webhookUrl: options.webhookUrl
1010
+ });
1011
+ summary.init = 'ok';
1012
+ }
1013
+
1014
+ const hooksInstalled = await areHooksInstalled(shell);
1015
+ if (!hooksInstalled) {
1016
+ await runHooksInstall({shell});
1017
+ summary.hooks = 'ok';
1018
+ }
1019
+
1020
+ console.log(
1021
+ JSON.stringify(
1022
+ {
1023
+ setup: 'ok',
1024
+ login: summary.login,
1025
+ init: summary.init,
1026
+ hooks: summary.hooks,
1027
+ next: 'Open a new terminal, then run claude or codex normally.'
1028
+ },
1029
+ null,
1030
+ 2
1031
+ )
1032
+ );
1033
+ }
1034
+
860
1035
  const program = new Command();
861
1036
 
862
- program.name('runsignal').description('RunSignal CLI').version('0.1.4');
1037
+ program.name('runsignal').description('RunSignal CLI').version('0.1.6');
1038
+
1039
+ program
1040
+ .command('setup')
1041
+ .description('Zero-friction onboarding: login + init + hooks install')
1042
+ .option('--app-url <url>', 'RunSignal app base URL')
1043
+ .option('--shell <shell>', 'auto|powershell|bash|zsh|all', 'auto')
1044
+ .option('--provider <provider>', 'telegram or slack (optional)')
1045
+ .option('--machine-name <name>', 'Machine name override')
1046
+ .option('--bot-token <token>', 'Telegram bot token')
1047
+ .option('--chat-id <id>', 'Telegram chat id')
1048
+ .option('--webhook-url <url>', 'Slack webhook URL')
1049
+ .action(async (options) => {
1050
+ await runSetup(options);
1051
+ });
863
1052
 
864
1053
  program
865
1054
  .command('login')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runsignal",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "RunSignal CLI for wrapping AI agent runs and handling approvals",
5
5
  "type": "module",
6
6
  "license": "MIT",