Skip to content

Commit dc32aab

Browse files
committed
Merge branch 'feature-project-config' - project config, runtime refactor, real tokenizer
2 parents e5a9fef + bfef712 commit dc32aab

10 files changed

Lines changed: 1712 additions & 42 deletions

src/config.test.ts

Lines changed: 270 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1-
import { expect, test, describe, beforeEach } from "bun:test";
2-
import { loadGlobalConfig, getCommandDefaults, applyDefaults, clearConfigCache } from "./config";
1+
import { expect, test, describe, beforeEach, afterEach } from "bun:test";
2+
import {
3+
loadGlobalConfig,
4+
getCommandDefaults,
5+
applyDefaults,
6+
clearConfigCache,
7+
findGitRoot,
8+
loadProjectConfig,
9+
loadFullConfig,
10+
clearProjectConfigCache,
11+
} from "./config";
12+
import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs";
13+
import { join } from "path";
14+
import { tmpdir } from "os";
315

416
describe("config", () => {
517
beforeEach(() => {
@@ -40,3 +52,259 @@ describe("config", () => {
4052
expect(result).toEqual(frontmatter);
4153
});
4254
});
55+
56+
describe("findGitRoot", () => {
57+
test("finds git root from current directory", () => {
58+
// The test is running inside the agents repo
59+
const gitRoot = findGitRoot(process.cwd());
60+
expect(gitRoot).not.toBeNull();
61+
expect(existsSync(join(gitRoot!, ".git"))).toBe(true);
62+
});
63+
64+
test("finds git root from subdirectory", () => {
65+
const gitRoot = findGitRoot(join(process.cwd(), "src"));
66+
expect(gitRoot).not.toBeNull();
67+
expect(existsSync(join(gitRoot!, ".git"))).toBe(true);
68+
});
69+
70+
test("returns null for non-git directory", () => {
71+
const gitRoot = findGitRoot(tmpdir());
72+
// tmpdir might be in a git repo on some systems, so we just check it doesn't error
73+
expect(gitRoot === null || typeof gitRoot === "string").toBe(true);
74+
});
75+
});
76+
77+
describe("loadProjectConfig", () => {
78+
const testDir = join(tmpdir(), `ma-test-${Date.now()}`);
79+
const subDir = join(testDir, "subdir");
80+
81+
beforeEach(() => {
82+
clearProjectConfigCache();
83+
mkdirSync(subDir, { recursive: true });
84+
});
85+
86+
afterEach(() => {
87+
rmSync(testDir, { recursive: true, force: true });
88+
});
89+
90+
test("returns empty config when no project config exists", async () => {
91+
const config = await loadProjectConfig(testDir);
92+
expect(config).toEqual({});
93+
});
94+
95+
test("loads ma.config.yaml from CWD", async () => {
96+
writeFileSync(
97+
join(testDir, "ma.config.yaml"),
98+
`commands:
99+
claude:
100+
model: opus
101+
`
102+
);
103+
104+
const config = await loadProjectConfig(testDir);
105+
expect(config.commands?.claude?.model).toBe("opus");
106+
});
107+
108+
test("loads .markdown-agent.yaml from CWD", async () => {
109+
writeFileSync(
110+
join(testDir, ".markdown-agent.yaml"),
111+
`commands:
112+
claude:
113+
model: sonnet
114+
`
115+
);
116+
117+
const config = await loadProjectConfig(testDir);
118+
expect(config.commands?.claude?.model).toBe("sonnet");
119+
});
120+
121+
test("loads .markdown-agent.json from CWD", async () => {
122+
writeFileSync(
123+
join(testDir, ".markdown-agent.json"),
124+
JSON.stringify({
125+
commands: {
126+
claude: {
127+
model: "haiku",
128+
},
129+
},
130+
})
131+
);
132+
133+
const config = await loadProjectConfig(testDir);
134+
expect(config.commands?.claude?.model).toBe("haiku");
135+
});
136+
137+
test("prefers ma.config.yaml over .markdown-agent.yaml", async () => {
138+
writeFileSync(
139+
join(testDir, "ma.config.yaml"),
140+
`commands:
141+
claude:
142+
model: opus
143+
`
144+
);
145+
writeFileSync(
146+
join(testDir, ".markdown-agent.yaml"),
147+
`commands:
148+
claude:
149+
model: sonnet
150+
`
151+
);
152+
153+
const config = await loadProjectConfig(testDir);
154+
expect(config.commands?.claude?.model).toBe("opus");
155+
});
156+
157+
test("handles invalid YAML gracefully", async () => {
158+
writeFileSync(join(testDir, "ma.config.yaml"), "invalid: yaml: content:");
159+
160+
const config = await loadProjectConfig(testDir);
161+
// Should return empty config on parse error
162+
expect(config).toEqual({});
163+
});
164+
165+
test("handles invalid JSON gracefully", async () => {
166+
writeFileSync(join(testDir, ".markdown-agent.json"), "{ invalid json }");
167+
168+
const config = await loadProjectConfig(testDir);
169+
expect(config).toEqual({});
170+
});
171+
});
172+
173+
describe("loadFullConfig", () => {
174+
const testDir = join(tmpdir(), `ma-full-test-${Date.now()}`);
175+
176+
beforeEach(() => {
177+
clearConfigCache();
178+
mkdirSync(testDir, { recursive: true });
179+
});
180+
181+
afterEach(() => {
182+
rmSync(testDir, { recursive: true, force: true });
183+
});
184+
185+
test("includes built-in defaults when no project config", async () => {
186+
const config = await loadFullConfig(testDir);
187+
expect(config.commands?.copilot?.$1).toBe("prompt");
188+
});
189+
190+
test("project config overrides global config", async () => {
191+
writeFileSync(
192+
join(testDir, "ma.config.yaml"),
193+
`commands:
194+
copilot:
195+
$1: custom-prompt
196+
`
197+
);
198+
199+
const config = await loadFullConfig(testDir);
200+
expect(config.commands?.copilot?.$1).toBe("custom-prompt");
201+
});
202+
203+
test("project config adds new commands", async () => {
204+
writeFileSync(
205+
join(testDir, "ma.config.yaml"),
206+
`commands:
207+
my-tool:
208+
$1: body
209+
verbose: true
210+
`
211+
);
212+
213+
const config = await loadFullConfig(testDir);
214+
// Built-in defaults preserved
215+
expect(config.commands?.copilot?.$1).toBe("prompt");
216+
// New command added
217+
expect(config.commands?.["my-tool"]?.$1).toBe("body");
218+
expect(config.commands?.["my-tool"]?.verbose).toBe(true);
219+
});
220+
221+
test("project config merges with existing command", async () => {
222+
writeFileSync(
223+
join(testDir, "ma.config.yaml"),
224+
`commands:
225+
copilot:
226+
verbose: true
227+
`
228+
);
229+
230+
const config = await loadFullConfig(testDir);
231+
// Built-in default preserved
232+
expect(config.commands?.copilot?.$1).toBe("prompt");
233+
// New setting added
234+
expect(config.commands?.copilot?.verbose).toBe(true);
235+
});
236+
});
237+
238+
describe("config cascade", () => {
239+
let testDir: string;
240+
let gitRoot: string;
241+
let subDir: string;
242+
243+
beforeEach(() => {
244+
clearConfigCache();
245+
// Use unique directory per test to avoid cache issues
246+
testDir = join(tmpdir(), `ma-cascade-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
247+
gitRoot = join(testDir, "repo");
248+
subDir = join(gitRoot, "packages", "app");
249+
// Create a fake git repo structure
250+
mkdirSync(join(gitRoot, ".git"), { recursive: true });
251+
mkdirSync(subDir, { recursive: true });
252+
});
253+
254+
afterEach(() => {
255+
rmSync(testDir, { recursive: true, force: true });
256+
});
257+
258+
test("CWD config overrides git root config", async () => {
259+
// Git root config
260+
writeFileSync(
261+
join(gitRoot, "ma.config.yaml"),
262+
`commands:
263+
claude:
264+
model: sonnet
265+
verbose: true
266+
`
267+
);
268+
269+
// CWD config (subdirectory)
270+
writeFileSync(
271+
join(subDir, "ma.config.yaml"),
272+
`commands:
273+
claude:
274+
model: opus
275+
`
276+
);
277+
278+
const config = await loadProjectConfig(subDir);
279+
// CWD wins for model
280+
expect(config.commands?.claude?.model).toBe("opus");
281+
// Git root setting preserved
282+
expect(config.commands?.claude?.verbose).toBe(true);
283+
});
284+
285+
test("git root config used when CWD has no config", async () => {
286+
writeFileSync(
287+
join(gitRoot, "ma.config.yaml"),
288+
`commands:
289+
claude:
290+
model: sonnet
291+
`
292+
);
293+
294+
const config = await loadProjectConfig(subDir);
295+
expect(config.commands?.claude?.model).toBe("sonnet");
296+
});
297+
298+
test("only CWD config used when at git root", async () => {
299+
writeFileSync(
300+
join(gitRoot, "ma.config.yaml"),
301+
`commands:
302+
claude:
303+
model: opus
304+
`
305+
);
306+
307+
const config = await loadProjectConfig(gitRoot);
308+
expect(config.commands?.claude?.model).toBe("opus");
309+
});
310+
});

0 commit comments

Comments
 (0)