Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ coverage/
.DS_Store
.stryker-tmp/
mutation-report/
reports/
115 changes: 115 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Architecture

This document explains the non-obvious design decisions in `ccm` — the ones that aren't visible from
the command list. The headline ideas: profiles have **no single source of truth**, so state is
*reconciled*; and every destructive operation is **transactional**, so a mid-operation failure never
leaves a half-migrated profile.

## State model

A profile is not one thing. It is up to three independent pieces of on-disk state:

| Piece | Location | Owner |
| ---------------- | --------------------------------- | ---------------- |
| Metadata | `~/.ccm/config.json` (`profiles`) | ccm |
| Config directory | `~/.ccm/profiles/<name>/` | Claude Code |
| Browser wrapper | `~/.ccm/browsers/<name>` | ccm (optional) |

These can drift out of sync — a directory deleted by hand, a config edited externally, a crash
between two writes. Rather than trust one source, `listStoredProfiles` / `getStoredProfile`
(`src/lib/profile-store.ts`) **reconcile** the config keys with the filesystem and classify each
profile into a state:

- `ready` — present in both config and filesystem (the normal case).
- `orphaned` — directory exists, but no config entry.
- `config-only` — config entry exists, but no directory.

`ccm list` and `ccm status` surface these states so drift is visible instead of silently misleading.
`profile-view.ts` projects this reconciled view (plus live Claude auth status) into the serializable
shape emitted by `--json`.

## Transactional protocol

`remove`, `rename`, and `copy-config` each mutate more than one of the three state pieces. A naive
implementation deletes/renames/copies in sequence and hopes nothing throws halfway. ccm instead uses a
**stage → mutate → finalize** protocol with rollback, so each operation is all-or-nothing.

### Why staging exists

`rename(2)` is atomic on a POSIX filesystem; a recursive copy is not. So:

- **Removal** (`removeStoredProfile`) does not delete eagerly. It first *stages* the directory and
browser wrapper by renaming them aside (`stageProfileDirRemoval` →
`.<name>.staged-<pid>-<timestamp>`), then removes the config entry, then *finalizes* by deleting the
staged copies. If the config write fails, the staged assets are renamed back. If finalization fails,
both the assets and the config entry are restored.
- **Rename** (`renameStoredProfile`) renames the directory, then the browser wrapper, then the config
entry — each step guarded. A failure at any step rolls the prior steps back in reverse order before
re-throwing.
- **Copy** (`applyProfileConfigCopy` in `profile-config-copy.ts`) stages any path it is about to
overwrite (rename to a staged path), copies the new content, then deletes the staged originals. On
failure it removes what it copied and renames the staged originals back.

### Rollback runs in reverse

`rollbackCopy` undoes work in the opposite order it was applied: remove copied targets newest-first,
then restore staged originals newest-first. Reverse order matters because later operations can depend
on earlier ones (a created parent directory, an overwritten file shadowing a staged one). Unwinding a
stack in LIFO order is the only ordering that is always safe.

### Staged path safety

`getStagedPath` builds the temporary name from `process.pid` + `Date.now()`, with an incrementing
suffix and a 100-attempt cap. The PID component keeps two concurrent `ccm` processes from colliding on
the same staged path; the timestamp + counter handle repeats within one process. If all 100 candidates
are somehow taken, it throws rather than guess — failing loudly beats clobbering a real file.

### Rollback-of-rollback

Rollback can itself fail (a filesystem that broke mid-operation may also break during recovery). The
code never swallows that. It aggregates recovery errors and folds them into the thrown message —
`Failed to remove profile "x": <original>. Recovery failed: <details>` — so the user learns both what
went wrong *and* that the automatic repair didn't fully succeed, including which assets to inspect by
hand. This is the difference between "operation failed, state intact" and "operation failed, state
unknown" — and the message tells you which.

## Testability is a design constraint, not an afterthought

Every library function takes its filesystem roots as parameters that default to the real paths:

```ts
export function listStoredProfiles(
configFile = CONFIG_FILE,
profilesDir = PROFILES_DIR,
): StoredProfile[]
```

This dependency-injection seam is deliberate. It lets tests run against temp directories with zero
global mocking, and it is *why* 100% line, branch, function, and mutation coverage is achievable on
real file I/O instead of stubs. When adding a function that touches disk, keep the seam.

## Error handling: `runAction`, not a top-level catch

CLI commands wrap their action body in `runAction` (`src/lib/run-action.ts`), which prints a single
`✗ <message>` line and exits with code 1 on any throw or rejection. It runs synchronous actions
synchronously (so failures surface on the same tick, which the tests rely on) and only awaits when the
action returns a promise.

The tempting alternative — one `try/catch` around `program.parse()` in `index.ts` — was rejected on
purpose. `index.ts` is excluded from coverage (it is pure command registration), so putting error
logic there would hide it from the 100% gate. `runAction` lives in a covered, mutation-tested library
file, so the behavior every command depends on is actually verified. Honest coverage over convenient
coverage.

Output styling (colors, icons, the `✓`/`✗`/`!`/`●` glyphs) is centralized in `src/lib/ui.ts`, built on
`node:util` `styleText`. Because `styleText` degrades to plain text on a non-TTY or under `NO_COLOR`,
piping `ccm list` or `--json` into another tool yields clean, escape-free strings without any
command-level branching.

## Privacy boundary

ccm never reads, parses, or copies Claude auth tokens. It manipulates *directories* and sets
`CLAUDE_CONFIG_DIR`; Claude Code owns everything inside a profile dir. `ccm copy-config` is explicitly
scoped to non-auth config (`settings.json`, `plugins`) and refuses anything else. Auth status shown by
`ccm status` comes from invoking `claude auth status --json` in the profile's environment, not from
inspecting credentials directly.
55 changes: 55 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# CLAUDE.md

Guidance for AI agents (and humans) working in this repo. See `ARCHITECTURE.md` for the design
deep-dive.

## What this is

`ccm` (`@remeic/ccm`) — nvm-like manager for Claude Code profiles. Each profile is an isolated
`CLAUDE_CONFIG_DIR`, so multiple Claude accounts can coexist without re-login. CLI built on
`commander` + `zod`. Zero other runtime deps — keep it that way.

## Commands

Bun is the dev package manager (`bun.lock`). Node 24 (`.nvmrc`, `engines`).

```bash
bun install
bun run build # tsup → single ESM file in dist/
bun run dev # tsup watch
bun run test # vitest
bun run test:coverage # vitest + coverage (gate)
bun run test:mutation # stryker (gate)
bun run lint # biome check
bun run lint:fix # biome check --write
bun run format # biome format
```

## Non-negotiable invariants

- **100% coverage** (vitest, all of branches/functions/lines/statements) and **100% mutation**
(Stryker, `break: 100`). Any new code must be fully tested AND mutation-proof. CI enforces both.
- **Every new `src/lib/*.ts` and mutated `src/commands/*.ts` must be added to `stryker.config.mjs`
`mutate[]`.** Omitting it silently weakens the 100%-mutation claim — a reviewer will diff `mutate[]`
against `src/`. Add a direct `tests/lib/*.test.ts` for each new lib file; don't rely on caller
coverage to kill mutants.
- `src/index.ts` and `src/types.ts` are excluded from coverage — keep them logic-free (registration
and schemas only). Don't move testable logic there to dodge the gate.

## Conventions

- **ESM `.js` import specifiers** in `.ts` source (e.g. `import { x } from './foo.js'`) — required by
the bundler's module resolution. New files follow this.
- **Layering:** `src/lib/*` = pure, testable logic (named exports + `/** JSDoc */`). `src/commands/*` =
CLI wiring (commander registration, I/O). Keep UI out of logic.
- **UI/output:** colors, icons, and print helpers live in `src/lib/ui.ts` (built on `node:util`
`styleText`, NO_COLOR/non-TTY aware). Never hand-write `\x1b[...` escapes in commands.
- **Errors:** `formatError` in `src/lib/errors.ts`; CLI error handling via `runAction` in
`src/lib/run-action.ts` (prints `✗ message` + `exit(1)`). Don't re-add per-command try/catch
boilerplate; keep only inner try/catch that carries real rollback semantics.
- **Dependency-injectable seams:** lib functions take `configFile`/`profilesDir`/`browsersDir` params
defaulting to the real paths. This is what makes 100% coverage achievable — preserve the pattern.
- **Validation:** all external input via zod schemas in `src/types.ts` (profile names, config shape,
Claude auth status). Parse with `.safeParse` and degrade gracefully.
- Biome style: single quotes, no semicolons, trailing commas, 100-col width. Conventional Commits
(commitlint-enforced).
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ The codebase separates pure logic from CLI wiring:
- `src/lib/` — core modules (config I/O, profile management, Claude interaction). No CLI framework dependencies.
- `src/commands/` — thin Commander.js command registrations that call into `lib/`.

This makes the core logic independently testable without mocking the CLI framework.
This makes the core logic independently testable without mocking the CLI framework. For the design
rationale (state reconciliation, the transactional stage/rollback protocol, and why error handling
lives in `runAction` rather than `index.ts`), see [ARCHITECTURE.md](ARCHITECTURE.md).

## Development

Expand Down
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Manage separate Claude Code profiles for personal, work, and client accounts wit
- [Quick Start](#quick-start)
- [Commands](#commands)
- [Compliance Notice](#compliance-notice)
- [Scripting (JSON Output)](#scripting-json-output)
- [Shell Completion](#shell-completion)
- [Passing Flags and Environment Variables](#passing-flags-and-environment-variables)
- [Multi-Account Login](#multi-account-login)
- [Different Browser per Profile](#different-browser-per-profile)
Expand Down Expand Up @@ -135,15 +137,16 @@ $ ccm use work
| -------------------------------------------------------- | ------------------------------------------------------------- |
| `ccm create <name> [-l label] [-b browser] [--from p]` | Create a profile. `--from` seeds non-auth config |
| `ccm compliance` / `ccm tos` | Show the compliance notice, disclaimer, and official sources |
| `ccm list` | List all profiles with auth status, including drifted entries |
| `ccm list [--json]` | List all profiles with auth status, including drifted entries. `--json` for scripts |
| `ccm use <name> [args...]` | Launch Claude Code. Extra args are passed to Claude |
| `ccm login <name> [--console] [-b browser] [--url-only]` | Authenticate a profile |
| `ccm status [name]` | Show auth status and storage state for one or all profiles |
| `ccm status [name] [--json]` | Show auth status and storage state for one or all profiles. `--json` for scripts |
| `ccm rename <old-name> <new-name>` | Rename a profile (config, directory, and browser wrapper) |
| `ccm remove <name> [-f]` | Remove a profile. `-f` skips confirmation |
| `ccm copy-config <source> <target> [--only x] [--dry-run] [-f]` | Copy non-auth config (settings/hooks/skills plugins) |
| `ccm run <name> -p <prompt>` | Run a prompt non-interactively |
| `ccm skills <add\|list\|remove\|update> <name> [repos...]` | Manage skills scoped to a profile (wraps `npx skills`) |
| `ccm completion <bash\|zsh\|fish>` | Print a shell completion script |

## Compliance Notice

Expand All @@ -162,6 +165,39 @@ The notice is intentionally conservative:
- If a team needs parallel access, they should use separate seats/accounts or API-key-based access under applicable Anthropic commercial terms.
- The notice is compliance guidance, not legal advice. Users remain responsible for reviewing Anthropic terms for their exact workflow.

## Scripting (JSON Output)

`ccm list` and `ccm status` accept `--json` for machine-readable output you can pipe into `jq` or
other tooling. Color is automatically disabled when output is not a TTY (or when `NO_COLOR` is set),
so human-facing commands stay clean in pipes and logs.

```bash
# All profiles as JSON
ccm list --json | jq '.[] | select(.loggedIn) | .name'

# A single profile
ccm status work --json | jq '.account'
```

Each entry: `{ name, state, authMethod, account, loggedIn, createdAt, hasConfig, hasDirectory }`.
Unknown values are `null` (never the `—` placeholder used in the human table).

## Shell Completion

`ccm completion <shell>` prints a completion script. The command list is derived from the live
command registry, so it never drifts as commands are added.

```bash
# zsh
ccm completion zsh > "${fpath[1]}/_ccm"

# bash
ccm completion bash >> ~/.bashrc

# fish
ccm completion fish > ~/.config/fish/completions/ccm.fish
```

## Passing Flags and Environment Variables

Extra args in `ccm use` are forwarded to `claude` (with or without the `--` separator). Env vars from your shell are inherited.
Expand Down Expand Up @@ -307,10 +343,16 @@ src/
│ ├── profiles.ts # Profile directory management and validation
│ ├── profile-store.ts # Reconciled config/filesystem profile view
│ ├── profile-config-copy.ts # Config copy planner + transactional apply/rollback
│ ├── profile-view.ts # Serializable projection shared by list/status (incl. --json)
│ ├── claude.ts # Claude binary discovery, spawning, auth status
│ ├── compliance.ts # Centralized compliance notice text and official sources
│ ├── completion.ts # Shell completion script generation (bash/zsh/fish)
│ ├── errors.ts # Shared error-message formatting
│ ├── run-action.ts # Wraps command actions with uniform error handling
│ ├── ui.ts # Terminal output helpers (NO_COLOR/TTY-aware)
│ └── browsers.ts # Browser wrapper generation and validation
└── commands/
├── completion.ts # Emit a shell completion script
├── copy-config.ts # Copy non-auth config between profiles (dry-run + rollback)
├── compliance.ts # Dedicated compliance/TOS command
├── create.ts # Create profile (with rollback on failure)
Expand All @@ -323,6 +365,9 @@ src/
└── run.ts # Run prompt with specific profile
```

For the design rationale behind the reconciled profile store and the transactional
staging/rollback used by remove, rename, and copy-config, see **[ARCHITECTURE.md](ARCHITECTURE.md)**.

### Profile Isolation

Claude Code reads auth tokens, settings, and project data from whatever directory `CLAUDE_CONFIG_DIR` points to. ccm exploits this by creating a separate directory per profile and setting this environment variable when spawning Claude:
Expand Down
28 changes: 28 additions & 0 deletions src/commands/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Command } from 'commander'
import { generateCompletion, parseShell, SUPPORTED_SHELLS } from '../lib/completion.js'
import { runAction } from '../lib/run-action.js'

/** Collects the top-level command names (and aliases) registered on a program. */
export function collectCommandNames(program: Command): string[] {
const names = new Set<string>()
for (const command of program.commands) {
const name = command.name()
if (name === 'completion') continue
names.add(name)
for (const alias of command.aliases()) names.add(alias)
}
return [...names].sort((left, right) => left.localeCompare(right))
}

/** Registers the CLI workflow for emitting shell completion scripts. */
export function registerCompletion(program: Command): void {
program
.command('completion <shell>')
.description(`Print a shell completion script (${SUPPORTED_SHELLS.join(', ')})`)
.action(
runAction((shell: string) => {
const parsed = parseShell(shell)
console.log(generateCompletion(parsed, collectCommandNames(program)))
}),
)
}
29 changes: 15 additions & 14 deletions src/commands/copy-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
type ProfileConfigCopySelector,
planProfileConfigCopy,
} from '../lib/profile-config-copy.js'
import { runAction } from '../lib/run-action.js'
import { printSuccess, warnLine } from '../lib/ui.js'

function confirm(question: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout })
Expand Down Expand Up @@ -56,12 +58,12 @@ export function registerCopyConfig(program: Command): void {
.option('--dry-run', 'Preview what would be copied without writing files')
.option('-f, --force', 'Skip overwrite confirmation and apply immediately')
.action(
async (
source: string,
target: string,
opts: { dryRun?: boolean; force?: boolean; only?: string[] },
) => {
try {
runAction(
async (
source: string,
target: string,
opts: { dryRun?: boolean; force?: boolean; only?: string[] },
) => {
const plan = planProfileConfigCopy(
source,
target,
Expand All @@ -79,7 +81,9 @@ export function registerCopyConfig(program: Command): void {

if (plan.overwriteCount > 0) {
console.log(
`! ${plan.overwriteCount} existing path(s) in "${target}" will be overwritten.`,
warnLine(
`${plan.overwriteCount} existing path(s) in "${target}" will be overwritten.`,
),
)
if (!opts.force) {
const ok = await confirm('Continue? (y/N) ')
Expand All @@ -91,13 +95,10 @@ export function registerCopyConfig(program: Command): void {
}

const result = applyProfileConfigCopy(plan)
console.log(
`\x1b[32m✓\x1b[0m Copied ${result.copiedCount} item(s) from "${source}" to "${target}" (${result.createdCount} created, ${result.overwrittenCount} overwritten)`,
printSuccess(
`Copied ${result.copiedCount} item(s) from "${source}" to "${target}" (${result.createdCount} created, ${result.overwrittenCount} overwritten)`,
)
} catch (e) {
console.error(`\x1b[31m✗\x1b[0m ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
}
},
},
),
)
}
Loading
Loading