changebook 0.2.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/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/analyze.js +126 -0
- package/dist/browser.js +33 -0
- package/dist/credentials.js +64 -0
- package/dist/git.js +37 -0
- package/dist/hook.js +69 -0
- package/dist/import.js +164 -0
- package/dist/index.js +181 -0
- package/dist/init.js +76 -0
- package/dist/login.js +109 -0
- package/dist/optimize.js +193 -0
- package/dist/supabase.js +218 -0
- package/dist/sync.js +268 -0
- package/dist/tools.js +338 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AppAtlas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# ChangeBook for coding agents (MCP server + CLI)
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server that lets coding agents — Claude Code,
|
|
4
|
+
Codex, Cursor — query the **ChangeBook product memory** (the module map and the
|
|
5
|
+
analyzed change history stored in Supabase) instead of re-reading the
|
|
6
|
+
codebase, plus a CLI that feeds that memory from any terminal: sign in,
|
|
7
|
+
analyze uncommitted changes, sync the product map. All MCP tools are
|
|
8
|
+
read-only, and Row Level Security scopes every query to the signed-in user.
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx changebook init # login (browser) + register in Claude Code/Codex + sync
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`init` does four things: opens the browser so you sign in on changebook.app
|
|
17
|
+
(one Authorize click — no token copy-pasting), registers the MCP server in
|
|
18
|
+
Claude Code (user scope) and Codex (`~/.codex/config.toml`), installs the
|
|
19
|
+
post-commit hook so the atlas updates itself, and writes the product map
|
|
20
|
+
into the project's `CLAUDE.md`/`AGENTS.md`.
|
|
21
|
+
|
|
22
|
+
## CLI commands
|
|
23
|
+
|
|
24
|
+
| Command | What it does |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `changebook login` | Browser sign-in; stores the session in `~/.changebook/credentials.json` (0600). |
|
|
27
|
+
| `changebook logout` | Forget the stored session. |
|
|
28
|
+
| `changebook analyze [dir]` | Analyze the repo's uncommitted changes (`git diff HEAD`) and update the atlas — same pipeline as the VS Code extension, no editor needed. |
|
|
29
|
+
| `changebook analyze --commit [ref]` | Analyze one commit. Deduped by hash server-side, so re-runs never bill. |
|
|
30
|
+
| `changebook hook install\|uninstall\|status [dir]` | Git post-commit hook: every new commit is analyzed in the background (never blocks the commit). One hook covers Claude Code, Codex and manual commits — they all commit through git. |
|
|
31
|
+
| `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
|
|
32
|
+
| `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
|
|
33
|
+
| `changebook open` | Open the web atlas in the browser. |
|
|
34
|
+
| `changebook serve` | Run the MCP server on stdio (also the default with no arguments). |
|
|
35
|
+
|
|
36
|
+
Every command also works as `npx changebook <command>` with no install.
|
|
37
|
+
|
|
38
|
+
## MCP tools
|
|
39
|
+
|
|
40
|
+
| Tool | What it does |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `atlas_recent_changes` | Recent analyzed changes: business impact, tech summary, modules touched. Supports `limit`, `offset`, `search`. |
|
|
43
|
+
| `atlas_modules` | Aggregated module map: domain, category, risk, change count, last change, files. Optional `domain` filter. |
|
|
44
|
+
| `atlas_module_detail` | One module's history: impact, notes, files and verbatim diff excerpts. |
|
|
45
|
+
|
|
46
|
+
### Manual registration (instead of `init`)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
claude mcp add --scope user changebook -- npx -y changebook serve
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or in the project's `.mcp.json`:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"changebook": {
|
|
58
|
+
"command": "npx",
|
|
59
|
+
"args": ["-y", "changebook", "serve"]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Credentials come from `changebook login` (`~/.changebook/credentials.json`); the
|
|
66
|
+
env vars below override them for CI/headless setups.
|
|
67
|
+
|
|
68
|
+
### Environment variables
|
|
69
|
+
|
|
70
|
+
| Variable | Default | Purpose |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `CHANGEBOOK_REFRESH_TOKEN` | — | Session token override (CI/headless; wins over the credentials file). |
|
|
73
|
+
| `CHANGEBOOK_ACCESS_TOKEN` | — | Short-lived JWT; refreshed automatically when it expires. |
|
|
74
|
+
| `CHANGEBOOK_PROJECT` | — | Scope every query to one project (matched by slug, then exact name — usually the workspace folder name). Unset = all projects. |
|
|
75
|
+
| `CHANGEBOOK_WEB_URL` | `https://changebook.app` | Web app used by `login`/`open` and printed after `analyze`. |
|
|
76
|
+
| `CHANGEBOOK_SUPABASE_URL` | production project | Override for other environments. |
|
|
77
|
+
| `CHANGEBOOK_SUPABASE_ANON_KEY` | production public key | Override for other environments. |
|
|
78
|
+
|
|
79
|
+
The embedded anon key is the same public key the web app ships — it grants
|
|
80
|
+
nothing by itself; your session token plus RLS decide what you can read.
|
|
81
|
+
|
|
82
|
+
## `sync`: product map inside CLAUDE.md / AGENTS.md
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx changebook sync [dir]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Writes (or refreshes, idempotently) an auto-generated section between
|
|
89
|
+
`<!-- changebook:start -->` / `<!-- changebook:end -->` markers in the target
|
|
90
|
+
directory's `CLAUDE.md` and `AGENTS.md`: the module map, the latest changes
|
|
91
|
+
and a hint telling agents to use the `atlas_*` tools. Those files load into
|
|
92
|
+
every Claude Code / Codex session and get prompt-cached, so the map orients
|
|
93
|
+
agents at near-zero marginal cost. Content outside the markers is never
|
|
94
|
+
touched. Re-run after analyzing changes (or wire it to a git hook).
|
|
95
|
+
|
|
96
|
+
## Security notes
|
|
97
|
+
|
|
98
|
+
- MCP tools are read-only; only `analyze` writes (through the same audited
|
|
99
|
+
Edge Function as the extension, with the same quotas).
|
|
100
|
+
- `login` uses a loopback-only handoff: the web app asks for an explicit
|
|
101
|
+
Authorize click and redirects the tokens to `http://127.0.0.1:<port>` in
|
|
102
|
+
the URL fragment — they never leave your machine, and a `state` nonce ties
|
|
103
|
+
the handoff to the CLI process that requested it.
|
|
104
|
+
- Your session lives in `~/.changebook/credentials.json` (0600) — treat it like
|
|
105
|
+
a password. `changebook logout` removes it.
|
package/dist/analyze.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `changebook analyze [dir]` — capture the working tree's uncommitted changes,
|
|
3
|
+
* compress them and send them to the analyze-diff Edge Function. Same
|
|
4
|
+
* pipeline as the VS Code extension, editor-free, so Claude Code / Codex
|
|
5
|
+
* hooks (or the user) can feed the atlas from any terminal.
|
|
6
|
+
*/
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { atlasWebUrl } from "./browser.js";
|
|
9
|
+
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, } from "./git.js";
|
|
10
|
+
import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
|
|
11
|
+
export async function analyze(db, options = {}) {
|
|
12
|
+
const cwd = path.resolve(options.dir ?? process.cwd());
|
|
13
|
+
const projectName = path.basename(cwd);
|
|
14
|
+
let rawDiff;
|
|
15
|
+
let commitHash;
|
|
16
|
+
let committedAt;
|
|
17
|
+
if (options.commit) {
|
|
18
|
+
const meta = await commitMeta(cwd, options.commit);
|
|
19
|
+
commitHash = meta.hash;
|
|
20
|
+
committedAt = meta.committedAt;
|
|
21
|
+
rawDiff = await commitDiff(cwd, meta.hash);
|
|
22
|
+
if (!rawDiff.trim()) {
|
|
23
|
+
console.error(`Commit ${meta.hash.slice(0, 8)} has no analyzable diff (merge?). Skipped.`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
rawDiff = await gitDiffHead(cwd);
|
|
29
|
+
if (!rawDiff.trim()) {
|
|
30
|
+
console.error("No uncommitted changes to analyze (git diff HEAD is empty).");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
let compressedDiff = optimizeTokensForAI(rawDiff);
|
|
35
|
+
if (compressedDiff.length > MAX_DIFF_CHARACTERS) {
|
|
36
|
+
console.error(`Diff is large (${compressedDiff.length} chars); truncating to ${MAX_DIFF_CHARACTERS}.`);
|
|
37
|
+
// Cut at a file boundary so no `### path` header is split (the server
|
|
38
|
+
// reads those to filter the atlas context).
|
|
39
|
+
compressedDiff = truncateAtFileBoundary(compressedDiff, MAX_DIFF_CHARACTERS);
|
|
40
|
+
}
|
|
41
|
+
if (!compressedDiff) {
|
|
42
|
+
console.error("Nothing left to analyze after compression. Skipped.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
console.error(`Analyzing ${compressedDiff.length} chars of changes in "${projectName}"…`);
|
|
46
|
+
const { status, body } = await db.invokeFunction("analyze-diff", {
|
|
47
|
+
compressedDiff,
|
|
48
|
+
rawDiffChars: rawDiff.length,
|
|
49
|
+
projectName,
|
|
50
|
+
commitHash,
|
|
51
|
+
committedAt,
|
|
52
|
+
});
|
|
53
|
+
if (status !== 200 || body.error) {
|
|
54
|
+
// The server's messages are user-facing (quota, waitlist…): pass through.
|
|
55
|
+
throw new Error(body.error ?? `analyze-diff failed with status ${status}`);
|
|
56
|
+
}
|
|
57
|
+
if (body.skipped) {
|
|
58
|
+
console.error(`Commit already in the atlas — nothing billed.`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const lines = [""];
|
|
62
|
+
if (body.summary)
|
|
63
|
+
lines.push(body.summary, "");
|
|
64
|
+
if (body.summaryTech)
|
|
65
|
+
lines.push(`Tech: ${body.summaryTech}`, "");
|
|
66
|
+
if (body.modules && body.modules.length > 0) {
|
|
67
|
+
const mods = body.modules
|
|
68
|
+
.map((m) => (m.module ?? "") + (m.risk && m.risk !== "low" ? ` [${m.risk}]` : ""))
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join(", ");
|
|
71
|
+
if (mods)
|
|
72
|
+
lines.push(`Modules: ${mods}`);
|
|
73
|
+
}
|
|
74
|
+
if (body.regressionWarnings && body.regressionWarnings.length > 0) {
|
|
75
|
+
lines.push("", "⚠ Posible regresión:");
|
|
76
|
+
for (const w of body.regressionWarnings) {
|
|
77
|
+
if (w.plain)
|
|
78
|
+
lines.push(` - ${w.plain}`);
|
|
79
|
+
}
|
|
80
|
+
lines.push("");
|
|
81
|
+
}
|
|
82
|
+
if (body.credits?.limit != null && body.credits.used != null) {
|
|
83
|
+
lines.push(`Credits: ${body.credits.used}/${body.credits.limit} this month`);
|
|
84
|
+
}
|
|
85
|
+
lines.push(`✓ Atlas updated → ${atlasWebUrl()}`);
|
|
86
|
+
console.error(lines.join("\n"));
|
|
87
|
+
}
|
|
88
|
+
async function commitMeta(cwd, ref) {
|
|
89
|
+
try {
|
|
90
|
+
const { stdout } = await execFileAsync("git",
|
|
91
|
+
// --end-of-options so a user-supplied ref beginning with "-" is treated as
|
|
92
|
+
// a revision, not as a git option.
|
|
93
|
+
["log", "-1", "--pretty=format:%H|%cI", "--end-of-options", ref], { cwd, encoding: "utf8" });
|
|
94
|
+
const [hash, committedAt] = stdout.trim().split("|");
|
|
95
|
+
if (!hash)
|
|
96
|
+
throw new Error("empty git log output");
|
|
97
|
+
return { hash, committedAt };
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw new Error(`Could not resolve commit "${ref}" in "${cwd}": ${gitErrorMessage(error)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function gitDiffHead(cwd) {
|
|
104
|
+
try {
|
|
105
|
+
const { stdout } = await execFileAsync("git", ["diff", "HEAD", "-U0"], {
|
|
106
|
+
cwd,
|
|
107
|
+
encoding: "utf8",
|
|
108
|
+
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
|
109
|
+
});
|
|
110
|
+
return stdout;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
const message = gitErrorMessage(error);
|
|
114
|
+
if (message.includes("not a git repository")) {
|
|
115
|
+
throw new Error(`"${cwd}" is not a git repository.`);
|
|
116
|
+
}
|
|
117
|
+
if (message.includes("Needed a single revision") ||
|
|
118
|
+
message.includes("unknown revision") ||
|
|
119
|
+
message.includes("ambiguous argument") ||
|
|
120
|
+
message.includes("bad revision")) {
|
|
121
|
+
throw new Error(`The repository at "${cwd}" has no commits yet; create the first commit and retry.`);
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`Could not read the git diff in "${cwd}": ${message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=analyze.js.map
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** Web app base URL (login handoff, `changebook open`). */
|
|
3
|
+
export function atlasWebUrl(env = process.env) {
|
|
4
|
+
return (env.CHANGEBOOK_WEB_URL ?? "https://changebook.app").replace(/\/+$/, "");
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Best-effort browser open; callers always print the URL too, so a headless
|
|
8
|
+
* machine can still finish the flow by hand.
|
|
9
|
+
*/
|
|
10
|
+
export function openInBrowser(url) {
|
|
11
|
+
// Windows: use rundll32 FileProtocolHandler rather than `cmd /c start`. The
|
|
12
|
+
// URL is a single argv element (no shell), so query separators like `&` in
|
|
13
|
+
// the login URL (…?connect=cli&port=…&state=…) aren't parsed as command
|
|
14
|
+
// separators — `cmd start` splits on them and the login can't complete.
|
|
15
|
+
const [cmd, args] = process.platform === "darwin"
|
|
16
|
+
? ["open", [url]]
|
|
17
|
+
: process.platform === "win32"
|
|
18
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
19
|
+
: ["xdg-open", [url]];
|
|
20
|
+
try {
|
|
21
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
22
|
+
// ENOENT arrives as an async 'error' event (try/catch can't see it);
|
|
23
|
+
// without a listener it would crash the process on headless machines.
|
|
24
|
+
child.on("error", () => {
|
|
25
|
+
/* the printed URL is the fallback */
|
|
26
|
+
});
|
|
27
|
+
child.unref();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// The printed URL is the fallback.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=browser.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential storage for the CLI: ~/.changebook/credentials.json (0600).
|
|
3
|
+
*
|
|
4
|
+
* `changebook login` writes it; the MCP server and every subcommand fall back
|
|
5
|
+
* to it when the CHANGEBOOK_* env vars are not set. Supabase rotates refresh
|
|
6
|
+
* tokens on use, so whoever refreshes must persist the new one back here or
|
|
7
|
+
* the stored token dies after the reuse window.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
const DIR = path.join(os.homedir(), ".changebook");
|
|
13
|
+
const FILE = path.join(DIR, "credentials.json");
|
|
14
|
+
export function credentialsPath() {
|
|
15
|
+
return FILE;
|
|
16
|
+
}
|
|
17
|
+
export function loadCredentials() {
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(FILE, "utf8");
|
|
20
|
+
const data = JSON.parse(raw);
|
|
21
|
+
if (typeof data.refresh_token !== "string" || !data.refresh_token) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function saveCredentials(creds) {
|
|
31
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
32
|
+
const data = {
|
|
33
|
+
...creds,
|
|
34
|
+
updated_at: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
// Write to a temp file in the same directory, then rename over the target.
|
|
37
|
+
// rename is atomic on POSIX and NTFS, so a concurrent reader (the MCP server
|
|
38
|
+
// and the post-commit hook share this file) never sees a half-written JSON,
|
|
39
|
+
// and two writers can't interleave into a corrupt file.
|
|
40
|
+
const tmp = path.join(DIR, `credentials.json.${process.pid}.tmp`);
|
|
41
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
42
|
+
try {
|
|
43
|
+
fs.renameSync(tmp, FILE);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
try {
|
|
47
|
+
fs.unlinkSync(tmp);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// best-effort cleanup
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function clearCredentials() {
|
|
56
|
+
try {
|
|
57
|
+
fs.unlinkSync(FILE);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=credentials.js.map
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared git helpers for the CLI subcommands (`analyze`, `import`). Both read
|
|
3
|
+
* commit diffs the same way, so the invocation, buffer limit and error
|
|
4
|
+
* normalization live here — a drift between them would make the same commit
|
|
5
|
+
* compress differently across subcommands and break the server-side dedup.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
export const execFileAsync = promisify(execFile);
|
|
10
|
+
export const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
// Mirror of the extension's default changebook.maxDiffCharacters (the server
|
|
12
|
+
// rejects anything above 60k anyway).
|
|
13
|
+
export const MAX_DIFF_CHARACTERS = 25_000;
|
|
14
|
+
/** `git show <hash> -U0` — the unified diff of a single commit, no context. */
|
|
15
|
+
export async function commitDiff(cwd, hash) {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execFileAsync("git",
|
|
18
|
+
// --end-of-options so a ref that starts with "-" (e.g. "-n5") is treated
|
|
19
|
+
// as a revision, never as a git option (argument injection).
|
|
20
|
+
["show", "--pretty=format:", "-U0", "--end-of-options", hash], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
21
|
+
return stdout;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw new Error(`Could not read the diff of ${hash.slice(0, 8)}: ${gitErrorMessage(error)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Pulls git's stderr out of an execFile rejection for a readable message. */
|
|
28
|
+
export function gitErrorMessage(error) {
|
|
29
|
+
if (typeof error === "object" &&
|
|
30
|
+
error !== null &&
|
|
31
|
+
"stderr" in error &&
|
|
32
|
+
typeof error.stderr === "string") {
|
|
33
|
+
return error.stderr;
|
|
34
|
+
}
|
|
35
|
+
return error instanceof Error ? error.message : String(error);
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=git.js.map
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `changebook hook install|uninstall|status [dir]` — git post-commit hook that
|
|
3
|
+
* analyzes each new commit into the atlas, in the background, without ever
|
|
4
|
+
* blocking the commit. One analysis per commit, deduped server-side by hash,
|
|
5
|
+
* so the cost is bounded and re-runs are free. This one hook covers every
|
|
6
|
+
* client that commits: Claude Code, Codex, the terminal, any editor.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { execFileAsync } from "./git.js";
|
|
12
|
+
const MARKER = "# changebook post-commit hook";
|
|
13
|
+
function hookScript() {
|
|
14
|
+
const entry = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
|
|
15
|
+
// Background subshell + `|| true`: a broken analyze (offline, out of
|
|
16
|
+
// credits, logged out) must never make `git commit` fail or feel slow.
|
|
17
|
+
// The log keeps only the last run so it can't grow unbounded.
|
|
18
|
+
return `#!/bin/sh
|
|
19
|
+
${MARKER} — analyzes each commit into your ChangeBook atlas.
|
|
20
|
+
# Runs in the background and never blocks the commit. Remove with:
|
|
21
|
+
# changebook hook uninstall
|
|
22
|
+
( ${JSON.stringify(process.execPath)} ${JSON.stringify(entry)} analyze --commit HEAD > "$(git rev-parse --git-dir)/changebook-hook.log" 2>&1 & ) || true
|
|
23
|
+
`;
|
|
24
|
+
}
|
|
25
|
+
async function hookPath(dir) {
|
|
26
|
+
// --git-path (not --git-dir + "/hooks") resolves core.hooksPath — set by
|
|
27
|
+
// Husky in most modern JS repos to .husky/ — and linked worktrees' common
|
|
28
|
+
// dir. Building <git-dir>/hooks/post-commit by hand ignores both, so the hook
|
|
29
|
+
// installed to a path git never runs and the atlas silently stops updating.
|
|
30
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", "hooks/post-commit"], {
|
|
31
|
+
cwd: dir,
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
}).catch(() => {
|
|
34
|
+
throw new Error(`"${dir}" is not a git repository.`);
|
|
35
|
+
});
|
|
36
|
+
// The path is relative to `dir`.
|
|
37
|
+
return path.resolve(dir, stdout.trim());
|
|
38
|
+
}
|
|
39
|
+
export async function installHook(dir) {
|
|
40
|
+
const file = await hookPath(dir);
|
|
41
|
+
if (fs.existsSync(file)) {
|
|
42
|
+
const current = fs.readFileSync(file, "utf8");
|
|
43
|
+
if (!current.includes(MARKER)) {
|
|
44
|
+
throw new Error(`A post-commit hook already exists at ${file} and it isn't ChangeBook's — not overwriting it. Add this line to it manually if you want both:\n\n` +
|
|
45
|
+
hookScript().split("\n").slice(-2).join("\n"));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
fs.writeFileSync(file, hookScript(), { mode: 0o755 });
|
|
50
|
+
console.error(`✓ post-commit hook installed (${file}).`);
|
|
51
|
+
console.error("Every new commit is analyzed into your atlas in the background (1 credit each; re-runs of the same commit are free).");
|
|
52
|
+
}
|
|
53
|
+
export async function uninstallHook(dir) {
|
|
54
|
+
const file = await hookPath(dir);
|
|
55
|
+
if (!fs.existsSync(file) || !fs.readFileSync(file, "utf8").includes(MARKER)) {
|
|
56
|
+
console.error("No ChangeBook post-commit hook found.");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
fs.unlinkSync(file);
|
|
60
|
+
console.error("✓ post-commit hook removed.");
|
|
61
|
+
}
|
|
62
|
+
export async function hookStatus(dir) {
|
|
63
|
+
const file = await hookPath(dir);
|
|
64
|
+
const installed = fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(MARKER);
|
|
65
|
+
console.error(installed
|
|
66
|
+
? `✓ ChangeBook post-commit hook installed (${file}).`
|
|
67
|
+
: "No ChangeBook post-commit hook in this repository. Install it with: changebook hook install");
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=hook.js.map
|
package/dist/import.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `changebook import [dir] [--commits N]` — backfill the atlas from git history
|
|
3
|
+
* through the import-batch Edge Function, which runs the analyses on the
|
|
4
|
+
* Anthropic Message Batches API (50% cheaper than the interactive path).
|
|
5
|
+
*
|
|
6
|
+
* Flow: resume any batch left pending by an interrupted run, then list the
|
|
7
|
+
* last N commits, skip trivial ones locally, compress each diff, submit in
|
|
8
|
+
* chunks and poll until every chunk is ingested. Already-imported commits are
|
|
9
|
+
* deduped server-side and never billed.
|
|
10
|
+
*/
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { atlasWebUrl } from "./browser.js";
|
|
13
|
+
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, } from "./git.js";
|
|
14
|
+
import { optimizeTokensForAI } from "./optimize.js";
|
|
15
|
+
// Under the server's MAX_BATCH_ITEMS (25) to leave headroom.
|
|
16
|
+
const CHUNK_SIZE = 20;
|
|
17
|
+
const POLL_INTERVAL_MS = 15_000;
|
|
18
|
+
const POLL_TIMEOUT_MS = 30 * 60_000;
|
|
19
|
+
// Files that never justify an analysis credit on their own (kept in sync
|
|
20
|
+
// with the VS Code extension's importHistory).
|
|
21
|
+
const SKIP_FILE_PATTERNS = [
|
|
22
|
+
/(^|\/)package-lock\.json$/,
|
|
23
|
+
/(^|\/)pnpm-lock\.yaml$/,
|
|
24
|
+
/(^|\/)yarn\.lock$/,
|
|
25
|
+
/(^|\/)(dist|build|out|node_modules|vendor)\//,
|
|
26
|
+
/\.min\.(js|css)$/,
|
|
27
|
+
/\.(png|jpe?g|gif|webp|ico|svg|woff2?|ttf|otf|eot|mp4|mov|pdf|zip)$/i,
|
|
28
|
+
];
|
|
29
|
+
export async function importHistory(db, options = {}) {
|
|
30
|
+
const cwd = path.resolve(options.dir ?? process.cwd());
|
|
31
|
+
const projectName = path.basename(cwd);
|
|
32
|
+
const count = Math.max(1, Math.min(options.commits ?? 25, 100));
|
|
33
|
+
// A previous run may have been interrupted after submitting: the Anthropic
|
|
34
|
+
// batch keeps running server-side, so finish those before spending more.
|
|
35
|
+
await resumePendingBatches(db);
|
|
36
|
+
console.error(`Leyendo los últimos ${count} commits de "${projectName}"…`);
|
|
37
|
+
const commits = await listCommits(cwd, count);
|
|
38
|
+
if (commits.length === 0) {
|
|
39
|
+
console.error("No hay commits que importar en este repositorio.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const items = [];
|
|
43
|
+
let trivial = 0;
|
|
44
|
+
for (const commit of commits) {
|
|
45
|
+
if (await isTrivialCommit(cwd, commit.hash)) {
|
|
46
|
+
trivial += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const diff = await commitDiff(cwd, commit.hash);
|
|
50
|
+
const compressed = optimizeTokensForAI(diff);
|
|
51
|
+
if (!compressed || compressed.length > MAX_DIFF_CHARACTERS) {
|
|
52
|
+
trivial += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
items.push({
|
|
56
|
+
compressedDiff: compressed,
|
|
57
|
+
rawDiffChars: diff.length,
|
|
58
|
+
commitHash: commit.hash,
|
|
59
|
+
committedAt: commit.date,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (items.length === 0) {
|
|
63
|
+
console.error(`Nada que importar: ${trivial} commits triviales (locks, assets, diffs enormes).`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
console.error(`${items.length} commits analizables (${trivial} triviales saltados). ` +
|
|
67
|
+
`Se envían por lotes al Batch API (50% de descuento); puede tardar varios minutos.`);
|
|
68
|
+
let imported = 0;
|
|
69
|
+
let failed = 0;
|
|
70
|
+
let duplicates = 0;
|
|
71
|
+
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
|
|
72
|
+
const chunk = items.slice(i, i + CHUNK_SIZE);
|
|
73
|
+
const { status, body } = await db.invokeFunction("import-batch", { action: "submit", projectName, items: chunk });
|
|
74
|
+
if (status !== 200 || body.error) {
|
|
75
|
+
// The server's messages are user-facing (quota, waitlist…). Everything
|
|
76
|
+
// ingested so far is already saved.
|
|
77
|
+
throw new Error(body.error ?? `import-batch failed with status ${status}`);
|
|
78
|
+
}
|
|
79
|
+
duplicates += body.skippedDuplicates ?? 0;
|
|
80
|
+
if (!body.batchId)
|
|
81
|
+
continue; // whole chunk was already in the atlas
|
|
82
|
+
console.error(`Lote enviado (${body.submitted} commits). Esperando resultados…`);
|
|
83
|
+
const result = await pollBatch(db, body.batchId);
|
|
84
|
+
imported += result.imported ?? 0;
|
|
85
|
+
failed += result.failed ?? 0;
|
|
86
|
+
}
|
|
87
|
+
const lines = [
|
|
88
|
+
"",
|
|
89
|
+
`✓ Import terminado: ${imported} importados` +
|
|
90
|
+
(duplicates > 0 ? `, ${duplicates} ya estaban en el atlas` : "") +
|
|
91
|
+
(trivial > 0 ? `, ${trivial} triviales saltados` : "") +
|
|
92
|
+
(failed > 0 ? `, ${failed} fallidos` : "") +
|
|
93
|
+
".",
|
|
94
|
+
`Atlas → ${atlasWebUrl()}`,
|
|
95
|
+
];
|
|
96
|
+
if (failed > 0) {
|
|
97
|
+
lines.push("Los commits fallidos pueden reintentarse ejecutando el import de nuevo.");
|
|
98
|
+
}
|
|
99
|
+
console.error(lines.join("\n"));
|
|
100
|
+
}
|
|
101
|
+
async function resumePendingBatches(db) {
|
|
102
|
+
const { status, body } = await db.invokeFunction("import-batch", { action: "pending" });
|
|
103
|
+
if (status !== 200 || body.error || !body.batches?.length)
|
|
104
|
+
return;
|
|
105
|
+
for (const batch of body.batches) {
|
|
106
|
+
console.error(`Reanudando un lote pendiente de una ejecución anterior (${batch.submitted} commits)…`);
|
|
107
|
+
await pollBatch(db, batch.batchId);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function pollBatch(db, batchId) {
|
|
111
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
112
|
+
for (;;) {
|
|
113
|
+
const { status, body } = await db.invokeFunction("import-batch", { action: "status", batchId });
|
|
114
|
+
if (status !== 200 || body.error) {
|
|
115
|
+
throw new Error(body.error ?? `import-batch status failed (${status})`);
|
|
116
|
+
}
|
|
117
|
+
if (body.status === "ended") {
|
|
118
|
+
return body;
|
|
119
|
+
}
|
|
120
|
+
if (Date.now() > deadline) {
|
|
121
|
+
throw new Error("El lote sigue procesándose tras 30 minutos. Vuelve a ejecutar `changebook import` más tarde: lo retomará donde quedó.");
|
|
122
|
+
}
|
|
123
|
+
if (body.succeeded != null || body.processing != null) {
|
|
124
|
+
console.error(` …procesando (${body.succeeded ?? 0} listos, ${body.processing ?? "?"} en cola)`);
|
|
125
|
+
}
|
|
126
|
+
await sleep(POLL_INTERVAL_MS);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function sleep(ms) {
|
|
130
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
131
|
+
}
|
|
132
|
+
// ── git helpers ───────────────────────────────────────────────────────────────
|
|
133
|
+
async function listCommits(cwd, count) {
|
|
134
|
+
try {
|
|
135
|
+
const { stdout } = await execFileAsync("git", ["log", "-n", String(count), "--no-merges", "--pretty=format:%H|%cI"], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
136
|
+
return stdout
|
|
137
|
+
.split("\n")
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.map((line) => {
|
|
140
|
+
const [hash, date] = line.split("|");
|
|
141
|
+
return { hash, date };
|
|
142
|
+
})
|
|
143
|
+
.filter((c) => c.hash && c.date);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const message = gitErrorMessage(error);
|
|
147
|
+
if (message.includes("not a git repository")) {
|
|
148
|
+
throw new Error(`"${cwd}" is not a git repository.`);
|
|
149
|
+
}
|
|
150
|
+
if (message.includes("does not have any commits") ||
|
|
151
|
+
message.includes("bad default revision")) {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`Could not read the git history in "${cwd}": ${message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function isTrivialCommit(cwd, hash) {
|
|
158
|
+
const { stdout } = await execFileAsync("git", ["show", "--name-only", "--pretty=format:", "--end-of-options", hash], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
159
|
+
const files = stdout.split("\n").filter(Boolean);
|
|
160
|
+
if (files.length === 0)
|
|
161
|
+
return true;
|
|
162
|
+
return files.every((file) => SKIP_FILE_PATTERNS.some((pattern) => pattern.test(file)));
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=import.js.map
|