readfirst 0.5.0 → 0.6.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 +25 -0
- package/package.json +1 -1
- package/src/cli.mjs +17 -1
- package/src/mcp.mjs +77 -0
package/README.md
CHANGED
|
@@ -63,6 +63,31 @@ Labels for yourself: `npx readfirst prompt --labels ko` (or `en`, ...) gives you
|
|
|
63
63
|
to the code. The view then has a switch between the two. `.readfirst/local/` is added to `.git/info/exclude`, so labels
|
|
64
64
|
stay on your machine and never reach the team's map.
|
|
65
65
|
|
|
66
|
+
### Any agent, through MCP
|
|
67
|
+
|
|
68
|
+
`npx readfirst mcp` is an MCP server on stdio with three tools: `read_list` (files → what to read and check before
|
|
69
|
+
changing them), `changed_read_list` (the same for everything changed on the branch) and `check_map`. It answers from the
|
|
70
|
+
checkout as it is and writes nothing.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
claude mcp add readfirst -- npx --yes readfirst mcp # Claude Code
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
// Cursor: .cursor/mcp.json · Windsurf: ~/.codeium/windsurf/mcp_config.json
|
|
78
|
+
{ "mcpServers": { "readfirst": { "command": "npx", "args": ["--yes", "readfirst", "mcp"] } } }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```toml
|
|
82
|
+
# Codex: ~/.codex/config.toml
|
|
83
|
+
[mcp_servers.readfirst]
|
|
84
|
+
command = "npx"
|
|
85
|
+
args = ["--yes", "readfirst", "mcp"]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The server reads the git repository it is started in. If your agent starts it elsewhere, add `"--root", "/path/to/repo"`
|
|
89
|
+
to the arguments.
|
|
90
|
+
|
|
66
91
|
## On every pull request (GitHub Action)
|
|
67
92
|
|
|
68
93
|
```yaml
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// readfirst diff [--base <ref>] the read list for the files changed since <ref> (default: origin/main, else main)
|
|
9
9
|
// readfirst verify [--base <ref>] run the checks the read list names for the changed files; exit 1 if one fails
|
|
10
10
|
// readfirst hook [--install] a Claude Code PreToolUse hook: the read list for the file an Edit or Write is about to change
|
|
11
|
+
// readfirst mcp an MCP server on stdio: read_list, changed_read_list and check_map as tools for any agent
|
|
11
12
|
// readfirst view [--out <file>] the map as a city in one HTML page (default .readfirst/local/view.html)
|
|
12
13
|
// Options: --root <dir> (default: the current git repository), --json, --limit <n>.
|
|
13
14
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
@@ -19,6 +20,7 @@ import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems } from './map.mjs';
|
|
|
19
20
|
import { agentBrief } from './prompt.mjs';
|
|
20
21
|
import { keepLocal, labelsBrief, LOCAL_DIR, readLabels } from './labels.mjs';
|
|
21
22
|
import { viewData } from './view.mjs';
|
|
23
|
+
import { mcpHandler, serveMcp } from './mcp.mjs';
|
|
22
24
|
import { cityData, viewHtmlCity } from './view-city.mjs';
|
|
23
25
|
import { emptyList, readList, toMarkdown } from './readlist.mjs';
|
|
24
26
|
|
|
@@ -33,6 +35,7 @@ const HELP = `readfirst — what to read and check before changing a file
|
|
|
33
35
|
readfirst verify [--base <ref>] run the checks for the files changed since <ref> (--dry-run lists them)
|
|
34
36
|
readfirst hook [--install] Claude Code hook: the read list before each edit (--install adds it to .claude/settings.json)
|
|
35
37
|
readfirst view [--out <file>] the map as a city in one HTML page
|
|
38
|
+
readfirst mcp MCP server on stdio for Cursor, Codex, Windsurf and other agents
|
|
36
39
|
readfirst prompt --labels <lang> brief for your agent to write personal labels for the view (e.g. ko, en)
|
|
37
40
|
|
|
38
41
|
Options: --root <dir>, --json, --limit <n>, --timeout <s> (verify, per check; default 900), --force (init: replace the map),
|
|
@@ -107,7 +110,10 @@ function addAgentLine(root) {
|
|
|
107
110
|
function changedFiles(root, base) {
|
|
108
111
|
const ref = base ?? ['origin/main', 'main', 'origin/master', 'master'].find((r) => { try { git(root, 'rev-parse', '--verify', '--quiet', r); return true; } catch { return false; } });
|
|
109
112
|
if (!ref) throw new Error('no base branch found: pass --base <ref>');
|
|
110
|
-
|
|
113
|
+
let since;
|
|
114
|
+
try { since = git(root, 'merge-base', ref, 'HEAD').trim(); } catch {
|
|
115
|
+
throw new Error(`cannot find where this branch left ${ref}, often because the clone is shallow: fetch more history (git fetch --deepen=100, or fetch-depth: 0 in actions/checkout) or pass --base <ref>`);
|
|
116
|
+
}
|
|
111
117
|
const names = new Set([
|
|
112
118
|
...git(root, 'diff', '--name-only', since).split('\n'),
|
|
113
119
|
...git(root, 'ls-files', '--others', '--exclude-standard').split('\n'),
|
|
@@ -292,6 +298,16 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
|
|
|
292
298
|
io.out(args.json ? `${JSON.stringify(list, null, 2)}\n` : `${toMarkdown(list)}\n`);
|
|
293
299
|
return 0;
|
|
294
300
|
}
|
|
301
|
+
if (cmd === 'mcp') {
|
|
302
|
+
const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
303
|
+
const run = (argv) => {
|
|
304
|
+
let out = ''; let err = ''; let code;
|
|
305
|
+
try { code = main(['--root', root, ...argv], { out: (s) => { out += s; }, err: (s) => { err += s; } }); } catch (e) { code = 2; err += e.message; }
|
|
306
|
+
return { code, out, err };
|
|
307
|
+
};
|
|
308
|
+
serveMcp({ handle: mcpHandler({ run, version }), input: process.stdin, output: process.stdout });
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
295
311
|
if (cmd === 'verify') {
|
|
296
312
|
const map = loadMapLenient(root, files, io);
|
|
297
313
|
const changed = changedFiles(root, args.base);
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// readfirst as an MCP server over stdio, so any agent that speaks MCP (Cursor, Codex, Windsurf, Claude Code...) can ask
|
|
2
|
+
// what to read before it changes a file. JSON-RPC 2.0, one message per line, written by hand: no dependencies.
|
|
3
|
+
// Every tool call runs the same code as the command line, on the checkout as it is at that moment, and writes nothing.
|
|
4
|
+
const VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
5
|
+
|
|
6
|
+
const TOOLS = [
|
|
7
|
+
{
|
|
8
|
+
name: 'read_list',
|
|
9
|
+
description: 'What to read and check before changing these files: the project rules and risks that apply, the checks to run, the tests that reach the code and the files that use it. Call it before editing.',
|
|
10
|
+
inputSchema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' }, description: 'paths relative to the repository root' } }, required: ['files'] },
|
|
11
|
+
args: (a) => {
|
|
12
|
+
if (!Array.isArray(a.files) || !a.files.length || a.files.some((f) => typeof f !== 'string' || !f)) throw new Error('files must be a non-empty list of paths');
|
|
13
|
+
return ['read', ...a.files];
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'changed_read_list',
|
|
18
|
+
description: 'The same read list for every file changed on this branch since the base (default origin/main, else main). Call it before finishing, then run the checks it lists.',
|
|
19
|
+
inputSchema: { type: 'object', properties: { base: { type: 'string', description: 'git ref to compare against' } } },
|
|
20
|
+
args: (a) => ['diff', ...(typeof a.base === 'string' && a.base ? ['--base', a.base] : [])],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'check_map',
|
|
24
|
+
description: 'Whether .readfirst/map.json still covers the code: files in no group, patterns that match nothing, broken rules.',
|
|
25
|
+
inputSchema: { type: 'object', properties: {} },
|
|
26
|
+
args: () => ['check'],
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// One request in, one response out (null for notifications). run(argv) runs the CLI and returns { code, out, err }.
|
|
31
|
+
export function mcpHandler({ run, version }) {
|
|
32
|
+
return (msg) => {
|
|
33
|
+
const reply = (result) => ({ jsonrpc: '2.0', id: msg.id, result });
|
|
34
|
+
const fail = (code, message) => ({ jsonrpc: '2.0', id: msg.id ?? null, error: { code, message } });
|
|
35
|
+
if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') return fail(-32600, 'invalid request');
|
|
36
|
+
if (msg.id === undefined) return null; // notifications (initialized, cancelled) need no answer
|
|
37
|
+
if (msg.method === 'initialize') {
|
|
38
|
+
const asked = msg.params?.protocolVersion;
|
|
39
|
+
return reply({
|
|
40
|
+
protocolVersion: VERSIONS.includes(asked) ? asked : VERSIONS[0],
|
|
41
|
+
capabilities: { tools: {} },
|
|
42
|
+
serverInfo: { name: 'readfirst', version },
|
|
43
|
+
instructions: 'Before changing a file, call read_list with it and follow what it says. Before you finish, call changed_read_list and run the checks it lists (or run `npx readfirst verify`).',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (msg.method === 'ping') return reply({});
|
|
47
|
+
if (msg.method === 'tools/list') return reply({ tools: TOOLS.map(({ args, ...t }) => t) });
|
|
48
|
+
if (msg.method === 'tools/call') {
|
|
49
|
+
const tool = TOOLS.find((t) => t.name === msg.params?.name);
|
|
50
|
+
if (!tool) return fail(-32602, `unknown tool ${msg.params?.name}`);
|
|
51
|
+
let r;
|
|
52
|
+
try { r = run(tool.args(msg.params?.arguments ?? {})); } catch (e) { r = { code: 2, out: '', err: e.message }; }
|
|
53
|
+
const text = [r.out.trimEnd(), r.err.trimEnd()].filter(Boolean).join('\n\n') || '(nothing)';
|
|
54
|
+
return reply({ content: [{ type: 'text', text }], isError: r.code === 2 });
|
|
55
|
+
}
|
|
56
|
+
return fail(-32601, `unknown method ${msg.method}`);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Reads newline-delimited JSON-RPC from input and writes answers to output until input ends.
|
|
61
|
+
export function serveMcp({ handle, input, output }) {
|
|
62
|
+
let buf = '';
|
|
63
|
+
input.setEncoding('utf8');
|
|
64
|
+
input.on('data', (chunk) => {
|
|
65
|
+
buf += chunk;
|
|
66
|
+
let nl;
|
|
67
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
68
|
+
const line = buf.slice(0, nl).trim();
|
|
69
|
+
buf = buf.slice(nl + 1);
|
|
70
|
+
if (!line) continue;
|
|
71
|
+
let msg;
|
|
72
|
+
try { msg = JSON.parse(line); } catch { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } })}\n`); continue; }
|
|
73
|
+
const res = handle(msg);
|
|
74
|
+
if (res) output.write(`${JSON.stringify(res)}\n`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|