When changing commands, options, or behavior: always update CLAUDE.md, README.md, and cmd_help() in ccs together. Run ./test.sh to verify.
ccs (Claude Code Switch) is a minimal POSIX sh script for switching Claude Code between AI providers. It is a sidecar tool — claude always works as-is with zero interference. ccs only injects env vars into the child process spawned when running ccs (defaults to launch).
claude must always work on its own. ccs never modifies the user's shell, dotfiles, or Claude Code config. All state lives in ~/.claude-provider/ and env vars only exist inside the ccs subprocess (exec env ... claude).
Single exception: ccs notify on|off edits ~/.claude/settings.json (hooks + preferredNotifChannel). Explicit opt-in, backed up to ~/.claude-provider/settings-backup.json, fully reversed by notify off; purge detaches the hooks first. Requires jq (soft dependency — only the notify command).
- Single script:
ccs(~600 lines of POSIX sh) - Config: INI format at
~/.claude-provider/config, parsed with shell builtins (while read+case) - State:
~/.claude-provider/activestores current provider/model (removed byccs reset) - Model cache:
~/.claude-provider/models-cachestores resolved context windows (survivesreset, removed bypurge) - No language runtimes: no python, no node. Two CLI dependencies, both declared in the
Homebrew formula:
jq(notify) andllm-models(context windows). Both degrade gracefully at runtime so a manualcurlinstall still works - Zero footprint:
ccs resetorccs purgeremoves all traces
- POSIX sh compatible (no bash-isms: no
[[ ]], no arrays, no${var//pattern}) localkeyword used despite not being strictly POSIX (supported everywhere in practice)env -uused incmd_launchto scrub conflicting inherited vars (same spirit aslocal: not strictly POSIX, supported by GNU/BSD/macOS/busybox). Native launch unsets third-party vars and vice versa;cmd_envnative branch unsets the tier vars a third-party eval may have exportedccs -h|--help|-v|--versionare intercepted inmain()BEFORE the generic-*claude passthrough — everything else starting with-goes to claude- Unconfigured launch (no active provider + no default api_key) falls back to vanilla
claudewith a warning instead of dying (launch_vanilla()) load_state()is the single place that resolves active state or[_defaults];cmd_launch,cmd_envandcmd_modelsall go through it (return 1 = no default provider, 2 = no api key)- The two token-limit vars are appended to
exec envas unquoted words that expand to nothing when unknown — safe only becauseis_uintguarantees they are digits-only (# shellcheck disable=SC2086) - Config values stored in
cfg_<section>_<key>shell variables, retrieved viaget_cfg() - All providers must expose an Anthropic Messages API compatible endpoint
anthropicprovider is special: usesANTHROPIC_API_KEY, noANTHROPIC_BASE_URL- Third-party providers use
ANTHROPIC_AUTH_TOKEN(notANTHROPIC_API_KEY) to avoid the "Detected a custom API key" interactive prompt - Section names must be
[a-zA-Z0-9_]only (no hyphens — invalid in shell variable names) - Color variables use
$(printf '\033[...]')to store real escape bytes (not literal strings) - All
printfcalls pass color variables via%s, never in the format string
ccs # Main script — all logic here
config.template # Default config with all providers
test.sh # Integration test suite (run in CI, hermetic: stubs llm-models)
.releaserc # semantic-release config
.version-hook.sh # Injects version into ccs during release
.github/workflows/ # release.yml (semantic-release on push to main)
# test.yml (test.sh + shellcheck on PRs, ubuntu + macos)
ccs use|list|status|config|launch|env|models|notify|reset|purge|help|version
notify on [terminal]generates three POSIX sh hook scripts (heredocs embedded inccs) into~/.claude-provider/hooks/:notify-emit.sh(terminal detection + OSC emission),notify-stop.sh(Stop hook),notify-attention.sh(Notification hook, filtersnotification_type), then jq-merges references into~/.claude/settings.jsonSubagentStopis deliberately NOT hooked andagent_completednotifications are ignored — subagents/background tasks must stay silent- Terminal methods: ghostty/wezterm → OSC 777, iterm2 → OSC 9, kitty → OSC 99, macos → osascript, bell → BEL only. All also emit a standalone BEL (dock badge/bounce).
auto(default) detects at hook runtime viaTERM_PROGRAM/KITTY_WINDOW_ID - Idempotent merge: entries whose command contains
/.claude-provider/hooks/are replaced, never duplicated; user's other settings are preserved notify offrestores the previouspreferredNotifChannel(saved in~/.claude-provider/notify-stateon first install)
- Claude Code assumes a 200k window for any model it doesn't ship in its own table.
ccssetsCLAUDE_CODE_MAX_CONTEXT_TOKENS(andCLAUDE_CODE_MAX_OUTPUT_TOKENS) to the model's real limits, which both sizes auto-compact and silences the "not a model this version recognizes" warning - Claude Code only honours
CLAUDE_CODE_MAX_CONTEXT_TOKENSfor model ids that do not start withclaude-, soccssets it in the third-party branch only and-u-scrubs it in the native one. It is a single global value — sized from the main model, not per tier - Values are plain integers.
200kparses as200, sois_uintrejects anything non-numeric - Metadata comes from
llm-models(github.com/maxgfr/llm-models), adepends_onin the Homebrew formula. The script still degrades gracefully when it is absent (manual installs, broken PATH) → no window set,ccsbehaves exactly as before. Do not make it fatal.llm_lookupprefersllm-models resolve --endpoint <base_url> --field …(>= 1.3, endpoint-scoped so a reseller entry can't win) and falls back toinfo --json+ awk for older versions - Answers are cached in
~/.claude-provider/models-cache(provider model ctx out epoch id, one line each, 7-day TTL,-for unknown fields). A stale entry is still used when a lookup fails.ccs purgeremoves it with the rest of the dir;ccs resetdeliberately does not - Resolution order in
load_limits:auto_context=false→context_tokens=/max_output_tokens=in the provider section → fresh cache → llm-models → stale cache → unknown (env var not set) test.shshadowsllm-modelswith a stub onPATHfor the whole suite (FAKE_LLM_MODELSholds the answer, unset means no match) so CI never touches the network- Do not reach for
modelOverridesto silence the leftover[claude-code:unrecognized_model]diagnostic. Its schema isRecord<string,string>mapping an Anthropic model id to a provider-specific one, so an entry makes Claude Code resolve the model to aclaude-*id — which makes it ignoreCLAUDE_CODE_MAX_CONTEXT_TOKENSand adopt that Anthropic model's window instead. It also lives in~/.claude/settings.json, off-limits outsideccs notify. Verified live: the context warning goes away, that one diagnostic line stays
- Add
[provider_name]section toconfig.templatewithbase_url,api_key,model - Add the same section to the inline fallback config in
require_config()insideccs - Update README.md providers table
- The provider must support the Anthropic Messages API format
Automated via semantic-release on push to main:
- Conventional commit → version bump
.version-hook.shinjects version intoccs- GitHub release created
homebrew-tapdaily cron auto-updates the formula SHA256
- Conventional commits:
feat:,fix:,docs:,refactor: printfinstead ofecho -n(portability)set -eufor safety- Colors only when stdout is a terminal
- API keys masked in
ccs statusoutput