Skip to content

Commit 57700c0

Browse files
feat: add Elixir framework support
Adds Elixir as a supported framework, stacked on the Go framework PR: - Integration.elixir enum entry in the frameworks block, before the language fallbacks - ELIXIR_AGENT_CONFIG in src/frameworks/elixir/ — claims a mix.exs with a def project definition, gathers whether the app is Phoenix (:phoenix dep) as agent context for the Plug integration nudge, installs by editing mix.exs deps then mix deps.get (Mix has no single add command) - mixPackageManager helper + FRAMEWORK_REGISTRY entry - bash-fence allowlist for mix: deps.get/deps.update/deps.tree/compile/format/hex.info only — mix runs arbitrary project-defined tasks, so run/test/phx.server/ecto.* stay denied - Pins framework: elixir on integration-elixir in the variant-resolution contract test, matching context-mill PR #268 which must release before this merges - Marks mix.exs as a real framework target in the agentic manifest comment Generated-By: PostHog Code Task-Id: 9c949855-7611-48f2-b8b5-7aa4112543e5
1 parent 1fa8314 commit 57700c0

10 files changed

Lines changed: 194 additions & 4 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/* Elixir wizard using posthog-agent with PostHog MCP */
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import type { WizardRunOptions } from '@utils/types';
5+
import type { FrameworkConfig } from '@lib/framework-config';
6+
import { mixPackageManager } from '@lib/detection/package-manager';
7+
import { Integration } from '@lib/constants';
8+
9+
type ElixirContext = {
10+
phoenix?: boolean;
11+
};
12+
13+
function readMixExs(installDir: string): string | undefined {
14+
const mixExsPath = path.join(installDir, 'mix.exs');
15+
if (!fs.existsSync(mixExsPath)) {
16+
return undefined;
17+
}
18+
return fs.readFileSync(mixExsPath, 'utf-8');
19+
}
20+
21+
/** Phoenix apps get the Plug integration; plain Elixir apps don't. */
22+
function isPhoenixProject(installDir: string): boolean {
23+
const mixExs = readMixExs(installDir);
24+
return !!mixExs && /\{\s*:phoenix\s*,/.test(mixExs);
25+
}
26+
27+
export const ELIXIR_AGENT_CONFIG: FrameworkConfig<ElixirContext> = {
28+
metadata: {
29+
name: 'Elixir',
30+
integration: Integration.elixir,
31+
docsUrl: 'https://posthog.com/docs/libraries/elixir',
32+
gatherContext: (options: WizardRunOptions) => {
33+
const phoenix = isPhoenixProject(options.installDir);
34+
return Promise.resolve({ phoenix });
35+
},
36+
},
37+
38+
detection: {
39+
packageName: 'posthog',
40+
packageDisplayName: 'Elixir',
41+
usesPackageJson: false,
42+
getVersion: () => undefined,
43+
// A mix.exs defining a project marks a Mix project root; the def project
44+
// check keeps stray files named mix.exs from claiming the directory.
45+
detect: (options) => {
46+
const mixExs = readMixExs(options.installDir);
47+
return Promise.resolve(!!mixExs && /def\s+project\b/.test(mixExs));
48+
},
49+
detectPackageManager: mixPackageManager,
50+
},
51+
52+
environment: {
53+
uploadToHosting: false,
54+
getEnvVars: (apiKey: string, host: string) => ({
55+
POSTHOG_API_KEY: apiKey,
56+
POSTHOG_HOST: host,
57+
}),
58+
},
59+
60+
analytics: {
61+
getTags: (context) => ({
62+
projectType: context.phoenix ? 'phoenix' : 'elixir',
63+
}),
64+
},
65+
66+
prompts: {
67+
projectTypeDetection:
68+
'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.',
69+
packageInstallation:
70+
'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.',
71+
getAdditionalContextLines: (context) => {
72+
const lines = [
73+
`Framework docs ID: elixir (use posthog://docs/frameworks/elixir for documentation)`,
74+
'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.',
75+
];
76+
if (context.phoenix) {
77+
lines.push(
78+
'This is a Phoenix app — add PostHog.Integrations.Plug before the router so request context is attached to captured events.',
79+
);
80+
}
81+
return lines;
82+
},
83+
},
84+
85+
ui: {
86+
successMessage: 'PostHog integration complete',
87+
estimatedDurationMinutes: 5,
88+
getOutroChanges: (context) => [
89+
`Analyzed your ${
90+
context.phoenix ? 'Phoenix' : 'Elixir'
91+
} project structure`,
92+
'Added the posthog Hex package to mix.exs and fetched it with mix deps.get',
93+
'Configured PostHog in application config from environment variables',
94+
'Instrumented meaningful events with PostHog.capture',
95+
],
96+
getOutroNextSteps: (context) => [
97+
context.phoenix
98+
? 'Start your Phoenix server with `mix phx.server` and trigger the instrumented code paths'
99+
: 'Run your Elixir application and trigger the instrumented code paths',
100+
'Visit your PostHog dashboard to see incoming events',
101+
'Use PostHog.capture/2 to track custom events',
102+
'Use PostHog.set_context/1 to set a distinct_id once per request or job process',
103+
],
104+
},
105+
};

src/lib/__tests__/wizard-can-use-tool.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,21 @@ describe('bash fence — allows real toolchain commands (from skills + field log
139139
expect(allow('carthage bootstrap')).toBe('allow');
140140
});
141141

142+
it('elixir ecosystem', () => {
143+
expect(allow('mix deps.get')).toBe('allow');
144+
expect(allow('mix deps.update posthog')).toBe('allow');
145+
expect(allow('mix deps.tree')).toBe('allow');
146+
expect(allow('mix compile')).toBe('allow');
147+
expect(allow('mix format')).toBe('allow');
148+
expect(allow('mix hex.info posthog')).toBe('allow');
149+
// mix runs arbitrary project-defined tasks — everything else stays denied.
150+
expect(allow('mix run priv/repo/seeds.exs')).toBe('deny');
151+
expect(allow('mix test')).toBe('deny');
152+
expect(allow('mix phx.server')).toBe('deny');
153+
expect(allow('mix ecto.migrate')).toBe('deny');
154+
expect(allow('mix do deps.get, run evil.exs')).toBe('deny');
155+
});
156+
142157
it('android/jvm ecosystem', () => {
143158
expect(allow('./gradlew assembleDebug')).toBe('allow');
144159
expect(allow('./gradlew :app:assembleDebug')).toBe('allow');

src/lib/agent/bash-fence.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ const SIMPLE_MANAGERS: Record<string, readonly string[]> = {
8484
swift: ['package', 'build'],
8585
pod: ['install', 'update', 'search'],
8686
carthage: ['bootstrap', 'update'],
87+
// mix runs arbitrary project-defined tasks, so only the dependency +
88+
// verify tasks are listed — run/test/phx.server execute project code.
89+
mix: [
90+
'deps.get',
91+
'deps.update',
92+
'deps.tree',
93+
'compile',
94+
'format',
95+
'hex.info',
96+
],
8797
};
8898

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

107118
function deny(analyticsReason: string, message: string): BashFenceDecision {
108119
return { allowed: false, message, analyticsReason };

src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const INTEGRATION_ENTRIES = [
4848
{ id: 'integration-javascript_node', framework: 'javascript_node' },
4949
{ id: 'integration-javascript_web', framework: 'javascript_web' },
5050
{ id: 'integration-ruby', framework: 'ruby' },
51-
{ id: 'integration-elixir' },
51+
{ id: 'integration-elixir', framework: 'elixir' },
5252
{ id: 'integration-go' },
5353
{ id: 'integration-swift', framework: 'swift' },
5454
{ id: 'integration-kmp', framework: 'kmp' },

src/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export enum Integration {
100100
swift = 'swift',
101101
android = 'android',
102102
rails = 'rails',
103+
elixir = 'elixir',
103104

104105
// Language fallbacks. Keep javascriptNode last: it matches any package.json.
105106
python = 'python',

src/lib/detection/__tests__/framework.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { detectFramework } from '@lib/detection/framework';
55
import { Integration } from '@lib/constants';
66
import { ANDROID_AGENT_CONFIG } from '../../../frameworks/android/android-wizard-agent';
77
import { KMP_AGENT_CONFIG } from '../../../frameworks/kmp/kmp-wizard-agent';
8+
import { ELIXIR_AGENT_CONFIG } from '../../../frameworks/elixir/elixir-wizard-agent';
89

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

141+
test('a Phoenix project resolves to elixir', async () => {
142+
const opts = project({
143+
'mix.exs':
144+
'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',
145+
'config/config.exs': 'import Config',
146+
});
147+
await expect(detectFramework(opts.installDir)).resolves.toBe(
148+
Integration.elixir,
149+
);
150+
});
151+
140152
test('a Flutter project is not claimed by anything', async () => {
141153
const opts = project({
142154
'pubspec.yaml': 'name: my_flutter_app\nenvironment:\n sdk: ^3.0.0\n',
@@ -169,6 +181,28 @@ describe('android detect', () => {
169181
});
170182
});
171183

184+
describe('elixir detect', () => {
185+
const detect = ELIXIR_AGENT_CONFIG.detection.detect;
186+
187+
const mixExs =
188+
'defmodule MyApp.MixProject do\n use Mix.Project\n\n def project do\n [app: :my_app]\n end\nend\n';
189+
190+
test('claims a Mix project', async () => {
191+
const opts = project({ 'mix.exs': mixExs });
192+
await expect(detect(opts)).resolves.toBe(true);
193+
});
194+
195+
test('does not claim a mix.exs without a project definition', async () => {
196+
const opts = project({ 'mix.exs': '# placeholder\n' });
197+
await expect(detect(opts)).resolves.toBe(false);
198+
});
199+
200+
test('does not claim a project without a mix.exs', async () => {
201+
const opts = project({ 'lib/my_app.ex': 'defmodule MyApp do\nend\n' });
202+
await expect(detect(opts)).resolves.toBe(false);
203+
});
204+
});
205+
172206
describe('kmp detect', () => {
173207
const detect = KMP_AGENT_CONFIG.detection.detect;
174208

src/lib/detection/__tests__/package-manager.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
composerPackageManager,
88
swiftPackageManager,
99
gradlePackageManager,
10+
mixPackageManager,
1011
} from '@lib/detection/package-manager';
1112

1213
vi.mock('../../../utils/debug');
@@ -188,6 +189,7 @@ describe('static package manager helpers', () => {
188189
{ fn: composerPackageManager, name: 'composer' },
189190
{ fn: swiftPackageManager, name: 'spm' },
190191
{ fn: gradlePackageManager, name: 'gradle' },
192+
{ fn: mixPackageManager, name: 'mix' },
191193
])('$name returns valid PackageManagerInfo', async ({ fn }) => {
192194
const result = await fn();
193195
expect(result.detected).toHaveLength(1);

src/lib/detection/agentic.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,12 @@ export const PROJECT_MANIFESTS: readonly string[] = [
7474
// Ruby / PHP
7575
'Gemfile',
7676
'composer.json',
77-
// Rust / Go / Elixir / JVM / .NET: no framework targets yet, but found so
77+
// Elixir
78+
'mix.exs',
79+
// Rust / Go / JVM / .NET: no framework targets yet, but found so
7880
// an existing PostHog SDK is reported (feeds self-driving's "continue" path).
7981
'Cargo.toml',
8082
'go.mod',
81-
'mix.exs',
8283
'pom.xml',
8384
'*.csproj',
8485
// Mobile / native

src/lib/detection/package-manager.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,25 @@ export function bundlerPackageManager(): Promise<PackageManagerInfo> {
215215
});
216216
}
217217

218+
// ---------------------------------------------------------------------------
219+
// Elixir (Mix) helper
220+
// ---------------------------------------------------------------------------
221+
222+
const MIX: DetectedPackageManager = {
223+
name: 'mix',
224+
label: 'Mix',
225+
installCommand: 'mix deps.get',
226+
};
227+
228+
export function mixPackageManager(): Promise<PackageManagerInfo> {
229+
return Promise.resolve({
230+
detected: [MIX],
231+
primary: MIX,
232+
recommendation:
233+
'Use Mix. Add the dependency to the deps list in mix.exs, then run mix deps.get.',
234+
});
235+
}
236+
218237
// ---------------------------------------------------------------------------
219238
// Android (Gradle) helper
220239
// ---------------------------------------------------------------------------

src/lib/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { SWIFT_AGENT_CONFIG } from '@frameworks/swift/swift-wizard-agent';
1818
import { KMP_AGENT_CONFIG } from '@frameworks/kmp/kmp-wizard-agent';
1919
import { ANDROID_AGENT_CONFIG } from '@frameworks/android/android-wizard-agent';
2020
import { RAILS_AGENT_CONFIG } from '@frameworks/rails/rails-wizard-agent';
21+
import { ELIXIR_AGENT_CONFIG } from '@frameworks/elixir/elixir-wizard-agent';
2122
import { PYTHON_AGENT_CONFIG } from '@frameworks/python/python-wizard-agent';
2223
import { RUBY_AGENT_CONFIG } from '@frameworks/ruby/ruby-wizard-agent';
2324
import { JAVASCRIPT_NODE_AGENT_CONFIG } from '@frameworks/javascript-node/javascript-node-wizard-agent';
@@ -42,6 +43,7 @@ export const FRAMEWORK_REGISTRY: Record<Integration, FrameworkConfig> = {
4243
[Integration.swift]: SWIFT_AGENT_CONFIG,
4344
[Integration.android]: ANDROID_AGENT_CONFIG,
4445
[Integration.rails]: RAILS_AGENT_CONFIG,
46+
[Integration.elixir]: ELIXIR_AGENT_CONFIG,
4547
[Integration.python]: PYTHON_AGENT_CONFIG,
4648
[Integration.ruby]: RUBY_AGENT_CONFIG,
4749
[Integration.javascriptNode]: JAVASCRIPT_NODE_AGENT_CONFIG,

0 commit comments

Comments
 (0)