From 9412b45712c7369bd1c2566b00892eb1666c459b Mon Sep 17 00:00:00 2001 From: eandreeva-twr Date: Fri, 24 Jul 2026 08:37:45 +0200 Subject: [PATCH 01/10] Integrate & migrate commands --- package.json | 1 + pnpm-lock.yaml | 47 ++++++ src/commands/integrate.ts | 72 +++++++++ src/commands/migrate.ts | 144 +++++++++++++++++ src/lib/agent/actions/integrate.ts | 25 +++ src/lib/agent/actions/migrate.ts | 57 +++++++ src/lib/agent/drivers.ts | 226 ++++++++++++++++++++++++++ src/lib/agent/prompt.ts | 116 +++++++++++++ src/lib/agent/run.ts | 131 +++++++++++++++ src/lib/agent/skill-source.ts | 49 ++++++ src/lib/agent/skills-install.ts | 20 +++ src/lib/agent/telemetry.ts | 59 +++++++ src/lib/agent/wizard.ts | 208 ++++++++++++++++++++++++ src/lib/project/billing.ts | 83 ++++++++++ src/lib/project/revenuecat.ts | 251 +++++++++++++++++++++++++++++ src/lib/project/scan.ts | 89 ++++++++++ src/lib/ui/ask.ts | 102 ++++++++++++ src/lib/ui/clipboard.ts | 17 ++ 18 files changed, 1697 insertions(+) create mode 100644 src/commands/integrate.ts create mode 100644 src/commands/migrate.ts create mode 100644 src/lib/agent/actions/integrate.ts create mode 100644 src/lib/agent/actions/migrate.ts create mode 100644 src/lib/agent/drivers.ts create mode 100644 src/lib/agent/prompt.ts create mode 100644 src/lib/agent/run.ts create mode 100644 src/lib/agent/skill-source.ts create mode 100644 src/lib/agent/skills-install.ts create mode 100644 src/lib/agent/telemetry.ts create mode 100644 src/lib/agent/wizard.ts create mode 100644 src/lib/project/billing.ts create mode 100644 src/lib/project/revenuecat.ts create mode 100644 src/lib/project/scan.ts create mode 100644 src/lib/ui/ask.ts create mode 100644 src/lib/ui/clipboard.ts diff --git a/package.json b/package.json index 833423e..a87fb37 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "url": "https://github.com/adaptyteam/adapty-cli/issues" }, "dependencies": { + "@clack/prompts": "^1.7.0", "@oclif/core": "^4", "@oclif/plugin-help": "^6", "open": "^11.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa4d7c7..d9a4811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@clack/prompts': + specifier: ^1.7.0 + version: 1.7.0 '@oclif/core': specifier: ^4 version: 4.8.0 @@ -247,6 +250,14 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1652,6 +1663,15 @@ packages: fast-levenshtein@3.0.0: resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fast-xml-parser@5.3.6: resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true @@ -2594,6 +2614,9 @@ packages: sinon@21.0.2: resolution: {integrity: sha512-VHV4UaoxIe5jrMd89Y9duI76T5g3Lp+ET+ctLhLDaZtSznDPah1KKpRElbdBV4RwqWSw2vadFiVs9Del7MbVeQ==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -3443,6 +3466,18 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -5218,6 +5253,16 @@ snapshots: dependencies: fastest-levenshtein: 1.0.16 + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 @@ -6186,6 +6231,8 @@ snapshots: diff: 8.0.3 supports-color: 7.2.0 + sisteransi@1.0.5: {} + snake-case@3.0.4: dependencies: dot-case: 3.0.4 diff --git a/src/commands/integrate.ts b/src/commands/integrate.ts new file mode 100644 index 0000000..c28aeb4 --- /dev/null +++ b/src/commands/integrate.ts @@ -0,0 +1,72 @@ +import {Command, Flags} from '@oclif/core' +import {resolve} from 'node:path' + +import {integrateAction} from '../lib/agent/actions/integrate.js' +import {emitCopyPrompt, reportActionFailure, runActionWithFollowUp} from '../lib/agent/run.js' +import {preparePromptContext, prepareWizard} from '../lib/agent/wizard.js' +import {billingLabel, detectBilling} from '../lib/project/billing.js' +import {confirm, select} from '../lib/ui/ask.js' + +export default class Integrate extends Command { + static description = 'Set up the Adapty SDK in your app using your coding agent (Claude Code or Codex)' +static examples = [ + '<%= config.bin %> integrate', + '<%= config.bin %> integrate --path ./apps/mobile', + '<%= config.bin %> integrate --copy', + ] +static flags = { + app: Flags.string({description: 'Adapty app ID (UUID) to connect; skips the app picker'}), + copy: Flags.boolean({ + description: 'Print the integration prompt instead of running an agent (paste it into any coding agent)', + }), + driver: Flags.string({description: 'Force a specific coding agent', options: ['claude', 'codex']}), + path: Flags.string({description: 'App directory (defaults to the current directory)'}), + } + + async run(): Promise { + const {flags} = await this.parse(Integrate) + const path = resolve(flags.path ?? process.cwd()) + + const setup = await prepareWizard(this, {...flags, path}) + if (!setup) return + const {driver, interactive, project, token} = setup + + const billing = await detectBilling(path) + if (billing) { + this.log( + `Found ${billingLabel(billing)} in this project - \`adapty migrate\` is built for replacing it. Continuing with a fresh integration.`, + ) + } + + // Paywall approach - the one product question the skill needs answered upfront. + const approach = await select( + 'How do you want to build paywalls?', + [ + {hint: 'no-code visual editor, recommended', label: 'Flow Builder', value: 'flow_builder'}, + {hint: 'you build the UI, Adapty handles products & purchases', label: 'Custom paywall', value: 'custom'}, + {hint: 'keep existing purchase code, Adapty only tracks', label: 'Observer mode', value: 'observer'}, + ], + 'flow_builder', + ) + if (!approach) return this.log('Cancelled.') + + const promptCtx = await preparePromptContext(setup, approach) + + if (flags.copy) { + return emitCopyPrompt(this, integrateAction, promptCtx) + } + + if (interactive && !(await confirm(`Integrate the Adapty SDK into "${project.name}" now?`))) { + return this.log('No problem - run `adapty integrate` again anytime, or use --copy to drive your own agent.') + } + + const result = await runActionWithFollowUp(this, { + action: integrateAction, + ctx: promptCtx, + driver: driver!, + env: token ? {ADAPTY_TOKEN: token} : undefined, + interactive, + }) + if (!result.ok) reportActionFailure(this, driver!, result) + } +} diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts new file mode 100644 index 0000000..a6dec5a --- /dev/null +++ b/src/commands/migrate.ts @@ -0,0 +1,144 @@ +import {Command, Flags} from '@oclif/core' +import {execFile} from 'node:child_process' +import {resolve} from 'node:path' +import {promisify} from 'node:util' + +import {buildMigrateAction} from '../lib/agent/actions/migrate.js' +import {emitCopyPrompt, reportActionFailure, runActionWithFollowUp} from '../lib/agent/run.js' +import {preparePromptContext, prepareWizard} from '../lib/agent/wizard.js' +import {BILLING_LABELS, type BillingId, billingLabel, detectBilling} from '../lib/project/billing.js' +import {fetchRcCatalog, renderRcCatalog} from '../lib/project/revenuecat.js' +import {confirm, select, spinner} from '../lib/ui/ask.js' + +export default class Migrate extends Command { + static description = 'Migrate your app from RevenueCat, Superwall, or Qonversion to Adapty using your coding agent' +static examples = [ + '<%= config.bin %> migrate', + '<%= config.bin %> migrate --path ./apps/mobile', + '<%= config.bin %> migrate --copy', + ] +static flags = { + app: Flags.string({description: 'Adapty app ID (UUID) to connect; skips the app picker'}), + copy: Flags.boolean({ + description: 'Print the migration prompt instead of running an agent (paste it into any coding agent)', + }), + driver: Flags.string({description: 'Force a specific coding agent', options: ['claude', 'codex']}), + path: Flags.string({description: 'App directory (defaults to the current directory)'}), + 'rc-key': Flags.string({ + description: + 'RevenueCat v2 secret API key - pulls your RC catalog (entitlements, products, offerings) so entities are recreated exactly instead of guessed from code', + }), + } + + async run(): Promise { + const {flags} = await this.parse(Migrate) + const path = resolve(flags.path ?? process.cwd()) + + const setup = await prepareWizard(this, {...flags, path}) + if (!setup) return + const {driver, interactive, project, token} = setup + + // With an RC key the source is a given; otherwise auto-detect and let the user correct. + let providerLabel = 'RevenueCat' + let rcCatalog: string | undefined + if (flags['rc-key']) { + const rcSpin = spinner() + rcSpin.start('Pulling your RevenueCat catalog') + const catalog = await fetchRcCatalog(flags['rc-key']) + if (catalog) { + rcSpin.stop( + `RevenueCat catalog loaded: "${catalog.projectName}" - ${catalog.entitlements.length} entitlement(s), ${catalog.products.length} product(s), ${catalog.offerings.length} offering(s)`, + ) + if (!catalog.complete) { + this.warn( + 'Some RevenueCat requests failed or were truncated - the catalog may be incomplete. The agent is told to flag this in ADAPTY_SETUP.md.', + ) + } + + rcCatalog = renderRcCatalog(catalog) + } else { + rcSpin.stop('Could not read the RevenueCat catalog') + this.error( + 'The RevenueCat API rejected the key or returned no project. Use a v2 SECRET key (sk_...) with read scopes for projects, apps, entitlements, products, and offerings.', + ) + } + } else { + const detected = await detectBilling(path) + if (detected) this.log(`Detected ${billingLabel(detected)} in this project`) + if (interactive) { + const choice = await select( + 'What are you migrating from?', + [ + {label: 'RevenueCat', value: 'revenuecat'}, + {label: 'Superwall', value: 'superwall'}, + {label: 'Qonversion', value: 'qonversion'}, + {hint: 'in_app_purchase, react-native-iap, Unity IAP, ...', label: 'A store plugin', value: 'store-plugin'}, + {hint: 'hand-rolled StoreKit / Play Billing', label: 'Custom store code', value: 'native-store'}, + ], + detected?.id, + ) + if (!choice) return this.log('Cancelled.') + // Prefer the detected detail ("the in_app_purchase plugin") when the user confirms the detected kind. + providerLabel = detected && detected.id === choice ? billingLabel(detected) : BILLING_LABELS[choice as BillingId] + if (choice === 'revenuecat') { + this.log( + 'Tip: pass --rc-key to recreate your RC entitlements, products, and offerings exactly.', + ) + } + } else { + // Headless: never dead-end on a missing answer - fall back to a generic label. + providerLabel = detected ? billingLabel(detected) : 'the current billing SDK' + } + } + + const approach = await select( + 'How do you want to build paywalls in Adapty?', + [ + {hint: 'no-code visual editor, recommended', label: 'Flow Builder', value: 'flow_builder'}, + {hint: 'you build the UI, Adapty handles products & purchases', label: 'Custom paywall', value: 'custom'}, + ], + 'flow_builder', + ) + if (!approach) return this.log('Cancelled.') + + const promptCtx = await preparePromptContext(setup, approach) + const action = buildMigrateAction(providerLabel, rcCatalog) + + if (flags.copy) { + return emitCopyPrompt(this, action, promptCtx) + } + + // A migration rewrites many files - a clean tree makes it reviewable and revertable. + if (await hasDirtyWorkingTree(path)) { + this.warn('This project has uncommitted changes. Commit or stash them so `git diff` shows only the migration.') + if (interactive && !(await confirm('Proceed on the dirty working tree anyway?', false))) { + return this.log('Commit your changes and re-run `adapty migrate`.') + } + } + + if (interactive && !(await confirm(`Migrate "${project.name}" from ${providerLabel} to Adapty now?`))) { + return this.log('No problem - run `adapty migrate` again anytime, or use --copy to drive your own agent.') + } + + const result = await runActionWithFollowUp(this, { + action, + ctx: promptCtx, + driver: driver!, + env: token ? {ADAPTY_TOKEN: token} : undefined, + interactive, + }) + if (!result.ok) reportActionFailure(this, driver!, result) + } +} + +const execFileAsync = promisify(execFile) + +/** Uncommitted changes present? Outside a git repo -> false (nothing to protect). */ +async function hasDirtyWorkingTree(dir: string): Promise { + try { + const {stdout} = await execFileAsync('git', ['-C', dir, 'status', '--porcelain'], {timeout: 5000}) + return stdout.trim().length > 0 + } catch { + return false + } +} diff --git a/src/lib/agent/actions/integrate.ts b/src/lib/agent/actions/integrate.ts new file mode 100644 index 0000000..071ec5a --- /dev/null +++ b/src/lib/agent/actions/integrate.ts @@ -0,0 +1,25 @@ +import type {AgentAction, PromptContext} from '../prompt.js' + +export const integrateAction: AgentAction = { + id: 'integrate', + task(ctx: PromptContext): string { + const {appId, cliCommand, paywallApproach, platformReference} = ctx + return `You are integrating the Adapty SDK into the user's app, end-to-end: + +1. Dashboard setup via \`${cliCommand}\` (app already exists - see context): + a. Get the access level ID: \`access-levels list --app ${appId || ''} --json\` (default is usually "premium"). + b. Products: run \`products list\` first. Create products ONLY when you know their real store product IDs (from the code, a .storekit file, or store config found in the project) - store IDs are immutable after creation, so a guessed ID is unfixable junk. Real IDs unknown -> create no products; put the ready-to-run \`products create\` commands in ADAPTY_SETUP.md instead (Android subscriptions also need --android-base-plan-id, and Google Play product IDs can only exist after an AAB with billing permission is uploaded). + c. ${ + paywallApproach === 'flow_builder' + ? 'Flow Builder flows are dashboard-only and can NOT be created from the CLI. Create the placement(s) only (`placements create --app --title "Main" --developer-id "main" --audiences \'[]\'`), and add to ADAPTY_SETUP.md: create a flow at https://app.adapty.io/flows and attach it to the placement(s).' + : 'Create a paywall and a placement: `paywalls create --app --title "Main Paywall" --json`, then `placements create --app --title "Main" --developer-id "main" --audiences \'[{"segment_ids":[],"paywall_id":"","priority":0}]\'`.' + } +2. Implement the SDK following the platform playbook below, stage by stage, fetching the listed docs pages before writing each stage's code. +3. If a build command exists for this project, build to verify it compiles. Fix what you broke; do not chase pre-existing failures. + +--- PLATFORM PLAYBOOK (from the adapty-sdk-integration skill) --- + +${platformReference}` + }, + title: 'integration', +} diff --git a/src/lib/agent/actions/migrate.ts b/src/lib/agent/actions/migrate.ts new file mode 100644 index 0000000..3795ebd --- /dev/null +++ b/src/lib/agent/actions/migrate.ts @@ -0,0 +1,57 @@ +import type {AgentAction, PromptContext} from '../prompt.js' + +/** + * Migration reuses the integration playbook (same skill) - what differs is + * the mission: map every call site of the current billing system to its + * Adapty equivalent instead of integrating from scratch. `label` names what + * is being replaced ("RevenueCat", "the in_app_purchase plugin", ...); + * `rcCatalog` is a rendered snapshot of the RevenueCat project (from + * --rc-key) that upgrades placeholder guessing into exact recreation. + * + * The mapping rules mirror the dashboard's RC import wizard: entities that + * do not map cleanly are NOT created - junk in the user's account is worse + * than a skipped entity. Everything skipped lands in ADAPTY_SETUP.md. + */ +export function buildMigrateAction(label: string, rcCatalog?: string): AgentAction { + return { + id: 'migrate', + task(ctx: PromptContext): string { + const {appId, cliCommand, platformReference} = ctx + return `You are migrating the user's app from ${label} to Adapty - Adapty fully replaces ${label}. + +1. Map the existing setup. Find every call site of ${label}: SDK init, user identification, paywall/product fetching, purchase & restore, entitlement/subscription checks, event listeners.${ + rcCatalog + ? ' The full RevenueCat catalog is provided below - treat it as ground truth; use the code only to learn which entities the app actually calls.' + : ' Extract the REAL store product IDs and entitlement names from the code and config - use them below instead of placeholders wherever they exist.' + } + +2. Dashboard setup via \`${cliCommand}\` (app already exists - see context; scope every command with --app ${appId || ''}). + + +The prime rule: an entity that does not map cleanly must NOT be created. Creating junk in the user's Adapty account is worse than skipping - when in doubt, create nothing and describe exactly what to do (and why you skipped it) in ADAPTY_SETUP.md. + +- Access levels: one per active entitlement, ID = the entitlement's lookup_key. Skip archived entitlements. No entitlements at all -> use the default "premium" access level and say so in ADAPTY_SETUP.md. +- Products: use EXACT store identifiers (never invent or "normalize" them) - store IDs are IMMUTABLE once an Adapty product is created, so a wrong ID can only be fixed by deleting and recreating the product. No real ID available -> create no product; put the ready-to-run \`products create\` command in ADAPTY_SETUP.md with a slot instead. A product's access level = its entitlement's lookup_key. Product attached to several entitlements -> assign the one the code actually gates on and flag the choice in ADAPTY_SETUP.md; attached to none -> the default access level, flagged. Same store identifier on both stores -> ONE Adapty product with both store IDs, not two. +- Placements: ID = the offering's lookup_key, so \`getOffering("x")\` call sites map mechanically to placement "x". Create a placement + paywall (paywall products in package order) only for offerings the app actually uses: the current offering plus any offering referenced in code by lookup_key. Offerings never referenced and not current -> list them in ADAPTY_SETUP.md instead of creating them. +- Offering backed by a PUBLISHED RC Paywall Builder paywall: create NOTHING for it. Paywall and flow placements share one ID namespace in Adapty, and the user should rebuild builder paywalls as flows - a placement created now would permanently block the flow placement with that ID. Add a "Rebuild as flows" section to ADAPTY_SETUP.md listing each reserved placement ID with its product list and metadata. +- Offering metadata maps to paywall remote config, which this CLI cannot set - put the metadata JSON in ADAPTY_SETUP.md next to its paywall. +- NEVER create or imitate: webhooks (Adapty's payload format differs - checklist item), audiences/targeting/experiments (not exportable from RC - checklist item), store credentials (write-only in RC, cannot be exported - the user re-enters them in Adapty). + +${rcCatalog ? `\n\n${rcCatalog}\n\n` : ''} +3. Replace the code, call site by call site, using the platform playbook below for every Adapty API (init/activate, identify, getPaywall/getFlow, makePurchase, restorePurchases, access level checks). Then remove ${label} from the dependencies and delete all now-dead code that used it. + +4. If a build command exists for this project, build to verify it compiles. Fix what you broke; do not chase pre-existing failures. + +Besides the standard sections, ADAPTY_SETUP.md must have a **Migration** section covering: the entitlement -> access level mapping; every entity you skipped and why, with exact steps to finish it by hand (reserved flow placements, offering metadata -> remote config, webhooks, audiences/experiments); re-entering store credentials in Adapty (cannot be exported from ${label}); switching App Store Server Notifications and Google Real-time Developer Notifications to Adapty; and historical data import - wait about a week after the Adapty release before importing (the SDK collects price data meanwhile), Google purchase tokens require a CSV requested from RC support.${ + rcCatalog + ? '' + : `\n\nYou worked WITHOUT access to the ${label} account, so the code was your only source - the account almost certainly holds entities you could not see. Open the Migration section with a **"Verify against your ${label} dashboard"** subsection listing, as concrete checkboxes, everything the user must compare by hand: the products you did NOT create because their store IDs were not in the code (include the ready-to-run commands); entitlements beyond the ones referenced in code; offerings/paywalls that exist only in the dashboard (each unmigrated offering needs a decision: recreate as placement + paywall, or rebuild as a flow if it used a visual paywall builder); and any offering metadata that should become paywall remote config. If the source is RevenueCat, mention that re-running \`adapty migrate --rc-key \` automates this comparison.` + } + +--- PLATFORM PLAYBOOK (from the adapty-sdk-integration skill) --- + +${platformReference}` + }, + title: 'migration', + } +} diff --git a/src/lib/agent/drivers.ts b/src/lib/agent/drivers.ts new file mode 100644 index 0000000..7e32317 --- /dev/null +++ b/src/lib/agent/drivers.ts @@ -0,0 +1,226 @@ +import {execFile, spawn} from 'node:child_process' +import {readFile, rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {createInterface} from 'node:readline' +import {promisify} from 'node:util' + +const execFileAsync = promisify(execFile) + +export type DriverId = 'claude' | 'codex' + +export interface AgentDriver { + bin: string + displayName: string + id: DriverId + /** How the user re-authenticates this agent when its own session expires. */ + loginHint: string +} + +export interface AgentRunOptions { + cwd: string + /** Extra env vars for the agent process only (e.g. ADAPTY_TOKEN) - never mutate process.env. */ + env?: Record + onStatus?: (text: string) => void + prompt: string +} + +export interface AgentResult { + /** Set when the failure is recognizably the agent's own expired/missing login. */ + failureReason?: 'auth' + finalText?: string + ok: boolean +} + +const DRIVERS: AgentDriver[] = [ + {bin: 'claude', displayName: 'Claude Code', id: 'claude', loginHint: 'run `claude` and complete /login'}, + {bin: 'codex', displayName: 'Codex', id: 'codex', loginHint: 'run `codex login`'}, +] + +/** The agent's own session is expired/missing (Anthropic OAuth, Codex login) - not an Adapty error. */ +const AGENT_AUTH_ERROR = + /OAuth (access|refresh) token (has )?expired|failed to authenticate|please run \/login|invalid api key|re-authenticate|not logged in/i + +function withAuthCheck(result: AgentResult): AgentResult { + if (!result.ok && result.finalText && AGENT_AUTH_ERROR.test(result.finalText)) { + return {...result, failureReason: 'auth'} + } + + return result +} + +async function onPath(bin: string): Promise { + try { + await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [bin], {timeout: 5000}) + return true + } catch { + return false + } +} + +export async function detectDrivers(): Promise { + const found = await Promise.all(DRIVERS.map(async (d) => ((await onPath(d.bin)) ? d : null))) + return found.filter((d): d is AgentDriver => d !== null) +} + +const ALLOWED_TOOLS = ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash', 'WebFetch', 'WebSearch'] + +function extractStatus(textBlock: string): null | string { + return textBlock.match(/\[STATUS\]\s*(.+?)\s*$/m)?.[1]?.trim() ?? null +} + +interface SpawnStreamOptions { + args: string[] + bin: string + cwd: string + env?: Record + onLine: (line: string) => void + onStderrLine?: (line: string) => void +} + +function spawnStream({args, bin, cwd, env, onLine, onStderrLine}: SpawnStreamOptions): Promise { + return new Promise((resolve, reject) => { + const child = spawn(bin, args, { + cwd, + env: env ? {...process.env, ...env} : process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + createInterface({input: child.stdout}).on('line', onLine) + createInterface({input: child.stderr}).on('line', onStderrLine ?? (() => {})) + child.on('error', reject) + child.on('close', (code) => resolve(code)) + }) +} + +const STDERR_TAIL_LINES = 30 + +/** Agents print auth errors to stderr without producing a result payload - keep a tail so failures stay diagnosable. */ +function stderrTailCollector(): {push: (line: string) => void; tail: () => string} { + const lines: string[] = [] + return { + push(line) { + lines.push(line) + if (lines.length > STDERR_TAIL_LINES) lines.shift() + }, + tail: () => lines.join('\n').trim(), + } +} + +async function runClaude(opts: AgentRunOptions): Promise { + const args = [ + '-p', + opts.prompt, + '--output-format', + 'stream-json', + '--verbose', + '--permission-mode', + 'acceptEdits', + '--add-dir', + opts.cwd, + '--allowedTools', + ALLOWED_TOOLS.join(','), + ] + + let finalText: string | undefined + let ok = false + const stderr = stderrTailCollector() + + const code = await spawnStream({ + args, + bin: 'claude', + cwd: opts.cwd, + env: opts.env, + onStderrLine: stderr.push, + // eslint-disable-next-line perfectionist/sort-objects + onLine(line) { + let msg: Record + try { + msg = JSON.parse(line) as Record + } catch { + return + } + + if (msg.type === 'assistant') { + const content = (msg.message as {content?: unknown})?.content + if (!Array.isArray(content)) return + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string') { + const status = extractStatus(block.text) + if (status) opts.onStatus?.(status) + } + } + } else if (msg.type === 'result') { + if (typeof msg.result === 'string') finalText = msg.result + ok = msg.subtype === 'success' && msg.is_error !== true + } + }, + }) + + // Auth errors often land only on stderr with no result payload. + if (!finalText && code !== 0) finalText = stderr.tail() || undefined + return withAuthCheck({finalText, ok: ok && code === 0}) +} + +async function runCodex(opts: AgentRunOptions): Promise { + const lastMsgFile = join(tmpdir(), `adapty-codex-${process.pid}-${Date.now()}.txt`) + const args = [ + 'exec', + opts.prompt, + '--json', + '--sandbox', + 'workspace-write', + '-c', + 'sandbox_workspace_write.network_access=true', + '-C', + opts.cwd, + '--skip-git-repo-check', + '--output-last-message', + lastMsgFile, + ] + + const stderr = stderrTailCollector() + const code = await spawnStream({ + args, + bin: 'codex', + cwd: opts.cwd, + env: opts.env, + onStderrLine: stderr.push, + // eslint-disable-next-line perfectionist/sort-objects + onLine(line) { + let msg: Record + try { + msg = JSON.parse(line) as Record + } catch { + return + } + + // Codex's event schema shifts between versions; pull message text defensively. + const node = (msg.msg ?? msg.item ?? msg) as Record + const type = String(node.type ?? msg.type ?? '') + if (type.includes('agent_message') || type.includes('assistant')) { + const textValue = node.message ?? node.text ?? node.delta + if (typeof textValue === 'string') { + const status = extractStatus(textValue) + if (status) opts.onStatus?.(status) + } + } + }, + }) + + let finalText: string | undefined + try { + finalText = (await readFile(lastMsgFile, 'utf8')).trim() || undefined + } catch { + // no final message file - fine + } finally { + await rm(lastMsgFile, {force: true}).catch(() => {}) + } + + // Auth errors often land only on stderr with no last-message file. + if (!finalText && code !== 0) finalText = stderr.tail() || undefined + return withAuthCheck({finalText, ok: code === 0}) +} + +export async function runAgent(driver: AgentDriver, opts: AgentRunOptions): Promise { + return driver.id === 'claude' ? runClaude(opts) : runCodex(opts) +} diff --git a/src/lib/agent/prompt.ts b/src/lib/agent/prompt.ts new file mode 100644 index 0000000..4a66fe5 --- /dev/null +++ b/src/lib/agent/prompt.ts @@ -0,0 +1,116 @@ +import {dirname, join} from 'node:path' + +import type {DetectedProject} from '../project/scan.js' + +/** + * Shared context every agent-driven command provides. Action-specific data + * (e.g. a migration's source provider) travels inside the action's task body. + */ +export interface PromptContext { + appId: string + /** How the agent invokes this exact CLI build (never `npx adapty@latest` - version skew). */ + cliCommand: string + paywallApproach: string + platformReference: string + project: DetectedProject + sdkKey: string +} + +/** + * One agent-driven task (integrate, migrate, ...). The shared wrapper below + * supplies the context block, the common rules, the [STATUS] protocol, and + * the ADAPTY_SETUP.md finish conventions - an action only defines its + * mission-specific task body and human labels. + */ +export interface AgentAction { + /** Command id, used in telemetry tags (e.g. 'integrate'). */ + id: string + /** The task body. Opens with the mission sentence; conditional content lives here. */ + task(ctx: PromptContext): string + /** Human noun for messages like "How was the integration?" */ + title: string +} + +/** 'headless' = run by this CLI, no user available; 'copy' = pasted into the user's own interactive agent. */ +export type PromptMode = 'copy' | 'headless' + +/** + * The command the agent must use to invoke THIS build of the CLI. Never + * `npx adapty@latest` (the published package can lag behind the commands the + * prompt relies on). bin/dev.js only works through its shebang (ts-node + * loader flags), so a bare `node dev.js` child would crash on .ts imports - + * point at its sibling run.js instead. + */ +export function resolveCliCommand(): string { + const entry = process.argv[1] + if (!entry) return 'npx -y adapty@latest' + const resolved = entry.endsWith('dev.js') ? join(dirname(entry), 'run.js') : entry + return `node "${resolved}"` +} + +function contextBlock(ctx: PromptContext): string { + const {appId, paywallApproach, project, sdkKey} = ctx + return ` + ${project.platformLabel} + ${project.path} + ${appId || '(not provided)'} + ${sdkKey || '(not provided - ask the user to paste it from the Adapty dashboard before running)'} + ${paywallApproach ? `${paywallApproach}\n ` : ''}https://adapty.io/docs/llms.txt lists all pages; fetch any page as https://adapty.io/docs/{slug}.md +` +} + +function rulesBlock(ctx: PromptContext, actionId: string, mode: PromptMode): string { + const interactionRule = + mode === 'headless' + ? `This is a HEADLESS run - there is no user to ask. Never ask questions. Where a playbook says to ask the user, pick the stated default (or the most conservative option), continue, and record the decision in ADAPTY_SETUP.md (see the finish instructions).` + : `When a decision genuinely needs the user (store product IDs, existing dashboard setup), ask - otherwise pick the stated default and record it in ADAPTY_SETUP.md (see the finish instructions).` + const statusRule = + mode === 'headless' + ? `\n Emit a one-line progress update prefixed with '[STATUS] ' before EVERY step, including before running any shell command (e.g. '[STATUS] Installing the Adapty package', '[STATUS] Creating the placement'). The user only sees these [STATUS] lines - never go more than one tool call without one.` + : '' + + return ` + Make the smallest set of edits that achieves a working, verifiable result. Do not refactor unrelated code or reformat files. + ${interactionRule} + Never invent an Adapty API key. Use the exact public SDK key from the context. Never write a secret key into source. + Before editing any file, read it first. Match the file's existing style and conventions. + Detect the package manager / build system from the project (lockfiles, Podfile, gradle files, pubspec.yaml) rather than assuming. + Dashboard entities (access levels, products, paywalls, placements) are managed with the Adapty CLI. Invoke it EXACTLY as \`${ctx.cliCommand} --json\` and scope every command with --app ${ctx.appId || ''}. It authenticates via the ADAPTY_TOKEN environment variable, which is already set for you. Never ask the user for IDs the CLI can return. + NEVER run the CLI's auth commands (auth login / logout / revoke) - login needs a browser and logout would destroy the user's session. If a CLI call fails with an auth error, do NOT retry or troubleshoot auth: record the exact remaining commands in ADAPTY_SETUP.md and continue with the code stages. + Store product IDs are IMMUTABLE in Adapty - once a product is created its store IDs can never be changed, only the whole product deleted and recreated. So NEVER create a product with a guessed or placeholder store ID. Create products only with real IDs you found in the code, config, or provided data. When you do not know the real ID, skip creating that product and put the exact ready-to-run \`products create\` command in ADAPTY_SETUP.md with a slot to fill in. Paywall product lists ARE editable later - create paywalls/placements now and note in ADAPTY_SETUP.md to attach the products once created. + Never invent an SDK API. Every symbol you write must come from the playbook below or a docs page you fetched. Where a docs URL in the playbook carries a ?ref=skill- tag, use ref=cli-${actionId} instead.${statusRule} +` +} + +function finishBlock(mode: PromptMode): string { + return ` +When the task is complete: + +1. Write a checklist file \`ADAPTY_SETUP.md\` in the app directory root covering everything that still needs a human (or another agent session). Use GitHub checkboxes (\`- [ ]\`), be specific (exact IDs, file paths, dashboard URLs), and group into these sections, skipping any that are empty: + - **Replace placeholders** - every placeholder or default you chose, with where it lives (Adapty dashboard and/or code) and what to put there instead. + - **Dashboard steps** - what must be done at https://app.adapty.io. + - **Verify on device** - run the app, see the paywall at the placement(s), complete a sandbox purchase. + - **Before release** - fetch https://adapty.io/docs/release-checklist.md and include the few items relevant to this app (e.g. server notifications, switching placeholder products to real ones). + Start the file with one line stating what was done and when to delete the file, and end it with: "Tip: you can hand this file to a coding agent - e.g. \`claude "work through ADAPTY_SETUP.md"\` - to finish these steps." +2. End with a 3-5 line summary (what was changed and configured, the key IDs, and that the remaining steps are in ADAPTY_SETUP.md).${ + mode === 'headless' ? `\n Then a final line '[STATUS] Done'.` : '' + } +` +} + +export function buildActionPrompt(action: AgentAction, ctx: PromptContext, mode: PromptMode = 'headless'): string { + return `${contextBlock(ctx)} + +${rulesBlock(ctx, action.id, mode)} + + +${action.task(ctx)} + + +${finishBlock(mode)}` +} + +/** Same prompt for pasting into the user's own interactive agent: no [STATUS] protocol, and questions are allowed. */ +export function buildCopyPrompt(action: AgentAction, ctx: PromptContext): string { + return buildActionPrompt(action, ctx, 'copy') +} diff --git a/src/lib/agent/run.ts b/src/lib/agent/run.ts new file mode 100644 index 0000000..7415dc8 --- /dev/null +++ b/src/lib/agent/run.ts @@ -0,0 +1,131 @@ +import type {Command} from '@oclif/core' + +import {existsSync} from 'node:fs' +import {join} from 'node:path' + +import {spinner, text} from '../ui/ask.js' +import {copyToClipboard} from '../ui/clipboard.js' +import {type AgentDriver, type AgentResult, runAgent} from './drivers.js' +import {type AgentAction, buildActionPrompt, buildCopyPrompt, type PromptContext} from './prompt.js' +import {installAgentSkills} from './skills-install.js' +import {trackAgentRun} from './telemetry.js' + +const DASHBOARD_URL = 'https://app.adapty.io' + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1) +} + +export interface RunActionResult { + failureReason?: 'auth' + finalText?: string + ok: boolean +} + +/** The --copy exit shared by every agent-driven command: put the prompt on the clipboard (or print it). */ +export async function emitCopyPrompt(command: Command, action: AgentAction, ctx: PromptContext): Promise { + const prompt = buildCopyPrompt(action, ctx) + const copied = await copyToClipboard(prompt) + if (copied) command.log(`\n${capitalize(action.title)} prompt copied to clipboard - paste it into any coding agent.`) + else command.log(`\n${prompt}\n\n(Copy the prompt above into any coding agent.)`) +} + +/** The failure exit shared by every agent-driven command; never returns. */ +export function reportActionFailure(command: Command, driver: AgentDriver, result: RunActionResult): never { + const name = command.id ?? 'integrate' + command.error( + result.failureReason === 'auth' + ? `${driver.displayName} isn't logged in (its own session expired - not your Adapty login). ` + + `To fix: ${driver.loginHint}, then re-run \`adapty ${name}\`. Or use --copy to drive your own agent.` + : `The agent stopped before finishing. Re-run \`adapty ${name}\`, or use --copy to drive your own agent.`, + ) +} + +export interface RunActionOptions { + action: AgentAction + ctx: PromptContext + driver: AgentDriver + /** Extra env for the agent process only (e.g. ADAPTY_TOKEN) - keeps secrets out of the global process.env. */ + env?: Record + interactive: boolean +} + +/** + * The full post-question sequence shared by every agent-driven command: + * run the agent headless → surface [STATUS] lines → rating → telemetry → + * install the Adapty skill → point at ADAPTY_SETUP.md. Returns the result; + * the caller decides how to fail. + */ +export async function runActionWithFollowUp( + command: Command, + {action, ctx, driver, env, interactive}: RunActionOptions, +): Promise { + const spin = spinner() + spin.start(`Running ${driver.displayName} - this can take a few minutes`) + const started = Date.now() + let result: AgentResult + try { + result = await runAgent(driver, { + cwd: ctx.project.path, + env, + onStatus: (statusText) => spin.message(statusText), + prompt: buildActionPrompt(action, ctx), + }) + } catch (error) { + // spawn itself failed (binary vanished, unspawnable) - fail with the crafted path, not a raw stack. + result = {finalText: error instanceof Error ? error.message : String(error), ok: false} + } + + spin.stop(result.ok ? `${capitalize(action.title)} complete` : 'The agent stopped before finishing.') + + const track = (rating: null | number) => + trackAgentRun({ + appId: ctx.appId, + command: action.id, + driver: driver.id, + durationS: Math.round((Date.now() - started) / 1000), + isDev: existsSync(join(command.config.root, '.git')), + ok: result.ok, + paywallApproach: ctx.paywallApproach, + platform: ctx.project.platform, + rating, + version: command.config.version, + }) + + if (!result.ok) { + if (result.finalText) command.log(`\n${result.finalText.slice(0, 1500)}`) + await track(null) + return {failureReason: result.failureReason, finalText: result.finalText, ok: false} + } + + if (result.finalText) command.log(`\n${result.finalText}`) + + let rating: null | number = null + if (interactive) { + const answer = await text(`How was the ${action.title}? Rate 1-5 (enter to skip)`) + const parsed = Number.parseInt(answer, 10) + if (parsed >= 1 && parsed <= 5) rating = parsed + } + + await track(rating) + + // Leave the skill installed in the user's agents so future sessions know Adapty. + const skillSpin = spinner() + skillSpin.start('Installing the Adapty skill into your coding agent') + const installed = await installAgentSkills() + skillSpin.stop( + installed + ? 'Adapty skill installed - your agent can now handle Adapty tasks in any session.' + : 'Skill install skipped - run `npx skills add adaptyteam/adapty-sdk-integration-skill` to add it manually.', + ) + + command.log(`\nDone. Review the changes with \`git diff\`, then finish up in the dashboard: ${DASHBOARD_URL}`) + if (existsSync(join(ctx.project.path, 'ADAPTY_SETUP.md'))) { + command.log( + 'The remaining steps are in ADAPTY_SETUP.md - work through it yourself, or hand it to your agent:\n' + + ' claude "work through ADAPTY_SETUP.md"', + ) + } + + return {finalText: result.finalText, ok: true} +} diff --git a/src/lib/agent/skill-source.ts b/src/lib/agent/skill-source.ts new file mode 100644 index 0000000..93463e5 --- /dev/null +++ b/src/lib/agent/skill-source.ts @@ -0,0 +1,49 @@ +import {readFile} from 'node:fs/promises' +import {join} from 'node:path' + +import type {Platform} from '../project/scan.js' + +/** + * Skill content is NOT vendored into this package - the single source of + * truth is the adapty-sdk-integration-skill repo. Files are fetched from + * GitHub raw at run time; set ADAPTY_SKILL_DIR to a local checkout of the + * skill directory (the one containing SKILL.md) to develop against local + * edits. + */ +const RAW_BASE = + 'https://raw.githubusercontent.com/adaptyteam/adapty-sdk-integration-skill/main/skills/adapty-sdk-integration' + +function stripFrontmatter(md: string): string { + const lines = md.split('\n') + if (lines[0]?.trim() === '---') { + const end = lines.indexOf('---', 1) + if (end > 0) return lines.slice(end + 1).join('\n').trim() + } + + return md.trim() +} + +async function loadSkillFile(rel: string): Promise { + let content: string + const localDir = process.env.ADAPTY_SKILL_DIR + if (localDir) { + content = await readFile(join(localDir, rel), 'utf8') + } else { + const response = await fetch(`${RAW_BASE}/${rel}`, {signal: AbortSignal.timeout(15_000)}) + if (!response.ok) { + throw new Error( + `Could not fetch skill file "${rel}" (HTTP ${response.status}). Check your connection and retry.`, + ) + } + + content = await response.text() + } + + // Normalize CRLF (e.g. a Windows checkout via ADAPTY_SKILL_DIR) so frontmatter stripping works. + return stripFrontmatter(content.replaceAll('\r\n', '\n')) +} + +/** The platform-specific integration playbook (references/.md). */ +export async function loadPlatformReference(platform: Platform): Promise { + return loadSkillFile(`references/${platform}.md`) +} diff --git a/src/lib/agent/skills-install.ts b/src/lib/agent/skills-install.ts new file mode 100644 index 0000000..4df1ca2 --- /dev/null +++ b/src/lib/agent/skills-install.ts @@ -0,0 +1,20 @@ +import {spawn} from 'node:child_process' + +/** + * Install the adapty-sdk-integration skill into the user's coding agents + * (Claude Code, Codex, Cursor, ...) via the `skills` CLI, so the agent knows + * Adapty in every future session - not just this run. Same source repo the + * integrate prompt is built from. + */ +const SKILL_SOURCE = 'adaptyteam/adapty-sdk-integration-skill' + +export function installAgentSkills(): Promise { + return new Promise((resolve) => { + const child = spawn('npx', ['-y', 'skills@latest', 'add', SKILL_SOURCE, '--global', '--yes'], { + stdio: ['ignore', 'ignore', 'ignore'], + timeout: 120_000, + }) + child.on('error', () => resolve(false)) + child.on('close', (code) => resolve(code === 0)) + }) +} diff --git a/src/lib/agent/telemetry.ts b/src/lib/agent/telemetry.ts new file mode 100644 index 0000000..0a49867 --- /dev/null +++ b/src/lib/agent/telemetry.ts @@ -0,0 +1,59 @@ +/** + * One event per agent-driven command run (integrate, migrate, ...), sent to + * the same feedback endpoint the adapty-sdk-integration skill uses (Slack + + * Airtable behind it), so CLI and skill sessions land in one funnel. + * Fire-and-forget: never blocks the command for more than 3s, never surfaces + * an error. + * + * Opt out with ADAPTY_TELEMETRY_DISABLED=1 or DO_NOT_TRACK=1. + * Override the endpoint with ADAPTY_FEEDBACK_URL (used by tests). + */ +const DEFAULT_ENDPOINT = 'https://feedback-endpoint-eandreeva-twrs-projects.vercel.app/api/sdk-integration-feedback' + +export interface AgentRunEvent { + appId: string + /** Which agent-driven command ran, e.g. 'integrate'. */ + command: string + driver: string + durationS: number + /** Running from a source checkout (has .git) rather than an npm install. */ + isDev: boolean + ok: boolean + paywallApproach: string + platform: string + rating: null | number + version: string +} + +export function telemetryDisabled(): boolean { + return process.env.ADAPTY_TELEMETRY_DISABLED === '1' || process.env.DO_NOT_TRACK === '1' +} + +export async function trackAgentRun(event: AgentRunEvent): Promise { + if (telemetryDisabled()) return + + const tags = `source:cli-${event.command} v${event.version} · driver:${event.driver}${event.isDev ? ' · dev' : ''}` + const slackText = + `[${event.platform} · ${event.paywallApproach}] CLI ${event.command} ${event.ok ? '✓' : '✗'} in ${event.durationS}s · ${tags}` + + (event.rating ? ` · Rating: ${event.rating}/5` : '') + + (event.appId ? ` · App: ${event.appId}` : '') + + try { + await fetch(process.env.ADAPTY_FEEDBACK_URL ?? DEFAULT_ENDPOINT, { + body: JSON.stringify({ + app_id: event.appId || null, + integrations: tags, + paywall_approach: event.paywallApproach, + phases_completed: event.ok ? 4 : 0, + platform: event.platform, + rating: event.rating, + slack_text: slackText, + }), + headers: {'Content-Type': 'application/json'}, + method: 'POST', + signal: AbortSignal.timeout(3000), + }) + } catch { + // telemetry must never break the workflow + } +} diff --git a/src/lib/agent/wizard.ts b/src/lib/agent/wizard.ts new file mode 100644 index 0000000..3e56730 --- /dev/null +++ b/src/lib/agent/wizard.ts @@ -0,0 +1,208 @@ +import type {Command} from '@oclif/core' + +import type {ApiClient} from '../api-client.js' +import type {AppDetailDTO, AppSummaryDTO} from '../api-schemas.js' + +import {resolveToken} from '../auth.js' +import {createAuthenticatedClient} from '../client-from-config.js' +import {isValidUuid, type PaginatedResponse, paginationParams} from '../flags.js' +import {type DetectedProject, scanProject} from '../project/scan.js' +import {confirm, isInteractive, select, spinner, text} from '../ui/ask.js' +import {type AgentDriver, detectDrivers} from './drivers.js' +import {type PromptContext, resolveCliCommand} from './prompt.js' +import {loadPlatformReference} from './skill-source.js' + +export interface WizardFlags { + app?: string + copy?: boolean + driver?: string + path: string +} + +export interface WizardSetup { + appId: string + /** null only in --copy mode. */ + driver: AgentDriver | null + interactive: boolean + project: DetectedProject + sdkKey: string + /** Adapty session token, for scoping into the agent's environment. Empty in keyless --copy runs. */ + token: string +} + +/** + * The gathering steps shared by every agent-driven command (integrate, + * migrate, ...): detect the project, find a coding agent, ensure an Adapty + * session, and bind an Adapty app + SDK key. Returns null when the user + * cancels (already logged); throws command.error on fatals. + */ +export async function prepareWizard(command: Command, flags: WizardFlags): Promise { + const commandName = command.id ?? 'integrate' + + // 1. What app is this? + const project = await scanProject(flags.path) + if (!project) { + command.error( + 'No supported mobile app found here. Supported: iOS, Android, Flutter, React Native, Capacitor, Unity, Kotlin Multiplatform.\n' + + 'Run from the app directory or pass --path.', + {exit: 2}, + ) + } + + command.log(`Detected ${project.platformLabel} app "${project.name}"`) + + // 2. Which agent will do the work? + let driver: AgentDriver | null = null + if (!flags.copy) { + const drivers = await detectDrivers() + driver = flags.driver ? (drivers.find((d) => d.id === flags.driver) ?? null) : (drivers[0] ?? null) + if (flags.driver && !driver) { + command.error( + `Agent "${flags.driver}" not found on PATH. Detected: ${drivers.map((d) => d.id).join(', ') || 'none'}.`, + ) + } + + if (!driver) { + command.log('\nNo coding agent found (looked for Claude Code and Codex).') + command.log(`Install one and re-run, or use \`adapty ${commandName} --copy\` to get a prompt for any agent:`) + command.log(' Claude Code: npm install --global @anthropic-ai/claude-code') + command.log(' Codex: npm install --global @openai/codex') + return null + } + + command.log(`Using ${driver.displayName} as the coding agent`) + } + + // 3. Auth - needed to pick/create the app and for the agent's `adapty` CLI calls. + const interactive = isInteractive() + let token = await resolveToken(command.config.configDir) + if (!token && interactive) { + const wantsLogin = await confirm('You are not logged in to Adapty. Log in now?') + if (wantsLogin === null) { + command.log('Cancelled.') + return null + } + + if (wantsLogin) { + await command.config.runCommand('auth:login') + token = await resolveToken(command.config.configDir) + } + } + + if (!token && !flags.copy) { + command.error('This command needs an authenticated session. Run `adapty auth login` and try again.') + } + + // 4. Connect to an Adapty app and get its public SDK key. With --copy this + // is best-effort: the prompt is still useful with the key left blank. + let appId = '' + let sdkKey = '' + if (token) { + try { + const picked = await resolveApp(command, project, interactive, flags.app) + if (!picked) { + command.log('Cancelled.') + return null + } + + appId = picked.appId + sdkKey = picked.sdkKey + if (!sdkKey) + command.warn('This app has no public SDK key yet - the agent will need one to call Adapty.activate().') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!flags.copy) { + command.error(`Couldn't bind an Adapty app (${message}). Fix that and try again.`) + } + + command.warn(`Couldn't bind an Adapty app (${message}) - the prompt will leave the SDK key blank.`) + } + } + + return {appId, driver, interactive, project, sdkKey, token: token ?? ''} +} + +/** Fetch the platform playbook and assemble the PromptContext - identical for every agent-driven command. */ +export async function preparePromptContext(setup: WizardSetup, paywallApproach: string): Promise { + const spin = spinner() + spin.start('Fetching the integration playbook') + const platformReference = await loadPlatformReference(setup.project.platform) + spin.stop('Integration playbook ready') + + return { + appId: setup.appId, + cliCommand: resolveCliCommand(), + paywallApproach, + platformReference, + project: setup.project, + sdkKey: setup.sdkKey, + } +} + +async function createApp( + command: Command, + client: ApiClient, + project: DetectedProject, +): Promise { + const title = await text('Name for the new app', project.name) + if (!title) return null + const app = await client.post('/apps', {title}) + command.log(`Created Adapty app "${app.title}"`) + return {appId: app.id, sdkKey: app.sdk_key ?? ''} +} + +async function resolveApp( + command: Command, + project: DetectedProject, + interactive: boolean, + appFlag?: string, +): Promise { + const client = await createAuthenticatedClient(command.config) + + if (appFlag) { + if (!isValidUuid(appFlag)) command.error('Invalid app ID format. Run `adapty apps list` to find your app ID.') + const app = await client.get(`/apps/${appFlag}`) + command.log(`Using Adapty app "${app.title}"`) + return {appId: app.id, sdkKey: app.sdk_key ?? ''} + } + + const {data: apps} = await client.get>( + '/apps', + paginationParams({page: 1, 'page-size': 100}), + ) + + // Never create or guess an app without a user: creating is a real, visible + // side effect. Headless runs must pass --app (or have exactly one app). + if (!interactive) { + if (apps.length === 1) { + const app = apps[0] + command.log(`Using Adapty app "${app.title}"`) + return {appId: app.id, sdkKey: app.sdk_key ?? ''} + } + + throw new Error( + apps.length === 0 + ? 'no apps in this account - run interactively to create one, or create it in the dashboard' + : `${apps.length} apps in this account - pass --app to choose (see \`adapty apps list\`)`, + ) + } + + if (apps.length === 0) { + command.log('No apps in your Adapty account yet.') + return createApp(command, client, project) + } + + const CREATE = '__create__' + const choice = await select( + 'Which Adapty app is this project?', + [ + {hint: 'a new app with its own SDK key', label: '+ Create a new app', value: CREATE}, + ...apps.map((app) => ({label: app.title, value: app.id})), + ], + CREATE, + ) + if (!choice) return null + if (choice === CREATE) return createApp(command, client, project) + const app = apps.find((a) => a.id === choice)! + return {appId: app.id, sdkKey: app.sdk_key ?? ''} +} diff --git a/src/lib/project/billing.ts b/src/lib/project/billing.ts new file mode 100644 index 0000000..3ba06fc --- /dev/null +++ b/src/lib/project/billing.ts @@ -0,0 +1,83 @@ +import {readFile} from 'node:fs/promises' +import {join} from 'node:path' + +/** + * What currently handles purchases in the project: + * - competitors (RevenueCat, Superwall, Qonversion) + * - official/community store plugins (in_app_purchase, react-native-iap, ...) + * - hand-rolled StoreKit / Google Play Billing code + */ +export type BillingId = 'native-store' | 'qonversion' | 'revenuecat' | 'store-plugin' | 'superwall' + +export interface DetectedBilling { + /** The specific tech matched, e.g. 'in_app_purchase', 'Google Play Billing'. */ + detail?: string + id: BillingId +} + +export const BILLING_LABELS: Record = { + 'native-store': 'custom StoreKit / Play Billing code', + qonversion: 'Qonversion', + revenuecat: 'RevenueCat', + 'store-plugin': 'the existing store plugin', + superwall: 'Superwall', +} + +/** Human label, using the matched tech when we have it: "the in_app_purchase plugin". */ +export function billingLabel(billing: DetectedBilling): string { + if (billing.id === 'store-plugin' && billing.detail) return `the ${billing.detail} plugin` + if (billing.id === 'native-store' && billing.detail) return `custom ${billing.detail} code` + return BILLING_LABELS[billing.id] +} + +/** + * Dependency manifests worth grepping, across all supported platforms. + * Deliberately NOT pubspec.lock: lockfiles list transitive dependencies at + * the same indentation as direct ones, which false-triggers plugin detection + * (e.g. in_app_purchase pulled in by another plugin). Direct deps always + * appear in pubspec.yaml. + */ +const MANIFESTS = [ + 'package.json', + 'pubspec.yaml', + 'Podfile', + 'ios/Podfile', + 'Package.swift', + 'build.gradle', + 'build.gradle.kts', + 'app/build.gradle', + 'app/build.gradle.kts', + 'android/app/build.gradle', + 'android/app/build.gradle.kts', + 'Packages/manifest.json', +] + +/** Xcode project files - the only manifest-level trace of a hand-rolled StoreKit integration. */ +const PBXPROJ_HINTS = ['ios/Runner.xcodeproj/project.pbxproj', 'ios/App/App.xcodeproj/project.pbxproj'] + +/** Ordered: competitors first, then store plugins, then native store code. First match wins. */ +const MATCHERS: Array = [ + {id: 'revenuecat', re: /revenuecat|react-native-purchases|purchases_flutter|purchases-capacitor|purchases-hybrid/i}, + {id: 'superwall', re: /superwall/i}, + {id: 'qonversion', re: /qonversion/i}, + {detail: 'in_app_purchase', id: 'store-plugin', re: /^\s+in_app_purchase\s*:/m}, + {detail: 'flutter_inapp_purchase', id: 'store-plugin', re: /flutter_inapp_purchase/}, + {detail: 'react-native-iap', id: 'store-plugin', re: /react-native-iap/}, + {detail: 'expo-in-app-purchases', id: 'store-plugin', re: /expo-in-app-purchases/}, + {detail: 'cordova-plugin-purchase', id: 'store-plugin', re: /cordova-plugin-purchase/}, + {detail: 'Unity IAP', id: 'store-plugin', re: /com\.unity\.purchasing/}, + {detail: 'Google Play Billing', id: 'native-store', re: /com\.android\.billingclient/}, + {detail: 'StoreKit', id: 'native-store', re: /StoreKit\.framework|import StoreKit/}, +] + +export async function detectBilling(dir: string): Promise { + const contents = await Promise.all( + [...MANIFESTS, ...PBXPROJ_HINTS].map((rel) => readFile(join(dir, rel), 'utf8').catch(() => '')), + ) + const blob = contents.join('\n') + for (const {re, ...billing} of MATCHERS) { + if (re.test(blob)) return billing + } + + return null +} diff --git a/src/lib/project/revenuecat.ts b/src/lib/project/revenuecat.ts new file mode 100644 index 0000000..228f30f --- /dev/null +++ b/src/lib/project/revenuecat.ts @@ -0,0 +1,251 @@ +/** + * Read-only RevenueCat v2 API client for `adapty migrate --rc-key`. + * One RC v2 secret key = one RC project (the wizard model from the dashboard + * import design). Everything is fetched once, up front; the agent receives a + * rendered snapshot and never sees the key itself. + * + * Every sub-fetch is best-effort, but failure is DISTINGUISHED from empty: + * a throttled or failed fetch must never masquerade as "this offering has no + * packages" or "no builder paywall" - the downstream mapping rules treat + * those states very differently (a misread builder paywall permanently burns + * a placement ID). + */ + +const rcApiBase = () => process.env.ADAPTY_RC_API_URL ?? 'https://api.revenuecat.com/v2' + +/** Max simultaneous RC requests - large accounts must not trip rate limiting. */ +const RC_CONCURRENCY = 5 +/** Pagination safety valve: 20 pages x 100 items per list. */ +const MAX_PAGES = 20 + +export interface RcEntitlement { + displayName: string + lookupKey: string + /** Store identifiers of the products attached to this entitlement. */ + productStoreIds: string[] +} + +export interface RcProduct { + displayName: string + entitlementLookupKeys: string[] + storeIdentifier: string + storeType: string +} + +export interface RcOffering { + displayName: string + /** true = published RC Paywall Builder paywall; 'unknown' = the API didn't say OR the check failed. */ + hasPublishedBuilderPaywall: 'unknown' | boolean + isCurrent: boolean + lookupKey: string + metadata: null | Record + /** "package lookup_key -> store identifiers" in position order; null = the fetch FAILED (not "no packages"). */ + packages: Array<{lookupKey: string; productStoreIds: string[]}> | null +} + +export interface RcCatalog { + apps: Array<{name: string; type: string}> + /** false when any list was truncated or a sub-fetch failed - the catalog may be incomplete. */ + complete: boolean + entitlements: RcEntitlement[] + offerings: RcOffering[] + products: RcProduct[] + projectName: string +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +/** Run fn over items with bounded concurrency, preserving order. */ +async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const results: R[] = Array.from({length: items.length}) + let next = 0 + const workers = Array.from({length: Math.min(limit, items.length)}, async () => { + for (;;) { + const index = next++ + if (index >= items.length) return + results[index] = await fn(items[index]) + } + }) + await Promise.all(workers) + return results +} + +async function rcGet(key: string, path: string): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch(`${rcApiBase()}${path}`, { + headers: {accept: 'application/json', authorization: `Bearer ${key}`}, + signal: AbortSignal.timeout(15_000), + }) + if (res.status === 429) { + const retryAfter = Number.parseFloat(res.headers.get('retry-after') ?? '1') + await sleep(Math.min(Number.isFinite(retryAfter) ? retryAfter * 1000 : 1000, 10_000)) + continue + } + + if (!res.ok) return null + return (await res.json()) as T + } catch { + return null + } + } + + return null +} + +// RC response shapes shift and are only partially documented; access is defensive throughout. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Raw = Record + +interface RcPage { + items?: Raw[] + next_page?: null | string +} + +/** Fetch every page of a list endpoint. null = the FIRST page failed; `complete` = no truncation/failure. */ +async function rcGetAll(key: string, path: string): Promise { + const items: Raw[] = [] + let next: null | string = `${path}${path.includes('?') ? '&' : '?'}limit=100` + for (let page = 0; page < MAX_PAGES && next; page++) { + const data: null | RcPage = await rcGet(key, next) + if (!data) return page === 0 ? null : {complete: false, items} + items.push(...(data.items ?? [])) + // next_page comes back as an absolute API path including the /v2 prefix our base already has. + next = data.next_page ? data.next_page.replace(/^\/v2/, '') : null + } + + return {complete: !next, items} +} + +export async function fetchRcCatalog(key: string): Promise { + const projects = await rcGet(key, '/projects?limit=100') + const project = projects?.items?.[0] + if (!project) return null + const pid = String(project.id) + + const [apps, entitlementsRaw, productsRaw, offeringsRaw] = await Promise.all([ + rcGetAll(key, `/projects/${pid}/apps`), + rcGetAll(key, `/projects/${pid}/entitlements`), + rcGetAll(key, `/projects/${pid}/products`), + rcGetAll(key, `/projects/${pid}/offerings`), + ]) + let complete = [apps, entitlementsRaw, productsRaw, offeringsRaw].every((r) => r !== null && r.complete) + + // product <-> entitlement association is only exposed from the entitlement side. + const entitlementProducts = new Map() + await mapLimit(entitlementsRaw?.items ?? [], RC_CONCURRENCY, async (ent) => { + const products = await rcGetAll(key, `/projects/${pid}/entitlements/${ent.id}/products`) + if (!products?.complete) complete = false + entitlementProducts.set(String(ent.id), products?.items ?? []) + }) + + const entitlements: RcEntitlement[] = (entitlementsRaw?.items ?? []).map((ent) => ({ + displayName: String(ent.display_name ?? ent.lookup_key ?? ent.id), + lookupKey: String(ent.lookup_key ?? ent.id), + productStoreIds: (entitlementProducts.get(String(ent.id)) ?? []).map((p) => String(p.store_identifier ?? p.id)), + })) + + const products: RcProduct[] = (productsRaw?.items ?? []).map((product) => { + const storeId = String(product.store_identifier ?? product.id) + return { + displayName: String(product.display_name ?? storeId), + entitlementLookupKeys: entitlements.filter((e) => e.productStoreIds.includes(storeId)).map((e) => e.lookupKey), + storeIdentifier: storeId, + storeType: String(product.app?.type ?? product.store ?? product.type ?? ''), + } + }) + + const offerings: RcOffering[] = await mapLimit(offeringsRaw?.items ?? [], RC_CONCURRENCY, async (off) => { + const packages = await rcGetAll(key, `/projects/${pid}/offerings/${off.id}/packages?expand=items.product`) + if (!packages?.complete) complete = false + + // Published builder paywall = non-null paywall id whose paywall has published_at. + // A FAILED check stays 'unknown' - downstream must not create anything for it. + let hasPublishedBuilderPaywall: 'unknown' | boolean = 'unknown' + if ('paywall_id' in off || 'paywall' in off) { + const paywallId = off.paywall_id ?? off.paywall?.id + if (paywallId) { + const paywall = await rcGet(key, `/projects/${pid}/paywalls/${paywallId}`) + hasPublishedBuilderPaywall = paywall ? Boolean(paywall.published_at) : 'unknown' + if (!paywall) complete = false + } else { + hasPublishedBuilderPaywall = false + } + } + + return { + displayName: String(off.display_name ?? off.lookup_key ?? off.id), + hasPublishedBuilderPaywall, + isCurrent: Boolean(off.is_current), + lookupKey: String(off.lookup_key ?? off.id), + metadata: off.metadata && typeof off.metadata === 'object' ? (off.metadata as Record) : null, + packages: packages + ? packages.items.map((pkg) => ({ + lookupKey: String(pkg.lookup_key ?? pkg.id), + productStoreIds: ((pkg.products?.items ?? pkg.products ?? []) as Raw[]).map((p) => + String(p.product?.store_identifier ?? p.store_identifier ?? p.id), + ), + })) + : null, + } + }) + + return { + apps: (apps?.items ?? []).map((a) => ({name: String(a.name ?? a.id), type: String(a.type ?? '')})), + complete, + entitlements, + offerings, + products, + projectName: String(project.name ?? pid), + } +} + +/** Compact markdown snapshot for the agent prompt. */ +export function renderRcCatalog(catalog: RcCatalog): string { + const lines: string[] = [ + `RevenueCat project: ${catalog.projectName}`, + `Stores: ${catalog.apps.map((a) => `${a.name} (${a.type})`).join(', ') || '(none listed)'}`, + ...(catalog.complete + ? [] + : [ + '', + 'WARNING: some RevenueCat requests failed or were truncated - this catalog may be INCOMPLETE. Add to ADAPTY_SETUP.md that the user must compare it against the RC dashboard before trusting it.', + ]), + '', + 'Entitlements (one Adapty access level each; access level ID = lookup_key):', + ...(catalog.entitlements.length > 0 + ? catalog.entitlements.map( + (e) => `- ${e.lookupKey} ("${e.displayName}") - products: ${e.productStoreIds.join(', ') || '(none)'}`, + ) + : ['- (none - use the default "premium" access level and note that in ADAPTY_SETUP.md)']), + '', + 'Products (use these EXACT store identifiers):', + ...catalog.products.map( + (p) => + `- ${p.storeIdentifier}${p.storeType ? ` [${p.storeType}]` : ''} ("${p.displayName}") - entitlements: ${p.entitlementLookupKeys.join(', ') || '(none - flag the access-level choice)'}`, + ), + '', + 'Offerings (placement ID = lookup_key):', + ...catalog.offerings.map((o) => { + const packages = + o.packages === null + ? 'FETCH FAILED - do not guess; list this offering in ADAPTY_SETUP.md for manual verification' + : o.packages.map((pkg) => `${pkg.lookupKey}: ${pkg.productStoreIds.join('+')}`).join('; ') || '(none)' + const builder = + o.hasPublishedBuilderPaywall === true + ? 'PUBLISHED BUILDER PAYWALL - create NOTHING for this offering, reserve the placement ID for a flow (see rules)' + : o.hasPublishedBuilderPaywall === 'unknown' + ? 'builder paywall status UNKNOWN (API did not confirm; RC v1 paywalls are also invisible to it) - create NOTHING for this offering now; add to ADAPTY_SETUP.md: check in the RC dashboard whether it has a builder paywall, then either create the placement + paywall or rebuild it as a flow' + : 'no builder paywall - create placement + paywall' + return ( + `- ${o.lookupKey} ("${o.displayName}")${o.isCurrent ? ' [CURRENT]' : ''} - packages: ${packages} - ${builder}` + + (o.metadata ? `\n metadata (-> paywall remote config): ${JSON.stringify(o.metadata)}` : '') + ) + }), + ] + return lines.join('\n') +} diff --git a/src/lib/project/scan.ts b/src/lib/project/scan.ts new file mode 100644 index 0000000..85259bc --- /dev/null +++ b/src/lib/project/scan.ts @@ -0,0 +1,89 @@ +import {readdir, readFile} from 'node:fs/promises' +import {basename, join} from 'node:path' + +export type Platform = 'android' | 'capacitor' | 'flutter' | 'ios' | 'kmp' | 'react-native' | 'unity' + +export interface DetectedProject { + name: string + path: string + platform: Platform + platformLabel: string +} + +export const PLATFORM_LABELS: Record = { + android: 'Android', + capacitor: 'Capacitor', + flutter: 'Flutter', + ios: 'iOS', + kmp: 'Kotlin Multiplatform', + 'react-native': 'React Native', + unity: 'Unity', +} + +async function readJson(path: string): Promise> { + try { + return JSON.parse(await readFile(path, 'utf8')) as Record + } catch { + return null + } +} + +async function entries(dir: string): Promise { + try { + return await readdir(dir) + } catch { + return [] + } +} + +async function hasKmpModule(dir: string, topLevel: string[]): Promise { + const checks = await Promise.all( + topLevel.map(async (entry) => (await entries(join(dir, entry, 'src'))).includes('commonMain')), + ) + return checks.includes(true) +} + +/** + * Detect the mobile framework of the project at `dir`. Signals mirror the + * adapty-sdk-integration skill's Phase 1 table. Only the top level is + * inspected - monorepos should pass the app directory explicitly. + */ +export async function scanProject(dir: string): Promise { + const topLevel = await entries(dir) + const has = (name: string): boolean => topLevel.includes(name) + const pkg = has('package.json') ? await readJson(join(dir, 'package.json')) : null + const deps = { + ...(pkg?.dependencies as Record | undefined), + ...(pkg?.devDependencies as Record | undefined), + } + + let platform: null | Platform = null + let name: null | string = null + + if (deps['@capacitor/core']) { + platform = 'capacitor' + } else if (deps['react-native']) { + platform = 'react-native' + } else if (has('pubspec.yaml')) { + platform = 'flutter' + const pubspec = await readFile(join(dir, 'pubspec.yaml'), 'utf8').catch(() => '') + name = pubspec.match(/^name:\s*(\S+)/m)?.[1] ?? null + } else if (has('Assets') && has('ProjectSettings')) { + platform = 'unity' + } else if (topLevel.some((f) => f === 'build.gradle' || f === 'build.gradle.kts' || f.startsWith('settings.gradle'))) { + platform = (await hasKmpModule(dir, topLevel)) ? 'kmp' : 'android' + } else { + const xcodeproj = topLevel.find((f) => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace')) + if (xcodeproj || has('Package.swift') || topLevel.some((f) => f.endsWith('.swift'))) { + platform = 'ios' + if (xcodeproj) name = xcodeproj.replace(/\.(xcodeproj|xcworkspace)$/, '') + } + } + + if (!platform) return null + + if (!name && typeof pkg?.name === 'string') name = pkg.name + if (!name) name = basename(dir) + + return {name, path: dir, platform, platformLabel: PLATFORM_LABELS[platform]} +} diff --git a/src/lib/ui/ask.ts b/src/lib/ui/ask.ts new file mode 100644 index 0000000..4bf070f --- /dev/null +++ b/src/lib/ui/ask.ts @@ -0,0 +1,102 @@ +import * as clack from '@clack/prompts' + +/** + * Thin wrapper over @clack/prompts so callers stay library-agnostic. + * Non-interactive runs (piped stdin OR stdout, CI) never render prompts - + * every function resolves to its default immediately, so headless flows keep + * working. + */ + +export interface SelectOption { + hint?: string + label: string + value: string +} + +/** + * The single definition of "can we ask the user something": both streams + * must be terminals (clack renders on stdout and reads stdin). Commands must + * use this - not process.stdin.isTTY alone - or they will believe a question + * was asked when it silently auto-resolved. + */ +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY) +} + +/** Arrow-key select. Returns the chosen value, or null when cancelled (Esc/Ctrl-C). */ +export async function select(message: string, options: SelectOption[], defaultValue?: string): Promise { + if (!isInteractive()) return defaultValue ?? null + const result = await clack.select({ + initialValue: defaultValue, + message, + options: options.map((o) => ({hint: o.hint, label: o.label, value: o.value})), + }) + return clack.isCancel(result) ? null : result +} + +/** Yes/no. Returns null when cancelled (Esc/Ctrl-C) so callers can distinguish "No" from "get me out". */ +export async function confirm(message: string, defaultYes = true): Promise { + if (!isInteractive()) return defaultYes + const result = await clack.confirm({initialValue: defaultYes, message}) + return clack.isCancel(result) ? null : result +} + +export async function text(message: string, defaultValue?: string): Promise { + if (!isInteractive()) return defaultValue ?? '' + const result = await clack.text({defaultValue: defaultValue ?? '', message, placeholder: defaultValue}) + return clack.isCancel(result) ? '' : (result ?? '') +} + +export interface Spinner { + message(text: string): void + start(text: string): void + stop(text: string): void +} + +/** East-Asian wide chars and emoji occupy ~2 terminal columns. */ +const WIDE_CHAR = + + /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|\p{Extended_Pictographic}/u + +/** + * A spinner line that wraps past the terminal edge breaks clack's in-place + * redraw - every frame then prints a NEW line and the spinner looks stuck in + * a loop. Clip by DISPLAY width (code points, wide chars counted double) so + * it can never wrap; clack's own prefix + frame glyph eat a few columns. + */ +function clipToWidth(text: string): string { + const budget = (process.stdout.columns ?? 80) - 8 + let width = 0 + let out = '' + for (const ch of text) { + width += WIDE_CHAR.test(ch) ? 2 : 1 + if (width > budget - 1) return `${out}…` + out += ch + } + + return out +} + +/** In-place spinner; in non-interactive runs it degrades to plain log lines so CI output stays readable. */ +export function spinner(): Spinner { + if (!isInteractive()) { + return { + message: (t) => console.log(` • ${t}`), + start: (t) => console.log(t), + stop: (t) => console.log(t), + } + } + + const s = clack.spinner() + let lastMessage = '' + return { + message(t) { + const clipped = clipToWidth(t) + if (clipped === lastMessage) return + lastMessage = clipped + s.message(clipped) + }, + start: (t) => s.start(clipToWidth(t)), + stop: (t) => s.stop(t), + } +} diff --git a/src/lib/ui/clipboard.ts b/src/lib/ui/clipboard.ts new file mode 100644 index 0000000..4ce1ef3 --- /dev/null +++ b/src/lib/ui/clipboard.ts @@ -0,0 +1,17 @@ +import {execFile} from 'node:child_process' + +export async function copyToClipboard(content: string): Promise { + const cmd = + process.platform === 'darwin' ? 'pbcopy' : process.platform === 'win32' ? 'clip' : 'xclip -selection clipboard' + try { + const [bin, ...args] = cmd.split(' ') + await new Promise((resolvePromise, rejectPromise) => { + const child = execFile(bin, args, (error) => (error ? rejectPromise(error) : resolvePromise())) + child.stdin?.write(content) + child.stdin?.end() + }) + return true + } catch { + return false + } +} From 408802d5a284e5106bc48d86294eed61c3a86692 Mon Sep 17 00:00:00 2001 From: eandreeva-twr Date: Fri, 24 Jul 2026 12:17:12 +0200 Subject: [PATCH 02/10] Disclose telemetry after agent runs and add --no-telemetry flag Print a one-line notice after every run that sends the usage event, stating what is collected and how to disable it (--no-telemetry, ADAPTY_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1). Nothing is sent or printed when disabled; --copy runs never send anything. --- src/commands/integrate.ts | 4 ++++ src/commands/migrate.ts | 4 ++++ src/lib/agent/run.ts | 17 +++++++++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/commands/integrate.ts b/src/commands/integrate.ts index c28aeb4..7b2044d 100644 --- a/src/commands/integrate.ts +++ b/src/commands/integrate.ts @@ -20,6 +20,9 @@ static flags = { description: 'Print the integration prompt instead of running an agent (paste it into any coding agent)', }), driver: Flags.string({description: 'Force a specific coding agent', options: ['claude', 'codex']}), + 'no-telemetry': Flags.boolean({ + description: 'Do not send anonymous usage stats (also honored: ADAPTY_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1)', + }), path: Flags.string({description: 'App directory (defaults to the current directory)'}), } @@ -66,6 +69,7 @@ static flags = { driver: driver!, env: token ? {ADAPTY_TOKEN: token} : undefined, interactive, + noTelemetry: flags['no-telemetry'], }) if (!result.ok) reportActionFailure(this, driver!, result) } diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index a6dec5a..5b19b8a 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -23,6 +23,9 @@ static flags = { description: 'Print the migration prompt instead of running an agent (paste it into any coding agent)', }), driver: Flags.string({description: 'Force a specific coding agent', options: ['claude', 'codex']}), + 'no-telemetry': Flags.boolean({ + description: 'Do not send anonymous usage stats (also honored: ADAPTY_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1)', + }), path: Flags.string({description: 'App directory (defaults to the current directory)'}), 'rc-key': Flags.string({ description: @@ -126,6 +129,7 @@ static flags = { driver: driver!, env: token ? {ADAPTY_TOKEN: token} : undefined, interactive, + noTelemetry: flags['no-telemetry'], }) if (!result.ok) reportActionFailure(this, driver!, result) } diff --git a/src/lib/agent/run.ts b/src/lib/agent/run.ts index 7415dc8..16fdf28 100644 --- a/src/lib/agent/run.ts +++ b/src/lib/agent/run.ts @@ -8,7 +8,7 @@ import {copyToClipboard} from '../ui/clipboard.js' import {type AgentDriver, type AgentResult, runAgent} from './drivers.js' import {type AgentAction, buildActionPrompt, buildCopyPrompt, type PromptContext} from './prompt.js' import {installAgentSkills} from './skills-install.js' -import {trackAgentRun} from './telemetry.js' +import {telemetryDisabled, trackAgentRun} from './telemetry.js' const DASHBOARD_URL = 'https://app.adapty.io' @@ -48,6 +48,8 @@ export interface RunActionOptions { /** Extra env for the agent process only (e.g. ADAPTY_TOKEN) - keeps secrets out of the global process.env. */ env?: Record interactive: boolean + /** --no-telemetry: skip the usage event entirely (also honored: ADAPTY_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1). */ + noTelemetry?: boolean } /** @@ -58,7 +60,7 @@ export interface RunActionOptions { */ export async function runActionWithFollowUp( command: Command, - {action, ctx, driver, env, interactive}: RunActionOptions, + {action, ctx, driver, env, interactive, noTelemetry}: RunActionOptions, ): Promise { const spin = spinner() spin.start(`Running ${driver.displayName} - this can take a few minutes`) @@ -78,8 +80,10 @@ export async function runActionWithFollowUp( spin.stop(result.ok ? `${capitalize(action.title)} complete` : 'The agent stopped before finishing.') - const track = (rating: null | number) => - trackAgentRun({ + const sendTelemetry = !noTelemetry && !telemetryDisabled() + const track = async (rating: null | number) => { + if (!sendTelemetry) return + await trackAgentRun({ appId: ctx.appId, command: action.id, driver: driver.id, @@ -91,6 +95,11 @@ export async function runActionWithFollowUp( rating, version: command.config.version, }) + // Superwall-style disclosure: say what was sent and how to turn it off, every time. + command.log( + '\nShared anonymous usage stats with Adapty (platform, outcome, duration - never your code or keys). Disable with --no-telemetry or ADAPTY_TELEMETRY_DISABLED=1.', + ) + } if (!result.ok) { if (result.finalText) command.log(`\n${result.finalText.slice(0, 1500)}`) From a163deacaea42cb0342831d21ee2132917f9dd95 Mon Sep 17 00:00:00 2001 From: eandreeva-twr Date: Fri, 24 Jul 2026 14:05:18 +0200 Subject: [PATCH 03/10] Support Gemini, Cursor, and Copilot agents in integrate & migrate Split drivers.ts into a driver registry (src/lib/agent/drivers/) with one module per agent and shared runners for the two output protocols (stream-json and plain text). Adds an interactive agent picker when several agents are installed, derives --driver options and install/login hints from the registry, and hardens the runners: idle-timeout kill, NO_COLOR, bounded stdout tail, anchored [STATUS] parsing, and an exit-code fallback for Cursor's variable result event. --- src/commands/integrate.ts | 7 +- src/commands/migrate.ts | 3 +- src/lib/agent/drivers.ts | 226 ------------------------------- src/lib/agent/drivers/claude.ts | 33 +++++ src/lib/agent/drivers/codex.ts | 83 ++++++++++++ src/lib/agent/drivers/copilot.ts | 15 ++ src/lib/agent/drivers/cursor.ts | 24 ++++ src/lib/agent/drivers/gemini.ts | 15 ++ src/lib/agent/drivers/index.ts | 37 +++++ src/lib/agent/drivers/shared.ts | 206 ++++++++++++++++++++++++++++ src/lib/agent/run.ts | 4 +- src/lib/agent/wizard.ts | 69 +++++++--- test/lib/agent/drivers.test.ts | 141 +++++++++++++++++++ 13 files changed, 614 insertions(+), 249 deletions(-) delete mode 100644 src/lib/agent/drivers.ts create mode 100644 src/lib/agent/drivers/claude.ts create mode 100644 src/lib/agent/drivers/codex.ts create mode 100644 src/lib/agent/drivers/copilot.ts create mode 100644 src/lib/agent/drivers/cursor.ts create mode 100644 src/lib/agent/drivers/gemini.ts create mode 100644 src/lib/agent/drivers/index.ts create mode 100644 src/lib/agent/drivers/shared.ts create mode 100644 test/lib/agent/drivers.test.ts diff --git a/src/commands/integrate.ts b/src/commands/integrate.ts index 7b2044d..3f16a3b 100644 --- a/src/commands/integrate.ts +++ b/src/commands/integrate.ts @@ -2,13 +2,16 @@ import {Command, Flags} from '@oclif/core' import {resolve} from 'node:path' import {integrateAction} from '../lib/agent/actions/integrate.js' +import {DRIVER_IDS, DRIVERS} from '../lib/agent/drivers/index.js' import {emitCopyPrompt, reportActionFailure, runActionWithFollowUp} from '../lib/agent/run.js' import {preparePromptContext, prepareWizard} from '../lib/agent/wizard.js' import {billingLabel, detectBilling} from '../lib/project/billing.js' import {confirm, select} from '../lib/ui/ask.js' export default class Integrate extends Command { - static description = 'Set up the Adapty SDK in your app using your coding agent (Claude Code or Codex)' + static description = `Set up the Adapty SDK in your app using your coding agent (${DRIVERS.map( + (d) => d.displayName, + ).join(', ')})` static examples = [ '<%= config.bin %> integrate', '<%= config.bin %> integrate --path ./apps/mobile', @@ -19,7 +22,7 @@ static flags = { copy: Flags.boolean({ description: 'Print the integration prompt instead of running an agent (paste it into any coding agent)', }), - driver: Flags.string({description: 'Force a specific coding agent', options: ['claude', 'codex']}), + driver: Flags.string({description: 'Force a specific coding agent', options: DRIVER_IDS}), 'no-telemetry': Flags.boolean({ description: 'Do not send anonymous usage stats (also honored: ADAPTY_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1)', }), diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 5b19b8a..e6940f3 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -4,6 +4,7 @@ import {resolve} from 'node:path' import {promisify} from 'node:util' import {buildMigrateAction} from '../lib/agent/actions/migrate.js' +import {DRIVER_IDS} from '../lib/agent/drivers/index.js' import {emitCopyPrompt, reportActionFailure, runActionWithFollowUp} from '../lib/agent/run.js' import {preparePromptContext, prepareWizard} from '../lib/agent/wizard.js' import {BILLING_LABELS, type BillingId, billingLabel, detectBilling} from '../lib/project/billing.js' @@ -22,7 +23,7 @@ static flags = { copy: Flags.boolean({ description: 'Print the migration prompt instead of running an agent (paste it into any coding agent)', }), - driver: Flags.string({description: 'Force a specific coding agent', options: ['claude', 'codex']}), + driver: Flags.string({description: 'Force a specific coding agent', options: DRIVER_IDS}), 'no-telemetry': Flags.boolean({ description: 'Do not send anonymous usage stats (also honored: ADAPTY_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1)', }), diff --git a/src/lib/agent/drivers.ts b/src/lib/agent/drivers.ts deleted file mode 100644 index 7e32317..0000000 --- a/src/lib/agent/drivers.ts +++ /dev/null @@ -1,226 +0,0 @@ -import {execFile, spawn} from 'node:child_process' -import {readFile, rm} from 'node:fs/promises' -import {tmpdir} from 'node:os' -import {join} from 'node:path' -import {createInterface} from 'node:readline' -import {promisify} from 'node:util' - -const execFileAsync = promisify(execFile) - -export type DriverId = 'claude' | 'codex' - -export interface AgentDriver { - bin: string - displayName: string - id: DriverId - /** How the user re-authenticates this agent when its own session expires. */ - loginHint: string -} - -export interface AgentRunOptions { - cwd: string - /** Extra env vars for the agent process only (e.g. ADAPTY_TOKEN) - never mutate process.env. */ - env?: Record - onStatus?: (text: string) => void - prompt: string -} - -export interface AgentResult { - /** Set when the failure is recognizably the agent's own expired/missing login. */ - failureReason?: 'auth' - finalText?: string - ok: boolean -} - -const DRIVERS: AgentDriver[] = [ - {bin: 'claude', displayName: 'Claude Code', id: 'claude', loginHint: 'run `claude` and complete /login'}, - {bin: 'codex', displayName: 'Codex', id: 'codex', loginHint: 'run `codex login`'}, -] - -/** The agent's own session is expired/missing (Anthropic OAuth, Codex login) - not an Adapty error. */ -const AGENT_AUTH_ERROR = - /OAuth (access|refresh) token (has )?expired|failed to authenticate|please run \/login|invalid api key|re-authenticate|not logged in/i - -function withAuthCheck(result: AgentResult): AgentResult { - if (!result.ok && result.finalText && AGENT_AUTH_ERROR.test(result.finalText)) { - return {...result, failureReason: 'auth'} - } - - return result -} - -async function onPath(bin: string): Promise { - try { - await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [bin], {timeout: 5000}) - return true - } catch { - return false - } -} - -export async function detectDrivers(): Promise { - const found = await Promise.all(DRIVERS.map(async (d) => ((await onPath(d.bin)) ? d : null))) - return found.filter((d): d is AgentDriver => d !== null) -} - -const ALLOWED_TOOLS = ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash', 'WebFetch', 'WebSearch'] - -function extractStatus(textBlock: string): null | string { - return textBlock.match(/\[STATUS\]\s*(.+?)\s*$/m)?.[1]?.trim() ?? null -} - -interface SpawnStreamOptions { - args: string[] - bin: string - cwd: string - env?: Record - onLine: (line: string) => void - onStderrLine?: (line: string) => void -} - -function spawnStream({args, bin, cwd, env, onLine, onStderrLine}: SpawnStreamOptions): Promise { - return new Promise((resolve, reject) => { - const child = spawn(bin, args, { - cwd, - env: env ? {...process.env, ...env} : process.env, - stdio: ['ignore', 'pipe', 'pipe'], - }) - createInterface({input: child.stdout}).on('line', onLine) - createInterface({input: child.stderr}).on('line', onStderrLine ?? (() => {})) - child.on('error', reject) - child.on('close', (code) => resolve(code)) - }) -} - -const STDERR_TAIL_LINES = 30 - -/** Agents print auth errors to stderr without producing a result payload - keep a tail so failures stay diagnosable. */ -function stderrTailCollector(): {push: (line: string) => void; tail: () => string} { - const lines: string[] = [] - return { - push(line) { - lines.push(line) - if (lines.length > STDERR_TAIL_LINES) lines.shift() - }, - tail: () => lines.join('\n').trim(), - } -} - -async function runClaude(opts: AgentRunOptions): Promise { - const args = [ - '-p', - opts.prompt, - '--output-format', - 'stream-json', - '--verbose', - '--permission-mode', - 'acceptEdits', - '--add-dir', - opts.cwd, - '--allowedTools', - ALLOWED_TOOLS.join(','), - ] - - let finalText: string | undefined - let ok = false - const stderr = stderrTailCollector() - - const code = await spawnStream({ - args, - bin: 'claude', - cwd: opts.cwd, - env: opts.env, - onStderrLine: stderr.push, - // eslint-disable-next-line perfectionist/sort-objects - onLine(line) { - let msg: Record - try { - msg = JSON.parse(line) as Record - } catch { - return - } - - if (msg.type === 'assistant') { - const content = (msg.message as {content?: unknown})?.content - if (!Array.isArray(content)) return - for (const block of content) { - if (block?.type === 'text' && typeof block.text === 'string') { - const status = extractStatus(block.text) - if (status) opts.onStatus?.(status) - } - } - } else if (msg.type === 'result') { - if (typeof msg.result === 'string') finalText = msg.result - ok = msg.subtype === 'success' && msg.is_error !== true - } - }, - }) - - // Auth errors often land only on stderr with no result payload. - if (!finalText && code !== 0) finalText = stderr.tail() || undefined - return withAuthCheck({finalText, ok: ok && code === 0}) -} - -async function runCodex(opts: AgentRunOptions): Promise { - const lastMsgFile = join(tmpdir(), `adapty-codex-${process.pid}-${Date.now()}.txt`) - const args = [ - 'exec', - opts.prompt, - '--json', - '--sandbox', - 'workspace-write', - '-c', - 'sandbox_workspace_write.network_access=true', - '-C', - opts.cwd, - '--skip-git-repo-check', - '--output-last-message', - lastMsgFile, - ] - - const stderr = stderrTailCollector() - const code = await spawnStream({ - args, - bin: 'codex', - cwd: opts.cwd, - env: opts.env, - onStderrLine: stderr.push, - // eslint-disable-next-line perfectionist/sort-objects - onLine(line) { - let msg: Record - try { - msg = JSON.parse(line) as Record - } catch { - return - } - - // Codex's event schema shifts between versions; pull message text defensively. - const node = (msg.msg ?? msg.item ?? msg) as Record - const type = String(node.type ?? msg.type ?? '') - if (type.includes('agent_message') || type.includes('assistant')) { - const textValue = node.message ?? node.text ?? node.delta - if (typeof textValue === 'string') { - const status = extractStatus(textValue) - if (status) opts.onStatus?.(status) - } - } - }, - }) - - let finalText: string | undefined - try { - finalText = (await readFile(lastMsgFile, 'utf8')).trim() || undefined - } catch { - // no final message file - fine - } finally { - await rm(lastMsgFile, {force: true}).catch(() => {}) - } - - // Auth errors often land only on stderr with no last-message file. - if (!finalText && code !== 0) finalText = stderr.tail() || undefined - return withAuthCheck({finalText, ok: code === 0}) -} - -export async function runAgent(driver: AgentDriver, opts: AgentRunOptions): Promise { - return driver.id === 'claude' ? runClaude(opts) : runCodex(opts) -} diff --git a/src/lib/agent/drivers/claude.ts b/src/lib/agent/drivers/claude.ts new file mode 100644 index 0000000..a917ecc --- /dev/null +++ b/src/lib/agent/drivers/claude.ts @@ -0,0 +1,33 @@ +import {type AgentDriver, OAUTH_AUTH_ERROR, runStreamJson} from './shared.js' + +const ALLOWED_TOOLS = ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash', 'WebFetch', 'WebSearch'] + +export const claudeDriver: AgentDriver = { + authErrorPattern: OAUTH_AUTH_ERROR, + bin: 'claude', + displayName: 'Claude Code', + id: 'claude', + installHint: 'npm install --global @anthropic-ai/claude-code', + loginHint: 'run `claude` and complete /login', + resumeHint: 'claude "work through ADAPTY_SETUP.md"', + run: (opts) => + runStreamJson( + { + args: [ + '-p', + opts.prompt, + '--output-format', + 'stream-json', + '--verbose', + '--permission-mode', + 'acceptEdits', + '--add-dir', + opts.cwd, + '--allowedTools', + ALLOWED_TOOLS.join(','), + ], + bin: 'claude', + }, + opts, + ), +} diff --git a/src/lib/agent/drivers/codex.ts b/src/lib/agent/drivers/codex.ts new file mode 100644 index 0000000..6f426bb --- /dev/null +++ b/src/lib/agent/drivers/codex.ts @@ -0,0 +1,83 @@ +import {readFile, rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import { + type AgentDriver, + type AgentResult, + type AgentRunOptions, + extractStatuses, + OAUTH_AUTH_ERROR, + spawnStream, + STDERR_TAIL_LINES, + tailCollector, +} from './shared.js' + +async function run(opts: AgentRunOptions): Promise { + const lastMsgFile = join(tmpdir(), `adapty-codex-${process.pid}-${Date.now()}.txt`) + const args = [ + 'exec', + opts.prompt, + '--json', + '--sandbox', + 'workspace-write', + '-c', + 'sandbox_workspace_write.network_access=true', + '-C', + opts.cwd, + '--skip-git-repo-check', + '--output-last-message', + lastMsgFile, + ] + + const stderr = tailCollector(STDERR_TAIL_LINES) + const code = await spawnStream({ + args, + bin: 'codex', + cwd: opts.cwd, + env: opts.env, + onLine(line) { + let msg: Record + try { + msg = JSON.parse(line) as Record + } catch { + return + } + + // Codex's event schema shifts between versions; pull message text defensively. + const node = (msg.msg ?? msg.item ?? msg) as Record + const type = String(node.type ?? msg.type ?? '') + if (type.includes('agent_message') || type.includes('assistant')) { + const textValue = node.message ?? node.text ?? node.delta + if (typeof textValue === 'string') { + for (const status of extractStatuses(textValue)) opts.onStatus?.(status) + } + } + }, + onStderrLine: stderr.push, + }) + + let finalText: string | undefined + try { + finalText = (await readFile(lastMsgFile, 'utf8')).trim() || undefined + } catch { + // no final message file - fine + } finally { + await rm(lastMsgFile, {force: true}).catch(() => {}) + } + + // Auth errors often land only on stderr with no last-message file. + if (!finalText && code !== 0) finalText = stderr.tail() || undefined + return {finalText, ok: code === 0} +} + +export const codexDriver: AgentDriver = { + authErrorPattern: OAUTH_AUTH_ERROR, + bin: 'codex', + displayName: 'Codex', + id: 'codex', + installHint: 'npm install --global @openai/codex', + loginHint: 'run `codex login`', + resumeHint: 'codex "work through ADAPTY_SETUP.md"', + run, +} diff --git a/src/lib/agent/drivers/copilot.ts b/src/lib/agent/drivers/copilot.ts new file mode 100644 index 0000000..df85799 --- /dev/null +++ b/src/lib/agent/drivers/copilot.ts @@ -0,0 +1,15 @@ +import {type AgentDriver, runPlainText} from './shared.js' + +export const copilotDriver: AgentDriver = { + authErrorPattern: /not (logged in|authenticated)|authentication (required|failed)|use \/login|gh auth login/i, + bin: 'copilot', + displayName: 'Copilot CLI', + id: 'copilot', + installHint: 'npm install --global @github/copilot', + loginHint: 'run `copilot` and complete /login', + resumeHint: 'copilot -p "work through ADAPTY_SETUP.md"', + // --allow-all-tools skips per-tool approval prompts (headless runs would + // otherwise stall); --add-dir grants write access to the project. + run: (opts) => + runPlainText({args: ['-p', opts.prompt, '--allow-all-tools', '--add-dir', opts.cwd], bin: 'copilot'}, opts), +} diff --git a/src/lib/agent/drivers/cursor.ts b/src/lib/agent/drivers/cursor.ts new file mode 100644 index 0000000..02abfed --- /dev/null +++ b/src/lib/agent/drivers/cursor.ts @@ -0,0 +1,24 @@ +import {type AgentDriver, runStreamJson} from './shared.js' + +export const cursorDriver: AgentDriver = { + authErrorPattern: /not (logged in|authenticated)|cursor-agent login|unauthorized|sign in/i, + bin: 'cursor-agent', + displayName: 'Cursor CLI', + id: 'cursor', + installHint: 'curl https://cursor.com/install -fsS | bash', + loginHint: 'run `cursor-agent login`', + resumeHint: 'cursor-agent "work through ADAPTY_SETUP.md"', + run: (opts) => + runStreamJson( + { + // --force allows file edits and commands in print mode - the same posture as the Claude driver. + args: ['-p', opts.prompt, '--output-format', 'stream-json', '--force'], + bin: 'cursor-agent', + // Cursor's stream-json protocol varies between versions; don't fail a clean exit just because no result event arrived. + exitCodeFallback: true, + // Cursor's result event doesn't always carry a subtype; is_error is the reliable signal. + okFromResult: (msg) => msg.is_error !== true, + }, + opts, + ), +} diff --git a/src/lib/agent/drivers/gemini.ts b/src/lib/agent/drivers/gemini.ts new file mode 100644 index 0000000..36ab26f --- /dev/null +++ b/src/lib/agent/drivers/gemini.ts @@ -0,0 +1,15 @@ +import {type AgentDriver, runPlainText} from './shared.js' + +export const geminiDriver: AgentDriver = { + authErrorPattern: /set an auth method|not authenticated|login required|oauth.*(expired|invalid)|reauthenticate/i, + bin: 'gemini', + displayName: 'Gemini CLI', + id: 'gemini', + installHint: 'npm install --global @google/gemini-cli', + loginHint: 'run `gemini` and pick an auth method', + resumeHint: 'gemini -i "work through ADAPTY_SETUP.md"', + // --yolo auto-approves edits and shell commands - the same posture as the + // Claude driver (acceptEdits + Bash in allowedTools); headless runs would + // otherwise stall on approval prompts. + run: (opts) => runPlainText({args: ['-p', opts.prompt, '--yolo'], bin: 'gemini'}, opts), +} diff --git a/src/lib/agent/drivers/index.ts b/src/lib/agent/drivers/index.ts new file mode 100644 index 0000000..6214e3d --- /dev/null +++ b/src/lib/agent/drivers/index.ts @@ -0,0 +1,37 @@ +import {execFile} from 'node:child_process' +import {promisify} from 'node:util' + +import {claudeDriver} from './claude.js' +import {codexDriver} from './codex.js' +import {copilotDriver} from './copilot.js' +import {cursorDriver} from './cursor.js' +import {geminiDriver} from './gemini.js' +import {type AgentDriver, type AgentResult, type AgentRunOptions, withAuthCheck} from './shared.js' + +export type {AgentDriver, AgentResult, AgentRunOptions, DriverId} from './shared.js' + +const execFileAsync = promisify(execFile) + +/** All supported agents; array order is the preference order when several are installed. */ +export const DRIVERS: AgentDriver[] = [claudeDriver, codexDriver, geminiDriver, cursorDriver, copilotDriver] + +/** For the --driver flag's `options` list. */ +export const DRIVER_IDS = DRIVERS.map((d) => d.id) + +async function onPath(bin: string): Promise { + try { + await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [bin], {timeout: 5000}) + return true + } catch { + return false + } +} + +export async function detectDrivers(): Promise { + const found = await Promise.all(DRIVERS.map(async (d) => ((await onPath(d.bin)) ? d : null))) + return found.filter((d): d is AgentDriver => d !== null) +} + +export async function runAgent(driver: AgentDriver, opts: AgentRunOptions): Promise { + return withAuthCheck(await driver.run(opts), driver.authErrorPattern) +} diff --git a/src/lib/agent/drivers/shared.ts b/src/lib/agent/drivers/shared.ts new file mode 100644 index 0000000..434ffa8 --- /dev/null +++ b/src/lib/agent/drivers/shared.ts @@ -0,0 +1,206 @@ +import {spawn} from 'node:child_process' +import {createInterface} from 'node:readline' + +export type DriverId = 'claude' | 'codex' | 'copilot' | 'cursor' | 'gemini' + +export interface AgentDriver { + /** Matches when a failure is recognizably this agent's own expired/missing login - drives the loginHint messaging. */ + authErrorPattern: RegExp + bin: string + displayName: string + id: DriverId + /** Shown when no agent is found on PATH. */ + installHint: string + /** How the user re-authenticates this agent when its own session expires. */ + loginHint: string + /** Command that hands ADAPTY_SETUP.md to this agent after the run. */ + resumeHint: string + run(opts: AgentRunOptions): Promise +} + +export interface AgentRunOptions { + cwd: string + /** Extra env vars for the agent process only (e.g. ADAPTY_TOKEN) - never mutate process.env. */ + env?: Record + onStatus?: (text: string) => void + prompt: string +} + +export interface AgentResult { + /** Set when the failure is recognizably the agent's own expired/missing login. */ + failureReason?: 'auth' + finalText?: string + ok: boolean +} + +/** Claude Code and Codex share OAuth-style login wording; kept in one place so a wording change can't drift between them. */ +export const OAUTH_AUTH_ERROR = + /OAuth (access|refresh) token (has )?expired|failed to authenticate|please run \/login|invalid api key|re-authenticate|not logged in/i + +export function withAuthCheck(result: AgentResult, authErrorPattern: RegExp): AgentResult { + if (!result.ok && result.finalText && authErrorPattern.test(result.finalText)) { + return {...result, failureReason: 'auth'} + } + + return result +} + +/** [STATUS] must start its line - agents also mention the token mid-sentence when describing their own work. */ +export function extractStatuses(textBlock: string): string[] { + return [...textBlock.matchAll(/^\[STATUS\]\s*(.+?)\s*$/gm)].map((m) => m[1].trim()) +} + +export interface SpawnStreamOptions { + args: string[] + bin: string + cwd: string + env?: Record + onLine: (line: string) => void + onStderrLine?: (line: string) => void +} + +/** Kill agents that stop producing output - a headless CLI stuck on an interactive prompt would otherwise hang the spinner forever. */ +const IDLE_TIMEOUT_MS = 15 * 60_000 + +export function spawnStream({args, bin, cwd, env, onLine, onStderrLine}: SpawnStreamOptions): Promise { + return new Promise((resolve, reject) => { + const child = spawn(bin, args, { + cwd, + // NO_COLOR keeps ANSI escapes out of everything surfaced to the user (stderr tails included). + env: {...process.env, NO_COLOR: '1', ...env}, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const idleTimer = setTimeout(() => child.kill('SIGTERM'), IDLE_TIMEOUT_MS) + createInterface({input: child.stdout}).on('line', (line) => { + idleTimer.refresh() + onLine(line) + }) + createInterface({input: child.stderr}).on('line', (line) => { + idleTimer.refresh() + onStderrLine?.(line) + }) + child.on('error', (error) => { + clearTimeout(idleTimer) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(idleTimer) + resolve(code) + }) + }) +} + +export const STDERR_TAIL_LINES = 30 +/** Enough for the closing summary the prompt asks for; bounded so a chatty transcript can't grow without limit. */ +const STDOUT_TAIL_LINES = 100 + +/** Agents print auth errors to stderr without producing a result payload, and chatty stdout is unbounded - keep tails so both stay diagnosable in constant memory. */ +export function tailCollector(maxLines: number): {push: (line: string) => void; tail: () => string} { + const lines: string[] = [] + return { + push(line) { + lines.push(line) + if (lines.length > maxLines) lines.shift() + }, + tail: () => lines.join('\n').trim(), + } +} + +export interface StreamJsonOptions { + args: string[] + bin: string + /** Fall back to the exit code when the stream never emits a result event - for third-party CLIs whose protocol varies between versions. */ + exitCodeFallback?: boolean + /** Whether a `result` event means success; default requires subtype "success" and no is_error. */ + okFromResult?: (msg: Record) => boolean +} + +/** + * Runner for agents that speak the Claude Code stream-json protocol + * (Claude Code, Cursor): `assistant` events carry [STATUS] text blocks, + * a final `result` event carries the outcome and summary. + */ +export async function runStreamJson( + {args, bin, exitCodeFallback, okFromResult}: StreamJsonOptions, + opts: AgentRunOptions, +): Promise { + const isOk = okFromResult ?? ((msg) => msg.subtype === 'success' && msg.is_error !== true) + let finalText: string | undefined + let ok = false + let sawResult = false + const stderr = tailCollector(STDERR_TAIL_LINES) + + const code = await spawnStream({ + args, + bin, + cwd: opts.cwd, + env: opts.env, + onLine(line) { + let msg: Record + try { + msg = JSON.parse(line) as Record + } catch { + return + } + + if (msg.type === 'assistant') { + const content = (msg.message as {content?: unknown})?.content + if (!Array.isArray(content)) return + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string') { + for (const status of extractStatuses(block.text)) opts.onStatus?.(status) + } + } + } else if (msg.type === 'result') { + sawResult = true + if (typeof msg.result === 'string') finalText = msg.result + ok = isOk(msg) + } + }, + onStderrLine: stderr.push, + }) + + if (!sawResult && exitCodeFallback) ok = code === 0 + // Auth errors often land only on stderr with no result payload. + if (!finalText && code !== 0) finalText = stderr.tail() || undefined + return {finalText, ok: ok && code === 0} +} + +/** + * Runner for agents that stream plain text with no event protocol + * (Gemini, Copilot): [STATUS] lines drive the spinner and everything else + * accumulates into finalText. + */ +export async function runPlainText( + {args, bin}: {args: string[]; bin: string}, + opts: AgentRunOptions, +): Promise { + const output = tailCollector(STDOUT_TAIL_LINES) + const stderr = tailCollector(STDERR_TAIL_LINES) + let sawStdout = false + + const code = await spawnStream({ + args, + bin, + cwd: opts.cwd, + env: opts.env, + onLine(line) { + sawStdout = true + const [status] = extractStatuses(line) + if (status) { + opts.onStatus?.(status) + return + } + + output.push(line) + }, + onStderrLine: stderr.push, + }) + + // An agent that exits 0 without a single stdout line almost certainly stalled + // (auth refusals often land only on stderr) - don't report that as success. + const ok = code === 0 && sawStdout + let finalText = output.tail() || undefined + if (!finalText && !ok) finalText = stderr.tail() || undefined + return {finalText, ok} +} diff --git a/src/lib/agent/run.ts b/src/lib/agent/run.ts index 16fdf28..c08aa9d 100644 --- a/src/lib/agent/run.ts +++ b/src/lib/agent/run.ts @@ -5,7 +5,7 @@ import {join} from 'node:path' import {spinner, text} from '../ui/ask.js' import {copyToClipboard} from '../ui/clipboard.js' -import {type AgentDriver, type AgentResult, runAgent} from './drivers.js' +import {type AgentDriver, type AgentResult, runAgent} from './drivers/index.js' import {type AgentAction, buildActionPrompt, buildCopyPrompt, type PromptContext} from './prompt.js' import {installAgentSkills} from './skills-install.js' import {telemetryDisabled, trackAgentRun} from './telemetry.js' @@ -132,7 +132,7 @@ export async function runActionWithFollowUp( if (existsSync(join(ctx.project.path, 'ADAPTY_SETUP.md'))) { command.log( 'The remaining steps are in ADAPTY_SETUP.md - work through it yourself, or hand it to your agent:\n' + - ' claude "work through ADAPTY_SETUP.md"', + ` ${driver.resumeHint}`, ) } diff --git a/src/lib/agent/wizard.ts b/src/lib/agent/wizard.ts index 3e56730..df48daf 100644 --- a/src/lib/agent/wizard.ts +++ b/src/lib/agent/wizard.ts @@ -8,7 +8,7 @@ import {createAuthenticatedClient} from '../client-from-config.js' import {isValidUuid, type PaginatedResponse, paginationParams} from '../flags.js' import {type DetectedProject, scanProject} from '../project/scan.js' import {confirm, isInteractive, select, spinner, text} from '../ui/ask.js' -import {type AgentDriver, detectDrivers} from './drivers.js' +import {type AgentDriver, detectDrivers, DRIVERS} from './drivers/index.js' import {type PromptContext, resolveCliCommand} from './prompt.js' import {loadPlatformReference} from './skill-source.js' @@ -52,29 +52,15 @@ export async function prepareWizard(command: Command, flags: WizardFlags): Promi command.log(`Detected ${project.platformLabel} app "${project.name}"`) // 2. Which agent will do the work? + const interactive = isInteractive() let driver: AgentDriver | null = null if (!flags.copy) { - const drivers = await detectDrivers() - driver = flags.driver ? (drivers.find((d) => d.id === flags.driver) ?? null) : (drivers[0] ?? null) - if (flags.driver && !driver) { - command.error( - `Agent "${flags.driver}" not found on PATH. Detected: ${drivers.map((d) => d.id).join(', ') || 'none'}.`, - ) - } - - if (!driver) { - command.log('\nNo coding agent found (looked for Claude Code and Codex).') - command.log(`Install one and re-run, or use \`adapty ${commandName} --copy\` to get a prompt for any agent:`) - command.log(' Claude Code: npm install --global @anthropic-ai/claude-code') - command.log(' Codex: npm install --global @openai/codex') - return null - } - + driver = await resolveDriver(command, commandName, interactive, flags.driver) + if (!driver) return null command.log(`Using ${driver.displayName} as the coding agent`) } // 3. Auth - needed to pick/create the app and for the agent's `adapty` CLI calls. - const interactive = isInteractive() let token = await resolveToken(command.config.configDir) if (!token && interactive) { const wantsLogin = await confirm('You are not logged in to Adapty. Log in now?') @@ -139,6 +125,53 @@ export async function preparePromptContext(setup: WizardSetup, paywallApproach: } } +/** + * Pick the coding agent: --driver wins, a single detected agent is used + * as-is, several detected agents become an interactive choice (first = + * default; headless runs also take the first). Returns null when nothing is + * installed or the user cancels (already logged). + */ +async function resolveDriver( + command: Command, + commandName: string, + interactive: boolean, + driverFlag?: string, +): Promise { + const drivers = await detectDrivers() + + if (driverFlag) { + const driver = drivers.find((d) => d.id === driverFlag) + if (!driver) { + command.error(`Agent "${driverFlag}" not found on PATH. Detected: ${drivers.map((d) => d.id).join(', ') || 'none'}.`) + } + + return driver + } + + if (drivers.length === 0) { + const longestName = Math.max(...DRIVERS.map((d) => d.displayName.length)) + command.log(`\nNo coding agent found (looked for ${DRIVERS.map((d) => d.displayName).join(', ')}).`) + command.log(`Install one and re-run, or use \`adapty ${commandName} --copy\` to get a prompt for any agent:`) + for (const d of DRIVERS) command.log(` ${`${d.displayName}:`.padEnd(longestName + 1)} ${d.installHint}`) + return null + } + + if (drivers.length === 1 || !interactive) return drivers[0] + + const choice = await select( + 'Which coding agent should do the work?', + drivers.map((d) => ({label: d.displayName, value: d.id})), + drivers[0].id, + ) + if (!choice) { + command.log('Cancelled.') + return null + } + + // choice comes from options built from this same drivers list, so the lookup always succeeds. + return drivers.find((d) => d.id === choice)! +} + async function createApp( command: Command, client: ApiClient, diff --git a/test/lib/agent/drivers.test.ts b/test/lib/agent/drivers.test.ts new file mode 100644 index 0000000..f107c7a --- /dev/null +++ b/test/lib/agent/drivers.test.ts @@ -0,0 +1,141 @@ +import {expect} from 'chai' + +import {DRIVER_IDS, DRIVERS} from '../../../src/lib/agent/drivers/index.js' +import {type AgentRunOptions, runPlainText, runStreamJson, withAuthCheck} from '../../../src/lib/agent/drivers/shared.js' + +/** Run a runner against `node -e