Skip to content

Commit 11ad4b4

Browse files
committed
feat(output-styles): customizable response formatting via /output-style
Markdown files in ~/.codebase/output-styles/<name>.md (user) or <cwd>/.codebase/output-styles/ (project) reshape how the agent writes its answers — terse, explanatory, report-mode, whatever. The body is appended to the system prompt as a 'Response style' section. /output-style lists available styles, /output-style <id> activates one (persisted to config; the agent rebuilds in place so it takes effect immediately while keeping the conversation), /output-style off clears. Project styles override user styles on id clash. Mirrors Claude Code's outputStyles. Extracted the skills frontmatter parser into src/config/frontmatter.ts so both subsystems share it (local-loader.ts now imports it instead of its own copy). 12 new tests cover the loader (layering, empty-skip, case-insensitive resolve) and config persistence. Also drops three dead biome-ignore suppressions in cap-tool-result.
1 parent c3f594d commit 11ad4b4

13 files changed

Lines changed: 431 additions & 66 deletions

File tree

CLAUDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,27 @@ the system prompt:
189189
- `CODEX.md`
190190
- `.cursorrules`
191191

192+
## Output styles
193+
194+
Reshape how the agent writes its answers (terse / explanatory /
195+
report-mode / …) without touching the base prompt. Markdown files in
196+
`~/.codebase/output-styles/<name>.md` (user) or
197+
`<cwd>/.codebase/output-styles/<name>.md` (project, wins on id clash):
198+
199+
```markdown
200+
---
201+
name: Terse
202+
description: One-liners, no preamble.
203+
---
204+
Answer in as few words as possible. Skip restating the question.
205+
```
206+
207+
`/output-style` lists them, `/output-style <id>` activates one (the
208+
body is appended to the system prompt and the agent rebuilds in place),
209+
`/output-style off` clears it. The choice persists in
210+
`~/.codebase/config.json`. Same frontmatter parser as skills
211+
(`src/config/frontmatter.ts`).
212+
192213
## Direct dependencies
193214

194215
Runtime:

src/agent/agent.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CredentialsStore } from "../auth/credentials.js";
77
import { TokenManager } from "../auth/token-manager.js";
88
import { CompactionEngine } from "../compaction/engine.js";
99
import { CompactionMonitor } from "../compaction/monitor.js";
10+
import { getOutputStyle } from "../config/output-styles.js";
1011
import { ConfigStore } from "../config/store.js";
1112
import { DiagnosticsEngine, formatDiagnostics } from "../diagnostics/engine.js";
1213
import { GlueClient, resolveGlueModels } from "../glue/client.js";
@@ -226,7 +227,8 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
226227
// .cursorrules) gets pinned to the prompt so the agent sees the
227228
// project's conventions on every turn. Memory addendum is appended
228229
// after — it's the user's accumulated long-term notes.
229-
const fullSystemPrompt = systemPrompt + buildProjectFilesAddendum(cwd) + buildMemoryAddendum(memory);
230+
const fullSystemPrompt =
231+
systemPrompt + buildProjectFilesAddendum(cwd) + buildMemoryAddendum(memory) + buildOutputStyleAddendum(persistedConfig, cwd);
230232

231233
const agent = new Agent({
232234
initialState: {
@@ -471,6 +473,22 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
471473
};
472474
}
473475

476+
/**
477+
* Build the output-style addendum appended to the system prompt. When a
478+
* style is selected in config and resolvable from
479+
* ~/.codebase/output-styles or <cwd>/.codebase/output-styles, its body
480+
* is wrapped in a labeled section so the model treats it as formatting
481+
* guidance. Returns "" when no style is active or the named style is
482+
* missing (e.g. config points at a deleted file).
483+
*/
484+
function buildOutputStyleAddendum(config: ConfigStore, cwd: string): string {
485+
const id = config.outputStyle();
486+
if (!id) return "";
487+
const style = getOutputStyle(id, { cwd });
488+
if (!style) return "";
489+
return `\n\n# Response style: ${style.name}\n${style.body}`;
490+
}
491+
474492
/** Extract the trailing assistant text content from an array of messages. */
475493
function lastAssistantText(messages: AgentMessage[]): string | undefined {
476494
for (let i = messages.length - 1; i >= 0; i--) {

src/commands/builtins/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { context, debug, help, pwd, whoami } from "./info.js";
66
import { init } from "./init.js";
77
import { memory } from "./memory.js";
88
import { modelCmd, modelsCmd } from "./model.js";
9+
import { outputStyleCmd } from "./output-style.js";
910
import { projects } from "./projects.js";
1011
import { commit, diff, review } from "./scm.js";
1112
import { clear, compact, exit, fresh, redo, resume, session } from "./session.js";
@@ -19,6 +20,7 @@ export const BUILTIN_COMMANDS: readonly Command[] = [
1920
cost,
2021
modelCmd,
2122
modelsCmd,
23+
outputStyleCmd,
2224
whoami,
2325
copy,
2426
diff,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { loadOutputStyles } from "../../config/output-styles.js";
2+
import { ConfigStore } from "../../config/store.js";
3+
import type { Command } from "../types.js";
4+
5+
/**
6+
* /output-style — list, set, or clear the active response style.
7+
*
8+
* Styles are Markdown files in ~/.codebase/output-styles/ (or the
9+
* project's .codebase/output-styles/) whose body is appended to the
10+
* system prompt to reshape how the agent writes its answers.
11+
*
12+
* Setting a style persists it to config and rebuilds the agent so it
13+
* takes effect immediately while keeping the conversation. The rebuild
14+
* preserves the active model (via the persisted model preference, or
15+
* the proxy default).
16+
*/
17+
export const outputStyleCmd: Command = {
18+
name: "output-style",
19+
aliases: ["style"],
20+
description: "Show, set, or clear the response style (from ~/.codebase/output-styles/*.md).",
21+
handler: async (args, ctx) => {
22+
const arg = args.trim();
23+
const config = new ConfigStore({ cwd: ctx.bundle.toolContext.cwd });
24+
const styles = loadOutputStyles({ cwd: ctx.bundle.toolContext.cwd });
25+
const active = config.outputStyle();
26+
27+
// No args → list available styles + which is active.
28+
if (!arg) {
29+
if (styles.length === 0) {
30+
ctx.emit("No output styles found.");
31+
ctx.emit("Create one at ~/.codebase/output-styles/<name>.md with a Markdown body, e.g.:");
32+
ctx.emit(" ---");
33+
ctx.emit(" name: Terse");
34+
ctx.emit(" description: One-liners, no preamble.");
35+
ctx.emit(" ---");
36+
ctx.emit(" Answer in as few words as possible.");
37+
return { handled: true };
38+
}
39+
ctx.emit("Output styles (* = active):");
40+
for (const s of styles) {
41+
const marker = s.id === active ? "*" : " ";
42+
ctx.emit(` ${marker} ${s.id}${s.description ? ` · ${s.description}` : ""}`);
43+
}
44+
ctx.emit(active ? `Clear with /output-style off.` : "Set with /output-style <id>.");
45+
return { handled: true };
46+
}
47+
48+
// Clear.
49+
if (arg === "off" || arg === "none" || arg === "clear" || arg === "default") {
50+
if (!active) {
51+
ctx.emit("No output style is active.");
52+
return { handled: true };
53+
}
54+
config.setOutputStyle(null);
55+
await rebuild(ctx, config);
56+
ctx.emit("Output style cleared.");
57+
return { handled: true };
58+
}
59+
60+
// Set.
61+
const want = arg.toLowerCase();
62+
const match = styles.find((s) => s.id === want);
63+
if (!match) {
64+
ctx.emit(`No output style named "${arg}".`);
65+
if (styles.length > 0) ctx.emit(`Available: ${styles.map((s) => s.id).join(", ")}`);
66+
return { handled: true };
67+
}
68+
config.setOutputStyle(match.id);
69+
await rebuild(ctx, config);
70+
ctx.emit(`Output style set to "${match.id}"${match.description ? ` — ${match.description}` : ""}.`);
71+
return { handled: true };
72+
},
73+
};
74+
75+
/**
76+
* Rebuild the agent so the new system prompt (with/without the style)
77+
* takes effect now, preserving the conversation. Re-applies the model
78+
* the user was already on: the persisted preference if any, else the
79+
* proxy default (null).
80+
*/
81+
async function rebuild(ctx: Parameters<typeof outputStyleCmd.handler>[1], config: ConfigStore): Promise<void> {
82+
const preferred = config.preferredModel();
83+
const spec = preferred?.modelId ? { provider: preferred.provider, modelId: preferred.modelId } : null;
84+
await ctx.switchModel(spec);
85+
}

src/config/frontmatter.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Minimal markdown-with-frontmatter parser, shared by skills and
3+
* output styles. Splits a `.md` file into its YAML-ish header and body.
4+
*
5+
* The accepted YAML subset is deliberately boring so users can
6+
* hand-write headers without a real YAML engine: each non-blank line is
7+
* `key: value`, `[a, b, c]` becomes a string list, quoted strings honor
8+
* `"…"`/`'…'`. No nested objects, multi-line values, or anchors.
9+
*/
10+
11+
export interface ParsedMarkdown {
12+
frontmatter: Record<string, string | readonly string[]>;
13+
body: string;
14+
}
15+
16+
/**
17+
* Split a markdown file into frontmatter + body. Tolerates files with
18+
* no frontmatter (whole file becomes the body) and preserves the body's
19+
* leading whitespace so prompt authors who want blank lines get them.
20+
*/
21+
export function parseMarkdownWithFrontmatter(raw: string): ParsedMarkdown {
22+
const FENCE = "---";
23+
const normalized = raw.replace(/^/, ""); // strip BOM
24+
if (!normalized.startsWith(FENCE)) {
25+
return { frontmatter: {}, body: normalized };
26+
}
27+
const closeIdx = normalized.indexOf(`\n${FENCE}`, FENCE.length);
28+
if (closeIdx === -1) {
29+
return { frontmatter: {}, body: normalized };
30+
}
31+
const fmText = normalized.slice(FENCE.length, closeIdx).trim();
32+
const bodyStart = closeIdx + 1 + FENCE.length;
33+
const body = normalized.slice(bodyStart).replace(/^\r?\n/, "");
34+
return { frontmatter: parseFrontmatter(fmText), body };
35+
}
36+
37+
export function parseFrontmatter(text: string): Record<string, string | readonly string[]> {
38+
const out: Record<string, string | readonly string[]> = {};
39+
for (const rawLine of text.split(/\r?\n/)) {
40+
const line = rawLine.trim();
41+
if (!line || line.startsWith("#")) continue;
42+
const colon = line.indexOf(":");
43+
if (colon === -1) continue;
44+
const key = line.slice(0, colon).trim();
45+
const value = line.slice(colon + 1).trim();
46+
if (!key) continue;
47+
if (value.startsWith("[") && value.endsWith("]")) {
48+
out[key] = value
49+
.slice(1, -1)
50+
.split(",")
51+
.map((s) => unquote(s.trim()))
52+
.filter((s) => s.length > 0);
53+
} else {
54+
out[key] = unquote(value);
55+
}
56+
}
57+
return out;
58+
}
59+
60+
export function unquote(s: string): string {
61+
if (s.length >= 2 && (s.startsWith('"') || s.startsWith("'"))) {
62+
const q = s[0];
63+
if (s.endsWith(q)) return s.slice(1, -1);
64+
}
65+
return s;
66+
}
67+
68+
/** Coerce a frontmatter value to a string, or undefined if it's a list. */
69+
export function strOrUndef(value: string | readonly string[] | undefined): string | undefined {
70+
return typeof value === "string" ? value : undefined;
71+
}
72+
73+
/** Coerce a frontmatter value to a string list, or undefined if it's a scalar. */
74+
export function strArrOrUndef(value: string | readonly string[] | undefined): readonly string[] | undefined {
75+
return Array.isArray(value) ? value : undefined;
76+
}

src/config/output-styles.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { getOutputStyle, loadOutputStyles } from "./output-styles.js";
6+
7+
describe("loadOutputStyles", () => {
8+
let home: string;
9+
let cwd: string;
10+
11+
beforeEach(() => {
12+
home = mkdtempSync(join(tmpdir(), "os-home-"));
13+
cwd = mkdtempSync(join(tmpdir(), "os-cwd-"));
14+
});
15+
afterEach(() => {
16+
rmSync(home, { recursive: true, force: true });
17+
rmSync(cwd, { recursive: true, force: true });
18+
});
19+
20+
function writeStyle(root: string, name: string, content: string): void {
21+
const dir = join(root, ".codebase", "output-styles");
22+
mkdirSync(dir, { recursive: true });
23+
writeFileSync(join(dir, name), content, "utf8");
24+
}
25+
26+
it("returns [] when no styles exist", () => {
27+
expect(loadOutputStyles({ home, cwd })).toEqual([]);
28+
});
29+
30+
it("loads a style with frontmatter name + description", () => {
31+
writeStyle(home, "terse.md", "---\nname: Terse\ndescription: Short answers.\n---\nBe brief.");
32+
const styles = loadOutputStyles({ home, cwd });
33+
expect(styles).toHaveLength(1);
34+
expect(styles[0]).toMatchObject({ id: "terse", name: "Terse", description: "Short answers.", body: "Be brief." });
35+
});
36+
37+
it("defaults name to the id when frontmatter omits it", () => {
38+
writeStyle(home, "report.md", "Write a formal report.");
39+
const styles = loadOutputStyles({ home, cwd });
40+
expect(styles[0]).toMatchObject({ id: "report", name: "report", body: "Write a formal report." });
41+
});
42+
43+
it("skips empty-body styles", () => {
44+
writeStyle(home, "blank.md", "---\nname: Blank\n---\n ");
45+
expect(loadOutputStyles({ home, cwd })).toEqual([]);
46+
});
47+
48+
it("project styles override user styles with the same id", () => {
49+
writeStyle(home, "voice.md", "user version");
50+
writeStyle(cwd, "voice.md", "project version");
51+
const styles = loadOutputStyles({ home, cwd });
52+
expect(styles).toHaveLength(1);
53+
expect(styles[0].body).toBe("project version");
54+
});
55+
56+
it("getOutputStyle resolves case-insensitively", () => {
57+
writeStyle(home, "Terse.md", "Be brief.");
58+
expect(getOutputStyle("TERSE", { home, cwd })?.body).toBe("Be brief.");
59+
expect(getOutputStyle("nope", { home, cwd })).toBeUndefined();
60+
});
61+
});

0 commit comments

Comments
 (0)