skalpel 3.4.6 → 3.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.4.6",
3
+ "version": "3.4.7",
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",
@@ -40,6 +40,7 @@
40
40
  "files": [
41
41
  "npm-bin/",
42
42
  "postinstall/",
43
+ "prosumer-hooks/",
43
44
  "design-tokens.json",
44
45
  "INSTALL.md",
45
46
  "README.md",
@@ -58,10 +59,10 @@
58
59
  "x64"
59
60
  ],
60
61
  "optionalDependencies": {
61
- "@skalpelai/skalpel-darwin-arm64": "3.4.6",
62
- "@skalpelai/skalpel-darwin-x64": "3.4.6",
63
- "@skalpelai/skalpel-linux-arm64": "3.4.6",
64
- "@skalpelai/skalpel-linux-x64": "3.4.6",
65
- "@skalpelai/skalpel-win32-x64": "3.4.6"
62
+ "@skalpelai/skalpel-darwin-arm64": "3.4.7",
63
+ "@skalpelai/skalpel-darwin-x64": "3.4.7",
64
+ "@skalpelai/skalpel-linux-arm64": "3.4.7",
65
+ "@skalpelai/skalpel-linux-x64": "3.4.7",
66
+ "@skalpelai/skalpel-win32-x64": "3.4.7"
66
67
  }
67
68
  }
@@ -28,6 +28,7 @@ const serviceRegister = require('./lib/service-register');
28
28
  const envInject = require('./lib/env-inject');
29
29
  const launch = require('./lib/launch');
30
30
  const caInstall = require('./lib/ca-install');
31
+ const prosumerHooks = require('./lib/prosumer-hooks');
31
32
 
32
33
  function printStyledSuccess() {
33
34
  if (!process.stdout.isTTY) return;
@@ -81,10 +82,12 @@ function helpText() {
81
82
  '',
82
83
  'Run automatically by npm install. The wizard performs:',
83
84
  ' 1. detect-prior probe configDir for an existing install',
84
- ' 2. sign-in start `skalpel login` (or hint at it)',
85
- ' 3. service-register register the per-OS daemon entry',
86
- ' 4. env-inject update shell rc managed-block',
87
- ' 5. launch print the run-skalpel hint',
85
+ ' 2. behavior-hooks stage the local graph hook runtime',
86
+ ' 3. sign-in start `skalpel login` (or hint at it)',
87
+ ' 4. service-register register the per-OS daemon entry',
88
+ ' 5. ca-install install the local interception CA',
89
+ ' 6. shell-activation defer wrapping until `skalpel setup`',
90
+ ' 7. launch print the run-skalpel hint',
88
91
  '',
89
92
  'Uninstall is owned by the Go binary now — run `skalpel uninstall`',
90
93
  'or `npx skalpel uninstall` to reverse this wizard.',
@@ -147,7 +150,7 @@ async function main(argv) {
147
150
  return 0;
148
151
  }
149
152
 
150
- const total = 6;
153
+ const total = 7;
151
154
  const mode = opts.dryRun ? 'dry-run' : 'live';
152
155
  log.info(`postinstall wizard starting (${mode} install) on ${process.platform}`);
153
156
 
@@ -163,7 +166,20 @@ async function main(argv) {
163
166
  const r = detectPrior.run({ dryRun: opts.dryRun });
164
167
  prior = r.prior;
165
168
 
166
- log.step(2, total, 'sign-in', prior ? 'skipping (prior auth)' : 'browser flow');
169
+ log.step(2, total, 'behavior-hooks', 'staging CLI-owned runtime');
170
+ try {
171
+ const hooks = prosumerHooks.stageAndHeal({ dryRun: opts.dryRun });
172
+ if (hooks.healed) {
173
+ log.info('behavior-hooks: refreshed an existing skalpel hook installation');
174
+ } else {
175
+ log.info('behavior-hooks: runtime staged; activation remains opt-in via `skalpel setup`');
176
+ }
177
+ } catch (err) {
178
+ log.warn(`behavior-hooks: ${err.message}; continuing`);
179
+ allWarnings.push(`behavior-hooks: ${err.message}`);
180
+ }
181
+
182
+ log.step(3, total, 'sign-in', prior ? 'skipping (prior auth)' : 'browser flow');
167
183
  // sign-in is non-critical: a missing dispatcher / login failure
168
184
  // is recoverable — user can run `skalpel login` later.
169
185
  try {
@@ -176,7 +192,7 @@ async function main(argv) {
176
192
  allWarnings.push(`sign-in: ${err.message}`);
177
193
  }
178
194
 
179
- log.step(3, total, 'service-register', `OS=${process.platform}`);
195
+ log.step(4, total, 'service-register', `OS=${process.platform}`);
180
196
  // service-register is critical: a stale service unit is a real
181
197
  // problem at boot. Errors propagate to the catch block below.
182
198
  const sr = serviceRegister.run({ dryRun: opts.dryRun }) || {};
@@ -184,7 +200,7 @@ async function main(argv) {
184
200
  allWarnings.push(...sr.warnings);
185
201
  }
186
202
 
187
- log.step(4, total, 'ca-install', `trust store on ${process.platform}`);
203
+ log.step(5, total, 'ca-install', `trust store on ${process.platform}`);
188
204
  // ca-install is NON-CRITICAL: declined prompts or missing sudo must NOT
189
205
  // fail postinstall. The deferred-install sentinel ensures first
190
206
  // `skalpel login` / `codex` invocation retries the trust install.
@@ -209,14 +225,14 @@ async function main(argv) {
209
225
  // managed block, now poison-free). Set SKALPEL_ACTIVATE_SHELL=1 to restore
210
226
  // the old auto-wrap-on-install behavior.
211
227
  if (process.env.SKALPEL_ACTIVATE_SHELL === '1') {
212
- log.step(5, total, 'env-inject', 'managed-block edit per shell (SKALPEL_ACTIVATE_SHELL=1)');
228
+ log.step(6, total, 'env-inject', 'managed-block edit per shell (SKALPEL_ACTIVATE_SHELL=1)');
213
229
  envInject.run({ dryRun: opts.dryRun });
214
230
  } else {
215
- log.step(5, total, 'shell-activation', 'deferred — opt-in via `skalpel setup`');
231
+ log.step(6, total, 'shell-activation', 'deferred — opt-in via `skalpel setup`');
216
232
  log.info('Shell integration is opt-in. Run `skalpel setup` to route Claude/Codex through skalpel.');
217
233
  }
218
234
 
219
- log.step(6, total, 'launch', 'next-step hint');
235
+ log.step(7, total, 'launch', 'next-step hint');
220
236
  launch.run({ dryRun: opts.dryRun });
221
237
  } catch (err) {
222
238
  critical = err;
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ const RUNTIME_FILES = [
9
+ 'auth.mjs',
10
+ 'bootstrap.mjs',
11
+ 'insights.mjs',
12
+ 'metrics.mjs',
13
+ 'stats.mjs',
14
+ 'skalpel-hook.mjs',
15
+ 'skalpel-hook-session.mjs',
16
+ 'skalpel-statusline.mjs',
17
+ 'install.mjs',
18
+ ];
19
+
20
+ const MANAGED_COMMAND = /skalpel-(?:prosumer-)?(?:hook(?:-session)?|statusline)/;
21
+
22
+ function runtimeDir() {
23
+ return path.join(os.homedir(), '.skalpel', 'hooks');
24
+ }
25
+
26
+ function sourceDir() {
27
+ return path.resolve(__dirname, '..', '..', 'prosumer-hooks');
28
+ }
29
+
30
+ function readText(file) {
31
+ try {
32
+ return fs.readFileSync(file, 'utf8');
33
+ } catch (_) {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ function hasManagedInstall() {
39
+ const home = os.homedir();
40
+ return [
41
+ path.join(home, '.claude', 'settings.json'),
42
+ path.join(home, '.claude', 'CLAUDE.md'),
43
+ path.join(home, '.codex', 'config.toml'),
44
+ path.join(home, '.codex', 'hooks.json'),
45
+ ].some((file) => MANAGED_COMMAND.test(readText(file)));
46
+ }
47
+
48
+ function copyRuntime() {
49
+ const srcDir = sourceDir();
50
+ const dstDir = runtimeDir();
51
+ fs.mkdirSync(dstDir, { recursive: true, mode: 0o700 });
52
+ for (const name of RUNTIME_FILES) {
53
+ const src = path.join(srcDir, name);
54
+ const dst = path.join(dstDir, name);
55
+ const tmp = `${dst}.tmp-${process.pid}`;
56
+ try {
57
+ fs.copyFileSync(src, tmp);
58
+ fs.renameSync(tmp, dst);
59
+ } finally {
60
+ try {
61
+ fs.unlinkSync(tmp);
62
+ } catch (_) {
63
+ // The rename consumed the temporary file.
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ function runInstaller(args, stdio = 'inherit') {
70
+ const staged = path.join(runtimeDir(), 'install.mjs');
71
+ const installer = fs.existsSync(staged) ? staged : path.join(sourceDir(), 'install.mjs');
72
+ const result = spawnSync(process.execPath, [installer, ...args], { stdio });
73
+ if (result.error) throw result.error;
74
+ if (typeof result.status === 'number' && result.status !== 0) {
75
+ throw new Error(`hook installer exited ${result.status}`);
76
+ }
77
+ }
78
+
79
+ function stageAndHeal({ dryRun = false } = {}) {
80
+ const heal = hasManagedInstall();
81
+ if (dryRun) return { staged: false, healed: false, wouldHeal: heal };
82
+ copyRuntime();
83
+ if (heal) runInstaller([]);
84
+ return { staged: true, healed: heal };
85
+ }
86
+
87
+ function uninstall({ removeRuntime = true } = {}) {
88
+ let error = null;
89
+ try {
90
+ runInstaller(['--uninstall']);
91
+ } catch (err) {
92
+ error = err;
93
+ }
94
+ if (removeRuntime) {
95
+ for (const name of RUNTIME_FILES) {
96
+ try {
97
+ fs.unlinkSync(path.join(runtimeDir(), name));
98
+ } catch (_) {
99
+ // Missing files are already clean.
100
+ }
101
+ }
102
+ try {
103
+ fs.rmdirSync(runtimeDir());
104
+ } catch (_) {
105
+ // Preserve the directory when it contains user-owned files.
106
+ }
107
+ }
108
+ if (error) throw error;
109
+ }
110
+
111
+ module.exports = { stageAndHeal, uninstall };
@@ -26,6 +26,7 @@
26
26
  const path = require('path');
27
27
  const fs = require('fs');
28
28
  const { spawnSync } = require('child_process');
29
+ const prosumerHooks = require('./lib/prosumer-hooks');
29
30
 
30
31
  const PLATFORM_PACKAGES = {
31
32
  'darwin-arm64': '@skalpelai/skalpel-darwin-arm64',
@@ -65,6 +66,13 @@ function findBinary() {
65
66
  if (process.env.SKALPEL_NO_PREUNINSTALL) {
66
67
  return;
67
68
  }
69
+ try {
70
+ prosumerHooks.uninstall({ removeRuntime: true });
71
+ } catch (err) {
72
+ process.stderr.write(
73
+ `[skalpel] preuninstall: behavior-hook cleanup failed: ${err.message}; continuing.\n`
74
+ );
75
+ }
68
76
  const bin = findBinary();
69
77
  if (!bin) {
70
78
  process.stderr.write(
@@ -0,0 +1,93 @@
1
+ // auth.mjs - shared hosted-hook identity from the canonical skalpel CLI login.
2
+ // There is deliberately no hook-owned auth file and no Supabase fallback: `skalpel login`
3
+ // is the only account plane for the globally installed product.
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ const DASHBOARD_API = process.env.SKALPEL_DASHBOARD_API || "https://cli.skalpel.ai";
9
+ const REFRESH_TIMEOUT_MS = 2000;
10
+
11
+ function cliAuthPath() {
12
+ if (process.env.SKALPEL_CONFIG_DIR) return join(process.env.SKALPEL_CONFIG_DIR, "auth.json");
13
+ if (process.platform === "win32") {
14
+ const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
15
+ return join(appData, "skalpel", "auth.json");
16
+ }
17
+ if (process.platform === "darwin") {
18
+ return join(homedir(), "Library", "Application Support", "skalpel", "auth.json");
19
+ }
20
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
21
+ return join(configHome, "skalpel", "auth.json");
22
+ }
23
+
24
+ function loadCliAuth() {
25
+ try {
26
+ const auth = JSON.parse(readFileSync(cliAuthPath(), "utf8"));
27
+ return typeof auth?.cognito?.id_token === "string" && auth.cognito.id_token ? auth : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function decodeJwtPayload(token) {
34
+ try {
35
+ const part = token.split(".")[1];
36
+ const padded = part + "=".repeat((4 - (part.length % 4)) % 4);
37
+ return JSON.parse(
38
+ Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(),
39
+ );
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function toEpochSeconds(value) {
46
+ if (typeof value === "number" && Number.isFinite(value)) {
47
+ return value > 1e12 ? Math.floor(value / 1000) : value;
48
+ }
49
+ if (typeof value === "string") {
50
+ const millis = Date.parse(value);
51
+ return Number.isFinite(millis) ? Math.floor(millis / 1000) : null;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ async function refreshIDToken(refreshToken) {
57
+ if (!refreshToken) return null;
58
+ const ctrl = new AbortController();
59
+ const timer = setTimeout(() => ctrl.abort(), REFRESH_TIMEOUT_MS);
60
+ try {
61
+ const response = await fetch(`${DASHBOARD_API}/v1/auth/refresh`, {
62
+ method: "POST",
63
+ headers: { "content-type": "application/json" },
64
+ body: JSON.stringify({ refresh_token: refreshToken }),
65
+ signal: ctrl.signal,
66
+ });
67
+ if (!response.ok) return null;
68
+ const tokens = await response.json();
69
+ return typeof tokens?.id_token === "string" ? tokens.id_token : null;
70
+ } catch {
71
+ return null;
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
77
+ export async function identity() {
78
+ if (process.env.SKALPEL_USER) return { uid: process.env.SKALPEL_USER, token: null };
79
+
80
+ const auth = loadCliAuth();
81
+ if (!auth) return { uid: null, token: null };
82
+
83
+ let idToken = auth.cognito.id_token;
84
+ const claims = decodeJwtPayload(idToken);
85
+ const expiresAt = toEpochSeconds(auth.cognito.expires_at) ?? claims?.exp ?? null;
86
+ if (expiresAt !== null && expiresAt - Math.floor(Date.now() / 1000) < 60) {
87
+ idToken = await refreshIDToken(auth.cognito.refresh_token);
88
+ if (!idToken) return { uid: null, token: null };
89
+ }
90
+
91
+ const uid = decodeJwtPayload(idToken)?.sub ?? auth.subject?.user_id ?? null;
92
+ return uid ? { uid, token: idToken } : { uid: null, token: null };
93
+ }