From 9fc690c0a107d9b977d7bbe45e48c90f8a3a9e52 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 26 Jun 2026 21:03:17 -0400 Subject: [PATCH] feat(runner): wizard tools as pi custom tools (#694) pi registers the wizard env-file tools (createWizardPiTools) as pi custom tools, so a pi run can manage .env keys the same way the anthropic path does. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + pnpm-lock.yaml | 3 + src/lib/agent/runner/backends/pi-tools.ts | 173 ++++++++++++++++++++++ src/lib/agent/runner/backends/pi.ts | 13 ++ 4 files changed, 190 insertions(+) create mode 100644 src/lib/agent/runner/backends/pi-tools.ts diff --git a/package.json b/package.json index cdb48441..4387cb5b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "read-env": "^1.3.0", "recast": "^0.23.3", "semver": "^7.5.3", + "typebox": "1.1.38", "uuid": "^11.1.0", "xcode": "3.0.1", "xml-js": "^1.6.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61cb1594..dda56fda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: semver: specifier: ^7.5.3 version: 7.7.1 + typebox: + specifier: 1.1.38 + version: 1.1.38 uuid: specifier: ^11.1.0 version: 11.1.0 diff --git a/src/lib/agent/runner/backends/pi-tools.ts b/src/lib/agent/runner/backends/pi-tools.ts new file mode 100644 index 00000000..95f0affc --- /dev/null +++ b/src/lib/agent/runner/backends/pi-tools.ts @@ -0,0 +1,173 @@ +/** + * Wizard capabilities as pi custom tools (#5). pi does not mount MCP servers, + * so the tools the wizard prompt depends on — skill discovery/install and + * fenced `.env` edits — are exposed to pi as native `defineTool` tools backed + * by the same helpers the claude-agent-sdk path uses (`fetchSkillMenu`, + * `installSkillById`, `parseEnvKeys`, `mergeEnvValues`). Same tool names as the + * MCP server so the shared prompt is unchanged. + * + * v1 covers the four tools a framework integration needs. `wizard_ask` is + * interactive-only (disabled in CI) and the secret-vault `secretRef` path is a + * follow-up — CI passes literal values. + */ + +import fs from 'fs'; +import path from 'path'; +import { Type } from 'typebox'; +import { defineTool } from '@earendil-works/pi-coding-agent'; +import type { ToolDefinition } from '@earendil-works/pi-coding-agent'; +import { logToFile } from '@utils/debug'; +import { + fetchSkillMenu, + installSkillById, + mergeEnvValues, + parseEnvKeys, + resolveEnvPath, +} from '@lib/wizard-tools'; + +function text(s: string): { + content: [{ type: 'text'; text: string }]; + details: unknown; +} { + return { content: [{ type: 'text', text: s }], details: {} }; +} + +export interface PiToolsContext { + workingDirectory: string; + skillsBaseUrl: string; +} + +export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] { + const { workingDirectory, skillsBaseUrl } = ctx; + + // Fetch the skill menu at most once per run — the agent calls load_skill_menu + // 2-3× otherwise, each a fresh HTTP round-trip (profiled slowness). + let menuPromise: ReturnType | undefined; + const getSkillMenu = () => (menuPromise ??= fetchSkillMenu(skillsBaseUrl)); + + const loadSkillMenu = defineTool({ + name: 'load_skill_menu', + label: 'Load skill menu', + description: + 'Load available PostHog skills for a category. Returns skill IDs and names. Call this first, then install_skill with the chosen ID.', + promptSnippet: + 'load_skill_menu(category) — list installable PostHog skills', + parameters: Type.Object({ + category: Type.String({ + description: 'Skill category, e.g. "integration"', + }), + }), + async execute(_id, args) { + const menu = await getSkillMenu(); + if (!menu) return text('Error: could not load the skill menu.'); + const skills = menu.categories[args.category] ?? []; + if (skills.length === 0) { + return text(`No skills found for category "${args.category}".`); + } + logToFile(`[pi] load_skill_menu: ${skills.length} skills`); + return text(skills.map((s) => `- ${s.id}: ${s.name}`).join('\n')); + }, + }); + + const installSkill = defineTool({ + name: 'install_skill', + label: 'Install skill', + description: + 'Download and install a PostHog skill by ID into .claude/skills//. Call load_skill_menu first. Then read the installed SKILL.md and follow it.', + promptSnippet: + 'install_skill(skillId) — install a skill, then read its SKILL.md', + parameters: Type.Object({ + skillId: Type.String({ description: 'Skill ID from load_skill_menu' }), + }), + async execute(_id, args) { + const result = await installSkillById( + args.skillId, + workingDirectory, + skillsBaseUrl, + ); + if (result.kind !== 'ok') { + logToFile(`[pi] install_skill ${args.skillId}: ${result.kind}`); + return text( + `Error installing skill "${args.skillId}": ${result.kind}. Use load_skill_menu to see valid IDs.`, + ); + } + logToFile(`[pi] install_skill ${args.skillId} -> ${result.path}`); + return text( + `Installed "${args.skillId}" at ${result.path}. Read ${result.path}/SKILL.md and follow it.`, + ); + }, + }); + + const checkEnvKeys = defineTool({ + name: 'check_env_keys', + label: 'Check env keys', + description: + 'Check which environment variable keys are present or missing in a .env file. Never reveals values.', + promptSnippet: 'check_env_keys(filePath, keys) — see which .env keys exist', + parameters: Type.Object({ + filePath: Type.String({ + description: 'Path to the .env file, relative to the project root', + }), + keys: Type.Array(Type.String(), { + description: 'Environment variable key names to check', + }), + }), + async execute(_id, args) { + const resolved = resolveEnvPath(workingDirectory, args.filePath); + const existing = fs.existsSync(resolved) + ? parseEnvKeys(await fs.promises.readFile(resolved, 'utf8')) + : new Set(); + const results: Record = {}; + for (const key of args.keys) { + results[key] = existing.has(key) ? 'present' : 'missing'; + } + return text(JSON.stringify(results, null, 2)); + }, + }); + + const setEnvValues = defineTool({ + name: 'set_env_values', + label: 'Set env values', + description: + 'Create or update environment variable keys in a .env file (creates the file if missing). Pass literal string values.', + promptSnippet: + 'set_env_values(filePath, values) — write .env keys (never hardcode secrets in source)', + parameters: Type.Object({ + filePath: Type.String({ + description: 'Path to the .env file, relative to the project root', + }), + values: Type.Record(Type.String(), Type.String(), { + description: 'Key → literal value', + }), + }), + async execute(_id, args) { + const forbidden = Object.keys(args.values).find( + (k) => k.toUpperCase() === 'POSTHOG_KEY', + ); + if (forbidden) { + return text( + `Error: "${forbidden}" is not a valid PostHog env var name. Use the framework-specific key (e.g. NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN).`, + ); + } + const resolved = resolveEnvPath(workingDirectory, args.filePath); + const existing = fs.existsSync(resolved) + ? await fs.promises.readFile(resolved, 'utf8') + : ''; + const merged = mergeEnvValues(existing, args.values); + const dir = path.dirname(resolved); + if (!fs.existsSync(dir)) + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(resolved, merged, 'utf8'); + logToFile( + `[pi] set_env_values: ${resolved} keys=${Object.keys(args.values).join( + ',', + )}`, + ); + return text( + `Wrote ${Object.keys(args.values).length} key(s) to ${args.filePath}.`, + ); + }, + }); + + return [loadSkillMenu, installSkill, checkEnvKeys, setEnvValues]; +} diff --git a/src/lib/agent/runner/backends/pi.ts b/src/lib/agent/runner/backends/pi.ts index e204a7cd..275b4645 100644 --- a/src/lib/agent/runner/backends/pi.ts +++ b/src/lib/agent/runner/backends/pi.ts @@ -140,12 +140,25 @@ export const piBackend: AgentRunner = { }); await resourceLoader.reload(); + // Wizard capabilities as custom tools (pi has no MCP): skill + // discovery/install + fenced .env edits, same names as the MCP server so + // the shared prompt is unchanged. pi's built-in Read/Write/Edit/Bash do + // the code changes. Loaded lazily — it pulls in typebox (ESM), which must + // stay out of the static module graph so CommonJS unit tests can load the + // backend seam without parsing it. + const { createWizardPiTools } = await import('./pi-tools'); + const customTools = createWizardPiTools({ + workingDirectory: session.installDir, + skillsBaseUrl: boot.skillsBaseUrl, + }); + const { session: agentSession } = await createAgentSession({ model, modelRegistry: registry, cwd: session.installDir, sessionManager: SessionManager.inMemory(session.installDir), resourceLoader, + customTools, }); // Map pi events onto the run spinner + the log file. Markers + todos are