Phase 1 Implementation Plan - Inkwell CLI¶
Date: 2025-11-06 Status: Planning Related: PRD, ADR-001
Overview¶
Phase 1 establishes the foundation for Inkwell as a professional-grade CLI tool. This plan goes beyond the basic checklist in the PRD to ensure we build production-ready infrastructure from day one.
Phase 1 Scope (from PRD)¶
Core Requirements: - Project setup (pyproject.toml, structure) - Config system (YAML read/write, encryption for auth) - Feed management commands (add, list, remove) - RSS parsing with feedparser
Professional Grade Additions: - Comprehensive testing framework - Robust error handling and logging - Security-first credential management - Developer tooling (linting, formatting, pre-commit hooks) - User experience polish (progress indicators, helpful errors) - Proper packaging and installation
Detailed Implementation Plan¶
1. Project Structure & Scaffolding¶
1.1 Directory Layout¶
inkwell-cli/
├── src/
│ └── inkwell/
│ ├── __init__.py
│ ├── __version__.py
│ ├── cli.py # Main CLI entry point (typer)
│ ├── config/
│ │ ├── __init__.py
│ │ ├── manager.py # Config CRUD operations
│ │ ├── schema.py # Pydantic models for validation
│ │ ├── crypto.py # Credential encryption/decryption
│ │ └── defaults.py # Default config templates
│ ├── feeds/
│ │ ├── __init__.py
│ │ ├── manager.py # Feed CRUD operations
│ │ ├── parser.py # RSS parsing with feedparser
│ │ ├── validator.py # Feed URL/auth validation
│ │ └── models.py # Feed data models
│ ├── utils/
│ │ ├── __init__.py
│ │ ├── logging.py # Logging setup
│ │ ├── errors.py # Custom exceptions
│ │ └── paths.py # XDG-compliant path handling
│ └── constants.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Pytest fixtures
│ ├── unit/
│ │ ├── test_config.py
│ │ ├── test_feeds.py
│ │ └── test_crypto.py
│ ├── integration/
│ │ └── test_cli.py
│ └── fixtures/
│ ├── sample_feeds.xml
│ └── sample_config.yaml
├── docs/ # Existing DKS
├── pyproject.toml
├── README.md
├── CLAUDE.md
├── LICENSE
├── .gitignore
├── .pre-commit-config.yaml
└── Makefile # Development shortcuts
Rationale:
- src/ layout for proper package isolation
- Modular structure with clear separation of concerns
- Test directory mirrors source structure
- Fixtures separate from test code
1.2 pyproject.toml Setup¶
Core Dependencies:
[project]
name = "inkwell-cli"
version = "0.1.0"
description = "Transform podcast episodes into structured markdown notes"
requires-python = ">=3.10"
dependencies = [
"typer[all]>=0.12.0", # CLI framework with rich support
"pyyaml>=6.0", # Config management
"feedparser>=6.0.0", # RSS parsing
"pydantic>=2.0.0", # Data validation
"pydantic-settings>=2.0.0", # Settings management
"cryptography>=42.0.0", # Credential encryption
"httpx>=0.27.0", # HTTP client for RSS fetching
"platformdirs>=4.0.0", # XDG-compliant paths
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-cov>=4.1.0",
"pytest-mock>=3.12.0",
"ruff>=0.3.0",
"mypy>=1.8.0",
"pre-commit>=3.6.0",
"types-pyyaml",
"respx>=0.20.0", # httpx mocking
]
[project.scripts]
inkwell = "inkwell.cli:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "A", "C4", "PT"]
ignore = []
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
addopts = "--cov=inkwell --cov-report=term-missing --cov-report=html"
Key Decisions: - Hatchling for build backend (modern, PEP 621 compliant) - Ruff for linting/formatting (fast, replaces black+flake8+isort) - Mypy for type checking (catch errors early) - Cryptography library for credential encryption (industry standard) - Platformdirs for XDG Base Directory compliance (proper Linux/macOS paths)
1.3 Development Tooling¶
Pre-commit hooks (.pre-commit-config.yaml): - Ruff linting and formatting - Mypy type checking - YAML validation - Trailing whitespace removal - End-of-file fixer
Makefile shortcuts:
install:
pip install -e ".[dev]"
test:
pytest
lint:
ruff check .
mypy src/
format:
ruff format .
clean:
rm -rf build/ dist/ *.egg-info .coverage htmlcov/ .pytest_cache/
2. Configuration System¶
2.1 Config Schema (Pydantic)¶
Goals: - Type-safe configuration - Automatic validation - Clear error messages on invalid config - Support for environment variables
Models (config/schema.py):
from pydantic import BaseModel, Field, HttpUrl, DirectoryPath
from typing import Literal, Optional
class AuthConfig(BaseModel):
type: Literal["none", "basic", "bearer"] = "none"
username: Optional[str] = None # Encrypted when stored
password: Optional[str] = None # Encrypted when stored
token: Optional[str] = None # Encrypted when stored
class FeedConfig(BaseModel):
url: HttpUrl
auth: AuthConfig = Field(default_factory=AuthConfig)
category: Optional[str] = None
custom_templates: list[str] = Field(default_factory=list)
class GlobalConfig(BaseModel):
version: str = "1"
default_output_dir: DirectoryPath = Field(
default="~/podcasts",
description="Where to save processed episodes"
)
transcription_model: str = "gemini-2.0-flash-exp"
interview_model: str = "claude-sonnet-4-5"
youtube_check: bool = True
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
default_templates: list[str] = Field(
default_factory=lambda: ["summary", "quotes", "key-concepts"]
)
template_categories: dict[str, list[str]] = Field(
default_factory=lambda: {
"tech": ["tools-mentioned", "frameworks-mentioned"],
"interview": ["books-mentioned", "people-mentioned"],
}
)
class Feeds(BaseModel):
feeds: dict[str, FeedConfig] = Field(default_factory=dict)
2.2 Config Storage¶
XDG Base Directory Compliance:
- Config: ~/.config/inkwell/ ($XDG_CONFIG_HOME/inkwell/)
- Data: ~/.local/share/inkwell/ ($XDG_DATA_HOME/inkwell/)
- Cache: ~/.cache/inkwell/ ($XDG_CACHE_HOME/inkwell/)
Files:
~/.config/inkwell/
├── config.yaml # Global settings
├── feeds.yaml # Feed configurations
└── .keyfile # Encryption key (600 permissions)
2.3 Credential Encryption¶
Strategy:
- Use Fernet (symmetric encryption from cryptography library)
- Generate encryption key on first run, store in ~/.config/inkwell/.keyfile
- Encrypt all sensitive fields (passwords, tokens, usernames)
- Never log or display decrypted credentials
Implementation (config/crypto.py):
from cryptography.fernet import Fernet
import os
class CredentialEncryptor:
def __init__(self, key_path: Path):
self.key_path = key_path
self._cipher = None
def _ensure_key(self) -> bytes:
if self.key_path.exists():
return self.key_path.read_bytes()
# Generate new key
key = Fernet.generate_key()
self.key_path.write_bytes(key)
self.key_path.chmod(0o600) # Owner read/write only
return key
def encrypt(self, plaintext: str) -> str:
if not plaintext:
return ""
cipher = Fernet(self._ensure_key())
return cipher.encrypt(plaintext.encode()).decode()
def decrypt(self, ciphertext: str) -> str:
if not ciphertext:
return ""
cipher = Fernet(self._ensure_key())
return cipher.decrypt(ciphertext.encode()).decode()
Security Considerations: - Key file has 600 permissions (owner only) - Warn user if key file permissions are too open - Support key rotation in future versions - Consider system keyring integration (v0.2+)
2.4 Config Manager¶
Responsibilities: - Load/save YAML files - Merge defaults with user config - Validate configuration - Encrypt/decrypt credentials transparently - Handle migrations between config versions
Key Methods (config/manager.py):
class ConfigManager:
def __init__(self, config_dir: Path | None = None):
self.config_dir = config_dir or get_config_dir()
self.encryptor = CredentialEncryptor(self.config_dir / ".keyfile")
def load_config(self) -> GlobalConfig:
"""Load and validate global config"""
def save_config(self, config: GlobalConfig) -> None:
"""Save global config with validation"""
def load_feeds(self) -> Feeds:
"""Load feeds with decrypted credentials"""
def save_feeds(self, feeds: Feeds) -> None:
"""Save feeds with encrypted credentials"""
def add_feed(self, name: str, feed_config: FeedConfig) -> None:
"""Add or update a feed"""
def remove_feed(self, name: str) -> None:
"""Remove a feed"""
def get_feed(self, name: str) -> FeedConfig:
"""Get single feed config"""
def list_feeds(self) -> dict[str, FeedConfig]:
"""List all feeds"""
3. Feed Management¶
3.1 RSS Parser¶
Responsibilities: - Fetch RSS feed with authentication - Parse with feedparser - Extract episode metadata - Handle malformed feeds gracefully - Support redirects - Cache feed data (optional)
Implementation (feeds/parser.py):
import feedparser
import httpx
from typing import Optional
class RSSParser:
def __init__(self, timeout: int = 30):
self.timeout = timeout
async def fetch_feed(
self,
url: str,
auth: Optional[AuthConfig] = None
) -> feedparser.FeedParserDict:
"""Fetch and parse RSS feed with auth support"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
headers = self._build_auth_headers(auth)
response = await client.get(url, headers=headers, follow_redirects=True)
response.raise_for_status()
feed = feedparser.parse(response.content)
if feed.bozo: # Feedparser error flag
# Log warning but continue if we got entries
if not feed.entries:
raise FeedParseError(f"Failed to parse feed: {feed.bozo_exception}")
return feed
def get_latest_episode(self, feed: feedparser.FeedParserDict) -> Episode:
"""Extract latest episode from feed"""
def get_episode_by_title(
self,
feed: feedparser.FeedParserDict,
title_keyword: str
) -> Episode:
"""Find episode by title keyword (fuzzy match)"""
def extract_episode_metadata(self, entry: dict) -> Episode:
"""Extract Episode model from feedparser entry"""
3.2 Feed Models¶
Data Models (feeds/models.py):
from pydantic import BaseModel, HttpUrl
from datetime import datetime
class Episode(BaseModel):
title: str
url: HttpUrl # Direct audio/video URL
published: datetime
description: str
duration_seconds: Optional[int] = None
podcast_name: str
episode_number: Optional[int] = None
season_number: Optional[int] = None
@property
def slug(self) -> str:
"""Generate filesystem-safe episode identifier"""
date_str = self.published.strftime("%Y-%m-%d")
title_slug = slugify(self.title)
return f"{self.podcast_name}-{date_str}-{title_slug}"
3.3 Feed Validator¶
Validation (feeds/validator.py):
class FeedValidator:
async def validate_feed_url(self, url: str, auth: Optional[AuthConfig] = None) -> bool:
"""Check if URL is valid and accessible"""
async def validate_auth(self, url: str, auth: AuthConfig) -> bool:
"""Verify authentication works"""
def validate_feed_has_episodes(self, feed: feedparser.FeedParserDict) -> bool:
"""Ensure feed has at least one episode"""
4. CLI Commands (Typer)¶
4.1 Command Structure¶
Main CLI (cli.py):
import typer
from rich.console import Console
from rich.table import Table
app = typer.Typer(
name="inkwell",
help="Transform podcast episodes into structured markdown notes",
no_args_is_help=True,
)
console = Console()
# Subcommands
@app.command()
def add(
url: str = typer.Argument(..., help="RSS feed URL"),
name: str = typer.Option(..., "--name", "-n", help="Feed identifier"),
auth: bool = typer.Option(False, "--auth", help="Prompt for authentication"),
category: Optional[str] = typer.Option(None, "--category", "-c", help="Feed category"),
) -> None:
"""Add a new podcast feed"""
@app.command()
def list() -> None:
"""List all configured feeds"""
@app.command()
def remove(
name: str = typer.Argument(..., help="Feed name to remove"),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
) -> None:
"""Remove a podcast feed"""
@app.command()
def config_show() -> None:
"""Display current configuration"""
@app.command()
def config_edit() -> None:
"""Open config file in $EDITOR"""
@app.command()
def config_set(
key: str = typer.Argument(..., help="Config key (dot notation)"),
value: str = typer.Argument(..., help="New value"),
) -> None:
"""Set a configuration value"""
4.2 Rich Output¶
Tables for list command:
def list_feeds(feeds: dict[str, FeedConfig]) -> None:
table = Table(title="Configured Podcast Feeds")
table.add_column("Name", style="cyan")
table.add_column("URL", style="blue")
table.add_column("Auth", style="yellow")
table.add_column("Category", style="green")
for name, feed in feeds.items():
auth_status = "✓" if feed.auth.type != "none" else "—"
table.add_row(name, str(feed.url), auth_status, feed.category or "—")
console.print(table)
Progress indicators:
- Use rich.progress for long operations
- Spinners for network requests
- Progress bars for downloads (Phase 2)
4.3 Error Handling¶
Custom Exceptions (utils/errors.py):
class InkwellError(Exception):
"""Base exception for all Inkwell errors"""
class ConfigError(InkwellError):
"""Configuration-related errors"""
class FeedError(InkwellError):
"""Feed management errors"""
class AuthenticationError(FeedError):
"""Authentication failures"""
class FeedParseError(FeedError):
"""RSS parsing failures"""
Error Display:
- Use rich.console.print with [red] for errors
- Show helpful suggestions (e.g., "Run inkwell config show to verify settings")
- Include debug info when --verbose flag is set
5. Logging¶
5.1 Logging Setup (utils/logging.py)¶
Strategy:
- Console logging via rich (user-facing)
- File logging for debugging (~/.cache/inkwell/inkwell.log)
- Structured logging with context
- Respect log_level from config
import logging
from rich.logging import RichHandler
def setup_logging(level: str = "INFO", log_file: Optional[Path] = None) -> None:
# Rich handler for console
console_handler = RichHandler(
show_time=False,
show_path=False,
markup=True,
)
# File handler for debugging
file_handler = logging.FileHandler(log_file or get_log_path())
file_handler.setFormatter(
logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
)
logging.basicConfig(
level=getattr(logging, level),
handlers=[console_handler, file_handler],
)
6. Testing Strategy¶
6.1 Test Coverage Targets¶
- Unit Tests: 90%+ coverage
- Integration Tests: All CLI commands
- Fixtures: Realistic sample data
6.2 Key Test Areas¶
Config Tests (tests/unit/test_config.py): - YAML serialization/deserialization - Pydantic validation - Default value handling - Config merging - Path resolution
Crypto Tests (tests/unit/test_crypto.py): - Encryption/decryption round-trip - Key generation and storage - Permission checking - Empty/null handling
Feed Tests (tests/unit/test_feeds.py): - RSS parsing (with fixtures) - Episode metadata extraction - URL validation - Auth header construction
CLI Tests (tests/integration/test_cli.py): - All command invocations - Error handling - Output formatting - Config file creation
6.3 Fixtures¶
Sample RSS Feed (tests/fixtures/sample_feed.xml): - Valid RSS 2.0 feed - Multiple episodes - Various metadata scenarios
Sample Config (tests/fixtures/sample_config.yaml): - Pre-configured feeds - Custom settings
6.4 Mocking Strategy¶
- Use
pytest-mockfor function mocking - Use
respxfor httpx request mocking - Mock filesystem operations for path tests
- Never hit real APIs in tests
7. Implementation Order¶
Week 1: Day-by-Day Breakdown¶
Day 1: Project Setup
- [ ] Create directory structure
- [ ] Setup pyproject.toml with all dependencies
- [ ] Configure ruff, mypy, pytest
- [ ] Setup pre-commit hooks
- [ ] Create Makefile
- [ ] Verify installation works (pip install -e ".[dev]")
Day 2: Config System - Part 1 - [ ] Implement Pydantic schemas (schema.py) - [ ] Implement path utilities (utils/paths.py) - [ ] Write unit tests for schemas - [ ] Create default config templates
Day 3: Config System - Part 2 - [ ] Implement credential encryption (config/crypto.py) - [ ] Write crypto tests - [ ] Implement ConfigManager (config/manager.py) - [ ] Write ConfigManager tests
Day 4: Feed Management - Backend
- [ ] Implement RSS parser (feeds/parser.py)
- [ ] Implement feed models (feeds/models.py)
- [ ] Implement feed validator (feeds/validator.py)
- [ ] Write feed tests with fixtures
- [x] Implement FeedManager (Not needed - ConfigManager handles feed operations)
Day 5: CLI Commands
- [ ] Implement main CLI entry point (cli.py)
- [ ] Implement add command
- [ ] Implement list command
- [ ] Implement remove command
- [ ] Implement config commands
- [ ] Write CLI integration tests
Day 6: Polish & Error Handling - [ ] Add comprehensive error messages - [ ] Add logging throughout - [ ] Add rich output formatting - [ ] Add command-line help text - [ ] Test error scenarios
Day 7: Documentation & Testing - [ ] Write README with installation and usage - [ ] Add docstrings to all public functions - [ ] Ensure 90%+ test coverage - [ ] Create example config files - [ ] Manual end-to-end testing - [ ] Create ADR for significant decisions made
Quality Gates¶
Before Considering Phase 1 Complete:¶
Functionality: - [ ] Can add feeds with all auth types (none, basic, bearer) - [ ] Can list feeds with rich table output - [ ] Can remove feeds with confirmation - [ ] Can modify config via CLI - [ ] Config stored in XDG-compliant paths - [ ] Credentials properly encrypted
Code Quality: - [ ] All tests passing - [ ] 90%+ test coverage - [ ] No mypy errors - [ ] No ruff warnings - [ ] Pre-commit hooks passing
User Experience:
- [ ] Clear, helpful error messages
- [ ] Rich terminal output (colors, tables)
- [ ] --help text is comprehensive
- [ ] Works on Linux and macOS
- [ ] Installation via pip works
Documentation: - [ ] README has installation instructions - [ ] README has usage examples - [ ] All public APIs have docstrings - [ ] DKS updated (devlogs, ADRs created)
Key Decisions & ADRs to Create¶
ADR-002: Config Management Strategy - Decision: XDG Base Directory + YAML + Pydantic - Alternatives considered: TOML, JSON, INI - Rationale: YAML human-friendly, Pydantic validation, XDG compliance
ADR-003: Credential Encryption Approach - Decision: Fernet symmetric encryption - Alternatives: System keyring, plaintext (rejected) - Rationale: Balance security and simplicity, no external deps
ADR-004: CLI Framework Selection - Decision: Typer (already in PRD) - Rationale: Modern, type-safe, rich integration
Open Questions for User¶
-
Package Distribution: Should we publish to PyPI immediately or wait until v0.1 is complete?
-
System Keyring: Should we support OS keyrings (macOS Keychain, Secret Service API) in Phase 1 or defer to Phase 2?
-
Config Migration: How should we handle config version upgrades in the future?
-
Error Reporting: Should we add telemetry/crash reporting (opt-in) or keep it fully offline?
-
Testing on Windows: PRD doesn't mention Windows - should we support it?
Success Criteria¶
Phase 1 is complete when: 1. A user can install inkwell via pip 2. A user can add/list/remove feeds including private feeds 3. All credentials are encrypted at rest 4. Configuration is validated and provides clear errors 5. Code is tested, typed, and linted 6. Documentation allows a new user to get started in <5 minutes
Next Steps¶
After Phase 1: - Phase 2: Transcription (YouTube API + Gemini fallback) - Phase 3: LLM extraction pipeline - Phase 4: Interview mode - Phase 5: Obsidian integration
Notes¶
- This plan is intentionally detailed to avoid ambiguity during implementation
- Each module is designed to be independently testable
- Security is prioritized (encrypted credentials, proper permissions)
- User experience is prioritized (rich output, helpful errors)
- Code quality is enforced via tooling (ruff, mypy, pre-commit)
- Following DKS: This devlog will be updated as implementation progresses