Skip to content

feat(cli-core): add clerk enable and clerk disable commands for orgs and billing#219

Open
nicolas-angelo wants to merge 10 commits intomainfrom
feat/billing-orgs-shortcut-commands
Open

feat(cli-core): add clerk enable and clerk disable commands for orgs and billing#219
nicolas-angelo wants to merge 10 commits intomainfrom
feat/billing-orgs-shortcut-commands

Conversation

@nicolas-angelo
Copy link
Copy Markdown

@nicolas-angelo nicolas-angelo commented Apr 23, 2026

Summary

This PR adds two top-level commands — clerk enable and clerk disable — for toggling Clerk features on the linked instance. The toggle layer is intentionally narrow: simple on/off plus a few discoverable convenience flags. Anything beyond that continues to flow through clerk config patch.

What changed

  • clerk enable orgs / clerk disable orgs: toggle organizations. enable accepts --force-selection, --auto-create, --max-members <n>, and --domains for the most common settings.
  • clerk enable billing / clerk disable billing: toggle billing for organizations and/or users. --for defaults to both targets when omitted; enabling for org cascades to enabling organizations.
  • Agent skill nudge: after a successful enable billing, offers to install the clerk-billing skill from clerk/skills (suppress with --no-skills). clerk init doesn't bundle this one as a default — billing is opt-in — so enable billing is the natural moment to surface it.
  • Next-steps hints: after each enable, prints pointers to clerk config schema --keys <section> (what's tunable) and clerk config pull --keys <section> (what's currently set) for deeper exploration. Skipped in agent mode.

Usage

Organizations

clerk enable orgs                              # bare toggle
clerk enable orgs --force-selection            # toggle + force selection
clerk enable orgs --max-members 10 --domains   # toggle + multiple flags
clerk enable orgs --dry-run                    # preview the patch
clerk disable orgs                             # turn off

Billing

clerk enable billing                           # both targets (default)
clerk enable billing --for org                 # org only (cascades to enabling orgs)
clerk enable billing --for user                # user only
clerk enable billing --for org user            # variadic form
clerk enable billing --for org,user            # CSV form
clerk disable billing --for org                # org billing off; orgs themselves untouched
clerk enable billing --no-skills               # skip the clerk-billing skill install

Before / After

Before (manual JSON):

clerk config patch --json '{"organization_settings":{"enabled":true,"force_organization_selection":true}}' --yes
clerk config patch --json '{"billing":{"organization_enabled":true},"organization_settings":{"enabled":true}}' --yes

After (convenience toggles):

clerk enable orgs --force-selection
clerk enable billing --for org

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 23, 2026

🦋 Changeset detected

Latest commit: e4669c9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
clerk Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

Warning

Rate limit exceeded

@nicolas-angelo has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 28 minutes and 33 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: db6062d9-5178-491d-b33e-e1e630945ee2

📥 Commits

Reviewing files that changed from the base of the PR and between 6bf1545 and e4669c9.

📒 Files selected for processing (2)
  • README.md
  • packages/cli-core/src/cli-program.ts
📝 Walkthrough

Walkthrough

Adds CLI commands to enable/disable Organizations and Billing on a linked Clerk instance: clerk enable/disable orgs and clerk enable/disable billing (with flags like --for, --force-selection, --auto-create, --max-members, --domains, --no-skills, --yes, and --dry-run). Implements a shared apply-config-patch flow, installs an optional agent skill when enabling billing, updates completion values, adds next-steps hints, comprehensive tests for both commands, documentation READMEs, and a changeset marking a minor release.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title 'feat(cli-core): add clerk enable and clerk disable commands for orgs and billing' accurately and specifically summarizes the main changes, clearly indicating new enable/disable commands for organizations and billing features.
Description check ✅ Passed The PR description accurately describes the changeset, explaining the two new top-level commands (clerk enable/disable) with their options, behavior, agent skill nudge, next-steps hints, and usage examples.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 28 minutes and 33 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli-core/src/commands/billing/index.ts`:
- Around line 109-120: Validate numeric CLI flags before assigning to plan: for
options.amount, options.annualAmount, and options.trialDays parseInt results
must be checked for NaN and integer-ness (e.g., const amt =
parseInt(options.amount,10); if (Number.isNaN(amt) || !Number.isFinite(amt))
return error/exit), and only then set plan.amount, plan.annual_monthly_amount,
and plan.free_trial_days; for trialDays also ensure it's a non-negative integer
and set plan.free_trial_enabled true only when validation passes. Apply the same
checks for the other numeric flags referenced later (the block noted at lines
~201-210) so no invalid or partial numeric input (like "12abc" → 12 or NaN) is
sent to the API.

In `@packages/cli-core/src/commands/orgs/index.ts`:
- Around line 20-24: The patch payload construction uses
parseInt(options.maxMembers, 10) which allows invalid or non-positive values
(NaN or truncated numbers) to be sent to patchInstanceConfig; validate
options.maxMembers before adding to patch: ensure it's present, is an integer
string with no trailing chars, and is > 0 (e.g., via Number.isInteger(parsed)
and parsed > 0) and only then set patch.max_allowed_memberships to the parsed
integer; if validation fails, return/throw a clear user-facing error or CLI exit
indicating the --max-members value is invalid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9b25373c-146e-4858-b48e-3f4ebe70879f

📥 Commits

Reviewing files that changed from the base of the PR and between c586b39 and a87ed32.

📒 Files selected for processing (9)
  • .changeset/billing-orgs-shortcut-commands.md
  • README.md
  • packages/cli-core/src/cli-program.ts
  • packages/cli-core/src/commands/billing/README.md
  • packages/cli-core/src/commands/billing/index.test.ts
  • packages/cli-core/src/commands/billing/index.ts
  • packages/cli-core/src/commands/orgs/README.md
  • packages/cli-core/src/commands/orgs/index.test.ts
  • packages/cli-core/src/commands/orgs/index.ts

Comment thread packages/cli-core/src/commands/billing/index.ts Outdated
Comment thread packages/cli-core/src/commands/orgs/index.ts Outdated
.option("--auto-create", "Auto-create an organization for new users")
.option("--max-members <n>", "Maximum members per organization")
.option("--domains", "Enable verified domains")
.option("--yes", "Skip confirmation prompts")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--yes is advertised on orgs enable/disable, billing enable/disable, and plans create/update/remove, but no implementation consults options.yes or calls confirm(). The commands always mutate silently. Users who see --yes will reasonably assume there is a prompt to skip.

Compare with config patch, which these shortcuts wrap: packages/cli-core/src/commands/config/push.ts:94-102 does if (!options.dryRun && isHuman() && !options.yes) { await confirm(...) }. Without an equivalent branch here, the shortcut is strictly more dangerous than the command it's supposed to replace.

Fix: either remove --yes and the yes?: boolean fields until a prompt exists, or add the real confirmation branch for every mutating command. Option two is the right call, these are production config edits.

`Removing plan ${cyan(slug)} on ${ctx.appLabel} (${ctx.instanceLabel})...`,
() =>
withApiContext(
patchInstanceConfig(ctx.appId, ctx.instanceId, config, { destructive: true }),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plansRemove fetches the current config, deletes the slug in memory, and PATCHes with { destructive: true }. No prompt, no diff, no dry-run. A typo in <slug> or the active instance silently wipes a live subscription plan.

The equivalent clerk config patch path would print a diff, warn, and ask "Proceed?". The shortcut regresses this safety.

Fix: gate the mutation behind isHuman() && !options.yes confirmation, support --dry-run, and show the plan (name, price, payer) being removed before confirming.

Comment thread packages/cli-core/src/cli-program.ts Outdated
.argument("<slug>", "Plan slug (display name auto-derived via title case)")
.option("--name <name>", "Override display name")
.requiredOption("--amount <cents>", "Monthly price in cents")
.addOption(createOption("--payer <type>", "Who pays").choices(["org", "user"]))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 527 uses .requiredOption("--amount <cents>", …). Line 528 uses .addOption(createOption("--payer <type>", "Who pays").choices(["org", "user"])) with no .makeOptionMandatory(). packages/cli-core/src/commands/billing/README.md:18 claims --payer is (required).

When omitted, options.payer === undefined, so billing/index.ts:110 writes payer_type: undefined. JSON.stringify drops the field, sending a malformed plan to the API.

Fix:

.addOption(
  createOption("--payer <type>", "Who pays")
    .choices(["org", "user"])
    .makeOptionMandatory(),
)

Add a test covering the missing---payer rejection.

Comment thread packages/cli-core/src/cli-program.ts Outdated
.description("Create a subscription plan")
.argument("<slug>", "Plan slug (display name auto-derived via title case)")
.option("--name <name>", "Override display name")
.requiredOption("--amount <cents>", "Monthly price in cents")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

patchInstanceConfig() accepts dryRun (see lib/plapi.ts:156-184). clerk config patch exposes --dry-run at cli-program.ts:367. None of the new shortcuts plumb it through.

A maintainer who wants to preview billing enable --for org or plans remove pro on production has to fall back to the raw config patch command, defeating the point of the wrappers. These are the exact operations you most want to dry-run.

Fix: add --dry-run to every mutating subcommand and pass { dryRun: options.dryRun } into patchInstanceConfig, matching config/push.ts:107-115.


const plan: Record<string, unknown> = {
name: options.name || titleCase(slug),
amount: parseInt(options.amount, 10),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every numeric option (--amount, --max-members, --trial-days, --annual-amount) goes through raw parseInt(value, 10) with no validation, here and at billing/index.ts:117, 120, 201, 204, 209, plus orgs/index.ts:23.

parseInt("abc", 10) returns NaN, which JSON.stringify emits as null. So clerk billing plans create pro --amount abc --payer org sends amount: null to the API, and --max-members abc sends max_allowed_memberships: null. Neither failure mode is user-friendly.

Fix: use Commander's option argParser at the registration site:

.option("--amount <cents>", "Monthly price in cents", (v) => {
  const n = Number.parseInt(v, 10);
  if (!Number.isFinite(n) || n < 0) {
    throwUsageError(`Invalid --amount: "${v}". Must be a non-negative integer.`);
  }
  return n;
})

Or extract a shared parseNonNegativeInt helper.


const billing = current.billing as Record<string, unknown> | undefined;
if (billing?.organization_enabled) {
log.warn(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

orgsDisable fetches billing config and calls log.warn("…Disabling organizations will also disable org billing."), then unconditionally issues the PATCH.

Emitting a warning and then doing the thing the warning describes is worse than not warning at all, in a CI log it looks like the warning was heeded. If the dependency is worth detecting (it is), it's worth gating behind confirmation.

Fix: when billing.organization_enabled is true, require explicit confirmation in human mode or a --force / --yes flag to proceed. In non-human mode, consider failing with a usage error directing the user to pass --force.


const config = { billing: patch };

const result = await withSpinner(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config push.ts:75-92 fetches the current config, computes a diff with hasConfigChanges / printDiff, prints it, then prompts. The shortcut commands send blind.

If confirmation is added per the critical-priority findings, pair it with the diff, otherwise the prompt just says "Proceed?" with no context about what's about to change.

Fix: reuse hasConfigChanges / printDiff from config/push.ts. They already handle partial-payload patch mode, which is what these wrappers generate.


log.data(JSON.stringify(result, null, 2));
log.success(`Billing enabled for ${target === "org" ? "organizations" : "users"}`);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log.data(JSON.stringify(result, null, 2)) emits the full server response on stdout after every mutation, here and at billing/index.ts:80, 128, 229, 267, plus orgs/index.ts:41, 73.

Per .claude/rules/logging.md, log.data is stdout for pipeable output. config patch does this because the user opted into a low-level config operation. For clerk orgs enable the user asked a single-intent question, piping a ~200-line config blob out every time is noise and will trip up shell pipelines.

Fix: demote to log.debug(...) for routine success, or emit only when --json / --verbose is set. Keep the log.success(...) line as the human-facing signal.

const patch: Record<string, unknown> = { enabled: true };
if (options.forceSelection) patch.force_organization_selection = true;
if (options.domains) patch.domains_enabled = true;
if (options.maxMembers) patch.max_allowed_memberships = parseInt(options.maxMembers, 10);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (options.forceSelection) patch.force_organization_selection = true;
if (options.domains) patch.domains_enabled = true;
if (options.autoCreate) {  enabled: true }

There's no --no-force-selection or --force-selection=false. To turn a flag off while keeping orgs enabled, the user has to fall back to clerk config patch, partially defeating the wrapper.

Fix: either (a) document in the README that these flags are one-way and point users to config patch for the inverse, or (b) accept Commander's boolean negation (--no-force-selection) and write false when it's set.

const currency = (plan.currency as string) || "usd";
const price = amount === 0 ? "Free" : `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`;
const payer = plan.payer_type as string;
const visible = plan.publicly_visible !== false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cyan(plan.name as string) casts without checking. If the config contains a plan with no name field (legacy or malformed), output reads undefined (slug) — $X.XX/mo ….

Fix: cyan((plan.name as string) ?? slug).

@nicolas-angelo nicolas-angelo changed the title feat(cli-core): add clerk orgs and clerk billing shortcut commands feat(cli-core): add clerk enable and clerk disable commands for orgs and billing Apr 30, 2026
}

async function offerBillingSkillInstall(options: BillingOptions): Promise<void> {
const skipPrompt = options.yes === true || isAgent();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

offerBillingSkillInstall runs even when applyConfigPatch returns early ("No changes detected"). On an already-configured instance, every clerk enable billing invocation in agent mode will silently attempt to install clerk-billing again since isAgent() skips the confirmation prompt.

Consider having applyConfigPatch return a boolean so you can gate the skill offer:

Suggested change
const skipPrompt = options.yes === true || isAgent();
const applied = await applyConfigPatch({
ctx,
payload,
verb: `Enabling billing for ${describeTargets(targets)}`,
successMessage: `Billing enabled for ${describeTargets(targets)}`,
failureContext: "Failed to enable billing",
yes: options.yes,
dryRun: options.dryRun,
});
// `clerk init` doesn't bundle clerk-billing -- it's opt-in. Surface it here.
if (applied && !options.dryRun && options.skills !== false) {
await offerBillingSkillInstall(options);
}
if (applied && !options.dryRun) printNextSteps(NEXT_STEPS.ENABLE_BILLING);

(with applyConfigPatch returning false on the early "No changes" exit)

if (!dryRun && isHuman() && !yes) {
if (warning) log.warn(warning);
const ok = await confirm({ message: "Proceed?" });
if (!ok) throwUserAbort();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning is only printed inside the !dryRun && isHuman() && !yes block. When orgsDisable calls this with --yes (or in agent mode with --yes), the warning about stranded billing is silently dropped and the PATCH fires with no indication.

Consider moving the warning emission before the confirmation gate so it's always visible:

Suggested change
if (!ok) throwUserAbort();
if (warning) log.warn(warning);
if (!dryRun && isHuman() && !yes) {
const ok = await confirm({ message: "Proceed?" });
if (!ok) throwUserAbort();
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants