Skip to content

Commit 226bd89

Browse files
committed
refactor(commands): split 732-LOC builtins.ts into per-category modules
No behavior change; this is the audit's J item — the monolith was the worst seam in the codebase, holding 22 commands as one object literal with no way to unit-test command logic in isolation. Split by topic: info.ts help, whoami, pwd, debug, context session.ts clear, fresh, compact, session, resume, redo, exit cost.ts cost (+ padNum) model.ts model, models (+ MODEL_ALIASES, parseModelSpec) copy.ts copy (+ resolveCopyTarget helpers) scm.ts diff, commit, review memory.ts memory auth.ts login, logout init.ts init projects.ts projects index.ts re-exports BUILTIN_COMMANDS in original order Every file under 150 LOC. Helpers colocated with the command that uses them; no shared module needed. App.tsx import updated. 741 tests still pass.
1 parent c12b68c commit 226bd89

13 files changed

Lines changed: 744 additions & 723 deletions

File tree

src/commands/builtins.ts

Lines changed: 0 additions & 722 deletions
This file was deleted.

src/commands/builtins/auth.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { CredentialsStore } from "../../auth/credentials.js";
2+
import type { Command } from "../types.js";
3+
4+
// ─── auth / session ───────────────────────────────────────────────────
5+
6+
export const login: Command = {
7+
name: "login",
8+
description: "Sign in via codebase.design OAuth (run from a fresh terminal: `codebase auth login`).",
9+
handler: (_args, ctx) => {
10+
ctx.emit(
11+
"to sign in, exit (Ctrl-C) and run:\n codebase auth login\n\n" +
12+
"that opens your browser to codebase.design and persists tokens to ~/.codebase/credentials.json. " +
13+
"after sign-in, restart codebase to use the new credentials.",
14+
);
15+
return { handled: true };
16+
},
17+
};
18+
19+
export const logout: Command = {
20+
name: "logout",
21+
description: "Clear saved credentials. Restart to take effect.",
22+
mutates: true,
23+
handler: (_args, ctx) => {
24+
const store = new CredentialsStore();
25+
const cleared = store.clear();
26+
if (cleared) {
27+
ctx.emit("cleared ~/.codebase/credentials.json. Restart codebase to use a different provider/sign-in.");
28+
} else {
29+
ctx.emit("no saved credentials to clear.");
30+
}
31+
return { handled: true };
32+
},
33+
};

src/commands/builtins/copy.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
2+
import { copyToClipboard, extractLastCodeBlock } from "../../clipboard/copy.js";
3+
import type { Command } from "../types.js";
4+
5+
export const copy: Command = {
6+
name: "copy",
7+
description:
8+
"Copy text to the system clipboard. /copy = last assistant message; /copy code = last code block; /copy <N> = message N.",
9+
handler: async (args, ctx) => {
10+
const messages = ctx.state.messages;
11+
const target = resolveCopyTarget(args, messages);
12+
if (target === null) {
13+
ctx.emit("no assistant messages yet to copy.");
14+
return { handled: true };
15+
}
16+
if (!target.text) {
17+
ctx.emit("could not find text to copy. Try /copy, /copy code, or /copy <N>.");
18+
return { handled: true };
19+
}
20+
try {
21+
const result = await copyToClipboard(target.text);
22+
const truncatedNote = result.truncated ? `, truncated to ${result.bytes}` : ` (${result.bytes} bytes)`;
23+
ctx.emit(`copied ${target.label} via ${result.method}${truncatedNote}`);
24+
} catch (err) {
25+
ctx.emit(`/copy failed: ${err instanceof Error ? err.message : String(err)}`);
26+
}
27+
return { handled: true };
28+
},
29+
};
30+
31+
interface CopyTarget {
32+
text: string;
33+
label: string;
34+
}
35+
36+
function resolveCopyTarget(args: string, messages: readonly AgentMessage[]): CopyTarget | null {
37+
const trimmed = args.trim().toLowerCase();
38+
if (trimmed === "code") {
39+
const last = lastAssistantText(messages);
40+
if (!last) return null;
41+
const block = extractLastCodeBlock(last);
42+
if (!block) return { text: "", label: "" };
43+
return { text: block, label: "last code block" };
44+
}
45+
if (/^\d+$/.test(trimmed)) {
46+
const idx = Number.parseInt(trimmed, 10) - 1;
47+
if (idx < 0 || idx >= messages.length) return { text: "", label: "" };
48+
const msg = messages[idx];
49+
const text = extractText(msg);
50+
if (!text) return { text: "", label: "" };
51+
return { text, label: `message ${idx + 1}` };
52+
}
53+
const last = lastAssistantText(messages);
54+
if (!last) return null;
55+
return { text: last, label: "last assistant message" };
56+
}
57+
58+
function lastAssistantText(messages: readonly AgentMessage[]): string {
59+
for (let i = messages.length - 1; i >= 0; i--) {
60+
const m = messages[i];
61+
if (m.role !== "assistant") continue;
62+
const text = extractText(m);
63+
if (text) return text;
64+
}
65+
return "";
66+
}
67+
68+
function extractText(message: AgentMessage): string {
69+
if (typeof message.content === "string") return message.content;
70+
if (!Array.isArray(message.content)) return "";
71+
const parts: string[] = [];
72+
for (const block of message.content as Array<{ type: string; text?: string }>) {
73+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
74+
}
75+
return parts.join("");
76+
}

src/commands/builtins/cost.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { Command } from "../types.js";
2+
3+
export const cost: Command = {
4+
name: "cost",
5+
description: "Detailed token + cost breakdown for the current session, including cache hit rate.",
6+
handler: (_args, ctx) => {
7+
const { state, bundle } = ctx;
8+
const u = state.usage;
9+
const turns = state.messages.filter((m) => m.role === "assistant").length;
10+
const promptTokens = u.input + u.cacheRead;
11+
const hitRate = promptTokens > 0 ? `${((u.cacheRead / promptTokens) * 100).toFixed(0)}%` : "—";
12+
const turnAvg = turns > 0 ? u.cost.total / turns : 0;
13+
const proxyNote = bundle.source === "proxy" ? " (proxied via codebase.foundation)" : "";
14+
15+
const lines = [
16+
`Session cost: $${u.cost.total.toFixed(4)}${proxyNote}`,
17+
"",
18+
"Tokens:",
19+
` Input ${padNum(u.input, 8)} ($${u.cost.input.toFixed(4)})`,
20+
` Output ${padNum(u.output, 8)} ($${u.cost.output.toFixed(4)})`,
21+
` Cache read ${padNum(u.cacheRead, 8)} ($${u.cost.cacheRead.toFixed(4)}) ${hitRate} hit rate`,
22+
` Cache write ${padNum(u.cacheWrite, 8)} ($${u.cost.cacheWrite.toFixed(4)})`,
23+
"",
24+
turns > 0
25+
? `Turn average: $${turnAvg.toFixed(4)} (${turns} turn${turns === 1 ? "" : "s"})`
26+
: "No assistant turns yet.",
27+
];
28+
ctx.emit(lines.join("\n"));
29+
return { handled: true };
30+
},
31+
};
32+
33+
function padNum(n: number, width: number): string {
34+
return n.toLocaleString().padStart(width, " ");
35+
}

src/commands/builtins/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { Command } from "../types.js";
2+
import { login, logout } from "./auth.js";
3+
import { copy } from "./copy.js";
4+
import { cost } from "./cost.js";
5+
import { context, debug, help, pwd, whoami } from "./info.js";
6+
import { init } from "./init.js";
7+
import { memory } from "./memory.js";
8+
import { modelCmd, modelsCmd } from "./model.js";
9+
import { projects } from "./projects.js";
10+
import { commit, diff, review } from "./scm.js";
11+
import { clear, compact, exit, fresh, redo, resume, session } from "./session.js";
12+
13+
export const BUILTIN_COMMANDS: readonly Command[] = [
14+
help,
15+
clear,
16+
fresh,
17+
compact,
18+
session,
19+
cost,
20+
modelCmd,
21+
modelsCmd,
22+
whoami,
23+
copy,
24+
diff,
25+
commit,
26+
review,
27+
memory,
28+
context,
29+
login,
30+
logout,
31+
resume,
32+
init,
33+
projects,
34+
pwd,
35+
redo,
36+
debug,
37+
exit,
38+
];

src/commands/builtins/info.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { copyToClipboard } from "../../clipboard/copy.js";
2+
import type { Command } from "../types.js";
3+
4+
export const help: Command = {
5+
name: "help",
6+
description: "List available slash commands and keyboard shortcuts.",
7+
handler: (_args, ctx) => {
8+
const lines: string[] = [];
9+
lines.push("Keyboard shortcuts:");
10+
lines.push(" / slash-command autocomplete (Tab to complete, ↑↓ to choose)");
11+
lines.push(" !cmd run a shell command directly (e.g. !git status)");
12+
lines.push(" @path inline a file's contents into the next prompt");
13+
lines.push(" ↑/↓ recall prior prompts (at line start)");
14+
lines.push(" \\<Enter> insert a newline instead of submitting");
15+
lines.push(" Ctrl-C cancel turn (busy) · twice to exit (idle)");
16+
lines.push("");
17+
lines.push("Slash commands:");
18+
for (const cmd of ctx.registry.list()) {
19+
const aliasPart = cmd.aliases?.length ? ` (${cmd.aliases.map((a) => `/${a}`).join(", ")})` : "";
20+
lines.push(` /${cmd.name}${aliasPart}${cmd.description}`);
21+
}
22+
ctx.emit(lines.join("\n"));
23+
return { handled: true };
24+
},
25+
};
26+
27+
export const whoami: Command = {
28+
name: "whoami",
29+
aliases: ["status"],
30+
description: "Show current sign-in status.",
31+
handler: (_args, ctx) => {
32+
const source = ctx.bundle.source;
33+
ctx.emit(
34+
source === "proxy"
35+
? "signed in via codebase.foundation (inference proxy)"
36+
: source === "explicit"
37+
? "using model selected via CODEBASE_PROVIDER + CODEBASE_MODEL"
38+
: "using auto-detected provider from env",
39+
);
40+
return { handled: true };
41+
},
42+
};
43+
44+
export const pwd: Command = {
45+
name: "pwd",
46+
aliases: ["cwd"],
47+
description: "Print the current working directory and copy it to the clipboard.",
48+
handler: async (_args, ctx) => {
49+
const cwd = ctx.bundle.toolContext.cwd;
50+
const copied = await copyToClipboard(cwd).catch(() => false);
51+
ctx.emit(copied ? `${cwd}\n(copied to clipboard)` : cwd);
52+
return { handled: true };
53+
},
54+
};
55+
56+
/**
57+
* Diagnostic for "the model isn't remembering what I told it earlier."
58+
* Compares the UI's display state (what the user sees in the transcript)
59+
* with the agent's internal _state.messages (what actually ships to the
60+
* model on the next turn). If those diverge, that's the bug. If they
61+
* match but the model still acts amnesiac, the issue is in the wire
62+
* call — pi-ai's openai-completions builder, the proxy, or the upstream.
63+
*/
64+
export const debug: Command = {
65+
name: "debug",
66+
description: "Inspect internal agent state — message count, token estimate, last few roles.",
67+
handler: (_args, ctx) => {
68+
const display = ctx.state.messages;
69+
const internal = ctx.bundle.agent.state.messages;
70+
const rolesTail = (msgs: readonly { role: string }[], n: number) =>
71+
msgs
72+
.slice(-n)
73+
.map((m) => m.role)
74+
.join(" → ") || "(empty)";
75+
const u = ctx.state.usage;
76+
const used = u.input + u.cacheRead;
77+
const compactAt = ctx.bundle.compaction.threshold();
78+
const divergent = display.length !== internal.length;
79+
const lines = [
80+
"Internal state inspection:",
81+
"",
82+
` Display messages (UI): ${display.length}`,
83+
` Agent state messages: ${internal.length}${divergent ? " ← MISMATCH!" : ""}`,
84+
"",
85+
` Last 5 display roles: ${rolesTail(display, 5)}`,
86+
` Last 5 agent state roles: ${rolesTail(internal, 5)}`,
87+
"",
88+
` Estimated tokens used: ${used.toLocaleString()}`,
89+
` Compaction triggers at: ${compactAt.toLocaleString()}`,
90+
` Streaming in progress: ${ctx.state.streaming ? "yes" : "no"}`,
91+
"",
92+
divergent
93+
? "Mismatch means the agent and the UI disagree about what's been said. " +
94+
"That's the source of 'the model forgot' — the next turn ships internal " +
95+
"messages, not display messages. Report this with a `codebase --debug-input` " +
96+
"transcript so we can see how it happened."
97+
: "Display and agent state match. If the model is still acting amnesiac, the " +
98+
"context is leaving the CLI correctly but something on the wire is dropping " +
99+
"it — capture with OPENAI_LOG=debug codebase to see the raw HTTP request body.",
100+
];
101+
ctx.emit(lines.join("\n"));
102+
return { handled: true };
103+
},
104+
};
105+
106+
export const context: Command = {
107+
name: "context",
108+
description: "Visualize how full the context window is right now.",
109+
handler: (_args, ctx) => {
110+
const u = ctx.state.usage;
111+
const used = u.input + u.cacheRead;
112+
// Anthropic Claude Sonnet 4.x is 200K input by default; Opus is 200K too.
113+
// pi-ai's model.contextLength would be the source of truth; for now use
114+
// 200K as the floor and clamp.
115+
const window = (ctx.state.model as { contextLength?: number }).contextLength ?? 200_000;
116+
const ratio = Math.min(1, used / window);
117+
const compactAt = ctx.bundle.compaction.threshold();
118+
const barWidth = 40;
119+
const filled = Math.round(ratio * barWidth);
120+
const compactMark = Math.round((compactAt / window) * barWidth);
121+
let bar = "";
122+
for (let i = 0; i < barWidth; i++) {
123+
if (i < filled) bar += "█";
124+
else if (i === compactMark) bar += "│";
125+
else bar += "░";
126+
}
127+
const pct = `${(ratio * 100).toFixed(1)}%`;
128+
const compactPct = `${((compactAt / window) * 100).toFixed(0)}%`;
129+
ctx.emit(
130+
`context window:\n ${bar}\n ${used.toLocaleString()} / ${window.toLocaleString()} tokens (${pct})\n │ = compaction triggers at ${compactAt.toLocaleString()} (${compactPct})`,
131+
);
132+
return { handled: true };
133+
},
134+
};

src/commands/builtins/init.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { existsSync, writeFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import type { Command } from "../types.js";
4+
5+
// ─── project + extensions ─────────────────────────────────────────────
6+
7+
export const init: Command = {
8+
name: "init",
9+
description: "Drop a starter CLAUDE.md at the project root with instructions for the agent.",
10+
mutates: true,
11+
handler: (_args, ctx) => {
12+
const cwd = ctx.bundle.toolContext.cwd;
13+
const target = join(cwd, "CLAUDE.md");
14+
if (existsSync(target)) {
15+
ctx.emit(`CLAUDE.md already exists at ${target}. Edit it directly; it's auto-injected on session start.`);
16+
return { handled: true };
17+
}
18+
const template = [
19+
"# Project Instructions",
20+
"",
21+
"This file is auto-loaded by `codebase` on session start.",
22+
"Use it to capture project-specific rules and context the agent should follow.",
23+
"",
24+
"## What this project is",
25+
"",
26+
"Replace this with a one-paragraph summary of what the codebase does, the",
27+
"primary language(s), and the user-visible product.",
28+
"",
29+
"## Commands",
30+
"",
31+
"- Build: `…`",
32+
"- Test: `…`",
33+
"- Lint: `…`",
34+
"",
35+
"## Coding conventions",
36+
"",
37+
"- …",
38+
"",
39+
"## Don'ts",
40+
"",
41+
"- …",
42+
"",
43+
].join("\n");
44+
writeFileSync(target, template);
45+
ctx.emit(`wrote ${target}. Edit it to capture project-specific guidance for the agent.`);
46+
return { handled: true };
47+
},
48+
};

src/commands/builtins/memory.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Command } from "../types.js";
2+
3+
// ─── memory + context ─────────────────────────────────────────────────
4+
5+
export const memory: Command = {
6+
name: "memory",
7+
description: "Show the MEMORY.md index of saved cross-session memories for this project.",
8+
handler: (_args, ctx) => {
9+
const index = ctx.bundle.memory.index();
10+
if (!index.trim()) {
11+
ctx.emit("no memories saved yet. The agent can write them via the save_memory tool.");
12+
return { handled: true };
13+
}
14+
ctx.emit(index);
15+
return { handled: true };
16+
},
17+
};

0 commit comments

Comments
 (0)