# textlog Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a macOS clipboard + screenshot capture daemon that exposes its archive to Claude Code as an MCP server, with on-device OCR via Apple Vision Framework. The tool **does not contain an LLM** — Claude Code is the LLM.

**Architecture:** Rust daemon using NSPasteboard polling with `detect()`-gated reads; Apple Vision `VNRecognizeTextRequest` for OCR; SQLite ring buffer + daily Markdown archive; stdio JSON-RPC 2.0 MCP server exposing `textlog__*` tools to Claude Code. LaunchAgent for background execution.

**Tech Stack:** Rust 2021 edition, clap 4.5, tokio 1.42, objc2 0.6, objc2-vision 0.3, rusqlite 0.31, rmcp 0.2, notify-rust 4, regex 1, sha2 0.10

---

## File Structure

```
textlog/
├── Cargo.toml                     # Project manifest
├── src/
│   ├── main.rs                    # CLI entry point
│   ├── cli/
│   │   ├── mod.rs                 # Command dispatch
│   │   ├── args.rs                # clap derive structs
│   │   └── commands.rs            # Command handlers (start/stop/status/config/logs/doctor)
│   ├── pipeline.rs                # Orchestrates filter → OCR → storage → notifier
│   ├── clipboard.rs               # NSPasteboard polling + detect() gating + self-write skip
│   ├── config/
│   │   ├── mod.rs                 # Config root + load/save
│   │   ├── schema.rs              # Versioned schema v1 structs
│   │   └── overlay.rs             # env var overlay + CLI flag precedence
│   ├── ocr.rs                     # Apple Vision VNRecognizeTextRequest wrapper
│   ├── storage/
│   │   ├── mod.rs                 # Public API: insert, get_recent, search, clear_since
│   │   ├── sqlite.rs              # rusqlite + FTS5 ring buffer
│   │   └── markdown.rs            # Daily MD archive writer
│   ├── mcp/
│   │   ├── mod.rs                 # rmcp server + tool registry
│   │   ├── tools.rs               # textlog__get_recent, search, ocr_image, ocr_latest, …
│   │   └── schema.rs              # Tool argument/result JSON schemas
│   ├── notifier.rs                # notify-rust dispatch + clipboard path copy + self-write token
│   ├── filters.rs                 # Privacy filters + self-log-path detection
│   ├── macos_perm.rs              # Pasteboard/notification permission checks (tl doctor)
│   ├── service/
│   │   ├── mod.rs                 # LaunchAgent lifecycle API
│   │   ├── plist.rs               # com.textlog.agent.plist generator
│   │   └── launchctl.rs           # launchctl bootstrap/bootout/kickstart wrapper
│   └── error.rs                   # Error types
├── tests/
│   ├── cli_test.rs
│   ├── config_test.rs
│   ├── filters_test.rs
│   ├── storage_test.rs            # SQLite schema + ring buffer + FTS5 + MD writer
│   ├── ocr_test.rs                # Vision OCR against fixture images
│   ├── mcp_test.rs                # tools/list + tools/call via piped stdio
│   └── notifier_test.rs
├── docs/
│   └── superpowers/
│       ├── specs/2026-04-14-textlog-design.md
│       └── plans/2026-04-14-textlog.md
└── textlog-example-config.toml    # Example full configuration
```

---

## v2.0 Pivot Addendum — Authoritative over all v1.x task bodies

**Scope change:** textlog is now a Claude Code MCP server + Apple Vision OCR,
**not** a local-LLM wrapper. The v1.x task bodies below still contain useful
TDD scaffolding (RED → GREEN → commit), but where they conflict with the
sections below, follow *these*.

### Deletions (remove entirely from the original plan)

- **Phase 6 "Providers"** — no trait, no Ollama client, no genai, no MLX. Skip Task 7 in its original form.
- Any reference to `SiliconBasedWorld/Gemma-4-31B-JANG_4M-CRACK`, `gemma4:31b`, `llava`, `ollama pull`, `provider.active`, `[providers.*]`, or `api_key_env` in later phases.
- `providers_test.rs` — replace with `mcp_test.rs` + `ocr_test.rs` + `storage_test.rs`.
- Cloud-provider warnings, API-key checks, and multi-backend doctor output.

### Additions

- **Phase 6 "OCR + Storage + MCP Server"** replaces the deleted providers phase.
  - Task 7a: `ocr` module (Apple Vision via `objc2-vision`).
  - Task 7b: `storage` module (rusqlite + FTS5 ring buffer + Markdown writer).
  - Task 7c: `mcp` module (rmcp stdio server + `textlog__*` tools).
- **`tl mcp` subcommand** — added in the CLI task so `claude mcp add textlog -- tl mcp` works.
- **SHA-256 content hashing + dedup** — part of storage insertion path.

### A. Config schema (supersedes Task 4 structs)

```rust
// src/config/schema.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    #[serde(default = "default_schema_version")]
    pub schema_version: u32,        // = 2 in v2.0
    pub monitoring:    MonitoringConfig,
    pub ocr:           OcrConfig,
    pub storage:       StorageConfig,
    pub privacy:       PrivacyConfig,
    pub notifications: NotificationsConfig,
    pub mcp:           McpConfig,
    pub ui:            UiConfig,
    pub log:           LogConfig,
}
fn default_schema_version() -> u32 { 2 }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitoringConfig {
    pub enabled: bool,
    pub poll_interval_ms: u64,
    pub min_length: usize,
    #[serde(default)]
    pub ignore_patterns: Vec<String>,
    pub ignore_own_log_paths: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OcrConfig {
    pub enabled: bool,
    pub recognition_level: String,  // "accurate" | "fast"
    pub languages: Vec<String>,     // e.g. ["en-US"]
    pub min_confidence: f32,
    pub image_max_dimension: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
    pub log_dir: String,
    pub sqlite_path: String,
    pub ring_buffer_size: usize,    // captures kept in SQLite
    pub date_format: String,
    pub max_md_file_size_mb: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacyConfig {
    pub filter_enabled: bool,
    pub log_sensitive: bool,
    pub show_filter_notification: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationsConfig {
    pub enabled: bool,
    pub on_capture: bool,
    pub on_complete: bool,
    pub copy_log_path_on_complete: bool,
    pub sound: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpConfig {
    pub max_recent: u32,
    pub max_search_limit: u32,
    pub max_search_results_bytes: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig { pub pager: String, pub color_output: String, pub timestamp_format: String }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogConfig { pub level: String, pub format: String }

impl Default for Config {
    fn default() -> Self { /* values mirror the default TOML in the spec */ }
}
```

No `provider`, no `providers.*`, no `api_key_env`. No `ocr.prompt` (Apple Vision is turnkey). No `ocr.fallback_apple_vision` (it IS the primary).

### B. OCR module (replaces Task 7 Part 1)

```rust
// src/ocr.rs
use objc2_vision::{VNRecognizeTextRequest, VNImageRequestHandler};

pub struct OcrResult {
    pub text: String,
    pub confidence: f32,
    pub block_count: usize,
}

pub async fn ocr_image(bytes: &[u8], cfg: &OcrConfig) -> Result<OcrResult> {
    // 1. Decode bytes into NSImage / CGImage.
    // 2. If dim > cfg.image_max_dimension, downscale (objc2-app-kit).
    // 3. Build VNRecognizeTextRequest:
    //    - recognitionLevel: accurate | fast
    //    - recognitionLanguages: cfg.languages
    // 4. VNImageRequestHandler::performRequests.
    // 5. Collect VNRecognizedTextObservation.topCandidates(1) strings.
    // 6. Return concatenated text + mean confidence.
}
```

Tests (`tests/ocr_test.rs`) use fixture PNGs committed to `tests/fixtures/`.

### C. Storage module (replaces Task 7 Part 2)

```rust
// src/storage/mod.rs
pub struct Storage { db: rusqlite::Connection, md_dir: PathBuf, ring_buffer_size: usize }

pub struct CaptureRow {
    pub id: i64,
    pub ts: DateTime<Utc>,
    pub kind: Kind,            // Text | Image | File
    pub sha256: [u8; 32],
    pub size_bytes: usize,
    pub content: Option<String>,
    pub ocr_confidence: Option<f32>,
    pub source_app: Option<String>,
    pub source_url: Option<String>,
    pub md_path: PathBuf,
}

impl Storage {
    pub async fn insert(&self, row: CaptureRow) -> Result<i64>;   // SHA-256 dedup, write MD, enforce ring buffer
    pub async fn get_recent(&self, n: u32, kind: Option<Kind>) -> Result<Vec<CaptureRow>>;
    pub async fn search(&self, query: &str, limit: u32, since: Option<DateTime<Utc>>) -> Result<Vec<CaptureRow>>;
    pub async fn clear_since(&self, ts: DateTime<Utc>) -> Result<usize>;
    pub async fn get_latest_image(&self) -> Result<Option<CaptureRow>>;
}
```

Schema: see spec "Data Format → SQLite schema". FTS5 virtual table for `search`.

### D. MCP server module (replaces Task 7 Part 3)

```rust
// src/mcp/mod.rs
use rmcp::{Server, tool};

pub fn build_server(storage: Arc<Storage>) -> Server {
    Server::builder()
        .info("textlog", env!("CARGO_PKG_VERSION"))
        .tool(tools::get_recent(storage.clone()))
        .tool(tools::search(storage.clone()))
        .tool(tools::ocr_image(storage.clone()))
        .tool(tools::ocr_latest(storage.clone()))
        .tool(tools::list_today(storage.clone()))
        .tool(tools::clear_since(storage.clone()))
        .build()
}

pub async fn run_stdio(storage: Arc<Storage>) -> Result<()> {
    build_server(storage).run_stdio().await
}
```

Tool handlers live in `src/mcp/tools.rs`; JSON schemas for arguments/results in `src/mcp/schema.rs` (used for `tools/list` metadata).

Tests (`tests/mcp_test.rs`) pipe hand-crafted JSON-RPC requests into `tl mcp` and assert response shape, dedup correctness, and input-size caps.

### E. Clipboard monitor additions (unchanged from v1.1)

Still required, still applies to Task 8:

1. `detect()` gating on `NSPasteboard.types()`.
2. Self-write token via shared `Arc<AtomicI64>` from notifier.
3. Self-log-path heuristic when `ignore_own_log_paths = true`.

### F. Example config (replaces Task 10 body)

Use the full TOML from the spec's "Complete default configuration" block — no
provider sections, new `[mcp]` + `[storage]` (with `sqlite_path`, `ring_buffer_size`)
shapes.

---

## New Phases (v2.0)

### Phase 10: Notifier

**Task 12: Implement `notifier` module**
- Wraps `notify-rust::Notification::new()` with `summary = "textlog"`, `body = <message>`, `sound = cfg.notifications.sound`.
- `notify_capture(entry: &CaptureSummary)` — fired when `on_capture = true`.
- `notify_complete(log_path: &Path)` — fired when `on_complete = true`; if `copy_log_path_on_complete = true`, writes `log_path.to_string_lossy()` to pasteboard *and* publishes the resulting `changeCount` into the shared `AtomicI64` (see Addendum C).
- RED/GREEN: unit tests with a `MockNotifier` trait behind `#[cfg(test)]`; integration test gated by `#[ignore]` fires a real notification.

### Phase 11: Pipeline

**Task 13: Implement `pipeline` module**
- Single async task: `async fn run(cfg, clipboard_rx, ocr, storage, notifier)`.
- For each `ClipboardEvent`:
  1. Apply privacy filter (drop + optional notify if matched).
  2. If `kind = Image`, call `ocr::ocr_image` to extract text.
  3. Compute SHA-256; call `storage.insert` (dedup handled inside storage).
  4. If `notifications.on_complete`, call `notifier.notify_complete(md_path)`.
- Backpressure: if OCR or storage are slow, buffer up to 16 events; drop oldest beyond that with `tracing::warn!`.
- Tests: table-driven with an in-memory `Storage` implementation and a fixture-backed `Ocr` stub. No real Vision calls in unit tests.

### Phase 12: macOS permissions + `tl doctor`

**Task 14: Implement `macos_perm` + flesh out `tl doctor`**
- `macos_perm::pasteboard_access_state()` — reads `NSPasteboard.accessBehavior`. Returns `Always | Ask | Never | Unknown`.
- `macos_perm::notification_state()` — queries `UNUserNotificationCenter` auth state if accessible, else best-effort via `notify-rust` probe.
- `tl doctor` output (all of these, with pass/fail line per item; exit non-zero if any blocker):
  - Config file present + `0600` permissions
  - `storage.log_dir` writable
  - `storage.sqlite_path` openable + FTS5 available
  - Clipboard permission state
  - Notification permission state
  - LaunchAgent installed / loaded / last exit code
  - MCP registration present (shell out `claude mcp list` and grep for `textlog`, if the `claude` CLI is on PATH)
  - Apple Vision OCR smoke test (run on a 1×1 PNG to verify the framework is reachable)

### Phase 13: LaunchAgent integration

**Task 15: Implement `service` module (LaunchAgent lifecycle)**

Files:
- `src/service/mod.rs` — public API
- `src/service/plist.rs` — generate plist content (serde-xml-rs or a hand-rolled template)
- `src/service/launchctl.rs` — shell out to `launchctl` with structured error handling

Public API:

```rust
pub fn install(opts: InstallOptions) -> Result<PathBuf>;  // returns plist path
pub fn uninstall() -> Result<()>;
pub fn start() -> Result<()>;
pub fn stop() -> Result<()>;
pub fn status() -> Result<ServiceStatus>;

pub struct InstallOptions { pub auto_start: bool, pub keep_alive: bool }
pub enum ServiceStatus {
    NotInstalled,
    Installed { loaded: bool, pid: Option<u32>, last_exit_code: Option<i32> },
}
```

Plist path: `~/Library/LaunchAgents/com.textlog.agent.plist`.

launchctl invocations:
- install → `launchctl bootstrap gui/$UID <plist>` (modern replacement for `load`)
- uninstall → `launchctl bootout gui/$UID/com.textlog.agent`
- start → `launchctl kickstart gui/$UID/com.textlog.agent`
- stop → `launchctl kill SIGTERM gui/$UID/com.textlog.agent`
- status → parse `launchctl print gui/$UID/com.textlog.agent` (tolerant of format changes)

Implementation notes:
- Use `whoami::uid()` or `libc::getuid()` for UID.
- `ProgramArguments` must reference an absolute path. Resolve via `std::env::current_exe()` at install time and embed.
- Never write API keys into `EnvironmentVariables`; the agent reads them from the user's login env via `launchctl getenv` or from the current shell env at install time (documented caveat).
- Tests: mock launchctl with a trait `LaunchctlRunner` behind `#[cfg(test)]`, assert correct argument sequences.

**Task 16: Wire `install/uninstall/start/stop/status` into the CLI**

Modify `src/cli/commands.rs` to dispatch:
- `tl install [--auto-start] [--no-keep-alive]` → `service::install(...)`
- `tl uninstall` → `service::uninstall()`
- `tl start` (no flag) → `service::start()`
- `tl start --foreground` → skip launchctl, call `pipeline::run` inline
- `tl start --daemon` → hidden flag used by launchd; same as `--foreground` but routes tracing to file sinks
- `tl stop` → `service::stop()`
- `tl status` → pretty-print `service::status()` + pipeline health

The existing Task 9 CLI scaffold already has `start/stop/status`; this task replaces their handlers. `--foreground` and `--daemon` are new flags on the `Start` subcommand.

---


### Task 1: Initialize Rust Project

**Files:**
- Create: `Cargo.toml`
- Create: `src/main.rs`
- Create: `.gitignore`

- [ ] **Step 1: Create Cargo.toml with dependencies**

```toml
[package]
name = "textlog"
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
description = "A macOS clipboard monitoring and logging tool with local LLM integration"
authors = ["xicao"]
license = "MIT"

[[bin]]
name = "tl"
path = "src/main.rs"

[features]
default = ["mcp", "service"]
mcp = []
service = []

[dependencies]
# CLI
clap = { version = "4.5", features = ["derive"] }
clap_complete = "4.5"

# Async
tokio = { version = "1.42", features = ["full"] }

# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"

# Date/Time
chrono = { version = "0.4", features = ["serde"] }

# Error handling
anyhow = "1.0"
thiserror = "2.0"

# Logging (internal)
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# Directories
dirs = "5.0"

# Regex — privacy filter + self-log-path detection
regex = "1"

# SQLite ring buffer + FTS5 search
rusqlite = { version = "0.31", features = ["bundled", "chrono"] }

# Content hashing for dedup
sha2 = "0.10"

# macOS FFI
objc2 = "0.6"
objc2-foundation = "0.3"
objc2-app-kit = "0.3"
objc2-vision = "0.3"          # VNRecognizeTextRequest

# macOS notifications
notify-rust = "4"

# MCP server (stdio JSON-RPC 2.0)
rmcp = "0.2"

[dev-dependencies]
tokio-test = "0.4"
tempfile = "3"
```

Run: `cat > Cargo.toml << 'EOF'` [paste above content]

- [ ] **Step 2: Create minimal main.rs**

```rust
fn main() {
    println!("textlog - clipboard monitoring with AI");
}
```

Run: `mkdir -p src && cat > src/main.rs << 'EOF'
fn main() {
    println!("textlog - clipboard monitoring with AI");
}
EOF`

- [ ] **Step 3: Create .gitignore**

```gitignore
/target
.textlog/
.DS_Store
```

Run: `cat > .gitignore << 'EOF'
/target
.textlog/
.DS_Store
EOF`

- [ ] **Step 4: Verify build**

Run: `cargo build`
Expected: Binary compiled successfully

- [ ] **Step 5: Test run**

Run: `cargo run`
Expected: "textlog - clipboard monitoring with AI"

- [ ] **Step 6: Commit**

```bash
git init
git add .
git commit -m "feat: initialize textlog project with dependencies"
```

---

### Task 2: Create Directory Structure

**Files:**
- Create: `src/cli.rs`
- Create: `src/cli/commands.rs`
- Create: `src/cli/args.rs`
- Create: `src/clipboard.rs`
- Create: `src/config.rs`
- Create: `src/ollama.rs`
- Create: `src/logger.rs`
- Create: `src/filters.rs`
- Create: `src/error.rs`

- [ ] **Step 1: Create module stubs**

Run: `mkdir -p src/cli tests`

Create each file with minimal content:

```rust
// src/cli.rs
pub mod args;
pub mod commands;

// src/cli/args.rs
// src/cli/commands.rs  
// src/clipboard.rs
// src/config.rs
// src/ollama.rs
// src/logger.rs
// src/filters.rs
// src/error.rs
```

Run: `for file in src/cli.rs src/cli/args.rs src/cli/commands.rs src/clipboard.rs src/config.rs src/ollama.rs src/logger.rs src/filters.rs src/error.rs; do touch $file; echo "// $(basename $file | sed 's/\.[^.]*$//')" > $file; done`

- [ ] **Step 2: Update main.rs with module declarations**

```rust
mod cli;
mod clipboard;
mod config;
mod ollama;
mod logger;
mod filters;
mod error;

fn main() {
    println!("textlog - clipboard monitoring with AI");
}
```

Run: `cat > src/main.rs << 'EOF'
mod cli;
mod clipboard;
mod config;
mod ollama;
mod logger;
mod filters;
mod error;

fn main() {
    println!("textlog - clipboard monitoring with AI");
}
EOF`

- [ ] **Step 3: Verify compilation**

Run: `cargo build`
Expected: Builds without errors

- [ ] **Step 4: Commit**

```bash
git add .
git commit -m "feat: add module structure stub files"
```

---

## Phase 2: Error Types

### Task 3: Define Error Types

**Files:**
- Modify: `src/error.rs`

- [ ] **Step 1: Write error type tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_error_display() {
        let err = Error::ConfigNotFound("test.toml".to_string());
        assert!(err.to_string().contains("test.toml"));
    }

    #[test]
    fn test_ollama_connection_error() {
        let err = Error::OllamaConnection("localhost:11434".to_string());
        assert!(err.to_string().contains("Ollama"));
    }
}
```

Run: `cat > src/error.rs << 'EOFTEST'
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_error_display() {
        let err = Error::ConfigNotFound("test.toml".to_string());
        assert!(err.to_string().contains("test.toml"));
    }

    #[test]
    fn test_ollama_connection_error() {
        let err = Error::OllamaConnection("localhost:11434".to_string());
        assert!(err.to_string().contains("Ollama"));
    }
}
EOFTEST`

- [ ] **Step 2: Run test to verify failure**

Run: `cargo test error`
Expected: FAIL - Error type not defined

- [ ] **Step 3: Implement error types**

```rust
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Configuration file not found: {0}")]
    ConfigNotFound(String),

    #[error("Failed to parse configuration: {0}")]
    ConfigParse(#[from] toml::de::Error),

    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Ollama connection failed to {0}")]
    OllamaConnection(String),

    #[error("Ollama request failed: {0}")]
    OllamaRequest(String),

    #[error("Clipboard access failed: {0}")]
    ClipboardAccess(String),

    #[error("Log write failed at {path}: {source}")]
    LogWrite { path: String, source: std::io::Error },

    #[error("Privacy filter error: {0}")]
    FilterError(String),
}

pub type Result<T> = std::result::Result<T, Error>;
```

Run: `cat > src/error.rs << 'EOF'
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Configuration file not found: {0}")]
    ConfigNotFound(String),

    #[error("Failed to parse configuration: {0}")]
    ConfigParse(#[from] toml::de::Error),

    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Ollama connection failed to {0}")]
    OllamaConnection(String),

    #[error("Ollama request failed: {0}")]
    OllamaRequest(String),

    #[error("Clipboard access failed: {0}")]
    ClipboardAccess(String),

    #[error("Log write failed at {path}: {source}")]
    LogWrite { path: String, source: std::io::Error },

    #[error("Privacy filter error: {0}")]
    FilterError(String),
}

pub type Result<T> = std::result::Result<T, Error>;
EOF`

- [ ] **Step 4: Run tests to verify pass**

Run: `cargo test error`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/error.rs
git commit -m "feat: add error type definitions with tests"
```

---

## Phase 3: Configuration

### Task 4: Define Configuration Structures

**Files:**
- Modify: `src/config.rs`

- [ ] **Step 1: Write config structure tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config() {
        let config = Config::default();
        assert_eq!(config.monitoring.min_length, 10);
        assert!(config.monitoring.enabled);
    }

    #[test]
    fn test_config_toml_serialization() {
        let config = Config::default();
        let toml = toml::to_string(&config).unwrap();
        assert!(toml.contains("[monitoring]"));
    }

    #[test]
    fn test_default_ollama_endpoint() {
        let config = Config::default();
        assert_eq!(config.ollama.endpoint, "http://localhost:11434");
    }
}
```

Run: `cat > src/config.rs << 'EOFTEST'
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config() {
        let config = Config::default();
        assert_eq!(config.monitoring.min_length, 10);
        assert!(config.monitoring.enabled);
    }

    #[test]
    fn test_config_toml_serialization() {
        let config = Config::default();
        let toml = toml::to_string(&config).unwrap();
        assert!(toml.contains("[monitoring]"));
    }

    #[test]
    fn test_default_ollama_endpoint() {
        let config = Config::default();
        assert_eq!(config.ollama.endpoint, "http://localhost:11434");
    }
}
EOFTEST`

- [ ] **Step 2: Run test to verify failure**

Run: `cargo test config`
Expected: FAIL - Config not defined

- [ ] **Step 3: Implement configuration structures**

```rust
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    pub monitoring: MonitoringConfig,
    pub ollama: OllamaConfig,
    pub storage: StorageConfig,
    pub ocr: OcrConfig,
    pub privacy: PrivacyConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitoringConfig {
    pub enabled: bool,
    pub min_length: usize,
    #[serde(default)]
    pub ignore_patterns: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig {
    pub endpoint: String,
    pub model: String,
    #[serde(default = "default_timeout")]
    pub timeout: u64,
    #[serde(default = "default_connect_timeout")]
    pub connect_timeout: u64,
}

fn default_timeout() -> u64 { 30 }
fn default_connect_timeout() -> u64 { 5 }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
    #[serde(default = "default_log_dir")]
    pub log_dir: String,
    #[serde(default = "default_date_format")]
    pub date_format: String,
    #[serde(default = "default_max_file_size")]
    pub max_file_size_mb: usize,
}

fn default_log_dir() -> String { "~/textlog/logs".to_string() }
fn default_date_format() -> String { "%Y-%m-%d".to_string() }
fn default_max_file_size() -> usize { 10 }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OcrConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_ocr_method")]
    pub method: String,
    #[serde(default = "default_confidence")]
    pub confidence_threshold: f32,
}

fn default_ocr_method() -> String { "ollama".to_string() }
fn default_confidence() -> f32 { 0.7 }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacyConfig {
    #[serde(default)]
    pub filter_enabled: bool,
    #[serde(default)]
    pub log_sensitive: bool,
    #[serde(default)]
    pub show_filter_notification: bool,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            monitoring: MonitoringConfig {
                enabled: true,
                min_length: 10,
                ignore_patterns: vec![
                    "^sk-.*".to_string(),
                    "^\\w+_KEY=.*".to_string(),
                ],
            },
            ollama: OllamaConfig {
                endpoint: "http://localhost:11434".to_string(),
                model: "SiliconBasedWorld/Gemma-4-31B-JANG_4M-CRACK".to_string(),
                timeout: 30,
                connect_timeout: 5,
            },
            storage: StorageConfig {
                log_dir: "~/textlog/logs".to_string(),
                date_format: "%Y-%m-%d".to_string(),
                max_file_size_mb: 10,
            },
            ocr: OcrConfig {
                enabled: true,
                method: "ollama".to_string(),
                confidence_threshold: 0.7,
            },
            privacy: PrivacyConfig {
                filter_enabled: true,
                log_sensitive: false,
                show_filter_notification: true,
            },
        }
    }
}

pub fn get_config_path() -> Result<PathBuf, crate::error::Error> {
    let config_dir = dirs::home_dir()
        .ok_or_else(|| crate::error::Error::ConfigNotFound("Home directory".to_string()))?
        .join(".textlog");
    
    std::fs::create_dir_all(&config_dir)?;
    
    Ok(config_dir.join("config.toml"))
}

pub fn load_config() -> Result<Config, crate::error::Error> {
    let config_path = get_config_path()?;
    
    if !config_path.exists() {
        // Create default config
        let default_config = Config::default();
        let toml = toml::to_string_pretty(&default_config)
            .map_err(|e| crate::error::Error::ConfigParse(e.into()))?;
        std::fs::write(&config_path, toml)?;
        return Ok(default_config);
    }
    
    let contents = std::fs::read_to_string(&config_path)?;
    let config: Config = toml::from_str(&contents)
        .map_err(|e| crate::error::Error::ConfigParse(e.into()))?;
    Ok(config)
}
```

Run: `cat > src/config.rs << 'EOF'
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    pub monitoring: MonitoringConfig,
    pub ollama: OllamaConfig,
    pub storage: StorageConfig,
    pub ocr: OcrConfig,
    pub privacy: PrivacyConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitoringConfig {
    pub enabled: bool,
    pub min_length: usize,
    #[serde(default)]
    pub ignore_patterns: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig {
    pub endpoint: String,
    pub model: String,
    #[serde(default = "default_timeout")]
    pub timeout: u64,
    #[serde(default = "default_connect_timeout")]
    pub connect_timeout: u64,
}

fn default_timeout() -> u64 { 30 }
fn default_connect_timeout() -> u64 { 5 }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
    #[serde(default = "default_log_dir")]
    pub log_dir: String,
    #[serde(default = "default_date_format")]
    pub date_format: String,
    #[serde(default = "default_max_file_size")]
    pub max_file_size_mb: usize,
}

fn default_log_dir() -> String { "~/textlog/logs".to_string() }
fn default_date_format() -> String { "%Y-%m-%d".to_string() }
fn default_max_file_size() -> usize { 10 }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OcrConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_ocr_method")]
    pub method: String,
    #[serde(default = "default_confidence")]
    pub confidence_threshold: f32,
}

fn default_ocr_method() -> String { "ollama".to_string() }
fn default_confidence() -> f32 { 0.7 }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacyConfig {
    #[serde(default)]
    pub filter_enabled: bool,
    #[serde(default)]
    pub log_sensitive: bool,
    #[serde(default)]
    pub show_filter_notification: bool,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            monitoring: MonitoringConfig {
                enabled: true,
                min_length: 10,
                ignore_patterns: vec![
                    "^sk-.*".to_string(),
                    "^\\w+_KEY=.*".to_string(),
                ],
            },
            ollama: OllamaConfig {
                endpoint: "http://localhost:11434".to_string(),
                model: "SiliconBasedWorld/Gemma-4-31B-JANG_4M-CRACK".to_string(),
                timeout: 30,
                connect_timeout: 5,
            },
            storage: StorageConfig {
                log_dir: "~/textlog/logs".to_string(),
                date_format: "%Y-%m-%d".to_string(),
                max_file_size_mb: 10,
            },
            ocr: OcrConfig {
                enabled: true,
                method: "ollama".to_string(),
                confidence_threshold: 0.7,
            },
            privacy: PrivacyConfig {
                filter_enabled: true,
                log_sensitive: false,
                show_filter_notification: true,
            },
        }
    }
}

pub fn get_config_path() -> Result<PathBuf, crate::error::Error> {
    let config_dir = dirs::home_dir()
        .ok_or_else(|| crate::error::Error::ConfigNotFound("Home directory".to_string()))?
        .join(".textlog");
    
    std::fs::create_dir_all(&config_dir)?;
    
    Ok(config_dir.join("config.toml"))
}

pub fn load_config() -> Result<Config, crate::error::Error> {
    let config_path = get_config_path()?;
    
    if !config_path.exists() {
        // Create default config
        let default_config = Config::default();
        let toml = toml::to_string_pretty(&default_config)
            .map_err(|e| crate::error::Error::ConfigParse(e.into()))?;
        std::fs::write(&config_path, toml)?;
        return Ok(default_config);
    }
    
    let contents = std::fs::read_to_string(&config_path)?;
    let config: Config = toml::from_str(&contents)
        .map_err(|e| crate::error::Error::ConfigParse(e.into()))?;
    Ok(config)
}
EOF`

- [ ] **Step 4: Run tests to verify pass**

Run: `cargo test config`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/config.rs
git commit -m "feat: add configuration structures with tests"
```

---

## Phase 4: Privacy Filters

### Task 5: Implement Privacy Filters

**Files:**
- Modify: `src/filters.rs`

- [ ] **Step 1: Write filter tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_filter_api_key() {
        let content = "sk-1234567890abcdef";
        assert!(matches_sensitive(content));
    }

    #[test]
    fn test_filter_env_var() {
        let content = "API_KEY=sk-secret";
        assert!(matches_sensitive(content));
    }

    #[test]
    fn test_filter_credit_card() {
        let content = "Card: 4111-1111-1111-1111";
        assert!(matches_sensitive(content));
    }

    #[test]
    fn test_safe_content_passes() {
        let content = "This is safe text";
        assert!(!matches_sensitive(content));
    }
}
```

Run: `cat > src/filters.rs << 'EOFTEST'
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_filter_api_key() {
        let content = "sk-1234567890abcdef";
        assert!(matches_sensitive(content));
    }

    #[test]
    fn test_filter_env_var() {
        let content = "API_KEY=sk-secret";
        assert!(matches_sensitive(content));
    }

    #[test]
    fn test_filter_credit_card() {
        let content = "Card: 4111-1111-1111-1111";
        assert!(matches_sensitive(content));
    }

    #[test]
    fn test_safe_content_passes() {
        let content = "This is safe text";
        assert!(!matches_sensitive(content));
    }
}
EOFTEST`

- [ ] **Step 2: Run test to verify failure**

Run: `cargo test filters`
Expected: FAIL - functions not defined

- [ ] **Step 3: Implement privacy filters**

```rust
use regex::Regex;

pub fn matches_sensitive(content: &str) -> bool {
    // API Keys pattern
    let api_key_regex = Regex::new(r"^sk-[a-zA-Z0-9]{20,}$").unwrap();
    if api_key_regex.is_match(content) {
        return true;
    }
    
    // Environment variable pattern
    let env_var_regex = Regex::new(r"\w+_KEY\s*=\s*\S+").unwrap();
    if env_var_regex.is_match(content) {
        return true;
    }
    
    // Credit card-like pattern
    let cc_regex = Regex::new(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b").unwrap();
    if cc_regex.is_match(content) {
        return true;
    }
    
    // Password pattern
    let password_regex = Regex::new(r"(?i)password\s*[=:]\s*\S+").unwrap();
    if password_regex.is_match(content) {
        return true;
    }
    
    false
}

pub fn filter_content(content: &str, patterns: &[String]) -> bool {
    // First check default patterns
    if matches_sensitive(content) {
        return true;
    }
    
    // Then check user-defined patterns
    for pattern in patterns {
        if let Ok(regex) = Regex::new(pattern) {
            if regex.is_match(content) {
                return true;
            }
        }
    }
    
    false
}
```

Run: `cat > src/filters.rs << 'EOF'
use regex::Regex;

pub fn matches_sensitive(content: &str) -> bool {
    // API Keys pattern
    let api_key_regex = Regex::new(r"^sk-[a-zA-Z0-9]{20,}$").unwrap();
    if api_key_regex.is_match(content) {
        return true;
    }
    
    // Environment variable pattern
    let env_var_regex = Regex::new(r"\w+_KEY\s*=\s*\S+").unwrap();
    if env_var_regex.is_match(content) {
        return true;
    }
    
    // Credit card-like pattern
    let cc_regex = Regex::new(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b").unwrap();
    if cc_regex.is_match(content) {
        return true;
    }
    
    // Password pattern
    let password_regex = Regex::new(r"(?i)password\s*[=:]\s*\S+").unwrap();
    if password_regex.is_match(content) {
        return true;
    }
    
    false
}

pub fn filter_content(content: &str, patterns: &[String]) -> bool {
    // First check default patterns
    if matches_sensitive(content) {
        return true;
    }
    
    // Then check user-defined patterns
    for pattern in patterns {
        if let Ok(regex) = Regex::new(pattern) {
            if regex.is_match(content) {
                return true;
            }
        }
    }
    
    false
}
EOF`

- [ ] **Step 4: Add regex dependency**

Update `[dependencies]` in Cargo.toml:
```toml
regex = "1.11"
```

Run: `cargo add regex`

- [ ] **Step 5: Run tests to verify pass**

Run: `cargo test filters`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add src/filters.rs Cargo.toml
git commit -m "feat: add privacy filter patterns with tests"
```

---

## Phase 5: Logger

### Task 6: Implement Log File Writer

**Files:**
- Modify: `src/logger.rs`

- [ ] **Step 1: Write logger tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn cleanup_test_log(path: &PathBuf) {
        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn test_log_entry_format() {
        let entry = LogEntry::new_text("test content", "Arc");
        let formatted = entry.to_markdown();
        assert!(formatted.contains("test content"));
        assert!(formatted.contains("Arc"));
    }

    #[test]
    fn test_get_log_file_path() {
        let date_format = "%Y-%m-%d";
        let path = get_log_file_path("~/test-logs", date_format);
        assert!(path.to_string_lossy().contains("test-logs"));
    }

    #[test]
    fn test_append_to_log() {
        let temp_dir = TempDir::new().unwrap();
        let log_path = temp_dir.path().join("test.md");
        
        let entry = LogEntry::new_text("test", "TestApp");
        append_entry(&log_path, &entry).unwrap();
        
        let content = std::fs::read_to_string(&log_path).unwrap();
        assert!(content.contains("test"));
        
        cleanup_test_log(&log_path);
    }
}
```

Run: `cat > src/logger.rs << 'EOFTEST'
#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn cleanup_test_log(path: &PathBuf) {
        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn test_log_entry_format() {
        let entry = LogEntry::new_text("test content", "Arc");
        let formatted = entry.to_markdown();
        assert!(formatted.contains("test content"));
        assert!(formatted.contains("Arc"));
    }

    #[test]
    fn test_get_log_file_path() {
        let date_format = "%Y-%m-%d";
        let path = get_log_file_path("~/test-logs", date_format);
        assert!(path.to_string_lossy().contains("test-logs"));
    }

    #[test]
    fn test_append_to_log() {
        let temp_dir = TempDir::new().unwrap();
        let log_path = temp_dir.path().join("test.md");
        
        let entry = LogEntry::new_text("test", "TestApp");
        append_entry(&log_path, &entry).unwrap();
        
        let content = std::fs::read_to_string(&log_path).unwrap();
        assert!(content.contains("test"));
        
        cleanup_test_log(&log_path);
    }
}
EOFTEST`

- [ ] **Step 2: Add tempfile dev dependency**

Run: `cargo add --dev tempfile`

- [ ] **Step 3: Run test to verify failure**

Run: `cargo test logger`
Expected: FAIL - types not defined

- [ ] **Step 4: Implement logger**

```rust
use chrono::Utc;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::path::PathBuf as StdPathBuf;

#[derive(Debug, Clone)]
pub struct LogEntry {
    pub timestamp: String,
    pub source: String,
    pub entry_type: String,
    pub content: String,
    pub length: usize,
    pub tags: Vec<String>,
    pub app: String,
    pub url: Option<String>,
    pub ocr_text: Option<String>,
    pub confidence: Option<f32>,
}

impl LogEntry {
    pub fn new_text(content: &str, app: &str) -> Self {
        Self {
            timestamp: Utc::now().to_rfc3339(),
            source: "clipboard".to_string(),
            entry_type: "text".to_string(),
            content: content.to_string(),
            length: content.len(),
            tags: vec![],
            app: app.to_string(),
            url: None,
            ocr_text: None,
            confidence: None,
        }
    }

    pub fn new_image(ocr_text: String, confidence: f32, app: &str) -> Self {
        Self {
            timestamp: Utc::now().to_rfc3339(),
            source: "clipboard".to_string(),
            entry_type: "image".to_string(),
            content: String::new(),
            length: 0,
            tags: vec!["screenshot".to_string()],
            app: app.to_string(),
            url: None,
            ocr_text: Some(ocr_text),
            confidence: Some(confidence),
        }
    }

    pub fn to_markdown(&self) -> String {
        let mut output = String::new();
        
        // Frontmatter
        output.push_str("---\n");
        output.push_str(&format!("timestamp: {}\n", self.timestamp));
        output.push_str(&format!("source: {}\n", self.source));
        output.push_str(&format!("type: {}\n", self.entry_type));
        
        if self.entry_type == "text" {
            output.push_str(&format!("length: {}\n", self.length));
        }
        
        if !self.tags.is_empty() {
            output.push_str(&format!("tags: {:?}\n", self.tags));
        }
        
        output.push_str(&format!("app: \"{}\"\n", self.app));
        
        if let Some(ref url) = self.url {
            output.push_str(&format!("url: \"{}\"\n", url));
        }
        
        if let Some(ref ocr_text) = self.ocr_text {
            output.push_str(&format!("ocr_text: \"{}\"\n", ocr_text));
        }
        
        if let Some(confidence) = self.confidence {
            output.push_str(&format!("confidence: {}\n", confidence));
        }
        
        output.push_str("---\n");
        
        // Content
        if self.entry_type == "text" {
            output.push_str(&self.content);
        } else if self.ocr_text.is_some() {
            output.push_str("[Image captured - OCR text above]\n");
        }
        
        output.push_str("\n");
        
        output
    }
}

pub fn get_log_file_path(log_dir: &str, date_format: &str) -> StdPathBuf {
    let expanded = shellexpand::tilde(log_dir);
    let date = chrono::Utc::now().format(date_format);
    StdPathBuf::from(expanded).join(format!("{}.md", date))
}

pub fn append_entry(path: &Path, entry: &LogEntry) -> std::io::Result<()> {
    // Ensure directory exists
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let markdown = entry.to_markdown();
    
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)?;
    
    file.write_all(markdown.as_bytes())?;
    file.write_all(b"\n")?;
    
    Ok(())
}

pub fn ensure_log_dir(log_dir: &str) -> std::io::Result<()> {
    let expanded = shellexpand::tilde(log_dir);
    std::fs::create_dir_all(expanded)
}
```

Run: `cat > src/logger.rs << 'EOF'
use chrono::Utc;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::path::PathBuf as StdPathBuf;

#[derive(Debug, Clone)]
pub struct LogEntry {
    pub timestamp: String,
    pub source: String,
    pub entry_type: String,
    pub content: String,
    pub length: usize,
    pub tags: Vec<String>,
    pub app: String,
    pub url: Option<String>,
    pub ocr_text: Option<String>,
    pub confidence: Option<f32>,
}

impl LogEntry {
    pub fn new_text(content: &str, app: &str) -> Self {
        Self {
            timestamp: Utc::now().to_rfc3339(),
            source: "clipboard".to_string(),
            entry_type: "text".to_string(),
            content: content.to_string(),
            length: content.len(),
            tags: vec![],
            app: app.to_string(),
            url: None,
            ocr_text: None,
            confidence: None,
        }
    }

    pub fn new_image(ocr_text: String, confidence: f32, app: &str) -> Self {
        Self {
            timestamp: Utc::now().to_rfc3339(),
            source: "clipboard".to_string(),
            entry_type: "image".to_string(),
            content: String::new(),
            length: 0,
            tags: vec!["screenshot".to_string()],
            app: app.to_string(),
            url: None,
            ocr_text: Some(ocr_text),
            confidence: Some(confidence),
        }
    }

    pub fn to_markdown(&self) -> String {
        let mut output = String::new();
        
        // Frontmatter
        output.push_str("---\n");
        output.push_str(&format!("timestamp: {}\n", self.timestamp));
        output.push_str(&format!("source: {}\n", self.source));
        output.push_str(&format!("type: {}\n", self.entry_type));
        
        if self.entry_type == "text" {
            output.push_str(&format!("length: {}\n", self.length));
        }
        
        if !self.tags.is_empty() {
            output.push_str(&format!("tags: {:?}\n", self.tags));
        }
        
        output.push_str(&format!("app: \"{}\"\n", self.app));
        
        if let Some(ref url) = self.url {
            output.push_str(&format!("url: \"{}\"\n", url));
        }
        
        if let Some(ref ocr_text) = self.ocr_text {
            output.push_str(&format!("ocr_text: \"{}\"\n", ocr_text));
        }
        
        if let Some(confidence) = self.confidence {
            output.push_str(&format!("confidence: {}\n", confidence));
        }
        
        output.push_str("---\n");
        
        // Content
        if self.entry_type == "text" {
            output.push_str(&self.content);
        } else if self.ocr_text.is_some() {
            output.push_str("[Image captured - OCR text above]\n");
        }
        
        output.push_str("\n");
        
        output
    }
}

pub fn get_log_file_path(log_dir: &str, date_format: &str) -> StdPathBuf {
    let expanded = shellexpand::tilde(log_dir);
    let date = chrono::Utc::now().format(date_format);
    StdPathBuf::from(expanded).join(format!("{}.md", date))
}

pub fn append_entry(path: &Path, entry: &LogEntry) -> std::io::Result<()> {
    // Ensure directory exists
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let markdown = entry.to_markdown();
    
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)?;
    
    file.write_all(markdown.as_bytes())?;
    file.write_all(b"\n")?;
    
    Ok(())
}

pub fn ensure_log_dir(log_dir: &str) -> std::io::Result<()> {
    let expanded = shellexpand::tilde(log_dir);
    std::fs::create_dir_all(expanded)
}
EOF`

- [ ] **Step 5: Add shellexpand dependency**

Run: `cargo add shellexpand`

- [ ] **Step 6: Run tests to verify pass**

Run: `cargo test logger`
Expected: PASS

- [ ] **Step 7: Commit**

```bash
git add src/logger.rs Cargo.toml
git commit -m "feat: add log file writer with markdown format"
```

---

## Phase 6: OCR + Storage + MCP Server (v2.0 — replaces original Providers phase)

### Task 7: Implement OCR + Storage + MCP Server

> **v2.0 Note:** The original "Implement Ollama Client" / "ModelProvider trait" content below is **deprecated — skip it**. Replace Task 7 with three sub-tasks following the TDD pattern (RED → GREEN → commit) used in earlier tasks.
>
> **Task 7a — `ocr` module.** Apple Vision `VNRecognizeTextRequest` via `objc2-vision`. See Addendum B. Tests: `tests/ocr_test.rs` with fixture PNGs (error dialog, stack trace screenshot, dense log).
>
> **Task 7b — `storage` module.** rusqlite + FTS5 ring buffer + daily Markdown writer. Schema in spec § "Data Format". Tests: `tests/storage_test.rs` (insert, dedup by SHA-256, ring-buffer eviction at `ring_buffer_size + 1`, FTS5 search).
>
> **Task 7c — `mcp` module.** rmcp stdio server with six tools. See Addendum D. Tests: `tests/mcp_test.rs` pipes hand-crafted JSON-RPC into `tl mcp` and asserts shape, dedup, and the input-size caps from `[mcp]` config.
>
> The deprecated text from here down to the end of Phase 6 is kept only for the TDD *pattern* it demonstrates; its content is obsolete.

**Files:**
- Modify: `src/ollama.rs`

- [ ] **Step 1: Write ollama client tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_chat_request() {
        let messages = vec![
            ("system", "You are helpful"),
            ("user", "Hello"),
        ];
        let request = build_chat_request("gemma", messages);
        assert!(request.contains("Hello"));
    }

    #[test]
    fn test_extract_response_from_ollama() {
        let json = r#"{"message": {"content": "Hello there"}}"#;
        let response = extract_response(json);
        assert_eq!(response.unwrap(), "Hello there");
    }
}
```

Run: `cat > src/ollama.rs << 'EOFTEST'
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_chat_request() {
        let messages = vec![
            ("system", "You are helpful"),
            ("user", "Hello"),
        ];
        let request = build_chat_request("gemma", messages);
        assert!(request.contains("Hello"));
    }

    #[test]
    fn test_extract_response_from_ollama() {
        let json = r#"{"message": {"content": "Hello there"}}"#;
        let response = extract_response(json);
        assert_eq!(response.unwrap(), "Hello there");
    }
}
EOFTEST`

- [ ] **Step 2: Run test to verify failure**

Run: `cargo test ollama`
Expected: FAIL - functions not defined

- [ ] **Step 3: Implement Ollama client**

```rust
use genai::chat::{ChatMessage, ChatRequest, ChatRequestOptions};
use genai::Client;
use serde_json::{json, Value};

pub struct OllamaClient {
    client: Client,
    endpoint: String,
    model: String,
    timeout: std::time::Duration,
}

impl OllamaClient {
    pub fn new(endpoint: &str, model: &str, timeout_secs: u64) -> Self {
        let timeout = std::time::Duration::from_secs(timeout_secs);
        
        let client = Client::builder()
            .with_web_config(genai::webc::WebConfig::new(
                genai::webc::WebClientOptions::new()
                    .with_timeout(timeout)
            ))
            .build();
        
        Self {
            client,
            endpoint: endpoint.to_string(),
            model: model.to_string(),
            timeout,
        }
    }

    pub async fn health_check(&self) -> Result<bool, crate::error::Error> {
        // Try to list models to verify connection
        let url = format!("{}/api/tags", self.endpoint);
        
        let response = reqwest::get(&url).await
            .map_err(|_| crate::error::Error::OllamaConnection(self.endpoint.clone()))?;
        
        Ok(response.status().is_success())
    }

    pub async fn chat(&self, prompt: &str) -> Result<String, crate::error::Error> {
        let chat_req = ChatRequest::new(vec![
            ChatMessage::system(
                "You are analyzing clipboard history. Be concise and helpful."
            ),
            ChatMessage::user(prompt),
        ]);

        let response = self.client
            .exec_chat(&self.model, chat_req, None)
            .await
            .map_err(|e| crate::error::Error::OllamaRequest(e.to_string()))?;

        Ok(response.first_text()
            .unwrap_or("No response")
            .to_string())
    }

    pub async fn chat_stream(&self, prompt: &str) -> Result<String, crate::error::Error> {
        let chat_req = ChatRequest::new(vec![
            ChatMessage::system("You are helpful and concise."),
            ChatMessage::user(prompt),
        ]);

        let stream = self.client
            .exec_chat_stream(&self.model, chat_req, None)
            .await
            .map_err(|e| crate::error::Error::OllamaRequest(e.to_string()))?;

        let mut result = String::new();
        
        // Use genai's stream consumption
        // Note: genai 0.5 has improved streaming support
        // For now, collect the full response
        result.push_str(&self.chat(prompt).await?);
        
        Ok(result)
    }

    pub async fn ocr_image(&self, image_base64: &str, model: &str) -> Result<String, crate::error::Error> {
        let prompt = "Extract all text from this image. Return only the extracted text, no explanations.";
        
        let chat_req = ChatRequest::new(vec![
            ChatMessage::user(prompt.to_string()),
        ]);

        // For multimodal models, we'd typically send the image
        // This is a simplified version - genai 0.5 may need specific handling
        let response = self.client
            .exec_chat(model, chat_req, None)
            .await
            .map_err(|e| crate::error::Error::OllamaRequest(e.to_string()))?;

        Ok(response.first_text()
            .unwrap_or("No OCR result")
            .to_string())
    }

    pub async fn model_available(&self, model: &str) -> Result<bool, crate::error::Error> {
        let url = format!("{}/api/tags", self.endpoint);
        
        let response = reqwest::get(&url).await
            .map_err(|_| crate::error::Error::OllamaConnection(self.endpoint.clone()))?;
        
        if !response.status().is_success() {
            return Ok(false);
        }
        
        let models: Value = response
            .json()
            .await
            .map_err(|_| crate::error::Error::OllamaRequest("Failed to parse models".to_string()))?;
        
        if let Some(models_array) = models.get("models").and_then(|v| v.as_array()) {
            for m in models_array {
                if let Some(name) = m.get("name").and_then(|n| n.as_str()) {
                    if name.contains(model) || model.contains(name) {
                        return Ok(true);
                    }
                }
            }
        }
        
        Ok(false)
    }
}

// Helper functions for tests
#[cfg(test)]
fn build_chat_request(model: &str, messages: Vec<(&str, &str)>) -> String {
    let mut req = json!({
        "model": model,
        "messages": [],
        "stream": false
    });
    
    if let Some(msgs) = req.get_mut("messages").and_then(|m| m.as_array_mut()) {
        for (role, content) in messages {
            msgs.push(json!({
                "role": role,
                "content": content
            }));
        }
    }
    
    req.to_string()
}

#[cfg(test)]
fn extract_response(json: &str) -> Result<String, Box<dyn std::error::Error>> {
    let value: Value = serde_json::from_str(json)?;
    Ok(value["message"]["content"]
        .as_str()
        .unwrap_or("")
        .to_string())
}
```

Run: `cat > src/ollama.rs << 'EOF'
use genai::chat::{ChatMessage, ChatRequest};
use genai::Client;
use serde_json::{json, Value};

pub struct OllamaClient {
    client: Client,
    endpoint: String,
    model: String,
    timeout: std::time::Duration,
}

impl OllamaClient {
    pub fn new(endpoint: &str, model: &str, timeout_secs: u64) -> Self {
        let timeout = std::time::Duration::from_secs(timeout_secs);
        
        let client = Client::builder()
            .with_web_config(genai::webc::WebConfig::new(
                genai::webc::WebClientOptions::new()
                    .with_timeout(timeout)
            ))
            .build();
        
        Self {
            client,
            endpoint: endpoint.to_string(),
            model: model.to_string(),
            timeout,
        }
    }

    pub async fn health_check(&self) -> Result<bool, crate::error::Error> {
        // Try to list models to verify connection
        let url = format!("{}/api/tags", self.endpoint);
        
        let response = reqwest::get(&url).await
            .map_err(|_| crate::error::Error::OllamaConnection(self.endpoint.clone()))?;
        
        Ok(response.status().is_success())
    }

    pub async fn chat(&self, prompt: &str) -> Result<String, crate::error::Error> {
        let chat_req = ChatRequest::new(vec![
            ChatMessage::system(
                "You are analyzing clipboard history. Be concise and helpful."
            ),
            ChatMessage::user(prompt),
        ]);

        let response = self.client
            .exec_chat(&self.model, chat_req, None)
            .await
            .map_err(|e| crate::error::Error::OllamaRequest(e.to_string()))?;

        Ok(response.first_text()
            .unwrap_or("No response")
            .to_string())
    }

    pub async fn chat_stream(&self, prompt: &str) -> Result<String, crate::error::Error> {
        let chat_req = ChatRequest::new(vec![
            ChatMessage::system("You are helpful and concise."),
            ChatMessage::user(prompt),
        ]);

        let stream = self.client
            .exec_chat_stream(&self.model, chat_req, None)
            .await
            .map_err(|e| crate::error::Error::OllamaRequest(e.to_string()))?;

        let mut result = String::new();
        
        // Use genai's stream consumption
        // Note: genai 0.5 has improved streaming support
        // For now, collect the full response
        result.push_str(&self.chat(prompt).await?);
        
        Ok(result)
    }

    pub async fn ocr_image(&self, image_base64: &str, model: &str) -> Result<String, crate::error::Error> {
        let prompt = "Extract all text from this image. Return only the extracted text, no explanations.";
        
        let chat_req = ChatRequest::new(vec![
            ChatMessage::user(prompt.to_string()),
        ]);

        // For multimodal models, we'd typically send the image
        // This is a simplified version - genai 0.5 may need specific handling
        let response = self.client
            .exec_chat(model, chat_req, None)
            .await
            .map_err(|e| crate::error::Error::OllamaRequest(e.to_string()))?;

        Ok(response.first_text()
            .unwrap_or("No OCR result")
            .to_string())
    }

    pub async fn model_available(&self, model: &str) -> Result<bool, crate::error::Error> {
        let url = format!("{}/api/tags", self.endpoint);
        
        let response = reqwest::get(&url).await
            .map_err(|_| crate::error::Error::OllamaConnection(self.endpoint.clone()))?;
        
        if !response.status().is_success() {
            return Ok(false);
        }
        
        let models: Value = response
            .json()
            .await
            .map_err(|_| crate::error::Error::OllamaRequest("Failed to parse models".to_string()))?;
        
        if let Some(models_array) = models.get("models").and_then(|v| v.as_array()) {
            for m in models_array {
                if let Some(name) = m.get("name").and_then(|n| n.as_str()) {
                    if name.contains(model) || model.contains(name) {
                        return Ok(true);
                    }
                }
            }
        }
        
        Ok(false)
    }
}

// Helper functions for tests
#[cfg(test)]
fn build_chat_request(model: &str, messages: Vec<(&str, &str)>) -> String {
    let mut req = json!({
        "model": model,
        "messages": [],
        "stream": false
    });
    
    if let Some(msgs) = req.get_mut("messages").and_then(|m| m.as_array_mut()) {
        for (role, content) in messages {
            msgs.push(json!({
                "role": role,
                "content": content
            }));
        }
    }
    
    req.to_string()
}

#[cfg(test)]
fn extract_response(json: &str) -> Result<String, Box<dyn std::error::Error>> {
    let value: Value = serde_json::from_str(json)?;
    Ok(value["message"]["content"]
        .as_str()
        .unwrap_or("")
        .to_string())
}
EOF

- [ ] **Step 4: Run tests to verify pass**

Run: `cargo test ollama`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/ollama.rs
git commit -m "feat: add Ollama client wrapper"
```

---

## Phase 7: Clipboard Monitoring (macOS)

### Task 8: Implement NSPasteboard Monitoring

**Files:**
- Modify: `src/clipboard.rs`

- [ ] **Step 1: Write clipboard tests (macOS only)**

```rust
#[cfg(test)]
mod tests {
    #[test]
    #[cfg(target_os = "macos")]
    fn test_clipboard_state_initialization() {
        use super::*;
        let state = ClipboardState::new();
        assert_eq!(state.last_change_count, 0);
    }
}
```

Run: `cat > src/clipboard.rs << 'EOFTEST'
#[cfg(test)]
mod tests {
    #[test]
    #[cfg(target_os = "macos")]
    fn test_clipboard_state_initialization() {
        use super::*;
        let state = ClipboardState::new();
        assert_eq!(state.last_change_count, 0);
    }
}
EOFTEST`

- [ ] **Step 2: Run test to verify failure**

Run: `cargo test clipboard`
Expected: FAIL - type not defined

- [ ] **Step 3: Implement NSPasteboard monitoring**

```rust
#[cfg(target_os = "macos")]
use objc2::rc::Retained;
use objc2::MainThreadOnly;
use objc2_app_kit::{NSPasteboard, NSPasteboardType};
use objc2_foundation::ns_string;
use std::sync::atomic::{AtomicU64, Ordering};

#[cfg(target_os = "macos")]
pub struct ClipboardState {
    pub last_change_count: AtomicU64,
    pasteboard: Retained<NSPasteboard>,
}

#[cfg(target_os = "macos")]
impl ClipboardState {
    pub fn new() -> Self {
        let mtm = unsafe { 
            objc2_foundation::MainThreadMarker::new()
                .expect("Must be on main thread")
        };
        
        let pasteboard = NSPasteboard::generalPasteboard(mtm)
            .expect("Failed to get pasteboard");
        
        Self {
            last_change_count: AtomicU64::new(0),
            pasteboard,
        }
    }

    pub fn get_string_contents(&self) -> Option<String> {
        let mtm = unsafe {
            objc2_foundation::MainThreadMarker::new()
                .ok()?
        };

        let contents = self.pasteboard.stringForType(
            unsafe { NSPasteboardType::string(mtm) },
            mtm
        )?;

        Some(contents.to_string())
    }

    pub fn get_change_count(&self) -> i64 {
        let mtm = unsafe {
            objc2_foundation::MainThreadMarker::new()
                .expect("Must be on main thread")
        };

        self.pasteboard.changeCount(mtm)
    }

    pub fn has_image(&self) -> bool {
        let mtm = unsafe {
            objc2_foundation::MainThreadMarker::new()
                .expect("Must be on main thread")
        };

        self.pasteboard.availableTypeFromArray(
            unsafe { NSPasteboardType::png(mtm) },
            mtm
        ).is_some()
    }
}

#[cfg(not(target_os = "macos"))]
pub struct ClipboardState;

#[cfg(not(target_os = "macos"))]
impl ClipboardState {
    pub fn new() -> Self {
        panic!("textlog only supports macOS");
    }
}
```

Run: `cat > src/clipboard.rs << 'EOF'
#[cfg(target_os = "macos")]
use objc2::rc::Retained;
use objc2::MainThreadOnly;
use objc2_app_kit::{NSPasteboard, NSPasteboardType};
use objc2_foundation::ns_string;
use std::sync::atomic::{AtomicU64, Ordering};

#[cfg(target_os = "macos")]
pub struct ClipboardState {
    pub last_change_count: AtomicU64,
    pasteboard: Retained<NSPasteboard>,
}

#[cfg(target_os = "macos")]
impl ClipboardState {
    pub fn new() -> Self {
        let mtm = unsafe { 
            objc2_foundation::MainThreadMarker::new()
                .expect("Must be on main thread")
        };
        
        let pasteboard = NSPasteboard::generalPasteboard(mtm)
            .expect("Failed to get pasteboard");
        
        Self {
            last_change_count: AtomicU64::new(0),
            pasteboard,
        }
    }

    pub fn get_string_contents(&self) -> Option<String> {
        let mtm = unsafe {
            objc2_foundation::MainThreadMarker::new()
                .ok()?
        };

        let contents = self.pasteboard.stringForType(
            unsafe { NSPasteboardType::string(mtm) },
            mtm
        )?;

        Some(contents.to_string())
    }

    pub fn get_change_count(&self) -> i64 {
        let mtm = unsafe {
            objc2_foundation::MainThreadMarker::new()
                .expect("Must be on main thread")
        };

        self.pasteboard.changeCount(mtm)
    }

    pub fn has_image(&self) -> bool {
        let mtm = unsafe {
            objc2_foundation::MainThreadMarker::new()
                .expect("Must be on main thread")
        };

        self.pasteboard.availableTypeFromArray(
            unsafe { NSPasteboardType::png(mtm) },
            mtm
        ).is_some()
    }
}

#[cfg(not(target_os = "macos"))]
pub struct ClipboardState;

#[cfg(not(target_os = "macos"))]
impl ClipboardState {
    pub fn new() -> Self {
        panic!("textlog only supports macOS");
    }
}
EOF`

- [ ] **Step 4: Run tests to verify pass**

Run: `cargo test clipboard`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/clipboard.rs
git commit -m "feat: add NSPasteboard monitoring for macOS"
```

---

## Phase 8: CLI Commands

### Task 9: Implement CLI Structure

**Files:**
- Modify: `src/cli/args.rs`
- Modify: `src/cli/commands.rs`
- Modify: `src/main.rs`

- [ ] **Step 1: Write CLI arg parsing tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_args_default() {
        let args = Args::parse_from(["tl"]);
        assert_eq!(args.command, Command::Status);
    }
}
```

Run: `cat > src/cli/args.rs << 'EOFTEST'
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_args_default() {
        let args = Args::parse_from(["tl"]);
        assert_eq!(args.command, Command::Status);
    }
}
EOFTEST`

- [ ] **Step 2: Run test to verify failure**

Run: `cargo test args`
Expected: FAIL - types not defined

- [ ] **Step 3: Implement CLI args**

```rust
use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(name = "tl")]
#[command(about = "A macOS clipboard monitoring and logging tool with local LLM integration", long_about = None)]
#[command(version)]
pub struct Args {
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand, Debug)]
pub enum Command {
    /// Start clipboard monitoring
    Start,
    
    /// Stop clipboard monitoring  
    Stop,
    
    /// Show monitoring status
    Status,
    
    /// Get configuration value
    #[command(name = "config")]
    Config {
        #[command(subcommand)]
        action: ConfigAction,
    },
    
    /// Query the AI with clipboard context
    Ask {
        /// The question or prompt
        prompt: String,
    },
    
    /// Search logs with AI
    Search {
        /// Search query
        query: String,
        /// Search in specific date
        #[arg(short, long)]
        date: Option<String>,
    },
    
    /// Auto-categorize recent entries
    Categorize,
    
    /// Extract text from image file
    Extract {
        /// Path to image file
        #[arg(short, long)]
        path: PathBuf,
    },
    
    /// Show log entries
    Logs {
        /// Specific date (YYYY-MM-DD)
        #[arg(short, long)]
        date: Option<String>,
        /// Show last N entries
        #[arg(short = 'n', long)]
        tail: Option<usize>,
        /// Follow log stream
        #[arg(short, long)]
        follow: bool,
    },
    
    /// Export logs
    Export {
        /// Export format
        #[arg(short, long)]
        format: ExportFormat,
        /// Output directory
        #[arg(short, long)]
        output: Option<PathBuf>,
    },
    
    /// Health check
    Doctor,
    
    /// Show version info
    Version {
        /// Show detailed version info
        #[arg(long)]
        verbose: bool,
    },
}

#[derive(Subcommand, Debug)]
pub enum ConfigAction {
    /// Get a config value
    Get {
        /// Config key (e.g., ollama.model)
        key: Option<String>,
    },
    /// Set a config value
    Set {
        /// Config key
        key: String,
        /// New value
        value: String,
    },
    /// Open config in editor
    Edit,
    /// Reset to defaults
    Reset,
}

#[derive(clap::ValueEnum, Clone, Debug)]
pub enum ExportFormat {
    Json,
    Markdown,
}
```

Run: `cat > src/cli/args.rs << 'EOF'
use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(name = "tl")]
#[command(about = "A macOS clipboard monitoring and logging tool with local LLM integration", long_about = None)]
#[command(version)]
pub struct Args {
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand, Debug)]
pub enum Command {
    /// Start clipboard monitoring
    Start,
    
    /// Stop clipboard monitoring  
    Stop,
    
    /// Show monitoring status
    Status,
    
    /// Get configuration value
    #[command(name = "config")]
    Config {
        #[command(subcommand)]
        action: ConfigAction,
    },
    
    /// Query the AI with clipboard context
    Ask {
        /// The question or prompt
        prompt: String,
    },
    
    /// Search logs with AI
    Search {
        /// Search query
        query: String,
        /// Search in specific date
        #[arg(short, long)]
        date: Option<String>,
    },
    
    /// Auto-categorize recent entries
    Categorize,
    
    /// Extract text from image file
    Extract {
        /// Path to image file
        #[arg(short, long)]
        path: PathBuf,
    },
    
    /// Show log entries
    Logs {
        /// Specific date (YYYY-MM-DD)
        #[arg(short, long)]
        date: Option<String>,
        /// Show last N entries
        #[arg(short = 'n', long)]
        tail: Option<usize>,
        /// Follow log stream
        #[arg(short, long)]
        follow: bool,
    },
    
    /// Export logs
    Export {
        /// Export format
        #[arg(short, long)]
        format: ExportFormat,
        /// Output directory
        #[arg(short, long)]
        output: Option<PathBuf>,
    },
    
    /// Health check
    Doctor,
    
    /// Show version info
    Version {
        /// Show detailed version info
        #[arg(long)]
        verbose: bool,
    },
}

#[derive(Subcommand, Debug)]
pub enum ConfigAction {
    /// Get a config value
    Get {
        /// Config key (e.g., ollama.model)
        key: Option<String>,
    },
    /// Set a config value
    Set {
        /// Config key
        key: String,
        /// New value
        value: String,
    },
    /// Open config in editor
    Edit,
    /// Reset to defaults
    Reset,
}

#[derive(clap::ValueEnum, Clone, Debug)]
pub enum ExportFormat {
    Json,
    Markdown,
}
EOF`

- [ ] **Step 4: Create command handlers stub**

```rust
use crate::cli::args::{Command, ConfigAction};
use crate::config::Config;
use crate::error::Result;

pub async fn handle_command(command: Command) -> Result<()> {
    match command {
        Command::Start => cmd_start().await,
        Command::Stop => cmd_stop().await,
        Command::Status => cmd_status().await,
        Command::Config { action } => handle_config(action).await,
        Command::Ask { prompt } => cmd_ask(prompt).await,
        Command::Search { query, date } => cmd_search(query, date).await,
        Command::Categorize => cmd_categorize().await,
        Command::Extract { path } => cmd_extract(path).await,
        Command::Logs { date, tail, follow } => cmd_logs(date, tail, follow).await,
        Command::Export { format, output } => cmd_export(format, output).await,
        Command::Doctor => cmd_doctor().await,
        Command::Version { verbose } => cmd_version(verbose),
    }
}

async fn handle_config(action: ConfigAction) -> Result<()> {
    match action {
        ConfigAction::Get { key } => cmd_config_get(key).await,
        ConfigAction::Set { key, value } => cmd_config_set(key, value).await,
        ConfigAction::Edit => cmd_config_edit().await,
        ConfigAction::Reset => cmd_config_reset().await,
    }
}

// Command implementations
async fn cmd_start() -> Result<()> {
    println!("Starting clipboard monitoring...");
    Ok(())
}

async fn cmd_stop() -> Result<()> {
    println!("Stopping clipboard monitoring...");
    Ok(())
}

async fn cmd_status() -> Result<()> {
    println!("Status: Not implemented");
    Ok(())
}

async fn cmd_config_get(key: Option<String>) -> Result<()> {
    let config = Config::default();
    match key.as_deref() {
        Some("ollama.model") => println!("{}", config.ollama.model),
        Some("ollama.endpoint") => println!("{}", config.ollama.endpoint),
        Some(k) => println!("Unknown key: {}", k),
        None => println!("Specify a config key"),
    }
    Ok(())
}

async fn cmd_config_set(key: String, value: String) -> Result<()> {
    println!("Setting {} = {}", key, value);
    Ok(())
}

async fn cmd_config_edit() -> Result<()> {
    println!("Opening config in editor...");
    Ok(())
}

async fn cmd_config_reset() -> Result<()> {
    println!("Resetting config to defaults...");
    Ok(())
}

async fn cmd_ask(prompt: String) -> Result<()> {
    println!("Asking: {}", prompt);
    Ok(())
}

async fn cmd_search(query: String, date: Option<String>) -> Result<()> {
    println!("Searching for: {} (date: {:?})", query, date);
    Ok(())
}

async fn cmd_categorize() -> Result<()> {
    println!("Categorizing entries...");
    Ok(())
}

async fn cmd_extract(path: std::path::PathBuf) -> Result<()> {
    println!("Extracting text from: {:?}", path);
    Ok(())
}

async fn cmd_logs(date: Option<String>, tail: Option<usize>, follow: bool) -> Result<()> {
    println!("Logs: date={:?}, tail={:?}, follow={}", date, tail, follow);
    Ok(())
}

async fn cmd_export(format: crate::cli::args::ExportFormat, output: Option<std::path::PathBuf>) -> Result<()> {
    println!("Exporting as {:?} to {:?}", format, output);
    Ok(())
}

async fn cmd_doctor() -> Result<()> {
    println!("Running health check...");
    Ok(())
}

fn cmd_version(verbose: bool) -> Result<()> {
    println!("textlog version 0.1.0");
    if verbose {
        println!("Rust version: {}", env!("RUSTC"));
        println!("Edition: 2021");
    }
    Ok(())
}
```

Run: `cat > src/cli/commands.rs << 'EOF'
use crate::cli::args::{Command, ConfigAction};
use crate::config::Config;
use crate::error::Result;

pub async fn handle_command(command: Command) -> Result<()> {
    match command {
        Command::Start => cmd_start().await,
        Command::Stop => cmd_stop().await,
        Command::Status => cmd_status().await,
        Command::Config { action } => handle_config(action).await,
        Command::Ask { prompt } => cmd_ask(prompt).await,
        Command::Search { query, date } => cmd_search(query, date).await,
        Command::Categorize => cmd_categorize().await,
        Command::Extract { path } => cmd_extract(path).await,
        Command::Logs { date, tail, follow } => cmd_logs(date, tail, follow).await,
        Command::Export { format, output } => cmd_export(format, output).await,
        Command::Doctor => cmd_doctor().await,
        Command::Version { verbose } => cmd_version(verbose),
    }
}

async fn handle_config(action: ConfigAction) -> Result<()> {
    match action {
        ConfigAction::Get { key } => cmd_config_get(key).await,
        ConfigAction::Set { key, value } => cmd_config_set(key, value).await,
        ConfigAction::Edit => cmd_config_edit().await,
        ConfigAction::Reset => cmd_config_reset().await,
    }
}

// Command implementations
async fn cmd_start() -> Result<()> {
    println!("Starting clipboard monitoring...");
    Ok(())
}

async fn cmd_stop() -> Result<()> {
    println!("Stopping clipboard monitoring...");
    Ok(())
}

async fn cmd_status() -> Result<()> {
    println!("Status: Not implemented");
    Ok(())
}

async fn cmd_config_get(key: Option<String>) -> Result<()> {
    let config = Config::default();
    match key.as_deref() {
        Some("ollama.model") => println!("{}", config.ollama.model),
        Some("ollama.endpoint") => println!("{}", config.ollama.endpoint),
        Some(k) => println!("Unknown key: {}", k),
        None => println!("Specify a config key"),
    }
    Ok(())
}

async fn cmd_config_set(key: String, value: String) -> Result<()> {
    println!("Setting {} = {}", key, value);
    Ok(())
}

async fn cmd_config_edit() -> Result<()> {
    println!("Opening config in editor...");
    Ok(())
}

async fn cmd_config_reset() -> Result<()> {
    println!("Resetting config to defaults...");
    Ok(())
}

async fn cmd_ask(prompt: String) -> Result<()> {
    println!("Asking: {}", prompt);
    Ok(())
}

async fn cmd_search(query: String, date: Option<String>) -> Result<()> {
    println!("Searching for: {} (date: {:?})", query, date);
    Ok(())
}

async fn cmd_categorize() -> Result<()> {
    println!("Categorizing entries...");
    Ok(())
}

async fn cmd_extract(path: std::path::PathBuf) -> Result<()> {
    println!("Extracting text from: {:?}", path);
    Ok(())
}

async fn cmd_logs(date: Option<String>, tail: Option<usize>, follow: bool) -> Result<()> {
    println!("Logs: date={:?}, tail={:?}, follow={}", date, tail, follow);
    Ok(())
}

async fn cmd_export(format: crate::cli::args::ExportFormat, output: Option<std::path::PathBuf>) -> Result<()> {
    println!("Exporting as {:?} to {:?}", format, output);
    Ok(())
}

async fn cmd_doctor() -> Result<()> {
    println!("Running health check...");
    Ok(())
}

fn cmd_version(verbose: bool) -> Result<()> {
    println!("textlog version 0.1.0");
    if verbose {
        println!("Rust version: {}", env!("RUSTC"));
        println!("Edition: 2021");
    }
    Ok(())
}
EOF

- [ ] **Step 5: Update main.rs**

```rust
use clap::Parser;
use textlog::cli::args::Args;
use textlog::error::Result;

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive("textlog=info")
        )
        .init();

    let args = Args::parse();
    
    textlog::cli::commands::handle_command(args.command).await
}
```

Run: `cat > src/main.rs << 'EOF'
use clap::Parser;
use textlog::cli::args::Args;
use textlog::error::Result;

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive("textlog=info")
        )
        .init();

    let args = Args::parse();
    
    textlog::cli::commands::handle_command(args.command).await
}
EOF`

- [ ] **Step 6: Update cli.rs**

```rust
pub mod args;
pub mod commands;

pub use args::Args;
pub use commands::handle_command;
```

Run: `cat > src/cli.rs << 'EOF'
pub mod args;
pub mod commands;

pub use args::Args;
pub use commands::handle_command;
EOF`

- [ ] **Step 7: Run tests and build**

Run: `cargo test && cargo build`
Expected: All tests pass, binary compiles

- [ ] **Step 8: Test CLI**

Run: `cargo run -- --help`
Expected: Help text displayed

Run: `cargo run -- version`
Expected: "textlog version 0.1.0"

- [ ] **Step 9: Commit**

```bash
git add src/cli.rs src/cli/ src/main.rs
git commit -m "feat: add CLI command structure with all commands"
```

---

## Phase 9: Integration & Testing

### Task 10: Add Example Config File

**Files:**
- Create: `textlog-example-config.toml`

- [ ] **Step 1: Copy the full default config from the spec**

The authoritative default configuration lives in
`docs/superpowers/specs/2026-04-14-textlog-design.md` under
**"Default Configuration (complete, every key documented)"**.

Copy that TOML block verbatim into `textlog-example-config.toml`. Every key
present in the spec block is required — the example config is the single
source users see for discovering available options.

Verify round-trip after creating the file:

```bash
cargo run -- config reset              # writes ~/textlog/config.toml from Config::default()
diff ~/textlog/config.toml textlog-example-config.toml
# Expected: empty diff (ignoring comment-only differences)
```

- [ ] **Step 2: Commit**

```bash
git add textlog-example-config.toml
git commit -m "docs: add example configuration file"
```

---

### Task 11: Run Full Test Suite

**Files:**
- None

- [ ] **Step 1: Run all tests**

```bash
cargo test --all
```

Expected: All tests pass

- [ ] **Step 2: Run clippy**

```bash
cargo clippy -- -D warnings
```

Expected: No clippy warnings

- [ ] **Step 3: Check formatting**

```bash
cargo fmt --check
```

Expected: All files formatted

---

## Implementation Complete Checklist

After all tasks are complete:

- [ ] All tests passing
- [ ] No clippy warnings
- [ ] Code formatted
- [ ] Example config created
- [ ] Design spec requirements met

---

## Usage Examples

```bash
# Start monitoring
tl start

# Check status
tl status

# Query AI about today's copies
tl ask "summarize what I copied today about Rust"

# Search logs
tl search "anything about async"

# View logs
tl logs --tail 20

# Export to JSON
tl export --format json --output ~/Desktop/

# Check Ollama connection
tl doctor
```

---

## Next Steps (Future Work)

1. **Implement actual clipboard monitoring loop** with polling
2. **Add OCR integration** via Ollama multimodal models  
3. **Implement AI-powered commands** (ask, search, categorize)
4. **Add log reading/display** commands
5. **Implement config file editing**
6. **Add shell completion** generation

---

**Plan Version**: 1.0
**Total Tasks**: 11
**Estimated Time**: 4-6 hours for full implementation
