Skip to content

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

Merged
nicolas-angelo merged 12 commits into
mainfrom
feat/billing-orgs-shortcut-commands
May 5, 2026
Merged

feat(cli-core): add clerk enable and clerk disable commands for orgs and billing#219
nicolas-angelo merged 12 commits into
mainfrom
feat/billing-orgs-shortcut-commands

Conversation

@nicolas-angelo
Copy link
Copy Markdown
Contributor

@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: f09d2ae

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1e6572da-285e-458e-a76d-5874f25b4de0

📥 Commits

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

📒 Files selected for processing (7)
  • README.md
  • packages/cli-core/src/cli-program.ts
  • packages/cli-core/src/commands/billing/index.test.ts
  • packages/cli-core/src/commands/billing/index.ts
  • packages/cli-core/src/commands/config/apply-patch.ts
  • packages/cli-core/src/commands/orgs/index.test.ts
  • packages/cli-core/src/commands/orgs/index.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/cli-core/src/commands/orgs/index.test.ts
  • packages/cli-core/src/cli-program.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/cli-core/src/commands/config/apply-patch.ts
  • packages/cli-core/src/commands/orgs/index.ts

📝 Walkthrough

Walkthrough

Adds new CLI commands to enable/disable Organizations and Billing on a linked Clerk instance: clerk enable/disable orgs and clerk enable/disable billing. Implements parsing and validation for options (e.g., --for, --force-selection, --auto-create, --max-members, --domains, --no-skills, --yes, --dry-run), a shared applyConfigPatch flow that fetches/ diffs/ confirms/patches instance config, optional post-enable agent-skill installation for billing, completion candidates for --for, next-steps hints, README documentation, comprehensive tests, 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
Title check ✅ Passed The title accurately summarizes the main change: adding new clerk enable and disable commands for orgs and billing features.
Description check ✅ Passed The description is directly related to the changeset, explaining the new commands, their flags, behavior, and usage examples with before/after comparisons.
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.

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

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 60 minutes.

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();
}

@nicolas-angelo nicolas-angelo merged commit 40ed6e5 into main May 5, 2026
13 of 14 checks passed
@nicolas-angelo nicolas-angelo deleted the feat/billing-orgs-shortcut-commands branch May 5, 2026 14:38
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