Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ something that does nothing.

## Footguns

### `bun run test` does not run e2e, and CI does

`test` is `bun test lib commands packages scripts`; the e2e suite is a separate
script (`test:e2e`, and `test:all` for both) because it needs
`--preload ./e2e/setup.ts`. So a green local `bun run test` is not the same
gate CI applies, and the difference is invisible in the output.

It matters most for anything asserted verbatim end to end: the chat delivery
frame, a CLI's `--json` envelope, a usage string. Those have exact-string
assertions in `e2e/tests/` that no unit suite covers, so a deliberate format
change reads as fully green locally and fails in CI. Run `bun run test:all`,
or at least the one e2e file covering the surface, before calling a change
verified. Claiming verification from a suite that never exercised the changed
contract is the actual defect here, not the red CI.

### Module registry

When adding a new command module referenced by `cli.ts` (any file with a `module:` entry in the command tree), you **must** also register it in `lib/module-registry.ts`. `bun build --compile` cannot resolve dynamic `import()` with runtime-constructed paths, so the compiled binary relies entirely on this registry to discover and bundle every command module. Running from source (`bun run cli.ts`) works fine without the registry entry because the dynamic import fallback succeeds, so you won't catch this locally -- it only breaks in the distributed binary.
Expand Down
44 changes: 38 additions & 6 deletions commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
chatLeave,
chatMark,
chatMessages,
chatAck,
chatPost,
chatRead,
chatRooms,
Expand Down Expand Up @@ -667,29 +668,58 @@ async function runPost(args: string[]): Promise<void> {
// so a bare args.slice(1).join(" ") would splice the flag back into the post.
const rest = positionals(args);
const room = rest[0];
if (!room) fail("usage: rt chat post <room> <text | <<'EOF'> [--file <path>] [--as-is]");
if (!room) fail("usage: rt chat post <room> <text | <<'EOF'> [--file <path>] [--as-is] [--quiet]");
requireValidName("room", room);
const body = await resolveBody(rest.slice(1), args, "usage: rt chat post <room> <text | <<'EOF'> [--file <path>] [--as-is]");
const body = await resolveBody(rest.slice(1), args, "usage: rt chat post <room> <text | <<'EOF'> [--file <path>] [--as-is] [--quiet]");
requireReadable(body, args);

const handle = resolveHandle(args);
requireValidName("handle", handle);

const res = await chatPost({ room, handle, body });
const quiet = args.includes("--quiet");
const res = await chatPost({ room, handle, body, quiet }, sockOpts(args));
const data = unwrap(res, "post");
// Output stays to a line or two: who was actually woken (a post that
// delivered to nobody used to be silent, indistinguishable from success
// at the prompt that caused it), plus the viewer link when configured.
const url = chatViewerUrl(readChatViewerUrlSetting(), room, data.id);
if (args.includes("--json")) {
console.log(JSON.stringify({ ok: true, id: data.id, recipients: data.recipients, url: url ?? null }));
console.log(JSON.stringify({ ok: true, id: data.id, quiet, recipients: data.recipients, url: url ?? null }));
return;
}
if (data.recipients.length > 0) console.log(`delivered to ${data.recipients.join(", ")}`);
// A quiet post's empty recipient list is the point, not the "woke nobody"
// failure the line below reports.
if (quiet) console.log("posted quietly (on the record, unread for every member, nobody woken)");
else if (data.recipients.length > 0) console.log(`delivered to ${data.recipients.join(", ")}`);
else console.log("delivered to nobody (no member was woken; rt chat who <room> shows who is listening)");
if (url) console.log(`posted → ${url}`);
}

/**
* The counterpart to a room post: acknowledging costs the author one wake and
* every other member nothing, where a posted "ack" wakes the whole room. The
* id comes from the delivered line (`[#room] handle #<id>: body`).
*/
async function runAck(args: string[]): Promise<void> {
const rest = positionals(args);
const raw = rest[0];
if (!raw) fail("usage: rt chat ack <messageId>");
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) fail(`not a message id: ${raw} (the delivered line shows it as "#<id>")`);

const handle = resolveHandle(args);
requireValidName("handle", handle);

const res = await chatAck({ id, handle }, sockOpts(args));
const data = unwrap(res, "ack");
if (args.includes("--json")) {
console.log(JSON.stringify({ ok: true, id, author: data.author, room: data.room, already: data.already }));
return;
}
if (data.already) console.log(`already acked #${id} (${data.author} was not woken again)`);
else console.log(`acked #${id} → ${data.author}`);
}

async function runRead(args: string[]): Promise<void> {
const room = positional(args);
if (room) requireValidName("room", room);
Expand Down Expand Up @@ -1103,9 +1133,10 @@ async function runBack(args: string[]): Promise<void> {
// ─── dispatcher ────────────────────────────────────────────────────────────────

const USAGE =
"usage: rt chat <join|leave|archive|post|read|rooms|who|mark|prune|sign-in|sign-out|away|back|buddies|dm|invite> ...";
"usage: rt chat <join|leave|archive|post|read|ack|rooms|who|mark|prune|sign-in|sign-out|away|back|buddies|dm|invite> ...";

const VERBS: Record<string, (args: string[]) => Promise<void>> = {
ack: runAck,
join: runJoin,
leave: runLeave,
archive: runArchive,
Expand All @@ -1126,6 +1157,7 @@ const VERBS: Record<string, (args: string[]) => Promise<void>> = {

const VERB_HINTS: Record<string, string> = {
read: "show recent messages",
ack: "acknowledge one message, waking only its author",
post: "send a message to a room",
dm: "send a direct message to a handle",
rooms: "list rooms",
Expand Down
6 changes: 3 additions & 3 deletions e2e/tests/chat-inbox-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ describe("rt chat inbox delivery (e2e)", () => {
const frame = await waitForFrame(inbox.frames, (f) => frameContent(f).includes("hello from e2e"));
expect(frame.type).toBe("user");
expect(frameContent(frame)).toBe(
'<cross-session-message from-name="poster (#testroom)">\n[#testroom] poster: @recipient hello from e2e\n' +
`<cross-session-message from-name="poster (#testroom)">\n[#testroom] poster #${posted.id}: @recipient hello from e2e\n` +
'reply via rt chat post <room> "..." or rt chat dm <handle> "..." (never SendMessage; this arrived through rt chat)\n</cross-session-message>',
);
}, 30_000);
Expand Down Expand Up @@ -269,11 +269,11 @@ describe("rt chat inbox delivery (e2e)", () => {
await waitForFrame(inboxB.frames, (f) => frameContent(f).includes("You're signed in"));

await signIn(home, "sess-c", "c", "testroom");
await dm(home, "a", "secret for a", "sess-c");
const sent = await dm(home, "a", "secret for a", "sess-c");

const frame = await waitForFrame(inboxA.frames, (f) => frameContent(f).includes("secret for a"));
expect(frameContent(frame)).toBe(
'<cross-session-message from-name="c (dm)">\n[dm] c: secret for a\n' +
`<cross-session-message from-name="c (dm)">\n[dm] c #${sent.id}: secret for a\n` +
'reply via rt chat post <room> "..." or rt chat dm <handle> "..." (never SendMessage; this arrived through rt chat)\n</cross-session-message>',
);
// b is not a participant of this DM: nothing about it ever reaches b's inbox.
Expand Down
6 changes: 3 additions & 3 deletions lib/command-tree-def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,8 +798,8 @@ export const TREE: Record<string, CommandNode> = {
fn: "chat",
omitBehavior: "picker",
args: [
{ name: "Verb", type: "text", placeholder: "join | leave | archive | post | read | rooms | who | mark | prune | sign-in | sign-out | away | back | buddies | dm | invite", hint: "The chat action to run" },
{ name: "Room", type: "text", optional: true, placeholder: "build", hint: "Room name for join/leave/archive/post/read/who/mark; the target handle for dm; the pane id for invite; omit on read/rooms/who to span everything, and on prune/sign-in/sign-out/buddies/back/away, which take no room" },
{ name: "Verb", type: "text", placeholder: "join | leave | archive | post | read | ack | rooms | who | mark | prune | sign-in | sign-out | away | back | buddies | dm | invite", hint: "The chat action to run" },
{ name: "Room", type: "text", optional: true, placeholder: "build", hint: "Room name for join/leave/archive/post/read/who/mark; the target handle for dm; the pane id for invite; the message id for ack; omit on read/rooms/who to span everything, and on prune/sign-in/sign-out/buddies/back/away, which take no room" },
{ name: "Text", type: "text", optional: true, placeholder: "@handle message", hint: "A one-line message body (every word after the room/handle) — post, dm; leave it out and feed the body on stdin (a heredoc) so paragraphs and lists survive; away takes this directly, with no room before it" },
{ name: "As handle", flag: "--as", type: "text", placeholder: "repo-tools-main", hint: "Override the derived handle for this invocation; refused while signed in (sign out first)" },
{ name: "Wake on", flag: "--wake-on", type: "text", placeholder: "mention | all | none", hint: "For join: when this handle gets delivered a message (default mention)" },
Expand All @@ -816,7 +816,7 @@ export const TREE: Record<string, CommandNode> = {
{ name: "Pane", flag: "--pane", type: "text", placeholder: "w1:p1", hint: "For sign-in/sign-out: target this herdr pane's Claude session daemon-side (resolved via herdr), no CLAUDE_CODE_SESSION_ID needed" },
{ name: "Body file", flag: "--file", type: "text", placeholder: "post.md", hint: "For post/dm: read the body from a file instead of stdin or the text" },
{ name: "As is", flag: "--as-is", type: "boolean", default: false, hint: "For post/dm: post a long single-line body anyway (500+ characters with no line break is refused by default)" },
{ name: "Quiet", flag: "--quiet", type: "boolean", default: false, hint: "For sign-out: suppress output (the SessionEnd hook's flag)" },
{ name: "Quiet", flag: "--quiet", type: "boolean", default: false, hint: "For post: put the message on the room record without waking anyone (it stays unread and catches up in a later delivery); for sign-out: suppress output (the SessionEnd hook's flag)" },
{ name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Emit machine-readable JSON instead of the plain rendering (join/leave/archive/post/read/rooms/who/mark/prune/buddies/dm/away/back/sign-in/sign-out/invite)" },
],
},
Expand Down
Loading
Loading