skalpel 3.4.10 → 3.4.12

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
@@ -65,6 +65,11 @@ The shell wrappers — and the `skalpel active` / `skalpel inactive` indicator e
65
65
 
66
66
  A user who independently runs `npm install -g skalpel` is still supported: `postinstall` prints a best-effort pointer to `https://skalpel.ai/signup`, and the first `skalpel` run repeats that pointer where output is guaranteed.
67
67
 
68
+ When Claude Code is already present, the persistent install also registers Skalpel's local,
69
+ read-only `statusLine` command. It is visible immediately as `~0.0h saved` and grows only from
70
+ confirmed savings. This does **not** opt the user into prompt/session hooks: those remain disabled
71
+ until the explicit `skalpel setup` flow below. An existing custom Claude status line is preserved.
72
+
68
73
  ### `skalpel setup` — detect and wrap coding agents
69
74
 
70
75
  Harness setup runs automatically at the tail of `login --activation` (see above), and is also available as a standalone command for re-running later or for `npm install -g` users. `skalpel setup` detects which coding-agent harnesses are installed (cross-platform on Linux, Windows, and macOS) via PATH and per-user config probes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.4.10",
3
+ "version": "3.4.12",
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",
@@ -59,10 +59,10 @@
59
59
  "x64"
60
60
  ],
61
61
  "optionalDependencies": {
62
- "@skalpelai/skalpel-darwin-arm64": "3.4.10",
63
- "@skalpelai/skalpel-darwin-x64": "3.4.10",
64
- "@skalpelai/skalpel-linux-arm64": "3.4.10",
65
- "@skalpelai/skalpel-linux-x64": "3.4.10",
66
- "@skalpelai/skalpel-win32-x64": "3.4.10"
62
+ "@skalpelai/skalpel-darwin-arm64": "3.4.12",
63
+ "@skalpelai/skalpel-darwin-x64": "3.4.12",
64
+ "@skalpelai/skalpel-linux-arm64": "3.4.12",
65
+ "@skalpelai/skalpel-linux-x64": "3.4.12",
66
+ "@skalpelai/skalpel-win32-x64": "3.4.12"
67
67
  }
68
68
  }
@@ -171,6 +171,11 @@ async function main(argv) {
171
171
  const hooks = prosumerHooks.stageAndHeal({ dryRun: opts.dryRun });
172
172
  if (hooks.healed) {
173
173
  log.info('behavior-hooks: refreshed an existing skalpel hook installation');
174
+ } else if (hooks.statuslineManaged) {
175
+ log.info(
176
+ 'behavior-hooks: runtime staged; Claude status line enabled at 0.0h; ' +
177
+ 'prompt hooks remain opt-in via `skalpel setup`'
178
+ );
174
179
  } else {
175
180
  log.info('behavior-hooks: runtime staged; activation remains opt-in via `skalpel setup`');
176
181
  }
@@ -17,7 +17,12 @@ const RUNTIME_FILES = [
17
17
  'install.mjs',
18
18
  ];
19
19
 
20
- const MANAGED_COMMAND = /skalpel-(?:prosumer-)?(?:hook(?:-session)?|statusline)/;
20
+ // Match managed command basenames/markers, not arbitrary parent-directory text. A path such as
21
+ // /tmp/skalpel-prosumer-hooks-test/... must never be misread as consent to activate prompt hooks.
22
+ const MANAGED_BEHAVIOR_COMMAND =
23
+ /(^|[/\\\s"'])skalpel-(?:prosumer-)?hook(?:-session)?(?:\.(?:mjs|js|exe))?(?=$|[:\s"'])/m;
24
+ const MANAGED_STATUSLINE_COMMAND =
25
+ /(^|[/\\\s"'])skalpel-(?:prosumer-)?statusline(?:\.(?:mjs|js|exe))?(?=$|[\s"'])/m;
21
26
 
22
27
  function runtimeDir() {
23
28
  return path.join(os.homedir(), '.skalpel', 'hooks');
@@ -35,14 +40,24 @@ function readText(file) {
35
40
  }
36
41
  }
37
42
 
38
- function hasManagedInstall() {
43
+ function hasManagedBehaviorInstall() {
39
44
  const home = os.homedir();
40
45
  return [
41
46
  path.join(home, '.claude', 'settings.json'),
42
47
  path.join(home, '.claude', 'CLAUDE.md'),
43
48
  path.join(home, '.codex', 'config.toml'),
44
49
  path.join(home, '.codex', 'hooks.json'),
45
- ].some((file) => MANAGED_COMMAND.test(readText(file)));
50
+ ].some((file) => MANAGED_BEHAVIOR_COMMAND.test(readText(file)));
51
+ }
52
+
53
+ function hasManagedStatusline() {
54
+ return MANAGED_STATUSLINE_COMMAND.test(
55
+ readText(path.join(os.homedir(), '.claude', 'settings.json'))
56
+ );
57
+ }
58
+
59
+ function hasClaudeConfig() {
60
+ return fs.existsSync(path.join(os.homedir(), '.claude'));
46
61
  }
47
62
 
48
63
  function copyRuntime() {
@@ -77,11 +92,29 @@ function runInstaller(args, stdio = 'inherit') {
77
92
  }
78
93
 
79
94
  function stageAndHeal({ dryRun = false } = {}) {
80
- const heal = hasManagedInstall();
81
- if (dryRun) return { staged: false, healed: false, wouldHeal: heal };
95
+ const healBehavior = hasManagedBehaviorInstall();
96
+ const configureStatusline = hasClaudeConfig();
97
+ if (dryRun) {
98
+ return {
99
+ staged: false,
100
+ healed: false,
101
+ wouldHeal: healBehavior,
102
+ wouldConfigureStatusline: configureStatusline,
103
+ };
104
+ }
82
105
  copyRuntime();
83
- if (heal) runInstaller([]);
84
- return { staged: true, healed: heal };
106
+ if (healBehavior) {
107
+ runInstaller([]);
108
+ } else if (configureStatusline) {
109
+ // Keep prompt/session hooks consent-based, but make the read-only zero-state indicator visible
110
+ // immediately. Crucially, a later upgrade must not mistake this statusline for hook opt-in.
111
+ runInstaller(['--statusline-only']);
112
+ }
113
+ return {
114
+ staged: true,
115
+ healed: healBehavior,
116
+ statuslineManaged: hasManagedStatusline(),
117
+ };
85
118
  }
86
119
 
87
120
  function uninstall({ removeRuntime = true } = {}) {
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ // CLI-owned behavior-hook packaging and status-line tests. Stdlib only; each case runs against an
3
+ // isolated HOME so npm-install behavior cannot touch the developer's real Claude/Codex settings.
4
+
5
+ 'use strict';
6
+
7
+ const assert = require('assert');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { spawnSync } = require('child_process');
12
+
13
+ const hooksLib = path.join(__dirname, 'prosumer-hooks.js');
14
+ const statusline = path.resolve(__dirname, '..', '..', 'prosumer-hooks', 'skalpel-statusline.mjs');
15
+ const packageJson = path.resolve(__dirname, '..', '..', 'package.json');
16
+
17
+ let pass = 0;
18
+ let fail = 0;
19
+
20
+ function test(name, fn) {
21
+ try {
22
+ fn();
23
+ process.stdout.write(` PASS ${name}\n`);
24
+ pass += 1;
25
+ } catch (err) {
26
+ process.stderr.write(` FAIL ${name}\n ${err && err.stack ? err.stack : err}\n`);
27
+ fail += 1;
28
+ }
29
+ }
30
+
31
+ function tempHome({ claude = false } = {}) {
32
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'skalpel-prosumer-hooks-test-'));
33
+ if (claude) fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
34
+ return home;
35
+ }
36
+
37
+ function childEnv(home) {
38
+ return {
39
+ ...process.env,
40
+ HOME: home,
41
+ USERPROFILE: home,
42
+ NO_COLOR: '1',
43
+ };
44
+ }
45
+
46
+ function stage(home) {
47
+ return spawnSync(
48
+ process.execPath,
49
+ ['-e', `require(${JSON.stringify(hooksLib)}).stageAndHeal()`],
50
+ { env: childEnv(home), encoding: 'utf8', timeout: 10_000 },
51
+ );
52
+ }
53
+
54
+ function render(home) {
55
+ const result = spawnSync(process.execPath, [statusline], {
56
+ env: childEnv(home),
57
+ encoding: 'utf8',
58
+ timeout: 5_000,
59
+ });
60
+ assert.strictEqual(result.status, 0, result.stderr);
61
+ return result.stdout.replace(/\x1b\[[0-9;]*m/g, '');
62
+ }
63
+
64
+ function settings(home) {
65
+ return JSON.parse(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8'));
66
+ }
67
+
68
+ function run() {
69
+ process.stdout.write('prosumer-hooks tests:\n');
70
+
71
+ test('status line renders an always-visible honest zero without stats', () => {
72
+ const home = tempHome();
73
+ try {
74
+ assert.strictEqual(render(home), 'skalpel · ~0.0h saved · 0 steers');
75
+ } finally {
76
+ fs.rmSync(home, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ test('status line still renders accumulated confirmed savings', () => {
81
+ const home = tempHome();
82
+ try {
83
+ fs.mkdirSync(path.join(home, '.skalpel'), { recursive: true });
84
+ fs.writeFileSync(
85
+ path.join(home, '.skalpel', 'stats.json'),
86
+ JSON.stringify({ session_hours: 0.5, total_hours: 2.25, total_events: 3 }),
87
+ );
88
+ assert.strictEqual(render(home), 'skalpel · ~2.3h saved · 3 steers');
89
+ } finally {
90
+ fs.rmSync(home, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test('npm package includes the complete status-line runtime directory', () => {
95
+ const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
96
+ assert.ok(
97
+ pkg.files.includes('prosumer-hooks/'),
98
+ 'npm package files must include the CLI-owned hook/status-line runtime',
99
+ );
100
+ assert.ok(fs.existsSync(statusline), 'status-line source is missing from the packaged directory');
101
+ });
102
+
103
+ test('fresh npm-style staging registers only the Claude status line', () => {
104
+ const home = tempHome({ claude: true });
105
+ try {
106
+ const result = stage(home);
107
+ assert.strictEqual(result.status, 0, result.stderr);
108
+ const cfg = settings(home);
109
+ assert.deepStrictEqual(cfg.statusLine, {
110
+ type: 'command',
111
+ command: `node ${path.join(home, '.skalpel', 'hooks', 'skalpel-statusline.mjs')}`,
112
+ });
113
+ assert.strictEqual(cfg.hooks, undefined, 'postinstall must not opt users into prompt hooks');
114
+ assert.strictEqual(
115
+ fs.existsSync(path.join(home, '.claude', 'CLAUDE.md')),
116
+ false,
117
+ 'postinstall must not add behavior-hook instructions',
118
+ );
119
+ } finally {
120
+ fs.rmSync(home, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test('status-line-only install stays status-line-only across upgrades', () => {
125
+ const home = tempHome({ claude: true });
126
+ try {
127
+ assert.strictEqual(stage(home).status, 0);
128
+ assert.strictEqual(stage(home).status, 0);
129
+ const cfg = settings(home);
130
+ assert.strictEqual(cfg.hooks, undefined, 'an existing status line is not behavior-hook consent');
131
+ assert.strictEqual(fs.existsSync(path.join(home, '.claude', 'CLAUDE.md')), false);
132
+ assert.strictEqual(fs.existsSync(path.join(home, '.codex', 'config.toml')), false);
133
+ } finally {
134
+ fs.rmSync(home, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ test('an existing behavior-hook opt-in is fully healed on upgrade', () => {
139
+ const home = tempHome({ claude: true });
140
+ try {
141
+ fs.writeFileSync(
142
+ path.join(home, '.claude', 'settings.json'),
143
+ JSON.stringify({
144
+ hooks: {
145
+ UserPromptSubmit: [
146
+ {
147
+ hooks: [
148
+ { type: 'command', command: 'node /old/.skalpel/hooks/skalpel-hook.mjs' },
149
+ ],
150
+ },
151
+ ],
152
+ },
153
+ }),
154
+ );
155
+ const result = stage(home);
156
+ assert.strictEqual(result.status, 0, result.stderr);
157
+ const cfg = settings(home);
158
+ assert.ok(cfg.hooks.UserPromptSubmit.length > 0);
159
+ assert.ok(cfg.hooks.SessionStart.length > 0);
160
+ assert.match(cfg.statusLine.command, /skalpel-statusline\.mjs$/);
161
+ assert.strictEqual(fs.existsSync(path.join(home, '.claude', 'CLAUDE.md')), true);
162
+ } finally {
163
+ fs.rmSync(home, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test('fresh staging preserves a custom Claude status line', () => {
168
+ const home = tempHome({ claude: true });
169
+ try {
170
+ const file = path.join(home, '.claude', 'settings.json');
171
+ const custom = { type: 'command', command: 'my-custom-status' };
172
+ fs.writeFileSync(file, JSON.stringify({ statusLine: custom, theme: 'dark' }));
173
+ const result = stage(home);
174
+ assert.strictEqual(result.status, 0, result.stderr);
175
+ assert.deepStrictEqual(settings(home), { statusLine: custom, theme: 'dark' });
176
+ } finally {
177
+ fs.rmSync(home, { recursive: true, force: true });
178
+ }
179
+ });
180
+
181
+ process.stdout.write(`\n pass=${pass} fail=${fail}\n`);
182
+ return fail === 0 ? 0 : 1;
183
+ }
184
+
185
+ if (require.main === module) process.exit(run());
186
+
187
+ module.exports = { run };
@@ -8,6 +8,9 @@ import { join, dirname } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  const uninstall = process.argv.includes("--uninstall");
11
+ // npm postinstall uses this mode to make the local, read-only status indicator visible without
12
+ // opting the user into prompt/session hooks. `skalpel setup` still runs the full installer.
13
+ const statuslineOnly = !uninstall && process.argv.includes("--statusline-only");
11
14
 
12
15
  // Where this installer's own files live — the globally-installed package, a source checkout, OR the
13
16
  // transient `npx` cache. All three must produce ONE working command, so we can't rely on a bin being
@@ -129,30 +132,32 @@ function isOurs(c, marker) {
129
132
  function claude() {
130
133
  const d = existsSync(CLAUDE) ? readJson(CLAUDE) : {};
131
134
  if (d === null) return "skipped-corrupt"; // corrupt settings backed up; never overwrite blindly
132
- d.hooks ??= {};
133
135
  let installed = 0;
134
136
  let refreshed = 0;
135
137
  let removed = 0;
136
- for (const [event, marker, command] of HOOKS) {
137
- d.hooks[event] ??= [];
138
- const arr = d.hooks[event];
139
- const isMine = (e) => {
140
- if (isOurs(e?.command, marker)) return true; // legacy bare {type,command} entry
141
- const inner = Array.isArray(e?.hooks) ? e.hooks : [];
142
- return inner.some((h) => isOurs(h?.command, marker));
143
- };
144
- const had = arr.some(isMine);
145
- const kept = arr.filter((e) => !isMine(e)); // drop ours (both shapes) AND repair
146
- // 8s, not 5s: the hosted backend can cold-start after idle, and the hook's own internal
147
- // DEADLINE_MS (4.5s) needs headroom under this outer harness timeout or it gets killed first.
148
- const group = { hooks: [{ type: "command", command, timeout: 8 }] };
149
- d.hooks[event] = uninstall ? kept : [...kept, group];
150
- if (uninstall) {
151
- if (had) removed += 1;
152
- } else if (had) {
153
- refreshed += 1;
154
- } else {
155
- installed += 1;
138
+ if (!statuslineOnly) {
139
+ d.hooks ??= {};
140
+ for (const [event, marker, command] of HOOKS) {
141
+ d.hooks[event] ??= [];
142
+ const arr = d.hooks[event];
143
+ const isMine = (e) => {
144
+ if (isOurs(e?.command, marker)) return true; // legacy bare {type,command} entry
145
+ const inner = Array.isArray(e?.hooks) ? e.hooks : [];
146
+ return inner.some((h) => isOurs(h?.command, marker));
147
+ };
148
+ const had = arr.some(isMine);
149
+ const kept = arr.filter((e) => !isMine(e)); // drop ours (both shapes) AND repair
150
+ // 8s, not 5s: the hosted backend can cold-start after idle, and the hook's own internal
151
+ // DEADLINE_MS (4.5s) needs headroom under this outer harness timeout or it gets killed first.
152
+ const group = { hooks: [{ type: "command", command, timeout: 8 }] };
153
+ d.hooks[event] = uninstall ? kept : [...kept, group];
154
+ if (uninstall) {
155
+ if (had) removed += 1;
156
+ } else if (had) {
157
+ refreshed += 1;
158
+ } else {
159
+ installed += 1;
160
+ }
156
161
  }
157
162
  }
158
163
  // statusLine: not a hooks.<event> array, a single top-level key — only touch it if it's unset or
@@ -167,6 +172,8 @@ function claude() {
167
172
  if (statuslineIsOurs) refreshed += 1;
168
173
  else installed += 1;
169
174
  d.statusLine = { type: "command", command: STATUSLINE_CMD };
175
+ } else if (statuslineOnly) {
176
+ return "preserved"; // a custom status line wins; do not rewrite unrelated Claude settings
170
177
  }
171
178
  writeJson(CLAUDE, d);
172
179
  if (uninstall) return removed ? "removed" : "absent";
@@ -345,11 +352,21 @@ function claudeMd() {
345
352
  return had ? "refreshed" : "installed";
346
353
  }
347
354
 
348
- console.log(
349
- `skalpel hook (${uninstall ? "uninstall" : CMD}) → claude: ${claude()} · claude.md: ${claudeMd()} · codex(config.toml): ${codexToml()} · codex(hooks.json): ${codexJson()}`,
350
- );
351
- console.log(
352
- uninstall
353
- ? "uninstalled."
354
- : `installed. Hooks run from ${HOOKS_DIR} and use the canonical skalpel Cognito login.`,
355
- );
355
+ const claudeResult = claude();
356
+ if (statuslineOnly) {
357
+ console.log(`skalpel statusline (${STATUSLINE_CMD}) → claude: ${claudeResult}`);
358
+ console.log(
359
+ claudeResult === "preserved"
360
+ ? "custom Claude status line preserved."
361
+ : "installed. Behavior hooks remain opt-in via `skalpel setup`.",
362
+ );
363
+ } else {
364
+ console.log(
365
+ `skalpel hook (${uninstall ? "uninstall" : CMD}) → claude: ${claudeResult} · claude.md: ${claudeMd()} · codex(config.toml): ${codexToml()} · codex(hooks.json): ${codexJson()}`,
366
+ );
367
+ console.log(
368
+ uninstall
369
+ ? "uninstalled."
370
+ : `installed. Hooks run from ${HOOKS_DIR} and use the canonical skalpel Cognito login.`,
371
+ );
372
+ }
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // skalpel statusline — reads the running "time saved" counter (bumped by skalpel-hook.mjs every time
3
3
  // a real recurring pattern fires) and prints one line for Claude Code's status bar. Brave's
4
- // ads-blocked-counter, but for AI-coding thrash. Fail-open: no stats yet -> print nothing.
4
+ // ads-blocked-counter, but for AI-coding thrash. The zero state stays visible so a fresh install
5
+ // advertises that skalpel is present before the first confirmed save lands.
5
6
  import { readFileSync } from "node:fs";
6
7
  import { homedir } from "node:os";
7
8
  import { join } from "node:path";
@@ -18,15 +19,18 @@ const DIM = "\x1b[2m";
18
19
  const RESET = "\x1b[0m";
19
20
 
20
21
  function main() {
21
- let s;
22
+ let s = {};
22
23
  try {
23
- s = JSON.parse(readFileSync(STATS_PATH, "utf8"));
24
+ const parsed = JSON.parse(readFileSync(STATS_PATH, "utf8"));
25
+ if (parsed && typeof parsed === "object") s = parsed;
24
26
  } catch {
25
- return; // no stats yet print nothing rather than a misleading "0 saved"
27
+ // Missing/corrupt first-run state is an honest zero, not a reason to hide the status line.
26
28
  }
27
- const sess = (s.session_hours || 0).toFixed(1);
28
- const total = (s.total_hours || 0).toFixed(1);
29
- const steers = s.total_events || 0;
29
+ const finiteOrZero = (value) =>
30
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
31
+ const sess = finiteOrZero(s.session_hours).toFixed(1);
32
+ const total = finiteOrZero(s.total_hours).toFixed(1);
33
+ const steers = finiteOrZero(s.total_events);
30
34
 
31
35
  // The LIVE steer (written by skalpel-hook.mjs each turn) — the deterministic awareness channel: the
32
36
  // user SEES skalpel steering right now, even when the model doesn't surface a 🔬 line in the answer.