diff --git a/README.md b/README.md
index b6ebe65..fa2c834 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,13 @@ Prefer to drive the agent yourself? Add `--skill` to `integrate`, `migrate`,
or `review` and the CLI prints the full playbook for your project's framework
instead of running it — paste it into any agent.
+`migrate` starts by asking whether to **plan first** or **migrate now**. Plan
+first is a read-only pass: it writes a reviewable `sw-migration.md` to your
+project root and copies `implement the plan at sw-migration.md` to your
+clipboard, so you can read the plan and then run it through your own agent.
+Migrate now hands the whole migration to the agent end to end. Skip the prompt
+with `superwall migrate --plan` (plan) or leave it off (migrate now).
+
## Manage your account
Everything in the dashboard, from the terminal:
diff --git a/prompts/actions/migrate-plan.xml b/prompts/actions/migrate-plan.xml
new file mode 100644
index 0000000..6c3011c
--- /dev/null
+++ b/prompts/actions/migrate-plan.xml
@@ -0,0 +1,13 @@
+
+Produce a reviewable plan to migrate this app from {{provider}} to Superwall - {{goal}}. This is plan-only: DO NOT modify, create, or delete any files. Read the project, then return the plan as your report output - the CLI writes it to disk, not you.
+
+{{skill}}
+
+Analyze how this app integrates {{provider}}, then return a detailed, step-by-step Superwall migration plan. Make it specific to this codebase: the {{provider}}→Superwall mapping, the exact files and call sites to change, the products / entitlements / campaigns / placements to create, and any manual steps left for a human. Do not touch a single file - the plan is your report.
+
+
+A complete step-by-step Superwall migration plan in markdown: the {{provider}}→Superwall mapping, per-file changes with call sites, the products / entitlements / campaigns / placements to create, and remaining manual steps. This report becomes `sw-migration.md`, so write it as the document a human will follow.
+
+
+Emit a [STATUS] before each step.
+
diff --git a/src/cli.ts b/src/cli.ts
index 2a11224..44196a8 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -58,7 +58,10 @@ function printHelp(): void {
"",
head("Workflow commands"),
row("integrate", "Set up Superwall in your app (agent-driven)"),
- row("migrate", "Migrate from RevenueCat / Adapty / Qonversion"),
+ row(
+ "migrate [--plan]",
+ "Migrate from RevenueCat / Adapty / Qonversion; --plan writes a plan first",
+ ),
row(
"review [--fix]",
"Audit an existing Superwall setup; --fix repairs issues",
@@ -190,6 +193,11 @@ export async function run(): Promise {
y.options({
...installDirOption,
"dry-run": { type: "boolean", describe: "Plan without changes" },
+ plan: {
+ type: "boolean",
+ describe:
+ "Write a reviewable migration plan (sw-migration.md) instead of migrating now",
+ },
...skillOption,
}),
(argv) => {
diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts
index 9a0f13c..e2e546d 100644
--- a/src/commands/migrate.ts
+++ b/src/commands/migrate.ts
@@ -1,3 +1,5 @@
+import { writeFileSync } from "node:fs";
+import { join } from "node:path";
import { DASHBOARD_URL, VERSION } from "@/constants";
import { ui } from "@/ui";
import { defineCommand } from "@/command";
@@ -11,6 +13,7 @@ import { loadSession } from "@/core/auth/storage";
import { performLoginWithUi } from "./integrate/app";
import { runAction } from "@/core/agent/run";
import { saveReport } from "@/core/state/reports";
+import { copyToClipboard } from "@/util/clipboard";
import { actionById, type ActionContext } from "@/core/agent/actions";
export const runMigrate = defineCommand({
@@ -18,6 +21,7 @@ export const runMigrate = defineCommand({
auth: "optional",
async run(ctx) {
const { installDir, dryRun } = ctx;
+ const plan = Boolean(ctx.argv.plan);
ui.intro(`Superwall migrate v${VERSION}`);
const drivers = await detectDrivers();
@@ -56,6 +60,28 @@ export const runMigrate = defineCommand({
}
ui.info(`Found \`${provider}\``);
+ let mode = plan ? "plan" : "migrate";
+ if (ctx.interactive && !dryRun) {
+ const sel = await ui.select({
+ message: `How do you want to migrate from ${provider}?`,
+ options: [
+ {
+ value: "plan",
+ label: "Plan first",
+ hint: "write a reviewable plan you run yourself",
+ },
+ {
+ value: "migrate",
+ label: "Migrate now",
+ hint: "let the agent do it end-to-end",
+ },
+ ],
+ initialValue: "plan",
+ });
+ if (sel === null) return void ui.warn("Cancelled.");
+ mode = sel;
+ }
+
let intent = "full";
if (ctx.interactive && !dryRun) {
const sel = await ui.select({
@@ -102,7 +128,8 @@ export const runMigrate = defineCommand({
ui.note(
[
`Provider: ${provider}`,
- `Mode: ${intent}`,
+ `Mode: ${mode === "plan" ? "plan first" : "migrate now"}`,
+ `Scope: ${intent}`,
`App: ${app.name} (${app.framework.name})`,
`Path: ${app.path}`,
].join("\n"),
@@ -112,6 +139,50 @@ export const runMigrate = defineCommand({
return;
}
+ if (mode === "plan") {
+ const spin = ui.spinner();
+ spin.start(`Planning migration from ${provider}`);
+ const result = await runAction({
+ driver,
+ fallbackDrivers,
+ action: actionById["migrate-plan"],
+ ctx: actionCtx,
+ onEvent: (e) => {
+ if (e.type === "status") spin.message(e.text);
+ },
+ });
+ if (!result.ok || !result.finalText) {
+ spin.stop("Planning stopped early");
+ if (result.finalText)
+ ui.report(stripReport(result.finalText).slice(0, 1500));
+ throw new Error("Migration plan did not complete.");
+ }
+ spin.stop("Migration plan ready");
+ const planText = stripReport(result.finalText);
+ writeFileSync(join(app.path, "sw-migration.md"), planText, "utf8");
+ ui.report(planText);
+
+ const runPrompt = "implement the plan at sw-migration.md";
+ const copied = await copyToClipboard(runPrompt);
+ if (copied) {
+ ui.note(
+ `\`${runPrompt}\` - paste it into Claude Code, Codex, Cursor, or another coding agent.`,
+ "Copied to clipboard",
+ );
+ } else {
+ ui.note(runPrompt, "Copy this into your agent to run the migration");
+ }
+
+ ctx.output.render(
+ blocks.nextSteps([
+ "Review `sw-migration.md`.",
+ "Paste `implement the plan at sw-migration.md` into your agent to execute.",
+ ]),
+ blocks.outro(`Done. ${DASHBOARD_URL}`),
+ );
+ return;
+ }
+
const spin = ui.spinner();
spin.start(`Migrating from ${provider}`);
const result = await runAction({
diff --git a/src/commands/print-skill.ts b/src/commands/print-skill.ts
index cb0812d..d716323 100644
--- a/src/commands/print-skill.ts
+++ b/src/commands/print-skill.ts
@@ -64,7 +64,8 @@ export function runPrintSkill(
case "dashboard-setup":
skill = "superwall-dashboard";
break;
- case "migrate": {
+ case "migrate":
+ case "migrate-plan": {
skill = "superwall-migrate";
const billing = app ? detectBilling(app) : null;
reference = billing?.revenueCat
diff --git a/src/core/agent/actions.ts b/src/core/agent/actions.ts
index 3141860..64b5313 100644
--- a/src/core/agent/actions.ts
+++ b/src/core/agent/actions.ts
@@ -25,7 +25,8 @@ export type ActionId =
| "placements-review"
| "placements-implement"
| "dashboard-setup"
- | "migrate";
+ | "migrate"
+ | "migrate-plan";
export interface ActionContext {
app: DetectedApp;
@@ -183,23 +184,38 @@ export const ACTIONS: WizardAction[] = [
requiresOrgKey: false,
outputMode: "report",
capabilities: WRITE_CAPABILITIES,
- vars: (ctx) => {
- const provider = ctx.billingProvider ?? "the current billing provider";
- return {
- provider,
- goal:
- ctx.migrateIntent === "paywalls"
- ? `paywalls only, keep ${provider} for billing`
- : "full migration",
- skill: loadBundledSkill(
- "superwall-migrate",
- PROVIDER_REF[provider.toLowerCase()],
- ),
- };
- },
+ vars: migrateVars,
+ },
+ {
+ id: "migrate-plan",
+ title: "Plan the migration",
+ hint: "Write a reviewable migration plan you run yourself",
+ runningLabel: "Planning the migration",
+ successLabel: "Migration plan ready",
+ requiresIntegration: false,
+ requiresOrgKey: false,
+ outputMode: "report",
+ capabilities: READ_CAPABILITIES,
+ vars: migrateVars,
},
];
+/** Provider + scope + skill vars shared by the migrate and migrate-plan actions. */
+function migrateVars(ctx: ActionContext): Record {
+ const provider = ctx.billingProvider ?? "the current billing provider";
+ return {
+ provider,
+ goal:
+ ctx.migrateIntent === "paywalls"
+ ? `paywalls only, keep ${provider} for billing`
+ : "full migration",
+ skill: loadBundledSkill(
+ "superwall-migrate",
+ PROVIDER_REF[provider.toLowerCase()],
+ ),
+ };
+}
+
export const actionById = Object.fromEntries(
ACTIONS.map((a) => [a.id, a]),
) as Record;