@tonyclaw/llm-inspector 1.19.0 → 1.19.2

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 (40) hide show
  1. package/.output/cli.js +338 -102
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-DwayZPPO.js → CompareDrawer-BzTsEelr.js} +1 -1
  4. package/.output/public/assets/{ProxyViewerContainer-iv3LVMEW.js → ProxyViewerContainer-BHm-n-_W.js} +9 -9
  5. package/.output/public/assets/{ReplayDialog-CaV1elYO.js → ReplayDialog-Dxxo80xO.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-CSfnjK7j.js → RequestAnatomy-D-swiaii.js} +1 -1
  7. package/.output/public/assets/{ResponseView-YkOL__xm.js → ResponseView-DvdH2bGk.js} +1 -1
  8. package/.output/public/assets/{StreamingChunkSequence-D_p6L-oB.js → StreamingChunkSequence-D_RzgyKq.js} +1 -1
  9. package/.output/public/assets/_sessionId-DdODJCYY.js +1 -0
  10. package/.output/public/assets/{index-DeJyypsp.css → index-Bqi9RAGS.css} +1 -1
  11. package/.output/public/assets/index-EvnsNPOK.js +1 -0
  12. package/.output/public/assets/{json-viewer-BB-9bqnP.js → json-viewer-DIHZbEId.js} +1 -1
  13. package/.output/public/assets/{main-COVN451W.js → main-Br2EjrqZ.js} +2 -2
  14. package/.output/server/{_sessionId-BJT5qIib.mjs → _sessionId-CPkCxTP8.mjs} +4 -3
  15. package/.output/server/_ssr/{CompareDrawer-DNGYdUXs.mjs → CompareDrawer-DKHgXC5-.mjs} +4 -4
  16. package/.output/server/_ssr/{ProxyViewerContainer-B-zDOLYE.mjs → ProxyViewerContainer-B41D-2Eo.mjs} +57 -9
  17. package/.output/server/_ssr/{ReplayDialog-DWeqMA4y.mjs → ReplayDialog-D2piRWb0.mjs} +5 -5
  18. package/.output/server/_ssr/{RequestAnatomy-TOsrMu9-.mjs → RequestAnatomy-Ce7QdQNP.mjs} +4 -3
  19. package/.output/server/_ssr/{ResponseView-BuqdPrzm.mjs → ResponseView-D50UPv-r.mjs} +5 -5
  20. package/.output/server/_ssr/{StreamingChunkSequence-DuzNZkqL.mjs → StreamingChunkSequence-CDlNFS3Z.mjs} +4 -4
  21. package/.output/server/_ssr/{index-1nCQUt3y.mjs → index-DhAQxjnZ.mjs} +4 -3
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{json-viewer-BL8xhHbi.mjs → json-viewer-BZRjG_f7.mjs} +4 -4
  24. package/.output/server/_ssr/{router-aCaUgVTW.mjs → router-yP98-Gq-.mjs} +126 -105
  25. package/.output/server/{_tanstack-start-manifest_v-cBRxvCjb.mjs → _tanstack-start-manifest_v-d4a4xlOi.mjs} +1 -1
  26. package/.output/server/index.mjs +64 -64
  27. package/README.md +22 -0
  28. package/package.json +3 -1
  29. package/src/cli/detect-tools.ts +1 -0
  30. package/src/cli/templates/skill-onboard.ts +204 -71
  31. package/src/cli.ts +164 -39
  32. package/src/components/ProxyViewerContainer.tsx +52 -0
  33. package/src/components/proxy-viewer/LogEntryHeader.tsx +1 -0
  34. package/src/proxy/logFinalizer.ts +7 -3
  35. package/src/proxy/sessionProcess.ts +14 -7
  36. package/src/proxy/sessionSupervisor.ts +3 -2
  37. package/src/proxy/socketTracker.ts +19 -7
  38. package/styles/globals.css +14 -7
  39. package/.output/public/assets/_sessionId-BgCVUC6R.js +0 -1
  40. package/.output/public/assets/index-CWA4S0FO.js +0 -1
package/.output/cli.js CHANGED
@@ -20,6 +20,7 @@ function which(bin) {
20
20
  const out2 = execFileSync("where", [bin], {
21
21
  encoding: "utf8",
22
22
  timeout: 3e3,
23
+ windowsHide: true,
23
24
  stdio: ["ignore", "pipe", "ignore"]
24
25
  });
25
26
  const first = out2.split(/\r?\n/).find((line) => line.trim().length > 0);
@@ -133,29 +134,117 @@ ${detectedSummary || " (no known AI tool detected \u2014 the user can still use
133
134
 
134
135
  Default proxy port: \`${port}\` (override with \`PORT=<n> llm-inspector\` or \`--port <n>\`).
135
136
 
137
+ > **PAUSE protocol.** Every \`**PAUSE**\` marker in this skill is a real stop.
138
+ > Use the \`AskUserQuestion\` tool to actually wait for the user before
139
+ > continuing. Do not stream past a PAUSE based on context \u2014 the user has
140
+ > not seen your output yet. Each PAUSE in the body below includes a sample
141
+ > question you can adapt.
142
+
136
143
  ---
137
144
 
138
- ## Preflight
145
+ ## Phase 0: Idempotency check
146
+
147
+ **EXPLAIN:** "Before we do anything, let me see what's already set up. If some of the steps are already done, we can skip them."
148
+
149
+ **DO:** Probe the three pieces of state this skill touches. Use targeted checks \u2014 do **not** read large JSON files into the conversation.
139
150
 
140
- Before starting, verify the environment.
151
+ \`\`\`bash
152
+ # 1. Is the proxy already up?
153
+ curl -fsS "http://localhost:${port}/api/health" 2>/dev/null && echo "PROXY: up" || echo "PROXY: down"
154
+
155
+ # 2. Does the config have a real provider key?
156
+ CFG="$HOME/.llm-inspector/config.json"
157
+ if [ -f "$CFG" ]; then
158
+ if grep -qE '"apiKey"[[:space:]]*:[[:space:]]*"(sk-[^"]+|REPLACE|REPLACE_)' "$CFG" 2>/dev/null; then
159
+ echo "CONFIG: has key (no REPLACE placeholder)"
160
+ else
161
+ echo "CONFIG: missing or has placeholder key"
162
+ fi
163
+ else
164
+ echo "CONFIG: file does not exist"
165
+ fi
166
+
167
+ # 3. Is the MCP server already wired? (project .mcp.json wins)
168
+ PROJ_MCP=".mcp.json"
169
+ HOME_MCP="$HOME/.claude.json"
170
+ if [ -f "$PROJ_MCP" ] && grep -q '"llm-inspector"' "$PROJ_MCP"; then
171
+ echo "MCP: wired in $PROJ_MCP"
172
+ elif [ -f "$HOME_MCP" ] && grep -q '"llm-inspector"' "$HOME_MCP"; then
173
+ echo "MCP: wired in $HOME_MCP"
174
+ else
175
+ echo "MCP: not wired"
176
+ fi
177
+ \`\`\`
178
+
179
+ \`\`\`powershell
180
+ # Windows PowerShell \u2014 single-quoted so $env: expands correctly
181
+ $port = ${port}
182
+ $cfg = Join-Path $env:USERPROFILE '.llm-inspector/config.json'
183
+
184
+ # 1. Is the proxy already up?
185
+ try {
186
+ $null = Invoke-RestMethod -Uri "http://localhost:$port/api/health" -TimeoutSec 2 -ErrorAction Stop
187
+ Write-Host 'PROXY: up'
188
+ } catch {
189
+ Write-Host 'PROXY: down'
190
+ }
191
+
192
+ # 2. Does the config have a real provider key?
193
+ if (Test-Path $cfg) {
194
+ $content = Get-Content $cfg -Raw
195
+ if ($content -match '"apiKey"[[:space:]]*:[[:space:]]*"(sk-[^"]+)"') {
196
+ Write-Host 'CONFIG: has key'
197
+ } elseif ($content -match 'REPLACE') {
198
+ Write-Host 'CONFIG: has placeholder key'
199
+ } else {
200
+ Write-Host 'CONFIG: missing key'
201
+ }
202
+ } else {
203
+ Write-Host 'CONFIG: file does not exist'
204
+ }
205
+
206
+ # 3. Is the MCP server already wired? (project .mcp.json wins)
207
+ $projMcp = Join-Path (Get-Location) '.mcp.json'
208
+ $homeMcp = Join-Path $env:USERPROFILE '.claude.json'
209
+ if ((Test-Path $projMcp) -and (Select-String -Path $projMcp -Pattern 'llm-inspector' -Quiet)) {
210
+ Write-Host "MCP: wired in $projMcp"
211
+ } elseif ((Test-Path $homeMcp) -and (Select-String -Path $homeMcp -Pattern 'llm-inspector' -Quiet)) {
212
+ Write-Host "MCP: wired in $homeMcp"
213
+ } else {
214
+ Write-Host 'MCP: not wired'
215
+ }
216
+ \`\`\`
217
+
218
+ **DO:** Summarize the three checks in one line, then use \`AskUserQuestion\` to ask whether to skip the corresponding phases.
219
+
220
+ > **PAUSE** \u2014 call \`AskUserQuestion\` with:
221
+ > - header: \`Skip done\`
222
+ > - question: \`Proxy/CONFIG/MCP state: <summary>. Skip the phases that are already done?\`
223
+ > - options: \`["Yes, skip what's done", "No, walk me through everything again"]\`
224
+ > Wait for the answer before moving to Preflight.
225
+
226
+ ---
227
+
228
+ ## Preflight
141
229
 
142
- **EXPLAIN:** "Let's make sure everything we need is in place. Two quick checks."
230
+ **EXPLAIN:** "Quick env sanity check \u2014 make sure Node and Claude Code are present."
143
231
 
144
- **DO:** Run the platform-appropriate commands below. The user can copy-paste, or you can run them yourself if you have shell access.
232
+ **DO:** Run the platform-appropriate commands below.
145
233
 
146
234
  \`\`\`bash
147
235
  # Unix / macOS / WSL
148
- node --version # expect >= 18
149
- test -d "$HOME/.claude" && echo "claude-code: present" || echo "claude-code: not detected"
236
+ node --version # expect >= 18
237
+ command -v claude >/dev/null && echo "claude-code: present" || echo "claude-code: not detected"
150
238
  \`\`\`
151
239
 
152
240
  \`\`\`powershell
153
- # Windows PowerShell
154
- node --version # expect >= 18
155
- if (Test-Path "$env:USERPROFILE\\.claude") { Write-Host "claude-code: present" } else { Write-Host "claude-code: not detected" }
241
+ # Windows PowerShell \u2014 single-quoted so $env: expands correctly
242
+ node --version # expect >= 18
243
+ $null = Get-Command claude -ErrorAction SilentlyContinue
244
+ if ($null) { Write-Host 'claude-code: present' } else { Write-Host 'claude-code: not detected' }
156
245
  \`\`\`
157
246
 
158
- **PAUSE** \u2014 if Node is older than 18, ask the user to install a newer version (https://nodejs.org) before continuing.
247
+ > **PAUSE** \u2014 if Node is older than 18, ask the user to install a newer version (https://nodejs.org) before continuing. Use \`AskUserQuestion\` with header \`Node version\`.
159
248
 
160
249
  ---
161
250
 
@@ -178,7 +267,7 @@ llm-inspector is a transparent HTTP proxy + Web UI for AI coding tools. Point yo
178
267
  Ready? Let's start with the provider.
179
268
  \`\`\`
180
269
 
181
- **PAUSE** \u2014 wait for the user to confirm.
270
+ > **PAUSE** \u2014 use \`AskUserQuestion\` with header \`Ready?\` and options \`["Yes, let's go", "Wait, I have a question"]\`. Wait for the user before continuing.
182
271
 
183
272
  ---
184
273
 
@@ -186,47 +275,94 @@ Ready? Let's start with the provider.
186
275
 
187
276
  **EXPLAIN:** "A 'provider' is an upstream LLM endpoint \u2014 Anthropic, OpenAI, MiniMax, etc. llm-inspector routes each request to the right upstream based on the model name. You need at least one provider configured for the proxy to forward traffic."
188
277
 
189
- **DO:** Open (or create) \`~/.llm-inspector/config.json\` in the user's editor. If the file doesn't exist, create it with the structure below. Walk the user through filling in their API key for their provider of choice.
278
+ **DO:** First, re-check whether the config already has a real key (Phase 0 may have raced with a manual edit). Use the same \`grep -qE '"apiKey":"sk-' "$HOME/.llm-inspector/config.json"\` (bash) or \`Select-String\` (PowerShell) check from Phase 0. If a real key is present, skip the rest of this phase.
190
279
 
191
- \`\`\`json
280
+ **DO:** If no real key, ask the user for the provider type and API key via \`AskUserQuestion\`. The question should be a free-form text field (no fixed options) \u2014 the API key is a secret, so don't echo it back in the question UI.
281
+
282
+ \`\`\`bash
283
+ # After collecting the key, write the config
284
+ mkdir -p "$HOME/.llm-inspector"
285
+ cat > "$HOME/.llm-inspector/config.json" <<'JSON'
192
286
  {
193
287
  "providers": [
194
288
  {
195
289
  "id": "anthropic",
196
290
  "type": "anthropic",
197
- "apiKey": "sk-ant-...",
291
+ "apiKey": "REPLACE_ME_BEFORE_WRITING",
198
292
  "baseUrl": "https://api.anthropic.com"
199
293
  }
200
294
  ]
201
295
  }
296
+ JSON
297
+ # Then patch the apiKey with the user-provided value (use jq if available)
202
298
  \`\`\`
203
299
 
204
- Alternative (avoid touching the config file): point the user at the Web UI \u2192 top-right Settings button \u2192 Providers tab, which has a form-driven flow.
300
+ \`\`\`powershell
301
+ # Windows PowerShell \u2014 single-quoted so $env: expands correctly
302
+ $dir = Join-Path $env:USERPROFILE '.llm-inspector'
303
+ $file = Join-Path $dir 'config.json'
304
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
305
+ @'
306
+ {
307
+ "providers": [
308
+ {
309
+ "id": "anthropic",
310
+ "type": "anthropic",
311
+ "apiKey": "REPLACE_ME_BEFORE_WRITING",
312
+ "baseUrl": "https://api.anthropic.com"
313
+ }
314
+ ]
315
+ }
316
+ '@ | Set-Content -Path $file -Encoding UTF8
317
+ # Then patch the apiKey with the user-provided value
318
+ \`\`\`
319
+
320
+ **DO:** Patch the placeholder with the actual key using \`jq\` (preferred) or a simple \`sed\`. Then read the file back to confirm the key is no longer \`REPLACE_ME_BEFORE_WRITING\`.
321
+
322
+ **DO:** If the user declines to provide a key in the AskUserQuestion (selects "Skip for now"), do **not** write a placeholder config. Tell the user that Phase 5 (First capture) will be skipped, and that they can re-run the skill after adding a key.
205
323
 
206
- **PAUSE** \u2014 wait for the user to confirm they have at least one provider with a key.
324
+ > **PAUSE** \u2014 use \`AskUserQuestion\` with header \`Provider key\` and options \`["Key is in, continue", "Skip for now, I'll add it later"]\`. Wait for the answer.
207
325
 
208
326
  ---
209
327
 
210
328
  ## Phase 3: Start proxy
211
329
 
212
- **EXPLAIN:** "Time to start the proxy. It binds to port ${port} by default, kills any process already on that port, and prints the URL."
330
+ **EXPLAIN:** "Time to start the proxy. It binds to port ${port} by default, reuses an already-running healthy llm-inspector, and prints the URL. Use \`--force-restart\` only when you intentionally want to replace the existing process."
213
331
 
214
- **DO:** Start the proxy in the background so you can keep working.
332
+ **DO:** Skip this phase entirely if the Phase 0 health check already reported \`PROXY: up\` and the user opted to skip done phases.
333
+
334
+ **DO:** Otherwise, start the proxy with the explicit \`--background --no-open\` flags. On Windows, resolve the npm shim first and launch it through \`Start-Process -WindowStyle Hidden\` so setup does not flash a command window.
215
335
 
216
336
  \`\`\`bash
217
337
  # Unix / macOS / WSL
218
- nohup llm-inspector --no-open > /tmp/llm-inspector.log 2>&1 &
338
+ llm-inspector --background --no-open > /tmp/llm-inspector.log 2>&1
219
339
  \`\`\`
220
340
 
221
341
  \`\`\`powershell
222
- # Windows PowerShell
223
- Start-Process -FilePath "llm-inspector" -ArgumentList "--no-open" -RedirectStandardOutput "$env:TEMP\\llm-inspector.log" -RedirectStandardError "$env:TEMP\\llm-inspector.err.log" -WindowStyle Hidden
342
+ # Windows PowerShell \u2014 single-quoted so $env: expands correctly.
343
+ # Locate the binary on PATH first (works for npm, pnpm, yarn, volta, fnm).
344
+ # If not on PATH, fall back to the common npm global shim at $env:APPDATA.
345
+ # As a last resort, let cmd /c resolve it through PATHEXT.
346
+ $log = Join-Path $env:TEMP 'llm-inspector.log'
347
+ $err = Join-Path $env:TEMP 'llm-inspector.err.log'
348
+ $found = Get-Command llm-inspector -ErrorAction SilentlyContinue
349
+ if ($found) {
350
+ $shim = $found.Source
351
+ $args = '--background','--no-open'
352
+ } elseif (Test-Path (Join-Path $env:APPDATA 'npm/llm-inspector.cmd')) {
353
+ $shim = Join-Path $env:APPDATA 'npm/llm-inspector.cmd'
354
+ $args = '--background','--no-open'
355
+ } else {
356
+ # bin not on PATH and not at the default npm prefix \u2014 let cmd resolve it
357
+ $shim = 'cmd.exe'
358
+ $args = '/c','llm-inspector','--background','--no-open'
359
+ }
360
+ Start-Process -FilePath $shim -ArgumentList $args -RedirectStandardOutput $log -RedirectStandardError $err -WindowStyle Hidden
224
361
  \`\`\`
225
362
 
226
363
  Then wait for the port to be ready:
227
364
 
228
365
  \`\`\`bash
229
- # Wait up to 10s for the port to come up
230
366
  for i in $(seq 1 20); do
231
367
  curl -fsS "http://localhost:${port}/api/health" >/dev/null 2>&1 && echo "ready" && break
232
368
  sleep 0.5
@@ -239,7 +375,7 @@ done
239
375
  curl -sS "http://localhost:${port}/api/health"
240
376
  \`\`\`
241
377
 
242
- **PAUSE** \u2014 if the health check fails, show the user the log file (\`/tmp/llm-inspector.log\` or \`%TEMP%\\\\llm-inspector.log\`) and diagnose. Common issues: another process on the port, firewall, missing providers.
378
+ > **PAUSE** \u2014 if the health check fails, show the user the log file (\`/tmp/llm-inspector.log\` or \`%TEMP%\\llm-inspector.log\`) and diagnose. Common issues: another process on the port, firewall, missing providers. Use \`AskUserQuestion\` with header \`Proxy up?\` and options \`["Yes, proxy is up", "No, I see an error in the log"]\`. Wait for the answer.
243
379
 
244
380
  ---
245
381
 
@@ -267,18 +403,22 @@ mimo
267
403
 
268
404
  For a tool that wasn't auto-detected, fall through to the generic curl test in the next phase \u2014 the user can wire their tool later.
269
405
 
270
- **PAUSE** \u2014 wait for the user to confirm they've set the env var (or that they're going to use the curl test instead).
406
+ > **PAUSE** \u2014 use \`AskUserQuestion\` with header \`Tool wired?\` and options \`["Yes, env var is set, claude is running", "No, I'm going to use the curl test instead"]\`. Wait for the answer.
271
407
 
272
408
  ---
273
409
 
274
410
  ## Phase 4.5: Wire MCP server
275
411
 
276
- **EXPLAIN:** "The proxy also exposes an MCP server at \`http://localhost:${port}/api/mcp\`. Your AI agent can query logs, replay requests, and test providers through it \u2014 no need to leave the editor. We'll add an \`mcpServers\` entry so your agent picks it up automatically."
412
+ **EXPLAIN:** "The proxy also exposes an MCP server at \`http://localhost:${port}/api/mcp\`. Your AI agent can query logs, replay requests, and test providers through it \u2014 no need to leave the editor."
277
413
 
278
- **DO:** Add (or merge) an \`mcpServers\` entry in the user's Claude Code config. The file is \`~/.claude.json\` on all platforms; the MCP spec entry uses HTTP Streamable transport:
414
+ **DO:** Skip this phase if Phase 0 reported \`MCP: wired in <path>\` and the user opted to skip done phases.
415
+
416
+ **DO:** Otherwise, check the project-level \`.mcp.json\` first (preferred \u2014 modern Claude Code convention), then fall back to \`~/.claude.json\`. Use the \`Read\` tool to inspect; do **not** \`cat\` a 40 KB file into the conversation.
417
+
418
+ If neither has an \`llm-inspector\` entry, add one. The simplest path is to write to project \`.mcp.json\` (create it if missing):
279
419
 
280
420
  \`\`\`json
281
- // ~/.claude.json (merge into existing mcpServers)
421
+ // .mcp.json (project root)
282
422
  {
283
423
  "mcpServers": {
284
424
  "llm-inspector": {
@@ -289,26 +429,7 @@ For a tool that wasn't auto-detected, fall through to the generic curl test in t
289
429
  }
290
430
  \`\`\`
291
431
 
292
- If \`mcpServers\` already exists, add \`llm-inspector\` as a new key \u2014 don't clobber existing entries.
293
-
294
- PowerShell one-liner that creates the file if missing, or merges if present:
295
-
296
- \`\`\`powershell
297
- $cfg = Join-Path $env:USERPROFILE ".claude.json"
298
- if (Test-Path $cfg) {
299
- $doc = Get-Content $cfg -Raw | ConvertFrom-Json
300
- } else {
301
- $doc = [pscustomobject]@{ mcpServers = [pscustomobject]@{} }
302
- }
303
- if (-not ($doc.PSObject.Properties.Name -contains "mcpServers")) {
304
- $doc | Add-Member -NotePropertyName mcpServers -NotePropertyValue ([pscustomobject]@{})
305
- }
306
- $doc.mcpServers | Add-Member -NotePropertyName "llm-inspector" -NotePropertyValue ([pscustomobject]@{
307
- type = "http"
308
- url = "http://localhost:${port}/api/mcp"
309
- }) -Force
310
- $doc | ConvertTo-Json -Depth 10 | Set-Content $cfg -Encoding UTF8
311
- \`\`\`
432
+ If \`mcpServers\` already exists in \`.mcp.json\`, merge the \`llm-inspector\` key into it via the \`Edit\` tool \u2014 do not overwrite other entries. If you can't create a project \`.mcp.json\` (no project root, permission, etc.), fall back to merging into \`~/.claude.json\` using the same \`Read\`/\`Edit\` pattern.
312
433
 
313
434
  **DO:** Verify the handshake. The MCP \`initialize\` request should return 200 with a \`serverInfo\` payload \u2014 that proves the server is mounted and reachable:
314
435
 
@@ -325,41 +446,33 @@ curl -sS -X POST "http://localhost:${port}/api/mcp" \\
325
446
  "capabilities": {},
326
447
  "clientInfo": { "name": "onboard-check", "version": "0" }
327
448
  }
328
- }'
329
- # expect: HTTP 200, body contains "result" with serverInfo.name = "llm-inspector"
449
+ }' | grep -o '"name":"llm-inspector"' && echo "handshake OK"
330
450
  \`\`\`
331
451
 
332
- After the handshake, issue a \`tools/list\` to confirm the tool catalog is reachable:
452
+ The \`grep -o '"name":"llm-inspector"'\` extracts only the serverInfo name \u2014 do not dump the full response. If the server returns session IDs, store the \`mcp-session-id\` header from the first response and use it for the follow-up \`tools/list\` call.
333
453
 
334
- \`\`\`bash
335
- SESSION=<session-id-from-initialize-response>
336
- curl -sS -X POST "http://localhost:${port}/api/mcp" \\
337
- -H "Content-Type: application/json" \\
338
- -H "Accept: application/json, text/event-stream" \\
339
- -H "mcp-session-id: $SESSION" \\
340
- -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
341
- # expect: a result.tools array with at least 1 entry
342
- \`\`\`
343
-
344
- **PAUSE** \u2014 if \`initialize\` returns non-200, show the user the proxy log and re-check the JSON syntax. If it returns 200 but \`tools/list\` fails, the server is up but the session wasn't carried over \u2014 re-use the \`mcp-session-id\` header from the first response.
454
+ > **PAUSE** \u2014 use \`AskUserQuestion\` with header \`MCP OK?\` and options \`["Yes, handshake returned 200", "No, the call failed"]\`. Wait for the answer.
345
455
 
346
456
  ---
347
457
 
348
458
  ## Phase 5: First capture
349
459
 
350
- **EXPLAIN:** "Let's prove the proxy works end-to-end. We'll send one real request through it and confirm the log shows up in the API. A 401/403 from the upstream is fine \u2014 the point is that the *request* reaches the proxy."
460
+ **EXPLAIN:** "Let's prove the proxy works end-to-end. We'll send one real request through it and confirm the log shows up in the API."
351
461
 
352
- **DO:** Fire a minimal Anthropic-format request through the proxy. This works regardless of which tool the user wired up:
462
+ **DO:** First, re-check the config. If the \`apiKey\` is still a \`REPLACE_ME_BEFORE_WRITING\` placeholder (user opted out in Phase 2), **skip the capture test** and tell the user to fill in their key and re-run the skill. A 401 from the upstream is fine if they did provide a real key \u2014 the proxy will still log the request.
463
+
464
+ Fire a minimal Anthropic-format request through the proxy:
353
465
 
354
466
  \`\`\`bash
355
467
  curl -sS -X POST "http://localhost:${port}/proxy/v1/messages" \\
356
468
  -H "Content-Type: application/json" \\
357
469
  -H "anthropic-version: 2023-06-01" \\
358
470
  -H "x-api-key: \${LLM_INSPECTOR_API_KEY:-sk-no-key-needed-for-routing}" \\
359
- -d '{"model":"claude-3-5-sonnet-20241022","max_tokens":1,"messages":[{"role":"user","content":"ping"}]}'
471
+ -d '{"model":"claude-3-5-sonnet-20241022","max_tokens":1,"messages":[{"role":"user","content":"ping"}]}' \\
472
+ -o /tmp/llm-inspector-capture.json -w 'STATUS:%{http_code}\\n'
360
473
  \`\`\`
361
474
 
362
- **DO:** Poll the logs API for up to 5 seconds. A 200 with at least one entry means success:
475
+ **DO:** Poll the logs API for up to 5 seconds. A 200 with at least one entry means the request reached the proxy:
363
476
 
364
477
  \`\`\`bash
365
478
  for i in $(seq 1 10); do
@@ -367,13 +480,24 @@ for i in $(seq 1 10); do
367
480
  count=$(echo "$resp" | grep -o '"total":[0-9]*' | head -1 | grep -o '[0-9]*$')
368
481
  if [ "\${count:-0}" -ge 1 ]; then
369
482
  echo "captured"
483
+ echo "$resp" | head -c 400
370
484
  break
371
485
  fi
372
486
  sleep 0.5
373
487
  done
374
488
  \`\`\`
375
489
 
376
- **PAUSE** \u2014 show the user the captured log entry (id, status, model, elapsed). If the count never reached 1, the proxy didn't see the request \u2014 re-check the env var and the proxy log.
490
+ **DO:** Diagnose the response based on the actual status and body. **Do not** default to "auth failure" for every 4xx.
491
+
492
+ | Status | Body hint | Meaning |
493
+ |--------|-----------|---------|
494
+ | 200 | normal | Real success \u2014 the upstream returned data |
495
+ | 401 | \`"unauthorized"\` or similar | Upstream rejected the key (expected with a test key) |
496
+ | 403 | \`"Request not allowed"\` | **Proxy's allowlist** \u2014 not an auth failure, the proxy rejected the model/config. Show the user the proxy log. |
497
+ | 403 | other text | Could be upstream ACL \u2014 different problem |
498
+ | 5xx | anything | Upstream network error |
499
+
500
+ > **PAUSE** \u2014 use \`AskUserQuestion\` with header \`Captured?\` and options matching the diagnosis above. Wait for the answer.
377
501
 
378
502
  ---
379
503
 
@@ -390,14 +514,14 @@ done
390
514
  # Unix / macOS
391
515
  lsof -ti:${port} | xargs -r kill -9
392
516
 
393
- # Windows PowerShell
394
- Get-NetTCPConnection -LocalPort ${port} | ForEach-Object { Stop-Process -Id \\$_.OwningProcess -Force }
517
+ # Windows PowerShell \u2014 single-quoted so $env: expands correctly
518
+ Get-NetTCPConnection -LocalPort $port | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
395
519
  \`\`\`
396
520
 
397
521
  - **Re-run onboard**: \`llm-inspector onboard --force\` refreshes this skill.
398
522
  - **Full docs**: see the project README (linked from the Web UI footer).
399
523
 
400
- **PAUSE** \u2014 let the user know they can come back to this skill at any time via \`/llm-inspector:onboard\` if they want a refresher, and call out that \`/llm-inspector:onboard --skip-tool-wire\` is the way to re-run later phases without re-detecting the tool.
524
+ > **PAUSE** \u2014 use \`AskUserQuestion\` with header \`All set?\` and options \`["All set, I'm done", "Wait, I want to revisit a phase"]\`. Wait for the answer.
401
525
 
402
526
  You're done. Happy inspecting.
403
527
  `;
@@ -407,6 +531,7 @@ var init_skill_onboard = __esm({
407
531
  "src/cli/templates/skill-onboard.ts"() {
408
532
  "use strict";
409
533
  REQUIRED_PHASE_HEADINGS = [
534
+ "Phase 0: Idempotency check",
410
535
  "Preflight",
411
536
  "Phase 1: Welcome",
412
537
  "Phase 2: Provider setup",
@@ -629,23 +754,105 @@ var init_onboard = __esm({
629
754
 
630
755
  // src/cli.ts
631
756
  import { spawn, execSync } from "node:child_process";
757
+ import { createConnection } from "node:net";
632
758
  import { fileURLToPath as fileURLToPath2 } from "node:url";
633
759
  import { dirname as dirname2, join as join3 } from "node:path";
634
760
  var __filename2 = fileURLToPath2(import.meta.url);
635
761
  var __dirname2 = dirname2(__filename2);
636
762
  var DEFAULT_PORT2 = 25947;
763
+ var LOCAL_PROBE_TIMEOUT_MS = 2e3;
637
764
  var subcommand = process.argv[2];
638
765
  if (subcommand === "onboard") {
639
766
  const { runOnboard: runOnboard2 } = await Promise.resolve().then(() => (init_onboard(), onboard_exports));
640
767
  const code = await runOnboard2(process.argv.slice(3));
641
768
  process.exit(code);
642
769
  }
643
- runStart(process.argv.slice(2));
644
- function runStart(args) {
770
+ await runStart(process.argv.slice(2));
771
+ async function isInspectorHealthy(port) {
772
+ const controller = new AbortController();
773
+ const timeout = setTimeout(() => controller.abort(), LOCAL_PROBE_TIMEOUT_MS);
774
+ try {
775
+ const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
776
+ cache: "no-store",
777
+ signal: controller.signal
778
+ });
779
+ return response.ok;
780
+ } catch {
781
+ return false;
782
+ } finally {
783
+ clearTimeout(timeout);
784
+ }
785
+ }
786
+ function isPortAcceptingConnections(port) {
787
+ return new Promise((resolve) => {
788
+ const socket = createConnection({ host: "127.0.0.1", port });
789
+ const finish = (value) => {
790
+ socket.removeAllListeners();
791
+ socket.destroy();
792
+ resolve(value);
793
+ };
794
+ socket.setTimeout(LOCAL_PROBE_TIMEOUT_MS);
795
+ socket.once("connect", () => finish(true));
796
+ socket.once("timeout", () => finish(false));
797
+ socket.once("error", () => finish(false));
798
+ });
799
+ }
800
+ function sleep(ms) {
801
+ return new Promise((resolve) => {
802
+ setTimeout(resolve, ms);
803
+ });
804
+ }
805
+ async function waitForInspectorHealthy(port, timeoutMs) {
806
+ const start = Date.now();
807
+ while (Date.now() - start < timeoutMs) {
808
+ if (await isInspectorHealthy(port)) return true;
809
+ await sleep(250);
810
+ }
811
+ return false;
812
+ }
813
+ function openBrowser(targetUrl) {
814
+ let command;
815
+ switch (process.platform) {
816
+ case "darwin":
817
+ command = ["open", targetUrl];
818
+ break;
819
+ case "linux":
820
+ command = ["xdg-open", targetUrl];
821
+ break;
822
+ case "win32":
823
+ command = ["cmd", "/c", "start", "", targetUrl];
824
+ break;
825
+ default:
826
+ break;
827
+ }
828
+ if (command === void 0) return;
829
+ const [bin, ...cmdArgs] = command;
830
+ if (bin === void 0) return;
831
+ const browserProcess = spawn(bin, cmdArgs, {
832
+ stdio: "ignore",
833
+ detached: true,
834
+ windowsHide: true
835
+ });
836
+ browserProcess.unref();
837
+ }
838
+ function waitForProcessExit(child) {
839
+ return new Promise((resolve) => {
840
+ child.once("exit", (code) => {
841
+ resolve(code ?? 1);
842
+ });
843
+ child.once("error", () => {
844
+ resolve(1);
845
+ });
846
+ });
847
+ }
848
+ async function runStart(args) {
645
849
  const envPort = process.env["PORT"];
646
850
  const portDefault = envPort !== void 0 ? Number(envPort) : DEFAULT_PORT2;
647
851
  let port = portDefault;
648
852
  let open = true;
853
+ let openWasSpecified = false;
854
+ let background = false;
855
+ let forceRestart = false;
649
856
  let configDir;
650
857
  let providersJson;
651
858
  for (let i = 0; i < args.length; i++) {
@@ -658,9 +865,18 @@ function runStart(args) {
658
865
  break;
659
866
  case "--no-open":
660
867
  open = false;
868
+ openWasSpecified = true;
661
869
  break;
662
870
  case "--open":
663
871
  open = true;
872
+ openWasSpecified = true;
873
+ break;
874
+ case "--force-restart":
875
+ case "--restart":
876
+ forceRestart = true;
877
+ break;
878
+ case "--background":
879
+ background = true;
664
880
  break;
665
881
  case "--config-dir":
666
882
  configDir = args[i + 1];
@@ -674,6 +890,11 @@ function runStart(args) {
674
890
  break;
675
891
  }
676
892
  }
893
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
894
+ console.error(`Invalid port: ${String(port)}. Use --port <1-65535>.`);
895
+ process.exitCode = 1;
896
+ return;
897
+ }
677
898
  function killProcessOnPort(targetPort) {
678
899
  const platform = process.platform;
679
900
  try {
@@ -681,7 +902,8 @@ function runStart(args) {
681
902
  if (platform === "win32") {
682
903
  const output = execSync(`netstat -ano | findstr :${targetPort}`, {
683
904
  encoding: "utf8",
684
- timeout: 5e3
905
+ timeout: 5e3,
906
+ windowsHide: true
685
907
  });
686
908
  const lines = output.trim().split("\n");
687
909
  for (const line of lines) {
@@ -700,8 +922,12 @@ function runStart(args) {
700
922
  pids = [...new Set(pids)];
701
923
  for (const pid of pids) {
702
924
  try {
703
- console.log(`Killing process ${pid} on port ${port}...`);
704
- execSync(`taskkill /PID ${pid} /F`, { encoding: "utf8", timeout: 5e3 });
925
+ console.log(`Killing process ${pid} on port ${targetPort}...`);
926
+ execSync(`taskkill /PID ${pid} /F`, {
927
+ encoding: "utf8",
928
+ timeout: 5e3,
929
+ windowsHide: true
930
+ });
705
931
  } catch {
706
932
  }
707
933
  }
@@ -717,7 +943,7 @@ function runStart(args) {
717
943
  pids = [...new Set(pids)];
718
944
  for (const pid of pids) {
719
945
  try {
720
- console.log(`Killing process ${pid} on port ${port}...`);
946
+ console.log(`Killing process ${pid} on port ${targetPort}...`);
721
947
  execSync(`kill -9 ${pid}`, { encoding: "utf8", timeout: 5e3 });
722
948
  } catch {
723
949
  }
@@ -727,8 +953,24 @@ function runStart(args) {
727
953
  }
728
954
  }
729
955
  process.env["PORT"] = String(port);
730
- killProcessOnPort(port);
731
956
  const url = `http://localhost:${port}`;
957
+ if (!forceRestart && await isInspectorHealthy(port)) {
958
+ console.log(`llm-inspector is already running at ${url}`);
959
+ console.log(`Use --force-restart to restart the existing instance.`);
960
+ if (open && openWasSpecified) {
961
+ openBrowser(url);
962
+ }
963
+ return;
964
+ }
965
+ if (!forceRestart && await isPortAcceptingConnections(port)) {
966
+ console.error(`Port ${port} is already in use, but it is not a healthy llm-inspector.`);
967
+ console.error(`Stop that process, choose --port <n>, or re-run with --force-restart.`);
968
+ process.exitCode = 1;
969
+ return;
970
+ }
971
+ if (forceRestart) {
972
+ killProcessOnPort(port);
973
+ }
732
974
  console.log(`Server running at ${url}`);
733
975
  console.log(` Proxy: ${url}/proxy`);
734
976
  console.log(``);
@@ -746,26 +988,6 @@ function runStart(args) {
746
988
  console.log(
747
989
  ` Example: ROUTES='{"claude-":"https://api.anthropic.com","MiniMax":"https://api.minimaxi.com/anthropic"}'`
748
990
  );
749
- const openBrowser = (targetUrl) => {
750
- let command;
751
- switch (process.platform) {
752
- case "darwin":
753
- command = ["open", targetUrl];
754
- break;
755
- case "linux":
756
- command = ["xdg-open", targetUrl];
757
- break;
758
- case "win32":
759
- command = ["cmd", "/c", "start", targetUrl];
760
- break;
761
- default:
762
- break;
763
- }
764
- if (command === void 0) return;
765
- const [bin, ...cmdArgs] = command;
766
- if (bin === void 0) return;
767
- spawn(bin, cmdArgs, { stdio: "ignore", detached: true });
768
- };
769
991
  if (open) {
770
992
  openBrowser(url);
771
993
  }
@@ -774,8 +996,11 @@ function runStart(args) {
774
996
  const serverEnv = { ...process.env };
775
997
  if (configDir !== void 0) {
776
998
  let resolvedPath = join3(configDir, "config.json");
777
- if (resolvedPath.startsWith("\\c\\")) {
778
- resolvedPath = "C:" + resolvedPath;
999
+ const msysMatch = /^\\([a-z])\\(.*)$/i.exec(resolvedPath);
1000
+ if (msysMatch !== null) {
1001
+ const drive = (msysMatch[1] ?? "").toUpperCase();
1002
+ const rest = msysMatch[2] ?? "";
1003
+ resolvedPath = `${drive}:\\${rest}`;
779
1004
  }
780
1005
  serverEnv["LLM_INSPECTOR_CONFIG_PATH"] = resolvedPath;
781
1006
  }
@@ -783,9 +1008,20 @@ function runStart(args) {
783
1008
  serverEnv["LLM_INSPECTOR_PROVIDERS_JSON"] = providersJson;
784
1009
  }
785
1010
  const serverProcess = spawn(process.execPath, [serverPath], {
786
- stdio: ["ignore", "inherit", "inherit"],
787
- detached: true,
788
- env: serverEnv
1011
+ stdio: background ? ["ignore", "ignore", "ignore"] : "inherit",
1012
+ detached: background,
1013
+ env: serverEnv,
1014
+ windowsHide: background
789
1015
  });
790
- serverProcess.unref();
1016
+ if (background) {
1017
+ serverProcess.unref();
1018
+ if (await waitForInspectorHealthy(port, 5e3)) {
1019
+ console.log(`llm-inspector background server is ready at ${url}`);
1020
+ return;
1021
+ }
1022
+ console.error(`llm-inspector background server did not become ready at ${url}.`);
1023
+ process.exitCode = 1;
1024
+ return;
1025
+ }
1026
+ process.exitCode = await waitForProcessExit(serverProcess);
791
1027
  }