Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ prerequisites; Ollama installation remains outside this flow.
Profiles supply per-project or named-profile overrides for `model` and `systemPromptExtensions` (the only allowed keys; any other key is rejected on load).

- Project profile: `.corbits/profile.json` in the repo root — committed, credential-free.
- Named profiles: `~/.corbits/profiles/<name>.json` — user-level overrides, inherited via the `profile` key or the `--profile` flag.
- Named profiles: `~/.corbits/profiles/<name>.json` — user-level overrides, inherited via the `profile` key or the `--profile` flag. A missing named file fails closed. A missing project `profile.json` overlay is optional.

```json
{
Expand Down
6 changes: 5 additions & 1 deletion src/config/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,19 @@ export async function loadProfile(path: string): Promise<ProfileConfig | null> {
export async function resolveProfile(
cwd: string,
profileName?: string,
home: string = homedir(),
): Promise<ProfileConfig> {
const projectProfile = await loadProfile(projectProfilePath(cwd));

const namedProfileName = profileName ?? projectProfile?.profile;

let namedProfile: ProfileConfig | null = null;
if (namedProfileName !== undefined) {
const namedPath = join(profilesDir(), `${namedProfileName}.json`);
const namedPath = join(profilesDir(home), `${namedProfileName}.json`);
namedProfile = await loadProfile(namedPath);
if (namedProfile === null) {
throw new Error(`Profile not found: ${namedPath}`);
}
}

// Merge: project profile fields override named profile fields.
Expand Down
85 changes: 51 additions & 34 deletions src/profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ function makeTmp(): string {
);
}

async function writeJson(path: string, value: unknown): Promise<void> {
await writeFile(path, JSON.stringify(value));
}

test("profilesDir returns ~/.corbits/profiles", () => {
const result = profilesDir("/home/user");
expect(result).toBe("/home/user/.corbits/profiles");
Expand All @@ -35,7 +39,7 @@ test("loadProfile parses valid profile", async () => {
const dir = makeTmp();
await mkdir(dir, { recursive: true });
const path = join(dir, "profile.json");
await writeFile(path, JSON.stringify({ model: "claude-opus-4-8" }));
await writeJson(path, { model: "claude-opus-4-8" });
const result = await loadProfile(path);
expect(result).toEqual({ model: "claude-opus-4-8" });
});
Expand All @@ -44,10 +48,9 @@ test("loadProfile parses systemPromptExtensions", async () => {
const dir = makeTmp();
await mkdir(dir, { recursive: true });
const path = join(dir, "profile.json");
await writeFile(
path,
JSON.stringify({ systemPromptExtensions: ["no-destructive-migrations"] }),
);
await writeJson(path, {
systemPromptExtensions: ["no-destructive-migrations"],
});
const result = await loadProfile(path);
expect(result).toEqual({
systemPromptExtensions: ["no-destructive-migrations"],
Expand All @@ -58,15 +61,15 @@ test("loadProfile rejects unknown keys", async () => {
const dir = makeTmp();
await mkdir(dir, { recursive: true });
const path = join(dir, "profile.json");
await writeFile(path, JSON.stringify({ model: "x", unknownKey: true }));
await writeJson(path, { model: "x", unknownKey: true });
await expect(loadProfile(path)).rejects.toThrow(/unknownKey must be removed/);
});

test("loadProfile rejects non-array systemPromptExtensions", async () => {
const dir = makeTmp();
await mkdir(dir, { recursive: true });
const path = join(dir, "profile.json");
await writeFile(path, JSON.stringify({ systemPromptExtensions: "bad" }));
await writeJson(path, { systemPromptExtensions: "bad" });
await expect(loadProfile(path)).rejects.toThrow(/systemPromptExtensions/);
});

Expand All @@ -85,58 +88,72 @@ test("resolveProfile returns empty object when no profile files exist", async ()
expect(result).toEqual({});
});

test("resolveProfile throws when --profile names a missing file", async () => {
const home = makeTmp();
const cwd = makeTmp();
await mkdir(cwd, { recursive: true });
const name = "does-not-exist";
const missingPath = join(profilesDir(home), `${name}.json`);
expect(await loadProfile(missingPath)).toBeNull();
await expect(resolveProfile(cwd, name, home)).rejects.toThrow(missingPath);
});

test("resolveProfile loads a valid named profile", async () => {
const home = makeTmp();
const cwd = makeTmp();
await mkdir(cwd, { recursive: true });
const namedDir = join(home, ".corbits", "profiles");
await mkdir(namedDir, { recursive: true });
await writeJson(join(namedDir, "work.json"), { model: "named-model" });
const result = await resolveProfile(cwd, "work", home);
expect(result.model).toBe("named-model");
expect(result.profile).toBe("work");
});

test("resolveProfile applies project profile fields", async () => {
const cwd = makeTmp();
const dir = join(cwd, ".corbits");
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, "profile.json"),
JSON.stringify({
model: "claude-sonnet",
systemPromptExtensions: ["ext1"],
}),
);
await writeJson(join(dir, "profile.json"), {
model: "claude-sonnet",
systemPromptExtensions: ["ext1"],
});
const result = await resolveProfile(cwd);
expect(result.model).toBe("claude-sonnet");
expect(result.systemPromptExtensions).toEqual(["ext1"]);
});

test("resolveProfile surfaces profile name when set", async () => {
test("resolveProfile throws when a named profile key points at a missing file", async () => {
const home = makeTmp();
const cwd = makeTmp();
const dir = join(cwd, ".corbits");
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, "profile.json"),
JSON.stringify({ profile: "work" }),
const name = "no-such-named-profile";
await writeJson(join(dir, "profile.json"), { profile: name });
const missingPath = join(profilesDir(home), `${name}.json`);
await expect(resolveProfile(cwd, undefined, home)).rejects.toThrow(
missingPath,
);
const result = await resolveProfile(cwd);
expect(result.profile).toBe("work");
});

test("resolveProfile: project profile fields override named profile fields", async () => {
const home = makeTmp();
const cwd = makeTmp();
const namedDir = join(home, ".corbits", "profiles");
await mkdir(namedDir, { recursive: true });
await writeFile(
await writeJson(
join(namedDir, "work.json"),
JSON.stringify({ model: "base-model", systemPromptExtensions: ["ext1"] }),
{ model: "base-model", systemPromptExtensions: ["ext1"] },
);
const localDir = join(cwd, ".corbits");
await mkdir(localDir, { recursive: true });
await writeFile(
await writeJson(
join(localDir, "profile.json"),
JSON.stringify({ profile: "work", model: "override-model" }),
{ profile: "work", model: "override-model" },
);

// We can't easily inject profilesDir home in resolveProfile without additional plumbing,
// so test the merge logic directly via the exported functions.
// Project profile model should win over named profile model.
const projectProfile = await loadProfile(join(localDir, "profile.json"));
const namedProfile = await loadProfile(join(namedDir, "work.json"));
const merged = { ...namedProfile };
if (projectProfile?.model !== undefined) merged.model = projectProfile.model;
expect(merged.model).toBe("override-model");
// systemPromptExtensions not in project profile so named profile value survives
expect(merged.systemPromptExtensions).toEqual(["ext1"]);
const result = await resolveProfile(cwd, undefined, home);
expect(result.model).toBe("override-model");
expect(result.systemPromptExtensions).toEqual(["ext1"]);
expect(result.profile).toBe("work");
});
Loading