readfirst 0.5.0 → 0.6.1

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
@@ -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
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "readfirst",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
+ "mcpName": "io.github.jeonghun917/readfirst",
4
5
  "description": "What to read and check before you (or your AI agent) change a file: project rules, risks and tests tied to code.",
5
6
  "type": "module",
6
7
  "license": "MIT",
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
- 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
+ }
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
+ }