ソースを参照

feat: initial zenmux-usage CLI with multi-account YAML config

Go CLI that renders ZenMux subscription quota windows (5h/7d/month) and
token USD value for one or more accounts configured in a YAML file.
Supports --account/--config/--api-key/--json/--no-color/--timeout flags
and distinct exit codes (0–7) for scripting.

Covered by automated tests: config parsing & validation, API client
status mapping, progress-bar rendering, single- and multi-account JSON
output, and every documented exit code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kotoyuuko 3 週間 前
コミット
955e114fe9
42 ファイル変更4098 行追加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. 24 0
      .gitignore
  10. 35 0
      Makefile
  11. 169 0
      README.md
  12. 288 0
      cmd/zenmux-usage/main.go
  13. 249 0
      cmd/zenmux-usage/main_test.go
  14. 17 0
      config.example.yaml
  15. 11 0
      go.mod
  16. 14 0
      go.sum
  17. 168 0
      internal/api/client.go
  18. 118 0
      internal/api/client_test.go
  19. 37 0
      internal/api/testdata/sample.json
  20. 188 0
      internal/config/config.go
  21. 186 0
      internal/config/config_test.go
  22. 29 0
      internal/config/perm_unix.go
  23. 8 0
      internal/config/perm_windows.go
  24. 5 0
      internal/config/testdata/duplicate_names.yaml
  25. 1 0
      internal/config/testdata/empty_accounts.yaml
  26. 3 0
      internal/config/testdata/malformed.yaml
  27. 5 0
      internal/config/testdata/missing_api_key.yaml
  28. 6 0
      internal/config/testdata/unknown_fields.yaml
  29. 5 0
      internal/config/testdata/valid_two_accounts.yaml
  30. 70 0
      internal/render/json.go
  31. 104 0
      internal/render/json_test.go
  32. 228 0
      internal/render/render.go
  33. 192 0
      internal/render/render_test.go
  34. 2 0
      openspec/changes/archive/2026-04-22-build-usage-cli/.openspec.yaml
  35. 159 0
      openspec/changes/archive/2026-04-22-build-usage-cli/design.md
  36. 35 0
      openspec/changes/archive/2026-04-22-build-usage-cli/proposal.md
  37. 87 0
      openspec/changes/archive/2026-04-22-build-usage-cli/specs/account-config/spec.md
  38. 112 0
      openspec/changes/archive/2026-04-22-build-usage-cli/specs/subscription-usage/spec.md
  39. 72 0
      openspec/changes/archive/2026-04-22-build-usage-cli/tasks.md
  40. 20 0
      openspec/config.yaml
  41. 85 0
      openspec/specs/account-config/spec.md
  42. 110 0
      openspec/specs/subscription-usage/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

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Binary
+/zenmux-usage
+/cmd/zenmux-usage/zenmux-usage
+
+# Release artifacts
+/dist/
+
+# User config (never commit real API keys)
+/config.yaml
+/.env
+
+# Editor/OS
+.DS_Store
+*.swp
+*.swo
+.idea/
+.vscode/
+
+# Claude Code local settings (machine-specific permissions)
+.claude/settings.local.json
+
+# Go build cache is typically outside repo but guard anyway
+*.test
+*.out

+ 35 - 0
Makefile

@@ -0,0 +1,35 @@
+BINARY := zenmux-usage
+PKG    := github.com/kotoyuuko/zenmux-usage-cli/cmd/zenmux-usage
+VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
+LDFLAGS := -s -w -X main.version=$(VERSION)
+
+.PHONY: build test vet lint run clean cross
+
+build:
+	go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/zenmux-usage
+
+test:
+	go test ./...
+
+vet:
+	go vet ./...
+
+lint: vet
+	@gofmt -l . | tee /tmp/gofmt.out; \
+	test ! -s /tmp/gofmt.out
+
+run: build
+	./$(BINARY)
+
+clean:
+	rm -f $(BINARY)
+	rm -rf dist/
+
+cross: clean
+	@mkdir -p dist
+	GOOS=darwin  GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/$(BINARY)-darwin-amd64  ./cmd/zenmux-usage
+	GOOS=darwin  GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/$(BINARY)-darwin-arm64  ./cmd/zenmux-usage
+	GOOS=linux   GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/$(BINARY)-linux-amd64   ./cmd/zenmux-usage
+	GOOS=linux   GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/$(BINARY)-linux-arm64   ./cmd/zenmux-usage
+	GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/$(BINARY)-windows-amd64.exe ./cmd/zenmux-usage
+	@ls -lh dist/

+ 169 - 0
README.md

@@ -0,0 +1,169 @@
+# zenmux-usage
+
+Terminal CLI for checking ZenMux subscription usage across one or more accounts.
+Shows the 5-hour, 7-day, and monthly quota windows plus the USD value of tokens
+consumed. Single static Go binary, no runtime dependencies.
+
+## Install
+
+```
+go install github.com/kotoyuuko/zenmux-usage-cli/cmd/zenmux-usage@latest
+```
+
+Or build from a checkout:
+
+```
+make build   # produces ./zenmux-usage
+make cross   # cross-compiles to dist/ for darwin/linux/windows
+```
+
+## Configure
+
+Copy the example config and add your ZenMux Management API keys (standard
+keys are rejected by the API — use a Management key):
+
+```
+mkdir -p ~/.config/zenmux-usage
+cp config.example.yaml ~/.config/zenmux-usage/config.yaml
+chmod 600 ~/.config/zenmux-usage/config.yaml
+```
+
+The CLI prints a stderr warning if the file has any group- or world-readable
+bits set. Keys are plaintext, so keep the file at `0600`.
+
+### Schema
+
+```yaml
+accounts:
+  - name: personal
+    api_key: sk-zm-...
+  - name: work
+    api_key: sk-zm-...
+```
+
+- `accounts` (required): list of one or more entries.
+- `name` (required): unique identifier used as the section header in output
+  and as the `--account` flag value.
+- `api_key` (required): ZenMux Management API key.
+- Unknown fields are ignored with a one-line stderr warning.
+
+### Alternative paths
+
+- `--config <path>` overrides the default location.
+- `$XDG_CONFIG_HOME/zenmux-usage/config.yaml` is honored when that env var is set.
+- Falls back to `~/.config/zenmux-usage/config.yaml`.
+
+### Zero-config fallback
+
+If no config file exists and `ZENMUX_MANAGEMENT_API_KEY` is set, the CLI runs
+in single-account mode (the account is labeled `env` in output).
+
+## Usage
+
+```
+zenmux-usage                   # render all configured accounts
+zenmux-usage --account work    # render one account from the config
+zenmux-usage --json            # machine-readable output
+zenmux-usage --api-key sk-...  # one-shot; ignore config file
+zenmux-usage --config ./c.yaml # explicit config path
+zenmux-usage --no-color        # strip ANSI
+zenmux-usage --timeout 5s      # HTTP timeout per account
+```
+
+### Example output
+
+```
+━━━ personal ━━━
+ZenMux Subscription — Ultra plan ($200/mo) · healthy · $0.03283/flow
+
+  5 hour  [██░░░░░░░░░░░░░░░░░░░░░░░░░░░░]    7.15%   57.2 / 800 flows      $1.88 / $26.26
+  7 day   [██░░░░░░░░░░░░░░░░░░░░░░░░░░░░]    6.73%   416.11 / 6182 flows   $13.66 / $202.95
+  month   [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]    n/a     — / 34560 flows       — / $1134.33
+
+  Tokens consumed (estimated USD value): $13.66
+  Next reset: 5h → 2026-04-22 22:05  ·  7d → 2026-04-28 08:00
+
+━━━ work ━━━
+ZenMux Subscription — Pro plan ($50/mo) · healthy · $0.01000/flow
+  ...
+```
+
+Bars are color-coded: green <60%, yellow 60–85%, red >85%.
+
+## Flags
+
+| Flag | Default | Purpose |
+| --- | --- | --- |
+| `--account <name>` | — | Filter to one account from the config |
+| `--config <path>` | `$XDG_CONFIG_HOME/zenmux-usage/config.yaml` | Override config file location |
+| `--api-key <key>` | — | Use this key directly; skip the config file |
+| `--json` | `false` | Emit JSON on stdout instead of the human layout |
+| `--no-color` | `false` | Disable ANSI colors (also honors `NO_COLOR`) |
+| `--timeout <dur>` | `10s` | HTTP timeout per account |
+| `--version` | — | Print version and exit |
+
+## Exit codes
+
+| Code | Meaning |
+| --- | --- |
+| `0` | All fetched accounts succeeded |
+| `1` | Unexpected error, **or** at least one account failed while others succeeded, **or** all accounts failed with mixed causes |
+| `2` | Invalid flags, unexpected positional args, or unknown `--account` name |
+| `3` | No account resolvable (no config file, no env var, no `--api-key`) |
+| `4` | Every fetched account failed with HTTP 401/403 (auth rejected) |
+| `5` | Every fetched account failed with HTTP 422 (rate limit) |
+| `6` | Every fetched account failed with network error or timeout |
+| `7` | Config file parse or schema error |
+
+**Rule for multi-account runs:** exit codes 4, 5, and 6 only apply when
+*every* account failed with the *same* cause. Any mix of successes and
+failures — or failures with different causes — yields exit code 1.
+Single-account runs always get the specific code.
+
+## JSON output
+
+`--json` emits different shapes depending on how many accounts were resolved.
+
+### Single account
+
+When only one account is fetched (via `--account`, `--api-key`, or the
+env-var fallback), the output is the raw API response unchanged:
+
+```bash
+zenmux-usage --api-key sk-zm-xxx --json | jq .data.plan.tier
+# "ultra"
+```
+
+### Multiple accounts
+
+When two or more accounts are fetched, the output is a JSON array ordered
+by config position. Each entry has `account`, `success`, `data`, and
+`error`. Failed accounts have `data: null` and a non-empty `error`.
+
+```bash
+zenmux-usage --json | jq '.[] | {account, pct: .data.quota_7_day.usage_percentage}'
+# {"account": "personal", "pct": 0.0673}
+# {"account": "work",     "pct": 0.4012}
+```
+
+```bash
+zenmux-usage --json | jq '.[] | select(.success == false) | .account'
+# "work"
+```
+
+## Development
+
+```
+make test    # run all tests
+make vet     # go vet
+make lint    # vet + gofmt check
+make cross   # build binaries for all supported targets under dist/
+```
+
+## API
+
+Uses `GET https://zenmux.ai/api/v1/management/subscription/detail`.
+See <https://zenmux.ai/docs/api/platform/subscription-detail.html>.
+
+The base URL can be overridden via `ZENMUX_BASE_URL` (useful for staging
+or local testing).

+ 288 - 0
cmd/zenmux-usage/main.go

@@ -0,0 +1,288 @@
+// Command zenmux-usage prints ZenMux subscription quota windows and the
+// USD value of tokens consumed for one or more configured accounts.
+package main
+
+import (
+	"context"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"time"
+
+	"github.com/fatih/color"
+	"github.com/mattn/go-isatty"
+
+	"github.com/kotoyuuko/zenmux-usage-cli/internal/api"
+	"github.com/kotoyuuko/zenmux-usage-cli/internal/config"
+	"github.com/kotoyuuko/zenmux-usage-cli/internal/render"
+)
+
+// version is overridable at build time: -ldflags "-X main.version=v0.1.0".
+var version = "dev"
+
+// Exit codes. See design §9 and specs/subscription-usage/spec.md.
+const (
+	exitOK          = 0
+	exitGeneric     = 1
+	exitUsage       = 2
+	exitNoAccount   = 3
+	exitAuth        = 4
+	exitRateLimited = 5
+	exitNetwork     = 6
+	exitConfigParse = 7
+)
+
+func main() {
+	os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
+}
+
+type opts struct {
+	account  string
+	config   string
+	apiKey   string
+	json     bool
+	noColor  bool
+	timeout  time.Duration
+	version  bool
+	// Internal: set when --config was explicitly provided (vs defaulted).
+	configExplicit bool
+}
+
+func run(args []string, stdout, stderr io.Writer) int {
+	o, proceed, code := parseFlags(args, stderr)
+	if !proceed {
+		return code
+	}
+	if o.version {
+		fmt.Fprintf(stdout, "zenmux-usage %s\n", version)
+		return exitOK
+	}
+
+	// Resolve accounts to fetch.
+	accounts, cfgLoaded, code := resolveAccounts(o, stderr)
+	if code != exitOK {
+		return code
+	}
+
+	// Permissions warning only fires when a config file was actually loaded.
+	if cfgLoaded != "" {
+		config.WarnIfLooseMode(cfgLoaded, stderr)
+	}
+
+	// Fetch all accounts sequentially.
+	client := api.NewClient(o.timeout)
+	if base := os.Getenv("ZENMUX_BASE_URL"); base != "" {
+		client.BaseURL = base
+	}
+	results, rawBodies := fetchAll(client, accounts, o.timeout)
+
+	// Render output.
+	if o.json {
+		if len(results) == 1 && results[0].Err == nil {
+			if err := render.RenderJSONSingle(stdout, rawBodies[0]); err != nil {
+				fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
+				return exitGeneric
+			}
+		} else {
+			if err := render.RenderJSONMulti(stdout, results); err != nil {
+				fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
+				return exitGeneric
+			}
+		}
+	} else {
+		useColor := shouldUseColor(o, stdout)
+		render.RenderAll(stdout, results, useColor)
+	}
+
+	return computeExitCode(results)
+}
+
+// parseFlags parses args into opts. Returns (opts, proceed, code):
+// proceed=true means run() should continue; proceed=false means return code
+// immediately (used for --help, --version, flag errors).
+func parseFlags(args []string, stderr io.Writer) (opts, bool, int) {
+	var o opts
+	fs := flag.NewFlagSet("zenmux-usage", flag.ContinueOnError)
+	fs.SetOutput(stderr)
+	fs.StringVar(&o.account, "account", "", "filter to a single account by name (from config)")
+	fs.StringVar(&o.config, "config", "", "path to config file (default: $XDG_CONFIG_HOME/zenmux-usage/config.yaml)")
+	fs.StringVar(&o.apiKey, "api-key", "", "use this API key directly; bypass config file")
+	fs.BoolVar(&o.json, "json", false, "emit JSON on stdout instead of the human layout")
+	fs.BoolVar(&o.noColor, "no-color", false, "disable ANSI colors in human output")
+	fs.DurationVar(&o.timeout, "timeout", 10*time.Second, "HTTP timeout per account")
+	fs.BoolVar(&o.version, "version", false, "print version and exit")
+	fs.Usage = func() {
+		fmt.Fprintf(stderr, "Usage: %s [flags]\n\nFlags:\n", fs.Name())
+		fs.PrintDefaults()
+	}
+
+	if err := fs.Parse(args); err != nil {
+		if errors.Is(err, flag.ErrHelp) {
+			return o, false, exitOK
+		}
+		return o, false, exitUsage
+	}
+	if fs.NArg() > 0 {
+		fmt.Fprintf(stderr, "zenmux-usage: unexpected positional arguments: %v\n", fs.Args())
+		return o, false, exitUsage
+	}
+	fs.Visit(func(f *flag.Flag) {
+		if f.Name == "config" {
+			o.configExplicit = true
+		}
+	})
+	if o.timeout <= 0 {
+		fmt.Fprintln(stderr, "zenmux-usage: --timeout must be positive")
+		return o, false, exitUsage
+	}
+	return o, true, exitOK
+}
+
+// resolveAccounts loads the config (if any) and produces the list of accounts
+// to fetch. Returns the list, the loaded-config path (for permission warning;
+// empty if no config file was read), and an exit code (exitOK on success).
+func resolveAccounts(o opts, stderr io.Writer) ([]config.Account, string, int) {
+	// --api-key short-circuits config loading entirely.
+	if o.apiKey != "" {
+		accs, err := config.Resolve(nil, config.ResolveFlags{APIKey: o.apiKey})
+		if err != nil {
+			fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
+			return nil, "", exitGeneric
+		}
+		return accs, "", exitOK
+	}
+
+	cfgPath := o.config
+	if cfgPath == "" {
+		cfgPath = config.DefaultPath()
+	}
+	var cfg *config.Config
+	loadedPath := ""
+	if cfgPath != "" {
+		c, err := config.Load(cfgPath, stderr)
+		switch {
+		case err == nil:
+			cfg = c
+			loadedPath = cfgPath
+		case errors.Is(err, config.ErrParse):
+			fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
+			return nil, "", exitConfigParse
+		case os.IsNotExist(err):
+			// Missing file: only fatal if caller explicitly asked for that path.
+			if o.configExplicit {
+				fmt.Fprintf(stderr, "zenmux-usage: config file not found: %s\n", cfgPath)
+				return nil, "", exitNoAccount
+			}
+			// Default path missing → fall through to env-var fallback.
+		default:
+			fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
+			return nil, "", exitGeneric
+		}
+	}
+
+	envKey := os.Getenv("ZENMUX_MANAGEMENT_API_KEY")
+	accs, err := config.Resolve(cfg, config.ResolveFlags{
+		AccountName: o.account,
+		EnvAPIKey:   envKey,
+	})
+	if err != nil {
+		switch {
+		case errors.Is(err, config.ErrAccountNotFound):
+			fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
+			return nil, "", exitUsage
+		case errors.Is(err, config.ErrNoAccount):
+			fmt.Fprintf(stderr,
+				"zenmux-usage: no account available. Create %s or set ZENMUX_MANAGEMENT_API_KEY.\n",
+				config.DefaultPath())
+			return nil, "", exitNoAccount
+		default:
+			fmt.Fprintf(stderr, "zenmux-usage: %v\n", err)
+			return nil, "", exitGeneric
+		}
+	}
+	return accs, loadedPath, exitOK
+}
+
+// fetchAll runs each account's request sequentially. Errors are attached
+// per-result rather than aborting the run.
+func fetchAll(client *api.Client, accounts []config.Account, timeout time.Duration) ([]render.AccountResult, [][]byte) {
+	results := make([]render.AccountResult, 0, len(accounts))
+	raws := make([][]byte, 0, len(accounts))
+	for _, acc := range accounts {
+		ctx, cancel := context.WithTimeout(context.Background(), timeout+time.Second)
+		resp, raw, err := client.FetchSubscriptionDetail(ctx, acc.APIKey)
+		cancel()
+		results = append(results, render.AccountResult{
+			Name:     acc.Name,
+			Response: resp,
+			Err:      err,
+		})
+		raws = append(raws, raw)
+	}
+	return results, raws
+}
+
+// computeExitCode applies the rules from design §9:
+//   - all succeeded → 0
+//   - all failed with the same cause → specific code (auth/rate/network)
+//   - mixed success+failure, or mixed causes → 1
+func computeExitCode(results []render.AccountResult) int {
+	if len(results) == 0 {
+		return exitGeneric
+	}
+	successes := 0
+	causeCounts := map[int]int{}
+	for _, r := range results {
+		if r.Err == nil {
+			successes++
+			continue
+		}
+		causeCounts[classify(r.Err)]++
+	}
+	if successes == len(results) {
+		return exitOK
+	}
+	if successes > 0 {
+		return exitGeneric // mixed success + failure
+	}
+	// All failed. Check single-cause.
+	if len(causeCounts) == 1 {
+		for code := range causeCounts {
+			return code
+		}
+	}
+	return exitGeneric
+}
+
+func classify(err error) int {
+	switch {
+	case errors.Is(err, api.ErrUnauthorized):
+		return exitAuth
+	case errors.Is(err, api.ErrRateLimited):
+		return exitRateLimited
+	case errors.Is(err, api.ErrTimeout):
+		return exitNetwork
+	default:
+		return exitGeneric
+	}
+}
+
+// shouldUseColor resolves the color decision: user flag wins, then NO_COLOR,
+// then stdout TTY detection.
+func shouldUseColor(o opts, stdout io.Writer) bool {
+	if o.noColor {
+		return false
+	}
+	if os.Getenv("NO_COLOR") != "" {
+		return false
+	}
+	// Prefer the file-descriptor test for the actual stdout.
+	if f, ok := stdout.(*os.File); ok {
+		return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
+	}
+	// Non-*os.File writer (e.g. test buffer) → stay monochrome.
+	_ = color.NoColor
+	return false
+}

+ 249 - 0
cmd/zenmux-usage/main_test.go

@@ -0,0 +1,249 @@
+package main
+
+import (
+	"bytes"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+// newTestServer returns an httptest server that maps API keys to status codes.
+// Keys ending with "-401" return 401; "-422" return 422; everything else returns 200 with the sample body.
+func newTestServer(t *testing.T) *httptest.Server {
+	t.Helper()
+	sample, err := os.ReadFile(filepath.Join("..", "..", "internal", "api", "testdata", "sample.json"))
+	if err != nil {
+		t.Fatalf("read sample: %v", err)
+	}
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		auth := r.Header.Get("Authorization")
+		switch {
+		case strings.HasSuffix(auth, "-401"):
+			w.WriteHeader(http.StatusUnauthorized)
+		case strings.HasSuffix(auth, "-422"):
+			w.WriteHeader(http.StatusUnprocessableEntity)
+		default:
+			w.Header().Set("Content-Type", "application/json")
+			_, _ = w.Write(sample)
+		}
+	}))
+}
+
+func writeConfig(t *testing.T, body string) string {
+	t.Helper()
+	dir := t.TempDir()
+	p := filepath.Join(dir, "config.yaml")
+	if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	return p
+}
+
+func TestRun_Version(t *testing.T) {
+	var out, errb bytes.Buffer
+	code := run([]string{"--version"}, &out, &errb)
+	if code != exitOK {
+		t.Errorf("exit = %d want %d", code, exitOK)
+	}
+	if !strings.Contains(out.String(), "zenmux-usage") {
+		t.Errorf("version output missing program name: %q", out.String())
+	}
+}
+
+func TestRun_UnknownAccountExits2(t *testing.T) {
+	cfg := writeConfig(t, `
+accounts:
+  - name: personal
+    api_key: sk-p
+`)
+	var out, errb bytes.Buffer
+	code := run([]string{"--config", cfg, "--account", "missing"}, &out, &errb)
+	if code != exitUsage {
+		t.Errorf("exit = %d want %d, stderr=%q", code, exitUsage, errb.String())
+	}
+	if !strings.Contains(errb.String(), "account not found") {
+		t.Errorf("stderr missing 'account not found': %q", errb.String())
+	}
+}
+
+func TestRun_NoConfigNoEnvExits3(t *testing.T) {
+	t.Setenv("ZENMUX_MANAGEMENT_API_KEY", "")
+	t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // point default path at an empty dir
+	var out, errb bytes.Buffer
+	code := run(nil, &out, &errb)
+	if code != exitNoAccount {
+		t.Errorf("exit = %d want %d, stderr=%q", code, exitNoAccount, errb.String())
+	}
+}
+
+func TestRun_ExplicitConfigMissingExits3(t *testing.T) {
+	var out, errb bytes.Buffer
+	code := run([]string{"--config", "/nonexistent/does/not/exist.yaml"}, &out, &errb)
+	if code != exitNoAccount {
+		t.Errorf("exit = %d want %d, stderr=%q", code, exitNoAccount, errb.String())
+	}
+}
+
+func TestRun_MalformedConfigExits7(t *testing.T) {
+	cfg := writeConfig(t, "accounts:\n  - name: a\n    api_key: [")
+	var out, errb bytes.Buffer
+	code := run([]string{"--config", cfg}, &out, &errb)
+	if code != exitConfigParse {
+		t.Errorf("exit = %d want %d, stderr=%q", code, exitConfigParse, errb.String())
+	}
+}
+
+func TestRun_InvalidTimeoutExits2(t *testing.T) {
+	var out, errb bytes.Buffer
+	code := run([]string{"--timeout", "0s"}, &out, &errb)
+	if code != exitUsage {
+		t.Errorf("exit = %d want %d", code, exitUsage)
+	}
+}
+
+func TestRun_UnexpectedPositionalExits2(t *testing.T) {
+	var out, errb bytes.Buffer
+	code := run([]string{"extra"}, &out, &errb)
+	if code != exitUsage {
+		t.Errorf("exit = %d want %d", code, exitUsage)
+	}
+}
+
+func TestRun_HelpExits0(t *testing.T) {
+	var out, errb bytes.Buffer
+	code := run([]string{"--help"}, &out, &errb)
+	if code != exitOK {
+		t.Errorf("exit = %d want %d", code, exitOK)
+	}
+}
+
+func TestRun_SingleAccountSuccess(t *testing.T) {
+	srv := newTestServer(t)
+	defer srv.Close()
+	t.Setenv("ZENMUX_BASE_URL", srv.URL)
+
+	var out, errb bytes.Buffer
+	code := run([]string{"--api-key", "sk-ok", "--no-color"}, &out, &errb)
+	if code != exitOK {
+		t.Fatalf("exit = %d want %d; stderr=%q", code, exitOK, errb.String())
+	}
+	if !strings.Contains(out.String(), "Ultra plan") {
+		t.Errorf("expected Ultra plan in output, got %q", out.String())
+	}
+}
+
+func TestRun_SingleAccountAuthFailExits4(t *testing.T) {
+	srv := newTestServer(t)
+	defer srv.Close()
+	t.Setenv("ZENMUX_BASE_URL", srv.URL)
+
+	var out, errb bytes.Buffer
+	code := run([]string{"--api-key", "sk-401", "--no-color"}, &out, &errb)
+	if code != exitAuth {
+		t.Fatalf("exit = %d want %d", code, exitAuth)
+	}
+}
+
+func TestRun_MultiAccountMixedExits1(t *testing.T) {
+	srv := newTestServer(t)
+	defer srv.Close()
+	t.Setenv("ZENMUX_BASE_URL", srv.URL)
+	cfg := writeConfig(t, `
+accounts:
+  - name: ok
+    api_key: sk-ok
+  - name: bad
+    api_key: sk-401
+`)
+
+	var out, errb bytes.Buffer
+	code := run([]string{"--config", cfg, "--no-color"}, &out, &errb)
+	if code != exitGeneric {
+		t.Fatalf("exit = %d want %d (mixed)", code, exitGeneric)
+	}
+	if !strings.Contains(out.String(), "━━━ ok ━━━") || !strings.Contains(out.String(), "━━━ bad ━━━") {
+		t.Errorf("both account headers should appear; got %q", out.String())
+	}
+	if !strings.Contains(out.String(), "error:") {
+		t.Errorf("failed account should have error line; got %q", out.String())
+	}
+}
+
+func TestRun_AllRateLimitedExits5(t *testing.T) {
+	srv := newTestServer(t)
+	defer srv.Close()
+	t.Setenv("ZENMUX_BASE_URL", srv.URL)
+	cfg := writeConfig(t, `
+accounts:
+  - name: a
+    api_key: sk-422
+  - name: b
+    api_key: sk-422
+`)
+	var out, errb bytes.Buffer
+	code := run([]string{"--config", cfg, "--no-color"}, &out, &errb)
+	if code != exitRateLimited {
+		t.Fatalf("exit = %d want %d", code, exitRateLimited)
+	}
+}
+
+func TestRun_JSONSingleAccountPassthrough(t *testing.T) {
+	srv := newTestServer(t)
+	defer srv.Close()
+	t.Setenv("ZENMUX_BASE_URL", srv.URL)
+
+	var out, errb bytes.Buffer
+	code := run([]string{"--api-key", "sk-ok", "--json"}, &out, &errb)
+	if code != exitOK {
+		t.Fatalf("exit = %d want %d", code, exitOK)
+	}
+	if !strings.Contains(out.String(), `"tier": "ultra"`) {
+		t.Errorf("expected raw passthrough including plan tier; got %q", out.String())
+	}
+}
+
+func TestRun_AllNetworkTimeoutExits6(t *testing.T) {
+	// Slow server forces a timeout.
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		time.Sleep(200 * time.Millisecond)
+	}))
+	defer srv.Close()
+	t.Setenv("ZENMUX_BASE_URL", srv.URL)
+
+	var out, errb bytes.Buffer
+	code := run([]string{"--api-key", "sk-slow", "--timeout", "10ms", "--no-color"}, &out, &errb)
+	if code != exitNetwork {
+		t.Fatalf("exit = %d want %d", code, exitNetwork)
+	}
+}
+
+func TestRun_JSONMultiAccount(t *testing.T) {
+	srv := newTestServer(t)
+	defer srv.Close()
+	t.Setenv("ZENMUX_BASE_URL", srv.URL)
+	cfg := writeConfig(t, `
+accounts:
+  - name: ok
+    api_key: sk-ok
+  - name: bad
+    api_key: sk-401
+`)
+	var out, errb bytes.Buffer
+	code := run([]string{"--config", cfg, "--json"}, &out, &errb)
+	// Mixed outcomes → 1
+	if code != exitGeneric {
+		t.Fatalf("exit = %d want %d", code, exitGeneric)
+	}
+	// Array shape.
+	o := strings.TrimSpace(out.String())
+	if !strings.HasPrefix(o, "[") || !strings.HasSuffix(o, "]") {
+		t.Errorf("expected JSON array, got %q", o)
+	}
+	if !strings.Contains(o, `"account": "ok"`) || !strings.Contains(o, `"account": "bad"`) {
+		t.Errorf("missing account keys in multi JSON: %q", o)
+	}
+}

+ 17 - 0
config.example.yaml

@@ -0,0 +1,17 @@
+# zenmux-usage configuration
+#
+# Install this file to $XDG_CONFIG_HOME/zenmux-usage/config.yaml
+# or ~/.config/zenmux-usage/config.yaml and set it to mode 0600:
+#   mkdir -p ~/.config/zenmux-usage
+#   cp config.example.yaml ~/.config/zenmux-usage/config.yaml
+#   chmod 600 ~/.config/zenmux-usage/config.yaml
+#
+# List one entry per ZenMux account. Names must be unique; api_key
+# must be a Management API key (standard keys are rejected by the API).
+
+accounts:
+  - name: personal
+    api_key: sk-zm-REPLACE-ME-personal
+
+  - name: work
+    api_key: sk-zm-REPLACE-ME-work

+ 11 - 0
go.mod

@@ -0,0 +1,11 @@
+module github.com/kotoyuuko/zenmux-usage-cli
+
+go 1.26.2
+
+require (
+	github.com/fatih/color v1.19.0 // indirect
+	github.com/mattn/go-colorable v0.1.14 // indirect
+	github.com/mattn/go-isatty v0.0.21 // indirect
+	golang.org/x/sys v0.42.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)

+ 14 - 0
go.sum

@@ -0,0 +1,14 @@
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
+github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+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=

+ 168 - 0
internal/api/client.go

@@ -0,0 +1,168 @@
+// Package api wraps the ZenMux subscription-detail endpoint.
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+)
+
+// DefaultBaseURL is the production ZenMux API.
+const DefaultBaseURL = "https://zenmux.ai"
+
+// Response mirrors the top-level JSON envelope returned by
+// GET /api/v1/management/subscription/detail.
+type Response struct {
+	Success bool   `json:"success"`
+	Data    Data   `json:"data"`
+	Error   string `json:"error,omitempty"`
+}
+
+// Data holds the populated fields when Success is true.
+type Data struct {
+	Plan                 Plan         `json:"plan"`
+	Currency             string       `json:"currency"`
+	BaseUSDPerFlow       float64      `json:"base_usd_per_flow"`
+	EffectiveUSDPerFlow  float64      `json:"effective_usd_per_flow"`
+	AccountStatus        string       `json:"account_status"`
+	Quota5Hour           QuotaWindow  `json:"quota_5_hour"`
+	Quota7Day            QuotaWindow  `json:"quota_7_day"`
+	QuotaMonthly         QuotaMonthly `json:"quota_monthly"`
+}
+
+// Plan describes the subscription tier.
+type Plan struct {
+	Tier      string  `json:"tier"`
+	AmountUSD float64 `json:"amount_usd"`
+	Interval  string  `json:"interval"`
+	ExpiresAt string  `json:"expires_at"`
+}
+
+// QuotaWindow is a rolling window (5-hour or 7-day) with used/max metrics.
+// ResetsAt is a pointer so we can distinguish null from the zero time.
+type QuotaWindow struct {
+	UsagePercentage float64 `json:"usage_percentage"`
+	ResetsAt        *string `json:"resets_at"`
+	MaxFlows        float64 `json:"max_flows"`
+	UsedFlows       float64 `json:"used_flows"`
+	RemainingFlows  float64 `json:"remaining_flows"`
+	UsedValueUSD    float64 `json:"used_value_usd"`
+	MaxValueUSD     float64 `json:"max_value_usd"`
+}
+
+// QuotaMonthly is the billing-cycle window. The API does not populate
+// used_* fields here, so we only model the max bounds.
+type QuotaMonthly struct {
+	MaxFlows    float64 `json:"max_flows"`
+	MaxValueUSD float64 `json:"max_value_usd"`
+}
+
+// Sentinel errors for transport and status mapping.
+var (
+	ErrUnauthorized = errors.New("authentication rejected")
+	ErrRateLimited  = errors.New("rate limited")
+	ErrTimeout      = errors.New("request timed out")
+	ErrServer       = errors.New("server error")
+	ErrBadResponse  = errors.New("malformed response")
+)
+
+// Client issues subscription-detail requests.
+type Client struct {
+	BaseURL    string
+	HTTPClient *http.Client
+}
+
+// NewClient returns a Client with a sensible default HTTP client.
+func NewClient(timeout time.Duration) *Client {
+	return &Client{
+		BaseURL: DefaultBaseURL,
+		HTTPClient: &http.Client{
+			Timeout: timeout,
+		},
+	}
+}
+
+// FetchSubscriptionDetail issues a single GET request using apiKey as the
+// bearer token. It returns the parsed response, the raw response body
+// (useful for --json passthrough), and a classified error.
+func (c *Client) FetchSubscriptionDetail(ctx context.Context, apiKey string) (*Response, []byte, error) {
+	endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/management/subscription/detail")
+	if err != nil {
+		return nil, nil, fmt.Errorf("build url: %w", err)
+	}
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, nil, fmt.Errorf("build request: %w", err)
+	}
+	req.Header.Set("Authorization", "Bearer "+apiKey)
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		if isTimeout(err) {
+			return nil, nil, fmt.Errorf("%w: %v", ErrTimeout, err)
+		}
+		return nil, nil, err
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, nil, fmt.Errorf("read body: %w", err)
+	}
+
+	switch resp.StatusCode {
+	case http.StatusUnauthorized, http.StatusForbidden:
+		return nil, body, fmt.Errorf("%w: HTTP %d", ErrUnauthorized, resp.StatusCode)
+	case http.StatusUnprocessableEntity, http.StatusTooManyRequests:
+		return nil, body, fmt.Errorf("%w: HTTP %d", ErrRateLimited, resp.StatusCode)
+	}
+	if resp.StatusCode >= 500 {
+		return nil, body, fmt.Errorf("%w: HTTP %d", ErrServer, resp.StatusCode)
+	}
+	if resp.StatusCode >= 400 {
+		return nil, body, fmt.Errorf("unexpected HTTP %d: %s", resp.StatusCode, trimBody(body))
+	}
+
+	var parsed Response
+	if err := json.Unmarshal(body, &parsed); err != nil {
+		return nil, body, fmt.Errorf("%w: %v", ErrBadResponse, err)
+	}
+	if !parsed.Success {
+		msg := parsed.Error
+		if msg == "" {
+			msg = "api returned success=false"
+		}
+		return &parsed, body, fmt.Errorf("%w: %s", ErrBadResponse, msg)
+	}
+	return &parsed, body, nil
+}
+
+// isTimeout unwraps net/url-style errors to check for a context or client timeout.
+func isTimeout(err error) bool {
+	if err == nil {
+		return false
+	}
+	if errors.Is(err, context.DeadlineExceeded) {
+		return true
+	}
+	var t interface{ Timeout() bool }
+	if errors.As(err, &t) {
+		return t.Timeout()
+	}
+	return false
+}
+
+// trimBody keeps error messages bounded.
+func trimBody(b []byte) string {
+	const max = 200
+	if len(b) <= max {
+		return string(b)
+	}
+	return string(b[:max]) + "..."
+}

+ 118 - 0
internal/api/client_test.go

@@ -0,0 +1,118 @@
+package api
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestFetchSubscriptionDetail_Success(t *testing.T) {
+	body, err := os.ReadFile(filepath.Join("testdata", "sample.json"))
+	if err != nil {
+		t.Fatalf("read sample: %v", err)
+	}
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
+			t.Errorf("Authorization = %q", got)
+		}
+		if r.URL.Path != "/api/v1/management/subscription/detail" {
+			t.Errorf("path = %q", r.URL.Path)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write(body)
+	}))
+	defer srv.Close()
+
+	c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()}
+	resp, raw, err := c.FetchSubscriptionDetail(context.Background(), "test-key")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !resp.Success {
+		t.Error("resp.Success = false")
+	}
+	if resp.Data.Plan.Tier != "ultra" {
+		t.Errorf("plan.tier = %q", resp.Data.Plan.Tier)
+	}
+	if resp.Data.Quota7Day.UsedValueUSD != 13.66 {
+		t.Errorf("quota_7_day.used_value_usd = %v", resp.Data.Quota7Day.UsedValueUSD)
+	}
+	if resp.Data.QuotaMonthly.MaxValueUSD != 1134.33 {
+		t.Errorf("quota_monthly.max_value_usd = %v", resp.Data.QuotaMonthly.MaxValueUSD)
+	}
+	if len(raw) != len(body) {
+		t.Errorf("raw body length mismatch: got %d want %d", len(raw), len(body))
+	}
+}
+
+func TestFetchSubscriptionDetail_StatusMapping(t *testing.T) {
+	cases := []struct {
+		name   string
+		status int
+		target error
+	}{
+		{"401", http.StatusUnauthorized, ErrUnauthorized},
+		{"403", http.StatusForbidden, ErrUnauthorized},
+		{"422", http.StatusUnprocessableEntity, ErrRateLimited},
+		{"429", http.StatusTooManyRequests, ErrRateLimited},
+		{"500", http.StatusInternalServerError, ErrServer},
+		{"502", http.StatusBadGateway, ErrServer},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(tc.status)
+				_, _ = w.Write([]byte(`{"success":false,"error":"x"}`))
+			}))
+			defer srv.Close()
+			c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()}
+			_, _, err := c.FetchSubscriptionDetail(context.Background(), "k")
+			if !errors.Is(err, tc.target) {
+				t.Fatalf("want %v, got %v", tc.target, err)
+			}
+		})
+	}
+}
+
+func TestFetchSubscriptionDetail_MalformedJSON(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(`{"success": not-json`))
+	}))
+	defer srv.Close()
+	c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()}
+	_, _, err := c.FetchSubscriptionDetail(context.Background(), "k")
+	if !errors.Is(err, ErrBadResponse) {
+		t.Fatalf("want ErrBadResponse, got %v", err)
+	}
+}
+
+func TestFetchSubscriptionDetail_SuccessFalse(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(`{"success":false,"error":"explicit failure"}`))
+	}))
+	defer srv.Close()
+	c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()}
+	_, _, err := c.FetchSubscriptionDetail(context.Background(), "k")
+	if !errors.Is(err, ErrBadResponse) {
+		t.Fatalf("want ErrBadResponse, got %v", err)
+	}
+}
+
+func TestFetchSubscriptionDetail_Timeout(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		time.Sleep(100 * time.Millisecond)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer srv.Close()
+	c := &Client{BaseURL: srv.URL, HTTPClient: &http.Client{Timeout: 5 * time.Millisecond}}
+	_, _, err := c.FetchSubscriptionDetail(context.Background(), "k")
+	if !errors.Is(err, ErrTimeout) {
+		t.Fatalf("want ErrTimeout, got %v", err)
+	}
+}

+ 37 - 0
internal/api/testdata/sample.json

@@ -0,0 +1,37 @@
+{
+  "success": true,
+  "data": {
+    "plan": {
+      "tier": "ultra",
+      "amount_usd": 200,
+      "interval": "month",
+      "expires_at": "2026-04-12T08:26:56.000Z"
+    },
+    "currency": "usd",
+    "base_usd_per_flow": 0.03283,
+    "effective_usd_per_flow": 0.03283,
+    "account_status": "healthy",
+    "quota_5_hour": {
+      "usage_percentage": 0.0715,
+      "resets_at": "2026-04-22T14:05:00.000Z",
+      "max_flows": 800,
+      "used_flows": 57.2,
+      "remaining_flows": 742.8,
+      "used_value_usd": 1.88,
+      "max_value_usd": 26.26
+    },
+    "quota_7_day": {
+      "usage_percentage": 0.0673,
+      "resets_at": "2026-04-28T00:00:00.000Z",
+      "max_flows": 6182,
+      "used_flows": 416.11,
+      "remaining_flows": 5765.89,
+      "used_value_usd": 13.66,
+      "max_value_usd": 202.95
+    },
+    "quota_monthly": {
+      "max_flows": 34560,
+      "max_value_usd": 1134.33
+    }
+  }
+}

+ 188 - 0
internal/config/config.go

@@ -0,0 +1,188 @@
+// Package config loads and resolves ZenMux accounts from a YAML file.
+package config
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+)
+
+// Account identifies a single ZenMux account the CLI can query.
+type Account struct {
+	Name   string `yaml:"name"`
+	APIKey string `yaml:"api_key"`
+}
+
+// Config is the on-disk YAML schema.
+type Config struct {
+	Accounts []Account `yaml:"accounts"`
+}
+
+// Sentinel errors. Wrapped with %w so callers can distinguish them.
+var (
+	ErrParse           = errors.New("config parse error")
+	ErrAccountNotFound = errors.New("account not found")
+	ErrNoAccount      = errors.New("no account available")
+)
+
+// DefaultPath returns the default config file path, honoring XDG_CONFIG_HOME
+// when set, and falling back to ~/.config/zenmux-usage/config.yaml.
+// Returns an empty string when the home directory cannot be determined.
+func DefaultPath() string {
+	if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
+		return filepath.Join(xdg, "zenmux-usage", "config.yaml")
+	}
+	home, err := os.UserHomeDir()
+	if err != nil || home == "" {
+		return ""
+	}
+	return filepath.Join(home, ".config", "zenmux-usage", "config.yaml")
+}
+
+// Load reads path, parses the YAML, validates the schema, and writes any
+// non-fatal warnings (e.g. unknown fields) to warnW.
+//
+// Returns a wrapped ErrParse on any schema or validation failure.
+// Filesystem errors (missing file, permission denied) are returned as-is
+// so callers can distinguish "no config" from "bad config".
+func Load(path string, warnW io.Writer) (*Config, error) {
+	raw, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+
+	// Decode into a generic map to detect unknown keys, then into the typed
+	// struct for the actual parse. yaml.v3's KnownFields(true) would fail
+	// hard on unknowns, but we want a warning, not a fatal error.
+	var loose map[string]any
+	if err := yaml.Unmarshal(raw, &loose); err != nil {
+		return nil, fmt.Errorf("%w: %v", ErrParse, err)
+	}
+
+	var cfg Config
+	if err := yaml.Unmarshal(raw, &cfg); err != nil {
+		return nil, fmt.Errorf("%w: %v", ErrParse, err)
+	}
+
+	warnUnknownFields(loose, warnW)
+
+	if err := validate(&cfg); err != nil {
+		return nil, err
+	}
+	return &cfg, nil
+}
+
+func validate(cfg *Config) error {
+	if len(cfg.Accounts) == 0 {
+		return fmt.Errorf("%w: accounts list must contain at least one entry", ErrParse)
+	}
+	seen := make(map[string]struct{}, len(cfg.Accounts))
+	for i, acc := range cfg.Accounts {
+		if strings.TrimSpace(acc.Name) == "" {
+			return fmt.Errorf("%w: accounts[%d].name is empty", ErrParse, i)
+		}
+		if strings.TrimSpace(acc.APIKey) == "" {
+			return fmt.Errorf("%w: accounts[%d] (%q) has empty api_key", ErrParse, i, acc.Name)
+		}
+		if _, dup := seen[acc.Name]; dup {
+			return fmt.Errorf("%w: duplicate account name %q", ErrParse, acc.Name)
+		}
+		seen[acc.Name] = struct{}{}
+	}
+	return nil
+}
+
+var (
+	knownTopLevel = map[string]struct{}{"accounts": {}}
+	knownAccount  = map[string]struct{}{"name": {}, "api_key": {}}
+)
+
+// warnUnknownFields writes one warning line to warnW for each unrecognized
+// top-level key and each unrecognized per-account key. Nothing is emitted
+// when warnW is nil.
+func warnUnknownFields(loose map[string]any, warnW io.Writer) {
+	if warnW == nil {
+		return
+	}
+	var unknownTop []string
+	for k := range loose {
+		if _, ok := knownTopLevel[k]; !ok {
+			unknownTop = append(unknownTop, k)
+		}
+	}
+	sort.Strings(unknownTop)
+	for _, k := range unknownTop {
+		fmt.Fprintf(warnW, "zenmux-usage: warning: unknown config field %q (ignored)\n", k)
+	}
+
+	rawAccounts, ok := loose["accounts"].([]any)
+	if !ok {
+		return
+	}
+	for i, raw := range rawAccounts {
+		accMap, ok := raw.(map[string]any)
+		if !ok {
+			continue
+		}
+		var unknownAcc []string
+		for k := range accMap {
+			if _, ok := knownAccount[k]; !ok {
+				unknownAcc = append(unknownAcc, k)
+			}
+		}
+		sort.Strings(unknownAcc)
+		for _, k := range unknownAcc {
+			fmt.Fprintf(warnW, "zenmux-usage: warning: unknown field %q in accounts[%d] (ignored)\n", k, i)
+		}
+	}
+}
+
+// ResolveFlags captures the CLI inputs that influence account resolution.
+type ResolveFlags struct {
+	APIKey      string // --api-key value, empty when unset
+	AccountName string // --account value, empty when unset
+	EnvAPIKey   string // ZENMUX_MANAGEMENT_API_KEY, empty when unset
+}
+
+// Resolve produces the ordered list of accounts to fetch, encoding the
+// precedence rules from the spec:
+//
+//  1. flags.APIKey set → a single synthetic "cli" account.
+//  2. cfg is non-nil → all accounts, optionally filtered by flags.AccountName.
+//  3. flags.EnvAPIKey set → a single synthetic "env" account.
+//  4. Otherwise → ErrNoAccount.
+//
+// An unknown AccountName returns a wrapped ErrAccountNotFound.
+func Resolve(cfg *Config, flags ResolveFlags) ([]Account, error) {
+	if flags.APIKey != "" {
+		return []Account{{Name: "cli", APIKey: flags.APIKey}}, nil
+	}
+	if cfg != nil {
+		if flags.AccountName != "" {
+			for _, acc := range cfg.Accounts {
+				if acc.Name == flags.AccountName {
+					return []Account{acc}, nil
+				}
+			}
+			available := make([]string, len(cfg.Accounts))
+			for i, acc := range cfg.Accounts {
+				available[i] = acc.Name
+			}
+			return nil, fmt.Errorf("%w: %q (available: %s)",
+				ErrAccountNotFound, flags.AccountName, strings.Join(available, ", "))
+		}
+		out := make([]Account, len(cfg.Accounts))
+		copy(out, cfg.Accounts)
+		return out, nil
+	}
+	if flags.EnvAPIKey != "" {
+		return []Account{{Name: "env", APIKey: flags.EnvAPIKey}}, nil
+	}
+	return nil, ErrNoAccount
+}

+ 186 - 0
internal/config/config_test.go

@@ -0,0 +1,186 @@
+package config
+
+import (
+	"bytes"
+	"errors"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestLoad_ValidTwoAccounts(t *testing.T) {
+	var warn bytes.Buffer
+	cfg, err := Load(filepath.Join("testdata", "valid_two_accounts.yaml"), &warn)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(cfg.Accounts) != 2 {
+		t.Fatalf("want 2 accounts, got %d", len(cfg.Accounts))
+	}
+	if cfg.Accounts[0].Name != "personal" || cfg.Accounts[1].Name != "work" {
+		t.Fatalf("wrong order or names: %+v", cfg.Accounts)
+	}
+	if warn.Len() != 0 {
+		t.Fatalf("expected no warnings, got %q", warn.String())
+	}
+}
+
+func TestLoad_DuplicateNames(t *testing.T) {
+	_, err := Load(filepath.Join("testdata", "duplicate_names.yaml"), nil)
+	if !errors.Is(err, ErrParse) {
+		t.Fatalf("want ErrParse, got %v", err)
+	}
+	if !strings.Contains(err.Error(), "duplicate") {
+		t.Fatalf("error message should mention duplicate, got %v", err)
+	}
+}
+
+func TestLoad_MissingAPIKey(t *testing.T) {
+	_, err := Load(filepath.Join("testdata", "missing_api_key.yaml"), nil)
+	if !errors.Is(err, ErrParse) {
+		t.Fatalf("want ErrParse, got %v", err)
+	}
+	if !strings.Contains(err.Error(), "api_key") {
+		t.Fatalf("error message should mention api_key, got %v", err)
+	}
+}
+
+func TestLoad_EmptyAccounts(t *testing.T) {
+	_, err := Load(filepath.Join("testdata", "empty_accounts.yaml"), nil)
+	if !errors.Is(err, ErrParse) {
+		t.Fatalf("want ErrParse, got %v", err)
+	}
+}
+
+func TestLoad_UnknownFieldsWarn(t *testing.T) {
+	var warn bytes.Buffer
+	cfg, err := Load(filepath.Join("testdata", "unknown_fields.yaml"), &warn)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(cfg.Accounts) != 1 {
+		t.Fatalf("want 1 account, got %d", len(cfg.Accounts))
+	}
+	warnings := warn.String()
+	if !strings.Contains(warnings, `"notifications"`) {
+		t.Errorf("expected top-level warning for notifications, got %q", warnings)
+	}
+	if !strings.Contains(warnings, `"color"`) {
+		t.Errorf("expected per-account warning for color, got %q", warnings)
+	}
+}
+
+func TestLoad_Malformed(t *testing.T) {
+	_, err := Load(filepath.Join("testdata", "malformed.yaml"), nil)
+	if !errors.Is(err, ErrParse) {
+		t.Fatalf("want ErrParse, got %v", err)
+	}
+}
+
+func TestLoad_MissingFile(t *testing.T) {
+	_, err := Load(filepath.Join("testdata", "does_not_exist.yaml"), nil)
+	if err == nil {
+		t.Fatal("expected error for missing file")
+	}
+	// Missing file is NOT an ErrParse — callers distinguish.
+	if errors.Is(err, ErrParse) {
+		t.Fatalf("missing file should not be ErrParse, got %v", err)
+	}
+}
+
+func TestResolve_APIKeyBeatsConfig(t *testing.T) {
+	cfg := &Config{Accounts: []Account{
+		{Name: "personal", APIKey: "sk-p"},
+		{Name: "work", APIKey: "sk-w"},
+	}}
+	accs, err := Resolve(cfg, ResolveFlags{APIKey: "sk-ad-hoc"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(accs) != 1 || accs[0].Name != "cli" || accs[0].APIKey != "sk-ad-hoc" {
+		t.Fatalf("want single cli account with ad-hoc key, got %+v", accs)
+	}
+}
+
+func TestResolve_AccountFilter(t *testing.T) {
+	cfg := &Config{Accounts: []Account{
+		{Name: "personal", APIKey: "sk-p"},
+		{Name: "work", APIKey: "sk-w"},
+	}}
+	accs, err := Resolve(cfg, ResolveFlags{AccountName: "work"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(accs) != 1 || accs[0].Name != "work" {
+		t.Fatalf("want work account, got %+v", accs)
+	}
+}
+
+func TestResolve_AccountNotFound(t *testing.T) {
+	cfg := &Config{Accounts: []Account{
+		{Name: "personal", APIKey: "sk-p"},
+	}}
+	_, err := Resolve(cfg, ResolveFlags{AccountName: "missing"})
+	if !errors.Is(err, ErrAccountNotFound) {
+		t.Fatalf("want ErrAccountNotFound, got %v", err)
+	}
+	if !strings.Contains(err.Error(), "personal") {
+		t.Fatalf("error should list available accounts, got %v", err)
+	}
+}
+
+func TestResolve_AllAccountsInOrder(t *testing.T) {
+	cfg := &Config{Accounts: []Account{
+		{Name: "a", APIKey: "1"},
+		{Name: "b", APIKey: "2"},
+		{Name: "c", APIKey: "3"},
+	}}
+	accs, err := Resolve(cfg, ResolveFlags{})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(accs) != 3 {
+		t.Fatalf("want 3 accounts, got %d", len(accs))
+	}
+	for i, want := range []string{"a", "b", "c"} {
+		if accs[i].Name != want {
+			t.Errorf("accs[%d].Name = %q, want %q", i, accs[i].Name, want)
+		}
+	}
+}
+
+func TestResolve_EnvFallback(t *testing.T) {
+	accs, err := Resolve(nil, ResolveFlags{EnvAPIKey: "sk-env"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(accs) != 1 || accs[0].Name != "env" || accs[0].APIKey != "sk-env" {
+		t.Fatalf("want env account, got %+v", accs)
+	}
+}
+
+func TestResolve_NoAccount(t *testing.T) {
+	_, err := Resolve(nil, ResolveFlags{})
+	if !errors.Is(err, ErrNoAccount) {
+		t.Fatalf("want ErrNoAccount, got %v", err)
+	}
+}
+
+func TestDefaultPath_XDGOverride(t *testing.T) {
+	t.Setenv("XDG_CONFIG_HOME", "/custom/xdg")
+	got := DefaultPath()
+	want := filepath.Join("/custom/xdg", "zenmux-usage", "config.yaml")
+	if got != want {
+		t.Errorf("DefaultPath() = %q, want %q", got, want)
+	}
+}
+
+func TestDefaultPath_HomeFallback(t *testing.T) {
+	t.Setenv("XDG_CONFIG_HOME", "")
+	t.Setenv("HOME", "/fake/home")
+	got := DefaultPath()
+	want := filepath.Join("/fake/home", ".config", "zenmux-usage", "config.yaml")
+	if got != want {
+		t.Errorf("DefaultPath() = %q, want %q", got, want)
+	}
+}

+ 29 - 0
internal/config/perm_unix.go

@@ -0,0 +1,29 @@
+//go:build !windows
+
+package config
+
+import (
+	"fmt"
+	"io"
+	"os"
+)
+
+// WarnIfLooseMode prints a one-line warning to w if path has any of the
+// group- or world-readable bits set (0044). POSIX only; no-op on Windows.
+// A missing file or stat error is silently ignored — callers have already
+// succeeded or failed on the file itself by this point.
+func WarnIfLooseMode(path string, w io.Writer) {
+	if w == nil || path == "" {
+		return
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		return
+	}
+	mode := info.Mode().Perm()
+	if mode&0o044 != 0 {
+		fmt.Fprintf(w,
+			"zenmux-usage: warning: config file %s has mode %04o; run `chmod 600 %s` to protect your API keys\n",
+			path, mode, path)
+	}
+}

+ 8 - 0
internal/config/perm_windows.go

@@ -0,0 +1,8 @@
+//go:build windows
+
+package config
+
+import "io"
+
+// WarnIfLooseMode is a no-op on Windows since POSIX mode bits do not apply.
+func WarnIfLooseMode(path string, w io.Writer) {}

+ 5 - 0
internal/config/testdata/duplicate_names.yaml

@@ -0,0 +1,5 @@
+accounts:
+  - name: personal
+    api_key: sk-zm-one
+  - name: personal
+    api_key: sk-zm-two

+ 1 - 0
internal/config/testdata/empty_accounts.yaml

@@ -0,0 +1 @@
+accounts: []

+ 3 - 0
internal/config/testdata/malformed.yaml

@@ -0,0 +1,3 @@
+accounts:
+  - name: personal
+    api_key: [this is not a string

+ 5 - 0
internal/config/testdata/missing_api_key.yaml

@@ -0,0 +1,5 @@
+accounts:
+  - name: personal
+    api_key: sk-zm-personal
+  - name: work
+    api_key: ""

+ 6 - 0
internal/config/testdata/unknown_fields.yaml

@@ -0,0 +1,6 @@
+notifications:
+  slack: "#usage"
+accounts:
+  - name: personal
+    api_key: sk-zm-personal
+    color: blue

+ 5 - 0
internal/config/testdata/valid_two_accounts.yaml

@@ -0,0 +1,5 @@
+accounts:
+  - name: personal
+    api_key: sk-zm-personal
+  - name: work
+    api_key: sk-zm-work

+ 70 - 0
internal/render/json.go

@@ -0,0 +1,70 @@
+package render
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+
+	"github.com/kotoyuuko/zenmux-usage-cli/internal/api"
+)
+
+// jsonAccountEntry is the shape of each element in multi-account JSON mode.
+type jsonAccountEntry struct {
+	Account string    `json:"account"`
+	Success bool      `json:"success"`
+	Data    *api.Data `json:"data"`
+	Error   *string   `json:"error"`
+}
+
+// RenderJSONSingle writes the raw API body to w with a trailing newline.
+// Used when exactly one account was resolved — preserves the exact shape
+// returned by the API so `jq .data.plan.tier` keeps working.
+func RenderJSONSingle(w io.Writer, raw []byte) error {
+	if _, err := w.Write(raw); err != nil {
+		return err
+	}
+	if len(raw) == 0 || raw[len(raw)-1] != '\n' {
+		if _, err := w.Write([]byte{'\n'}); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// RenderJSONMulti emits an ordered JSON array with one object per result.
+// Successful entries have data populated and error: null; failed entries
+// have data: null and a non-empty error message.
+func RenderJSONMulti(w io.Writer, results []AccountResult) error {
+	out := make([]jsonAccountEntry, len(results))
+	for i, r := range results {
+		entry := jsonAccountEntry{Account: r.Name}
+		switch {
+		case r.Err != nil:
+			entry.Success = false
+			msg := r.Err.Error()
+			entry.Error = &msg
+		case r.Response == nil:
+			entry.Success = false
+			msg := "no response"
+			entry.Error = &msg
+		default:
+			entry.Success = r.Response.Success
+			entry.Data = &r.Response.Data
+			if !r.Response.Success {
+				msg := r.Response.Error
+				if msg == "" {
+					msg = "api returned success=false"
+				}
+				entry.Error = &msg
+				entry.Data = nil
+			}
+		}
+		out[i] = entry
+	}
+	enc := json.NewEncoder(w)
+	enc.SetIndent("", "  ")
+	if err := enc.Encode(out); err != nil {
+		return fmt.Errorf("encode multi-account json: %w", err)
+	}
+	return nil
+}

+ 104 - 0
internal/render/json_test.go

@@ -0,0 +1,104 @@
+package render
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/kotoyuuko/zenmux-usage-cli/internal/api"
+)
+
+func TestRenderJSONSingle_BytePassthrough(t *testing.T) {
+	src, err := os.ReadFile(filepath.Join("..", "api", "testdata", "sample.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var buf bytes.Buffer
+	if err := RenderJSONSingle(&buf, src); err != nil {
+		t.Fatalf("RenderJSONSingle: %v", err)
+	}
+
+	// Output must parse as the same JSON body.
+	var orig, got map[string]any
+	if err := json.Unmarshal(src, &orig); err != nil {
+		t.Fatalf("parse src: %v", err)
+	}
+	if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
+		t.Fatalf("parse out: %v", err)
+	}
+	origStr, _ := json.Marshal(orig)
+	gotStr, _ := json.Marshal(got)
+	if string(origStr) != string(gotStr) {
+		t.Errorf("roundtrip mismatch:\nwant %s\n got %s", origStr, gotStr)
+	}
+
+	// Must end with a newline.
+	if buf.Len() == 0 || buf.Bytes()[buf.Len()-1] != '\n' {
+		t.Error("output should end with a newline")
+	}
+}
+
+func TestRenderJSONMulti_ShapeAndOrder(t *testing.T) {
+	src, _ := os.ReadFile(filepath.Join("..", "api", "testdata", "sample.json"))
+	var r api.Response
+	if err := json.Unmarshal(src, &r); err != nil {
+		t.Fatalf("parse sample: %v", err)
+	}
+
+	results := []AccountResult{
+		{Name: "personal", Response: &r},
+		{Name: "work", Err: errors.New("authentication rejected: HTTP 401")},
+		{Name: "extra", Response: &api.Response{Success: false, Error: "quota exhausted"}},
+	}
+	var buf bytes.Buffer
+	if err := RenderJSONMulti(&buf, results); err != nil {
+		t.Fatalf("RenderJSONMulti: %v", err)
+	}
+
+	var got []map[string]any
+	if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
+		t.Fatalf("parse out: %v", err)
+	}
+	if len(got) != 3 {
+		t.Fatalf("want 3 entries, got %d", len(got))
+	}
+	if got[0]["account"] != "personal" || got[1]["account"] != "work" || got[2]["account"] != "extra" {
+		t.Errorf("order wrong: %+v", got)
+	}
+
+	// personal: success=true, data non-null, error=null
+	if got[0]["success"] != true {
+		t.Error("personal.success should be true")
+	}
+	if got[0]["data"] == nil {
+		t.Error("personal.data should be populated")
+	}
+	if got[0]["error"] != nil {
+		t.Errorf("personal.error should be null, got %v", got[0]["error"])
+	}
+
+	// work: success=false, data=null, error non-empty
+	if got[1]["success"] != false {
+		t.Error("work.success should be false")
+	}
+	if got[1]["data"] != nil {
+		t.Error("work.data should be null")
+	}
+	if got[1]["error"] == nil || got[1]["error"].(string) == "" {
+		t.Error("work.error should be non-empty")
+	}
+
+	// extra: API-level failure (success=false in body). data=null, error from payload.
+	if got[2]["success"] != false {
+		t.Error("extra.success should be false")
+	}
+	if got[2]["data"] != nil {
+		t.Error("extra.data should be null")
+	}
+	if got[2]["error"].(string) != "quota exhausted" {
+		t.Errorf("extra.error = %q, want quota exhausted", got[2]["error"])
+	}
+}

+ 228 - 0
internal/render/render.go

@@ -0,0 +1,228 @@
+// Package render draws ZenMux subscription usage to the terminal.
+package render
+
+import (
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/fatih/color"
+
+	"github.com/kotoyuuko/zenmux-usage-cli/internal/api"
+)
+
+const (
+	barWidth  = 30
+	barFilled = "█"
+	barEmpty  = "░"
+	emDash    = "—"
+)
+
+// AccountResult bundles a single account's fetch outcome for RenderAll.
+type AccountResult struct {
+	Name     string
+	Response *api.Response
+	Err      error
+}
+
+// ProgressBar returns a width-char string of filled and empty glyphs
+// representing percent in [0, 1]. Out-of-range inputs are clamped.
+func ProgressBar(percent float64, width int) string {
+	if percent < 0 {
+		percent = 0
+	}
+	if percent > 1 {
+		percent = 1
+	}
+	filled := int(percent*float64(width) + 0.5)
+	if filled > width {
+		filled = width
+	}
+	return strings.Repeat(barFilled, filled) + strings.Repeat(barEmpty, width-filled)
+}
+
+// BandColor returns a fatih/color attribute based on usage thresholds:
+// green < 60%, yellow 60–85%, red > 85%.
+func BandColor(percent float64) color.Attribute {
+	switch {
+	case percent > 0.85:
+		return color.FgRed
+	case percent >= 0.60:
+		return color.FgYellow
+	default:
+		return color.FgGreen
+	}
+}
+
+// RenderAll writes one block per account, separated by a blank line.
+// useColor controls ANSI emission; callers handle TTY/NO_COLOR detection.
+func RenderAll(w io.Writer, results []AccountResult, useColor bool) {
+	setGlobalColor(useColor)
+	for i, r := range results {
+		if i > 0 {
+			fmt.Fprintln(w)
+		}
+		if r.Err != nil {
+			RenderAccountError(w, r.Name, r.Err, useColor)
+			continue
+		}
+		RenderAccount(w, r.Name, r.Response, useColor)
+	}
+}
+
+// setGlobalColor flips fatih/color's global NoColor flag.
+// The package is designed around this global; localizing it would require
+// wrapping every Sprint call, which the codebase is too small to justify.
+func setGlobalColor(useColor bool) {
+	color.NoColor = !useColor
+}
+
+// RenderAccount writes the header + three quota rows + token summary for a
+// single account. Caller must have already called setGlobalColor via RenderAll,
+// or set color.NoColor directly.
+func RenderAccount(w io.Writer, name string, resp *api.Response, useColor bool) {
+	writeHeader(w, name)
+	writePlanLine(w, resp)
+	fmt.Fprintln(w)
+
+	d := resp.Data
+	rows := []struct {
+		label   string
+		window  api.QuotaWindow
+		monthly bool
+	}{
+		{"5 hour", d.Quota5Hour, false},
+		{"7 day", d.Quota7Day, false},
+		{"month", api.QuotaWindow{
+			MaxFlows:    d.QuotaMonthly.MaxFlows,
+			MaxValueUSD: d.QuotaMonthly.MaxValueUSD,
+		}, true},
+	}
+
+	// Pre-format each row's segments so we can align by max width.
+	type formatted struct {
+		label, bar, pct, flows, dollars string
+		pctAttr                         color.Attribute
+	}
+	fmts := make([]formatted, len(rows))
+	for i, r := range rows {
+		f := formatted{label: r.label}
+		if r.monthly {
+			f.bar = ProgressBar(0, barWidth) // no used data available
+			f.pct = "  n/a  "
+			f.pctAttr = color.FgWhite
+			f.flows = fmt.Sprintf("%s / %s flows", emDash, fmtFlow(r.window.MaxFlows))
+			f.dollars = fmt.Sprintf("%s / %s", emDash, fmtUSD(r.window.MaxValueUSD))
+		} else {
+			f.bar = ProgressBar(r.window.UsagePercentage, barWidth)
+			f.pct = fmt.Sprintf("%6.2f%%", r.window.UsagePercentage*100)
+			f.pctAttr = BandColor(r.window.UsagePercentage)
+			f.flows = fmt.Sprintf("%s / %s flows", fmtFlow(r.window.UsedFlows), fmtFlow(r.window.MaxFlows))
+			f.dollars = fmt.Sprintf("%s / %s", fmtUSD(r.window.UsedValueUSD), fmtUSD(r.window.MaxValueUSD))
+		}
+		fmts[i] = f
+	}
+	flowsW, dollarsW := 0, 0
+	for _, f := range fmts {
+		if len(f.flows) > flowsW {
+			flowsW = len(f.flows)
+		}
+		if len(f.dollars) > dollarsW {
+			dollarsW = len(f.dollars)
+		}
+	}
+
+	for _, f := range fmts {
+		bar := color.New(f.pctAttr).Sprint(f.bar)
+		pct := color.New(f.pctAttr).Sprint(f.pct)
+		fmt.Fprintf(w, "  %-7s [%s]  %s   %-*s   %-*s\n",
+			f.label, bar, pct, flowsW, f.flows, dollarsW, f.dollars)
+	}
+
+	fmt.Fprintln(w)
+	tokens := fmtUSD(d.Quota7Day.UsedValueUSD)
+	fmt.Fprintf(w, "  Tokens consumed (estimated USD value): %s\n", tokens)
+
+	resets := resetsLine(d.Quota5Hour.ResetsAt, d.Quota7Day.ResetsAt)
+	if resets != "" {
+		fmt.Fprintf(w, "  Next reset: %s\n", resets)
+	}
+}
+
+// RenderAccountError writes the header block and a single red error line,
+// then returns. Used for per-account failures in multi-account runs.
+func RenderAccountError(w io.Writer, name string, err error, useColor bool) {
+	writeHeader(w, name)
+	msg := color.New(color.FgRed).Sprintf("  error: %v", err)
+	fmt.Fprintln(w, msg)
+}
+
+func writeHeader(w io.Writer, name string) {
+	label := fmt.Sprintf("━━━ %s ━━━", name)
+	fmt.Fprintln(w, color.New(color.Bold).Sprint(label))
+}
+
+func writePlanLine(w io.Writer, resp *api.Response) {
+	d := resp.Data
+	tier := titleCase(d.Plan.Tier)
+	status := colorizeStatus(d.AccountStatus)
+	fmt.Fprintf(w, "ZenMux Subscription — %s plan ($%.0f/mo) · %s · $%.5f/flow\n",
+		tier, d.Plan.AmountUSD, status, d.EffectiveUSDPerFlow)
+}
+
+// resetsLine composes "5h → 2026-04-22 14:05  ·  7d → 2026-04-28 00:00".
+// Missing or unparseable timestamps are skipped silently.
+func resetsLine(fiveHour, sevenDay *string) string {
+	parts := make([]string, 0, 2)
+	if s := formatReset(fiveHour); s != "" {
+		parts = append(parts, "5h → "+s)
+	}
+	if s := formatReset(sevenDay); s != "" {
+		parts = append(parts, "7d → "+s)
+	}
+	return strings.Join(parts, "  ·  ")
+}
+
+func formatReset(s *string) string {
+	if s == nil || *s == "" {
+		return ""
+	}
+	t, err := time.Parse(time.RFC3339, *s)
+	if err != nil {
+		return ""
+	}
+	return t.Local().Format("2006-01-02 15:04")
+}
+
+func colorizeStatus(status string) string {
+	switch status {
+	case "healthy":
+		return color.New(color.FgGreen).Sprint(status)
+	case "monitored":
+		return color.New(color.FgYellow).Sprint(status)
+	case "abusive", "suspended", "banned":
+		return color.New(color.FgRed).Sprint(status)
+	default:
+		return status
+	}
+}
+
+func titleCase(s string) string {
+	if s == "" {
+		return s
+	}
+	return strings.ToUpper(s[:1]) + s[1:]
+}
+
+// fmtFlow prints a flow count trimming trailing zeros but keeping
+// at most 2 decimal places so 57.2 stays "57.2", 800 stays "800",
+// and 416.11 stays "416.11".
+func fmtFlow(v float64) string {
+	return strconv.FormatFloat(v, 'f', -1, 64)
+}
+
+func fmtUSD(v float64) string {
+	return fmt.Sprintf("$%.2f", v)
+}

+ 192 - 0
internal/render/render_test.go

@@ -0,0 +1,192 @@
+package render
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"testing"
+
+	"github.com/kotoyuuko/zenmux-usage-cli/internal/api"
+)
+
+var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
+func stripANSI(s string) string { return ansiRE.ReplaceAllString(s, "") }
+
+func loadSample(t *testing.T) *api.Response {
+	t.Helper()
+	data, err := os.ReadFile(filepath.Join("..", "api", "testdata", "sample.json"))
+	if err != nil {
+		t.Fatalf("read sample: %v", err)
+	}
+	var r api.Response
+	if err := json.Unmarshal(data, &r); err != nil {
+		t.Fatalf("parse sample: %v", err)
+	}
+	return &r
+}
+
+func TestProgressBar(t *testing.T) {
+	cases := []struct {
+		pct   float64
+		want  string
+	}{
+		{0, strings.Repeat(barEmpty, barWidth)},
+		{1, strings.Repeat(barFilled, barWidth)},
+		{0.5, strings.Repeat(barFilled, 15) + strings.Repeat(barEmpty, 15)},
+		{-0.1, strings.Repeat(barEmpty, barWidth)},
+		{1.5, strings.Repeat(barFilled, barWidth)},
+	}
+	for _, tc := range cases {
+		got := ProgressBar(tc.pct, barWidth)
+		if got != tc.want {
+			t.Errorf("ProgressBar(%v) got %q want %q", tc.pct, got, tc.want)
+		}
+	}
+}
+
+func TestBandColor(t *testing.T) {
+	cases := []struct {
+		pct  float64
+		want string
+	}{
+		{0.10, "green"},
+		{0.59, "green"},
+		{0.60, "yellow"},
+		{0.85, "yellow"},
+		{0.86, "red"},
+		{1.00, "red"},
+	}
+	for _, tc := range cases {
+		attr := BandColor(tc.pct)
+		var got string
+		switch attr {
+		case 32: // FgGreen
+			got = "green"
+		case 33: // FgYellow
+			got = "yellow"
+		case 31: // FgRed
+			got = "red"
+		}
+		if got != tc.want {
+			t.Errorf("BandColor(%v) got %s want %s", tc.pct, got, tc.want)
+		}
+	}
+}
+
+func TestRenderAccount_Snapshot(t *testing.T) {
+	resp := loadSample(t)
+	var buf bytes.Buffer
+	setGlobalColor(false)
+	RenderAccount(&buf, "personal", resp, false)
+
+	got := stripANSI(buf.String())
+	wants := []string{
+		"━━━ personal ━━━",
+		"Ultra plan ($200/mo)",
+		"healthy",
+		"$0.03283/flow",
+		"5 hour",
+		"7 day",
+		"month",
+		"7.15%",
+		"6.73%",
+		" n/a",
+		"57.2 / 800 flows",
+		"416.11 / 6182 flows",
+		"— / 34560 flows",
+		"$1.88 / $26.26",
+		"— / $1134.33",
+		"Tokens consumed (estimated USD value): $13.66",
+	}
+	for _, w := range wants {
+		if !strings.Contains(got, w) {
+			t.Errorf("output missing %q\n---\n%s\n---", w, got)
+		}
+	}
+}
+
+func TestRenderAll_MultiAccountWithError(t *testing.T) {
+	resp := loadSample(t)
+	results := []AccountResult{
+		{Name: "personal", Response: resp},
+		{Name: "work", Err: errors.New("authentication rejected: HTTP 401")},
+	}
+	var buf bytes.Buffer
+	RenderAll(&buf, results, false)
+	got := stripANSI(buf.String())
+
+	if !strings.Contains(got, "━━━ personal ━━━") {
+		t.Error("missing personal header")
+	}
+	if !strings.Contains(got, "━━━ work ━━━") {
+		t.Error("missing work header")
+	}
+	if !strings.Contains(got, "error: authentication rejected") {
+		t.Error("missing error line for work account")
+	}
+	// Personal block comes before work block.
+	pIdx := strings.Index(got, "personal")
+	wIdx := strings.Index(got, "work")
+	if pIdx < 0 || wIdx < 0 || pIdx >= wIdx {
+		t.Errorf("account ordering wrong: personal@%d work@%d", pIdx, wIdx)
+	}
+}
+
+func TestRenderAccount_UsesColorWhenEnabled(t *testing.T) {
+	resp := loadSample(t)
+	var buf bytes.Buffer
+	setGlobalColor(true)
+	defer setGlobalColor(false)
+	RenderAccount(&buf, "personal", resp, true)
+	if !ansiRE.MatchString(buf.String()) {
+		t.Error("expected ANSI escape sequences when useColor=true")
+	}
+}
+
+func TestRenderAccount_NoColorWhenDisabled(t *testing.T) {
+	resp := loadSample(t)
+	var buf bytes.Buffer
+	setGlobalColor(false)
+	RenderAccount(&buf, "personal", resp, false)
+	if ansiRE.MatchString(buf.String()) {
+		t.Errorf("expected no ANSI when useColor=false, got %q", buf.String())
+	}
+}
+
+func TestBandColor_HighUsageRed(t *testing.T) {
+	// Sanity: a 90% usage window should render with a red bar/percent.
+	resp := &api.Response{
+		Success: true,
+		Data: api.Data{
+			Plan:                api.Plan{Tier: "pro", AmountUSD: 50},
+			AccountStatus:       "healthy",
+			EffectiveUSDPerFlow: 0.01,
+			Quota5Hour: api.QuotaWindow{
+				UsagePercentage: 0.92, MaxFlows: 100, UsedFlows: 92,
+				UsedValueUSD: 0.92, MaxValueUSD: 1.00,
+			},
+			Quota7Day: api.QuotaWindow{
+				UsagePercentage: 0.70, MaxFlows: 1000, UsedFlows: 700,
+				UsedValueUSD: 7.00, MaxValueUSD: 10.00,
+			},
+			QuotaMonthly: api.QuotaMonthly{MaxFlows: 4000, MaxValueUSD: 40},
+		},
+	}
+	var buf bytes.Buffer
+	setGlobalColor(true)
+	defer setGlobalColor(false)
+	RenderAccount(&buf, "n", resp, true)
+	out := buf.String()
+	// 31 = FgRed, 33 = FgYellow. Both should appear.
+	if !strings.Contains(out, "\x1b[31m") {
+		t.Error("expected red ANSI somewhere in output for 92% bar")
+	}
+	if !strings.Contains(out, "\x1b[33m") {
+		t.Error("expected yellow ANSI somewhere in output for 70% bar")
+	}
+}

+ 2 - 0
openspec/changes/archive/2026-04-22-build-usage-cli/.openspec.yaml

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

+ 159 - 0
openspec/changes/archive/2026-04-22-build-usage-cli/design.md

@@ -0,0 +1,159 @@
+## Context
+
+Greenfield Go project. Target is a single static binary that a developer runs at the terminal and gets back a snapshot of their ZenMux subscription usage across one or more accounts. The only upstream dependency is the ZenMux Management API (`GET /api/v1/management/subscription/detail`), which returns three rolling quota windows (`quota_5_hour`, `quota_7_day`, `quota_monthly`) along with plan, rate, and account status metadata. The API is authenticated via a Management API key passed as `Authorization: Bearer <key>`; each account has its own key.
+
+Accounts are configured via a YAML file; the CLI reads it once, fans out one HTTP request per account, and renders each account as a labeled block. There are no existing conventions in this repo yet — the design establishes them.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Single-command usage: `zenmux-usage` prints every configured account's three windows and USD value in under a second per account on a healthy network.
+- Readable at a glance: usage percentages are rendered as colored progress bars; absolute numbers (used/max flows, used/max USD) sit alongside; multiple accounts are clearly separated with a per-account header.
+- Scriptable: `--json` emits machine-readable output (array when multiple accounts, object when one) so it can be piped to `jq`.
+- Portable: pure-Go, cross-compiles to macOS/Linux/Windows with no runtime dependencies.
+- Clear errors: config-missing, auth failure, and rate-limit errors exit with distinct non-zero codes; a single failing account does NOT take down the whole run.
+- One config file, one command — no per-account flag juggling needed in the common case.
+
+**Non-Goals:**
+- Historical tracking, charting over time, or local caching — this is a point-in-time snapshot tool.
+- Managing API keys or editing the config file from within the CLI — read-only config consumer.
+- Watch/polling mode or TUI dashboard. (Could come later; not in this change.)
+- Supporting env-var-based per-account configuration (`ZENMUX_KEY_PERSONAL`, `ZENMUX_KEY_WORK`, ...). YAML file is the single source of truth for multi-account. The single env var is only a zero-config fallback.
+- Automatic encryption / keychain integration for the config file. Filesystem permissions are the user's responsibility in v1.
+
+## Decisions
+
+### 1. Language & module layout
+**Decision:** Go 1.22+; module path `github.com/kotoyuuko/zenmux-usage-cli` (placeholder — actual owner path can be swapped later). Layout:
+```
+cmd/zenmux-usage/main.go        // entrypoint, flag parsing, exit codes, fan-out
+internal/api/client.go          // HTTP client + typed response structs
+internal/api/client_test.go     // table-driven tests against httptest.Server
+internal/config/config.go       // YAML load + account resolution
+internal/config/config_test.go
+internal/render/render.go       // progress bars, per-account block, multi-account layout
+internal/render/render_test.go
+config.example.yaml             // committed example, no real keys
+```
+**Why:** `internal/` keeps packages unimportable outside this module — we're not publishing a library. `cmd/` is idiomatic for multi-binary repos. Config gets its own package so the CLI entrypoint stays thin.
+**Alternatives considered:** Flat package at root (simpler, but awkward to test rendering in isolation); `pkg/` (over-promises reusability).
+
+### 2. Config file schema
+**Decision:** Single YAML file, default path `~/.config/zenmux-usage/config.yaml` (XDG-style). Schema:
+
+```yaml
+accounts:
+  - name: personal
+    api_key: sk-zm-...
+  - name: work
+    api_key: sk-zm-...
+```
+
+- `accounts` (required, list, min length 1): each entry has `name` (required, unique, kebab- or snake-case) and `api_key` (required, non-empty string).
+- Unknown top-level keys are ignored with a one-line stderr warning (forward-compat).
+- No `default:` field in v1 — the default UX renders every account. Users can pin `--account` in a shell alias if they prefer one.
+
+**Why:** Flat list is the simplest structure that meets the requirement. A map (`accounts: {personal: {...}}`) would preserve uniqueness via YAML's own semantics but loses user-specified ordering, and ordering matters because the CLI renders accounts in file order.
+**Alternatives considered:** TOML (YAML is more common for this kind of user config and the yaml.v3 dep is tiny); map-of-accounts (loses order); per-account files in `~/.config/zenmux-usage/accounts.d/` (overkill).
+
+### 3. Config discovery and precedence
+**Decision:** Resolution order for *what to fetch*:
+
+1. `--api-key <key>` set → single ad-hoc account, name `cli`, no config file loaded.
+2. `--config <path>` set → load that file (error if missing/invalid).
+3. Default config at `~/.config/zenmux-usage/config.yaml` exists → load it.
+4. No config file, `ZENMUX_MANAGEMENT_API_KEY` set → single ad-hoc account, name `env`.
+5. None of the above → exit code 3 with a message pointing to the config path.
+
+If a config is loaded and `--account <name>` is set, filter to just that account (error if the name is not in the file).
+
+**Why:** Preserves a zero-config "set env var, run" path so users can try the binary before writing a config; the flag beats env beats config-file beats nothing in the usual CLI way. `--api-key` is explicitly highest precedence because it's a one-off override.
+
+### 4. HTTP client
+**Decision:** `net/http` standard library with a 10-second default timeout, configurable via `--timeout`. Decode into typed structs using `encoding/json`. No third-party HTTP client. One request per account, fired **sequentially** in v1; 1–5 accounts is the realistic upper bound and sequential keeps logs and errors simple.
+**Why:** One endpoint, no retry/backoff complexity. Sequential is fast enough for ≤5 accounts (~1s each) and avoids interleaved error messages.
+**Alternatives considered:** `errgroup` with bounded parallelism — marginal wall-clock gain, much noisier failure modes; revisit if users report large account lists.
+
+### 5. Terminal rendering
+**Decision:** Use `github.com/fatih/color` for ANSI color support with automatic no-TTY detection. Render progress bars by hand: a fixed width (default 30 chars) filled with `█` and empty with `░`, colorized by usage band (green < 60%, yellow 60–85%, red > 85%). For multiple accounts, separate blocks with a blank line and a bold header like `━━━ personal ━━━` so the eye can scan.
+**Why:** `fatih/color` handles `NO_COLOR`, Windows, and non-TTY. Hand-rolling the bar keeps us off the lipgloss/bubbletea dependency tree.
+**Alternatives considered:**
+- `charmbracelet/lipgloss` — overkill for fixed-width ASCII.
+- Pure stdlib ANSI codes — re-implementing NO_COLOR/non-TTY is annoying.
+
+### 6. Flag parsing
+**Decision:** Standard `flag` package. Flags: `--account <name>`, `--config <path>`, `--api-key <key>`, `--json`, `--no-color`, `--timeout <duration>` (default `10s`), `--version`.
+**Why:** Matches Go conventions, one flag set, no cobra/urfave/cli dependency needed for ~7 flags.
+
+### 7. Output format (human mode)
+**Decision:** For a single account, three sections, printed in this order:
+
+```
+━━━ personal ━━━
+ZenMux Subscription — Ultra plan ($200/mo) · healthy · $0.03283/flow
+
+  5 hour   [██░░░░░░░░░░░░░░░░░░░░░░░░░░░░]   7.15%   57.2 / 800 flows      $1.88 / $26.26
+  7 day    [██░░░░░░░░░░░░░░░░░░░░░░░░░░░░]   6.73%   416.11 / 6182 flows   $13.66 / $202.95
+  month    [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]    n/a    — / 34560 flows       — / $1134.33
+
+  Tokens consumed (estimated USD value): $13.66
+  Next reset: 5h → 2026-04-22 14:05  ·  7d → 2026-04-28 00:00
+```
+
+For multiple accounts, repeat the block with a blank line between accounts. A failed account still gets a header, but the body is replaced by a single red error line.
+
+**Trade-off:** The monthly window has `max_flows` and `max_value_usd` but no `used_*` fields per the spec. We render what we have and don't extrapolate. The "Tokens consumed" summary pulls from the 7-day `used_value_usd` since the monthly window lacks a used value.
+
+### 8. JSON mode (`--json`) shape
+**Decision:** Array of objects, one per account, in config order:
+
+```json
+[
+  {"account": "personal", "success": true, "data": {...}},
+  {"account": "work", "success": false, "error": "rate limited"}
+]
+```
+
+When only a single account is resolved (via `--account`, `--api-key`, or env var), emit a single object with no wrapping array — matching the raw API shape so `jq .data.plan.tier` keeps working.
+
+**Why:** Preserves the single-account pipe ergonomics while giving multi-account users a clean iterable. Per-account errors go into the JSON (not stderr) so scripts don't have to interleave streams.
+
+### 9. Exit codes
+**Decision:**
+- `0` — all fetched accounts succeeded
+- `1` — any other unexpected error, OR at least one account failed and the rest are uncertain
+- `2` — invalid arguments/flags
+- `3` — no config/key available, or config file not found when required
+- `4` — auth failure (401/403) — only when it's the sole failure mode across all accounts
+- `5` — rate limited (422) — same rule as above
+- `6` — network/timeout — same rule as above
+- `7` — config file parse error (malformed YAML or missing required fields)
+
+In multi-account mode, if accounts fail with mixed causes, the CLI uses exit code `1` and surfaces per-account error lines in the output. If all accounts fail with the *same* cause, the corresponding specific code is used.
+
+**Why:** Distinct codes help scripts branch. The "mixed failures → 1" rule keeps the mapping unambiguous.
+
+### 10. Testing
+**Decision:** Use `httptest.NewServer` for the client; rendering tested by snapshotting string output with ANSI stripped. Config tested with YAML fixtures under `internal/config/testdata/`. Keep the example JSON payload from the API docs as a golden fixture in `internal/api/testdata/`.
+**Why:** No real network or filesystem dependencies in tests. Snapshot-style assertions catch formatting regressions cheaply.
+
+## Risks / Trade-offs
+
+- **API schema drift** → If ZenMux adds/renames fields we miss them silently. *Mitigation:* treat unknown JSON fields as non-fatal (default `encoding/json` behavior), and log a one-line warning to stderr if `success: false`.
+- **Monthly `used_value_usd` absent** → Users may expect a monthly USD-burned figure. *Mitigation:* the "Tokens consumed USD" summary is explicitly scoped to the 7-day window; monthly row shows `—` rather than a faked computed value.
+- **Config contains plaintext keys** → Anyone with FS access can exfiltrate them. *Mitigation:* document `chmod 600`, include `.gitignore` entry, ship only `config.example.yaml`. On load, if file mode is world-readable on POSIX, print a stderr warning (not fatal).
+- **Sequential fetch latency** → N accounts × per-account latency. *Mitigation:* fine for the realistic N ≤ 5; parallel fan-out can be added later without a schema change.
+- **Terminal width** → Long numbers or narrow terminals break alignment. *Mitigation:* fixed 30-char bar, right-aligned numeric columns; no responsive layout for v1.
+- **Rate-limit (`422`)** → One account tripping rate limit could fail others if we share state. *Mitigation:* per-request HTTP client, no shared retry backoff, rate-limit is scoped to that account's result.
+- **Secret leakage via `--api-key` flag** → Visible in shell history and `ps`. *Mitigation:* prefer YAML config, document the flag as a one-off override only.
+- **YAML footguns** → Duplicate account names, typos in keys. *Mitigation:* validate on load: unique names required, required fields checked, unknown fields warned; fail fast with exit code 7.
+
+## Migration Plan
+
+N/A — net-new binary, nothing to migrate. First release ships as `v0.1.0`. A `config.example.yaml` is committed to the repo to lower onboarding friction.
+
+## Open Questions
+
+- Should the "Tokens consumed (estimated USD value)" line show the 7-day window (our current pick) or the 5-hour window? Defaulting to 7-day; revisit after first real-world use.
+- Should we support XDG's `$XDG_CONFIG_HOME` override in addition to the literal `~/.config/zenmux-usage/config.yaml` path? Leaning yes, trivial to implement — treat as a spec detail, not a separate decision.
+- Future: parallel fan-out for large account counts. Punt.

+ 35 - 0
openspec/changes/archive/2026-04-22-build-usage-cli/proposal.md

@@ -0,0 +1,35 @@
+## Why
+
+ZenMux users on paid tiers need a fast, terminal-native way to see how much of their rolling subscription quota they have burned through. The web dashboard is fine for occasional checks, but for developers iterating at the command line it is too many clicks away. A small Go CLI that hits `GET /api/v1/management/subscription/detail` and renders the 5-hour, 7-day, and monthly windows — plus the USD value of tokens consumed — gives immediate visibility without leaving the shell. Many users also run more than one ZenMux account (personal + work, or multiple projects), so the CLI needs to pull several accounts from a single config file and show them side by side.
+
+## What Changes
+
+- Introduce a new Go CLI binary `zenmux-usage` that renders ZenMux subscription quota data in the terminal.
+- **Multi-account support via a YAML config file** at `~/.config/zenmux-usage/config.yaml` listing one or more accounts (`name`, `api_key`). By default, the CLI fetches and renders every account in the file, one section per account.
+- Support flags:
+  - `--account <name>` — restrict output to a single named account from the config
+  - `--config <path>` — override the default config file path
+  - `--api-key <key>` — one-shot single-account override (bypasses config entirely)
+  - `--json` — raw passthrough of the API response(s); shape is an array when multiple accounts, single object when only one
+  - `--no-color` — disable ANSI
+  - `--timeout <duration>` — HTTP timeout
+- Render each account's three quota windows as labeled progress bars with usage percentage, used vs. max flows, and used USD value; below the bars show the plan, effective per-flow rate, and account status, plus the tokens-consumed USD summary.
+- Preserve a no-config fallback: if no config file exists and `ZENMUX_MANAGEMENT_API_KEY` is set, behave as single-account.
+- Handle common error cases (missing config/key, 401/403, 422 rate-limit, network timeout) with distinct exit codes and clear messages. With multiple accounts, one account's failure MUST NOT prevent the others from rendering.
+
+## Capabilities
+
+### New Capabilities
+- `subscription-usage`: Fetch ZenMux subscription detail and render quota windows and token USD value in the terminal.
+- `account-config`: Load one or more ZenMux accounts from a YAML config file and resolve which account(s) the CLI should act on for a given invocation.
+
+### Modified Capabilities
+<!-- None — greenfield project. -->
+
+## Impact
+
+- New Go module at repo root with `main.go` entrypoint under `cmd/zenmux-usage/`.
+- Dependencies: Go standard library for HTTP/JSON; `github.com/fatih/color` for terminal rendering; `gopkg.in/yaml.v3` for config parsing.
+- No backend or API changes — this consumes an existing ZenMux endpoint (one request per account).
+- Adds a `README.md` usage section documenting the YAML schema and an example `config.example.yaml` file committed to the repo.
+- Config file contains API keys, so documentation MUST advise `chmod 600` and avoid committing it.

+ 87 - 0
openspec/changes/archive/2026-04-22-build-usage-cli/specs/account-config/spec.md

@@ -0,0 +1,87 @@
+## ADDED Requirements
+
+### Requirement: Load accounts from a YAML config file
+
+The CLI SHALL read a YAML config file containing one or more ZenMux accounts. The default path SHALL be `$XDG_CONFIG_HOME/zenmux-usage/config.yaml`, falling back to `~/.config/zenmux-usage/config.yaml` when `XDG_CONFIG_HOME` is unset. The schema MUST be:
+
+```yaml
+accounts:
+  - name: <string, required, unique>
+    api_key: <string, required, non-empty>
+```
+
+The CLI SHALL validate that `accounts` has at least one entry, that every entry has a non-empty `name` and `api_key`, and that `name` values are unique. Unknown top-level or per-account fields MUST produce a single-line stderr warning but MUST NOT fail the load.
+
+#### Scenario: Valid config with multiple accounts
+- **WHEN** the default config file contains two accounts `personal` and `work`, each with a non-empty `api_key`
+- **THEN** the CLI loads both accounts in file order and exposes them to the fetch layer
+
+#### Scenario: Duplicate account names
+- **WHEN** the config file contains two accounts both named `personal`
+- **THEN** the CLI exits with code 7 and writes a message identifying the duplicate name to stderr
+
+#### Scenario: Missing required field
+- **WHEN** an account in the config has no `api_key` or an empty `api_key`
+- **THEN** the CLI exits with code 7 and writes a message identifying the offending account entry
+
+#### Scenario: Empty accounts list
+- **WHEN** the config file contains `accounts: []` or no `accounts` key
+- **THEN** the CLI exits with code 7 and writes a message to stderr
+
+#### Scenario: Unknown top-level field is ignored
+- **WHEN** the config file contains an unrecognized top-level key such as `notifications:`
+- **THEN** the CLI still loads successfully and writes a one-line stderr warning naming the unknown key
+
+### Requirement: Override config path with flag
+
+The CLI SHALL honor a `--config <path>` flag that overrides the default config file path. If the explicit path does not exist or cannot be read, the CLI SHALL exit with code 3.
+
+#### Scenario: Explicit config path
+- **WHEN** the user runs `zenmux-usage --config ./my-config.yaml`
+- **THEN** the CLI reads `./my-config.yaml` instead of the default location
+
+#### Scenario: Explicit config path missing
+- **WHEN** the user runs `zenmux-usage --config ./nope.yaml` and that file does not exist
+- **THEN** the CLI exits with code 3 and writes a message to stderr
+
+### Requirement: Resolve which account(s) to fetch
+
+The CLI SHALL resolve the set of accounts to fetch in this precedence:
+
+1. `--api-key <key>` set → a single synthetic account named `cli` using that key; config is not loaded.
+2. Otherwise, config is loaded (from `--config` or the default path). If `--account <name>` is provided, the set is filtered to that single matching account. If `--account` is not provided, the set is every account in the config, in file order.
+3. If no config file exists at the resolved path AND `ZENMUX_MANAGEMENT_API_KEY` is set, the CLI SHALL use a single synthetic account named `env` with that key.
+4. If none of the above yields at least one account, the CLI SHALL exit with code 3 and print a message directing the user to create the config file.
+
+#### Scenario: --api-key beats config
+- **WHEN** a valid config file exists and the user runs `zenmux-usage --api-key sk-ad-hoc`
+- **THEN** the CLI does not read the config file and fetches using only the `cli` synthetic account
+- **AND** the outgoing request uses `Authorization: Bearer sk-ad-hoc`
+
+#### Scenario: --account filters to a single entry
+- **WHEN** the config has accounts `personal` and `work`, and the user runs `zenmux-usage --account work`
+- **THEN** only the `work` account is fetched and rendered
+
+#### Scenario: --account name not found
+- **WHEN** the config has accounts `personal` and `work`, and the user runs `zenmux-usage --account missing`
+- **THEN** the CLI exits with code 2 and writes a message listing the available account names to stderr
+
+#### Scenario: No config, env var fallback
+- **WHEN** no config file exists at the default path and `ZENMUX_MANAGEMENT_API_KEY=env-key` is set
+- **THEN** the CLI fetches a single synthetic account named `env` using `env-key`
+
+#### Scenario: No config, no env
+- **WHEN** no config file exists at the default path and no `ZENMUX_MANAGEMENT_API_KEY` env var is set, and no `--api-key` flag is passed
+- **THEN** the CLI exits with code 3 and writes a message pointing the user at the default config path
+
+### Requirement: Warn on world-readable config permissions
+
+On POSIX systems, if the resolved config file has group- or world-readable mode bits set (i.e. mode has any of `0044` set), the CLI SHALL write a one-line stderr warning advising the user to run `chmod 600` on the file. The warning MUST NOT be fatal and MUST NOT be printed on Windows.
+
+#### Scenario: Config mode 0644
+- **WHEN** the config file permissions are `0644` on a POSIX system
+- **THEN** the CLI prints a one-line stderr warning about permissions and continues normally
+
+#### Scenario: Config mode 0600
+- **WHEN** the config file permissions are `0600`
+- **THEN** the CLI prints no permission warning

+ 112 - 0
openspec/changes/archive/2026-04-22-build-usage-cli/specs/subscription-usage/spec.md

@@ -0,0 +1,112 @@
+## ADDED Requirements
+
+### Requirement: Fetch subscription detail from ZenMux per account
+
+The CLI SHALL call `GET https://zenmux.ai/api/v1/management/subscription/detail` with an `Authorization: Bearer <key>` header for each resolved account and parse the JSON response into typed structures covering `plan`, `currency`, `base_usd_per_flow`, `effective_usd_per_flow`, `account_status`, `quota_5_hour`, `quota_7_day`, and `quota_monthly`. Accounts MUST be fetched sequentially in the order they were resolved. A failure on one account MUST NOT abort fetches for the remaining accounts.
+
+#### Scenario: Single-account fetch
+- **WHEN** the user runs `zenmux-usage` with exactly one resolved account
+- **THEN** the CLI issues exactly one GET request to the subscription detail endpoint with `Authorization: Bearer <key>` and parses `success: true` into typed structs
+
+#### Scenario: Multi-account fetch order
+- **WHEN** the user runs `zenmux-usage` with accounts `personal` then `work` resolved
+- **THEN** the CLI first fetches `personal`, then `work`, rendering each as soon as its response is available
+
+#### Scenario: Partial failure does not abort
+- **WHEN** three accounts are resolved and the second account returns HTTP 401
+- **THEN** the CLI still fetches the third account and renders blocks for all three (one with an error body)
+
+#### Scenario: Request respects a configurable timeout
+- **WHEN** the user runs `zenmux-usage --timeout 2s` and a server has not responded within 2 seconds
+- **THEN** the CLI aborts that account's request and records it as a timeout error while continuing with any remaining accounts
+
+### Requirement: Render three quota windows per account
+
+In human output mode the CLI SHALL render, for each resolved account, one labeled row per window (`5 hour`, `7 day`, `month`). Each row MUST include a fixed-width progress bar, the usage percentage to two decimals, the used-vs-max flows, and the used-vs-max USD value when the API provides them. Bars MUST be color-coded by usage band: green below 60%, yellow from 60% through 85%, red above 85%. The monthly window MUST render the `max_flows` and `max_value_usd` values and display `—` (em-dash) in place of used values since the API does not return `used_*` fields for that window.
+
+#### Scenario: Healthy 5-hour window
+- **WHEN** the API returns `quota_5_hour.usage_percentage = 0.0715`, `used_flows = 57.2`, `max_flows = 800`, `used_value_usd = 1.88`, `max_value_usd = 26.26` for an account
+- **THEN** that account's block contains a row labeled `5 hour`, a green-colored bar roughly 7% filled, `7.15%`, `57.2 / 800 flows`, and `$1.88 / $26.26`
+
+#### Scenario: Monthly window has no used values
+- **WHEN** the API returns `quota_monthly` with only `max_flows` and `max_value_usd` populated
+- **THEN** the monthly row prints `—` in place of used flows and used USD, and shows the `max_flows` and `max_value_usd` values
+
+#### Scenario: Color disabled via flag
+- **WHEN** the user runs `zenmux-usage --no-color`
+- **THEN** no ANSI color escape sequences appear in the output
+
+#### Scenario: Non-TTY output is automatically plain
+- **WHEN** stdout is piped or redirected to a file
+- **THEN** the CLI omits ANSI color escape sequences regardless of the `--no-color` flag
+
+### Requirement: Render per-account header block
+
+For every resolved account the CLI SHALL print, before the quota rows, a header that identifies the account by name followed by a plan/status line. The account header MUST be visually distinct (e.g., `━━━ <name> ━━━` with bold styling when colors are enabled). The plan line MUST include the plan tier (capitalized), the monthly `amount_usd`, the `account_status`, and the `effective_usd_per_flow` rate.
+
+#### Scenario: Single account header
+- **WHEN** one resolved account named `personal` returns `plan.tier = "ultra"`, `plan.amount_usd = 200`, `account_status = "healthy"`, `effective_usd_per_flow = 0.03283`
+- **THEN** the output contains a header block showing `personal`, and a plan line containing `Ultra plan`, `$200/mo`, `healthy`, and `$0.03283/flow`
+
+#### Scenario: Multiple accounts separated by blank lines
+- **WHEN** two accounts `personal` and `work` are rendered in human mode
+- **THEN** each account has its own header block and the two blocks are separated by at least one blank line, in the order they were resolved
+
+### Requirement: Display token USD value consumed per account
+
+Below each account's three quota rows the CLI SHALL print a summary line labeled "Tokens consumed (estimated USD value)" showing the `used_value_usd` from the 7-day window for that account, formatted as USD to two decimals.
+
+#### Scenario: 7-day used value present
+- **WHEN** an account's `quota_7_day.used_value_usd = 13.66`
+- **THEN** that account's summary line reads `Tokens consumed (estimated USD value): $13.66`
+
+### Requirement: JSON passthrough mode
+
+When invoked with `--json` the CLI SHALL write machine-readable JSON to stdout and MUST NOT print any human-formatted output.
+
+- If exactly one account was resolved, the output MUST be the single API response body unchanged (pretty-printing is not required; a trailing newline is permitted).
+- If more than one account was resolved, the output MUST be a JSON array whose elements are objects with shape `{"account": <name>, "success": <bool>, "data": <API data object or null>, "error": <string or null>}` in the order accounts were resolved. Successful accounts MUST have `error: null`; failed accounts MUST have `data: null` and a human-readable `error` string.
+
+Exit codes behave identically to human mode.
+
+#### Scenario: Single-account JSON
+- **WHEN** exactly one account is resolved and the user runs `zenmux-usage --json | jq .data.plan.tier`
+- **THEN** `jq` receives valid JSON and extracts the plan tier from `data.plan.tier`
+
+#### Scenario: Multi-account JSON
+- **WHEN** two accounts `personal` and `work` are resolved and `work` returns HTTP 429
+- **THEN** stdout is a JSON array of length 2; the `personal` element has `success: true` and a populated `data` field; the `work` element has `success: false`, `data: null`, and a non-null `error`
+
+#### Scenario: JSON mode on full failure
+- **WHEN** the CLI fails before any account was fetched (e.g., config parse error)
+- **THEN** the CLI writes the error message to stderr, exits with the corresponding non-zero code, and writes nothing to stdout
+
+### Requirement: Map API and transport failures to distinct exit codes
+
+The CLI SHALL exit with these codes and write a single human-readable line to stderr for CLI-global failures:
+
+- `2` — invalid flag or argument (including unknown `--account` name)
+- `3` — no account resolvable (missing config and env, or explicit `--config` file missing)
+- `4` — authentication rejected (HTTP 401 or 403) — only when every fetched account failed with this cause
+- `5` — rate limited (HTTP 422) — only when every fetched account failed with this cause
+- `6` — network error or timeout — only when every fetched account failed with this cause
+- `7` — config file parse error or schema validation failure
+- `1` — any other unexpected error, or a mix of failure causes across accounts, or at least one account failed while others succeeded
+
+On `success: false` in a response body the CLI SHALL treat that account as a failure and surface any error message from the payload.
+
+#### Scenario: All accounts auth-rejected
+- **WHEN** every resolved account returns HTTP 401
+- **THEN** the CLI exits with code 4 after rendering each account's error block
+
+#### Scenario: Mixed outcomes
+- **WHEN** account `personal` returns 200 and account `work` returns 401
+- **THEN** the CLI exits with code 1 after rendering both accounts
+
+#### Scenario: All transport timeouts
+- **WHEN** every resolved account's HTTP request exceeds the configured timeout
+- **THEN** the CLI exits with code 6 after rendering each account's timeout block
+
+#### Scenario: Single-account run preserves specific code
+- **WHEN** a single-account run (`--account work`, `--api-key`, or env-var fallback) fails with HTTP 401
+- **THEN** the CLI exits with code 4

+ 72 - 0
openspec/changes/archive/2026-04-22-build-usage-cli/tasks.md

@@ -0,0 +1,72 @@
+## 1. Project scaffolding
+
+- [x] 1.1 Initialize Go module at repo root (`go mod init github.com/kotoyuuko/zenmux-usage-cli`, Go 1.22+)
+- [x] 1.2 Create directory skeleton: `cmd/zenmux-usage/`, `internal/api/`, `internal/config/`, `internal/render/`, `internal/api/testdata/`, `internal/config/testdata/`
+- [x] 1.3 Add dependencies via `go get`: `github.com/fatih/color`, `gopkg.in/yaml.v3`; commit `go.mod`/`go.sum`
+- [x] 1.4 Add `.gitignore` covering `zenmux-usage` binary, `dist/`, `config.yaml` (but not `config.example.yaml`)
+- [x] 1.5 Commit a `config.example.yaml` showing the two-account schema with placeholder keys
+- [x] 1.6 Add a minimal `README.md` with install, config schema, env-var fallback, and example output sections
+
+## 2. Config loader (`internal/config`)
+
+- [x] 2.1 Define structs `Config{Accounts []Account}` and `Account{Name, APIKey string}` with `yaml:"..."` tags
+- [x] 2.2 Implement `DefaultPath() string` honoring `$XDG_CONFIG_HOME` with fallback to `~/.config/zenmux-usage/config.yaml`
+- [x] 2.3 Implement `Load(path string) (*Config, error)` that parses YAML, validates required fields and unique names, and returns a typed `ErrParse` on schema/validation failure
+- [x] 2.4 Emit a stderr warning (not fatal) for unknown top-level or per-account keys
+- [x] 2.5 Implement `WarnIfLooseMode(path string, w io.Writer)` that checks POSIX mode bits and prints a one-line warning if `0044` bits are set; no-op on Windows
+- [x] 2.6 Implement `Resolve(cfg *Config, flag ResolveFlags) ([]Account, error)` encoding the precedence: `--api-key` → `--account` filter → all accounts → env-var fallback → error
+- [x] 2.7 Write tests with fixtures under `internal/config/testdata/`: valid two-account, duplicate names, missing `api_key`, empty accounts list, unknown field warning, env-var fallback path, `--account` miss
+
+## 3. API client (`internal/api`)
+
+- [x] 3.1 Define response structs (`Response`, `Data`, `Plan`, `QuotaWindow`, `QuotaMonthly`) matching the documented JSON exactly
+- [x] 3.2 Implement `Client` with configurable base URL, HTTP client, and timeout; default base URL `https://zenmux.ai`
+- [x] 3.3 Implement `FetchSubscriptionDetail(ctx, apiKey) (*Response, []byte, error)` returning parsed struct, raw body (for `--json` passthrough), and error
+- [x] 3.4 Map HTTP status codes to typed error values: `ErrUnauthorized` (401/403), `ErrRateLimited` (422), wrap timeouts as `ErrTimeout`
+- [x] 3.5 Save example payload from the API docs to `internal/api/testdata/sample.json`
+- [x] 3.6 Table-driven tests with `httptest.Server`: success, 401, 403, 422, 500, timeout, malformed JSON
+
+## 4. Rendering (`internal/render`)
+
+- [x] 4.1 Implement `ProgressBar(percent float64, width int) string` returning the `█`/`░` glyph string
+- [x] 4.2 Implement `BandColor(percent float64)` returning green/yellow/red attribute based on the 60%/85% thresholds
+- [x] 4.3 Implement `RenderAccount(w io.Writer, name string, resp *Response, useColor bool)` producing the header block + three quota rows + token USD summary
+- [x] 4.4 Implement `RenderAccountError(w io.Writer, name string, err error, useColor bool)` printing the header and a single red error line
+- [x] 4.5 Implement `RenderAll(w io.Writer, results []AccountResult, useColor bool)` that iterates results and inserts a blank line between account blocks
+- [x] 4.6 Format USD values with `$%.2f`, flows preserving up to 2 decimal places; right-align numeric columns
+- [x] 4.7 Render the monthly row with `—` for used values (no `used_*` fields in API)
+- [x] 4.8 TTY detection: disable colors when stdout is not a terminal, when `--no-color` is set, or when `NO_COLOR` env is present
+- [x] 4.9 Snapshot tests with ANSI stripped: single account success, multi-account mixed (success + error), each color band
+
+## 5. JSON output
+
+- [x] 5.1 Implement `RenderJSONSingle(w io.Writer, raw []byte)` that writes the raw API body followed by a newline
+- [x] 5.2 Implement `RenderJSONMulti(w io.Writer, results []AccountResult)` emitting `[{account, success, data, error}, ...]` in resolved order
+- [x] 5.3 Ensure failed accounts in multi mode have `data: null` and a non-empty `error` string
+- [x] 5.4 Tests: single-account passthrough is byte-identical to input; multi-account array shape and ordering
+
+## 6. CLI entrypoint (`cmd/zenmux-usage/main.go`)
+
+- [x] 6.1 Parse flags with stdlib `flag`: `--account`, `--config`, `--api-key`, `--json`, `--no-color`, `--timeout` (default `10s`), `--version`
+- [x] 6.2 Invoke `config.Resolve` to produce `[]Account`; handle exit codes 2/3/7 from its errors
+- [x] 6.3 Emit the permissions warning via `config.WarnIfLooseMode` when a config file was actually loaded
+- [x] 6.4 Iterate accounts sequentially, calling `FetchSubscriptionDetail`, collecting results (including per-account errors)
+- [x] 6.5 Dispatch to JSON or human renderer based on `--json` and the single-vs-multi account count
+- [x] 6.6 Compute the final exit code per design §9 (all-same-cause specific code, mixed → 1, all success → 0)
+- [x] 6.7 Write all error messages to stderr; never write partial human output before a CLI-global error
+- [x] 6.8 Stamp `--version` output with a `version` constant (ldflags-overridable for release builds)
+
+## 7. Integration and packaging
+
+- [x] 7.1 Add a `Makefile` (or `justfile`) with `build`, `test`, `lint`, `run` targets
+- [x] 7.2 Verify `go vet ./...` and `go test ./...` pass cleanly
+- [x] 7.3 Cross-compile sanity check for `darwin/amd64`, `darwin/arm64`, `linux/amd64` via `GOOS`/`GOARCH`
+- [x] 7.4 Manual end-to-end: populate config with two real accounts, run `zenmux-usage`, confirm both blocks render and USD summaries match; run `zenmux-usage --account <name>` to verify filtering; run `zenmux-usage --json | jq '.[0].data.plan.tier'`
+- [x] 7.5 Verify each exit code manually: unknown `--account` (2), no config + no env (3), bad key single-account (4), invalid timeout single-account (6), malformed YAML (7), mixed outcomes (1)
+
+## 8. Documentation
+
+- [x] 8.1 Update `README.md` with a screenshot or ASCII sample showing a multi-account render
+- [x] 8.2 Document the YAML config schema, default path, `--config` flag, and `chmod 600` recommendation
+- [x] 8.3 Document exit codes and the multi- vs single-account exit-code rule
+- [x] 8.4 Document `--json` mode shape differences between single and multi account, with one `jq` example for each

+ 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

+ 85 - 0
openspec/specs/account-config/spec.md

@@ -0,0 +1,85 @@
+### Requirement: Load accounts from a YAML config file
+
+The CLI SHALL read a YAML config file containing one or more ZenMux accounts. The default path SHALL be `$XDG_CONFIG_HOME/zenmux-usage/config.yaml`, falling back to `~/.config/zenmux-usage/config.yaml` when `XDG_CONFIG_HOME` is unset. The schema MUST be:
+
+```yaml
+accounts:
+  - name: <string, required, unique>
+    api_key: <string, required, non-empty>
+```
+
+The CLI SHALL validate that `accounts` has at least one entry, that every entry has a non-empty `name` and `api_key`, and that `name` values are unique. Unknown top-level or per-account fields MUST produce a single-line stderr warning but MUST NOT fail the load.
+
+#### Scenario: Valid config with multiple accounts
+- **WHEN** the default config file contains two accounts `personal` and `work`, each with a non-empty `api_key`
+- **THEN** the CLI loads both accounts in file order and exposes them to the fetch layer
+
+#### Scenario: Duplicate account names
+- **WHEN** the config file contains two accounts both named `personal`
+- **THEN** the CLI exits with code 7 and writes a message identifying the duplicate name to stderr
+
+#### Scenario: Missing required field
+- **WHEN** an account in the config has no `api_key` or an empty `api_key`
+- **THEN** the CLI exits with code 7 and writes a message identifying the offending account entry
+
+#### Scenario: Empty accounts list
+- **WHEN** the config file contains `accounts: []` or no `accounts` key
+- **THEN** the CLI exits with code 7 and writes a message to stderr
+
+#### Scenario: Unknown top-level field is ignored
+- **WHEN** the config file contains an unrecognized top-level key such as `notifications:`
+- **THEN** the CLI still loads successfully and writes a one-line stderr warning naming the unknown key
+
+### Requirement: Override config path with flag
+
+The CLI SHALL honor a `--config <path>` flag that overrides the default config file path. If the explicit path does not exist or cannot be read, the CLI SHALL exit with code 3.
+
+#### Scenario: Explicit config path
+- **WHEN** the user runs `zenmux-usage --config ./my-config.yaml`
+- **THEN** the CLI reads `./my-config.yaml` instead of the default location
+
+#### Scenario: Explicit config path missing
+- **WHEN** the user runs `zenmux-usage --config ./nope.yaml` and that file does not exist
+- **THEN** the CLI exits with code 3 and writes a message to stderr
+
+### Requirement: Resolve which account(s) to fetch
+
+The CLI SHALL resolve the set of accounts to fetch in this precedence:
+
+1. `--api-key <key>` set → a single synthetic account named `cli` using that key; config is not loaded.
+2. Otherwise, config is loaded (from `--config` or the default path). If `--account <name>` is provided, the set is filtered to that single matching account. If `--account` is not provided, the set is every account in the config, in file order.
+3. If no config file exists at the resolved path AND `ZENMUX_MANAGEMENT_API_KEY` is set, the CLI SHALL use a single synthetic account named `env` with that key.
+4. If none of the above yields at least one account, the CLI SHALL exit with code 3 and print a message directing the user to create the config file.
+
+#### Scenario: --api-key beats config
+- **WHEN** a valid config file exists and the user runs `zenmux-usage --api-key sk-ad-hoc`
+- **THEN** the CLI does not read the config file and fetches using only the `cli` synthetic account
+- **AND** the outgoing request uses `Authorization: Bearer sk-ad-hoc`
+
+#### Scenario: --account filters to a single entry
+- **WHEN** the config has accounts `personal` and `work`, and the user runs `zenmux-usage --account work`
+- **THEN** only the `work` account is fetched and rendered
+
+#### Scenario: --account name not found
+- **WHEN** the config has accounts `personal` and `work`, and the user runs `zenmux-usage --account missing`
+- **THEN** the CLI exits with code 2 and writes a message listing the available account names to stderr
+
+#### Scenario: No config, env var fallback
+- **WHEN** no config file exists at the default path and `ZENMUX_MANAGEMENT_API_KEY=env-key` is set
+- **THEN** the CLI fetches a single synthetic account named `env` using `env-key`
+
+#### Scenario: No config, no env
+- **WHEN** no config file exists at the default path and no `ZENMUX_MANAGEMENT_API_KEY` env var is set, and no `--api-key` flag is passed
+- **THEN** the CLI exits with code 3 and writes a message pointing the user at the default config path
+
+### Requirement: Warn on world-readable config permissions
+
+On POSIX systems, if the resolved config file has group- or world-readable mode bits set (i.e. mode has any of `0044` set), the CLI SHALL write a one-line stderr warning advising the user to run `chmod 600` on the file. The warning MUST NOT be fatal and MUST NOT be printed on Windows.
+
+#### Scenario: Config mode 0644
+- **WHEN** the config file permissions are `0644` on a POSIX system
+- **THEN** the CLI prints a one-line stderr warning about permissions and continues normally
+
+#### Scenario: Config mode 0600
+- **WHEN** the config file permissions are `0600`
+- **THEN** the CLI prints no permission warning

+ 110 - 0
openspec/specs/subscription-usage/spec.md

@@ -0,0 +1,110 @@
+### Requirement: Fetch subscription detail from ZenMux per account
+
+The CLI SHALL call `GET https://zenmux.ai/api/v1/management/subscription/detail` with an `Authorization: Bearer <key>` header for each resolved account and parse the JSON response into typed structures covering `plan`, `currency`, `base_usd_per_flow`, `effective_usd_per_flow`, `account_status`, `quota_5_hour`, `quota_7_day`, and `quota_monthly`. Accounts MUST be fetched sequentially in the order they were resolved. A failure on one account MUST NOT abort fetches for the remaining accounts.
+
+#### Scenario: Single-account fetch
+- **WHEN** the user runs `zenmux-usage` with exactly one resolved account
+- **THEN** the CLI issues exactly one GET request to the subscription detail endpoint with `Authorization: Bearer <key>` and parses `success: true` into typed structs
+
+#### Scenario: Multi-account fetch order
+- **WHEN** the user runs `zenmux-usage` with accounts `personal` then `work` resolved
+- **THEN** the CLI first fetches `personal`, then `work`, rendering each as soon as its response is available
+
+#### Scenario: Partial failure does not abort
+- **WHEN** three accounts are resolved and the second account returns HTTP 401
+- **THEN** the CLI still fetches the third account and renders blocks for all three (one with an error body)
+
+#### Scenario: Request respects a configurable timeout
+- **WHEN** the user runs `zenmux-usage --timeout 2s` and a server has not responded within 2 seconds
+- **THEN** the CLI aborts that account's request and records it as a timeout error while continuing with any remaining accounts
+
+### Requirement: Render three quota windows per account
+
+In human output mode the CLI SHALL render, for each resolved account, one labeled row per window (`5 hour`, `7 day`, `month`). Each row MUST include a fixed-width progress bar, the usage percentage to two decimals, the used-vs-max flows, and the used-vs-max USD value when the API provides them. Bars MUST be color-coded by usage band: green below 60%, yellow from 60% through 85%, red above 85%. The monthly window MUST render the `max_flows` and `max_value_usd` values and display `—` (em-dash) in place of used values since the API does not return `used_*` fields for that window.
+
+#### Scenario: Healthy 5-hour window
+- **WHEN** the API returns `quota_5_hour.usage_percentage = 0.0715`, `used_flows = 57.2`, `max_flows = 800`, `used_value_usd = 1.88`, `max_value_usd = 26.26` for an account
+- **THEN** that account's block contains a row labeled `5 hour`, a green-colored bar roughly 7% filled, `7.15%`, `57.2 / 800 flows`, and `$1.88 / $26.26`
+
+#### Scenario: Monthly window has no used values
+- **WHEN** the API returns `quota_monthly` with only `max_flows` and `max_value_usd` populated
+- **THEN** the monthly row prints `—` in place of used flows and used USD, and shows the `max_flows` and `max_value_usd` values
+
+#### Scenario: Color disabled via flag
+- **WHEN** the user runs `zenmux-usage --no-color`
+- **THEN** no ANSI color escape sequences appear in the output
+
+#### Scenario: Non-TTY output is automatically plain
+- **WHEN** stdout is piped or redirected to a file
+- **THEN** the CLI omits ANSI color escape sequences regardless of the `--no-color` flag
+
+### Requirement: Render per-account header block
+
+For every resolved account the CLI SHALL print, before the quota rows, a header that identifies the account by name followed by a plan/status line. The account header MUST be visually distinct (e.g., `━━━ <name> ━━━` with bold styling when colors are enabled). The plan line MUST include the plan tier (capitalized), the monthly `amount_usd`, the `account_status`, and the `effective_usd_per_flow` rate.
+
+#### Scenario: Single account header
+- **WHEN** one resolved account named `personal` returns `plan.tier = "ultra"`, `plan.amount_usd = 200`, `account_status = "healthy"`, `effective_usd_per_flow = 0.03283`
+- **THEN** the output contains a header block showing `personal`, and a plan line containing `Ultra plan`, `$200/mo`, `healthy`, and `$0.03283/flow`
+
+#### Scenario: Multiple accounts separated by blank lines
+- **WHEN** two accounts `personal` and `work` are rendered in human mode
+- **THEN** each account has its own header block and the two blocks are separated by at least one blank line, in the order they were resolved
+
+### Requirement: Display token USD value consumed per account
+
+Below each account's three quota rows the CLI SHALL print a summary line labeled "Tokens consumed (estimated USD value)" showing the `used_value_usd` from the 7-day window for that account, formatted as USD to two decimals.
+
+#### Scenario: 7-day used value present
+- **WHEN** an account's `quota_7_day.used_value_usd = 13.66`
+- **THEN** that account's summary line reads `Tokens consumed (estimated USD value): $13.66`
+
+### Requirement: JSON passthrough mode
+
+When invoked with `--json` the CLI SHALL write machine-readable JSON to stdout and MUST NOT print any human-formatted output.
+
+- If exactly one account was resolved, the output MUST be the single API response body unchanged (pretty-printing is not required; a trailing newline is permitted).
+- If more than one account was resolved, the output MUST be a JSON array whose elements are objects with shape `{"account": <name>, "success": <bool>, "data": <API data object or null>, "error": <string or null>}` in the order accounts were resolved. Successful accounts MUST have `error: null`; failed accounts MUST have `data: null` and a human-readable `error` string.
+
+Exit codes behave identically to human mode.
+
+#### Scenario: Single-account JSON
+- **WHEN** exactly one account is resolved and the user runs `zenmux-usage --json | jq .data.plan.tier`
+- **THEN** `jq` receives valid JSON and extracts the plan tier from `data.plan.tier`
+
+#### Scenario: Multi-account JSON
+- **WHEN** two accounts `personal` and `work` are resolved and `work` returns HTTP 429
+- **THEN** stdout is a JSON array of length 2; the `personal` element has `success: true` and a populated `data` field; the `work` element has `success: false`, `data: null`, and a non-null `error`
+
+#### Scenario: JSON mode on full failure
+- **WHEN** the CLI fails before any account was fetched (e.g., config parse error)
+- **THEN** the CLI writes the error message to stderr, exits with the corresponding non-zero code, and writes nothing to stdout
+
+### Requirement: Map API and transport failures to distinct exit codes
+
+The CLI SHALL exit with these codes and write a single human-readable line to stderr for CLI-global failures:
+
+- `2` — invalid flag or argument (including unknown `--account` name)
+- `3` — no account resolvable (missing config and env, or explicit `--config` file missing)
+- `4` — authentication rejected (HTTP 401 or 403) — only when every fetched account failed with this cause
+- `5` — rate limited (HTTP 422) — only when every fetched account failed with this cause
+- `6` — network error or timeout — only when every fetched account failed with this cause
+- `7` — config file parse error or schema validation failure
+- `1` — any other unexpected error, or a mix of failure causes across accounts, or at least one account failed while others succeeded
+
+On `success: false` in a response body the CLI SHALL treat that account as a failure and surface any error message from the payload.
+
+#### Scenario: All accounts auth-rejected
+- **WHEN** every resolved account returns HTTP 401
+- **THEN** the CLI exits with code 4 after rendering each account's error block
+
+#### Scenario: Mixed outcomes
+- **WHEN** account `personal` returns 200 and account `work` returns 401
+- **THEN** the CLI exits with code 1 after rendering both accounts
+
+#### Scenario: All transport timeouts
+- **WHEN** every resolved account's HTTP request exceeds the configured timeout
+- **THEN** the CLI exits with code 6 after rendering each account's timeout block
+
+#### Scenario: Single-account run preserves specific code
+- **WHEN** a single-account run (`--account work`, `--api-key`, or env-var fallback) fails with HTTP 401
+- **THEN** the CLI exits with code 4