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/dist/tools.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registrations for the ChangeBook MCP server.
|
|
3
|
+
*
|
|
4
|
+
* All tools are read-only queries over the user's own ChangeBook data
|
|
5
|
+
* (Row Level Security scopes every request to the signed-in user).
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { SupabaseError } from "./supabase.js";
|
|
9
|
+
const CHARACTER_LIMIT = 25_000;
|
|
10
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
11
|
+
function errorResult(error) {
|
|
12
|
+
const message = error instanceof SupabaseError || error instanceof Error
|
|
13
|
+
? error.message
|
|
14
|
+
: String(error);
|
|
15
|
+
// isError lets the MCP client (and the agent) tell a failed call from a valid
|
|
16
|
+
// result whose text happens to start with "Error:".
|
|
17
|
+
return {
|
|
18
|
+
isError: true,
|
|
19
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function truncate(text) {
|
|
23
|
+
if (text.length <= CHARACTER_LIMIT)
|
|
24
|
+
return text;
|
|
25
|
+
return (text.slice(0, CHARACTER_LIMIT) +
|
|
26
|
+
"\n\n[Truncated. Use a smaller `limit`, an `offset`, or filters to narrow the result.]");
|
|
27
|
+
}
|
|
28
|
+
// Build a tool result. structuredContent duplicates the rendered text, so when
|
|
29
|
+
// the text has to be truncated we drop it — otherwise the full, unbounded object
|
|
30
|
+
// would be sent anyway and defeat the very CHARACTER_LIMIT that truncated it.
|
|
31
|
+
function toolResult(text, structured) {
|
|
32
|
+
if (text.length <= CHARACTER_LIMIT) {
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: "text", text }],
|
|
35
|
+
structuredContent: structured,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { content: [{ type: "text", text: truncate(text) }] };
|
|
39
|
+
}
|
|
40
|
+
function fileList(files) {
|
|
41
|
+
return Array.isArray(files) ? files.map(String) : [];
|
|
42
|
+
}
|
|
43
|
+
// Summary-first excerpts: the stored diff excerpts are the most expensive part
|
|
44
|
+
// of atlas_module_detail (~1.5k chars each), and most calls only need to know
|
|
45
|
+
// WHAT changed. Default to a short preview; `full: true` returns them verbatim.
|
|
46
|
+
const EXCERPT_PREVIEW_LINES = 4;
|
|
47
|
+
const EXCERPT_PREVIEW_CHARS = 320;
|
|
48
|
+
function previewExcerpt(excerpt) {
|
|
49
|
+
let text = excerpt.split("\n").slice(0, EXCERPT_PREVIEW_LINES).join("\n");
|
|
50
|
+
if (text.length > EXCERPT_PREVIEW_CHARS) {
|
|
51
|
+
text = text.slice(0, EXCERPT_PREVIEW_CHARS);
|
|
52
|
+
}
|
|
53
|
+
return { text: text.trimEnd(), truncated: text.length < excerpt.length };
|
|
54
|
+
}
|
|
55
|
+
function day(iso) {
|
|
56
|
+
return iso.slice(0, 10);
|
|
57
|
+
}
|
|
58
|
+
/** PostgREST `or=(...ilike...)` needs the pattern URL-encoded once. */
|
|
59
|
+
function ilikePattern(search) {
|
|
60
|
+
return encodeURIComponent(`*${search.replace(/[%*,()]/g, " ").trim()}*`);
|
|
61
|
+
}
|
|
62
|
+
// ── Registration ──────────────────────────────────────────────────────────────
|
|
63
|
+
export function registerTools(server, db) {
|
|
64
|
+
server.registerTool("atlas_recent_changes", {
|
|
65
|
+
title: "Recent ChangeBook changes",
|
|
66
|
+
description: `List the most recent analyzed code changes from the ChangeBook changelog (newest first).
|
|
67
|
+
|
|
68
|
+
Each entry has a business-level impact summary, an optional technical summary, and the modules it touched. Use this to catch up on what changed in the product without re-reading the codebase.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
- limit (1-50, default 10): entries to return.
|
|
72
|
+
- offset (default 0): pagination offset.
|
|
73
|
+
- search (optional): case-insensitive text filter over the business and technical summaries.
|
|
74
|
+
|
|
75
|
+
Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, summary_tech, diff_chars, modules: [{ module, risk }] }] }
|
|
76
|
+
|
|
77
|
+
Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
78
|
+
inputSchema: {
|
|
79
|
+
limit: z.number().int().min(1).max(50).default(10)
|
|
80
|
+
.describe("Entries to return (1-50)"),
|
|
81
|
+
offset: z.number().int().min(0).default(0)
|
|
82
|
+
.describe("Pagination offset"),
|
|
83
|
+
search: z.string().min(2).max(120).optional()
|
|
84
|
+
.describe("Case-insensitive filter over summaries"),
|
|
85
|
+
},
|
|
86
|
+
annotations: {
|
|
87
|
+
readOnlyHint: true,
|
|
88
|
+
destructiveHint: false,
|
|
89
|
+
idempotentHint: true,
|
|
90
|
+
openWorldHint: true,
|
|
91
|
+
},
|
|
92
|
+
}, async ({ limit, offset, search }) => {
|
|
93
|
+
try {
|
|
94
|
+
let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count` +
|
|
95
|
+
`&order=created_at.desc&limit=${limit}&offset=${offset}` +
|
|
96
|
+
(await db.projectFilter());
|
|
97
|
+
if (search) {
|
|
98
|
+
const p = ilikePattern(search);
|
|
99
|
+
query += `&or=(business_impact.ilike.${p},summary_tech.ilike.${p})`;
|
|
100
|
+
}
|
|
101
|
+
const rows = await db.rest(query);
|
|
102
|
+
const modulesByChange = new Map();
|
|
103
|
+
if (rows.length > 0) {
|
|
104
|
+
const ids = rows.map((r) => r.id).join(",");
|
|
105
|
+
const mods = await db.rest(
|
|
106
|
+
// Only the module name and risk are used below; don't pull note/tech/
|
|
107
|
+
// excerpt/files (up to ~1.5k each) for every row of every change.
|
|
108
|
+
`change_module?select=changelog_id,module,risk&changelog_id=in.(${ids})`);
|
|
109
|
+
for (const m of mods) {
|
|
110
|
+
const list = modulesByChange.get(m.changelog_id);
|
|
111
|
+
if (list)
|
|
112
|
+
list.push(m);
|
|
113
|
+
else
|
|
114
|
+
modulesByChange.set(m.changelog_id, [m]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const changes = rows.map((r) => ({
|
|
118
|
+
id: r.id,
|
|
119
|
+
date: day(r.created_at),
|
|
120
|
+
business_impact: r.business_impact ?? "",
|
|
121
|
+
summary_tech: r.summary_tech ?? null,
|
|
122
|
+
diff_chars: r.diff_character_count ?? null,
|
|
123
|
+
modules: (modulesByChange.get(r.id) ?? []).map((m) => ({
|
|
124
|
+
module: m.module,
|
|
125
|
+
risk: m.risk,
|
|
126
|
+
})),
|
|
127
|
+
}));
|
|
128
|
+
const output = {
|
|
129
|
+
count: changes.length,
|
|
130
|
+
offset,
|
|
131
|
+
has_more: rows.length === limit,
|
|
132
|
+
changes,
|
|
133
|
+
};
|
|
134
|
+
const lines = [`# Recent ChangeBook changes (${changes.length})`, ""];
|
|
135
|
+
for (const c of changes) {
|
|
136
|
+
const mods = c.modules
|
|
137
|
+
.map((m) => m.module + (m.risk ? ` [${m.risk}]` : ""))
|
|
138
|
+
.join(", ");
|
|
139
|
+
lines.push(`## ${c.date} — ${c.business_impact}`);
|
|
140
|
+
if (c.summary_tech)
|
|
141
|
+
lines.push(`- Tech: ${c.summary_tech}`);
|
|
142
|
+
if (mods)
|
|
143
|
+
lines.push(`- Modules: ${mods}`);
|
|
144
|
+
lines.push("");
|
|
145
|
+
}
|
|
146
|
+
if (changes.length === 0) {
|
|
147
|
+
lines.push(search
|
|
148
|
+
? `No changes match "${search}". Try a broader search or omit it.`
|
|
149
|
+
: "No analyzed changes yet. Analyze a diff from the ChangeBook extension first.");
|
|
150
|
+
}
|
|
151
|
+
return toolResult(lines.join("\n"), output);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return errorResult(error);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
server.registerTool("atlas_modules", {
|
|
158
|
+
title: "ChangeBook module map",
|
|
159
|
+
description: `List the modules of the product as known by ChangeBook, aggregated from the change history.
|
|
160
|
+
|
|
161
|
+
For each module: domain, category, latest risk level, number of analyzed changes, last-change date and the files touched most recently. Use this to orient yourself in an unfamiliar codebase or to pick a module for atlas_module_detail.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
- domain (optional): filter by domain (e.g. "billing").
|
|
165
|
+
|
|
166
|
+
Returns (structured): { count, modules: [{ module, domain, category, risk, changes, last_changed, files }] }`,
|
|
167
|
+
inputSchema: {
|
|
168
|
+
domain: z.string().min(1).max(80).optional()
|
|
169
|
+
.describe("Only modules in this domain"),
|
|
170
|
+
},
|
|
171
|
+
annotations: {
|
|
172
|
+
readOnlyHint: true,
|
|
173
|
+
destructiveHint: false,
|
|
174
|
+
idempotentHint: true,
|
|
175
|
+
openWorldHint: true,
|
|
176
|
+
},
|
|
177
|
+
}, async ({ domain }) => {
|
|
178
|
+
try {
|
|
179
|
+
let query =
|
|
180
|
+
// The aggregation below uses only these columns; note/tech/excerpt
|
|
181
|
+
// (up to ~1.5k each × 1000 rows) would move 1-2 MB per call for nothing.
|
|
182
|
+
`change_module?select=module,domain,category,risk,files,created_at` +
|
|
183
|
+
`&order=created_at.desc&limit=1000` +
|
|
184
|
+
(await db.projectFilter());
|
|
185
|
+
if (domain)
|
|
186
|
+
query += `&domain=eq.${encodeURIComponent(domain)}`;
|
|
187
|
+
const rows = await db.rest(query);
|
|
188
|
+
const byModule = new Map();
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
const list = byModule.get(row.module);
|
|
191
|
+
if (list)
|
|
192
|
+
list.push(row);
|
|
193
|
+
else
|
|
194
|
+
byModule.set(row.module, [row]);
|
|
195
|
+
}
|
|
196
|
+
const modules = [...byModule.entries()].map(([name, list]) => {
|
|
197
|
+
const latest = list[0]; // rows arrive newest-first
|
|
198
|
+
return {
|
|
199
|
+
module: name,
|
|
200
|
+
domain: latest.domain,
|
|
201
|
+
category: latest.category,
|
|
202
|
+
risk: latest.risk,
|
|
203
|
+
changes: list.length,
|
|
204
|
+
last_changed: day(latest.created_at),
|
|
205
|
+
files: fileList(latest.files),
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
modules.sort((a, b) => (a.last_changed < b.last_changed ? 1 : -1));
|
|
209
|
+
const output = { count: modules.length, modules };
|
|
210
|
+
const lines = [`# ChangeBook module map (${modules.length} modules)`, ""];
|
|
211
|
+
for (const m of modules) {
|
|
212
|
+
lines.push(`- **${m.module}**${m.domain ? ` (${m.domain})` : ""} — ` +
|
|
213
|
+
`${m.changes} change(s), last ${m.last_changed}` +
|
|
214
|
+
(m.risk ? `, risk: ${m.risk}` : "") +
|
|
215
|
+
(m.files.length ? ` — files: ${m.files.join(", ")}` : ""));
|
|
216
|
+
}
|
|
217
|
+
if (modules.length === 0) {
|
|
218
|
+
lines.push(domain
|
|
219
|
+
? `No modules in domain "${domain}". Call atlas_modules without a domain to see all.`
|
|
220
|
+
: "No modules yet. Analyze a diff from the ChangeBook extension first.");
|
|
221
|
+
}
|
|
222
|
+
return toolResult(lines.join("\n"), output);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return errorResult(error);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
server.registerTool("atlas_module_detail", {
|
|
229
|
+
title: "ChangeBook module detail",
|
|
230
|
+
description: `Get the change history of one module: what changed, why it matters, the files touched and (when available) a verbatim excerpt of each diff.
|
|
231
|
+
|
|
232
|
+
This is the token-saving alternative to re-reading a module's source: it gives an agent the recent evolution and risk picture in one call.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
- module (required): exact module name as returned by atlas_modules.
|
|
236
|
+
- limit (1-20, default 5): number of recent changes to include.
|
|
237
|
+
- include_excerpts (default true): include diff excerpts (short previews unless full is set).
|
|
238
|
+
- full (default false): return the diff excerpts verbatim instead of the token-saving previews. Only pass it when you actually need the code lines.
|
|
239
|
+
|
|
240
|
+
Returns (structured): { module, count, changes: [{ date, risk, note, tech, files, business_impact, excerpt, excerpt_truncated }] }`,
|
|
241
|
+
inputSchema: {
|
|
242
|
+
module: z.string().min(1).max(120)
|
|
243
|
+
.describe("Exact module name (see atlas_modules)"),
|
|
244
|
+
limit: z.number().int().min(1).max(20).default(5)
|
|
245
|
+
.describe("Recent changes to include (1-20)"),
|
|
246
|
+
include_excerpts: z.boolean().default(true)
|
|
247
|
+
.describe("Include diff excerpts (previews unless full)"),
|
|
248
|
+
full: z.boolean().default(false)
|
|
249
|
+
.describe("Verbatim excerpts instead of short previews"),
|
|
250
|
+
},
|
|
251
|
+
annotations: {
|
|
252
|
+
readOnlyHint: true,
|
|
253
|
+
destructiveHint: false,
|
|
254
|
+
idempotentHint: true,
|
|
255
|
+
openWorldHint: true,
|
|
256
|
+
},
|
|
257
|
+
}, async ({ module, limit, include_excerpts, full }) => {
|
|
258
|
+
try {
|
|
259
|
+
const rows = await db.rest(`change_module?select=changelog_id,module,domain,category,risk,files,note,tech,excerpt,created_at` +
|
|
260
|
+
`&module=eq.${encodeURIComponent(module)}&order=created_at.desc&limit=${limit}` +
|
|
261
|
+
(await db.projectFilter()));
|
|
262
|
+
if (rows.length === 0) {
|
|
263
|
+
return {
|
|
264
|
+
content: [
|
|
265
|
+
{
|
|
266
|
+
type: "text",
|
|
267
|
+
text: `No module named "${module}". Call atlas_modules to list the exact module names.`,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const ids = [...new Set(rows.map((r) => r.changelog_id))].join(",");
|
|
273
|
+
const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count&id=in.(${ids})`);
|
|
274
|
+
const logById = new Map(logs.map((l) => [l.id, l]));
|
|
275
|
+
const changes = rows.map((r) => {
|
|
276
|
+
let excerpt = null;
|
|
277
|
+
let excerptTruncated = false;
|
|
278
|
+
if (include_excerpts && r.excerpt) {
|
|
279
|
+
if (full) {
|
|
280
|
+
excerpt = r.excerpt;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
const preview = previewExcerpt(r.excerpt);
|
|
284
|
+
excerpt = preview.text;
|
|
285
|
+
excerptTruncated = preview.truncated;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
date: day(r.created_at),
|
|
290
|
+
risk: r.risk,
|
|
291
|
+
note: r.note,
|
|
292
|
+
tech: r.tech,
|
|
293
|
+
files: fileList(r.files),
|
|
294
|
+
business_impact: logById.get(r.changelog_id)?.business_impact ?? null,
|
|
295
|
+
excerpt,
|
|
296
|
+
excerpt_truncated: excerptTruncated,
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
const latest = rows[0];
|
|
300
|
+
const output = {
|
|
301
|
+
module,
|
|
302
|
+
domain: latest.domain,
|
|
303
|
+
category: latest.category,
|
|
304
|
+
count: changes.length,
|
|
305
|
+
changes,
|
|
306
|
+
};
|
|
307
|
+
const lines = [
|
|
308
|
+
`# Module: ${module}` +
|
|
309
|
+
(latest.domain ? ` (${latest.domain}` +
|
|
310
|
+
(latest.category ? ` / ${latest.category}` : "") + ")" : ""),
|
|
311
|
+
"",
|
|
312
|
+
];
|
|
313
|
+
for (const c of changes) {
|
|
314
|
+
lines.push(`## ${c.date}${c.risk ? ` — risk: ${c.risk}` : ""}`);
|
|
315
|
+
if (c.business_impact)
|
|
316
|
+
lines.push(`- Impact: ${c.business_impact}`);
|
|
317
|
+
if (c.note)
|
|
318
|
+
lines.push(`- Note: ${c.note}`);
|
|
319
|
+
if (c.tech)
|
|
320
|
+
lines.push(`- Tech: ${c.tech}`);
|
|
321
|
+
if (c.files.length)
|
|
322
|
+
lines.push(`- Files: ${c.files.join(", ")}`);
|
|
323
|
+
if (c.excerpt) {
|
|
324
|
+
lines.push("", "```diff", c.excerpt, "```");
|
|
325
|
+
if (c.excerpt_truncated) {
|
|
326
|
+
lines.push("_Excerpt preview — call again with `full: true` for the verbatim diff._");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
lines.push("");
|
|
330
|
+
}
|
|
331
|
+
return toolResult(lines.join("\n"), output);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
return errorResult(error);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
//# sourceMappingURL=tools.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "changebook",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"changebook": "dist/index.js",
|
|
9
|
+
"changebook-mcp": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"!dist/**/*.map",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/raulbr90/appatlas.git",
|
|
20
|
+
"directory": "changebook-mcp"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://changebook.dev",
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"model-context-protocol",
|
|
26
|
+
"claude",
|
|
27
|
+
"claude-code",
|
|
28
|
+
"changelog",
|
|
29
|
+
"product-map",
|
|
30
|
+
"ai",
|
|
31
|
+
"cli"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"start": "node dist/index.js",
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"clean": "rm -rf dist",
|
|
38
|
+
"prepublishOnly": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.6.1",
|
|
45
|
+
"zod": "^3.23.8"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.10.0",
|
|
49
|
+
"typescript": "^5.7.2"
|
|
50
|
+
}
|
|
51
|
+
}
|