feat(skills): Reverse is Pro only; the CLI reads the real plan and links /pricing

Per the Oct 29 decisions: a signed-in Free person is told Reverse is a Pro feature and given
`billing subscribe`, and nothing starts until they are on Pro.

The CLI read the plan from `plan`/`tier`, which the subscription route never sends, so a Pro
account read as no plan. It now reads `subscription.plan_name` (null on Free, unpaid
placeholders not counted), shows Free in auth status, and upgrade and credits links go to
hyperframes.studio/pricing and /account/plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Miao Yang
2026-10-29 23:11:17 +08:00
co-authored by Claude Opus 5.5
parent 4b70b52eaa
commit a0fa9cd6dd
9 changed files with 64 additions and 13 deletions
+1 -1
View File
@@ -250,7 +250,7 @@ function collectStatusRows(s: VerifiedStatus): [string, string][] {
rows.push([
"HyperFrames:",
plan.plan === "none"
? `no plan · Subscribe: ${pageUrl("plans")}${tag}`
? `Free · Upgrade to Pro: ${pageUrl("plans")}${tag}`
: `${plan.plan} plan, ${plan.credits.toLocaleString("en-US")} credits${tag}`,
]);
}
+1 -3
View File
@@ -5,9 +5,7 @@ import { failCommand } from "../utils/commandResult.js";
import { defineCommand } from "../utils/defineCommand.js";
const describe = (p: Plan | null) =>
p && p.plan !== "none"
? `${p.plan} plan, ${p.credits.toLocaleString("en-US")} credits`
: "no plan";
p && p.plan !== "none" ? `${p.plan} plan, ${p.credits.toLocaleString("en-US")} credits` : "Free";
/** Opens the plans or credits page, then waits until the account shows the change, so the run continues on its own. */
export default defineCommand({
+30
View File
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../cloud/auth.js", () => ({
resolveCloudAuthHeaders: async () => ({ "x-api-key": "k" }),
resolveCloudBaseUrl: () => "https://api.test",
}));
const { readPlan } = await import("./plan.js");
const answer = (data: unknown) =>
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ code: 100, data })));
afterEach(() => vi.unstubAllGlobals());
describe("readPlan", () => {
it("reads the plan the way the subscription route sends it", async () => {
answer({
subscription: { plan_name: "pro", status: "active" },
usage: { remaining_units: 25000 },
});
expect(await readPlan()).toEqual({ plan: "pro", credits: 25000 });
answer({ subscription: null, usage: { remaining_units: 0 } });
expect(await readPlan()).toEqual({ plan: "none", credits: 0 });
answer({
subscription: { plan_name: "pro", status: "incomplete" },
usage: { remaining_units: 0 },
});
expect((await readPlan())?.plan).toBe("none");
});
});
+14 -4
View File
@@ -7,6 +7,8 @@ export interface Plan {
mock?: boolean;
}
const UNPAID = new Set(["incomplete", "incomplete_expired"]);
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
@@ -30,15 +32,23 @@ export async function readPlan(): Promise<Plan | null> {
const data = isRecord(body) && isRecord(body.data) ? body.data : body;
if (!res.ok || !isRecord(data)) return mockPlan();
const usage = isRecord(data.usage) ? data.usage : {};
const plan = String(data.plan ?? data.tier ?? "none") || "none";
return { plan, credits: Number(usage.remaining_units ?? data.credits ?? 0) || 0 };
// Shaped as studio-contracts' BillingSubscription: `subscription` is null on Free, and an unpaid placeholder
// carries its plan's name before the first invoice funds it.
const sub = isRecord(data.subscription) ? data.subscription : null;
const paid = !!sub && !UNPAID.has(String(sub.status));
return {
plan: paid ? String(sub.plan_name ?? "paid") : "none",
credits: Number(usage.remaining_units ?? 0) || 0,
};
} catch {
return mockPlan();
}
}
export const plansUrl = () => process.env["HYPERFRAMES_PLANS_URL"] || "https://hyperframes.studio/";
export const creditsUrl = () => process.env["HYPERFRAMES_CREDITS_URL"] || plansUrl();
export const plansUrl = () =>
process.env["HYPERFRAMES_PLANS_URL"] || "https://hyperframes.studio/pricing";
export const creditsUrl = () =>
process.env["HYPERFRAMES_CREDITS_URL"] || "https://hyperframes.studio/account/plan";
/** A plans or credits page link carrying the fields the web side reads (source, needed credits, the waiting step). */
export function pageUrl(
+1 -1
View File
@@ -47,7 +47,7 @@ describe("runJob", () => {
reply(402, { error: { code: "hf_subscription_required", message: "no plan" } }),
);
await expect(runJob("/v3/x", {}, "k", (id) => id)).rejects.toMatchObject({
fix: "Subscribe: https://hyperframes.studio/?source=cli",
fix: "Subscribe: https://hyperframes.studio/pricing?source=cli",
});
vi.stubGlobal("fetch", async () =>
reply(402, { code: "hf_usage_exhausted", url: "https://pay.test/credits" }),
+1 -1
View File
@@ -38,7 +38,7 @@ function refusal(status: number, body: unknown): Fix {
// The fix is the one line the agent relays as printed; the problem says how to wait for the person.
if (/hf_subscription_required/i.test(code))
return new Fix(
`this needs a HyperFrames plan; nothing was charged. Run \`npx hyperframes billing subscribe\`: it opens the page and waits`,
`this is a HyperFrames Pro feature; nothing was charged. Run \`npx hyperframes billing subscribe\`: it opens the page and waits`,
`Subscribe: ${url ?? pageUrl("plans", { needed })}`,
);
if (/hf_usage_exhausted/i.test(code))
+1 -1
View File
@@ -26,7 +26,7 @@
"files": 76
},
"reverse-any-video": {
"hash": "3ede55ed2d1eaa54",
"hash": "515f16c74b1116fc",
"files": 4
},
"slideshow": {
+1 -1
View File
@@ -11,7 +11,7 @@ metadata:
The result is a HyperFrames project whose render matches the original frame for frame and stays editable. Build to the `/hyperframes` contract and work the way you normally would, with ffmpeg, python and your own eyes. This skill holds only what earlier rebuilds got wrong or spent hours on. Reverse videos the person has the right to use.
Sign in first; start only when `npx hyperframes auth status` shows a sign-in. Open with `references/start.md`. Rebuild the video as it is; remix it only when the person asks.
Reverse is a HyperFrames Pro feature: start only when `npx hyperframes auth status` shows a sign-in on a paid plan, not Free. Open with `references/start.md`. Rebuild the video as it is; remix it only when the person asks.
## The original decides the frame
+14 -1
View File
@@ -21,7 +21,20 @@ video, so they move and speak as they do in the original. For example:
> Then it takes about 20–50 minutes: I study the video frame by frame, rebuild every layer as a project you can edit,
> and check it against the original until it matches.
## Signed in
## Signed in on Free
Reverse is part of Pro. Say so once, give the upgrade step, and start nothing until they are on Pro. For example:
> Reverse is part of HyperFrames Pro: it re-shoots the people in your video with HeyGen. Upgrade by typing this in the
> prompt:
>
> ```
> ! npx hyperframes billing subscribe
> ```
>
> It opens the plans page and finishes once you're on Pro. Reply "done" and I'll start.
## Signed in on Pro
One line, then start.