From 7c174b479fa3044d1544acdc85b34ae466a39d1b Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Tue, 9 Jun 2026 17:00:11 -0400 Subject: [PATCH 01/11] feat(cli): typed surface + manifest snapshot + command migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core of the CLI overhaul: adds the ProgramCliSurface type, snapshots context-mill's cli-manifest.json into a build-time TS module (schema-validated via ajv in prebuild — a fetched-but-invalid manifest fails the build), adds the native + skill command factories, the `wizard audit` family with an interactive picker (recommended leaf pre-highlighted), and the `wizard skill` catalog (internal role hidden by default). Old single-purpose command entry files are replaced by manifest-derived commands. Manifest contract uses context-mill's role/command/skill/internal + recommended vocabulary (renamed from the earlier surface/public/catalog + default). Generated-By: PostHog Code Task-Id: 4debb1ba-a98a-494f-ba44-29df9fd1d87b --- .gitignore | 7 +- .prettierignore | 2 + bin.ts | 12 +- cli-manifest.bootstrap.json | 70 +++++ cli-manifest.schema.bootstrap.json | 73 +++++ package.json | 3 +- pnpm-lock.yaml | 3 + scripts/generate-cli-manifest.cjs | 277 ++++++++++++++++++ src/__tests__/programs-cli.test.ts | 102 +++++-- src/__tests__/wizard.test.ts | 10 +- src/commands/audit-3000.ts | 23 -- src/commands/audit.ts | 77 ++--- src/commands/basic-integration/index.ts | 25 +- src/commands/command.ts | 23 +- src/commands/events-audit.ts | 23 -- .../factories/__tests__/family-picker.test.ts | 178 +++++++++++ .../__tests__/native-command-factory.test.ts | 105 +++++++ .../__tests__/skill-command-factory.test.ts | 136 +++++++++ src/commands/factories/family-picker.tsx | 141 +++++++++ .../factories/native-command-factory.ts | 35 +++ src/commands/factories/shared.ts | 40 +++ .../factories/skill-command-factory.ts | 60 ++++ src/commands/integrate.ts | 25 -- src/commands/migrate.ts | 48 +-- src/commands/revenue.ts | 50 ++-- src/commands/skill-program-options.ts | 31 +- src/commands/skill.ts | 122 ++++++++ src/commands/source-maps.ts | 7 + src/commands/upload-sourcemaps.ts | 25 -- .../index.ts | 2 +- src/lib/programs/migration/index.ts | 31 +- src/lib/programs/program-registry.ts | 2 +- src/lib/programs/program-step.ts | 55 +++- src/wizard.ts | 34 ++- 34 files changed, 1567 insertions(+), 290 deletions(-) create mode 100644 .prettierignore create mode 100644 cli-manifest.bootstrap.json create mode 100644 cli-manifest.schema.bootstrap.json create mode 100644 scripts/generate-cli-manifest.cjs delete mode 100644 src/commands/audit-3000.ts delete mode 100644 src/commands/events-audit.ts create mode 100644 src/commands/factories/__tests__/family-picker.test.ts create mode 100644 src/commands/factories/__tests__/native-command-factory.test.ts create mode 100644 src/commands/factories/__tests__/skill-command-factory.test.ts create mode 100644 src/commands/factories/family-picker.tsx create mode 100644 src/commands/factories/native-command-factory.ts create mode 100644 src/commands/factories/shared.ts create mode 100644 src/commands/factories/skill-command-factory.ts delete mode 100644 src/commands/integrate.ts create mode 100644 src/commands/skill.ts create mode 100644 src/commands/source-maps.ts delete mode 100644 src/commands/upload-sourcemaps.ts diff --git a/.gitignore b/.gitignore index 2f606990..84ca9782 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,9 @@ plugins e2e-tests/fixtures/.tracking/* # Generated at build time by scripts/generate-version.js -src/lib/version.ts \ No newline at end of file +src/lib/version.ts + +# Generated at build time by scripts/generate-cli-manifest.cjs +src/lib/programs/cli-manifest.generated.ts +.cache/cli-manifest.json +.cache/cli-manifest.schema.json \ No newline at end of file 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/bin.ts b/bin.ts index 2d204511..6b28ece8 100644 --- a/bin.ts +++ b/bin.ts @@ -32,23 +32,19 @@ 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 { uploadSourcemapsCommand } from './src/commands/upload-sourcemaps'; +import { sourceMapsCommand } from './src/commands/source-maps'; +import { skillCommand } from './src/commands/skill'; Wizard.use(basicIntegrationCommand) .use(mcpCommand) - .use(integrateCommand) .use(auditCommand) - .use(audit3000Command) .use(doctorCommand) .use(migrateCommand) - .use(eventsAuditCommand) .use(revenueCommand) - .use(uploadSourcemapsCommand) + .use(sourceMapsCommand) + .use(skillCommand) .init(); diff --git a/cli-manifest.bootstrap.json b/cli-manifest.bootstrap.json new file mode 100644 index 00000000..d812bffc --- /dev/null +++ b/cli-manifest.bootstrap.json @@ -0,0 +1,70 @@ +{ + "version": "1.0", + "buildVersion": "bootstrap", + "buildTimestamp": "2026-06-09T00:00:00.000Z", + "entries": [ + { + "skillId": "migrate-statsig", + "role": "command", + "command": "migrate", + "displayName": "Statsig → PostHog", + "description": "Migrate an existing analytics or feature-flag vendor to PostHog. Replaces SDK call sites in-place, removes the source package, and writes a migration report. Replacement-only, doesn't adds new instrumentation." + }, + { + "skillId": "revenue-analytics-setup", + "role": "command", + "command": "revenue-analytics", + "displayName": "Stripe Revenue Analytics", + "description": "Set up Stripe revenue analytics with PostHog" + }, + { + "skillId": "audit", + "role": "command", + "parentCommand": "audit", + "command": "all", + "recommended": true, + "displayName": "PostHog audit", + "description": "Audit an existing PostHog integration for correctness and best practices" + }, + { + "skillId": "audit-autocapture", + "role": "command", + "parentCommand": "audit", + "command": "autocapture", + "displayName": "PostHog audit — autocapture", + "description": "Audit a PostHog autocapture setup for correctness and cost-optimization opportunities" + }, + { + "skillId": "audit-events", + "role": "command", + "parentCommand": "audit", + "command": "events", + "displayName": "PostHog audit — events", + "description": "Audit a PostHog integration's event capture quality and cost-optimization opportunities" + }, + { + "skillId": "audit-feature-flags", + "role": "command", + "parentCommand": "audit", + "command": "feature-flags", + "displayName": "PostHog audit — feature flags", + "description": "Audit a PostHog integration's feature flag usage for correctness and cost-optimization opportunities" + }, + { + "skillId": "audit-identify", + "role": "command", + "parentCommand": "audit", + "command": "identify", + "displayName": "PostHog audit — identify", + "description": "Audit a PostHog integration's $identify implementation for correctness and cost-optimization opportunities" + }, + { + "skillId": "audit-session-replay", + "role": "command", + "parentCommand": "audit", + "command": "session-replay", + "displayName": "PostHog audit — session replay", + "description": "Audit a PostHog session replay setup for correctness and cost-optimization opportunities" + } + ] +} diff --git a/cli-manifest.schema.bootstrap.json b/cli-manifest.schema.bootstrap.json new file mode 100644 index 00000000..e205885d --- /dev/null +++ b/cli-manifest.schema.bootstrap.json @@ -0,0 +1,73 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/PostHog/context-mill/releases/latest/download/cli-manifest.schema.json", + "title": "Wizard CLI manifest", + "description": "Shape of dist/skills/cli-manifest.json. Published alongside the manifest itself so downstream consumers (notably the wizard's prebuild) can validate the manifest's structure before snapshotting it. The wizard's CONTRIBUTING.md and context-mill's CONTRIBUTING.md cover the field semantics.", + "type": "object", + "additionalProperties": false, + "required": ["version", "buildVersion", "buildTimestamp", "entries"], + "properties": { + "version": { + "type": "string", + "description": "Schema version. Bumped when the manifest shape changes in a breaking way." + }, + "buildVersion": { + "type": "string", + "description": "Context-mill release version that produced this manifest. May be 'dev', 'bootstrap', or 'empty-fallback' depending on the source." + }, + "buildTimestamp": { + "type": "string", + "minLength": 1, + "description": "ISO 8601 timestamp the manifest was emitted." + }, + "entries": { + "type": "array", + "items": { "$ref": "#/definitions/CliManifestEntry" } + } + }, + "definitions": { + "CliManifestEntry": { + "type": "object", + "additionalProperties": false, + "required": ["skillId", "role", "displayName", "description"], + "properties": { + "skillId": { + "type": "string", + "minLength": 1, + "description": "Context-mill skill id (e.g. 'audit-events', 'migrate-statsig')." + }, + "role": { + "type": "string", + "enum": ["command", "skill", "internal"], + "description": "Where this skill appears in the wizard CLI surface." + }, + "command": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "minLength": 2, + "maxLength": 20, + "description": "User-typed word that registers this skill as a command. Required when role is 'command'." + }, + "parentCommand": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "minLength": 2, + "maxLength": 20, + "description": "Optional parent command this skill nests under." + }, + "recommended": { + "type": "boolean", + "description": "When true, this leaf is pre-highlighted when the user invokes the family parent with no subcommand (e.g. `wizard audit` pre-highlights the leaf marked recommended, so a single Enter runs it). The picker always opens; at most one leaf per family may be marked recommended." + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/package.json b/package.json index 3e6150ba..1e2cdab8 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@types/yargs": "^16.0.9", "@typescript-eslint/eslint-plugin": "^5.13.0", "@typescript-eslint/parser": "^5.13.0", + "ajv": "^8.20.0", "babel-jest": "^29.7.0", "dotenv": "^16.4.7", "eslint": "^8.18.0", @@ -99,7 +100,7 @@ "packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b", "scripts": { "clean": "rm -rf ./dist", - "prebuild": "pnpm clean && node scripts/generate-version.cjs", + "prebuild": "pnpm clean && node scripts/generate-version.cjs && node scripts/generate-cli-manifest.cjs", "build:watch": "tsdown --watch", "build": "tsdown", "build:ci": "WIZARD_BUILD_NODE_ENV=ci pnpm build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c50822e..195bab5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: '@typescript-eslint/parser': specifier: ^5.13.0 version: 5.62.0(eslint@8.57.1)(typescript@5.7.3) + ajv: + specifier: ^8.20.0 + version: 8.20.0 babel-jest: specifier: ^29.7.0 version: 29.7.0(@babel/core@7.29.0) diff --git a/scripts/generate-cli-manifest.cjs b/scripts/generate-cli-manifest.cjs new file mode 100644 index 00000000..9859689f --- /dev/null +++ b/scripts/generate-cli-manifest.cjs @@ -0,0 +1,277 @@ +/** + * Snapshot context-mill's cli-manifest.json into a typed TS module. + * + * Fetches from REMOTE_SKILLS_BASE_URL at build time. Falls back to a local + * cache when the network or release is unavailable, and finally to an empty + * manifest so the build never breaks. The generated file is gitignored — + * regenerated on every prebuild. + * + * Each load is validated against a JSON Schema before being accepted. + * Schema drift between context-mill and wizard (extra fields, wrong types, + * naming-convention violations) gets caught here at build time instead of + * surfacing at runtime. A manifest we successfully fetched but that fails + * validation is treated as real drift and FAILS the build (exit 1) — it is + * not swallowed by the offline fallback chain. + * + * Fallback chain: + * 1. Remote (GitHub release URL) + * 2. Local cache at .cache/cli-manifest.json + * 3. Bootstrap snapshot at cli-manifest.bootstrap.json (committed to the + * repo — keeps the wizard buildable before context-mill cuts a release + * with the new file) + * 4. Empty manifest (no entries) + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const Ajv = require('ajv'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const CACHE_DIR = path.join(REPO_ROOT, '.cache'); +const CACHE_PATH = path.join(CACHE_DIR, 'cli-manifest.json'); +const BOOTSTRAP_PATH = path.join(REPO_ROOT, 'cli-manifest.bootstrap.json'); +const SCHEMA_BOOTSTRAP_PATH = path.join( + REPO_ROOT, + 'cli-manifest.schema.bootstrap.json', +); +const SCHEMA_CACHE_PATH = path.join(CACHE_DIR, 'cli-manifest.schema.json'); +const OUT_PATH = path.join( + REPO_ROOT, + 'src', + 'lib', + 'programs', + 'cli-manifest.generated.ts', +); + +// Mirrors REMOTE_SKILLS_BASE_URL in src/lib/constants.ts. Kept as a literal +// here so the prebuild doesn't need to import the TS source. +const REMOTE_BASE_URL = + 'https://github.com/PostHog/context-mill/releases/latest/download'; +const REMOTE_MANIFEST_URL = `${REMOTE_BASE_URL}/cli-manifest.json`; +const REMOTE_SCHEMA_URL = `${REMOTE_BASE_URL}/cli-manifest.schema.json`; + +function logWarning(message) { + process.stderr.write(`[generate-cli-manifest] ${message}\n`); +} + +function fetchJson(url, redirectsRemaining = 5) { + return new Promise((resolve, reject) => { + const request = https.get(url, (resp) => { + const status = resp.statusCode || 0; + if (status >= 300 && status < 400 && resp.headers.location) { + if (redirectsRemaining <= 0) { + reject(new Error('too many redirects')); + return; + } + resp.resume(); + fetchJson(resp.headers.location, redirectsRemaining - 1) + .then(resolve, reject); + return; + } + if (status !== 200) { + resp.resume(); + reject(new Error(`HTTP ${status}`)); + return; + } + const chunks = []; + resp.on('data', (c) => chunks.push(c)); + resp.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch (err) { + reject(new Error(`JSON parse failed: ${err.message}`)); + } + }); + resp.on('error', reject); + }); + request.on('error', reject); + request.setTimeout(10_000, () => { + request.destroy(new Error('fetch timed out after 10s')); + }); + }); +} + +function readCache() { + if (!fs.existsSync(CACHE_PATH)) return null; + try { + return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); + } catch (err) { + logWarning(`cache at ${CACHE_PATH} is unreadable: ${err.message}`); + return null; + } +} + +function writeCache(manifest) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + fs.writeFileSync(CACHE_PATH, JSON.stringify(manifest, null, 2)); +} + +/** + * Load the JSON Schema with the same fallback chain as the manifest itself. + * Remote → cache → bootstrap. Empty fallback is never used here — without a + * schema we have no contract, so we'd rather fail loudly than validate + * nothing. + */ +async function loadSchema() { + try { + const remote = await fetchJson(REMOTE_SCHEMA_URL); + fs.mkdirSync(CACHE_DIR, { recursive: true }); + fs.writeFileSync(SCHEMA_CACHE_PATH, JSON.stringify(remote, null, 2)); + return { schema: remote, source: `remote (${REMOTE_SCHEMA_URL})` }; + } catch (err) { + logWarning(`remote schema fetch failed: ${err.message}`); + } + if (fs.existsSync(SCHEMA_CACHE_PATH)) { + try { + return { + schema: JSON.parse(fs.readFileSync(SCHEMA_CACHE_PATH, 'utf8')), + source: `cache (${SCHEMA_CACHE_PATH})`, + }; + } catch (err) { + logWarning(`cached schema is unreadable: ${err.message}`); + } + } + if (fs.existsSync(SCHEMA_BOOTSTRAP_PATH)) { + return { + schema: JSON.parse(fs.readFileSync(SCHEMA_BOOTSTRAP_PATH, 'utf8')), + source: `bootstrap (${SCHEMA_BOOTSTRAP_PATH})`, + }; + } + throw new Error( + 'no JSON Schema available — refusing to write the generated TS without a contract to validate against.', + ); +} + +function buildValidator(schema) { + const ajv = new Ajv({ allErrors: true, strict: false }); + return ajv.compile(schema); +} + +function validateManifest(raw, source, validator) { + if (!validator(raw)) { + const formatted = (validator.errors ?? []) + .map((e) => `${e.instancePath || '/'} ${e.message}`) + .join('; '); + throw new Error(`${source}: schema validation failed — ${formatted}`); + } + return raw; +} + +function emptyManifest() { + return { + version: '1.0', + buildVersion: 'empty-fallback', + buildTimestamp: '1970-01-01T00:00:00.000Z', + entries: [], + }; +} + +function renderTypeScript(manifest, source) { + const json = JSON.stringify(manifest, null, 2); + return `// Auto-generated by scripts/generate-cli-manifest.cjs — do not edit. +// Source: ${source} +// +// Snapshot of context-mill's dist/skills/cli-manifest.json taken at build +// time. The wizard imports this module instead of fetching at runtime, so +// the published binary is offline-capable. + +import type { ProgramCliSurface } from '@lib/programs/program-step'; + +export interface CliManifestEntry { + skillId: string; + role: ProgramCliSurface['role']; + command?: string; + parentCommand?: string; + /** + * When true, this leaf is the recommended (pre-highlighted) option when + * the family parent is invoked with no subcommand (e.g. \`wizard audit\` + * pre-highlights the entry marked recommended). At most one entry per + * family parent should be marked. + */ + recommended?: boolean; + displayName: string; + description: string; +} + +export interface CliManifest { + version: string; + buildVersion: string; + buildTimestamp: string; + entries: CliManifestEntry[]; +} + +export const CLI_MANIFEST: CliManifest = ${json}; +`; +} + +async function loadManifest(validator) { + // Separate "couldn't reach the network" from "fetched a manifest that + // doesn't match the schema". The first is an offline build — fall back + // quietly. The second is real schema drift between context-mill and the + // wizard, so we fail loudly (validateManifest throws → exit 1) instead of + // silently shipping a stale surface from the cache or bootstrap. + let remote; + try { + remote = await fetchJson(REMOTE_MANIFEST_URL); + } catch (remoteErr) { + logWarning(`remote fetch failed: ${remoteErr.message}`); + } + if (remote !== undefined) { + const validated = validateManifest(remote, 'remote', validator); + writeCache(validated); + return { manifest: validated, source: `remote (${REMOTE_MANIFEST_URL})` }; + } + + const cached = readCache(); + if (cached) { + try { + const validated = validateManifest(cached, 'cache', validator); + return { + manifest: validated, + source: `local cache (${CACHE_PATH})`, + }; + } catch (cacheErr) { + logWarning(`cache is invalid: ${cacheErr.message}`); + } + } + + if (fs.existsSync(BOOTSTRAP_PATH)) { + try { + const bootstrap = JSON.parse(fs.readFileSync(BOOTSTRAP_PATH, 'utf8')); + const validated = validateManifest(bootstrap, 'bootstrap', validator); + return { + manifest: validated, + source: `bootstrap snapshot (${BOOTSTRAP_PATH})`, + }; + } catch (bootstrapErr) { + logWarning(`bootstrap is invalid: ${bootstrapErr.message}`); + } + } + + logWarning( + 'no manifest available — writing empty fallback. Run with network access to populate.', + ); + // The empty fallback isn't schema-validated by design — it's the + // last-resort "build never breaks" path. Real manifests must validate. + return { manifest: emptyManifest(), source: 'empty fallback' }; +} + +async function main() { + const { schema, source: schemaSource } = await loadSchema(); + process.stdout.write( + `[generate-cli-manifest] loaded schema from ${schemaSource}\n`, + ); + const validator = buildValidator(schema); + const { manifest, source } = await loadManifest(validator); + fs.mkdirSync(path.dirname(OUT_PATH), { recursive: true }); + fs.writeFileSync(OUT_PATH, renderTypeScript(manifest, source)); + process.stdout.write( + `[generate-cli-manifest] wrote ${OUT_PATH} (${manifest.entries.length} entries, source: ${source})\n`, + ); +} + +main().catch((err) => { + logWarning(`fatal: ${err.message}`); + process.exit(1); +}); diff --git a/src/__tests__/programs-cli.test.ts b/src/__tests__/programs-cli.test.ts index 1d440ae3..462abdbd 100644 --- a/src/__tests__/programs-cli.test.ts +++ b/src/__tests__/programs-cli.test.ts @@ -7,27 +7,35 @@ jest.mock('@lib/runners', () => ({ })); import type { Arguments } from 'yargs'; -import { integrateCommand } from '../commands/integrate'; +import type { Command } from '../commands/command'; import { auditCommand } from '../commands/audit'; import { migrateCommand } from '../commands/migrate'; +import { revenueCommand } from '../commands/revenue'; import { parseCommand } from './helpers/parse-command.no-jest'; function makeArgv(extra: Record = {}): Arguments { return { _: [], $0: 'wizard', ...extra } as Arguments; } +function findChild(parent: Command, name: string): Command | undefined { + return parent.children?.find((c) => { + const first = Array.isArray(c.name) ? c.name[0] : c.name; + return first.split(/\s+/)[0] === name; + }); +} + describe('program commands', () => { beforeEach(() => { jest.clearAllMocks(); }); - test('each command exposes its CLI name', () => { - expect(integrateCommand.name).toBe('integrate'); + test('each top-level command exposes its CLI name', () => { expect(auditCommand.name).toBe('audit'); expect(migrateCommand.name).toBe('migrate'); + expect(revenueCommand.name).toBe('revenue-analytics'); }); - test('nests web analytics doctor under audit', () => { + test('audit nests web-analytics-doctor as a wizard-native child', () => { expect(auditCommand.children).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'web-analytics' }), @@ -35,53 +43,89 @@ describe('program commands', () => { ); }); - test('dispatches to runWizard by default', () => { - auditCommand.handler!(makeArgv({ debug: true })); + test('audit exposes a subcommand for each public manifest entry', () => { + const names = (auditCommand.children ?? []).map((c) => + Array.isArray(c.name) ? c.name[0] : c.name, + ); + expect(names).toEqual( + expect.arrayContaining([ + 'all', + 'autocapture', + 'events', + 'feature-flags', + 'identify', + 'session-replay', + ]), + ); + }); + + test('migrate is a flat command while only one vendor exists', () => { + expect(migrateCommand.name).toBe('migrate'); + expect(migrateCommand.children).toBeUndefined(); + }); + + test('audit family has no top-level handler (subcommand required)', () => { + expect(auditCommand.handler).toBeUndefined(); + }); + + test('audit events dispatches to runWizard by default', () => { + const child = findChild(auditCommand, 'events'); + expect(child).toBeDefined(); + child!.handler!(makeArgv({ debug: true })); expect(mockRunWizard).toHaveBeenCalledTimes(1); expect(mockRunWizardCI).not.toHaveBeenCalled(); expect(mockRunWizard.mock.calls[0][1]).toMatchObject({ debug: true }); }); - test('dispatches to runWizardCI when --ci is set', () => { - auditCommand.handler!(makeArgv({ ci: true })); + test('audit events dispatches to runWizardCI when --ci is set', () => { + const child = findChild(auditCommand, 'events'); + child!.handler!(makeArgv({ 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; + test('skillCommandFactory injects the manifest entry skillId into the dispatched config', () => { + const events = findChild(auditCommand, 'events'); + events!.handler!(makeArgv()); + const dispatchedConfig = mockRunWizard.mock.calls[0][0] as { + skillId?: string; + }; + expect(dispatchedConfig.skillId).toBe('audit-events'); + }); + + 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('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('revenue-analytics is a flat skill command', () => { + expect(revenueCommand.name).toBe('revenue-analytics'); + expect(revenueCommand.children).toBeUndefined(); + revenueCommand.handler!(makeArgv({ debug: true })); + const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }]; + expect(config.skillId).toBe('revenue-analytics-setup'); }); test('exposes the shared skill options on each command', () => { - expect(auditCommand.options).toMatchObject({ - debug: expect.any(Object), + const child = findChild(auditCommand, 'events'); + // Global flags (--debug, --local-mcp, --benchmark, --yara-report, --ci) + // live in wizard.ts GLOBAL_OPTIONS now, so they're applied at the + // parser level rather than mirrored onto every command's options. + // Only per-command flags are asserted here. + expect(child!.options).toMatchObject({ 'install-dir': expect.any(Object), - 'local-mcp': expect.any(Object), - benchmark: expect.any(Object), - }); - }); - - 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('camelCases --install-dir end-to-end through yargs', async () => { const argv = await parseCommand( auditCommand, - 'audit --install-dir /tmp/app', + 'audit events --install-dir /tmp/app', ); expect(argv.installDir).toBe('/tmp/app'); }); diff --git a/src/__tests__/wizard.test.ts b/src/__tests__/wizard.test.ts index fd533caa..9cd97a6a 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 { sourceMapsCommand } from '../commands/source-maps'; +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, + sourceMapsCommand, + 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..ad8c075d 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -1,43 +1,54 @@ -import { runWizard, runWizardCI } from '@lib/runners'; +import { CLI_MANIFEST } from '@lib/programs/cli-manifest.generated'; import { auditConfig } from '@lib/programs/audit/index'; +import { agentSkillConfig } from '@lib/programs/program-registry'; import { webAnalyticsDoctorConfig } from '@lib/programs/web-analytics-doctor/index'; -import { skillProgramOptions } from './skill-program-options'; + import type { Command } from './command'; +import { createFamilyPickerDefault } from './factories/family-picker'; +import { nativeCommandFactory } from './factories/native-command-factory'; +import { skillCommandFactory } from './factories/skill-command-factory'; -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); - } -}; +/** + * The `wizard audit` family. Children come from two places: + * + * 1. Manifest entries with `parentCommand: 'audit'` — six skill-backed + * leaves: all, events, flags, identify, session-replay, autocapture. + * Each dispatches through `skillCommandFactory` so the entry's + * `skillId` is what actually runs. + * + * 2. Wizard-native children that declare `parentCommand: 'audit'` on + * their `ProgramConfig` — currently just `web-analytics-doctor`. These + * live in the wizard (not in context-mill) because they do more than + * run a single skill. + * + * `wizard audit` with no leaf opens an interactive TUI picker over the + * children via `interactiveDefault`. `wizard audit --help` still works. + */ -const webAnalyticsCommand: Command = { - name: webAnalyticsDoctorConfig.command!, - description: webAnalyticsDoctorConfig.description, - options: { - ...skillProgramOptions, - ...(webAnalyticsDoctorConfig.cliOptions ?? {}), - }, - handler: (argv) => { - dispatchProgram(webAnalyticsDoctorConfig, argv as Record); - }, -}; +// The comprehensive `wizard audit all` uses the specialized auditConfig +// (custom hooks, content blocks). The narrower audits (events, flags, etc.) +// use the generic agent-skill program — the manifest entry's skillId is what +// drives execution. +function resolveAuditConfig(skillId: string) { + return skillId === 'audit' ? auditConfig : agentSkillConfig; +} + +const auditSkillChildren = CLI_MANIFEST.entries + .filter( + (entry) => entry.role === 'command' && entry.parentCommand === 'audit', + ) + .map((entry) => + skillCommandFactory(entry, resolveAuditConfig(entry.skillId)), + ); + +const auditChildren: Command[] = [ + ...auditSkillChildren, + nativeCommandFactory(webAnalyticsDoctorConfig), +]; export const auditCommand: Command = { name: 'audit', description: auditConfig.description, - children: [webAnalyticsCommand], - options: { - ...skillProgramOptions, - ...(auditConfig.cliOptions ?? {}), - }, - handler: (argv) => { - dispatchProgram(auditConfig, argv as Record); - }, + children: auditChildren, + interactiveDefault: createFamilyPickerDefault('wizard audit', auditChildren), }; diff --git a/src/commands/basic-integration/index.ts b/src/commands/basic-integration/index.ts index ba52f6b7..b3681dfc 100644 --- a/src/commands/basic-integration/index.ts +++ b/src/commands/basic-integration/index.ts @@ -14,21 +14,16 @@ 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, }, @@ -36,11 +31,7 @@ export const basicIntegrationCommand: Command = { describe: 'Run a specific context-mill skill by ID\nenv: POSTHOG_WIZARD_SKILL', type: 'string', - }, - name: { - describe: - 'Name for account creation with --ci --signup\nenv: POSTHOG_WIZARD_NAME', - type: 'string', + hidden: true, }, }, check: (argv) => { diff --git a/src/commands/command.ts b/src/commands/command.ts index da7164f1..c3d0897d 100644 --- a/src/commands/command.ts +++ b/src/commands/command.ts @@ -13,7 +13,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; /** @@ -25,6 +26,22 @@ 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, the family parent runs this child by default when invoked + * with no subcommand. At most one child per parent should be marked + * default. Propagated from the context-mill manifest's `default: true` + * field through `skillCommandFactory`. + */ + default?: boolean; } /** Extract the bare command word(s) from a yargs name spec, dropping positionals and aliases' arg syntax. */ @@ -50,11 +67,11 @@ 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: cmd.handler ?? (() => undefined), + handler: cmd.handler ?? cmd.interactiveDefault ?? (() => undefined), }; } 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..28e59c62 --- /dev/null +++ b/src/commands/factories/__tests__/family-picker.test.ts @@ -0,0 +1,178 @@ +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('still exposes children so yargs can route `wizard audit `', () => { + expect(auditCommand.children?.length).toBeGreaterThan(0); + }); +}); 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/__tests__/skill-command-factory.test.ts b/src/commands/factories/__tests__/skill-command-factory.test.ts new file mode 100644 index 00000000..1d18d505 --- /dev/null +++ b/src/commands/factories/__tests__/skill-command-factory.test.ts @@ -0,0 +1,136 @@ +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 type { CliManifestEntry } from '@lib/programs/cli-manifest.generated'; + +import { skillCommandFactory } from '../skill-command-factory'; + +function makeArgv(extra: Record = {}): Arguments { + return { _: [], $0: 'wizard', ...extra } as Arguments; +} + +function buildTestEntry( + overrides: Partial = {}, +): CliManifestEntry { + return { + skillId: 'demo-skill', + role: 'command', + command: 'demo', + displayName: 'Demo Skill', + description: 'demo description from manifest', + ...overrides, + }; +} + +function buildTestConfig( + overrides: Partial = {}, +): ProgramConfig { + return { + id: 'demo', + description: 'demo description from config', + steps: [], + ...overrides, + }; +} + +describe('skillCommandFactory', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses command name from the manifest entry, not the program config', () => { + const cmd = skillCommandFactory( + buildTestEntry({ command: 'from-entry' }), + buildTestConfig({ command: 'from-config' }), + ); + expect(cmd.name).toBe('from-entry'); + }); + + it('uses description from the manifest entry, not the program config', () => { + const cmd = skillCommandFactory(buildTestEntry(), buildTestConfig()); + expect(cmd.description).toBe('demo description from manifest'); + }); + + it('throws when the manifest entry is not a command role', () => { + expect(() => + skillCommandFactory(buildTestEntry({ role: 'skill' }), buildTestConfig()), + ).toThrow(/role "skill"/); + expect(() => + skillCommandFactory( + buildTestEntry({ role: 'internal' }), + buildTestConfig(), + ), + ).toThrow(/role "internal"/); + }); + + it('throws when the manifest entry has no command name', () => { + expect(() => + skillCommandFactory( + { ...buildTestEntry(), command: undefined }, + buildTestConfig(), + ), + ).toThrow(/missing `command`/); + }); + + it('merges skill-program options with the program-specific cliOptions', () => { + const cmd = skillCommandFactory( + buildTestEntry(), + buildTestConfig({ + cliOptions: { + flavor: { type: 'string' as const }, + }, + }), + ); + // --install-dir is the only per-command shared option; global flags + // (--debug, --local-mcp, --benchmark, etc.) live in GLOBAL_OPTIONS. + expect(cmd.options).toHaveProperty('install-dir'); + expect(cmd.options).toHaveProperty('flavor'); + }); + + it('handler dispatches to runWizard with skillId overridden from the entry', () => { + const config = buildTestConfig({ + skillId: 'will-be-overridden', + mapCliOptions: (argv) => ({ derived: argv.foo }), + }); + const cmd = skillCommandFactory( + buildTestEntry({ skillId: 'from-manifest-entry' }), + config, + ); + cmd.handler!(makeArgv({ foo: 'bar' })); + + expect(mockRunWizard).toHaveBeenCalledTimes(1); + const [calledConfig, calledOptions] = mockRunWizard.mock.calls[0]; + expect(calledConfig).toMatchObject({ + id: config.id, + skillId: 'from-manifest-entry', + }); + expect(calledOptions).toMatchObject({ foo: 'bar', derived: 'bar' }); + }); + + it('handler routes to runWizardCI when --ci is set', () => { + const cmd = skillCommandFactory(buildTestEntry(), buildTestConfig()); + cmd.handler!(makeArgv({ ci: true })); + expect(mockRunWizardCI).toHaveBeenCalledTimes(1); + expect(mockRunWizard).not.toHaveBeenCalled(); + }); + + it('passes children through unchanged', () => { + const child = { + name: 'inner', + description: 'inner', + handler: () => undefined, + }; + const cmd = skillCommandFactory(buildTestEntry(), buildTestConfig(), { + children: [child], + }); + expect(cmd.children).toEqual([child]); + }); +}); diff --git a/src/commands/factories/family-picker.tsx b/src/commands/factories/family-picker.tsx new file mode 100644 index 00000000..0d2950e7 --- /dev/null +++ b/src/commands/factories/family-picker.tsx @@ -0,0 +1,141 @@ +/** + * 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 always opens for families; the `default` flag on a child + * just controls which option is pre-highlighted (so `wizard audit` → + * Enter still runs `audit all`, but the user sees every other audit + * before committing). Discovery and consent in one extra keystroke. + * + * 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 comprehensive audit by default. + * + * 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; + 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/factories/skill-command-factory.ts b/src/commands/factories/skill-command-factory.ts new file mode 100644 index 00000000..71f683e9 --- /dev/null +++ b/src/commands/factories/skill-command-factory.ts @@ -0,0 +1,60 @@ +import type { ProgramConfig } from '@lib/programs/program-step'; +import type { CliManifestEntry } from '@lib/programs/cli-manifest.generated'; + +import type { Command } from '../command'; + +import { dispatchProgram, mergeCommandOptions } from './shared'; + +export interface SkillCommandFactoryOpts { + /** Subcommands nested under this command. */ + children?: readonly Command[]; +} + +/** + * Build a yargs `Command` from a context-mill manifest entry plus the + * wizard-side `ProgramConfig` that supplies the runner mechanics. + * + * The manifest entry owns the user-visible bits — command name, description, + * role, skill id — while `ProgramConfig` supplies the run mechanics + * (steps, hooks, content blocks, options). Each side stays responsible for + * what it knows best: context-mill curates the CLI surface, wizard owns + * execution. + * + * The entry's `skillId` shadows the base config's `skillId` at dispatch + * time, so one shared config (e.g. the generic `agent-skill` program) can + * back many manifest entries by skill id. + * + * Only `role: 'command'` entries become commands. `skill` and `internal` + * entries are reachable through different paths (`wizard skill `, + * `--skill=`) and throw if passed here. + */ +export function skillCommandFactory( + entry: CliManifestEntry, + config: ProgramConfig, + opts: SkillCommandFactoryOpts = {}, +): Command { + if (entry.role !== 'command') { + throw new Error( + `skillCommandFactory: entry "${entry.skillId}" has role "${entry.role}" — only "command" entries become commands`, + ); + } + if (!entry.command) { + throw new Error( + `skillCommandFactory: entry "${entry.skillId}" is missing \`command\` — context-mill must declare a name for every command entry`, + ); + } + const dispatchConfig: ProgramConfig = { + ...config, + skillId: entry.skillId, + }; + return { + name: entry.command, + description: entry.description, + options: mergeCommandOptions(dispatchConfig), + children: opts.children, + handler: (argv) => dispatchProgram(dispatchConfig, argv), + // The manifest's `recommended` flag feeds the family picker's `default` + // (pre-highlighted) slot — two different names, one bridge. + default: entry.recommended, + }; +} 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..df3d5ac9 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -1,23 +1,31 @@ -import { runWizard, runWizardCI } from '@lib/runners'; +import { CLI_MANIFEST } from '@lib/programs/cli-manifest.generated'; import { migrationConfig } from '@lib/programs/migration/index'; -import { skillProgramOptions } from './skill-program-options'; + import type { Command } from './command'; +import { skillCommandFactory } from './factories/skill-command-factory'; + +/** + * `wizard migrate` — flat skill command, Statsig today. Stays flat + * while there's only one vendor. When a second vendor lands, this file + * restructures into a family (parentCommand: migrate, command per + * vendor) and the picker opens — a deliberate breaking change at that + * point, not silent magic introduced now. + */ +const migrateEntry = CLI_MANIFEST.entries.find( + (entry) => + entry.role === 'command' && + !entry.parentCommand && + entry.command === 'migrate', +); + +if (!migrateEntry) { + throw new Error( + 'commands/migrate: no public `migrate` entry in CLI_MANIFEST. ' + + 'Check cli-manifest.bootstrap.json or the latest context-mill release.', + ); +} -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); - } - }, -}; +export const migrateCommand: Command = skillCommandFactory( + migrateEntry, + migrationConfig, +); diff --git a/src/commands/revenue.ts b/src/commands/revenue.ts index 621e4089..99f9c220 100644 --- a/src/commands/revenue.ts +++ b/src/commands/revenue.ts @@ -1,24 +1,32 @@ -import { runWizard, runWizardCI } from '@lib/runners'; +import { CLI_MANIFEST } from '@lib/programs/cli-manifest.generated'; import { revenueAnalyticsConfig } from '@lib/programs/revenue-analytics/index'; -import { skillProgramOptions } from './skill-program-options'; + import type { Command } from './command'; +import { skillCommandFactory } from './factories/skill-command-factory'; + +/** + * `wizard revenue-analytics` — flat skill command, Stripe today. Stays + * flat while there's only one provider. When a second provider lands, + * this file restructures into a family (parentCommand: + * revenue-analytics, command per vendor) and the picker opens — a + * deliberate breaking change at that point, not silent magic + * introduced now. + */ +const revenueEntry = CLI_MANIFEST.entries.find( + (entry) => + entry.role === 'command' && + !entry.parentCommand && + entry.skillId === 'revenue-analytics-setup', +); + +if (!revenueEntry) { + throw new Error( + 'commands/revenue: no public `revenue-analytics-setup` entry in CLI_MANIFEST. ' + + 'Check cli-manifest.bootstrap.json or the latest context-mill release.', + ); +} -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); - } - }, -}; +export const revenueCommand: Command = skillCommandFactory( + revenueEntry, + 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 new file mode 100644 index 00000000..5aa6bda8 --- /dev/null +++ b/src/commands/skill.ts @@ -0,0 +1,122 @@ +import { agentSkillConfig } from '@lib/programs/program-registry'; +import { + CLI_MANIFEST, + type CliManifestEntry, +} from '@lib/programs/cli-manifest.generated'; + +import { dispatchProgram, mergeCommandOptions } from './factories/shared'; +import type { Command } from './command'; + +/** + * Catalog-access subcommands: `wizard skill list`, `wizard skill search`, + * and `wizard skill `. + * + * `list` and `search` are read-only catalog inspection — they print to + * stdout and exit without spinning up the TUI. The bare `wizard skill ` + * form dispatches to the generic `agent-skill` program with the supplied + * skill id, so any skill in the catalog is runnable even when it's not + * promoted as a top-level public command. + * + * The catalog source today is `CLI_MANIFEST.entries` (the build-time + * snapshot). The browsable listings (`list`, `search`, and bare `skill`) + * show `command` + `skill` entries; `internal` skills are hidden unless + * `--role internal` is passed, but stay runnable by id. + */ +function formatEntry(entry: CliManifestEntry): string { + const path = entry.parentCommand + ? `wizard ${entry.parentCommand} ${entry.command}` + : `wizard ${entry.command}`; + return ` ${entry.skillId.padEnd(38)} ${path.padEnd(36)} ${ + entry.description + }`; +} + +/** + * Roles shown in the browsable catalog by default. `internal` skills are + * hidden unless explicitly requested via `--role internal`, mirroring how + * internal flags are kept out of `--help`. They remain runnable by id for + * anyone who knows it (`wizard skill ` / the `--skill` escape hatch). + */ +const BROWSABLE_ROLES = new Set(['command', 'skill']); + +function printEntries(entries: readonly CliManifestEntry[]): void { + if (entries.length === 0) { + process.stdout.write( + 'No skills found. The CLI manifest may not have been fetched yet — run a build with network access.\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`); + } +} + +const listCommand: Command = { + name: 'list', + description: 'List skills in the wizard catalog', + options: { + role: { + describe: 'Filter by role', + type: 'string', + choices: ['command', 'skill', 'internal'] as const, + }, + }, + handler: (argv) => { + const role = (argv.role as string | undefined) ?? undefined; + const entries = CLI_MANIFEST.entries.filter((e) => + role == null ? BROWSABLE_ROLES.has(e.role) : e.role === role, + ); + printEntries(entries); + }, +}; + +const searchCommand: Command = { + name: 'search ', + description: 'Search the wizard skill catalog by name or description', + handler: (argv) => { + const query = String(argv.query ?? '').toLowerCase(); + if (!query) { + process.stdout.write('No query provided.\n'); + return; + } + const matches = CLI_MANIFEST.entries.filter((entry) => { + if (!BROWSABLE_ROLES.has(entry.role)) return false; + return ( + entry.skillId.toLowerCase().includes(query) || + entry.displayName.toLowerCase().includes(query) || + entry.description.toLowerCase().includes(query) || + (entry.command?.toLowerCase().includes(query) ?? false) || + (entry.parentCommand?.toLowerCase().includes(query) ?? false) + ); + }); + printEntries(matches); + }, +}; + +export const skillCommand: Command = { + name: 'skill [id]', + description: 'Explore and run skills from the wizard catalog', + children: [listCommand, searchCommand], + options: mergeCommandOptions(agentSkillConfig), + handler: (argv) => { + const id = (argv.id as string | undefined)?.trim(); + if (!id) { + // Bare `wizard skill` with no positional — list the catalog so the + // user sees what's available. Cheaper than yargs's help dump and the + // ids are what they need to invoke a specific skill. Internal skills + // stay hidden here (see BROWSABLE_ROLES). + printEntries( + CLI_MANIFEST.entries.filter((e) => BROWSABLE_ROLES.has(e.role)), + ); + return; + } + const config = { ...agentSkillConfig, skillId: id }; + dispatchProgram(config, argv); + }, +}; diff --git a/src/commands/source-maps.ts b/src/commands/source-maps.ts new file mode 100644 index 00000000..1a5dd7a3 --- /dev/null +++ b/src/commands/source-maps.ts @@ -0,0 +1,7 @@ +import { errorTrackingUploadSourceMapsConfig } from '@lib/programs/error-tracking-upload-source-maps/index'; + +import { nativeCommandFactory } from './factories/native-command-factory'; + +export const sourceMapsCommand = nativeCommandFactory( + errorTrackingUploadSourceMapsConfig, +); diff --git a/src/commands/upload-sourcemaps.ts b/src/commands/upload-sourcemaps.ts deleted file mode 100644 index 59d9b1ed..00000000 --- a/src/commands/upload-sourcemaps.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { runWizard, runWizardCI } from '@lib/runners'; -import { errorTrackingUploadSourceMapsConfig } from '@lib/programs/error-tracking-upload-source-maps/index'; -import { skillProgramOptions } from './skill-program-options'; -import type { Command } from './command'; - -export const uploadSourcemapsCommand: Command = { - name: 'upload-sourcemaps', - description: errorTrackingUploadSourceMapsConfig.description, - options: { - ...skillProgramOptions, - ...(errorTrackingUploadSourceMapsConfig.cliOptions ?? {}), - }, - handler: (argv) => { - const extras = - errorTrackingUploadSourceMapsConfig.mapCliOptions?.( - argv as Record, - ) ?? {}; - const options = { ...argv, ...extras }; - if (options.ci) { - runWizardCI(errorTrackingUploadSourceMapsConfig, options); - } else { - runWizard(errorTrackingUploadSourceMapsConfig, options); - } - }, -}; diff --git a/src/lib/programs/error-tracking-upload-source-maps/index.ts b/src/lib/programs/error-tracking-upload-source-maps/index.ts index 70a9d5d0..2d93d823 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/index.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/index.ts @@ -19,7 +19,7 @@ const REPORT_FILE = 'posthog-source-maps-report.md'; const DOCS_URL = 'https://posthog.com/docs/error-tracking/upload-source-maps'; export const errorTrackingUploadSourceMapsConfig: ProgramConfig = { - command: 'upload-source-maps', + command: 'source-maps', description: 'Upload source maps to PostHog Error Tracking', id: 'error-tracking-upload-source-maps', steps: ERROR_TRACKING_UPLOAD_SOURCE_MAPS_PROGRAM, 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 df1d8dfb..0ac6c494 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -31,7 +31,7 @@ import { // 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 = { +export const agentSkillConfig: ProgramConfig = { id: 'agent-skill', description: 'Run an arbitrary context-mill skill', steps: AGENT_SKILL_STEPS, diff --git a/src/lib/programs/program-step.ts b/src/lib/programs/program-step.ts index feabcb4a..285eb326 100644 --- a/src/lib/programs/program-step.ts +++ b/src/lib/programs/program-step.ts @@ -101,6 +101,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. * @@ -108,7 +156,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 @@ -179,6 +227,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/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, }); } From 4b8a5899d4f4e10be0c57e3eef656543e00653a0 Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Wed, 10 Jun 2026 12:53:45 -0400 Subject: [PATCH 02/11] fix(cli): harden command surface + make skill/audit runs actually execute - flatSkillCommand: graceful fallback when a manifest entry is missing, instead of throwing at import (which took down the whole CLI); both flat commands now resolve by stable skillId - command.ts: declare positionals so `skill [name]` parses under strictOptions; correct the `recommended` doc comment - skill: `wizard skill` lists, `wizard skill ` runs, `wizard skill search ` searches; reject unknown skill names - agentSkillConfig: add a run recipe so `wizard skill ` and the narrow audit leaves actually run (were silently skipped via skipAgent) - family-picker: document the argv-bypass on the picker path - tests: regression guard for the agentSkillConfig run recipe Generated-By: PostHog Code Task-Id: b42696d8-ac3c-4cd2-ba12-747a846b8c23 --- src/commands/command.ts | 28 +++- src/commands/factories/family-picker.tsx | 5 + src/commands/factories/flat-skill-command.ts | 55 ++++++++ src/commands/migrate.ts | 24 +--- src/commands/revenue.ts | 24 +--- src/commands/skill.ts | 122 +++++++++--------- .../__tests__/program-registry.test.ts | 30 +++++ src/lib/programs/program-registry.ts | 26 +++- 8 files changed, 211 insertions(+), 103 deletions(-) create mode 100644 src/commands/factories/flat-skill-command.ts diff --git a/src/commands/command.ts b/src/commands/command.ts index c3d0897d..0ec0900f 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'; export interface Command { /** Yargs command name. Use `['$0']` for the default command. */ @@ -6,6 +12,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. */ @@ -36,10 +50,11 @@ export interface Command { */ interactiveDefault?: (argv: Arguments) => void | Promise; /** - * When true, the family parent runs this child by default when invoked - * with no subcommand. At most one child per parent should be marked - * default. Propagated from the context-mill manifest's `default: true` - * field through `skillCommandFactory`. + * When true, this child is the "recommended" 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 `recommended` flag through `skillCommandFactory`. */ default?: boolean; } @@ -59,6 +74,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); diff --git a/src/commands/factories/family-picker.tsx b/src/commands/factories/family-picker.tsx index 0d2950e7..e6b934c2 100644 --- a/src/commands/factories/family-picker.tsx +++ b/src/commands/factories/family-picker.tsx @@ -136,6 +136,11 @@ export function createFamilyPickerDefault( 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/flat-skill-command.ts b/src/commands/factories/flat-skill-command.ts new file mode 100644 index 00000000..1e3a27e7 --- /dev/null +++ b/src/commands/factories/flat-skill-command.ts @@ -0,0 +1,55 @@ +import { + CLI_MANIFEST, + type CliManifestEntry, +} from '@lib/programs/cli-manifest.generated'; +import type { ProgramConfig } from '@lib/programs/program-step'; + +import type { Command } from '../command'; +import { skillCommandFactory } from './skill-command-factory'; + +/** + * Build a flat (top-level, no parent) skill command from the manifest entry + * identified by `skillId`, with a graceful fallback when the manifest snapshot + * doesn't carry it. + * + * Why this exists: + * + * 1. Robustness. `migrate.ts` / `revenue.ts` used to `throw` at module load + * if their entry was missing. Because those modules are imported at + * startup (via bin.ts), that throw took down the ENTIRE CLI — even + * `wizard --help` — whenever a build landed on the empty-manifest + * fallback. Here we degrade instead: synthesize the command from the + * local `ProgramConfig` (which already carries the command name, + * description, and skill id) and warn. `wizard migrate` keeps working, + * minus catalog polish, rather than bricking the binary. + * + * 2. Consistency. Both flat skill commands resolve their entry the same way + * — keyed on the stable `skillId`, not the user-facing command word + * (which is exactly what a CLI overhaul renames). + */ +export function flatSkillCommand( + skillId: string, + config: ProgramConfig, +): Command { + const entry = CLI_MANIFEST.entries.find( + (e) => e.role === 'command' && !e.parentCommand && e.skillId === skillId, + ); + if (entry) { + return skillCommandFactory(entry, config); + } + + process.stderr.write( + `[wizard] skill "${skillId}" is missing from the CLI manifest snapshot — ` + + `falling back to the built-in command definition. Rebuild with network ` + + `access to refresh the manifest.\n`, + ); + + const fallback: CliManifestEntry = { + skillId, + role: 'command', + command: config.command ?? skillId, + displayName: config.command ?? skillId, + description: config.description, + }; + return skillCommandFactory(fallback, config); +} diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index df3d5ac9..822b01bb 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -1,8 +1,7 @@ -import { CLI_MANIFEST } from '@lib/programs/cli-manifest.generated'; import { migrationConfig } from '@lib/programs/migration/index'; import type { Command } from './command'; -import { skillCommandFactory } from './factories/skill-command-factory'; +import { flatSkillCommand } from './factories/flat-skill-command'; /** * `wizard migrate` — flat skill command, Statsig today. Stays flat @@ -10,22 +9,11 @@ import { skillCommandFactory } from './factories/skill-command-factory'; * restructures into a family (parentCommand: migrate, command per * vendor) and the picker opens — a deliberate breaking change at that * point, not silent magic introduced now. + * + * Resolved from the manifest by skillId; falls back to the built-in + * config if the snapshot is missing the entry (see flatSkillCommand). */ -const migrateEntry = CLI_MANIFEST.entries.find( - (entry) => - entry.role === 'command' && - !entry.parentCommand && - entry.command === 'migrate', -); - -if (!migrateEntry) { - throw new Error( - 'commands/migrate: no public `migrate` entry in CLI_MANIFEST. ' + - 'Check cli-manifest.bootstrap.json or the latest context-mill release.', - ); -} - -export const migrateCommand: Command = skillCommandFactory( - migrateEntry, +export const migrateCommand: Command = flatSkillCommand( + 'migrate-statsig', migrationConfig, ); diff --git a/src/commands/revenue.ts b/src/commands/revenue.ts index 99f9c220..b367283e 100644 --- a/src/commands/revenue.ts +++ b/src/commands/revenue.ts @@ -1,8 +1,7 @@ -import { CLI_MANIFEST } from '@lib/programs/cli-manifest.generated'; import { revenueAnalyticsConfig } from '@lib/programs/revenue-analytics/index'; import type { Command } from './command'; -import { skillCommandFactory } from './factories/skill-command-factory'; +import { flatSkillCommand } from './factories/flat-skill-command'; /** * `wizard revenue-analytics` — flat skill command, Stripe today. Stays @@ -11,22 +10,11 @@ import { skillCommandFactory } from './factories/skill-command-factory'; * revenue-analytics, command per vendor) and the picker opens — a * deliberate breaking change at that point, not silent magic * introduced now. + * + * Resolved from the manifest by skillId; falls back to the built-in + * config if the snapshot is missing the entry (see flatSkillCommand). */ -const revenueEntry = CLI_MANIFEST.entries.find( - (entry) => - entry.role === 'command' && - !entry.parentCommand && - entry.skillId === 'revenue-analytics-setup', -); - -if (!revenueEntry) { - throw new Error( - 'commands/revenue: no public `revenue-analytics-setup` entry in CLI_MANIFEST. ' + - 'Check cli-manifest.bootstrap.json or the latest context-mill release.', - ); -} - -export const revenueCommand: Command = skillCommandFactory( - revenueEntry, +export const revenueCommand: Command = flatSkillCommand( + 'revenue-analytics-setup', revenueAnalyticsConfig, ); diff --git a/src/commands/skill.ts b/src/commands/skill.ts index 5aa6bda8..28406115 100644 --- a/src/commands/skill.ts +++ b/src/commands/skill.ts @@ -8,37 +8,38 @@ import { dispatchProgram, mergeCommandOptions } from './factories/shared'; import type { Command } from './command'; /** - * Catalog-access subcommands: `wizard skill list`, `wizard skill search`, - * and `wizard skill `. + * `wizard skill` — list the runnable skills in the catalog. + * `wizard skill ` — run that one skill. * - * `list` and `search` are read-only catalog inspection — they print to - * stdout and exit without spinning up the TUI. The bare `wizard skill ` - * form dispatches to the generic `agent-skill` program with the supplied - * skill id, so any skill in the catalog is runnable even when it's not - * promoted as a top-level public command. + * Two forms, nothing else: bare lists, named runs. The listing is the menu; + * the name picks from it. Anything you see listed, you can run by name. * - * The catalog source today is `CLI_MANIFEST.entries` (the build-time - * snapshot). The browsable listings (`list`, `search`, and bare `skill`) - * show `command` + `skill` entries; `internal` skills are hidden unless - * `--role internal` is passed, but stay runnable by id. + * Running dispatches the generic `agent-skill` program with the chosen skill + * id, so any catalogued skill is runnable without being promoted to a + * top-level command of its own. + * + * Catalog source is the build-time `CLI_MANIFEST` snapshot. `internal` skills + * are kept out of both the listing and the runnable set (reachable only via + * the hidden `--skill=` dev escape hatch), mirroring how internal flags + * stay out of `--help`. */ +const BROWSABLE_ROLES = new Set(['command', 'skill']); + +function browsableEntries(): CliManifestEntry[] { + return CLI_MANIFEST.entries.filter((e) => BROWSABLE_ROLES.has(e.role)); +} + function formatEntry(entry: CliManifestEntry): string { const path = entry.parentCommand ? `wizard ${entry.parentCommand} ${entry.command}` - : `wizard ${entry.command}`; + : entry.command + ? `wizard ${entry.command}` + : `wizard skill ${entry.skillId}`; return ` ${entry.skillId.padEnd(38)} ${path.padEnd(36)} ${ entry.description }`; } -/** - * Roles shown in the browsable catalog by default. `internal` skills are - * hidden unless explicitly requested via `--role internal`, mirroring how - * internal flags are kept out of `--help`. They remain runnable by id for - * anyone who knows it (`wizard skill ` / the `--skill` escape hatch). - */ -const BROWSABLE_ROLES = new Set(['command', 'skill']); - function printEntries(entries: readonly CliManifestEntry[]): void { if (entries.length === 0) { process.stdout.write( @@ -57,66 +58,69 @@ function printEntries(entries: readonly CliManifestEntry[]): void { } } -const listCommand: Command = { - name: 'list', - description: 'List skills in the wizard catalog', - options: { - role: { - describe: 'Filter by role', +const searchCommand: Command = { + name: 'search ', + description: 'Search the skill catalog by name or description', + positionals: { + query: { type: 'string', - choices: ['command', 'skill', 'internal'] as const, + describe: 'Text to match against skill name or description', }, }, - handler: (argv) => { - const role = (argv.role as string | undefined) ?? undefined; - const entries = CLI_MANIFEST.entries.filter((e) => - role == null ? BROWSABLE_ROLES.has(e.role) : e.role === role, - ); - printEntries(entries); - }, -}; - -const searchCommand: Command = { - name: 'search ', - description: 'Search the wizard skill catalog by name or description', handler: (argv) => { const query = String(argv.query ?? '').toLowerCase(); if (!query) { process.stdout.write('No query provided.\n'); return; } - const matches = CLI_MANIFEST.entries.filter((entry) => { - if (!BROWSABLE_ROLES.has(entry.role)) return false; - return ( + const matches = browsableEntries().filter( + (entry) => entry.skillId.toLowerCase().includes(query) || entry.displayName.toLowerCase().includes(query) || entry.description.toLowerCase().includes(query) || (entry.command?.toLowerCase().includes(query) ?? false) || - (entry.parentCommand?.toLowerCase().includes(query) ?? false) - ); - }); + (entry.parentCommand?.toLowerCase().includes(query) ?? false), + ); printEntries(matches); }, }; export const skillCommand: Command = { - name: 'skill [id]', - description: 'Explore and run skills from the wizard catalog', - children: [listCommand, searchCommand], + name: 'skill [name]', + description: 'List skills, or run one by name', + children: [searchCommand], options: mergeCommandOptions(agentSkillConfig), + positionals: { + name: { + type: 'string', + describe: 'Skill to run (omit to list every runnable skill)', + }, + }, handler: (argv) => { - const id = (argv.id as string | undefined)?.trim(); - if (!id) { - // Bare `wizard skill` with no positional — list the catalog so the - // user sees what's available. Cheaper than yargs's help dump and the - // ids are what they need to invoke a specific skill. Internal skills - // stay hidden here (see BROWSABLE_ROLES). - printEntries( - CLI_MANIFEST.entries.filter((e) => BROWSABLE_ROLES.has(e.role)), - ); + // The name the user types is the skill's name straight from the listing + // (its context-mill skill id, e.g. `audit-events`). There's no separate + // "id" — name and id are the same readable string here. + const name = (argv.name as string | undefined)?.trim(); + + // Bare `wizard skill` — list the runnable catalog so the user sees what + // they can run, then exit. No TUI, no agent run. + if (!name) { + printEntries(browsableEntries()); return; } - const config = { ...agentSkillConfig, skillId: id }; - dispatchProgram(config, argv); + + // `wizard skill ` — run that skill, but only if the catalog knows + // it. `skill` is a user-facing surface, so we refuse unknown (or internal) + // names rather than handing an arbitrary string to the agent runner. You + // can run anything the bare listing shows; nothing else. + const runnable = browsableEntries().some((entry) => entry.skillId === name); + if (!runnable) { + process.stderr.write( + `\x1b[1;91m✖ Unknown skill "${name}".\x1b[0m Run \`wizard skill\` to see what you can run.\n`, + ); + process.exit(1); + } + + dispatchProgram({ ...agentSkillConfig, skillId: name }, argv); }, }; diff --git a/src/lib/programs/__tests__/program-registry.test.ts b/src/lib/programs/__tests__/program-registry.test.ts index 4399f6f4..36dcec09 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', () => { @@ -65,3 +67,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/program-registry.ts b/src/lib/programs/program-registry.ts index 0ac6c494..a76a4653 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -11,6 +11,7 @@ */ 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'; @@ -28,15 +29,34 @@ import { mcpTutorialConfig, } from './mcp/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. +// 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 = [ From 6d95cb4e25863589f30e8721dd8e264f96f8aec4 Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Wed, 10 Jun 2026 13:02:25 -0400 Subject: [PATCH 03/11] fix(cli): default the audit family to events instead of the comprehensive audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the `recommended` flag from `audit all` to `audit-events` so a bare `wizard audit` pre-highlights the events audit. `audit all` stays a selectable leaf — dropping it (and the auditConfig/screen cleanup that requires) is deferred to the multi-select PR. Mirrors the matching context-mill `cli:` change; the build currently sources this bootstrap snapshot since context-mill hasn't released the manifest yet. Generated-By: PostHog Code Task-Id: b42696d8-ac3c-4cd2-ba12-747a846b8c23 --- cli-manifest.bootstrap.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli-manifest.bootstrap.json b/cli-manifest.bootstrap.json index d812bffc..48a6d86d 100644 --- a/cli-manifest.bootstrap.json +++ b/cli-manifest.bootstrap.json @@ -22,7 +22,6 @@ "role": "command", "parentCommand": "audit", "command": "all", - "recommended": true, "displayName": "PostHog audit", "description": "Audit an existing PostHog integration for correctness and best practices" }, @@ -39,6 +38,7 @@ "role": "command", "parentCommand": "audit", "command": "events", + "recommended": true, "displayName": "PostHog audit — events", "description": "Audit a PostHog integration's event capture quality and cost-optimization opportunities" }, From 684167a9a9e41904e79223e4ea4567cbb7f8dca4 Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Wed, 10 Jun 2026 16:58:40 -0400 Subject: [PATCH 04/11] fix(audit): match the events-audit slide guard to the audit-events skill id AuditRunScreen picked the slide deck by checking `session.skillId === 'events-audit'`, but context-mill renamed that skill to `audit-events` (the bootstrap manifest and the programs-cli dispatch test both use `audit-events`). The stale check silently fell through to the comprehensive AUDIT_AREA_SLIDES, so `wizard audit events` showed the wrong deck. Match the current id. Generated-By: PostHog Code Task-Id: 95407a79-1f13-4f1d-a37b-43fe4a62b857 --- src/ui/tui/screens/audit/AuditRunScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 = ( From 7e502627ca35b66035f2f96c469288a177f5d790 Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Thu, 11 Jun 2026 11:12:52 -0400 Subject: [PATCH 05/11] feat(cli): resolve skill subcommands at runtime; retire audit-3000 Replaces the build-time CLI manifest snapshot with a runtime resolver that reads cliEntries from skill-menu.json each invocation. Adding a skill-backed subcommand is now a context-mill release; no wizard release needed. How it fits together: - dispatch-family.ts owns the runtime resolution. dispatchFamily(family, argv) tries the native handler registry first (today: just audit/web-analytics), then fetches skill-menu.json and matches parentCommand + command -> skillId. The comprehensive `audit all` keeps its specialized auditConfig; everything else runs through the generic agent-skill program with the resolved skillId injected. - family-command-factory.ts wraps dispatchFamily as a yargs Command (name: ` [skill]`). interactiveDefault opens the picker with native + live entries combined; the recommended leaf is pre-highlighted. - audit.ts collapses to one factory call. migrate.ts / revenue.ts swap flatSkillCommand for nativeCommandFactory now that the config has the right command name and skillId. skill.ts fetches the catalog at handler time instead of reading a baked snapshot. Deletions: - audit-3000 retired (skill, screens, registry entry, agent-interface model branch, playground demo, screen-registry wiring) - skill-command-factory.ts + flat-skill-command.ts: the runtime resolver replaces both, including the empty-manifest fallback flat-skill-command existed to work around - cli-manifest.bootstrap.json, cli-manifest.schema.bootstrap.json, scripts/generate-cli-manifest.cjs, src/lib/programs/cli-manifest.generated.ts - ajv dev-dep; the generate-cli-manifest.cjs step in prebuild; three .gitignore lines covering the deleted bootstrap apparatus Test surface: programs-cli mocks fetchSkillMenu and asserts dispatchFamily routing (skill-backed, native, comprehensive). family-picker keeps its auditCommand shape assertion but flipped to "no static children by design". program-registry catches the revenue-analytics command-name rename. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 +- cli-manifest.bootstrap.json | 70 ----- cli-manifest.schema.bootstrap.json | 73 ----- package.json | 3 +- scripts/generate-cli-manifest.cjs | 277 ------------------ src/__tests__/programs-cli.test.ts | 171 ++++++----- src/commands/audit.ts | 58 +--- .../factories/__tests__/family-picker.test.ts | 9 +- .../__tests__/skill-command-factory.test.ts | 136 --------- .../factories/family-command-factory.ts | 72 +++++ src/commands/factories/flat-skill-command.ts | 55 ---- .../factories/skill-command-factory.ts | 60 ---- src/commands/migrate.ts | 21 +- src/commands/revenue.ts | 16 +- src/commands/skill.ts | 124 ++++---- src/lib/agent/agent-interface.ts | 7 +- .../__tests__/program-registry.test.ts | 6 +- src/lib/programs/audit-3000/index.ts | 254 ---------------- src/lib/programs/dispatch-family.ts | 133 +++++++++ src/lib/programs/program-registry.ts | 3 - src/lib/programs/program-step.ts | 4 +- src/lib/programs/revenue-analytics/index.ts | 3 +- src/lib/wizard-tools.ts | 21 ++ src/ui/tui/playground/demos/LearnDeckDemo.tsx | 12 +- src/ui/tui/screen-registry.tsx | 6 - src/ui/tui/screen-sequences.ts | 3 - .../screens/audit-3000/Audit3000AreaPane.tsx | 142 --------- .../audit-3000/Audit3000ChecksPanel.tsx | 171 ----------- .../audit-3000/Audit3000IntroScreen.tsx | 163 ----------- .../audit-3000/Audit3000OutroScreen.tsx | 213 -------------- .../screens/audit-3000/Audit3000RunScreen.tsx | 102 ------- .../tui/screens/audit-3000/HedgehogRunner.tsx | 149 ---------- .../__tests__/hedgehog-runner-engine.test.ts | 138 --------- .../tui/screens/audit-3000/arcade-colors.ts | 5 - .../audit-3000/hedgehog-runner-engine.ts | 158 ---------- .../audit-3000/slides/eventQuality.tsx | 34 --- .../screens/audit-3000/slides/expansion.tsx | 37 --- .../audit-3000/slides/featureFlags.tsx | 33 --- src/ui/tui/screens/audit-3000/slides/index.ts | 24 -- 39 files changed, 444 insertions(+), 2529 deletions(-) delete mode 100644 cli-manifest.bootstrap.json delete mode 100644 cli-manifest.schema.bootstrap.json delete mode 100644 scripts/generate-cli-manifest.cjs delete mode 100644 src/commands/factories/__tests__/skill-command-factory.test.ts create mode 100644 src/commands/factories/family-command-factory.ts delete mode 100644 src/commands/factories/flat-skill-command.ts delete mode 100644 src/commands/factories/skill-command-factory.ts delete mode 100644 src/lib/programs/audit-3000/index.ts create mode 100644 src/lib/programs/dispatch-family.ts delete mode 100644 src/ui/tui/screens/audit-3000/Audit3000AreaPane.tsx delete mode 100644 src/ui/tui/screens/audit-3000/Audit3000ChecksPanel.tsx delete mode 100644 src/ui/tui/screens/audit-3000/Audit3000IntroScreen.tsx delete mode 100644 src/ui/tui/screens/audit-3000/Audit3000OutroScreen.tsx delete mode 100644 src/ui/tui/screens/audit-3000/Audit3000RunScreen.tsx delete mode 100644 src/ui/tui/screens/audit-3000/HedgehogRunner.tsx delete mode 100644 src/ui/tui/screens/audit-3000/__tests__/hedgehog-runner-engine.test.ts delete mode 100644 src/ui/tui/screens/audit-3000/arcade-colors.ts delete mode 100644 src/ui/tui/screens/audit-3000/hedgehog-runner-engine.ts delete mode 100644 src/ui/tui/screens/audit-3000/slides/eventQuality.tsx delete mode 100644 src/ui/tui/screens/audit-3000/slides/expansion.tsx delete mode 100644 src/ui/tui/screens/audit-3000/slides/featureFlags.tsx delete mode 100644 src/ui/tui/screens/audit-3000/slides/index.ts diff --git a/.gitignore b/.gitignore index 84ca9782..2f606990 100644 --- a/.gitignore +++ b/.gitignore @@ -40,9 +40,4 @@ plugins e2e-tests/fixtures/.tracking/* # Generated at build time by scripts/generate-version.js -src/lib/version.ts - -# Generated at build time by scripts/generate-cli-manifest.cjs -src/lib/programs/cli-manifest.generated.ts -.cache/cli-manifest.json -.cache/cli-manifest.schema.json \ No newline at end of file +src/lib/version.ts \ No newline at end of file diff --git a/cli-manifest.bootstrap.json b/cli-manifest.bootstrap.json deleted file mode 100644 index 48a6d86d..00000000 --- a/cli-manifest.bootstrap.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "version": "1.0", - "buildVersion": "bootstrap", - "buildTimestamp": "2026-06-09T00:00:00.000Z", - "entries": [ - { - "skillId": "migrate-statsig", - "role": "command", - "command": "migrate", - "displayName": "Statsig → PostHog", - "description": "Migrate an existing analytics or feature-flag vendor to PostHog. Replaces SDK call sites in-place, removes the source package, and writes a migration report. Replacement-only, doesn't adds new instrumentation." - }, - { - "skillId": "revenue-analytics-setup", - "role": "command", - "command": "revenue-analytics", - "displayName": "Stripe Revenue Analytics", - "description": "Set up Stripe revenue analytics with PostHog" - }, - { - "skillId": "audit", - "role": "command", - "parentCommand": "audit", - "command": "all", - "displayName": "PostHog audit", - "description": "Audit an existing PostHog integration for correctness and best practices" - }, - { - "skillId": "audit-autocapture", - "role": "command", - "parentCommand": "audit", - "command": "autocapture", - "displayName": "PostHog audit — autocapture", - "description": "Audit a PostHog autocapture setup for correctness and cost-optimization opportunities" - }, - { - "skillId": "audit-events", - "role": "command", - "parentCommand": "audit", - "command": "events", - "recommended": true, - "displayName": "PostHog audit — events", - "description": "Audit a PostHog integration's event capture quality and cost-optimization opportunities" - }, - { - "skillId": "audit-feature-flags", - "role": "command", - "parentCommand": "audit", - "command": "feature-flags", - "displayName": "PostHog audit — feature flags", - "description": "Audit a PostHog integration's feature flag usage for correctness and cost-optimization opportunities" - }, - { - "skillId": "audit-identify", - "role": "command", - "parentCommand": "audit", - "command": "identify", - "displayName": "PostHog audit — identify", - "description": "Audit a PostHog integration's $identify implementation for correctness and cost-optimization opportunities" - }, - { - "skillId": "audit-session-replay", - "role": "command", - "parentCommand": "audit", - "command": "session-replay", - "displayName": "PostHog audit — session replay", - "description": "Audit a PostHog session replay setup for correctness and cost-optimization opportunities" - } - ] -} diff --git a/cli-manifest.schema.bootstrap.json b/cli-manifest.schema.bootstrap.json deleted file mode 100644 index e205885d..00000000 --- a/cli-manifest.schema.bootstrap.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/PostHog/context-mill/releases/latest/download/cli-manifest.schema.json", - "title": "Wizard CLI manifest", - "description": "Shape of dist/skills/cli-manifest.json. Published alongside the manifest itself so downstream consumers (notably the wizard's prebuild) can validate the manifest's structure before snapshotting it. The wizard's CONTRIBUTING.md and context-mill's CONTRIBUTING.md cover the field semantics.", - "type": "object", - "additionalProperties": false, - "required": ["version", "buildVersion", "buildTimestamp", "entries"], - "properties": { - "version": { - "type": "string", - "description": "Schema version. Bumped when the manifest shape changes in a breaking way." - }, - "buildVersion": { - "type": "string", - "description": "Context-mill release version that produced this manifest. May be 'dev', 'bootstrap', or 'empty-fallback' depending on the source." - }, - "buildTimestamp": { - "type": "string", - "minLength": 1, - "description": "ISO 8601 timestamp the manifest was emitted." - }, - "entries": { - "type": "array", - "items": { "$ref": "#/definitions/CliManifestEntry" } - } - }, - "definitions": { - "CliManifestEntry": { - "type": "object", - "additionalProperties": false, - "required": ["skillId", "role", "displayName", "description"], - "properties": { - "skillId": { - "type": "string", - "minLength": 1, - "description": "Context-mill skill id (e.g. 'audit-events', 'migrate-statsig')." - }, - "role": { - "type": "string", - "enum": ["command", "skill", "internal"], - "description": "Where this skill appears in the wizard CLI surface." - }, - "command": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$", - "minLength": 2, - "maxLength": 20, - "description": "User-typed word that registers this skill as a command. Required when role is 'command'." - }, - "parentCommand": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$", - "minLength": 2, - "maxLength": 20, - "description": "Optional parent command this skill nests under." - }, - "recommended": { - "type": "boolean", - "description": "When true, this leaf is pre-highlighted when the user invokes the family parent with no subcommand (e.g. `wizard audit` pre-highlights the leaf marked recommended, so a single Enter runs it). The picker always opens; at most one leaf per family may be marked recommended." - }, - "displayName": { - "type": "string", - "minLength": 1 - }, - "description": { - "type": "string", - "minLength": 1 - } - } - } - } -} diff --git a/package.json b/package.json index 1e2cdab8..3e6150ba 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "@types/yargs": "^16.0.9", "@typescript-eslint/eslint-plugin": "^5.13.0", "@typescript-eslint/parser": "^5.13.0", - "ajv": "^8.20.0", "babel-jest": "^29.7.0", "dotenv": "^16.4.7", "eslint": "^8.18.0", @@ -100,7 +99,7 @@ "packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b", "scripts": { "clean": "rm -rf ./dist", - "prebuild": "pnpm clean && node scripts/generate-version.cjs && node scripts/generate-cli-manifest.cjs", + "prebuild": "pnpm clean && node scripts/generate-version.cjs", "build:watch": "tsdown --watch", "build": "tsdown", "build:ci": "WIZARD_BUILD_NODE_ENV=ci pnpm build", diff --git a/scripts/generate-cli-manifest.cjs b/scripts/generate-cli-manifest.cjs deleted file mode 100644 index 9859689f..00000000 --- a/scripts/generate-cli-manifest.cjs +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Snapshot context-mill's cli-manifest.json into a typed TS module. - * - * Fetches from REMOTE_SKILLS_BASE_URL at build time. Falls back to a local - * cache when the network or release is unavailable, and finally to an empty - * manifest so the build never breaks. The generated file is gitignored — - * regenerated on every prebuild. - * - * Each load is validated against a JSON Schema before being accepted. - * Schema drift between context-mill and wizard (extra fields, wrong types, - * naming-convention violations) gets caught here at build time instead of - * surfacing at runtime. A manifest we successfully fetched but that fails - * validation is treated as real drift and FAILS the build (exit 1) — it is - * not swallowed by the offline fallback chain. - * - * Fallback chain: - * 1. Remote (GitHub release URL) - * 2. Local cache at .cache/cli-manifest.json - * 3. Bootstrap snapshot at cli-manifest.bootstrap.json (committed to the - * repo — keeps the wizard buildable before context-mill cuts a release - * with the new file) - * 4. Empty manifest (no entries) - */ - -const fs = require('fs'); -const path = require('path'); -const https = require('https'); -const Ajv = require('ajv'); - -const REPO_ROOT = path.resolve(__dirname, '..'); -const CACHE_DIR = path.join(REPO_ROOT, '.cache'); -const CACHE_PATH = path.join(CACHE_DIR, 'cli-manifest.json'); -const BOOTSTRAP_PATH = path.join(REPO_ROOT, 'cli-manifest.bootstrap.json'); -const SCHEMA_BOOTSTRAP_PATH = path.join( - REPO_ROOT, - 'cli-manifest.schema.bootstrap.json', -); -const SCHEMA_CACHE_PATH = path.join(CACHE_DIR, 'cli-manifest.schema.json'); -const OUT_PATH = path.join( - REPO_ROOT, - 'src', - 'lib', - 'programs', - 'cli-manifest.generated.ts', -); - -// Mirrors REMOTE_SKILLS_BASE_URL in src/lib/constants.ts. Kept as a literal -// here so the prebuild doesn't need to import the TS source. -const REMOTE_BASE_URL = - 'https://github.com/PostHog/context-mill/releases/latest/download'; -const REMOTE_MANIFEST_URL = `${REMOTE_BASE_URL}/cli-manifest.json`; -const REMOTE_SCHEMA_URL = `${REMOTE_BASE_URL}/cli-manifest.schema.json`; - -function logWarning(message) { - process.stderr.write(`[generate-cli-manifest] ${message}\n`); -} - -function fetchJson(url, redirectsRemaining = 5) { - return new Promise((resolve, reject) => { - const request = https.get(url, (resp) => { - const status = resp.statusCode || 0; - if (status >= 300 && status < 400 && resp.headers.location) { - if (redirectsRemaining <= 0) { - reject(new Error('too many redirects')); - return; - } - resp.resume(); - fetchJson(resp.headers.location, redirectsRemaining - 1) - .then(resolve, reject); - return; - } - if (status !== 200) { - resp.resume(); - reject(new Error(`HTTP ${status}`)); - return; - } - const chunks = []; - resp.on('data', (c) => chunks.push(c)); - resp.on('end', () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); - } catch (err) { - reject(new Error(`JSON parse failed: ${err.message}`)); - } - }); - resp.on('error', reject); - }); - request.on('error', reject); - request.setTimeout(10_000, () => { - request.destroy(new Error('fetch timed out after 10s')); - }); - }); -} - -function readCache() { - if (!fs.existsSync(CACHE_PATH)) return null; - try { - return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); - } catch (err) { - logWarning(`cache at ${CACHE_PATH} is unreadable: ${err.message}`); - return null; - } -} - -function writeCache(manifest) { - fs.mkdirSync(CACHE_DIR, { recursive: true }); - fs.writeFileSync(CACHE_PATH, JSON.stringify(manifest, null, 2)); -} - -/** - * Load the JSON Schema with the same fallback chain as the manifest itself. - * Remote → cache → bootstrap. Empty fallback is never used here — without a - * schema we have no contract, so we'd rather fail loudly than validate - * nothing. - */ -async function loadSchema() { - try { - const remote = await fetchJson(REMOTE_SCHEMA_URL); - fs.mkdirSync(CACHE_DIR, { recursive: true }); - fs.writeFileSync(SCHEMA_CACHE_PATH, JSON.stringify(remote, null, 2)); - return { schema: remote, source: `remote (${REMOTE_SCHEMA_URL})` }; - } catch (err) { - logWarning(`remote schema fetch failed: ${err.message}`); - } - if (fs.existsSync(SCHEMA_CACHE_PATH)) { - try { - return { - schema: JSON.parse(fs.readFileSync(SCHEMA_CACHE_PATH, 'utf8')), - source: `cache (${SCHEMA_CACHE_PATH})`, - }; - } catch (err) { - logWarning(`cached schema is unreadable: ${err.message}`); - } - } - if (fs.existsSync(SCHEMA_BOOTSTRAP_PATH)) { - return { - schema: JSON.parse(fs.readFileSync(SCHEMA_BOOTSTRAP_PATH, 'utf8')), - source: `bootstrap (${SCHEMA_BOOTSTRAP_PATH})`, - }; - } - throw new Error( - 'no JSON Schema available — refusing to write the generated TS without a contract to validate against.', - ); -} - -function buildValidator(schema) { - const ajv = new Ajv({ allErrors: true, strict: false }); - return ajv.compile(schema); -} - -function validateManifest(raw, source, validator) { - if (!validator(raw)) { - const formatted = (validator.errors ?? []) - .map((e) => `${e.instancePath || '/'} ${e.message}`) - .join('; '); - throw new Error(`${source}: schema validation failed — ${formatted}`); - } - return raw; -} - -function emptyManifest() { - return { - version: '1.0', - buildVersion: 'empty-fallback', - buildTimestamp: '1970-01-01T00:00:00.000Z', - entries: [], - }; -} - -function renderTypeScript(manifest, source) { - const json = JSON.stringify(manifest, null, 2); - return `// Auto-generated by scripts/generate-cli-manifest.cjs — do not edit. -// Source: ${source} -// -// Snapshot of context-mill's dist/skills/cli-manifest.json taken at build -// time. The wizard imports this module instead of fetching at runtime, so -// the published binary is offline-capable. - -import type { ProgramCliSurface } from '@lib/programs/program-step'; - -export interface CliManifestEntry { - skillId: string; - role: ProgramCliSurface['role']; - command?: string; - parentCommand?: string; - /** - * When true, this leaf is the recommended (pre-highlighted) option when - * the family parent is invoked with no subcommand (e.g. \`wizard audit\` - * pre-highlights the entry marked recommended). At most one entry per - * family parent should be marked. - */ - recommended?: boolean; - displayName: string; - description: string; -} - -export interface CliManifest { - version: string; - buildVersion: string; - buildTimestamp: string; - entries: CliManifestEntry[]; -} - -export const CLI_MANIFEST: CliManifest = ${json}; -`; -} - -async function loadManifest(validator) { - // Separate "couldn't reach the network" from "fetched a manifest that - // doesn't match the schema". The first is an offline build — fall back - // quietly. The second is real schema drift between context-mill and the - // wizard, so we fail loudly (validateManifest throws → exit 1) instead of - // silently shipping a stale surface from the cache or bootstrap. - let remote; - try { - remote = await fetchJson(REMOTE_MANIFEST_URL); - } catch (remoteErr) { - logWarning(`remote fetch failed: ${remoteErr.message}`); - } - if (remote !== undefined) { - const validated = validateManifest(remote, 'remote', validator); - writeCache(validated); - return { manifest: validated, source: `remote (${REMOTE_MANIFEST_URL})` }; - } - - const cached = readCache(); - if (cached) { - try { - const validated = validateManifest(cached, 'cache', validator); - return { - manifest: validated, - source: `local cache (${CACHE_PATH})`, - }; - } catch (cacheErr) { - logWarning(`cache is invalid: ${cacheErr.message}`); - } - } - - if (fs.existsSync(BOOTSTRAP_PATH)) { - try { - const bootstrap = JSON.parse(fs.readFileSync(BOOTSTRAP_PATH, 'utf8')); - const validated = validateManifest(bootstrap, 'bootstrap', validator); - return { - manifest: validated, - source: `bootstrap snapshot (${BOOTSTRAP_PATH})`, - }; - } catch (bootstrapErr) { - logWarning(`bootstrap is invalid: ${bootstrapErr.message}`); - } - } - - logWarning( - 'no manifest available — writing empty fallback. Run with network access to populate.', - ); - // The empty fallback isn't schema-validated by design — it's the - // last-resort "build never breaks" path. Real manifests must validate. - return { manifest: emptyManifest(), source: 'empty fallback' }; -} - -async function main() { - const { schema, source: schemaSource } = await loadSchema(); - process.stdout.write( - `[generate-cli-manifest] loaded schema from ${schemaSource}\n`, - ); - const validator = buildValidator(schema); - const { manifest, source } = await loadManifest(validator); - fs.mkdirSync(path.dirname(OUT_PATH), { recursive: true }); - fs.writeFileSync(OUT_PATH, renderTypeScript(manifest, source)); - process.stdout.write( - `[generate-cli-manifest] wrote ${OUT_PATH} (${manifest.entries.length} entries, source: ${source})\n`, - ); -} - -main().catch((err) => { - logWarning(`fatal: ${err.message}`); - process.exit(1); -}); diff --git a/src/__tests__/programs-cli.test.ts b/src/__tests__/programs-cli.test.ts index 462abdbd..dbd53803 100644 --- a/src/__tests__/programs-cli.test.ts +++ b/src/__tests__/programs-cli.test.ts @@ -6,91 +6,139 @@ 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 type { Command } from '../commands/command'; import { auditCommand } from '../commands/audit'; import { migrateCommand } from '../commands/migrate'; import { revenueCommand } from '../commands/revenue'; +import { dispatchFamily } from '@lib/programs/dispatch-family'; +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; } -function findChild(parent: Command, name: string): Command | undefined { - return parent.children?.find((c) => { - const first = Array.isArray(c.name) ? c.name[0] : c.name; - return first.split(/\s+/)[0] === name; - }); +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('program commands', () => { +describe('top-level command shapes', () => { beforeEach(() => { jest.clearAllMocks(); }); - test('each top-level command exposes its CLI name', () => { - expect(auditCommand.name).toBe('audit'); - expect(migrateCommand.name).toBe('migrate'); - expect(revenueCommand.name).toBe('revenue-analytics'); + 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('audit nests web-analytics-doctor as a wizard-native child', () => { - expect(auditCommand.children).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'web-analytics' }), - ]), - ); + test('migrate is a flat command while only one vendor exists', () => { + expect(migrateCommand.name).toBe('migrate'); + expect(migrateCommand.children).toBeUndefined(); }); - test('audit exposes a subcommand for each public manifest entry', () => { - const names = (auditCommand.children ?? []).map((c) => - Array.isArray(c.name) ? c.name[0] : c.name, - ); - expect(names).toEqual( - expect.arrayContaining([ - 'all', - 'autocapture', - 'events', - 'feature-flags', - 'identify', - 'session-replay', - ]), - ); + test('revenue-analytics is a flat skill command', () => { + expect(revenueCommand.name).toBe('revenue-analytics'); + expect(revenueCommand.children).toBeUndefined(); }); - test('migrate is a flat command while only one vendor exists', () => { - expect(migrateCommand.name).toBe('migrate'); - expect(migrateCommand.children).toBeUndefined(); + test('audit exposes the shared skill options on the parent', () => { + expect(auditCommand.options).toMatchObject({ + 'install-dir': expect.any(Object), + }); }); +}); - test('audit family has no top-level handler (subcommand required)', () => { - expect(auditCommand.handler).toBeUndefined(); +describe('dispatchFamily', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - test('audit events dispatches to runWizard by default', () => { - const child = findChild(auditCommand, 'events'); - expect(child).toBeDefined(); - child!.handler!(makeArgv({ debug: true })); + 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('audit events dispatches to runWizardCI when --ci is set', () => { - const child = findChild(auditCommand, 'events'); - child!.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('skillCommandFactory injects the manifest entry skillId into the dispatched config', () => { - const events = findChild(auditCommand, 'events'); - events!.handler!(makeArgv()); - const dispatchedConfig = mockRunWizard.mock.calls[0][0] as { - skillId?: string; - }; - expect(dispatchedConfig.skillId).toBe('audit-events'); + 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('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); + }); +}); + +describe('flat skill commands', () => { + beforeEach(() => { + jest.clearAllMocks(); }); test('migrate dispatches with migrate-statsig skillId', () => { @@ -103,31 +151,21 @@ describe('program commands', () => { expect(opts.installDir).toBe('/tmp/some-app'); }); - test('revenue-analytics is a flat skill command', () => { - expect(revenueCommand.name).toBe('revenue-analytics'); - expect(revenueCommand.children).toBeUndefined(); + 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'); }); +}); - test('exposes the shared skill options on each command', () => { - const child = findChild(auditCommand, 'events'); - // Global flags (--debug, --local-mcp, --benchmark, --yara-report, --ci) - // live in wizard.ts GLOBAL_OPTIONS now, so they're applied at the - // parser level rather than mirrored onto every command's options. - // Only per-command flags are asserted here. - expect(child!.options).toMatchObject({ - 'install-dir': expect.any(Object), - }); - }); - - test('camelCases --install-dir end-to-end through yargs', async () => { +describe('yargs parsing for the audit family', () => { + test('camelCases --install-dir end-to-end', async () => { const argv = await parseCommand( auditCommand, '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 () => { @@ -136,5 +174,6 @@ describe('program commands', () => { 'audit web-analytics --install-dir /tmp/app', ); expect(argv.installDir).toBe('/tmp/app'); + expect(argv.skill).toBe('web-analytics'); }); }); diff --git a/src/commands/audit.ts b/src/commands/audit.ts index ad8c075d..e838bbe2 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -1,54 +1,22 @@ -import { CLI_MANIFEST } from '@lib/programs/cli-manifest.generated'; 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 { Command } from './command'; -import { createFamilyPickerDefault } from './factories/family-picker'; -import { nativeCommandFactory } from './factories/native-command-factory'; -import { skillCommandFactory } from './factories/skill-command-factory'; +import { familyCommandFactory } from './factories/family-command-factory'; /** - * The `wizard audit` family. Children come from two places: + * The `wizard audit` family. * - * 1. Manifest entries with `parentCommand: 'audit'` — six skill-backed - * leaves: all, events, flags, identify, session-replay, autocapture. - * Each dispatches through `skillCommandFactory` so the entry's - * `skillId` is what actually runs. + * 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. * - * 2. Wizard-native children that declare `parentCommand: 'audit'` on - * their `ProgramConfig` — currently just `web-analytics-doctor`. These - * live in the wizard (not in context-mill) because they do more than - * run a single skill. - * - * `wizard audit` with no leaf opens an interactive TUI picker over the - * children via `interactiveDefault`. `wizard audit --help` still works. + * Adding a new skill-backed audit subcommand is a context-mill release — + * no wizard release needed. */ - -// The comprehensive `wizard audit all` uses the specialized auditConfig -// (custom hooks, content blocks). The narrower audits (events, flags, etc.) -// use the generic agent-skill program — the manifest entry's skillId is what -// drives execution. -function resolveAuditConfig(skillId: string) { - return skillId === 'audit' ? auditConfig : agentSkillConfig; -} - -const auditSkillChildren = CLI_MANIFEST.entries - .filter( - (entry) => entry.role === 'command' && entry.parentCommand === 'audit', - ) - .map((entry) => - skillCommandFactory(entry, resolveAuditConfig(entry.skillId)), - ); - -const auditChildren: Command[] = [ - ...auditSkillChildren, - nativeCommandFactory(webAnalyticsDoctorConfig), -]; - -export const auditCommand: Command = { - name: 'audit', +export const auditCommand: Command = familyCommandFactory({ + family: 'audit', description: auditConfig.description, - children: auditChildren, - interactiveDefault: createFamilyPickerDefault('wizard audit', auditChildren), -}; + optionsFrom: auditConfig, +}); diff --git a/src/commands/factories/__tests__/family-picker.test.ts b/src/commands/factories/__tests__/family-picker.test.ts index 28e59c62..e6c14064 100644 --- a/src/commands/factories/__tests__/family-picker.test.ts +++ b/src/commands/factories/__tests__/family-picker.test.ts @@ -172,7 +172,12 @@ describe('auditCommand', () => { expect(typeof auditCommand.interactiveDefault).toBe('function'); }); - it('still exposes children so yargs can route `wizard audit `', () => { - expect(auditCommand.children?.length).toBeGreaterThan(0); + 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__/skill-command-factory.test.ts b/src/commands/factories/__tests__/skill-command-factory.test.ts deleted file mode 100644 index 1d18d505..00000000 --- a/src/commands/factories/__tests__/skill-command-factory.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -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 type { CliManifestEntry } from '@lib/programs/cli-manifest.generated'; - -import { skillCommandFactory } from '../skill-command-factory'; - -function makeArgv(extra: Record = {}): Arguments { - return { _: [], $0: 'wizard', ...extra } as Arguments; -} - -function buildTestEntry( - overrides: Partial = {}, -): CliManifestEntry { - return { - skillId: 'demo-skill', - role: 'command', - command: 'demo', - displayName: 'Demo Skill', - description: 'demo description from manifest', - ...overrides, - }; -} - -function buildTestConfig( - overrides: Partial = {}, -): ProgramConfig { - return { - id: 'demo', - description: 'demo description from config', - steps: [], - ...overrides, - }; -} - -describe('skillCommandFactory', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('uses command name from the manifest entry, not the program config', () => { - const cmd = skillCommandFactory( - buildTestEntry({ command: 'from-entry' }), - buildTestConfig({ command: 'from-config' }), - ); - expect(cmd.name).toBe('from-entry'); - }); - - it('uses description from the manifest entry, not the program config', () => { - const cmd = skillCommandFactory(buildTestEntry(), buildTestConfig()); - expect(cmd.description).toBe('demo description from manifest'); - }); - - it('throws when the manifest entry is not a command role', () => { - expect(() => - skillCommandFactory(buildTestEntry({ role: 'skill' }), buildTestConfig()), - ).toThrow(/role "skill"/); - expect(() => - skillCommandFactory( - buildTestEntry({ role: 'internal' }), - buildTestConfig(), - ), - ).toThrow(/role "internal"/); - }); - - it('throws when the manifest entry has no command name', () => { - expect(() => - skillCommandFactory( - { ...buildTestEntry(), command: undefined }, - buildTestConfig(), - ), - ).toThrow(/missing `command`/); - }); - - it('merges skill-program options with the program-specific cliOptions', () => { - const cmd = skillCommandFactory( - buildTestEntry(), - buildTestConfig({ - cliOptions: { - flavor: { type: 'string' as const }, - }, - }), - ); - // --install-dir is the only per-command shared option; global flags - // (--debug, --local-mcp, --benchmark, etc.) live in GLOBAL_OPTIONS. - expect(cmd.options).toHaveProperty('install-dir'); - expect(cmd.options).toHaveProperty('flavor'); - }); - - it('handler dispatches to runWizard with skillId overridden from the entry', () => { - const config = buildTestConfig({ - skillId: 'will-be-overridden', - mapCliOptions: (argv) => ({ derived: argv.foo }), - }); - const cmd = skillCommandFactory( - buildTestEntry({ skillId: 'from-manifest-entry' }), - config, - ); - cmd.handler!(makeArgv({ foo: 'bar' })); - - expect(mockRunWizard).toHaveBeenCalledTimes(1); - const [calledConfig, calledOptions] = mockRunWizard.mock.calls[0]; - expect(calledConfig).toMatchObject({ - id: config.id, - skillId: 'from-manifest-entry', - }); - expect(calledOptions).toMatchObject({ foo: 'bar', derived: 'bar' }); - }); - - it('handler routes to runWizardCI when --ci is set', () => { - const cmd = skillCommandFactory(buildTestEntry(), buildTestConfig()); - cmd.handler!(makeArgv({ ci: true })); - expect(mockRunWizardCI).toHaveBeenCalledTimes(1); - expect(mockRunWizard).not.toHaveBeenCalled(); - }); - - it('passes children through unchanged', () => { - const child = { - name: 'inner', - description: 'inner', - handler: () => undefined, - }; - const cmd = skillCommandFactory(buildTestEntry(), buildTestConfig(), { - children: [child], - }); - expect(cmd.children).toEqual([child]); - }); -}); diff --git a/src/commands/factories/family-command-factory.ts b/src/commands/factories/family-command-factory.ts new file mode 100644 index 00000000..2a45eba1 --- /dev/null +++ b/src/commands/factories/family-command-factory.ts @@ -0,0 +1,72 @@ +import type { Arguments } from 'yargs'; + +import type { ProgramConfig } from '@lib/programs/program-step'; +import { + buildFamilyPickerChildren, + dispatchFamily, +} 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) — `interactiveDefault` fetches the + * registry, builds a children list combining native + live entries, and + * opens the family picker. The recommended leaf (if any) is + * pre-highlighted. + * + * 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 { + 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) => { + void dispatchFamily(family, argv); + }, + interactiveDefault: async (argv: Arguments) => { + const skillsBaseUrl = getSkillsBaseUrl(Boolean(argv['local-mcp'])); + const menu = await fetchSkillMenu(skillsBaseUrl); + const children = buildFamilyPickerChildren( + family, + menu?.cliEntries ?? [], + ); + const picker = createFamilyPickerDefault(`wizard ${family}`, children); + await picker(argv); + }, + }; +} diff --git a/src/commands/factories/flat-skill-command.ts b/src/commands/factories/flat-skill-command.ts deleted file mode 100644 index 1e3a27e7..00000000 --- a/src/commands/factories/flat-skill-command.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - CLI_MANIFEST, - type CliManifestEntry, -} from '@lib/programs/cli-manifest.generated'; -import type { ProgramConfig } from '@lib/programs/program-step'; - -import type { Command } from '../command'; -import { skillCommandFactory } from './skill-command-factory'; - -/** - * Build a flat (top-level, no parent) skill command from the manifest entry - * identified by `skillId`, with a graceful fallback when the manifest snapshot - * doesn't carry it. - * - * Why this exists: - * - * 1. Robustness. `migrate.ts` / `revenue.ts` used to `throw` at module load - * if their entry was missing. Because those modules are imported at - * startup (via bin.ts), that throw took down the ENTIRE CLI — even - * `wizard --help` — whenever a build landed on the empty-manifest - * fallback. Here we degrade instead: synthesize the command from the - * local `ProgramConfig` (which already carries the command name, - * description, and skill id) and warn. `wizard migrate` keeps working, - * minus catalog polish, rather than bricking the binary. - * - * 2. Consistency. Both flat skill commands resolve their entry the same way - * — keyed on the stable `skillId`, not the user-facing command word - * (which is exactly what a CLI overhaul renames). - */ -export function flatSkillCommand( - skillId: string, - config: ProgramConfig, -): Command { - const entry = CLI_MANIFEST.entries.find( - (e) => e.role === 'command' && !e.parentCommand && e.skillId === skillId, - ); - if (entry) { - return skillCommandFactory(entry, config); - } - - process.stderr.write( - `[wizard] skill "${skillId}" is missing from the CLI manifest snapshot — ` + - `falling back to the built-in command definition. Rebuild with network ` + - `access to refresh the manifest.\n`, - ); - - const fallback: CliManifestEntry = { - skillId, - role: 'command', - command: config.command ?? skillId, - displayName: config.command ?? skillId, - description: config.description, - }; - return skillCommandFactory(fallback, config); -} diff --git a/src/commands/factories/skill-command-factory.ts b/src/commands/factories/skill-command-factory.ts deleted file mode 100644 index 71f683e9..00000000 --- a/src/commands/factories/skill-command-factory.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ProgramConfig } from '@lib/programs/program-step'; -import type { CliManifestEntry } from '@lib/programs/cli-manifest.generated'; - -import type { Command } from '../command'; - -import { dispatchProgram, mergeCommandOptions } from './shared'; - -export interface SkillCommandFactoryOpts { - /** Subcommands nested under this command. */ - children?: readonly Command[]; -} - -/** - * Build a yargs `Command` from a context-mill manifest entry plus the - * wizard-side `ProgramConfig` that supplies the runner mechanics. - * - * The manifest entry owns the user-visible bits — command name, description, - * role, skill id — while `ProgramConfig` supplies the run mechanics - * (steps, hooks, content blocks, options). Each side stays responsible for - * what it knows best: context-mill curates the CLI surface, wizard owns - * execution. - * - * The entry's `skillId` shadows the base config's `skillId` at dispatch - * time, so one shared config (e.g. the generic `agent-skill` program) can - * back many manifest entries by skill id. - * - * Only `role: 'command'` entries become commands. `skill` and `internal` - * entries are reachable through different paths (`wizard skill `, - * `--skill=`) and throw if passed here. - */ -export function skillCommandFactory( - entry: CliManifestEntry, - config: ProgramConfig, - opts: SkillCommandFactoryOpts = {}, -): Command { - if (entry.role !== 'command') { - throw new Error( - `skillCommandFactory: entry "${entry.skillId}" has role "${entry.role}" — only "command" entries become commands`, - ); - } - if (!entry.command) { - throw new Error( - `skillCommandFactory: entry "${entry.skillId}" is missing \`command\` — context-mill must declare a name for every command entry`, - ); - } - const dispatchConfig: ProgramConfig = { - ...config, - skillId: entry.skillId, - }; - return { - name: entry.command, - description: entry.description, - options: mergeCommandOptions(dispatchConfig), - children: opts.children, - handler: (argv) => dispatchProgram(dispatchConfig, argv), - // The manifest's `recommended` flag feeds the family picker's `default` - // (pre-highlighted) slot — two different names, one bridge. - default: entry.recommended, - }; -} diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 822b01bb..e7389efc 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -1,19 +1,16 @@ import { migrationConfig } from '@lib/programs/migration/index'; import type { Command } from './command'; -import { flatSkillCommand } from './factories/flat-skill-command'; +import { nativeCommandFactory } from './factories/native-command-factory'; /** - * `wizard migrate` — flat skill command, Statsig today. Stays flat - * while there's only one vendor. When a second vendor lands, this file - * restructures into a family (parentCommand: migrate, command per - * vendor) and the picker opens — a deliberate breaking change at that - * point, not silent magic introduced now. + * `wizard migrate` — flat skill command, Statsig today. * - * Resolved from the manifest by skillId; falls back to the built-in - * config if the snapshot is missing the entry (see flatSkillCommand). + * 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 = flatSkillCommand( - 'migrate-statsig', - migrationConfig, -); +export const migrateCommand: Command = nativeCommandFactory(migrationConfig); diff --git a/src/commands/revenue.ts b/src/commands/revenue.ts index b367283e..a41f6af0 100644 --- a/src/commands/revenue.ts +++ b/src/commands/revenue.ts @@ -1,20 +1,14 @@ import { revenueAnalyticsConfig } from '@lib/programs/revenue-analytics/index'; import type { Command } from './command'; -import { flatSkillCommand } from './factories/flat-skill-command'; +import { nativeCommandFactory } from './factories/native-command-factory'; /** - * `wizard revenue-analytics` — flat skill command, Stripe today. Stays - * flat while there's only one provider. When a second provider lands, - * this file restructures into a family (parentCommand: - * revenue-analytics, command per vendor) and the picker opens — a - * deliberate breaking change at that point, not silent magic - * introduced now. + * `wizard revenue-analytics` — flat skill command, Stripe today. * - * Resolved from the manifest by skillId; falls back to the built-in - * config if the snapshot is missing the entry (see flatSkillCommand). + * Stays flat while there's only one provider. Restructure into a family + * if/when a second provider lands. */ -export const revenueCommand: Command = flatSkillCommand( - 'revenue-analytics-setup', +export const revenueCommand: Command = nativeCommandFactory( revenueAnalyticsConfig, ); diff --git a/src/commands/skill.ts b/src/commands/skill.ts index 28406115..229380f6 100644 --- a/src/commands/skill.ts +++ b/src/commands/skill.ts @@ -1,8 +1,8 @@ +import type { Arguments } from 'yargs'; + import { agentSkillConfig } from '@lib/programs/program-registry'; -import { - CLI_MANIFEST, - type CliManifestEntry, -} from '@lib/programs/cli-manifest.generated'; +import { getSkillsBaseUrl } from '@lib/constants'; +import { fetchSkillMenu, type CliEntry } from '@lib/wizard-tools'; import { dispatchProgram, mergeCommandOptions } from './factories/shared'; import type { Command } from './command'; @@ -11,25 +11,34 @@ import type { Command } from './command'; * `wizard skill` — list the runnable skills in the catalog. * `wizard skill ` — run that one skill. * - * Two forms, nothing else: bare lists, named runs. The listing is the menu; - * the name picks from it. Anything you see listed, you can run by name. - * - * Running dispatches the generic `agent-skill` program with the chosen skill - * id, so any catalogued skill is runnable without being promoted to a - * top-level command of its own. - * - * Catalog source is the build-time `CLI_MANIFEST` snapshot. `internal` skills - * are kept out of both the listing and the runnable set (reachable only via - * the hidden `--skill=` dev escape hatch), mirroring how internal flags - * stay out of `--help`. + * The catalog is fetched live from `skill-menu.json` each invocation — + * no baked snapshot. `internal` skills are excluded from both the listing + * and the runnable set; they're reachable only via the hidden + * `--skill=` dev escape hatch. */ -const BROWSABLE_ROLES = new Set(['command', 'skill']); +const BROWSABLE_ROLES: ReadonlySet = new Set([ + 'command', + 'skill', +]); + +async function fetchBrowsableEntries( + argv: Arguments, +): Promise { + const skillsBaseUrl = getSkillsBaseUrl(Boolean(argv['local-mcp'])); + const menu = await fetchSkillMenu(skillsBaseUrl); + if (!menu) return null; + return (menu.cliEntries ?? []).filter((e) => BROWSABLE_ROLES.has(e.role)); +} -function browsableEntries(): CliManifestEntry[] { - return CLI_MANIFEST.entries.filter((e) => BROWSABLE_ROLES.has(e.role)); +function failFetch(): never { + 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); } -function formatEntry(entry: CliManifestEntry): string { +function formatEntry(entry: CliEntry): string { const path = entry.parentCommand ? `wizard ${entry.parentCommand} ${entry.command}` : entry.command @@ -40,11 +49,9 @@ function formatEntry(entry: CliManifestEntry): string { }`; } -function printEntries(entries: readonly CliManifestEntry[]): void { +function printEntries(entries: readonly CliEntry[]): void { if (entries.length === 0) { - process.stdout.write( - 'No skills found. The CLI manifest may not have been fetched yet — run a build with network access.\n', - ); + process.stdout.write('No skills found.\n'); return; } process.stdout.write( @@ -68,20 +75,24 @@ const searchCommand: Command = { }, }, handler: (argv) => { - const query = String(argv.query ?? '').toLowerCase(); - if (!query) { - process.stdout.write('No query provided.\n'); - return; - } - const matches = browsableEntries().filter( - (entry) => - entry.skillId.toLowerCase().includes(query) || - entry.displayName.toLowerCase().includes(query) || - entry.description.toLowerCase().includes(query) || - (entry.command?.toLowerCase().includes(query) ?? false) || - (entry.parentCommand?.toLowerCase().includes(query) ?? false), - ); - printEntries(matches); + void (async () => { + const query = String(argv.query ?? '').toLowerCase(); + if (!query) { + process.stdout.write('No query provided.\n'); + return; + } + const entries = await fetchBrowsableEntries(argv); + if (!entries) failFetch(); + const matches = entries.filter( + (entry) => + entry.skillId.toLowerCase().includes(query) || + entry.displayName.toLowerCase().includes(query) || + entry.description.toLowerCase().includes(query) || + (entry.command?.toLowerCase().includes(query) ?? false) || + (entry.parentCommand?.toLowerCase().includes(query) ?? false), + ); + printEntries(matches); + })(); }, }; @@ -97,30 +108,25 @@ export const skillCommand: Command = { }, }, handler: (argv) => { - // The name the user types is the skill's name straight from the listing - // (its context-mill skill id, e.g. `audit-events`). There's no separate - // "id" — name and id are the same readable string here. - const name = (argv.name as string | undefined)?.trim(); + void (async () => { + const name = (argv.name as string | undefined)?.trim(); + const entries = await fetchBrowsableEntries(argv); + if (!entries) failFetch(); - // Bare `wizard skill` — list the runnable catalog so the user sees what - // they can run, then exit. No TUI, no agent run. - if (!name) { - printEntries(browsableEntries()); - return; - } + if (!name) { + printEntries(entries); + return; + } - // `wizard skill ` — run that skill, but only if the catalog knows - // it. `skill` is a user-facing surface, so we refuse unknown (or internal) - // names rather than handing an arbitrary string to the agent runner. You - // can run anything the bare listing shows; nothing else. - const runnable = browsableEntries().some((entry) => entry.skillId === name); - if (!runnable) { - process.stderr.write( - `\x1b[1;91m✖ Unknown skill "${name}".\x1b[0m Run \`wizard skill\` to see what you can run.\n`, - ); - process.exit(1); - } + const runnable = entries.some((entry) => entry.skillId === name); + if (!runnable) { + process.stderr.write( + `\x1b[1;91m✖ Unknown skill "${name}".\x1b[0m Run \`wizard skill\` to see what you can run.\n`, + ); + process.exit(1); + } - dispatchProgram({ ...agentSkillConfig, skillId: name }, argv); + dispatchProgram({ ...agentSkillConfig, skillId: name }, argv); + })(); }, }; diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 8c3be73e..3b3e6bb5 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -699,14 +699,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 36dcec09..4a1baa37 100644 --- a/src/lib/programs/__tests__/program-registry.test.ts +++ b/src/lib/programs/__tests__/program-registry.test.ts @@ -23,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', + ); }); }); @@ -33,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(); } 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..2e0f075d --- /dev/null +++ b/src/lib/programs/dispatch-family.ts @@ -0,0 +1,133 @@ +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) { + process.stderr.write( + `\n\x1b[1;91m✖ \`wizard ${family}\` requires a subcommand.\x1b[0m\n` + + ` Run \`wizard ${family}\` with no argument to open the picker.\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.recommended, + })); + return [...natives, ...live]; +} diff --git a/src/lib/programs/program-registry.ts b/src/lib/programs/program-registry.ts index a76a4653..b4b5fde7 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -16,7 +16,6 @@ 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'; @@ -65,7 +64,6 @@ export const PROGRAM_REGISTRY = [ errorTrackingUploadSourceMapsConfig, auditConfig, eventsAuditConfig, - audit3000Config, posthogDoctorConfig, webAnalyticsDoctorConfig, migrationConfig, @@ -87,7 +85,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 285eb326..f235e36f 100644 --- a/src/lib/programs/program-step.ts +++ b/src/lib/programs/program-step.ts @@ -198,8 +198,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[]; /** 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..c2cc6856 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; + recommended?: 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 de41a818..ba0f02ba 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 { RunScreen } from './screens/RunScreen.js'; @@ -81,9 +78,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 0e042c5d..f3e68e3f 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -24,9 +24,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 8134ef93..00000000 --- a/src/ui/tui/screens/audit-3000/Audit3000ChecksPanel.tsx +++ /dev/null @@ -1,171 +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 e4aedac5..00000000 --- a/src/ui/tui/screens/audit-3000/Audit3000IntroScreen.tsx +++ /dev/null @@ -1,163 +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, -]; From b050e24d3baee5cea39e737e7ef1ac2122cf1766 Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Fri, 12 Jun 2026 09:47:26 -0400 Subject: [PATCH 06/11] chore: regenerate pnpm-lock.yaml after dropping ajv CI's `pnpm install --frozen-lockfile` rejected the previous push because the lockfile still pinned ajv@^8.20.0 as a direct dep even though package.json had already removed it. The remaining ajv entries are now transitive (via warlock and zod-to-json-schema), which is fine. Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5473eb56..d78cbd1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,9 +132,6 @@ importers: '@typescript-eslint/parser': specifier: ^5.13.0 version: 5.62.0(eslint@8.57.1)(typescript@5.7.3) - ajv: - specifier: ^8.20.0 - version: 8.20.0 babel-jest: specifier: ^29.7.0 version: 29.7.0(@babel/core@7.29.0) From 38ea54febad0d84b3904e27373a8a0ec45baf374 Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Tue, 16 Jun 2026 18:54:26 -0400 Subject: [PATCH 07/11] =?UTF-8?q?docs(cli):=20document=20old=E2=86=92new?= =?UTF-8?q?=20command=20mapping=20(README=20+=20AGENTS.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the command-overhaul mapping table to the README and a new AGENTS.md for agents working in the repo. Covers the command-vs-program distinction and where the command surface is defined. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 42 +++++++++++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1ac87004 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# AGENTS.md — PostHog Wizard + +Guidance for AI agents working in this repo. For human contributor docs see +[`README.md`](README.md); for architecture and design discipline see +[`CLAUDE.md`](CLAUDE.md) and `.claude/skills/wizard-development/SKILL.md`. + +## 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 two 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 [skill]` | now a family; `audit all` = comprehensive | +| `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 | + +### 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) + +Mirror the existing pattern: 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. + +## Before you change the command surface + +Read `.claude/skills/wizard-development/SKILL.md`. Keep product knowledge out of +infrastructure code — commands dispatch to typed `ProgramConfig`s; they don't +embed PostHog-specific logic. + +## Verify changes + +```bash +pnpm build && pnpm test && pnpm fix +``` diff --git a/README.md b/README.md index 35f3e66a..dd5a5428 100644 --- a/README.md +++ b/README.md @@ -37,16 +37,35 @@ 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 +npx @posthog/wizard audit + +# Run a specific audit +npx @posthog/wizard audit events # event instrumentation +npx @posthog/wizard audit web-analytics # web analytics setup +npx @posthog/wizard audit all # comprehensive audit +``` + +Subcommands resolve at runtime from the published skill registry, so new audits +appear without a wizard release. + ## 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) @@ -157,6 +176,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 ``` +# 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 [skill]` | Now a family; `wizard audit all` runs the comprehensive audit | +| `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 From 90c4dbfd06a71685c4052d122a2d15e3d5799c2a Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Tue, 16 Jun 2026 19:17:13 -0400 Subject: [PATCH 08/11] docs: make AGENTS.md the single source of truth; CLAUDE.md points to it Consolidates the repo guidance into AGENTS.md (the cross-tool standard so all agents read it) and reduces CLAUDE.md to an `@AGENTS.md` import. Avoids drift between two overlapping instruction files. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++------- CLAUDE.md | 86 +++-------------------------------------------------- 2 files changed, 82 insertions(+), 93 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ac87004..d93b4388 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,49 @@ # AGENTS.md — PostHog Wizard -Guidance for AI agents working in this repo. For human contributor docs see -[`README.md`](README.md); for architecture and design discipline see -[`CLAUDE.md`](CLAUDE.md) and `.claude/skills/wizard-development/SKILL.md`. +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 two are kept as aliases. +command names.** Old names mostly no longer exist — only some are kept as aliases. | Old command | New command | Status | |---|---|---| @@ -44,20 +80,51 @@ command names.** Old names mostly no longer exist — only two are kept as alias ### Adding a command alias (keep an old name working) -Mirror the existing pattern: give the `Command.name` an array of -`[newName, ...legacyNames]`. yargs treats the extra entries as aliases. See +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. -## Before you change the command surface +## Commands -Read `.claude/skills/wizard-development/SKILL.md`. Keep product knowledge out of -infrastructure code — commands dispatch to typed `ProgramConfig`s; they don't -embed PostHog-specific logic. +```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 +``` -## Verify 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 From a3582922683e49c25c4234bfe106122dd14b8687 Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Tue, 16 Jun 2026 19:32:46 -0400 Subject: [PATCH 09/11] refactor(cli): read cli.default (was cli.recommended) from the manifest Context-mill renamed the cli: block's `recommended` field to `default`. Update CliEntry and the family dispatcher to read `entry.default`, and align the doc comments. The wizard's Command.default field name is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/command.ts | 4 ++-- src/commands/factories/family-command-factory.ts | 2 +- src/lib/programs/dispatch-family.ts | 2 +- src/lib/wizard-tools.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/command.ts b/src/commands/command.ts index 3f7b0216..4f7c4c1c 100644 --- a/src/commands/command.ts +++ b/src/commands/command.ts @@ -51,11 +51,11 @@ export interface Command { */ interactiveDefault?: (argv: Arguments) => void | Promise; /** - * When true, this child is the "recommended" leaf in its family: the + * 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 `recommended` flag through `skillCommandFactory`. + * entry's `default` flag through `skillCommandFactory`. */ default?: boolean; } diff --git a/src/commands/factories/family-command-factory.ts b/src/commands/factories/family-command-factory.ts index 2a45eba1..cc09d4d9 100644 --- a/src/commands/factories/family-command-factory.ts +++ b/src/commands/factories/family-command-factory.ts @@ -33,7 +33,7 @@ export interface FamilyCommandFactoryOpts { * `skill-menu.json`. Unknown subs error with the available list. * - `wizard ` (no positional) — `interactiveDefault` fetches the * registry, builds a children list combining native + live entries, and - * opens the family picker. The recommended leaf (if any) is + * opens the family picker. The default leaf (if any) is * pre-highlighted. * * No static yargs children. New skill-backed subcommands appear after a diff --git a/src/lib/programs/dispatch-family.ts b/src/lib/programs/dispatch-family.ts index 2e0f075d..d00b8160 100644 --- a/src/lib/programs/dispatch-family.ts +++ b/src/lib/programs/dispatch-family.ts @@ -127,7 +127,7 @@ export function buildFamilyPickerChildren( skill: entry.command, } as Arguments); }, - default: entry.recommended, + default: entry.default, })); return [...natives, ...live]; } diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index c2cc6856..7b2f6693 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -55,7 +55,7 @@ export type CliEntry = { role: 'command' | 'skill' | 'internal'; command?: string; parentCommand?: string; - recommended?: boolean; + default?: boolean; displayName: string; description: string; }; From 22f3955b1e637af19b44be5c0d7d6c59803b8a7d Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Tue, 16 Jun 2026 20:52:24 -0400 Subject: [PATCH 10/11] fix(cli): bare `wizard audit` opens the picker (events-only for now) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare `wizard audit` errored ("requires a subcommand") because the family handler always ran dispatchFamily, which rejects an empty subcommand — the interactiveDefault picker was never reached. Route the no-subcommand case to the picker in an interactive terminal; keep the error in non-TTY/CI so we don't hang on an Ink picker that can't render. The picker shows only the default leaf (`audit events`) for now via the new `pickerChildrenToShow` helper; the other audit subcommands stay runnable directly (`wizard audit `). Adds a test locking in that behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/programs-cli.test.ts | 30 +++++++++++- .../factories/family-command-factory.ts | 46 +++++++++++++------ src/commands/factories/family-picker.tsx | 12 +++-- src/lib/programs/dispatch-family.ts | 18 +++++++- 4 files changed, 84 insertions(+), 22 deletions(-) diff --git a/src/__tests__/programs-cli.test.ts b/src/__tests__/programs-cli.test.ts index 5dd82332..0cce2aec 100644 --- a/src/__tests__/programs-cli.test.ts +++ b/src/__tests__/programs-cli.test.ts @@ -19,7 +19,11 @@ import { auditCommand } from '../commands/audit'; import { migrateCommand } from '../commands/migrate'; import { revenueCommand } from '../commands/revenue'; import { uploadSourcemapsCommand } from '../commands/upload-sourcemaps'; -import { dispatchFamily } from '@lib/programs/dispatch-family'; +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'; @@ -191,3 +195,27 @@ describe('yargs parsing for the audit family', () => { 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/commands/factories/family-command-factory.ts b/src/commands/factories/family-command-factory.ts index cc09d4d9..95f9a4ba 100644 --- a/src/commands/factories/family-command-factory.ts +++ b/src/commands/factories/family-command-factory.ts @@ -4,6 +4,7 @@ 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'; @@ -31,10 +32,11 @@ export interface FamilyCommandFactoryOpts { * - `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) — `interactiveDefault` fetches the - * registry, builds a children list combining native + live entries, and - * opens the family picker. The default leaf (if any) is - * pre-highlighted. + * - `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 @@ -45,6 +47,20 @@ export function familyCommandFactory({ 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, @@ -56,17 +72,17 @@ export function familyCommandFactory({ }, }, handler: (argv: Arguments) => { - void dispatchFamily(family, argv); - }, - interactiveDefault: async (argv: Arguments) => { - const skillsBaseUrl = getSkillsBaseUrl(Boolean(argv['local-mcp'])); - const menu = await fetchSkillMenu(skillsBaseUrl); - const children = buildFamilyPickerChildren( - family, - menu?.cliEntries ?? [], - ); - const picker = createFamilyPickerDefault(`wizard ${family}`, children); - await picker(argv); + 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 index e6b934c2..3f14baab 100644 --- a/src/commands/factories/family-picker.tsx +++ b/src/commands/factories/family-picker.tsx @@ -6,10 +6,12 @@ * `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 always opens for families; the `default` flag on a child - * just controls which option is pre-highlighted (so `wizard audit` → - * Enter still runs `audit all`, but the user sees every other audit - * before committing). Discovery and consent in one extra keystroke. + * 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` @@ -64,7 +66,7 @@ function describe(child: Command): string { * 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 comprehensive audit by default. + * `wizard audit`" run the default leaf (today `audit events`). * * Exported for testability — the ordering logic stays pure and * inspectable without mounting Ink. diff --git a/src/lib/programs/dispatch-family.ts b/src/lib/programs/dispatch-family.ts index d00b8160..f587f675 100644 --- a/src/lib/programs/dispatch-family.ts +++ b/src/lib/programs/dispatch-family.ts @@ -59,9 +59,11 @@ export async function dispatchFamily( ): 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` + - ` Run \`wizard ${family}\` with no argument to open the picker.\n\n`, + ` Pass one (e.g. \`wizard ${family} \`), or run it in an interactive terminal to pick from a menu.\n\n`, ); process.exit(1); } @@ -131,3 +133,17 @@ export function buildFamilyPickerChildren( })); 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]; +} From a1b189c0c2488ba56e0a804f0f8d289ca3816dac Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Tue, 16 Jun 2026 20:52:24 -0400 Subject: [PATCH 11/11] docs(cli): list audit subcommands and clarify commands vs. skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List every audit subcommand in README/AGENTS.md, add a "run a single skill" section, and explain that `wizard audit ` chooses an audit area — it does not take a skill name (subcommands are skills promoted to commands; `wizard skill ` runs un-promoted ones). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 28 +++++++++++++++++++++++++++- README.md | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d93b4388..68189191 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,11 +49,37 @@ command names.** Old names mostly no longer exist — only some are kept as alia |---|---|---| | `wizard integrate` | `wizard` (default flow) | command removed | | `wizard events-audit` | `wizard audit events` | moved into `audit` family | -| `wizard audit` (single) | `wizard audit [skill]` | now a family; `audit all` = comprehensive | +| `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. diff --git a/README.md b/README.md index b7bd27dc..510923c1 100644 --- a/README.md +++ b/README.md @@ -56,17 +56,38 @@ Audit an existing PostHog integration for correctness and best practices. The interactive picker, or pass a subcommand directly: ```bash -# Interactive picker +# Interactive picker (Enter runs `events`, the default) npx @posthog/wizard audit -# Run a specific audit -npx @posthog/wizard audit events # event instrumentation -npx @posthog/wizard audit web-analytics # web analytics setup -npx @posthog/wizard audit all # comprehensive 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 ``` -Subcommands resolve at runtime from the published skill registry, so new audits -appear without a wizard release. +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 @@ -198,7 +219,7 @@ surface. If you used an older command, here's where it went: |---|---|---| | `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 [skill]` | Now a family; `wizard audit all` runs the comprehensive audit | +| `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 |