Skip to content
Draft
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

157 changes: 157 additions & 0 deletions src/lib/agent/runner/backends/pi-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* 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;

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 fetchSkillMenu(skillsBaseUrl);
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/<skillId>/. 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(fs.readFileSync(resolved, 'utf8'))
: new Set<string>();
const results: Record<string, 'present' | 'missing'> = {};
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)
? fs.readFileSync(resolved, 'utf8')
: '';
const merged = mergeEnvValues(existing, args.values);
const dir = path.dirname(resolved);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(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];
}
13 changes: 13 additions & 0 deletions src/lib/agent/runner/backends/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,25 @@ export const piBackend: AgentBackend = {
});
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
Expand Down
Loading