This file provides project-specific guidance to Claude Code when working on the shh voice transcription CLI.
For general Python development preferences, see ~/.claude/CLAUDE.md. This project file overrides global preferences where specified.
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.
Core Features:
- Record audio from microphone or transcribe existing files
- Format transcriptions with AI (casual, business, or neutral style)
- Translate transcriptions to any language
- Copy results to clipboard automatically
- Async architecture for responsive UX
Tech Stack: Python 3.11+ • OpenAI Whisper • PydanticAI (gpt-4o-mini) • Typer • Rich • prompt_toolkit (transitive via ipython, used for the history picker) • sounddevice
This project follows a Pragmatic Layered Architecture. The CLI calls a thin service layer that orchestrates the recording/transcription/formatting flow.
CLI (Typer)
├─ Output UI: Rich / Quiet / Pipe (TTY-aware selection)
└─ History picker (prompt_toolkit, shh history)
↓
Services Layer RecordingService
↓
┌───────┴────────┐
Core Adapters
(models, (audio, whisper,
styles) llm, clipboard, history)
Dependency Rule: CLI → Services → (Core + Adapters). Lower layers never import from upper layers. Adapters are framework-agnostic and never import cli/ or services/.
shh/
├── cli/ # CLI Layer - Typer commands + UI abstraction
│ ├── app.py # Typer app entry point (callback pattern, default = record)
│ ├── commands/ # Subcommands: record (default), setup, config, history
│ └── ui/ # UIOutput Protocol + RichUI / QuietUI / PipeUI + history_picker
├── services/ # Orchestration layer
│ └── recording.py # RecordingService (record + transcribe + format + persist)
├── core/ # Domain models & enums (no I/O, framework-agnostic)
│ ├── models.py # RecordingOptions, TranscriptionOutput, HistoryEntry, WhisperTranscription
│ └── styles.py # TranscriptionStyle enum
├── adapters/ # External Integrations (framework-agnostic)
│ ├── audio/ # sounddevice recording + scipy WAV processing
│ ├── whisper/ # OpenAI Whisper API client (AsyncOpenAI, verbose_json)
│ ├── llm/ # PydanticAI formatting agent (gpt-4o-mini)
│ ├── clipboard/ # pyperclip wrapper
│ └── history/ # JSONL-backed HistoryStore with size-bounded rotation
├── config/ # Configuration management
│ └── settings.py # pydantic-settings (env + JSON config file)
└── utils/ # Shared utilities
├── exceptions.py # Custom exceptions
└── logger.py # rich.logging setup
- Async-first: All I/O operations use
async/await(Whisper API, LLM calls, recording service). - Type safety: Full type hints +
mypy --strictenforcement. - Service layer for orchestration: Record/transcribe/format flow lives in
RecordingService, not in a CLI command. Commands stay thin. - Protocol-based UI: Output is abstracted through the
UIOutputProtocol (shh/cli/ui/base.py). New UIs (e.g.,QuietUI,RichUI) implement the methods structurally — no inheritance required. - Framework-agnostic core/adapters/services: No Typer / Rich imports outside
cli/. - Temporary files: Audio recorded to temp WAV, deleted immediately after transcription (
try/finally).
# Create virtual environment and install
uv venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
uv pip install -e ".[dev]"
# Setup API key
shh setup# Quick record (press Ctrl+C to stop or enter)
shh
# Record with duration
shh record --duration 60
# Format with style
shh --style business
# Translate
shh --translate fr
# Minimal output (for scripts / piping)
shh --quiet
# Verbose output (overrides quiet_mode config)
shh --verbose
# Skip persisting this transcription to history
shh --no-history
# Browse past transcriptions (interactive picker; Enter = copy, Esc = quit)
shh history
# Purge all history entries (with confirmation)
shh history clearRecommended (using Poe):
uv run poe test # Run all tests
uv run poe test-cov # Run with coverage
uv run poe test-unit # Run only unit tests
uv run poe test-integration # Run only integration tests
uv run poe test-no-e2e # Skip E2E tests (no real API calls)Direct pytest commands:
pytest # Run all tests
pytest --cov=shh --cov-report=html --cov-report=term # With coverage
pytest tests/unit/test_formatting.py # Specific test file
pytest -m "not e2e" # Excluding E2E tests
pytest -v # Verbose outputThis project uses Poe the Poet for task automation. All tasks are run via uv run poe <task>.
# Type checking (strict mode enforced)
uv run poe type # mypy --strict shh/
# Linting
uv run poe lint # ruff check .
uv run poe lint-fix # ruff check . --fix
# Formatting
uv run poe format # ruff format .
# Combined quality check (before commit)
uv run poe check # Runs: type + lint + test
# Auto-fix and format
uv run poe fix # Runs: lint-fix + formatDirect commands (if needed):
mypy --strict shh/
ruff check .
ruff check . --fix# Clean cache directories
uv run poe clean
# Install in editable mode
uv pip install -e .
# Install with dev dependencies
uv pip install -e ".[dev]"Platform-specific paths via platformdirs:
- macOS:
~/Library/Application Support/shh/config.json - Linux:
~/.config/shh/config.json - Windows:
%APPDATA%\shh\config.json
{
"openai_api_key": "sk-...",
"default_output": ["clipboard", "stdout"],
"show_progress": true,
"default_style": "neutral",
"default_translation_language": null,
"quiet_mode": false,
"whisper_model": "whisper-1",
"history_enabled": true,
"history_retention": 200
}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.
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.
- CLI flags (highest)
- Environment variables (
SHH_*prefix) - Config file
- Defaults (lowest)
- Library:
sounddevicefor cross-platform recording - Format: WAV (16kHz sample rate, optimal for Whisper)
- Storage: Temporary files only, deleted after transcription
- Modes: Duration-based (
--duration 60) or interactive (Ctrl+C to stop)
- Client:
AsyncOpenAI(from theopenaiSDK) - Adapter:
shh/adapters/whisper/client.py— uploads the temp WAV, returns the raw transcript. - Model:
whisper-1(configurable viaSettings.whisper_model).
- Agent:
pydantic_ai.AgentwithOpenAIChatModel("gpt-4o-mini")(shh/adapters/llm/formatter.py:103). - Styles:
neutral: No LLM call unless atarget_languageis provided — text is returned as-is from Whisper.casual: Conversational tone, removes filler words.business: Formal tone, structured paragraphs.
- Output: Structured
FormattedTranscriptionPydantic model. - Errors: Wrapped in
FormattingErrorand re-raised at the adapter boundary.
- Defined in
shh/cli/ui/base.py.UIOutputis atyping.Protocol— structural typing, no inheritance. - Implementations:
RichUI— default. A singleLive(transient=True)spinner morphs throughRecording → Transcribing → [Formatting]and erases on stop. Final output is plain text + dim✓ copied to clipboard(no Panel).QuietUI— minimal: progress bar + final text.PipeUI— non-TTY: writes only the transcribed text to stdout; errors/warnings to stderr.
- TTY-aware selection (
shh/cli/commands/record.py::_select_ui):not sys.stdout.isatty()→PipeUI(overrides every other flag — pipes always get clean text).--quietor (Settings.quiet_modeand not--verbose) →QuietUI.- Otherwise →
RichUI.
- 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.
- 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. - Model:
HistoryEntryinshh/core/models.py(id,ts,text,style,translate_to,duration_s,detected_lang). - Persistence:
RecordingServiceappends one entry per successful transcription whensettings.history_enabled and not skip_history.OSErroron append is logged and swallowed — a successful transcription should never be lost because history failed to persist. --no-historyflag onshh/ record path threads asskip_history=Truethrough the service.- Picker UI:
shh/cli/ui/history_picker.py—prompt_toolkitApplicationwith 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. - Commands:
shh historyopens the picker;shh history clearpurges with confirmation.
shh/cli/app.pyuses Typer's@app.callback(invoke_without_command=True)so that the bareshhcommand runs the default record flow while still allowingshh setup,shh config, andshh historysubcommands.- Whisper detected language is extracted via the SDK's
verbose_jsonoverload — the response is annotatedTranscriptionVerboseand.languageis accessed directly (nogetattrdefensiveness).
- Philosophy: Fail fast with clear error messages
- Custom exceptions: Defined in
utils/exceptions.py - API errors: Translated at adapter boundaries, never leaked to core
- Cleanup: Temp files deleted in
try/finallyblocks
- Full type hints: Every function must have complete type annotations.
- Pass mypy strict:
mypy --strict shh/must pass before committing. - Clean up temp files: Use
try/finallyto ensure WAV files are deleted. - Layer boundaries:
CLI → Services → (Core + Adapters). Never the reverse. - Structured outputs: Use Pydantic models for all LLM responses.
- Orchestration in services: New record/transcribe/format logic goes in
services/, not in a command.
- Import
typer/richincore/,adapters/, orservices/. - Import
cli/fromservices/,core/, oradapters/. - Put orchestration logic directly in a Typer command — call
RecordingServiceinstead. - Leave temp WAV files on disk after transcription.
- Skip type hints or use
Anywithout justification. - Commit code that fails
mypy --strictorruff check.
- Plan: Identify which layer owns the logic (CLI / Services / Core / Adapters).
- Write tests first: Unit tests for services and adapters, integration tests for end-to-end flows.
- Implement: Follow layer boundaries strictly. Orchestration belongs in
services/. - Type check: Run
uv run poe type. - Lint: Run
uv run poe lint(oruv run poe fixto auto-fix). - Test: Run
uv run poe testto ensure all tests pass. - Document: Update CLAUDE.md if architectural changes were made.
# Full quality check (type + lint + test)
uv run poe check
# If all pass, you're good to commit
git add .
git commit -m "feat: description"Alternative (manual commands):
mypy --strict shh/ && ruff check . && pytestSee .roadmap.md for:
- Detailed architectural decisions
- Complete implementation checklist
- Phase-by-phase development plan
- Open questions and design philosophy
Note: .roadmap.md is gitignored and contains extensive planning details. This CLAUDE.md focuses on practical development guidance.
- PydanticAI Documentation
- Typer Documentation
- OpenAI Whisper API
- Rich Documentation
- sounddevice Documentation
- Use Context7 mcp for access to documentations