readfirst 0.4.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 CHANGED
@@ -24,6 +24,7 @@ Commit `.readfirst/map.json`. From then on:
24
24
  npx readfirst read <file>... # before changing files
25
25
  npx readfirst diff # everything your branch changed since main
26
26
  npx readfirst diff --json # the same, for tools and agents
27
+ npx readfirst verify # run the checks the map lists for everything your branch changed (exit 1 if one fails)
27
28
  npx readfirst fix # after moving code: place new files, drop patterns for deleted ones
28
29
  ```
29
30
 
@@ -33,7 +34,8 @@ npx readfirst fix # after moving code: place new files, drop patt
33
34
 
34
35
  ```md
35
36
  Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the
36
- checks it lists. Before you finish, run `npx readfirst diff` and run every check it lists.
37
+ checks it lists. Before you finish, run `npx readfirst verify`: it runs the checks the map lists for everything you
38
+ changed; fix what fails.
37
39
  When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.
38
40
  ```
39
41
 
@@ -61,6 +63,31 @@ Labels for yourself: `npx readfirst prompt --labels ko` (or `en`, ...) gives you
61
63
  to the code. The view then has a switch between the two. `.readfirst/local/` is added to `.git/info/exclude`, so labels
62
64
  stay on your machine and never reach the team's map.
63
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
+
64
91
  ## On every pull request (GitHub Action)
65
92
 
66
93
  ```yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readfirst",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "What to read and check before you (or your AI agent) change a file: project rules, risks and tests tied to code.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.mjs CHANGED
@@ -6,10 +6,12 @@
6
6
  // readfirst fix place new files and drop patterns for deleted ones, then check
7
7
  // readfirst read <file>... the read list for files you are about to change
8
8
  // readfirst diff [--base <ref>] the read list for the files changed since <ref> (default: origin/main, else main)
9
+ // readfirst verify [--base <ref>] run the checks the read list names for the changed files; exit 1 if one fails
9
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
10
12
  // readfirst view [--out <file>] the map as a city in one HTML page (default .readfirst/local/view.html)
11
13
  // Options: --root <dir> (default: the current git repository), --json, --limit <n>.
12
- import { execFileSync } from 'node:child_process';
14
+ import { execFileSync, spawnSync } from 'node:child_process';
13
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
16
  import { tmpdir } from 'node:os';
15
17
  import { basename, isAbsolute, join, relative } from 'node:path';
@@ -18,6 +20,7 @@ import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems } from './map.mjs';
18
20
  import { agentBrief } from './prompt.mjs';
19
21
  import { keepLocal, labelsBrief, LOCAL_DIR, readLabels } from './labels.mjs';
20
22
  import { viewData } from './view.mjs';
23
+ import { mcpHandler, serveMcp } from './mcp.mjs';
21
24
  import { cityData, viewHtmlCity } from './view-city.mjs';
22
25
  import { emptyList, readList, toMarkdown } from './readlist.mjs';
23
26
 
@@ -29,11 +32,13 @@ const HELP = `readfirst — what to read and check before changing a file
29
32
  readfirst fix place new files, drop patterns for deleted ones
30
33
  readfirst read <file>... read list for files you are about to change
31
34
  readfirst diff [--base <ref>] read list for files changed since <ref>
35
+ readfirst verify [--base <ref>] run the checks for the files changed since <ref> (--dry-run lists them)
32
36
  readfirst hook [--install] Claude Code hook: the read list before each edit (--install adds it to .claude/settings.json)
33
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
34
39
  readfirst prompt --labels <lang> brief for your agent to write personal labels for the view (e.g. ko, en)
35
40
 
36
- Options: --root <dir>, --json, --limit <n>, --force (init: replace the map),
41
+ Options: --root <dir>, --json, --limit <n>, --timeout <s> (verify, per check; default 900), --force (init: replace the map),
37
42
  --agent (init: add the read-first line to CLAUDE.md / AGENTS.md / .cursorrules)`;
38
43
 
39
44
  function parse(argv) {
@@ -44,6 +49,7 @@ function parse(argv) {
44
49
  else if (a === '--force') args.force = true;
45
50
  else if (a === '--agent') args.agent = true;
46
51
  else if (a === '--install') args.install = true;
52
+ else if (a === '--dry-run') args.dryRun = true;
47
53
  else if (a === '-h' || a === '--help') args.help = true;
48
54
  else if (a.startsWith('--')) { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); args[a.slice(2)] = v; }
49
55
  else args._.push(a);
@@ -87,7 +93,7 @@ function loadMapLenient(root, files, io) {
87
93
 
88
94
  // The one line that makes a coding agent use readfirst, added to the agent notes the project already has (CLAUDE.md,
89
95
  // AGENTS.md, .cursorrules), or to a new AGENTS.md when it has none. A file that already mentions readfirst is left alone.
90
- export const AGENT_LINE = 'Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the checks it lists. Before you finish, run `npx readfirst diff` and run every check it lists. When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.';
96
+ export const AGENT_LINE = 'Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the checks it lists. Before you finish, run `npx readfirst verify`: it runs the checks the map lists for everything you changed; fix what fails. When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.';
91
97
  function addAgentLine(root) {
92
98
  const notes = ['CLAUDE.md', 'AGENTS.md', '.cursorrules'].filter((f) => existsSync(join(root, f)));
93
99
  const wrote = [];
@@ -104,7 +110,10 @@ function addAgentLine(root) {
104
110
  function changedFiles(root, base) {
105
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; } });
106
112
  if (!ref) throw new Error('no base branch found: pass --base <ref>');
107
- const since = git(root, 'merge-base', ref, 'HEAD').trim();
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
+ }
108
117
  const names = new Set([
109
118
  ...git(root, 'diff', '--name-only', since).split('\n'),
110
119
  ...git(root, 'ls-files', '--others', '--exclude-standard').split('\n'),
@@ -112,6 +121,37 @@ function changedFiles(root, base) {
112
121
  return { ref, files: [...names].sort() };
113
122
  }
114
123
 
124
+ // Runs each check with the shell in the repository root, one after another, and keeps the end of the output of the ones
125
+ // that fail. readfirst writes nothing itself; the commands are the map's own and may write, as they would by hand.
126
+ export function verifyChecks(root, checks, { dryRun = false, timeout = 900, progress = () => {} } = {}) {
127
+ const out = [];
128
+ for (const c of checks) {
129
+ const row = { run: c.run, why: c.why, for: c.for ?? [] };
130
+ if (dryRun) { out.push({ ...row, status: 'listed' }); continue; }
131
+ progress(`running ${c.run}\n`);
132
+ const started = Date.now();
133
+ const r = spawnSync(c.run, { cwd: root, shell: true, encoding: 'utf8', timeout: timeout * 1000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, CI: process.env.CI ?? '1' } });
134
+ const seconds = Math.round((Date.now() - started) / 100) / 10;
135
+ const timedOut = r.error?.code === 'ETIMEDOUT';
136
+ const status = timedOut ? 'timeout' : r.status === 0 ? 'pass' : 'fail';
137
+ const tail = status === 'pass' ? undefined : `${r.stdout ?? ''}${r.stderr ?? ''}${r.error && !timedOut ? r.error.message : ''}`.trimEnd().split('\n').slice(-30).join('\n');
138
+ out.push({ ...row, status, code: r.status, seconds, ...(tail !== undefined ? { output: tail } : {}) });
139
+ }
140
+ return out;
141
+ }
142
+
143
+ function verifyText(report, dryRun) {
144
+ if (!report.files.length) return `no files changed since ${report.base}: nothing to verify.\n`;
145
+ if (!report.checks.length) return `${report.files.length} changed file(s) since ${report.base}; the map lists no checks for them.\n`;
146
+ if (dryRun) return `checks for ${report.files.length} changed file(s) since ${report.base}:\n${report.checks.map((c) => `- ${c.run}${c.why ? ` — ${c.why}` : ''}`).join('\n')}\n`;
147
+ const mark = { pass: 'pass', fail: 'FAIL', timeout: 'TIMEOUT' };
148
+ let s = `\nverify: ${report.checks.length} check(s) for ${report.files.length} changed file(s) since ${report.base}\n`;
149
+ for (const c of report.checks) s += `${mark[c.status]} ${c.run} (${c.seconds}s)\n`;
150
+ for (const c of report.checks.filter((x) => x.status !== 'pass')) s += `\n--- ${c.run} (${c.status}${c.code != null ? `, exit ${c.code}` : ''}); last lines:\n${c.output}\n`;
151
+ s += report.ok ? '\nall checks passed.\n' : `\n${report.checks.filter((c) => c.status !== 'pass').length} check(s) failed.\n`;
152
+ return s;
153
+ }
154
+
115
155
  // The read list as a few factual lines for an agent's context: rules, risks and checks, nothing else.
116
156
  export function brief(list) {
117
157
  const lines = [];
@@ -258,5 +298,29 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
258
298
  io.out(args.json ? `${JSON.stringify(list, null, 2)}\n` : `${toMarkdown(list)}\n`);
259
299
  return 0;
260
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
+ }
311
+ if (cmd === 'verify') {
312
+ const map = loadMapLenient(root, files, io);
313
+ const changed = changedFiles(root, args.base);
314
+ const list = changed.files.length
315
+ ? readList({ map, files, imports: importEdges(files, readText(root)), targets: changed.files, commit, limit: 1 })
316
+ : emptyList(commit);
317
+ const timeout = Number(args.timeout ?? 900);
318
+ if (!(timeout > 0)) throw new Error('--timeout needs a number of seconds above 0');
319
+ const result = verifyChecks(root, list.checks, { dryRun: args.dryRun, timeout, progress: args.json ? () => {} : io.out });
320
+ const report = { format: 'readfirst/verify@1', commit, base: changed.ref, files: changed.files, ok: result.every((c) => c.status === 'pass' || c.status === 'listed'), checks: result };
321
+ if (args.json) io.out(`${JSON.stringify(report, null, 2)}\n`);
322
+ else io.out(verifyText(report, args.dryRun));
323
+ return report.ok ? 0 : 1;
324
+ }
261
325
  throw new Error(`unknown command ${cmd}\n\n${HELP}`);
262
326
  }
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
+ }