From 29a77c683941b3b26575ee4683130abc4528a0f8 Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 10 Apr 2026 23:54:21 +0100 Subject: [PATCH] feat: add subagents runner config loader --- src/config.test.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++++ src/config.ts | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/config.test.ts create mode 100644 src/config.ts diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..46757f7 --- /dev/null +++ b/src/config.test.ts @@ -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, "\\$&")}`), + ); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..9c4d751 --- /dev/null +++ b/src/config.ts @@ -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, + }; +}