@coderook/cli 0.13.0 → 0.14.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
@@ -122,6 +122,37 @@ it helps, raw where it does not, and a SHA-256 for every chunk and file.
122
122
  Extraction is verified and atomic — a damaged bundle fails rather than leaving
123
123
  a half-written tree.
124
124
 
125
+ ## Claude Code and Codex
126
+
127
+ CodeRook can be a tool your assistant uses while it works. Both speak the same
128
+ protocol, so one server covers them.
129
+
130
+ Claude Code:
131
+
132
+ ```
133
+ claude mcp add coderook -- coderook mcp
134
+ ```
135
+
136
+ Codex, in `~/.codex/config.toml`:
137
+
138
+ ```toml
139
+ [mcp_servers.coderook]
140
+ command = "coderook"
141
+ args = ["mcp"]
142
+ ```
143
+
144
+ It offers five things, all of which read: your projects, a project's versions,
145
+ the files in a version, one file's contents at a version, and what has changed
146
+ in a local folder.
147
+
148
+ It cannot save a version, delete anything, or sign in or out. That is
149
+ deliberate — an assistant that goes wrong can waste your time but not your
150
+ work, and saving stays something a person types. A project whose owner has
151
+ turned off machine reading is refused in words rather than as a status code.
152
+
153
+ The server is launched by the assistant and exits with it; there is no reason
154
+ to run `coderook mcp` by hand.
155
+
125
156
  ## Checking things
126
157
 
127
158
  ```
@@ -25,6 +25,7 @@ const help_js_1 = require("./help.js");
25
25
  const progress_js_1 = require("./progress.js");
26
26
  const project_commands_js_1 = require("./project_commands.js");
27
27
  const track_commands_js_1 = require("./track_commands.js");
28
+ const mcp_js_1 = require("./mcp.js");
28
29
  const service_commands_js_1 = require("./service_commands.js");
29
30
  const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
30
31
  const upload_js_1 = require("../../desktop-app/src/main/upload.js");
@@ -1443,6 +1444,39 @@ const SPECS = [
1443
1444
  examples: ["coderook runner my-game --labels windows,signing"],
1444
1445
  run: commandRunner,
1445
1446
  },
1447
+ {
1448
+ /*
1449
+ The assistants speak one protocol between them, so this is one server
1450
+ rather than two integrations. Hidden from nobody but unlikely to be
1451
+ typed by hand: it is launched by Claude Code or Codex, talks on its own
1452
+ stdin and stdout, and exits with them.
1453
+ */
1454
+ name: "mcp",
1455
+ group: "Other",
1456
+ summary: "serve CodeRook to Claude Code, Codex and other assistants",
1457
+ usage: "mcp",
1458
+ detail: "Speaks the Model Context Protocol on stdin and stdout, so an assistant\n" +
1459
+ "can look at your projects while it works. Not run by hand — point the\n" +
1460
+ "assistant at it and it starts and stops the process itself.\n" +
1461
+ "\n" +
1462
+ "Claude Code:\n" +
1463
+ " claude mcp add coderook -- coderook mcp\n" +
1464
+ "\n" +
1465
+ "Codex, in ~/.codex/config.toml:\n" +
1466
+ " [mcp_servers.coderook]\n" +
1467
+ " command = 'coderook'\n" +
1468
+ " args = ['mcp']\n" +
1469
+ "\n" +
1470
+ "Everything it offers reads. It lists projects, versions and files, shows\n" +
1471
+ "one file at a version, and reports what has changed in a folder. It\n" +
1472
+ "cannot save a version, delete anything, or sign in or out — an assistant\n" +
1473
+ "that goes wrong can waste your time but not your work.\n" +
1474
+ "\n" +
1475
+ "A project whose owner has turned off machine reading is refused, in\n" +
1476
+ "words rather than as a status code.\n",
1477
+ examples: ["claude mcp add coderook -- coderook mcp"],
1478
+ run: () => (0, mcp_js_1.commandMcp)(),
1479
+ },
1446
1480
  {
1447
1481
  name: "doctor",
1448
1482
  group: "Other",
@@ -0,0 +1,349 @@
1
+ "use strict";
2
+ /**
3
+ * CodeRook as a tool an assistant can use.
4
+ *
5
+ * Claude Code and Codex both take integrations the same way: a small process
6
+ * speaking JSON-RPC over its own stdin and stdout, launched by the assistant
7
+ * and shut down with it. One server therefore covers both, and shipping it
8
+ * inside the CLI means the install is the install somebody already did — no
9
+ * second package, no second sign-in, no second copy of the upload engine.
10
+ *
11
+ * Written against the wire rather than against a library. The protocol here
12
+ * is a handshake and two methods; the CLI has no runtime dependencies at all,
13
+ * which is a large part of why installing it globally is safe, and adding a
14
+ * dependency tree to gain three hundred lines is a poor trade.
15
+ *
16
+ * What it will not do matters as much as what it will. Everything here reads.
17
+ * Nothing writes to an account, nothing deletes, and nothing signs anything
18
+ * in or out — an assistant that has gone wrong can waste your time but cannot
19
+ * cost you work. Saving a version stays a thing a person types.
20
+ */
21
+ var __importDefault = (this && this.__importDefault) || function (mod) {
22
+ return (mod && mod.__esModule) ? mod : { "default": mod };
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.commandMcp = commandMcp;
26
+ const promises_1 = require("node:fs/promises");
27
+ const node_os_1 = __importDefault(require("node:os"));
28
+ const node_path_1 = __importDefault(require("node:path"));
29
+ const node_process_1 = __importDefault(require("node:process"));
30
+ const download_js_1 = require("../../desktop-app/src/main/download.js");
31
+ const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
32
+ const api_js_1 = require("./api.js");
33
+ const config_js_1 = require("./config.js");
34
+ /** What the protocol calls itself. Both clients accept this revision. */
35
+ const PROTOCOL_VERSION = "2024-11-05";
36
+ /** Beyond this a file is described rather than pasted into a conversation. */
37
+ const READ_LIMIT = 256 * 1024;
38
+ function text(value) {
39
+ return { content: [{ type: "text", text: value }] };
40
+ }
41
+ function bytes(value) {
42
+ if (value >= 1024 ** 3)
43
+ return `${(value / 1024 ** 3).toFixed(2)} GB`;
44
+ if (value >= 1024 ** 2)
45
+ return `${(value / 1024 ** 2).toFixed(1)} MB`;
46
+ if (value >= 1024)
47
+ return `${(value / 1024).toFixed(0)} KB`;
48
+ return `${value} B`;
49
+ }
50
+ /**
51
+ * The project a call is about, named or taken from the folder.
52
+ *
53
+ * Assistants are usually already sitting in a project directory, so a tool
54
+ * that insists on being told which project every time is a tool that gets
55
+ * called wrongly. Naming one still wins when it is given.
56
+ */
57
+ async function resolve(named) {
58
+ const wanted = (typeof named === "string" && named.trim()) ||
59
+ (await (0, config_js_1.readLink)(node_process_1.default.cwd()))?.slug;
60
+ if (!wanted) {
61
+ throw new Error("No project named, and this folder is not linked to one. Pass `project`.");
62
+ }
63
+ const found = await (0, api_js_1.findProject)(wanted);
64
+ if (!found)
65
+ throw new Error(`No project matching "${wanted}" on your account.`);
66
+ return { id: found.id, slug: found.slug };
67
+ }
68
+ /**
69
+ * Whether this project's owner allows machines to read it.
70
+ *
71
+ * The four switches on a project are the owner's answer about automated
72
+ * access, and an assistant reading on somebody's behalf is exactly what they
73
+ * describe. Checked here rather than left to the service, so the refusal says
74
+ * why in words the assistant can pass on rather than arriving as a status
75
+ * code it will most likely retry.
76
+ */
77
+ async function assertMayRead(repositoryId, slug) {
78
+ const access = await (0, api_js_1.aiAccess)(repositoryId);
79
+ if (!access.aiRead) {
80
+ throw new Error(`The owner of "${slug}" has asked that machines not read it. ` +
81
+ "A person can still open it in the desktop application or the browser.");
82
+ }
83
+ }
84
+ async function withVersion(projectId, wanted) {
85
+ const saved = await (0, api_js_1.versions)(projectId);
86
+ if (!saved.length)
87
+ throw new Error("This project has no saved versions yet.");
88
+ if (wanted === undefined || wanted === null || wanted === "latest") {
89
+ return { id: saved[0].id, sequence: saved[0].sequence };
90
+ }
91
+ const sequence = Number(wanted);
92
+ const found = saved.find((one) => one.sequence === sequence);
93
+ if (!found)
94
+ throw new Error(`No version ${wanted} in this project.`);
95
+ return { id: found.id, sequence: found.sequence };
96
+ }
97
+ const TOOLS = [
98
+ {
99
+ name: "coderook_projects",
100
+ description: "Every project on the signed-in CodeRook account, with how much each " +
101
+ "holds and when it last changed.",
102
+ inputSchema: { type: "object", properties: {} },
103
+ run: async () => {
104
+ const all = await (0, api_js_1.projects)();
105
+ if (!all.length)
106
+ return "No projects on this account yet.";
107
+ return all
108
+ .map((one) => `${one.slug} ${one.visibility} ${one.versionCount} version${one.versionCount === 1 ? "" : "s"} ${bytes(one.storedBytes ?? 0)}`)
109
+ .join("\n");
110
+ },
111
+ },
112
+ {
113
+ name: "coderook_versions",
114
+ description: "What has been saved to a project, newest first. Each version is a " +
115
+ "complete snapshot, not a difference from the one before it.",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ project: { type: "string", description: "Slug. Defaults to this folder's project." },
120
+ },
121
+ },
122
+ run: async (input) => {
123
+ const project = await resolve(input.project);
124
+ await assertMayRead(project.id, project.slug);
125
+ const saved = await (0, api_js_1.versions)(project.id);
126
+ if (!saved.length)
127
+ return "No versions saved yet.";
128
+ return saved
129
+ .map((one) => `v${one.sequence} ${one.createdAt.slice(0, 10)} ${one.fileCount} files ` +
130
+ `${bytes(one.storedSize)} ${one.message || "(no message)"}`)
131
+ .join("\n");
132
+ },
133
+ },
134
+ {
135
+ name: "coderook_files",
136
+ description: "The files a version contains, with their sizes. Use before reading " +
137
+ "one, so a path is known to exist rather than guessed at.",
138
+ inputSchema: {
139
+ type: "object",
140
+ properties: {
141
+ project: { type: "string" },
142
+ version: {
143
+ type: ["number", "string"],
144
+ description: 'Version number, or "latest". Defaults to latest.',
145
+ },
146
+ },
147
+ },
148
+ run: async (input) => {
149
+ const project = await resolve(input.project);
150
+ await assertMayRead(project.id, project.slug);
151
+ const version = await withVersion(project.id, input.version);
152
+ const files = await new download_js_1.Downloader(config_js_1.credentials).files(project.id, version.id);
153
+ if (!files.length)
154
+ return `v${version.sequence} holds no files.`;
155
+ const shown = files.slice(0, 2000);
156
+ const lines = shown.map((file) => `${file.path} ${bytes(file.sourceSize)}`);
157
+ if (files.length > shown.length) {
158
+ lines.push(`… and ${files.length - shown.length} more`);
159
+ }
160
+ return `v${version.sequence} · ${files.length} files\n${lines.join("\n")}`;
161
+ },
162
+ },
163
+ {
164
+ name: "coderook_read_file",
165
+ description: "The contents of one file at one version, as stored. Verified against " +
166
+ "the digest the version recorded.",
167
+ inputSchema: {
168
+ type: "object",
169
+ properties: {
170
+ path: { type: "string", description: "Path within the project." },
171
+ project: { type: "string" },
172
+ version: { type: ["number", "string"] },
173
+ },
174
+ required: ["path"],
175
+ },
176
+ run: async (input) => {
177
+ const wanted = String(input.path ?? "").trim();
178
+ if (!wanted)
179
+ throw new Error("Which file? Pass `path`.");
180
+ const project = await resolve(input.project);
181
+ await assertMayRead(project.id, project.slug);
182
+ const version = await withVersion(project.id, input.version);
183
+ const downloader = new download_js_1.Downloader(config_js_1.credentials);
184
+ const files = await downloader.files(project.id, version.id);
185
+ const file = files.find((one) => one.path === wanted);
186
+ if (!file)
187
+ throw new Error(`No file "${wanted}" in v${version.sequence}.`);
188
+ if (file.sourceSize > READ_LIMIT) {
189
+ return (`${wanted} is ${bytes(file.sourceSize)}, larger than this tool will ` +
190
+ `paste into a conversation. Fetch the project with \`coderook get\` ` +
191
+ `and read it from disk.`);
192
+ }
193
+ /*
194
+ Through the same download path a person uses, temporary file and all,
195
+ rather than a second way of fetching bytes. That path verifies each
196
+ piece against the digest the version recorded — the check that caught
197
+ a 7.34 GB file arriving as 5.09 GB under a clean 200.
198
+ */
199
+ const scratch = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "coderook-mcp-"));
200
+ try {
201
+ const target = node_path_1.default.join(scratch, node_path_1.default.basename(wanted) || "file");
202
+ await downloader.fileTo(project.id, version.id, file, target);
203
+ const held = await (0, promises_1.readFile)(target);
204
+ if (held.includes(0)) {
205
+ return `${wanted} is binary (${bytes(file.sourceSize)}); not shown.`;
206
+ }
207
+ return held.toString("utf8");
208
+ }
209
+ finally {
210
+ await (0, promises_1.rm)(scratch, { recursive: true, force: true });
211
+ }
212
+ },
213
+ },
214
+ {
215
+ name: "coderook_status",
216
+ description: "What has changed in a local folder since its last saved version — " +
217
+ "added, edited and removed files. Reads the folder; changes nothing.",
218
+ inputSchema: {
219
+ type: "object",
220
+ properties: {
221
+ folder: { type: "string", description: "Defaults to the working directory." },
222
+ },
223
+ },
224
+ run: async (input) => {
225
+ const folder = node_path_1.default.resolve(typeof input.folder === "string" && input.folder.trim()
226
+ ? input.folder
227
+ : node_process_1.default.cwd());
228
+ const link = await (0, config_js_1.readLink)(folder);
229
+ const rules = await (0, worktree_js_1.readRules)(folder);
230
+ const changed = await (0, worktree_js_1.changedFiles)(folder, rules, null);
231
+ if (!changed.length)
232
+ return "Nothing here has changed since the last version.";
233
+ /*
234
+ Said in the words the scan actually produces: a file the folder no
235
+ longer has is a deletion, one with nothing removed is new, and the
236
+ rest are edits with their line counts, because "changed" on its own
237
+ tells an assistant nothing about how much.
238
+ */
239
+ const lines = changed.slice(0, 500).map((file) => {
240
+ if (file.deleted)
241
+ return `deleted ${file.path}`;
242
+ if (!file.removed)
243
+ return `added ${file.path} +${file.added}`;
244
+ return `edited ${file.path} +${file.added} -${file.removed}`;
245
+ });
246
+ if (changed.length > lines.length) {
247
+ lines.push(`… and ${changed.length - lines.length} more`);
248
+ }
249
+ const where = link ? `${link.slug} (v${link.sequence})` : "not linked to a project";
250
+ return `${folder} — ${where}\n${changed.length} changed\n${lines.join("\n")}`;
251
+ },
252
+ },
253
+ ];
254
+ /* ------------------------------------------------------------------ wire */
255
+ function reply(id, result) {
256
+ node_process_1.default.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
257
+ }
258
+ function fail(id, code, message) {
259
+ node_process_1.default.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`);
260
+ }
261
+ async function handle(request) {
262
+ const { id, method, params } = request;
263
+ /*
264
+ Notifications carry no id and expect no answer. Replying to one is a
265
+ protocol error, and the client that receives it is entitled to hang up.
266
+ */
267
+ const isNotification = id === undefined || id === null;
268
+ if (method === "initialize") {
269
+ reply(id, {
270
+ protocolVersion: PROTOCOL_VERSION,
271
+ capabilities: { tools: {} },
272
+ serverInfo: { name: "coderook", version: node_process_1.default.env.CODEROOK_CLI_VERSION ?? "0.13.0" },
273
+ });
274
+ return;
275
+ }
276
+ if (method === "notifications/initialized" || method === "notifications/cancelled") {
277
+ return;
278
+ }
279
+ if (method === "tools/list") {
280
+ reply(id, {
281
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({
282
+ name,
283
+ description,
284
+ inputSchema,
285
+ })),
286
+ });
287
+ return;
288
+ }
289
+ if (method === "tools/call") {
290
+ const name = String(params?.name ?? "");
291
+ const tool = TOOLS.find((one) => one.name === name);
292
+ if (!tool) {
293
+ if (!isNotification)
294
+ fail(id, -32602, `No tool called "${name}".`);
295
+ return;
296
+ }
297
+ try {
298
+ const answer = await tool.run(params?.arguments ?? {});
299
+ reply(id, text(answer));
300
+ }
301
+ catch (error) {
302
+ /*
303
+ Returned as a result rather than a protocol error. A refusal — the
304
+ owner does not allow machines, the file is not there — is an answer
305
+ the assistant should read and act on, where a JSON-RPC error is
306
+ something it will most likely retry or surface as a crash.
307
+ */
308
+ reply(id, {
309
+ ...text(error instanceof Error ? error.message : String(error)),
310
+ isError: true,
311
+ });
312
+ }
313
+ return;
314
+ }
315
+ if (!isNotification)
316
+ fail(id, -32601, `Unsupported method "${method}".`);
317
+ }
318
+ /**
319
+ * Read newline-delimited JSON from stdin until it closes.
320
+ *
321
+ * Buffered by line rather than by chunk, because a chunk boundary falls
322
+ * wherever the pipe decides and a half-read message parses as nothing.
323
+ */
324
+ async function commandMcp() {
325
+ node_process_1.default.stdin.setEncoding("utf8");
326
+ let buffer = "";
327
+ await new Promise((done) => {
328
+ node_process_1.default.stdin.on("data", (piece) => {
329
+ buffer += piece;
330
+ let cut = buffer.indexOf("\n");
331
+ while (cut >= 0) {
332
+ const line = buffer.slice(0, cut).trim();
333
+ buffer = buffer.slice(cut + 1);
334
+ cut = buffer.indexOf("\n");
335
+ if (!line)
336
+ continue;
337
+ try {
338
+ void handle(JSON.parse(line));
339
+ }
340
+ catch {
341
+ /* Not JSON. Nothing to answer, and nobody to answer to. */
342
+ }
343
+ }
344
+ });
345
+ node_process_1.default.stdin.on("end", () => done());
346
+ node_process_1.default.stdin.on("close", () => done());
347
+ });
348
+ return 0;
349
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",