UNPKG

@posthog/wizard

Version:

The PostHog wizard helps you to configure your project

588 lines 26.2 kB
"use strict"; /** * Shared agent interface for PostHog wizards * Uses Claude Agent SDK directly with PostHog LLM gateway */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.AgentErrorType = exports.AgentSignals = void 0; exports.wizardCanUseTool = wizardCanUseTool; exports.initializeAgent = initializeAgent; exports.runAgent = runAgent; const path_1 = __importDefault(require("path")); const clack_1 = __importDefault(require("../utils/clack")); const debug_1 = require("../utils/debug"); const analytics_1 = require("../utils/analytics"); const constants_1 = require("./constants"); const urls_1 = require("../utils/urls"); const safe_tools_1 = require("./safe-tools"); // Dynamic import cache for ESM module let _sdkModule = null; async function getSDKModule() { if (!_sdkModule) { _sdkModule = await import('@anthropic-ai/claude-agent-sdk'); } return _sdkModule; } /** * Get the path to the bundled Claude Code CLI from the SDK package. * This ensures we use the SDK's bundled version rather than the user's installed Claude Code. */ function getClaudeCodeExecutablePath() { // require.resolve finds the package's main entry, then we get cli.js from same dir const sdkPackagePath = require.resolve('@anthropic-ai/claude-agent-sdk'); return path_1.default.join(path_1.default.dirname(sdkPackagePath), 'cli.js'); } exports.AgentSignals = { /** Signal emitted when the agent reports progress to the user */ STATUS: '[STATUS]', /** Signal emitted when the agent cannot access the PostHog MCP server */ ERROR_MCP_MISSING: '[ERROR-MCP-MISSING]', /** Signal emitted when the agent cannot access the setup resource */ ERROR_RESOURCE_MISSING: '[ERROR-RESOURCE-MISSING]', /** Signal emitted when the agent provides a remark about its run */ WIZARD_REMARK: '[WIZARD-REMARK]', }; /** * Error types that can be returned from agent execution. * These correspond to the error signals that the agent emits. */ var AgentErrorType; (function (AgentErrorType) { /** Agent could not access the PostHog MCP server */ AgentErrorType["MCP_MISSING"] = "WIZARD_MCP_MISSING"; /** Agent could not access the setup resource */ AgentErrorType["RESOURCE_MISSING"] = "WIZARD_RESOURCE_MISSING"; /** API rate limit exceeded */ AgentErrorType["RATE_LIMIT"] = "WIZARD_RATE_LIMIT"; /** Generic API error */ AgentErrorType["API_ERROR"] = "WIZARD_API_ERROR"; })(AgentErrorType || (exports.AgentErrorType = AgentErrorType = {})); /** * Package managers that can be used to run commands. */ const PACKAGE_MANAGERS = [ // JavaScript 'npm', 'pnpm', 'yarn', 'bun', 'npx', // Python 'pip', 'pip3', 'poetry', 'pipenv', 'uv', ]; /** * Safe scripts/commands that can be run with any package manager. * Uses startsWith matching, so 'build' matches 'build', 'build:prod', etc. * Note: Linting tools are in LINTING_TOOLS and checked separately. */ const SAFE_SCRIPTS = [ // Package installation 'install', 'add', 'ci', // Build 'build', // Type checking (various naming conventions) 'tsc', 'typecheck', 'type-check', 'check-types', 'types', // Linting/formatting script names (actual tools are in LINTING_TOOLS) 'lint', 'format', ]; /** * Dangerous shell operators that could allow command injection. * Note: We handle `2>&1` and `| tail/head` separately as safe patterns. * Note: `&&` is allowed for specific safe patterns like skill installation. */ const DANGEROUS_OPERATORS = /[;`$()]/; /** * Check if command is a PostHog skill installation from MCP. * We control the MCP server, so we only need to verify: * 1. It installs to .claude/skills/ * 2. It downloads from our GitHub releases or localhost (dev) */ function isSkillInstallCommand(command) { if (!command.startsWith('mkdir -p .claude/skills/')) return false; const urlMatch = command.match(/curl -sL ['"]([^'"]+)['"]/); if (!urlMatch) return false; const url = urlMatch[1]; return (url.startsWith('https://github.com/PostHog/examples/releases/') || /^http:\/\/localhost:\d+\//.test(url)); } /** * Check if command is an allowed package manager command. * Matches: <pkg-manager> [run|exec] <safe-script> [args...] */ function matchesAllowedPrefix(command) { const parts = command.split(/\s+/); if (parts.length === 0 || !PACKAGE_MANAGERS.includes(parts[0])) { return false; } // Skip 'run' or 'exec' if present let scriptIndex = 1; if (parts[scriptIndex] === 'run' || parts[scriptIndex] === 'exec') { scriptIndex++; } // Get the script/command portion (may include args) const scriptPart = parts.slice(scriptIndex).join(' '); // Check if script starts with any safe script name or linting tool return (SAFE_SCRIPTS.some((safe) => scriptPart.startsWith(safe)) || safe_tools_1.LINTING_TOOLS.some((tool) => scriptPart.startsWith(tool))); } /** * Permission hook that allows only safe commands. * - Package manager install commands * - Build/typecheck/lint commands for verification * - Piping to tail/head for output limiting is allowed * - Stderr redirection (2>&1) is allowed * - PostHog skill installation commands from MCP */ function wizardCanUseTool(toolName, input) { // Allow all non-Bash tools if (toolName !== 'Bash') { return { behavior: 'allow', updatedInput: input }; } const command = (typeof input.command === 'string' ? input.command : '').trim(); // Check for PostHog skill installation command (before dangerous operator check) // These commands use && chaining but are generated by MCP with a strict format if (isSkillInstallCommand(command)) { (0, debug_1.logToFile)(`Allowing skill installation command: ${command}`); (0, debug_1.debug)(`Allowing skill installation command: ${command}`); return { behavior: 'allow', updatedInput: input }; } // Block definitely dangerous operators: ; ` $ ( ) if (DANGEROUS_OPERATORS.test(command)) { (0, debug_1.logToFile)(`Denying bash command with dangerous operators: ${command}`); (0, debug_1.debug)(`Denying bash command with dangerous operators: ${command}`); analytics_1.analytics.capture(constants_1.WIZARD_INTERACTION_EVENT_NAME, { action: 'bash command denied', reason: 'dangerous operators', command, }); return { behavior: 'deny', message: `Bash command not allowed. Shell operators like ; \` $ ( ) are not permitted.`, }; } // Normalize: remove safe stderr redirection (2>&1, 2>&2, etc.) const normalized = command.replace(/\s*\d*>&\d+\s*/g, ' ').trim(); // Check for pipe to tail/head (safe output limiting) const pipeMatch = normalized.match(/^(.+?)\s*\|\s*(tail|head)(\s+\S+)*\s*$/); if (pipeMatch) { const baseCommand = pipeMatch[1].trim(); // Block if base command has pipes or & (multiple chaining) if (/[|&]/.test(baseCommand)) { (0, debug_1.logToFile)(`Denying bash command with multiple pipes: ${command}`); (0, debug_1.debug)(`Denying bash command with multiple pipes: ${command}`); analytics_1.analytics.capture(constants_1.WIZARD_INTERACTION_EVENT_NAME, { action: 'bash command denied', reason: 'multiple pipes', command, }); return { behavior: 'deny', message: `Bash command not allowed. Only single pipe to tail/head is permitted.`, }; } if (matchesAllowedPrefix(baseCommand)) { (0, debug_1.logToFile)(`Allowing bash command with output limiter: ${command}`); (0, debug_1.debug)(`Allowing bash command with output limiter: ${command}`); return { behavior: 'allow', updatedInput: input }; } } // Block remaining pipes and & (not covered by tail/head case above) if (/[|&]/.test(normalized)) { (0, debug_1.logToFile)(`Denying bash command with pipe/&: ${command}`); (0, debug_1.debug)(`Denying bash command with pipe/&: ${command}`); analytics_1.analytics.capture(constants_1.WIZARD_INTERACTION_EVENT_NAME, { action: 'bash command denied', reason: 'disallowed pipe', command, }); return { behavior: 'deny', message: `Bash command not allowed. Pipes are only permitted with tail/head for output limiting.`, }; } // Check if command starts with any allowed prefix (package manager commands) if (matchesAllowedPrefix(normalized)) { (0, debug_1.logToFile)(`Allowing bash command: ${command}`); (0, debug_1.debug)(`Allowing bash command: ${command}`); return { behavior: 'allow', updatedInput: input }; } (0, debug_1.logToFile)(`Denying bash command: ${command}`); (0, debug_1.debug)(`Denying bash command: ${command}`); analytics_1.analytics.capture(constants_1.WIZARD_INTERACTION_EVENT_NAME, { action: 'bash command denied', reason: 'not in allowlist', command, }); return { behavior: 'deny', message: `Bash command not allowed. Only install, build, typecheck, lint, and formatting commands are permitted.`, }; } /** * Initialize agent configuration for the LLM gateway */ function initializeAgent(config, options) { // Initialize log file for this run (0, debug_1.initLogFile)(); (0, debug_1.logToFile)('Agent initialization starting'); (0, debug_1.logToFile)('Install directory:', options.installDir); clack_1.default.log.step('Initializing Claude agent...'); try { // Configure LLM gateway environment variables (inherited by SDK subprocess) const gatewayUrl = (0, urls_1.getLlmGatewayUrlFromHost)(config.posthogApiHost); process.env.ANTHROPIC_BASE_URL = gatewayUrl; process.env.ANTHROPIC_AUTH_TOKEN = config.posthogApiKey; // Use CLAUDE_CODE_OAUTH_TOKEN to override any stored /login credentials process.env.CLAUDE_CODE_OAUTH_TOKEN = config.posthogApiKey; // Disable experimental betas (like input_examples) that the LLM gateway doesn't support process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 'true'; (0, debug_1.logToFile)('Configured LLM gateway:', gatewayUrl); // Configure MCP server with PostHog authentication const mcpServers = { posthog: { type: 'http', url: config.posthogMcpUrl, headers: { Authorization: `Bearer ${config.posthogApiKey}`, }, }, }; const agentRunConfig = { workingDirectory: config.workingDirectory, mcpServers, model: 'claude-opus-4-5-20251101', }; (0, debug_1.logToFile)('Agent config:', { workingDirectory: agentRunConfig.workingDirectory, posthogMcpUrl: config.posthogMcpUrl, gatewayUrl, apiKeyPresent: !!config.posthogApiKey, }); if (options.debug) { (0, debug_1.debug)('Agent config:', { workingDirectory: agentRunConfig.workingDirectory, posthogMcpUrl: config.posthogMcpUrl, gatewayUrl, apiKeyPresent: !!config.posthogApiKey, }); } clack_1.default.log.step(`Verbose logs: ${debug_1.LOG_FILE_PATH}`); clack_1.default.log.success("Agent initialized. Let's get cooking!"); return agentRunConfig; } catch (error) { clack_1.default.log.error(`Failed to initialize agent: ${error.message}`); (0, debug_1.logToFile)('Agent initialization error:', error); (0, debug_1.debug)('Agent initialization error:', error); throw error; } } /** * Execute an agent with the provided prompt and options * Handles the full lifecycle: spinner, execution, error handling * * @returns An object containing any error detected in the agent's output */ async function runAgent(agentConfig, prompt, options, spinner, config) { const { estimatedDurationMinutes = 8, spinnerMessage = 'Customizing your PostHog setup...', successMessage = 'PostHog integration complete', errorMessage = 'Integration failed', } = config ?? {}; const { query } = await getSDKModule(); clack_1.default.log.step(`This whole process should take about ${estimatedDurationMinutes} minutes including error checking and fixes.\n\nGrab some coffee!`); spinner.start(spinnerMessage); const cliPath = getClaudeCodeExecutablePath(); (0, debug_1.logToFile)('Starting agent run'); (0, debug_1.logToFile)('Claude Code executable:', cliPath); (0, debug_1.logToFile)('Prompt:', prompt); const startTime = Date.now(); const collectedText = []; // Track if we received a successful result (before any cleanup errors) let receivedSuccessResult = false; // Workaround for SDK bug: stdin closes before canUseTool responses can be sent. // The fix is to use an async generator for the prompt that stays open until // the result is received, keeping the stdin stream alive for permission responses. // See: https://github.com/anthropics/claude-code/issues/4775 // See: https://github.com/anthropics/claude-agent-sdk-typescript/issues/41 let signalDone; const resultReceived = new Promise((resolve) => { signalDone = resolve; }); const createPromptStream = async function* () { yield { type: 'user', session_id: '', message: { role: 'user', content: prompt }, parent_tool_use_id: null, }; await resultReceived; }; // Helper to handle successful completion (used in normal path and race condition recovery) const completeWithSuccess = (suppressedError) => { const durationMs = Date.now() - startTime; const durationSeconds = Math.round(durationMs / 1000); if (suppressedError) { (0, debug_1.logToFile)(`Ignoring post-completion error, agent completed successfully in ${durationSeconds}s`); (0, debug_1.logToFile)('Suppressed error:', suppressedError.message); } else { (0, debug_1.logToFile)(`Agent run completed in ${durationSeconds}s`); } // Extract and capture the agent's reflection on the run const outputText = collectedText.join('\n'); const remarkRegex = new RegExp(`${exports.AgentSignals.WIZARD_REMARK.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*(.+?)(?:\\n|$)`, 's'); const remarkMatch = outputText.match(remarkRegex); if (remarkMatch && remarkMatch[1]) { const remark = remarkMatch[1].trim(); if (remark) { analytics_1.analytics.capture(constants_1.WIZARD_REMARK_EVENT_NAME, { remark }); } } analytics_1.analytics.capture(constants_1.WIZARD_INTERACTION_EVENT_NAME, { action: 'agent integration completed', duration_ms: durationMs, duration_seconds: durationSeconds, }); spinner.stop(successMessage); return {}; }; try { // Tools needed for the wizard: // - File operations: Read, Write, Edit // - Search: Glob, Grep // - Commands: Bash (with restrictions via canUseTool) // - MCP discovery: ListMcpResourcesTool (to find available skills) // - Skills: Skill (to load installed PostHog skills) // MCP tools (PostHog) come from mcpServers, not allowedTools const allowedTools = [ 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'ListMcpResourcesTool', 'Skill', ]; const response = query({ prompt: createPromptStream(), options: { model: agentConfig.model, cwd: agentConfig.workingDirectory, permissionMode: 'acceptEdits', mcpServers: agentConfig.mcpServers, // Load skills from project's .claude/skills/ directory settingSources: ['project'], // Explicitly enable required tools including Skill allowedTools, env: { ...process.env, // Prevent user's Anthropic API key from overriding the wizard's OAuth token ANTHROPIC_API_KEY: undefined, }, canUseTool: (toolName, input) => { (0, debug_1.logToFile)('canUseTool called:', { toolName, input }); const result = wizardCanUseTool(toolName, input); (0, debug_1.logToFile)('canUseTool result:', result); return Promise.resolve(result); }, tools: { type: 'preset', preset: 'claude_code' }, // Capture stderr from CLI subprocess for debugging stderr: (data) => { (0, debug_1.logToFile)('CLI stderr:', data); if (options.debug) { (0, debug_1.debug)('CLI stderr:', data); } }, // Stop hook to have the agent reflect on its run hooks: { Stop: [ { hooks: [ (input) => { (0, debug_1.logToFile)('Stop hook triggered', { stop_hook_active: input.stop_hook_active, }); // Only ask for reflection on first stop (not after reflection is provided) if (input.stop_hook_active) { (0, debug_1.logToFile)('Stop hook: allowing stop (already reflected)'); return {}; // Allow stopping } (0, debug_1.logToFile)('Stop hook: requesting reflection'); return { decision: 'block', reason: `Before concluding, provide a brief remark about what information or guidance would have been useful to have in the integration prompt or documentation for this run. Specifically cite anything that would have prevented tool failures, erroneous edits, or other wasted turns. Format your response exactly as: ${exports.AgentSignals.WIZARD_REMARK} Your remark here`, }; }, ], timeout: 30, }, ], }, }, }); // Process the async generator for await (const message of response) { handleSDKMessage(message, options, spinner, collectedText); // Signal completion when result received if (message.type === 'result') { // Track successful results before any potential cleanup errors // The SDK may emit a second error result during cleanup due to a race condition if (message.subtype === 'success' && !message.is_error) { receivedSuccessResult = true; } signalDone(); } } const outputText = collectedText.join('\n'); // Check for error markers in the agent's output if (outputText.includes(exports.AgentSignals.ERROR_MCP_MISSING)) { (0, debug_1.logToFile)('Agent error: MCP_MISSING'); spinner.stop('Agent could not access PostHog MCP'); return { error: AgentErrorType.MCP_MISSING }; } if (outputText.includes(exports.AgentSignals.ERROR_RESOURCE_MISSING)) { (0, debug_1.logToFile)('Agent error: RESOURCE_MISSING'); spinner.stop('Agent could not access setup resource'); return { error: AgentErrorType.RESOURCE_MISSING }; } // Check for API errors (rate limits, etc.) if (outputText.includes('API Error: 429')) { (0, debug_1.logToFile)('Agent error: RATE_LIMIT'); spinner.stop('Rate limit exceeded'); return { error: AgentErrorType.RATE_LIMIT, message: outputText }; } if (outputText.includes('API Error:')) { (0, debug_1.logToFile)('Agent error: API_ERROR'); spinner.stop('API error occurred'); return { error: AgentErrorType.API_ERROR, message: outputText }; } return completeWithSuccess(); } catch (error) { // Signal done to unblock the async generator signalDone(); // If we already received a successful result, the error is from SDK cleanup // This happens due to a race condition: the SDK tries to send a cleanup command // after the prompt stream closes, but streaming mode is still active. // See: https://github.com/anthropics/claude-agent-sdk-typescript/issues/41 if (receivedSuccessResult) { return completeWithSuccess(error); } // Check if we collected an API error before the exception was thrown const outputText = collectedText.join('\n'); // Extract just the API error line(s), not the entire output const apiErrorMatch = outputText.match(/API Error: [^\n]+/g); const apiErrorMessage = apiErrorMatch ? apiErrorMatch.join('\n') : 'Unknown API error'; if (outputText.includes('API Error: 429')) { (0, debug_1.logToFile)('Agent error (caught): RATE_LIMIT'); spinner.stop('Rate limit exceeded'); return { error: AgentErrorType.RATE_LIMIT, message: apiErrorMessage }; } if (outputText.includes('API Error:')) { (0, debug_1.logToFile)('Agent error (caught): API_ERROR'); spinner.stop('API error occurred'); return { error: AgentErrorType.API_ERROR, message: apiErrorMessage }; } // No API error found, re-throw the original exception spinner.stop(errorMessage); clack_1.default.log.error(`Error: ${error.message}`); (0, debug_1.logToFile)('Agent run failed:', error); (0, debug_1.debug)('Full error:', error); throw error; } } /** * Handle SDK messages and provide user feedback */ function handleSDKMessage(message, options, spinner, collectedText) { (0, debug_1.logToFile)(`SDK Message: ${message.type}`, JSON.stringify(message, null, 2)); if (options.debug) { (0, debug_1.debug)(`SDK Message type: ${message.type}`); } switch (message.type) { case 'assistant': { // Extract text content from assistant messages const content = message.message?.content; if (Array.isArray(content)) { for (const block of content) { if (block.type === 'text' && typeof block.text === 'string') { collectedText.push(block.text); // Check for [STATUS] markers const statusRegex = new RegExp(`^.*${exports.AgentSignals.STATUS.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*(.+?)$`, 'm'); const statusMatch = block.text.match(statusRegex); if (statusMatch) { spinner.stop(statusMatch[1].trim()); spinner.start('Integrating PostHog...'); } } } } break; } case 'result': { // Check is_error flag - can be true even when subtype is 'success' if (message.is_error) { (0, debug_1.logToFile)('Agent result with error:', message.result); if (typeof message.result === 'string') { collectedText.push(message.result); } if (message.errors) { for (const err of message.errors) { clack_1.default.log.error(`Error: ${err}`); (0, debug_1.logToFile)('ERROR:', err); } } } else if (message.subtype === 'success') { (0, debug_1.logToFile)('Agent completed successfully'); if (typeof message.result === 'string') { collectedText.push(message.result); } } else { // Error result (0, debug_1.logToFile)('Agent error result:', message.subtype); if (message.errors) { for (const err of message.errors) { clack_1.default.log.error(`Error: ${err}`); (0, debug_1.logToFile)('ERROR:', err); } } } break; } case 'system': { if (message.subtype === 'init') { (0, debug_1.logToFile)('Agent session initialized', { model: message.model, tools: message.tools?.length, mcpServers: message.mcp_servers, }); } break; } default: // Log other message types for debugging if (options.debug) { (0, debug_1.debug)(`Unhandled message type: ${message.type}`); } break; } } //# sourceMappingURL=agent-interface.js.map