浏览代码

Initial commit: cc-switch CLI

Go tool that switches between Claude Code provider subscriptions by
cleaning (union of all providers' env keys), injecting the selected
provider's env, and launching `claude` with that environment.
Supports env:VAR references to keep secrets out of the config file
and ships with six built-in provider templates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kotoyuuko 2 周之前
当前提交
8e0caa89bc
共有 55 个文件被更改,包括 5712 次插入0 次删除
  1. 152 0
      .claude/commands/opsx/apply.md
  2. 157 0
      .claude/commands/opsx/archive.md
  3. 173 0
      .claude/commands/opsx/explore.md
  4. 106 0
      .claude/commands/opsx/propose.md
  5. 156 0
      .claude/skills/openspec-apply-change/SKILL.md
  6. 114 0
      .claude/skills/openspec-archive-change/SKILL.md
  7. 288 0
      .claude/skills/openspec-explore/SKILL.md
  8. 110 0
      .claude/skills/openspec-propose/SKILL.md
  9. 26 0
      .github/workflows/ci.yml
  10. 26 0
      .github/workflows/release.yml
  11. 15 0
      .gitignore
  12. 50 0
      .goreleaser.yaml
  13. 33 0
      Makefile
  14. 146 0
      README.md
  15. 16 0
      cmd/cc-switch/main.go
  16. 29 0
      examples/config.example.yaml
  17. 12 0
      go.mod
  18. 16 0
      go.sum
  19. 232 0
      internal/cli/add.go
  20. 39 0
      internal/cli/cli.go
  21. 509 0
      internal/cli/cli_test.go
  22. 126 0
      internal/cli/config_cmd.go
  23. 81 0
      internal/cli/edit.go
  24. 68 0
      internal/cli/list.go
  25. 27 0
      internal/cli/remove.go
  26. 137 0
      internal/cli/root.go
  27. 55 0
      internal/cli/templates_cmd.go
  28. 19 0
      internal/cli/tty.go
  29. 169 0
      internal/cli/use.go
  30. 193 0
      internal/config/config.go
  31. 294 0
      internal/config/config_test.go
  32. 125 0
      internal/config/io.go
  33. 30 0
      internal/config/path.go
  34. 16 0
      internal/mask/mask.go
  35. 20 0
      internal/mask/mask_test.go
  36. 139 0
      internal/provider/provider.go
  37. 162 0
      internal/provider/provider_test.go
  38. 115 0
      internal/runner/runner.go
  39. 22 0
      internal/runner/runner_other.go
  40. 171 0
      internal/runner/runner_test.go
  41. 51 0
      internal/runner/runner_unix.go
  42. 98 0
      internal/templates/templates.go
  43. 61 0
      internal/templates/templates.yaml
  44. 90 0
      internal/templates/templates_test.go
  45. 2 0
      openspec/changes/archive/2026-04-27-init-cc-switch-cli/.openspec.yaml
  46. 109 0
      openspec/changes/archive/2026-04-27-init-cc-switch-cli/design.md
  47. 36 0
      openspec/changes/archive/2026-04-27-init-cc-switch-cli/proposal.md
  48. 145 0
      openspec/changes/archive/2026-04-27-init-cc-switch-cli/specs/cli/spec.md
  49. 113 0
      openspec/changes/archive/2026-04-27-init-cc-switch-cli/specs/provider-config/spec.md
  50. 116 0
      openspec/changes/archive/2026-04-27-init-cc-switch-cli/specs/provider-launch/spec.md
  51. 105 0
      openspec/changes/archive/2026-04-27-init-cc-switch-cli/tasks.md
  52. 20 0
      openspec/config.yaml
  53. 151 0
      openspec/specs/cli/spec.md
  54. 119 0
      openspec/specs/provider-config/spec.md
  55. 122 0
      openspec/specs/provider-launch/spec.md

+ 152 - 0
.claude/commands/opsx/apply.md

@@ -0,0 +1,152 @@
+---
+name: "OPSX: Apply"
+description: Implement tasks from an OpenSpec change (Experimental)
+category: Workflow
+tags: [workflow, artifacts, experimental]
+---
+
+Implement tasks from an OpenSpec change.
+
+**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+
+**Steps**
+
+1. **Select the change**
+
+   If a name is provided, use it. Otherwise:
+   - Infer from conversation context if the user mentioned a change
+   - Auto-select if only one active change exists
+   - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
+
+   Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
+
+2. **Check status to understand the schema**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used (e.g., "spec-driven")
+   - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
+
+3. **Get apply instructions**
+
+   ```bash
+   openspec instructions apply --change "<name>" --json
+   ```
+
+   This returns:
+   - Context file paths (varies by schema)
+   - Progress (total, complete, remaining)
+   - Task list with status
+   - Dynamic instruction based on current state
+
+   **Handle states:**
+   - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
+   - If `state: "all_done"`: congratulate, suggest archive
+   - Otherwise: proceed to implementation
+
+4. **Read context files**
+
+   Read the files listed in `contextFiles` from the apply instructions output.
+   The files depend on the schema being used:
+   - **spec-driven**: proposal, specs, design, tasks
+   - Other schemas: follow the contextFiles from CLI output
+
+5. **Show current progress**
+
+   Display:
+   - Schema being used
+   - Progress: "N/M tasks complete"
+   - Remaining tasks overview
+   - Dynamic instruction from CLI
+
+6. **Implement tasks (loop until done or blocked)**
+
+   For each pending task:
+   - Show which task is being worked on
+   - Make the code changes required
+   - Keep changes minimal and focused
+   - Mark task complete in the tasks file: `- [ ]` → `- [x]`
+   - Continue to next task
+
+   **Pause if:**
+   - Task is unclear → ask for clarification
+   - Implementation reveals a design issue → suggest updating artifacts
+   - Error or blocker encountered → report and wait for guidance
+   - User interrupts
+
+7. **On completion or pause, show status**
+
+   Display:
+   - Tasks completed this session
+   - Overall progress: "N/M tasks complete"
+   - If all done: suggest archive
+   - If paused: explain why and wait for guidance
+
+**Output During Implementation**
+
+```
+## Implementing: <change-name> (schema: <schema-name>)
+
+Working on task 3/7: <task description>
+[...implementation happening...]
+✓ Task complete
+
+Working on task 4/7: <task description>
+[...implementation happening...]
+✓ Task complete
+```
+
+**Output On Completion**
+
+```
+## Implementation Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 7/7 tasks complete ✓
+
+### Completed This Session
+- [x] Task 1
+- [x] Task 2
+...
+
+All tasks complete! You can archive this change with `/opsx:archive`.
+```
+
+**Output On Pause (Issue Encountered)**
+
+```
+## Implementation Paused
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 4/7 tasks complete
+
+### Issue Encountered
+<description of the issue>
+
+**Options:**
+1. <option 1>
+2. <option 2>
+3. Other approach
+
+What would you like to do?
+```
+
+**Guardrails**
+- Keep going through tasks until done or blocked
+- Always read context files before starting (from the apply instructions output)
+- If task is ambiguous, pause and ask before implementing
+- If implementation reveals issues, pause and suggest artifact updates
+- Keep code changes minimal and scoped to each task
+- Update task checkbox immediately after completing each task
+- Pause on errors, blockers, or unclear requirements - don't guess
+- Use contextFiles from CLI output, don't assume specific file names
+
+**Fluid Workflow Integration**
+
+This skill supports the "actions on a change" model:
+
+- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
+- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

+ 157 - 0
.claude/commands/opsx/archive.md

@@ -0,0 +1,157 @@
+---
+name: "OPSX: Archive"
+description: Archive a completed change in the experimental workflow
+category: Workflow
+tags: [workflow, archive, experimental]
+---
+
+Archive a completed change in the experimental workflow.
+
+**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+
+**Steps**
+
+1. **If no change name provided, prompt for selection**
+
+   Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
+
+   Show only active changes (not already archived).
+   Include the schema used for each change if available.
+
+   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
+
+2. **Check artifact completion status**
+
+   Run `openspec status --change "<name>" --json` to check artifact completion.
+
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used
+   - `artifacts`: List of artifacts with their status (`done` or other)
+
+   **If any artifacts are not `done`:**
+   - Display warning listing incomplete artifacts
+   - Prompt user for confirmation to continue
+   - Proceed if user confirms
+
+3. **Check task completion status**
+
+   Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
+
+   Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
+
+   **If incomplete tasks found:**
+   - Display warning showing count of incomplete tasks
+   - Prompt user for confirmation to continue
+   - Proceed if user confirms
+
+   **If no tasks file exists:** Proceed without task-related warning.
+
+4. **Assess delta spec sync state**
+
+   Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
+
+   **If delta specs exist:**
+   - Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
+   - Determine what changes would be applied (adds, modifications, removals, renames)
+   - Show a combined summary before prompting
+
+   **Prompt options:**
+   - If changes needed: "Sync now (recommended)", "Archive without syncing"
+   - If already synced: "Archive now", "Sync anyway", "Cancel"
+
+   If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
+
+5. **Perform the archive**
+
+   Create the archive directory if it doesn't exist:
+   ```bash
+   mkdir -p openspec/changes/archive
+   ```
+
+   Generate target name using current date: `YYYY-MM-DD-<change-name>`
+
+   **Check if target already exists:**
+   - If yes: Fail with error, suggest renaming existing archive or using different date
+   - If no: Move the change directory to archive
+
+   ```bash
+   mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
+   ```
+
+6. **Display summary**
+
+   Show archive completion summary including:
+   - Change name
+   - Schema that was used
+   - Archive location
+   - Spec sync status (synced / sync skipped / no delta specs)
+   - Note about any warnings (incomplete artifacts/tasks)
+
+**Output On Success**
+
+```
+## Archive Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** ✓ Synced to main specs
+
+All artifacts complete. All tasks complete.
+```
+
+**Output On Success (No Delta Specs)**
+
+```
+## Archive Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** No delta specs
+
+All artifacts complete. All tasks complete.
+```
+
+**Output On Success With Warnings**
+
+```
+## Archive Complete (with warnings)
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** Sync skipped (user chose to skip)
+
+**Warnings:**
+- Archived with 2 incomplete artifacts
+- Archived with 3 incomplete tasks
+- Delta spec sync was skipped (user chose to skip)
+
+Review the archive if this was not intentional.
+```
+
+**Output On Error (Archive Exists)**
+
+```
+## Archive Failed
+
+**Change:** <change-name>
+**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
+
+Target archive directory already exists.
+
+**Options:**
+1. Rename the existing archive
+2. Delete the existing archive if it's a duplicate
+3. Wait until a different date to archive
+```
+
+**Guardrails**
+- Always prompt for change selection if not provided
+- Use artifact graph (openspec status --json) for completion checking
+- Don't block archive on warnings - just inform and confirm
+- Preserve .openspec.yaml when moving to archive (it moves with the directory)
+- Show clear summary of what happened
+- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
+- If delta specs exist, always run the sync assessment and show the combined summary before prompting

+ 173 - 0
.claude/commands/opsx/explore.md

@@ -0,0 +1,173 @@
+---
+name: "OPSX: Explore"
+description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
+category: Workflow
+tags: [workflow, explore, experimental, thinking]
+---
+
+Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
+
+**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
+
+**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
+
+**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
+- A vague idea: "real-time collaboration"
+- A specific problem: "the auth system is getting unwieldy"
+- A change name: "add-dark-mode" (to explore in context of that change)
+- A comparison: "postgres vs sqlite for this"
+- Nothing (just enter explore mode)
+
+---
+
+## The Stance
+
+- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
+- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
+- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
+- **Adaptive** - Follow interesting threads, pivot when new information emerges
+- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
+- **Grounded** - Explore the actual codebase when relevant, don't just theorize
+
+---
+
+## What You Might Do
+
+Depending on what the user brings, you might:
+
+**Explore the problem space**
+- Ask clarifying questions that emerge from what they said
+- Challenge assumptions
+- Reframe the problem
+- Find analogies
+
+**Investigate the codebase**
+- Map existing architecture relevant to the discussion
+- Find integration points
+- Identify patterns already in use
+- Surface hidden complexity
+
+**Compare options**
+- Brainstorm multiple approaches
+- Build comparison tables
+- Sketch tradeoffs
+- Recommend a path (if asked)
+
+**Visualize**
+```
+┌─────────────────────────────────────────┐
+│     Use ASCII diagrams liberally        │
+├─────────────────────────────────────────┤
+│                                         │
+│      ┌────────┐         ┌────────┐      │
+│      │ State  │────────▶│ State  │      │
+│      │   A    │         │   B    │      │
+│      └────────┘         └────────┘      │
+│                                         │
+│   System diagrams, state machines,      │
+│   data flows, architecture sketches,    │
+│   dependency graphs, comparison tables  │
+│                                         │
+└─────────────────────────────────────────┘
+```
+
+**Surface risks and unknowns**
+- Identify what could go wrong
+- Find gaps in understanding
+- Suggest spikes or investigations
+
+---
+
+## OpenSpec Awareness
+
+You have full context of the OpenSpec system. Use it naturally, don't force it.
+
+### Check for context
+
+At the start, quickly check what exists:
+```bash
+openspec list --json
+```
+
+This tells you:
+- If there are active changes
+- Their names, schemas, and status
+- What the user might be working on
+
+If the user mentioned a specific change name, read its artifacts for context.
+
+### When no change exists
+
+Think freely. When insights crystallize, you might offer:
+
+- "This feels solid enough to start a change. Want me to create a proposal?"
+- Or keep exploring - no pressure to formalize
+
+### When a change exists
+
+If the user mentions a change or you detect one is relevant:
+
+1. **Read existing artifacts for context**
+   - `openspec/changes/<name>/proposal.md`
+   - `openspec/changes/<name>/design.md`
+   - `openspec/changes/<name>/tasks.md`
+   - etc.
+
+2. **Reference them naturally in conversation**
+   - "Your design mentions using Redis, but we just realized SQLite fits better..."
+   - "The proposal scopes this to premium users, but we're now thinking everyone..."
+
+3. **Offer to capture when decisions are made**
+
+    | Insight Type               | Where to Capture               |
+    |----------------------------|--------------------------------|
+    | New requirement discovered | `specs/<capability>/spec.md` |
+    | Requirement changed        | `specs/<capability>/spec.md` |
+    | Design decision made       | `design.md`                  |
+    | Scope changed              | `proposal.md`                |
+    | New work identified        | `tasks.md`                   |
+    | Assumption invalidated     | Relevant artifact              |
+
+   Example offers:
+   - "That's a design decision. Capture it in design.md?"
+   - "This is a new requirement. Add it to specs?"
+   - "This changes scope. Update the proposal?"
+
+4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
+
+---
+
+## What You Don't Have To Do
+
+- Follow a script
+- Ask the same questions every time
+- Produce a specific artifact
+- Reach a conclusion
+- Stay on topic if a tangent is valuable
+- Be brief (this is thinking time)
+
+---
+
+## Ending Discovery
+
+There's no required ending. Discovery might:
+
+- **Flow into a proposal**: "Ready to start? I can create a change proposal."
+- **Result in artifact updates**: "Updated design.md with these decisions"
+- **Just provide clarity**: User has what they need, moves on
+- **Continue later**: "We can pick this up anytime"
+
+When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
+
+---
+
+## Guardrails
+
+- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
+- **Don't fake understanding** - If something is unclear, dig deeper
+- **Don't rush** - Discovery is thinking time, not task time
+- **Don't force structure** - Let patterns emerge naturally
+- **Don't auto-capture** - Offer to save insights, don't just do it
+- **Do visualize** - A good diagram is worth many paragraphs
+- **Do explore the codebase** - Ground discussions in reality
+- **Do question assumptions** - Including the user's and your own

+ 106 - 0
.claude/commands/opsx/propose.md

@@ -0,0 +1,106 @@
+---
+name: "OPSX: Propose"
+description: Propose a new change - create it and generate all artifacts in one step
+category: Workflow
+tags: [workflow, artifacts, experimental]
+---
+
+Propose a new change - create the change and generate all artifacts in one step.
+
+I'll create a change with artifacts:
+- proposal.md (what & why)
+- design.md (how)
+- tasks.md (implementation steps)
+
+When ready to implement, run /opsx:apply
+
+---
+
+**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
+
+**Steps**
+
+1. **If no input provided, ask what they want to build**
+
+   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
+   > "What change do you want to work on? Describe what you want to build or fix."
+
+   From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
+
+   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
+
+2. **Create the change directory**
+   ```bash
+   openspec new change "<name>"
+   ```
+   This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
+
+3. **Get the artifact build order**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to get:
+   - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
+   - `artifacts`: list of all artifacts with their status and dependencies
+
+4. **Create artifacts in sequence until apply-ready**
+
+   Use the **TodoWrite tool** to track progress through the artifacts.
+
+   Loop through artifacts in dependency order (artifacts with no pending dependencies first):
+
+   a. **For each artifact that is `ready` (dependencies satisfied)**:
+      - Get instructions:
+        ```bash
+        openspec instructions <artifact-id> --change "<name>" --json
+        ```
+      - The instructions JSON includes:
+        - `context`: Project background (constraints for you - do NOT include in output)
+        - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
+        - `template`: The structure to use for your output file
+        - `instruction`: Schema-specific guidance for this artifact type
+        - `outputPath`: Where to write the artifact
+        - `dependencies`: Completed artifacts to read for context
+      - Read any completed dependency files for context
+      - Create the artifact file using `template` as the structure
+      - Apply `context` and `rules` as constraints - but do NOT copy them into the file
+      - Show brief progress: "Created <artifact-id>"
+
+   b. **Continue until all `applyRequires` artifacts are complete**
+      - After creating each artifact, re-run `openspec status --change "<name>" --json`
+      - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
+      - Stop when all `applyRequires` artifacts are done
+
+   c. **If an artifact requires user input** (unclear context):
+      - Use **AskUserQuestion tool** to clarify
+      - Then continue with creation
+
+5. **Show final status**
+   ```bash
+   openspec status --change "<name>"
+   ```
+
+**Output**
+
+After completing all artifacts, summarize:
+- Change name and location
+- List of artifacts created with brief descriptions
+- What's ready: "All artifacts created! Ready for implementation."
+- Prompt: "Run `/opsx:apply` to start implementing."
+
+**Artifact Creation Guidelines**
+
+- Follow the `instruction` field from `openspec instructions` for each artifact type
+- The schema defines what each artifact should contain - follow it
+- Read dependency artifacts for context before creating new ones
+- Use `template` as the structure for your output file - fill in its sections
+- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
+  - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
+  - These guide what you write, but should never appear in the output
+
+**Guardrails**
+- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
+- Always read dependency artifacts before creating a new one
+- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
+- If a change with that name already exists, ask if user wants to continue it or create a new one
+- Verify each artifact file exists after writing before proceeding to next

+ 156 - 0
.claude/skills/openspec-apply-change/SKILL.md

@@ -0,0 +1,156 @@
+---
+name: openspec-apply-change
+description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
+license: MIT
+compatibility: Requires openspec CLI.
+metadata:
+  author: openspec
+  version: "1.0"
+  generatedBy: "1.3.0"
+---
+
+Implement tasks from an OpenSpec change.
+
+**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+
+**Steps**
+
+1. **Select the change**
+
+   If a name is provided, use it. Otherwise:
+   - Infer from conversation context if the user mentioned a change
+   - Auto-select if only one active change exists
+   - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
+
+   Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
+
+2. **Check status to understand the schema**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used (e.g., "spec-driven")
+   - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
+
+3. **Get apply instructions**
+
+   ```bash
+   openspec instructions apply --change "<name>" --json
+   ```
+
+   This returns:
+   - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
+   - Progress (total, complete, remaining)
+   - Task list with status
+   - Dynamic instruction based on current state
+
+   **Handle states:**
+   - If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
+   - If `state: "all_done"`: congratulate, suggest archive
+   - Otherwise: proceed to implementation
+
+4. **Read context files**
+
+   Read the files listed in `contextFiles` from the apply instructions output.
+   The files depend on the schema being used:
+   - **spec-driven**: proposal, specs, design, tasks
+   - Other schemas: follow the contextFiles from CLI output
+
+5. **Show current progress**
+
+   Display:
+   - Schema being used
+   - Progress: "N/M tasks complete"
+   - Remaining tasks overview
+   - Dynamic instruction from CLI
+
+6. **Implement tasks (loop until done or blocked)**
+
+   For each pending task:
+   - Show which task is being worked on
+   - Make the code changes required
+   - Keep changes minimal and focused
+   - Mark task complete in the tasks file: `- [ ]` → `- [x]`
+   - Continue to next task
+
+   **Pause if:**
+   - Task is unclear → ask for clarification
+   - Implementation reveals a design issue → suggest updating artifacts
+   - Error or blocker encountered → report and wait for guidance
+   - User interrupts
+
+7. **On completion or pause, show status**
+
+   Display:
+   - Tasks completed this session
+   - Overall progress: "N/M tasks complete"
+   - If all done: suggest archive
+   - If paused: explain why and wait for guidance
+
+**Output During Implementation**
+
+```
+## Implementing: <change-name> (schema: <schema-name>)
+
+Working on task 3/7: <task description>
+[...implementation happening...]
+✓ Task complete
+
+Working on task 4/7: <task description>
+[...implementation happening...]
+✓ Task complete
+```
+
+**Output On Completion**
+
+```
+## Implementation Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 7/7 tasks complete ✓
+
+### Completed This Session
+- [x] Task 1
+- [x] Task 2
+...
+
+All tasks complete! Ready to archive this change.
+```
+
+**Output On Pause (Issue Encountered)**
+
+```
+## Implementation Paused
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 4/7 tasks complete
+
+### Issue Encountered
+<description of the issue>
+
+**Options:**
+1. <option 1>
+2. <option 2>
+3. Other approach
+
+What would you like to do?
+```
+
+**Guardrails**
+- Keep going through tasks until done or blocked
+- Always read context files before starting (from the apply instructions output)
+- If task is ambiguous, pause and ask before implementing
+- If implementation reveals issues, pause and suggest artifact updates
+- Keep code changes minimal and scoped to each task
+- Update task checkbox immediately after completing each task
+- Pause on errors, blockers, or unclear requirements - don't guess
+- Use contextFiles from CLI output, don't assume specific file names
+
+**Fluid Workflow Integration**
+
+This skill supports the "actions on a change" model:
+
+- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
+- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

+ 114 - 0
.claude/skills/openspec-archive-change/SKILL.md

@@ -0,0 +1,114 @@
+---
+name: openspec-archive-change
+description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
+license: MIT
+compatibility: Requires openspec CLI.
+metadata:
+  author: openspec
+  version: "1.0"
+  generatedBy: "1.3.0"
+---
+
+Archive a completed change in the experimental workflow.
+
+**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+
+**Steps**
+
+1. **If no change name provided, prompt for selection**
+
+   Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
+
+   Show only active changes (not already archived).
+   Include the schema used for each change if available.
+
+   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
+
+2. **Check artifact completion status**
+
+   Run `openspec status --change "<name>" --json` to check artifact completion.
+
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used
+   - `artifacts`: List of artifacts with their status (`done` or other)
+
+   **If any artifacts are not `done`:**
+   - Display warning listing incomplete artifacts
+   - Use **AskUserQuestion tool** to confirm user wants to proceed
+   - Proceed if user confirms
+
+3. **Check task completion status**
+
+   Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
+
+   Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
+
+   **If incomplete tasks found:**
+   - Display warning showing count of incomplete tasks
+   - Use **AskUserQuestion tool** to confirm user wants to proceed
+   - Proceed if user confirms
+
+   **If no tasks file exists:** Proceed without task-related warning.
+
+4. **Assess delta spec sync state**
+
+   Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
+
+   **If delta specs exist:**
+   - Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
+   - Determine what changes would be applied (adds, modifications, removals, renames)
+   - Show a combined summary before prompting
+
+   **Prompt options:**
+   - If changes needed: "Sync now (recommended)", "Archive without syncing"
+   - If already synced: "Archive now", "Sync anyway", "Cancel"
+
+   If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
+
+5. **Perform the archive**
+
+   Create the archive directory if it doesn't exist:
+   ```bash
+   mkdir -p openspec/changes/archive
+   ```
+
+   Generate target name using current date: `YYYY-MM-DD-<change-name>`
+
+   **Check if target already exists:**
+   - If yes: Fail with error, suggest renaming existing archive or using different date
+   - If no: Move the change directory to archive
+
+   ```bash
+   mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
+   ```
+
+6. **Display summary**
+
+   Show archive completion summary including:
+   - Change name
+   - Schema that was used
+   - Archive location
+   - Whether specs were synced (if applicable)
+   - Note about any warnings (incomplete artifacts/tasks)
+
+**Output On Success**
+
+```
+## Archive Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
+
+All artifacts complete. All tasks complete.
+```
+
+**Guardrails**
+- Always prompt for change selection if not provided
+- Use artifact graph (openspec status --json) for completion checking
+- Don't block archive on warnings - just inform and confirm
+- Preserve .openspec.yaml when moving to archive (it moves with the directory)
+- Show clear summary of what happened
+- If sync is requested, use openspec-sync-specs approach (agent-driven)
+- If delta specs exist, always run the sync assessment and show the combined summary before prompting

+ 288 - 0
.claude/skills/openspec-explore/SKILL.md

@@ -0,0 +1,288 @@
+---
+name: openspec-explore
+description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
+license: MIT
+compatibility: Requires openspec CLI.
+metadata:
+  author: openspec
+  version: "1.0"
+  generatedBy: "1.3.0"
+---
+
+Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
+
+**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
+
+**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
+
+---
+
+## The Stance
+
+- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
+- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
+- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
+- **Adaptive** - Follow interesting threads, pivot when new information emerges
+- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
+- **Grounded** - Explore the actual codebase when relevant, don't just theorize
+
+---
+
+## What You Might Do
+
+Depending on what the user brings, you might:
+
+**Explore the problem space**
+- Ask clarifying questions that emerge from what they said
+- Challenge assumptions
+- Reframe the problem
+- Find analogies
+
+**Investigate the codebase**
+- Map existing architecture relevant to the discussion
+- Find integration points
+- Identify patterns already in use
+- Surface hidden complexity
+
+**Compare options**
+- Brainstorm multiple approaches
+- Build comparison tables
+- Sketch tradeoffs
+- Recommend a path (if asked)
+
+**Visualize**
+```
+┌─────────────────────────────────────────┐
+│     Use ASCII diagrams liberally        │
+├─────────────────────────────────────────┤
+│                                         │
+│      ┌────────┐         ┌────────┐      │
+│      │ State  │────────▶│ State  │      │
+│      │   A    │         │   B    │      │
+│      └────────┘         └────────┘      │
+│                                         │
+│   System diagrams, state machines,      │
+│   data flows, architecture sketches,    │
+│   dependency graphs, comparison tables  │
+│                                         │
+└─────────────────────────────────────────┘
+```
+
+**Surface risks and unknowns**
+- Identify what could go wrong
+- Find gaps in understanding
+- Suggest spikes or investigations
+
+---
+
+## OpenSpec Awareness
+
+You have full context of the OpenSpec system. Use it naturally, don't force it.
+
+### Check for context
+
+At the start, quickly check what exists:
+```bash
+openspec list --json
+```
+
+This tells you:
+- If there are active changes
+- Their names, schemas, and status
+- What the user might be working on
+
+### When no change exists
+
+Think freely. When insights crystallize, you might offer:
+
+- "This feels solid enough to start a change. Want me to create a proposal?"
+- Or keep exploring - no pressure to formalize
+
+### When a change exists
+
+If the user mentions a change or you detect one is relevant:
+
+1. **Read existing artifacts for context**
+   - `openspec/changes/<name>/proposal.md`
+   - `openspec/changes/<name>/design.md`
+   - `openspec/changes/<name>/tasks.md`
+   - etc.
+
+2. **Reference them naturally in conversation**
+   - "Your design mentions using Redis, but we just realized SQLite fits better..."
+   - "The proposal scopes this to premium users, but we're now thinking everyone..."
+
+3. **Offer to capture when decisions are made**
+
+    | Insight Type               | Where to Capture               |
+    |----------------------------|--------------------------------|
+    | New requirement discovered | `specs/<capability>/spec.md` |
+    | Requirement changed        | `specs/<capability>/spec.md` |
+    | Design decision made       | `design.md`                  |
+    | Scope changed              | `proposal.md`                |
+    | New work identified        | `tasks.md`                   |
+    | Assumption invalidated     | Relevant artifact              |
+
+   Example offers:
+   - "That's a design decision. Capture it in design.md?"
+   - "This is a new requirement. Add it to specs?"
+   - "This changes scope. Update the proposal?"
+
+4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
+
+---
+
+## What You Don't Have To Do
+
+- Follow a script
+- Ask the same questions every time
+- Produce a specific artifact
+- Reach a conclusion
+- Stay on topic if a tangent is valuable
+- Be brief (this is thinking time)
+
+---
+
+## Handling Different Entry Points
+
+**User brings a vague idea:**
+```
+User: I'm thinking about adding real-time collaboration
+
+You: Real-time collab is a big space. Let me think about this...
+
+      COLLABORATION SPECTRUM
+      ════════════════════════════════════════════
+
+      Awareness          Coordination         Sync
+          │                   │                 │
+          ▼                   ▼                 ▼
+      ┌────────┐         ┌────────┐        ┌────────┐
+      │Presence│         │Cursors │        │  CRDT  │
+      │  "3    │         │ Multi  │        │Conflict│
+      │online" │         │ select │        │  free  │
+      └────────┘         └────────┘        └────────┘
+          │                   │                 │
+       trivial            moderate           complex
+
+      Where's your head at?
+```
+
+**User brings a specific problem:**
+```
+User: The auth system is a mess
+
+You: [reads codebase]
+
+     ┌─────────────────────────────────────────────┐
+     │              CURRENT AUTH FLOW              │
+     └─────────────────────────────────────────────┘
+                        │
+          ┌─────────────┼─────────────┐
+          ▼             ▼             ▼
+     ┌─────────┐  ┌─────────┐  ┌─────────┐
+     │ Google  │  │  GitHub │  │  Email  │
+     │  OAuth  │  │  OAuth  │  │  Magic  │
+     └────┬────┘  └────┬────┘  └────┬────┘
+          │             │             │
+          └─────────────┼─────────────┘
+                        ▼
+                  ┌───────────┐
+                  │  Session  │
+                  └─────┬─────┘
+                        │
+                        ▼
+                  ┌───────────┐
+                  │   Perms   │
+                  └───────────┘
+
+     I see three tangles. Which one's burning?
+```
+
+**User is stuck mid-implementation:**
+```
+User: /opsx:explore add-auth-system
+      The OAuth integration is more complex than expected
+
+You: [reads change artifacts]
+
+     You're on task 4: "Implement OAuth flow"
+
+     Let me trace what's involved...
+
+     [draws diagram, explores options, suggests paths]
+
+     Want to update the design to reflect this?
+     Or add a spike task to investigate?
+```
+
+**User wants to compare options:**
+```
+User: Should we use Postgres or SQLite?
+
+You: Generic answer is boring. What's the context?
+
+User: A CLI tool that tracks local dev environments
+
+You: That changes everything.
+
+     ┌─────────────────────────────────────────────────┐
+     │          CLI TOOL DATA STORAGE                  │
+     └─────────────────────────────────────────────────┘
+
+     Key constraints:
+     • No daemon running
+     • Must work offline
+     • Single user
+
+                  SQLite          Postgres
+     Deployment   embedded ✓      needs server ✗
+     Offline      yes ✓           no ✗
+     Single file  yes ✓           no ✗
+
+     SQLite. Not even close.
+
+     Unless... is there a sync component?
+```
+
+---
+
+## Ending Discovery
+
+There's no required ending. Discovery might:
+
+- **Flow into a proposal**: "Ready to start? I can create a change proposal."
+- **Result in artifact updates**: "Updated design.md with these decisions"
+- **Just provide clarity**: User has what they need, moves on
+- **Continue later**: "We can pick this up anytime"
+
+When it feels like things are crystallizing, you might summarize:
+
+```
+## What We Figured Out
+
+**The problem**: [crystallized understanding]
+
+**The approach**: [if one emerged]
+
+**Open questions**: [if any remain]
+
+**Next steps** (if ready):
+- Create a change proposal
+- Keep exploring: just keep talking
+```
+
+But this summary is optional. Sometimes the thinking IS the value.
+
+---
+
+## Guardrails
+
+- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
+- **Don't fake understanding** - If something is unclear, dig deeper
+- **Don't rush** - Discovery is thinking time, not task time
+- **Don't force structure** - Let patterns emerge naturally
+- **Don't auto-capture** - Offer to save insights, don't just do it
+- **Do visualize** - A good diagram is worth many paragraphs
+- **Do explore the codebase** - Ground discussions in reality
+- **Do question assumptions** - Including the user's and your own

+ 110 - 0
.claude/skills/openspec-propose/SKILL.md

@@ -0,0 +1,110 @@
+---
+name: openspec-propose
+description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
+license: MIT
+compatibility: Requires openspec CLI.
+metadata:
+  author: openspec
+  version: "1.0"
+  generatedBy: "1.3.0"
+---
+
+Propose a new change - create the change and generate all artifacts in one step.
+
+I'll create a change with artifacts:
+- proposal.md (what & why)
+- design.md (how)
+- tasks.md (implementation steps)
+
+When ready to implement, run /opsx:apply
+
+---
+
+**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
+
+**Steps**
+
+1. **If no clear input provided, ask what they want to build**
+
+   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
+   > "What change do you want to work on? Describe what you want to build or fix."
+
+   From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
+
+   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
+
+2. **Create the change directory**
+   ```bash
+   openspec new change "<name>"
+   ```
+   This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
+
+3. **Get the artifact build order**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to get:
+   - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
+   - `artifacts`: list of all artifacts with their status and dependencies
+
+4. **Create artifacts in sequence until apply-ready**
+
+   Use the **TodoWrite tool** to track progress through the artifacts.
+
+   Loop through artifacts in dependency order (artifacts with no pending dependencies first):
+
+   a. **For each artifact that is `ready` (dependencies satisfied)**:
+      - Get instructions:
+        ```bash
+        openspec instructions <artifact-id> --change "<name>" --json
+        ```
+      - The instructions JSON includes:
+        - `context`: Project background (constraints for you - do NOT include in output)
+        - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
+        - `template`: The structure to use for your output file
+        - `instruction`: Schema-specific guidance for this artifact type
+        - `outputPath`: Where to write the artifact
+        - `dependencies`: Completed artifacts to read for context
+      - Read any completed dependency files for context
+      - Create the artifact file using `template` as the structure
+      - Apply `context` and `rules` as constraints - but do NOT copy them into the file
+      - Show brief progress: "Created <artifact-id>"
+
+   b. **Continue until all `applyRequires` artifacts are complete**
+      - After creating each artifact, re-run `openspec status --change "<name>" --json`
+      - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
+      - Stop when all `applyRequires` artifacts are done
+
+   c. **If an artifact requires user input** (unclear context):
+      - Use **AskUserQuestion tool** to clarify
+      - Then continue with creation
+
+5. **Show final status**
+   ```bash
+   openspec status --change "<name>"
+   ```
+
+**Output**
+
+After completing all artifacts, summarize:
+- Change name and location
+- List of artifacts created with brief descriptions
+- What's ready: "All artifacts created! Ready for implementation."
+- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
+
+**Artifact Creation Guidelines**
+
+- Follow the `instruction` field from `openspec instructions` for each artifact type
+- The schema defines what each artifact should contain - follow it
+- Read dependency artifacts for context before creating new ones
+- Use `template` as the structure for your output file - fill in its sections
+- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
+  - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
+  - These guide what you write, but should never appear in the output
+
+**Guardrails**
+- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
+- Always read dependency artifacts before creating a new one
+- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
+- If a change with that name already exists, ask if user wants to continue it or create a new one
+- Verify each artifact file exists after writing before proceeding to next

+ 26 - 0
.github/workflows/ci.yml

@@ -0,0 +1,26 @@
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+jobs:
+  test:
+    strategy:
+      matrix:
+        os: [ubuntu-latest, macos-latest]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-go@v5
+        with:
+          go-version: "1.25.x"
+          cache: true
+      - name: vet
+        run: go vet ./...
+      - name: test
+        run: go test -race ./...
+      - name: build
+        run: go build -trimpath ./cmd/cc-switch

+ 26 - 0
.github/workflows/release.yml

@@ -0,0 +1,26 @@
+name: Release
+
+on:
+  push:
+    tags: ["v*"]
+
+permissions:
+  contents: write
+
+jobs:
+  goreleaser:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - uses: actions/setup-go@v5
+        with:
+          go-version: "1.25.x"
+          cache: true
+      - uses: goreleaser/goreleaser-action@v6
+        with:
+          version: latest
+          args: release --clean
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+/dist/
+/cc-switch
+/cc-switch.exe
+
+# local user config that might accidentally land here during testing
+/config.yaml
+
+# per-user Claude Code permissions
+.claude/settings.local.json
+
+# editor / OS
+.DS_Store
+.idea/
+.vscode/
+*.swp

+ 50 - 0
.goreleaser.yaml

@@ -0,0 +1,50 @@
+version: 2
+
+project_name: cc-switch
+
+before:
+  hooks:
+    - go mod tidy
+
+builds:
+  - id: cc-switch
+    main: ./cmd/cc-switch
+    binary: cc-switch
+    env:
+      - CGO_ENABLED=0
+    goos:
+      - darwin
+      - linux
+    goarch:
+      - amd64
+      - arm64
+    ldflags:
+      - -s -w
+      - -X github.com/kotoyuuko/cc-switch-cli/internal/cli.version={{.Version}}
+      - -X github.com/kotoyuuko/cc-switch-cli/internal/cli.commit={{.Commit}}
+      - -X github.com/kotoyuuko/cc-switch-cli/internal/cli.date={{.Date}}
+
+archives:
+  - formats: [tar.gz]
+    name_template: >-
+      {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
+    files:
+      - README.md
+      - LICENSE*
+      - examples/*
+
+checksum:
+  name_template: "checksums.txt"
+  algorithm: sha256
+
+changelog:
+  sort: asc
+  filters:
+    exclude:
+      - "^docs:"
+      - "^chore:"
+      - "^test:"
+
+release:
+  draft: true
+  prerelease: auto

+ 33 - 0
Makefile

@@ -0,0 +1,33 @@
+BINARY := cc-switch
+PKG    := ./cmd/cc-switch
+DIST   := dist
+
+VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
+COMMIT  ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
+DATE    ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
+
+LDFLAGS := -s -w \
+	-X github.com/kotoyuuko/cc-switch-cli/internal/cli.version=$(VERSION) \
+	-X github.com/kotoyuuko/cc-switch-cli/internal/cli.commit=$(COMMIT) \
+	-X github.com/kotoyuuko/cc-switch-cli/internal/cli.date=$(DATE)
+
+.PHONY: build test vet fmt clean install
+
+build:
+	@mkdir -p $(DIST)
+	go build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY) $(PKG)
+
+test:
+	go test ./...
+
+vet:
+	go vet ./...
+
+fmt:
+	gofmt -w .
+
+install:
+	go install -trimpath -ldflags '$(LDFLAGS)' $(PKG)
+
+clean:
+	rm -rf $(DIST)

+ 146 - 0
README.md

@@ -0,0 +1,146 @@
+# cc-switch
+
+Switch between multiple Claude Code coding-plan subscriptions with one command.
+
+If you've signed up for a few different Anthropic-compatible providers (official
+Anthropic, OpenRouter, DeepSeek, Moonshot/Kimi, Zhipu GLM, or your own proxy),
+each uses a slightly different set of environment variables. Swapping them by
+hand is fiddly and easy to get wrong. `cc-switch` stores them once, then:
+
+1. Cleans your subprocess env of every variable any provider uses.
+2. Injects the selected provider's env.
+3. Launches `claude` with that environment.
+4. On exit, your parent shell is untouched — nothing leaked.
+
+## Install
+
+```
+go install github.com/kotoyuuko/cc-switch-cli/cmd/cc-switch@latest
+```
+
+Or clone and build:
+
+```
+git clone https://github.com/kotoyuuko/cc-switch-cli
+cd cc-switch-cli
+make build   # binary at ./dist/cc-switch
+```
+
+Supported platforms: macOS, Linux. Windows users should use WSL.
+
+## Quick start
+
+```sh
+# 1. Add one or more providers.
+cc-switch add official \
+  --from-template anthropic-official \
+  --env ANTHROPIC_API_KEY=sk-ant-xxxxxxxxx
+
+cc-switch add or \
+  --from-template openrouter \
+  --env ANTHROPIC_AUTH_TOKEN=sk-or-xxxxxxxxx \
+  --env ANTHROPIC_MODEL=anthropic/claude-opus-4
+
+# 2. (Optional) set a default so bare `cc-switch` picks it.
+cc-switch config set default official
+
+# 3. Run claude with a chosen provider.
+cc-switch use or
+# or just `cc-switch` — opens an interactive picker in a tty.
+```
+
+`cc-switch` passes its exit code through from `claude`, forwards SIGINT /
+SIGTERM / SIGHUP, and inherits stdio verbatim — it behaves like a thin wrapper.
+
+## Commands
+
+| Command | What it does |
+| --- | --- |
+| `cc-switch add <name> [--env K=V ...] [--from-template T] [--non-interactive]` | Register a provider. |
+| `cc-switch list [-V]` | Show providers; `-V` includes masked env keys. |
+| `cc-switch edit <name> [--env K=V] [--remove-env K] [--description ...]` | Modify a provider. |
+| `cc-switch remove <name>` | Delete a provider (clears `default_provider` if applicable). |
+| `cc-switch use [<name>]` | Clean → inject → launch claude. Interactive picker when tty + no name. |
+| `cc-switch config show / set / get` | Inspect or change `claude_path` / `default_provider`. |
+| `cc-switch templates list / show <t>` | Explore built-in env-key skeletons. |
+| `cc-switch version` | Print build info. |
+
+Flags that work everywhere:
+
+- `-v / --verbose`: trace config path, resolved claude path, union keys, injected keys (never values) to stderr.
+- `--config <path>`: override config file location (beats `$CC_SWITCH_CONFIG`).
+
+## Using references to keep secrets out of the config file
+
+Instead of pasting your API key into `config.yaml`, you can reference any env
+variable present in your shell when `cc-switch use` runs:
+
+```yaml
+providers:
+  official:
+    env:
+      ANTHROPIC_API_KEY: env:MY_ANTHROPIC_KEY   # resolved at launch
+      ANTHROPIC_BASE_URL: https://api.anthropic.com
+```
+
+Pair it with [1Password CLI](https://developer.1password.com/docs/cli/),
+[direnv](https://direnv.net/), or any other secret-exposing tool:
+
+```sh
+# ~/.zshrc
+export MY_ANTHROPIC_KEY="$(op item get 'Anthropic API' --field credential)"
+cc-switch use official
+```
+
+If the referenced variable is missing at launch, `cc-switch` refuses to start
+claude and tells you which key referenced which missing variable — no silent
+fallbacks.
+
+## Why "union" cleanup?
+
+When `cc-switch use foo` runs, the cleanup step removes **every** env key that
+appears in **any** configured provider, not just the currently selected one.
+That way, if you used `openrouter` last time and it set `ANTHROPIC_AUTH_TOKEN`,
+switching to `official` (which only sets `ANTHROPIC_API_KEY`) doesn't leave the
+stale auth token around to confuse claude.
+
+Consequence: if a provider uses a key you haven't registered anywhere, that key
+won't be cleaned. So register every key you might use in at least one provider.
+
+## Config file
+
+Located via `$CC_SWITCH_CONFIG` → `$XDG_CONFIG_HOME/cc-switch/config.yaml` →
+`~/.config/cc-switch/config.yaml`. Permissions are `0600` on create; if yours
+become more permissive, `cc-switch` prints a warning on every invocation.
+
+Example config (`examples/config.example.yaml`):
+
+```yaml
+claude_path: ~/.claude/local/claude
+default_provider: official
+
+providers:
+  official:
+    description: "Anthropic official"
+    env:
+      ANTHROPIC_API_KEY: env:MY_ANTHROPIC_KEY
+      ANTHROPIC_BASE_URL: https://api.anthropic.com
+
+  openrouter:
+    env:
+      ANTHROPIC_BASE_URL: https://openrouter.ai/api/v1
+      ANTHROPIC_AUTH_TOKEN: sk-or-example
+      ANTHROPIC_MODEL: anthropic/claude-opus-4
+```
+
+## Non-goals (v1)
+
+- Not a secret vault — values are stored in plain YAML (mitigations: `0600`
+  permissions and `env:VAR` indirection).
+- Doesn't modify your shell profile, doesn't export anything into your parent
+  shell — env changes live only in the claude subprocess.
+- No Windows support (macOS / Linux only; WSL works).
+- No user-supplied template directory (built-ins only; PRs welcome).
+- No chained `env:VAR` resolution (one hop only).
+- No escape for a literal `env:` prefix at value start (rare; file an issue if
+  you hit it).

+ 16 - 0
cmd/cc-switch/main.go

@@ -0,0 +1,16 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/cli"
+)
+
+func main() {
+	code, err := cli.Execute(os.Args[1:])
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "cc-switch:", err)
+	}
+	os.Exit(code)
+}

+ 29 - 0
examples/config.example.yaml

@@ -0,0 +1,29 @@
+# Example cc-switch configuration.
+# Copy this to ~/.config/cc-switch/config.yaml and chmod 600 it, then replace
+# the placeholder values with real keys — or better, use env:VAR indirection
+# and keep secrets out of this file entirely.
+
+claude_path: ~/.claude/local/claude
+default_provider: official
+
+providers:
+  official:
+    description: Anthropic official
+    env:
+      # Indirection: at launch time, cc-switch will read MY_ANTHROPIC_KEY from
+      # the parent shell and substitute its value here.
+      ANTHROPIC_API_KEY: env:MY_ANTHROPIC_KEY
+      ANTHROPIC_BASE_URL: https://api.anthropic.com
+
+  openrouter:
+    description: OpenRouter (Anthropic-compatible endpoint)
+    env:
+      ANTHROPIC_BASE_URL: https://openrouter.ai/api/v1
+      ANTHROPIC_AUTH_TOKEN: sk-or-replace-me
+      ANTHROPIC_MODEL: anthropic/claude-opus-4
+
+  deepseek:
+    env:
+      ANTHROPIC_BASE_URL: https://api.deepseek.com/anthropic
+      ANTHROPIC_AUTH_TOKEN: sk-replace-me
+      ANTHROPIC_MODEL: deepseek-chat

+ 12 - 0
go.mod

@@ -0,0 +1,12 @@
+module github.com/kotoyuuko/cc-switch-cli
+
+go 1.25.0
+
+require (
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/spf13/cobra v1.10.2 // indirect
+	github.com/spf13/pflag v1.0.9 // indirect
+	golang.org/x/sys v0.43.0 // indirect
+	golang.org/x/term v0.42.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)

+ 16 - 0
go.sum

@@ -0,0 +1,16 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
+golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 232 - 0
internal/cli/add.go

@@ -0,0 +1,232 @@
+package cli
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/config"
+	"github.com/kotoyuuko/cc-switch-cli/internal/templates"
+)
+
+func newAddCmd(app *appState) *cobra.Command {
+	var (
+		envFlags       []string
+		description    string
+		fromTemplate   string
+		nonInteractive bool
+	)
+	cmd := &cobra.Command{
+		Use:   "add <name>",
+		Short: "Add a new provider",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runAdd(app, args[0], envFlags, description, fromTemplate, nonInteractive)
+		},
+	}
+	cmd.Flags().StringArrayVar(&envFlags, "env", nil,
+		"KEY=VALUE env entry (repeatable)")
+	cmd.Flags().StringVar(&description, "description", "",
+		"optional human-readable description")
+	cmd.Flags().StringVar(&fromTemplate, "from-template", "",
+		"use a built-in template as the env skeleton")
+	cmd.Flags().BoolVar(&nonInteractive, "non-interactive", false,
+		"never prompt; require values via --env / template defaults")
+	return cmd
+}
+
+func runAdd(app *appState, name string, envFlags []string, description, fromTemplate string, nonInteractive bool) error {
+	if err := config.ValidateProviderName(name); err != nil {
+		return err
+	}
+	if _, exists := app.cfg.Providers[name]; exists {
+		return fmt.Errorf("provider %q already exists (use `edit` or pick a different name)", name)
+	}
+
+	// Parse --env flags into a map up front; flags override any template seed.
+	explicitEnv, err := parseEnvFlags(envFlags)
+	if err != nil {
+		return err
+	}
+
+	var env map[string]string
+
+	tty := isTTY(app.stdin)
+
+	switch {
+	case fromTemplate != "":
+		tpl, ok := templates.Get(fromTemplate)
+		if !ok {
+			return fmt.Errorf("unknown template %q; run `cc-switch templates list` to see available",
+				fromTemplate)
+		}
+		env = make(map[string]string, len(tpl.Env))
+		if nonInteractive || !tty {
+			// Fill with defaults for keys the user didn't override.
+			for _, e := range tpl.Env {
+				if v, set := explicitEnv[e.Name]; set {
+					env[e.Name] = v
+					continue
+				}
+				env[e.Name] = e.Default
+			}
+			// Flags might also introduce keys not in the template.
+			for k, v := range explicitEnv {
+				if _, already := env[k]; !already {
+					env[k] = v
+				}
+			}
+		} else {
+			env, err = runTemplateWizard(app, tpl, explicitEnv)
+			if err != nil {
+				return err
+			}
+		}
+
+	case len(explicitEnv) > 0:
+		env = explicitEnv
+
+	default:
+		// No template, no --env.
+		if !tty || nonInteractive {
+			return fmt.Errorf(
+				"non-interactive invocation requires --env KEY=VALUE or --from-template <tpl>")
+		}
+		env, err = runFreeWizard(app)
+		if err != nil {
+			return err
+		}
+	}
+
+	// Reject empty values — the spec forbids landing with a placeholder.
+	for k, v := range env {
+		if v == "" {
+			return fmt.Errorf("env key %q has no value (after template+flags)", k)
+		}
+	}
+	if len(env) == 0 {
+		return fmt.Errorf("provider %q: env must contain at least one key", name)
+	}
+
+	p := config.Provider{Description: description, Env: env}
+	if err := app.cfg.AddProvider(name, p); err != nil {
+		return err
+	}
+	if err := app.save(); err != nil {
+		return err
+	}
+	fmt.Fprintf(app.stdout, "added provider %q (%d env keys)\n", name, len(env))
+	return nil
+}
+
+// parseEnvFlags converts --env KEY=VALUE (repeatable) to a map. KEY must be
+// non-empty; VALUE may contain '=' freely.
+func parseEnvFlags(flags []string) (map[string]string, error) {
+	out := make(map[string]string, len(flags))
+	for _, kv := range flags {
+		eq := strings.IndexByte(kv, '=')
+		if eq <= 0 {
+			return nil, fmt.Errorf("--env %q: expected KEY=VALUE", kv)
+		}
+		k := kv[:eq]
+		v := kv[eq+1:]
+		out[k] = v
+	}
+	return out, nil
+}
+
+// runTemplateWizard walks the user through each template key. Values from
+// explicitEnv are treated as "already provided" and skipped. Empty input
+// accepts the template default if present; otherwise the user must supply
+// a non-empty value.
+func runTemplateWizard(app *appState, tpl templates.Template, explicit map[string]string) (map[string]string, error) {
+	env := make(map[string]string, len(tpl.Env))
+	reader := bufio.NewReader(app.stdin)
+
+	fmt.Fprintf(app.stdout, "using template %q: %s\n", tpl.Name, tpl.Description)
+
+	for _, e := range tpl.Env {
+		if v, set := explicit[e.Name]; set {
+			env[e.Name] = v
+			fmt.Fprintf(app.stdout, "  %s: (from --env)\n", e.Name)
+			continue
+		}
+		prompt := fmt.Sprintf("  %s", e.Name)
+		if e.Hint != "" {
+			prompt += fmt.Sprintf(" (%s)", e.Hint)
+		}
+		if e.Default != "" {
+			prompt += fmt.Sprintf(" [%s]", e.Default)
+		}
+		prompt += ": "
+		fmt.Fprint(app.stdout, prompt)
+
+		line, err := readLine(reader)
+		if err != nil {
+			return nil, err
+		}
+		line = strings.TrimSpace(line)
+		if line == "" {
+			if e.Default == "" {
+				return nil, fmt.Errorf("key %q requires a value", e.Name)
+			}
+			env[e.Name] = e.Default
+		} else {
+			env[e.Name] = line
+		}
+	}
+
+	// Allow --env to introduce extra keys the template didn't list.
+	for k, v := range explicit {
+		if _, already := env[k]; !already {
+			env[k] = v
+		}
+	}
+	return env, nil
+}
+
+// runFreeWizard keeps prompting for key/value pairs until the user gives an
+// empty key.
+func runFreeWizard(app *appState) (map[string]string, error) {
+	env := map[string]string{}
+	reader := bufio.NewReader(app.stdin)
+
+	fmt.Fprintln(app.stdout, "Enter env keys one at a time. Blank key to finish.")
+	for {
+		fmt.Fprint(app.stdout, "  KEY: ")
+		k, err := readLine(reader)
+		if err != nil {
+			return nil, err
+		}
+		k = strings.TrimSpace(k)
+		if k == "" {
+			break
+		}
+		fmt.Fprintf(app.stdout, "  %s value: ", k)
+		v, err := readLine(reader)
+		if err != nil {
+			return nil, err
+		}
+		env[k] = strings.TrimRight(v, "\r\n")
+	}
+	if len(env) == 0 {
+		return nil, fmt.Errorf("no env keys supplied")
+	}
+	return env, nil
+}
+
+// readLine reads one line (without trailing newline). On EOF with no data it
+// returns io.EOF so callers can distinguish end-of-stream from empty input.
+func readLine(r *bufio.Reader) (string, error) {
+	line, err := r.ReadString('\n')
+	if len(line) == 0 && err != nil {
+		return "", err
+	}
+	return strings.TrimRight(line, "\n"), nil
+}
+
+// silence "unused import" on io if we drop readLine later.
+var _ io.Reader

+ 39 - 0
internal/cli/cli.go

@@ -0,0 +1,39 @@
+// Package cli wires cobra subcommands onto the config/provider/runner/templates
+// layers. Each subcommand file (add.go, list.go, use.go, ...) exposes a
+// factory function that receives a shared *appState carrying the effective
+// config path, loaded config, output streams, and a verbose flag.
+//
+// Tests drive this package by calling Execute with an argv slice and inspecting
+// the (exitCode, err) result plus the stdout/stderr io.Writers they pass via
+// ExecuteWithStreams.
+package cli
+
+import (
+	"io"
+	"os"
+)
+
+// Execute runs the CLI against os.Stdin/Stdout/Stderr using argv.
+func Execute(argv []string) (int, error) {
+	return ExecuteWithStreams(argv, os.Stdin, os.Stdout, os.Stderr)
+}
+
+// ExecuteWithStreams is the testable form of Execute; callers supply the
+// standard streams explicitly. The function returns the exit code cc-switch
+// should terminate with. A non-nil error is already printed to stderr by cobra
+// (or by the subcommand) before return, so main() need not print it again.
+func ExecuteWithStreams(argv []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+	app := &appState{
+		stdin:  stdin,
+		stdout: stdout,
+		stderr: stderr,
+	}
+	root := newRootCmd(app)
+	root.SetArgs(argv)
+	root.SetIn(stdin)
+	root.SetOut(stdout)
+	root.SetErr(stderr)
+
+	err := root.Execute()
+	return app.exitCode(err), err
+}

+ 509 - 0
internal/cli/cli_test.go

@@ -0,0 +1,509 @@
+package cli
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+)
+
+// runCLI is a test helper that invokes Execute-equivalent with a temp config,
+// captured stdout/stderr, and an empty stdin (non-tty).
+func runCLI(t *testing.T, configPath string, args ...string) (int, string, string, error) {
+	t.Helper()
+	full := append([]string{"--config", configPath}, args...)
+	var stdout, stderr bytes.Buffer
+	stdin := bytes.NewReader(nil)
+	code, err := ExecuteWithStreams(full, stdin, &stdout, &stderr)
+	return code, stdout.String(), stderr.String(), err
+}
+
+// setupTestConfig yields a config path and ensures CC_SWITCH_CONFIG is not set.
+func setupTestConfig(t *testing.T) string {
+	t.Helper()
+	t.Setenv("CC_SWITCH_CONFIG", "")
+	t.Setenv("XDG_CONFIG_HOME", "")
+	dir := t.TempDir()
+	return filepath.Join(dir, "config.yaml")
+}
+
+func TestListEmpty(t *testing.T) {
+	cfg := setupTestConfig(t)
+	code, stdout, _, err := runCLI(t, cfg, "list")
+	if err != nil {
+		t.Fatalf("unexpected: %v", err)
+	}
+	if code != 0 {
+		t.Errorf("code = %d", code)
+	}
+	if !strings.Contains(stdout, "no providers configured") {
+		t.Errorf("stdout: %q", stdout)
+	}
+}
+
+func TestAddAndList(t *testing.T) {
+	cfg := setupTestConfig(t)
+
+	_, _, _, err := runCLI(t, cfg,
+		"add", "foo",
+		"--env", "ANTHROPIC_API_KEY=sk-ant-12345678",
+		"--env", "ANTHROPIC_BASE_URL=https://api.anthropic.com",
+		"--description", "official")
+	if err != nil {
+		t.Fatalf("add: %v", err)
+	}
+
+	_, stdout, _, err := runCLI(t, cfg, "list")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(stdout, "foo") {
+		t.Errorf("missing name: %q", stdout)
+	}
+	if !strings.Contains(stdout, "official") {
+		t.Errorf("missing description: %q", stdout)
+	}
+
+	_, stdout, _, err = runCLI(t, cfg, "list", "-V")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(stdout, "sk-ant-12345678") {
+		t.Errorf("raw secret leaked in -V: %q", stdout)
+	}
+	if !strings.Contains(stdout, "sk-a***") {
+		t.Errorf("expected masked value: %q", stdout)
+	}
+}
+
+func TestAddEnvRefNotMasked(t *testing.T) {
+	cfg := setupTestConfig(t)
+	if _, _, _, err := runCLI(t, cfg,
+		"add", "byref",
+		"--env", "ANTHROPIC_API_KEY=env:MY_KEY",
+	); err != nil {
+		t.Fatal(err)
+	}
+	_, stdout, _, err := runCLI(t, cfg, "list", "-V")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(stdout, "env:MY_KEY") {
+		t.Errorf("env ref should be shown literal: %q", stdout)
+	}
+	if strings.Contains(stdout, "env:***") {
+		t.Errorf("env ref should NOT be masked: %q", stdout)
+	}
+}
+
+func TestAddDuplicate(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, err := runCLI(t, cfg, "add", "foo", "--env", "K=v")
+	if err != nil {
+		t.Fatal(err)
+	}
+	code, _, _, err := runCLI(t, cfg, "add", "foo", "--env", "K=v")
+	if err == nil {
+		t.Fatal("expected duplicate error")
+	}
+	if code == 0 {
+		t.Error("duplicate should non-zero exit")
+	}
+}
+
+func TestAddInvalidName(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, err := runCLI(t, cfg, "add", "bad name", "--env", "K=v")
+	if err == nil {
+		t.Fatal("expected invalid name error")
+	}
+}
+
+func TestAddRequiresNonInteractiveInputs(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, err := runCLI(t, cfg, "add", "foo")
+	if err == nil {
+		t.Fatal("expected error (non-tty with no --env or template)")
+	}
+}
+
+func TestAddFromTemplateNonInteractive(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, err := runCLI(t, cfg,
+		"add", "or",
+		"--from-template", "openrouter",
+		"--non-interactive",
+		"--env", "ANTHROPIC_AUTH_TOKEN=sk-or-xxxxxx",
+		"--env", "ANTHROPIC_MODEL=anthropic/claude-opus-4",
+	)
+	if err != nil {
+		t.Fatalf("unexpected: %v", err)
+	}
+	// Template should have filled ANTHROPIC_BASE_URL with its default.
+	_, stdout, _, err := runCLI(t, cfg, "list", "-V")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(stdout, "ANTHROPIC_BASE_URL") {
+		t.Errorf("template key missing: %q", stdout)
+	}
+	if !strings.Contains(stdout, "https://openrouter.ai/api/v1") {
+		// OR's default gets masked too (it's shorter than 4... no it isn't — check masked form)
+		// "https://openrouter.ai/api/v1" is 29 chars; masked as "http***"
+		if !strings.Contains(stdout, "http***") {
+			t.Errorf("expected base URL (possibly masked): %q", stdout)
+		}
+	}
+}
+
+func TestAddFromTemplateUnknown(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, stderr, err := runCLI(t, cfg,
+		"add", "foo", "--from-template", "nonexistent", "--non-interactive")
+	if err == nil {
+		t.Fatal("expected unknown-template error")
+	}
+	if !strings.Contains(stderr, "templates list") && !strings.Contains(err.Error(), "templates list") {
+		t.Errorf("expected hint, stderr=%q err=%v", stderr, err)
+	}
+}
+
+func TestTemplatesList(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, stdout, _, err := runCLI(t, cfg, "templates", "list")
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, name := range []string{"anthropic-official", "openrouter", "deepseek", "moonshot", "zhipu", "custom-base"} {
+		if !strings.Contains(stdout, name) {
+			t.Errorf("missing template %q in output: %q", name, stdout)
+		}
+	}
+}
+
+func TestTemplatesShow(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, stdout, _, err := runCLI(t, cfg, "templates", "show", "openrouter")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(stdout, "ANTHROPIC_BASE_URL") {
+		t.Errorf("missing env key in show: %q", stdout)
+	}
+	if !strings.Contains(stdout, "openrouter.ai") {
+		t.Errorf("missing default in show: %q", stdout)
+	}
+}
+
+func TestTemplatesShowMissing(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, err := runCLI(t, cfg, "templates", "show", "nonexistent")
+	if err == nil {
+		t.Fatal("expected missing-template error")
+	}
+}
+
+func TestEditAddAndRemoveKey(t *testing.T) {
+	cfg := setupTestConfig(t)
+	if _, _, _, err := runCLI(t, cfg, "add", "foo",
+		"--env", "ANTHROPIC_API_KEY=sk-xxxxxx",
+		"--env", "ANTHROPIC_BASE_URL=https://one"); err != nil {
+		t.Fatal(err)
+	}
+	if _, _, _, err := runCLI(t, cfg, "edit", "foo",
+		"--env", "ANTHROPIC_MODEL=claude-opus",
+		"--remove-env", "ANTHROPIC_BASE_URL",
+		"--description", "new desc"); err != nil {
+		t.Fatal(err)
+	}
+	_, stdout, _, err := runCLI(t, cfg, "list", "-V")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(stdout, "ANTHROPIC_MODEL") {
+		t.Errorf("model not added: %q", stdout)
+	}
+	if strings.Contains(stdout, "ANTHROPIC_BASE_URL") {
+		t.Errorf("base URL not removed: %q", stdout)
+	}
+	if !strings.Contains(stdout, "new desc") {
+		t.Errorf("description not updated: %q", stdout)
+	}
+}
+
+func TestRemoveClearsDefault(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, _ = runCLI(t, cfg, "add", "foo", "--env", "K=v")
+	_, _, _, _ = runCLI(t, cfg, "config", "set", "default", "foo")
+	_, stdout, _, err := runCLI(t, cfg, "config", "get", "default")
+	if err != nil || strings.TrimSpace(stdout) != "foo" {
+		t.Fatalf("default not set: stdout=%q err=%v", stdout, err)
+	}
+	if _, _, _, err := runCLI(t, cfg, "remove", "foo"); err != nil {
+		t.Fatal(err)
+	}
+	_, stdout, _, _ = runCLI(t, cfg, "config", "get", "default")
+	if strings.TrimSpace(stdout) != "" {
+		t.Errorf("default not cleared: %q", stdout)
+	}
+}
+
+func TestConfigSetInvalidKey(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, err := runCLI(t, cfg, "config", "set", "bogus", "x")
+	if err == nil {
+		t.Fatal("expected error for unknown config key")
+	}
+}
+
+func TestConfigSetDefaultMissingProvider(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, err := runCLI(t, cfg, "config", "set", "default", "nope")
+	if err == nil {
+		t.Fatal("expected error")
+	}
+}
+
+func TestUseRejectsUnknownProvider(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, _ = runCLI(t, cfg, "add", "foo", "--env", "K=v")
+	_, _, _, err := runCLI(t, cfg, "use", "bar")
+	if err == nil {
+		t.Fatal("expected unknown provider error")
+	}
+}
+
+func TestUseNonTTYNoDefault(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, _, _, _ = runCLI(t, cfg, "add", "foo", "--env", "K=v")
+	_, _, _, err := runCLI(t, cfg, "use")
+	if err == nil {
+		t.Fatal("expected non-tty-no-default error")
+	}
+}
+
+func TestUseLaunchesWithFakeClaude(t *testing.T) {
+	cfg := setupTestConfig(t)
+	dir := t.TempDir()
+	// Fake claude: print FOO to a file, exit 0.
+	fakeClaude := filepath.Join(dir, "claude")
+	outFile := filepath.Join(dir, "out")
+	script := "#!/bin/sh\nprintf '%s' \"$FOO\" > " + outFile + "\nexit 0\n"
+	if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, _, _, err := runCLI(t, cfg, "add", "ff", "--env", "FOO=bar"); err != nil {
+		t.Fatal(err)
+	}
+	if _, _, _, err := runCLI(t, cfg, "config", "set", "claude-path", fakeClaude); err != nil {
+		t.Fatal(err)
+	}
+	code, _, _, err := runCLI(t, cfg, "use", "ff")
+	if err != nil {
+		t.Fatalf("use: %v", err)
+	}
+	if code != 0 {
+		t.Errorf("exit code = %d", code)
+	}
+	b, err := os.ReadFile(outFile)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(b) != "bar" {
+		t.Errorf("env not injected: %q", string(b))
+	}
+}
+
+func TestUseEnvRefMissingDoesNotLaunch(t *testing.T) {
+	cfg := setupTestConfig(t)
+	dir := t.TempDir()
+	fakeClaude := filepath.Join(dir, "claude")
+	sentinel := filepath.Join(dir, "ran")
+	script := "#!/bin/sh\ntouch " + sentinel + "\nexit 0\n"
+	if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, _, _, err := runCLI(t, cfg, "add", "needs-ref",
+		"--env", "ANTHROPIC_API_KEY=env:MY_MISSING_KEY"); err != nil {
+		t.Fatal(err)
+	}
+	if _, _, _, err := runCLI(t, cfg, "config", "set", "claude-path", fakeClaude); err != nil {
+		t.Fatal(err)
+	}
+	// Ensure MY_MISSING_KEY is NOT set.
+	t.Setenv("MY_MISSING_KEY", "")
+	os.Unsetenv("MY_MISSING_KEY")
+
+	_, _, _, err := runCLI(t, cfg, "use", "needs-ref")
+	if err == nil {
+		t.Fatal("expected error for missing ref")
+	}
+	if _, statErr := os.Stat(sentinel); statErr == nil {
+		t.Error("claude should not have been launched")
+	}
+}
+
+func TestUseExitCodePassthrough(t *testing.T) {
+	cfg := setupTestConfig(t)
+	dir := t.TempDir()
+	fakeClaude := filepath.Join(dir, "claude")
+	script := "#!/bin/sh\nexit 42\n"
+	if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	_, _, _, _ = runCLI(t, cfg, "add", "x", "--env", "FOO=b")
+	_, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
+
+	code, _, _, _ := runCLI(t, cfg, "use", "x")
+	if code != 42 {
+		t.Errorf("exit code = %d, want 42", code)
+	}
+}
+
+// writeFakeClaude creates a shell script that prints each listed env var's
+// value (one "KEY=VAL" per line) to outFile and exits with the given code.
+func writeFakeClaude(t *testing.T, dir string, keys []string, outFile string, exitCode int) string {
+	t.Helper()
+	path := filepath.Join(dir, "claude")
+	var sb strings.Builder
+	sb.WriteString("#!/bin/sh\n")
+	sb.WriteString(": > " + outFile + "\n")
+	for _, k := range keys {
+		// Print KEY=VAL if set (use `set` so unset yields no output for that key).
+		sb.WriteString("if [ \"${" + k + "+x}\" = x ]; then printf '%s=%s\\n' \"" + k + "\" \"${" + k + "}\" >> " + outFile + "; fi\n")
+	}
+	sb.WriteString("exit " + strconv.Itoa(exitCode) + "\n")
+	if err := os.WriteFile(path, []byte(sb.String()), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	return path
+}
+
+func TestUseCleansParentEnvUnion(t *testing.T) {
+	cfg := setupTestConfig(t)
+	dir := t.TempDir()
+	outFile := filepath.Join(dir, "out")
+	fakeClaude := writeFakeClaude(t, dir, []string{"ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"}, outFile, 0)
+
+	// provider A owns ANTHROPIC_API_KEY, provider B owns ANTHROPIC_BASE_URL.
+	// Union includes both. We USE A: B's key should be stripped from env.
+	_, _, _, _ = runCLI(t, cfg, "add", "a", "--env", "ANTHROPIC_API_KEY=sk-new")
+	_, _, _, _ = runCLI(t, cfg, "add", "b", "--env", "ANTHROPIC_BASE_URL=https://b")
+	_, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
+
+	// Simulate a polluted parent shell.
+	t.Setenv("ANTHROPIC_API_KEY", "stale-should-be-overridden")
+	t.Setenv("ANTHROPIC_BASE_URL", "stale-should-be-cleaned")
+
+	if _, _, _, err := runCLI(t, cfg, "use", "a"); err != nil {
+		t.Fatalf("use: %v", err)
+	}
+	b, err := os.ReadFile(outFile)
+	if err != nil {
+		t.Fatal(err)
+	}
+	got := string(b)
+	if !strings.Contains(got, "ANTHROPIC_API_KEY=sk-new") {
+		t.Errorf("missing injected key: %q", got)
+	}
+	if strings.Contains(got, "stale") {
+		t.Errorf("stale parent value leaked: %q", got)
+	}
+	if strings.Contains(got, "ANTHROPIC_BASE_URL") {
+		t.Errorf("union key should have been cleaned: %q", got)
+	}
+}
+
+func TestUseLiteralValueNotShellExpanded(t *testing.T) {
+	cfg := setupTestConfig(t)
+	dir := t.TempDir()
+	outFile := filepath.Join(dir, "out")
+	fakeClaude := writeFakeClaude(t, dir, []string{"WEIRD"}, outFile, 0)
+
+	_, _, _, _ = runCLI(t, cfg, "add", "x", "--env", "WEIRD=$HOME/api")
+	_, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
+	if _, _, _, err := runCLI(t, cfg, "use", "x"); err != nil {
+		t.Fatal(err)
+	}
+	b, _ := os.ReadFile(outFile)
+	if !strings.Contains(string(b), "WEIRD=$HOME/api") {
+		t.Errorf("value should be literal: %q", string(b))
+	}
+}
+
+func TestUseEnvRefResolvesFromParent(t *testing.T) {
+	cfg := setupTestConfig(t)
+	dir := t.TempDir()
+	outFile := filepath.Join(dir, "out")
+	fakeClaude := writeFakeClaude(t, dir, []string{"ANTHROPIC_API_KEY"}, outFile, 0)
+
+	_, _, _, _ = runCLI(t, cfg, "add", "ref", "--env", "ANTHROPIC_API_KEY=env:MY_EXTERNAL_KEY")
+	_, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
+
+	t.Setenv("MY_EXTERNAL_KEY", "sk-from-parent")
+
+	if _, _, _, err := runCLI(t, cfg, "use", "ref"); err != nil {
+		t.Fatalf("use: %v", err)
+	}
+	b, _ := os.ReadFile(outFile)
+	if !strings.Contains(string(b), "ANTHROPIC_API_KEY=sk-from-parent") {
+		t.Errorf("ref not resolved: %q", string(b))
+	}
+}
+
+func TestUseEnvRefResolvesBeforeCleanup(t *testing.T) {
+	// Critical edge: provider B says `ANTHROPIC_API_KEY=env:ANTHROPIC_API_KEY`.
+	// The referenced VAR is ALSO in the union (provider A also defines that
+	// key). We must snapshot parent env before cleanup, so the ref still
+	// resolves to the parent shell's value.
+	cfg := setupTestConfig(t)
+	dir := t.TempDir()
+	outFile := filepath.Join(dir, "out")
+	fakeClaude := writeFakeClaude(t, dir, []string{"ANTHROPIC_API_KEY"}, outFile, 0)
+
+	_, _, _, _ = runCLI(t, cfg, "add", "a", "--env", "ANTHROPIC_API_KEY=sk-a")
+	_, _, _, _ = runCLI(t, cfg, "add", "b", "--env", "ANTHROPIC_API_KEY=env:ANTHROPIC_API_KEY")
+	_, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
+
+	t.Setenv("ANTHROPIC_API_KEY", "sk-parent")
+
+	if _, _, _, err := runCLI(t, cfg, "use", "b"); err != nil {
+		t.Fatalf("use: %v", err)
+	}
+	b, _ := os.ReadFile(outFile)
+	if !strings.Contains(string(b), "ANTHROPIC_API_KEY=sk-parent") {
+		t.Errorf("snapshot-before-cleanup broke: %q", string(b))
+	}
+}
+
+func TestPermissionWarning(t *testing.T) {
+	cfg := setupTestConfig(t)
+	if _, _, _, err := runCLI(t, cfg, "add", "x", "--env", "K=v"); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chmod(cfg, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	_, _, stderr, err := runCLI(t, cfg, "list")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(stderr, "chmod 600") {
+		t.Errorf("expected permission warning; stderr=%q", stderr)
+	}
+}
+
+func TestVersionCmd(t *testing.T) {
+	cfg := setupTestConfig(t)
+	_, stdout, _, err := runCLI(t, cfg, "version")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.HasPrefix(stdout, "cc-switch ") {
+		t.Errorf("unexpected: %q", stdout)
+	}
+}

+ 126 - 0
internal/cli/config_cmd.go

@@ -0,0 +1,126 @@
+package cli
+
+import (
+	"fmt"
+	"sort"
+
+	"github.com/spf13/cobra"
+)
+
+func newConfigCmd(app *appState) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "config",
+		Short: "Inspect or modify global config fields",
+	}
+	cmd.AddCommand(
+		newConfigShowCmd(app),
+		newConfigSetCmd(app),
+		newConfigGetCmd(app),
+	)
+	return cmd
+}
+
+func newConfigShowCmd(app *appState) *cobra.Command {
+	return &cobra.Command{
+		Use:   "show",
+		Short: "Print the current config (env values masked)",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			fmt.Fprintf(app.stdout, "# %s\n", app.configPath)
+			// Build a display copy: mask or mark each env value.
+			type provView struct {
+				desc string
+				env  map[string]string
+			}
+			disp := map[string]provView{}
+			for n, p := range app.cfg.Providers {
+				ev := make(map[string]string, len(p.Env))
+				for k, v := range p.Env {
+					ev[k] = displayValue(v)
+				}
+				disp[n] = provView{desc: p.Description, env: ev}
+			}
+
+			if app.cfg.ClaudePath != "" {
+				fmt.Fprintf(app.stdout, "claude_path: %s\n", app.cfg.ClaudePath)
+			}
+			if app.cfg.DefaultProvider != "" {
+				fmt.Fprintf(app.stdout, "default_provider: %s\n", app.cfg.DefaultProvider)
+			}
+			if len(disp) == 0 {
+				fmt.Fprintln(app.stdout, "providers: {}")
+				return nil
+			}
+			fmt.Fprintln(app.stdout, "providers:")
+			names := make([]string, 0, len(disp))
+			for n := range disp {
+				names = append(names, n)
+			}
+			sort.Strings(names)
+			for _, n := range names {
+				v := disp[n]
+				fmt.Fprintf(app.stdout, "  %s:\n", n)
+				if v.desc != "" {
+					fmt.Fprintf(app.stdout, "    description: %s\n", v.desc)
+				}
+				fmt.Fprintln(app.stdout, "    env:")
+				keys := make([]string, 0, len(v.env))
+				for k := range v.env {
+					keys = append(keys, k)
+				}
+				sort.Strings(keys)
+				for _, k := range keys {
+					fmt.Fprintf(app.stdout, "      %s: %s\n", k, v.env[k])
+				}
+			}
+			return nil
+		},
+	}
+}
+
+func newConfigSetCmd(app *appState) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "set <key> <value>",
+		Short: "Set a top-level config field (claude-path | default)",
+		Args:  cobra.ExactArgs(2),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			key, value := args[0], args[1]
+			switch key {
+			case "claude-path":
+				if err := app.cfg.SetClaudePath(value); err != nil {
+					return err
+				}
+			case "default":
+				if err := app.cfg.SetDefault(value); err != nil {
+					return err
+				}
+			default:
+				return fmt.Errorf("unknown config key %q (allowed: claude-path, default)", key)
+			}
+			if err := app.save(); err != nil {
+				return err
+			}
+			fmt.Fprintf(app.stdout, "set %s\n", key)
+			return nil
+		},
+	}
+	return cmd
+}
+
+func newConfigGetCmd(app *appState) *cobra.Command {
+	return &cobra.Command{
+		Use:   "get <key>",
+		Short: "Print a top-level config field (claude-path | default)",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			switch args[0] {
+			case "claude-path":
+				fmt.Fprintln(app.stdout, app.cfg.ClaudePath)
+			case "default":
+				fmt.Fprintln(app.stdout, app.cfg.DefaultProvider)
+			default:
+				return fmt.Errorf("unknown config key %q (allowed: claude-path, default)", args[0])
+			}
+			return nil
+		},
+	}
+}

+ 81 - 0
internal/cli/edit.go

@@ -0,0 +1,81 @@
+package cli
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/config"
+)
+
+func newEditCmd(app *appState) *cobra.Command {
+	var (
+		envFlags       []string
+		removeEnv      []string
+		setDescription string
+		clearDesc      bool
+	)
+	cmd := &cobra.Command{
+		Use:   "edit <name>",
+		Short: "Modify an existing provider's env or description",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runEdit(app, args[0], envFlags, removeEnv, setDescription, clearDesc, cmd.Flags().Changed("description"))
+		},
+	}
+	cmd.Flags().StringArrayVar(&envFlags, "env", nil,
+		"KEY=VALUE to add or overwrite (repeatable)")
+	cmd.Flags().StringArrayVar(&removeEnv, "remove-env", nil,
+		"env key to delete (repeatable)")
+	cmd.Flags().StringVar(&setDescription, "description", "",
+		"set description; pass empty string to clear")
+	cmd.Flags().BoolVar(&clearDesc, "clear-description", false,
+		"alias for --description ''")
+	return cmd
+}
+
+func runEdit(app *appState, name string, envFlags, removeEnv []string, newDesc string, clearDesc bool, descChanged bool) error {
+	if err := config.ValidateProviderName(name); err != nil {
+		return err
+	}
+	p, ok := app.cfg.Providers[name]
+	if !ok {
+		return fmt.Errorf("provider %q does not exist", name)
+	}
+
+	// Mutate a copy so we don't leave a partial change on validation error.
+	env := make(map[string]string, len(p.Env))
+	for k, v := range p.Env {
+		env[k] = v
+	}
+
+	adds, err := parseEnvFlags(envFlags)
+	if err != nil {
+		return err
+	}
+	for k, v := range adds {
+		env[k] = v
+	}
+	for _, k := range removeEnv {
+		delete(env, k)
+	}
+	if len(env) == 0 {
+		return fmt.Errorf("provider %q would have zero env keys after edit", name)
+	}
+
+	updated := config.Provider{Description: p.Description, Env: env}
+	if clearDesc {
+		updated.Description = ""
+	} else if descChanged {
+		updated.Description = newDesc
+	}
+
+	if err := app.cfg.UpdateProvider(name, updated); err != nil {
+		return err
+	}
+	if err := app.save(); err != nil {
+		return err
+	}
+	fmt.Fprintf(app.stdout, "updated provider %q\n", name)
+	return nil
+}

+ 68 - 0
internal/cli/list.go

@@ -0,0 +1,68 @@
+package cli
+
+import (
+	"fmt"
+	"sort"
+
+	"github.com/spf13/cobra"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/config"
+	"github.com/kotoyuuko/cc-switch-cli/internal/mask"
+)
+
+func newListCmd(app *appState) *cobra.Command {
+	var verbose bool
+	cmd := &cobra.Command{
+		Use:     "list",
+		Aliases: []string{"ls"},
+		Short:   "List configured providers",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runList(app, verbose)
+		},
+	}
+	cmd.Flags().BoolVarP(&verbose, "verbose", "V", false,
+		"also print env keys with masked values (references shown as-is)")
+	return cmd
+}
+
+func runList(app *appState, verbose bool) error {
+	if len(app.cfg.Providers) == 0 {
+		fmt.Fprintln(app.stdout, "(no providers configured)")
+		return nil
+	}
+	names := app.cfg.SortedProviderNames()
+	for _, name := range names {
+		p := app.cfg.Providers[name]
+		marker := " "
+		if app.cfg.DefaultProvider == name {
+			marker = "*"
+		}
+		line := fmt.Sprintf("%s %s", marker, name)
+		if p.Description != "" {
+			line += "  " + p.Description
+		}
+		fmt.Fprintln(app.stdout, line)
+
+		if verbose {
+			keys := make([]string, 0, len(p.Env))
+			for k := range p.Env {
+				keys = append(keys, k)
+			}
+			sort.Strings(keys)
+			for _, k := range keys {
+				fmt.Fprintf(app.stdout, "    %s=%s\n", k, displayValue(p.Env[k]))
+			}
+		}
+	}
+	return nil
+}
+
+// displayValue returns the string to show the user for an env value.
+// `env:VAR` references are shown as-is (they are pointers, not secrets);
+// literal values are masked.
+func displayValue(v string) string {
+	if _, ok := config.IsEnvRef(v); ok {
+		return v
+	}
+	return mask.Value(v)
+}

+ 27 - 0
internal/cli/remove.go

@@ -0,0 +1,27 @@
+package cli
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+func newRemoveCmd(app *appState) *cobra.Command {
+	return &cobra.Command{
+		Use:     "remove <name>",
+		Aliases: []string{"rm"},
+		Short:   "Delete a provider",
+		Args:    cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			name := args[0]
+			if err := app.cfg.RemoveProvider(name); err != nil {
+				return err
+			}
+			if err := app.save(); err != nil {
+				return err
+			}
+			fmt.Fprintf(app.stdout, "removed provider %q\n", name)
+			return nil
+		},
+	}
+}

+ 137 - 0
internal/cli/root.go

@@ -0,0 +1,137 @@
+package cli
+
+import (
+	"fmt"
+	"io"
+	"os"
+
+	"github.com/spf13/cobra"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/config"
+)
+
+// appState is threaded through every subcommand via cobra's persistent
+// pre-run. Subcommands read from app.cfg, mutate it in place, and call
+// app.save() to persist.
+type appState struct {
+	// Streams. Tests substitute these; production uses os.Std*.
+	stdin  io.Reader
+	stdout io.Writer
+	stderr io.Writer
+
+	// Flags.
+	verbose    bool
+	configFlag string // --config; beats env var precedence
+
+	// Loaded state.
+	configPath string
+	cfg        config.Config
+
+	// requestedExit lets subcommands like `use` hand back a specific exit
+	// code (claude's own) that bypasses cobra's 0/1 mapping.
+	requestedExit *int
+}
+
+func (a *appState) tracef(format string, args ...any) {
+	if !a.verbose {
+		return
+	}
+	fmt.Fprintf(a.stderr, "[cc-switch] "+format+"\n", args...)
+}
+
+// exitCode collapses (requestedExit, cobra err) into a single process exit
+// code. requestedExit takes precedence — subcommands use it to propagate
+// claude's own exit code even when we also returned an error for logging.
+func (a *appState) exitCode(err error) int {
+	if a.requestedExit != nil {
+		return *a.requestedExit
+	}
+	if err != nil {
+		return 1
+	}
+	return 0
+}
+
+func (a *appState) save() error {
+	return config.Save(a.configPath, a.cfg)
+}
+
+func newRootCmd(app *appState) *cobra.Command {
+	root := &cobra.Command{
+		Use:           "cc-switch",
+		Short:         "Switch between Claude Code provider subscriptions",
+		Long:          "cc-switch manages multiple Claude Code provider env profiles and launches the `claude` CLI with the right environment.",
+		SilenceUsage:  true,
+		SilenceErrors: false,
+		// Bare run -> `use` (interactive when tty).
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runUse(cmd, app, args)
+		},
+	}
+
+	root.PersistentFlags().BoolVarP(&app.verbose, "verbose", "v", false,
+		"print trace output to stderr")
+	root.PersistentFlags().StringVar(&app.configFlag, "config", "",
+		"path to config file (overrides $CC_SWITCH_CONFIG)")
+
+	root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
+		// Compute effective config path.
+		if app.configFlag != "" {
+			expanded, err := config.ExpandUser(app.configFlag)
+			if err != nil {
+				return err
+			}
+			app.configPath = expanded
+		} else {
+			p, err := config.ResolvePath()
+			if err != nil {
+				return err
+			}
+			app.configPath = p
+		}
+		app.tracef("config path: %s", app.configPath)
+
+		res, err := config.Load(app.configPath)
+		if err != nil {
+			return err
+		}
+		if res.Warning != "" {
+			fmt.Fprintln(app.stderr, res.Warning)
+		}
+		app.cfg = res.Config
+		return nil
+	}
+
+	root.AddCommand(
+		newAddCmd(app),
+		newListCmd(app),
+		newEditCmd(app),
+		newRemoveCmd(app),
+		newUseCmd(app),
+		newConfigCmd(app),
+		newTemplatesCmd(app),
+		newVersionCmd(app),
+	)
+	return root
+}
+
+// Build-time injected values (see Makefile LDFLAGS).
+var (
+	version = "dev"
+	commit  = "none"
+	date    = "unknown"
+)
+
+func newVersionCmd(app *appState) *cobra.Command {
+	return &cobra.Command{
+		Use:   "version",
+		Short: "Print version information",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			_, err := fmt.Fprintf(app.stdout, "cc-switch %s (commit %s, built %s)\n", version, commit, date)
+			return err
+		},
+	}
+}
+
+// ensure os.Stdin satisfies io.Reader — helps static tools.
+var _ io.Reader = os.Stdin

+ 55 - 0
internal/cli/templates_cmd.go

@@ -0,0 +1,55 @@
+package cli
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/templates"
+)
+
+func newTemplatesCmd(app *appState) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "templates",
+		Short: "Inspect built-in provider templates",
+	}
+	cmd.AddCommand(
+		&cobra.Command{
+			Use:   "list",
+			Short: "List template names",
+			RunE: func(cmd *cobra.Command, args []string) error {
+				for _, t := range templates.All() {
+					fmt.Fprintf(app.stdout, "%s\t%s\n", t.Name, t.Description)
+				}
+				return nil
+			},
+		},
+		&cobra.Command{
+			Use:   "show <name>",
+			Short: "Show a template's env keys, hints, and defaults",
+			Args:  cobra.ExactArgs(1),
+			RunE: func(cmd *cobra.Command, args []string) error {
+				t, ok := templates.Get(args[0])
+				if !ok {
+					return fmt.Errorf(
+						"template %q not found; run `cc-switch templates list`",
+						args[0])
+				}
+				fmt.Fprintf(app.stdout, "%s — %s\n", t.Name, t.Description)
+				fmt.Fprintln(app.stdout, "env:")
+				for _, e := range t.Env {
+					line := "  " + e.Name
+					if e.Default != "" {
+						line += fmt.Sprintf(" [%s]", e.Default)
+					}
+					if e.Hint != "" {
+						line += "  # " + e.Hint
+					}
+					fmt.Fprintln(app.stdout, line)
+				}
+				return nil
+			},
+		},
+	)
+	return cmd
+}

+ 19 - 0
internal/cli/tty.go

@@ -0,0 +1,19 @@
+package cli
+
+import (
+	"io"
+	"os"
+
+	"golang.org/x/term"
+)
+
+// isTTY reports whether r is attached to a terminal. In production the app
+// passes os.Stdin, which might be a tty. In tests a bytes.Buffer is supplied
+// and the type assertion fails, so we correctly treat it as non-tty.
+func isTTY(r io.Reader) bool {
+	f, ok := r.(*os.File)
+	if !ok {
+		return false
+	}
+	return term.IsTerminal(int(f.Fd()))
+}

+ 169 - 0
internal/cli/use.go

@@ -0,0 +1,169 @@
+package cli
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/provider"
+	"github.com/kotoyuuko/cc-switch-cli/internal/runner"
+)
+
+const pickerMaxRetries = 3
+
+func newUseCmd(app *appState) *cobra.Command {
+	return &cobra.Command{
+		Use:   "use [name]",
+		Short: "Launch claude with a provider's env",
+		Args:  cobra.MaximumNArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runUse(cmd, app, args)
+		},
+	}
+}
+
+func runUse(cmd *cobra.Command, app *appState, args []string) error {
+	if len(app.cfg.Providers) == 0 {
+		return fmt.Errorf("no providers configured; run `cc-switch add <name> ...` first")
+	}
+
+	name, err := selectProvider(app, args)
+	if err != nil {
+		return err
+	}
+	p, ok := app.cfg.Providers[name]
+	if !ok {
+		return fmt.Errorf("provider %q does not exist", name)
+	}
+	app.tracef("selected provider: %s", name)
+
+	// Resolve claude path BEFORE we start doing any env work, so a missing
+	// binary fails loud and early.
+	claudePath, err := runner.ResolveClaudePath(app.cfg)
+	if err != nil {
+		return err
+	}
+	app.tracef("claude path: %s", claudePath)
+
+	// 1. Snapshot parent env (before any cleanup or mutation).
+	snapshot := provider.SnapshotEnv(os.Environ())
+	// 2. Resolve env:VAR references against that snapshot.
+	resolved, err := provider.ResolveEnvRefs(p, snapshot)
+	if err != nil {
+		return err
+	}
+	// 3. Compute union of all providers' env keys (cleanup set).
+	union := provider.UnionEnvKeys(app.cfg.Providers)
+	app.tracef("union env keys: %s", strings.Join(union, ","))
+	// 4. Build the child env.
+	childEnv := provider.BuildChildEnv(os.Environ(), union, resolved)
+	// For trace, print the final injected keys (never values).
+	if app.verbose {
+		keys := make([]string, 0, len(resolved))
+		for k := range resolved {
+			keys = append(keys, k)
+		}
+		sort.Strings(keys)
+		app.tracef("injecting keys: %s", strings.Join(keys, ","))
+	}
+
+	// Pass through any extra argv after `use <name>` if support were ever
+	// added; for now Args caps us at 1 arg, so no extra args flow through.
+	// (cobra stores them in positional args; we already consumed args[0].)
+	extraArgs := []string{}
+	if len(args) > 1 {
+		extraArgs = args[1:]
+	}
+
+	code, runErr := runner.Run(claudePath, childEnv, extraArgs)
+	// Record whatever claude's exit code was so the CLI propagates it.
+	exit := code
+	app.requestedExit = &exit
+	app.tracef("cleanup complete; claude exit code=%d", code)
+	if runErr != nil {
+		// A launch/wait failure. Surface it as the returned error while
+		// still honoring the requested exit code.
+		return runErr
+	}
+	return nil
+}
+
+// selectProvider decides which provider to run. Rules (from cli spec):
+//   - if args has one name, validate and return it.
+//   - if tty: interactive menu, up to pickerMaxRetries attempts.
+//   - if non-tty: fall back to default_provider, else error.
+func selectProvider(app *appState, args []string) (string, error) {
+	if len(args) >= 1 {
+		name := args[0]
+		if _, ok := app.cfg.Providers[name]; !ok {
+			return "", fmt.Errorf("provider %q does not exist (see `cc-switch list`)", name)
+		}
+		return name, nil
+	}
+
+	if !isTTY(app.stdin) {
+		if app.cfg.DefaultProvider != "" {
+			return app.cfg.DefaultProvider, nil
+		}
+		return "", fmt.Errorf(
+			"non-interactive invocation requires a provider name or a configured default " +
+				"(set one with `cc-switch config set default <name>`)")
+	}
+
+	return promptProvider(app)
+}
+
+// promptProvider renders the numbered list and reads user input. Empty input
+// selects the default (if set). The user may type a number or a name.
+func promptProvider(app *appState) (string, error) {
+	names := app.cfg.SortedProviderNames()
+	reader := bufio.NewReader(app.stdin)
+
+	for attempt := 0; attempt < pickerMaxRetries; attempt++ {
+		fmt.Fprintln(app.stdout, "Select provider:")
+		for i, n := range names {
+			marker := "  "
+			if n == app.cfg.DefaultProvider {
+				marker = "* "
+			}
+			fmt.Fprintf(app.stdout, "%s%d) %s\n", marker, i+1, n)
+		}
+		if app.cfg.DefaultProvider != "" {
+			fmt.Fprintf(app.stdout, "> [%s] ", app.cfg.DefaultProvider)
+		} else {
+			fmt.Fprint(app.stdout, "> ")
+		}
+
+		line, err := reader.ReadString('\n')
+		if err != nil && line == "" {
+			return "", err
+		}
+		input := strings.TrimSpace(line)
+
+		if input == "" {
+			if app.cfg.DefaultProvider == "" {
+				fmt.Fprintln(app.stderr, "no default provider configured; enter a number or name")
+				continue
+			}
+			return app.cfg.DefaultProvider, nil
+		}
+
+		if idx, err := strconv.Atoi(input); err == nil {
+			if idx >= 1 && idx <= len(names) {
+				return names[idx-1], nil
+			}
+			fmt.Fprintf(app.stderr, "invalid selection %d\n", idx)
+			continue
+		}
+		if _, ok := app.cfg.Providers[input]; ok {
+			return input, nil
+		}
+		fmt.Fprintf(app.stderr, "unknown provider %q\n", input)
+	}
+	return "", fmt.Errorf("gave up after %d invalid selections", pickerMaxRetries)
+}

+ 193 - 0
internal/config/config.go

@@ -0,0 +1,193 @@
+// Package config handles the on-disk YAML configuration for cc-switch:
+// path resolution (CC_SWITCH_CONFIG > XDG_CONFIG_HOME > ~/.config), atomic
+// read/write, permission warnings, and the CRUD surface used by the CLI.
+package config
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+)
+
+// Config is the root document persisted to ~/.config/cc-switch/config.yaml.
+type Config struct {
+	ClaudePath      string              `yaml:"claude_path,omitempty"`
+	DefaultProvider string              `yaml:"default_provider,omitempty"`
+	Providers       map[string]Provider `yaml:"providers,omitempty"`
+}
+
+// Provider is one coding-plan subscription's env payload.
+type Provider struct {
+	Description string            `yaml:"description,omitempty"`
+	Env         map[string]string `yaml:"env"`
+}
+
+var (
+	providerNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
+	envRefRe       = regexp.MustCompile(`^env:([A-Za-z_][A-Za-z0-9_]*)$`)
+)
+
+// ValidateProviderName returns an error if name is empty or contains
+// characters outside [A-Za-z0-9_-].
+func ValidateProviderName(name string) error {
+	if name == "" {
+		return fmt.Errorf("provider name must not be empty")
+	}
+	if !providerNameRe.MatchString(name) {
+		return fmt.Errorf("provider name %q is invalid (allowed: letters, digits, '-', '_')", name)
+	}
+	return nil
+}
+
+// Validate checks the whole config for structural problems (duplicate or
+// misspelled fields are already caught by the YAML unmarshaller).
+func (c *Config) Validate() error {
+	for name, p := range c.Providers {
+		if err := ValidateProviderName(name); err != nil {
+			return err
+		}
+		if len(p.Env) == 0 {
+			return fmt.Errorf("provider %q: env must contain at least one key", name)
+		}
+	}
+	if c.DefaultProvider != "" {
+		if _, ok := c.Providers[c.DefaultProvider]; !ok {
+			return fmt.Errorf("default_provider %q is not among providers", c.DefaultProvider)
+		}
+	}
+	return nil
+}
+
+// IsEnvRef reports whether s is a reference to a parent-env variable using the
+// `env:VAR_NAME` syntax. When ok is true, varName is the referenced variable.
+// Non-matching strings are treated as literal values elsewhere in the code.
+func IsEnvRef(s string) (varName string, ok bool) {
+	m := envRefRe.FindStringSubmatch(s)
+	if m == nil {
+		return "", false
+	}
+	return m[1], true
+}
+
+// SortedProviderNames returns provider names in lexicographic order.
+func (c *Config) SortedProviderNames() []string {
+	names := make([]string, 0, len(c.Providers))
+	for n := range c.Providers {
+		names = append(names, n)
+	}
+	sort.Strings(names)
+	return names
+}
+
+// AddProvider inserts a new provider. Returns an error if the name already
+// exists or is invalid, or if env is empty.
+func (c *Config) AddProvider(name string, p Provider) error {
+	if err := ValidateProviderName(name); err != nil {
+		return err
+	}
+	if len(p.Env) == 0 {
+		return fmt.Errorf("provider %q: env must contain at least one key", name)
+	}
+	if _, exists := c.Providers[name]; exists {
+		return fmt.Errorf("provider %q already exists (use `edit` or pick a different name)", name)
+	}
+	if c.Providers == nil {
+		c.Providers = map[string]Provider{}
+	}
+	c.Providers[name] = p
+	return nil
+}
+
+// UpdateProvider replaces an existing provider in-place.
+func (c *Config) UpdateProvider(name string, p Provider) error {
+	if err := ValidateProviderName(name); err != nil {
+		return err
+	}
+	if len(p.Env) == 0 {
+		return fmt.Errorf("provider %q: env must contain at least one key", name)
+	}
+	if _, exists := c.Providers[name]; !exists {
+		return fmt.Errorf("provider %q does not exist", name)
+	}
+	c.Providers[name] = p
+	return nil
+}
+
+// RemoveProvider deletes a provider. If the removed provider was the current
+// default, default_provider is cleared as well.
+func (c *Config) RemoveProvider(name string) error {
+	if _, exists := c.Providers[name]; !exists {
+		return fmt.Errorf("provider %q does not exist", name)
+	}
+	delete(c.Providers, name)
+	if c.DefaultProvider == name {
+		c.DefaultProvider = ""
+	}
+	return nil
+}
+
+// SetDefault sets default_provider; the provider must already exist.
+func (c *Config) SetDefault(name string) error {
+	if _, exists := c.Providers[name]; !exists {
+		return fmt.Errorf("provider %q does not exist", name)
+	}
+	c.DefaultProvider = name
+	return nil
+}
+
+// SetClaudePath validates that path (after ~ expansion) points to an existing,
+// regular, executable file and stores the expanded absolute form.
+func (c *Config) SetClaudePath(path string) error {
+	expanded, err := ExpandUser(path)
+	if err != nil {
+		return err
+	}
+	abs, err := filepath.Abs(expanded)
+	if err != nil {
+		return fmt.Errorf("resolve %q: %w", path, err)
+	}
+	info, err := os.Stat(abs)
+	if err != nil {
+		return fmt.Errorf("claude path %q: %w", abs, err)
+	}
+	if !info.Mode().IsRegular() {
+		return fmt.Errorf("claude path %q is not a regular file", abs)
+	}
+	if info.Mode().Perm()&0o111 == 0 {
+		return fmt.Errorf("claude path %q is not executable", abs)
+	}
+	c.ClaudePath = abs
+	return nil
+}
+
+// ExpandUser expands a leading "~" or "~/" to the current user's home
+// directory. Other paths are returned unchanged.
+func ExpandUser(p string) (string, error) {
+	if p == "" || (p[0] != '~') {
+		return p, nil
+	}
+	if p == "~" {
+		return os.UserHomeDir()
+	}
+	if strings.HasPrefix(p, "~/") {
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return "", err
+		}
+		return filepath.Join(home, p[2:]), nil
+	}
+	// "~user" isn't supported — no standard POSIX way to resolve it in Go.
+	return p, nil
+}
+
+// Marshal serialises the config to YAML, sorting provider keys for stable
+// diffs and preserving human-friendly field ordering.
+func (c *Config) Marshal() ([]byte, error) {
+	// Re-use yaml.v3 to get deterministic output without pulling in another dep.
+	return yaml.Marshal(c)
+}

+ 294 - 0
internal/config/config_test.go

@@ -0,0 +1,294 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestIsEnvRef(t *testing.T) {
+	cases := []struct {
+		in      string
+		wantVar string
+		wantOK  bool
+	}{
+		{"env:FOO", "FOO", true},
+		{"env:FOO_BAR", "FOO_BAR", true},
+		{"env:_X", "_X", true},
+		{"env:foo", "foo", true},
+
+		{"env:", "", false},
+		{"env:1NAME", "", false},
+		{"env: FOO", "", false},
+		{"env:FOO ", "", false},
+		{"plain-value", "", false},
+		{"", "", false},
+		{" env:FOO", "", false},
+		{"env:FOO-BAR", "", false},
+	}
+	for _, c := range cases {
+		gotVar, gotOK := IsEnvRef(c.in)
+		if gotVar != c.wantVar || gotOK != c.wantOK {
+			t.Errorf("IsEnvRef(%q) = (%q, %v), want (%q, %v)",
+				c.in, gotVar, gotOK, c.wantVar, c.wantOK)
+		}
+	}
+}
+
+func TestValidateProviderName(t *testing.T) {
+	good := []string{"foo", "Foo_2", "a", "my-provider", "A-Z_0-9"}
+	for _, n := range good {
+		if err := ValidateProviderName(n); err != nil {
+			t.Errorf("ValidateProviderName(%q) unexpectedly errored: %v", n, err)
+		}
+	}
+	bad := []string{"", "has space", "has/slash", "has.dot", "zh中文"}
+	for _, n := range bad {
+		if err := ValidateProviderName(n); err == nil {
+			t.Errorf("ValidateProviderName(%q) should have errored", n)
+		}
+	}
+}
+
+func TestConfigValidate(t *testing.T) {
+	c := Config{
+		Providers: map[string]Provider{
+			"ok": {Env: map[string]string{"K": "v"}},
+		},
+	}
+	if err := c.Validate(); err != nil {
+		t.Fatalf("unexpected: %v", err)
+	}
+
+	// empty env
+	bad := Config{Providers: map[string]Provider{"x": {}}}
+	if err := bad.Validate(); err == nil {
+		t.Fatal("expected error for empty env")
+	}
+
+	// bad name
+	bad2 := Config{Providers: map[string]Provider{"bad name": {Env: map[string]string{"K": "v"}}}}
+	if err := bad2.Validate(); err == nil {
+		t.Fatal("expected error for bad name")
+	}
+
+	// default not in providers
+	bad3 := Config{
+		DefaultProvider: "missing",
+		Providers:       map[string]Provider{"ok": {Env: map[string]string{"K": "v"}}},
+	}
+	if err := bad3.Validate(); err == nil {
+		t.Fatal("expected error for dangling default_provider")
+	}
+}
+
+func TestCRUDAndDeleteDefault(t *testing.T) {
+	var c Config
+	if err := c.AddProvider("a", Provider{Env: map[string]string{"K": "v"}}); err != nil {
+		t.Fatalf("add a: %v", err)
+	}
+	if err := c.AddProvider("a", Provider{Env: map[string]string{"K": "v"}}); err == nil {
+		t.Fatal("expected duplicate-name error")
+	}
+	if err := c.AddProvider("b", Provider{Env: map[string]string{"K2": "v2"}}); err != nil {
+		t.Fatalf("add b: %v", err)
+	}
+	if err := c.SetDefault("a"); err != nil {
+		t.Fatalf("set default: %v", err)
+	}
+	if err := c.SetDefault("missing"); err == nil {
+		t.Fatal("expected error setting missing default")
+	}
+
+	if err := c.UpdateProvider("a", Provider{Env: map[string]string{"K": "v2"}}); err != nil {
+		t.Fatalf("update a: %v", err)
+	}
+	if c.Providers["a"].Env["K"] != "v2" {
+		t.Fatalf("update didn't persist: %#v", c.Providers["a"])
+	}
+	if err := c.UpdateProvider("missing", Provider{Env: map[string]string{"K": "v"}}); err == nil {
+		t.Fatal("expected update-missing error")
+	}
+
+	if err := c.RemoveProvider("a"); err != nil {
+		t.Fatalf("remove a: %v", err)
+	}
+	if c.DefaultProvider != "" {
+		t.Fatalf("removing default didn't clear: %q", c.DefaultProvider)
+	}
+	if err := c.RemoveProvider("a"); err == nil {
+		t.Fatal("expected error removing missing provider")
+	}
+}
+
+func TestResolvePathPrecedence(t *testing.T) {
+	// 1. CC_SWITCH_CONFIG wins
+	t.Setenv(EnvConfigPath, "/tmp/explicit.yaml")
+	t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg")
+	got, err := ResolvePath()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got != "/tmp/explicit.yaml" {
+		t.Errorf("CC_SWITCH_CONFIG path: got %q", got)
+	}
+
+	// 2. XDG fallback
+	t.Setenv(EnvConfigPath, "")
+	t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg")
+	got, err = ResolvePath()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got != "/tmp/xdg/cc-switch/config.yaml" {
+		t.Errorf("XDG path: got %q", got)
+	}
+
+	// 3. ~/.config fallback
+	t.Setenv(EnvConfigPath, "")
+	t.Setenv("XDG_CONFIG_HOME", "")
+	got, err = ResolvePath()
+	if err != nil {
+		t.Fatal(err)
+	}
+	home, _ := os.UserHomeDir()
+	want := filepath.Join(home, ".config", "cc-switch", "config.yaml")
+	if got != want {
+		t.Errorf("home fallback: got %q want %q", got, want)
+	}
+}
+
+func TestLoadMissingFile(t *testing.T) {
+	dir := t.TempDir()
+	res, err := Load(filepath.Join(dir, "does-not-exist.yaml"))
+	if err != nil {
+		t.Fatalf("unexpected: %v", err)
+	}
+	if len(res.Config.Providers) != 0 || res.Warning != "" {
+		t.Fatalf("expected empty: %#v", res)
+	}
+}
+
+func TestSaveLoadRoundtrip(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "sub", "config.yaml") // MkdirAll path
+
+	in := Config{
+		ClaudePath:      "/usr/bin/true",
+		DefaultProvider: "foo",
+		Providers: map[string]Provider{
+			"foo": {Description: "test", Env: map[string]string{
+				"ANTHROPIC_API_KEY":  "sk-xxx",
+				"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
+			}},
+		},
+	}
+	if err := Save(path, in); err != nil {
+		t.Fatalf("save: %v", err)
+	}
+
+	// Verify perms on created file.
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatalf("stat: %v", err)
+	}
+	if info.Mode().Perm() != 0o600 {
+		t.Errorf("file perms = %o, want 0600", info.Mode().Perm())
+	}
+	// Verify parent dir perms too.
+	dinfo, err := os.Stat(filepath.Dir(path))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if dinfo.Mode().Perm() != 0o700 {
+		t.Errorf("dir perms = %o, want 0700", dinfo.Mode().Perm())
+	}
+
+	res, err := Load(path)
+	if err != nil {
+		t.Fatalf("load: %v", err)
+	}
+	if res.Warning != "" {
+		t.Errorf("did not expect warning: %q", res.Warning)
+	}
+	out := res.Config
+	if out.DefaultProvider != "foo" || out.ClaudePath != "/usr/bin/true" {
+		t.Errorf("top-level roundtrip mismatch: %#v", out)
+	}
+	if got := out.Providers["foo"].Env["ANTHROPIC_API_KEY"]; got != "sk-xxx" {
+		t.Errorf("env roundtrip mismatch: %q", got)
+	}
+}
+
+func TestLoadPermissiveWarning(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "config.yaml")
+	in := Config{Providers: map[string]Provider{"ok": {Env: map[string]string{"K": "v"}}}}
+	if err := Save(path, in); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chmod(path, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	res, err := Load(path)
+	if err != nil {
+		t.Fatalf("load: %v", err)
+	}
+	if res.Warning == "" {
+		t.Error("expected permission warning for 0644 file")
+	}
+	if !strings.Contains(res.Warning, "chmod 600") {
+		t.Errorf("warning should suggest chmod 600: %q", res.Warning)
+	}
+}
+
+func TestSaveNoTempLeakOnValidationError(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "config.yaml")
+	// provider with empty env should fail Validate before any temp write
+	bad := Config{Providers: map[string]Provider{"x": {}}}
+	if err := Save(path, bad); err == nil {
+		t.Fatal("expected validation error")
+	}
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, e := range entries {
+		if strings.HasPrefix(e.Name(), ".config.yaml") {
+			t.Errorf("temp file leaked: %s", e.Name())
+		}
+	}
+}
+
+func TestSetClaudePath(t *testing.T) {
+	var c Config
+
+	// non-existent
+	if err := c.SetClaudePath("/definitely/not/a/file"); err == nil {
+		t.Error("expected error for missing path")
+	}
+
+	// create executable temp
+	dir := t.TempDir()
+	exe := filepath.Join(dir, "claude")
+	if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := c.SetClaudePath(exe); err != nil {
+		t.Fatalf("set: %v", err)
+	}
+	if c.ClaudePath != exe {
+		t.Errorf("claude_path = %q, want %q", c.ClaudePath, exe)
+	}
+
+	// non-executable
+	noexec := filepath.Join(dir, "not-exec")
+	if err := os.WriteFile(noexec, []byte(""), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := c.SetClaudePath(noexec); err == nil {
+		t.Error("expected non-executable error")
+	}
+}

+ 125 - 0
internal/config/io.go

@@ -0,0 +1,125 @@
+package config
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+
+	"gopkg.in/yaml.v3"
+)
+
+const (
+	dirMode  fs.FileMode = 0o700
+	fileMode fs.FileMode = 0o600
+)
+
+// LoadResult bundles a loaded config with a non-fatal warning (if any).
+// A missing file returns an empty Config and no warning.
+type LoadResult struct {
+	Config  Config
+	Warning string // e.g. permission > 0600
+}
+
+// Load reads and parses the config at path. If the file does not exist, an
+// empty Config is returned without error (first-run behavior). Files with
+// permissions more permissive than 0600 still load, but the result carries a
+// Warning the caller should surface.
+func Load(path string) (LoadResult, error) {
+	var res LoadResult
+
+	info, err := os.Stat(path)
+	if err != nil {
+		if errors.Is(err, fs.ErrNotExist) {
+			return res, nil
+		}
+		return res, fmt.Errorf("stat config %q: %w", path, err)
+	}
+	if !info.Mode().IsRegular() {
+		return res, fmt.Errorf("config path %q is not a regular file", path)
+	}
+	if info.Mode().Perm()&0o077 != 0 {
+		res.Warning = fmt.Sprintf(
+			"warning: config %s has permissions %#o; API keys may be readable by other users. Consider `chmod 600 %s`.",
+			path, info.Mode().Perm(), path,
+		)
+	}
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return res, fmt.Errorf("read config %q: %w", path, err)
+	}
+	if len(data) == 0 {
+		return res, nil
+	}
+
+	if err := yaml.Unmarshal(data, &res.Config); err != nil {
+		return res, fmt.Errorf("parse config %q: %w", path, err)
+	}
+	if err := res.Config.Validate(); err != nil {
+		return res, fmt.Errorf("invalid config %q: %w", path, err)
+	}
+	return res, nil
+}
+
+// Save serialises cfg and writes it to path atomically:
+//
+//	1. ensure parent dir exists (0700)
+//	2. write to sibling "<path>.tmp" with 0600 perms
+//	3. fsync
+//	4. os.Rename -> path
+//
+// Callers should Validate() before invoking Save() if they've built a Config
+// programmatically (Add/Update/Remove methods already enforce invariants).
+func Save(path string, cfg Config) error {
+	if err := cfg.Validate(); err != nil {
+		return err
+	}
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, dirMode); err != nil {
+		return fmt.Errorf("create config dir %q: %w", dir, err)
+	}
+
+	data, err := yaml.Marshal(cfg)
+	if err != nil {
+		return fmt.Errorf("marshal config: %w", err)
+	}
+
+	tmp, err := os.CreateTemp(dir, ".config.yaml.*.tmp")
+	if err != nil {
+		return fmt.Errorf("create temp file in %q: %w", dir, err)
+	}
+	tmpPath := tmp.Name()
+	// Clean up on any failure path.
+	committed := false
+	defer func() {
+		if !committed {
+			_ = os.Remove(tmpPath)
+		}
+	}()
+
+	if err := tmp.Chmod(fileMode); err != nil {
+		_ = tmp.Close()
+		return fmt.Errorf("chmod temp file: %w", err)
+	}
+	if _, err := io.Copy(tmp, bytes.NewReader(data)); err != nil {
+		_ = tmp.Close()
+		return fmt.Errorf("write temp file: %w", err)
+	}
+	if err := tmp.Sync(); err != nil {
+		_ = tmp.Close()
+		return fmt.Errorf("fsync temp file: %w", err)
+	}
+	if err := tmp.Close(); err != nil {
+		return fmt.Errorf("close temp file: %w", err)
+	}
+	if err := os.Rename(tmpPath, path); err != nil {
+		return fmt.Errorf("rename temp into place: %w", err)
+	}
+	committed = true
+	return nil
+}
+

+ 30 - 0
internal/config/path.go

@@ -0,0 +1,30 @@
+package config
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+// EnvConfigPath is the env var that, when set, overrides XDG lookup entirely.
+const EnvConfigPath = "CC_SWITCH_CONFIG"
+
+// ResolvePath returns the path cc-switch reads/writes. Precedence:
+//  1. CC_SWITCH_CONFIG  (full path, used as-is)
+//  2. $XDG_CONFIG_HOME/cc-switch/config.yaml
+//  3. ~/.config/cc-switch/config.yaml
+//
+// The --config flag, when provided, bypasses this function entirely.
+func ResolvePath() (string, error) {
+	if v := os.Getenv(EnvConfigPath); v != "" {
+		return ExpandUser(v)
+	}
+	if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
+		return filepath.Join(xdg, "cc-switch", "config.yaml"), nil
+	}
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", fmt.Errorf("resolve home dir: %w", err)
+	}
+	return filepath.Join(home, ".config", "cc-switch", "config.yaml"), nil
+}

+ 16 - 0
internal/mask/mask.go

@@ -0,0 +1,16 @@
+// Package mask provides value-masking for env values shown to humans.
+// Rule: values of length <= 4 are fully replaced with "***"; longer values
+// show their first 4 characters followed by "***".
+//
+// Values that are `env:VAR` references (parent-env indirections) are NOT
+// masked; callers should detect those via config.IsEnvRef and print them
+// as-is. This helper intentionally stays unaware of that distinction.
+package mask
+
+// Value returns a masked representation of v.
+func Value(v string) string {
+	if len(v) <= 4 {
+		return "***"
+	}
+	return v[:4] + "***"
+}

+ 20 - 0
internal/mask/mask_test.go

@@ -0,0 +1,20 @@
+package mask
+
+import "testing"
+
+func TestValue(t *testing.T) {
+	cases := []struct {
+		in, want string
+	}{
+		{"", "***"},
+		{"a", "***"},
+		{"abcd", "***"},
+		{"abcde", "abcd***"},
+		{"sk-ant-xxxxxxxxx", "sk-a***"},
+	}
+	for _, c := range cases {
+		if got := Value(c.in); got != c.want {
+			t.Errorf("Value(%q) = %q want %q", c.in, got, c.want)
+		}
+	}
+}

+ 139 - 0
internal/provider/provider.go

@@ -0,0 +1,139 @@
+// Package provider computes the runtime environment handed to the claude
+// subprocess: the union of all provider env keys (for cleanup), the resolved
+// env for the selected provider (dereferencing env:VAR indirections against
+// a snapshot of the parent environment), and the final child env slice.
+//
+// All functions here are pure — they never touch os.Environ or os.Setenv.
+// The caller is responsible for capturing the parent-env snapshot before
+// invoking any of these helpers; see cli.useCmd for orchestration.
+package provider
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/config"
+)
+
+// UnionEnvKeys returns the sorted, deduplicated union of env key names across
+// every provider in the config. This is the "cleanup set" subtracted from the
+// inherited environment before the selected provider's env is injected.
+func UnionEnvKeys(providers map[string]config.Provider) []string {
+	set := map[string]struct{}{}
+	for _, p := range providers {
+		for k := range p.Env {
+			set[k] = struct{}{}
+		}
+	}
+	out := make([]string, 0, len(set))
+	for k := range set {
+		out = append(out, k)
+	}
+	sort.Strings(out)
+	return out
+}
+
+// EnvRefError is returned when a provider's env value references an env var
+// that is not present in the parent snapshot. The CLI prints its message and
+// exits non-zero WITHOUT launching claude.
+type EnvRefError struct {
+	// Key is the env var the provider wants to set (e.g. ANTHROPIC_API_KEY).
+	Key string
+	// Var is the parent-env var that was referenced (e.g. MY_ANTHROPIC_KEY).
+	Var string
+}
+
+func (e *EnvRefError) Error() string {
+	return fmt.Sprintf("%s references env var %s which is not set", e.Key, e.Var)
+}
+
+// ResolveEnvRefs materialises a provider's env map by expanding any value of
+// the form `env:VAR_NAME` against parentSnapshot.
+//
+// Resolution happens exactly once: if the looked-up value itself matches the
+// env:VAR syntax, it is still treated as a literal string (no chained lookup).
+// If a reference cannot be satisfied, an *EnvRefError is returned and the map
+// is partially-built state; callers MUST NOT proceed to launch claude.
+//
+// parentSnapshot should be captured from os.Environ() BEFORE any cleanup, so
+// that a reference `env:X` can find `X` even when `X` is also in the
+// cleanup-union for some other provider.
+func ResolveEnvRefs(selected config.Provider, parentSnapshot map[string]string) (map[string]string, error) {
+	out := make(map[string]string, len(selected.Env))
+	for k, raw := range selected.Env {
+		if varName, ok := config.IsEnvRef(raw); ok {
+			val, present := parentSnapshot[varName]
+			if !present {
+				return nil, &EnvRefError{Key: k, Var: varName}
+			}
+			out[k] = val
+			continue
+		}
+		out[k] = raw
+	}
+	return out, nil
+}
+
+// BuildChildEnv produces the KEY=VALUE slice to hand to exec.Cmd.Env.
+//
+// Contract:
+//  1. Start from `parent` (typically os.Environ()).
+//  2. Drop every entry whose key is in `union`.
+//  3. Append every entry from `resolved` (resolved values win on collisions
+//     since map entries come after the filtered parent).
+//
+// No shell expansion is performed on values at any stage.
+func BuildChildEnv(parent []string, union []string, resolved map[string]string) []string {
+	unionSet := make(map[string]struct{}, len(union))
+	for _, k := range union {
+		unionSet[k] = struct{}{}
+	}
+	// Also consider resolved keys as "to-drop" so that parent entries with the
+	// same key don't linger before the provider's value. (If a key is both in
+	// union and in resolved, it's dropped then added; same for resolved-only
+	// keys.)
+	for k := range resolved {
+		unionSet[k] = struct{}{}
+	}
+
+	out := make([]string, 0, len(parent)+len(resolved))
+	for _, kv := range parent {
+		eq := strings.IndexByte(kv, '=')
+		if eq < 0 {
+			// malformed entry; keep it to stay faithful to inheritance
+			out = append(out, kv)
+			continue
+		}
+		k := kv[:eq]
+		if _, drop := unionSet[k]; drop {
+			continue
+		}
+		out = append(out, kv)
+	}
+	// Append provider-supplied entries in deterministic order for stable trace logs.
+	keys := make([]string, 0, len(resolved))
+	for k := range resolved {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	for _, k := range keys {
+		out = append(out, k+"="+resolved[k])
+	}
+	return out
+}
+
+// SnapshotEnv captures a parent env slice (KEY=VALUE) into a map for cheap
+// lookup during ref resolution. Later duplicates (same key) win, matching
+// standard shell semantics.
+func SnapshotEnv(env []string) map[string]string {
+	m := make(map[string]string, len(env))
+	for _, kv := range env {
+		eq := strings.IndexByte(kv, '=')
+		if eq < 0 {
+			continue
+		}
+		m[kv[:eq]] = kv[eq+1:]
+	}
+	return m
+}

+ 162 - 0
internal/provider/provider_test.go

@@ -0,0 +1,162 @@
+package provider
+
+import (
+	"errors"
+	"reflect"
+	"sort"
+	"strings"
+	"testing"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/config"
+)
+
+func TestUnionEnvKeys(t *testing.T) {
+	in := map[string]config.Provider{
+		"a": {Env: map[string]string{"X": "1", "Y": "2"}},
+		"b": {Env: map[string]string{"Y": "3", "Z": "4"}},
+	}
+	got := UnionEnvKeys(in)
+	want := []string{"X", "Y", "Z"}
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("UnionEnvKeys = %v, want %v", got, want)
+	}
+}
+
+func TestResolveEnvRefs_Literal(t *testing.T) {
+	p := config.Provider{Env: map[string]string{"A": "plain", "B": "$HOME/api"}}
+	got, err := ResolveEnvRefs(p, map[string]string{"HOME": "/root"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got["A"] != "plain" {
+		t.Errorf("A: %q", got["A"])
+	}
+	if got["B"] != "$HOME/api" {
+		t.Errorf("B shouldn't be shell-expanded: %q", got["B"])
+	}
+}
+
+func TestResolveEnvRefs_Ref(t *testing.T) {
+	p := config.Provider{Env: map[string]string{
+		"ANTHROPIC_API_KEY":  "env:MY_KEY",
+		"ANTHROPIC_BASE_URL": "https://x",
+	}}
+	snap := map[string]string{"MY_KEY": "sk-ok"}
+	got, err := ResolveEnvRefs(p, snap)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got["ANTHROPIC_API_KEY"] != "sk-ok" {
+		t.Errorf("ref not resolved: %q", got["ANTHROPIC_API_KEY"])
+	}
+	if got["ANTHROPIC_BASE_URL"] != "https://x" {
+		t.Errorf("literal changed: %q", got["ANTHROPIC_BASE_URL"])
+	}
+}
+
+func TestResolveEnvRefs_Missing(t *testing.T) {
+	p := config.Provider{Env: map[string]string{"K": "env:MISSING"}}
+	_, err := ResolveEnvRefs(p, map[string]string{})
+	if err == nil {
+		t.Fatal("expected error")
+	}
+	var ref *EnvRefError
+	if !errors.As(err, &ref) {
+		t.Fatalf("wrong error type: %T %v", err, err)
+	}
+	if ref.Key != "K" || ref.Var != "MISSING" {
+		t.Errorf("unexpected: %#v", ref)
+	}
+	if !strings.Contains(err.Error(), "MISSING") {
+		t.Errorf("message should name missing var: %q", err.Error())
+	}
+}
+
+func TestResolveEnvRefs_NoChain(t *testing.T) {
+	// `env:A` → snap has A="env:B", B="plain". We should get "env:B" literally.
+	p := config.Provider{Env: map[string]string{"K": "env:A"}}
+	snap := map[string]string{"A": "env:B", "B": "plain"}
+	got, err := ResolveEnvRefs(p, snap)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got["K"] != "env:B" {
+		t.Errorf("chain should NOT be followed; got %q", got["K"])
+	}
+}
+
+func TestBuildChildEnv_CleansUnion(t *testing.T) {
+	parent := []string{"HOME=/root", "ANTHROPIC_API_KEY=old", "PATH=/usr/bin"}
+	union := []string{"ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"}
+	resolved := map[string]string{} // selected provider has NONE of those
+
+	got := BuildChildEnv(parent, union, resolved)
+	for _, kv := range got {
+		if strings.HasPrefix(kv, "ANTHROPIC_API_KEY=") {
+			t.Errorf("old API key leaked: %q", kv)
+		}
+	}
+	// HOME/PATH should survive.
+	want := map[string]bool{"HOME=/root": true, "PATH=/usr/bin": true}
+	for _, kv := range got {
+		delete(want, kv)
+	}
+	if len(want) != 0 {
+		t.Errorf("unrelated vars dropped: %v", want)
+	}
+}
+
+func TestBuildChildEnv_InjectOverrides(t *testing.T) {
+	parent := []string{"HOME=/root", "ANTHROPIC_MODEL=x"}
+	union := []string{} // empty — model not in anyone's provider env
+	resolved := map[string]string{"ANTHROPIC_MODEL": "y"}
+	got := BuildChildEnv(parent, union, resolved)
+
+	// HOME present once, model = y exactly once
+	var home, model int
+	for _, kv := range got {
+		if kv == "HOME=/root" {
+			home++
+		}
+		if strings.HasPrefix(kv, "ANTHROPIC_MODEL=") {
+			if kv != "ANTHROPIC_MODEL=y" {
+				t.Errorf("wrong model value: %q", kv)
+			}
+			model++
+		}
+	}
+	if home != 1 {
+		t.Errorf("HOME count = %d", home)
+	}
+	if model != 1 {
+		t.Errorf("model count = %d", model)
+	}
+}
+
+func TestBuildChildEnv_DeterministicOrder(t *testing.T) {
+	parent := []string{}
+	union := []string{}
+	resolved := map[string]string{"B": "2", "A": "1", "C": "3"}
+
+	got := BuildChildEnv(parent, union, resolved)
+	// The injected tail should be sorted.
+	sorted := make([]string, len(got))
+	copy(sorted, got)
+	sort.Strings(sorted)
+	if !reflect.DeepEqual(got, sorted) {
+		t.Errorf("injected env not sorted: %v", got)
+	}
+}
+
+func TestSnapshotEnv(t *testing.T) {
+	got := SnapshotEnv([]string{"A=1", "B=2=3", "MALFORMED", "A=override"})
+	if got["A"] != "override" {
+		t.Errorf("last-wins: %q", got["A"])
+	}
+	if got["B"] != "2=3" {
+		t.Errorf("first = only: %q", got["B"])
+	}
+	if _, ok := got["MALFORMED"]; ok {
+		t.Error("malformed entry should be dropped")
+	}
+}

+ 115 - 0
internal/runner/runner.go

@@ -0,0 +1,115 @@
+// Package runner starts the claude subprocess with a pre-built environment,
+// transparently forwards stdio and signals, and returns claude's exit code as
+// cc-switch's own.
+//
+// The contract (spec: provider-launch):
+//   - stdin/stdout/stderr are inherited verbatim.
+//   - SIGINT/SIGTERM/SIGHUP are forwarded to the child; cc-switch waits for
+//     the child to finish and then returns.
+//   - Exit code passthrough: normal exit -> child's code; signal termination
+//     -> 128 + signum (POSIX convention).
+//   - We never mutate os.Environ; the supplied childEnv is the whole
+//     environment the child sees.
+package runner
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/config"
+)
+
+// ResolveClaudePath returns the absolute path of the claude executable to
+// launch. Precedence: cfg.ClaudePath (if non-empty) > exec.LookPath("claude").
+// If neither succeeds, an error is returned that suggests the `config set
+// claude-path` escape hatch.
+func ResolveClaudePath(cfg config.Config) (string, error) {
+	if cfg.ClaudePath != "" {
+		expanded, err := config.ExpandUser(cfg.ClaudePath)
+		if err != nil {
+			return "", fmt.Errorf("expand claude_path %q: %w", cfg.ClaudePath, err)
+		}
+		abs, err := filepath.Abs(expanded)
+		if err != nil {
+			return "", fmt.Errorf("resolve claude_path %q: %w", cfg.ClaudePath, err)
+		}
+		info, err := os.Stat(abs)
+		if err != nil {
+			return "", fmt.Errorf("claude_path %q: %w", abs, err)
+		}
+		if !info.Mode().IsRegular() {
+			return "", fmt.Errorf("claude_path %q is not a regular file", abs)
+		}
+		if info.Mode().Perm()&0o111 == 0 {
+			return "", fmt.Errorf("claude_path %q is not executable", abs)
+		}
+		return abs, nil
+	}
+	p, err := exec.LookPath("claude")
+	if err != nil {
+		return "", fmt.Errorf(
+			"claude not found in PATH and no claude_path configured; try `cc-switch config set claude-path <path>`",
+		)
+	}
+	return p, nil
+}
+
+// ExitError is returned by Run when the child was killed by a signal. Its
+// ExitCode already follows the 128+signum convention.
+type ExitError struct {
+	Signal   os.Signal // nil if exited normally
+	ExitCode int
+}
+
+func (e *ExitError) Error() string {
+	if e.Signal != nil {
+		return fmt.Sprintf("claude terminated by signal %v (exit code %d)", e.Signal, e.ExitCode)
+	}
+	return fmt.Sprintf("claude exited with code %d", e.ExitCode)
+}
+
+// asExitError converts a cmd.Wait error into an ExitCode value consistent with
+// the spec: normal exit -> err.ExitCode(); signal kill -> 128+signum.
+func asExitError(err error) (int, error) {
+	if err == nil {
+		return 0, nil
+	}
+	var exitErr *exec.ExitError
+	if !errors.As(err, &exitErr) {
+		// Something went wrong that isn't the child reporting an exit (e.g.
+		// we couldn't wait on it at all). Surface the raw error and a
+		// generic 1 so callers can still print something useful.
+		return 1, err
+	}
+	if code := exitErr.ExitCode(); code >= 0 {
+		return code, nil
+	}
+	// code == -1 means the child was killed by a signal. Look up which.
+	if sig, ok := extractSignal(exitErr); ok {
+		return 128 + int(sig), nil
+	}
+	return 1, err
+}
+
+// Run starts claudePath with the given env and args and returns its exit code.
+// It installs the signal-forwarding handler as configured per platform
+// (see runner_unix.go / runner_other.go).
+func Run(claudePath string, childEnv []string, args []string) (int, error) {
+	cmd := exec.Command(claudePath, args...)
+	cmd.Env = childEnv
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	if err := cmd.Start(); err != nil {
+		return 1, fmt.Errorf("start claude: %w", err)
+	}
+
+	stopForward := startSignalForwarding(cmd.Process)
+	defer stopForward()
+
+	return asExitError(cmd.Wait())
+}

+ 22 - 0
internal/runner/runner_other.go

@@ -0,0 +1,22 @@
+//go:build !unix
+
+package runner
+
+import (
+	"os"
+	"os/exec"
+	"syscall"
+)
+
+// Non-Unix platforms: cc-switch v1 only targets macOS and Linux. We compile
+// on other OSes so `go build` works as a smoke test, but signal forwarding
+// and signal decoding are no-ops. Running the binary on, e.g., Windows is
+// unsupported; users should use WSL.
+
+func startSignalForwarding(_ *os.Process) func() {
+	return func() {}
+}
+
+func extractSignal(_ *exec.ExitError) (syscall.Signal, bool) {
+	return 0, false
+}

+ 171 - 0
internal/runner/runner_test.go

@@ -0,0 +1,171 @@
+package runner
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"syscall"
+	"testing"
+	"time"
+
+	"github.com/kotoyuuko/cc-switch-cli/internal/config"
+)
+
+// makeExec writes an executable script at dir/name and returns its path.
+func makeExec(t *testing.T, dir, name, body string) string {
+	t.Helper()
+	p := filepath.Join(dir, name)
+	if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	return p
+}
+
+func TestResolveClaudePath_FromConfig(t *testing.T) {
+	dir := t.TempDir()
+	p := makeExec(t, dir, "claude", "#!/bin/sh\nexit 0\n")
+	got, err := ResolveClaudePath(config.Config{ClaudePath: p})
+	if err != nil {
+		t.Fatalf("unexpected: %v", err)
+	}
+	if got != p {
+		t.Errorf("got %q want %q", got, p)
+	}
+}
+
+func TestResolveClaudePath_FromPATH(t *testing.T) {
+	dir := t.TempDir()
+	_ = makeExec(t, dir, "claude", "#!/bin/sh\nexit 0\n")
+	t.Setenv("PATH", dir)
+	got, err := ResolveClaudePath(config.Config{})
+	if err != nil {
+		t.Fatalf("unexpected: %v", err)
+	}
+	// LookPath may return a symlink-resolved form; just check it matches.
+	if !strings.HasSuffix(got, "/claude") {
+		t.Errorf("unexpected path: %q", got)
+	}
+}
+
+func TestResolveClaudePath_NotFound(t *testing.T) {
+	t.Setenv("PATH", t.TempDir()) // empty dir on PATH
+	_, err := ResolveClaudePath(config.Config{})
+	if err == nil {
+		t.Fatal("expected error")
+	}
+	if !strings.Contains(err.Error(), "config set claude-path") {
+		t.Errorf("missing hint: %v", err)
+	}
+}
+
+func TestResolveClaudePath_NonExecutable(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "claude")
+	if err := os.WriteFile(p, []byte(""), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	_, err := ResolveClaudePath(config.Config{ClaudePath: p})
+	if err == nil {
+		t.Fatal("expected non-executable error")
+	}
+}
+
+func TestRun_EnvInjected(t *testing.T) {
+	dir := t.TempDir()
+	out := filepath.Join(dir, "out")
+	// Capture FOO's value to a file and exit 0.
+	script := makeExec(t, dir, "test", "#!/bin/sh\nprintf '%s' \"$FOO\" > "+out+"\nexit 0\n")
+
+	code, err := Run(script, []string{"FOO=bar", "PATH=/usr/bin"}, nil)
+	if err != nil {
+		t.Fatalf("Run: %v", err)
+	}
+	if code != 0 {
+		t.Errorf("exit code = %d", code)
+	}
+	b, err := os.ReadFile(out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(b) != "bar" {
+		t.Errorf("FOO not injected: %q", string(b))
+	}
+}
+
+func TestRun_ExitCodePassthrough(t *testing.T) {
+	dir := t.TempDir()
+	script := makeExec(t, dir, "test", "#!/bin/sh\nexit 42\n")
+	code, err := Run(script, nil, nil)
+	if err != nil {
+		t.Fatalf("Run: %v", err)
+	}
+	if code != 42 {
+		t.Errorf("exit code = %d, want 42", code)
+	}
+}
+
+func TestRun_SignalForwarding(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("signal forwarding is Unix-only")
+	}
+
+	dir := t.TempDir()
+	// Child traps INT, exits with 130 (128+2).
+	script := makeExec(t, dir, "trapper", "#!/bin/sh\ntrap 'exit 130' INT\nsleep 5\n")
+
+	// Use a goroutine to launch Run, then send SIGINT to our own pid (Run
+	// installs the handler which forwards it to the child).
+	type result struct {
+		code int
+		err  error
+	}
+	done := make(chan result, 1)
+	go func() {
+		c, e := Run(script, nil, nil)
+		done <- result{c, e}
+	}()
+
+	// Give the child a moment to install its trap.
+	time.Sleep(200 * time.Millisecond)
+	if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil {
+		t.Fatal(err)
+	}
+
+	select {
+	case r := <-done:
+		if r.err != nil {
+			t.Fatalf("Run: %v", r.err)
+		}
+		if r.code != 130 {
+			t.Errorf("exit code = %d, want 130", r.code)
+		}
+	case <-time.After(3 * time.Second):
+		t.Fatal("timed out waiting for child to exit")
+	}
+}
+
+func TestRun_SignalKill_128Plus(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("Unix-only")
+	}
+	// Child that ignores nothing and just sleeps; we kill it with SIGKILL
+	// directly to avoid the signal-forwarding path (which would route via the
+	// current process first).
+	dir := t.TempDir()
+	script := makeExec(t, dir, "sleeper", "#!/bin/sh\nsleep 3\n")
+
+	cmd := exec.Command(script)
+	if err := cmd.Start(); err != nil {
+		t.Fatal(err)
+	}
+	go func() {
+		time.Sleep(100 * time.Millisecond)
+		_ = cmd.Process.Signal(syscall.SIGKILL)
+	}()
+	code, _ := asExitError(cmd.Wait())
+	if code != 128+int(syscall.SIGKILL) {
+		t.Errorf("exit code = %d, want %d", code, 128+int(syscall.SIGKILL))
+	}
+}

+ 51 - 0
internal/runner/runner_unix.go

@@ -0,0 +1,51 @@
+//go:build unix
+
+package runner
+
+import (
+	"os"
+	"os/exec"
+	"os/signal"
+	"syscall"
+)
+
+// startSignalForwarding installs handlers for SIGINT, SIGTERM, and SIGHUP and
+// forwards them to proc. The returned stop function removes the handlers and
+// drains the channel; callers should defer-call it.
+//
+// We deliberately do NOT exit on signal receipt — the child gets the signal
+// and owns its shutdown lifecycle; we just relay and keep waiting for it.
+func startSignalForwarding(proc *os.Process) func() {
+	ch := make(chan os.Signal, 4)
+	signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
+
+	done := make(chan struct{})
+	go func() {
+		for {
+			select {
+			case sig := <-ch:
+				_ = proc.Signal(sig)
+			case <-done:
+				return
+			}
+		}
+	}()
+
+	return func() {
+		signal.Stop(ch)
+		close(done)
+	}
+}
+
+// extractSignal pulls the signal from an *exec.ExitError on Unix.
+// If the error wasn't a signal termination, ok is false.
+func extractSignal(exitErr *exec.ExitError) (syscall.Signal, bool) {
+	ws, ok := exitErr.Sys().(syscall.WaitStatus)
+	if !ok {
+		return 0, false
+	}
+	if !ws.Signaled() {
+		return 0, false
+	}
+	return ws.Signal(), true
+}

+ 98 - 0
internal/templates/templates.go

@@ -0,0 +1,98 @@
+// Package templates provides the built-in provider env-key skeletons shipped
+// inside the cc-switch binary. The data is embedded at compile time from
+// templates.yaml; no network or external file I/O is ever performed.
+//
+// Templates intentionally do NOT include secrets — only key names, hints, and
+// occasional defaults (e.g. a provider's canonical base URL).
+package templates
+
+import (
+	_ "embed"
+	"fmt"
+	"sort"
+
+	"gopkg.in/yaml.v3"
+)
+
+// Template describes one provider env skeleton.
+type Template struct {
+	Name        string      `yaml:"name"`
+	Description string      `yaml:"description"`
+	Env         []EnvKey    `yaml:"env"`
+}
+
+// EnvKey is one env entry inside a template.
+type EnvKey struct {
+	Name    string `yaml:"name"`
+	Hint    string `yaml:"hint,omitempty"`
+	Default string `yaml:"default,omitempty"`
+}
+
+//go:embed templates.yaml
+var embeddedYAML []byte
+
+type fileShape struct {
+	Templates []Template `yaml:"templates"`
+}
+
+// Parse parses the given YAML bytes into a slice of Templates. Exposed for
+// testing.
+func Parse(data []byte) ([]Template, error) {
+	var f fileShape
+	if err := yaml.Unmarshal(data, &f); err != nil {
+		return nil, fmt.Errorf("parse templates: %w", err)
+	}
+	for i, t := range f.Templates {
+		if t.Name == "" {
+			return nil, fmt.Errorf("template[%d]: empty name", i)
+		}
+		if len(t.Env) == 0 {
+			return nil, fmt.Errorf("template %q: env must have at least one key", t.Name)
+		}
+		for j, e := range t.Env {
+			if e.Name == "" {
+				return nil, fmt.Errorf("template %q env[%d]: empty key name", t.Name, j)
+			}
+		}
+	}
+	return f.Templates, nil
+}
+
+var cache []Template
+
+// All returns every embedded template. The result is cached; callers MUST NOT
+// mutate the returned slice (treat as read-only).
+func All() []Template {
+	if cache != nil {
+		return cache
+	}
+	ts, err := Parse(embeddedYAML)
+	if err != nil {
+		// Embedded data is authored in-repo; a parse failure here is a
+		// programmer bug caught by tests, not a runtime condition.
+		panic(fmt.Sprintf("embedded templates.yaml is invalid: %v", err))
+	}
+	cache = ts
+	return cache
+}
+
+// Get returns the template with the given name.
+func Get(name string) (Template, bool) {
+	for _, t := range All() {
+		if t.Name == name {
+			return t, true
+		}
+	}
+	return Template{}, false
+}
+
+// Names returns a sorted list of available template names.
+func Names() []string {
+	all := All()
+	names := make([]string, len(all))
+	for i, t := range all {
+		names[i] = t.Name
+	}
+	sort.Strings(names)
+	return names
+}

+ 61 - 0
internal/templates/templates.yaml

@@ -0,0 +1,61 @@
+templates:
+  - name: anthropic-official
+    description: Anthropic's official API (claude.ai / console.anthropic.com)
+    env:
+      - name: ANTHROPIC_API_KEY
+        hint: "Anthropic API key, starts with sk-ant-"
+      - name: ANTHROPIC_BASE_URL
+        hint: "Leave blank to use Anthropic's default endpoint"
+        default: https://api.anthropic.com
+
+  - name: openrouter
+    description: OpenRouter unified API with Anthropic-compatible endpoint
+    env:
+      - name: ANTHROPIC_BASE_URL
+        hint: "OpenRouter's Anthropic-compatible endpoint"
+        default: https://openrouter.ai/api/v1
+      - name: ANTHROPIC_AUTH_TOKEN
+        hint: "OpenRouter API key, starts with sk-or-"
+      - name: ANTHROPIC_MODEL
+        hint: "Model slug, e.g. anthropic/claude-opus-4"
+
+  - name: deepseek
+    description: DeepSeek's Anthropic-compatible endpoint
+    env:
+      - name: ANTHROPIC_BASE_URL
+        default: https://api.deepseek.com/anthropic
+      - name: ANTHROPIC_AUTH_TOKEN
+        hint: "DeepSeek API key, starts with sk-"
+      - name: ANTHROPIC_MODEL
+        hint: "e.g. deepseek-chat"
+        default: deepseek-chat
+
+  - name: moonshot
+    description: Moonshot / Kimi K2 Anthropic-compatible endpoint
+    env:
+      - name: ANTHROPIC_BASE_URL
+        default: https://api.moonshot.cn/anthropic
+      - name: ANTHROPIC_AUTH_TOKEN
+        hint: "Moonshot API key"
+      - name: ANTHROPIC_MODEL
+        hint: "e.g. kimi-k2-0905-preview"
+
+  - name: zhipu
+    description: Zhipu GLM's Anthropic-compatible endpoint
+    env:
+      - name: ANTHROPIC_BASE_URL
+        default: https://open.bigmodel.cn/api/anthropic
+      - name: ANTHROPIC_AUTH_TOKEN
+        hint: "Zhipu / bigmodel.cn API key"
+      - name: ANTHROPIC_MODEL
+        hint: "e.g. glm-4.6"
+
+  - name: custom-base
+    description: Generic Anthropic-compatible endpoint (any OpenAI-ish proxy that exposes /v1/messages)
+    env:
+      - name: ANTHROPIC_BASE_URL
+        hint: "Proxy's base URL, including /v1 if applicable"
+      - name: ANTHROPIC_AUTH_TOKEN
+        hint: "API key / bearer token"
+      - name: ANTHROPIC_MODEL
+        hint: "Model identifier expected by the proxy"

+ 90 - 0
internal/templates/templates_test.go

@@ -0,0 +1,90 @@
+package templates
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestAll_SeedComplete(t *testing.T) {
+	want := []string{
+		"anthropic-official",
+		"openrouter",
+		"deepseek",
+		"moonshot",
+		"zhipu",
+		"custom-base",
+	}
+	got := All()
+	if len(got) != len(want) {
+		t.Fatalf("template count: got %d, want %d", len(got), len(want))
+	}
+	have := map[string]bool{}
+	for _, tpl := range got {
+		if tpl.Description == "" {
+			t.Errorf("%q: missing description", tpl.Name)
+		}
+		if len(tpl.Env) == 0 {
+			t.Errorf("%q: empty env", tpl.Name)
+		}
+		for _, e := range tpl.Env {
+			if e.Name == "" {
+				t.Errorf("%q: empty env key name", tpl.Name)
+			}
+		}
+		have[tpl.Name] = true
+	}
+	for _, n := range want {
+		if !have[n] {
+			t.Errorf("missing seed template %q", n)
+		}
+	}
+}
+
+func TestGet(t *testing.T) {
+	tpl, ok := Get("openrouter")
+	if !ok {
+		t.Fatal("openrouter missing")
+	}
+	if tpl.Name != "openrouter" {
+		t.Errorf("name: %q", tpl.Name)
+	}
+	if _, ok := Get("nonexistent"); ok {
+		t.Error("nonexistent template should miss")
+	}
+}
+
+func TestNamesSorted(t *testing.T) {
+	got := Names()
+	want := []string{
+		"anthropic-official",
+		"custom-base",
+		"deepseek",
+		"moonshot",
+		"openrouter",
+		"zhipu",
+	}
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("sorted names: got %v want %v", got, want)
+	}
+}
+
+func TestParseInvalid(t *testing.T) {
+	cases := []string{
+		`templates:
+  - name: ""
+    env:
+      - name: K`,
+		`templates:
+  - name: ok
+    env: []`,
+		`templates:
+  - name: ok
+    env:
+      - name: ""`,
+	}
+	for i, c := range cases {
+		if _, err := Parse([]byte(c)); err == nil {
+			t.Errorf("case %d: expected error, got nil", i)
+		}
+	}
+}

+ 2 - 0
openspec/changes/archive/2026-04-27-init-cc-switch-cli/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-27

+ 109 - 0
openspec/changes/archive/2026-04-27-init-cc-switch-cli/design.md

@@ -0,0 +1,109 @@
+## Context
+
+用户在多家 Claude Code 兼容的 coding plan 间切换,本质是"换一组环境变量再启动 `claude`"。当前痛点:
+- 不同 provider 用到的 env 变量集合会有差异(例如有的用 `ANTHROPIC_AUTH_TOKEN`,有的用 `ANTHROPIC_API_KEY` + `ANTHROPIC_BASE_URL`,有的还设 `ANTHROPIC_MODEL` / `ANTHROPIC_SMALL_FAST_MODEL`),漏清理会造成"以为切了但没切"的串扰;
+- 在 shell 里写一堆 alias/函数维护成本高,也不利于多机同步。
+
+该工具目标是做成一个薄封装:配置集中、切换确定性强、启动 `claude` 后行为与直接在 shell 里运行一致(stdio、信号、退出码都透传)。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 单个 Go 二进制,零外部运行时依赖即可使用。
+- 配置文件人类可读可手改,支持多 provider,每 provider 任意条 env 变量。
+- 切换流程确定性:清理 → 注入 → 启动;不污染用户当前 shell。
+- 子进程退出码、stdio、信号(SIGINT/SIGTERM/SIGHUP)与裸跑 `claude` 等价。
+- 非交互 (`cc-switch use <name>`) 与交互 (`cc-switch` 裸跑或 `cc-switch use` 无参) 两种入口。
+
+**Non-Goals:**
+- 不做 API Key 加密存储(v1 依赖 0600 文件权限;后续可评估接入 OS keychain)。
+- 不管理 shell rc 文件、不写 profile、不 export 到父 shell。
+- 不内置 provider 模板/预设(v1 要求用户自填 env 键值)。
+- 不做多租户/团队共享配置同步(留给用户自己用 dotfiles/Git 同步)。
+- 不代理或改写 `claude` 行为,只负责"换环境再启动"。
+
+## Decisions
+
+### Decision: 配置格式与位置
+采用 YAML,路径遵循 XDG:默认 `$XDG_CONFIG_HOME/cc-switch/config.yaml`(未设置时回落到 `~/.config/cc-switch/config.yaml`);`CC_SWITCH_CONFIG` 环境变量可覆盖。
+- **Why**: YAML 对多行、嵌套 map 友好;env 变量很适合 `key: value` 展示。用户可能同时维护多个 provider,手改体验优于 TOML/JSON。
+- **Alternatives considered**: TOML(层次表达稍弱);JSON(无注释);split file per provider(增加 IO 复杂度,收益不明显)。
+
+### Decision: 环境变量"清理"的实现方式
+**构造子进程的 `Env` 切片**,而非修改 `cc-switch` 自身的 `os.Environ`:
+1. 读取 `os.Environ()` 作为基础;
+2. 从中剔除 key ∈ `union(all providers' env keys)`;
+3. 追加选中 provider 的 key=value 对;
+4. 赋给 `exec.Cmd.Env`,启动 `claude`。
+这样"清理 → 注入"只作用于子进程环境,`cc-switch` 自身进程和父 shell 完全不受影响;"退出后清理"由进程隔离天然保证(子进程环境随其生命周期结束)。对外我们保留"退出后再清理一次"的语义承诺,在 `cc-switch` 退出前打印一条 trace(可选 `--verbose`)确认,但不做实际 mutation。
+- **Why**: 避免对 `os.Environ` 做有状态 mutation 带来的测试复杂度和泄漏风险;读写集中在一处易于单测。
+- **Alternatives considered**: 用 `os.Setenv` / `os.Unsetenv` 再 fork 子进程——需要在 defer 中回滚,panic 路径容易漏;对多 goroutine 场景不友好。
+
+### Decision: `claude` 可执行文件解析
+优先使用配置里的 `claude_path`(绝对或相对路径,支持 `~`);未配置时 `exec.LookPath("claude")`;都找不到则以明确错误退出(退出码保留给用户,不伪装成 `claude` 自身错误)。
+- **Why**: 不少用户把 `claude` 装在 `~/.claude/local/claude` 这类非 PATH 路径,需要显式覆盖;同时多数用户 PATH 里就有,不应强制配置。
+
+### Decision: 运行方式——subprocess 而非 exec-replace
+使用 `os/exec` 启动 `claude`,`cc-switch` 作为父进程守护:
+- 透传 stdin/stdout/stderr;
+- 注册信号处理器,把 SIGINT/SIGTERM/SIGHUP 转发给子进程;
+- `cmd.Wait()` 完成后提取 `ExitCode()` 作为 `cc-switch` 自身退出码。
+不使用 `syscall.Exec`(替换当前进程映像)。
+- **Why**: 保留子进程退出后做记账/清理/提示的机会;跨平台兼容(Windows 无 `exec` 语义);便于未来加 `--dry-run`、`--print-env` 等 hook。
+- **Trade-off**: 多一层进程,但 `claude` 本身就是长交互进程,进程链深一层对用户无感。
+
+### Decision: CLI 框架
+采用 `github.com/spf13/cobra` 做子命令装配,`gopkg.in/yaml.v3` 做 YAML 序列化。
+- **Why**: cobra 是 Go 生态事实标准,子命令/help/flag 等基础设施完善,减少样板;yaml.v3 能保留 key 顺序,用户手改 diff 友好。
+- **Alternatives considered**: 标准库 `flag`(子命令层级要自己搭)、`urfave/cli`(也可行但生态小一号)。
+
+### Decision: Provider 选择的交互 UI
+优先使用轻量方案:在 tty 下用简单数字菜单(stdin 读一行),不引入完整 TUI 库。若未来体验不足再评估 `survey`/`bubbletea`。
+- **Why**: 二进制体积、依赖面、CI 环境兼容性(非 tty 时自动退化为报错提示用户给出 `<name>`)。
+
+### Decision: 配置文件权限
+创建配置文件与其父目录时使用 `0700`(目录)、`0600`(文件)。读写配置前若发现权限过宽(group/other 可读),打印一条 warning 但不中止——降低误操作阻塞成本。
+- **Why**: 配置里存 API key,POSIX 权限是第一道防线;warning 留一条可见线索。
+
+### Decision: 原子写入
+保存配置使用 write-to-temp-then-rename:先写 `config.yaml.tmp`,fsync,再 `os.Rename` 为目标文件。
+- **Why**: 防止编辑/并发导致半写坏文件丢掉所有 provider 配置。
+
+### Decision: Env 值的间接引用语法
+支持值形如 `env:VAR_NAME` 从启动时的父进程环境中读取实际值(正则约束 `^env:[A-Za-z_][A-Za-z0-9_]*$`)。解析时机:**启动阶段**,在"并集清理"之前先对 `os.Environ()` 取快照,针对选中 provider 的 env 值逐一解析引用;引用的 VAR_NAME 未在快照中出现则非零退出并打印哪一个 key 引用的哪个 VAR 缺失。引用的变量值本身仍被视为最终 env value,写入子进程环境前不会再做任何 shell 扩展。
+- **Why**: 让用户可以把 API key 放在系统 keychain / 1Password CLI / direnv 暴露的 env 中,配置文件里只存"指针",降低明文泄露面。
+- **为什么先取快照再清理**: 被引用的 VAR 本身可能恰好落在并集里(例如 provider A 用 key `ANTHROPIC_API_KEY: env:ANTHROPIC_API_KEY`),如果清理后再解析就读不到了。先快照绕过这层次序耦合。
+- **Alternatives considered**: `${VAR}` 风格(与"value 字面传递"的承诺冲突、视觉上像 shell 扩展易误解);YAML 显式结构 `{from_env: VAR}`(schema 复杂、增删改 UX 差);`!env` YAML 标签(不易被通用 YAML 工具处理)。
+- **局限**: 值字面量恰好以 `env:` 开头且并非引用的情况极少,v1 不支持转义;文档明示"若确实需要字面 `env:xxx`,请改用不同写法或等 v2 转义方案"。
+
+### Decision: Provider 模板
+在二进制中内嵌(`go:embed` 一份 `templates.yaml`)一组常见 provider 的 env key 骨架,作为 `cc-switch add --from-template <tpl>` 的起点。v1 的种子模板:`anthropic-official`、`openrouter`、`deepseek`、`moonshot`(Kimi)、`zhipu`(GLM)、`custom-base`(仅 BASE_URL + AUTH_TOKEN,给未列出的 OpenAI-兼容服务用)。每个模板包含:`description`、`env` key 列表(仅键名 + 每项可选的 `hint` 提示文案和可选的 `default` 示例 value,例如 `ANTHROPIC_BASE_URL` 默认填官方 URL)。
+- **交互流程**:`add <name> --from-template openrouter` 进入向导,逐个 key 提示输入(显示 hint;允许直接输入 value 或 `env:VAR` 引用;有 default 的回车接受)。提供 `--non-interactive` 时只带出 key 骨架写入(value 为空占位),要求用户后续 `edit` 补齐。
+- **扩展途径**:v1 不支持用户自定义模板目录(non-goal);v2 可加 `$XDG_CONFIG_HOME/cc-switch/templates/`。README 会附贡献新模板到 upstream 的 PR 指引。
+- **Why**: 让首次上手不用翻各家文档即可拼出 env;限制在"仅键名骨架"避免工具把"该用哪个 model id"这种快速过时的信息硬编码。
+- **Alternatives considered**: 纯文档 snippet(用户仍需手抄);远程拉取模板(新增网络依赖、安全面、离线不可用)。
+
+## Risks / Trade-offs
+
+- **[Risk] 明文存储 API key** → 以 0600 权限 + 文档中明示;后续版本评估 keychain 集成;提供 `env:VAR_NAME` 语法支持从外部 env 引用(v1 可先设计字段,实现留到后续)——v1 不实现该语法,但在 schema 中预留扩展位。
+- **[Risk] 用户在 shell 里已经 export 了某些 ANTHROPIC_* 变量,和 provider 冲突** → 因为我们对 `union(all providers' keys)` 做清理,即便这些变量来自父 shell 也会被覆盖;但如果用户 export 的某个 key 未出现在**任何** provider 配置里,则会被原样继承(这是预期行为)。文档中明确说明"union"范围,并建议用户把所有相关 key 都登记到至少一个 provider 里以便统一清理。
+- **[Risk] 信号转发在 Windows 与 Unix 语义差异** → v1 明确只支持 macOS/Linux(goreleaser/发布时不打 Windows 包);Windows 用户可走 WSL。
+- **[Risk] 交互菜单在非 tty(CI、管道)下卡死** → 检测 `stdin.IsTerminal()`,非 tty 时要求必须显式给 `<name>`,否则非零退出并打印提示。
+- **[Trade-off] 不做 exec-replace** → 多一层父进程,但换来跨平台、可测试性、未来扩展空间,值得。
+- **[Trade-off] 无内置 provider 模板** → 用户第一次上手要自己查各家文档;作为补偿,`cc-switch add` 提供交互式向导,把常见 key 列出来让用户勾选填值。
+
+## Migration Plan
+
+这是全新仓库、无历史用户:
+1. 先落地 module 骨架与基础命令(`add`/`list`/`use`);
+2. 实现 env 计算与子进程启动;
+3. 补齐 `edit`/`remove`/`config`;
+4. 加交互选择;
+5. 发布 v0.1.0(Homebrew tap + `go install` 双通道)。
+无回滚策略需求(新项目)。
+
+## Open Questions
+
+- 是否在 `add` 时提供常见 provider 的 env key preset(Anthropic 官方、DeepSeek、Kimi、Moonshot、OpenRouter 等兼容层)作为可选引导?—— 倾向 v1 不做,由 README 给出 snippet;v2 观察用户反馈再加。
+- 是否支持"一次性覆盖":`cc-switch use foo --set ANTHROPIC_MODEL=claude-opus-4-7`?—— v1 不做,保持配置单一事实来源;v2 可加 `--set/--unset` 作为临时 override。
+- 是否需要 `cc-switch doctor` 做健康检查(claude 路径存在、配置权限正确、tty 检测)?—— v1 暂不做,但在 `list -v` 中附带展示 claude 路径解析结果。

+ 36 - 0
openspec/changes/archive/2026-04-27-init-cc-switch-cli/proposal.md

@@ -0,0 +1,36 @@
+## Why
+
+用户订阅了多家 Claude Code 兼容的 coding plan 供应商(例如官方 Anthropic、各类第三方中转/代理服务),每家都通过不同的环境变量(如 `ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_MODEL` 等)接入 Claude Code。在不同供应商之间切换时,用户需要手动 unset 旧变量、export 新变量、再启动 `claude`,容易漏设或残留,体验繁琐且易错。需要一个 CLI 工具让"选一个 provider → 跑起来 claude"变成一条命令。
+
+## What Changes
+
+- 新增一个 Go 语言 CLI 工具 `cc-switch`(二进制名),负责 provider 切换与 Claude Code 启动。
+- 支持在本地配置文件中登记多个 provider,每个 provider 包含:名称、若干环境变量键值对、可选描述。
+- 支持配置全局的 `claude` 可执行文件路径(默认从 `PATH` 中查找)。
+- 提供 provider 的增删改查命令(`add` / `list` / `edit` / `remove` / `use`)。
+- 支持 env 值使用 `env:VAR_NAME` 语法引用启动时父进程环境中的变量(避免把 API key 明文写进配置),启动时才解析;引用的变量不存在则拒绝启动。
+- 内置常见 provider 的模板(Anthropic 官方、OpenRouter、DeepSeek、Kimi/Moonshot、智谱 GLM 等),通过 `cc-switch add <name> --from-template <tpl>` 带出 env key 骨架;`cc-switch templates list/show` 可查阅。
+- `use <provider>`(或交互式选择)子命令执行核心流程:
+  1. 计算所有 provider 的环境变量键名**并集**,在当前进程环境中 unset 掉这些键;
+  2. 再根据选中的 provider 设置其环境变量;
+  3. 以该环境启动配置的 `claude` 可执行文件(前台运行、继承 stdio、转发信号);
+  4. `claude` 退出后,从当前进程环境中再次 unset 这些键(相当于兜底清理)。
+- 配置文件采用用户级路径(遵循 XDG 规范,默认 `~/.config/cc-switch/config.yaml`,可通过 `CC_SWITCH_CONFIG` 覆盖)。
+- 首次运行自动创建空配置骨架并给出引导。
+
+## Capabilities
+
+### New Capabilities
+- `provider-config`: 管理 provider 列表的本地持久化配置(增删改查、默认 provider、claude 路径设置、配置文件定位与初始化)。
+- `provider-launch`: 执行"清理 → 注入 → 启动 claude → 再清理"的运行时流程,包含信号转发与退出码透传。
+- `cli`: 基于子命令的命令行接口,将上述能力暴露给终端用户,包括交互式选择与非交互式调用两种模式。
+
+### Modified Capabilities
+<!-- 无 -->
+
+## Impact
+
+- 新建仓库代码结构:`cmd/cc-switch/`(入口)、`internal/config/`(配置读写)、`internal/provider/`(provider 模型与并集计算)、`internal/runner/`(claude 启动与信号处理)、`internal/cli/`(子命令装配)。
+- 新增 Go module,依赖候选:`github.com/spf13/cobra`(子命令)、`gopkg.in/yaml.v3`(配置序列化);交互选择可选 `github.com/AlecAivazis/survey/v2` 或自实现简易 TUI。
+- 新增配置文件契约:`~/.config/cc-switch/config.yaml`,用户在多机之间可同步。
+- 不影响现有系统(新项目);不修改用户 shell profile,只在 `cc-switch` 子进程的环境中操作变量。

+ 145 - 0
openspec/changes/archive/2026-04-27-init-cc-switch-cli/specs/cli/spec.md

@@ -0,0 +1,145 @@
+## ADDED Requirements
+
+### Requirement: 二进制与子命令入口
+工具 SHALL 以单个二进制 `cc-switch` 分发,采用子命令架构。顶层 SHALL 至少暴露以下子命令:`add`、`list`(别名 `ls`)、`edit`、`remove`(别名 `rm`)、`use`、`config`、`templates`、`help`、`version`。未识别的子命令 SHALL 以非零退出码退出并建议 `cc-switch --help`。
+
+#### Scenario: 列出帮助
+- **WHEN** 用户执行 `cc-switch --help`
+- **THEN** 输出包含上述所有子命令的一行摘要,且以 0 退出
+
+#### Scenario: 未知子命令
+- **WHEN** 用户执行 `cc-switch foobar`
+- **THEN** 工具以非零退出码退出,并在 stderr 提示 `foobar` 未知、可查看 `--help`
+
+#### Scenario: 版本信息
+- **WHEN** 用户执行 `cc-switch version`
+- **THEN** 输出至少包含语义化版本号;构建时可注入 commit 与日期信息
+
+### Requirement: 裸跑入口 = 交互式 use
+执行 `cc-switch`(无参数)SHALL 等价于 `cc-switch use`(无参数),即进入交互式 provider 选择流程。
+
+#### Scenario: tty 裸跑
+- **WHEN** 用户在终端裸跑 `cc-switch`
+- **THEN** 屏幕出现带编号的 provider 列表,等待用户输入编号或名称
+
+### Requirement: 交互式 provider 选择
+`cc-switch use`(无参)在 stdin 为 tty 时 SHALL 展示编号列表让用户选择;若存在 `default_provider`,在列表中 SHALL 标注为默认,空输入(直接回车)SHALL 选中默认 provider;用户可输入编号或 provider 名。输入不匹配时 SHALL 重新提示(最多 3 次),全部失败后以非零退出码退出。
+
+#### Scenario: 回车选默认
+- **WHEN** 存在 `default_provider=foo`,用户执行 `cc-switch use` 并直接回车
+- **THEN** `foo` 被选中、进入启动流程
+
+#### Scenario: 输入编号
+- **WHEN** 列表中编号 2 对应 `bar`,用户输入 `2` 回车
+- **THEN** `bar` 被选中、进入启动流程
+
+#### Scenario: 输入名称
+- **WHEN** 用户输入 `bar` 回车
+- **THEN** `bar` 被选中、进入启动流程
+
+#### Scenario: 连续非法输入
+- **WHEN** 用户连续 3 次输入不存在的编号或名称
+- **THEN** 工具非零退出、不启动 `claude`
+
+### Requirement: 非交互式 provider 选择
+`cc-switch use <name>` SHALL 直接选中指定 provider 并进入启动流程,无需任何 tty 交互。未指定 `<name>` 且 stdin 非 tty 时 SHALL 尝试使用 `default_provider`;若也未设置默认,SHALL 以非零退出码退出并提示需要显式 `<name>`。
+
+#### Scenario: 直接指定
+- **WHEN** 用户执行 `cc-switch use foo` 且 `foo` 已配置
+- **THEN** 工具立即进入清理/注入/启动流程,不做任何交互
+
+#### Scenario: 指定不存在 provider
+- **WHEN** 用户执行 `cc-switch use nonexistent`
+- **THEN** 工具非零退出、stderr 提示 `nonexistent` 不存在、建议 `cc-switch list`
+
+#### Scenario: 管道模式使用默认
+- **WHEN** stdin 非 tty(例如被 `:</dev/null` 重定向),用户执行 `cc-switch use`,且 `default_provider=foo`
+- **THEN** 工具不进入交互,直接使用 `foo` 启动
+
+#### Scenario: 管道模式且无默认
+- **WHEN** stdin 非 tty,未设置 `default_provider`,用户执行 `cc-switch use`
+- **THEN** 工具非零退出,提示需要 `<name>` 或先设置默认 provider
+
+### Requirement: 全局 flag
+工具 SHALL 支持以下全局 flag:`-v / --verbose`(输出 trace 日志到 stderr)、`--config <path>`(等价于 `CC_SWITCH_CONFIG` 但优先级更高)、`-h / --help`。这些 flag SHALL 在任意子命令中可用。
+
+#### Scenario: --config 覆盖 env
+- **WHEN** 已设置 `CC_SWITCH_CONFIG=/a.yaml`,用户执行 `cc-switch --config /b.yaml list`
+- **THEN** 工具读写 `/b.yaml`,忽略 env 中的路径
+
+#### Scenario: verbose 生效
+- **WHEN** 用户执行 `cc-switch -v use foo`
+- **THEN** stderr 中可见解析到的配置路径、最终 claude 路径、最终 env key 列表(value 不打印)等 trace 信息
+
+### Requirement: `config` 子命令
+`cc-switch config` SHALL 提供 `get` / `set` / `show` 子命令:
+- `config show`:打印当前配置文件路径与内容(env value 脱敏为 `***`)
+- `config set claude-path <path>`:设置 `claude_path`
+- `config set default <name>`:设置 `default_provider`
+- `config get <key>`:打印单个配置项值(同样脱敏 env value)
+
+#### Scenario: show
+- **WHEN** 用户执行 `cc-switch config show`
+- **THEN** 第一行打印配置文件绝对路径,之后打印脱敏后的 YAML
+
+#### Scenario: set default
+- **WHEN** 用户执行 `cc-switch config set default foo`
+- **THEN** 若 `foo` 存在,写入配置并打印确认;若不存在,非零退出
+
+### Requirement: Add 向导(可选交互)
+`cc-switch add <name>` SHALL 支持三种用法:
+1. 通过 `--env KEY=VALUE`(可重复)与 `--description "..."` 一次性填入;
+2. 通过 `--from-template <tpl>` 以内置模板为起点,tty 下逐 key 提示输入 value(显示 hint、回车接受 default);可与 `--description` 共存;
+3. 不带任何 `--env` / `--from-template` 时进入交互式自由向导,逐个提示输入 key/value(输入空 key 结束),tty 下可用。
+
+所有方式 MUST 最终落入同一份校验逻辑(至少一个 env key、provider 名合法)。`--from-template` 与 `--env` 可以组合使用——先以模板为骨架,`--env` 再覆盖或补充同名/新 key。value 输入阶段 MUST 允许用户输入 `env:VAR_NAME` 形式的引用(见 provider-config spec 对应 Requirement),工具 MUST NOT 在 `add` 阶段尝试解析该引用、MUST NOT 校验该 VAR 是否存在。
+
+#### Scenario: 一次性命令行
+- **WHEN** 用户执行 `cc-switch add foo --env K1=v1 --env K2=v2 --description "desc"`
+- **THEN** `foo` 被写入,`env` 含两个键,`description=desc`
+
+#### Scenario: 交互式向导
+- **WHEN** 用户在 tty 下执行 `cc-switch add foo`,依次输入 `K1` / `v1` / `K2` / `v2` / 空键
+- **THEN** `foo` 被写入与一次性命令行等价的结构
+
+#### Scenario: 非 tty 且无 --env 无模板
+- **WHEN** stdin 非 tty 用户执行 `cc-switch add foo` 且未提供任何 `--env` 或 `--from-template`
+- **THEN** 工具非零退出,提示在非交互模式下需给出 `--env KEY=VALUE` 或 `--from-template`
+
+#### Scenario: 使用模板(tty)
+- **WHEN** 用户在 tty 下执行 `cc-switch add official --from-template anthropic-official`,依次对每个模板 key 按回车接受 default / 输入新 value
+- **THEN** `official` 被写入,`env` 包含模板定义的所有 key,value 为用户确认后的值
+
+#### Scenario: 模板 + --env 覆盖
+- **WHEN** 用户执行 `cc-switch add x --from-template openrouter --env ANTHROPIC_MODEL=anthropic/claude-opus-4 --non-interactive`
+- **THEN** `x` 被写入,env 结构为"模板骨架 ∪ --env 覆盖",`ANTHROPIC_MODEL` 取自 `--env`;模板中未被 `--env` 提供的 key 保留模板 default 或占位空字符串(value 为空的 key 在落盘前 MUST 报错要求补齐)
+
+#### Scenario: 引用语法直接写入
+- **WHEN** 用户执行 `cc-switch add foo --env ANTHROPIC_API_KEY=env:MY_KEY`
+- **THEN** 配置写入的字面 value 即为 `env:MY_KEY`,不在 add 阶段校验 `MY_KEY` 是否存在
+
+#### Scenario: 未知模板名
+- **WHEN** 用户执行 `cc-switch add foo --from-template nonexistent`
+- **THEN** 工具非零退出,stderr 列出可用模板名并建议 `cc-switch templates list`
+
+### Requirement: `templates` 子命令
+`cc-switch templates` SHALL 暴露两个子命令:`list` 与 `show <tpl>`。模板为内嵌资源,工具 MUST 不访问网络、MUST 不读取用户目录之外的文件。
+
+#### Scenario: 列出模板
+- **WHEN** 用户执行 `cc-switch templates list`
+- **THEN** 输出每个内置模板的名称与一行 description
+
+#### Scenario: 查看模板细节
+- **WHEN** 用户执行 `cc-switch templates show openrouter`
+- **THEN** 输出该模板的 description、env key 列表、每个 key 的 hint 与 default(若有)
+
+#### Scenario: 查看不存在的模板
+- **WHEN** 用户执行 `cc-switch templates show nonexistent`
+- **THEN** 工具非零退出,提示不存在并建议 `cc-switch templates list`
+
+### Requirement: v1 内嵌模板集
+二进制 SHALL 内嵌至少以下模板:`anthropic-official`、`openrouter`、`deepseek`、`moonshot`、`zhipu`、`custom-base`。每个模板 MUST 至少包含一条 env key;模板 key 列表 MUST 只描述键名与 hint/default,MUST NOT 预置任何真实 token 或 API key。
+
+#### Scenario: 种子模板齐全
+- **WHEN** 用户执行 `cc-switch templates list`
+- **THEN** 输出至少包含上述 6 个模板名

+ 113 - 0
openspec/changes/archive/2026-04-27-init-cc-switch-cli/specs/provider-config/spec.md

@@ -0,0 +1,113 @@
+## ADDED Requirements
+
+### Requirement: 配置文件定位与初始化
+工具 SHALL 按如下优先级解析配置文件路径:`$CC_SWITCH_CONFIG` > `$XDG_CONFIG_HOME/cc-switch/config.yaml` > `~/.config/cc-switch/config.yaml`。若目标文件不存在,SHALL 在首次需要写入时创建父目录(权限 `0700`)与空骨架文件(权限 `0600`)。读操作遇文件不存在 MUST 返回"空配置"而非错误。
+
+#### Scenario: 默认路径首次读
+- **WHEN** 用户未设置 `CC_SWITCH_CONFIG`、`XDG_CONFIG_HOME`,也从未运行过 `cc-switch`
+- **THEN** `cc-switch list` 成功执行,输出 "(no providers configured)" 提示,且不在磁盘创建任何文件
+
+#### Scenario: 默认路径首次写
+- **WHEN** 用户执行 `cc-switch add foo ...` 且配置文件从未被创建
+- **THEN** 工具创建 `~/.config/cc-switch/`(权限 `0700`)与 `config.yaml`(权限 `0600`),并写入新增 provider
+
+#### Scenario: XDG 路径覆盖
+- **WHEN** 环境变量 `XDG_CONFIG_HOME=/tmp/xdg` 已设置
+- **THEN** 工具使用 `/tmp/xdg/cc-switch/config.yaml` 作为配置文件路径
+
+#### Scenario: 显式路径覆盖
+- **WHEN** 环境变量 `CC_SWITCH_CONFIG=/custom/path.yaml` 已设置
+- **THEN** 无论 XDG 如何设置,工具均使用 `/custom/path.yaml`
+
+#### Scenario: 宽松权限 warning
+- **WHEN** 配置文件存在且权限为 `0644`
+- **THEN** 工具继续执行命令,并向 stderr 打印一条 warning 提示权限过宽、建议 `chmod 600`
+
+### Requirement: 配置 schema
+配置文件 SHALL 使用 YAML 编码,顶层字段包括:`claude_path`(string,可选)、`default_provider`(string,可选)、`providers`(map,key 为 provider 名)。每个 provider SHALL 包含 `env`(map[string]string,必填且至少一个键)与可选 `description`(string)。Provider 名 SHALL 为非空且仅含字母、数字、`-`、`_`。
+
+#### Scenario: 合法配置解析
+- **WHEN** 配置文件内容合法,包含两个 provider 及 `default_provider: foo`
+- **THEN** 工具解析成功并在后续命令中识别这两个 provider
+
+#### Scenario: 非法 provider 名
+- **WHEN** 配置文件中 provider 名包含空格或非法字符
+- **THEN** 工具返回非零退出码并指出哪一个 provider 名不合法
+
+#### Scenario: 空 env map
+- **WHEN** 某个 provider 的 `env` 字段为空 map
+- **THEN** 工具返回非零退出码并提示该 provider 至少需要一个环境变量
+
+### Requirement: Env 值的间接引用语法
+Provider 的 `env` map 中,value 为字符串类型;若 value 严格匹配正则 `^env:[A-Za-z_][A-Za-z0-9_]*$`,SHALL 被识别为"间接引用",表示"启动时从父进程环境中读取名为 `VAR_NAME` 的变量的值作为实际 value"。所有其他 value MUST 被视为字面字符串,不做任何展开。引用仅在"启动阶段"解析(见 provider-launch spec),在 `list -v` / `config show` / `config get` 等展示命令中,引用 value 按原字符串展示(不脱敏、不展开),让用户一眼识别"这一项是指针"。
+
+#### Scenario: 字面 value 保留
+- **WHEN** provider 的 `env.FOO` 值为 `hello`
+- **THEN** 工具视为字面 `hello`;`list -v` 显示脱敏后的 `he***` 或等价样式
+
+#### Scenario: env 引用被识别
+- **WHEN** provider 的 `env.ANTHROPIC_API_KEY` 值为 `env:MY_ANTHROPIC_KEY`
+- **THEN** 工具视为对父环境变量 `MY_ANTHROPIC_KEY` 的引用;`list -v` 显示 `env:MY_ANTHROPIC_KEY`(按原字符串,不脱敏、不解析)
+
+#### Scenario: 非法引用形式回退为字面
+- **WHEN** provider 的 `env.FOO` 值为 `env:` 或 `env:1NAME`(不符合正则)
+- **THEN** 工具视为普通字面字符串,不报错也不解析
+
+### Requirement: 原子写入
+工具修改配置文件时 SHALL 采用"写临时文件 → fsync → 重命名"模式;MUST 不允许部分写入后崩溃导致的文件损坏。
+
+#### Scenario: 写入中途崩溃
+- **WHEN** 在 `cc-switch add` 写入 `config.yaml` 过程中进程被强制终止
+- **THEN** `config.yaml` 要么保留原内容、要么是完整的新内容,不会出现截断或空文件
+
+### Requirement: Provider 增删改查
+工具 SHALL 支持以下 provider 管理操作:
+- **add**:新增 provider,禁止与已有同名冲突
+- **list**:列出所有 provider,标记 `default`,可选 `-v` 显示 env 键名(value 脱敏)
+- **edit**:修改已有 provider 的 env 或 description
+- **remove**:按名称删除 provider;若删除的是当前 `default_provider`,`default_provider` 字段 SHALL 被一并清空
+- **use-default** 支持(见 provider-launch 与 cli 能力)
+
+所有写操作 SHALL 在修改成功后原子落盘。
+
+#### Scenario: 新增 provider
+- **WHEN** 用户执行 `cc-switch add official --env ANTHROPIC_API_KEY=sk-xxx --env ANTHROPIC_BASE_URL=https://api.anthropic.com`
+- **THEN** `providers.official.env` 写入两个键,`cc-switch list` 能列出 `official`
+
+#### Scenario: 重名 add
+- **WHEN** 已存在名为 `official` 的 provider,用户再次 `cc-switch add official ...`
+- **THEN** 工具返回非零退出码并提示使用 `edit` 或换一个名字
+
+#### Scenario: list 脱敏
+- **WHEN** 用户执行 `cc-switch list -v`
+- **THEN** 输出中对每个 env 值展示为 `***` 或前 4 位 + `***`,不打印完整 value
+
+#### Scenario: 删除默认 provider
+- **WHEN** 当前 `default_provider=foo`,用户执行 `cc-switch remove foo`
+- **THEN** `foo` 被删除,`default_provider` 被置空,`cc-switch list` 不再展示 default 标记
+
+#### Scenario: 删除不存在 provider
+- **WHEN** 用户执行 `cc-switch remove nonexistent`
+- **THEN** 工具返回非零退出码并提示 provider 不存在
+
+### Requirement: 全局 claude 可执行文件路径
+工具 SHALL 支持通过 `cc-switch config set claude-path <path>` 命令设置 `claude_path`;支持 `~` 展开;SHALL 在设置时校验路径存在且可执行,不通过则拒绝写入。未设置时后续启动流程回落到 `exec.LookPath("claude")`。
+
+#### Scenario: 设置有效路径
+- **WHEN** 用户执行 `cc-switch config set claude-path ~/.claude/local/claude` 且该文件存在且可执行
+- **THEN** `claude_path` 被写入展开后的绝对路径
+
+#### Scenario: 设置无效路径
+- **WHEN** 用户执行 `cc-switch config set claude-path /not/exist`
+- **THEN** 工具返回非零退出码且不修改配置文件
+
+### Requirement: 默认 provider
+工具 SHALL 支持通过 `cc-switch config set default <name>` 设置默认 provider,前提是该 provider 存在。`cc-switch use` 在未指定 `<name>` 且处于非 tty 环境时 SHALL 使用默认 provider;tty 下仍进入交互选择(默认值为 `default_provider`)。
+
+#### Scenario: 设置默认
+- **WHEN** 用户已有 `foo` provider,执行 `cc-switch config set default foo`
+- **THEN** `default_provider: foo` 被写入配置
+
+#### Scenario: 设置不存在 provider 为默认
+- **WHEN** 用户执行 `cc-switch config set default bar` 且 `bar` 不存在
+- **THEN** 工具返回非零退出码且不修改配置

+ 116 - 0
openspec/changes/archive/2026-04-27-init-cc-switch-cli/specs/provider-launch/spec.md

@@ -0,0 +1,116 @@
+## ADDED Requirements
+
+### Requirement: 解析 env 引用
+启动流程在"并集清理"之前 SHALL 先对父进程环境取一次快照(`os.Environ()`)。对于选中 provider 的每一项 `env` value,若匹配 `^env:[A-Za-z_][A-Za-z0-9_]*$`,SHALL 从该快照中查找同名变量:
+- 若存在,该 key 的实际 value 替换为快照中的值;
+- 若不存在,工具 MUST 以非零退出码退出,stderr 指出**哪个 key 引用的哪个 VAR** 缺失,且此时 MUST NOT 启动 `claude`、MUST NOT 对 env 做任何 mutation。
+
+解析完成后得到的"物化"env map 作为后续"并集清理 + 注入"的输入。解析到的值 MUST 不做任何 shell 扩展或二次引用解析(不支持链式 `env:A` → `env:B`)。
+
+#### Scenario: 引用成功
+- **WHEN** 父 shell 已 export `MY_KEY=sk-abc`,provider `foo.env.ANTHROPIC_API_KEY=env:MY_KEY`,用户执行 `cc-switch use foo`
+- **THEN** 子进程 `claude` 收到 `ANTHROPIC_API_KEY=sk-abc`
+
+#### Scenario: 引用变量缺失
+- **WHEN** 父 shell 未 export `MY_KEY`,provider `foo.env.ANTHROPIC_API_KEY=env:MY_KEY`
+- **THEN** 工具非零退出;stderr 形如 `ANTHROPIC_API_KEY references env var MY_KEY which is not set`;`claude` 未被启动
+
+#### Scenario: 引用的 VAR 恰好在并集中
+- **WHEN** provider A 的 env key 含 `ANTHROPIC_API_KEY`,provider B 的 `env.ANTHROPIC_API_KEY=env:ANTHROPIC_API_KEY`,父 shell 已 export `ANTHROPIC_API_KEY=sk-real`,用户执行 `cc-switch use B`
+- **THEN** 由于解析发生在清理之前,子进程 `claude` 正确收到 `ANTHROPIC_API_KEY=sk-real`(来自快照)
+
+#### Scenario: 不做链式解析
+- **WHEN** 父 shell 有 `A=env:B` 与 `B=plain`,provider 的 value 为 `env:A`
+- **THEN** 工具解析一次,子进程收到字面 `env:B`(不继续解析 B)
+
+### Requirement: 环境变量并集清理
+启动 `claude` 前,工具 SHALL 计算所有已配置 provider 的 env key 的并集(记为 `K`),并从即将传给子进程的环境中**移除** `K` 中每一个 key。该清理 MUST 在注入选中 provider 的 env 之前完成。工具 MUST NOT 修改 `cc-switch` 自身进程的 `os.Environ`,也 MUST NOT 尝试修改父 shell 的环境。
+
+#### Scenario: 父 shell 存在旧变量
+- **WHEN** 父 shell 已 export `ANTHROPIC_API_KEY=old` 且该 key 出现在某个 provider 的 env 中,用户执行 `cc-switch use foo`(`foo.env` 不含 `ANTHROPIC_API_KEY`)
+- **THEN** 子进程 `claude` 的环境中 `ANTHROPIC_API_KEY` **不存在**(被清理、且 `foo` 未重新设置)
+
+#### Scenario: 与 provider 无关的变量原样继承
+- **WHEN** 父 shell 有 `HOME`、`PATH`、`LANG` 等变量,它们不在任何 provider 的 env key 集合中
+- **THEN** 子进程 `claude` 继承这些变量、值与父进程一致
+
+#### Scenario: 并集跨 provider 生效
+- **WHEN** provider A 的 env 含 `ANTHROPIC_API_KEY`、provider B 的 env 含 `ANTHROPIC_AUTH_TOKEN`,用户 `cc-switch use A`
+- **THEN** 子进程环境中 `ANTHROPIC_AUTH_TOKEN` 被清理(因其属于并集),同时 provider A 的 `ANTHROPIC_API_KEY` 被写入
+
+### Requirement: 选中 provider 的 env 注入
+清理完成后,工具 SHALL 将选中 provider 的 `env` 中每一个 key=value 写入子进程环境。若 key 与清理后的继承环境中某个 key 同名,provider 的 value SHALL 胜出(注入覆盖)。Value MUST 原样传递,不做任何 shell 扩展或变量替换。
+
+#### Scenario: 注入覆盖继承值
+- **WHEN** 父 shell 有 `ANTHROPIC_MODEL=x`,该 key 不在并集中但选中 provider 设置了 `ANTHROPIC_MODEL=y`
+- **THEN** 子进程环境中 `ANTHROPIC_MODEL=y`
+
+#### Scenario: Value 不做 shell 扩展
+- **WHEN** 选中 provider 的 `ANTHROPIC_BASE_URL=$HOME/api`
+- **THEN** 子进程收到的值字面量就是 `$HOME/api`,不会被展开为用户 home 目录
+
+### Requirement: 解析 claude 可执行文件路径
+工具 SHALL 按以下顺序解析要执行的路径:
+1. 若 `claude_path` 已配置,使用之(支持 `~` 展开),并校验可执行位;
+2. 否则使用 `exec.LookPath("claude")`。
+任一步找到且可执行即使用该路径;均失败时 SHALL 以非零退出码终止,并输出清晰错误(指出用户可执行 `cc-switch config set claude-path <path>` 来解决)。
+
+#### Scenario: 使用配置路径
+- **WHEN** `claude_path=~/.claude/local/claude` 且该文件存在且有执行位
+- **THEN** 工具以该路径启动子进程
+
+#### Scenario: 回退到 PATH 查找
+- **WHEN** `claude_path` 未配置且 `PATH` 中存在 `claude`
+- **THEN** 工具使用 `exec.LookPath` 结果启动子进程
+
+#### Scenario: 无处可寻
+- **WHEN** `claude_path` 未配置且 `PATH` 中无 `claude`
+- **THEN** 工具非零退出并打印引导用户配置 `claude-path` 的提示
+
+### Requirement: 子进程透传 stdio
+启动 `claude` 时,工具 SHALL 将 `cc-switch` 自身的 stdin / stdout / stderr 直接继承给子进程(`cmd.Stdin/Stdout/Stderr = os.Stdin/Stdout/Stderr`),不做任何缓冲或改写。
+
+#### Scenario: 交互输入透传
+- **WHEN** 用户在 `claude` 运行期间敲入字符
+- **THEN** 字符被 `claude` 即时读取,与直接运行 `claude` 行为一致
+
+#### Scenario: 输出无额外前缀
+- **WHEN** `claude` 写入 stdout
+- **THEN** 终端看到的字节与直接运行 `claude` 完全一致,`cc-switch` 不添加前缀、颜色或时间戳
+
+### Requirement: 信号转发
+`cc-switch` SHALL 捕获 SIGINT、SIGTERM、SIGHUP 并转发给子进程 `claude`;收到信号后 MUST NOT 立即退出,而是继续等待子进程自行收尾后读取其退出码。
+
+#### Scenario: Ctrl+C 传递
+- **WHEN** 用户在 `claude` 运行时按下 Ctrl+C(SIGINT)
+- **THEN** 子进程 `claude` 收到 SIGINT,进入自己的取消流程;`cc-switch` 不立即退出
+
+#### Scenario: 终端关闭
+- **WHEN** 用户关闭终端窗口(SIGHUP 送达 `cc-switch`)
+- **THEN** `cc-switch` 向子进程转发 SIGHUP,等待其退出后再退出
+
+### Requirement: 退出码透传
+`cc-switch` SHALL 使用子进程 `claude` 的退出码作为自身退出码。若子进程因信号终止,`cc-switch` SHALL 退出码 128 + signum(符合 POSIX 约定)。若 `cc-switch` 自身在调用 `claude` 前失败(如配置错误、路径解析失败),退出码 SHALL 为非零且**不是** 0–125 范围内容易与 `claude` 混淆的值(使用 64、70 之类的约定值或 `>= 1`,在实现中固定)。
+
+#### Scenario: 正常退出码
+- **WHEN** `claude` 以退出码 0 退出
+- **THEN** `cc-switch` 以退出码 0 退出
+
+#### Scenario: 非零退出码
+- **WHEN** `claude` 以退出码 42 退出
+- **THEN** `cc-switch` 以退出码 42 退出
+
+#### Scenario: 信号终止
+- **WHEN** `claude` 被 SIGKILL(9)杀死
+- **THEN** `cc-switch` 以退出码 137(128+9)退出
+
+### Requirement: 退出后清理语义
+`claude` 子进程退出后,并集中的 env key MUST 不残留在任何与 `cc-switch` 运行相关的 shell 状态中。由于工具采用"构造子进程 Env 切片"的实现,子进程退出即意味着其私有环境被销毁,`cc-switch` 本身未修改过 `os.Environ`,父 shell 未受影响,该要求天然满足。工具 SHALL 在 `--verbose` 模式下打印一条 trace 日志确认"cleanup complete"。
+
+#### Scenario: 父 shell 不受影响
+- **WHEN** 用户 `cc-switch use foo` 运行并退出后,回到 shell 执行 `env | grep ANTHROPIC_`
+- **THEN** 父 shell 中没有任何由 `foo` 新引入的 env 变量(只保留用户自己 export 的那些)
+
+#### Scenario: verbose trace
+- **WHEN** 用户执行 `cc-switch -v use foo`,`claude` 正常退出
+- **THEN** stderr 中可见一条 `cleanup complete` 或等价语义的 trace 行

+ 105 - 0
openspec/changes/archive/2026-04-27-init-cc-switch-cli/tasks.md

@@ -0,0 +1,105 @@
+## 1. 项目脚手架
+
+- [x] 1.1 初始化 Go module:`go mod init github.com/kotoyuuko/cc-switch-cli`,设 `go 1.22+`
+- [x] 1.2 添加依赖:`github.com/spf13/cobra`、`gopkg.in/yaml.v3`
+- [x] 1.3 建立目录结构:`cmd/cc-switch/main.go`、`internal/config/`、`internal/provider/`、`internal/runner/`、`internal/cli/`
+- [x] 1.4 在 `cmd/cc-switch/main.go` 写空的 cobra root command,可运行 `go run ./cmd/cc-switch --help`
+- [x] 1.5 加入 `Makefile` 或 `justfile`:`build`、`test`、`vet`、`fmt` 常用 target
+- [x] 1.6 添加 `.gitignore`(忽略二进制、`dist/`、本地 `config.yaml`)
+
+## 2. 配置层(internal/config)
+
+- [x] 2.1 定义 `Config` / `Provider` 结构体,字段与 `specs/provider-config/spec.md` 一致(`claude_path`、`default_provider`、`providers`、`env`、`description`)
+- [x] 2.2 实现配置路径解析:`CC_SWITCH_CONFIG` > `XDG_CONFIG_HOME` > `~/.config/cc-switch/config.yaml`;支持 `~` 展开
+- [x] 2.3 实现 `Load()`:文件不存在返回空 `Config`,存在则 yaml 反序列化;对权限 > 0600 的文件打印 warning
+- [x] 2.4 实现 `Save()`:写 `config.yaml.tmp` → fsync → `os.Rename`;父目录缺失则以 0700 创建;新建文件以 0600 权限
+- [x] 2.5 实现 provider 名校验(正则 `^[A-Za-z0-9_-]+$`)与 env 非空校验
+- [x] 2.6 实现增删改查方法:`AddProvider`、`RemoveProvider`、`UpdateProvider`、`ListProviders`、`SetDefault`、`SetClaudePath`(后者校验路径存在且可执行)
+- [x] 2.7 实现 env value 引用识别辅助:`IsEnvRef(s string) (varName string, ok bool)` 基于正则 `^env:[A-Za-z_][A-Za-z0-9_]*$`;展示层(list -v、config show)遇到引用 value 原样输出、不脱敏
+- [x] 2.8 覆盖单元测试:路径解析、load/save 原子性、权限 warning、CRUD、非法名、删除 default 清空逻辑、`IsEnvRef` 的正反例(包括 `env:`、`env:1x`、`env:FOO` 等)
+
+## 3. Provider 运行时计算(internal/provider)
+
+- [x] 3.1 实现 `UnionEnvKeys(providers) []string`:遍历所有 provider 的 env key 去重
+- [x] 3.2 实现 `ResolveEnvRefs(selected Provider, parentSnapshot map[string]string) (resolved map[string]string, err error)`:对 `selected.env` 中匹配 `env:VAR` 的 value 逐项从 parentSnapshot 查找;缺失则返回 error 指出哪个 key 引用哪个 VAR 缺失;只解析一次不做链式
+- [x] 3.3 实现 `BuildChildEnv(parent []string, union []string, resolved map[string]string) []string`:从 parent 剔除 union 中的 key,再 append resolved 的 key=value(resolved 的值覆盖同名)
+- [x] 3.4 单元测试:
+  - 父环境覆盖、并集剔除、value 不做 shell 展开、同名 key 以 selected 为准
+  - `ResolveEnvRefs` 正常解析、VAR 缺失报错、被引用 VAR 恰好在并集中(快照先于清理)、不做链式解析
+
+## 4. Runner 子进程层(internal/runner)
+
+- [x] 4.1 实现 `ResolveClaudePath(cfg Config) (string, error)`:优先 `claude_path`(带 `~` 展开 + 可执行位校验),否则 `exec.LookPath("claude")`;都失败则返回带引导信息的错误
+- [x] 4.2 实现 `Run(ctx, claudePath string, childEnv []string, args []string) (exitCode int, err error)`:
+  - `cmd.Env = childEnv`;`cmd.Stdin/Stdout/Stderr = os.Std*`
+  - 使用 `os/signal.Notify` 捕获 SIGINT/SIGTERM/SIGHUP 并 `cmd.Process.Signal(sig)` 转发
+  - `cmd.Wait()` 后返回 `ExitCode()`;信号终止时返回 `128 + signum`
+- [x] 4.3 单元测试:用 `exec.Command("/bin/sh", "-c", "echo $FOO")` 作为替身验证 env 注入;用 short-lived 子进程验证 exit code 透传;用 `sleep` + SIGINT 验证信号转发
+- [x] 4.4 在 Unix 专属文件 `runner_unix.go` 中实现信号转发;预留 `runner_other.go` 返回"unsupported platform"错误(为 Windows/Plan9 兜底)
+
+## 5. CLI 装配(internal/cli)
+
+- [x] 5.1 在 `internal/cli/root.go` 定义 root cmd 与全局 flag:`-v/--verbose`、`--config`
+- [x] 5.2 在 `root.go` 的 `PersistentPreRunE` 中加载配置(若文件不存在则持有空配置)
+- [x] 5.3 实现 `cc-switch add`:flag 模式 `--env KEY=VALUE`(可重复)、`--description`、`--from-template <tpl>`、`--non-interactive`;
+  - 非 tty 且无 `--env` 也无 `--from-template` 时报错
+  - tty 且无 `--env`/`--from-template` 时进入自由向导(空 key 结束)
+  - `--from-template` 带出模板 key 骨架,tty 下逐 key 提示 value(显示 hint、回车接受 default、允许 `env:VAR` 引用),`--non-interactive` + 模板时为占位空字符串
+  - 组合规则:先模板、后 `--env` 覆盖/追加
+  - 校验:落盘前任何 value 为空字符串的 key 拒绝写入
+- [x] 5.4 实现 `cc-switch list` / `ls`:普通模式只列名称 + default 标记;`-v` 列出 env key 与脱敏 value(`***` 或首 4 字符 + `***`)
+- [x] 5.5 实现 `cc-switch edit <name>`:支持 `--env`、`--description`、`--remove-env KEY` 等 flag
+- [x] 5.6 实现 `cc-switch remove <name>` / `rm <name>`:删除后若等于 default_provider 则清空该字段
+- [x] 5.7 实现 `cc-switch config show|set|get`:`set claude-path`、`set default`、`get <key>`、`show`(脱敏)
+- [x] 5.8 实现 `cc-switch use [name]`:
+  - tty + 无 name → 交互菜单(编号/名称输入,空输入选 default,最多 3 次非法重试)
+  - 非 tty + 无 name → 使用 `default_provider`;无 default 则报错
+  - 有 name → 直接选中;不存在则报错
+  - 选中后流程:取 `os.Environ()` 快照 → `ResolveEnvRefs`(缺失即退出、不启动)→ `UnionEnvKeys` → `BuildChildEnv` → runner 启动 → 等待 → 退出码透传
+- [x] 5.9 实现 `cc-switch`(root 裸跑)→ 转发到 `use` 子命令
+- [x] 5.10 实现 `cc-switch version`:注入构建时的 version/commit/date(通过 ldflags)
+- [x] 5.11 `verbose` 时在 stderr 打印 trace:解析到的 config 路径、claude 路径、并集 key、选中 provider、cleanup complete
+- [x] 5.12 实现 `cc-switch templates list` / `templates show <tpl>`,只读内嵌模板数据
+- [x] 5.13 覆盖 cli 集成测试(用 `cobra` 的 `SetArgs` + `SetOut/Err` 捕获):add/list/remove/use/templates 的各 scenario,包含 `--from-template`、`env:VAR` 写入、引用解析失败的 use 路径
+
+## 6. 脱敏与日志
+
+- [x] 6.1 实现 value 脱敏函数:长度 ≤ 4 全部 `***`;否则前 4 字符 + `***`
+- [x] 6.2 在所有打印 provider env value 的地方统一走脱敏函数(list -v、config show、config get),但**引用 value(`env:VAR`)原样输出不脱敏**
+- [x] 6.3 verbose trace 中永不打印 env value,仅 key 列表
+
+## 7. 内嵌模板(internal/templates)
+
+- [x] 7.1 设计模板数据结构:`Template{Name, Description string; Env []TemplateEnvKey{Name, Hint, Default string}}`
+- [x] 7.2 创建 `internal/templates/templates.yaml`(`go:embed`),填充 v1 种子模板:`anthropic-official`、`openrouter`、`deepseek`、`moonshot`、`zhipu`、`custom-base`,**只填键名 + hint + 可选 default(官方 base URL 等),绝不预置 token**
+- [x] 7.3 实现 `Load() []Template`、`Get(name string) (Template, bool)`、`List() []Template`
+- [x] 7.4 单元测试:加载完整性(所有种子模板非空、env key 列表非空)、`Get` 正反例、yaml 解析错误路径
+
+## 8. 端到端测试
+
+- [x] 8.1 黑盒 e2e:用 `go test` 启动 `cc-switch`,用一个简短 shell 脚本充当"fake claude"(打印所有 ANTHROPIC_* 变量后退出),验证:
+  - 并集清理:父 shell 设置 `ANTHROPIC_API_KEY=old`、provider 未设置该 key,fake claude 输出中不包含该 key
+  - 注入覆盖:provider 设置 `ANTHROPIC_API_KEY=new`,fake claude 输出中值为 `new`
+  - value 字面传递:值为 `$HOME` 时,fake claude 输出字面 `$HOME`
+  - 退出码透传:fake claude `exit 42`,`cc-switch` 也 42
+- [x] 8.2 信号 e2e:fake claude = 长 sleep + 捕获 SIGINT 并 `exit 130`,`cc-switch` 收 SIGINT 后也 130 退出(覆盖于 `internal/runner` 单测)
+- [x] 8.3 非 tty 行为:用 `stdin</dev/null` 运行 `cc-switch use`,无 default 时报错;有 default 时正确启动
+- [x] 8.4 权限 warning:chmod 0644 后运行任一命令,stderr 有 warning 且功能正常
+- [x] 8.5 env 引用 e2e:
+  - 引用成功:父 shell 设 `MY_KEY=sk-ok`,provider `env.ANTHROPIC_API_KEY=env:MY_KEY`,fake claude 收到 `ANTHROPIC_API_KEY=sk-ok`
+  - 引用缺失:父 shell 不设 `MY_KEY`,`cc-switch use` 非零退出,fake claude 未被启动(可通过 claude 端写入一个 sentinel 文件检测"没执行")
+  - 快照顺序:provider B `env.ANTHROPIC_API_KEY=env:ANTHROPIC_API_KEY`,父 shell 设该 var,fake claude 收到父 shell 的值(验证先快照后清理)
+- [x] 8.6 模板 e2e:`cc-switch add x --from-template openrouter --non-interactive --env ANTHROPIC_AUTH_TOKEN=sk-test --env ANTHROPIC_MODEL=anthropic/claude-opus-4`(其他 key 由模板带出)能成功落盘;`cc-switch templates list` 输出含全部种子模板
+
+## 9. 打包与发布准备
+
+- [x] 9.1 编写最小可用的 `README.md`:安装、快速上手(add → use)、配置文件示例(Anthropic 官方 / OpenRouter / 其他兼容层 snippet)、`env:VAR` 引用用法(配合 1Password CLI / direnv 的示例)、`--from-template` 用法
+- [x] 9.2 添加 GoReleaser 配置(`.goreleaser.yaml`):darwin/linux 的 amd64+arm64 二进制;SHA256 校验;可选 Homebrew tap
+- [x] 9.3 在 GitHub Actions 添加 CI:`vet` + `test` + `build`(PR & main)
+- [ ] 9.4 打 tag `v0.1.0`,触发发布流水线(验收通过后) — **留给用户**
+
+## 10. 文档与示例配置
+
+- [x] 10.1 在 README 中明确"并集清理"语义,配合示例解释"为什么把所有可能用到的 key 都登记"
+- [x] 10.2 附一个 `examples/config.example.yaml`,含两三个 provider 示例(占位 key + 至少一条 `env:VAR` 引用示例)
+- [x] 10.3 记录 Non-Goal:不改父 shell、不加密存储、暂不支持 Windows、v1 不支持用户自定义模板目录、不做 `env:VAR` 链式解析、不做字面 `env:` 转义

+ 20 - 0
openspec/config.yaml

@@ -0,0 +1,20 @@
+schema: spec-driven
+
+# Project context (optional)
+# This is shown to AI when creating artifacts.
+# Add your tech stack, conventions, style guides, domain knowledge, etc.
+# Example:
+#   context: |
+#     Tech stack: TypeScript, React, Node.js
+#     We use conventional commits
+#     Domain: e-commerce platform
+
+# Per-artifact rules (optional)
+# Add custom rules for specific artifacts.
+# Example:
+#   rules:
+#     proposal:
+#       - Keep proposals under 500 words
+#       - Always include a "Non-goals" section
+#     tasks:
+#       - Break tasks into chunks of max 2 hours

+ 151 - 0
openspec/specs/cli/spec.md

@@ -0,0 +1,151 @@
+# cli Specification
+
+## Purpose
+
+`cc-switch` 的命令行入口与交互形态规范:二进制布局、子命令分发、裸跑与交互模式、全局 flag,以及 `config` / `add` / `templates` 等子命令的用户可见行为。
+
+## Requirements
+
+### Requirement: 二进制与子命令入口
+工具 SHALL 以单个二进制 `cc-switch` 分发,采用子命令架构。顶层 SHALL 至少暴露以下子命令:`add`、`list`(别名 `ls`)、`edit`、`remove`(别名 `rm`)、`use`、`config`、`templates`、`help`、`version`。未识别的子命令 SHALL 以非零退出码退出并建议 `cc-switch --help`。
+
+#### Scenario: 列出帮助
+- **WHEN** 用户执行 `cc-switch --help`
+- **THEN** 输出包含上述所有子命令的一行摘要,且以 0 退出
+
+#### Scenario: 未知子命令
+- **WHEN** 用户执行 `cc-switch foobar`
+- **THEN** 工具以非零退出码退出,并在 stderr 提示 `foobar` 未知、可查看 `--help`
+
+#### Scenario: 版本信息
+- **WHEN** 用户执行 `cc-switch version`
+- **THEN** 输出至少包含语义化版本号;构建时可注入 commit 与日期信息
+
+### Requirement: 裸跑入口 = 交互式 use
+执行 `cc-switch`(无参数)SHALL 等价于 `cc-switch use`(无参数),即进入交互式 provider 选择流程。
+
+#### Scenario: tty 裸跑
+- **WHEN** 用户在终端裸跑 `cc-switch`
+- **THEN** 屏幕出现带编号的 provider 列表,等待用户输入编号或名称
+
+### Requirement: 交互式 provider 选择
+`cc-switch use`(无参)在 stdin 为 tty 时 SHALL 展示编号列表让用户选择;若存在 `default_provider`,在列表中 SHALL 标注为默认,空输入(直接回车)SHALL 选中默认 provider;用户可输入编号或 provider 名。输入不匹配时 SHALL 重新提示(最多 3 次),全部失败后以非零退出码退出。
+
+#### Scenario: 回车选默认
+- **WHEN** 存在 `default_provider=foo`,用户执行 `cc-switch use` 并直接回车
+- **THEN** `foo` 被选中、进入启动流程
+
+#### Scenario: 输入编号
+- **WHEN** 列表中编号 2 对应 `bar`,用户输入 `2` 回车
+- **THEN** `bar` 被选中、进入启动流程
+
+#### Scenario: 输入名称
+- **WHEN** 用户输入 `bar` 回车
+- **THEN** `bar` 被选中、进入启动流程
+
+#### Scenario: 连续非法输入
+- **WHEN** 用户连续 3 次输入不存在的编号或名称
+- **THEN** 工具非零退出、不启动 `claude`
+
+### Requirement: 非交互式 provider 选择
+`cc-switch use <name>` SHALL 直接选中指定 provider 并进入启动流程,无需任何 tty 交互。未指定 `<name>` 且 stdin 非 tty 时 SHALL 尝试使用 `default_provider`;若也未设置默认,SHALL 以非零退出码退出并提示需要显式 `<name>`。
+
+#### Scenario: 直接指定
+- **WHEN** 用户执行 `cc-switch use foo` 且 `foo` 已配置
+- **THEN** 工具立即进入清理/注入/启动流程,不做任何交互
+
+#### Scenario: 指定不存在 provider
+- **WHEN** 用户执行 `cc-switch use nonexistent`
+- **THEN** 工具非零退出、stderr 提示 `nonexistent` 不存在、建议 `cc-switch list`
+
+#### Scenario: 管道模式使用默认
+- **WHEN** stdin 非 tty(例如被 `:</dev/null` 重定向),用户执行 `cc-switch use`,且 `default_provider=foo`
+- **THEN** 工具不进入交互,直接使用 `foo` 启动
+
+#### Scenario: 管道模式且无默认
+- **WHEN** stdin 非 tty,未设置 `default_provider`,用户执行 `cc-switch use`
+- **THEN** 工具非零退出,提示需要 `<name>` 或先设置默认 provider
+
+### Requirement: 全局 flag
+工具 SHALL 支持以下全局 flag:`-v / --verbose`(输出 trace 日志到 stderr)、`--config <path>`(等价于 `CC_SWITCH_CONFIG` 但优先级更高)、`-h / --help`。这些 flag SHALL 在任意子命令中可用。
+
+#### Scenario: --config 覆盖 env
+- **WHEN** 已设置 `CC_SWITCH_CONFIG=/a.yaml`,用户执行 `cc-switch --config /b.yaml list`
+- **THEN** 工具读写 `/b.yaml`,忽略 env 中的路径
+
+#### Scenario: verbose 生效
+- **WHEN** 用户执行 `cc-switch -v use foo`
+- **THEN** stderr 中可见解析到的配置路径、最终 claude 路径、最终 env key 列表(value 不打印)等 trace 信息
+
+### Requirement: `config` 子命令
+`cc-switch config` SHALL 提供 `get` / `set` / `show` 子命令:
+- `config show`:打印当前配置文件路径与内容(env value 脱敏为 `***`)
+- `config set claude-path <path>`:设置 `claude_path`
+- `config set default <name>`:设置 `default_provider`
+- `config get <key>`:打印单个配置项值(同样脱敏 env value)
+
+#### Scenario: show
+- **WHEN** 用户执行 `cc-switch config show`
+- **THEN** 第一行打印配置文件绝对路径,之后打印脱敏后的 YAML
+
+#### Scenario: set default
+- **WHEN** 用户执行 `cc-switch config set default foo`
+- **THEN** 若 `foo` 存在,写入配置并打印确认;若不存在,非零退出
+
+### Requirement: Add 向导(可选交互)
+`cc-switch add <name>` SHALL 支持三种用法:
+1. 通过 `--env KEY=VALUE`(可重复)与 `--description "..."` 一次性填入;
+2. 通过 `--from-template <tpl>` 以内置模板为起点,tty 下逐 key 提示输入 value(显示 hint、回车接受 default);可与 `--description` 共存;
+3. 不带任何 `--env` / `--from-template` 时进入交互式自由向导,逐个提示输入 key/value(输入空 key 结束),tty 下可用。
+
+所有方式 MUST 最终落入同一份校验逻辑(至少一个 env key、provider 名合法)。`--from-template` 与 `--env` 可以组合使用——先以模板为骨架,`--env` 再覆盖或补充同名/新 key。value 输入阶段 MUST 允许用户输入 `env:VAR_NAME` 形式的引用(见 provider-config spec 对应 Requirement),工具 MUST NOT 在 `add` 阶段尝试解析该引用、MUST NOT 校验该 VAR 是否存在。
+
+#### Scenario: 一次性命令行
+- **WHEN** 用户执行 `cc-switch add foo --env K1=v1 --env K2=v2 --description "desc"`
+- **THEN** `foo` 被写入,`env` 含两个键,`description=desc`
+
+#### Scenario: 交互式向导
+- **WHEN** 用户在 tty 下执行 `cc-switch add foo`,依次输入 `K1` / `v1` / `K2` / `v2` / 空键
+- **THEN** `foo` 被写入与一次性命令行等价的结构
+
+#### Scenario: 非 tty 且无 --env 无模板
+- **WHEN** stdin 非 tty 用户执行 `cc-switch add foo` 且未提供任何 `--env` 或 `--from-template`
+- **THEN** 工具非零退出,提示在非交互模式下需给出 `--env KEY=VALUE` 或 `--from-template`
+
+#### Scenario: 使用模板(tty)
+- **WHEN** 用户在 tty 下执行 `cc-switch add official --from-template anthropic-official`,依次对每个模板 key 按回车接受 default / 输入新 value
+- **THEN** `official` 被写入,`env` 包含模板定义的所有 key,value 为用户确认后的值
+
+#### Scenario: 模板 + --env 覆盖
+- **WHEN** 用户执行 `cc-switch add x --from-template openrouter --env ANTHROPIC_MODEL=anthropic/claude-opus-4 --non-interactive`
+- **THEN** `x` 被写入,env 结构为"模板骨架 ∪ --env 覆盖",`ANTHROPIC_MODEL` 取自 `--env`;模板中未被 `--env` 提供的 key 保留模板 default 或占位空字符串(value 为空的 key 在落盘前 MUST 报错要求补齐)
+
+#### Scenario: 引用语法直接写入
+- **WHEN** 用户执行 `cc-switch add foo --env ANTHROPIC_API_KEY=env:MY_KEY`
+- **THEN** 配置写入的字面 value 即为 `env:MY_KEY`,不在 add 阶段校验 `MY_KEY` 是否存在
+
+#### Scenario: 未知模板名
+- **WHEN** 用户执行 `cc-switch add foo --from-template nonexistent`
+- **THEN** 工具非零退出,stderr 列出可用模板名并建议 `cc-switch templates list`
+
+### Requirement: `templates` 子命令
+`cc-switch templates` SHALL 暴露两个子命令:`list` 与 `show <tpl>`。模板为内嵌资源,工具 MUST 不访问网络、MUST 不读取用户目录之外的文件。
+
+#### Scenario: 列出模板
+- **WHEN** 用户执行 `cc-switch templates list`
+- **THEN** 输出每个内置模板的名称与一行 description
+
+#### Scenario: 查看模板细节
+- **WHEN** 用户执行 `cc-switch templates show openrouter`
+- **THEN** 输出该模板的 description、env key 列表、每个 key 的 hint 与 default(若有)
+
+#### Scenario: 查看不存在的模板
+- **WHEN** 用户执行 `cc-switch templates show nonexistent`
+- **THEN** 工具非零退出,提示不存在并建议 `cc-switch templates list`
+
+### Requirement: v1 内嵌模板集
+二进制 SHALL 内嵌至少以下模板:`anthropic-official`、`openrouter`、`deepseek`、`moonshot`、`zhipu`、`custom-base`。每个模板 MUST 至少包含一条 env key;模板 key 列表 MUST 只描述键名与 hint/default,MUST NOT 预置任何真实 token 或 API key。
+
+#### Scenario: 种子模板齐全
+- **WHEN** 用户执行 `cc-switch templates list`
+- **THEN** 输出至少包含上述 6 个模板名

+ 119 - 0
openspec/specs/provider-config/spec.md

@@ -0,0 +1,119 @@
+# provider-config Specification
+
+## Purpose
+
+配置文件(`config.yaml`)的定位、读写、schema、provider 增删改查,以及 env 值的间接引用语法与 `claude_path` / `default_provider` 等顶层字段的语义。
+
+## Requirements
+
+### Requirement: 配置文件定位与初始化
+工具 SHALL 按如下优先级解析配置文件路径:`$CC_SWITCH_CONFIG` > `$XDG_CONFIG_HOME/cc-switch/config.yaml` > `~/.config/cc-switch/config.yaml`。若目标文件不存在,SHALL 在首次需要写入时创建父目录(权限 `0700`)与空骨架文件(权限 `0600`)。读操作遇文件不存在 MUST 返回"空配置"而非错误。
+
+#### Scenario: 默认路径首次读
+- **WHEN** 用户未设置 `CC_SWITCH_CONFIG`、`XDG_CONFIG_HOME`,也从未运行过 `cc-switch`
+- **THEN** `cc-switch list` 成功执行,输出 "(no providers configured)" 提示,且不在磁盘创建任何文件
+
+#### Scenario: 默认路径首次写
+- **WHEN** 用户执行 `cc-switch add foo ...` 且配置文件从未被创建
+- **THEN** 工具创建 `~/.config/cc-switch/`(权限 `0700`)与 `config.yaml`(权限 `0600`),并写入新增 provider
+
+#### Scenario: XDG 路径覆盖
+- **WHEN** 环境变量 `XDG_CONFIG_HOME=/tmp/xdg` 已设置
+- **THEN** 工具使用 `/tmp/xdg/cc-switch/config.yaml` 作为配置文件路径
+
+#### Scenario: 显式路径覆盖
+- **WHEN** 环境变量 `CC_SWITCH_CONFIG=/custom/path.yaml` 已设置
+- **THEN** 无论 XDG 如何设置,工具均使用 `/custom/path.yaml`
+
+#### Scenario: 宽松权限 warning
+- **WHEN** 配置文件存在且权限为 `0644`
+- **THEN** 工具继续执行命令,并向 stderr 打印一条 warning 提示权限过宽、建议 `chmod 600`
+
+### Requirement: 配置 schema
+配置文件 SHALL 使用 YAML 编码,顶层字段包括:`claude_path`(string,可选)、`default_provider`(string,可选)、`providers`(map,key 为 provider 名)。每个 provider SHALL 包含 `env`(map[string]string,必填且至少一个键)与可选 `description`(string)。Provider 名 SHALL 为非空且仅含字母、数字、`-`、`_`。
+
+#### Scenario: 合法配置解析
+- **WHEN** 配置文件内容合法,包含两个 provider 及 `default_provider: foo`
+- **THEN** 工具解析成功并在后续命令中识别这两个 provider
+
+#### Scenario: 非法 provider 名
+- **WHEN** 配置文件中 provider 名包含空格或非法字符
+- **THEN** 工具返回非零退出码并指出哪一个 provider 名不合法
+
+#### Scenario: 空 env map
+- **WHEN** 某个 provider 的 `env` 字段为空 map
+- **THEN** 工具返回非零退出码并提示该 provider 至少需要一个环境变量
+
+### Requirement: Env 值的间接引用语法
+Provider 的 `env` map 中,value 为字符串类型;若 value 严格匹配正则 `^env:[A-Za-z_][A-Za-z0-9_]*$`,SHALL 被识别为"间接引用",表示"启动时从父进程环境中读取名为 `VAR_NAME` 的变量的值作为实际 value"。所有其他 value MUST 被视为字面字符串,不做任何展开。引用仅在"启动阶段"解析(见 provider-launch spec),在 `list -v` / `config show` / `config get` 等展示命令中,引用 value 按原字符串展示(不脱敏、不展开),让用户一眼识别"这一项是指针"。
+
+#### Scenario: 字面 value 保留
+- **WHEN** provider 的 `env.FOO` 值为 `hello`
+- **THEN** 工具视为字面 `hello`;`list -v` 显示脱敏后的 `he***` 或等价样式
+
+#### Scenario: env 引用被识别
+- **WHEN** provider 的 `env.ANTHROPIC_API_KEY` 值为 `env:MY_ANTHROPIC_KEY`
+- **THEN** 工具视为对父环境变量 `MY_ANTHROPIC_KEY` 的引用;`list -v` 显示 `env:MY_ANTHROPIC_KEY`(按原字符串,不脱敏、不解析)
+
+#### Scenario: 非法引用形式回退为字面
+- **WHEN** provider 的 `env.FOO` 值为 `env:` 或 `env:1NAME`(不符合正则)
+- **THEN** 工具视为普通字面字符串,不报错也不解析
+
+### Requirement: 原子写入
+工具修改配置文件时 SHALL 采用"写临时文件 → fsync → 重命名"模式;MUST 不允许部分写入后崩溃导致的文件损坏。
+
+#### Scenario: 写入中途崩溃
+- **WHEN** 在 `cc-switch add` 写入 `config.yaml` 过程中进程被强制终止
+- **THEN** `config.yaml` 要么保留原内容、要么是完整的新内容,不会出现截断或空文件
+
+### Requirement: Provider 增删改查
+工具 SHALL 支持以下 provider 管理操作:
+- **add**:新增 provider,禁止与已有同名冲突
+- **list**:列出所有 provider,标记 `default`,可选 `-v` 显示 env 键名(value 脱敏)
+- **edit**:修改已有 provider 的 env 或 description
+- **remove**:按名称删除 provider;若删除的是当前 `default_provider`,`default_provider` 字段 SHALL 被一并清空
+- **use-default** 支持(见 provider-launch 与 cli 能力)
+
+所有写操作 SHALL 在修改成功后原子落盘。
+
+#### Scenario: 新增 provider
+- **WHEN** 用户执行 `cc-switch add official --env ANTHROPIC_API_KEY=sk-xxx --env ANTHROPIC_BASE_URL=https://api.anthropic.com`
+- **THEN** `providers.official.env` 写入两个键,`cc-switch list` 能列出 `official`
+
+#### Scenario: 重名 add
+- **WHEN** 已存在名为 `official` 的 provider,用户再次 `cc-switch add official ...`
+- **THEN** 工具返回非零退出码并提示使用 `edit` 或换一个名字
+
+#### Scenario: list 脱敏
+- **WHEN** 用户执行 `cc-switch list -v`
+- **THEN** 输出中对每个 env 值展示为 `***` 或前 4 位 + `***`,不打印完整 value
+
+#### Scenario: 删除默认 provider
+- **WHEN** 当前 `default_provider=foo`,用户执行 `cc-switch remove foo`
+- **THEN** `foo` 被删除,`default_provider` 被置空,`cc-switch list` 不再展示 default 标记
+
+#### Scenario: 删除不存在 provider
+- **WHEN** 用户执行 `cc-switch remove nonexistent`
+- **THEN** 工具返回非零退出码并提示 provider 不存在
+
+### Requirement: 全局 claude 可执行文件路径
+工具 SHALL 支持通过 `cc-switch config set claude-path <path>` 命令设置 `claude_path`;支持 `~` 展开;SHALL 在设置时校验路径存在且可执行,不通过则拒绝写入。未设置时后续启动流程回落到 `exec.LookPath("claude")`。
+
+#### Scenario: 设置有效路径
+- **WHEN** 用户执行 `cc-switch config set claude-path ~/.claude/local/claude` 且该文件存在且可执行
+- **THEN** `claude_path` 被写入展开后的绝对路径
+
+#### Scenario: 设置无效路径
+- **WHEN** 用户执行 `cc-switch config set claude-path /not/exist`
+- **THEN** 工具返回非零退出码且不修改配置文件
+
+### Requirement: 默认 provider
+工具 SHALL 支持通过 `cc-switch config set default <name>` 设置默认 provider,前提是该 provider 存在。`cc-switch use` 在未指定 `<name>` 且处于非 tty 环境时 SHALL 使用默认 provider;tty 下仍进入交互选择(默认值为 `default_provider`)。
+
+#### Scenario: 设置默认
+- **WHEN** 用户已有 `foo` provider,执行 `cc-switch config set default foo`
+- **THEN** `default_provider: foo` 被写入配置
+
+#### Scenario: 设置不存在 provider 为默认
+- **WHEN** 用户执行 `cc-switch config set default bar` 且 `bar` 不存在
+- **THEN** 工具返回非零退出码且不修改配置

+ 122 - 0
openspec/specs/provider-launch/spec.md

@@ -0,0 +1,122 @@
+# provider-launch Specification
+
+## Purpose
+
+选中 provider 后启动 `claude` 子进程的完整语义:env 引用解析、并集清理、注入规则、可执行文件查找、stdio 透传、信号转发、退出码传播与退出清理保证。
+
+## Requirements
+
+### Requirement: 解析 env 引用
+启动流程在"并集清理"之前 SHALL 先对父进程环境取一次快照(`os.Environ()`)。对于选中 provider 的每一项 `env` value,若匹配 `^env:[A-Za-z_][A-Za-z0-9_]*$`,SHALL 从该快照中查找同名变量:
+- 若存在,该 key 的实际 value 替换为快照中的值;
+- 若不存在,工具 MUST 以非零退出码退出,stderr 指出**哪个 key 引用的哪个 VAR** 缺失,且此时 MUST NOT 启动 `claude`、MUST NOT 对 env 做任何 mutation。
+
+解析完成后得到的"物化"env map 作为后续"并集清理 + 注入"的输入。解析到的值 MUST 不做任何 shell 扩展或二次引用解析(不支持链式 `env:A` → `env:B`)。
+
+#### Scenario: 引用成功
+- **WHEN** 父 shell 已 export `MY_KEY=sk-abc`,provider `foo.env.ANTHROPIC_API_KEY=env:MY_KEY`,用户执行 `cc-switch use foo`
+- **THEN** 子进程 `claude` 收到 `ANTHROPIC_API_KEY=sk-abc`
+
+#### Scenario: 引用变量缺失
+- **WHEN** 父 shell 未 export `MY_KEY`,provider `foo.env.ANTHROPIC_API_KEY=env:MY_KEY`
+- **THEN** 工具非零退出;stderr 形如 `ANTHROPIC_API_KEY references env var MY_KEY which is not set`;`claude` 未被启动
+
+#### Scenario: 引用的 VAR 恰好在并集中
+- **WHEN** provider A 的 env key 含 `ANTHROPIC_API_KEY`,provider B 的 `env.ANTHROPIC_API_KEY=env:ANTHROPIC_API_KEY`,父 shell 已 export `ANTHROPIC_API_KEY=sk-real`,用户执行 `cc-switch use B`
+- **THEN** 由于解析发生在清理之前,子进程 `claude` 正确收到 `ANTHROPIC_API_KEY=sk-real`(来自快照)
+
+#### Scenario: 不做链式解析
+- **WHEN** 父 shell 有 `A=env:B` 与 `B=plain`,provider 的 value 为 `env:A`
+- **THEN** 工具解析一次,子进程收到字面 `env:B`(不继续解析 B)
+
+### Requirement: 环境变量并集清理
+启动 `claude` 前,工具 SHALL 计算所有已配置 provider 的 env key 的并集(记为 `K`),并从即将传给子进程的环境中**移除** `K` 中每一个 key。该清理 MUST 在注入选中 provider 的 env 之前完成。工具 MUST NOT 修改 `cc-switch` 自身进程的 `os.Environ`,也 MUST NOT 尝试修改父 shell 的环境。
+
+#### Scenario: 父 shell 存在旧变量
+- **WHEN** 父 shell 已 export `ANTHROPIC_API_KEY=old` 且该 key 出现在某个 provider 的 env 中,用户执行 `cc-switch use foo`(`foo.env` 不含 `ANTHROPIC_API_KEY`)
+- **THEN** 子进程 `claude` 的环境中 `ANTHROPIC_API_KEY` **不存在**(被清理、且 `foo` 未重新设置)
+
+#### Scenario: 与 provider 无关的变量原样继承
+- **WHEN** 父 shell 有 `HOME`、`PATH`、`LANG` 等变量,它们不在任何 provider 的 env key 集合中
+- **THEN** 子进程 `claude` 继承这些变量、值与父进程一致
+
+#### Scenario: 并集跨 provider 生效
+- **WHEN** provider A 的 env 含 `ANTHROPIC_API_KEY`、provider B 的 env 含 `ANTHROPIC_AUTH_TOKEN`,用户 `cc-switch use A`
+- **THEN** 子进程环境中 `ANTHROPIC_AUTH_TOKEN` 被清理(因其属于并集),同时 provider A 的 `ANTHROPIC_API_KEY` 被写入
+
+### Requirement: 选中 provider 的 env 注入
+清理完成后,工具 SHALL 将选中 provider 的 `env` 中每一个 key=value 写入子进程环境。若 key 与清理后的继承环境中某个 key 同名,provider 的 value SHALL 胜出(注入覆盖)。Value MUST 原样传递,不做任何 shell 扩展或变量替换。
+
+#### Scenario: 注入覆盖继承值
+- **WHEN** 父 shell 有 `ANTHROPIC_MODEL=x`,该 key 不在并集中但选中 provider 设置了 `ANTHROPIC_MODEL=y`
+- **THEN** 子进程环境中 `ANTHROPIC_MODEL=y`
+
+#### Scenario: Value 不做 shell 扩展
+- **WHEN** 选中 provider 的 `ANTHROPIC_BASE_URL=$HOME/api`
+- **THEN** 子进程收到的值字面量就是 `$HOME/api`,不会被展开为用户 home 目录
+
+### Requirement: 解析 claude 可执行文件路径
+工具 SHALL 按以下顺序解析要执行的路径:
+1. 若 `claude_path` 已配置,使用之(支持 `~` 展开),并校验可执行位;
+2. 否则使用 `exec.LookPath("claude")`。
+任一步找到且可执行即使用该路径;均失败时 SHALL 以非零退出码终止,并输出清晰错误(指出用户可执行 `cc-switch config set claude-path <path>` 来解决)。
+
+#### Scenario: 使用配置路径
+- **WHEN** `claude_path=~/.claude/local/claude` 且该文件存在且有执行位
+- **THEN** 工具以该路径启动子进程
+
+#### Scenario: 回退到 PATH 查找
+- **WHEN** `claude_path` 未配置且 `PATH` 中存在 `claude`
+- **THEN** 工具使用 `exec.LookPath` 结果启动子进程
+
+#### Scenario: 无处可寻
+- **WHEN** `claude_path` 未配置且 `PATH` 中无 `claude`
+- **THEN** 工具非零退出并打印引导用户配置 `claude-path` 的提示
+
+### Requirement: 子进程透传 stdio
+启动 `claude` 时,工具 SHALL 将 `cc-switch` 自身的 stdin / stdout / stderr 直接继承给子进程(`cmd.Stdin/Stdout/Stderr = os.Stdin/Stdout/Stderr`),不做任何缓冲或改写。
+
+#### Scenario: 交互输入透传
+- **WHEN** 用户在 `claude` 运行期间敲入字符
+- **THEN** 字符被 `claude` 即时读取,与直接运行 `claude` 行为一致
+
+#### Scenario: 输出无额外前缀
+- **WHEN** `claude` 写入 stdout
+- **THEN** 终端看到的字节与直接运行 `claude` 完全一致,`cc-switch` 不添加前缀、颜色或时间戳
+
+### Requirement: 信号转发
+`cc-switch` SHALL 捕获 SIGINT、SIGTERM、SIGHUP 并转发给子进程 `claude`;收到信号后 MUST NOT 立即退出,而是继续等待子进程自行收尾后读取其退出码。
+
+#### Scenario: Ctrl+C 传递
+- **WHEN** 用户在 `claude` 运行时按下 Ctrl+C(SIGINT)
+- **THEN** 子进程 `claude` 收到 SIGINT,进入自己的取消流程;`cc-switch` 不立即退出
+
+#### Scenario: 终端关闭
+- **WHEN** 用户关闭终端窗口(SIGHUP 送达 `cc-switch`)
+- **THEN** `cc-switch` 向子进程转发 SIGHUP,等待其退出后再退出
+
+### Requirement: 退出码透传
+`cc-switch` SHALL 使用子进程 `claude` 的退出码作为自身退出码。若子进程因信号终止,`cc-switch` SHALL 退出码 128 + signum(符合 POSIX 约定)。若 `cc-switch` 自身在调用 `claude` 前失败(如配置错误、路径解析失败),退出码 SHALL 为非零且**不是** 0–125 范围内容易与 `claude` 混淆的值(使用 64、70 之类的约定值或 `>= 1`,在实现中固定)。
+
+#### Scenario: 正常退出码
+- **WHEN** `claude` 以退出码 0 退出
+- **THEN** `cc-switch` 以退出码 0 退出
+
+#### Scenario: 非零退出码
+- **WHEN** `claude` 以退出码 42 退出
+- **THEN** `cc-switch` 以退出码 42 退出
+
+#### Scenario: 信号终止
+- **WHEN** `claude` 被 SIGKILL(9)杀死
+- **THEN** `cc-switch` 以退出码 137(128+9)退出
+
+### Requirement: 退出后清理语义
+`claude` 子进程退出后,并集中的 env key MUST 不残留在任何与 `cc-switch` 运行相关的 shell 状态中。由于工具采用"构造子进程 Env 切片"的实现,子进程退出即意味着其私有环境被销毁,`cc-switch` 本身未修改过 `os.Environ`,父 shell 未受影响,该要求天然满足。工具 SHALL 在 `--verbose` 模式下打印一条 trace 日志确认"cleanup complete"。
+
+#### Scenario: 父 shell 不受影响
+- **WHEN** 用户 `cc-switch use foo` 运行并退出后,回到 shell 执行 `env | grep ANTHROPIC_`
+- **THEN** 父 shell 中没有任何由 `foo` 新引入的 env 变量(只保留用户自己 export 的那些)
+
+#### Scenario: verbose trace
+- **WHEN** 用户执行 `cc-switch -v use foo`,`claude` 正常退出
+- **THEN** stderr 中可见一条 `cleanup complete` 或等价语义的 trace 行