Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/move-config-to-app-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"perstack": patch
"create-expert": patch
---

refactor: move config resolution from library packages to application layer
11 changes: 5 additions & 6 deletions apps/create-expert/bin/cli.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
#!/usr/bin/env node

import { readFileSync } from "node:fs"
import { parseWithFriendlyError, perstackConfigSchema } from "@perstack/core"
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 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")),
)

new Command()
.name(packageJson.name)
Expand All @@ -22,8 +17,12 @@ new Command()
.option("--continue", "Continue the most recent job with new query")
.option("--continue-job <jobId>", "Continue the specified job with new query")
.action(async (query: string | undefined, options: Record<string, unknown>) => {
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]
Expand Down
5 changes: 2 additions & 3 deletions apps/create-expert/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,12 @@
"typecheck": "tsc --noEmit"
},
"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:*",
Expand Down
29 changes: 26 additions & 3 deletions apps/perstack/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -89,7 +105,10 @@ program
"--filter <types>",
"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")
Expand Down Expand Up @@ -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()
1 change: 1 addition & 0 deletions apps/perstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/installer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
13 changes: 7 additions & 6 deletions packages/installer/src/handler.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { writeFile } from "node:fs/promises"
import path from "node:path"
import type { Lockfile, LockfileExpert } from "@perstack/core"
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 { 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
configPath: string
perstackConfig: PerstackConfig
envPath?: string[]
}): Promise<void> {
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
Expand Down
3 changes: 2 additions & 1 deletion packages/installer/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export { generateLockfileToml } from "@perstack/perstack-toml"
export {
configExpertToExpert,
type PublishedExpertData,
resolveAllExperts,
toRuntimeExpert,
} from "./expert-resolver.js"
export { installHandler } from "./handler.js"
export { expertToLockfileExpert, generateLockfileToml } from "./lockfile-generator.js"
export { expertToLockfileExpert } from "./lockfile-generator.js"
7 changes: 1 addition & 6 deletions packages/installer/src/lockfile-generator.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,7 +21,3 @@ export function expertToLockfileExpert(
toolDefinitions,
}
}

export function generateLockfileToml(lockfile: Lockfile): string {
return TOML.stringify(lockfile)
}
44 changes: 44 additions & 0 deletions packages/perstack-toml/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand All @@ -10,12 +11,12 @@ export async function getPerstackConfig(configPath?: string): Promise<PerstackCo
if (configString === null) {
throw new Error("perstack.toml not found. Create one or specify --config path.")
}
return await parsePerstackConfig(configString)
return parsePerstackConfig(configString)
}

function isRemoteUrl(configPath: string): boolean {
const lower = configPath.toLowerCase()
return lower.startsWith("https://") || lower.startsWith("http://")
export function parsePerstackConfig(config: string): PerstackConfig {
const toml = TOML.parse(config ?? "")
return parseWithFriendlyError(perstackConfigSchema, toml, "perstack.toml")
}

async function fetchRemoteConfig(url: string): Promise<string> {
Expand Down Expand Up @@ -70,11 +71,6 @@ async function findPerstackConfigStringRecursively(cwd: string): Promise<string
}
}

async function parsePerstackConfig(config: string): Promise<PerstackConfig> {
const toml = TOML.parse(config ?? "")
return parseWithFriendlyError(perstackConfigSchema, toml, "perstack.toml")
}

export async function findConfigPath(configPath?: string): Promise<string> {
if (configPath) {
return path.resolve(process.cwd(), configPath)
Expand Down
2 changes: 2 additions & 0 deletions packages/perstack-toml/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { findConfigPath, getPerstackConfig, parsePerstackConfig } from "./config.js"
export { findLockfile, generateLockfileToml, loadLockfile } from "./lockfile.js"
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
}
4 changes: 4 additions & 0 deletions packages/perstack-toml/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function isRemoteUrl(configPath: string): boolean {
const lower = configPath.toLowerCase()
return lower.startsWith("https://") || lower.startsWith("http://")
}
5 changes: 5 additions & 0 deletions packages/perstack-toml/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}
5 changes: 1 addition & 4 deletions packages/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -21,15 +20,13 @@
"@paralleldrive/cuid2": "^3.3.0",
"@perstack/core": "workspace:*",
"@perstack/tui-components": "workspace:*",
"dotenv": "^17.3.1",
"smol-toml": "^1.6.0"
"dotenv": "^17.3.1"
},
"devDependencies": {
"@perstack/filesystem-storage": "workspace:*",
"@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"
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Loading