@rucher/cli 0.0.0-stage → 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/dist/cli.mjs +1027 -0
  4. package/package.json +52 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Florian Varrin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,14 @@
1
- # Temporary Holding Version
1
+ # @rucher/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The `rucher` binary: status, journal and sessions of your Claude Code sub-agents, from the terminal. Built on [`@rucher/carnet`](../carnet).
4
+
5
+ ```bash
6
+ rucher init --name shop --repos ~/code/shop-api,~/code/shop-web
7
+ rucher status --watch # the status pane
8
+ rucher log # the journal
9
+ rucher sessions # named sessions
10
+ rucher resume shop-auth # back into a named session
11
+ rucher serve # the Carnet MCP server, started by the Claude Code plugin
12
+ ```
13
+
14
+ See the repository [README](../../README.md) for install and the plugin, and `docs/carnet-spec.md` for every command.
package/dist/cli.mjs ADDED
@@ -0,0 +1,1027 @@
1
+ #!/usr/bin/env node
2
+ import { defineCommand, runMain } from "citty";
3
+ import { existsSync, readdirSync } from "node:fs";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import tab from "@bomb.sh/tab/citty";
6
+ import { CarnetError, LEGEND, addGroupRepo, claudeCode, formatClock, handleSessionStart, handleUserPromptSubmit, hostById, initGroup, isGroupRoot, locateGroup, openCarnet, readRegistry, removeGroupRepo, renderSections, repoPathFor, shortId, unregisterGroups } from "@rucher/carnet";
7
+ import { styleText } from "node:util";
8
+ import { spawnSync } from "node:child_process";
9
+ import { serveCarnet } from "@rucher/carnet/mcp";
10
+ // src/completion.ts
11
+ const hiddenCommands = /* @__PURE__ */ new Set(["hook", "serve"]);
12
+ function quietly(suggest) {
13
+ try {
14
+ suggest();
15
+ } catch {}
16
+ }
17
+ function suggestSessions(context, complete) {
18
+ quietly(() => {
19
+ const carnet = openCarnet(context);
20
+ try {
21
+ const sessions = carnet.sessions.list({ includeTemporary: true });
22
+ const suggestions = [...sessions.flatMap((session) => session.name ? [{
23
+ value: session.name,
24
+ description: session.purpose ?? session.status
25
+ }] : []), ...sessions.flatMap((session) => session.name ? [] : [{
26
+ value: shortId(session.id),
27
+ description: session.status
28
+ }])];
29
+ for (const suggestion of suggestions) if (!context.typedWords.slice(0, -1).includes(suggestion.value)) complete(suggestion.value, suggestion.description);
30
+ } finally {
31
+ carnet.close();
32
+ }
33
+ });
34
+ }
35
+ function suggestGroups(context, complete) {
36
+ quietly(() => {
37
+ const groups = readRegistry(context.env);
38
+ for (const group of groups) complete(groups.filter((other) => other.name === group.name).length > 1 ? group.path : group.name, group.path);
39
+ });
40
+ }
41
+ function suggestRepos(context, complete) {
42
+ quietly(() => {
43
+ for (const repo of locateGroup(context).repos) complete(repo, "");
44
+ });
45
+ }
46
+ function suggestDirectories(context, complete) {
47
+ quietly(() => {
48
+ const partial = context.typedWords.at(-1) ?? "";
49
+ const base = partial.endsWith("/") ? partial : dirname(partial) === "." ? "" : `${dirname(partial)}/`;
50
+ for (const entry of readdirSync(resolve(context.cwd, base || "."), { withFileTypes: true })) if (entry.isDirectory() && !entry.name.startsWith(".")) complete(join(base, entry.name), "");
51
+ });
52
+ }
53
+ function processContext() {
54
+ const separator = process.argv.indexOf("--");
55
+ const typedWords = separator === -1 ? [] : process.argv.slice(separator + 1);
56
+ return {
57
+ cwd: process.cwd(),
58
+ env: process.env,
59
+ typedWords
60
+ };
61
+ }
62
+ const sessions = (complete) => suggestSessions(processContext(), complete);
63
+ const groups = (complete) => suggestGroups(processContext(), complete);
64
+ const repos = (complete) => suggestRepos(processContext(), complete);
65
+ const directories = (complete) => suggestDirectories(processContext(), complete);
66
+ const completionConfig = { subCommands: {
67
+ status: { options: { session: sessions } },
68
+ log: { options: { session: sessions } },
69
+ init: { args: { directory: directories } },
70
+ resume: { args: { name: sessions } },
71
+ sessions: { subCommands: { merge: { args: {
72
+ from: sessions,
73
+ into: sessions
74
+ } } } },
75
+ groups: { subCommands: { forget: { args: { reference: groups } } } },
76
+ group: { subCommands: {
77
+ add: { args: { path: directories } },
78
+ remove: { args: { path: repos } }
79
+ } }
80
+ } };
81
+ async function setupCompletion(main) {
82
+ const subCommands = await (typeof main.subCommands === "function" ? main.subCommands() : main.subCommands);
83
+ const visible = Object.fromEntries(Object.entries(subCommands ?? {}).filter(([name]) => !hiddenCommands.has(name)));
84
+ await tab({
85
+ ...main,
86
+ subCommands: visible
87
+ }, completionConfig);
88
+ const complete = visible["complete"];
89
+ if (complete) main.subCommands = {
90
+ ...subCommands,
91
+ complete
92
+ };
93
+ }
94
+ // package.json
95
+ var version = "0.1.0";
96
+ // src/color.ts
97
+ function paint(io) {
98
+ const style = (name) => (text) => io.color ? styleText(name, text, { validateStream: false }) : text;
99
+ return {
100
+ bold: style("bold"),
101
+ dim: style("dim"),
102
+ yellow: style("yellow"),
103
+ red: style("red"),
104
+ cyan: style("cyan")
105
+ };
106
+ }
107
+ // src/commands/init.ts
108
+ function syncContextFiles(group, mode) {
109
+ return group.hosts.flatMap((id) => hostById(id).contextFiles.map((file) => ({
110
+ path: file.path,
111
+ result: file.sync(group, mode)
112
+ })));
113
+ }
114
+ function init(options, io) {
115
+ const colors = paint(io);
116
+ const root = resolve(io.cwd, options.directory ?? ".");
117
+ let group;
118
+ try {
119
+ group = initGroup(root, {
120
+ name: options.name,
121
+ repos: options.repos?.map((repo) => resolve(io.cwd, repo)),
122
+ env: io.env
123
+ });
124
+ } catch (error) {
125
+ if (error instanceof CarnetError) {
126
+ io.stderr(`error: ${error.message}\n`);
127
+ return 1;
128
+ }
129
+ throw error;
130
+ }
131
+ const lines = [
132
+ `${colors.bold(group.name)} initialised in ${group.root}`,
133
+ ` repos: ${group.repos.length ? group.repos.join(", ") : "none yet, add them with `rucher group add <path>`"}`,
134
+ ` database: ${group.dbPath}`
135
+ ];
136
+ for (const { path, result } of syncContextFiles(group, "merge")) {
137
+ if (result === "unchanged") continue;
138
+ lines.push(result === "no-markers" ? ` kept existing ${path} ${colors.dim("(add the rucher:repos markers to keep its repo list in sync, and a line: multi-agent work uses the rucher tools)")}` : ` ${result} ${path}`);
139
+ }
140
+ const hosts = group.hosts.map((id) => hostById(id).displayName).join(" or ");
141
+ lines.push("", `Next: start ${hosts} in this directory, then \`rucher status --watch\` in a side pane.`);
142
+ io.stdout(`${lines.join("\n")}\n`);
143
+ return 0;
144
+ }
145
+ // src/commands/group.ts
146
+ function groupShow(io) {
147
+ const colors = paint(io);
148
+ const group = locateGroup({
149
+ cwd: io.cwd,
150
+ env: io.env
151
+ });
152
+ const repos = group.repos.length ? group.repos.map((r) => ` ${basename(r)} ${colors.dim(r)}`).join("\n") : ` ${colors.dim("none")}`;
153
+ io.stdout(`${colors.bold(group.name)} ${colors.dim(group.root)}\nrepos:\n${repos}\n`);
154
+ return 0;
155
+ }
156
+ function syncNotes(group, mode) {
157
+ return syncContextFiles(group, mode).filter(({ result }) => result !== "unchanged").map(({ path, result }) => result === "no-markers" ? `${path} has no rucher:repos markers, update its repo list yourself` : `${result} ${path}`);
158
+ }
159
+ function withNotes(line, notes) {
160
+ return `${line}${notes.length ? ` (${notes.join(", ")})` : ""}\n`;
161
+ }
162
+ function carnetErrors(io, run) {
163
+ try {
164
+ return run();
165
+ } catch (error) {
166
+ if (error instanceof CarnetError) {
167
+ io.stderr(`error: ${error.message}\n`);
168
+ return 1;
169
+ }
170
+ throw error;
171
+ }
172
+ }
173
+ function groupAdd(repo, io) {
174
+ const found = locateGroup({
175
+ cwd: io.cwd,
176
+ env: io.env
177
+ });
178
+ return carnetErrors(io, () => {
179
+ const path = resolve(io.cwd, repo);
180
+ const group = addGroupRepo(found.root, path);
181
+ io.stdout(withNotes(`added ${basename(path)} to ${group.name}`, syncNotes(group, "merge")));
182
+ return 0;
183
+ });
184
+ }
185
+ function groupRemove(repo, io) {
186
+ const found = locateGroup({
187
+ cwd: io.cwd,
188
+ env: io.env
189
+ });
190
+ return carnetErrors(io, () => {
191
+ const path = resolve(io.cwd, repo);
192
+ const group = removeGroupRepo(found.root, path);
193
+ io.stdout(withNotes(`removed ${basename(path)} from ${group.name}`, syncNotes(group, "exact")));
194
+ return 0;
195
+ });
196
+ }
197
+ function groupSync(io) {
198
+ const group = locateGroup({
199
+ cwd: io.cwd,
200
+ env: io.env
201
+ });
202
+ const notes = syncNotes(group, "exact");
203
+ io.stdout(notes.length ? withNotes(`synced ${group.name}`, notes) : `${group.name} already in sync\n`);
204
+ return 0;
205
+ }
206
+ // src/commands/groups.ts
207
+ function missing(reference) {
208
+ return !isGroupRoot(reference.path);
209
+ }
210
+ function groupsList(io) {
211
+ const colors = paint(io);
212
+ const groups = readRegistry(io.env);
213
+ if (groups.length === 0) {
214
+ io.stdout(`${colors.dim("no registered group")}\n`);
215
+ return 0;
216
+ }
217
+ const width = Math.max(...groups.map((group) => group.name.length));
218
+ const lines = groups.map((group) => `${colors.bold(group.name.padEnd(width))} ${group.path}${missing(group) ? ` ${colors.yellow("missing")}` : ""}`);
219
+ io.stdout(`${lines.join("\n")}\n`);
220
+ return 0;
221
+ }
222
+ function groupsForget(reference, io) {
223
+ const groups = readRegistry(io.env);
224
+ const byPath = groups.filter((group) => group.path === resolve(io.cwd, reference));
225
+ const matches = byPath.length ? byPath : groups.filter((group) => group.name === reference);
226
+ const [match] = matches;
227
+ if (!match) {
228
+ io.stderr(`error: no registered group named or located at "${reference}"\n`);
229
+ return 1;
230
+ }
231
+ if (matches.length > 1) {
232
+ io.stderr(`error: several groups are named "${reference}", forget one by path:\n${matches.map((group) => ` ${group.path}`).join("\n")}\n`);
233
+ return 1;
234
+ }
235
+ unregisterGroups([match.path], io.env);
236
+ io.stdout(`forgot ${match.name} (${match.path}); its meta directory is untouched\n`);
237
+ return 0;
238
+ }
239
+ function groupsPrune(io) {
240
+ const gone = readRegistry(io.env).filter(missing);
241
+ if (gone.length === 0) {
242
+ io.stdout("nothing to prune\n");
243
+ return 0;
244
+ }
245
+ unregisterGroups(gone.map((group) => group.path), io.env);
246
+ io.stdout(`pruned ${gone.length}:\n${gone.map((g) => ` ${g.name} ${g.path}`).join("\n")}\n`);
247
+ return 0;
248
+ }
249
+ // src/commands/hook.ts
250
+ const host = claudeCode;
251
+ function runHook(io, run) {
252
+ try {
253
+ run();
254
+ } catch (error) {
255
+ if (error instanceof CarnetError && error.code === "NO_GROUP") return 0;
256
+ io.stderr(`rucher hook: ${error instanceof Error ? error.message : String(error)}\n`);
257
+ }
258
+ return 0;
259
+ }
260
+ function withCarnet(cwd, io, use) {
261
+ const carnet = openCarnet({
262
+ cwd: cwd ?? io.cwd,
263
+ env: io.env
264
+ });
265
+ try {
266
+ use(carnet);
267
+ } finally {
268
+ carnet.close();
269
+ }
270
+ }
271
+ function sessionStart(stdinText, io) {
272
+ return runHook(io, () => {
273
+ const payload = host.parseSessionPayload(stdinText);
274
+ withCarnet(payload.cwd, io, (carnet) => {
275
+ const { additionalContext } = handleSessionStart(carnet, payload, {
276
+ host: host.id,
277
+ processId: host.processId(io.env),
278
+ RUCHER_PURGE_DAYS: io.env.RUCHER_PURGE_DAYS
279
+ });
280
+ if (additionalContext) {
281
+ const hookSpecificOutput = {
282
+ hookEventName: "SessionStart",
283
+ additionalContext
284
+ };
285
+ io.stdout(`${JSON.stringify({ hookSpecificOutput })}\n`);
286
+ }
287
+ });
288
+ });
289
+ }
290
+ function subagentHook(action, stdinText, io) {
291
+ return runHook(io, () => {
292
+ const payload = host.parseSubagentPayload(stdinText);
293
+ withCarnet(payload.cwd, io, (carnet) => {
294
+ carnet.agents[action](payload.session_id, payload.agent_id);
295
+ });
296
+ });
297
+ }
298
+ function subagentStart(stdinText, io) {
299
+ return subagentHook("pairSubagent", stdinText, io);
300
+ }
301
+ function subagentStop(stdinText, io) {
302
+ return subagentHook("stopSubagent", stdinText, io);
303
+ }
304
+ function userPromptSubmit(stdinText, io) {
305
+ return runHook(io, () => {
306
+ const payload = host.parsePromptPayload(stdinText);
307
+ withCarnet(payload.cwd, io, (carnet) => {
308
+ handleUserPromptSubmit(carnet, host, payload);
309
+ });
310
+ });
311
+ }
312
+ // src/session.ts
313
+ var CliError = class extends Error {
314
+ hint;
315
+ constructor(message, hint) {
316
+ super(message);
317
+ this.hint = hint;
318
+ this.name = "CliError";
319
+ }
320
+ };
321
+ function findSession(carnet, reference) {
322
+ try {
323
+ return carnet.sessions.find(reference);
324
+ } catch (error) {
325
+ if (!(error instanceof CarnetError) || error.code !== "SESSION_NOT_FOUND") throw error;
326
+ const known = carnet.sessions.list().map((session) => session.name).filter(Boolean);
327
+ throw new CliError(error.message, known.length ? `known sessions: ${known.join(", ")}` : "pass a session name, id or id prefix, see rucher sessions --all");
328
+ }
329
+ }
330
+ function resolveSession(carnet, reference) {
331
+ if (reference) return findSession(carnet, reference);
332
+ const session = carnet.sessions.default();
333
+ if (!session) throw new CliError("No session in this group yet", "start a Claude Code session inside the group");
334
+ return session;
335
+ }
336
+ // src/commands/log.ts
337
+ function formatEvents(events, io) {
338
+ const colors = paint(io);
339
+ if (events.length === 0) return colors.dim("no events yet");
340
+ return events.map((event) => {
341
+ return [
342
+ colors.dim(formatClock(new Date(event.at))),
343
+ colors.bold(event.type.padEnd(9)),
344
+ event.agent ?? "",
345
+ event.link ?? event.note ?? "",
346
+ event.commit ? colors.cyan(event.commit.slice(0, 7)) : ""
347
+ ].filter(Boolean).join(" ");
348
+ }).join("\n");
349
+ }
350
+ function formatDay(date) {
351
+ const month = String(date.getMonth() + 1).padStart(2, "0");
352
+ const day = String(date.getDate()).padStart(2, "0");
353
+ return `${date.getFullYear()}-${month}-${day}`;
354
+ }
355
+ function markdownEvent(event) {
356
+ const parts = [`${formatClock(new Date(event.at))} **${event.type}**`];
357
+ if (event.agent) parts.push(`\`${event.agent}\``);
358
+ const detail = [
359
+ event.note,
360
+ event.link ? `[report](${event.link})` : null,
361
+ event.commit ? `\`${event.commit.slice(0, 7)}\`` : null
362
+ ].filter(Boolean).join(" · ");
363
+ return `- ${parts.join(" ")}${detail ? `: ${detail}` : ""}`;
364
+ }
365
+ function formatEventsMarkdown(events, title) {
366
+ const lines = [`# Journal: ${title}`];
367
+ if (events.length === 0) return `${lines[0]}\n\nNo events yet.`;
368
+ let day = "";
369
+ for (const event of events) {
370
+ const current = formatDay(new Date(event.at));
371
+ if (current !== day) {
372
+ day = current;
373
+ lines.push("", `## ${day}`, "");
374
+ }
375
+ lines.push(markdownEvent(event));
376
+ }
377
+ return lines.join("\n");
378
+ }
379
+ function sessionTitle(carnet, session) {
380
+ return `${carnet.group.name} · ${session.name ?? session.id.slice(0, 8)}`;
381
+ }
382
+ function logOnce(carnet, io, options) {
383
+ const session = resolveSession(carnet, options.session);
384
+ const events = carnet.events.read(session.id, options.count);
385
+ io.stdout(`${options.md ? formatEventsMarkdown(events, sessionTitle(carnet, session)) : formatEvents(events, io)}\n`);
386
+ }
387
+ // src/commands/resume.ts
388
+ function resumeTarget(carnet, reference, io) {
389
+ const session = findSession(carnet, reference);
390
+ if (session.status === "dropped") io.stderr(`warning: session ${reference} was dropped\n`);
391
+ const cwd = session.cwd && existsSync(session.cwd) ? session.cwd : io.cwd;
392
+ return {
393
+ ...hostById(session.host).resumeCommand(session),
394
+ cwd
395
+ };
396
+ }
397
+ function runTarget({ command, args, cwd }) {
398
+ const result = spawnSync(command, args, {
399
+ cwd,
400
+ stdio: "inherit"
401
+ });
402
+ if (result.error) throw result.error;
403
+ return result.status ?? 1;
404
+ }
405
+ // src/commands/serve.ts
406
+ function serve(io) {
407
+ serveCarnet({
408
+ cwd: io.cwd,
409
+ env: io.env
410
+ });
411
+ }
412
+ // src/commands/status.ts
413
+ const SECTION_HEADERS = /* @__PURE__ */ new Set([
414
+ "On the table",
415
+ "Waiting on you",
416
+ "Running"
417
+ ]);
418
+ function truncate(line, columns) {
419
+ const chars = Array.from(line);
420
+ return chars.length > columns ? `${chars.slice(0, Math.max(columns - 1, 1)).join("")}…` : line;
421
+ }
422
+ function colorizeLine(line, io) {
423
+ const colors = paint(io);
424
+ if (line === LEGEND) return colors.dim(line);
425
+ const cut = truncate(line, io.columns);
426
+ if (SECTION_HEADERS.has(line)) return colors.bold(cut);
427
+ if (line.startsWith(" ⚙️")) return colors.yellow(cut);
428
+ if (line === " nothing") return colors.dim(cut);
429
+ return cut;
430
+ }
431
+ function sessionWhere(carnet, session) {
432
+ return repoPathFor(carnet.group, session.cwd) ?? session.repo;
433
+ }
434
+ function sessionLabel(carnet, session, columns) {
435
+ const name = session.name ?? session.id.slice(0, 8);
436
+ const where = sessionWhere(carnet, session);
437
+ if (!where) return name;
438
+ const room = columns - Array.from(name).length - 3;
439
+ return room > 1 ? `${name} · ${truncate(where, room)}` : name;
440
+ }
441
+ function sessionHeader(carnet, session, io) {
442
+ return paint(io).cyan(sessionLabel(carnet, session, io.columns));
443
+ }
444
+ function sessionLines(carnet, session, view, io) {
445
+ if (session.status === "dropped") return [paint(io).dim(" dropped, see rucher sessions --all")];
446
+ return renderSections(view).split("\n").map((line) => colorizeLine(line, io));
447
+ }
448
+ function hasSomethingToShow(carnet, session) {
449
+ return carnet.agents.list(session.id).length > 0 || carnet.table.get(session.id) !== void 0;
450
+ }
451
+ function sessionBlock(carnet, session, view, io) {
452
+ return [sessionHeader(carnet, session, io), ...sessionLines(carnet, session, view, io)].join("\n");
453
+ }
454
+ function singleView(carnet, session, io) {
455
+ const colors = paint(io);
456
+ const view = carnet.status.view(session);
457
+ const separator = " - ";
458
+ const label = sessionLabel(carnet, session, io.columns - view.time.length - 3);
459
+ return [
460
+ `${colors.dim(view.time)}${separator}${colors.cyan(label)}`,
461
+ "",
462
+ ...sessionLines(carnet, session, view, io),
463
+ "",
464
+ colors.dim(LEGEND)
465
+ ].join("\n");
466
+ }
467
+ function renderStatusView(carnet, io, options) {
468
+ const sessions = options.all ? carnet.sessions.list({ includeTemporary: true }).filter((session) => hasSomethingToShow(carnet, session)) : [];
469
+ if (sessions.length === 0) return singleView(carnet, resolveSession(carnet, options.session), io);
470
+ let time = "";
471
+ const blocks = sessions.map((session) => {
472
+ const view = carnet.status.view(session);
473
+ time ||= view.time;
474
+ return sessionBlock(carnet, session, view, io);
475
+ });
476
+ return [
477
+ paint(io).dim(time),
478
+ "",
479
+ blocks.join("\n\n"),
480
+ "",
481
+ paint(io).dim(LEGEND)
482
+ ].join("\n");
483
+ }
484
+ function statusOnce(carnet, io, options) {
485
+ io.stdout(`${renderStatusView(carnet, io, options)}\n`);
486
+ }
487
+ const CLEAR = "\x1B[H\x1B[2J";
488
+ const HIDE_CURSOR = "\x1B[?25l";
489
+ const SHOW_CURSOR = "\x1B[?25h";
490
+ function statusWatch(carnet, io, options) {
491
+ return new Promise((resolve) => {
492
+ let last = "";
493
+ const repaint = () => {
494
+ let text;
495
+ try {
496
+ text = renderStatusView(carnet, io, options);
497
+ } catch (error) {
498
+ text = paint(io).dim(error instanceof Error ? error.message : String(error));
499
+ }
500
+ if (text === last) return;
501
+ last = text;
502
+ io.stdout(`${CLEAR}${text}\n`);
503
+ };
504
+ const timer = setInterval(repaint, options.intervalMilliseconds);
505
+ const stop = () => {
506
+ clearInterval(timer);
507
+ io.stdout(SHOW_CURSOR);
508
+ resolve();
509
+ };
510
+ options.signal?.addEventListener("abort", stop, { once: true });
511
+ io.stdout(HIDE_CURSOR);
512
+ repaint();
513
+ });
514
+ }
515
+ // src/commands/sessions.ts
516
+ function lastActive(at, now) {
517
+ const date = new Date(at);
518
+ return date.toDateString() === now.toDateString() ? formatClock(date) : date.toISOString().slice(0, 10);
519
+ }
520
+ function row(carnet, session, now) {
521
+ const open = carnet.agents.list(session.id).length;
522
+ return [
523
+ session.name ?? session.id.slice(0, 8),
524
+ session.status,
525
+ sessionWhere(carnet, session) ?? "",
526
+ lastActive(session.lastActiveAt, now),
527
+ open ? `${open} agent${open > 1 ? "s" : ""}` : "",
528
+ session.purpose ?? ""
529
+ ];
530
+ }
531
+ function sessionsOnce(carnet, io, options = {}) {
532
+ const colors = paint(io);
533
+ const sessions = carnet.sessions.list({
534
+ includeTemporary: options.all,
535
+ includeDropped: options.all
536
+ });
537
+ if (sessions.length === 0) {
538
+ const hint = options.all ? "" : " (temporary ones are hidden, use --all)";
539
+ io.stdout(`${colors.dim(`no named session yet${hint}`)}\n`);
540
+ return;
541
+ }
542
+ const now = options.now ?? /* @__PURE__ */ new Date();
543
+ const rows = sessions.map((session) => ({
544
+ session,
545
+ cells: row(carnet, session, now)
546
+ }));
547
+ const widths = rows.reduce((current, { cells }) => cells.map((cell, column) => Math.max(current[column] ?? 0, cell.length)), []);
548
+ const lines = rows.map(({ session, cells }) => {
549
+ return cells.map((cell, column) => {
550
+ const padded = column < cells.length - 1 ? cell.padEnd(widths[column] ?? 0) : cell;
551
+ if (column === 0) return colors.bold(padded);
552
+ if (column === 1 && session.status !== "active") return colors.dim(padded);
553
+ return padded;
554
+ }).join(" ").trimEnd();
555
+ });
556
+ io.stdout(`${lines.join("\n")}\n`);
557
+ }
558
+ function sessionsMerge(carnet, fromReference, intoReference, io) {
559
+ const from = findSession(carnet, fromReference);
560
+ const into = findSession(carnet, intoReference);
561
+ const result = carnet.sessions.merge(from.id, into.id);
562
+ const label = (session) => session.name ?? session.id.slice(0, 8);
563
+ const lines = [
564
+ `merged ${label(from)} into ${label(result.session)}`,
565
+ ` agents moved: ${result.movedAgents.join(", ") || "none"}`,
566
+ ...result.closedAgents.length ? [` agents closed (name already used): ${result.closedAgents.join(", ")}`] : [],
567
+ ` events moved: ${result.movedEvents}`,
568
+ ` table line moved: ${result.movedTable ? "yes" : "no"}`
569
+ ];
570
+ io.stdout(`${lines.join("\n")}\n`);
571
+ }
572
+ // src/io.ts
573
+ function processIo(options = {}) {
574
+ const tty = process.stdout.isTTY === true;
575
+ const colorWanted = options.color ?? true;
576
+ return {
577
+ stdout: (text) => process.stdout.write(text),
578
+ stderr: (text) => process.stderr.write(text),
579
+ cwd: process.cwd(),
580
+ env: process.env,
581
+ color: tty && colorWanted && !process.env.NO_COLOR,
582
+ columns: process.stdout.columns ?? 80
583
+ };
584
+ }
585
+ function readStdin() {
586
+ return new Promise((resolve) => {
587
+ let data = "";
588
+ process.stdin.setEncoding("utf8");
589
+ process.stdin.on("data", (chunk) => {
590
+ data += chunk;
591
+ });
592
+ process.stdin.on("end", () => resolve(data));
593
+ process.stdin.on("error", () => resolve(data));
594
+ });
595
+ }
596
+ // src/main.ts
597
+ function fail(io, error) {
598
+ if (error instanceof CliError) io.stderr(`error: ${error.message}${error.hint ? `\n ${error.hint}` : ""}\n`);
599
+ else if (error instanceof CarnetError) io.stderr(`error: ${error.message}\n`);
600
+ else io.stderr(`error: ${error instanceof Error ? error.message : String(error)}\n`);
601
+ process.exit(1);
602
+ }
603
+ const colorArgument = { color: {
604
+ type: "boolean",
605
+ default: true,
606
+ description: "Colored output (--no-color to disable)"
607
+ } };
608
+ const sessionArgument = { session: {
609
+ type: "string",
610
+ alias: "s",
611
+ description: "Session name, id or id prefix (default: most recently active)"
612
+ } };
613
+ const initCommand = defineCommand({
614
+ meta: {
615
+ name: "init",
616
+ description: "Make the current directory a Rucher group (meta directory)"
617
+ },
618
+ args: {
619
+ directory: {
620
+ type: "positional",
621
+ description: "Meta directory (default: current directory)",
622
+ required: false
623
+ },
624
+ name: {
625
+ type: "string",
626
+ description: "Group name (default: directory name)"
627
+ },
628
+ repos: {
629
+ type: "string",
630
+ description: "Comma-separated repo paths (more later with `rucher group add`)"
631
+ },
632
+ ...colorArgument
633
+ },
634
+ run({ args }) {
635
+ const io = processIo({ color: args.color });
636
+ const repos = args.repos ? args.repos.split(",").map((repo) => repo.trim()).filter(Boolean) : void 0;
637
+ process.exitCode = init({
638
+ directory: args.directory,
639
+ name: args.name,
640
+ repos
641
+ }, io);
642
+ }
643
+ });
644
+ const statusCommand = defineCommand({
645
+ meta: {
646
+ name: "status",
647
+ description: "Show agent statuses of a session"
648
+ },
649
+ args: {
650
+ ...sessionArgument,
651
+ all: {
652
+ type: "boolean",
653
+ default: false,
654
+ description: "Every session with activity"
655
+ },
656
+ watch: {
657
+ type: "boolean",
658
+ alias: "w",
659
+ default: false,
660
+ description: "Refresh continuously"
661
+ },
662
+ interval: {
663
+ type: "string",
664
+ default: "1",
665
+ description: "Refresh interval in seconds with --watch"
666
+ },
667
+ ...colorArgument
668
+ },
669
+ async run({ args }) {
670
+ const io = processIo({ color: args.color });
671
+ try {
672
+ const carnet = openCarnet({
673
+ cwd: io.cwd,
674
+ env: io.env
675
+ });
676
+ const options = {
677
+ session: args.session,
678
+ all: args.all
679
+ };
680
+ if (!args.watch) {
681
+ statusOnce(carnet, io, options);
682
+ return;
683
+ }
684
+ const controller = new AbortController();
685
+ process.on("SIGINT", () => controller.abort());
686
+ process.on("SIGTERM", () => controller.abort());
687
+ const intervalMilliseconds = Math.max(Number(args.interval) || 1, .2) * 1e3;
688
+ await statusWatch(carnet, io, {
689
+ ...options,
690
+ intervalMilliseconds,
691
+ signal: controller.signal
692
+ });
693
+ } catch (error) {
694
+ fail(io, error);
695
+ }
696
+ }
697
+ });
698
+ const logCommand = defineCommand({
699
+ meta: {
700
+ name: "log",
701
+ description: "Show the last journal events of a session"
702
+ },
703
+ args: {
704
+ ...sessionArgument,
705
+ count: {
706
+ type: "string",
707
+ alias: "n",
708
+ default: "20",
709
+ description: "Number of events"
710
+ },
711
+ md: {
712
+ type: "boolean",
713
+ default: false,
714
+ description: "Markdown output, for archiving"
715
+ },
716
+ ...colorArgument
717
+ },
718
+ run({ args }) {
719
+ const io = processIo({ color: args.color });
720
+ try {
721
+ logOnce(openCarnet({
722
+ cwd: io.cwd,
723
+ env: io.env
724
+ }), io, {
725
+ session: args.session,
726
+ count: Number(args.count) || 20,
727
+ md: args.md
728
+ });
729
+ } catch (error) {
730
+ fail(io, error);
731
+ }
732
+ }
733
+ });
734
+ const sessionsListCommand = defineCommand({
735
+ meta: {
736
+ name: "list",
737
+ description: "List the named sessions of the group"
738
+ },
739
+ args: {
740
+ all: {
741
+ type: "boolean",
742
+ default: false,
743
+ description: "Include temporary and dropped sessions"
744
+ },
745
+ ...colorArgument
746
+ },
747
+ run({ args }) {
748
+ const io = processIo({ color: args.color });
749
+ try {
750
+ sessionsOnce(openCarnet({
751
+ cwd: io.cwd,
752
+ env: io.env
753
+ }), io, { all: args.all });
754
+ } catch (error) {
755
+ fail(io, error);
756
+ }
757
+ }
758
+ });
759
+ const sessionsMergeCommand = defineCommand({
760
+ meta: {
761
+ name: "merge",
762
+ description: "Move the agents, table line and journal of one session into another, then drop it"
763
+ },
764
+ args: {
765
+ from: {
766
+ type: "positional",
767
+ description: "Session to empty (name, id or id prefix)",
768
+ required: true
769
+ },
770
+ into: {
771
+ type: "positional",
772
+ description: "Session that keeps everything (name, id or id prefix)",
773
+ required: true
774
+ }
775
+ },
776
+ run({ args }) {
777
+ const io = processIo();
778
+ try {
779
+ const carnet = openCarnet({
780
+ cwd: io.cwd,
781
+ env: io.env
782
+ });
783
+ sessionsMerge(carnet, args.from, args.into, io);
784
+ carnet.close();
785
+ } catch (error) {
786
+ fail(io, error);
787
+ }
788
+ }
789
+ });
790
+ const sessionsCommand = defineCommand({
791
+ meta: {
792
+ name: "sessions",
793
+ description: "List or merge the sessions of the group"
794
+ },
795
+ default: "list",
796
+ subCommands: {
797
+ list: sessionsListCommand,
798
+ merge: sessionsMergeCommand
799
+ }
800
+ });
801
+ const resumeCommand = defineCommand({
802
+ meta: {
803
+ name: "resume",
804
+ description: "Resume a named session in its host (Claude Code today)"
805
+ },
806
+ args: { name: {
807
+ type: "positional",
808
+ description: "Session name, id or id prefix",
809
+ required: true
810
+ } },
811
+ run({ args }) {
812
+ const io = processIo();
813
+ try {
814
+ const carnet = openCarnet({
815
+ cwd: io.cwd,
816
+ env: io.env
817
+ });
818
+ const target = resumeTarget(carnet, args.name, io);
819
+ carnet.close();
820
+ process.exitCode = runTarget(target);
821
+ } catch (error) {
822
+ fail(io, error);
823
+ }
824
+ }
825
+ });
826
+ const groupCommand = defineCommand({
827
+ meta: {
828
+ name: "group",
829
+ description: "Show the current group, add or remove repos, sync its settings"
830
+ },
831
+ subCommands: {
832
+ show: defineCommand({
833
+ meta: {
834
+ name: "show",
835
+ description: "Name, meta directory and repos of the current group"
836
+ },
837
+ args: { ...colorArgument },
838
+ run({ args }) {
839
+ const io = processIo({ color: args.color });
840
+ try {
841
+ process.exitCode = groupShow(io);
842
+ } catch (error) {
843
+ fail(io, error);
844
+ }
845
+ }
846
+ }),
847
+ add: defineCommand({
848
+ meta: {
849
+ name: "add",
850
+ description: "Add a repo path to the current group"
851
+ },
852
+ args: {
853
+ path: {
854
+ type: "positional",
855
+ description: "Repo path",
856
+ required: true
857
+ },
858
+ ...colorArgument
859
+ },
860
+ run({ args }) {
861
+ const io = processIo({ color: args.color });
862
+ try {
863
+ process.exitCode = groupAdd(args.path, io);
864
+ } catch (error) {
865
+ fail(io, error);
866
+ }
867
+ }
868
+ }),
869
+ remove: defineCommand({
870
+ meta: {
871
+ name: "remove",
872
+ description: "Remove a repo path from the current group"
873
+ },
874
+ args: {
875
+ path: {
876
+ type: "positional",
877
+ description: "Repo path",
878
+ required: true
879
+ },
880
+ ...colorArgument
881
+ },
882
+ run({ args }) {
883
+ const io = processIo({ color: args.color });
884
+ try {
885
+ process.exitCode = groupRemove(args.path, io);
886
+ } catch (error) {
887
+ fail(io, error);
888
+ }
889
+ }
890
+ }),
891
+ sync: defineCommand({
892
+ meta: {
893
+ name: "sync",
894
+ description: "Rewrite the hosts' context files (repo list, directory access) from group.json"
895
+ },
896
+ args: { ...colorArgument },
897
+ run({ args }) {
898
+ const io = processIo({ color: args.color });
899
+ try {
900
+ process.exitCode = groupSync(io);
901
+ } catch (error) {
902
+ fail(io, error);
903
+ }
904
+ }
905
+ })
906
+ }
907
+ });
908
+ const groupsCommand = defineCommand({
909
+ meta: {
910
+ name: "groups",
911
+ description: "Registered groups of this machine"
912
+ },
913
+ subCommands: {
914
+ list: defineCommand({
915
+ meta: {
916
+ name: "list",
917
+ description: "Registered groups, missing ones flagged"
918
+ },
919
+ args: { ...colorArgument },
920
+ run({ args }) {
921
+ process.exitCode = groupsList(processIo({ color: args.color }));
922
+ }
923
+ }),
924
+ forget: defineCommand({
925
+ meta: {
926
+ name: "forget",
927
+ description: "Unregister a group by name or meta directory path"
928
+ },
929
+ args: {
930
+ reference: {
931
+ type: "positional",
932
+ description: "Group name or path",
933
+ required: true
934
+ },
935
+ ...colorArgument
936
+ },
937
+ run({ args }) {
938
+ process.exitCode = groupsForget(args.reference, processIo({ color: args.color }));
939
+ }
940
+ }),
941
+ prune: defineCommand({
942
+ meta: {
943
+ name: "prune",
944
+ description: "Unregister groups whose meta directory is gone"
945
+ },
946
+ args: { ...colorArgument },
947
+ run({ args }) {
948
+ process.exitCode = groupsPrune(processIo({ color: args.color }));
949
+ }
950
+ })
951
+ }
952
+ });
953
+ const hookCommand = defineCommand({
954
+ meta: {
955
+ name: "hook",
956
+ description: "Agent host hook entry points (Claude Code, JSON payload on stdin)"
957
+ },
958
+ subCommands: {
959
+ "session-start": defineCommand({
960
+ meta: {
961
+ name: "session-start",
962
+ description: "SessionStart hook: records the session"
963
+ },
964
+ async run() {
965
+ process.exitCode = sessionStart(await readStdin(), processIo());
966
+ }
967
+ }),
968
+ "user-prompt-submit": defineCommand({
969
+ meta: {
970
+ name: "user-prompt-submit",
971
+ description: "UserPromptSubmit hook: names the session after a /rename"
972
+ },
973
+ async run() {
974
+ process.exitCode = userPromptSubmit(await readStdin(), processIo());
975
+ }
976
+ }),
977
+ "subagent-start": defineCommand({
978
+ meta: {
979
+ name: "subagent-start",
980
+ description: "SubagentStart hook: pairs the fork with its agent"
981
+ },
982
+ async run() {
983
+ process.exitCode = subagentStart(await readStdin(), processIo());
984
+ }
985
+ }),
986
+ "subagent-stop": defineCommand({
987
+ meta: {
988
+ name: "subagent-stop",
989
+ description: "SubagentStop hook: flags a fork that never called agent_done"
990
+ },
991
+ async run() {
992
+ process.exitCode = subagentStop(await readStdin(), processIo());
993
+ }
994
+ })
995
+ }
996
+ });
997
+ const serveCommand = defineCommand({
998
+ meta: {
999
+ name: "serve",
1000
+ description: "Run the Carnet MCP server over stdio"
1001
+ },
1002
+ run() {
1003
+ serve(processIo());
1004
+ }
1005
+ });
1006
+ const main = defineCommand({
1007
+ meta: {
1008
+ name: "rucher",
1009
+ version,
1010
+ description: "Status, journal and sessions of your coding agents (Claude Code today)"
1011
+ },
1012
+ subCommands: {
1013
+ init: initCommand,
1014
+ status: statusCommand,
1015
+ log: logCommand,
1016
+ sessions: sessionsCommand,
1017
+ resume: resumeCommand,
1018
+ group: groupCommand,
1019
+ groups: groupsCommand,
1020
+ hook: hookCommand,
1021
+ serve: serveCommand
1022
+ }
1023
+ });
1024
+ // src/cli.ts
1025
+ await setupCompletion(main);
1026
+ runMain(main);
1027
+ export {};
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@rucher/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "rucher: status, journal and sessions of your Claude Code sub-agents, from the terminal",
5
+ "keywords": [
6
+ "agents",
7
+ "claude-code",
8
+ "cli",
9
+ "multi-agent",
10
+ "status"
11
+ ],
12
+ "homepage": "https://github.com/Florian-Varrin/Rucher/tree/master/packages/cli#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Florian-Varrin/Rucher/issues"
15
+ },
16
+ "license": "MIT",
17
+ "author": "Florian Varrin",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Florian-Varrin/Rucher.git",
21
+ "directory": "packages/cli"
22
+ },
23
+ "bin": {
24
+ "rucher": "./dist/cli.mjs"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "type": "module",
32
+ "sideEffects": false,
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "@bomb.sh/tab": "0.0.22",
38
+ "@rucher/carnet": "0.1.0",
39
+ "citty": "^0.2.2"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^24",
43
+ "typescript": "^7.0.2",
44
+ "vite-plus": "1.4.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=24"
48
+ },
49
+ "scripts": {
50
+ "build": "vp pack",
51
+ "dev": "vp pack --watch",
52
+ "test": "vp test",
53
+ "check": "vp check"
54
+ }
6
55
  }