diff --git a/src/flask/flask-wizard-agent.ts b/src/flask/flask-wizard-agent.ts new file mode 100644 index 0000000..bb8959d --- /dev/null +++ b/src/flask/flask-wizard-agent.ts @@ -0,0 +1,154 @@ +/* Flask wizard using posthog-agent with PostHog MCP */ +import type { WizardOptions } from '../utils/types'; +import type { FrameworkConfig } from '../lib/framework-config'; +import { enableDebugLogs } from '../utils/debug'; +import { runAgentWizard } from '../lib/agent-runner'; +import { Integration } from '../lib/constants'; +import clack from '../utils/clack'; +import chalk from 'chalk'; +import * as semver from 'semver'; +import { + getFlaskVersion, + getFlaskProjectType, + getFlaskProjectTypeName, + getFlaskVersionBucket, + FlaskProjectType, + findFlaskAppFile, +} from './utils'; + +/** + * Flask framework configuration for the universal agent runner + */ +const MINIMUM_FLASK_VERSION = '2.0.0'; + +const FLASK_AGENT_CONFIG: FrameworkConfig = { + metadata: { + name: 'Flask', + integration: Integration.flask, + docsUrl: 'https://posthog.com/docs/libraries/python', + unsupportedVersionDocsUrl: 'https://posthog.com/docs/libraries/python', + gatherContext: async (options: WizardOptions) => { + const projectType = await getFlaskProjectType(options); + const appFile = await findFlaskAppFile(options); + return { projectType, appFile }; + }, + }, + + detection: { + packageName: 'flask', + packageDisplayName: 'Flask', + usesPackageJson: false, + getVersion: (_packageJson: any) => { + // For Flask, we don't use package.json. Version is extracted separately + // from requirements.txt or pyproject.toml in the wizard entry point + return undefined; + }, + getVersionBucket: getFlaskVersionBucket, + }, + + environment: { + uploadToHosting: false, + getEnvVars: (apiKey: string, host: string) => ({ + POSTHOG_API_KEY: apiKey, + POSTHOG_HOST: host, + }), + }, + + analytics: { + getTags: (context: any) => { + const projectType = context.projectType as FlaskProjectType; + return { + projectType: projectType || 'unknown', + }; + }, + }, + + prompts: { + projectTypeDetection: + 'This is a Python/Flask project. Look for requirements.txt, pyproject.toml, setup.py, Pipfile, or app.py/wsgi.py to confirm.', + packageInstallation: + 'Use pip, poetry, or pipenv based on existing config files (requirements.txt, pyproject.toml, Pipfile). Do not pin the posthog version - just add "posthog" without version constraints.', + getAdditionalContextLines: (context: any) => { + const projectType = context.projectType as FlaskProjectType; + const projectTypeName = projectType + ? getFlaskProjectTypeName(projectType) + : 'unknown'; + + // Map project type to framework ID for MCP docs resource + const frameworkIdMap: Record = { + [FlaskProjectType.STANDARD]: 'flask', + [FlaskProjectType.RESTFUL]: 'flask', + [FlaskProjectType.RESTX]: 'flask', + [FlaskProjectType.SMOREST]: 'flask', + [FlaskProjectType.BLUEPRINT]: 'flask', + }; + + const frameworkId = projectType ? frameworkIdMap[projectType] : 'flask'; + + const lines = [ + `Project type: ${projectTypeName}`, + `Framework docs ID: ${frameworkId} (use posthog://docs/frameworks/${frameworkId} for documentation)`, + ]; + + if (context.appFile) { + lines.push(`App file: ${context.appFile}`); + } + + return lines; + }, + }, + + ui: { + successMessage: 'PostHog integration complete', + estimatedDurationMinutes: 5, + getOutroChanges: (context: any) => { + const projectType = context.projectType as FlaskProjectType; + const projectTypeName = projectType + ? getFlaskProjectTypeName(projectType) + : 'Flask'; + return [ + `Analyzed your ${projectTypeName} project structure`, + `Installed the PostHog Python package`, + `Configured PostHog in your Flask application`, + `Added PostHog initialization with automatic event tracking`, + ]; + }, + getOutroNextSteps: () => [ + 'Start your Flask development server to see PostHog in action', + 'Visit your PostHog dashboard to see incoming events', + 'Use posthog.identify() to associate events with users', + ], + }, +}; + +/** + * Flask wizard powered by the universal agent runner. + */ +export async function runFlaskWizardAgent( + options: WizardOptions, +): Promise { + if (options.debug) { + enableDebugLogs(); + } + + // Check Flask version - agent wizard requires >= 2.0.0 + const flaskVersion = await getFlaskVersion(options); + + if (flaskVersion) { + const coercedVersion = semver.coerce(flaskVersion); + if (coercedVersion && semver.lt(coercedVersion, MINIMUM_FLASK_VERSION)) { + const docsUrl = + FLASK_AGENT_CONFIG.metadata.unsupportedVersionDocsUrl ?? + FLASK_AGENT_CONFIG.metadata.docsUrl; + + clack.log.warn( + `Sorry: the wizard can't help you with Flask ${flaskVersion}. Upgrade to Flask ${MINIMUM_FLASK_VERSION} or later, or check out the manual setup guide.`, + ); + clack.log.info(`Setup Flask manually: ${chalk.cyan(docsUrl)}`); + clack.outro('PostHog wizard will see you next time!'); + return; + } + } + + await runAgentWizard(FLASK_AGENT_CONFIG, options); +} diff --git a/src/flask/utils.ts b/src/flask/utils.ts new file mode 100644 index 0000000..a3c2e00 --- /dev/null +++ b/src/flask/utils.ts @@ -0,0 +1,394 @@ +import { major, minVersion } from 'semver'; +import fg from 'fast-glob'; +import clack from '../utils/clack'; +import type { WizardOptions } from '../utils/types'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export enum FlaskProjectType { + STANDARD = 'standard', // Basic Flask app + RESTFUL = 'restful', // Flask-RESTful API + RESTX = 'restx', // Flask-RESTX (Swagger docs) + SMOREST = 'smorest', // flask-smorest (OpenAPI) + BLUEPRINT = 'blueprint', // Large app with blueprints +} + +const IGNORE_PATTERNS = [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/venv/**', + '**/.venv/**', + '**/env/**', + '**/.env/**', + '**/__pycache__/**', + '**/migrations/**', + '**/instance/**', +]; + +/** + * Get Flask version bucket for analytics + */ +export function getFlaskVersionBucket(version: string | undefined): string { + if (!version) { + return 'none'; + } + + try { + const minVer = minVersion(version); + if (!minVer) { + return 'invalid'; + } + const majorVersion = major(minVer); + if (majorVersion >= 2) { + return `${majorVersion}.x`; + } + return `<2.0.0`; + } catch { + return 'unknown'; + } +} + +/** + * Extract Flask version from requirements files or pyproject.toml + */ +export async function getFlaskVersion( + options: Pick, +): Promise { + const { installDir } = options; + + // Check requirements files + const requirementsFiles = await fg( + ['**/requirements*.txt', '**/pyproject.toml', '**/setup.py', '**/Pipfile'], + { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }, + ); + + for (const reqFile of requirementsFiles) { + try { + const content = fs.readFileSync(path.join(installDir, reqFile), 'utf-8'); + + // Try to extract version from requirements.txt format (Flask==3.0.0 or flask>=2.0) + const requirementsMatch = content.match( + /[Ff]lask[=<>~!]+([0-9]+\.[0-9]+(?:\.[0-9]+)?)/, + ); + if (requirementsMatch) { + return requirementsMatch[1]; + } + + // Try to extract from pyproject.toml format + const pyprojectMatch = content.match( + /[Ff]lask["\s]*[=<>~!]+\s*["']?([0-9]+\.[0-9]+(?:\.[0-9]+)?)/, + ); + if (pyprojectMatch) { + return pyprojectMatch[1]; + } + } catch { + // Skip files that can't be read + continue; + } + } + + return undefined; +} + +/** + * Check if Flask-RESTful is installed + */ +async function hasFlaskRESTful({ + installDir, +}: Pick): Promise { + const requirementsFiles = await fg( + ['**/requirements*.txt', '**/pyproject.toml', '**/Pipfile'], + { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }, + ); + + for (const reqFile of requirementsFiles) { + try { + const content = fs.readFileSync(path.join(installDir, reqFile), 'utf-8'); + if ( + content.includes('flask-restful') || + content.includes('Flask-RESTful') + ) { + return true; + } + } catch { + continue; + } + } + + // Also check imports in Python files + const pyFiles = await fg(['**/*.py'], { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }); + + for (const pyFile of pyFiles) { + try { + const content = fs.readFileSync(path.join(installDir, pyFile), 'utf-8'); + if ( + content.includes('from flask_restful import') || + content.includes('import flask_restful') + ) { + return true; + } + } catch { + continue; + } + } + + return false; +} + +/** + * Check if Flask-RESTX is installed + */ +async function hasFlaskRESTX({ + installDir, +}: Pick): Promise { + const requirementsFiles = await fg( + ['**/requirements*.txt', '**/pyproject.toml', '**/Pipfile'], + { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }, + ); + + for (const reqFile of requirementsFiles) { + try { + const content = fs.readFileSync(path.join(installDir, reqFile), 'utf-8'); + if (content.includes('flask-restx') || content.includes('Flask-RESTX')) { + return true; + } + } catch { + continue; + } + } + + // Also check imports in Python files + const pyFiles = await fg(['**/*.py'], { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }); + + for (const pyFile of pyFiles) { + try { + const content = fs.readFileSync(path.join(installDir, pyFile), 'utf-8'); + if ( + content.includes('from flask_restx import') || + content.includes('import flask_restx') + ) { + return true; + } + } catch { + continue; + } + } + + return false; +} + +/** + * Check if flask-smorest is installed + */ +async function hasFlaskSmorest({ + installDir, +}: Pick): Promise { + const requirementsFiles = await fg( + ['**/requirements*.txt', '**/pyproject.toml', '**/Pipfile'], + { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }, + ); + + for (const reqFile of requirementsFiles) { + try { + const content = fs.readFileSync(path.join(installDir, reqFile), 'utf-8'); + if (content.includes('flask-smorest')) { + return true; + } + } catch { + continue; + } + } + + // Also check imports in Python files + const pyFiles = await fg(['**/*.py'], { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }); + + for (const pyFile of pyFiles) { + try { + const content = fs.readFileSync(path.join(installDir, pyFile), 'utf-8'); + if ( + content.includes('from flask_smorest import') || + content.includes('import flask_smorest') + ) { + return true; + } + } catch { + continue; + } + } + + return false; +} + +/** + * Check if app uses Flask Blueprints + */ +async function hasBlueprints({ + installDir, +}: Pick): Promise { + const pyFiles = await fg(['**/*.py'], { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }); + + for (const pyFile of pyFiles) { + try { + const content = fs.readFileSync(path.join(installDir, pyFile), 'utf-8'); + if ( + content.includes('Blueprint(') || + content.includes('register_blueprint(') || + content.includes('from flask import Blueprint') + ) { + return true; + } + } catch { + continue; + } + } + + return false; +} + +/** + * Detect Flask project type + */ +export async function getFlaskProjectType( + options: WizardOptions, +): Promise { + const { installDir } = options; + + // Check for Flask-RESTX first (most specific - includes Swagger) + if (await hasFlaskRESTX({ installDir })) { + clack.log.info('Detected Flask-RESTX project'); + return FlaskProjectType.RESTX; + } + + // Check for flask-smorest (OpenAPI-first) + if (await hasFlaskSmorest({ installDir })) { + clack.log.info('Detected flask-smorest project'); + return FlaskProjectType.SMOREST; + } + + // Check for Flask-RESTful + if (await hasFlaskRESTful({ installDir })) { + clack.log.info('Detected Flask-RESTful project'); + return FlaskProjectType.RESTFUL; + } + + // Check for Blueprints (large app structure) + if (await hasBlueprints({ installDir })) { + clack.log.info('Detected Flask project with Blueprints'); + return FlaskProjectType.BLUEPRINT; + } + + // Default to standard Flask + clack.log.info('Detected standard Flask project'); + return FlaskProjectType.STANDARD; +} + +/** + * Get human-readable name for Flask project type + */ +export function getFlaskProjectTypeName(projectType: FlaskProjectType): string { + switch (projectType) { + case FlaskProjectType.STANDARD: + return 'Standard Flask'; + case FlaskProjectType.RESTFUL: + return 'Flask-RESTful'; + case FlaskProjectType.RESTX: + return 'Flask-RESTX'; + case FlaskProjectType.SMOREST: + return 'flask-smorest'; + case FlaskProjectType.BLUEPRINT: + return 'Flask with Blueprints'; + } +} + +/** + * Find the main Flask app file + */ +export async function findFlaskAppFile( + options: Pick, +): Promise { + const { installDir } = options; + + // Common Flask app file patterns + const commonPatterns = [ + '**/app.py', + '**/wsgi.py', + '**/application.py', + '**/run.py', + '**/main.py', + '**/__init__.py', + ]; + + const appFiles = await fg(commonPatterns, { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }); + + // Look for files with Flask() instantiation or create_app() factory + for (const appFile of appFiles) { + try { + const content = fs.readFileSync(path.join(installDir, appFile), 'utf-8'); + // Check for Flask app instantiation or application factory + if ( + content.includes('Flask(__name__)') || + content.includes('Flask(') || + content.includes('def create_app(') + ) { + return appFile; + } + } catch { + continue; + } + } + + // If no file with Flask() found, check all Python files + const allPyFiles = await fg(['**/*.py'], { + cwd: installDir, + ignore: IGNORE_PATTERNS, + }); + + for (const pyFile of allPyFiles) { + try { + const content = fs.readFileSync(path.join(installDir, pyFile), 'utf-8'); + if ( + content.includes('Flask(__name__)') || + content.includes('def create_app(') + ) { + return pyFile; + } + } catch { + continue; + } + } + + // Return first common pattern file if exists + if (appFiles.length > 0) { + return appFiles[0]; + } + + return undefined; +} diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index c67ec25..f59833e 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -81,7 +81,20 @@ type AgentRunConfig = { /** * Package managers that can be used to run commands. */ -const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun', 'npx']; +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. diff --git a/src/lib/config.ts b/src/lib/config.ts index bb4460e..7630350 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -228,6 +228,106 @@ export const INTEGRATION_CONFIG = { nextSteps: '• Use identify_context() within new_context() to associate events with users\n• Call posthog.capture() to capture custom events\n• Use feature flags with posthog.feature_enabled()', }, + [Integration.flask]: { + name: 'Flask', + filterPatterns: ['**/*.py'], + ignorePatterns: [ + 'node_modules', + 'dist', + 'build', + 'public', + 'static', + 'venv', + '.venv', + 'env', + '.env', + '__pycache__', + '*.pyc', + 'migrations', + 'instance', + ], + detect: async (options) => { + const { installDir } = options; + + // Note: Django is checked before Flask in INTEGRATION_ORDER, + // so if we get here, the project is not a Django project. + + // Check for Flask in requirements files + const requirementsFiles = await fg( + [ + '**/requirements*.txt', + '**/pyproject.toml', + '**/setup.py', + '**/Pipfile', + ], + { + cwd: installDir, + ignore: ['**/venv/**', '**/.venv/**', '**/env/**', '**/.env/**'], + }, + ); + + for (const reqFile of requirementsFiles) { + try { + const content = fs.readFileSync( + path.join(installDir, reqFile), + 'utf-8', + ); + // Check for flask package (case-insensitive) + // Match "flask" as a standalone package, not just as part of plugin names + if ( + /^flask([<>=~!]|$|\s)/im.test(content) || + /["']flask["']/i.test(content) + ) { + return true; + } + } catch { + continue; + } + } + + // Check for Flask app patterns in Python files + const pyFiles = await fg( + ['**/app.py', '**/wsgi.py', '**/application.py', '**/__init__.py'], + { + cwd: installDir, + ignore: [ + '**/venv/**', + '**/.venv/**', + '**/env/**', + '**/.env/**', + '**/__pycache__/**', + ], + }, + ); + + for (const pyFile of pyFiles) { + try { + const content = fs.readFileSync( + path.join(installDir, pyFile), + 'utf-8', + ); + if ( + content.includes('from flask import') || + content.includes('import flask') || + /Flask\s*\(/.test(content) + ) { + return true; + } + } catch { + continue; + } + } + + return false; + }, + generateFilesRules: '', + filterFilesRules: '', + docsUrl: 'https://posthog.com/docs/libraries/flask', + defaultChanges: + '• Installed posthog Python package\n• Added PostHog initialization to your Flask app\n• Configured automatic event tracking', + nextSteps: + '• Use posthog.identify() to associate events with users\n• Call posthog.capture() to capture custom events\n• Use feature flags with posthog.feature_enabled()', + }, } as const satisfies Record; export const INTEGRATION_ORDER = [ @@ -237,5 +337,6 @@ export const INTEGRATION_ORDER = [ Integration.reactNative, Integration.reactRouter, Integration.django, + Integration.flask, Integration.react, ] as const; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 48978fd..6c2f63b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -6,6 +6,7 @@ export enum Integration { astro = 'astro', reactRouter = 'react-router', django = 'django', + flask = 'flask', } export enum FeatureFlagDefinition { @@ -28,6 +29,8 @@ export function getIntegrationDescription(type: string): string { return 'React Router'; case Integration.django: return 'Django'; + case Integration.flask: + return 'Flask'; default: throw new Error(`Unknown integration ${type}`); } diff --git a/src/run.ts b/src/run.ts index d336793..df957c6 100644 --- a/src/run.ts +++ b/src/run.ts @@ -19,6 +19,7 @@ import { runReactNativeWizard } from './react-native/react-native-wizard'; import { runAstroWizard } from './astro/astro-wizard'; import { runReactRouterWizardAgent } from './react-router/react-router-wizard-agent'; import { runDjangoWizardAgent } from './django/django-wizard-agent'; +import { runFlaskWizardAgent } from './flask/flask-wizard-agent'; import { EventEmitter } from 'events'; import chalk from 'chalk'; import { RateLimitError } from './utils/errors'; @@ -117,6 +118,16 @@ export async function runWizard(argv: Args) { ); await runDjangoWizardAgent(wizardOptions); break; + case Integration.flask: + clack.log.info( + `${chalk.yellow( + '[BETA]', + )} The Flask wizard is in beta. Questions or feedback? Email ${chalk.cyan( + 'wizard@posthog.com', + )}`, + ); + await runFlaskWizardAgent(wizardOptions); + break; default: clack.log.error('No setup wizard selected!'); } @@ -179,6 +190,7 @@ async function getIntegrationForSetup( { value: Integration.react, label: 'React' }, { value: Integration.reactRouter, label: 'React Router' }, { value: Integration.django, label: 'Django' }, + { value: Integration.flask, label: 'Flask' }, { value: Integration.svelte, label: 'Svelte' }, { value: Integration.reactNative, label: 'React Native' }, ],