Skip to content
Open
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ npx @posthog/wizard self-driving

If PostHog isn't already installed, the wizard runs the default integration first (composed run) before starting the self-driving setup.

### Feature flags

Add one real PostHog feature flag to an existing application. The wizard detects
the project's framework, installs the matching context-mill skill, creates or
reuses a flag, implements one safe use case, and verifies the result:

```bash
npx @posthog/wizard feature-flags
```

This program requires an existing PostHog SDK integration. If the integration
is missing, run the default wizard first and then rerun this command.

### Audit

Audit an existing PostHog integration for correctness and best practices. The
Expand Down
2 changes: 2 additions & 0 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { basicIntegrationCommand } from './src/commands/basic-integration';
import { mcpCommand } from './src/commands/mcp';
import { mcpAnalyticsCommand } from './src/commands/mcp-analytics';
import { aiObservabilityCommand } from './src/commands/ai-observability';
import { featureFlagsCommand } from './src/commands/feature-flags';
import { auditCommand } from './src/commands/audit';
import { doctorCommand } from './src/commands/doctor';
import { migrateCommand } from './src/commands/migrate';
Expand Down Expand Up @@ -81,6 +82,7 @@ Wizard.use(basicIntegrationCommand)
.use(mcpCommand)
.use(mcpAnalyticsCommand)
.use(aiObservabilityCommand)
.use(featureFlagsCommand)
.use(cliCommand)
.use(auditCommand)
.use(doctorCommand)
Expand Down
10 changes: 10 additions & 0 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ node --input-type=module -e "import '$DIST_BIN'" 2>&1 | head -5 | grep -q 'PostH
exit 1
}

# The feature-flags program is still registered manually in bin.ts. Exercise
# the compiled command so registry tests cannot pass while the public CLI is
# accidentally left unmounted.
feature_flags_help=$(node "$DIST_BIN" feature-flags --help 2>&1)
if ! echo "$feature_flags_help" | grep -q 'Add a PostHog feature flag to an existing app'; then
echo 'Smoke test failed: feature-flags command is not mounted' >&2
echo "$feature_flags_help" | head -5 >&2
exit 1
fi

# ── 2. CI flag overrides physically absent from production builds ───────────
# The override path (src/utils/ci-flag-overrides.ts) is dead code in published
# builds and tsdown strips it; its env var name appearing in dist/*.js means
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/programs-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { revenueCommand } from '../commands/revenue';
import { warehouseCommand } from '../commands/warehouse';
import { uploadSourcemapsCommand } from '../commands/upload-sourcemaps';
import { selfDrivingCommand } from '../commands/self-driving';
import { featureFlagsCommand } from '../commands/feature-flags';
import {
dispatchFamily,
pickerChildrenToShow,
Expand Down Expand Up @@ -90,6 +91,11 @@ describe('top-level command shapes', () => {
expect(warehouseCommand.children).toBeUndefined();
});

test('feature-flags is a flat wizard-native command', () => {
expect(featureFlagsCommand.name).toBe('feature-flags');
expect(featureFlagsCommand.children).toBeUndefined();
});

test('audit exposes the shared skill options on the parent', () => {
expect(auditCommand.options).toMatchObject({
'install-dir': expect.any(Object),
Expand Down Expand Up @@ -193,6 +199,18 @@ describe('flat skill commands', () => {
expect(config.skillId).toBe('data-warehouse-source-setup');
expect(opts.installDir).toBe('/tmp/some-app');
});

test('feature-flags dispatches without a fixed skillId', () => {
expect(featureFlagsCommand.handler).toBeDefined();
featureFlagsCommand.handler?.(makeArgv({ installDir: '/tmp/some-app' }));
const [config, opts] = mockRunWizard.mock.calls[0] as [
{ id?: string; skillId?: string },
Record<string, unknown>,
];
expect(config.id).toBe('feature-flags');
expect(config.skillId).toBeUndefined();
expect(opts.installDir).toBe('/tmp/some-app');
});
});

describe('yargs parsing for the audit family', () => {
Expand Down
7 changes: 7 additions & 0 deletions src/commands/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { featureFlagsConfig } from '@lib/programs/feature-flags/index';

import type { Command } from './command';
import { nativeCommandFactory } from './factories/native-command-factory';

export const featureFlagsCommand: Command =
nativeCommandFactory(featureFlagsConfig);
1 change: 1 addition & 0 deletions src/lib/agent/runner/switchboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const PROGRAM_BINDINGS: Partial<Record<ProgramId, ProgramBinding>> = {
harness: Harness.anthropic,
model: SONNET_5_MODEL,
},
'feature-flags': DEFAULT_BINDING,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you try running this with the other harnesses and models? just curious

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no not at all, I didn't have the time to test it with others but i tried to follow the patterns in the codebase to make it work with other harnesses. If i had more time, that's something I'd love to do as i use Pi as my daily driver.

slack: DEFAULT_BINDING,
};

Expand Down
4 changes: 3 additions & 1 deletion src/lib/oauth/program-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
* Current additions: `McpTutorial` layers read-only on every product
* surface (feature flags, experiments, surveys, replays, errors, web
* analytics, LLM analytics, cohorts, persons) plus read/write on
* annotations; `AgentSkill` adds feature-flag read/write; the default
* annotations; `AgentSkill` and `FeatureFlags` add feature-flag
* read/write; the default
* `PostHogIntegration` run and the standalone `slack` flow add
* `integration:read` for the Connect-Slack step. Persistence writes (dashboard:write,
* insight:write, notebook:write, query:read) come for free from the
Expand Down Expand Up @@ -219,6 +220,7 @@ const PROGRAM_SCOPE_ADDITIONS: Partial<Record<ProgramId, readonly string[]>> = {
// ever changes, this line will fail to type-check.
'mcp-tutorial': MCP_TUTORIAL_SCOPE_ADDITIONS,
'agent-skill': AGENT_SKILL_SCOPE_ADDITIONS,
'feature-flags': AGENT_SKILL_SCOPE_ADDITIONS,
'self-driving': SELF_DRIVING_SCOPE_ADDITIONS,
'warehouse-source': WAREHOUSE_SOURCE_SCOPE_ADDITIONS,
'posthog-integration': CONNECT_SLACK_SCOPE_ADDITIONS,
Expand Down
40 changes: 40 additions & 0 deletions src/lib/programs/__tests__/feature-flags-deck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Layout guard for the narrowest LearnCard split view. At 80 terminal columns,
* the feature-flags deck receives a 37-character pane.
*/

import type { ReactElement, ReactNode } from 'react';
import { getContentBlocks } from '@lib/programs/feature-flags/content/index';

const PANE_WIDTH_80COL = 37;

function textOf(node: ReactNode): string {
if (node == null || typeof node === 'boolean') return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textOf).join('');
const element = node as ReactElement<{ children?: ReactNode }>;
return textOf(element.props?.children);
}

describe('feature-flags learn deck', () => {
it('keeps fixed-layout lines within the 80-column pane', () => {
const wideLines: string[] = [];

for (const block of getContentBlocks()) {
if (
typeof block !== 'object' ||
!('type' in block) ||
block.type !== 'lines'
) {
continue;
}

for (const line of block.lines) {
const text = textOf(line);
if ([...text].length > PANE_WIDTH_80COL) wideLines.push(text);
}
}

expect(wideLines).toEqual([]);
});
});
110 changes: 110 additions & 0 deletions src/lib/programs/__tests__/feature-flags-prompt.test.ts
Comment thread
hacktivist123 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { featureFlagsConfig } from '@lib/programs/feature-flags';

function getRun() {
const run = featureFlagsConfig.run;
expect(run).toBeDefined();
expect(typeof run).not.toBe('function');

if (!run || typeof run === 'function') {
throw new Error('Expected a static feature flags run configuration');
}

return run;
}

describe('feature flags program', () => {
it('installs the skill before planning, then gates writes on confirmation', () => {
const prompt = getRun().customPrompt?.({} as never) ?? '';

const installSkill = prompt.indexOf('Call `install_skill`');
const createTasks = prompt.indexOf('`TaskCreate` calls');
const confirmProposal = prompt.indexOf(
'The proposal is an interactive decision gate',
);

expect(installSkill).toBeGreaterThan(-1);
expect(createTasks).toBeGreaterThan(installSkill);
expect(confirmProposal).toBeGreaterThan(createTasks);
expect(prompt).toContain('category: "feature-flags"');
expect(prompt).toContain('one call per workflow stage');
expect(prompt).toMatch(
/Do not call `TaskUpdate`, run\s+another tool, or start workflow work until every initial task exists/,
);
expect(prompt).toMatch(
/Before any PostHog write or\s+application source edit, call `wizard_ask`/,
);
expect(prompt).toContain(
'[ABORT] Feature flag proposal confirmation is required.',
);
expect(prompt).toContain(
'[ABORT] A matching PostHog feature flag skill could not be installed.',
);
});

it('keeps SDK and environment inspection inside the installed workflow', () => {
const prompt = getRun().customPrompt?.({} as never) ?? '';

expect(prompt).toContain(
'do not install, reinstall, or upgrade a PostHog SDK package',
);
expect(prompt).toMatch(
/Never\s+open, read, or search value-bearing `\.env\*` files/,
);
expect(prompt).toContain('use `check_env_keys`');
expect(prompt).toContain(
'The installed skill owns the feature-flag workflow',
);
});

it('ends with a project-specific flag verification handoff', () => {
const outro = getRun().buildOutroData?.(
{} as never,
{
projectId: 532532,
host: { appHost: 'https://us.posthog.com/' },
} as never,
);

expect(outro).toMatchObject({
message: 'Your feature flag is ready to test',
primaryLink: {
label: 'Open Feature Flags',
url: 'https://us.posthog.com/project/532532/feature_flags',
},
nextSteps: {
heading: 'Try your flag:',
items: [
'Exercise the control experience',
'Enable the flag for your test person and exercise the flagged experience',
'Confirm the live evaluation in PostHog, then restore the safe rollout',
],
},
reportFile: 'posthog-feature-flags-report.md',
docsUrl: 'https://posthog.com/docs/feature-flags',
});
expect(outro?.handoffPrompt).toContain(
'complete any blocked or not-run checks',
);
expect(outro?.handoffPrompt).toContain(
'get my approval before changing the flag rollout',
);
});

it('maps every Wizard and Context Mill abort contract', () => {
const abortCases = getRun().abortCases ?? [];
const emittedSignals = [
'A working PostHog SDK integration is required.',
'A matching PostHog feature flag skill could not be installed.',
'Feature flag proposal confirmation is required.',
'PostHog feature flag access is required.',
'The selected feature flag skill does not match this application.',
];

for (const signal of emittedSignals) {
expect(
abortCases.some((abortCase) => abortCase.match.test(signal)),
`Missing abort case for: ${signal}`,
).toBe(true);
}
});
});
Loading