feat: add subagents runner config loader

This commit is contained in:
pi
2026-04-10 23:54:21 +01:00
parent d0cab98f01
commit 29a77c6839
2 changed files with 115 additions and 0 deletions

67
src/config.test.ts Normal file
View File

@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadSubagentsConfig } from "./config.ts";
async function makeFixture() {
const root = await mkdtemp(join(tmpdir(), "pi-subagents-config-"));
const homeDir = join(root, "home");
const cwd = join(root, "repo");
await mkdir(join(homeDir, ".pi", "agent"), { recursive: true });
await mkdir(join(cwd, ".pi"), { recursive: true });
return { root, homeDir, cwd };
}
test("loadSubagentsConfig defaults to process when no config files exist", async () => {
const { homeDir, cwd } = await makeFixture();
const config = loadSubagentsConfig(cwd, { homeDir });
assert.equal(config.runner, "process");
assert.equal(config.globalPath, join(homeDir, ".pi", "agent", "subagents.json"));
assert.equal(config.projectPath, join(cwd, ".pi", "subagents.json"));
});
test("loadSubagentsConfig uses global config when project config is absent", async () => {
const { homeDir, cwd } = await makeFixture();
await writeFile(
join(homeDir, ".pi", "agent", "subagents.json"),
JSON.stringify({ runner: "tmux" }, null, 2),
"utf8",
);
const config = loadSubagentsConfig(cwd, { homeDir });
assert.equal(config.runner, "tmux");
});
test("loadSubagentsConfig lets project config override global config", async () => {
const { homeDir, cwd } = await makeFixture();
await writeFile(
join(homeDir, ".pi", "agent", "subagents.json"),
JSON.stringify({ runner: "tmux" }, null, 2),
"utf8",
);
await writeFile(
join(cwd, ".pi", "subagents.json"),
JSON.stringify({ runner: "process" }, null, 2),
"utf8",
);
const config = loadSubagentsConfig(cwd, { homeDir });
assert.equal(config.runner, "process");
});
test("loadSubagentsConfig throws clear error for invalid runner values", async () => {
const { homeDir, cwd } = await makeFixture();
const projectPath = join(cwd, ".pi", "subagents.json");
await writeFile(projectPath, JSON.stringify({ runner: "fork" }, null, 2), "utf8");
assert.throws(
() => loadSubagentsConfig(cwd, { homeDir }),
new RegExp(`Invalid runner .*fork.*${projectPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
);
});

48
src/config.ts Normal file
View File

@@ -0,0 +1,48 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
export type RunnerMode = "process" | "tmux";
export interface SubagentsConfig {
runner: RunnerMode;
globalPath: string;
projectPath: string;
}
export function getSubagentsConfigPaths(cwd: string, homeDir = homedir()) {
return {
globalPath: join(homeDir, ".pi", "agent", "subagents.json"),
projectPath: resolve(cwd, ".pi", "subagents.json"),
};
}
function readConfigFile(path: string): { runner?: RunnerMode } | undefined {
if (!existsSync(path)) return undefined;
let parsed: any;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to parse ${path}: ${message}`);
}
if (parsed.runner !== undefined && parsed.runner !== "process" && parsed.runner !== "tmux") {
throw new Error(`Invalid runner ${JSON.stringify(parsed.runner)} in ${path}. Expected "process" or "tmux".`);
}
return parsed;
}
export function loadSubagentsConfig(cwd: string, options: { homeDir?: string } = {}): SubagentsConfig {
const { globalPath, projectPath } = getSubagentsConfigPaths(cwd, options.homeDir);
const globalConfig = readConfigFile(globalPath) ?? {};
const projectConfig = readConfigFile(projectPath) ?? {};
return {
runner: projectConfig.runner ?? globalConfig.runner ?? "process",
globalPath,
projectPath,
};
}