codex-to-im 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.
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env bash
2
+ # macOS supervisor — launchd-based process management.
3
+ # Sourced by daemon.sh; expects CTI_HOME, SKILL_DIR, PID_FILE, STATUS_FILE, LOG_FILE.
4
+
5
+ LAUNCHD_LABEL="com.claude-to-im.bridge"
6
+ PLIST_DIR="$HOME/Library/LaunchAgents"
7
+ PLIST_FILE="$PLIST_DIR/$LAUNCHD_LABEL.plist"
8
+
9
+ # ── launchd helpers ──
10
+
11
+ # Collect env vars that should be forwarded into the plist.
12
+ # We honour clean_env() logic by reading *after* clean_env runs.
13
+ build_env_dict() {
14
+ local indent=" "
15
+ local dict=""
16
+
17
+ # Always forward basics
18
+ for var in HOME PATH USER SHELL LANG TMPDIR; do
19
+ local val="${!var:-}"
20
+ [ -z "$val" ] && continue
21
+ dict+="${indent}<key>${var}</key>\n${indent}<string>${val}</string>\n"
22
+ done
23
+
24
+ # Forward CTI_* vars
25
+ while IFS='=' read -r name val; do
26
+ case "$name" in CTI_*)
27
+ dict+="${indent}<key>${name}</key>\n${indent}<string>${val}</string>\n"
28
+ ;; esac
29
+ done < <(env)
30
+
31
+ # Forward runtime-specific API keys
32
+ local runtime
33
+ runtime=$(grep "^CTI_RUNTIME=" "$CTI_HOME/config.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'" | tr -d '"' || true)
34
+ runtime="${runtime:-claude}"
35
+
36
+ case "$runtime" in
37
+ codex|auto)
38
+ for var in OPENAI_API_KEY CODEX_API_KEY CTI_CODEX_API_KEY CTI_CODEX_BASE_URL; do
39
+ local val="${!var:-}"
40
+ [ -z "$val" ] && continue
41
+ dict+="${indent}<key>${var}</key>\n${indent}<string>${val}</string>\n"
42
+ done
43
+ ;;
44
+ esac
45
+ case "$runtime" in
46
+ claude|auto)
47
+ # Auto-forward all ANTHROPIC_* env vars (sourced from config.env by daemon.sh).
48
+ # Third-party API providers need these to reach the CLI subprocess.
49
+ while IFS='=' read -r name val; do
50
+ case "$name" in ANTHROPIC_*)
51
+ dict+="${indent}<key>${name}</key>\n${indent}<string>${val}</string>\n"
52
+ ;; esac
53
+ done < <(env)
54
+ ;;
55
+ esac
56
+
57
+ echo -e "$dict"
58
+ }
59
+
60
+ generate_plist() {
61
+ local node_path
62
+ node_path=$(command -v node)
63
+
64
+ mkdir -p "$PLIST_DIR"
65
+ local env_dict
66
+ env_dict=$(build_env_dict)
67
+
68
+ cat > "$PLIST_FILE" <<PLIST
69
+ <?xml version="1.0" encoding="UTF-8"?>
70
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
71
+ <plist version="1.0">
72
+ <dict>
73
+ <key>Label</key>
74
+ <string>${LAUNCHD_LABEL}</string>
75
+
76
+ <key>ProgramArguments</key>
77
+ <array>
78
+ <string>${node_path}</string>
79
+ <string>${SKILL_DIR}/dist/daemon.mjs</string>
80
+ </array>
81
+
82
+ <key>WorkingDirectory</key>
83
+ <string>${SKILL_DIR}</string>
84
+
85
+ <key>StandardOutPath</key>
86
+ <string>${LOG_FILE}</string>
87
+ <key>StandardErrorPath</key>
88
+ <string>${LOG_FILE}</string>
89
+
90
+ <key>RunAtLoad</key>
91
+ <false/>
92
+
93
+ <key>KeepAlive</key>
94
+ <dict>
95
+ <key>SuccessfulExit</key>
96
+ <false/>
97
+ </dict>
98
+
99
+ <key>ThrottleInterval</key>
100
+ <integer>10</integer>
101
+
102
+ <key>EnvironmentVariables</key>
103
+ <dict>
104
+ ${env_dict} </dict>
105
+ </dict>
106
+ </plist>
107
+ PLIST
108
+ }
109
+
110
+ # ── Public interface (called by daemon.sh) ──
111
+
112
+ supervisor_start() {
113
+ launchctl bootout "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null || true
114
+ generate_plist
115
+ launchctl bootstrap "gui/$(id -u)" "$PLIST_FILE"
116
+ launchctl kickstart -k "gui/$(id -u)/$LAUNCHD_LABEL"
117
+ }
118
+
119
+ supervisor_stop() {
120
+ launchctl bootout "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null || true
121
+ rm -f "$PID_FILE"
122
+ }
123
+
124
+ supervisor_is_managed() {
125
+ launchctl print "gui/$(id -u)/$LAUNCHD_LABEL" &>/dev/null
126
+ }
127
+
128
+ supervisor_status_extra() {
129
+ if supervisor_is_managed; then
130
+ echo "Bridge is registered with launchd ($LAUNCHD_LABEL)"
131
+ # Extract PID from launchctl as the authoritative source
132
+ local lc_pid
133
+ lc_pid=$(launchctl print "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null | grep -m1 'pid = ' | sed 's/.*pid = //' | tr -d ' ')
134
+ if [ -n "$lc_pid" ] && [ "$lc_pid" != "0" ] && [ "$lc_pid" != "-" ]; then
135
+ echo "launchd reports PID: $lc_pid"
136
+ fi
137
+ fi
138
+ }
139
+
140
+ # Override: on macOS, check launchctl first, then fall back to PID file
141
+ supervisor_is_running() {
142
+ # Primary: launchctl knows the process
143
+ if supervisor_is_managed; then
144
+ local lc_pid
145
+ lc_pid=$(launchctl print "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null | grep -m1 'pid = ' | sed 's/.*pid = //' | tr -d ' ')
146
+ if [ -n "$lc_pid" ] && [ "$lc_pid" != "0" ] && [ "$lc_pid" != "-" ]; then
147
+ return 0
148
+ fi
149
+ fi
150
+ # Fallback: PID file
151
+ local pid
152
+ pid=$(read_pid)
153
+ pid_alive "$pid"
154
+ }
@@ -0,0 +1,481 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Windows daemon manager for claude-to-im bridge.
4
+
5
+ .DESCRIPTION
6
+ Manages the bridge process on Windows.
7
+ Preferred: WinSW or NSSM wrapping as a Windows Service.
8
+ Fallback: Start-Process with hidden window + PID tracking.
9
+
10
+ Usage:
11
+ powershell -File scripts\daemon.ps1 start
12
+ powershell -File scripts\daemon.ps1 stop
13
+ powershell -File scripts\daemon.ps1 status
14
+ powershell -File scripts\daemon.ps1 logs [N]
15
+ powershell -File scripts\daemon.ps1 install-service # WinSW/NSSM setup
16
+ powershell -File scripts\daemon.ps1 uninstall-service
17
+ #>
18
+
19
+ param(
20
+ [Parameter(Position=0)]
21
+ [ValidateSet('start','stop','status','logs','install-service','uninstall-service','help')]
22
+ [string]$Command = 'help',
23
+
24
+ [Parameter(Position=1)]
25
+ [int]$LogLines = 50
26
+ )
27
+
28
+ $ErrorActionPreference = 'Stop'
29
+
30
+ # ── Paths ──
31
+ $CtiHome = if ($env:CTI_HOME) { $env:CTI_HOME } else { Join-Path $env:USERPROFILE '.claude-to-im' }
32
+ $SkillDir = Split-Path -Parent (Split-Path -Parent $PSCommandPath)
33
+ $RuntimeDir = Join-Path $CtiHome 'runtime'
34
+ $PidFile = Join-Path $RuntimeDir 'bridge.pid'
35
+ $StatusFile = Join-Path $RuntimeDir 'status.json'
36
+ $LogFile = Join-Path (Join-Path $CtiHome 'logs') 'bridge.log'
37
+ $ErrLogFile = Join-Path (Join-Path $CtiHome 'logs') 'bridge.stderr.log'
38
+ $DaemonMjs = Join-Path (Join-Path $SkillDir 'dist') 'daemon.mjs'
39
+ $ConfigFile = Join-Path $CtiHome 'config.env'
40
+
41
+ $ServiceName = 'ClaudeToIMBridge'
42
+
43
+ # ── Helpers ──
44
+
45
+ function Ensure-Dirs {
46
+ @('data','logs','runtime','data/messages') | ForEach-Object {
47
+ $dir = Join-Path $CtiHome $_
48
+ if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
49
+ }
50
+ }
51
+
52
+ function Ensure-Built {
53
+ if (-not (Test-Path $DaemonMjs)) {
54
+ Write-Host "Building daemon bundle..."
55
+ Push-Location $SkillDir
56
+ npm run build
57
+ Pop-Location
58
+ } else {
59
+ $srcFiles = Get-ChildItem -Path (Join-Path $SkillDir 'src') -Filter '*.ts' -Recurse
60
+ $bundleTime = (Get-Item $DaemonMjs).LastWriteTime
61
+ $stale = $srcFiles | Where-Object { $_.LastWriteTime -gt $bundleTime } | Select-Object -First 1
62
+ if ($stale) {
63
+ Write-Host "Rebuilding daemon bundle (source changed)..."
64
+ Push-Location $SkillDir
65
+ npm run build
66
+ Pop-Location
67
+ }
68
+ }
69
+ }
70
+
71
+ function Get-ConfigEnvironment {
72
+ $configEnv = @{}
73
+ if (-not (Test-Path $ConfigFile)) {
74
+ return $configEnv
75
+ }
76
+
77
+ foreach ($line in Get-Content $ConfigFile) {
78
+ $trimmed = $line.Trim()
79
+ if (-not $trimmed -or $trimmed.StartsWith('#')) { continue }
80
+ $eqIndex = $trimmed.IndexOf('=')
81
+ if ($eqIndex -lt 1) { continue }
82
+
83
+ $name = $trimmed.Substring(0, $eqIndex).Trim()
84
+ $value = $trimmed.Substring($eqIndex + 1).Trim()
85
+ if (
86
+ ($value.StartsWith('"') -and $value.EndsWith('"')) -or
87
+ ($value.StartsWith("'") -and $value.EndsWith("'"))
88
+ ) {
89
+ $value = $value.Substring(1, $value.Length - 2)
90
+ }
91
+ $configEnv[$name] = $value
92
+ }
93
+
94
+ return $configEnv
95
+ }
96
+
97
+ function Read-Pid {
98
+ if (Test-Path $PidFile) { return (Get-Content $PidFile -Raw).Trim() }
99
+ return $null
100
+ }
101
+
102
+ function Test-PidAlive {
103
+ param([string]$ProcessId)
104
+ if (-not $ProcessId) { return $false }
105
+ try { $null = Get-Process -Id ([int]$ProcessId) -ErrorAction Stop; return $true }
106
+ catch { return $false }
107
+ }
108
+
109
+ function Test-StatusRunning {
110
+ if (-not (Test-Path $StatusFile)) { return $false }
111
+ $json = Get-Content $StatusFile -Raw | ConvertFrom-Json
112
+ return $json.running -eq $true
113
+ }
114
+
115
+ function Show-LastExitReason {
116
+ if (Test-Path $StatusFile) {
117
+ $json = Get-Content $StatusFile -Raw | ConvertFrom-Json
118
+ if ($json.lastExitReason) {
119
+ Write-Host "Last exit reason: $($json.lastExitReason)"
120
+ }
121
+ }
122
+ }
123
+
124
+ function Show-FailureHelp {
125
+ Write-Host ""
126
+ Write-Host "Recent logs:"
127
+ if (Test-Path $LogFile) {
128
+ Get-Content $LogFile -Tail 20
129
+ } else {
130
+ Write-Host " (no log file)"
131
+ }
132
+ if (Test-Path $ErrLogFile) {
133
+ Write-Host ""
134
+ Write-Host "Recent stderr:"
135
+ Get-Content $ErrLogFile -Tail 20
136
+ }
137
+ Write-Host ""
138
+ Write-Host "Next steps:"
139
+ Write-Host " 1. Run diagnostics: powershell -File `"$SkillDir\scripts\doctor.ps1`""
140
+ Write-Host " 2. Check full logs: powershell -File `"$SkillDir\scripts\daemon.ps1`" logs 100"
141
+ Write-Host " 3. Rebuild bundle: cd `"$SkillDir`"; npm run build"
142
+ }
143
+
144
+ function Get-NodePath {
145
+ $nodePath = (Get-Command node -ErrorAction SilentlyContinue).Source
146
+ if (-not $nodePath) {
147
+ Write-Error "Node.js not found in PATH. Install Node.js >= 20."
148
+ exit 1
149
+ }
150
+ return $nodePath
151
+ }
152
+
153
+ # ── WinSW / NSSM detection ──
154
+
155
+ function Find-ServiceManager {
156
+ # Prefer WinSW, then NSSM
157
+ $winsw = Get-Command 'WinSW.exe' -ErrorAction SilentlyContinue
158
+ if ($winsw) { return @{ type = 'winsw'; path = $winsw.Source } }
159
+
160
+ $nssm = Get-Command 'nssm.exe' -ErrorAction SilentlyContinue
161
+ if ($nssm) { return @{ type = 'nssm'; path = $nssm.Source } }
162
+
163
+ return $null
164
+ }
165
+
166
+ function Install-WinSWService {
167
+ param([string]$WinSWPath)
168
+ $nodePath = Get-NodePath
169
+ $xmlPath = Join-Path $SkillDir "$ServiceName.xml"
170
+ $configEnv = Get-ConfigEnvironment
171
+
172
+ # Run as current user so the service can access ~/.claude-to-im and Codex login state
173
+ $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
174
+ Write-Host "Service will run as: $currentUser"
175
+ $cred = Get-Credential -UserName $currentUser -Message "Enter password for '$currentUser' (required for Windows Service logon)"
176
+ $plainPwd = $cred.GetNetworkCredential().Password
177
+
178
+ # Generate WinSW config XML
179
+ $xml = @"
180
+ <service>
181
+ <id>$ServiceName</id>
182
+ <name>Claude-to-IM Bridge</name>
183
+ <description>Claude-to-IM bridge daemon</description>
184
+ <executable>$nodePath</executable>
185
+ <arguments>$DaemonMjs</arguments>
186
+ <workingdirectory>$SkillDir</workingdirectory>
187
+ <serviceaccount>
188
+ <username>$currentUser</username>
189
+ <password>$([System.Security.SecurityElement]::Escape($plainPwd))</password>
190
+ <allowservicelogon>true</allowservicelogon>
191
+ </serviceaccount>
192
+ <env name="USERPROFILE" value="$env:USERPROFILE"/>
193
+ <env name="APPDATA" value="$env:APPDATA"/>
194
+ <env name="LOCALAPPDATA" value="$env:LOCALAPPDATA"/>
195
+ <env name="PATH" value="$env:PATH"/>
196
+ <env name="CTI_HOME" value="$CtiHome"/>
197
+ <logpath>$(Join-Path $CtiHome 'logs')</logpath>
198
+ <log mode="append">
199
+ <logfile>bridge-service.log</logfile>
200
+ </log>
201
+ <onfailure action="restart" delay="10 sec"/>
202
+ <onfailure action="restart" delay="30 sec"/>
203
+ <onfailure action="none"/>
204
+ </service>
205
+ "@
206
+
207
+ $extraEnvXml = ($configEnv.GetEnumerator() | ForEach-Object {
208
+ ' <env name="{0}" value="{1}"/>' -f $_.Key, [System.Security.SecurityElement]::Escape($_.Value)
209
+ }) -join "`r`n"
210
+ if ($extraEnvXml) {
211
+ $xml = $xml -replace ' <logpath>', "$extraEnvXml`r`n <logpath>"
212
+ }
213
+
214
+ $xml | Set-Content -Path $xmlPath -Encoding UTF8
215
+
216
+ # Copy WinSW next to the XML with matching name
217
+ $winswCopy = Join-Path $SkillDir "$ServiceName.exe"
218
+ Copy-Item $WinSWPath $winswCopy -Force
219
+
220
+ & $winswCopy install
221
+ Write-Host "Service '$ServiceName' installed via WinSW."
222
+ Write-Host " Service account: $currentUser"
223
+ Write-Host "Start with: & `"$winswCopy`" start"
224
+ Write-Host "Or: sc.exe start $ServiceName"
225
+ }
226
+
227
+ function Install-NSSMService {
228
+ param([string]$NSSMPath)
229
+ $nodePath = Get-NodePath
230
+ $configEnv = Get-ConfigEnvironment
231
+
232
+ # Run as current user so the service can access ~/.claude-to-im and Codex login state
233
+ $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
234
+ Write-Host "Service will run as: $currentUser"
235
+ $cred = Get-Credential -UserName $currentUser -Message "Enter password for '$currentUser' (required for Windows Service logon)"
236
+ $plainPwd = $cred.GetNetworkCredential().Password
237
+
238
+ & $NSSMPath install $ServiceName $nodePath $DaemonMjs
239
+ & $NSSMPath set $ServiceName AppDirectory $SkillDir
240
+ & $NSSMPath set $ServiceName ObjectName $currentUser $plainPwd
241
+ & $NSSMPath set $ServiceName AppStdout $LogFile
242
+ & $NSSMPath set $ServiceName AppStderr $LogFile
243
+ & $NSSMPath set $ServiceName AppStdoutCreationDisposition 4
244
+ & $NSSMPath set $ServiceName AppStderrCreationDisposition 4
245
+ & $NSSMPath set $ServiceName Description "Claude-to-IM bridge daemon"
246
+ & $NSSMPath set $ServiceName AppRestartDelay 10000
247
+ $envArgs = @(
248
+ "USERPROFILE=$env:USERPROFILE",
249
+ "APPDATA=$env:APPDATA",
250
+ "LOCALAPPDATA=$env:LOCALAPPDATA",
251
+ "CTI_HOME=$CtiHome"
252
+ )
253
+ foreach ($entry in $configEnv.GetEnumerator()) {
254
+ $envArgs += "$($entry.Key)=$($entry.Value)"
255
+ }
256
+ & $NSSMPath set $ServiceName AppEnvironmentExtra @envArgs
257
+
258
+ Write-Host "Service '$ServiceName' installed via NSSM."
259
+ Write-Host " Service account: $currentUser"
260
+ Write-Host "Start with: nssm start $ServiceName"
261
+ Write-Host "Or: sc.exe start $ServiceName"
262
+ }
263
+
264
+ # ── Fallback: Start-Process (no service manager) ──
265
+
266
+ function Start-Fallback {
267
+ $nodePath = Get-NodePath
268
+ $configEnv = Get-ConfigEnvironment
269
+
270
+ # Clean env
271
+ $envClone = [System.Collections.Hashtable]::new()
272
+ foreach ($key in [System.Environment]::GetEnvironmentVariables().Keys) {
273
+ $envClone[$key] = [System.Environment]::GetEnvironmentVariable($key)
274
+ }
275
+ # Remove CLAUDECODE
276
+ [System.Environment]::SetEnvironmentVariable('CLAUDECODE', $null)
277
+ [System.Environment]::SetEnvironmentVariable('CTI_HOME', $CtiHome, 'Process')
278
+
279
+ foreach ($entry in $configEnv.GetEnumerator()) {
280
+ [System.Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process')
281
+ }
282
+
283
+ try {
284
+ $proc = Start-Process -FilePath $nodePath `
285
+ -ArgumentList $DaemonMjs `
286
+ -WorkingDirectory $SkillDir `
287
+ -WindowStyle Hidden `
288
+ -RedirectStandardOutput $LogFile `
289
+ -RedirectStandardError $ErrLogFile `
290
+ -PassThru
291
+ } finally {
292
+ foreach ($key in $envClone.Keys) {
293
+ [System.Environment]::SetEnvironmentVariable($key, $envClone[$key], 'Process')
294
+ }
295
+ foreach ($entry in $configEnv.GetEnumerator()) {
296
+ if (-not $envClone.ContainsKey($entry.Key)) {
297
+ [System.Environment]::SetEnvironmentVariable($entry.Key, $null, 'Process')
298
+ }
299
+ }
300
+ if (-not $envClone.ContainsKey('CTI_HOME')) {
301
+ [System.Environment]::SetEnvironmentVariable('CTI_HOME', $null, 'Process')
302
+ }
303
+ }
304
+
305
+ # Write initial PID (main.ts will overwrite with real PID)
306
+ Set-Content -Path $PidFile -Value $proc.Id
307
+ return $proc.Id
308
+ }
309
+
310
+ # ── Commands ──
311
+
312
+ switch ($Command) {
313
+ 'start' {
314
+ Ensure-Dirs
315
+ Ensure-Built
316
+
317
+ $existingPid = Read-Pid
318
+ if ($existingPid -and (Test-PidAlive $existingPid)) {
319
+ Write-Host "Bridge already running (PID: $existingPid)"
320
+ if (Test-Path $StatusFile) { Get-Content $StatusFile -Raw }
321
+ exit 1
322
+ }
323
+
324
+ # Check if registered as Windows Service
325
+ $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
326
+ if ($svc) {
327
+ Write-Host "Starting bridge via Windows Service..."
328
+ Start-Service -Name $ServiceName
329
+ Start-Sleep -Seconds 3
330
+
331
+ $newPid = Read-Pid
332
+ if ($newPid -and (Test-PidAlive $newPid) -and (Test-StatusRunning)) {
333
+ Write-Host "Bridge started (PID: $newPid, managed by Windows Service)"
334
+ if (Test-Path $StatusFile) { Get-Content $StatusFile -Raw }
335
+ } else {
336
+ Write-Host "Failed to start bridge via service."
337
+ Show-LastExitReason
338
+ Show-FailureHelp
339
+ exit 1
340
+ }
341
+ } else {
342
+ Write-Host "Starting bridge (background process)..."
343
+ $bridgePid = Start-Fallback
344
+ Start-Sleep -Seconds 3
345
+
346
+ $newPid = Read-Pid
347
+ if ($newPid -and (Test-PidAlive $newPid) -and (Test-StatusRunning)) {
348
+ Write-Host "Bridge started (PID: $newPid)"
349
+ if (Test-Path $StatusFile) { Get-Content $StatusFile -Raw }
350
+ } else {
351
+ Write-Host "Failed to start bridge."
352
+ if (-not $newPid -or -not (Test-PidAlive $newPid)) {
353
+ Write-Host " Process exited immediately."
354
+ }
355
+ Show-LastExitReason
356
+ Show-FailureHelp
357
+ exit 1
358
+ }
359
+ }
360
+ }
361
+
362
+ 'stop' {
363
+ $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
364
+ if ($svc -and $svc.Status -eq 'Running') {
365
+ Write-Host "Stopping bridge via Windows Service..."
366
+ Stop-Service -Name $ServiceName -Force
367
+ Write-Host "Bridge stopped"
368
+ if (Test-Path $PidFile) { Remove-Item $PidFile -Force }
369
+ } else {
370
+ $bridgePid = Read-Pid
371
+ if (-not $bridgePid) { Write-Host "No bridge running"; exit 0 }
372
+ if (Test-PidAlive $bridgePid) {
373
+ Stop-Process -Id ([int]$bridgePid) -Force
374
+ Write-Host "Bridge stopped"
375
+ } else {
376
+ Write-Host "Bridge was not running (stale PID file)"
377
+ }
378
+ if (Test-Path $PidFile) { Remove-Item $PidFile -Force }
379
+ }
380
+ }
381
+
382
+ 'status' {
383
+ $bridgePid = Read-Pid
384
+
385
+ # Check Windows Service
386
+ $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
387
+ if ($svc) {
388
+ Write-Host "Windows Service '$ServiceName': $($svc.Status)"
389
+ }
390
+
391
+ if ($bridgePid -and (Test-PidAlive $bridgePid)) {
392
+ Write-Host "Bridge process is running (PID: $bridgePid)"
393
+ if (Test-StatusRunning) {
394
+ Write-Host "Bridge status: running"
395
+ } else {
396
+ Write-Host "Bridge status: process alive but status.json not reporting running"
397
+ }
398
+ if (Test-Path $StatusFile) { Get-Content $StatusFile -Raw }
399
+ } else {
400
+ Write-Host "Bridge is not running"
401
+ if (Test-Path $PidFile) { Remove-Item $PidFile -Force }
402
+ Show-LastExitReason
403
+ }
404
+ }
405
+
406
+ 'logs' {
407
+ if (Test-Path $LogFile) {
408
+ Get-Content $LogFile -Tail $LogLines | ForEach-Object {
409
+ $_ -replace '(token|secret|password)(["'']?\s*[:=]\s*["'']?)[^\s"]+', '$1$2*****'
410
+ }
411
+ } else {
412
+ Write-Host "No log file found at $LogFile"
413
+ }
414
+ if (Test-Path $ErrLogFile) {
415
+ Write-Host ""
416
+ Write-Host "stderr:"
417
+ Get-Content $ErrLogFile -Tail $LogLines
418
+ }
419
+ }
420
+
421
+ 'install-service' {
422
+ Ensure-Dirs
423
+ Ensure-Built
424
+
425
+ $mgr = Find-ServiceManager
426
+ if (-not $mgr) {
427
+ Write-Host "No service manager found. Install one of:"
428
+ Write-Host " WinSW: https://github.com/winsw/winsw/releases"
429
+ Write-Host " NSSM: https://nssm.cc/download"
430
+ Write-Host ""
431
+ Write-Host "After installing, add it to PATH and re-run:"
432
+ Write-Host " powershell -File `"$PSCommandPath`" install-service"
433
+ exit 1
434
+ }
435
+
436
+ switch ($mgr.type) {
437
+ 'winsw' { Install-WinSWService -WinSWPath $mgr.path }
438
+ 'nssm' { Install-NSSMService -NSSMPath $mgr.path }
439
+ }
440
+ }
441
+
442
+ 'uninstall-service' {
443
+ $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
444
+ if (-not $svc) {
445
+ Write-Host "Service '$ServiceName' is not installed."
446
+ exit 0
447
+ }
448
+
449
+ if ($svc.Status -eq 'Running') {
450
+ Stop-Service -Name $ServiceName -Force
451
+ }
452
+
453
+ $mgr = Find-ServiceManager
454
+ if ($mgr -and $mgr.type -eq 'nssm') {
455
+ & $mgr.path remove $ServiceName confirm
456
+ } else {
457
+ # WinSW or generic
458
+ $winswExe = Join-Path $SkillDir "$ServiceName.exe"
459
+ if (Test-Path $winswExe) {
460
+ & $winswExe uninstall
461
+ Remove-Item $winswExe -Force -ErrorAction SilentlyContinue
462
+ Remove-Item (Join-Path $SkillDir "$ServiceName.xml") -Force -ErrorAction SilentlyContinue
463
+ } else {
464
+ sc.exe delete $ServiceName
465
+ }
466
+ }
467
+ Write-Host "Service '$ServiceName' uninstalled."
468
+ }
469
+
470
+ 'help' {
471
+ Write-Host "Usage: daemon.ps1 {start|stop|status|logs [N]|install-service|uninstall-service}"
472
+ Write-Host ""
473
+ Write-Host "Commands:"
474
+ Write-Host " start Start the bridge daemon"
475
+ Write-Host " stop Stop the bridge daemon"
476
+ Write-Host " status Show bridge status"
477
+ Write-Host " logs [N] Show last N log lines (default 50)"
478
+ Write-Host " install-service Install as Windows Service (requires WinSW or NSSM)"
479
+ Write-Host " uninstall-service Remove the Windows Service"
480
+ }
481
+ }