|
| 1 | +# CLAUDE.md - shh Project |
| 2 | + |
| 3 | +This file provides **project-specific** guidance to Claude Code when working on the **shh** voice transcription CLI. |
| 4 | + |
| 5 | +For general Python development preferences, see `~/.claude/CLAUDE.md`. **This project file overrides global preferences where specified.** |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Project Overview |
| 10 | + |
| 11 | +**shh** is a voice transcription CLI powered by OpenAI Whisper. It allows users to record audio from their terminal, transcribe it using the Whisper API, and optionally format or translate the output using AI. |
| 12 | + |
| 13 | +**Core Features**: |
| 14 | +- Record audio from microphone or transcribe existing files |
| 15 | +- Format transcriptions with AI (casual, business, or neutral style) |
| 16 | +- Translate transcriptions to any language |
| 17 | +- Copy results to clipboard automatically |
| 18 | +- Async architecture for responsive UX |
| 19 | + |
| 20 | +**Tech Stack**: Python 3.11+ • OpenAI Whisper • PydanticAI (`gpt-4o-mini`) • Typer • Rich • prompt_toolkit (transitive via ipython, used for the history picker) • sounddevice |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Architecture |
| 25 | + |
| 26 | +This project follows a **Pragmatic Layered Architecture**. The CLI calls a thin service layer that orchestrates the recording/transcription/formatting flow. |
| 27 | + |
| 28 | +``` |
| 29 | + CLI (Typer) |
| 30 | + ├─ Output UI: Rich / Quiet / Pipe (TTY-aware selection) |
| 31 | + └─ History picker (prompt_toolkit, shh history) |
| 32 | + ↓ |
| 33 | + Services Layer RecordingService |
| 34 | + ↓ |
| 35 | + ┌───────┴────────┐ |
| 36 | + Core Adapters |
| 37 | + (models, (audio, whisper, |
| 38 | + styles) llm, clipboard, history) |
| 39 | +``` |
| 40 | + |
| 41 | +**Dependency Rule**: `CLI → Services → (Core + Adapters)`. Lower layers never import from upper layers. Adapters are framework-agnostic and never import `cli/` or `services/`. |
| 42 | + |
| 43 | +### Directory Structure |
| 44 | + |
| 45 | +``` |
| 46 | +shh/ |
| 47 | +├── cli/ # CLI Layer - Typer commands + UI abstraction |
| 48 | +│ ├── app.py # Typer app entry point (callback pattern, default = record) |
| 49 | +│ ├── commands/ # Subcommands: record (default), setup, config, history |
| 50 | +│ └── ui/ # UIOutput Protocol + RichUI / QuietUI / PipeUI + history_picker |
| 51 | +├── services/ # Orchestration layer |
| 52 | +│ └── recording.py # RecordingService (record + transcribe + format + persist) |
| 53 | +├── core/ # Domain models & enums (no I/O, framework-agnostic) |
| 54 | +│ ├── models.py # RecordingOptions, TranscriptionOutput, HistoryEntry, WhisperTranscription |
| 55 | +│ └── styles.py # TranscriptionStyle enum |
| 56 | +├── adapters/ # External Integrations (framework-agnostic) |
| 57 | +│ ├── audio/ # sounddevice recording + scipy WAV processing |
| 58 | +│ ├── whisper/ # OpenAI Whisper API client (AsyncOpenAI, verbose_json) |
| 59 | +│ ├── llm/ # PydanticAI formatting agent (gpt-4o-mini) |
| 60 | +│ ├── clipboard/ # pyperclip wrapper |
| 61 | +│ └── history/ # JSONL-backed HistoryStore with size-bounded rotation |
| 62 | +├── config/ # Configuration management |
| 63 | +│ └── settings.py # pydantic-settings (env + JSON config file) |
| 64 | +└── utils/ # Shared utilities |
| 65 | + ├── exceptions.py # Custom exceptions |
| 66 | + └── logger.py # rich.logging setup |
| 67 | +``` |
| 68 | + |
| 69 | +### Key Architectural Principles |
| 70 | + |
| 71 | +1. **Async-first**: All I/O operations use `async/await` (Whisper API, LLM calls, recording service). |
| 72 | +2. **Type safety**: Full type hints + `mypy --strict` enforcement. |
| 73 | +3. **Service layer for orchestration**: Record/transcribe/format flow lives in `RecordingService`, not in a CLI command. Commands stay thin. |
| 74 | +4. **Protocol-based UI**: Output is abstracted through the `UIOutput` Protocol (`shh/cli/ui/base.py`). New UIs (e.g., `QuietUI`, `RichUI`) implement the methods structurally — no inheritance required. |
| 75 | +5. **Framework-agnostic core/adapters/services**: No Typer / Rich imports outside `cli/`. |
| 76 | +6. **Temporary files**: Audio recorded to temp WAV, deleted immediately after transcription (`try/finally`). |
| 77 | + |
| 78 | +--- |
| 79 | + |
| 80 | +## Development Commands |
| 81 | + |
| 82 | +### Environment Setup |
| 83 | + |
| 84 | +```bash |
| 85 | +# Create virtual environment and install |
| 86 | +uv venv |
| 87 | +source .venv/bin/activate # Windows: .venv\Scripts\activate |
| 88 | +uv pip install -e ".[dev]" |
| 89 | + |
| 90 | +# Setup API key |
| 91 | +shh setup |
| 92 | +``` |
| 93 | + |
| 94 | +### Running the CLI |
| 95 | + |
| 96 | +```bash |
| 97 | +# Quick record (press Ctrl+C to stop or enter) |
| 98 | +shh |
| 99 | + |
| 100 | +# Record with duration |
| 101 | +shh record --duration 60 |
| 102 | + |
| 103 | +# Format with style |
| 104 | +shh --style business |
| 105 | + |
| 106 | +# Translate |
| 107 | +shh --translate fr |
| 108 | + |
| 109 | +# Minimal output (for scripts / piping) |
| 110 | +shh --quiet |
| 111 | + |
| 112 | +# Verbose output (overrides quiet_mode config) |
| 113 | +shh --verbose |
| 114 | + |
| 115 | +# Skip persisting this transcription to history |
| 116 | +shh --no-history |
| 117 | + |
| 118 | +# Browse past transcriptions (interactive picker; Enter = copy, Esc = quit) |
| 119 | +shh history |
| 120 | + |
| 121 | +# Purge all history entries (with confirmation) |
| 122 | +shh history clear |
| 123 | +``` |
| 124 | + |
| 125 | +### Testing |
| 126 | + |
| 127 | +**Recommended (using Poe):** |
| 128 | +```bash |
| 129 | +uv run poe test # Run all tests |
| 130 | +uv run poe test-cov # Run with coverage |
| 131 | +uv run poe test-unit # Run only unit tests |
| 132 | +uv run poe test-integration # Run only integration tests |
| 133 | +uv run poe test-no-e2e # Skip E2E tests (no real API calls) |
| 134 | +``` |
| 135 | + |
| 136 | +**Direct pytest commands:** |
| 137 | +```bash |
| 138 | +pytest # Run all tests |
| 139 | +pytest --cov=shh --cov-report=html --cov-report=term # With coverage |
| 140 | +pytest tests/unit/test_formatting.py # Specific test file |
| 141 | +pytest -m "not e2e" # Excluding E2E tests |
| 142 | +pytest -v # Verbose output |
| 143 | +``` |
| 144 | + |
| 145 | +### Code Quality |
| 146 | + |
| 147 | +This project uses **Poe the Poet** for task automation. All tasks are run via `uv run poe <task>`. |
| 148 | + |
| 149 | +```bash |
| 150 | +# Type checking (strict mode enforced) |
| 151 | +uv run poe type # mypy --strict shh/ |
| 152 | + |
| 153 | +# Linting |
| 154 | +uv run poe lint # ruff check . |
| 155 | +uv run poe lint-fix # ruff check . --fix |
| 156 | + |
| 157 | +# Formatting |
| 158 | +uv run poe format # ruff format . |
| 159 | + |
| 160 | +# Combined quality check (before commit) |
| 161 | +uv run poe check # Runs: type + lint + test |
| 162 | + |
| 163 | +# Auto-fix and format |
| 164 | +uv run poe fix # Runs: lint-fix + format |
| 165 | +``` |
| 166 | + |
| 167 | +**Direct commands (if needed):** |
| 168 | +```bash |
| 169 | +mypy --strict shh/ |
| 170 | +ruff check . |
| 171 | +ruff check . --fix |
| 172 | +``` |
| 173 | + |
| 174 | +### Development Utilities |
| 175 | + |
| 176 | +```bash |
| 177 | +# Clean cache directories |
| 178 | +uv run poe clean |
| 179 | + |
| 180 | +# Install in editable mode |
| 181 | +uv pip install -e . |
| 182 | + |
| 183 | +# Install with dev dependencies |
| 184 | +uv pip install -e ".[dev]" |
| 185 | +``` |
| 186 | + |
| 187 | +--- |
| 188 | + |
| 189 | +## Configuration |
| 190 | + |
| 191 | +### Config File Location |
| 192 | + |
| 193 | +Platform-specific paths via `platformdirs`: |
| 194 | +- **macOS**: `~/Library/Application Support/shh/config.json` |
| 195 | +- **Linux**: `~/.config/shh/config.json` |
| 196 | +- **Windows**: `%APPDATA%\shh\config.json` |
| 197 | + |
| 198 | +### Config Structure |
| 199 | + |
| 200 | +```json |
| 201 | +{ |
| 202 | + "openai_api_key": "sk-...", |
| 203 | + "default_output": ["clipboard", "stdout"], |
| 204 | + "show_progress": true, |
| 205 | + "default_style": "neutral", |
| 206 | + "default_translation_language": null, |
| 207 | + "quiet_mode": false, |
| 208 | + "whisper_model": "whisper-1", |
| 209 | + "history_enabled": true, |
| 210 | + "history_retention": 200 |
| 211 | +} |
| 212 | +``` |
| 213 | + |
| 214 | +`quiet_mode` selects `QuietUI` over `RichUI` for `shh` invocations on a TTY. The CLI flags `--quiet` / `--verbose` override this field at runtime. When stdout is not a TTY, `PipeUI` is selected regardless. See `shh/config/settings.py` for the authoritative schema. |
| 215 | + |
| 216 | +History persists to `<config_dir>/history.jsonl` next to `settings.json`. `history_retention` is the maximum entry count before rotation (rewrite with the most recent N). `history_enabled = false` disables persistence globally; `--no-history` skips a single invocation. |
| 217 | + |
| 218 | +### Priority Order |
| 219 | + |
| 220 | +1. CLI flags (highest) |
| 221 | +2. Environment variables (`SHH_*` prefix) |
| 222 | +3. Config file |
| 223 | +4. Defaults (lowest) |
| 224 | + |
| 225 | +--- |
| 226 | + |
| 227 | +## Implementation Patterns |
| 228 | + |
| 229 | +### Audio Recording |
| 230 | + |
| 231 | +- **Library**: `sounddevice` for cross-platform recording |
| 232 | +- **Format**: WAV (16kHz sample rate, optimal for Whisper) |
| 233 | +- **Storage**: Temporary files only, deleted after transcription |
| 234 | +- **Modes**: Duration-based (`--duration 60`) or interactive (Ctrl+C to stop) |
| 235 | + |
| 236 | +### Whisper Transcription |
| 237 | + |
| 238 | +- **Client**: `AsyncOpenAI` (from the `openai` SDK) |
| 239 | +- **Adapter**: `shh/adapters/whisper/client.py` — uploads the temp WAV, returns the raw transcript. |
| 240 | +- **Model**: `whisper-1` (configurable via `Settings.whisper_model`). |
| 241 | + |
| 242 | +### PydanticAI Formatting |
| 243 | + |
| 244 | +- **Agent**: `pydantic_ai.Agent` with `OpenAIChatModel("gpt-4o-mini")` (`shh/adapters/llm/formatter.py:103`). |
| 245 | +- **Styles**: |
| 246 | + - `neutral`: No LLM call **unless** a `target_language` is provided — text is returned as-is from Whisper. |
| 247 | + - `casual`: Conversational tone, removes filler words. |
| 248 | + - `business`: Formal tone, structured paragraphs. |
| 249 | +- **Output**: Structured `FormattedTranscription` Pydantic model. |
| 250 | +- **Errors**: Wrapped in `FormattingError` and re-raised at the adapter boundary. |
| 251 | + |
| 252 | +### UI Output Layer (Protocol) |
| 253 | + |
| 254 | +- **Defined in** `shh/cli/ui/base.py`. `UIOutput` is a `typing.Protocol` — structural typing, no inheritance. |
| 255 | +- **Implementations**: |
| 256 | + - `RichUI` — default. A **single** `Live(transient=True)` spinner morphs through `Recording → Transcribing → [Formatting]` and erases on stop. Final output is plain text + dim `✓ copied to clipboard` (no Panel). |
| 257 | + - `QuietUI` — minimal: progress bar + final text. |
| 258 | + - `PipeUI` — non-TTY: writes only the transcribed text to stdout; errors/warnings to stderr. |
| 259 | +- **TTY-aware selection** (`shh/cli/commands/record.py::_select_ui`): |
| 260 | + - `not sys.stdout.isatty()` → `PipeUI` (overrides every other flag — pipes always get clean text). |
| 261 | + - `--quiet` or (`Settings.quiet_mode` and not `--verbose`) → `QuietUI`. |
| 262 | + - Otherwise → `RichUI`. |
| 263 | +- **Adding a new UI**: implement every method of `UIOutput` (`show_error`, `show_recording_progress`, `show_result`, `cleanup`, …) in a new class — no base class to extend. |
| 264 | + |
| 265 | +### Transcription History |
| 266 | + |
| 267 | +- **Store**: `shh/adapters/history/store.py` — `HistoryStore(path, retention)`. JSONL, append-only, size-bounded rotation (keeps last N lines after append). Malformed lines on read are skipped with a logger warning. |
| 268 | +- **Model**: `HistoryEntry` in `shh/core/models.py` (`id`, `ts`, `text`, `style`, `translate_to`, `duration_s`, `detected_lang`). |
| 269 | +- **Persistence**: `RecordingService` appends one entry per successful transcription when `settings.history_enabled and not skip_history`. `OSError` on append is logged and swallowed — a successful transcription should never be lost because history failed to persist. |
| 270 | +- **`--no-history` flag** on `shh` / record path threads as `skip_history=True` through the service. |
| 271 | +- **Picker UI**: `shh/cli/ui/history_picker.py` — `prompt_toolkit` `Application` with list + preview + footer. Pure helpers (`filter_entries`, `format_relative_time`, `truncate_text`, `render_row`) are testable in isolation; the interactive layer is not unit-tested. |
| 272 | +- **Commands**: `shh history` opens the picker; `shh history clear` purges with confirmation. |
| 273 | + |
| 274 | +### CLI Entry Point Pattern |
| 275 | + |
| 276 | +- `shh/cli/app.py` uses Typer's `@app.callback(invoke_without_command=True)` so that the bare `shh` command runs the default record flow while still allowing `shh setup`, `shh config`, and `shh history` subcommands. |
| 277 | +- Whisper detected language is extracted via the SDK's `verbose_json` overload — the response is annotated `TranscriptionVerbose` and `.language` is accessed directly (no `getattr` defensiveness). |
| 278 | + |
| 279 | +### Error Handling |
| 280 | + |
| 281 | +- **Philosophy**: Fail fast with clear error messages |
| 282 | +- **Custom exceptions**: Defined in `utils/exceptions.py` |
| 283 | +- **API errors**: Translated at adapter boundaries, never leaked to core |
| 284 | +- **Cleanup**: Temp files deleted in `try/finally` blocks |
| 285 | + |
| 286 | +--- |
| 287 | + |
| 288 | +## Critical Constraints |
| 289 | + |
| 290 | +### MUST Do |
| 291 | + |
| 292 | +1. **Full type hints**: Every function must have complete type annotations. |
| 293 | +2. **Pass mypy strict**: `mypy --strict shh/` must pass before committing. |
| 294 | +3. **Clean up temp files**: Use `try/finally` to ensure WAV files are deleted. |
| 295 | +4. **Layer boundaries**: `CLI → Services → (Core + Adapters)`. Never the reverse. |
| 296 | +5. **Structured outputs**: Use Pydantic models for all LLM responses. |
| 297 | +6. **Orchestration in services**: New record/transcribe/format logic goes in `services/`, not in a command. |
| 298 | + |
| 299 | +### MUST NOT Do |
| 300 | + |
| 301 | +- Import `typer` / `rich` in `core/`, `adapters/`, or `services/`. |
| 302 | +- Import `cli/` from `services/`, `core/`, or `adapters/`. |
| 303 | +- Put orchestration logic directly in a Typer command — call `RecordingService` instead. |
| 304 | +- Leave temp WAV files on disk after transcription. |
| 305 | +- Skip type hints or use `Any` without justification. |
| 306 | +- Commit code that fails `mypy --strict` or `ruff check`. |
| 307 | + |
| 308 | +--- |
| 309 | + |
| 310 | +### Adding a New Feature |
| 311 | + |
| 312 | +1. **Plan**: Identify which layer owns the logic (CLI / Services / Core / Adapters). |
| 313 | +2. **Write tests first**: Unit tests for services and adapters, integration tests for end-to-end flows. |
| 314 | +3. **Implement**: Follow layer boundaries strictly. Orchestration belongs in `services/`. |
| 315 | +4. **Type check**: Run `uv run poe type`. |
| 316 | +5. **Lint**: Run `uv run poe lint` (or `uv run poe fix` to auto-fix). |
| 317 | +6. **Test**: Run `uv run poe test` to ensure all tests pass. |
| 318 | +7. **Document**: Update CLAUDE.md if architectural changes were made. |
| 319 | + |
| 320 | +### Before Committing |
| 321 | + |
| 322 | +```bash |
| 323 | +# Full quality check (type + lint + test) |
| 324 | +uv run poe check |
| 325 | + |
| 326 | +# If all pass, you're good to commit |
| 327 | +git add . |
| 328 | +git commit -m "feat: description" |
| 329 | +``` |
| 330 | + |
| 331 | +**Alternative (manual commands):** |
| 332 | +```bash |
| 333 | +mypy --strict shh/ && ruff check . && pytest |
| 334 | +``` |
| 335 | + |
| 336 | + |
| 337 | +## Roadmap Reference |
| 338 | + |
| 339 | +See `.roadmap.md` for: |
| 340 | +- Detailed architectural decisions |
| 341 | +- Complete implementation checklist |
| 342 | +- Phase-by-phase development plan |
| 343 | +- Open questions and design philosophy |
| 344 | + |
| 345 | +**Note**: `.roadmap.md` is gitignored and contains extensive planning details. This CLAUDE.md focuses on practical development guidance. |
| 346 | + |
| 347 | +--- |
| 348 | + |
| 349 | +## References |
| 350 | + |
| 351 | +- [PydanticAI Documentation](https://ai.pydantic.dev/) |
| 352 | +- [Typer Documentation](https://typer.tiangolo.com/) |
| 353 | +- [OpenAI Whisper API](https://platform.openai.com/docs/guides/speech-to-text) |
| 354 | +- [Rich Documentation](https://rich.readthedocs.io/) |
| 355 | +- [sounddevice Documentation](https://python-sounddevice.readthedocs.io/) |
| 356 | +- Use Context7 mcp for access to documentations |
0 commit comments