// The tools `storebound mcp` offers, as data. Pure, so tests can check the list and the argv each call builds. // Rules: one tool per CLI command; every tool that writes anything has `dry_run` defaulting to true; nothing that // submits for review, releases to the public, resumes / completes a rollout, cancels a submission or deletes is // exposed. Those stay terminal commands the owner runs (they also ask for confirmation there). export interface JsonSchema { type: string; description?: string; enum?: (string | number)[]; default?: unknown; items?: JsonSchema; minimum?: number; maximum?: number; pattern?: string; additionalProperties?: JsonSchema | boolean } export interface ToolDef { name: string; title: string; description: string; /** CLI words before the options, e.g. ['play', 'listing']. */ command: string[]; /** Writes to a store or to local files. Gets `dry_run` (default true). */ write: boolean; /** Replaces existing data (screenshots, a whole form) instead of only adding. */ destructive?: boolean; idempotent?: boolean; /** Talks to a store API or the internet. */ network: boolean; props?: Record; required?: string[]; /** Input -> extra CLI options (dry_run is handled for every write tool). */ args?: (i: Record) => string[]; } const only: JsonSchema = { type: 'string', description: 'Comma-separated ids / language codes to limit the run to (optional)' }; const lang: JsonSchema = { type: 'string', description: 'Comma-separated language ids from the config (default: all)' }; const variant: JsonSchema = { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$', description: 'A/B variant id from shots.variants (default: the base renders)' }; const source: JsonSchema = { type: 'string', enum: ['web', 'ios-sim', 'android-emu', 'folder'], description: 'Default: from the config' }; const APP_NAMES = '^[^,\\x00-\\x1f]{1,120}(,[^,\\x00-\\x1f]{1,120}){0,99}$'; const opt = (flag: string, v: unknown): string[] => (v === undefined || v === null || v === '' ? [] : [`--${flag}`, String(v)]); const flag = (name: string, v: unknown): string[] => (v === true ? [`--${name}`] : []); export const TOOLS: ToolDef[] = [ { name: 'check', title: 'Offline config check', command: ['check'], write: false, network: false, description: 'Validate storebound.config.ts, both listing files, screenshot copy, Data safety answers, product texts and referenced files. Offline.' }, { name: 'check_aso', title: 'Keyword audit', command: ['check'], write: false, network: false, description: 'Offline ASO audit of the listing files: App Store keyword field rules (100 chars, no spaces, no name/subtitle repeats, >= 90% used) and Play keyword stuffing.', props: { storefront: { type: 'string', description: 'App Store storefront for the cross-locale hint', default: 'US' } }, args: i => ['--aso', ...opt('storefront', i.storefront)] }, { name: 'scan', title: 'Rejection-risk scan of the project', command: ['scan'], write: false, network: false, description: 'Offline, read-only scan of the project itself before submitting: detects the stack (iOS, Android, React Native, Expo, Capacitor, Cordova, Flutter, Unity, Godot) and SDKs (ads, analytics, crash, sign-in, payments, push) with versions and file:line, then reports App Store and Google Play rejection risks with the guideline, evidence, fix, severity and skill (demo account 2.1, account deletion 5.1.1(v), Sign in with Apple 4.8, Restore Purchases 3.1.1, subscription info 3.1.2, ATT 5.1.2, purpose strings, privacy manifest, other platforms 2.3.10, keywords 2.3.7, placeholder text, web wrappers 4.2, UIWebView, ATS, IPv6, export compliance, kids ads, Play target SDK, delete-account URL, restricted permissions and their declarations (background location, photo / video, All files access, accessibility, install packages, Health Connect, full-screen intent, VPN, foreground services, exact alarms, QUERY_ALL_PACKAGES, SMS / call log), prominent disclosure, Families, AD_ID, 16 KB page sizes, testing for personal accounts, Play metadata, ads at launch, payments, impersonation) and proposes the Data safety / App Privacy / ATT / privacy manifest answers as a diff against the config. Never writes (the owner runs `storebound scan --write` in a terminal).', props: { store: { type: 'string', enum: ['apple', 'play'], description: 'Only this store\'s findings (default: both)' }, originality: { type: 'boolean', default: false, description: 'Also the 4.3 / Play repetitive content check against your other apps (folders from against, else originality.against in the config)' }, against: { type: 'string', description: 'Comma-separated folders of your other apps (with originality)' } }, args: i => [...opt('store', i.store), ...flag('originality', i.originality), ...(i.originality ? opt('against', i.against) : [])] }, { name: 'validate', title: 'Store readiness report', command: ['validate'], write: false, network: true, description: 'Read-only readiness report for App Store Connect and/or Google Play with ordered blockers (build, encryption, metadata, screenshots, review details and demo account, age rating, prices, IAPs, subscriptions, privacy, delete-account and terms URLs, app-ads.txt, tracks, products, base plans).', props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' } }, args: i => (i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []) }, { name: 'health', title: 'Crash and vitals health', command: ['health'], write: false, network: true, description: 'Read-only crash / ANR vitals from the Play Developer Reporting API (user-perceived rates vs the bad behavior thresholds 1.09 % / 0.47 %, worst device models, top issues) and App Store power and performance metrics, diagnostic signatures and TestFlight crash reports. Exit 1 when a metric is over a store threshold.', props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 7 } }, args: i => [...(i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []), ...opt('days', i.days)] }, { name: 'health_report', title: 'Weekly health report', command: ['health', '--weekly'], write: true, network: true, idempotent: true, description: 'Weekly health summary as Markdown in .storebound/health/.md (plus .json for the next week-over-week comparison). Local files only; dry_run prints the report.', props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' } }, args: i => (i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []) }, { name: 'reviews_list', title: 'Store reviews', command: ['reviews', 'list'], write: false, network: true, description: 'Read-only list of recent App Store and Google Play reviews (date, stars, territory / language, replied or not, text). Google Play only returns reviews from the last 7 days.', props: { store: { type: 'string', enum: ['apple', 'play'], description: 'Default: both' }, days: { type: 'integer', minimum: 1, maximum: 365, default: 14 }, unanswered: { type: 'boolean', default: false }, rating: { type: 'string', pattern: '^[1-5](,[1-5])*$', description: 'e.g. 1,2' } }, args: i => [...opt('store', i.store), ...opt('days', i.days), ...flag('unanswered', i.unanswered), ...opt('rating', i.rating)] }, { name: 'reviews_draft', title: 'Draft review replies', command: ['reviews', 'draft'], write: true, network: true, idempotent: true, description: 'Write one local Markdown draft per unanswered review into .storebound/replies/-.md (approved: false; existing drafts are kept). Then write the reply under "## Reply" following the review-replies skill; the owner approves and sends from the terminal. Nothing is sent to a store.', props: { store: { type: 'string', enum: ['apple', 'play'], description: 'Default: both' }, days: { type: 'integer', minimum: 1, maximum: 365, default: 14 }, rating: { type: 'string', pattern: '^[1-5](,[1-5])*$', description: 'Only these star ratings, e.g. 1,2,3' } }, args: i => [...opt('store', i.store), ...opt('days', i.days), ...opt('rating', i.rating)] }, { name: 'catalog_diff', title: 'Cross-store catalog parity', command: ['catalog', 'diff'], write: false, network: true, description: 'Read-only parity report: the products and subscriptions of the config against App Store Connect and Google Play (missing / extra items, type, billing period, level, US and override prices, localizations, offers) plus regions where the two stores price the same product differently (same currency, over the threshold).', props: { store: { type: 'string', enum: ['apple', 'play'] }, threshold: { type: 'integer', minimum: 0, maximum: 100, default: 10, description: 'Percent difference between the stores to report' }, json: { type: 'boolean', default: false } }, args: i => [...opt('store', i.store), ...opt('threshold', i.threshold), ...flag('json', i.json)] }, { name: 'prices_diff', title: 'Price sheet diff', command: ['prices', 'import'], write: false, network: true, description: 'Read-only: compare the prices CSV (product, store, region, currency, price, start_date) with the current App Store and Google Play prices and print the diff table. Applying it is an owner-only terminal command (storebound prices import, asks first).', props: { csv: { type: 'string', description: 'CSV path (default prices.csv in the config, else store/prices.csv)' }, store: { type: 'string', enum: ['apple', 'play'] }, only, all: { type: 'boolean', default: false, description: 'Also list unchanged rows' } }, args: i => [...opt('csv', i.csv), ...opt('store', i.store), ...opt('only', i.only), ...flag('all', i.all), '--dry'] }, { name: 'prices_ppp', title: 'Purchasing power price sheet', command: ['prices', 'ppp'], write: true, network: true, idempotent: true, description: 'Proposes per-region prices adjusted for purchasing power (bundled World Bank ICP PPP factors, CC BY 4.0, or the Big Mac index / a CSV), clamped to floor..ceiling of the US price and snapped to store price points, and writes a price sheet in the prices import format (default store/prices-ppp.csv) with a diff against the current store prices. Writes only that local file; nothing goes to a store (prices import does, after the owner confirms in a terminal).', props: { base: { type: 'number', minimum: 0.01, description: 'USD base price for every selected product (default: each product\'s usd)' }, source: { type: 'string', enum: ['worldbank', 'bigmac', 'file'] }, store: { type: 'string', enum: ['apple', 'play'] }, only, floor: { type: 'number', minimum: 0.01, maximum: 5 }, ceiling: { type: 'number', minimum: 0.01, maximum: 5 }, offline: { type: 'boolean', default: false, description: 'No store reads: snap with the local ending rules only' } }, args: i => [...opt('base', i.base), ...opt('source', i.source), ...opt('store', i.store), ...opt('only', i.only), ...opt('floor', i.floor), ...opt('ceiling', i.ceiling), ...flag('offline', i.offline)] }, { name: 'ads_apple_status', title: 'Apple Ads access', command: ['ads', 'apple', 'status'], write: false, network: true, description: 'Read-only: does the Apple Ads API key work, which ad accounts and roles it has, and which one storebound uses.' }, { name: 'ads_apple_campaigns', title: 'Apple Ads campaigns', command: ['ads', 'apple', 'campaigns'], write: false, network: true, description: 'Read-only list of Apple Ads campaigns (status, daily budget, countries, app) for the config\'s app, or every app with all.', props: { all: { type: 'boolean', default: false } }, args: i => flag('all', i.all) }, { name: 'ads_apple_report', title: 'Apple Ads report', command: ['ads', 'apple', 'report'], write: false, network: true, description: 'Read-only Apple Ads report per campaign or keyword: spend, impressions, taps, installs, TTR, conversion rate, CPA, average CPT and totals. Bids, budgets and pausing are owner decisions in a terminal, never here.', props: { days: { type: 'integer', minimum: 1, maximum: 90, default: 7 }, by: { type: 'string', enum: ['campaign', 'keyword'], default: 'campaign' }, campaign: { type: 'string', pattern: '^\\d{1,20}$' }, all: { type: 'boolean', default: false } }, args: i => [...opt('days', i.days), ...opt('by', i.by), ...opt('campaign', i.campaign), ...flag('all', i.all)] }, { name: 'web_init', title: 'Universal / app links files', command: ['web', 'init'], write: true, network: false, idempotent: true, description: 'Writes apple-app-site-association, .well-known/assetlinks.json, the Smart App Banner tag and optionally a minimal landing page (store badges, privacy and support links) into a local folder (default web/) from the config. Local files only; deploying them is the owner\'s step.', props: { out: { type: 'string', pattern: '^[A-Za-z0-9._/-]{1,200}$', description: 'Output folder (default web.out or web)' }, landing: { type: 'boolean', default: false } }, args: i => [...opt('out', i.out), ...flag('landing', i.landing)] }, { name: 'web_check', title: 'Check the live link files', command: ['web', 'check'], write: false, network: true, description: 'Read-only check of https:///.well-known/apple-app-site-association and assetlinks.json: HTTP 200 without redirects, JSON content type, valid JSON, team / bundle id and package / signing fingerprint match the config.', props: { domain: { type: 'string', pattern: '^[a-z0-9.-]{1,253}$', description: 'Default: web.domain of the config' } }, args: i => (i.domain ? [String(i.domain)] : []) }, { name: 'apple_adhoc_devices', title: 'Registered test devices', command: ['apple', 'adhoc', 'devices'], write: false, network: true, description: 'Read-only list of the devices registered for ad-hoc / development builds (UDIDs masked) and the yearly slots used. Registering devices stays a terminal command.', props: { platform: { type: 'string', enum: ['IOS', 'MAC_OS'] } }, args: i => opt('platform', i.platform) }, { name: 'apple_adhoc_options', title: 'Ad-hoc ExportOptions.plist', command: ['apple', 'adhoc', 'options'], write: true, network: false, idempotent: true, description: 'Writes an ExportOptions.plist for an ad-hoc / release-testing export (method, team id, signing style) to build/ExportOptions.plist. Local file only.', props: { method: { type: 'string', enum: ['release-testing', 'ad-hoc'] } }, args: i => opt('method', i.method) }, { name: 'revenuecat_diff', title: 'RevenueCat parity', command: ['revenuecat', 'diff'], write: false, network: true, description: 'Read-only parity report between the config catalog and the RevenueCat project: products, entitlements and their products, the default offering and its packages. Syncing is a terminal command the owner confirms.' }, { name: 'prices_export', title: 'Export prices to CSV', command: ['prices', 'export'], write: true, network: true, idempotent: true, description: 'Read the current prices of every product on both stores and write them as the prices CSV (local file only; Apple equalized prices with with_automatic, marked kind=auto). dry_run prints the CSV.', props: { out: { type: 'string', description: 'CSV path' }, store: { type: 'string', enum: ['apple', 'play'] }, only, with_automatic: { type: 'boolean', default: false } }, args: i => [...opt('out', i.out), ...opt('store', i.store), ...opt('only', i.only), ...flag('with-automatic', i.with_automatic)] }, { name: 'analytics_snapshot', title: 'Store analytics snapshot', command: ['analytics', 'snapshot'], write: true, network: true, idempotent: true, description: 'Read store analytics (App Store: impressions, product page views, downloads via the Analytics Reports API; Google Play: store listing visitors, acquisitions, installs from the Play reports bucket) and write .storebound/analytics/.json. Local file only; dry_run reads and prints the totals.', props: { days: { type: 'integer', minimum: 1, maximum: 120, default: 28 }, store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('days', i.days), ...opt('store', i.store)] }, { name: 'analytics_compare', title: 'Analytics vs listing changes', command: ['analytics', 'compare'], write: false, network: false, description: 'Offline: line the analytics snapshots up with the git history of the store files (listing text, screenshots, config) and show daily impressions / visitors and conversion in the days before vs after each change.', props: { days: { type: 'integer', minimum: 1, maximum: 60, default: 7 }, md: { type: 'boolean', default: false, description: 'Markdown table' } }, args: i => [...opt('days', i.days), ...flag('md', i.md)] }, { name: 'analytics_setup', title: 'Start App Store analytics reports', command: ['analytics', 'setup'], write: true, network: true, idempotent: true, description: 'Create the ONGOING App Store Connect analytics report request once (needs an Admin key; reports start 1-2 days later). Changes nothing public.' }, { name: 'apple_status', title: 'App Store status', command: ['apple', 'status'], write: false, network: true, description: 'Read-only App Store Connect overview: versions and their states, recent builds, in-app purchases, review submissions.' }, { name: 'apple_next_build', title: 'Next iOS build number', command: ['apple', 'next-build'], write: false, network: true, description: 'Next safe CFBundleVersion: one above every build uploaded for the app.' }, { name: 'apple_signing_status', title: 'Code signing status', command: ['apple', 'signing', 'status'], write: false, network: true, description: 'Read-only signing overview from App Store Connect: bundle id capabilities vs apple.signing.capabilities, distribution certificates (and whether this machine holds a private key for one), App Store provisioning profiles per bundle id and whether they are installed for Xcode. Creating certificates and profiles stays a terminal command (apple signing ensure, asks first).', args: () => ['--json'] }, { name: 'originality', title: 'Originality check (Guideline 4.3)', command: ['originality'], write: false, network: false, description: 'Offline comparison of this app with the owner\'s other apps (folders with a storebound.config.ts): listing text, name, icon and screenshot perceptual hashes, project structure. Scores per axis, overall, near-duplicate flags and suggestions; exit 1 when a pair is flagged. Pair with the portfolio-originality skill.', props: { against: { type: 'string', description: 'Comma-separated folders of the other apps, or a folder holding several (default: originality.against in the config)' }, threshold: { type: 'number', minimum: 0.05, maximum: 0.95, description: 'Overall score that counts as a near-duplicate (default 0.7)' } }, args: i => ['--json', ...opt('against', i.against), ...opt('threshold', i.threshold)] }, { name: 'play_status', title: 'Google Play status', command: ['play', 'status'], write: false, network: true, description: 'Read-only Play overview: tracks and releases (with rollout fraction), listing languages, one-time products.' }, { name: 'play_next_version_code', title: 'Next Play versionCode', command: ['play', 'next-version-code'], write: false, network: true, description: 'Next Play versionCode: one above every uploaded bundle and track release.' }, { name: 'shots_check', title: 'Check store renders', command: ['shots', 'check'], write: false, network: false, description: 'Local store checks of the renders folder: pixel sizes per store type, alpha, Play JPEG / 24-bit PNG and 8 MB, counts per set, text overflow flags, app preview videos (H.264, 30 fps, 15-30 s, stereo AAC) and folder frames. Read-only.', props: { lang, variants: { type: 'boolean', default: false, description: 'Also check the A/B variant folders' } }, args: i => [...opt('lang', i.lang), ...flag('variants', i.variants)] }, { name: 'privacy_manifest', title: 'iOS privacy manifest scan', command: ['privacy-manifest'], write: false, network: false, description: 'Local scan of an iOS project: PrivacyInfo.xcprivacy present, required-reason APIs declared, SDKs without manifests.', props: { ios: { type: 'string', description: 'Path of the iOS project folder, e.g. ios/App' }, sdk_dirs: { type: 'string', description: 'Extra plugin / SDK folders, comma-separated (node_modules plugins next to the app, workspaces, local SwiftPM packages and Pods are found without it)' } }, required: ['ios'], args: i => [...opt('ios', i.ios), ...opt('sdk-dirs', i.sdk_dirs)] }, { name: 'whats_new', title: 'Draft release notes', command: ['whats-new'], write: true, network: false, idempotent: true, description: 'Draft user-facing release notes from the git log since a tag; with dry_run false writes the draft into the listing files (local text only, nothing is sent to a store).', props: { since: { type: 'string', description: 'Git tag or ref (default: latest tag)' }, lang: { type: 'string' }, all_languages: { type: 'boolean', default: false } }, args: i => [...opt('since', i.since), ...opt('lang', i.lang), ...flag('all', i.all_languages)] }, { name: 'play_listing', title: 'Push Play listing text', command: ['play', 'listing'], write: true, network: true, idempotent: true, description: 'Google Play app details + title / short / full description per language from the listing file. dry_run prints the plan (old -> new per field); draft languages are skipped (owner terminal run with --include-drafts). Not public until the next review / publish.', props: { only }, args: i => opt('only', i.only) }, { name: 'play_images', title: 'Upload Play graphics', command: ['play', 'images'], write: true, destructive: true, network: true, idempotent: true, description: 'Replace icon, feature graphic and phone screenshots per Play language from the renders folder (or an A/B variant folder); optional 7" / 10" tablet, TV and Wear sets only when rendered (play.tablets). A missing optional set never deletes what Play has (--prune is an owner terminal run). Slow (minutes): prefer the terminal with a log for many languages.', props: { only, variant }, args: i => [...opt('only', i.only), ...opt('variant', i.variant)] }, { name: 'play_products', title: 'Sync Play one-time products', command: ['play', 'products'], write: true, network: true, idempotent: true, description: 'Create one-time products with regional prices and activate them; update listing text of existing ones. dry_run shows per product what would change. Price, region or activation changes of existing products are the owner\'s (terminal confirmation): here they are skipped with a warning.', props: { only }, args: i => opt('only', i.only) }, { name: 'play_subscriptions', title: 'Sync Play subscriptions', command: ['play', 'subscriptions'], write: true, network: true, idempotent: true, description: 'Auto-renewable subscriptions on Google Play from the config: listings per language (title, description, benefits), one auto-renewing base plan with regional prices (convertRegionPrices + overrides + optional prices CSV), activation, and the introductory offer for new subscribers. dry_run prints the plan. Without a terminal it creates only what is new; price changes of existing base plans are an owner terminal run.', props: { only, csv: { type: 'string', description: 'Prices CSV with rows for subscription ids' } }, args: i => [...opt('only', i.only), ...opt('csv', i.csv)] }, { name: 'play_data_safety', title: 'Push Play Data safety', command: ['play', 'data-safety'], write: true, destructive: true, network: true, idempotent: true, description: 'Replace the whole Play Data safety form with the answers from the config (CSV import). Skipped when the CSV equals the last upload of this project (hash in .storebound/; force: true sends it anyway). dry_run prints the CSV.', props: { force: { type: 'boolean', default: false, description: 'Upload even when the CSV equals the last upload (after a manual change in Play Console)' } }, args: i => flag('force', i.force) }, { name: 'play_release_testing', title: 'Release to a Play testing track', command: ['play', 'release'], write: true, network: true, description: 'Upload an AAB (or reuse a versionCode) to a TESTING track (internal, alpha, beta) with release notes. Production is not available here.', props: { aab: { type: 'string', description: 'Path to the .aab' }, version_code: { type: 'integer', minimum: 1 }, track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, status: { type: 'string', enum: ['completed', 'draft'], default: 'completed' } }, args: i => [...(i.aab ? [String(i.aab)] : []), ...opt('version-code', i.version_code), `--${String(i.track ?? 'internal')}`, String(i.status ?? 'completed')] }, { name: 'apple_bundle', title: 'Register bundle id', command: ['apple', 'bundle'], write: true, network: true, idempotent: true, description: 'Register the bundle id if missing and look up the App Store Connect app record.' }, { name: 'apple_iap', title: 'Sync App Store IAPs', command: ['apple', 'iap'], write: true, network: true, idempotent: true, description: 'Create missing in-app purchases, localizations, price schedules and availability. Never submits.', props: { only }, args: i => opt('only', i.only) }, { name: 'apple_subscriptions', title: 'Sync App Store subscriptions', command: ['apple', 'subscriptions'], write: true, network: true, idempotent: true, description: 'Auto-renewable subscriptions from the config: subscription groups and display names, subscriptions (period, level), localizations, availability, prices (USA price point + equalizations + overrides, optional prices CSV), introductory offers, review screenshot. dry_run prints the plan. Without a terminal it creates only what is new; price changes of existing subscriptions are an owner terminal run. Never submits.', props: { only, csv: { type: 'string', description: 'Prices CSV (product, store, region, currency, price, start_date) with rows for subscription ids' } }, args: i => [...opt('only', i.only), ...opt('csv', i.csv)] }, { name: 'apple_iap_review', title: 'IAP review screenshots', command: ['apple', 'iap-review'], write: true, destructive: true, network: true, idempotent: true, description: 'Upload (replace when changed) the App Review screenshot of every in-app purchase. Never submits.' }, { name: 'apple_listing', title: 'Push App Store version + text', command: ['apple', 'listing'], write: true, network: true, idempotent: true, description: 'Create / update the editable version (manual release), attach a build by explicit number, and push name / subtitle / description / keywords / promo / what\'s new per locale. dry_run prints the plan: version keep / update / create / refuse with the reason, per locale old -> new per field; draft locales are skipped (owner terminal run with --include-drafts).', props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' }, build: { type: 'string', description: 'Exact build number to attach (use the number you just uploaded, never "latest")' }, only }, required: ['version'], args: i => [...opt('version', i.version), ...opt('build', i.build), ...opt('only', i.only)] }, { name: 'apple_screenshots', title: 'Upload App Store screenshots', command: ['apple', 'screenshots'], write: true, destructive: true, network: true, idempotent: true, description: 'Sync 6.9" and 6.5" (+ 13" iPad with apple.ipad) screenshots per locale from the renders folder (or an A/B variant folder; MD5-idempotent; changed sets are replaced). Slow: prefer the terminal with a log.', props: { only, variant }, args: i => [...opt('only', i.only), ...opt('variant', i.variant)] }, { name: 'apple_appinfo', title: 'App Store app info', command: ['apple', 'appinfo'], write: true, network: true, idempotent: true, description: 'Categories, age rating declaration, content rights, Free price and availability (when missing).' }, { name: 'apple_review_details', title: 'App Review information', command: ['apple', 'review-details'], write: true, network: true, idempotent: true, description: 'App Review contact and, when reviewAccess.signInRequired, the demo account (both from .storebound/credentials.json or env, masked in the output), plus the review notes (apple.info.reviewNotes, sign-in instructions, where account deletion is).' }, { name: 'apple_encryption', title: 'Export compliance answer', command: ['apple', 'encryption'], write: true, network: true, idempotent: true, description: 'Answer export compliance for builds from apple.usesNonExemptEncryption in the config (never guesses).', props: { build: { type: 'string', description: 'Build number (default: every build without an answer)' } }, args: i => opt('build', i.build) }, { name: 'apple_phased_release', title: 'Phased release on / pause', command: ['apple', 'release'], write: true, network: true, idempotent: true, description: 'Turn on the 7-day phased release for the upcoming version, or pause an active one (pausing is not a rollback). Resume / complete / release are owner-only terminal commands.', props: { action: { type: 'string', enum: ['on', 'pause'] }, version: { type: 'string' } }, required: ['action'], args: i => ['--phased', String(i.action), ...opt('version', i.version)] }, { name: 'shots_capture', title: 'Capture raw frames', command: ['shots', 'capture'], write: true, network: false, idempotent: true, description: 'Raw app or game frames per language into shots.frames from a web build (Playwright), the iOS Simulator (simctl), an Android emulator / device (adb) or a folder check. Local files only; a simulator it boots is shut down again, an emulator is never booted. dry_run lists the plan and the simctl / adb commands.', props: { lang, only: { type: 'string', description: 'Comma-separated shot names' }, device: { type: 'string', enum: ['phone', 'tablet'] }, source, concurrency: { type: 'integer', minimum: 1, maximum: 32, description: 'Web: parallel browser contexts (default from the CPU count)' }, mode: { type: 'string', enum: ['changed', 'only-missing', 'force'], default: 'changed', description: 'Web: skip frames with unchanged inputs (default), only missing ones, or capture all' } }, args: i => [...opt('lang', i.lang), ...opt('only', i.only), ...opt('device', i.device), ...opt('source', i.source), ...(i.concurrency ? ['--concurrency', String(i.concurrency)] : []), ...(i.mode === 'force' ? ['--force'] : i.mode === 'only-missing' ? ['--only-missing'] : [])] }, { name: 'shots_render', title: 'Render store screenshots', command: ['shots', 'render'], write: true, network: false, idempotent: true, description: 'Store screenshots + Play feature graphic in every size and language from the frames and the copy file (local PNGs, needs Playwright + ffmpeg; slow for many languages). dry_run lists what would be rendered.', props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, ipad13, play, tab7, tab10, feature' }, scenes: { type: 'string', description: 'Comma-separated scene ids' }, variant, all_variants: { type: 'boolean', default: false, description: 'Base + every variant' }, concurrency: { type: 'integer', minimum: 1, maximum: 32, description: 'Parallel pages (default min(4, CPU cores / 2))' } }, args: i => [...opt('lang', i.lang), ...opt('size', i.size), ...opt('scenes', i.scenes), ...(i.all_variants === true ? ['--variants'] : opt('variant', i.variant)), ...(i.concurrency ? ['--concurrency', String(i.concurrency)] : [])] }, { name: 'shots_gallery', title: 'Review gallery', command: ['shots', 'gallery'], write: true, network: false, idempotent: true, description: 'Write /index.html: one self-contained page with every language, size, scene, variant and app preview, store check issues highlighted. Open it in a browser to review before uploading.', props: { lang }, args: i => opt('lang', i.lang) }, { name: 'shots_record', title: 'Record app or gameplay clips', command: ['shots', 'record'], write: true, network: false, idempotent: true, description: 'App or gameplay clips for app previews from shots.video.record: web build (Chromium screencast), iOS Simulator (simctl recordVideo), Android emulator / device (screenrecord, never booted) or a folder listing. Local files only.', props: { lang, only: { type: 'string', description: 'Comma-separated clip names' }, source }, args: i => [...opt('lang', i.lang), ...opt('only', i.only), ...opt('source', i.source)] }, { name: 'shots_video', title: 'Cut app previews', command: ['shots', 'video'], write: true, network: false, idempotent: true, description: 'App Store app previews (886x1920 iPhone, 1200x1600 iPad) and a YouTube promo video (Play) from the clips: captions from the screenshot copy, silent or music track, poster frame, store checks. Local files only; nothing is uploaded.', props: { lang, target: { type: 'string', description: 'Comma-separated: iphone, ipad, youtube, youtube-landscape' }, seconds: { type: 'integer', minimum: 15, maximum: 30 }, captions: { type: 'boolean', default: true } }, args: i => [...opt('lang', i.lang), ...opt('target', i.target), ...opt('seconds', i.seconds), ...(i.captions === false ? ['--no-captions'] : [])] }, { name: 'apple_previews', title: 'Upload App Store app previews', command: ['apple', 'previews'], write: true, destructive: true, network: true, idempotent: true, description: 'Sync app preview videos per locale to the editable App Store version (checked locally first; MD5-idempotent, changed sets are replaced; poster frame time code). Not public until the version is submitted and released. Slow: prefer the terminal with a log.', props: { only, poster: { type: 'integer', minimum: 0, maximum: 29, description: 'Poster frame second (default shots.video.poster or 5)' } }, args: i => [...opt('only', i.only), ...opt('poster', i.poster)] }, { name: 'apple_testflight_status', title: 'TestFlight status', command: ['apple', 'testflight', 'status'], write: false, network: true, description: 'Read-only TestFlight overview: beta groups (internal / external, tester and build counts, public link), recent builds with processing, internal and external beta states.' }, { name: 'apple_testflight_groups', title: 'Create TestFlight groups', command: ['apple', 'testflight', 'groups'], write: true, network: true, idempotent: true, description: 'Create the beta groups of apple.testflight.groups that are missing on App Store Connect (matched by name; existing groups are never changed or deleted). Sends nothing to testers.' }, { name: 'apple_testflight_what_to_test', title: 'TestFlight What to Test', command: ['apple', 'testflight', 'what-to-test'], write: true, network: true, idempotent: true, description: 'Set "What to Test" (betaBuildLocalizations) of one build, by its exact number, per locale from the App Store listing file (### whatToTest, else ### whatsNew) or from text.', props: { build: { type: 'string', pattern: '^[0-9][0-9.]*$', description: 'Exact build number (CFBundleVersion) you uploaded' }, version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$', description: 'Marketing version when the build number repeats' }, locale: { type: 'string', description: 'Comma-separated App Store locales (default: every locale with text)' }, text: { type: 'string', description: 'Same text for the given locales instead of the listing file' } }, required: ['build'], args: i => [...opt('build', i.build), ...opt('version', i.version), ...opt('locale', i.locale), ...opt('text', i.text)] }, { name: 'apple_testflight_info', title: 'TestFlight test information', command: ['apple', 'testflight', 'info'], write: true, network: true, idempotent: true, description: 'Beta App Description per locale (apple.testflight.description), feedback email, privacy and marketing URLs, and the Beta App Review contact (.storebound/credentials.json) and notes. Needed before external testing.' }, { name: 'apple_testflight_distribute', title: 'Build to internal TestFlight groups', command: ['apple', 'testflight', 'distribute'], write: true, network: true, idempotent: true, description: 'Add one build (exact number) to INTERNAL beta groups (your App Store Connect team; they get the TestFlight notification). External groups and Beta App Review submissions are owner-only terminal commands.', props: { build: { type: 'string', pattern: '^[0-9][0-9.]*$', description: 'Exact build number you uploaded' }, groups: { type: 'string', description: 'Comma-separated internal group names' }, version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' } }, required: ['build', 'groups'], args: i => [...opt('build', i.build), ...opt('group', i.groups), ...opt('version', i.version), '--internal-only'] }, { name: 'apple_ppo_status', title: 'Product Page Optimization status', command: ['apple', 'ppo', 'status'], write: false, network: true, description: 'Read-only list of App Store Product Page Optimization tests (state, traffic, dates) with their treatments, locales and screenshot counts. Results per treatment are only in App Store Connect.' }, { name: 'apple_ppo_create', title: 'Create a PPO test', command: ['apple', 'ppo', 'create'], write: true, network: true, idempotent: true, description: 'Create an App Store Product Page Optimization test (not started) with one treatment per shots variant (max 3) and a treatment localization per App Store locale. Starting it is an owner-only terminal command.', props: { variants: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*(,[a-z0-9][a-z0-9-]*){0,2}$', description: 'Comma-separated shots.variants ids, e.g. b or b,c' }, name: { type: 'string', description: 'Test name (default "storebound ")' }, traffic: { type: 'integer', minimum: 1, maximum: 100, default: 30, description: 'Percent of visitors who see a treatment' } }, required: ['variants'], args: i => [...opt('variant', i.variants), ...opt('name', i.name), ...opt('traffic', i.traffic)] }, { name: 'apple_ppo_upload', title: 'Upload PPO treatment screenshots', command: ['apple', 'ppo', 'upload'], write: true, destructive: true, network: true, idempotent: true, description: 'Sync the screenshots of one shots variant (renders///...) into the matching treatment of the editable Product Page Optimization test, per locale (MD5-idempotent; changed sets are replaced). Nothing is shown to players until the owner starts the test.', props: { variant, experiment: { type: 'string', description: 'Test name (default: the one editable test)' }, only }, required: ['variant'], args: i => [...opt('variant', i.variant), ...opt('experiment', i.experiment), ...opt('only', i.only)] }, { name: 'api_get', title: 'Any store API read', command: ['api', 'get'], write: false, network: true, description: 'Read-only call of any App Store Connect API or Google Play Developer API GET operation by its operation id (every one is listed by `storebound api list` and in docs/api-coverage.md), e.g. store apple, operation apps_getCollection, params {"filter[bundleId]": "com.example.app"}. GET only: writes, and anything that submits, releases, deletes or cancels, are never available here.', props: { store: { type: 'string', enum: ['apple', 'play'] }, operation: { type: 'string', pattern: '^[A-Za-z][A-Za-z0-9_.]{0,200}$', description: 'Operation id: Apple e.g. apps_getCollection, Google e.g. androidpublisher.reviews.list (the API prefix may be left out)' }, params: { type: 'object', additionalProperties: { type: 'string' }, description: 'Path and query parameters by name (string, number or boolean values; lists comma-separated)' }, all_pages: { type: 'boolean', description: 'Follow every page and merge them' } }, required: ['store', 'operation'], args: i => [String(i.store), String(i.operation), ...Object.entries((i.params ?? {}) as Record).map(([k, v]) => `--param=${k}=${String(v)}`), ...flag('all-pages', i.all_pages), '--json'] }, { name: 'icons', title: 'Generate icons', command: ['icons'], write: true, network: false, idempotent: true, description: 'Write every store / launcher / web icon from the artwork in the config (local files, needs ffmpeg).' }, { name: 'import_fastlane', title: 'Import a fastlane setup', command: ['import', 'fastlane'], write: true, network: false, idempotent: true, description: 'Read the project\'s fastlane folder(s) (Appfile, Deliverfile, deliver + supply metadata, screenshots) and plan storebound.config.ts, store/appstore.md, store/listing.md and screenshot frames (folder capture source). Local files only; existing files are never overwritten (a .imported. copy goes next to them). Keys, team ids, the App Review contact and demo account are only named, never copied. dry_run prints the plan and the report of what is not mapped.', props: { dir: { type: 'string', pattern: '^(?!/)(?!.*\\.\\.)(?!.*,/)[A-Za-z0-9._ /,-]{1,400}$', description: 'fastlane folder(s) relative to the project, comma-separated (default: fastlane, ios/fastlane, android/fastlane)' }, profile: { type: 'string', enum: ['app', 'game', 'subscription-app'], description: 'Default: game for the GAMES category, else app' } }, args: i => [...opt('dir', i.dir), ...opt('profile', i.profile)] }, { name: 'workspace_list', title: 'Workspace apps', command: ['ws', 'list'], write: false, network: false, description: 'Offline: the apps of the storebound.workspace.json above the project (app folders from its globs, store-only apps from `ws apps import`), paired across iOS and Android by the desktop\'s rules, with studios and accounts (credential profile names and whether their keys are there; never a key).', props: { all: { type: 'boolean', default: false, description: 'Also the ignored apps' }, json: { type: 'boolean', default: false } }, args: i => [...flag('all', i.all), ...flag('json', i.json)] }, { name: 'workspace_status', title: 'Workspace portfolio status', command: ['ws', 'status'], write: false, network: true, description: 'Read-only portfolio table of every app in the workspace across both stores: the version that reaches users and the next one (build > testing > review > live > rollout %), catalog parity, offline check, read in parallel with each account\'s credential profile.', props: { apps: { type: 'string', pattern: APP_NAMES, description: 'Comma-separated app names or folders (default: all)' }, offline: { type: 'boolean', default: false, description: 'Skip the store reads' }, json: { type: 'boolean', default: false } }, args: i => [...opt('apps', i.apps), ...flag('offline', i.offline), ...flag('json', i.json)] }, { name: 'release_status', title: 'Release trains', command: ['release', 'status'], write: false, network: true, description: 'Read-only release trains: per app, both stores on one train per version (build > testing > review > live > rollout %), the version that reaches users and the next one; versions lists every version. In an app folder that app, in a workspace every app (or app).', props: { app: { type: 'string', pattern: APP_NAMES, description: 'Workspace app names (comma-separated)' }, versions: { type: 'boolean', default: false }, json: { type: 'boolean', default: false } }, args: i => [...opt('app', i.app), ...flag('versions', i.versions), ...flag('json', i.json)] }, { name: 'release_plan', title: 'Release plan for a version', command: ['release', 'plan'], write: false, network: true, description: 'Read-only: what is left for one version on each store, in fix order: config problems (check), the version on the store, store blockers (validate), the next move (owner steps such as submitting or releasing are named, never done), warnings.', props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,3}$' }, app: { type: 'string', pattern: APP_NAMES, description: 'Workspace app names (comma-separated)' }, no_validate: { type: 'boolean', default: false, description: 'Skip the store readiness read' }, json: { type: 'boolean', default: false } }, required: ['version'], args: i => [String(i.version), ...opt('app', i.app), ...flag('no-validate', i.no_validate), ...flag('json', i.json)] }, { name: 'flow_list', title: 'Repo-local workflows', command: ['flow', 'list'], write: false, network: false, description: 'The named workflows of this project (store/workflows/*.yml, storebound.workflows.*) with their params and steps, each step classified as read, write (runs as a dry run), wait, shell or "your click" (owner steps such as Submit for Review, never run).', args: () => ['--json'] }, { name: 'flow_run', title: 'Run a workflow as a dry run', command: ['flow', 'run'], write: false, network: true, description: 'Run one workflow step by step: read steps run, every write step runs with --dry (nothing changes on a store or in files), wait steps check once, shell steps are skipped, owner steps are listed as the owner\'s clicks. plan_only shows the plan without running anything. Running the writes for real is a terminal command the owner runs (storebound flow run --apply).', props: { name: { type: 'string', pattern: '^[a-z0-9][a-z0-9._-]{0,62}$', description: 'Flow name (flow_list)' }, params: { type: 'object', additionalProperties: { type: 'string' }, description: 'Flow params, e.g. {"VERSION": "1.2.0"}' }, from: { type: 'string', pattern: '^[a-z0-9][a-z0-9._-]{0,62}$', description: 'Start at this step id' }, plan_only: { type: 'boolean', default: false } }, required: ['name'], args: i => [String(i.name), ...Object.entries((i.params ?? {}) as Record).filter(([k]) => /^[A-Z][A-Z0-9_]{0,40}$/.test(k)).map(([k, v]) => `${k}=${String(v)}`), ...opt('from', i.from), ...flag('dry', i.plan_only), '--json'] }, // ---- store operations (read-only plans, local pulls and reports; submits, codes, offers and pre-orders stay terminal-only) ---- { name: 'apple_testflight_wait', title: 'Wait for a TestFlight build', command: ['apple', 'testflight', 'wait'], write: false, network: true, description: 'Poll one uploaded build (exact number) until processing ends: exit 0 VALID, 1 FAILED / INVALID, 7 timeout. Read-only. check_once reads the state once without waiting.', props: { build: { type: 'string', pattern: '^[0-9][0-9.]*$', description: 'Exact build number (CFBundleVersion) you uploaded' }, version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' }, timeout_minutes: { type: 'integer', minimum: 1, maximum: 60, default: 30 }, check_once: { type: 'boolean', default: false } }, required: ['build'], args: i => [...opt('build', i.build), ...opt('version', i.version), '--timeout', `${Number(i.timeout_minutes ?? 30)}m`, '--json', ...flag('dry', i.check_once)] }, { name: 'apple_iap_review_plan', title: 'IAP review submission plan', command: ['apple', 'iap-submit'], write: false, network: true, description: 'Read-only plan for sending in-app purchases and subscriptions to App Review: which version of each goes in, which are the first of their type (Apple reviews those only with a new app version), what is left out and why. Submitting is an owner-only terminal command.', props: { only, with_version: { type: 'boolean', default: false, description: 'Plan with the editable app version in the same submission' } }, args: i => [...opt('only', i.only), ...flag('with-version', i.with_version), '--json', '--dry'] }, { name: 'apple_listing_pull', title: 'Pull App Store texts', command: ['apple', 'listing', 'pull'], write: true, network: true, idempotent: true, description: 'Read the live App Store texts (name, subtitle, description, keywords, promotional text, what\'s new per locale) and diff them against the App Store listing file. dry_run shows the diff; with dry_run false the file is updated (local file only).', props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$', description: 'Version to read (default: the editable one, else the newest)' } }, args: i => [...opt('version', i.version), '--json', ...(i.dry_run === false ? ['--write'] : [])] }, { name: 'play_listing_pull', title: 'Pull Google Play texts', command: ['play', 'listing', 'pull'], write: true, network: true, idempotent: true, description: 'Read the live Play listings (title, short and full description per language; one throwaway edit, deleted) and diff them against the Play listing file. dry_run shows the diff; with dry_run false the file is updated (local file only).', args: i => ['--json', ...(i.dry_run === false ? ['--write'] : [])] }, { name: 'catalog_pull', title: 'Pull IAP and subscription texts', command: ['catalog', 'pull'], write: true, network: true, idempotent: true, description: 'Store titles and descriptions (Play: benefits) of in-app purchases and subscriptions vs the config listings, as a proposal diff. storebound.config.ts is never rewritten; with dry_run false the proposal is saved to .storebound/pull/catalog.json.', props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] }, { name: 'whats_new_languages', title: 'Release notes per language', command: ['whats-new'], write: true, network: false, idempotent: true, description: 'Draft release notes from git into one file per language (store/whats-new/.md): the first language gets the draft, the others status=translate files for you to translate (keep the header line, set status=translated). Then whats_new_check and whats_new_apply. Local files only.', props: { languages: { type: 'string', pattern: '^[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+)*$', description: 'Comma-separated language ids or store codes, source language first' }, since: { type: 'string', description: 'Git tag or ref (default: latest tag)' } }, required: ['languages'], args: i => [...opt('languages', i.languages), ...opt('since', i.since), '--json'] }, { name: 'whats_new_check', title: 'Check release notes per language', command: ['whats-new', 'check'], write: false, network: false, description: 'Offline: the per-language release notes files against the store limits (Google Play 500, App Store 4000 characters), untranslated or identical-to-source files, long first lines.', args: () => ['--json'] }, { name: 'whats_new_apply', title: 'Apply release notes per language', command: ['whats-new', 'apply'], write: true, network: false, idempotent: true, description: 'Write the checked per-language release notes into both listing files (Play "What\'s new" per Play code, App Store whatsNew per locale). Refuses while a language is over a limit or untranslated. Local files only; nothing is sent to a store.', args: () => ['--json'] }, { name: 'listing_translate', title: 'Listing translation drafts', command: ['listing', 'translate'], write: true, network: false, idempotent: true, description: 'Add a section per target language to both listing files (App Store: name, subtitle, promotionalText, keywords, description; Google Play: title, short, full) with the source language\'s text and a status=translate marker. Then translate each field in place within the store limits (App Store subtitle 30, promotional text 170, keywords 100 comma-separated without duplicates or name words, description 4000; Play title 30, short 80, full 4000), set the marker to status=draft and run listing_check. The owner reviews the drafts (storebound listing reviewed). Reviewed sections are kept. Local files only.', props: { to: { type: 'string', pattern: '^[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+)*$', description: 'Comma-separated target languages (config ids, App Store locales or Play codes)' }, from: { type: 'string', pattern: '^[A-Za-z0-9_-]+$', description: 'Source language (default: the primary one)' }, store: { type: 'string', enum: ['apple', 'play'] } }, required: ['to'], args: i => [...opt('to', i.to), ...opt('from', i.from), ...opt('store', i.store), '--json'] }, { name: 'listing_check', title: 'Check listing translations', command: ['listing', 'check'], write: false, network: false, description: 'Offline: every language section of both listing files against the store limits and keyword rules, with untranslated (status=translate) and unreviewed machine-translated (status=draft) sections.', args: () => ['--json'] }, { name: 'reports', title: 'Sales and finance reports', command: ['reports'], write: true, network: true, idempotent: true, description: 'Download a sales or finance report into .storebound/reports/ (gitignored) and print a summary per product and currency (amounts never added across currencies) plus a summary CSV. Apple needs ASC_VENDOR_NUMBER and a team key; Play needs play.reportsBucket and "View financial data". dry_run shows the request only.', props: { kind: { type: 'string', enum: ['apple-sales', 'apple-finance', 'play-earnings', 'play-sales'] }, month: { type: 'string', pattern: '^\\d{4}-\\d{2}$', description: 'YYYY-MM (apple-finance: Apple fiscal month; Play reports)' }, date: { type: 'string', pattern: '^\\d{4}(-\\d{2}(-\\d{2})?)?$', description: 'apple-sales report date (DAILY / WEEKLY YYYY-MM-DD, MONTHLY YYYY-MM, YEARLY YYYY)' }, frequency: { type: 'string', enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'] }, type: { type: 'string', enum: ['SALES', 'PRE_ORDER', 'SUBSCRIPTION', 'SUBSCRIPTION_EVENT', 'SUBSCRIBER', 'SUBSCRIPTION_OFFER_CODE_REDEMPTION', 'WIN_BACK_ELIGIBILITY'] } }, required: ['kind'], args: i => [String(i.kind), ...opt('month', i.month), ...opt('date', i.date), ...opt('frequency', i.frequency), ...opt('type', i.type), '--json'] }, { name: 'triage', title: 'Crash and feedback triage', command: ['triage'], write: false, network: true, description: 'Read-only: TestFlight crash reports (signature from each crash log), TestFlight screenshot feedback and Google Play error reports of the top issues, grouped by signature with their top devices, OS versions and builds. Tester emails are never read.', props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 14 }, build: { type: 'string', pattern: '^[0-9][0-9.]*$' }, issues: { type: 'integer', minimum: 1, maximum: 50, default: 10 } }, args: i => [...(i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []), ...opt('days', i.days), ...opt('build', i.build), ...opt('issues', i.issues), '--json'] }, { name: 'aso_rank', title: 'App Store keyword ranks', command: ['aso', 'rank'], write: true, network: true, idempotent: true, description: 'Rank of the app per keyword (the keyword field of store/appstore.md, plus extra keywords) and storefront in Apple\'s public iTunes Search API results, with the top competitors. One request every 3.5 s (Apple allows about 20 a minute). dry_run lists the queries; with dry_run false it queries and saves .storebound/aso/.json. Search API order is close to, not equal to, App Store search.', props: { storefronts: { type: 'string', pattern: '^[a-z]{2}(=[A-Za-z-]+)?(,[a-z]{2}(=[A-Za-z-]+)?)*$', description: 'e.g. us,de=de-DE (default us)' }, keywords: { type: 'string', description: 'Extra keywords, comma-separated' }, competitors: { type: 'integer', minimum: 0, maximum: 10, default: 3 } }, args: i => [...opt('storefronts', i.storefronts), ...opt('keywords', i.keywords), ...opt('competitors', i.competitors), '--json'] }, { name: 'aso_competitors', title: 'App Store competitors per keyword', command: ['aso', 'competitors'], write: false, network: true, description: 'Read-only: for each keyword of the locale\'s keyword field (plus terms) and storefront, the top apps in Apple\'s public iTunes Search API (name, developer, rating count, first release, last update, localized genre) and the words in their names that your name / subtitle / keywords do not cover (only words of at least two developers: a one-developer word is likely a brand). One request every 3.5 s. Saves no snapshot (the terminal run `storebound aso competitors` does). Search API order is close to, not equal to, App Store search; competitor subtitles are not in any public API.', props: { storefront: { type: 'string', pattern: '^[a-z]{2}(=[A-Za-z-]+)?(,[a-z]{2}(=[A-Za-z-]+)?)*$', description: 'e.g. us,tr (default us)' }, terms: { type: 'string', description: 'Extra terms, comma-separated' }, top: { type: 'integer', minimum: 1, maximum: 50, default: 10 } }, args: i => [...opt('storefront', i.storefront), ...opt('term', i.terms), ...opt('top', i.top), '--no-save', '--json'] }, { name: 'aso_suggest', title: 'Keyword field proposal', command: ['aso', 'suggest'], write: false, network: true, description: 'Read-only proposal: keyword candidates per App Store locale from your own name / subtitle / description, the latest `aso competitors` snapshot, the localized category names and, when Apple Ads credentials exist, Apple Ads keyword suggestions (relative popularity 0-100; without credentials popularity is "unknown", never estimated). Scores relevance (own text, competitor names) and competition (top apps with the word in their name) and returns a 100-byte keyword field per locale as old -> new. Never writes: the owner runs `storebound aso suggest --write` in a terminal, which marks the result as a draft for review.', props: { locale: { type: 'string', description: 'Comma-separated App Store locales (default: every locale of store/appstore.md)' }, fresh: { type: 'boolean', default: false, description: 'Do not keep the current keywords first' }, offline: { type: 'boolean', default: false, description: 'Skip Apple Ads' } }, args: i => [...opt('locale', i.locale), ...flag('fresh', i.fresh), ...flag('offline', i.offline), '--json'] }, { name: 'aso_audit', title: 'ASO audit', command: ['aso', 'audit'], write: false, network: false, description: 'Offline ASO report: keyword field findings (check --aso), cross-locale free keyword room, rank trend from the `aso rank` snapshots, competitor coverage gaps, Play short description use and keyword density, App Store analytics conversion from the snapshots, Product Page Optimization / Play experiment ideas (suggestions only) and the next 3 actions.', props: { storefront: { type: 'string', description: 'App Store storefront for the cross-locale hint', default: 'US' } }, args: i => [...opt('storefront', i.storefront), '--json'] }, { name: 'ads_scan', title: 'AdAttributionKit scan', command: ['ads', 'scan'], write: false, network: false, description: 'Local scan of an iOS project for AdAttributionKit and SKAdNetwork: ad network ids (SKAdNetworkItems, AdNetworkIdentifiers, lowercase), postback copy endpoints (AttributionCopyEndpoint, re-engagement copies, NSAdvertisingAttributionReportEndpoint), attribution windows and cooldown, conversion value updates in code, ad SDKs without ids.', props: { ios: { type: 'string', description: 'Path of the iOS project folder, e.g. ios/App' } }, required: ['ios'], args: i => [...opt('ios', i.ios), '--json'] }, { name: 'apple_cpp_list', title: 'Custom product pages', command: ['apple', 'cpp', 'list'], write: false, network: true, description: 'Read-only list of the App Store custom product pages (visible or not, version state, URL) and the configured ones not created yet (at most 70 per app).', args: () => ['--json'] }, { name: 'apple_cpp_create', title: 'Create custom product pages', command: ['apple', 'cpp', 'create'], write: true, network: true, idempotent: true, description: 'Create the custom product pages of apple.customProductPages that are missing (with a localization per App Store locale, promotional text, deep link) and update editable ones. Nothing is shown to customers: submitting a page and making it visible are owner-only terminal commands.', props: { name: { type: 'string', description: 'Only this page' } }, args: i => opt('name', i.name) }, { name: 'apple_cpp_upload', title: 'Upload custom product page screenshots', command: ['apple', 'cpp', 'upload'], write: true, destructive: true, network: true, idempotent: true, description: 'Sync the screenshots of a custom product page from the renders of its shots variant, per locale (MD5-idempotent; changed sets are replaced). Only on an editable page version.', props: { name: { type: 'string', description: 'Page name from apple.customProductPages' }, only }, required: ['name'], args: i => [...opt('name', i.name), ...opt('only', i.only)] }, { name: 'apple_events_list', title: 'In-app events', command: ['apple', 'events', 'list'], write: false, network: true, description: 'Read-only list of the App Store in-app events (badge, state, schedule) and the configured ones not created yet (10 published / 15 approved per app).', args: () => ['--json'] }, { name: 'apple_events_create', title: 'Create in-app events', command: ['apple', 'events', 'create'], write: true, network: true, idempotent: true, description: 'Create the in-app events of apple.events that are missing (reference name, badge, purpose, priority, deep link, schedule, localizations with name 30 / short 50 / long 120 characters) and update draft ones. Sending an event to App Review is an owner-only terminal command.', props: { name: { type: 'string', description: 'Only this event (reference name)' } }, args: i => opt('name', i.name) }, { name: 'apple_events_upload', title: 'Upload in-app event images', command: ['apple', 'events', 'upload'], write: true, destructive: true, network: true, idempotent: true, description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.', props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) }, { name: 'console_plan', title: 'Console form plan (agent-first)', command: ['console', 'plan'], write: false, network: false, description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form, how to drive long flows (an in-page async loop writing window.__log) and how to verify. Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.', props: { form: { type: 'string', enum: ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] } }, required: ['form'], args: i => [String(i.form), '--json'] }, { name: 'console_verify', title: 'Verify a console form', command: ['console', 'verify'], write: false, network: true, description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text). apple-app-record and apple-age-rating without text read the App Store Connect API. Returns a per-item match / mismatch table; exit code 1 on a mismatch.', props: { form: { type: 'string', enum: ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] }, text: { type: 'string', description: 'The page text (or the capture JSON) of the finished form' } }, required: ['form'], args: i => [String(i.form), ...(typeof i.text === 'string' && i.text ? [`--text=${i.text}`] : []), '--json'] }, { name: 'apple_preorder_status', title: 'App Store pre-order status', command: ['apple', 'preorder', 'status'], write: false, network: true, description: 'Read-only: per territory whether the app is available, its release date, pre-order on / off and its content status. Enabling or ending a pre-order is an owner-only terminal command.', args: () => ['--json'] }, ]; /** CLI flags / commands that must never be reachable through MCP. Tests assert none of the tools can produce them. */ export const FORBIDDEN_ARGS = ['--write', '--yes', '--manual-release', '--cancel-submission', '--production', 'rollout', 'resume', 'complete', '--submit-review', '--remove', '--invite', 'testers', 'send', 'start', 'stop']; /** * The one exception to FORBIDDEN_ARGS: the pull tools, whose `--write` (only with dry_run false) writes the live store * texts into a local file (store/*.md, .storebound/pull/catalog.json; never storebound.config.ts, never a store). * `scan --write` (compliance answers into the config) stays unreachable: scan is not in this list. */ export const LOCAL_WRITE_TOOLS: Record = { apple_listing_pull: ['--write'], play_listing_pull: ['--write'], catalog_pull: ['--write'], }; export function inputSchema(t: ToolDef): Record { const props: Record = { ...(t.props ?? {}) }; if (t.write) props.dry_run = { type: 'boolean', default: true, description: 'Default true: only show what would change. Set false only after the owner agreed.' }; return { type: 'object', properties: props, ...(t.required?.length ? { required: t.required } : {}), additionalProperties: false }; } export const OUTPUT_SCHEMA = { type: 'object', properties: { exitCode: { type: 'integer' }, dryRun: { type: 'boolean' }, command: { type: 'string' }, output: { type: 'string' } }, required: ['exitCode', 'command', 'output'], } as const; export function listTools(): Record[] { return TOOLS.map(t => ({ name: t.name, title: t.title, description: t.description, inputSchema: inputSchema(t), outputSchema: OUTPUT_SCHEMA, annotations: { title: t.title, readOnlyHint: !t.write, destructiveHint: t.write ? !!t.destructive : false, idempotentHint: !!t.idempotent || !t.write, openWorldHint: t.network }, })); } /** Checks an input against the tool schema; returns problems (empty = fine). */ export function validateInput(t: ToolDef, input: unknown): string[] { const e: string[] = []; if (input === undefined || input === null) input = {}; if (typeof input !== 'object' || Array.isArray(input)) return ['arguments must be an object']; const s = inputSchema(t) as { properties: Record; required?: string[] }; const i = input as Record; for (const r of s.required ?? []) if (i[r] === undefined) e.push(`${r}: required`); for (const [k, v] of Object.entries(i)) { const p = s.properties[k]; if (!p) { e.push(`${k}: unknown argument`); continue; } const ok = p.type === 'integer' ? Number.isInteger(v) : p.type === 'number' ? typeof v === 'number' && Number.isFinite(v) : p.type === 'boolean' ? typeof v === 'boolean' : p.type === 'string' ? typeof v === 'string' : p.type === 'object' ? typeof v === 'object' && v !== null && !Array.isArray(v) && Object.entries(v).every(([pk, pv]) => /^[A-Za-z][A-Za-z0-9_.[\]+-]{0,100}$/.test(pk) && ['string', 'number', 'boolean'].includes(typeof pv) && !/[\n\r]/.test(String(pv))) : true; if (!ok) { e.push(`${k}: must be ${p.type === 'object' ? 'an object of name -> string / number / boolean' : p.type}`); continue; } if (p.enum && !p.enum.includes(v as string)) e.push(`${k}: one of ${p.enum.join(', ')}`); if (p.pattern && typeof v === 'string' && !new RegExp(p.pattern).test(v)) e.push(`${k}: does not match ${p.pattern}`); if (p.minimum !== undefined && typeof v === 'number' && v < p.minimum) e.push(`${k}: >= ${p.minimum}`); if (p.maximum !== undefined && typeof v === 'number' && v > p.maximum) e.push(`${k}: <= ${p.maximum}`); if (typeof v === 'string' && /^-/.test(v)) e.push(`${k}: must not start with "-"`); } return e; } /** argv after `storebound` for a validated call. Write tools get --dry unless dry_run is explicitly false. */ export function toArgv(t: ToolDef, input: Record = {}): { argv: string[]; dryRun: boolean } { const dryRun = t.write ? input.dry_run !== false : false; const argv = [...t.command, ...(t.args?.(input) ?? []), ...(dryRun ? ['--dry'] : [])]; return { argv, dryRun }; }