diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..fc627564 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Generated at build time by scripts/generate-cli-manifest.cjs +src/lib/programs/cli-manifest.generated.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..68189191 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,156 @@ +# AGENTS.md — PostHog Wizard + +Instructions for all agents (and humans) working in this repo. This is the +single source of truth; [`CLAUDE.md`](CLAUDE.md) just points here. User-facing +docs: https://posthog.com/docs/ai-engineering/ai-wizard + +The PostHog wizard (`npx @posthog/wizard`) is a CLI that adds PostHog to a user's project using an AI agent. It authenticates the user, detects their framework, runs an agent that integrates the SDK and instruments events, and walks the user through their first dashboard. All from the terminal. + +## Design discipline + +This codebase follows a specific design discipline: **product knowledge never enters infrastructure code.** The runner pipeline, the TUI store, the detection loop, and the prompt assembler are machinery. They don't know what PostHog is. They don't know what a framework is. They execute a pipeline driven by typed configuration surfaces. + +Each domain has a dedicated boundary: + +- **Frameworks** → `FrameworkConfig` in `src/frameworks//` +- **Integration knowledge** → markdown skills in the +[context-mill](https://github.com/PostHog/context-mill) repo +- **Security policy** → YARA-X rules in the [warlock](https://github.com/PostHog/warlock) sibling repo. The wizard wires the scanner via PostToolUse/PreToolUse hooks (`src/lib/yara-hooks.ts`); the rule content itself lives in warlock. +- **Programs** → step arrays in `src/lib/programs/` +- **TUI** → screen components and primitives in `src/ui/tui/` + +Adding a new concern means finding the narrowest existing surface, not adding logic to the runner. The wizard is small (~20K lines) because boundaries prevent damage from propagating between concerns. + +## Before making structural changes + +Read `.claude/skills/wizard-development/SKILL.md` first. It covers the design discipline, a decision framework for new extensions, and warning signs that a change is drifting off-pattern. Two reference files extend it: + +- `references/ARCHITECTURE.md` — pipeline anatomy, data flow, security +boundaries, screen resolution +- `references/ANTI-PATTERNS.md` — concrete failure modes with alternatives + +## Skills available + +Four skills live under `.claude/skills/`. Read `wizard-development` first for any structural change; then load the relevant procedural skill: + +| Skill | When to use | +|---|---| +| `wizard-development` | Before any structural change. Design principles + decision framework. | +| `adding-framework-support` | Adding a new framework integration (e.g. Ruby on Rails, Go, Angular). | +| `adding-skill-program` | Adding a new skill-based program (e.g. a new product feature setup). | +| `ink-tui` | Building or modifying TUI screens, layouts, and primitives. | + +## CLI command surface + +The CLI was overhauled to a smaller, extensible command surface. **Use the new +command names.** Old names mostly no longer exist — only some are kept as aliases. + +| Old command | New command | Status | +|---|---|---| +| `wizard integrate` | `wizard` (default flow) | command removed | +| `wizard events-audit` | `wizard audit events` | moved into `audit` family | +| `wizard audit` (single) | `wizard audit ` | now a family — see [Audit subcommands](#audit-subcommands) | +| `wizard audit-3000` | *removed* | retired | +| `wizard revenue` | `wizard revenue-analytics` | renamed (old `revenue` removed) | +| `wizard upload-sourcemaps` | `wizard upload-source-maps` | renamed; `upload-sourcemaps` kept as alias | + +### Audit subcommands + +`audit` is the only family with skill-backed subcommands today: + +| Subcommand | What it audits | +|---|---| +| `wizard audit events` | event capture quality + cost (**default** leaf) | +| `wizard audit all` | comprehensive audit across every area | +| `wizard audit autocapture` | autocapture setup + cost | +| `wizard audit feature-flags` | feature flag usage + cost | +| `wizard audit identify` | `$identify` implementation | +| `wizard audit session-replay` | session replay setup | +| `wizard audit web-analytics` | web analytics setup (**wizard-native**, not a skill) | + +### Commands vs. skills (the `audit [skill]` gotcha) + +A skill and a command are the **same machinery** — a context-mill skill becomes a +command when its `cli:` block sets `role: command`. So `wizard audit events` +*is* the `audit-events` skill, just promoted. `wizard skill ` +([`skill.ts`](src/commands/skill.ts)) runs a skill that **wasn't** promoted. + +Two surfaces, one mechanism. So `wizard audit ` is choosing an audit +area — it is **not** asking for a skill name, despite `wizard audit --help` +labelling the positional `[skill]` (a wizard-internal name we left as-is). Don't +confuse it with the top-level `wizard skill` command. + +### Where the surface is defined (source of truth) + +- **Registration:** [`bin.ts`](bin.ts) — the `.use()` chain wires each command. +- **Command shape:** [`src/commands/command.ts`](src/commands/command.ts) — the + `Command` interface every command implements. +- **Flat native commands** (e.g. `revenue-analytics`, `upload-source-maps`) are + built with `nativeCommandFactory` + ([`src/commands/factories/native-command-factory.ts`](src/commands/factories/native-command-factory.ts)). +- **Family commands** (e.g. `audit`) resolve subcommands at runtime against the + `cliEntries` in `skill-menu.json`. Logic lives in + [`src/lib/programs/dispatch-family.ts`](src/lib/programs/dispatch-family.ts). + Adding a skill-backed subcommand is a **context-mill** release, not a wizard + change. + +### Commands vs. programs (don't confuse these) + +- A **command** is the word a user types (`audit`, `revenue-analytics`). +- A **program** is the internal business logic (`posthog-integration`, + `revenue-analytics-setup`) that a command invokes, and that other programs + depend on via `requires: [...]`. +- `posthog-integration` is a **program id, not a command**. It powers the default + flow and is a dependency of most other programs. Do not treat it as a CLI + command or reference it in CI as one. + +### Adding a command alias (keep an old name working) + +Give the `Command.name` an array of `[newName, ...legacyNames]`. yargs treats the +extra entries as aliases. See +[`src/commands/upload-sourcemaps.ts`](src/commands/upload-sourcemaps.ts). Reserve +aliases for names that external callers (users' scripts) may still use — when the +only caller is one we control, update the caller instead. + +## Commands + +```bash +pnpm install # Install dependencies +pnpm try --install-dir= # Run the wizard locally against a test project +pnpm build # Compile TypeScript +pnpm test # Unit tests (builds first) +pnpm test:watch # Unit tests in watch mode +pnpm test:e2e # End-to-end tests +pnpm lint # Prettier + ESLint checks +pnpm fix # Auto-fix lint issues +pnpm dev # Build, link globally, watch for changes +``` + +After any change, verify with: + +```bash +pnpm build && pnpm test && pnpm fix +``` + +## Repository conventions + +- TypeScript everywhere. Use `type` (not `interface`) for framework context +types so they satisfy `Record`. +- All UI calls go through `getUI()` (returns `WizardUI` interface). Never import +the store directly from business logic. +- Session mutations go through explicit store setters that call `emitChange()`. +Never mutate `session` directly — nanostore holds a shallow copy. +- The router resolves the active screen from session state. No imperative +navigation (`goTo`, `navigate`, `push`) anywhere. +- Never write secrets to source code or hardcode API keys. Use the +`wizard-tools` MCP server (`check_env_keys` / `set_env_values`) for `.env` file operations. +- Feedback / issues: wizard@posthog.com or +[GitHub Issues](https://github.com/posthog/wizard/issues). + +## Companion projects + +- **[context-mill](https://github.com/PostHog/context-mill)** — builds and +publishes the markdown skills the wizard agent uses for framework-specific integration knowledge. Skills are decoupled from the wizard release cycle so docs and integration patterns can update independently. +- **[wizard-workbench](https://github.com/PostHog/wizard-workbench)** — the +development and testing environment. Houses framework test apps (Next.js, React Router, Django, Flask, Laravel, SvelteKit, Swift, TanStack, FastAPI) with no PostHog installed, plus an `mprocs`-driven local dev stack that runs context-mill + MCP + the wizard together with hot reload. Use this to develop and test wizard changes against real projects. +- **[warlock](https://github.com/PostHog/warlock)** — the security scanner engine for PostHog's agentic flows. Bundles YARA-X rules for prompt injection, exfiltration, destructive operations, supply chain attacks, hardcoded secrets, and PII. Engine-only: it returns matches with category/severity/action metadata; the wizard decides how to respond. New security rules belong in warlock, not in the wizard. diff --git a/CLAUDE.md b/CLAUDE.md index 2a219224..079267d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,84 +1,6 @@ -# PostHog Wizard - -The PostHog wizard (`npx @posthog/wizard`) is a CLI that adds PostHog to a user's project using an AI agent. It authenticates the user, detects their framework, runs an agent that integrates the SDK and instruments events, and walks the user through their first dashboard. All from the terminal. - -User-facing docs: https://posthog.com/docs/ai-engineering/ai-wizard - -## Design discipline - -This codebase follows a specific design discipline: **product knowledge never enters infrastructure code.** The runner pipeline, the TUI store, the detection loop, and the prompt assembler are machinery. They don't know what PostHog is. They don't know what a framework is. They execute a pipeline driven by typed configuration surfaces. - -Each domain has a dedicated boundary: - -- **Frameworks** → `FrameworkConfig` in `src/frameworks//` -- **Integration knowledge** → markdown skills in the -[context-mill](https://github.com/PostHog/context-mill) repo -- **Security policy** → YARA-X rules in the [warlock](https://github.com/PostHog/warlock) sibling repo. The wizard wires the scanner via PostToolUse/PreToolUse hooks (`src/lib/yara-hooks.ts`); the rule content itself lives in warlock. -- **Programs** → step arrays in `src/lib/programs/` -- **TUI** → screen components and primitives in `src/ui/tui/` - -Adding a new concern means finding the narrowest existing surface, not adding logic to the runner. The wizard is small (~20K lines) because boundaries prevent damage from propagating between concerns. - -## Before making structural changes - -Read `.claude/skills/wizard-development/SKILL.md` first. It covers the design discipline, a decision framework for new extensions, and warning signs that a change is drifting off-pattern. Two reference files extend it: - -- `references/ARCHITECTURE.md` — pipeline anatomy, data flow, security -boundaries, screen resolution -- `references/ANTI-PATTERNS.md` — concrete failure modes with alternatives - -## Skills available - -Four skills live under `.claude/skills/`. Read `wizard-development` first for any structural change; then load the relevant procedural skill: - -| Skill | When to use | -|---|---| -| `wizard-development` | Before any structural change. Design principles + decision framework. | -| `adding-framework-support` | Adding a new framework integration (e.g. Ruby on Rails, Go, Angular). | -| `adding-skill-program` | Adding a new skill-based program (e.g. a new product feature setup). | -| `ink-tui` | Building or modifying TUI screens, layouts, and primitives. | - -## Commands - -```bash -pnpm install # Install dependencies -pnpm try --install-dir= # Run the wizard locally against a test project -pnpm build # Compile TypeScript -pnpm test # Unit tests (builds first) -pnpm test:watch # Unit tests in watch mode -pnpm test:e2e # End-to-end tests -pnpm lint # Prettier + ESLint checks -pnpm fix # Auto-fix lint issues -pnpm dev # Build, link globally, watch for changes -``` - -After any change, verify with: - -```bash -pnpm build && pnpm test && pnpm fix -``` - -## Companion projects - -- **[context-mill](https://github.com/PostHog/context-mill)** — builds and -publishes the markdown skills the wizard agent uses for framework-specific integration knowledge. Skills are decoupled from the wizard release cycle so docs and integration patterns can update independently. -- **[wizard-workbench](https://github.com/PostHog/wizard-workbench)** — the -development and testing environment. Houses framework test apps (Next.js, React Router, Django, Flask, Laravel, SvelteKit, Swift, TanStack, FastAPI) with no PostHog installed, plus an `mprocs`-driven local dev stack that runs context-mill + MCP + the wizard together with hot reload. Use this to develop and test wizard changes against real projects. -- **[warlock](https://github.com/PostHog/warlock)** — the security scanner engine for PostHog's agentic flows. Bundles YARA-X rules for prompt injection, exfiltration, destructive operations, supply chain attacks, hardcoded secrets, and PII. Engine-only: it returns matches with category/severity/action metadata; the wizard decides how to respond. New security rules belong in warlock, not in the wizard. - -## Repository conventions - -- TypeScript everywhere. Use `type` (not `interface`) for framework context -types so they satisfy `Record`. -- All UI calls go through `getUI()` (returns `WizardUI` interface). Never import -the store directly from business logic. -- Session mutations go through explicit store setters that call `emitChange()`. -Never mutate `session` directly — nanostore holds a shallow copy. -- The router resolves the active screen from session state. No imperative -navigation (`goTo`, `navigate`, `push`) anywhere. -- Never write secrets to source code or hardcode API keys. Use the -`wizard-tools` MCP server (`check_env_keys` / `set_env_values`) for `.env` file operations. -- Feedback / issues: wizard@posthog.com or -[GitHub Issues](https://github.com/posthog/wizard/issues). +# CLAUDE.md +Repo guidance for all agents lives in [AGENTS.md](AGENTS.md) — the single source +of truth. It's imported below so Claude Code picks it up automatically. +@AGENTS.md diff --git a/README.md b/README.md index 54d16e1c..510923c1 100644 --- a/README.md +++ b/README.md @@ -49,16 +49,56 @@ npx @posthog/wizard mcp add npx @posthog/wizard mcp remove ``` +## Audit + +Audit an existing PostHog integration for correctness and best practices. The +`audit` command is a **family** — run it with no subcommand to open an +interactive picker, or pass a subcommand directly: + +```bash +# Interactive picker (Enter runs `events`, the default) +npx @posthog/wizard audit + +# Or run a specific audit directly +npx @posthog/wizard audit events # event capture quality + cost (default) +npx @posthog/wizard audit all # comprehensive audit across every area +npx @posthog/wizard audit autocapture # autocapture setup + cost +npx @posthog/wizard audit feature-flags # feature flag usage + cost +npx @posthog/wizard audit identify # your $identify implementation +npx @posthog/wizard audit session-replay # session replay setup +npx @posthog/wizard audit web-analytics # web analytics setup +``` + +Most audit subcommands resolve at runtime from the published skill registry, so +new audits appear without a wizard release (`web-analytics` is wizard-native). + +> **`audit ` chooses an audit area — it does not take a skill name.** +> The audit subcommands above *are* context-mill skills promoted to commands (via +> a `cli: role: command` block); [`wizard skill `](#run-a-single-skill) +> runs a skill that hasn't been promoted. Same machinery, two surfaces. +> (`wizard audit --help` still labels the positional `[skill]` — read it as "pick +> a subcommand.") + +## Run a single skill + +Run any context-mill skill directly by name, even if it isn't exposed as its own +command: + +```bash +npx @posthog/wizard skill list # list every available skill +npx @posthog/wizard skill # run one by name +``` + ## Revenue Analytics Wire up an existing PostHog + Stripe project for revenue analytics: ```bash -npx @posthog/wizard revenue +npx @posthog/wizard revenue-analytics ``` Requires PostHog and Stripe SDKs already installed. Supports `--ci` with the -same flags as the main wizard. +same flags as the main wizard. (Renamed from `revenue` in the CLI overhaul.) ## Headless signup + install (agents / CI) @@ -170,6 +210,25 @@ ceiling, for bookkeeping: user:read,project:read,llm_gateway:read,dashboard:read,dashboard:write,insight:read,insight:write,query:read,notebook:read,notebook:write,health_issue:read,wizard_session:read,wizard_session:write,feature_flag:read,experiment:read,experiment_saved_metric:read,survey:read,session_recording:read,error_tracking:read,web_analytics:read,llm_analytics:read,cohort:read,person:read,annotation:read,annotation:write,activity_log:read,property_definition:read,event_definition:read,action:read,warehouse_table:read,warehouse_view:read,alert:read,subscription:read,feature_flag:write,integration:read,organization:read ``` +# Command changes (CLI overhaul) + +The CLI was overhauled to consolidate commands into a smaller, extensible +surface. If you used an older command, here's where it went: + +| Old command | New command | What changed | +|---|---|---| +| `wizard integrate` | `wizard` (default flow) | Command removed; the default flow runs the integration | +| `wizard events-audit` | `wizard audit events` | Now an `audit`-family subcommand | +| `wizard audit` (single audit) | `wizard audit ` | Now a family; see [Audit](#audit) for the subcommands | +| `wizard audit-3000` | *removed* | Retired | +| `wizard revenue` | `wizard revenue-analytics` | Renamed (old `revenue` removed) | +| `wizard upload-sourcemaps` | `wizard upload-source-maps` | Renamed; `upload-sourcemaps` still works as an alias | + +> **Commands vs. programs:** `integrate` was the *command*; the program behind it +> is `posthog-integration`, which still exists and now powers the default flow. +> Other commands depend on it via `requires: ['posthog-integration']`. The +> program id is internal — it was never a command you typed. + # Steal this code While the wizard works great on its own, we also find the approach used by this diff --git a/bin.ts b/bin.ts index 412698b0..a43099be 100644 --- a/bin.ts +++ b/bin.ts @@ -32,12 +32,9 @@ if (process.env.NODE_ENV === 'test') { import { Wizard } from './src/wizard'; import { basicIntegrationCommand } from './src/commands/basic-integration'; import { mcpCommand } from './src/commands/mcp'; -import { integrateCommand } from './src/commands/integrate'; import { auditCommand } from './src/commands/audit'; -import { audit3000Command } from './src/commands/audit-3000'; import { doctorCommand } from './src/commands/doctor'; import { migrateCommand } from './src/commands/migrate'; -import { eventsAuditCommand } from './src/commands/events-audit'; import { revenueCommand } from './src/commands/revenue'; import { slackCommand } from './src/commands/slack'; import { uploadSourcemapsCommand } from './src/commands/upload-sourcemaps'; @@ -61,12 +58,9 @@ function resolveInstallDir(): string { Wizard.use(basicIntegrationCommand) .use(mcpCommand) - .use(integrateCommand) .use(auditCommand) - .use(audit3000Command) .use(doctorCommand) .use(migrateCommand) - .use(eventsAuditCommand) .use(revenueCommand) .use(slackCommand) .use(uploadSourcemapsCommand) diff --git a/src/__tests__/programs-cli.test.ts b/src/__tests__/programs-cli.test.ts index 17743981..0cce2aec 100644 --- a/src/__tests__/programs-cli.test.ts +++ b/src/__tests__/programs-cli.test.ts @@ -6,85 +6,171 @@ jest.mock('@lib/runners', () => ({ runWizardCI: mockRunWizardCI, })); +jest.mock('@lib/wizard-tools', () => { + const actual = jest.requireActual('@lib/wizard-tools'); + return { + ...actual, + fetchSkillMenu: jest.fn(), + }; +}); + import type { Arguments } from 'yargs'; -import { integrateCommand } from '../commands/integrate'; import { auditCommand } from '../commands/audit'; import { migrateCommand } from '../commands/migrate'; +import { revenueCommand } from '../commands/revenue'; import { uploadSourcemapsCommand } from '../commands/upload-sourcemaps'; +import { + dispatchFamily, + pickerChildrenToShow, +} from '@lib/programs/dispatch-family'; +import type { Command } from '../commands/command'; +import { fetchSkillMenu, type CliEntry } from '@lib/wizard-tools'; +import { auditConfig } from '@lib/programs/audit/index'; +import { webAnalyticsDoctorConfig } from '@lib/programs/web-analytics-doctor/index'; import { parseCommand } from './helpers/parse-command.no-jest'; +const mockFetchSkillMenu = fetchSkillMenu as jest.MockedFunction< + typeof fetchSkillMenu +>; + function makeArgv(extra: Record = {}): Arguments { return { _: [], $0: 'wizard', ...extra } as Arguments; } -describe('program commands', () => { +function entry(partial: Partial & { skillId: string }): CliEntry { + return { + role: 'command', + displayName: partial.skillId, + description: `desc for ${partial.skillId}`, + ...partial, + }; +} + +function mockMenu(cliEntries: CliEntry[]): void { + mockFetchSkillMenu.mockResolvedValue({ categories: {}, cliEntries }); +} + +describe('top-level command shapes', () => { beforeEach(() => { jest.clearAllMocks(); }); - test('each command exposes its CLI name', () => { - expect(integrateCommand.name).toBe('integrate'); - expect(auditCommand.name).toBe('audit'); + test('audit registers as a family with a [skill] positional', () => { + expect(auditCommand.name).toBe('audit [skill]'); + // The family parent dispatches via dispatchFamily; subcommands are + // resolved at runtime, not declared as static yargs children. + expect(auditCommand.children).toBeUndefined(); + expect(auditCommand.handler).toBeDefined(); + expect(auditCommand.interactiveDefault).toBeDefined(); + }); + + test('migrate is a flat command while only one vendor exists', () => { expect(migrateCommand.name).toBe('migrate'); + expect(migrateCommand.children).toBeUndefined(); }); - test('nests web analytics doctor under audit', () => { - expect(auditCommand.children).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'web-analytics' }), - ]), - ); + test('revenue-analytics is a flat skill command', () => { + expect(revenueCommand.name).toBe('revenue-analytics'); + expect(revenueCommand.children).toBeUndefined(); }); - test('dispatches to runWizard by default', () => { - auditCommand.handler!(makeArgv({ debug: true })); + test('audit exposes the shared skill options on the parent', () => { + expect(auditCommand.options).toMatchObject({ + 'install-dir': expect.any(Object), + }); + }); +}); + +describe('dispatchFamily', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('routes a skill-backed subcommand to runWizard with the resolved skillId', async () => { + mockMenu([ + entry({ + skillId: 'audit-events', + command: 'events', + parentCommand: 'audit', + }), + ]); + await dispatchFamily('audit', makeArgv({ skill: 'events', debug: true })); expect(mockRunWizard).toHaveBeenCalledTimes(1); expect(mockRunWizardCI).not.toHaveBeenCalled(); - expect(mockRunWizard.mock.calls[0][1]).toMatchObject({ debug: true }); + const [config, opts] = mockRunWizard.mock.calls[0] as [ + { skillId?: string }, + Record, + ]; + expect(config.skillId).toBe('audit-events'); + expect(opts).toMatchObject({ debug: true }); }); - test('dispatches to runWizardCI when --ci is set', () => { - auditCommand.handler!(makeArgv({ ci: true })); + test('routes through runWizardCI when --ci is set', async () => { + mockMenu([ + entry({ + skillId: 'audit-events', + command: 'events', + parentCommand: 'audit', + }), + ]); + await dispatchFamily('audit', makeArgv({ skill: 'events', ci: true })); expect(mockRunWizardCI).toHaveBeenCalledTimes(1); expect(mockRunWizard).not.toHaveBeenCalled(); }); - test('forwards --install-dir to the runner', () => { - integrateCommand.handler!(makeArgv({ installDir: '/tmp/some-app' })); - const opts = mockRunWizard.mock.calls[0][1] as Record; - expect(opts.installDir).toBe('/tmp/some-app'); + test('runs the wizard-native handler for `audit web-analytics` without touching the registry', async () => { + // fetchSkillMenu must not be reached for natives — verifies the native + // override short-circuits before any network work. + await dispatchFamily('audit', makeArgv({ skill: 'web-analytics' })); + expect(mockFetchSkillMenu).not.toHaveBeenCalled(); + expect(mockRunWizard).toHaveBeenCalledTimes(1); + const [config] = mockRunWizard.mock.calls[0] as [{ id?: string }]; + expect(config.id).toBe(webAnalyticsDoctorConfig.id); }); - test('merges mapCliOptions output into runner args (migrate)', () => { - migrateCommand.handler!(makeArgv({ product: 'statsig' })); - const opts = mockRunWizard.mock.calls[0][1] as Record; - expect(opts.product).toBe('statsig'); - // migration maps --product into a skillId - expect(typeof opts.skillId).toBe('string'); + test('the comprehensive `audit all` runs the specialized auditConfig, not agent-skill', async () => { + // skillId 'audit' (what context-mill emits for `audit all`) signals + // the wizard to use auditConfig (custom hooks, content blocks). + mockMenu([ + entry({ skillId: 'audit', command: 'all', parentCommand: 'audit' }), + ]); + await dispatchFamily('audit', makeArgv({ skill: 'all' })); + expect(mockRunWizard).toHaveBeenCalledTimes(1); + const [config] = mockRunWizard.mock.calls[0] as [{ id?: string }]; + expect(config.id).toBe(auditConfig.id); }); +}); - test('exposes the shared skill options on each command', () => { - expect(auditCommand.options).toMatchObject({ - debug: expect.any(Object), - 'install-dir': expect.any(Object), - 'local-mcp': expect.any(Object), - benchmark: expect.any(Object), - }); +describe('flat skill commands', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - test('merges per-program cliOptions on top of the shared set (migrate)', () => { - expect(migrateCommand.options).toMatchObject({ - debug: expect.any(Object), - product: expect.any(Object), - }); + test('migrate dispatches with migrate-statsig skillId', () => { + migrateCommand.handler!(makeArgv({ installDir: '/tmp/some-app' })); + const [config, opts] = mockRunWizard.mock.calls[0] as [ + { skillId?: string }, + Record, + ]; + expect(config.skillId).toBe('migrate-statsig'); + expect(opts.installDir).toBe('/tmp/some-app'); }); - test('camelCases --install-dir end-to-end through yargs', async () => { + test('revenue-analytics dispatches with revenue-analytics-setup skillId', () => { + revenueCommand.handler!(makeArgv({ debug: true })); + const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }]; + expect(config.skillId).toBe('revenue-analytics-setup'); + }); +}); + +describe('yargs parsing for the audit family', () => { + test('camelCases --install-dir end-to-end', async () => { const argv = await parseCommand( auditCommand, - 'audit --install-dir /tmp/app', + 'audit events --install-dir /tmp/app', ); expect(argv.installDir).toBe('/tmp/app'); + expect(argv.skill).toBe('events'); }); test('parses audit web-analytics through yargs', async () => { @@ -93,6 +179,7 @@ describe('program commands', () => { 'audit web-analytics --install-dir /tmp/app', ); expect(argv.installDir).toBe('/tmp/app'); + expect(argv.skill).toBe('web-analytics'); }); test('accepts upload-source-maps and legacy upload-sourcemaps alias', async () => { @@ -108,3 +195,27 @@ describe('program commands', () => { expect(legacy.region).toBe('eu'); }); }); + +describe('pickerChildrenToShow (today: picker shows only the default leaf)', () => { + const make = (name: string, isDefault?: boolean): Command => ({ + name, + description: `${name} desc`, + handler: () => undefined, + ...(isDefault ? { default: true } : {}), + }); + + test('shows only the default-marked child when one exists', () => { + const shown = pickerChildrenToShow([ + make('web-analytics'), + make('events', true), + make('all'), + make('feature-flags'), + ]); + expect(shown.map((c) => c.name)).toEqual(['events']); + }); + + test('falls back to all children when none is marked default', () => { + const shown = pickerChildrenToShow([make('events'), make('all')]); + expect(shown.map((c) => c.name)).toEqual(['events', 'all']); + }); +}); diff --git a/src/__tests__/wizard.test.ts b/src/__tests__/wizard.test.ts index fd533caa..eae39976 100644 --- a/src/__tests__/wizard.test.ts +++ b/src/__tests__/wizard.test.ts @@ -1,13 +1,12 @@ import { commandKeys, type Command } from '../commands/command'; import { basicIntegrationCommand } from '../commands/basic-integration'; import { mcpCommand } from '../commands/mcp'; -import { integrateCommand } from '../commands/integrate'; import { auditCommand } from '../commands/audit'; -import { audit3000Command } from '../commands/audit-3000'; import { doctorCommand } from '../commands/doctor'; import { migrateCommand } from '../commands/migrate'; -import { eventsAuditCommand } from '../commands/events-audit'; import { revenueCommand } from '../commands/revenue'; +import { uploadSourcemapsCommand } from '../commands/upload-sourcemaps'; +import { skillCommand } from '../commands/skill'; const cmd = (name: string | readonly string[]): Command => ({ name, @@ -83,13 +82,12 @@ describe('production command tree', () => { const tree = [ basicIntegrationCommand, mcpCommand, - integrateCommand, auditCommand, - audit3000Command, doctorCommand, migrateCommand, - eventsAuditCommand, revenueCommand, + uploadSourcemapsCommand, + skillCommand, ]; // On failure, findConflicts returns the offending path(s) — i.e. which // command collides, not just that one did. diff --git a/src/commands/audit-3000.ts b/src/commands/audit-3000.ts deleted file mode 100644 index 08009e4e..00000000 --- a/src/commands/audit-3000.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { runWizard, runWizardCI } from '@lib/runners'; -import { audit3000Config } from '@lib/programs/audit-3000/index'; -import { skillProgramOptions } from './skill-program-options'; -import type { Command } from './command'; - -export const audit3000Command: Command = { - name: 'audit-3000', - description: audit3000Config.description, - options: { - ...skillProgramOptions, - ...(audit3000Config.cliOptions ?? {}), - }, - handler: (argv) => { - const extras = - audit3000Config.mapCliOptions?.(argv as Record) ?? {}; - const options = { ...argv, ...extras }; - if (options.ci) { - runWizardCI(audit3000Config, options); - } else { - runWizard(audit3000Config, options); - } - }, -}; diff --git a/src/commands/audit.ts b/src/commands/audit.ts index e3cad194..e838bbe2 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -1,43 +1,22 @@ -import { runWizard, runWizardCI } from '@lib/runners'; import { auditConfig } from '@lib/programs/audit/index'; -import { webAnalyticsDoctorConfig } from '@lib/programs/web-analytics-doctor/index'; -import { skillProgramOptions } from './skill-program-options'; -import type { Command } from './command'; - -const dispatchProgram = ( - config: typeof auditConfig | typeof webAnalyticsDoctorConfig, - argv: Record, -): void => { - const extras = config.mapCliOptions?.(argv) ?? {}; - const options = { ...argv, ...extras }; - if (options.ci) { - runWizardCI(config, options); - } else { - runWizard(config, options); - } -}; -const webAnalyticsCommand: Command = { - name: webAnalyticsDoctorConfig.command!, - description: webAnalyticsDoctorConfig.description, - options: { - ...skillProgramOptions, - ...(webAnalyticsDoctorConfig.cliOptions ?? {}), - }, - handler: (argv) => { - dispatchProgram(webAnalyticsDoctorConfig, argv as Record); - }, -}; +import type { Command } from './command'; +import { familyCommandFactory } from './factories/family-command-factory'; -export const auditCommand: Command = { - name: 'audit', +/** + * The `wizard audit` family. + * + * Subcommands are resolved at runtime: the wizard fetches `cliEntries` from + * `skill-menu.json` and dispatches based on `parentCommand: 'audit'`. The + * wizard-native handler for `web-analytics` lives in `NATIVE_HANDLERS` over + * in `dispatch-family.ts`. `wizard audit` with no positional opens the + * family picker, which combines native + live entries. + * + * Adding a new skill-backed audit subcommand is a context-mill release — + * no wizard release needed. + */ +export const auditCommand: Command = familyCommandFactory({ + family: 'audit', description: auditConfig.description, - children: [webAnalyticsCommand], - options: { - ...skillProgramOptions, - ...(auditConfig.cliOptions ?? {}), - }, - handler: (argv) => { - dispatchProgram(auditConfig, argv as Record); - }, -}; + optionsFrom: auditConfig, +}); diff --git a/src/commands/basic-integration/index.ts b/src/commands/basic-integration/index.ts index 909767ae..db6d1723 100644 --- a/src/commands/basic-integration/index.ts +++ b/src/commands/basic-integration/index.ts @@ -15,29 +15,19 @@ export const basicIntegrationCommand: Command = { 'Directory to install PostHog in\nenv: POSTHOG_WIZARD_INSTALL_DIR', type: 'string', }, - playground: { - default: false, - describe: 'Launch the TUI primitives playground', - type: 'boolean', - }, - benchmark: { - default: false, + name: { describe: - 'Run in benchmark mode with per-phase token tracking\nenv: POSTHOG_WIZARD_BENCHMARK', - type: 'boolean', + 'Name for account creation with --ci --signup\nenv: POSTHOG_WIZARD_NAME', + type: 'string', }, - 'yara-report': { + // ── Internal modes ─────────────────────────────────────────────── + // Hidden from `--help`. See CONTRIBUTING.md for what each one does. + playground: { default: false, - describe: - 'Print YARA scanner summary after the agent run\nenv: POSTHOG_WIZARD_YARA_REPORT', + describe: 'Launch the TUI primitives playground', type: 'boolean', hidden: true, }, - name: { - describe: - 'Name for account creation with --ci --signup\nenv: POSTHOG_WIZARD_NAME', - type: 'string', - }, }, check: (argv) => { // --playground is the standalone TUI demo; it can't combine with --ci. diff --git a/src/commands/command.ts b/src/commands/command.ts index 891883a3..4f7c4c1c 100644 --- a/src/commands/command.ts +++ b/src/commands/command.ts @@ -1,4 +1,10 @@ -import type { Arguments, Argv, CommandModule, Options } from 'yargs'; +import type { + Arguments, + Argv, + CommandModule, + Options, + PositionalOptions, +} from 'yargs'; import { setEntryCommand } from '@utils/links'; export interface Command { @@ -7,6 +13,14 @@ export interface Command { description: string; /** Flags exposed by this command. Same shape as yargs `.options()`. */ options?: Record; + /** + * Positional arguments declared in `name` (e.g. the `id` in `skill [id]`). + * Under `.strictOptions()`, yargs only treats a positional as a known + * argument once it's registered via `.positional()` — a command-string + * positional alone is rejected as `Unknown argument`. Declare each one here + * so an optional positional like `skill [id]` actually accepts its value. + */ + positionals?: Record; /** Nested subcommands. */ children?: readonly Command[]; /** `--help` examples shown for this command. */ @@ -14,7 +28,8 @@ export interface Command { /** * Called synchronously by yargs when the command matches. Wrap async work in * `void (async () => { ... })()`. Optional only when `children` is set — in - * that case yargs requires the user to pick a subcommand. + * that case yargs requires the user to pick a subcommand (or to set + * `interactiveDefault` for an in-process picker). */ handler?: (argv: Arguments) => void; /** @@ -26,6 +41,23 @@ export interface Command { * sees what you test for (e.g. truthiness). */ check?: (argv: Arguments) => boolean; + /** + * Optional handler invoked when this command has `children` but the user + * supplied no subcommand. Use it to mount an interactive picker over the + * children so `wizard audit` (no leaf) opens a TUI menu instead of yargs + * help. When set, suppresses the implicit `demandCommand(1)`. + * + * May return a Promise — yargs awaits the result before exiting. + */ + interactiveDefault?: (argv: Arguments) => void | Promise; + /** + * When true, this child is the "default" leaf in its family: the + * family picker pre-highlights it so a single Enter runs it. The picker + * still always opens — this never auto-runs the child. At most one child + * per parent should be marked. Propagated from the context-mill manifest + * entry's `default` flag through `skillCommandFactory`. + */ + default?: boolean; } /** Extract the bare command word(s) from a yargs name spec, dropping positionals and aliases' arg syntax. */ @@ -48,6 +80,9 @@ export function toCommandModule( describe: cmd.description, builder: (y: Argv) => { let next = cmd.options ? y.options(cmd.options) : y; + for (const [key, opts] of Object.entries(cmd.positionals ?? {})) { + next = next.positional(key, opts); + } if (cmd.check) next = next.check(cmd.check); for (const [usage, description] of cmd.examples ?? []) { next = next.example(usage, description); @@ -56,14 +91,15 @@ export function toCommandModule( for (const child of cmd.children ?? []) { next = next.command(toCommandModule(child, ownPath)); } - if (cmd.children?.length && !cmd.handler) { + if (cmd.children?.length && !cmd.handler && !cmd.interactiveDefault) { next = next.demandCommand(1); } return next; }, handler: (argv: Arguments) => { if (entryCommand) setEntryCommand(entryCommand); - cmd.handler?.(argv); + const run = cmd.handler ?? cmd.interactiveDefault ?? (() => undefined); + run(argv); }, }; } diff --git a/src/commands/events-audit.ts b/src/commands/events-audit.ts deleted file mode 100644 index 5377a75e..00000000 --- a/src/commands/events-audit.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { runWizard, runWizardCI } from '@lib/runners'; -import { eventsAuditConfig } from '@lib/programs/events-audit/index'; -import { skillProgramOptions } from './skill-program-options'; -import type { Command } from './command'; - -export const eventsAuditCommand: Command = { - name: 'events-audit', - description: eventsAuditConfig.description, - options: { - ...skillProgramOptions, - ...(eventsAuditConfig.cliOptions ?? {}), - }, - handler: (argv) => { - const extras = - eventsAuditConfig.mapCliOptions?.(argv as Record) ?? {}; - const options = { ...argv, ...extras }; - if (options.ci) { - runWizardCI(eventsAuditConfig, options); - } else { - runWizard(eventsAuditConfig, options); - } - }, -}; diff --git a/src/commands/factories/__tests__/family-picker.test.ts b/src/commands/factories/__tests__/family-picker.test.ts new file mode 100644 index 00000000..e6c14064 --- /dev/null +++ b/src/commands/factories/__tests__/family-picker.test.ts @@ -0,0 +1,183 @@ +import type { Arguments } from 'yargs'; + +// Stub only Ink's `render` so `chooseFamilyChild` can build its options +// without mounting a real TUI; everything else in `ink` stays real. +jest.mock('ink', () => { + const actual = jest.requireActual('ink'); + return { ...actual, render: jest.fn() }; +}); + +import { render } from 'ink'; + +import type { Command } from '../../command'; +import { auditCommand } from '../../audit'; +import { + chooseFamilyChild, + createFamilyPickerDefault, + orderFamilyChildren, +} from '../family-picker'; + +function makeArgv(extras: Record = {}): Arguments { + return { _: [], $0: 'wizard', ...extras } as Arguments; +} + +describe('orderFamilyChildren', () => { + it('hoists the default-marked child to the front', () => { + const a: Command = { name: 'a', description: 'a', handler: jest.fn() }; + const b: Command = { + name: 'b', + description: 'b', + handler: jest.fn(), + default: true, + }; + const c: Command = { name: 'c', description: 'c', handler: jest.fn() }; + const ordered = orderFamilyChildren([a, b, c]); + expect(ordered.map((cmd) => cmd.name)).toEqual(['b', 'a', 'c']); + }); + + it('preserves order when no child is marked default', () => { + const a: Command = { name: 'a', description: 'a', handler: jest.fn() }; + const b: Command = { name: 'b', description: 'b', handler: jest.fn() }; + expect(orderFamilyChildren([a, b])).toEqual([a, b]); + }); + + it('drops children that have neither a handler nor children', () => { + const dead: Command = { name: 'dead', description: 'd' }; + const real: Command = { + name: 'real', + description: 'd', + handler: jest.fn(), + }; + expect(orderFamilyChildren([dead, real])).toEqual([real]); + }); +}); + +describe('chooseFamilyChild', () => { + it('renders the default leaf first so it is pre-highlighted (Enter runs it)', () => { + (render as jest.Mock).mockClear(); + const all: Command = { + name: 'all', + description: 'comprehensive', + handler: jest.fn(), + default: true, + }; + const events: Command = { + name: 'events', + description: 'events', + handler: jest.fn(), + }; + + // Input order puts the default LAST — the picker must reorder it to index 0. + void chooseFamilyChild('wizard audit', [events, all]); + + expect(render as jest.Mock).toHaveBeenCalledTimes(1); + const element = (render as jest.Mock).mock.calls[0][0]; + const options = element.props.options as { + label: string; + value: Command; + }[]; + expect(options.map((o) => o.label)).toEqual(['all', 'events']); + expect(options[0].value.default).toBe(true); + }); +}); + +describe('createFamilyPickerDefault', () => { + it('always opens the picker — even when one child is marked default', async () => { + const childHandler = jest.fn(); + const child: Command = { + name: 'all', + description: 'comprehensive', + handler: childHandler, + default: true, + }; + const sibling: Command = { + name: 'events', + description: 'events', + handler: jest.fn(), + }; + const chooser = jest.fn().mockResolvedValue(child); + + const handler = createFamilyPickerDefault( + 'wizard audit', + [child, sibling], + chooser, + ); + const argv = makeArgv({ debug: true }); + await handler(argv); + + expect(chooser).toHaveBeenCalledWith('wizard audit', [child, sibling]); + expect(childHandler).toHaveBeenCalledWith(argv); + }); + + it('dispatches whichever child the picker resolves', async () => { + const aHandler = jest.fn(); + const bHandler = jest.fn(); + const a: Command = { + name: 'a', + description: 'a', + handler: aHandler, + default: true, + }; + const b: Command = { name: 'b', description: 'b', handler: bHandler }; + const chooser = jest.fn().mockResolvedValue(b); + + const handler = createFamilyPickerDefault('wizard family', [a, b], chooser); + await handler(makeArgv()); + + expect(bHandler).toHaveBeenCalled(); + expect(aHandler).not.toHaveBeenCalled(); + }); + + it('is a no-op when the user aborts the picker', async () => { + const handler = createFamilyPickerDefault( + 'wizard family', + [ + { name: 'a', description: 'a', handler: jest.fn() }, + { name: 'b', description: 'b', handler: jest.fn() }, + ], + jest.fn().mockResolvedValue(null), + ); + await handler(makeArgv()); + // No expectations on handlers — they shouldn't run, but the test that + // matters is that handler() resolves without throwing. + }); + + it('awaits async child handlers before resolving', async () => { + let resolved = false; + const child: Command = { + name: 'events', + description: 'audit events', + handler: () => + new Promise( + (resolve) => + setTimeout(() => { + resolved = true; + resolve(); + }, 5), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any, + }; + const handler = createFamilyPickerDefault( + 'wizard audit', + [child], + jest.fn().mockResolvedValue(child), + ); + await handler(makeArgv()); + expect(resolved).toBe(true); + }); +}); + +describe('auditCommand', () => { + it('wires interactiveDefault for the bare `wizard audit` invocation', () => { + expect(typeof auditCommand.interactiveDefault).toBe('function'); + }); + + it('routes leaves through a runtime handler (no static yargs children)', () => { + // Skill-backed audit leaves resolve via `dispatchFamily` at runtime + // against `cliEntries` in `skill-menu.json`, not via baked yargs + // children. So `auditCommand.children` is intentionally empty; the + // `[skill]` positional + handler is the routing surface. + expect(auditCommand.children).toBeUndefined(); + expect(typeof auditCommand.handler).toBe('function'); + }); +}); diff --git a/src/commands/factories/__tests__/native-command-factory.test.ts b/src/commands/factories/__tests__/native-command-factory.test.ts new file mode 100644 index 00000000..c29fa2fe --- /dev/null +++ b/src/commands/factories/__tests__/native-command-factory.test.ts @@ -0,0 +1,105 @@ +const mockRunWizard = jest.fn(); +const mockRunWizardCI = jest.fn(); + +jest.mock('@lib/runners', () => ({ + runWizard: mockRunWizard, + runWizardCI: mockRunWizardCI, +})); + +import type { Arguments } from 'yargs'; + +import type { ProgramConfig } from '@lib/programs/program-step'; + +import { nativeCommandFactory } from '../native-command-factory'; + +function makeArgv(extra: Record = {}): Arguments { + return { _: [], $0: 'wizard', ...extra } as Arguments; +} + +function buildTestConfig( + overrides: Partial = {}, +): ProgramConfig { + return { + command: 'demo', + description: 'demo program', + id: 'demo', + steps: [], + ...overrides, + }; +} + +describe('nativeCommandFactory', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses command and description from the program config', () => { + const cmd = nativeCommandFactory(buildTestConfig()); + expect(cmd.name).toBe('demo'); + expect(cmd.description).toBe('demo program'); + }); + + it('throws when the program has no command name', () => { + expect(() => + nativeCommandFactory(buildTestConfig({ command: undefined })), + ).toThrow(/has no `command`/); + }); + + it('merges skill-program options with program-specific cliOptions', () => { + const cmd = nativeCommandFactory( + buildTestConfig({ + cliOptions: { + flavor: { + type: 'string' as const, + choices: ['vanilla', 'chocolate'], + }, + }, + }), + ); + // Per-command skill-program option (--install-dir) is present. + // Global flags (--debug, --local-mcp, --benchmark, --yara-report, --ci) + // live in wizard.ts GLOBAL_OPTIONS, not on the per-command options. + expect(cmd.options).toHaveProperty('install-dir'); + // Program-specific options are present + expect(cmd.options).toHaveProperty('flavor'); + }); + + it('passes children through unchanged', () => { + const child = { + name: 'inner', + description: 'inner', + handler: () => undefined, + }; + const cmd = nativeCommandFactory(buildTestConfig(), { children: [child] }); + expect(cmd.children).toEqual([child]); + }); + + it('handler routes to runWizard by default and applies mapCliOptions', () => { + const config = buildTestConfig({ + mapCliOptions: (argv) => ({ + extra: `derived-from-${argv.foo as string}`, + }), + }); + const cmd = nativeCommandFactory(config); + cmd.handler!(makeArgv({ foo: 'bar' })); + + expect(mockRunWizardCI).not.toHaveBeenCalled(); + expect(mockRunWizard).toHaveBeenCalledTimes(1); + const [calledConfig, calledOptions] = mockRunWizard.mock.calls[0]; + expect(calledConfig).toBe(config); + expect(calledOptions).toMatchObject({ + foo: 'bar', + extra: 'derived-from-bar', + }); + }); + + it('handler routes to runWizardCI when --ci is set', () => { + const config = buildTestConfig(); + const cmd = nativeCommandFactory(config); + cmd.handler!(makeArgv({ ci: true })); + + expect(mockRunWizard).not.toHaveBeenCalled(); + expect(mockRunWizardCI).toHaveBeenCalledTimes(1); + expect(mockRunWizardCI.mock.calls[0][0]).toBe(config); + }); +}); diff --git a/src/commands/factories/family-command-factory.ts b/src/commands/factories/family-command-factory.ts new file mode 100644 index 00000000..95f9a4ba --- /dev/null +++ b/src/commands/factories/family-command-factory.ts @@ -0,0 +1,88 @@ +import type { Arguments } from 'yargs'; + +import type { ProgramConfig } from '@lib/programs/program-step'; +import { + buildFamilyPickerChildren, + dispatchFamily, + pickerChildrenToShow, +} from '@lib/programs/dispatch-family'; +import { getSkillsBaseUrl } from '@lib/constants'; +import { fetchSkillMenu } from '@lib/wizard-tools'; + +import type { Command } from '../command'; +import { createFamilyPickerDefault } from './family-picker'; +import { mergeCommandOptions } from './shared'; + +export interface FamilyCommandFactoryOpts { + /** The family's CLI name (e.g. 'audit'). */ + family: string; + /** Help text for `wizard --help`. */ + description: string; + /** + * Source for shared CLI options (e.g. --install-dir) merged onto the + * family parent. Usually the family's flagship native config, or the + * generic agent-skill config. + */ + optionsFrom: ProgramConfig; +} + +/** + * Build a yargs `Command` for a family parent (`wizard audit`, etc.). + * + * - `wizard ` — `dispatchFamily` resolves `` against + * native handlers first, then the live `cliEntries` from + * `skill-menu.json`. Unknown subs error with the available list. + * - `wizard ` (no positional) — in an interactive terminal, opens the + * family picker (`openPicker`). For now the picker surfaces only the leaf + * marked `default` (e.g. `audit events`); the others stay runnable directly. + * In non-TTY/CI, falls through to `dispatchFamily`, which prints + * "requires a subcommand" rather than hanging on a picker that can't render. + * + * No static yargs children. New skill-backed subcommands appear after a + * context-mill release without a wizard release. New *native* subcommands + * (rare) are added by updating `NATIVE_HANDLERS` in `dispatch-family.ts`. + */ +export function familyCommandFactory({ + family, + description, + optionsFrom, +}: FamilyCommandFactoryOpts): Command { + const openPicker = async (argv: Arguments): Promise => { + const skillsBaseUrl = getSkillsBaseUrl(Boolean(argv['local-mcp'])); + const menu = await fetchSkillMenu(skillsBaseUrl); + const children = buildFamilyPickerChildren(family, menu?.cliEntries ?? []); + // Today the picker surfaces only the default leaf (e.g. `audit events`); + // other subcommands stay runnable directly. See `pickerChildrenToShow`. + const pickerChildren = pickerChildrenToShow(children); + const picker = createFamilyPickerDefault( + `wizard ${family}`, + pickerChildren, + ); + await picker(argv); + }; + + return { + name: `${family} [skill]`, + description, + options: mergeCommandOptions(optionsFrom), + positionals: { + skill: { + type: 'string', + describe: 'Subcommand to run (omit to open the interactive picker)', + }, + }, + handler: (argv: Arguments) => { + const sub = (argv.skill as string | undefined)?.trim(); + // With a subcommand, resolve and run it. Without one, open the picker — + // but only in an interactive terminal. In non-TTY/CI, fall through to + // dispatchFamily, which prints "requires a subcommand" rather than hanging + // on an Ink picker that can't render. + if (sub || !process.stdout.isTTY) { + void dispatchFamily(family, argv); + } else { + void openPicker(argv); + } + }, + interactiveDefault: openPicker, + }; +} diff --git a/src/commands/factories/family-picker.tsx b/src/commands/factories/family-picker.tsx new file mode 100644 index 00000000..3f14baab --- /dev/null +++ b/src/commands/factories/family-picker.tsx @@ -0,0 +1,148 @@ +/** + * Mount an Ink picker over a command's `children` and dispatch the + * selected child's handler. + * + * Used as the `interactiveDefault` for family parents like + * `wizard audit` — when the user invokes the parent without a leaf, this + * shows a TUI menu instead of yargs's `demandCommand(1)` help dump. + * + * The picker opens for families in an interactive terminal; the `default` + * flag on a child controls which option is pre-highlighted (so `wizard audit` + * → Enter runs the default leaf, today `audit events`). The caller decides + * which children to pass in — `familyCommandFactory` currently passes only the + * default leaf, so other subcommands stay runnable directly but aren't listed + * here yet. + * + * Single-option commands aren't families — they should be flat + * commands wired with `skillCommandFactory` / `nativeCommandFactory` + * directly, not run through this module. + */ + +import type { Arguments } from 'yargs'; +import { Box, Text, render } from 'ink'; +import { createElement } from 'react'; + +import { Colors } from '@ui/tui/styles'; +import { PickerMenu } from '@ui/tui/primitives/PickerMenu'; + +import { commandKeys, type Command } from '../command'; + +interface FamilyPickerAppProps { + parentLabel: string; + options: { label: string; value: Command; hint?: string }[]; + onSelect: (cmd: Command) => void; +} + +function FamilyPickerApp(props: FamilyPickerAppProps) { + return createElement( + Box, + { flexDirection: 'column', paddingX: 1, paddingY: 1 }, + createElement( + Text, + { bold: true, color: Colors.accent }, + props.parentLabel, + ), + createElement(Box, { height: 1 }), + createElement(PickerMenu, { + message: 'Pick a subcommand', + options: props.options, + optionMarginBottom: 1, + onSelect: (value) => { + // PickerMenu in single mode returns one value; only the multi-mode + // signature is the array variant. Narrow defensively. + const cmd = Array.isArray(value) ? value[0] : value; + if (cmd) props.onSelect(cmd); + }, + }), + ); +} + +function describe(child: Command): string { + // Strip positional syntax (`search ` → `search`) for the picker label. + return commandKeys(child.name)[0] ?? ''; +} + +/** + * Reorder children so the `default`-marked entry is first, while + * preserving the relative order of the rest. The picker's initial + * focus is index 0, so this is what makes "press Enter on + * `wizard audit`" run the default leaf (today `audit events`). + * + * Exported for testability — the ordering logic stays pure and + * inspectable without mounting Ink. + */ +export function orderFamilyChildren(children: readonly Command[]): Command[] { + const selectable = children.filter((c) => c.handler || c.children?.length); + const defaults = selectable.filter((c) => c.default); + const rest = selectable.filter((c) => !c.default); + return [...defaults, ...rest]; +} + +/** + * Render the picker. Resolves once the user has selected a child; + * dispatching the child's handler is the caller's responsibility (so this + * function stays pure-UI and easy to test by stubbing `render`). + */ +export function chooseFamilyChild( + parentLabel: string, + children: readonly Command[], +): Promise { + const ordered = orderFamilyChildren(children); + if (ordered.length === 0) return Promise.resolve(null); + + const options = ordered.map((child) => ({ + label: describe(child), + value: child, + hint: child.description, + })); + + return new Promise((resolve) => { + let app: ReturnType | null = null; + const handleSelect = (cmd: Command): void => { + app?.unmount(); + resolve(cmd); + }; + app = render( + createElement(FamilyPickerApp, { + parentLabel, + options, + onSelect: handleSelect, + }), + ); + }); +} + +/** + * Returns an `interactiveDefault` handler for a family parent's no-leaf + * invocation. Always opens the picker; the `default`-marked child is + * shown first (pre-highlighted), so a single Enter keystroke runs it. + * + * Discovery + consent in one extra keystroke vs. auto-running silently. + * + * Wire onto a family parent: + * export const auditCommand: Command = { + * name: 'audit', + * description: '...', + * children: [...], + * interactiveDefault: createFamilyPickerDefault('audit', auditChildren), + * }; + */ +export function createFamilyPickerDefault( + parentLabel: string, + children: readonly Command[], + chooser: ( + label: string, + children: readonly Command[], + ) => Promise = chooseFamilyChild, +): (argv: Arguments) => Promise { + return async (argv) => { + const chosen = await chooser(parentLabel, children); + if (!chosen) return; + // We forward the PARENT's parsed argv straight to the chosen child. The + // child's own option defaults and `check` validator do NOT run on this + // path — they only run when the leaf is invoked directly + // (`wizard audit events`). Harmless while leaves declare neither, but if a + // leaf ever grows a `check` or a defaulted option, this path will skip it. + await Promise.resolve(chosen.handler?.(argv)); + }; +} diff --git a/src/commands/factories/native-command-factory.ts b/src/commands/factories/native-command-factory.ts new file mode 100644 index 00000000..d9fd65a2 --- /dev/null +++ b/src/commands/factories/native-command-factory.ts @@ -0,0 +1,35 @@ +import type { ProgramConfig } from '@lib/programs/program-step'; + +import type { Command } from '../command'; + +import { dispatchProgram, mergeCommandOptions } from './shared'; + +export interface NativeCommandFactoryOpts { + /** Subcommands nested under this command. */ + children?: readonly Command[]; +} + +/** + * Build a yargs `Command` from a wizard-native `ProgramConfig`. + * + * Collapses the previously duplicated boilerplate (read `config.command`, + * merge skill-program flags with program-specific options, dispatch via + * `runWizard` / `runWizardCI`) into a single call. + */ +export function nativeCommandFactory( + config: ProgramConfig, + opts: NativeCommandFactoryOpts = {}, +): Command { + if (!config.command) { + throw new Error( + `nativeCommandFactory: program "${config.id}" has no \`command\` — wizard-native programs must declare a CLI name`, + ); + } + return { + name: config.command, + description: config.description, + options: mergeCommandOptions(config), + children: opts.children, + handler: (argv) => dispatchProgram(config, argv), + }; +} diff --git a/src/commands/factories/shared.ts b/src/commands/factories/shared.ts new file mode 100644 index 00000000..b37b8a5f --- /dev/null +++ b/src/commands/factories/shared.ts @@ -0,0 +1,40 @@ +import type { Arguments, Options } from 'yargs'; + +import { runWizard, runWizardCI } from '@lib/runners'; +import type { ProgramConfig } from '@lib/programs/program-step'; + +import { skillProgramOptions } from '../skill-program-options'; + +/** + * Dispatch a parsed yargs invocation to the wizard runner. Applies the + * program's `mapCliOptions` transform, then routes to `runWizard` or + * `runWizardCI` based on the `--ci` flag. + * + * Every command file used to inline this; the factories call it instead. + */ +export function dispatchProgram(config: ProgramConfig, argv: Arguments): void { + const argvRecord = argv as unknown as Record; + const extras = config.mapCliOptions?.(argvRecord) ?? {}; + const options = { ...argvRecord, ...extras }; + if (options.ci) { + runWizardCI(config, options); + } else { + runWizard(config, options); + } +} + +/** + * Merge the standard skill-program flags (`--debug`, `--install-dir`, etc.) + * with any program-specific options declared on `cliOptions`. + * + * Program-specific options shadow the standard ones — that's intentional, so + * a program can override a default flag if it ever needs to. + */ +export function mergeCommandOptions( + config: ProgramConfig, +): Record { + return { + ...skillProgramOptions, + ...((config.cliOptions ?? {}) as Record), + }; +} diff --git a/src/commands/integrate.ts b/src/commands/integrate.ts deleted file mode 100644 index 5eda682b..00000000 --- a/src/commands/integrate.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { runWizard, runWizardCI } from '@lib/runners'; -import { posthogIntegrationConfig } from '@lib/programs/posthog-integration/index'; -import { skillProgramOptions } from './skill-program-options'; -import type { Command } from './command'; - -export const integrateCommand: Command = { - name: 'integrate', - description: posthogIntegrationConfig.description, - options: { - ...skillProgramOptions, - ...(posthogIntegrationConfig.cliOptions ?? {}), - }, - handler: (argv) => { - const extras = - posthogIntegrationConfig.mapCliOptions?.( - argv as Record, - ) ?? {}; - const options = { ...argv, ...extras }; - if (options.ci) { - runWizardCI(posthogIntegrationConfig, options); - } else { - runWizard(posthogIntegrationConfig, options); - } - }, -}; diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 282bfe52..e7389efc 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -1,23 +1,16 @@ -import { runWizard, runWizardCI } from '@lib/runners'; import { migrationConfig } from '@lib/programs/migration/index'; -import { skillProgramOptions } from './skill-program-options'; + import type { Command } from './command'; +import { nativeCommandFactory } from './factories/native-command-factory'; -export const migrateCommand: Command = { - name: 'migrate', - description: migrationConfig.description, - options: { - ...skillProgramOptions, - ...(migrationConfig.cliOptions ?? {}), - }, - handler: (argv) => { - const extras = - migrationConfig.mapCliOptions?.(argv as Record) ?? {}; - const options = { ...argv, ...extras }; - if (options.ci) { - runWizardCI(migrationConfig, options); - } else { - runWizard(migrationConfig, options); - } - }, -}; +/** + * `wizard migrate` — flat skill command, Statsig today. + * + * Stays flat while there's only one vendor. When a second vendor lands, + * restructure into a family with `familyCommandFactory` and publish each + * vendor as a `cliEntries` entry with `parentCommand: 'migrate'` from + * context-mill. That move is a deliberate breaking change for users + * (`wizard migrate` stops running Statsig directly), so do it explicitly + * when the second vendor arrives, not pre-emptively. + */ +export const migrateCommand: Command = nativeCommandFactory(migrationConfig); diff --git a/src/commands/revenue.ts b/src/commands/revenue.ts index 621e4089..a41f6af0 100644 --- a/src/commands/revenue.ts +++ b/src/commands/revenue.ts @@ -1,24 +1,14 @@ -import { runWizard, runWizardCI } from '@lib/runners'; import { revenueAnalyticsConfig } from '@lib/programs/revenue-analytics/index'; -import { skillProgramOptions } from './skill-program-options'; + import type { Command } from './command'; +import { nativeCommandFactory } from './factories/native-command-factory'; -export const revenueCommand: Command = { - name: 'revenue', - description: revenueAnalyticsConfig.description, - options: { - ...skillProgramOptions, - ...(revenueAnalyticsConfig.cliOptions ?? {}), - }, - handler: (argv) => { - const extras = - revenueAnalyticsConfig.mapCliOptions?.(argv as Record) ?? - {}; - const options = { ...argv, ...extras }; - if (options.ci) { - runWizardCI(revenueAnalyticsConfig, options); - } else { - runWizard(revenueAnalyticsConfig, options); - } - }, -}; +/** + * `wizard revenue-analytics` — flat skill command, Stripe today. + * + * Stays flat while there's only one provider. Restructure into a family + * if/when a second provider lands. + */ +export const revenueCommand: Command = nativeCommandFactory( + revenueAnalyticsConfig, +); diff --git a/src/commands/skill-program-options.ts b/src/commands/skill-program-options.ts index d0f7db31..f0d1eb76 100644 --- a/src/commands/skill-program-options.ts +++ b/src/commands/skill-program-options.ts @@ -1,28 +1,15 @@ -/** Flags shared by every skill-based program command (integrate, audit, …). */ +/** + * Per-command options shared by every skill-based program command + * (`audit events`, `migrate statsig`, `revenue`, `source-maps`, …). + * + * Only flags that are unique to skill commands live here. Global flags + * (`--debug`, `--local-mcp`, `--benchmark`, `--yara-report`, `--ci`) are + * declared once in `wizard.ts::GLOBAL_OPTIONS` and apply automatically + * across every command — no need to repeat them per subcommand. + */ export const skillProgramOptions = { - debug: { - default: false, - describe: 'Enable verbose logging', - type: 'boolean' as const, - }, 'install-dir': { describe: 'Directory to install in', type: 'string' as const, }, - 'local-mcp': { - default: false, - describe: 'Use local MCP server', - type: 'boolean' as const, - }, - benchmark: { - default: false, - describe: 'Run in benchmark mode', - type: 'boolean' as const, - }, - 'yara-report': { - default: false, - describe: 'Print YARA scanner summary', - type: 'boolean' as const, - hidden: true, - }, }; diff --git a/src/commands/skill.ts b/src/commands/skill.ts index 48ed5df0..6bab2cbc 100644 --- a/src/commands/skill.ts +++ b/src/commands/skill.ts @@ -1,4 +1,8 @@ import type { Arguments } from 'yargs'; + +import { getSkillsBaseUrl } from '@lib/constants'; +import { fetchSkillMenu, type CliEntry } from '@lib/wizard-tools'; + import { runSkillMode } from './basic-integration/skill'; import { skillProgramOptions } from './skill-program-options'; import type { Command } from './command'; @@ -8,8 +12,65 @@ function readSkillName(argv: Arguments): string { return String(argv.skillName ?? argv['skill-name'] ?? '').trim(); } +const BROWSABLE_ROLES: ReadonlySet = new Set([ + 'command', + 'skill', +]); + +function formatEntry(entry: CliEntry): string { + const path = entry.parentCommand + ? `wizard ${entry.parentCommand} ${entry.command}` + : entry.command + ? `wizard ${entry.command}` + : `wizard skill ${entry.skillId}`; + return ` ${entry.skillId.padEnd(38)} ${path.padEnd(36)} ${ + entry.description + }`; +} + +/** + * `wizard skill list` — fetch and print every browsable skill in the catalog. + * + * Reads the live `skill-menu.json` so new skills appear immediately after a + * context-mill release. `internal` skills are excluded from the listing. + */ +const listCommand: Command = { + name: 'list', + description: 'List every browsable skill in the catalog', + handler: (argv) => { + void (async () => { + const skillsBaseUrl = getSkillsBaseUrl(Boolean(argv['local-mcp'])); + const menu = await fetchSkillMenu(skillsBaseUrl); + if (!menu) { + process.stderr.write( + `\n\x1b[1;91m✖ Couldn't reach the skill registry.\x1b[0m\n` + + ` Check your network connection and try again.\n\n`, + ); + process.exit(1); + } + const entries = (menu.cliEntries ?? []).filter((e) => + BROWSABLE_ROLES.has(e.role), + ); + if (entries.length === 0) { + process.stdout.write('No skills found.\n'); + return; + } + process.stdout.write( + `${entries.length} skill${entries.length === 1 ? '' : 's'}:\n`, + ); + process.stdout.write( + ` ${'SKILL ID'.padEnd(38)} ${'COMMAND'.padEnd(36)} DESCRIPTION\n`, + ); + for (const entry of entries) { + process.stdout.write(`${formatEntry(entry)}\n`); + } + })(); + }, +}; + /** * `wizard skill ` — run a single context-mill skill by id. + * `wizard skill list` — list every browsable skill in the catalog. * * Replaces the old `--skill=` flag on the default command. The skill id * is fetched from context-mill's release at runtime (same mechanism the flag @@ -17,14 +78,19 @@ function readSkillName(argv: Arguments): string { */ export const skillCommand: Command = { name: 'skill ', - description: 'Run a specific context-mill skill by name', + description: 'Run a specific context-mill skill by name (or `list` them)', + children: [listCommand], options: { ...skillProgramOptions, }, - // yargs already requires the positional, but an explicitly-empty value - // (`wizard skill ""`) would otherwise slip through to a broken run with no - // skill id. Reject it with the same friendly message the old flag gave. + // yargs already enforces the `` positional, but an + // explicitly-empty value (`wizard skill ""`) would otherwise slip + // through to a broken run. Reject it with the same friendly message + // the old --skill flag gave. When `wizard skill list` matched the + // child instead, yargs leaves the positional unset — the `null` guard + // keeps the check from rejecting that route. check: (argv) => { + if (argv.skillName == null && argv['skill-name'] == null) return true; if (!readSkillName(argv)) { throw new Error( 'skill needs a skill name, e.g. `wizard skill audit-events`', diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 865e8012..aefb5bc4 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -581,14 +581,9 @@ export async function initializeAgent( }); mcpServers['wizard-tools'] = wizardToolsServer; - // audit-3000 needs Opus 4.7's depth for the multi-phase audit chain; - // every other program runs on Sonnet 4.6. // Bare model IDs (no `anthropic/` prefix) so the LLM gateway's Bedrock // fallback can match map_to_bedrock_model()'s strict lookup. - const model = - config.integrationLabel === 'audit-3000' - ? 'claude-opus-4-6' - : 'claude-sonnet-4-6'; + const model = 'claude-sonnet-4-6'; const agentRunConfig: AgentRunConfig = { workingDirectory: config.workingDirectory, diff --git a/src/lib/programs/__tests__/program-registry.test.ts b/src/lib/programs/__tests__/program-registry.test.ts index 4399f6f4..4a1baa37 100644 --- a/src/lib/programs/__tests__/program-registry.test.ts +++ b/src/lib/programs/__tests__/program-registry.test.ts @@ -1,8 +1,10 @@ import { PROGRAM_REGISTRY, + agentSkillConfig, getProgramConfig, getSubcommandPrograms, } from '@lib/programs/program-registry'; +import type { WizardSession } from '@lib/wizard-session'; describe('PROGRAM_REGISTRY', () => { it('every entry has unique id, description, and non-empty steps', () => { @@ -21,7 +23,9 @@ describe('getProgramConfig', () => { expect(getProgramConfig('posthog-integration').id).toBe( 'posthog-integration', ); - expect(getProgramConfig('revenue-analytics-setup').command).toBe('revenue'); + expect(getProgramConfig('revenue-analytics-setup').command).toBe( + 'revenue-analytics', + ); }); }); @@ -31,7 +35,7 @@ describe('getSubcommandPrograms', () => { const commands = subcommands.map((c) => c.command); expect(commands).toContain('integrate'); - expect(commands).toContain('revenue'); + expect(commands).toContain('revenue-analytics'); for (const config of subcommands) { expect(config.command).toBeTruthy(); } @@ -65,3 +69,31 @@ describe('parentCommand nesting', () => { } }); }); + +describe('agentSkillConfig run recipe', () => { + // Regression guard: `agentSkillConfig` backs `wizard skill ` and the + // narrow `audit` leaves. The runner skips the agent entirely when a config + // has no `run` (run-wizard.ts `skipAgent`), so a missing recipe means those + // commands silently no-op instead of running the skill. + it('defines a run recipe so the agent is not skipped', () => { + expect(agentSkillConfig.run).toBeDefined(); + }); + + it('derives run metadata from the dispatched skillId', async () => { + expect(typeof agentSkillConfig.run).toBe('function'); + const session = { skillId: 'audit-events' } as unknown as WizardSession; + const run = + typeof agentSkillConfig.run === 'function' + ? await agentSkillConfig.run(session) + : agentSkillConfig.run!; + + expect(run.skillId).toBe('audit-events'); + expect(run.integrationLabel).toBe('audit-events'); + expect(run.reportFile).toContain('audit-events'); + // Fields the runner relies on to render the run + outro. + expect(run.spinnerMessage).toBeTruthy(); + expect(run.successMessage).toBeTruthy(); + expect(run.docsUrl).toBeTruthy(); + expect(run.estimatedDurationMinutes).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/programs/audit-3000/index.ts b/src/lib/programs/audit-3000/index.ts deleted file mode 100644 index 64ae5b2a..00000000 --- a/src/lib/programs/audit-3000/index.ts +++ /dev/null @@ -1,254 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { - AGENT_SKILL_STEPS, - createSkillProgram, -} from '@lib/programs/agent-skill/index'; -import type { ProgramStep, ProgramConfig } from '@lib/programs/program-step'; -import type { ProgramRun } from '@lib/agent/agent-runner'; -import type { WizardSession } from '@lib/wizard-session'; -import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; -import { AUDIT_ABORT_CASES } from '@lib/programs/audit/detect'; -import { - AUDIT_CHECKS_FILE, - AUDIT_CHECKS_KEY, - type AuditCheck, -} from '@lib/programs/audit/types'; -import { AUDIT_SEED_CHECKS } from '@lib/programs/audit/seed'; -import { logToFile } from '@utils/debug'; - -const AUDIT3000_REPORT_FILE = 'posthog-audit-3000-report.md'; - -// Extra checks the v3000 audit adds on top of the base 10. IDs must match -// those referenced in the audit-3000 skill's step files (Event Quality, -// stale feature-flag review, session replay [fix + optimize], per-product -// use-case expansion, and phase markers for the post-flags chain). -const AUDIT3000_EXTRA_CHECKS: AuditCheck[] = [ - // ── Event Quality (Step 5) ── - { - id: 'event-naming-standardization', - area: 'Event Quality', - label: 'Event naming convention is consistent', - status: 'pending', - }, - { - id: 'event-duplicates-and-bloat', - area: 'Event Quality', - label: 'No duplicate or bloated event capture', - status: 'pending', - }, - { - id: 'event-quality-context-review', - area: 'Event Quality', - label: 'Event property context reviewed', - status: 'pending', - }, - { - id: 'event-usage-coverage', - area: 'Event Quality', - label: 'Captured events match insights / dashboards usage', - status: 'pending', - }, - // ── Feature Flags (Step 6) ── - { - id: 'stale-feature-flags-reviewed', - area: 'Feature Flags', - label: 'Stale feature flags reviewed', - status: 'pending', - }, - // ── Session Replay — fix (Step 6b) ── - { - id: 'replay-minimum-duration-set', - area: 'Session Replay', - label: 'Minimum duration set on init', - status: 'pending', - }, - { - id: 'replay-mask-config', - area: 'Session Replay', - label: 'Mask config covers sensitive surfaces', - status: 'pending', - }, - { - id: 'replay-disabled-in-test-envs', - area: 'Session Replay', - label: 'Disabled in test / CI environments', - status: 'pending', - }, - { - id: 'replay-strict-minimum-duration', - area: 'Session Replay', - label: 'Strict minimum duration enforced', - status: 'pending', - }, - // ── Session Replay — optimize (Step 6b cost wave) ── - { - id: 'replay-sampling-rate', - area: 'Session Replay — Optimize', - label: 'Sampling rate tuned for cost', - status: 'pending', - }, - { - id: 'replay-triggers-configured', - area: 'Session Replay — Optimize', - label: 'Triggers configured (event / URL / flag)', - status: 'pending', - }, - { - id: 'replay-network-recording-filtered', - area: 'Session Replay — Optimize', - label: 'Network recording filtered', - status: 'pending', - }, - { - id: 'replay-mobile-sampling', - area: 'Session Replay — Optimize', - label: 'Mobile sampling configured', - status: 'pending', - }, - // ── Use Case: Expansion (Step 9) ── - { - id: 'expansion-product-analytics', - area: 'Use Case: Expansion', - label: 'Product analytics coverage', - status: 'pending', - }, - { - id: 'expansion-error-tracking', - area: 'Use Case: Expansion', - label: 'Error tracking coverage', - status: 'pending', - }, - { - id: 'expansion-llm-observability', - area: 'Use Case: Expansion', - label: 'LLM observability coverage', - status: 'pending', - }, - { - id: 'expansion-session-replay', - area: 'Use Case: Expansion', - label: 'Session replay coverage', - status: 'pending', - }, - { - id: 'expansion-feature-flags', - area: 'Use Case: Expansion', - label: 'Feature flags coverage', - status: 'pending', - }, - { - id: 'expansion-surveys', - area: 'Use Case: Expansion', - label: 'Surveys coverage', - status: 'pending', - }, - { - id: 'expansion-logs', - area: 'Use Case: Expansion', - label: 'Logs coverage', - status: 'pending', - }, - { - id: 'expansion-web-analytics', - area: 'Use Case: Expansion', - label: 'Web analytics coverage', - status: 'pending', - }, - // ── Additional Sections (Steps 7, 8, 10 phase markers) ── - // Tracked in the ledger so the UI can surface "did it run / was it - // skipped" alongside the regular checks. use-case-expansion is omitted - // because the eight `expansion-*` checks above cover that phase. - { - id: 'customer-enrichment', - area: 'Additional Sections', - label: 'Customer enrichment (Harmonic + PDL)', - status: 'pending', - }, - { - id: 'use-case-match', - area: 'Additional Sections', - label: 'Use-case match', - status: 'pending', - }, - { - id: 'final-report', - area: 'Additional Sections', - label: 'Final audit report written', - status: 'pending', - }, -]; - -const AUDIT3000_SEED_CHECKS: AuditCheck[] = [ - ...AUDIT_SEED_CHECKS, - ...AUDIT3000_EXTRA_CHECKS, -]; - -// Audit-3000 has its own arcade-flavoured intro / run / outro screens. The -// shared audit screens stay reserved for the original `audit` program. -const AUDIT3000_SCREEN_BY_STEP: Record = { - intro: 'audit-3000-intro', - run: 'audit-3000-run', - outro: 'audit-3000-outro', -}; - -const seedAudit3000Ledger = (installDir: string): void => { - const target = path.join(installDir, AUDIT_CHECKS_FILE); - const tmp = `${target}.tmp`; - fs.writeFileSync(tmp, JSON.stringify(AUDIT3000_SEED_CHECKS, null, 2), 'utf8'); - fs.renameSync(tmp, target); - logToFile( - `seedAudit3000Ledger: wrote ${AUDIT3000_SEED_CHECKS.length} entries to ${target}`, - ); -}; - -const seedBeforeAudit3000Run = (session: WizardSession): void => { - seedAudit3000Ledger(session.installDir); - session.frameworkContext[AUDIT_CHECKS_KEY] = AUDIT3000_SEED_CHECKS; -}; - -const withAudit3000Screens = (steps: ProgramStep[]): ProgramStep[] => - steps.map((step) => { - const override = AUDIT3000_SCREEN_BY_STEP[step.id]; - return override ? { ...step, screenId: override } : step; - }); - -const audit3000Steps: ProgramStep[] = withAudit3000Screens(AGENT_SKILL_STEPS); - -const baseConfig = createSkillProgram({ - skillId: 'audit-3000', - command: 'audit-3000', - id: 'audit-3000', - description: - 'Audit an existing PostHog integration (v3000 — adds event quality, stale-flag hygiene, customer enrichment, use-case match)', - integrationLabel: 'audit-3000', - customPrompt: - 'Run the audit-3000 skill end-to-end. Follow the step chain starting at references/1-version.md. Do not modify any project files — only create the final audit report and (when enrichment is enabled) the enrichment report.', - successMessage: `Audit complete! View the report at ./${AUDIT3000_REPORT_FILE}`, - reportFile: AUDIT3000_REPORT_FILE, - docsUrl: 'https://posthog.com/docs/product-analytics/best-practices', - spinnerMessage: 'Running PostHog Audit 3000...', - estimatedDurationMinutes: 6, - requires: ['posthog-integration'], - abortCases: AUDIT_ABORT_CASES, -}); - -const audit3000Run = async (session: WizardSession): Promise => { - seedBeforeAudit3000Run(session); - - if (!baseConfig.run) { - throw new Error('audit-3000 program has no run configuration.'); - } - - return typeof baseConfig.run === 'function' - ? baseConfig.run(session) - : baseConfig.run; -}; - -export const audit3000Config: ProgramConfig = { - ...baseConfig, - steps: audit3000Steps, - run: audit3000Run, - allowedTools: ['Agent'], - disallowedTools: [WIZARD_TOOL_NAMES.wizardAsk], -}; diff --git a/src/lib/programs/dispatch-family.ts b/src/lib/programs/dispatch-family.ts new file mode 100644 index 00000000..f587f675 --- /dev/null +++ b/src/lib/programs/dispatch-family.ts @@ -0,0 +1,149 @@ +import type { Arguments } from 'yargs'; + +import { auditConfig } from '@lib/programs/audit/index'; +import { agentSkillConfig } from '@lib/programs/program-registry'; +import { webAnalyticsDoctorConfig } from '@lib/programs/web-analytics-doctor/index'; +import type { ProgramConfig } from '@lib/programs/program-step'; +import { getSkillsBaseUrl } from '@lib/constants'; +import { fetchSkillMenu, type CliEntry } from '@lib/wizard-tools'; + +import { dispatchProgram } from '../../commands/factories/shared'; +import type { Command } from '../../commands/command'; + +/** + * Family commands (`wizard audit`, `wizard migrate`, ...) resolve their + * subcommands at runtime against the published `cliEntries` inside + * `skill-menu.json`. Adding a subcommand is a context-mill release — no + * wizard release needed. + * + * Wizard-native subcommands (programs that aren't backed by a single skill, + * e.g. `wizard audit web-analytics`) live here in code, dispatched directly + * without touching the registry. Adding a native is a wizard PR. + */ + +/** Wizard-native subcommands keyed by family. */ +const NATIVE_HANDLERS: Record> = { + audit: { 'web-analytics': webAnalyticsDoctorConfig }, +}; + +/** + * Resolve a fetched CliEntry to the ProgramConfig that actually runs it. + * Most entries run via the generic agent-skill program with the entry's + * `skillId` injected. The comprehensive `audit all` is the one exception — + * skillId 'audit' triggers the specialized auditConfig (custom hooks, + * content blocks, screens). + */ +function configForCliEntry(entry: CliEntry): ProgramConfig { + if (entry.skillId === 'audit') return auditConfig; + return { ...agentSkillConfig, skillId: entry.skillId }; +} + +function familyEntries(family: string, entries: CliEntry[]): CliEntry[] { + return entries.filter( + (e) => + e.role === 'command' && e.parentCommand === family && Boolean(e.command), + ); +} + +/** + * Dispatch `wizard ` to the right program. + * + * Order: + * 1. Native handler for (family, sub) — runs immediately, no network. + * 2. Fetched CliEntry — runs the resolved skill. + * 3. Unknown — prints the available list and exits non-zero. + */ +export async function dispatchFamily( + family: string, + argv: Arguments, +): Promise { + const sub = (argv.skill as string | undefined)?.trim(); + if (!sub) { + // Reached only in non-TTY/CI — an interactive terminal routes the no-sub + // case to the picker before this runs, so don't suggest opening it here. + process.stderr.write( + `\n\x1b[1;91m✖ \`wizard ${family}\` requires a subcommand.\x1b[0m\n` + + ` Pass one (e.g. \`wizard ${family} \`), or run it in an interactive terminal to pick from a menu.\n\n`, + ); + process.exit(1); + } + + const native = NATIVE_HANDLERS[family]?.[sub]; + if (native) { + dispatchProgram(native, argv); + return; + } + + const skillsBaseUrl = getSkillsBaseUrl(Boolean(argv['local-mcp'])); + const menu = await fetchSkillMenu(skillsBaseUrl); + if (!menu) { + process.stderr.write( + `\n\x1b[1;91m✖ Couldn't reach the skill registry at ${skillsBaseUrl}.\x1b[0m\n` + + ` Check your network connection and try again.\n\n`, + ); + process.exit(1); + } + + const entries = menu.cliEntries ?? []; + const entry = familyEntries(family, entries).find((e) => e.command === sub); + if (entry) { + dispatchProgram(configForCliEntry(entry), argv); + return; + } + + const available = [ + ...Object.keys(NATIVE_HANDLERS[family] ?? {}), + ...familyEntries(family, entries).map((e) => e.command!), + ].sort(); + process.stderr.write( + `\n\x1b[1;91m✖ Unknown subcommand "${sub}" under \`${family}\`.\x1b[0m\n` + + (available.length + ? ` Available: ${available.join(', ')}\n\n` + : ` No subcommands published for "${family}" yet.\n\n`), + ); + process.exit(1); +} + +/** + * Build the children list shown in the family's interactive picker. + * Combines native handlers with skill-backed entries from the live registry. + * Used by `familyCommandFactory`'s `interactiveDefault`. + */ +export function buildFamilyPickerChildren( + family: string, + entries: CliEntry[], +): Command[] { + const natives: Command[] = Object.entries(NATIVE_HANDLERS[family] ?? {}).map( + ([cmd, program]) => ({ + name: cmd, + description: program.description, + handler: (argv: Arguments) => dispatchProgram(program, argv), + }), + ); + const live: Command[] = familyEntries(family, entries).map((entry) => ({ + name: entry.command!, + description: entry.description, + handler: (argv: Arguments) => { + void dispatchFamily(family, { + ...argv, + skill: entry.command, + } as Arguments); + }, + default: entry.default, + })); + return [...natives, ...live]; +} + +/** + * The children the family picker shows **today**: only the leaf marked + * `default` (e.g. `audit events`). Every other subcommand stays runnable + * directly (`wizard audit `) — they just aren't listed in the picker yet. + * Falls back to all children when nothing is marked `default`. + * + * Temporary: when we're ready to surface the full menu, return `children` + * unchanged (and delete this note). + */ +export function pickerChildrenToShow(children: readonly Command[]): Command[] { + const defaults = children.filter((c) => c.default); + return defaults.length > 0 ? [...defaults] : [...children]; +} diff --git a/src/lib/programs/migration/index.ts b/src/lib/programs/migration/index.ts index c333107f..27cf0b3d 100644 --- a/src/lib/programs/migration/index.ts +++ b/src/lib/programs/migration/index.ts @@ -18,41 +18,24 @@ const MIGRATION_ABORT_CASES: AbortCase[] = [ }, ]; -/** - * Map each `--product=` choice to the context-mill skill ID that handles - * it. Adding a variant: drop a new row here. The CLI `choices` and the - * runtime lookup both read from this map, so the two stay in sync. - */ -const PRODUCT_TO_SKILL_ID = { - statsig: 'migrate-statsig', -} as const; - -type MigrateProduct = keyof typeof PRODUCT_TO_SKILL_ID; -const MIGRATE_PRODUCTS = Object.keys(PRODUCT_TO_SKILL_ID) as MigrateProduct[]; +// Default skill id when nothing else picks one. The `wizard migrate ` +// subcommands override this via skillCommandFactory using each manifest +// entry's skillId, so this default only kicks in for legacy callers (e.g. +// programmatic uses of migrationConfig directly). +const DEFAULT_MIGRATE_SKILL_ID = 'migrate-statsig'; export const migrationConfig: ProgramConfig = { command: 'migrate', description: 'Migrate to PostHog from another analytics provider', id: 'migration', - skillId: PRODUCT_TO_SKILL_ID.statsig, + skillId: DEFAULT_MIGRATE_SKILL_ID, steps: MIGRATION_PROGRAM, reportFile: MIGRATION_REPORT_FILE, getContentBlocks, allowedTools: ['Agent'], disallowedTools: [WIZARD_TOOL_NAMES.wizardAsk], - cliOptions: { - product: { - describe: 'Source SDK to migrate from', - type: 'string', - choices: MIGRATE_PRODUCTS, - demandOption: true, - }, - }, - mapCliOptions: (argv) => ({ - skillId: PRODUCT_TO_SKILL_ID[argv.product as MigrateProduct], - }), run: { - skillId: PRODUCT_TO_SKILL_ID.statsig, + skillId: DEFAULT_MIGRATE_SKILL_ID, integrationLabel: 'migration', customPrompt: () => 'Migrate this project from its existing third-party analytics, ' + diff --git a/src/lib/programs/program-registry.ts b/src/lib/programs/program-registry.ts index 5f323725..f77aa46a 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -11,11 +11,11 @@ */ import type { ProgramConfig } from './program-step.js'; +import { POSTHOG_DOCS_URL } from '../constants.js'; import { posthogIntegrationConfig } from './posthog-integration/index.js'; import { revenueAnalyticsConfig } from './revenue-analytics/index.js'; import { auditConfig } from './audit/index.js'; import { eventsAuditConfig } from './events-audit/index.js'; -import { audit3000Config } from './audit-3000/index.js'; import { posthogDoctorConfig } from './posthog-doctor/index.js'; import { webAnalyticsDoctorConfig } from './web-analytics-doctor/index.js'; import { migrationConfig } from './migration/index.js'; @@ -29,15 +29,34 @@ import { } from './mcp/index.js'; import { slackConnectConfig } from './slack/index.js'; -// Generic skill program — invoked when the wizard runs an arbitrary -// context-mill skill chosen at runtime (session.skillId) rather than a -// registered named program. No CLI command, no run config. -const agentSkillConfig: ProgramConfig = { +// Generic skill program — runs an arbitrary context-mill skill chosen at +// dispatch time (session.skillId) rather than a registered named program. +// Backs `wizard skill ` and the narrow `audit` leaves (events, +// feature-flags, identify, session-replay, autocapture); each injects its +// skillId onto the config, which lands on session.skillId before the run. +// +// The `run` recipe is a function rather than a static block because the +// skillId isn't known until dispatch. Without a `run` recipe the runner's +// `skipAgent` guard (run-wizard.ts) fires and the skill never executes — so we +// derive generic run metadata from the resolved skill id at run time. +export const agentSkillConfig: ProgramConfig = { id: 'agent-skill', description: 'Run an arbitrary context-mill skill', steps: AGENT_SKILL_STEPS, getContentBlocks: agentSkillContentBlocks, allowedTools: ['Agent'], + run: (session) => { + const skillId = session.skillId ?? 'agent-skill'; + return Promise.resolve({ + skillId, + integrationLabel: skillId, + spinnerMessage: `Running ${skillId}...`, + successMessage: `${skillId} complete!`, + estimatedDurationMinutes: 5, + reportFile: `posthog-${skillId}-report.md`, + docsUrl: POSTHOG_DOCS_URL, + }); + }, }; export const PROGRAM_REGISTRY = [ @@ -46,7 +65,6 @@ export const PROGRAM_REGISTRY = [ errorTrackingUploadSourceMapsConfig, auditConfig, eventsAuditConfig, - audit3000Config, posthogDoctorConfig, webAnalyticsDoctorConfig, migrationConfig, @@ -69,7 +87,6 @@ export const Program = { Migration: migrationConfig.id, Audit: auditConfig.id, EventsAudit: eventsAuditConfig.id, - Audit3000: audit3000Config.id, PosthogDoctor: posthogDoctorConfig.id, WebAnalyticsDoctor: webAnalyticsDoctorConfig.id, AgentSkill: agentSkillConfig.id, diff --git a/src/lib/programs/program-step.ts b/src/lib/programs/program-step.ts index 32621333..55a91167 100644 --- a/src/lib/programs/program-step.ts +++ b/src/lib/programs/program-step.ts @@ -103,6 +103,54 @@ export interface ProgramStep { onReady?: (ctx: ProgramReadyContext) => void | Promise; } +/** + * Declares a program's place in the wizard CLI surface. + * + * Mirrors the `cli:` block in context-mill skill configs so wizard-native + * programs and skill-backed programs share one vocabulary. Field names + * match `ProgramConfig.command` / `parentCommand` above, so contributors + * only learn one set of words. + * + * - `role: 'command'` — appears as a normal wizard command. + * - `role: 'skill'` — reachable only via `wizard skill `. + * - `role: 'internal'` — hidden everywhere, only reachable via the + * `--skill=` dev escape hatch. + * + * Mapping table — declaration on the left, registered command on the right: + * + * { role: 'command', → wizard revenue-analytics + * command: 'revenue-analytics' } + * + * { role: 'command', → wizard audit feature-flags + * parentCommand: 'audit', + * command: 'feature-flags' } + * + * { role: 'skill' } → wizard skill + * + * `cli` only configures the command shape — the verbs the user types. + * Flags and positional args (e.g. `--since=30d`) are configured on + * `cliOptions`, not here. + * + * Naming rule: commands use the full PostHog product name with hyphens + * (`revenue-analytics`, `feature-flags`, `session-replay`), not + * abbreviations like `revenue` or `flags`. + */ +export interface ProgramCliSurface { + /** Where the program appears in the wizard CLI surface. */ + role: 'command' | 'skill' | 'internal'; + /** + * The user-typed word that registers this program (e.g. `'feature-flags'` + * in `wizard audit feature-flags`, or `'revenue-analytics'` in + * `wizard revenue-analytics`). Required when `role` is `'command'`. + */ + command?: string; + /** + * The command this program nests under (e.g. `'audit'` for + * `wizard audit feature-flags`). Omit for flat / standalone commands. + */ + parentCommand?: string; +} + /** * Uniform configuration for a wizard program. * @@ -110,7 +158,7 @@ export interface ProgramStep { * for CLI registration, sequence/step wiring, and skill bootstrap. */ export interface ProgramConfig { - /** CLI command name (e.g. 'revenue'). Omit for the default program. */ + /** CLI command name (e.g. 'revenue-analytics'). Omit for the default program. */ command?: string; /** * Parent CLI command to nest this program under. When set, the program is @@ -165,8 +213,8 @@ export interface ProgramConfig { /** * LearnCard deck rendered in the shared `RunScreen` while the agent * runs. Lives at `/content/index.tsx` by convention. - * Programs that ship a custom RunScreen variant (audit, audit-3000) - * or skip the run step (posthog-doctor) leave this unset. + * Programs that ship a custom RunScreen variant (audit) or skip the + * run step (posthog-doctor) leave this unset. */ getContentBlocks?: (store?: WizardStore) => ContentBlock[]; /** @@ -194,6 +242,11 @@ export interface ProgramConfig { * dispatch in a program whose steps are explicitly single-agent. */ disallowedTools?: readonly string[]; + /** + * Declares this program's place in the wizard CLI surface. See + * `ProgramCliSurface` for semantics. + */ + cli?: ProgramCliSurface; } /** diff --git a/src/lib/programs/revenue-analytics/index.ts b/src/lib/programs/revenue-analytics/index.ts index b5fb18bd..27a30ffa 100644 --- a/src/lib/programs/revenue-analytics/index.ts +++ b/src/lib/programs/revenue-analytics/index.ts @@ -5,9 +5,10 @@ import { REVENUE_ABORT_CASES } from './detect.js'; import { getContentBlocks } from './content/index.js'; export const revenueAnalyticsConfig: ProgramConfig = { - command: 'revenue', + command: 'revenue-analytics', description: 'Set up PostHog revenue analytics (e.g. Stripe integration)', id: 'revenue-analytics-setup', + skillId: 'revenue-analytics-setup', steps: REVENUE_ANALYTICS_PROGRAM, getContentBlocks, allowedTools: ['Agent'], diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index ab8824b6..7b2f6693 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -44,8 +44,29 @@ async function getSDKModule(): Promise { export type SkillEntry = { id: string; name: string; downloadUrl: string }; +/** + * Entry in the wizard's runtime CLI registry. Mirrors the shape context-mill + * publishes under `cliEntries` inside `skill-menu.json`. The wizard uses these + * to register skill-backed subcommands at runtime instead of from a baked + * build-time snapshot. + */ +export type CliEntry = { + skillId: string; + role: 'command' | 'skill' | 'internal'; + command?: string; + parentCommand?: string; + default?: boolean; + displayName: string; + description: string; +}; + export interface SkillMenu { categories: Record; + /** + * Skills exposed as CLI commands. Optional because context-mill releases + * older than the runtime-resolver cutover don't emit this field. + */ + cliEntries?: CliEntry[]; } // --------------------------------------------------------------------------- diff --git a/src/ui/tui/playground/demos/LearnDeckDemo.tsx b/src/ui/tui/playground/demos/LearnDeckDemo.tsx index f7f6bf23..a38e3ecf 100644 --- a/src/ui/tui/playground/demos/LearnDeckDemo.tsx +++ b/src/ui/tui/playground/demos/LearnDeckDemo.tsx @@ -30,7 +30,6 @@ import { Colors } from '@ui/tui/styles'; import type { WizardStore } from '@ui/tui/store'; import { PROGRAM_REGISTRY } from '@lib/programs/program-registry'; import { AUDIT_AREA_SLIDES } from '@ui/tui/screens/audit/slides/index'; -import { AUDIT_3000_AREA_SLIDES } from '@ui/tui/screens/audit-3000/slides/index'; import type { AreaSlide } from '@ui/tui/screens/audit/slides/shared'; interface Deck { @@ -108,19 +107,14 @@ export const LearnDeckDemo = ({ store }: LearnDeckDemoProps) => { }); } - // Audit + audit-3000 ship their own per-area slide model (not the - // ContentBlock deck most programs use). Adapt each AreaSlide into a - // flat ContentBlock list so the flipper can review them the same way. + // Audit ships its own per-area slide model (not the ContentBlock deck + // most programs use). Adapt each AreaSlide into a flat ContentBlock + // list so the flipper can review them the same way. all.push({ id: 'audit:area-slides', label: 'audit · area slides', blocks: areaSlidesToBlocks(AUDIT_AREA_SLIDES), }); - all.push({ - id: 'audit-3000:area-slides', - label: 'audit-3000 · area slides', - blocks: areaSlidesToBlocks(AUDIT_3000_AREA_SLIDES), - }); return all; }, [store]); diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 112f00d7..cf0056b0 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -28,9 +28,6 @@ import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; import { AuditIntroScreen } from './screens/audit/AuditIntroScreen.js'; import { AuditRunScreen } from './screens/audit/AuditRunScreen.js'; import { AuditOutroScreen } from './screens/audit/AuditOutroScreen.js'; -import { Audit3000IntroScreen } from './screens/audit-3000/Audit3000IntroScreen.js'; -import { Audit3000RunScreen } from './screens/audit-3000/Audit3000RunScreen.js'; -import { Audit3000OutroScreen } from './screens/audit-3000/Audit3000OutroScreen.js'; import { SetupScreen } from './screens/SetupScreen.js'; import { AuthScreen } from './screens/AuthScreen.js'; import { AiOptInRequiredScreen } from './screens/AiOptInRequiredScreen.js'; @@ -85,9 +82,6 @@ export function createScreens( [ScreenId.AuditIntro]: , [ScreenId.AuditRun]: , [ScreenId.AuditOutro]: , - [ScreenId.Audit3000Intro]: , - [ScreenId.Audit3000Run]: , - [ScreenId.Audit3000Outro]: , [ScreenId.HealthCheck]: , [ScreenId.DoctorIntro]: , [ScreenId.DoctorReport]: , diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index a17f8d4f..cd83e569 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -25,9 +25,6 @@ export enum ScreenId { AuditIntro = 'audit-intro', AuditRun = 'audit-run', AuditOutro = 'audit-outro', - Audit3000Intro = 'audit-3000-intro', - Audit3000Run = 'audit-3000-run', - Audit3000Outro = 'audit-3000-outro', HealthCheck = 'health-check', DoctorIntro = 'doctor-intro', DoctorReport = 'doctor-report', diff --git a/src/ui/tui/screens/audit-3000/Audit3000AreaPane.tsx b/src/ui/tui/screens/audit-3000/Audit3000AreaPane.tsx deleted file mode 100644 index 419cc057..00000000 --- a/src/ui/tui/screens/audit-3000/Audit3000AreaPane.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Audit-3000 right pane — arcade-flavoured fork of `AuditAreaPane`. - * - * Mirrors the audit pane's three-state logic (active slide → empty → - * wrap-up) but routes through the audit-3000 slide registry and uses - * "LEVEL N: " framing instead of "Verifying ...". - */ - -import { Fragment } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { spawn } from 'node:child_process'; -import { Colors } from '@ui/tui/styles'; -import { type AuditCheck } from '@lib/programs/audit/types'; -import { AUDIT_3000_AREA_SLIDES, type AreaSlide } from './slides/index.js'; - -const FINDING_STATUSES: AuditCheck['status'][] = [ - 'error', - 'warning', - 'suggestion', -]; - -const isFinding = (c: AuditCheck) => FINDING_STATUSES.includes(c.status); - -const fallbackSlide = (area: string): AreaSlide => ({ - area, - intro: [`Now playing: ${area.toLowerCase()}\u2026`], - docsUrl: '', -}); - -const openLink = (url: string) => { - const cmd = - process.platform === 'darwin' - ? 'open' - : process.platform === 'win32' - ? 'cmd' - : 'xdg-open'; - const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; - spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); -}; - -interface Audit3000AreaPaneProps { - checks: AuditCheck[]; - reportPath: string; -} - -export const Audit3000AreaPane = ({ - checks, - reportPath, -}: Audit3000AreaPaneProps) => { - const pendingChecks = checks.filter((c) => c.status === 'pending'); - const activeArea = pendingChecks[0]?.area; - const slide = activeArea - ? AUDIT_3000_AREA_SLIDES.find((s) => s.area === activeArea) ?? - fallbackSlide(activeArea) - : null; - - const levelIndex = activeArea - ? AUDIT_3000_AREA_SLIDES.findIndex((s) => s.area === activeArea) - : -1; - const level = levelIndex >= 0 ? levelIndex + 1 : null; - - useInput((input) => { - if (input.toLowerCase() === 'o' && slide?.docsUrl) { - openLink(slide.docsUrl); - } - }); - - if (slide) { - const hasFindings = checks.some(isFinding); - return ( - - ); - } - - if (checks.length === 0) { - return null; - } - - return ; -}; - -const ActiveSlide = ({ - slide, - level, - hasFindings, -}: { - slide: AreaSlide; - level: number | null; - hasFindings: boolean; -}) => ( - - - {level ? `LEVEL ${level}: ` : ''} - {slide.area.toUpperCase()} - - - - {slide.visual} - {slide.intro.map((paragraph, i) => ( - - {i > 0 && } - {paragraph} - - ))} - - - - {slide.docsUrl && ( - <> - [O] Learn more - - )} - {hasFindings && ( - <> - {slide.docsUrl && ' '}[ - {'\u2192'}] View issues - - )} - - - -); - -const WritingReport = ({ reportPath }: { reportPath: string }) => ( - - - STAGE CLEAR. - - - - All checks resolved. Compiling your high-score reel at{' '} - {reportPath}. - - - - The report covers everything we checked, what we found, and what to do - next. - - - {'Stand by\u2026'} - -); diff --git a/src/ui/tui/screens/audit-3000/Audit3000ChecksPanel.tsx b/src/ui/tui/screens/audit-3000/Audit3000ChecksPanel.tsx deleted file mode 100644 index efc72d43..00000000 --- a/src/ui/tui/screens/audit-3000/Audit3000ChecksPanel.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Audit-3000 left pane on the Run screen. Arcade-flavoured fork of the - * audit program's `PendingChecksList`: a running score banner sits on - * top, then the area-level "level" headers underneath. - * - * Per-check rows are deliberately omitted here — the Hi-score Table tab - * has the full check-by-check breakdown. This pane is the at-a-glance - * stage overview. - */ - -import { Box, Text } from 'ink'; -import { Spinner } from '@inkjs/ui'; -import { type AuditCheck, type AuditStatus } from '@lib/programs/audit/types'; -import { Colors, Icons } from '@ui/tui/styles'; -import { LoadingBox } from '@ui/tui/primitives/index'; - -const NEON_PINK = '#F54E00'; -const NEON_GOLD = '#F9BD2B'; -const NEON_BLUE = '#1D4AFF'; - -interface Audit3000ChecksPanelProps { - checks: AuditCheck[]; -} - -interface Group { - area: string; - checks: AuditCheck[]; -} - -function groupByArea(checks: AuditCheck[]): Group[] { - const order: string[] = []; - const map = new Map(); - for (const c of checks) { - if (!map.has(c.area)) { - map.set(c.area, []); - order.push(c.area); - } - map.get(c.area)!.push(c); - } - return order.map((area) => ({ area, checks: map.get(area)! })); -} - -function countByStatus(checks: AuditCheck[]): Record { - const counts: Record = { - pending: 0, - pass: 0, - error: 0, - warning: 0, - suggestion: 0, - }; - for (const c of checks) counts[c.status] += 1; - return counts; -} - -const ScoreBanner = ({ checks }: { checks: AuditCheck[] }) => { - const counts = countByStatus(checks); - const resolved = checks.length - counts.pending; - const issues = counts.error + counts.warning + counts.suggestion; - - return ( - - - - {'SCORE '} - - - {resolved.toString().padStart(2, '0')} - - {' / '} - {checks.length.toString().padStart(2, '0')} - - - {`PASS \u25B2 ${counts.pass}`} - {' '} - {`MISS \u25BC ${issues}`} - {' '} - {`QUEUE \u25CB ${counts.pending}`} - - - ); -}; - -function groupIcon(group: Group): { icon: string; color: string } { - const total = group.checks.length; - const complete = group.checks.filter((c) => c.status !== 'pending').length; - if (complete === 0) return { icon: Icons.squareOpen, color: Colors.muted }; - if (complete === total) - return { icon: Icons.squareFilled, color: Colors.success }; - return { icon: Icons.triangleRight, color: Colors.primary }; -} - -const GroupHeader = ({ - group, - level, - showIcon, - isActive, -}: { - group: Group; - level: number; - showIcon: boolean; - isActive: boolean; -}) => { - const complete = group.checks.filter((c) => c.status !== 'pending').length; - const total = group.checks.length; - const { icon, color } = groupIcon(group); - return ( - - {isActive ? ( - - - - ) : showIcon ? ( - - {icon}{' '} - - ) : null} - - {`L${level} `} - {group.area}{' '} - - ({complete}/{total}) - - - - ); -}; - -export const Audit3000ChecksPanel = ({ checks }: Audit3000ChecksPanelProps) => { - if (checks.length === 0) { - return ( - - AUDIT-3000 - - - - ); - } - - const groups = groupByArea(checks); - const activeIndex = groups.findIndex((g) => - g.checks.some((c) => c.status === 'pending'), - ); - - return ( - - - AUDIT-3000 - - - - {groups.map((group, i) => ( - - ))} - - - Full breakdown: Hi-score table (report){' '} - tab - - - - ); -}; diff --git a/src/ui/tui/screens/audit-3000/Audit3000IntroScreen.tsx b/src/ui/tui/screens/audit-3000/Audit3000IntroScreen.tsx deleted file mode 100644 index 8eb88820..00000000 --- a/src/ui/tui/screens/audit-3000/Audit3000IntroScreen.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { Box, Text } from 'ink'; -import { useEffect, useState, useSyncExternalStore } from 'react'; -import type { WizardStore } from '@ui/tui/store'; -import { IntroScreenLayout } from '@ui/tui/screens/IntroScreenLayout'; -import { - SkillSourceInfo, - useSkillEntry, -} from '@ui/tui/screens/SkillSourceInfo'; -import { NEON_BLUE, NEON_GOLD, NEON_PINK } from './arcade-colors.js'; - -const AUDIT3000_SKILL_ID = 'audit-3000'; - -const ArcadeBanner = () => { - // Blink the "INSERT COIN" tagline once per 600ms — classic attract-mode - // pacing without burning Ink with rapid re-renders. - const [blinkOn, setBlinkOn] = useState(true); - useEffect(() => { - const id = setInterval(() => setBlinkOn((v) => !v), 600); - return () => clearInterval(id); - }, []); - - const top = '\u250F' + '\u2501'.repeat(32) + '\u2513'; - const bottom = '\u2517' + '\u2501'.repeat(32) + '\u251B'; - - return ( - - - {top} - - - - {'\u2503'} - - - {' A U D I T '} - - - {'-'} - - - {' 3 0 0 0 '} - - - {'\u2503'} - - - - - {'\u2503'} - - - {' \u25B6 INSERT COIN TO PLAY \u25C0 '} - - - {'\u2503'} - - - - {bottom} - - - ); -}; - -interface Audit3000IntroScreenProps { - store: WizardStore; -} - -export const Audit3000IntroScreen = ({ store }: Audit3000IntroScreenProps) => { - useSyncExternalStore( - (cb) => store.subscribe(cb), - () => store.getSnapshot(), - ); - - const [showingMoreInfo, setShowingMoreInfo] = useState(false); - const { session } = store; - const { skillEntry, fetchFailed } = useSkillEntry( - AUDIT3000_SKILL_ID, - session.localMcp, - ); - - const body = showingMoreInfo ? ( - - - - The wizard is an agent that executes PostHog tasks. Its code is open - source: https://github.com/PostHog/wizard - - - - - The{' '} - - {AUDIT3000_SKILL_ID} - {' '} - program reviews your PostHog integration across 34 checks — SDK install, - identification, event capture, event quality, stale feature flag - hygiene, session replay (fix + optimize), and use-case expansion across - 8 PostHog products. When enrichment is available it also produces a - company profile and use-case match. Nothing in your project is modified. - - - - Results stream live to the{' '} - - Hi-score Table - {' '} - tab during the run — that's your live report. When the audit - finishes, the same report is also exported to{' '} - ./posthog-audit-3000-report.md in your - project folder. - - - - - - - ) : ( - - - - 34 checks. 9 levels. 1 final report. - - High-score your PostHog integration before the boss fight. - - - - Live report: Hi-score Table tab · - Export: ./posthog-audit-3000-report.md - - - - - ); - - const menuOptions = showingMoreInfo - ? [{ label: 'Back', value: 'back' }] - : [ - { label: 'PRESS START', value: 'continue' }, - { label: 'More info', value: 'more-info' }, - { label: 'Cancel', value: 'cancel' }, - ]; - - const handleSelect = (value: string) => { - if (value === 'cancel') process.exit(0); - else if (value === 'more-info') setShowingMoreInfo(true); - else if (value === 'back') setShowingMoreInfo(false); - else store.completeSetup(); - }; - - return ( - - ); -}; diff --git a/src/ui/tui/screens/audit-3000/Audit3000OutroScreen.tsx b/src/ui/tui/screens/audit-3000/Audit3000OutroScreen.tsx deleted file mode 100644 index 9c1cbac6..00000000 --- a/src/ui/tui/screens/audit-3000/Audit3000OutroScreen.tsx +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Audit3000OutroScreen — high-score-style summary after a v3000 audit run. - * - * On success: arcade FINAL SCORE banner with pass / miss tallies, the - * absolute report path, and the standard problematic-items list. - * - * Error and cancel branches mirror `AuditOutroScreen` so failure modes - * stay legible without arcade dressing. - */ - -import { join } from 'node:path'; -import { Box, Text, useInput } from 'ink'; -import { useSyncExternalStore } from 'react'; -import type { WizardStore } from '@ui/tui/store'; -import { OutroKind } from '@lib/wizard-session'; -import { Colors } from '@ui/tui/styles'; -import { - getAuditChecks, - type AuditCheck, - type AuditStatus, -} from '@lib/programs/audit/types'; -import { AuditChecksOutroSection } from '@ui/tui/screens/audit/AuditChecksOutroSection'; - -const NEON_PINK = '#F54E00'; -const NEON_GOLD = '#F9BD2B'; -const NEON_BLUE = '#1D4AFF'; - -const PANEL_WIDTH = 48; - -const padCenter = (s: string, width: number): string => { - if (s.length >= width) return s; - const total = width - s.length; - const left = Math.floor(total / 2); - const right = total - left; - return ' '.repeat(left) + s + ' '.repeat(right); -}; - -function countByStatus(checks: AuditCheck[]): Record { - const counts: Record = { - pending: 0, - pass: 0, - error: 0, - warning: 0, - suggestion: 0, - }; - for (const c of checks) counts[c.status] += 1; - return counts; -} - -const FinalScorePanel = ({ checks }: { checks: AuditCheck[] }) => { - const counts = countByStatus(checks); - const resolved = checks.length - counts.pending; - const issues = counts.error + counts.warning + counts.suggestion; - - const top = '\u250F' + '\u2501'.repeat(PANEL_WIDTH) + '\u2513'; - const bottom = '\u2517' + '\u2501'.repeat(PANEL_WIDTH) + '\u251B'; - const sep = '\u2520' + '\u2500'.repeat(PANEL_WIDTH) + '\u2528'; - - const row = (content: string) => ( - - - {'\u2503'} - - {content} - - {'\u2503'} - - - ); - - return ( - - - {top} - - {row(padCenter('GAME OVER', PANEL_WIDTH))} - - - {'\u2503'} - - - {padCenter( - `FINAL SCORE ${resolved} / ${checks.length}`, - PANEL_WIDTH, - )} - - - {'\u2503'} - - - {sep} - - - {'\u2503'} - - - {padCenter(`PASS \u25B2 ${counts.pass}`, PANEL_WIDTH)} - - - {'\u2503'} - - - - - {'\u2503'} - - - {padCenter(`MISS \u25BC ${issues}`, PANEL_WIDTH)} - - - {'\u2503'} - - - - {bottom} - - - ); -}; - -interface Audit3000OutroScreenProps { - store: WizardStore; -} - -export const Audit3000OutroScreen = ({ store }: Audit3000OutroScreenProps) => { - useSyncExternalStore( - (cb) => store.subscribe(cb), - () => store.getSnapshot(), - ); - - useInput(() => { - store.setOutroDismissed(); - }); - - const outroData = store.session.outroData; - - if (!outroData) { - return ( - - {'Counting your tokens\u2026'} - - ); - } - - const checks = getAuditChecks(store.session); - - return ( - - {outroData.kind === OutroKind.Success && ( - - - - - - {'\u2714'} {outroData.message || 'AUDIT-3000 complete!'} - - - - {outroData.reportFile && ( - - - High-score reel saved to: - - - {join(store.session.installDir, outroData.reportFile)} - - - A markdown file in your project folder — open it in any editor - to read the full audit. - - - )} - - - - {outroData.docsUrl && ( - - - Learn more: {outroData.docsUrl} - - - )} - - )} - - {outroData.kind === OutroKind.Error && ( - - - {'\u2718'} {outroData.message || 'An error occurred'} - - {outroData.body && ( - - {outroData.body} - - )} - - )} - - {outroData.kind === OutroKind.Cancel && ( - - {'\u25A0'} {outroData.message || 'Cancelled'} - - )} - - - Press any key to continue - - - ); -}; diff --git a/src/ui/tui/screens/audit-3000/Audit3000RunScreen.tsx b/src/ui/tui/screens/audit-3000/Audit3000RunScreen.tsx deleted file mode 100644 index a630a226..00000000 --- a/src/ui/tui/screens/audit-3000/Audit3000RunScreen.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useState, useSyncExternalStore } from 'react'; -import { join } from 'node:path'; -import { Box } from 'ink'; -import type { WizardStore } from '@ui/tui/store'; -import { - TabContainer, - SplitView, - LogViewer, - HNViewer, -} from '@ui/tui/primitives/index'; -import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; -import { useFileWatcher } from '@ui/tui/hooks/file-watcher'; -import { AuditChecksViewer } from '@ui/tui/screens/audit/AuditChecksViewer/AuditChecksViewer'; -import { Audit3000AreaPane } from './Audit3000AreaPane.js'; -import { Audit3000ChecksPanel } from './Audit3000ChecksPanel.js'; -import { HedgehogRunner } from './HedgehogRunner.js'; -import { initialState } from './hedgehog-runner-engine.js'; -import { - AUDIT_CHECKS_FILE, - AUDIT_CHECKS_KEY, - coerceAuditChecks, - getAuditChecks, -} from '@lib/programs/audit/types'; -import { getProgramConfig } from '@lib/programs/program-registry'; -import { WIZARD_LOG_FILE } from '@utils/paths'; - -const AUDIT_3000_REPORT_FILE_FALLBACK = 'posthog-audit-3000-report.md'; - -interface Audit3000RunScreenProps { - store: WizardStore; -} - -export const Audit3000RunScreen = ({ store }: Audit3000RunScreenProps) => { - useSyncExternalStore( - (cb) => store.subscribe(cb), - () => store.getSnapshot(), - ); - - // Mirror the agent's audit ledger into the store. The audit-3000 skill - // writes to the same `.posthog-audit-checks.json` path the original - // audit uses, so the file watcher key is shared. - useFileWatcher(join(store.session.installDir, AUDIT_CHECKS_FILE), (parsed) => - store.setFrameworkContext(AUDIT_CHECKS_KEY, coerceAuditChecks(parsed)), - ); - - const statuses = - store.statusMessages.length > 0 ? store.statusMessages : undefined; - - const [columns] = useStdoutDimensions(); - // Game state is lifted here so it survives tab switches — the HedgehogRunner - // unmounts whenever the user views another tab, but the score / position / - // obstacles stay frozen until they switch back. - const [gameState, setGameState] = useState(() => initialState()); - const checks = getAuditChecks(store.session); - const reportFile = - getProgramConfig(store.router.activeProgram).reportFile ?? - AUDIT_3000_REPORT_FILE_FALLBACK; - const reportPath = `./${reportFile}`; - const checksPanel = ; - const areaPane = ( - - ); - - // Narrow terminals: drop the area pane. - const statusComponent = - columns < 80 ? ( - - {checksPanel} - - ) : ( - - ); - - const tabs = [ - { id: 'status', label: 'Arcade', component: statusComponent }, - { - id: 'audit-checks', - label: 'Hi-score table (report)', - component: , - }, - { - id: 'play', - label: 'Play', - component: , - }, - { - id: 'logs', - label: 'Tail logs', - component: , - }, - { id: 'hn', label: 'HN', component: }, - ]; - - return ( - - ); -}; diff --git a/src/ui/tui/screens/audit-3000/HedgehogRunner.tsx b/src/ui/tui/screens/audit-3000/HedgehogRunner.tsx deleted file mode 100644 index b0659470..00000000 --- a/src/ui/tui/screens/audit-3000/HedgehogRunner.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/** - * HedgehogRunner — playable arcade game shown while the audit runs. - * - * Game state lives in the parent (Audit3000RunScreen) so it survives tab - * switches. This component owns the render loop (setInterval) and key - * bindings; when the user switches tabs the component unmounts, the - * interval clears, and state freezes in the parent — free pause behaviour. - */ - -import { Box, Text } from 'ink'; -import { Fragment, useEffect, type Dispatch, type SetStateAction } from 'react'; -import { Colors } from '@ui/tui/styles'; -import { NEON_BLUE, NEON_GOLD, NEON_PINK } from './arcade-colors.js'; -import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; -import { - useKeyBindings, - KeyMatch, - type KeyBinding, -} from '@ui/tui/hooks/useKeyBindings'; -import { - HEDGEHOG_COL, - PLAYFIELD_WIDTH, - jump, - restart, - tick, - type GameState, -} from './hedgehog-runner-engine.js'; - -const TICK_MS = 150; -const PLAYFIELD_ROWS = 3; -const MIN_TERMINAL_COLUMNS = 50; -const HEDGEHOG_GLYPH = 'O'; -const SPIKE_GLYPH = '^'; -const RING_GLYPH = 'o'; -const GROUND_GLYPH = '='; - -interface HedgehogRunnerProps { - state: GameState; - onChange: Dispatch>; -} - -const pad4 = (n: number) => String(n).padStart(4, '0'); - -export const HedgehogRunner = ({ state, onChange }: HedgehogRunnerProps) => { - const [columns] = useStdoutDimensions(); - - useEffect(() => { - const id = setInterval(() => { - onChange((prev) => tick(prev)); - }, TICK_MS); - return () => clearInterval(id); - }, [onChange]); - - const bindings: KeyBinding[] = [ - { - match: KeyMatch.Space, - label: 'space', - action: 'jump', - handler: () => onChange((prev) => jump(prev)), - }, - { - match: 'r', - label: 'r', - action: 'restart', - handler: () => - onChange((prev) => (prev.isGameOver ? restart(prev) : prev)), - }, - ]; - useKeyBindings('hedgehog-runner', bindings); - - if (columns < MIN_TERMINAL_COLUMNS) { - return ( - - - Widen the terminal to at least {MIN_TERMINAL_COLUMNS} columns to play - Hedgehog Runner. - - - ); - } - - return ( - - - - SCORE {pad4(state.score)} - - {' '} - - HI {pad4(state.hiScore)} - - {state.isGameOver && ( - <> - {' '} - - ✱ GAME OVER ✱ - - - )} - - - {Array.from({ length: PLAYFIELD_ROWS }, (_, row) => ( - - ))} - - {GROUND_GLYPH.repeat(PLAYFIELD_WIDTH)} - - ); -}; - -interface PlayfieldRowProps { - row: number; - state: GameState; -} - -const PlayfieldRow = ({ row, state }: PlayfieldRowProps) => { - const cells: Array<{ ch: string; color?: string; bold?: boolean }> = []; - for (let x = 0; x < PLAYFIELD_WIDTH; x++) { - if (x === HEDGEHOG_COL && row === state.hedgehogRow) { - cells.push({ ch: HEDGEHOG_GLYPH, color: NEON_PINK, bold: true }); - continue; - } - const obstacle = state.obstacles.find((o) => o.x === x && o.row === row); - if (obstacle) { - cells.push( - obstacle.kind === 'spike' - ? { ch: SPIKE_GLYPH, color: 'red', bold: true } - : { ch: RING_GLYPH, color: NEON_GOLD, bold: true }, - ); - continue; - } - cells.push({ ch: ' ' }); - } - return ( - - {cells.map((c, i) => ( - - {c.color ? ( - - {c.ch} - - ) : ( - c.ch - )} - - ))} - - ); -}; diff --git a/src/ui/tui/screens/audit-3000/__tests__/hedgehog-runner-engine.test.ts b/src/ui/tui/screens/audit-3000/__tests__/hedgehog-runner-engine.test.ts deleted file mode 100644 index 559cb448..00000000 --- a/src/ui/tui/screens/audit-3000/__tests__/hedgehog-runner-engine.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - AIR_ROW, - GROUND_ROW, - HEDGEHOG_COL, - JUMP_DURATION_TICKS, - RING_VALUE, - initialState, - jump, - restart, - tick, - type GameState, -} from '@ui/tui/screens/audit-3000/hedgehog-runner-engine'; - -describe('hedgehog-runner-engine', () => { - describe('initialState', () => { - it('starts grounded with score zero', () => { - const s = initialState(); - expect(s.hedgehogState).toBe('grounded'); - expect(s.hedgehogRow).toBe(GROUND_ROW); - expect(s.score).toBe(0); - expect(s.isGameOver).toBe(false); - expect(s.obstacles).toEqual([]); - }); - - it('accepts an initial hi-score so it survives restarts', () => { - expect(initialState(42).hiScore).toBe(42); - }); - }); - - describe('jump', () => { - it('lifts the hedgehog from the ground row to the air row', () => { - const next = jump(initialState()); - expect(next.hedgehogState).toBe('jumping'); - expect(next.hedgehogRow).toBe(AIR_ROW); - expect(next.jumpFramesRemaining).toBe(JUMP_DURATION_TICKS); - }); - - it('is a no-op while already jumping so held space keys do not stack', () => { - const airborne = jump(initialState()); - expect(jump(airborne)).toBe(airborne); - }); - - it('is a no-op after game over', () => { - const gameOver: GameState = { ...initialState(), isGameOver: true }; - expect(jump(gameOver)).toBe(gameOver); - }); - }); - - describe('tick', () => { - it('increments score by one each tick while alive', () => { - const t1 = tick(initialState()); - expect(t1.score).toBe(1); - expect(t1.tick).toBe(1); - }); - - it('moves obstacles one column left per tick', () => { - const seeded: GameState = { - ...initialState(), - obstacles: [{ kind: 'spike', x: 20, row: GROUND_ROW }], - }; - const next = tick(seeded); - const spike = next.obstacles.find((o) => o.kind === 'spike'); - expect(spike?.x).toBe(19); - }); - - it('drops obstacles once they leave the playfield on the left', () => { - const seeded: GameState = { - ...initialState(), - obstacles: [{ kind: 'spike', x: 0, row: GROUND_ROW }], - }; - const next = tick(seeded); - expect(next.obstacles.find((o) => o.kind === 'spike')).toBeUndefined(); - }); - - it('returns the hedgehog to the ground after the jump duration elapses', () => { - let state = jump(initialState()); - for (let i = 0; i < JUMP_DURATION_TICKS; i++) { - state = tick(state); - } - expect(state.hedgehogState).toBe('grounded'); - expect(state.hedgehogRow).toBe(GROUND_ROW); - }); - - it('ends the game and updates hi-score on spike collision', () => { - const seeded: GameState = { - ...initialState(7), - obstacles: [{ kind: 'spike', x: HEDGEHOG_COL + 1, row: GROUND_ROW }], - score: 12, - }; - const next = tick(seeded); - expect(next.isGameOver).toBe(true); - expect(next.hiScore).toBe(13); // 12 + 1 survival tick - }); - - it('does not regress an existing higher hi-score on death', () => { - const seeded: GameState = { - ...initialState(100), - obstacles: [{ kind: 'spike', x: HEDGEHOG_COL + 1, row: GROUND_ROW }], - score: 5, - }; - expect(tick(seeded).hiScore).toBe(100); - }); - - it('collects rings by adding their value and removing them', () => { - // Hedgehog mid-jump on AIR_ROW; ring sits one column to the right. - const seeded: GameState = { - ...jump(initialState()), - obstacles: [{ kind: 'ring', x: HEDGEHOG_COL + 1, row: AIR_ROW }], - }; - const next = tick(seeded); - expect(next.isGameOver).toBe(false); - expect(next.score).toBe(1 + RING_VALUE); - expect(next.obstacles.find((o) => o.kind === 'ring')).toBeUndefined(); - }); - - it('ignores ticks while the game is over so the loop can idle safely', () => { - const gameOver: GameState = { ...initialState(), isGameOver: true }; - expect(tick(gameOver)).toBe(gameOver); - }); - }); - - describe('restart', () => { - it('resets the run but preserves the hi-score', () => { - const ended: GameState = { - ...initialState(), - score: 99, - hiScore: 250, - isGameOver: true, - obstacles: [{ kind: 'spike', x: 3, row: GROUND_ROW }], - }; - const fresh = restart(ended); - expect(fresh.score).toBe(0); - expect(fresh.isGameOver).toBe(false); - expect(fresh.obstacles).toEqual([]); - expect(fresh.hiScore).toBe(250); - }); - }); -}); diff --git a/src/ui/tui/screens/audit-3000/arcade-colors.ts b/src/ui/tui/screens/audit-3000/arcade-colors.ts deleted file mode 100644 index f95aff3e..00000000 --- a/src/ui/tui/screens/audit-3000/arcade-colors.ts +++ /dev/null @@ -1,5 +0,0 @@ -// PostHog brand palette, tuned for the arcade theme. Shared by the -// audit-3000 intro panel and the hedgehog runner mini-game. -export const NEON_PINK = '#F54E00'; -export const NEON_BLUE = '#1D4AFF'; -export const NEON_GOLD = '#F9BD2B'; diff --git a/src/ui/tui/screens/audit-3000/hedgehog-runner-engine.ts b/src/ui/tui/screens/audit-3000/hedgehog-runner-engine.ts deleted file mode 100644 index 9b030c71..00000000 --- a/src/ui/tui/screens/audit-3000/hedgehog-runner-engine.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Hedgehog Runner — pure game engine. - * - * No Ink, React, or stdout imports. All state transitions are pure functions - * so the game is deterministic given an RNG seed and unit-testable in - * isolation from the TUI. - * - * The playfield is a fixed grid: - * row 0: sky (rings can spawn here when hedgehog is mid-jump) - * row 1: air (hedgehog occupies this row mid-jump; rings spawn here) - * row 2: ground (hedgehog default position; spikes spawn here) - * - * Obstacles enter at the right edge (PLAYFIELD_WIDTH - 1) and move left one - * column per tick. The hedgehog sits at HEDGEHOG_COL. Collision triggers when - * an obstacle reaches HEDGEHOG_COL while occupying the same row as the - * hedgehog. - */ - -export const PLAYFIELD_WIDTH = 40; -export const HEDGEHOG_COL = 4; -export const GROUND_ROW = 2; -export const AIR_ROW = 1; -export const JUMP_DURATION_TICKS = 8; -export const SPAWN_COOLDOWN_MIN = 6; -export const SPAWN_COOLDOWN_MAX = 14; -export const RING_VALUE = 5; - -export type HedgehogState = 'grounded' | 'jumping'; - -export interface Obstacle { - kind: 'spike' | 'ring'; - x: number; - row: number; -} - -export interface GameState { - hedgehogState: HedgehogState; - hedgehogRow: number; - jumpFramesRemaining: number; - obstacles: Obstacle[]; - score: number; - hiScore: number; - isGameOver: boolean; - tick: number; - ticksUntilNextSpawn: number; - rngSeed: number; -} - -// Mulberry32 — deterministic PRNG, used so tests can assert exact sequences. -function nextRandom(seed: number): { value: number; nextSeed: number } { - let t = (seed + 0x6d2b79f5) >>> 0; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - const value = ((t ^ (t >>> 14)) >>> 0) / 4294967296; - return { value, nextSeed: t >>> 0 }; -} - -function randomInt(seed: number, min: number, max: number) { - const { value, nextSeed } = nextRandom(seed); - return { - value: min + Math.floor(value * (max - min + 1)), - nextSeed, - }; -} - -export function initialState(hiScore = 0, rngSeed = 1): GameState { - return { - hedgehogState: 'grounded', - hedgehogRow: GROUND_ROW, - jumpFramesRemaining: 0, - obstacles: [], - score: 0, - hiScore, - isGameOver: false, - tick: 0, - ticksUntilNextSpawn: SPAWN_COOLDOWN_MIN, - rngSeed, - }; -} - -export function jump(state: GameState): GameState { - if (state.isGameOver) return state; - if (state.hedgehogState !== 'grounded') return state; - return { - ...state, - hedgehogState: 'jumping', - hedgehogRow: AIR_ROW, - jumpFramesRemaining: JUMP_DURATION_TICKS, - }; -} - -export function restart(state: GameState): GameState { - return initialState(state.hiScore, state.rngSeed); -} - -export function tick(state: GameState): GameState { - if (state.isGameOver) return state; - - let { hedgehogState, hedgehogRow, jumpFramesRemaining } = state; - if (hedgehogState === 'jumping') { - jumpFramesRemaining -= 1; - if (jumpFramesRemaining <= 0) { - hedgehogState = 'grounded'; - hedgehogRow = GROUND_ROW; - jumpFramesRemaining = 0; - } - } - - const movedObstacles: Obstacle[] = []; - let scoreDelta = 1; - let hit = false; - for (const obs of state.obstacles) { - const next = { ...obs, x: obs.x - 1 }; - if (next.x < 0) continue; - if (next.x === HEDGEHOG_COL && next.row === hedgehogRow) { - if (next.kind === 'spike') { - hit = true; - movedObstacles.push(next); - continue; - } - // Ring collected — score it and drop from the field. - scoreDelta += RING_VALUE; - continue; - } - movedObstacles.push(next); - } - - let rngSeed = state.rngSeed; - let ticksUntilNextSpawn = state.ticksUntilNextSpawn - 1; - if (ticksUntilNextSpawn <= 0) { - const kindRoll = nextRandom(rngSeed); - rngSeed = kindRoll.nextSeed; - const kind: Obstacle['kind'] = kindRoll.value < 0.65 ? 'spike' : 'ring'; - const row = kind === 'spike' ? GROUND_ROW : AIR_ROW; - movedObstacles.push({ kind, x: PLAYFIELD_WIDTH - 1, row }); - - const cooldown = randomInt(rngSeed, SPAWN_COOLDOWN_MIN, SPAWN_COOLDOWN_MAX); - rngSeed = cooldown.nextSeed; - ticksUntilNextSpawn = cooldown.value; - } - - const score = state.score + scoreDelta; - const isGameOver = hit; - const hiScore = isGameOver ? Math.max(state.hiScore, score) : state.hiScore; - - return { - hedgehogState, - hedgehogRow, - jumpFramesRemaining, - obstacles: movedObstacles, - score, - hiScore, - isGameOver, - tick: state.tick + 1, - ticksUntilNextSpawn, - rngSeed, - }; -} diff --git a/src/ui/tui/screens/audit-3000/slides/eventQuality.tsx b/src/ui/tui/screens/audit-3000/slides/eventQuality.tsx deleted file mode 100644 index ae3c748a..00000000 --- a/src/ui/tui/screens/audit-3000/slides/eventQuality.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Text } from 'ink'; -import { VisualBox, type AreaSlide } from '@ui/tui/screens/audit/slides/shared'; - -const EventQualityVisual = () => ( - - - {'event_clicked '} - {'\u2713'} - - - {'eventClicked '} - {'~ duplicate?'} - - - {'click_event '} - {'~ duplicate?'} - - - {'big_kitchen_sink '} - {'\u2717 22 props'} - - -); - -export const EventQualitySlide: AreaSlide = { - area: 'Event Quality', - intro: [ - 'LEVEL 5: EVENT QUALITY. The capture call-sites are clean. The events themselves are the real boss fight.', - 'Scanning for: naming inconsistencies, semantic duplicates, kitchen-sink event payloads, and (if your PostHog project is linked) which captured events actually drive insights and dashboards.', - '4 subagents fan out in parallel. The ticker shows them clearing checks live.', - ], - visual: , - docsUrl: 'https://posthog.com/docs/product-analytics/best-practices', -}; diff --git a/src/ui/tui/screens/audit-3000/slides/expansion.tsx b/src/ui/tui/screens/audit-3000/slides/expansion.tsx deleted file mode 100644 index ccbda651..00000000 --- a/src/ui/tui/screens/audit-3000/slides/expansion.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Text } from 'ink'; -import { VisualBox, type AreaSlide } from '@ui/tui/screens/audit/slides/shared'; - -const ExpansionVisual = () => ( - - - {'product analytics '} - {'\u25A0\u25A0\u25A0\u25A0\u25A0'} - - - {'error tracking '} - {'\u25A1\u25A1\u25A1\u25A1\u25A1'} - {' sentry detected'} - - - {'session replay '} - {'\u25A0\u25A0\u25A1\u25A1\u25A1'} - {' partial'} - - - {'llm observability '} - {'\u25A1\u25A1\u25A1\u25A1\u25A1'} - {' greenfield'} - - -); - -export const ExpansionSlide: AreaSlide = { - area: 'Use Case: Expansion', - intro: [ - 'BONUS ROUND: EXPANSION. You might be paying for tools PostHog covers natively.', - 'Scanning for competitive SDKs (Sentry, LaunchDarkly, Mixpanel, Datadog, OpenTelemetry, GA4) and PostHog coverage gaps across 8 product surfaces.', - '8 subagents in two waves of 4. Each one returns one of: cross-sell, greenfield, gap, or pass.', - ], - visual: , - docsUrl: 'https://posthog.com/docs', -}; diff --git a/src/ui/tui/screens/audit-3000/slides/featureFlags.tsx b/src/ui/tui/screens/audit-3000/slides/featureFlags.tsx deleted file mode 100644 index decadc16..00000000 --- a/src/ui/tui/screens/audit-3000/slides/featureFlags.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Text } from 'ink'; -import { VisualBox, type AreaSlide } from '@ui/tui/screens/audit/slides/shared'; - -const FeatureFlagsVisual = () => ( - - - {'new-checkout-v2 '} - {'no code refs '} - {'DROP'} - - - {'beta-dashboard '} - {'1 ref, 100% on '} - {'REVIEW'} - - - {'killswitch-payments'} - {'live experiment'} - {'KEEP'} - - -); - -export const FeatureFlagsSlide: AreaSlide = { - area: 'Feature Flags', - intro: [ - 'LEVEL 6: STALE FLAGS. Old flags add evaluation overhead and confuse the next engineer who wonders if a flag is still live.', - "Cross-referencing PostHog's stale-flag classification against your source tree. Each flag scored: safe-to-disable, needs-review, or unknown.", - 'The final report ships with a copy-paste cleanup prompt. We never touch a flag.', - ], - visual: , - docsUrl: 'https://posthog.com/docs/feature-flags', -}; diff --git a/src/ui/tui/screens/audit-3000/slides/index.ts b/src/ui/tui/screens/audit-3000/slides/index.ts deleted file mode 100644 index b589b7ba..00000000 --- a/src/ui/tui/screens/audit-3000/slides/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Audit-3000 slide registry. Re-uses the original audit slides for the - * shared areas (Installation, Identification, Event Capture) and adds - * arcade-flavoured slides for the three new areas the v3000 audit covers. - */ - -import type { AreaSlide } from '@ui/tui/screens/audit/slides/shared'; -import { InstallationSlide } from '@ui/tui/screens/audit/slides/installation'; -import { IdentificationSlide } from '@ui/tui/screens/audit/slides/identification'; -import { EventCaptureSlide } from '@ui/tui/screens/audit/slides/eventCapture'; -import { EventQualitySlide } from './eventQuality.js'; -import { FeatureFlagsSlide } from './featureFlags.js'; -import { ExpansionSlide } from './expansion.js'; - -export type { AreaSlide }; - -export const AUDIT_3000_AREA_SLIDES: AreaSlide[] = [ - InstallationSlide, - IdentificationSlide, - EventCaptureSlide, - EventQualitySlide, - FeatureFlagsSlide, - ExpansionSlide, -]; diff --git a/src/ui/tui/screens/audit/AuditRunScreen.tsx b/src/ui/tui/screens/audit/AuditRunScreen.tsx index e1a665f0..1f5e22b7 100644 --- a/src/ui/tui/screens/audit/AuditRunScreen.tsx +++ b/src/ui/tui/screens/audit/AuditRunScreen.tsx @@ -51,7 +51,7 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { const reportPath = `./${reportFile}`; const pendingChecksList = ; const slides = - store.session.skillId === 'events-audit' + store.session.skillId === 'audit-events' ? EVENTS_AUDIT_AREA_SLIDES : AUDIT_AREA_SLIDES; const areaPane = ( diff --git a/src/wizard.ts b/src/wizard.ts index f60816aa..0d575e23 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -7,6 +7,10 @@ import { toCommandModule, type Command } from './commands/command'; /** * Global yargs options applied to every command. These are read from the * `POSTHOG_WIZARD` env prefix as well as flags. + * + * Options with `hidden: true` are "internal modes" — they don't show up in + * `--help` but are still accepted on every command. The catalog of internal + * flags and what each one does lives in CONTRIBUTING.md. */ export const GLOBAL_OPTIONS = { debug: { @@ -25,12 +29,6 @@ export const GLOBAL_OPTIONS = { 'Create a new PostHog account during setup\nenv: POSTHOG_WIZARD_SIGNUP', type: 'boolean' as const, }, - 'local-mcp': { - default: false, - describe: - 'Use local MCP server at http://localhost:8787/mcp\nenv: POSTHOG_WIZARD_LOCAL_MCP', - type: 'boolean' as const, - }, telemetry: { default: true, describe: @@ -52,6 +50,29 @@ export const GLOBAL_OPTIONS = { 'Email address for signup (used with --signup)\nenv: POSTHOG_WIZARD_EMAIL', type: 'string' as const, }, + // ── Internal modes ───────────────────────────────────────────────── + // Hidden from `--help`. See CONTRIBUTING.md for what each one does. + 'local-mcp': { + default: false, + describe: + 'Use local MCP server at http://localhost:8787/mcp\nenv: POSTHOG_WIZARD_LOCAL_MCP', + type: 'boolean' as const, + hidden: true, + }, + benchmark: { + default: false, + describe: + 'Run in benchmark mode with per-phase token tracking\nenv: POSTHOG_WIZARD_BENCHMARK', + type: 'boolean' as const, + hidden: true, + }, + 'yara-report': { + default: false, + describe: + 'Print YARA scanner summary after the agent run\nenv: POSTHOG_WIZARD_YARA_REPORT', + type: 'boolean' as const, + hidden: true, + }, }; export class Wizard { @@ -72,6 +93,7 @@ export class Wizard { describe: 'Enable CI mode for non-interactive execution\nenv: POSTHOG_WIZARD_CI', type: 'boolean', + hidden: true, }); }