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.
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/README_CN.md +161 -0
- package/SECURITY.md +38 -0
- package/SKILL.md +79 -0
- package/config.env.example +106 -0
- package/dist/cli.mjs +182 -0
- package/dist/daemon.mjs +206122 -0
- package/dist/ui-server.mjs +7725 -0
- package/docs/codex-to-im-prd.md +424 -0
- package/docs/codex-to-im-shared-thread-design.md +572 -0
- package/docs/install-windows.md +287 -0
- package/package.json +55 -0
- package/references/setup-guides.md +329 -0
- package/references/token-validation.md +44 -0
- package/references/troubleshooting.md +88 -0
- package/references/usage.md +46 -0
- package/scripts/build.js +36 -0
- package/scripts/daemon.ps1 +16 -0
- package/scripts/daemon.sh +225 -0
- package/scripts/doctor.ps1 +27 -0
- package/scripts/doctor.sh +450 -0
- package/scripts/install-codex.sh +65 -0
- package/scripts/run-tests.js +44 -0
- package/scripts/supervisor-linux.sh +49 -0
- package/scripts/supervisor-macos.sh +154 -0
- package/scripts/supervisor-windows.ps1 +481 -0
|
@@ -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
|
+
}
|