Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clerk-branch-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add `clerk branch` commands (create/list/delete/diff) and `--branch <name>` targeting for `config` and `env` commands, plus `createBranch`/`deleteInstance` Platform API client methods.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Commands:
users [options] Manage Clerk users
env Manage environment variables
config Manage instance configuration
branch Fork, list, diff, and delete instance branches
enable Enable Clerk features on the linked instance
disable Disable Clerk features on the linked instance
api [options] [endpoint] [filter] Make authenticated requests to the Clerk API
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { registerApps } from "./commands/apps/index.ts";
import { registerUsers } from "./commands/users/index.ts";
import { registerEnv } from "./commands/env/index.ts";
import { registerConfig } from "./commands/config/index.ts";
import { registerBranch } from "./commands/branch/index.ts";
import { registerToggles } from "./commands/toggles/index.ts";
import { registerApi } from "./commands/api/index.ts";
import { registerDoctor } from "./commands/doctor/index.ts";
Expand Down Expand Up @@ -62,6 +63,7 @@ const registrants: CommandRegistrant[] = [
registerUsers,
registerEnv,
registerConfig,
registerBranch,
registerToggles,
registerApi,
registerDoctor,
Expand Down
95 changes: 95 additions & 0 deletions packages/cli-core/src/commands/branch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# clerk branch

Manage Clerk instance branches. Branches are development instances forked from an existing instance, enabling isolated configuration experiments without affecting production or the primary development environment.

## Subcommands

### `clerk branch create --name <name> [--from <instance>] [--app <app_id>]`

Forks an existing instance into a new named branch. By default clones from the `production` instance.

Options:

- `--name <name>` (required) — Name for the new branch
- `--from <instance>` — Source instance to clone from (`production`, `development`, or a literal instance ID). Defaults to `production`.
- `--app <app_id>` — Target application ID (overrides linked project)

Platform API: `POST /v1/platform/applications/{appId}/instances`

Agent mode output:

```json
{
"status": "created",
"branch_name": "feature-auth",
"instance_id": "ins_abc123",
"parent_instance_id": "ins_prod456"
}
```

### `clerk branch list [--app <app_id>]`

Lists all branches for the linked or specified application. Only instances with a `branch_name` are shown.

Options:

- `--app <app_id>` — Target application ID (overrides linked project)

Platform API: `GET /v1/platform/applications/{appId}`

Human output: one line per branch, tab-separated `branch_name\tinstance_id`.

Agent mode output:

```json
{
"branches": [
{
"branch_name": "feature-auth",
"instance_id": "ins_abc123",
"parent_instance_id": "ins_dev456"
}
]
}
```

### `clerk branch delete <name> [--app <app_id>]`

Deletes a named branch instance. The branch is looked up by name from the application's instance list.

Options:

- `<name>` (positional, required) — Name of the branch to delete
- `--app <app_id>` — Target application ID (overrides linked project)

Platform API:

1. `GET /v1/platform/applications/{appId}` — resolve branch name to instance ID
2. `DELETE /v1/platform/applications/{appId}/instances/{instanceId}` — delete the instance

Agent mode output:

```json
{
"status": "deleted",
"branch_name": "feature-auth",
"instance_id": "ins_abc123"
}
```

### `clerk branch diff <name> [--against <instance>] [--app <app_id>]`

Shows a configuration diff between a branch and another instance (defaults to `production`).

Options:

- `<name>` (positional, required) — Name of the branch to diff
- `--against <instance>` — Instance to compare against (`production`, `development`, or a literal instance ID). Defaults to `production`.
- `--app <app_id>` — Target application ID (overrides linked project)

Platform API:

1. `GET /v1/platform/applications/{appId}/instances/{branchInstanceId}/config`
2. `GET /v1/platform/applications/{appId}/instances/{parentInstanceId}/config`

Output: a human-readable diff of changed leaf values, grouped by top-level config key. No agent-mode JSON — the diff is always rendered as text.
39 changes: 39 additions & 0 deletions packages/cli-core/src/commands/branch/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { resolveAppContext } from "../../lib/config.ts";
import { createBranch } from "../../lib/plapi.ts";
import { isAgent } from "../../mode.ts";
import { withApiContext } from "../../lib/errors.ts";
import { withSpinner } from "../../lib/spinner.ts";
import { log } from "../../lib/log.ts";

interface BranchCreateOptions {
app?: string;
name: string;
from?: string;
}

export async function branchCreate(options: BranchCreateOptions): Promise<void> {
const ctx = await resolveAppContext({ app: options.app, instance: options.from ?? "production" });
const branch = await withSpinner(`Forking ${ctx.instanceLabel} → ${options.name}...`, () =>
withApiContext(
createBranch(ctx.appId, { cloneInstanceId: ctx.instanceId, branchName: options.name }),
"Failed to create branch",
),
);

if (isAgent()) {
log.data(
JSON.stringify(
{
status: "created",
branch_name: options.name,
instance_id: branch.id,
parent_instance_id: ctx.instanceId,
},
null,
2,
),
);
return;
}
log.success(`Forked \`${ctx.instanceLabel}\` → \`${options.name}\` (${branch.id})`);
}
36 changes: 36 additions & 0 deletions packages/cli-core/src/commands/branch/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { resolveAppContext } from "../../lib/config.ts";
import { fetchApplication, deleteInstance } from "../../lib/plapi.ts";
import { isAgent } from "../../mode.ts";
import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts";
import { withSpinner } from "../../lib/spinner.ts";
import { log } from "../../lib/log.ts";

interface BranchDeleteOptions {
app?: string;
name: string;
}

export async function branchDelete(options: BranchDeleteOptions): Promise<void> {
const ctx = await resolveAppContext({ app: options.app });
const app = await withApiContext(fetchApplication(ctx.appId), "Failed to resolve branch");
const match = app.instances.find((i) => i.branch_name === options.name);
if (!match) {
throw new CliError(`No branch named "${options.name}".`, {
code: ERROR_CODE.INSTANCE_NOT_FOUND,
});
}
await withSpinner(`Deleting ${options.name}...`, () =>
withApiContext(deleteInstance(ctx.appId, match.instance_id), "Failed to delete branch"),
);
if (isAgent()) {
log.data(
JSON.stringify(
{ status: "deleted", branch_name: options.name, instance_id: match.instance_id },
null,
2,
),
);
return;
}
log.success(`Deleted branch \`${options.name}\``);
}
36 changes: 36 additions & 0 deletions packages/cli-core/src/commands/branch/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { resolveAppContext } from "../../lib/config.ts";
import { fetchInstanceConfig } from "../../lib/plapi.ts";
import { printDiff } from "../config/push.ts";
import { withApiContext } from "../../lib/errors.ts";
import { withSpinner } from "../../lib/spinner.ts";

interface BranchDiffOptions {
app?: string;
name: string;
against?: string;
}

export async function branchDiff(options: BranchDiffOptions): Promise<void> {
const branchCtx = await resolveAppContext({ app: options.app, branch: options.name });
const parentCtx = await resolveAppContext({
app: options.app,
instance: options.against ?? "production",
});
const [parentConfig, branchConfig] = await withSpinner(
`Diffing ${options.name} against ${parentCtx.instanceLabel}...`,
() =>
Promise.all([
withApiContext(
fetchInstanceConfig(parentCtx.appId, parentCtx.instanceId),
"Failed to fetch parent config",
),
withApiContext(
fetchInstanceConfig(branchCtx.appId, branchCtx.instanceId),
"Failed to fetch branch config",
),
]),
);
delete (parentConfig as Record<string, unknown>).config_version;
delete (branchConfig as Record<string, unknown>).config_version;
printDiff(parentConfig, branchConfig, false);
}
52 changes: 52 additions & 0 deletions packages/cli-core/src/commands/branch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Program } from "../../cli-program.ts";
import { branchCreate } from "./create.ts";
import { branchList } from "./list.ts";
import { branchDelete } from "./delete.ts";
import { branchDiff } from "./diff.ts";

export function registerBranch(program: Program): void {
const branch = program
.command("branch")
.description("Fork, list, diff, and delete instance branches")
.setExamples([
{
command: "clerk branch create --name agent/pr-42 --from production",
description: "Fork production into a branch",
},
{ command: "clerk branch list", description: "List branches" },
{
command: "clerk branch diff agent/pr-42 --against prod",
description: "Diff a branch against production",
},
{ command: "clerk branch delete agent/pr-42", description: "Delete a branch" },
]);

branch
.command("create")
.description("Fork an instance into a new branch (a development instance)")
.requiredOption("--name <name>", "Branch name (e.g. agent/pr-42)")
.option("--from <id>", "Parent instance to fork (dev, prod, or instance ID)", "production")
.option("--app <id>", "Application ID to target (works from any directory)")
.action(branchCreate);

branch
.command("list")
.description("List branches for the application")
.option("--app <id>", "Application ID to target (works from any directory)")
.action(branchList);

branch
.command("delete")
.description("Delete a branch")
.argument("<name>", "Branch name")
.option("--app <id>", "Application ID to target (works from any directory)")
.action((name, opts) => branchDelete({ ...opts, name }));

branch
.command("diff")
.description("Diff a branch's config against another instance")
.argument("<name>", "Branch name")
.option("--against <id>", "Instance to diff against (default: production)", "production")
.option("--app <id>", "Application ID to target (works from any directory)")
.action((name, opts) => branchDiff({ ...opts, name }));
}
Loading