skalpel 3.0.25 → 3.0.27

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/INSTALL.md CHANGED
@@ -37,19 +37,19 @@ All three paths produce identical binaries — same SHAs, same cosign signatures
37
37
 
38
38
  ## First-run flow
39
39
 
40
- The first-run flow assumes the user has obtained an `sk-skalpel-*` API key on Web (per the Auth handoff section of `../../design-dockets/cross-surface-contract.md`). The flow is:
40
+ Post-W4 (OAuth port): the first-run flow is a single browser-loopback Cognito sign-in. The historical `sk-skalpel-*` API-key paste path is gone — the daemon no longer persists or sends api_keys on any code path. The flow is:
41
41
 
42
- 1. The user runs `npx skalpel` (or `skalpel` after a global install). The npm package is materialized; both binaries are present.
43
- 2. The TUI detects the absence of `auth.json` and enters the install wizard. The wizard prompts the user to paste the API key copied from Web. The plaintext value is shown on Web exactly once at creation time; the user pastes it here.
44
- 3. The wizard writes `auth.json` to the per-OS configuration directory (per `SPEC.md` §7). The file is owned by the user with `0600` permissions.
45
- 4. The wizard invokes `skalpeld` to start the daemon. The TUI shows the `Starting skalpeld…` splash for up to three seconds (per spec §8.5).
42
+ 1. The user runs `npx skalpel login` (or `skalpel login` after a global install). The npm package is materialized; both binaries are present.
43
+ 2. `skalpel login` opens a browser to the Skalpel hosted-UI sign-in page (Cognito), waits on a loopback HTTP callback, and validates the signed envelope returned by the callback.
44
+ 3. On a verified envelope, the daemon writes `auth.json` to the per-OS configuration directory (per `SPEC.md` §7). The file is owned by the user with `0600` permissions and carries the Cognito JWT bundle (access_token, refresh_token, expires_at).
45
+ 4. The user runs `skalpel` (or `npx skalpel`). The TUI detects a usable `auth.json`, invokes `skalpeld` to start the daemon, and shows the `Starting skalpeld…` splash for up to three seconds (per spec §8.5).
46
46
  5. Once the daemon is reachable, the TUI lands the user on the Engines tab. The matrix renders for whatever agents the daemon has detected so far. If no agents have been opened since installation, the empty-state message from spec §3.2 is shown.
47
47
 
48
48
  This flow corresponds to Journey 1 — First launch, fresh authentication — in `SPEC.md`. That narrative describes what the user feels at each step; this document describes what the install machinery actually does.
49
49
 
50
- If the user runs `skalpel` without first obtaining an API key, the TUI exits per spec §8.4 with a stderr pointer at `skalpel login`. The wizard above is the path for users who arrived through the post-signup install hub on Web; users with broken or missing auth state are returned to the shell. To sign out of an existing account on this machine, run `skalpel logout` from a shell — the CLI revokes the backend session (best-effort) and removes `auth.json` via `internal/auth.Delete`.
50
+ If the user runs `skalpel` without first running `skalpel login`, the TUI exits per spec §8.4 with a stderr pointer at `skalpel login`. To sign out of an existing account on this machine, run `skalpel logout` from a shell — the CLI revokes the backend session (best-effort) and removes `auth.json` via `internal/auth.Delete`.
51
51
 
52
- An alternative first-run flow uses `skalpel login` from the shell directly. That command runs a device-code flow against Cognito (per the Auth handoff section of the cross-surface contract) and writes `auth.json` without the user having to paste an API key. The two flows produce equivalent on-disk state; the API-key-paste flow is the one a user lands in when arriving from the Web post-signup install hub, and the device-code flow is the one a user runs when reauthenticating an account that has already been signed in on this machine before.
52
+ Historical note: prior versions of skalpel supported pasting a `sk-skalpel-*` API key copied from the web dashboard as an alternative to `skalpel login`. The W4 OAuth-port grep-delete removed both the api_key-paste wizard and the daemon-side api_key sending path. Users on stale auth.json files (api_key-only, no Cognito bundle) need to run `skalpel login` once to refresh.
53
53
 
54
54
  ## Daemon lifecycle on subsequent launches
55
55
 
@@ -123,7 +123,7 @@ The handshake is also where the TUI reads the daemon's engine list and the daemo
123
123
  A small set of bundling questions are recorded here for the build phase. Each will be resolved before v1 ships; none affect the design commitments above.
124
124
 
125
125
  - **`npx` cache eviction and version-pinning UX.** `npx skalpel` caches per-version; a user who runs `npx skalpel@1.2.3` after `1.2.4` has shipped continues to execute `1.2.3` until npm's cache resolves. The auto-update notice surfaces in the TUI, but the underlying npx semantics are an open ergonomic question.
126
- - **Signed-link pre-population of the API key.** Whether the post-signup install path emits a signed link that pre-populates the `npx skalpel` wizard with the user's API key, or whether the user pastes the key by hand, is recorded in `../../design-dockets/cross-surface-contract.md` as an open question. The latter is the safer default; the former would shorten the first-run flow by one step.
126
+ - ~~**Signed-link pre-population of the API key.**~~ Obsolete post-W4 (OAuth-port grep-delete): the api_key-paste wizard the question referred to is gone. The first-run flow is browser-loopback Cognito sign-in via `skalpel login`. Left here as a historical breadcrumb for anyone tracing the pre-W4 design dockets.
127
127
  - **Bundle size budget.** The two binaries together set a download-size budget for first-time `npx` users; whether to ship a slim TUI front-end and a separately-downloaded `skalpeld` payload via a `postinstall` fetch (still in the same npm transaction) or to ship both in the package tarball directly is a build-phase decision. Either approach preserves the bundling commitment because both binaries are still resolved and authenticated within the same install operation.
128
128
  - **Install-time telemetry.** Whether the install path emits any telemetry event (e.g., "first install completed") is open. The TUI itself does not phone home in v1 (per the privacy stance in `SPEC.md` §05); whether the install wizard shares that posture or whether anonymous install counts are acceptable for release-quality measurement is a build-phase decision.
129
129
  - **Windows install ergonomics.** Whether `npx skalpel` on Windows requires Windows Terminal or a recent PowerShell to render the Bubble Tea TUI's adaptive colours and key bindings cleanly, and whether a fallback message should explain the requirement, is open. The bundling commitment is platform-agnostic; the first-run experience may not be.
package/README.md CHANGED
@@ -4,13 +4,13 @@ Skalpel is the local proxy and terminal UI for coding agents — Cursor, Claude
4
4
 
5
5
  ## Install and first run
6
6
 
7
- After signing up at [skalpel.ai](https://skalpel.ai) and copying your `sk-skalpel-*` API key:
7
+ After signing up at [skalpel.ai](https://skalpel.ai):
8
8
 
9
9
  ```
10
- npx skalpel
10
+ npx skalpel login
11
11
  ```
12
12
 
13
- `npx` materializes both binaries, walks you through pasting the API key, starts `skalpeld`, and lands you on the Engines tab. To install persistently:
13
+ `npx skalpel login` materializes both binaries and runs a browser-loopback sign-in against Cognito; the daemon writes the Cognito JWT bundle to `auth.json` and the next `skalpel` launch lands you on the Engines tab. To install persistently:
14
14
 
15
15
  ```
16
16
  npm install -g skalpel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.0.25",
3
+ "version": "3.0.27",
4
4
  "description": "Skalpel — local proxy and TUI for coding agents (skalpel + skalpeld bundle).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://skalpel.ai",
@@ -31,7 +31,8 @@
31
31
  "postinstall": "node postinstall/index.js",
32
32
  "preuninstall": "node postinstall/uninstall.js",
33
33
  "test": "echo 'no top-level tests; run make test or npm run test:rc-edit' && exit 0",
34
- "test:rc-edit": "node postinstall/lib/rc-edit.test.js"
34
+ "test:rc-edit": "node postinstall/lib/rc-edit.test.js",
35
+ "test:postinstall": "node --test postinstall/index.test.js"
35
36
  },
36
37
  "files": [
37
38
  "npm-bin/",
@@ -54,10 +55,10 @@
54
55
  "x64"
55
56
  ],
56
57
  "optionalDependencies": {
57
- "@skalpelai/skalpel-darwin-arm64": "3.0.25",
58
- "@skalpelai/skalpel-darwin-x64": "3.0.25",
59
- "@skalpelai/skalpel-linux-arm64": "3.0.25",
60
- "@skalpelai/skalpel-linux-x64": "3.0.25",
61
- "@skalpelai/skalpel-win32-x64": "3.0.25"
58
+ "@skalpelai/skalpel-darwin-arm64": "3.0.27",
59
+ "@skalpelai/skalpel-darwin-x64": "3.0.27",
60
+ "@skalpelai/skalpel-linux-arm64": "3.0.27",
61
+ "@skalpelai/skalpel-linux-x64": "3.0.27",
62
+ "@skalpelai/skalpel-win32-x64": "3.0.27"
62
63
  }
63
64
  }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // Postinstall wizard regression — exercises the entry-point shipped
3
+ // to every `npm install skalpel` user. Tests run under Node's built-in
4
+ // `node --test` runner (stdlib only; matches the convention set by
5
+ // postinstall/lib/rc-edit.test.js).
6
+ //
7
+ // Scope (closes GAP-5):
8
+ // - parseArgs maps flags, env-var dry-run, and help correctly
9
+ // - dry-run install completes with exit 0 and writes nothing
10
+ // - re-running dry-run install is idempotent (no observable drift)
11
+ // - --help returns 0 and prints usage
12
+ //
13
+ // We exercise the wizard as a child process so the existing entry-
14
+ // point IIFE in index.js does not need to be refactored; tests stay
15
+ // hermetic by setting SKALPEL_INSTALL_DRY_RUN=1 and forcing all
16
+ // service / rc paths to writable t.TempDir() so a regression cannot
17
+ // touch a developer's real shell rc.
18
+
19
+ 'use strict';
20
+
21
+ const { test } = require('node:test');
22
+ const assert = require('node:assert/strict');
23
+ const { spawnSync } = require('node:child_process');
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+
28
+ const indexPath = path.join(__dirname, 'index.js');
29
+
30
+ function mkTempEnv() {
31
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'skalpel-postinstall-test-'));
32
+ const xdgConfig = path.join(home, '.config');
33
+ fs.mkdirSync(xdgConfig, { recursive: true });
34
+ return {
35
+ HOME: home,
36
+ USERPROFILE: home,
37
+ XDG_CONFIG_HOME: xdgConfig,
38
+ SKALPEL_INSTALL_DRY_RUN: '1',
39
+ SKALPEL_BIN_DIR: path.join(home, 'bin'),
40
+ NO_COLOR: '1',
41
+ PATH: process.env.PATH || '',
42
+ // Force npx detection off so the wizard reaches its full step
43
+ // sequence rather than short-circuiting.
44
+ npm_config_user_agent: 'npm/test',
45
+ };
46
+ }
47
+
48
+ function runWizard(extraArgs = []) {
49
+ const env = mkTempEnv();
50
+ return spawnSync(process.execPath, [indexPath, ...extraArgs], {
51
+ env,
52
+ encoding: 'utf8',
53
+ timeout: 15_000,
54
+ });
55
+ }
56
+
57
+ test('parseArgs maps short and long flags', () => {
58
+ // require()-time evaluation must not run the IIFE: index.js gates
59
+ // it with `if (require.main === module)`, so require() is safe.
60
+ const mod = require('./index');
61
+ assert.equal(typeof mod.parseArgs, 'function', 'parseArgs export missing');
62
+ const dry = mod.parseArgs(['node', 'index.js', '--dry-run']);
63
+ assert.equal(dry.dryRun, true);
64
+ const help = mod.parseArgs(['node', 'index.js', '-h']);
65
+ assert.equal(help.help, true);
66
+ const verbose = mod.parseArgs(['node', 'index.js', '--verbose']);
67
+ assert.equal(verbose.verbose, true);
68
+ const skipBundle = mod.parseArgs(['node', 'index.js', '--skip-bundle']);
69
+ assert.equal(skipBundle.skipBundle, true);
70
+ });
71
+
72
+ test('SKALPEL_INSTALL_DRY_RUN=1 forces dry-run even without --dry-run', () => {
73
+ const prev = process.env.SKALPEL_INSTALL_DRY_RUN;
74
+ process.env.SKALPEL_INSTALL_DRY_RUN = '1';
75
+ try {
76
+ const mod = require('./index');
77
+ const opts = mod.parseArgs(['node', 'index.js']);
78
+ assert.equal(opts.dryRun, true);
79
+ } finally {
80
+ if (prev === undefined) delete process.env.SKALPEL_INSTALL_DRY_RUN;
81
+ else process.env.SKALPEL_INSTALL_DRY_RUN = prev;
82
+ }
83
+ });
84
+
85
+ test('--help exits 0 and prints usage', () => {
86
+ const r = runWizard(['--help']);
87
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
88
+ assert.match(r.stdout, /postinstall wizard/i);
89
+ assert.match(r.stdout, /usage:/i);
90
+ });
91
+
92
+ test('dry-run install completes with exit 0', () => {
93
+ const r = runWizard(['--dry-run']);
94
+ // Wizard contract: even on critical step failure, return 0 so npm
95
+ // install never aborts. Tests assert the *invariant*, not the path.
96
+ assert.equal(r.status, 0, `non-zero exit: stderr=${r.stderr}\nstdout=${r.stdout}`);
97
+ });
98
+
99
+ test('dry-run install is idempotent: second run mirrors first', () => {
100
+ // Same temp HOME both runs; the wizard should not leave behind
101
+ // state that perturbs the second run. We compare exit codes; output
102
+ // diffs are tolerated because timestamps / random suffixes vary.
103
+ const env = mkTempEnv();
104
+ function once() {
105
+ return spawnSync(process.execPath, [indexPath, '--dry-run'], {
106
+ env,
107
+ encoding: 'utf8',
108
+ timeout: 15_000,
109
+ });
110
+ }
111
+ const r1 = once();
112
+ const r2 = once();
113
+ assert.equal(r1.status, 0, `first run: stderr=${r1.stderr}`);
114
+ assert.equal(r2.status, 0, `second run: stderr=${r2.stderr}`);
115
+ });
116
+
117
+ test('dry-run install does not write to HOME', () => {
118
+ const env = mkTempEnv();
119
+ const home = env.HOME;
120
+ // Capture initial file inventory.
121
+ function snapshot(dir) {
122
+ const acc = [];
123
+ (function walk(p) {
124
+ let entries;
125
+ try { entries = fs.readdirSync(p, { withFileTypes: true }); }
126
+ catch { return; }
127
+ for (const e of entries) {
128
+ const full = path.join(p, e.name);
129
+ acc.push(full);
130
+ if (e.isDirectory()) walk(full);
131
+ }
132
+ })(dir);
133
+ return acc.sort();
134
+ }
135
+ const before = snapshot(home);
136
+ spawnSync(process.execPath, [indexPath, '--dry-run'], {
137
+ env,
138
+ encoding: 'utf8',
139
+ timeout: 15_000,
140
+ });
141
+ const after = snapshot(home);
142
+ // Dry-run must not create *new* files in HOME beyond what mkTempEnv
143
+ // pre-seeded (.config). Any drift is a regression on the dry-run
144
+ // contract.
145
+ const added = after.filter((p) => !before.includes(p));
146
+ assert.deepEqual(added, [], `dry-run wrote files: ${added.join(', ')}`);
147
+ });
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ function envBlockValues(port) {
4
+ const p = String(port || 7878);
5
+ const root = `http://127.0.0.1:${p}`;
6
+ return {
7
+ ANTHROPIC_API_URL: root,
8
+ ANTHROPIC_BASE_URL: root,
9
+ OPENAI_BASE_URL: `${root}/v1`,
10
+ OPENAI_API_BASE: `${root}/v1`,
11
+ SKALPEL_PROXY_URL: root,
12
+ SKALPEL_CODEX_OPENAI_BASE_URL: `${root}/v1`,
13
+ SKALPEL_CODEX_CHATGPT_BASE_URL: `${root}/backend-api`,
14
+ };
15
+ }
16
+
17
+ const claudeCode = {
18
+ name: 'claude-code',
19
+ wrappers: {
20
+ posix: `
21
+ # Pre-launch status hint + fail-open for Claude Code.
22
+ # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
23
+ if [ -z "\${SKALPEL_NO_AGENT_WRAP:-}" ] && ! alias claude >/dev/null 2>&1 && command -v skalpel >/dev/null 2>&1; then
24
+ claude() {
25
+ if skalpel status >&2; then
26
+ command claude "$@"
27
+ else
28
+ ANTHROPIC_API_URL= ANTHROPIC_BASE_URL= OPENAI_BASE_URL= OPENAI_API_BASE= SKALPEL_PROXY_URL= SKALPEL_CODEX_OPENAI_BASE_URL= SKALPEL_CODEX_CHATGPT_BASE_URL= command claude "$@"
29
+ fi
30
+ }
31
+ fi`,
32
+ fish: `
33
+ # Pre-launch status hint + fail-open for Claude Code.
34
+ # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
35
+ if not set -q SKALPEL_NO_AGENT_WRAP; and not functions -q claude; and command -q skalpel
36
+ function claude
37
+ if skalpel status 1>&2
38
+ command claude $argv
39
+ else
40
+ env -u ANTHROPIC_API_URL -u ANTHROPIC_BASE_URL -u OPENAI_BASE_URL -u OPENAI_API_BASE -u SKALPEL_PROXY_URL -u SKALPEL_CODEX_OPENAI_BASE_URL -u SKALPEL_CODEX_CHATGPT_BASE_URL command claude $argv
41
+ end
42
+ end
43
+ end`,
44
+ powershell: `
45
+ # Pre-launch status hint + fail-open for Claude Code.
46
+ # Set $env:SKALPEL_NO_AGENT_WRAP=1 to disable.
47
+ $_skalpelOrigClaude = Get-Command claude.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
48
+ $_skalpelStatusBin = Get-Command skalpel -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
49
+ if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigClaude -and $_skalpelStatusBin) {
50
+ function global:claude {
51
+ & $script:_skalpelStatusBin.Source status 1>&2
52
+ if ($LASTEXITCODE -eq 0) {
53
+ & $script:_skalpelOrigClaude.Source @args
54
+ } else {
55
+ $_savedAnthropicApi = $env:ANTHROPIC_API_URL
56
+ $_savedAnthropicBase = $env:ANTHROPIC_BASE_URL
57
+ $_savedOpenAiBase = $env:OPENAI_BASE_URL
58
+ $_savedOpenAiApiBase = $env:OPENAI_API_BASE
59
+ $_savedSkalpelProxy = $env:SKALPEL_PROXY_URL
60
+ $_savedCodexOpenAi = $env:SKALPEL_CODEX_OPENAI_BASE_URL
61
+ $_savedCodexChatGPT = $env:SKALPEL_CODEX_CHATGPT_BASE_URL
62
+ $env:ANTHROPIC_API_URL = ''
63
+ $env:ANTHROPIC_BASE_URL = ''
64
+ $env:OPENAI_BASE_URL = ''
65
+ $env:OPENAI_API_BASE = ''
66
+ $env:SKALPEL_PROXY_URL = ''
67
+ $env:SKALPEL_CODEX_OPENAI_BASE_URL = ''
68
+ $env:SKALPEL_CODEX_CHATGPT_BASE_URL = ''
69
+ try { & $script:_skalpelOrigClaude.Source @args }
70
+ finally {
71
+ $env:ANTHROPIC_API_URL = $_savedAnthropicApi
72
+ $env:ANTHROPIC_BASE_URL = $_savedAnthropicBase
73
+ $env:OPENAI_BASE_URL = $_savedOpenAiBase
74
+ $env:OPENAI_API_BASE = $_savedOpenAiApiBase
75
+ $env:SKALPEL_PROXY_URL = $_savedSkalpelProxy
76
+ $env:SKALPEL_CODEX_OPENAI_BASE_URL = $_savedCodexOpenAi
77
+ $env:SKALPEL_CODEX_CHATGPT_BASE_URL = $_savedCodexChatGPT
78
+ }
79
+ }
80
+ }
81
+ }`,
82
+ },
83
+ };
84
+
85
+ const codex = {
86
+ name: 'codex',
87
+ wrappers: {
88
+ posix: `
89
+ # Codex CLI/App integration. Config overrides are native Codex -c knobs.
90
+ # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
91
+ if [ -z "\${SKALPEL_NO_AGENT_WRAP:-}" ] && ! alias codex >/dev/null 2>&1 && command -v codex >/dev/null 2>&1 && command -v skalpel >/dev/null 2>&1; then
92
+ codex() {
93
+ if skalpel status >&2; then
94
+ _skalpel_codex_home="\${CODEX_HOME:-$HOME/.codex}"
95
+ _skalpel_codex_auth_mode="$(awk -F'"' '/"auth_mode"/ { print $4; exit }' "\${_skalpel_codex_home}/auth.json" 2>/dev/null)"
96
+ case "\${_skalpel_codex_auth_mode}" in
97
+ apikey|api_key|api)
98
+ command codex -c "openai_base_url='\${SKALPEL_CODEX_OPENAI_BASE_URL}'" "$@"
99
+ ;;
100
+ *)
101
+ command codex -c "chatgpt_base_url='\${SKALPEL_CODEX_CHATGPT_BASE_URL}'" "$@"
102
+ ;;
103
+ esac
104
+ else
105
+ command codex "$@"
106
+ fi
107
+ }
108
+ fi`,
109
+ fish: `
110
+ # Codex CLI/App integration. Config overrides are native Codex -c knobs.
111
+ # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
112
+ if not set -q SKALPEL_NO_AGENT_WRAP; and not functions -q codex; and command -q codex; and command -q skalpel
113
+ function codex
114
+ if skalpel status 1>&2
115
+ set -l _skalpel_codex_home "$HOME/.codex"
116
+ if set -q CODEX_HOME
117
+ set _skalpel_codex_home "$CODEX_HOME"
118
+ end
119
+ set -l _skalpel_codex_auth_mode (awk -F'"' '/"auth_mode"/ { print $4; exit }' "$_skalpel_codex_home/auth.json" 2>/dev/null)
120
+ switch "$_skalpel_codex_auth_mode"
121
+ case apikey api_key api
122
+ command codex -c "openai_base_url='$SKALPEL_CODEX_OPENAI_BASE_URL'" $argv
123
+ case '*'
124
+ command codex -c "chatgpt_base_url='$SKALPEL_CODEX_CHATGPT_BASE_URL'" $argv
125
+ end
126
+ else
127
+ command codex $argv
128
+ end
129
+ end
130
+ end`,
131
+ powershell: `
132
+ # Codex CLI/App integration. Config overrides are native Codex -c knobs.
133
+ # Set $env:SKALPEL_NO_AGENT_WRAP=1 to disable.
134
+ $_skalpelOrigCodex = Get-Command codex.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
135
+ $_skalpelStatusBin = Get-Command skalpel -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
136
+ if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigCodex -and $_skalpelStatusBin) {
137
+ function global:codex {
138
+ & $script:_skalpelStatusBin.Source status 1>&2
139
+ if ($LASTEXITCODE -eq 0) {
140
+ $_skalpelCodexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME '.codex' }
141
+ $_skalpelCodexAuthMode = ''
142
+ try {
143
+ $_skalpelCodexAuthPath = Join-Path $_skalpelCodexHome 'auth.json'
144
+ if (Test-Path $_skalpelCodexAuthPath) {
145
+ $_skalpelCodexAuthMode = (Get-Content -Raw $_skalpelCodexAuthPath | ConvertFrom-Json).auth_mode
146
+ }
147
+ } catch {}
148
+ if ($_skalpelCodexAuthMode -in @('apikey', 'api_key', 'api')) {
149
+ & $script:_skalpelOrigCodex.Source -c "openai_base_url='$env:SKALPEL_CODEX_OPENAI_BASE_URL'" @args
150
+ } else {
151
+ & $script:_skalpelOrigCodex.Source -c "chatgpt_base_url='$env:SKALPEL_CODEX_CHATGPT_BASE_URL'" @args
152
+ }
153
+ } else {
154
+ & $script:_skalpelOrigCodex.Source @args
155
+ }
156
+ }
157
+ }`,
158
+ },
159
+ };
160
+
161
+ const registry = [claudeCode, codex];
162
+
163
+ function wrapperBlock(shell) {
164
+ const key = shell === 'fish'
165
+ ? 'fish'
166
+ : shell === 'powershell' || shell === 'powershell-legacy'
167
+ ? 'powershell'
168
+ : 'posix';
169
+ return registry.map((integration) => integration.wrappers[key]).join('\n');
170
+ }
171
+
172
+ module.exports = {
173
+ claudeCode,
174
+ codex,
175
+ envBlockValues,
176
+ registry,
177
+ wrapperBlock,
178
+ };
@@ -10,6 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
+ const integrations = require('./integrations');
13
14
 
14
15
  // Decision D260 / B30 — vendor-prefixed fence format:
15
16
  // # >>> @skalpelai/skalpel begin (managed)
@@ -20,15 +21,7 @@ const FENCE_BEGIN_PS = '# >>> @skalpelai/skalpel begin (managed) >>>';
20
21
  const FENCE_END_PS = '# <<< @skalpelai/skalpel end (managed) <<<';
21
22
 
22
23
  function envBlockValues(port) {
23
- const p = String(port || 7878);
24
- const root = `http://127.0.0.1:${p}`;
25
- return {
26
- ANTHROPIC_API_URL: root,
27
- ANTHROPIC_BASE_URL: root,
28
- OPENAI_BASE_URL: `${root}/v1`,
29
- OPENAI_API_BASE: `${root}/v1`,
30
- SKALPEL_PROXY_URL: root,
31
- };
24
+ return integrations.envBlockValues(port);
32
25
  }
33
26
 
34
27
  function fenceFor(shell) {
@@ -48,96 +41,6 @@ function shellEscapePsSQ(v) {
48
41
  return String(v).replace(/'/g, "''");
49
42
  }
50
43
 
51
- // agentWrapPosix is a tiny bash/zsh-portable shim that prints a
52
- // one-line "skalpel active/inactive" hint right before launching
53
- // `claude`. The status is computed by `skalpel status` which checks
54
- // both the daemon socket AND the compressor toggle in config.toml so
55
- // the line accurately reflects whether anything will happen on the
56
- // next request.
57
- //
58
- // Fail-open: if `skalpel status` exits non-zero (daemon down /
59
- // compressor off / config corrupt), we run claude with the proxy env
60
- // vars unset so requests reach Anthropic directly. Without this, a
61
- // crashed daemon would leave ANTHROPIC_BASE_URL pointing at a closed
62
- // port and every claude invocation would hang on connection refused.
63
- //
64
- // Self-disabling knobs:
65
- // - SKALPEL_NO_AGENT_WRAP=1 → operator opt-out (env var, anywhere).
66
- // - existing `claude` alias → we don't override aliases.
67
- // - `skalpel` not on PATH → wrapper installs nothing.
68
- const agentWrapPosix = `
69
- # Pre-launch status hint + fail-open for coding agents in this shell.
70
- # Status comes from \`skalpel status\` (checks daemon + engine config);
71
- # non-zero exit ⇒ unset proxy env vars so claude reaches Anthropic
72
- # directly instead of hanging on a closed proxy port.
73
- # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
74
- if [ -z "\${SKALPEL_NO_AGENT_WRAP:-}" ] && ! alias claude >/dev/null 2>&1 && command -v skalpel >/dev/null 2>&1; then
75
- claude() {
76
- if skalpel status >&2; then
77
- command claude "$@"
78
- else
79
- ANTHROPIC_API_URL= ANTHROPIC_BASE_URL= OPENAI_BASE_URL= OPENAI_API_BASE= SKALPEL_PROXY_URL= command claude "$@"
80
- fi
81
- }
82
- fi`;
83
-
84
- // agentWrapFish is the fish-shell port of agentWrapPosix.
85
- const agentWrapFish = `
86
- # Pre-launch status hint + fail-open for coding agents in this shell.
87
- # Status comes from \`skalpel status\` (checks daemon + engine config);
88
- # non-zero exit ⇒ unset proxy env vars so claude reaches Anthropic
89
- # directly instead of hanging on a closed proxy port.
90
- # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
91
- if not set -q SKALPEL_NO_AGENT_WRAP; and not functions -q claude; and command -q skalpel
92
- function claude
93
- if skalpel status 1>&2
94
- command claude $argv
95
- else
96
- env -u ANTHROPIC_API_URL -u ANTHROPIC_BASE_URL -u OPENAI_BASE_URL -u OPENAI_API_BASE -u SKALPEL_PROXY_URL command claude $argv
97
- end
98
- end
99
- end`;
100
-
101
- // agentWrapPosh is the PowerShell port. Same delegation: ask the
102
- // `skalpel` binary for the status, write it to stderr, then forward.
103
- // On non-zero exit, scrub the proxy env vars from the child's
104
- // environment for the one invocation (Remove-Item Env:… inside a
105
- // scoped script block), so claude reaches Anthropic directly.
106
- const agentWrapPosh = `
107
- # Pre-launch status hint + fail-open for coding agents in this shell.
108
- # Status comes from \`skalpel status\` (checks daemon + engine config);
109
- # non-zero exit ⇒ unset proxy env vars for the one claude invocation.
110
- # Set $env:SKALPEL_NO_AGENT_WRAP=1 to disable.
111
- $_skalpelOrigClaude = Get-Command claude.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
112
- $_skalpelStatusBin = Get-Command skalpel -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
113
- if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigClaude -and $_skalpelStatusBin) {
114
- function global:claude {
115
- & $script:_skalpelStatusBin.Source status 1>&2
116
- if ($LASTEXITCODE -eq 0) {
117
- & $script:_skalpelOrigClaude.Source @args
118
- } else {
119
- $_savedAnthropicApi = $env:ANTHROPIC_API_URL
120
- $_savedAnthropicBase = $env:ANTHROPIC_BASE_URL
121
- $_savedOpenAiBase = $env:OPENAI_BASE_URL
122
- $_savedOpenAiApiBase = $env:OPENAI_API_BASE
123
- $_savedSkalpelProxy = $env:SKALPEL_PROXY_URL
124
- $env:ANTHROPIC_API_URL = ''
125
- $env:ANTHROPIC_BASE_URL = ''
126
- $env:OPENAI_BASE_URL = ''
127
- $env:OPENAI_API_BASE = ''
128
- $env:SKALPEL_PROXY_URL = ''
129
- try { & $script:_skalpelOrigClaude.Source @args }
130
- finally {
131
- $env:ANTHROPIC_API_URL = $_savedAnthropicApi
132
- $env:ANTHROPIC_BASE_URL = $_savedAnthropicBase
133
- $env:OPENAI_BASE_URL = $_savedOpenAiBase
134
- $env:OPENAI_API_BASE = $_savedOpenAiApiBase
135
- $env:SKALPEL_PROXY_URL = $_savedSkalpelProxy
136
- }
137
- }
138
- }
139
- }`;
140
-
141
44
  function bodyFor(shell, env) {
142
45
  const note =
143
46
  'This block is managed by skalpel install. Do not edit by hand;\n' +
@@ -162,7 +65,7 @@ function bodyFor(shell, env) {
162
65
  ...Object.entries(env).map(
163
66
  ([k, v]) => ` set -gx ${k} "${shellEscapePosixDQ(v)}"`
164
67
  ),
165
- agentWrapFish.replace(/^/gm, ' ').replace(/^ $/gm, ''),
68
+ integrations.wrapperBlock(shell).replace(/^/gm, ' ').replace(/^ $/gm, ''),
166
69
  'end',
167
70
  ].join('\n');
168
71
  case 'powershell':
@@ -175,7 +78,7 @@ function bodyFor(shell, env) {
175
78
  ...Object.entries(env).map(
176
79
  ([k, v]) => ` $env:${k} = '${shellEscapePsSQ(v)}'`
177
80
  ),
178
- agentWrapPosh.replace(/^/gm, ' ').replace(/^ $/gm, ''),
81
+ integrations.wrapperBlock(shell).replace(/^/gm, ' ').replace(/^ $/gm, ''),
179
82
  '}',
180
83
  ].join('\n');
181
84
  default:
@@ -187,7 +90,7 @@ function bodyFor(shell, env) {
187
90
  ...Object.entries(env).map(
188
91
  ([k, v]) => ` export ${k}="${shellEscapePosixDQ(v)}"`
189
92
  ),
190
- agentWrapPosix.replace(/^/gm, ' ').replace(/^ $/gm, ''),
93
+ integrations.wrapperBlock(shell).replace(/^/gm, ' ').replace(/^ $/gm, ''),
191
94
  'fi',
192
95
  ].join('\n');
193
96
  }
@@ -257,6 +160,7 @@ module.exports = {
257
160
  shellEscapePosixDQ,
258
161
  shellEscapePsSQ,
259
162
  xmlEscape,
163
+ integrations,
260
164
  FENCE_BEGIN_POSIX,
261
165
  FENCE_END_POSIX,
262
166
  FENCE_BEGIN_PS,
@@ -187,6 +187,17 @@ function run() {
187
187
  }
188
188
  });
189
189
 
190
+ test('TestRcEdit_Body_Has_Codex_Wrapper', () => {
191
+ const env = rc.envBlockValues(7878);
192
+ const block = rc.buildBlock('bash', env);
193
+ assert.ok(block.includes('codex()'), 'missing codex wrapper');
194
+ assert.ok(block.includes('openai_base_url'), 'missing Codex OpenAI base config');
195
+ assert.ok(block.includes('chatgpt_base_url'), 'missing Codex ChatGPT base config');
196
+ assert.ok(block.includes('auth_mode'), 'missing Codex auth-mode routing');
197
+ assert.ok(block.includes('http://127.0.0.1:7878/backend-api'), 'missing Codex ChatGPT backend base');
198
+ assert.ok(!block.includes('network.proxy_url'), 'Codex wrapper should not generic-proxy metadata routes');
199
+ });
200
+
190
201
  process.stdout.write(`\n pass=${pass} fail=${fail}\n`);
191
202
  return fail === 0 ? 0 : 1;
192
203
  }