From 1618b3201ae6392b2b277d16f808fa602faf0450 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 1 Sep 2026 12:26:39 -0500 Subject: [PATCH] =?UTF-8?q?feat(cli):=20iris=20pricing=20=E2=80=94=20the?= =?UTF-8?q?=20agent=20can=20finally=20tell=20a=20user=20where=20to=20sign?= =?UTF-8?q?=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IRIS's conversion event is an install: someone runs the CLI or the desktop app and the agent is meant to hand them everything after that. It could not hand them the one thing that closes the loop. There is no pricing, plans, billing, account or subscribe verb; `packages` is operator tooling for EDITING the catalogue, not a way for a user to buy. A user who installed the CLI had no in-terminal path to a plan — they had to already know a URL nobody had told them. Two properties, both learned from live bugs rather than chosen: WORKS SIGNED OUT. The person who needs this most has just installed and has no account. Gating pricing behind auth fails exactly the user it exists for. The catalogue read is tolerant of any failure and still prints the pricing URL, because a URL with no table beats a table nobody can reach. NEVER PRINTS THE OTHER BRAND'S URL. heyiris.io/register and heyiris.io/pricing both shipped redirects to freelabel.net — an IRIS visitor handed another company's page. An agent repeating that in a terminal is trusted more than a web page is, and no analytics catches it. Filtering is on each package's own brand field. The brand mapping is not cosmetic. The catalogue's stored values are "iris" and "elon" — NOT "freelabel". The first version of this file filtered on "freelabel" and matched zero of the 24 live packages, rendering an empty list. Caught by running the filter against the real catalogue rather than against what the word ought to have been. BRAND_VALUES maps the user-facing word to the stored legacy one so the CLI's vocabulary stays honest without asking the catalogue to migrate. Verified against all 24 live packages: --brand iris returns 19, --brand freelabel returns 5, and neither leaks a plan or a URL belonging to the other. Not verified: yargs wiring, irisFetch at runtime, and rendered output — a full `bun install` needs ~2GB and fails on a native build script, so the assembled command could not be executed here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WcmgfXD81U3cgNv9L2ZzfL --- packages/opencode/src/cli/cmd/pricing.ts | 177 +++++++++++++++++++++++ packages/opencode/src/index.ts | 2 + 2 files changed, 179 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/pricing.ts diff --git a/packages/opencode/src/cli/cmd/pricing.ts b/packages/opencode/src/cli/cmd/pricing.ts new file mode 100644 index 000000000000..66fb45f597b7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/pricing.ts @@ -0,0 +1,177 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, printDivider, dim, writeJson } from "./iris-api" + +/** + * `iris pricing` — where to sign up, answered on the machine the agent is running on. + * + * IRIS's conversion event is an install: someone downloads the CLI or the desktop app and the + * agent is meant to hand them everything after that. Until now it could not hand them the one + * thing that closes the loop. There was no pricing, plans, billing, account or subscribe verb; + * `packages` is operator tooling for EDITING the catalogue, not a way for a user to buy. + * + * Two properties this command must have, both learned from live bugs: + * + * 1. It works SIGNED OUT. The user who needs it most has just installed and has no account — + * gating pricing behind auth fails exactly the person it exists for. The catalogue endpoint + * is public; if it cannot be reached for any reason we still print the pricing URL, because + * a URL with no table beats a table nobody can reach. + * + * 2. It never prints the other brand's URL. heyiris.io/pricing and heyiris.io/register both + * shipped redirects to freelabel.net — an IRIS visitor sent to another company's page. An + * agent repeating that in a terminal is trusted more than a web page, and no analytics will + * catch it. Filtering is on each package's own `brand` field. + * + * See /p/epic-entry-points (ADR-02) and bug #183182. + */ + +type Brand = "iris" | "freelabel" + +/** + * The catalogue's own brand values are "iris" and "elon" — NOT "freelabel". Filtering on + * "freelabel" matches zero of the 24 live packages and renders an empty list, which is what + * the first version of this file did. The user-facing word is FREELABEL; the stored value is + * a legacy product name. Mapping them here keeps the CLI's vocabulary honest without asking + * the catalogue to migrate. + */ +const BRAND_VALUES: Record = { + iris: ["iris"], + freelabel: ["elon", "freelabel"], +} + +const SITE: Record = { + iris: { + name: "IRIS", + pricing: "https://web.heyiris.io/pricing", + install: "https://heyiris.io/downloads", + }, + freelabel: { + name: "FREELABEL", + pricing: "https://web.freelabel.net/pricing", + install: "https://freelabel.net/p/freelabel-start", + }, +} + +/** The CLI is an IRIS product, so IRIS is the default. --brand switches it. */ +const DEFAULT_BRAND: Brand = "iris" + +function brandOf(pkg: any): string { + return String(pkg?.brand ?? "").toLowerCase() +} + +function money(pkg: any): string { + const price = pkg?.price ?? 0 + const period = pkg?.billing_period ?? "month" + if (Number(price) === 0) return "free" + return `$${price}/${period === "once" ? "once" : period}` +} + +/** + * Public catalogue read. Deliberately tolerant: any failure returns [] rather than throwing, + * because the URL below it is the part the user actually needs. + */ +async function fetchPackages(): Promise { + try { + const res = await irisFetch("/api/v1/platform/packages") + if (!res.ok) return [] + const data = (await res.json()) as { data?: any[] } + return data?.data ?? (Array.isArray(data) ? (data as any) : []) + } catch { + return [] + } +} + +function sellable(pkgs: any[], brand: Brand): any[] { + const accepted = BRAND_VALUES[brand] + return pkgs + .filter((p) => accepted.includes(brandOf(p))) + .filter((p) => p?.public !== false) + .sort((a, b) => Number(a?.price ?? 0) - Number(b?.price ?? 0)) +} + +const ListCmd = cmd({ + command: "$0 [slug]", + describe: "show plans and where to sign up", + builder: (y: any) => + y + .positional("slug", { describe: "a single package slug", type: "string" }) + .option("brand", { + describe: "which brand's plans (iris | freelabel)", + type: "string", + choices: ["iris", "freelabel"], + default: DEFAULT_BRAND, + }) + .option("url", { describe: "print only the signup URL", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args: any) { + const brand: Brand = (args.brand as Brand) ?? DEFAULT_BRAND + const site = SITE[brand] + + // --url is for piping and for the agent. No chrome, no spinner, no auth, one line. + if (args.url) { + console.log(site.pricing) + return + } + + const all = await fetchPackages() + const plans = sellable(all, brand) + const one = args.slug ? plans.find((p) => p?.slug === args.slug) : null + + if (args.json) { + await writeJson({ + brand, + pricing_url: site.pricing, + install_url: site.install, + packages: one ? [one] : plans, + }) + return + } + + UI.empty() + prompts.intro(`◈ ${site.name} pricing`) + + if (args.slug && !one) { + console.log(` No public ${site.name} plan with slug "${args.slug}".`) + console.log(dim(` Run 'iris pricing' to see the list.`)) + prompts.outro(site.pricing) + return + } + + const show = one ? [one] : plans + + if (show.length === 0) { + // The catalogue was unreachable or empty. Still answer the question that was asked. + console.log(` Could not read the plan list just now.`) + console.log(` Plans and signup: ${site.pricing}`) + prompts.outro(dim("Nothing else is needed to sign up.")) + return + } + + printDivider() + for (const p of show) { + const star = p?.popular ? " ★" : "" + console.log(` ${p.slug ?? "?"} ${dim(`#${p.id ?? "?"}`)} ${money(p)}${star}`) + if (p?.title) console.log(` ${p.title}`) + if (p?.subtitle) console.log(dim(` ${p.subtitle}`)) + if (one) { + const feats = p?.features?.displayFeatures + if (Array.isArray(feats)) for (const f of feats.slice(0, 8)) console.log(dim(` · ${f}`)) + } + console.log() + } + printDivider() + + console.log(` Sign up: ${site.pricing}`) + console.log(dim(` New here? ${site.install}`)) + prompts.outro(dim("iris pricing for one plan · --url to pipe it · --json for the agent")) + }, +}) + +export const PricingCommand = cmd({ + command: "pricing", + aliases: ["plans"], + describe: "plans and where to sign up — works signed out", + builder: (y: any) => y.command(ListCmd).help(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 3136648d1196..01e561d08599 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -136,6 +136,7 @@ import { PlatformDriveCommand } from "./cli/cmd/platform-drive" import { PlatformObsidianCommand } from "./cli/cmd/platform-obsidian" import { PlatformCreativeCommand } from "./cli/cmd/platform-creative" import { PlatformPackagesCommand } from "./cli/cmd/platform-packages" +import { PricingCommand } from "./cli/cmd/pricing" import { PlatformMarketplaceCommand } from "./cli/cmd/platform-marketplace" import { PlatformMemoryCommand } from "./cli/cmd/platform-memory" import { PlatformProfileCommand } from "./cli/cmd/platform-profile" @@ -418,6 +419,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformObsidianCommand)) .command(reg(PlatformCreativeCommand)) .command(reg(PlatformPackagesCommand)) + .command(reg(PricingCommand)) .command(reg(PlatformMarketplaceCommand)) .command(reg(PlatformMemoryCommand)) .command(reg(PlatformProfileCommand))