diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore index b4237d4..fbdb8fd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ .DS_Store .stryker-tmp/ mutation-report/ +reports/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..950933d --- /dev/null +++ b/ARCHITECTURE.md @@ -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//` | Claude Code | +| Browser wrapper | `~/.ccm/browsers/` | 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` → + `..staged--`), 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": . Recovery failed:
` — 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 +`✗ ` 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2635ab0 --- /dev/null +++ b/CLAUDE.md @@ -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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index deff891..a004f05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 2f9c13e..b5675bb 100644 --- a/README.md +++ b/README.md @@ -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) @@ -135,15 +137,16 @@ $ ccm use work | -------------------------------------------------------- | ------------------------------------------------------------- | | `ccm create [-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 [args...]` | Launch Claude Code. Extra args are passed to Claude | | `ccm login [--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 ` | Rename a profile (config, directory, and browser wrapper) | | `ccm remove [-f]` | Remove a profile. `-f` skips confirmation | | `ccm copy-config [--only x] [--dry-run] [-f]` | Copy non-auth config (settings/hooks/skills plugins) | | `ccm run -p ` | Run a prompt non-interactively | | `ccm skills [repos...]` | Manage skills scoped to a profile (wraps `npx skills`) | +| `ccm completion ` | Print a shell completion script | ## Compliance Notice @@ -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 ` 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. @@ -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) @@ -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: diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..a72891a --- /dev/null +++ b/src/commands/completion.ts @@ -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() + 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 ') + .description(`Print a shell completion script (${SUPPORTED_SHELLS.join(', ')})`) + .action( + runAction((shell: string) => { + const parsed = parseShell(shell) + console.log(generateCompletion(parsed, collectCommandNames(program))) + }), + ) +} diff --git a/src/commands/copy-config.ts b/src/commands/copy-config.ts index 4ed544e..d049cff 100644 --- a/src/commands/copy-config.ts +++ b/src/commands/copy-config.ts @@ -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 { const rl = createInterface({ input: process.stdin, output: process.stdout }) @@ -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, @@ -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) ') @@ -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) - } - }, + }, + ), ) } diff --git a/src/commands/create.ts b/src/commands/create.ts index 929c908..138eb40 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -4,6 +4,8 @@ import { getCompactComplianceNoticeLines } from '../lib/compliance.js' import { addProfile } from '../lib/config.js' import { applyProfileConfigCopy, planProfileConfigCopy } from '../lib/profile-config-copy.js' import { createProfileDir, removeProfileDir } from '../lib/profiles.js' +import { runAction } from '../lib/run-action.js' +import { printSuccess } from '../lib/ui.js' /** Registers the CLI workflow for creating a managed profile. */ export function registerCreate(program: Command): void { @@ -13,8 +15,8 @@ export function registerCreate(program: Command): void { .option('-l, --label