skalpel 3.4.19 → 4.0.0
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/README.md +29 -37
- package/auth.mjs +179 -0
- package/{prosumer-hooks/bootstrap.mjs → bootstrap.mjs} +14 -8
- package/{prosumer-hooks/incremental-ingest.mjs → incremental-ingest.mjs} +5 -10
- package/{prosumer-hooks/install.mjs → install.mjs} +6 -9
- package/login.mjs +164 -0
- package/package.json +17 -57
- package/postinstall.mjs +68 -0
- package/skalpel-setup.mjs +896 -0
- package/{prosumer-hooks/skalpel-statusline.mjs → skalpel-statusline.mjs} +1 -2
- package/INSTALL.md +0 -250
- package/LICENSE +0 -201
- package/design-tokens.json +0 -52
- package/npm-bin/colors.js +0 -125
- package/npm-bin/skalpel.bug-0039.test.js +0 -232
- package/npm-bin/skalpel.js +0 -517
- package/npm-bin/skalpeld.js +0 -20
- package/postinstall/index.js +0 -277
- package/postinstall/index.test.js +0 -147
- package/postinstall/launchd/com.skalpel.skalpeld.plist.tmpl +0 -46
- package/postinstall/lib/ca-install.js +0 -268
- package/postinstall/lib/ca-install.test.js +0 -327
- package/postinstall/lib/detect-prior.js +0 -62
- package/postinstall/lib/env-inject.js +0 -105
- package/postinstall/lib/integrations.js +0 -207
- package/postinstall/lib/launch.js +0 -38
- package/postinstall/lib/log.js +0 -31
- package/postinstall/lib/paths.js +0 -236
- package/postinstall/lib/prosumer-hooks.js +0 -146
- package/postinstall/lib/prosumer-hooks.test.js +0 -278
- package/postinstall/lib/rc-edit.js +0 -168
- package/postinstall/lib/rc-edit.test.js +0 -243
- package/postinstall/lib/run-tests.js +0 -59
- package/postinstall/lib/service-register.js +0 -248
- package/postinstall/lib/sign-in.js +0 -80
- package/postinstall/lib/template.js +0 -36
- package/postinstall/preinstall.js +0 -111
- package/postinstall/preinstall.test.js +0 -47
- package/postinstall/snippets/bash.sh.tmpl +0 -12
- package/postinstall/snippets/fish.fish.tmpl +0 -11
- package/postinstall/snippets/powershell.ps1.tmpl +0 -12
- package/postinstall/snippets/zsh.sh.tmpl +0 -13
- package/postinstall/systemd/skalpeld.service.tmpl +0 -34
- package/postinstall/uninstall.js +0 -98
- package/postinstall/windows/Task.xml.tmpl +0 -42
- package/postinstall/windows/register-task.ps1.tmpl +0 -45
- package/prosumer-hooks/auth.mjs +0 -93
- package/prosumer-hooks/bootstrap.test.mjs +0 -93
- package/prosumer-hooks/incremental-ingest.test.mjs +0 -320
- /package/{prosumer-hooks/insights.mjs → insights.mjs} +0 -0
- /package/{prosumer-hooks/metrics.mjs → metrics.mjs} +0 -0
- /package/{prosumer-hooks/skalpel-hook-session-end.mjs → skalpel-hook-session-end.mjs} +0 -0
- /package/{prosumer-hooks/skalpel-hook-session.mjs → skalpel-hook-session.mjs} +0 -0
- /package/{prosumer-hooks/skalpel-hook.mjs → skalpel-hook.mjs} +0 -0
- /package/{prosumer-hooks/stats.mjs → stats.mjs} +0 -0
package/README.md
CHANGED
|
@@ -1,51 +1,43 @@
|
|
|
1
1
|
# skalpel
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
## What it does
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
19
|
+
## Architecture (pure node — no daemon, no binary)
|
|
26
20
|
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
## Auth
|
|
34
34
|
|
|
35
|
-
|
|
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
|
-
|
|
39
|
+
## Uninstall
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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"}`, {
|
|
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)
|
|
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"}`, {
|
|
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)
|
|
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), {
|
|
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
|
-
|
|
362
|
-
|
|
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": "
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
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.0",
|
|
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": "
|
|
28
|
-
"skalpeld": "npm-bin/skalpeld.js"
|
|
7
|
+
"skalpel": "skalpel-setup.mjs"
|
|
29
8
|
},
|
|
30
9
|
"scripts": {
|
|
31
|
-
"
|
|
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
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"prosumer-hooks/",
|
|
44
|
-
"design-tokens.json",
|
|
45
|
-
"INSTALL.md",
|
|
46
|
-
"README.md",
|
|
47
|
-
"LICENSE"
|
|
13
|
+
"*.mjs",
|
|
14
|
+
"README.md"
|
|
48
15
|
],
|
|
49
|
-
"
|
|
50
|
-
"
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
51
18
|
},
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"arm64",
|
|
59
|
-
"x64"
|
|
19
|
+
"keywords": [
|
|
20
|
+
"claude-code",
|
|
21
|
+
"codex",
|
|
22
|
+
"ai",
|
|
23
|
+
"productivity",
|
|
24
|
+
"behavioral-analytics"
|
|
60
25
|
],
|
|
61
|
-
"
|
|
62
|
-
|
|
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
|
}
|