From cd13aad834fbdd3736a1e89bceb87e014ac24787 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Sun, 15 Feb 2026 10:16:16 +0000 Subject: [PATCH 1/4] refactor: extract @perstack/perstack-toml package for TOML config and lockfile operations Consolidate perstack.toml parsing, config resolution, and lockfile management into a single dedicated package, eliminating code duplication across tui, installer, and create-expert. Co-Authored-By: Claude Opus 4.6 --- apps/create-expert/bin/cli.ts | 8 +--- apps/create-expert/package.json | 4 +- packages/installer/package.json | 4 +- packages/installer/src/handler.ts | 4 +- packages/installer/src/index.ts | 3 +- packages/installer/src/lockfile-generator.ts | 7 +-- packages/perstack-toml/package.json | 44 ++++++++++++++++++ .../src/config.test.ts} | 2 +- .../src/config.ts} | 14 ++---- packages/perstack-toml/src/index.ts | 2 + .../src/lockfile.test.ts | 0 .../{tui => perstack-toml}/src/lockfile.ts | 10 ++-- packages/perstack-toml/src/utils.ts | 4 ++ packages/perstack-toml/tsconfig.json | 5 ++ packages/tui/package.json | 5 +- packages/tui/src/lib/context.ts | 2 +- packages/tui/src/run-handler.ts | 2 +- packages/tui/src/start-handler.ts | 2 +- packages/tui/tsup.config.ts | 1 - pnpm-lock.yaml | 46 +++++++++++++++---- 20 files changed, 119 insertions(+), 50 deletions(-) create mode 100644 packages/perstack-toml/package.json rename packages/{tui/src/lib/perstack-toml.test.ts => perstack-toml/src/config.test.ts} (99%) rename packages/{tui/src/lib/perstack-toml.ts => perstack-toml/src/config.ts} (90%) create mode 100644 packages/perstack-toml/src/index.ts rename packages/{tui => perstack-toml}/src/lockfile.test.ts (100%) rename packages/{tui => perstack-toml}/src/lockfile.ts (87%) create mode 100644 packages/perstack-toml/src/utils.ts create mode 100644 packages/perstack-toml/tsconfig.json diff --git a/apps/create-expert/bin/cli.ts b/apps/create-expert/bin/cli.ts index 01a3a5ad..4901edf8 100644 --- a/apps/create-expert/bin/cli.ts +++ b/apps/create-expert/bin/cli.ts @@ -1,18 +1,14 @@ #!/usr/bin/env node import { readFileSync } from "node:fs" -import { parseWithFriendlyError, perstackConfigSchema } from "@perstack/core" +import { parsePerstackConfig } from "@perstack/perstack-toml" import { startHandler } from "@perstack/tui" import { PROVIDER_ENV_MAP } from "@perstack/tui/provider-config" import { Command } from "commander" -import TOML from "smol-toml" import packageJson from "../package.json" with { type: "json" } const tomlPath = new URL("../perstack.toml", import.meta.url) -const config = parseWithFriendlyError( - perstackConfigSchema, - TOML.parse(readFileSync(tomlPath, "utf-8")), -) +const config = parsePerstackConfig(readFileSync(tomlPath, "utf-8")) new Command() .name(packageJson.name) diff --git a/apps/create-expert/package.json b/apps/create-expert/package.json index 92e61713..4c8717bf 100644 --- a/apps/create-expert/package.json +++ b/apps/create-expert/package.json @@ -24,12 +24,12 @@ }, "dependencies": { "@perstack/core": "workspace:*", + "@perstack/perstack-toml": "workspace:*", "@perstack/runtime": "workspace:*", "commander": "^14.0.3", "dotenv": "^17.3.1", "ink": "^6.7.0", - "react": "^19.2.4", - "smol-toml": "^1.6.0" + "react": "^19.2.4" }, "devDependencies": { "@perstack/tui": "workspace:*", diff --git a/packages/installer/package.json b/packages/installer/package.json index 632d4aa5..17c5e406 100644 --- a/packages/installer/package.json +++ b/packages/installer/package.json @@ -29,8 +29,8 @@ "dependencies": { "@perstack/api-client": "^0.0.55", "@perstack/core": "workspace:*", - "@perstack/runtime": "workspace:*", - "smol-toml": "^1.6.0" + "@perstack/perstack-toml": "workspace:*", + "@perstack/runtime": "workspace:*" }, "devDependencies": { "@perstack/tui": "workspace:*", diff --git a/packages/installer/src/handler.ts b/packages/installer/src/handler.ts index 3df0e817..506a8513 100644 --- a/packages/installer/src/handler.ts +++ b/packages/installer/src/handler.ts @@ -1,11 +1,11 @@ import { writeFile } from "node:fs/promises" import path from "node:path" import type { Lockfile, LockfileExpert } from "@perstack/core" +import { findConfigPath, generateLockfileToml, getPerstackConfig } from "@perstack/perstack-toml" import { collectToolDefinitionsForExpert } from "@perstack/runtime" import { getEnv } from "@perstack/tui/get-env" -import { findConfigPath, getPerstackConfig } from "@perstack/tui/perstack-toml" import { resolveAllExperts } from "./expert-resolver.js" -import { expertToLockfileExpert, generateLockfileToml } from "./lockfile-generator.js" +import { expertToLockfileExpert } from "./lockfile-generator.js" export async function installHandler(options: { config?: string diff --git a/packages/installer/src/index.ts b/packages/installer/src/index.ts index 5c99514a..b1c4c1bc 100644 --- a/packages/installer/src/index.ts +++ b/packages/installer/src/index.ts @@ -1,3 +1,4 @@ +export { generateLockfileToml } from "@perstack/perstack-toml" export { configExpertToExpert, type PublishedExpertData, @@ -5,4 +6,4 @@ export { toRuntimeExpert, } from "./expert-resolver.js" export { installHandler } from "./handler.js" -export { expertToLockfileExpert, generateLockfileToml } from "./lockfile-generator.js" +export { expertToLockfileExpert } from "./lockfile-generator.js" diff --git a/packages/installer/src/lockfile-generator.ts b/packages/installer/src/lockfile-generator.ts index 548e4247..da2dd51a 100644 --- a/packages/installer/src/lockfile-generator.ts +++ b/packages/installer/src/lockfile-generator.ts @@ -1,5 +1,4 @@ -import type { Expert, Lockfile, LockfileExpert } from "@perstack/core" -import TOML from "smol-toml" +import type { Expert, LockfileExpert } from "@perstack/core" export function expertToLockfileExpert( expert: Expert, @@ -22,7 +21,3 @@ export function expertToLockfileExpert( toolDefinitions, } } - -export function generateLockfileToml(lockfile: Lockfile): string { - return TOML.stringify(lockfile) -} diff --git a/packages/perstack-toml/package.json b/packages/perstack-toml/package.json new file mode 100644 index 00000000..0321cee0 --- /dev/null +++ b/packages/perstack-toml/package.json @@ -0,0 +1,44 @@ +{ + "private": true, + "version": "0.0.1", + "name": "@perstack/perstack-toml", + "description": "Perstack TOML configuration file resolution, parsing, and lockfile management", + "author": "Wintermute Technologies, Inc.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "pnpm run clean && tsup --config ../../tsup.config.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@perstack/core": "workspace:*", + "smol-toml": "^1.6.0" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.2.3", + "memfs": "^4.56.10", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/packages/tui/src/lib/perstack-toml.test.ts b/packages/perstack-toml/src/config.test.ts similarity index 99% rename from packages/tui/src/lib/perstack-toml.test.ts rename to packages/perstack-toml/src/config.test.ts index ba2c3f65..fea567fa 100644 --- a/packages/tui/src/lib/perstack-toml.test.ts +++ b/packages/perstack-toml/src/config.test.ts @@ -1,6 +1,6 @@ import { vol } from "memfs" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { getPerstackConfig } from "./perstack-toml.js" +import { getPerstackConfig } from "./config.js" vi.mock("node:fs/promises", async () => { const memfs = await import("memfs") diff --git a/packages/tui/src/lib/perstack-toml.ts b/packages/perstack-toml/src/config.ts similarity index 90% rename from packages/tui/src/lib/perstack-toml.ts rename to packages/perstack-toml/src/config.ts index 633cbfdf..7e89e911 100644 --- a/packages/tui/src/lib/perstack-toml.ts +++ b/packages/perstack-toml/src/config.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises" import path from "node:path" import { type PerstackConfig, parseWithFriendlyError, perstackConfigSchema } from "@perstack/core" import TOML from "smol-toml" +import { isRemoteUrl } from "./utils.js" const ALLOWED_CONFIG_HOSTS = ["raw.githubusercontent.com"] @@ -10,12 +11,12 @@ export async function getPerstackConfig(configPath?: string): Promise { @@ -70,11 +71,6 @@ async function findPerstackConfigStringRecursively(cwd: string): Promise { - const toml = TOML.parse(config ?? "") - return parseWithFriendlyError(perstackConfigSchema, toml, "perstack.toml") -} - export async function findConfigPath(configPath?: string): Promise { if (configPath) { return path.resolve(process.cwd(), configPath) diff --git a/packages/perstack-toml/src/index.ts b/packages/perstack-toml/src/index.ts new file mode 100644 index 00000000..da6a7fb8 --- /dev/null +++ b/packages/perstack-toml/src/index.ts @@ -0,0 +1,2 @@ +export { findConfigPath, getPerstackConfig, parsePerstackConfig } from "./config.js" +export { findLockfile, generateLockfileToml, loadLockfile } from "./lockfile.js" diff --git a/packages/tui/src/lockfile.test.ts b/packages/perstack-toml/src/lockfile.test.ts similarity index 100% rename from packages/tui/src/lockfile.test.ts rename to packages/perstack-toml/src/lockfile.test.ts diff --git a/packages/tui/src/lockfile.ts b/packages/perstack-toml/src/lockfile.ts similarity index 87% rename from packages/tui/src/lockfile.ts rename to packages/perstack-toml/src/lockfile.ts index 3960a956..340cab5d 100644 --- a/packages/tui/src/lockfile.ts +++ b/packages/perstack-toml/src/lockfile.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs" import path from "node:path" import { type Lockfile, lockfileSchema, parseWithFriendlyError } from "@perstack/core" import TOML from "smol-toml" +import { isRemoteUrl } from "./utils.js" export function loadLockfile(lockfilePath: string): Lockfile | null { try { @@ -13,11 +14,6 @@ export function loadLockfile(lockfilePath: string): Lockfile | null { } } -function isRemoteUrl(configPath: string): boolean { - const lower = configPath.toLowerCase() - return lower.startsWith("https://") || lower.startsWith("http://") -} - export function findLockfile(configPath?: string): string | null { if (configPath) { // Remote config URLs don't support lockfiles - skip silently @@ -42,3 +38,7 @@ function findLockfileRecursively(cwd: string): string | null { return findLockfileRecursively(path.dirname(cwd)) } } + +export function generateLockfileToml(lockfile: Lockfile): string { + return TOML.stringify(lockfile) +} diff --git a/packages/perstack-toml/src/utils.ts b/packages/perstack-toml/src/utils.ts new file mode 100644 index 00000000..542c7f55 --- /dev/null +++ b/packages/perstack-toml/src/utils.ts @@ -0,0 +1,4 @@ +export function isRemoteUrl(configPath: string): boolean { + const lower = configPath.toLowerCase() + return lower.startsWith("https://") || lower.startsWith("http://") +} diff --git a/packages/perstack-toml/tsconfig.json b/packages/perstack-toml/tsconfig.json new file mode 100644 index 00000000..a10de72d --- /dev/null +++ b/packages/perstack-toml/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 1a854bf9..1a20f4e4 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -9,7 +9,6 @@ "./context": "./src/lib/context.ts", "./interactive": "./src/lib/interactive.ts", "./get-env": "./src/lib/get-env.ts", - "./perstack-toml": "./src/lib/perstack-toml.ts", "./provider-config": "./src/lib/provider-config.ts" }, "scripts": { @@ -20,9 +19,9 @@ "dependencies": { "@paralleldrive/cuid2": "^3.3.0", "@perstack/core": "workspace:*", + "@perstack/perstack-toml": "workspace:*", "@perstack/tui-components": "workspace:*", - "dotenv": "^17.3.1", - "smol-toml": "^1.6.0" + "dotenv": "^17.3.1" }, "devDependencies": { "@perstack/filesystem-storage": "workspace:*", diff --git a/packages/tui/src/lib/context.ts b/packages/tui/src/lib/context.ts index 6a248850..4230ab64 100644 --- a/packages/tui/src/lib/context.ts +++ b/packages/tui/src/lib/context.ts @@ -1,6 +1,6 @@ import type { Checkpoint, PerstackConfig, ProviderConfig, ProviderName } from "@perstack/core" +import { getPerstackConfig } from "@perstack/perstack-toml" import { getEnv } from "./get-env.js" -import { getPerstackConfig } from "./perstack-toml.js" import { getProviderConfig } from "./provider-config.js" import { getCheckpointById, getMostRecentCheckpoint } from "./run-manager.js" diff --git a/packages/tui/src/run-handler.ts b/packages/tui/src/run-handler.ts index 8a1f407a..b9a22848 100644 --- a/packages/tui/src/run-handler.ts +++ b/packages/tui/src/run-handler.ts @@ -14,13 +14,13 @@ import { retrieveJob, storeJob, } from "@perstack/filesystem-storage" +import { findLockfile, loadLockfile } from "@perstack/perstack-toml" import { run as perstackRun } from "@perstack/runtime" import { resolveRunContext } from "./lib/context.js" import { parseInteractiveToolCallResult, parseInteractiveToolCallResultJson, } from "./lib/interactive.js" -import { findLockfile, loadLockfile } from "./lockfile.js" const defaultEventListener = (event: RunEvent | RuntimeEvent) => console.log(JSON.stringify(event)) diff --git a/packages/tui/src/start-handler.ts b/packages/tui/src/start-handler.ts index 9ce50091..b7e93e24 100644 --- a/packages/tui/src/start-handler.ts +++ b/packages/tui/src/start-handler.ts @@ -14,6 +14,7 @@ import { retrieveJob, storeJob, } from "@perstack/filesystem-storage" +import { findLockfile, loadLockfile } from "@perstack/perstack-toml" import { run as perstackRun, runtimeVersion } from "@perstack/runtime" import { type CheckpointHistoryItem, @@ -31,7 +32,6 @@ import { type getEventContents, getRecentExperts, } from "./lib/run-manager.js" -import { findLockfile, loadLockfile } from "./lockfile.js" const CONTINUE_TIMEOUT_MS = 60_000 diff --git a/packages/tui/tsup.config.ts b/packages/tui/tsup.config.ts index 5e2b7803..900d5551 100644 --- a/packages/tui/tsup.config.ts +++ b/packages/tui/tsup.config.ts @@ -8,7 +8,6 @@ export default defineConfig({ "src/lib/context": "src/lib/context.ts", "src/lib/interactive": "src/lib/interactive.ts", "src/lib/get-env": "src/lib/get-env.ts", - "src/lib/perstack-toml": "src/lib/perstack-toml.ts", "src/lib/provider-config": "src/lib/provider-config.ts", }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c28a8212..bc4d2618 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@perstack/core': specifier: workspace:* version: link:../../packages/core + '@perstack/perstack-toml': + specifier: workspace:* + version: link:../../packages/perstack-toml '@perstack/runtime': specifier: workspace:* version: link:../../packages/runtime @@ -114,9 +117,6 @@ importers: react: specifier: ^19.2.4 version: 19.2.4 - smol-toml: - specifier: ^1.6.0 - version: 1.6.0 devDependencies: '@perstack/tui': specifier: workspace:* @@ -266,12 +266,12 @@ importers: '@perstack/core': specifier: workspace:* version: link:../core + '@perstack/perstack-toml': + specifier: workspace:* + version: link:../perstack-toml '@perstack/runtime': specifier: workspace:* version: link:../runtime - smol-toml: - specifier: ^1.6.0 - version: 1.6.0 devDependencies: '@perstack/tui': specifier: workspace:* @@ -317,6 +317,34 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/perstack-toml: + dependencies: + '@perstack/core': + specifier: workspace:* + version: link:../core + smol-toml: + specifier: ^1.6.0 + version: 1.6.0 + devDependencies: + '@tsconfig/node22': + specifier: ^22.0.5 + version: 22.0.5 + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + memfs: + specifier: ^4.56.10 + version: 4.56.10(tslib@2.8.1) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/providers/anthropic: dependencies: '@ai-sdk/anthropic': @@ -735,15 +763,15 @@ importers: '@perstack/core': specifier: workspace:* version: link:../core + '@perstack/perstack-toml': + specifier: workspace:* + version: link:../perstack-toml '@perstack/tui-components': specifier: workspace:* version: link:../tui-components dotenv: specifier: ^17.3.1 version: 17.3.1 - smol-toml: - specifier: ^1.6.0 - version: 1.6.0 devDependencies: '@perstack/filesystem-storage': specifier: workspace:* From 11fce4110ab8eb59c17bc88e5758f468d2ba2683 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Sun, 15 Feb 2026 10:34:57 +0000 Subject: [PATCH 2/4] refactor: move config resolution from library packages to application layer Config resolution (getPerstackConfig, findLockfile, loadLockfile) is an application concern, not a library concern. Move it to .action() handlers in apps/perstack and apps/create-expert so that --help works without a valid perstack.toml and library packages receive pre-resolved config. - Make perstackConfig required in resolveRunContext, startHandler, runHandler - Add RunHandlerOptions with perstackConfig and lockfile - Make installHandler receive configPath and perstackConfig as parameters - Remove @perstack/perstack-toml and memfs dependencies from @perstack/tui - Update context tests to pass PerstackConfig directly instead of TOML files Co-Authored-By: Claude Opus 4.6 --- apps/create-expert/bin/cli.ts | 7 +- apps/perstack/bin/cli.ts | 29 ++++- apps/perstack/package.json | 1 + packages/installer/src/handler.ts | 11 +- packages/tui/package.json | 2 - packages/tui/src/index.ts | 2 +- packages/tui/src/lib/context.test.ts | 186 +++++++++------------------ packages/tui/src/lib/context.ts | 6 +- packages/tui/src/run-handler.ts | 15 ++- packages/tui/src/start-handler.ts | 14 +- pnpm-lock.yaml | 9 +- 11 files changed, 117 insertions(+), 165 deletions(-) diff --git a/apps/create-expert/bin/cli.ts b/apps/create-expert/bin/cli.ts index 4901edf8..cd7460f5 100644 --- a/apps/create-expert/bin/cli.ts +++ b/apps/create-expert/bin/cli.ts @@ -1,14 +1,13 @@ #!/usr/bin/env node import { readFileSync } from "node:fs" -import { parsePerstackConfig } from "@perstack/perstack-toml" +import { findLockfile, loadLockfile, parsePerstackConfig } from "@perstack/perstack-toml" import { startHandler } from "@perstack/tui" import { PROVIDER_ENV_MAP } from "@perstack/tui/provider-config" import { Command } from "commander" import packageJson from "../package.json" with { type: "json" } const tomlPath = new URL("../perstack.toml", import.meta.url) -const config = parsePerstackConfig(readFileSync(tomlPath, "utf-8")) new Command() .name(packageJson.name) @@ -18,8 +17,12 @@ new Command() .option("--continue", "Continue the most recent job with new query") .option("--continue-job ", "Continue the specified job with new query") .action(async (query: string | undefined, options: Record) => { + const config = parsePerstackConfig(readFileSync(tomlPath, "utf-8")) + const lockfilePath = findLockfile() + const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined await startHandler("expert", query, options, { perstackConfig: config, + lockfile, additionalEnv: (env) => { const provider = config.provider?.providerName ?? "anthropic" const envKey = PROVIDER_ENV_MAP[provider] diff --git a/apps/perstack/bin/cli.ts b/apps/perstack/bin/cli.ts index a322a496..568b0c36 100755 --- a/apps/perstack/bin/cli.ts +++ b/apps/perstack/bin/cli.ts @@ -2,10 +2,23 @@ import { installHandler } from "@perstack/installer" import { logHandler, parsePositiveInt } from "@perstack/log" +import { + findConfigPath, + findLockfile, + getPerstackConfig, + loadLockfile, +} from "@perstack/perstack-toml" import { runHandler, startHandler } from "@perstack/tui" import { Command } from "commander" import packageJson from "../package.json" with { type: "json" } +async function resolveConfigAndLockfile(configOption?: string) { + const perstackConfig = await getPerstackConfig(configOption) + const lockfilePath = findLockfile(configOption) + const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined + return { perstackConfig, lockfile } +} + const program = new Command() .name(packageJson.name) .description(packageJson.description) @@ -47,7 +60,10 @@ program "Resume from a specific checkpoint (requires --continue or --continue-job)", ) .option("-i, --interactive-tool-call-result", "Query is interactive tool call result") - .action((expertKey, query, options) => startHandler(expertKey, query, options)) + .action(async (expertKey, query, options) => { + const { perstackConfig, lockfile } = await resolveConfigAndLockfile(options.config) + await startHandler(expertKey, query, options, { perstackConfig, lockfile }) + }) program .command("run") @@ -89,7 +105,10 @@ program "--filter ", "Filter events by type (comma-separated, e.g., completeRun,stopRunByError)", ) - .action((expertKey, query, options) => runHandler(expertKey, query, options)) + .action(async (expertKey, query, options) => { + const { perstackConfig, lockfile } = await resolveConfigAndLockfile(options.config) + await runHandler(expertKey, query, options, { perstackConfig, lockfile }) + }) program .command("log") @@ -129,6 +148,10 @@ program (value: string, previous: string[]) => previous.concat(value), [] as string[], ) - .action((options) => installHandler(options)) + .action(async (options) => { + const configPath = await findConfigPath(options.config) + const perstackConfig = await getPerstackConfig(options.config) + await installHandler({ configPath, perstackConfig, envPath: options.envPath }) + }) program.parse() diff --git a/apps/perstack/package.json b/apps/perstack/package.json index 8881034d..668e9d23 100644 --- a/apps/perstack/package.json +++ b/apps/perstack/package.json @@ -28,6 +28,7 @@ "devDependencies": { "@perstack/installer": "workspace:*", "@perstack/log": "workspace:*", + "@perstack/perstack-toml": "workspace:*", "@perstack/tui": "workspace:*", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.2.3", diff --git a/packages/installer/src/handler.ts b/packages/installer/src/handler.ts index 506a8513..3112fd9f 100644 --- a/packages/installer/src/handler.ts +++ b/packages/installer/src/handler.ts @@ -1,19 +1,20 @@ import { writeFile } from "node:fs/promises" import path from "node:path" -import type { Lockfile, LockfileExpert } from "@perstack/core" -import { findConfigPath, generateLockfileToml, getPerstackConfig } from "@perstack/perstack-toml" +import type { Lockfile, LockfileExpert, PerstackConfig } from "@perstack/core" +import { generateLockfileToml } from "@perstack/perstack-toml" import { collectToolDefinitionsForExpert } from "@perstack/runtime" import { getEnv } from "@perstack/tui/get-env" import { resolveAllExperts } from "./expert-resolver.js" import { expertToLockfileExpert } from "./lockfile-generator.js" export async function installHandler(options: { - config?: string + configPath: string + perstackConfig: PerstackConfig envPath?: string[] }): Promise { try { - const configPath = await findConfigPath(options.config) - const config = await getPerstackConfig(options.config) + const configPath = options.configPath + const config = options.perstackConfig const envPath = options.envPath && options.envPath.length > 0 ? options.envPath diff --git a/packages/tui/package.json b/packages/tui/package.json index 1a20f4e4..838a3546 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -19,7 +19,6 @@ "dependencies": { "@paralleldrive/cuid2": "^3.3.0", "@perstack/core": "workspace:*", - "@perstack/perstack-toml": "workspace:*", "@perstack/tui-components": "workspace:*", "dotenv": "^17.3.1" }, @@ -28,7 +27,6 @@ "@perstack/runtime": "workspace:*", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.2.3", - "memfs": "^4.56.10", "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.0.18" diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 525c690c..00c01b36 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -1,2 +1,2 @@ -export { runHandler } from "./run-handler.js" +export { type RunHandlerOptions, runHandler } from "./run-handler.js" export { type StartHandlerOptions, startHandler } from "./start-handler.js" diff --git a/packages/tui/src/lib/context.test.ts b/packages/tui/src/lib/context.test.ts index 923f16d4..8b998343 100644 --- a/packages/tui/src/lib/context.test.ts +++ b/packages/tui/src/lib/context.test.ts @@ -1,12 +1,7 @@ -import { vol } from "memfs" +import type { PerstackConfig } from "@perstack/core" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { resolveRunContext } from "./context.js" -vi.mock("node:fs/promises", async () => { - const memfs = await import("memfs") - return memfs.fs.promises -}) - vi.mock("./run-manager.js", () => ({ getCheckpointById: vi.fn(), getMostRecentCheckpoint: vi.fn(), @@ -15,10 +10,16 @@ vi.mock("./run-manager.js", () => ({ describe("resolveRunContext", () => { const originalEnv = { ...process.env } - beforeEach(() => { - vol.reset() - vi.spyOn(process, "cwd").mockReturnValue("/test") + const minimalConfig: PerstackConfig = { + experts: { + test: { + description: "Test expert", + instruction: "Do testing", + }, + }, + } + beforeEach(() => { // Clear process.env for (const key of Object.keys(process.env)) { delete process.env[key] @@ -37,16 +38,7 @@ describe("resolveRunContext", () => { }) it("resolves context with minimal config", async () => { - const tomlContent = ` -[experts.test] -description = "Test expert" -instruction = "Do testing" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - const context = await resolveRunContext({}) + const context = await resolveRunContext({ perstackConfig: minimalConfig }) expect(context.perstackConfig).toBeDefined() expect(context.env).toBeDefined() @@ -56,16 +48,7 @@ instruction = "Do testing" }) it("uses default provider and model", async () => { - const tomlContent = ` -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - const context = await resolveRunContext({}) + const context = await resolveRunContext({ perstackConfig: minimalConfig }) expect(context.providerConfig.providerName).toBe("anthropic") expect(context.model).toBe("claude-sonnet-4-5") @@ -74,84 +57,56 @@ instruction = "Test" it("uses provider from input", async () => { process.env.OPENAI_API_KEY = "openai-key" - const tomlContent = ` -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - const context = await resolveRunContext({ provider: "openai" }) + const context = await resolveRunContext({ perstackConfig: minimalConfig, provider: "openai" }) expect(context.providerConfig.providerName).toBe("openai") }) it("uses model from input", async () => { - const tomlContent = ` -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, + const context = await resolveRunContext({ + perstackConfig: minimalConfig, + model: "claude-opus-4", }) - const context = await resolveRunContext({ model: "claude-opus-4" }) - expect(context.model).toBe("claude-opus-4") }) it("uses provider from perstack.toml", async () => { process.env.GOOGLE_GENERATIVE_AI_API_KEY = "google-key" - const tomlContent = ` -[provider] -providerName = "google" - -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) + const config: PerstackConfig = { + provider: { providerName: "google" }, + experts: { test: { description: "Test", instruction: "Test" } }, + } - const context = await resolveRunContext({}) + const context = await resolveRunContext({ perstackConfig: config }) expect(context.providerConfig.providerName).toBe("google") }) it("uses model from perstack.toml", async () => { - const tomlContent = ` -model = "claude-opus-4" - -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) + const config: PerstackConfig = { + model: "claude-opus-4", + experts: { test: { description: "Test", instruction: "Test" } }, + } - const context = await resolveRunContext({}) + const context = await resolveRunContext({ perstackConfig: config }) expect(context.model).toBe("claude-opus-4") }) it("populates experts with key, name, and version", async () => { - const tomlContent = ` -[experts.my-expert] -description = "My expert" -instruction = "Do stuff" -version = "2.0.0" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) + const config: PerstackConfig = { + experts: { + "my-expert": { + description: "My expert", + instruction: "Do stuff", + version: "2.0.0", + }, + }, + } - const context = await resolveRunContext({}) + const context = await resolveRunContext({ perstackConfig: config }) expect(context.experts["my-expert"]).toBeDefined() expect(context.experts["my-expert"].name).toBe("my-expert") @@ -159,53 +114,37 @@ version = "2.0.0" }) it("defaults version to 1.0.0", async () => { - const tomlContent = ` -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - const context = await resolveRunContext({}) + const context = await resolveRunContext({ perstackConfig: minimalConfig }) expect(context.experts.test.version).toBe("1.0.0") }) it("includes expert skills and delegates", async () => { - const tomlContent = ` -[experts.test] -description = "Test" -instruction = "Test" -delegates = ["other-expert"] - -[experts.test.skills.base] -type = "mcpStdioSkill" -command = "npx" -packageName = "@perstack/base" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) + const config: PerstackConfig = { + experts: { + test: { + description: "Test", + instruction: "Test", + delegates: ["other-expert"], + skills: { + base: { + type: "mcpStdioSkill", + command: "npx", + packageName: "@perstack/base", + }, + }, + }, + }, + } - const context = await resolveRunContext({}) + const context = await resolveRunContext({ perstackConfig: config }) expect(context.experts.test.skills).toBeDefined() expect(context.experts.test.delegates).toEqual(["other-expert"]) }) it("leaves skills undefined when not specified to allow expertSchema default", async () => { - const tomlContent = ` -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - const context = await resolveRunContext({}) + const context = await resolveRunContext({ perstackConfig: minimalConfig }) // skills should be undefined, not an empty object // This allows expertSchema's default value (@perstack/base) to be applied @@ -213,17 +152,8 @@ instruction = "Test" }) it("throws when --resume-from is used without --continue-job", async () => { - const tomlContent = ` -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - await expect(resolveRunContext({ resumeFrom: "checkpoint-123" })).rejects.toThrow( - "--resume-from requires --continue-job", - ) + await expect( + resolveRunContext({ perstackConfig: minimalConfig, resumeFrom: "checkpoint-123" }), + ).rejects.toThrow("--resume-from requires --continue-job") }) }) diff --git a/packages/tui/src/lib/context.ts b/packages/tui/src/lib/context.ts index 4230ab64..8e1c21e3 100644 --- a/packages/tui/src/lib/context.ts +++ b/packages/tui/src/lib/context.ts @@ -1,5 +1,4 @@ import type { Checkpoint, PerstackConfig, ProviderConfig, ProviderName } from "@perstack/core" -import { getPerstackConfig } from "@perstack/perstack-toml" import { getEnv } from "./get-env.js" import { getProviderConfig } from "./provider-config.js" import { getCheckpointById, getMostRecentCheckpoint } from "./run-manager.js" @@ -19,7 +18,7 @@ export type RunContext = { } export type ResolveRunContextInput = { - configPath?: string + perstackConfig: PerstackConfig provider?: string model?: string envPath?: string[] @@ -27,11 +26,10 @@ export type ResolveRunContextInput = { continueJob?: string resumeFrom?: string expertKey?: string - perstackConfig?: PerstackConfig } export async function resolveRunContext(input: ResolveRunContextInput): Promise { - const perstackConfig = input.perstackConfig ?? (await getPerstackConfig(input.configPath)) + const perstackConfig = input.perstackConfig let checkpoint: Checkpoint | undefined if (input.resumeFrom) { if (!input.continueJob) { diff --git a/packages/tui/src/run-handler.ts b/packages/tui/src/run-handler.ts index b9a22848..e61c3cc0 100644 --- a/packages/tui/src/run-handler.ts +++ b/packages/tui/src/run-handler.ts @@ -1,5 +1,5 @@ import { createId } from "@paralleldrive/cuid2" -import type { RunEvent, RuntimeEvent } from "@perstack/core" +import type { Lockfile, PerstackConfig, RunEvent, RuntimeEvent } from "@perstack/core" import { createFilteredEventListener, parseWithFriendlyError, @@ -14,7 +14,6 @@ import { retrieveJob, storeJob, } from "@perstack/filesystem-storage" -import { findLockfile, loadLockfile } from "@perstack/perstack-toml" import { run as perstackRun } from "@perstack/runtime" import { resolveRunContext } from "./lib/context.js" import { @@ -24,10 +23,16 @@ import { const defaultEventListener = (event: RunEvent | RuntimeEvent) => console.log(JSON.stringify(event)) +export interface RunHandlerOptions { + perstackConfig: PerstackConfig + lockfile?: Lockfile +} + export async function runHandler( expertKey: string, query: string, options: Record, + handlerOptions: RunHandlerOptions, ): Promise { const input = parseWithFriendlyError(runCommandInputSchema, { expertKey, query, options }) @@ -51,7 +56,7 @@ export async function runHandler( try { const { perstackConfig, checkpoint, env, providerConfig, model, experts } = await resolveRunContext({ - configPath: input.options.config, + perstackConfig: handlerOptions.perstackConfig, provider: input.options.provider, model: input.options.model, envPath: input.options.envPath, @@ -61,9 +66,7 @@ export async function runHandler( expertKey: input.expertKey, }) - // Load lockfile if present - const lockfilePath = findLockfile() - const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined + const lockfile = handlerOptions.lockfile // Generate job and run IDs const jobId = checkpoint?.jobId ?? input.options.jobId ?? createId() diff --git a/packages/tui/src/start-handler.ts b/packages/tui/src/start-handler.ts index b7e93e24..3d14dc18 100644 --- a/packages/tui/src/start-handler.ts +++ b/packages/tui/src/start-handler.ts @@ -2,6 +2,7 @@ import { createId } from "@paralleldrive/cuid2" import { defaultMaxRetries, defaultTimeout, + type Lockfile, type PerstackConfig, parseWithFriendlyError, startCommandInputSchema, @@ -14,7 +15,6 @@ import { retrieveJob, storeJob, } from "@perstack/filesystem-storage" -import { findLockfile, loadLockfile } from "@perstack/perstack-toml" import { run as perstackRun, runtimeVersion } from "@perstack/runtime" import { type CheckpointHistoryItem, @@ -36,7 +36,8 @@ import { const CONTINUE_TIMEOUT_MS = 60_000 export interface StartHandlerOptions { - perstackConfig?: PerstackConfig + perstackConfig: PerstackConfig + lockfile?: Lockfile additionalEnv?: (env: Record) => Record } @@ -44,7 +45,7 @@ export async function startHandler( expertKey: string | undefined, query: string | undefined, options: Record, - handlerOptions?: StartHandlerOptions, + handlerOptions: StartHandlerOptions, ): Promise { const input = parseWithFriendlyError(startCommandInputSchema, { expertKey, query, options }) @@ -52,7 +53,7 @@ export async function startHandler( // Phase 1: Initialize context const { perstackConfig, checkpoint, env, providerConfig, model, experts } = await resolveRunContext({ - configPath: input.options.config, + perstackConfig: handlerOptions.perstackConfig, provider: input.options.provider, model: input.options.model, envPath: input.options.envPath, @@ -60,7 +61,6 @@ export async function startHandler( continueJob: input.options.continueJob, resumeFrom: input.options.resumeFrom, expertKey: input.expertKey, - perstackConfig: handlerOptions?.perstackConfig, }) if (handlerOptions?.additionalEnv) { @@ -136,9 +136,7 @@ export async function startHandler( return } - // Load lockfile if present - const lockfilePath = findLockfile() - const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined + const lockfile = handlerOptions.lockfile // Phase 3: Execution loop let currentQuery: string | null = selection.query diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc4d2618..cd05d0d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: '@perstack/log': specifier: workspace:* version: link:../../packages/log + '@perstack/perstack-toml': + specifier: workspace:* + version: link:../../packages/perstack-toml '@perstack/tui': specifier: workspace:* version: link:../../packages/tui @@ -763,9 +766,6 @@ importers: '@perstack/core': specifier: workspace:* version: link:../core - '@perstack/perstack-toml': - specifier: workspace:* - version: link:../perstack-toml '@perstack/tui-components': specifier: workspace:* version: link:../tui-components @@ -785,9 +785,6 @@ importers: '@types/node': specifier: ^25.2.3 version: 25.2.3 - memfs: - specifier: ^4.56.10 - version: 4.56.10(tslib@2.8.1) tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) From d80b353ccd38159c43c8b1b9f12ac293f26bd02f Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Sun, 15 Feb 2026 10:37:36 +0000 Subject: [PATCH 3/4] chore: add changeset for config resolution refactor Co-Authored-By: Claude Opus 4.6 --- .changeset/move-config-to-app-layer.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/move-config-to-app-layer.md diff --git a/.changeset/move-config-to-app-layer.md b/.changeset/move-config-to-app-layer.md new file mode 100644 index 00000000..7adb3701 --- /dev/null +++ b/.changeset/move-config-to-app-layer.md @@ -0,0 +1,6 @@ +--- +"perstack": patch +"create-expert": patch +--- + +refactor: move config resolution from library packages to application layer From a5d96d31efa037762caa0ca71aa421ca6eadb0fc Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Sun, 15 Feb 2026 10:39:08 +0000 Subject: [PATCH 4/4] chore: remove unused @perstack/core dependency from create-expert Co-Authored-By: Claude Opus 4.6 --- apps/create-expert/package.json | 1 - pnpm-lock.yaml | 3 --- 2 files changed, 4 deletions(-) diff --git a/apps/create-expert/package.json b/apps/create-expert/package.json index 4c8717bf..caccae84 100644 --- a/apps/create-expert/package.json +++ b/apps/create-expert/package.json @@ -23,7 +23,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@perstack/core": "workspace:*", "@perstack/perstack-toml": "workspace:*", "@perstack/runtime": "workspace:*", "commander": "^14.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd05d0d9..b3698060 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,9 +96,6 @@ importers: apps/create-expert: dependencies: - '@perstack/core': - specifier: workspace:* - version: link:../../packages/core '@perstack/perstack-toml': specifier: workspace:* version: link:../../packages/perstack-toml