skalpel 3.4.19 → 4.0.1

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 (55) hide show
  1. package/README.md +29 -37
  2. package/auth.mjs +179 -0
  3. package/{prosumer-hooks/bootstrap.mjs → bootstrap.mjs} +14 -8
  4. package/{prosumer-hooks/incremental-ingest.mjs → incremental-ingest.mjs} +5 -10
  5. package/{prosumer-hooks/install.mjs → install.mjs} +6 -9
  6. package/login.mjs +164 -0
  7. package/package.json +17 -57
  8. package/postinstall.mjs +68 -0
  9. package/skalpel-setup.mjs +898 -0
  10. package/{prosumer-hooks/skalpel-statusline.mjs → skalpel-statusline.mjs} +1 -2
  11. package/INSTALL.md +0 -250
  12. package/LICENSE +0 -201
  13. package/design-tokens.json +0 -52
  14. package/npm-bin/colors.js +0 -125
  15. package/npm-bin/skalpel.bug-0039.test.js +0 -232
  16. package/npm-bin/skalpel.js +0 -517
  17. package/npm-bin/skalpeld.js +0 -20
  18. package/postinstall/index.js +0 -277
  19. package/postinstall/index.test.js +0 -147
  20. package/postinstall/launchd/com.skalpel.skalpeld.plist.tmpl +0 -46
  21. package/postinstall/lib/ca-install.js +0 -268
  22. package/postinstall/lib/ca-install.test.js +0 -327
  23. package/postinstall/lib/detect-prior.js +0 -62
  24. package/postinstall/lib/env-inject.js +0 -105
  25. package/postinstall/lib/integrations.js +0 -207
  26. package/postinstall/lib/launch.js +0 -38
  27. package/postinstall/lib/log.js +0 -31
  28. package/postinstall/lib/paths.js +0 -236
  29. package/postinstall/lib/prosumer-hooks.js +0 -146
  30. package/postinstall/lib/prosumer-hooks.test.js +0 -278
  31. package/postinstall/lib/rc-edit.js +0 -168
  32. package/postinstall/lib/rc-edit.test.js +0 -243
  33. package/postinstall/lib/run-tests.js +0 -59
  34. package/postinstall/lib/service-register.js +0 -248
  35. package/postinstall/lib/sign-in.js +0 -80
  36. package/postinstall/lib/template.js +0 -36
  37. package/postinstall/preinstall.js +0 -111
  38. package/postinstall/preinstall.test.js +0 -47
  39. package/postinstall/snippets/bash.sh.tmpl +0 -12
  40. package/postinstall/snippets/fish.fish.tmpl +0 -11
  41. package/postinstall/snippets/powershell.ps1.tmpl +0 -12
  42. package/postinstall/snippets/zsh.sh.tmpl +0 -13
  43. package/postinstall/systemd/skalpeld.service.tmpl +0 -34
  44. package/postinstall/uninstall.js +0 -98
  45. package/postinstall/windows/Task.xml.tmpl +0 -42
  46. package/postinstall/windows/register-task.ps1.tmpl +0 -45
  47. package/prosumer-hooks/auth.mjs +0 -93
  48. package/prosumer-hooks/bootstrap.test.mjs +0 -93
  49. package/prosumer-hooks/incremental-ingest.test.mjs +0 -320
  50. /package/{prosumer-hooks/insights.mjs → insights.mjs} +0 -0
  51. /package/{prosumer-hooks/metrics.mjs → metrics.mjs} +0 -0
  52. /package/{prosumer-hooks/skalpel-hook-session-end.mjs → skalpel-hook-session-end.mjs} +0 -0
  53. /package/{prosumer-hooks/skalpel-hook-session.mjs → skalpel-hook-session.mjs} +0 -0
  54. /package/{prosumer-hooks/skalpel-hook.mjs → skalpel-hook.mjs} +0 -0
  55. /package/{prosumer-hooks/stats.mjs → stats.mjs} +0 -0
package/README.md CHANGED
@@ -1,51 +1,43 @@
1
1
  # skalpel
2
2
 
3
- Skalpel is the local proxy and terminal UI for coding agents — Cursor, Claude Code, Codex, and others. It runs as two binaries on your machine: `skalpel`, a Bubble Tea TUI configurator, and `skalpeld`, the local daemon that intercepts agent traffic, applies engines, and forwards to upstream providers. The two ship as a single npm package and are always installed together.
3
+ Behavioral graph for AI-assisted coding. Learns how you work from your Claude Code + Codex history and
4
+ steers the model in real time — off the loops, re-explains, and re-fixes that eat your day.
4
5
 
5
- ## Install and first run
6
-
7
- After signing up at [skalpel.ai](https://skalpel.ai):
8
-
9
- ```
10
- npx skalpel login
11
- ```
12
-
13
- `npx skalpel login` materializes both binaries and runs a browser-loopback sign-in against Cognito. A successful login writes the Cognito JWT bundle, idempotently activates the managed Claude Code and Codex behavior hooks, and starts or resumes intent-graph learning; browser login then opens the TUI so progress is visible. To install persistently:
14
-
15
- ```
6
+ ```bash
16
7
  npm install -g skalpel
8
+ skalpel # sign in with Google → build your graph → see your insights → you're live
17
9
  ```
18
10
 
19
- Both `skalpel` and `skalpeld` are placed on your `PATH`. Then run:
11
+ ## What it does
20
12
 
21
- ```
22
- skalpel
23
- ```
13
+ - **`skalpel`** (or the postinstall nudge) runs the onboarding: red-banner wordmark → **Sign in with
14
+ Google** (AWS-hosted, no localhost) → **builds your graph** (async, live progress) → **reveals your
15
+ insights** (your hours, rework %, signature trap, biggest time-sink) → wires the hooks → you're live.
16
+ - After that, every prompt in Claude Code / Codex pulls your own behavioral graph via a fast hook, and a
17
+ status line shows time saved. Silent when it has nothing to say.
24
18
 
25
- to open the guided first-run walkthrough (brand banner, step-by-step setup, sign-in handoff, and toggle tips).
19
+ ## Architecture (pure node no daemon, no binary)
26
20
 
27
- Note: npm may hide lifecycle-script output; if you want to see install wizard logs during `npm install`, use:
21
+ The client is ~30-line hooks that call the **hosted graph** (`graph.skalpel.ai`, a Python engine on
22
+ AWS). No engine, no Python, no per-OS binary on your machine — it rides Claude Code's own Node runtime,
23
+ so it's cross-platform for free. This supersedes the retired Go daemon client (`skalpeld`); the
24
+ postinstall retires any leftover daemon on upgrade.
28
25
 
29
- ```
30
- npm install -g skalpel --foreground-scripts
31
- ```
26
+ - `skalpel-setup.mjs` — the `skalpel` bin: the onboarding flow.
27
+ - `login.mjs` — Google sign-in via the AWS-hosted OAuth callback.
28
+ - `install.mjs` — wires the hooks into Claude Code + Codex (idempotent, non-clobbering).
29
+ - `skalpel-hook.mjs` / `skalpel-hook-session.mjs` — the per-turn + session hooks.
30
+ - `skalpel-statusline.mjs` — the status line.
31
+ - `auth.mjs` — refresh-capable identity (Google id_token, refreshed through AWS).
32
32
 
33
- For details on what gets installed where, per-OS service registration, updates, and uninstall, see [INSTALL.md](./INSTALL.md).
33
+ ## Auth
34
34
 
35
- ## Subcommands
35
+ Google OAuth, brokered entirely through AWS: the browser redirects to `graph.skalpel.ai/oauth/callback`,
36
+ the server exchanges the code (holding the client secret), and the CLI polls for the finished token. The
37
+ Google client secret never ships in the package.
36
38
 
37
- `skalpel` accepts a small set of subcommands:
39
+ ## Uninstall
38
40
 
39
- - `skalpel` — launch the Bubble Tea TUI (default).
40
- - `skalpel login` sign in, heal behavior hooks, and start/resume intent-graph learning.
41
- - `skalpel setup` — repair optional external-shell wrappers and behavior hooks; not required for normal login/TUI onboarding.
42
- - `skalpel logout` — revoke the active session (best-effort) and delete `auth.json`; supports `--yes` to skip the `[y/N]` confirm.
43
- - `skalpel uninstall` — clean up skalpel state on this machine.
44
- - `skalpel --version` / `skalpel --help` — utilities.
45
-
46
- ## Links
47
-
48
- - Homepage: https://skalpel.ai
49
- - Documentation: see `INSTALL.md` and `SPEC.md` in this repo
50
- - Issues: https://github.com/skalpelai/Skalpelai_Client/issues
51
- - License: Apache-2.0 (see [LICENSE](./LICENSE))
41
+ ```bash
42
+ skalpel uninstall # removes the hooks from Claude Code + Codex
43
+ ```
package/auth.mjs ADDED
@@ -0,0 +1,179 @@
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, writeFileSync } 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
+ // Direct Cognito refresh (public app client — no secret). The old `${DASHBOARD_API}/v1/auth/refresh`
11
+ // route is gone (404s), which silently expired every hook; refresh straight against Cognito instead.
12
+ const COGNITO_TOKEN_URL =
13
+ process.env.SKALPEL_COGNITO_TOKEN_URL ||
14
+ "https://skalpel-prod.auth.us-west-2.amazoncognito.com/oauth2/token";
15
+ const COGNITO_CLIENT_ID = process.env.SKALPEL_COGNITO_CLIENT_ID || "2gfil041fv9u58jemc7l5u2pht";
16
+ const GRAPH_API = process.env.SKALPEL_API || "https://graph.skalpel.ai";
17
+
18
+ function cliAuthPath() {
19
+ if (process.env.SKALPEL_CONFIG_DIR) return join(process.env.SKALPEL_CONFIG_DIR, "auth.json");
20
+ if (process.platform === "win32") {
21
+ const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
22
+ return join(appData, "skalpel", "auth.json");
23
+ }
24
+ if (process.platform === "darwin") {
25
+ return join(homedir(), "Library", "Application Support", "skalpel", "auth.json");
26
+ }
27
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
28
+ return join(configHome, "skalpel", "auth.json");
29
+ }
30
+
31
+ function loadCliAuth() {
32
+ try {
33
+ const auth = JSON.parse(readFileSync(cliAuthPath(), "utf8"));
34
+ const hasGoogle = typeof auth?.google?.id_token === "string" && auth.google.id_token;
35
+ const hasCognito = typeof auth?.cognito?.id_token === "string" && auth.cognito.id_token;
36
+ return hasGoogle || hasCognito ? auth : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ // Refresh a Google id_token THROUGH the graph service (server holds the client secret) — the CLI
43
+ // never ships a Google secret. Returns { id_token, expires_in } or null.
44
+ async function googleRefresh(refreshToken) {
45
+ if (!refreshToken) return null;
46
+ const ctrl = new AbortController();
47
+ const timer = setTimeout(() => ctrl.abort(), REFRESH_TIMEOUT_MS);
48
+ try {
49
+ const r = await fetch(`${GRAPH_API}/oauth/refresh`, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json" },
52
+ body: JSON.stringify({ refresh_token: refreshToken }),
53
+ signal: ctrl.signal,
54
+ });
55
+ if (!r.ok) return null;
56
+ const t = await r.json();
57
+ return t && typeof t.id_token === "string" ? t : null;
58
+ } catch {
59
+ return null;
60
+ } finally {
61
+ clearTimeout(timer);
62
+ }
63
+ }
64
+
65
+ function decodeJwtPayload(token) {
66
+ try {
67
+ const part = token.split(".")[1];
68
+ const padded = part + "=".repeat((4 - (part.length % 4)) % 4);
69
+ return JSON.parse(
70
+ Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(),
71
+ );
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function toEpochSeconds(value) {
78
+ if (typeof value === "number" && Number.isFinite(value)) {
79
+ return value > 1e12 ? Math.floor(value / 1000) : value;
80
+ }
81
+ if (typeof value === "string") {
82
+ const millis = Date.parse(value);
83
+ return Number.isFinite(millis) ? Math.floor(millis / 1000) : null;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ async function refreshTokens(refreshToken) {
89
+ if (!refreshToken) return null;
90
+ const ctrl = new AbortController();
91
+ const timer = setTimeout(() => ctrl.abort(), REFRESH_TIMEOUT_MS);
92
+ try {
93
+ const body = new URLSearchParams({
94
+ grant_type: "refresh_token",
95
+ client_id: COGNITO_CLIENT_ID,
96
+ refresh_token: refreshToken,
97
+ });
98
+ const response = await fetch(COGNITO_TOKEN_URL, {
99
+ method: "POST",
100
+ headers: { "content-type": "application/x-www-form-urlencoded" },
101
+ body,
102
+ signal: ctrl.signal,
103
+ });
104
+ if (!response.ok) return null;
105
+ const t = await response.json();
106
+ return t && typeof t.access_token === "string" ? t : null;
107
+ } catch {
108
+ return null;
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
113
+
114
+ // Persist a refreshed token set back into auth.json so the next hook reuses it instead of
115
+ // re-refreshing every turn. Best-effort — a write failure never blocks the hook.
116
+ function persistTokens(auth, t) {
117
+ try {
118
+ auth.cognito = {
119
+ ...auth.cognito,
120
+ access_token: t.access_token,
121
+ id_token: t.id_token || auth.cognito.id_token,
122
+ expires_at: new Date(Date.now() + (Number(t.expires_in) || 3600) * 1000).toISOString(),
123
+ };
124
+ writeFileSync(cliAuthPath(), JSON.stringify(auth));
125
+ } catch {
126
+ /* never block on a persist failure */
127
+ }
128
+ }
129
+
130
+ export async function identity() {
131
+ if (process.env.SKALPEL_USER) return { uid: process.env.SKALPEL_USER, token: null };
132
+
133
+ const auth = loadCliAuth();
134
+ if (!auth) return { uid: null, token: null };
135
+
136
+ // Google is the account plane (from `skalpel login`). Send the Google id_token; the server verifies
137
+ // it (aud-pinned) and keys the graph by the verified email. Refresh through AWS (/oauth/refresh) so
138
+ // no Google secret ever ships in the hook.
139
+ if (auth.google?.id_token) {
140
+ let idt = auth.google.id_token;
141
+ const gexp = toEpochSeconds(auth.google.expires_at) ?? decodeJwtPayload(idt)?.exp ?? null;
142
+ if (gexp !== null && gexp - Math.floor(Date.now() / 1000) < 60) {
143
+ const t = await googleRefresh(auth.google.refresh_token);
144
+ if (t) {
145
+ idt = t.id_token;
146
+ try {
147
+ auth.google.id_token = idt;
148
+ auth.google.expires_at = new Date(
149
+ Date.now() + (Number(t.expires_in) || 3600) * 1000,
150
+ ).toISOString();
151
+ writeFileSync(cliAuthPath(), JSON.stringify(auth));
152
+ } catch {
153
+ /* never block on persist */
154
+ }
155
+ } else {
156
+ idt = null; // refresh failed — fall through to the Cognito bridge
157
+ }
158
+ }
159
+ if (idt) {
160
+ const uid = auth.google.sub || auth.google.email || decodeJwtPayload(idt)?.sub || null;
161
+ return { uid, token: idt };
162
+ }
163
+ }
164
+
165
+ // The hosted graph verifies the ACCESS token (Cognito /oauth2/userInfo -> email; an id_token is
166
+ // rejected there). Send the access token; the server keys the graph by the verified email.
167
+ let access = auth.cognito.access_token;
168
+ const claims = decodeJwtPayload(access);
169
+ const expiresAt = toEpochSeconds(auth.cognito.expires_at) ?? claims?.exp ?? null;
170
+ if (!access || (expiresAt !== null && expiresAt - Math.floor(Date.now() / 1000) < 60)) {
171
+ const t = await refreshTokens(auth.cognito.refresh_token);
172
+ if (!t) return { uid: null, token: null };
173
+ access = t.access_token;
174
+ persistTokens(auth, t);
175
+ }
176
+
177
+ const uid = decodeJwtPayload(access)?.sub ?? auth.subject?.user_id ?? null;
178
+ return uid ? { uid, token: access } : { uid: null, token: null };
179
+ }
@@ -29,10 +29,7 @@ const STATE_PATH = join(DIR, "bootstrap.json");
29
29
  const LOCK_PATH = join(DIR, "bootstrap.lock");
30
30
  const FORCE = process.argv.includes("--force");
31
31
  const LOOKBACK_DAYS = Math.max(1, Number.parseInt(process.env.SKALPEL_LOOKBACK_DAYS || "30", 10));
32
- const MIN_USER_TURNS = Math.max(
33
- 1,
34
- Number.parseInt(process.env.SKALPEL_MIN_USER_TURNS || "5", 10),
35
- );
32
+ const MIN_USER_TURNS = Math.max(1, Number.parseInt(process.env.SKALPEL_MIN_USER_TURNS || "5", 10));
36
33
  const MAX_SESSIONS = Math.max(
37
34
  1,
38
35
  Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "20", 10),
@@ -443,13 +440,17 @@ async function ingestAndFinish(files, auth, runStartMs) {
443
440
  sessions,
444
441
  last_ingest_at: new Date(runStartMs).toISOString(),
445
442
  });
446
- emit("complete", `Learned from ${sessions} session${sessions === 1 ? "" : "s"}`, { terminal: true, sessions });
443
+ emit("complete", `Learned from ${sessions} session${sessions === 1 ? "" : "s"}`, {
444
+ terminal: true,
445
+ sessions,
446
+ });
447
447
  return sessions;
448
448
  }
449
449
 
450
450
  async function main() {
451
451
  const auth = await identity();
452
- if (!auth.uid || !auth.token) throw new Error("sign in with `skalpel login` before learning history");
452
+ if (!auth.uid || !auth.token)
453
+ throw new Error("sign in with `skalpel login` before learning history");
453
454
 
454
455
  const prior = readState();
455
456
  const runStartMs = Date.now();
@@ -488,7 +489,10 @@ async function main() {
488
489
  sessions,
489
490
  last_ingest_at: new Date(runStartMs).toISOString(),
490
491
  });
491
- emit("complete", `Learned from ${sessions} session${sessions === 1 ? "" : "s"}`, { terminal: true, sessions });
492
+ emit("complete", `Learned from ${sessions} session${sessions === 1 ? "" : "s"}`, {
493
+ terminal: true,
494
+ sessions,
495
+ });
492
496
  return;
493
497
  }
494
498
 
@@ -522,7 +526,9 @@ async function run() {
522
526
  await main()
523
527
  .catch((error) => {
524
528
  const prior = readState();
525
- const message = String(error?.message || error).replace(/[\r\n\t]+/g, " ").slice(0, 240);
529
+ const message = String(error?.message || error)
530
+ .replace(/[\r\n\t]+/g, " ")
531
+ .slice(0, 240);
526
532
  writeState({ ...prior, state: "failed", error: message });
527
533
  emit("failed", message, {
528
534
  terminal: true,
@@ -137,7 +137,10 @@ function writeLedger(home, ledger) {
137
137
  const entries = Object.entries(ledger.keys || {})
138
138
  .sort((a, b) => String(b[1]).localeCompare(String(a[1])))
139
139
  .slice(0, MAX_DELIVERED_KEYS);
140
- writeAtomic(join(outboxDir(home), LEDGER_FILE), { version: 1, keys: Object.fromEntries(entries) });
140
+ writeAtomic(join(outboxDir(home), LEDGER_FILE), {
141
+ version: 1,
142
+ keys: Object.fromEntries(entries),
143
+ });
141
144
  }
142
145
 
143
146
  function markDelivered(home, item, now) {
@@ -232,15 +235,7 @@ function scheduleTerminalRetry(home, item, status, now, insightFn) {
232
235
  }
233
236
 
234
237
  async function pollAcceptedJob(home, item, auth, options, deadline) {
235
- const {
236
- api,
237
- fetchFn,
238
- insightFn,
239
- now,
240
- pollIntervalMs,
241
- requestTimeoutMs,
242
- sleepFn,
243
- } = options;
238
+ const { api, fetchFn, insightFn, now, pollIntervalMs, requestTimeoutMs, sleepFn } = options;
244
239
  const statusURL = `${api}/ingest/status?job_id=${encodeURIComponent(item.job_id)}`;
245
240
  for (;;) {
246
241
  let response;
@@ -72,8 +72,7 @@ const sessionEndRuntime =
72
72
  process.env.SKALPEL_HOOK_BIN && PKG_DIR !== HOOKS_DIR
73
73
  ? join(PKG_DIR, "skalpel-hook-session-end.mjs")
74
74
  : SESSION_END_FILE;
75
- const SESSION_END_CMD =
76
- process.env.SKALPEL_HOOK_SESSION_END_BIN || `node ${sessionEndRuntime}`;
75
+ const SESSION_END_CMD = process.env.SKALPEL_HOOK_SESSION_END_BIN || `node ${sessionEndRuntime}`;
77
76
  const STATUSLINE_CMD = process.env.SKALPEL_STATUSLINE_BIN || `node ${STATUSLINE_FILE}`;
78
77
  const CLAUDE = join(homedir(), ".claude", "settings.json");
79
78
  const CODEX_DIR = join(homedir(), ".codex");
@@ -93,10 +92,7 @@ const HOOKS = [
93
92
  // Claude Code supports a once-per-session SessionEnd event and native background command hooks.
94
93
  // Codex currently supports neither SessionEnd nor async command hooks, so its honest fallback is
95
94
  // the existing next-launch history scan; do not write an inert Codex hook and claim parity.
96
- const CLAUDE_HOOKS = [
97
- ...HOOKS,
98
- ["SessionEnd", markerOf(SESSION_END_CMD), SESSION_END_CMD],
99
- ];
95
+ const CLAUDE_HOOKS = [...HOOKS, ["SessionEnd", markerOf(SESSION_END_CMD), SESSION_END_CMD]];
100
96
 
101
97
  function readJson(p) {
102
98
  let raw;
@@ -357,9 +353,10 @@ function claudeMd() {
357
353
  const startIdx = stripped.indexOf(start);
358
354
  const endIdx = stripped.indexOf(end);
359
355
  if (startIdx !== -1 && endIdx !== -1 && endIdx >= startIdx) {
360
- stripped = (
361
- stripped.slice(0, startIdx) + stripped.slice(endIdx + end.length)
362
- ).replace(/\n{3,}/g, "\n\n");
356
+ stripped = (stripped.slice(0, startIdx) + stripped.slice(endIdx + end.length)).replace(
357
+ /\n{3,}/g,
358
+ "\n\n",
359
+ );
363
360
  had = true;
364
361
  }
365
362
  }
package/login.mjs ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // skalpel login — Sign in with Google, fully through AWS (no localhost).
3
+ // this CLI → browser → Google → https://graph.skalpel.ai/oauth/callback (AWS) → we poll AWS for
4
+ // the token → save it. The server holds the client secret and does the code exchange; we only ever
5
+ // see the finished Google id_token. Branded onboarding reproduces the original prosumer `setup.mjs`
6
+ // red block wordmark 1:1. Run: `node skalpel-login.mjs`.
7
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join, dirname } from "node:path";
10
+ import { randomBytes } from "node:crypto";
11
+ import { spawn } from "node:child_process";
12
+
13
+ const API = process.env.SKALPEL_API || "https://graph.skalpel.ai";
14
+ const CLIENT_ID =
15
+ process.env.SKALPEL_GOOGLE_CLIENT_ID ||
16
+ "717225126942-6kp72hm7snalbvi3fgl5llmsshm0c8o2.apps.googleusercontent.com";
17
+ const REDIRECT_URI = `${API}/oauth/callback`;
18
+ const SCOPE = "openid email profile";
19
+
20
+ // ── brand (from the original prosumer setup.mjs — 1:1) ────────────────────
21
+ const R = "\x1b[38;2;233;38;31m", // skalpel red
22
+ G = "\x1b[38;2;124;184;108m", // success green
23
+ D = "\x1b[2m",
24
+ B = "\x1b[1m",
25
+ X = "\x1b[0m";
26
+ const isTTY = process.stdout.isTTY && !process.env.CI;
27
+
28
+ function banner() {
29
+ console.log(`
30
+ ${R} ███████╗██╗ ██╗ █████╗ ██╗ ██████╗ ███████╗██╗
31
+ ██╔════╝██║ ██╔╝██╔══██╗██║ ██╔══██╗██╔════╝██║
32
+ ███████╗█████╔╝ ███████║██║ ██████╔╝█████╗ ██║
33
+ ╚════██║██╔═██╗ ██╔══██║██║ ██╔═══╝ ██╔══╝ ██║
34
+ ███████║██║ ██╗██║ ██║███████╗██║ ███████╗███████╗
35
+ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══════╝╚══════╝${X}`);
36
+ console.log(`\n ${D}your AI coding sessions, learned — and steered in real time${X}\n`);
37
+ }
38
+
39
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
40
+
41
+ function authPath() {
42
+ if (process.env.SKALPEL_CONFIG_DIR) return join(process.env.SKALPEL_CONFIG_DIR, "auth.json");
43
+ if (process.platform === "win32")
44
+ return join(
45
+ process.env.APPDATA || join(homedir(), "AppData", "Roaming"),
46
+ "skalpel",
47
+ "auth.json",
48
+ );
49
+ if (process.platform === "darwin")
50
+ return join(homedir(), "Library", "Application Support", "skalpel", "auth.json");
51
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "skalpel", "auth.json");
52
+ }
53
+
54
+ function openBrowser(url) {
55
+ const cmd =
56
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
57
+ try {
58
+ spawn(cmd, [url], {
59
+ stdio: "ignore",
60
+ detached: true,
61
+ shell: process.platform === "win32",
62
+ }).unref();
63
+ } catch {
64
+ /* manual-paste fallback below */
65
+ }
66
+ }
67
+
68
+ function decodeJwt(token) {
69
+ try {
70
+ const p = token.split(".")[1];
71
+ const padded = p + "=".repeat((4 - (p.length % 4)) % 4);
72
+ return JSON.parse(
73
+ Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(),
74
+ );
75
+ } catch {
76
+ return {};
77
+ }
78
+ }
79
+
80
+ async function main() {
81
+ banner();
82
+ const state = randomBytes(32).toString("hex");
83
+ const authUrl =
84
+ `https://accounts.google.com/o/oauth2/v2/auth?` +
85
+ new URLSearchParams({
86
+ client_id: CLIENT_ID,
87
+ redirect_uri: REDIRECT_URI,
88
+ response_type: "code",
89
+ scope: SCOPE,
90
+ state,
91
+ access_type: "offline",
92
+ prompt: "consent",
93
+ include_granted_scopes: "true",
94
+ }).toString();
95
+
96
+ console.log(` ${B}Sign in with Google${X}${D} — opening your browser…${X}`);
97
+ console.log(` ${D}if it doesn't open, paste this:${X}`);
98
+ console.log(` ${D}${authUrl}${X}\n`);
99
+ openBrowser(authUrl);
100
+
101
+ // Poll the AWS callback store for our state until the token lands (or ~3 min), red spinner.
102
+ if (isTTY) process.stdout.write("\x1b[?25l"); // hide cursor
103
+ const deadline = Date.now() + 180_000;
104
+ let payload = null;
105
+ let i = 0;
106
+ while (Date.now() < deadline) {
107
+ if (isTTY)
108
+ process.stdout.write(
109
+ `\r\x1b[2K ${R}${FRAMES[i++ % FRAMES.length]}${X} waiting for sign-in…`,
110
+ );
111
+ await new Promise((r) => setTimeout(r, 250));
112
+ if (i % 8 !== 0) continue; // spin ~250ms, poll ~2s
113
+ try {
114
+ const r = await fetch(`${API}/oauth/token?state=${encodeURIComponent(state)}`);
115
+ if (r.ok) {
116
+ const j = await r.json();
117
+ if (j && j.id_token) {
118
+ payload = j;
119
+ break;
120
+ }
121
+ }
122
+ } catch {
123
+ /* transient — keep polling */
124
+ }
125
+ }
126
+ if (isTTY) process.stdout.write(`\r\x1b[2K`);
127
+ if (!payload) {
128
+ console.log(
129
+ ` ${R}✗${X} timed out waiting for sign-in. Run \`skalpel login\` again.${isTTY ? "\x1b[?25h" : ""}`,
130
+ );
131
+ process.exit(1);
132
+ }
133
+
134
+ const claims = decodeJwt(payload.id_token);
135
+ const p = authPath();
136
+ mkdirSync(dirname(p), { recursive: true });
137
+ let auth = {};
138
+ try {
139
+ auth = JSON.parse(readFileSync(p, "utf8"));
140
+ } catch {
141
+ /* fresh */
142
+ }
143
+ auth.google = {
144
+ id_token: payload.id_token,
145
+ refresh_token: payload.refresh_token || auth.google?.refresh_token || null,
146
+ expires_at: new Date(Date.now() + (Number(payload.expires_in) || 3600) * 1000).toISOString(),
147
+ email: claims.email || null,
148
+ sub: claims.sub || null,
149
+ };
150
+ auth.subject = { ...(auth.subject || {}), email: claims.email || auth.subject?.email };
151
+ writeFileSync(p, JSON.stringify(auth, null, 2));
152
+
153
+ console.log(` ${G}✓${X} signed in as ${B}${claims.email || "your Google account"}${X}`);
154
+ console.log(` ${G}✓${X} skalpel is live`);
155
+ console.log(
156
+ ` ${D}your next Claude Code prompt is steered by your own behavioral graph${X}\n${isTTY ? "\x1b[?25h" : ""}`,
157
+ );
158
+ process.exit(0);
159
+ }
160
+
161
+ main().catch((e) => {
162
+ console.log(` ${R}✗${X} login error: ${D}${e?.message || String(e)}${X}\x1b[?25h`);
163
+ process.exit(1);
164
+ });
package/package.json CHANGED
@@ -1,68 +1,28 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.4.19",
4
- "description": "Skalpellocal proxy and TUI for coding agents (skalpel + skalpeld bundle).",
5
- "license": "Apache-2.0",
6
- "homepage": "https://skalpel.ai",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/skalpelai/Skalpelai_Client.git"
10
- },
11
- "bugs": {
12
- "url": "https://github.com/skalpelai/Skalpelai_Client/issues"
13
- },
14
- "author": "SkalpelAI Inc.",
15
- "keywords": [
16
- "coding-agent",
17
- "proxy",
18
- "tui",
19
- "claude",
20
- "cursor",
21
- "codex"
22
- ],
23
- "engines": {
24
- "node": ">=18"
25
- },
3
+ "version": "4.0.1",
4
+ "description": "Behavioral graph for AI-assisted coding learns how you work and steers Claude Code + Codex in real time.",
5
+ "type": "module",
26
6
  "bin": {
27
- "skalpel": "npm-bin/skalpel.js",
28
- "skalpeld": "npm-bin/skalpeld.js"
7
+ "skalpel": "skalpel-setup.mjs"
29
8
  },
30
9
  "scripts": {
31
- "preinstall": "node postinstall/preinstall.js",
32
- "postinstall": "node postinstall/index.js",
33
- "preuninstall": "node postinstall/uninstall.js",
34
- "test": "node postinstall/lib/run-tests.js && node npm-bin/skalpel.bug-0039.test.js",
35
- "test:rc-edit": "node postinstall/lib/rc-edit.test.js",
36
- "test:postinstall": "node --test postinstall/index.test.js",
37
- "test:preinstall": "node --test postinstall/preinstall.test.js",
38
- "test:bug-0039": "node npm-bin/skalpel.bug-0039.test.js"
10
+ "postinstall": "node postinstall.mjs || true"
39
11
  },
40
12
  "files": [
41
- "npm-bin/",
42
- "postinstall/",
43
- "prosumer-hooks/",
44
- "design-tokens.json",
45
- "INSTALL.md",
46
- "README.md",
47
- "LICENSE"
13
+ "*.mjs",
14
+ "README.md"
48
15
  ],
49
- "dependencies": {
50
- "picocolors": "^1.1.1"
16
+ "engines": {
17
+ "node": ">=18"
51
18
  },
52
- "os": [
53
- "darwin",
54
- "linux",
55
- "win32"
56
- ],
57
- "cpu": [
58
- "arm64",
59
- "x64"
19
+ "keywords": [
20
+ "claude-code",
21
+ "codex",
22
+ "ai",
23
+ "productivity",
24
+ "behavioral-analytics"
60
25
  ],
61
- "optionalDependencies": {
62
- "@skalpelai/skalpel-darwin-arm64": "3.4.19",
63
- "@skalpelai/skalpel-darwin-x64": "3.4.19",
64
- "@skalpelai/skalpel-linux-arm64": "3.4.19",
65
- "@skalpelai/skalpel-linux-x64": "3.4.19",
66
- "@skalpelai/skalpel-win32-x64": "3.4.19"
67
- }
26
+ "license": "UNLICENSED",
27
+ "homepage": "https://skalpel.ai"
68
28
  }