Skip to content

Commit 77ef56f

Browse files
committed
feat(commands): /export and /doctor
/export renders the conversation as markdown to clipboard or file (tool calls summarized, reminders stripped). /doctor one-screens the install health: node version, auth + expiry, config/mcp JSON validity, live MCP status, search keys, storage writability — so support requests can start with a paste.
1 parent 0939c80 commit 77ef56f

3 files changed

Lines changed: 228 additions & 0 deletions

File tree

src/commands/builtins/doctor.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { accessSync, constants, existsSync, readFileSync } from "node:fs";
2+
import { homedir } from "node:os";
3+
import { join } from "node:path";
4+
import { CredentialsStore } from "../../auth/credentials.js";
5+
import { VERSION } from "../../version.js";
6+
import type { Command } from "../types.js";
7+
8+
/**
9+
* /doctor — diagnose the install: runtime, credentials, config files,
10+
* MCP servers, search keys, storage. Each check is one ✓/✗/– line so a
11+
* support request can start with a paste of this output.
12+
*/
13+
export const doctor: Command = {
14+
name: "doctor",
15+
description: "Diagnose the installation: runtime, auth, config, MCP, storage.",
16+
handler: (_args, ctx) => {
17+
const lines: string[] = [`codebase ${VERSION} · doctor`];
18+
const home = join(homedir(), ".codebase");
19+
20+
// Runtime
21+
const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
22+
lines.push(check(major >= 20, `node ${process.versions.node}`, "node ≥ 20 required"));
23+
24+
// Credentials
25+
const credStore = new CredentialsStore();
26+
const creds = credStore.load();
27+
if (!creds) {
28+
lines.push(
29+
info(
30+
credStore.exists()
31+
? "credentials file present but unreadable/invalid"
32+
: "not signed in (env-key or BYOK mode)",
33+
),
34+
);
35+
} else if (credStore.isExpired(creds)) {
36+
lines.push(
37+
check(
38+
false,
39+
"",
40+
`credentials expired${creds.refreshToken ? " (will auto-refresh on next call)" : " — run codebase auth login"}`,
41+
),
42+
);
43+
} else {
44+
const until = creds.expiresAt ? ` until ${new Date(creds.expiresAt).toLocaleString()}` : "";
45+
lines.push(check(true, `signed in (${creds.source})${until}`, ""));
46+
}
47+
48+
// Model resolution for this session
49+
lines.push(
50+
check(
51+
true,
52+
`model: ${ctx.state.model.name} (${ctx.state.model.provider}/${ctx.state.model.id}) via ${ctx.bundle.source}`,
53+
"",
54+
),
55+
);
56+
57+
// Config files parse
58+
for (const path of [join(home, "config.json"), join(ctx.bundle.toolContext.cwd, ".codebase", "config.json")]) {
59+
if (!existsSync(path)) continue;
60+
lines.push(check(parses(path), `config ${path}`, `config ${path} is not valid JSON`));
61+
}
62+
for (const path of [join(home, "mcp.json"), join(ctx.bundle.toolContext.cwd, ".codebase", "mcp.json")]) {
63+
if (!existsSync(path)) continue;
64+
lines.push(check(parses(path), `mcp config ${path}`, `mcp config ${path} is not valid JSON`));
65+
}
66+
67+
// MCP server status (live)
68+
for (const s of ctx.bundle.mcp.status()) {
69+
lines.push(check(s.connected, `mcp ${s.name}: ${s.toolCount} tools`, `mcp ${s.name}: ${s.error ?? "failed"}`));
70+
}
71+
72+
// Web search keys
73+
const hasSearch = Boolean(process.env.TAVILY_API_KEY || process.env.BRAVE_API_KEY || process.env.SEARXNG_URL);
74+
lines.push(
75+
hasSearch
76+
? check(true, "web_search configured", "")
77+
: info("web_search unconfigured — set TAVILY_API_KEY, BRAVE_API_KEY, or SEARXNG_URL to enable"),
78+
);
79+
80+
// Storage writable
81+
lines.push(
82+
check(writable(home), `${home} writable`, `${home} is not writable — sessions/credentials can't persist`),
83+
);
84+
85+
// Sessions + skills + agents on disk
86+
lines.push(info(`sessions for this directory: ${ctx.bundle.sessions.list().length}`));
87+
const subagents = ctx.bundle.toolContext.subagentTypes ?? [];
88+
lines.push(info(`subagent types: ${subagents.map((t) => t.name).join(", ") || "none"}`));
89+
90+
ctx.emit(lines.join("\n"));
91+
return { handled: true };
92+
},
93+
};
94+
95+
function check(ok: boolean, okText: string, failText: string): string {
96+
return ok ? ` ✓ ${okText}` : ` ✗ ${failText}`;
97+
}
98+
99+
function info(text: string): string {
100+
return ` – ${text}`;
101+
}
102+
103+
function parses(path: string): boolean {
104+
try {
105+
JSON.parse(readFileSync(path, "utf8"));
106+
return true;
107+
} catch {
108+
return false;
109+
}
110+
}
111+
112+
function writable(dir: string): boolean {
113+
try {
114+
accessSync(dir, constants.W_OK);
115+
return true;
116+
} catch {
117+
return false;
118+
}
119+
}

src/commands/builtins/export.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { writeFileSync } from "node:fs";
2+
import { isAbsolute, resolve } from "node:path";
3+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
4+
import { copyToClipboard } from "../../clipboard/copy.js";
5+
import type { Command } from "../types.js";
6+
7+
/**
8+
* /export — render the conversation as markdown. No args copies it to
9+
* the clipboard; with a path it writes a file. Tool calls are summarized
10+
* as one-liners; tool results are omitted (they're huge and rarely what
11+
* a shared transcript needs).
12+
*/
13+
export const exportCmd: Command = {
14+
name: "export",
15+
description: "Export the conversation as markdown. /export = clipboard; /export <path> = file.",
16+
handler: async (args, ctx) => {
17+
if (ctx.state.messages.length === 0) {
18+
ctx.emit("nothing to export — no messages yet.");
19+
return { handled: true };
20+
}
21+
const markdown = renderTranscript(ctx.state.messages, ctx.state.model.name);
22+
23+
const target = args.trim();
24+
if (target) {
25+
const path = isAbsolute(target) ? target : resolve(ctx.bundle.toolContext.cwd, target);
26+
try {
27+
writeFileSync(path, markdown, "utf8");
28+
ctx.emit(`exported ${ctx.state.messages.length} messages to ${path}`);
29+
} catch (err) {
30+
ctx.emit(`export failed: ${err instanceof Error ? err.message : String(err)}`);
31+
}
32+
return { handled: true };
33+
}
34+
35+
try {
36+
const result = await copyToClipboard(markdown);
37+
const note = result.truncated
38+
? ` (truncated to ${result.bytes} bytes — use /export <path> for the full transcript)`
39+
: "";
40+
ctx.emit(`exported ${ctx.state.messages.length} messages to the clipboard via ${result.method}${note}.`);
41+
} catch (err) {
42+
ctx.emit(
43+
`clipboard unavailable (${err instanceof Error ? err.message : String(err)}) — use /export <path> instead.`,
44+
);
45+
}
46+
return { handled: true };
47+
},
48+
};
49+
50+
function renderTranscript(messages: readonly AgentMessage[], modelName: string): string {
51+
const lines: string[] = [
52+
`# codebase session`,
53+
"",
54+
`Model: ${modelName} · Exported: ${new Date().toISOString()}`,
55+
"",
56+
];
57+
for (const m of messages) {
58+
if (m.role === "user") {
59+
const text = extractText(m.content);
60+
if (!text) continue; // tool results / reminders aren't part of the dialogue
61+
lines.push("## User", "", text, "");
62+
} else if (m.role === "assistant") {
63+
const text = extractText(m.content);
64+
const calls = extractToolCalls(m.content);
65+
if (!text && calls.length === 0) continue;
66+
lines.push("## Assistant", "");
67+
if (text) lines.push(text, "");
68+
for (const c of calls) lines.push(`> 🔧 ${c}`);
69+
if (calls.length > 0) lines.push("");
70+
}
71+
}
72+
return lines.join("\n");
73+
}
74+
75+
function extractText(content: AgentMessage["content"]): string {
76+
if (typeof content === "string") {
77+
return stripReminders(content);
78+
}
79+
if (!Array.isArray(content)) return "";
80+
return stripReminders(
81+
content
82+
.filter(
83+
(b): b is { type: "text"; text: string } =>
84+
b.type === "text" && typeof (b as { text?: unknown }).text === "string",
85+
)
86+
.map((b) => b.text)
87+
.join("\n"),
88+
);
89+
}
90+
91+
function extractToolCalls(content: AgentMessage["content"]): string[] {
92+
if (!Array.isArray(content)) return [];
93+
const out: string[] = [];
94+
for (const block of content) {
95+
if (block.type !== "toolCall") continue;
96+
const call = block as { name?: string; arguments?: Record<string, unknown> };
97+
const arg = call.arguments?.path ?? call.arguments?.command ?? call.arguments?.task ?? "";
98+
out.push(`${call.name ?? "tool"}${arg ? ` — ${String(arg).slice(0, 80)}` : ""}`);
99+
}
100+
return out;
101+
}
102+
103+
function stripReminders(text: string): string {
104+
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").trim();
105+
}

src/commands/builtins/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { agents } from "./agents.js";
33
import { login, logout } from "./auth.js";
44
import { copy } from "./copy.js";
55
import { cost } from "./cost.js";
6+
import { doctor } from "./doctor.js";
7+
import { exportCmd } from "./export.js";
68
import { context, debug, help, pwd, whoami } from "./info.js";
79
import { init } from "./init.js";
810
import { mcp } from "./mcp.js";
@@ -27,6 +29,8 @@ export const BUILTIN_COMMANDS: readonly Command[] = [
2729
outputStyleCmd,
2830
whoami,
2931
copy,
32+
exportCmd,
33+
doctor,
3034
diff,
3135
commit,
3236
review,

0 commit comments

Comments
 (0)