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
105 changes: 105 additions & 0 deletions src/frameworks/elixir/elixir-wizard-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* Elixir wizard using posthog-agent with PostHog MCP */
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { WizardRunOptions } from '@utils/types';
import type { FrameworkConfig } from '@lib/framework-config';
import { mixPackageManager } from '@lib/detection/package-manager';
import { Integration } from '@lib/constants';

type ElixirContext = {
phoenix?: boolean;
};

function readMixExs(installDir: string): string | undefined {
const mixExsPath = path.join(installDir, 'mix.exs');
if (!fs.existsSync(mixExsPath)) {
return undefined;
}
return fs.readFileSync(mixExsPath, 'utf-8');
}

/** Phoenix apps get the Plug integration; plain Elixir apps don't. */
function isPhoenixProject(installDir: string): boolean {
const mixExs = readMixExs(installDir);
return !!mixExs && /\{\s*:phoenix\s*,/.test(mixExs);
}

export const ELIXIR_AGENT_CONFIG: FrameworkConfig<ElixirContext> = {
metadata: {
name: 'Elixir',
integration: Integration.elixir,
docsUrl: 'https://posthog.com/docs/libraries/elixir',
gatherContext: (options: WizardRunOptions) => {
const phoenix = isPhoenixProject(options.installDir);
return Promise.resolve({ phoenix });
},
},

detection: {
packageName: 'posthog',
packageDisplayName: 'Elixir',
usesPackageJson: false,
getVersion: () => undefined,
// A mix.exs defining a project marks a Mix project root; the def project
// check keeps stray files named mix.exs from claiming the directory.
detect: (options) => {
const mixExs = readMixExs(options.installDir);
return Promise.resolve(!!mixExs && /def\s+project\b/.test(mixExs));
},
detectPackageManager: mixPackageManager,
},

environment: {
uploadToHosting: false,
getEnvVars: (apiKey: string, host: string) => ({
POSTHOG_API_KEY: apiKey,
POSTHOG_HOST: host,
}),
},

analytics: {
getTags: (context) => ({
projectType: context.phoenix ? 'phoenix' : 'elixir',
}),
},

prompts: {
projectTypeDetection:
'This is an Elixir project. Look for mix.exs, mix.lock, config/, lib/, and application.ex to confirm; Phoenix apps also have endpoint and router modules.',
packageInstallation:
'Mix has no single add command. Add `{:posthog, "~> 2.0"}` to the deps list in mix.exs, then run `mix deps.get` to fetch it.',
getAdditionalContextLines: (context) => {
const lines = [
`Framework docs ID: elixir (use posthog://docs/frameworks/elixir for documentation)`,
'Configure PostHog in application config (api_key, api_host, in_app_otp_apps) reading from environment variables; set test_mode: true in the test environment.',
];
if (context.phoenix) {
lines.push(
'This is a Phoenix app — add PostHog.Integrations.Plug before the router so request context is attached to captured events.',
);
}
return lines;
},
},

ui: {
successMessage: 'PostHog integration complete',
estimatedDurationMinutes: 5,
getOutroChanges: (context) => [
`Analyzed your ${
context.phoenix ? 'Phoenix' : 'Elixir'
} project structure`,
'Added the posthog Hex package to mix.exs and fetched it with mix deps.get',
'Configured PostHog in application config from environment variables',
'Instrumented meaningful events with PostHog.capture',
],
getOutroNextSteps: (context) => [
context.phoenix
? 'Start your Phoenix server with `mix phx.server` and trigger the instrumented code paths'
: 'Run your Elixir application and trigger the instrumented code paths',
'Visit your PostHog dashboard to see incoming events',
'Use PostHog.capture/2 to track custom events',
'Use PostHog.set_context/1 to set a distinct_id once per request or job process',
],
},
};
15 changes: 15 additions & 0 deletions src/lib/__tests__/wizard-can-use-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,21 @@ describe('bash fence — allows real toolchain commands (from skills + field log
expect(allow('carthage bootstrap')).toBe('allow');
});

it('elixir ecosystem', () => {
expect(allow('mix deps.get')).toBe('allow');
expect(allow('mix deps.update posthog')).toBe('allow');
expect(allow('mix deps.tree')).toBe('allow');
expect(allow('mix compile')).toBe('allow');
expect(allow('mix format')).toBe('allow');
expect(allow('mix hex.info posthog')).toBe('allow');
// mix runs arbitrary project-defined tasks — everything else stays denied.
expect(allow('mix run priv/repo/seeds.exs')).toBe('deny');
expect(allow('mix test')).toBe('deny');
expect(allow('mix phx.server')).toBe('deny');
expect(allow('mix ecto.migrate')).toBe('deny');
expect(allow('mix do deps.get, run evil.exs')).toBe('deny');
});

it('android/jvm ecosystem', () => {
expect(allow('./gradlew assembleDebug')).toBe('allow');
expect(allow('./gradlew :app:assembleDebug')).toBe('allow');
Expand Down
13 changes: 12 additions & 1 deletion src/lib/agent/bash-fence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ const SIMPLE_MANAGERS: Record<string, readonly string[]> = {
swift: ['package', 'build'],
pod: ['install', 'update', 'search'],
carthage: ['bootstrap', 'update'],
// mix runs arbitrary project-defined tasks, so only the dependency +
// verify tasks are listed — run/test/phx.server execute project code.
mix: [
'deps.get',
'deps.update',
'deps.tree',
'compile',
'format',
'hex.info',
],
};

// Gradle tasks are verb-anchored camelCase: assembleDebug yes, publishToMavenCentral no.
Expand All @@ -102,7 +112,8 @@ const ALLOWED_TOOLS_SUMMARY =
'composer (install|require|update|remove|show), bundle (install|add|remove|update|show|exec <lint tool>), ' +
'gem (install|uninstall|list|search), swift (package|build), pod (install|update|search), carthage (bootstrap|update), ' +
'xcodebuild (build/clean/archive actions), gradle/gradlew (build|clean|dependencies|assemble*/compile*/bundle*/lint* tasks), ' +
'mvn (install|compile|package|verify|dependency:tree).';
'mvn (install|compile|package|verify|dependency:tree), ' +
'mix (deps.get|deps.update|deps.tree|compile|format|hex.info).';

function deny(analyticsReason: string, message: string): BashFenceDecision {
return { allowed: false, message, analyticsReason };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const INTEGRATION_ENTRIES = [
{ id: 'integration-javascript_node', framework: 'javascript_node' },
{ id: 'integration-javascript_web', framework: 'javascript_web' },
{ id: 'integration-ruby', framework: 'ruby' },
{ id: 'integration-elixir' },
{ id: 'integration-elixir', framework: 'elixir' },
{ id: 'integration-go' },
{ id: 'integration-swift', framework: 'swift' },
{ id: 'integration-kmp', framework: 'kmp' },
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export enum Integration {
swift = 'swift',
android = 'android',
rails = 'rails',
elixir = 'elixir',

// Language fallbacks. Keep javascriptNode last: it matches any package.json.
python = 'python',
Expand Down
34 changes: 34 additions & 0 deletions src/lib/detection/__tests__/framework.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { detectFramework } from '@lib/detection/framework';
import { Integration } from '@lib/constants';
import { ANDROID_AGENT_CONFIG } from '../../../frameworks/android/android-wizard-agent';
import { KMP_AGENT_CONFIG } from '../../../frameworks/kmp/kmp-wizard-agent';
import { ELIXIR_AGENT_CONFIG } from '../../../frameworks/elixir/elixir-wizard-agent';

/** A throwaway project dir seeded with the given files. */
function makeProject(files: Record<string, string>): string {
Expand Down Expand Up @@ -137,6 +138,17 @@ describe('detectFramework (end-to-end over real project dirs)', () => {
);
});

test('a Phoenix project resolves to elixir', async () => {
const opts = project({
'mix.exs':
'defmodule MyApp.MixProject do\n use Mix.Project\n\n def project do\n [app: :my_app, deps: deps()]\n end\n\n defp deps do\n [{:phoenix, "~> 1.7"}]\n end\nend\n',
'config/config.exs': 'import Config',
});
await expect(detectFramework(opts.installDir)).resolves.toBe(
Integration.elixir,
);
});

test('a Flutter project is not claimed by anything', async () => {
const opts = project({
'pubspec.yaml': 'name: my_flutter_app\nenvironment:\n sdk: ^3.0.0\n',
Expand Down Expand Up @@ -169,6 +181,28 @@ describe('android detect', () => {
});
});

describe('elixir detect', () => {
const detect = ELIXIR_AGENT_CONFIG.detection.detect;

const mixExs =
'defmodule MyApp.MixProject do\n use Mix.Project\n\n def project do\n [app: :my_app]\n end\nend\n';

test('claims a Mix project', async () => {
const opts = project({ 'mix.exs': mixExs });
await expect(detect(opts)).resolves.toBe(true);
});

test('does not claim a mix.exs without a project definition', async () => {
const opts = project({ 'mix.exs': '# placeholder\n' });
await expect(detect(opts)).resolves.toBe(false);
});

test('does not claim a project without a mix.exs', async () => {
const opts = project({ 'lib/my_app.ex': 'defmodule MyApp do\nend\n' });
await expect(detect(opts)).resolves.toBe(false);
});
});

describe('kmp detect', () => {
const detect = KMP_AGENT_CONFIG.detection.detect;

Expand Down
2 changes: 2 additions & 0 deletions src/lib/detection/__tests__/package-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
composerPackageManager,
swiftPackageManager,
gradlePackageManager,
mixPackageManager,
} from '@lib/detection/package-manager';

vi.mock('../../../utils/debug');
Expand Down Expand Up @@ -188,6 +189,7 @@ describe('static package manager helpers', () => {
{ fn: composerPackageManager, name: 'composer' },
{ fn: swiftPackageManager, name: 'spm' },
{ fn: gradlePackageManager, name: 'gradle' },
{ fn: mixPackageManager, name: 'mix' },
])('$name returns valid PackageManagerInfo', async ({ fn }) => {
const result = await fn();
expect(result.detected).toHaveLength(1);
Expand Down
5 changes: 3 additions & 2 deletions src/lib/detection/agentic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,12 @@ export const PROJECT_MANIFESTS: readonly string[] = [
// Ruby / PHP
'Gemfile',
'composer.json',
// Rust / Go / Elixir / JVM / .NET: no framework targets yet, but found so
// Elixir
'mix.exs',
// Rust / Go / JVM / .NET: no framework targets yet, but found so
// an existing PostHog SDK is reported (feeds self-driving's "continue" path).
'Cargo.toml',
'go.mod',
'mix.exs',
'pom.xml',
'*.csproj',
// Mobile / native
Expand Down
19 changes: 19 additions & 0 deletions src/lib/detection/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,25 @@ export function bundlerPackageManager(): Promise<PackageManagerInfo> {
});
}

// ---------------------------------------------------------------------------
// Elixir (Mix) helper
// ---------------------------------------------------------------------------

const MIX: DetectedPackageManager = {
name: 'mix',
label: 'Mix',
installCommand: 'mix deps.get',
};

export function mixPackageManager(): Promise<PackageManagerInfo> {
return Promise.resolve({
detected: [MIX],
primary: MIX,
recommendation:
'Use Mix. Add the dependency to the deps list in mix.exs, then run mix deps.get.',
});
}

// ---------------------------------------------------------------------------
// Android (Gradle) helper
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { SWIFT_AGENT_CONFIG } from '@frameworks/swift/swift-wizard-agent';
import { KMP_AGENT_CONFIG } from '@frameworks/kmp/kmp-wizard-agent';
import { ANDROID_AGENT_CONFIG } from '@frameworks/android/android-wizard-agent';
import { RAILS_AGENT_CONFIG } from '@frameworks/rails/rails-wizard-agent';
import { ELIXIR_AGENT_CONFIG } from '@frameworks/elixir/elixir-wizard-agent';
import { PYTHON_AGENT_CONFIG } from '@frameworks/python/python-wizard-agent';
import { RUBY_AGENT_CONFIG } from '@frameworks/ruby/ruby-wizard-agent';
import { JAVASCRIPT_NODE_AGENT_CONFIG } from '@frameworks/javascript-node/javascript-node-wizard-agent';
Expand All @@ -42,6 +43,7 @@ export const FRAMEWORK_REGISTRY: Record<Integration, FrameworkConfig> = {
[Integration.swift]: SWIFT_AGENT_CONFIG,
[Integration.android]: ANDROID_AGENT_CONFIG,
[Integration.rails]: RAILS_AGENT_CONFIG,
[Integration.elixir]: ELIXIR_AGENT_CONFIG,
[Integration.python]: PYTHON_AGENT_CONFIG,
[Integration.ruby]: RUBY_AGENT_CONFIG,
[Integration.javascriptNode]: JAVASCRIPT_NODE_AGENT_CONFIG,
Expand Down
Loading