# csess Implementation Plan

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

**Goal:** Build `csess`, a fast Rust CLI that lists Claude Code sessions for a folder and its subfolder projects, with fuzzy search, filtering, sorting, table + JSON output, and full GitHub release tooling.

**Architecture:** Single static binary. Discovery scans `~/.claude/projects/`, fast-decodes/encodes directory names to pre-filter against the target path, then confirms each session's real `cwd` (read from the file) is under the target — correcting the lossy `-`↔`/` encoding. Files are parsed in parallel with `rayon`.

**Tech Stack:** Rust 2021, `clap` (derive), `serde`/`serde_json`, `chrono`, `rayon`, `comfy-table`, `fuzzy-matcher`, `dirs`, `anyhow`. Dev: `assert_cmd`, `predicates`, `tempfile`.

**User decisions (already made):**
- Language: Rust single binary, consumed mainly by Claude itself; ship a `SKILL.md`.
- Default scope: current dir + subfolder projects; `--global` and explicit `PATH` supported.
- Path matching: fast name-decode pre-filter + in-file `cwd` verification.
- Search: best-fit convenience → fuzzy (`fuzzy-matcher`).
- Output: table default, `--json` flag.
- Publish: personal GitHub `sibincbaby` (HTTPS), MIT license, SemVer + releases + README + CI.

---

## File Structure

- `Cargo.toml` — package metadata + dependencies
- `src/main.rs` — module decls, `main()`, `run()` orchestration
- `src/cli.rs` — clap `Cli` struct
- `src/discovery.rs` — projects root, target resolution, encode/decode, path matching
- `src/session.rs` — `Session` struct + `parse_session()`
- `src/filter.rs` — `SortKey`, time parsing/filtering, fuzzy search, sorting
- `src/output.rs` — table + JSON rendering
- `tests/cli.rs` — end-to-end integration tests
- Distribution: `README.md`, `SKILL.md`, `LICENSE`, `CHANGELOG.md`, `.github/workflows/{ci,release}.yml`, `rustfmt.toml`, `.gitignore`

---

### Task 0: Project scaffold

**Goal:** A compiling, runnable Cargo binary with all dependencies declared.

**Files:**
- Create: `Cargo.toml`
- Create: `src/main.rs`
- Create: `rustfmt.toml`
- Modify: `.gitignore` (already has `/target`)

**Acceptance Criteria:**
- [ ] `cargo build` succeeds.
- [ ] `cargo run -- --help` prints usage (clap auto-help) once CLI lands; for Task 0 a bare binary runs.

**Verify:** `cargo build` → finishes without errors.

**Steps:**

- [ ] **Step 1: Write `Cargo.toml`**

```toml
[package]
name = "csess"
version = "0.1.0"
edition = "2021"
description = "Fast lister for Claude Code sessions in a folder and its subprojects"
repository = "https://github.com/sibincbaby/csess"
homepage = "https://github.com/sibincbaby/csess"
license = "MIT"
readme = "README.md"
keywords = ["claude", "cli", "sessions", "ai"]
categories = ["command-line-utilities"]

[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["clock", "serde"] }
rayon = "1"
comfy-table = "7"
fuzzy-matcher = "0.3"
dirs = "5"
anyhow = "1"

[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"

[profile.release]
strip = true
lto = true
```

- [ ] **Step 2: Write minimal `src/main.rs`**

```rust
fn main() {
    println!("csess scaffold");
}
```

- [ ] **Step 3: Write `rustfmt.toml`**

```toml
edition = "2021"
```

- [ ] **Step 4: Build**

Run: `cargo build`
Expected: compiles; `target/debug/csess` exists.

- [ ] **Step 5: Commit**

```bash
git add Cargo.toml Cargo.lock src/main.rs rustfmt.toml
git commit -m "chore: scaffold csess cargo project"
```

---

### Task 1: Discovery — projects root, target resolution, path matching

**Goal:** Pure functions to locate the projects root, resolve/normalize the target path, and match project directories (loose, encoded) and real cwds (strict).

**Files:**
- Create: `src/discovery.rs`
- Modify: `src/main.rs` (add `mod discovery;`)
- Test: unit tests inside `src/discovery.rs`

**Acceptance Criteria:**
- [ ] `encode_path` replaces `/` with `-`.
- [ ] `dir_matches` accepts exact + child encoded dirs (recursive), rejects child when non-recursive and rejects unrelated dirs.
- [ ] `path_under` excludes a dashed sibling (`my-works-backup`) that the loose pre-filter would wrongly include.

**Verify:** `cargo test discovery` → all pass.

**Steps:**

- [ ] **Step 1: Add module declaration to `src/main.rs`**

Replace `src/main.rs` body's top with:

```rust
mod discovery;

fn main() {
    println!("csess scaffold");
}
```

- [ ] **Step 2: Write `src/discovery.rs` with tests first**

```rust
use anyhow::Result;
use std::path::{Path, PathBuf};

/// Resolve the Claude projects root (or an explicit override for tests).
pub fn projects_root(override_dir: Option<&str>) -> Result<PathBuf> {
    if let Some(d) = override_dir {
        return Ok(PathBuf::from(d));
    }
    let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
    Ok(home.join(".claude").join("projects"))
}

/// Resolve the target folder to an absolute, lightly-normalized path.
pub fn resolve_target(path_arg: Option<&str>) -> Result<PathBuf> {
    let raw = match path_arg {
        Some(p) => PathBuf::from(p),
        None => std::env::current_dir()?,
    };
    let abs = if raw.is_absolute() {
        raw
    } else {
        std::env::current_dir()?.join(raw)
    };
    Ok(normalize(&abs))
}

/// Strip `.` components and any trailing slash without touching symlinks.
fn normalize(p: &Path) -> PathBuf {
    let mut out = PathBuf::new();
    for comp in p.components() {
        match comp {
            std::path::Component::CurDir => {}
            other => out.push(other.as_os_str()),
        }
    }
    out
}

/// Encode a real path the way Claude names project dirs: `/` -> `-`.
pub fn encode_path(path: &Path) -> String {
    path.to_string_lossy().replace('/', "-")
}

/// Loose pre-filter on encoded directory names. Recursive includes children.
/// Intentionally over-includes dashed siblings; `path_under` corrects later.
pub fn dir_matches(encoded_target: &str, dir_name: &str, recursive: bool) -> bool {
    if dir_name == encoded_target {
        return true;
    }
    if recursive {
        if let Some(rest) = dir_name.strip_prefix(encoded_target) {
            return rest.starts_with('-');
        }
    }
    false
}

/// Strict, component-wise check that `cwd` is the target or (if recursive) a descendant.
pub fn path_under(target: &Path, cwd: &Path, recursive: bool) -> bool {
    if cwd == target {
        return true;
    }
    if recursive {
        return cwd.starts_with(target);
    }
    false
}

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

    #[test]
    fn encode_path_replaces_slashes() {
        assert_eq!(encode_path(Path::new("/home/sibin/my-works")), "-home-sibin-my-works");
    }

    #[test]
    fn dir_matches_exact_and_child() {
        let t = "-home-sibin-my-works";
        assert!(dir_matches(t, "-home-sibin-my-works", true));
        assert!(dir_matches(t, "-home-sibin-my-works-tiptestapp", true));
        assert!(!dir_matches(t, "-home-sibin-other", true));
        assert!(!dir_matches(t, "-home-sibin-my-works-tiptestapp", false));
    }

    #[test]
    fn path_under_excludes_dashed_sibling() {
        let target = Path::new("/home/sibin/my-works");
        assert!(path_under(target, Path::new("/home/sibin/my-works"), true));
        assert!(path_under(target, Path::new("/home/sibin/my-works/tiptestapp"), true));
        // The loose encoded pre-filter would include this; strict check must reject it:
        assert!(!path_under(target, Path::new("/home/sibin/my-works-backup"), true));
        assert!(!path_under(target, Path::new("/home/sibin/my-works/tiptestapp"), false));
    }
}
```

- [ ] **Step 3: Run tests (expect pass)**

Run: `cargo test discovery`
Expected: 3 tests pass.

- [ ] **Step 4: Commit**

```bash
git add src/discovery.rs src/main.rs
git commit -m "feat: project discovery and path matching"
```

---

### Task 2: Session parsing

**Goal:** Parse one `.jsonl` file into a `Session`, resolving name (summary → first prompt), cwd, timestamps, branch, version, and message count.

**Files:**
- Create: `src/session.rs`
- Modify: `src/main.rs` (add `mod session;`)
- Test: unit tests inside `src/session.rs`

**Acceptance Criteria:**
- [ ] Name = first real user prompt when no summary; cwd, branch, message count extracted.
- [ ] A `summary`-type entry takes precedence over the first prompt.
- [ ] Malformed/empty lines are skipped; a file with no usable data returns `Ok(None)`.

**Verify:** `cargo test session` → all pass.

**Steps:**

- [ ] **Step 1: Add `mod session;` to `src/main.rs`**

```rust
mod discovery;
mod session;

fn main() {
    println!("csess scaffold");
}
```

- [ ] **Step 2: Write `src/session.rs` (impl + tests)**

```rust
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;

#[derive(Debug, Clone, Serialize)]
pub struct Session {
    pub session_id: String,
    pub short: String,
    pub name: String,
    pub cwd: String,
    pub last_active: DateTime<Utc>,
    pub created: Option<DateTime<Utc>>,
    pub message_count: usize,
    pub git_branch: String,
    pub version: String,
    pub size_bytes: u64,
    pub file_path: String,
}

/// Parse a single session file. Returns Ok(None) when the file holds no session data.
pub fn parse_session(path: &Path) -> Result<Option<Session>> {
    let meta = fs::metadata(path)?;
    let size_bytes = meta.len();
    let last_active: DateTime<Utc> = meta.modified()?.into();

    let session_id = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("")
        .to_string();

    let file = fs::File::open(path)?;
    let reader = BufReader::new(file);

    let mut cwd = String::new();
    let mut git_branch = String::new();
    let mut version = String::new();
    let mut summary: Option<String> = None;
    let mut first_prompt: Option<String> = None;
    let mut created: Option<DateTime<Utc>> = None;
    let mut message_count = 0usize;

    for line in reader.lines() {
        let line = match line {
            Ok(l) => l,
            Err(_) => continue,
        };
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let v: serde_json::Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue,
        };
        let typ = v.get("type").and_then(|t| t.as_str()).unwrap_or("");

        if cwd.is_empty() {
            if let Some(c) = v.get("cwd").and_then(|c| c.as_str()) {
                cwd = c.to_string();
            }
        }
        if git_branch.is_empty() {
            if let Some(b) = v.get("gitBranch").and_then(|b| b.as_str()) {
                git_branch = b.to_string();
            }
        }
        if version.is_empty() {
            if let Some(ver) = v.get("version").and_then(|x| x.as_str()) {
                version = ver.to_string();
            }
        }
        if created.is_none() {
            if let Some(ts) = v.get("timestamp").and_then(|t| t.as_str()) {
                if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
                    created = Some(dt.with_timezone(&Utc));
                }
            }
        }

        match typ {
            "summary" => {
                if let Some(s) = v.get("summary").and_then(|s| s.as_str()) {
                    if !s.trim().is_empty() {
                        summary = Some(s.trim().to_string());
                    }
                }
            }
            "user" => {
                message_count += 1;
                let is_sidechain = v.get("isSidechain").and_then(|b| b.as_bool()).unwrap_or(false);
                if first_prompt.is_none() && !is_sidechain {
                    if let Some(text) = extract_user_text(&v) {
                        let t = text.trim();
                        if !t.is_empty() && !is_meta_text(t) {
                            first_prompt = Some(t.to_string());
                        }
                    }
                }
            }
            "assistant" => {
                message_count += 1;
            }
            _ => {}
        }
    }

    if cwd.is_empty() && summary.is_none() && first_prompt.is_none() && message_count == 0 {
        return Ok(None);
    }

    let name = summary.or(first_prompt).unwrap_or_else(|| "(no name)".to_string());
    let short = session_id.chars().take(8).collect();

    Ok(Some(Session {
        session_id,
        short,
        name,
        cwd,
        last_active,
        created,
        message_count,
        git_branch,
        version,
        size_bytes,
        file_path: path.to_string_lossy().to_string(),
    }))
}

/// Pull text out of a user message whose content may be a string or text blocks.
fn extract_user_text(v: &serde_json::Value) -> Option<String> {
    let content = v.get("message")?.get("content")?;
    if let Some(s) = content.as_str() {
        return Some(s.to_string());
    }
    if let Some(arr) = content.as_array() {
        let mut out = String::new();
        for block in arr {
            if block.get("type").and_then(|t| t.as_str()) == Some("text") {
                if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
                    if !out.is_empty() {
                        out.push(' ');
                    }
                    out.push_str(t);
                }
            }
        }
        if !out.is_empty() {
            return Some(out);
        }
    }
    None
}

/// Heuristic: skip command/attachment/system meta messages when naming a session.
fn is_meta_text(t: &str) -> bool {
    t.starts_with("<command-")
        || t.starts_with("<local-command")
        || t.starts_with("<bash-")
        || t.starts_with("Caveat:")
}

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

    #[test]
    fn parse_session_extracts_name_and_cwd() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("11111111-2222-3333-4444-555555555555.jsonl");
        fs::write(
            &p,
            concat!(
                "{\"type\":\"user\",\"cwd\":\"/home/sibin/proj\",\"gitBranch\":\"main\",\"version\":\"1.0\",\"timestamp\":\"2026-06-16T01:00:00.000Z\",\"message\":{\"role\":\"user\",\"content\":\"Hello world\"}}\n",
                "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Hi\"}]}}\n",
            ),
        )
        .unwrap();
        let s = parse_session(&p).unwrap().unwrap();
        assert_eq!(s.name, "Hello world");
        assert_eq!(s.cwd, "/home/sibin/proj");
        assert_eq!(s.git_branch, "main");
        assert_eq!(s.message_count, 2);
        assert_eq!(s.short, "11111111");
    }

    #[test]
    fn parse_session_prefers_summary() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("aaaaaaaa-0000-0000-0000-000000000000.jsonl");
        fs::write(
            &p,
            concat!(
                "{\"type\":\"summary\",\"summary\":\"Refactor auth\"}\n",
                "{\"type\":\"user\",\"cwd\":\"/x\",\"message\":{\"role\":\"user\",\"content\":\"hi\"}}\n",
            ),
        )
        .unwrap();
        let s = parse_session(&p).unwrap().unwrap();
        assert_eq!(s.name, "Refactor auth");
    }

    #[test]
    fn parse_session_skips_garbage_and_empty() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("bbbbbbbb-0000-0000-0000-000000000000.jsonl");
        fs::write(&p, "not json\n\n{ broken\n").unwrap();
        assert!(parse_session(&p).unwrap().is_none());
    }
}
```

- [ ] **Step 3: Run tests**

Run: `cargo test session`
Expected: 3 tests pass.

- [ ] **Step 4: Commit**

```bash
git add src/session.rs src/main.rs
git commit -m "feat: parse session jsonl into Session"
```

---

### Task 3: Filtering, fuzzy search, sorting

**Goal:** Time-window parsing/filtering, fuzzy search with score ordering, and sort keys.

**Files:**
- Create: `src/filter.rs`
- Modify: `src/main.rs` (add `mod filter;`)
- Test: unit tests inside `src/filter.rs`

**Acceptance Criteria:**
- [ ] `parse_when` handles `30m`/`24h`/`7d` relative and `YYYY-MM-DD` absolute; errors on garbage.
- [ ] `period_bounds` returns correct windows for today/yesterday/week/month.
- [ ] `search` returns only fuzzy-matching sessions, best score first.
- [ ] `sort_sessions` orders by each key; `reverse` flips.

**Verify:** `cargo test filter` → all pass.

**Steps:**

- [ ] **Step 1: Add `mod filter;` to `src/main.rs`**

```rust
mod cli;
mod discovery;
mod filter;
mod output;
mod session;
```

> Note: add `mod cli;` and `mod output;` lines too only after those files exist (Tasks 4 & 5). For Task 3, add just `mod filter;` alongside the existing `mod discovery;` and `mod session;`.

- [ ] **Step 2: Write `src/filter.rs` (impl + tests)**

```rust
use crate::session::Session;
use anyhow::Result;
use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;

#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum SortKey {
    Active,
    Created,
    Name,
    Messages,
    Size,
}

/// Parse a relative (`30m`/`24h`/`7d`) or absolute (`YYYY-MM-DD`) time bound.
pub fn parse_when(s: &str, now: DateTime<Utc>) -> Result<DateTime<Utc>> {
    let s = s.trim();
    for (suffix, mult) in [("m", 1i64), ("h", 60), ("d", 1440)] {
        if let Some(num) = s.strip_suffix(suffix) {
            if let Ok(n) = num.parse::<i64>() {
                return Ok(now - Duration::minutes(n * mult));
            }
        }
    }
    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
        return Ok(Utc.from_utc_datetime(&d.and_hms_opt(0, 0, 0).unwrap()));
    }
    anyhow::bail!("invalid time value '{s}' (use e.g. 30m, 24h, 7d, or 2026-06-01)")
}

/// Named period presets -> (since, until) bounds.
pub fn period_bounds(p: &str, now: DateTime<Utc>) -> Result<(Option<DateTime<Utc>>, Option<DateTime<Utc>>)> {
    let midnight = |dt: DateTime<Utc>| Utc.from_utc_datetime(&dt.date_naive().and_hms_opt(0, 0, 0).unwrap());
    match p {
        "today" => Ok((Some(midnight(now)), None)),
        "yesterday" => {
            let today = midnight(now);
            Ok((Some(today - Duration::days(1)), Some(today)))
        }
        "week" => Ok((Some(now - Duration::days(7)), None)),
        "month" => Ok((Some(now - Duration::days(30)), None)),
        other => anyhow::bail!("invalid period '{other}' (use today|yesterday|week|month)"),
    }
}

/// Retain sessions whose last_active falls within [since, until).
pub fn time_filter(sessions: &mut Vec<Session>, since: Option<DateTime<Utc>>, until: Option<DateTime<Utc>>) {
    sessions.retain(|s| {
        if let Some(start) = since {
            if s.last_active < start {
                return false;
            }
        }
        if let Some(end) = until {
            if s.last_active >= end {
                return false;
            }
        }
        true
    });
}

/// Fuzzy match on name + cwd, returning matches ordered by descending score.
pub fn search(sessions: Vec<Session>, term: &str) -> Vec<Session> {
    let matcher = SkimMatcherV2::default();
    let mut scored: Vec<(i64, Session)> = sessions
        .into_iter()
        .filter_map(|s| {
            let hay = format!("{} {}", s.name, s.cwd);
            matcher.fuzzy_match(&hay, term).map(|score| (score, s))
        })
        .collect();
    scored.sort_by(|a, b| b.0.cmp(&a.0));
    scored.into_iter().map(|(_, s)| s).collect()
}

/// Sort in place by key. Defaults: time/size/messages descending, name ascending.
pub fn sort_sessions(sessions: &mut [Session], key: SortKey, reverse: bool) {
    match key {
        SortKey::Active => sessions.sort_by(|a, b| b.last_active.cmp(&a.last_active)),
        SortKey::Created => sessions.sort_by(|a, b| b.created.cmp(&a.created)),
        SortKey::Name => sessions.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
        SortKey::Messages => sessions.sort_by(|a, b| b.message_count.cmp(&a.message_count)),
        SortKey::Size => sessions.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)),
    }
    if reverse {
        sessions.reverse();
    }
}

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

    fn now() -> DateTime<Utc> {
        Utc.with_ymd_and_hms(2026, 6, 16, 12, 0, 0).unwrap()
    }

    #[test]
    fn parse_when_relative_and_absolute() {
        let n = now();
        assert_eq!(parse_when("24h", n).unwrap(), n - Duration::hours(24));
        assert_eq!(parse_when("7d", n).unwrap(), n - Duration::days(7));
        assert_eq!(parse_when("30m", n).unwrap(), n - Duration::minutes(30));
        assert_eq!(
            parse_when("2026-06-01", n).unwrap(),
            Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap()
        );
        assert!(parse_when("nonsense", n).is_err());
    }

    #[test]
    fn period_bounds_today_and_week() {
        let n = now();
        let (since, until) = period_bounds("today", n).unwrap();
        assert_eq!(since.unwrap(), Utc.with_ymd_and_hms(2026, 6, 16, 0, 0, 0).unwrap());
        assert!(until.is_none());
        let (wsince, _) = period_bounds("week", n).unwrap();
        assert_eq!(wsince.unwrap(), n - Duration::days(7));
        assert!(period_bounds("decade", n).is_err());
    }

    fn sample(name: &str, cwd: &str) -> Session {
        Session {
            session_id: "id".into(),
            short: "id".into(),
            name: name.into(),
            cwd: cwd.into(),
            last_active: now(),
            created: None,
            message_count: 0,
            git_branch: String::new(),
            version: String::new(),
            size_bytes: 0,
            file_path: String::new(),
        }
    }

    #[test]
    fn search_filters_and_ranks() {
        let sessions = vec![
            sample("Refactor authentication", "/a"),
            sample("Update README", "/b"),
            sample("Add auth tests", "/c"),
        ];
        let out = search(sessions, "auth");
        assert!(out.len() == 2);
        assert!(out.iter().all(|s| s.name.to_lowercase().contains("auth")));
    }

    #[test]
    fn sort_by_name_and_reverse() {
        let mut sessions = vec![sample("Beta", "/b"), sample("Alpha", "/a")];
        sort_sessions(&mut sessions, SortKey::Name, false);
        assert_eq!(sessions[0].name, "Alpha");
        sort_sessions(&mut sessions, SortKey::Name, true);
        assert_eq!(sessions[0].name, "Beta");
    }
}
```

- [ ] **Step 3: Run tests**

Run: `cargo test filter`
Expected: 4 tests pass.

- [ ] **Step 4: Commit**

```bash
git add src/filter.rs src/main.rs
git commit -m "feat: time filtering, fuzzy search, sorting"
```

---

### Task 4: Output rendering (table + JSON)

**Goal:** Render a `Session` slice as an aligned table (default) or pretty JSON.

**Files:**
- Create: `src/output.rs`
- Modify: `src/main.rs` (add `mod output;`)
- Test: unit tests inside `src/output.rs`

**Acceptance Criteria:**
- [ ] `render_json` produces a valid JSON array containing each session's `name`.
- [ ] `render_table` includes the short id, name, and a `N sessions` footer.
- [ ] `relative_time` and `truncate` behave (helper unit tests).

**Verify:** `cargo test output` → all pass.

**Steps:**

- [ ] **Step 1: Add `mod output;` to `src/main.rs`**

(Keep the existing module lines; add `mod output;`.)

- [ ] **Step 2: Write `src/output.rs` (impl + tests)**

```rust
use crate::session::Session;
use anyhow::Result;
use chrono::{DateTime, Utc};
use comfy_table::presets::UTF8_BORDERS_ONLY;
use comfy_table::Table;

/// Pretty JSON array of full session objects (always valid, even when empty).
pub fn render_json(sessions: &[Session]) -> Result<String> {
    Ok(serde_json::to_string_pretty(sessions)?)
}

/// Human-readable aligned table with a trailing count.
pub fn render_table(sessions: &[Session], now: DateTime<Utc>) -> String {
    let mut table = Table::new();
    table.load_preset(UTF8_BORDERS_ONLY);
    table.set_header(vec!["SHORT", "NAME", "LAST ACTIVE", "MSGS", "BRANCH", "PATH"]);
    for s in sessions {
        let name = truncate(&s.name.replace('\n', " "), 50);
        table.add_row(vec![
            s.short.clone(),
            name,
            relative_time(s.last_active, now),
            s.message_count.to_string(),
            s.git_branch.clone(),
            truncate(&s.cwd, 45),
        ]);
    }
    format!("{table}\n{} sessions", sessions.len())
}

fn truncate(s: &str, max: usize) -> String {
    let chars: Vec<char> = s.chars().collect();
    if chars.len() <= max {
        return s.to_string();
    }
    let cut = max.saturating_sub(1);
    let head: String = chars[..cut].iter().collect();
    format!("{head}…")
}

fn relative_time(then: DateTime<Utc>, now: DateTime<Utc>) -> String {
    let d = now.signed_duration_since(then);
    let secs = d.num_seconds();
    if secs < 0 {
        return "in future".to_string();
    }
    if secs < 60 {
        return "just now".to_string();
    }
    if d.num_minutes() < 60 {
        return format!("{}m ago", d.num_minutes());
    }
    if d.num_hours() < 24 {
        return format!("{}h ago", d.num_hours());
    }
    let days = d.num_days();
    if days < 30 {
        return format!("{days}d ago");
    }
    if days < 365 {
        return format!("{}mo ago", days / 30);
    }
    format!("{}y ago", days / 365)
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::TimeZone;

    fn sample() -> Session {
        Session {
            session_id: "11111111-x".into(),
            short: "11111111".into(),
            name: "Build the thing".into(),
            cwd: "/home/sibin/demo".into(),
            last_active: Utc.with_ymd_and_hms(2026, 6, 16, 10, 0, 0).unwrap(),
            created: None,
            message_count: 4,
            git_branch: "main".into(),
            version: "1.0".into(),
            size_bytes: 10,
            file_path: "/x".into(),
        }
    }

    #[test]
    fn json_contains_name() {
        let out = render_json(&[sample()]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["name"], "Build the thing");
    }

    #[test]
    fn table_has_short_and_footer() {
        let now = Utc.with_ymd_and_hms(2026, 6, 16, 12, 0, 0).unwrap();
        let out = render_table(&[sample()], now);
        assert!(out.contains("11111111"));
        assert!(out.contains("Build the thing"));
        assert!(out.contains("1 sessions"));
    }

    #[test]
    fn truncate_and_relative() {
        assert_eq!(truncate("hello", 10), "hello");
        assert_eq!(truncate("hello", 3), "he…");
        let now = Utc.with_ymd_and_hms(2026, 6, 16, 12, 0, 0).unwrap();
        let then = Utc.with_ymd_and_hms(2026, 6, 16, 10, 0, 0).unwrap();
        assert_eq!(relative_time(then, now), "2h ago");
    }
}
```

- [ ] **Step 3: Run tests**

Run: `cargo test output`
Expected: 3 tests pass.

- [ ] **Step 4: Commit**

```bash
git add src/output.rs src/main.rs
git commit -m "feat: table and json output rendering"
```

---

### Task 5: CLI + orchestration + integration tests

**Goal:** Wire `clap` args and the full `run()` pipeline; verify end-to-end against a fake projects root.

**Files:**
- Create: `src/cli.rs`
- Create: `tests/cli.rs`
- Modify: `src/main.rs` (final `run()` implementation)

**Acceptance Criteria:**
- [ ] `csess <path> --projects-dir <fake>` prints the seeded session in a table.
- [ ] `--json` prints a valid JSON array whose first item's `name` matches.
- [ ] A dashed-sibling project is excluded from a target's results.
- [ ] Missing projects dir exits with code 2; normal empty result exits 0.

**Verify:** `cargo test` → unit + integration all pass; `cargo run -- --help` shows all flags.

**Steps:**

- [ ] **Step 1: Write `src/cli.rs`**

```rust
use crate::filter::SortKey;
use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "csess", version, about = "List Claude Code sessions for a folder and its subprojects")]
pub struct Cli {
    /// Folder to scan (default: current directory)
    pub path: Option<String>,

    /// List sessions across all Claude projects
    #[arg(short = 'g', long)]
    pub global: bool,

    /// Only the exact folder (default also scans subfolders)
    #[arg(short = 'R', long = "no-recursive")]
    pub no_recursive: bool,

    /// Fuzzy match on session name / path
    #[arg(short = 's', long)]
    pub search: Option<String>,

    /// Lower time bound: 2026-06-01 | 7d | 24h | 30m
    #[arg(long)]
    pub since: Option<String>,

    /// Upper time bound (same formats)
    #[arg(long)]
    pub until: Option<String>,

    /// today | yesterday | week | month
    #[arg(long)]
    pub period: Option<String>,

    /// Sort key (default: active)
    #[arg(long, value_enum)]
    pub sort: Option<SortKey>,

    /// Reverse sort order
    #[arg(short = 'r', long)]
    pub reverse: bool,

    /// Limit number of results
    #[arg(short = 'n', long)]
    pub limit: Option<usize>,

    /// Machine-readable JSON output
    #[arg(long)]
    pub json: bool,

    /// Override projects root (for testing)
    #[arg(long, hide = true)]
    pub projects_dir: Option<String>,
}
```

- [ ] **Step 2: Replace `src/main.rs` with the full implementation**

```rust
mod cli;
mod discovery;
mod filter;
mod output;
mod session;

use anyhow::Result;
use chrono::Utc;
use clap::Parser;
use rayon::prelude::*;
use std::path::{Path, PathBuf};

use cli::Cli;
use filter::SortKey;

fn main() {
    let cli = Cli::parse();
    if let Err(e) = run(cli) {
        eprintln!("error: {e:#}");
        std::process::exit(1);
    }
}

fn run(cli: Cli) -> Result<()> {
    let now = Utc::now();

    let root = discovery::projects_root(cli.projects_dir.as_deref())?;
    if !root.is_dir() {
        eprintln!("error: Claude projects directory not found at {}", root.display());
        std::process::exit(2);
    }

    let recursive = !cli.no_recursive;
    let target = discovery::resolve_target(cli.path.as_deref())?;
    let encoded_target = discovery::encode_path(&target);

    // 1. candidate project dirs (loose encoded pre-filter)
    let mut dirs: Vec<PathBuf> = Vec::new();
    for entry in std::fs::read_dir(&root)? {
        let entry = match entry {
            Ok(e) => e,
            Err(_) => continue,
        };
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let name = match path.file_name().and_then(|n| n.to_str()) {
            Some(n) => n,
            None => continue,
        };
        if cli.global || discovery::dir_matches(&encoded_target, name, recursive) {
            dirs.push(path);
        }
    }

    // 2. gather .jsonl files
    let mut files: Vec<PathBuf> = Vec::new();
    for dir in &dirs {
        let rd = match std::fs::read_dir(dir) {
            Ok(r) => r,
            Err(_) => continue,
        };
        for entry in rd.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
                files.push(path);
            }
        }
    }

    // 3. parse in parallel, 4. verify real cwd is under target
    let mut sessions: Vec<session::Session> = files
        .par_iter()
        .filter_map(|p| match session::parse_session(p) {
            Ok(Some(s)) => Some(s),
            Ok(None) => None,
            Err(e) => {
                eprintln!("warning: skipping {}: {e}", p.display());
                None
            }
        })
        .filter(|s| {
            if cli.global {
                return true;
            }
            if s.cwd.is_empty() {
                return false;
            }
            discovery::path_under(&target, Path::new(&s.cwd), recursive)
        })
        .collect();

    // 5. time filtering (period first, explicit since/until override)
    let (mut since, mut until) = (None, None);
    if let Some(p) = &cli.period {
        let (s, u) = filter::period_bounds(p, now)?;
        since = s;
        until = u;
    }
    if let Some(s) = &cli.since {
        since = Some(filter::parse_when(s, now)?);
    }
    if let Some(u) = &cli.until {
        until = Some(filter::parse_when(u, now)?);
    }
    filter::time_filter(&mut sessions, since, until);

    // 6. fuzzy search (score order)
    let searched = cli.search.is_some();
    if let Some(term) = &cli.search {
        sessions = filter::search(sessions, term);
    }

    // 7. sort
    match cli.sort {
        Some(key) => filter::sort_sessions(&mut sessions, key, cli.reverse),
        None => {
            if !searched {
                filter::sort_sessions(&mut sessions, SortKey::Active, cli.reverse);
            } else if cli.reverse {
                sessions.reverse();
            }
        }
    }

    // 8. limit
    if let Some(n) = cli.limit {
        sessions.truncate(n);
    }

    // 9. output
    if cli.json {
        println!("{}", output::render_json(&sessions)?);
    } else {
        println!("{}", output::render_table(&sessions, now));
    }
    Ok(())
}
```

- [ ] **Step 3: Write `tests/cli.rs`**

```rust
use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;

fn seed(root: &std::path::Path, dir: &str, file: &str, cwd: &str, prompt: &str) {
    let proj = root.join(dir);
    fs::create_dir_all(&proj).unwrap();
    let line = format!(
        "{{\"type\":\"user\",\"cwd\":\"{cwd}\",\"timestamp\":\"2026-06-16T01:00:00.000Z\",\"message\":{{\"role\":\"user\",\"content\":\"{prompt}\"}}}}\n"
    );
    fs::write(proj.join(file), line).unwrap();
}

fn setup() -> tempfile::TempDir {
    let root = tempfile::tempdir().unwrap();
    seed(
        root.path(),
        "-home-sibin-my-works-demo",
        "11111111-aaaa-bbbb-cccc-dddddddddddd.jsonl",
        "/home/sibin/my-works/demo",
        "Build the thing",
    );
    // dashed sibling: loose pre-filter includes it, cwd verify must drop it
    seed(
        root.path(),
        "-home-sibin-my-works-demo-backup",
        "22222222-aaaa-bbbb-cccc-dddddddddddd.jsonl",
        "/home/sibin/my-works/demo-backup",
        "Sibling session",
    );
    root
}

#[test]
fn lists_sessions_as_table() {
    let root = setup();
    Command::cargo_bin("csess")
        .unwrap()
        .args([
            "/home/sibin/my-works/demo",
            "--projects-dir",
            root.path().to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("Build the thing"))
        .stdout(predicate::str::contains("11111111"))
        .stdout(predicate::str::contains("Sibling session").not());
}

#[test]
fn json_output_is_valid() {
    let root = setup();
    let out = Command::cargo_bin("csess")
        .unwrap()
        .args([
            "/home/sibin/my-works/demo",
            "--json",
            "--projects-dir",
            root.path().to_str().unwrap(),
        ])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
    assert!(v.is_array());
    assert_eq!(v[0]["name"], "Build the thing");
    assert_eq!(v.as_array().unwrap().len(), 1);
}

#[test]
fn missing_projects_dir_exits_2() {
    Command::cargo_bin("csess")
        .unwrap()
        .args(["/tmp/whatever", "--projects-dir", "/nonexistent/path/xyz"])
        .assert()
        .code(2);
}
```

- [ ] **Step 4: Run full suite**

Run: `cargo test`
Expected: all unit + 3 integration tests pass.

- [ ] **Step 5: Sanity-check help + clippy/fmt**

Run: `cargo run -- --help` → shows flags.
Run: `cargo clippy --all-targets -- -D warnings` → no warnings.
Run: `cargo fmt --check` → clean.

- [ ] **Step 6: Commit**

```bash
git add src/cli.rs src/main.rs tests/cli.rs
git commit -m "feat: wire CLI and end-to-end pipeline"
```

---

### Task 6: Distribution — docs, license, changelog, CI/release

**Goal:** Make the repo installable and shareable: README, SKILL.md, LICENSE, CHANGELOG, GitHub Actions CI + release builds.

**Files:**
- Create: `README.md`, `SKILL.md`, `LICENSE`, `CHANGELOG.md`
- Create: `.github/workflows/ci.yml`, `.github/workflows/release.yml`
- Create: `install.sh`

**Acceptance Criteria:**
- [ ] README documents install (cargo / prebuilt / script), usage, examples, JSON fields.
- [ ] `SKILL.md` has valid frontmatter (`name`, `description`) and AI usage guidance.
- [ ] CI workflow runs fmt + clippy + test on push/PR; release workflow builds musl + gnu on tag `v*`.
- [ ] `LICENSE` is MIT with the user's name; `CHANGELOG.md` has a `0.1.0` entry.

**Verify:** `cargo build --release` succeeds; `ls .github/workflows` shows both files.

**Steps:**

- [ ] **Step 1: Write `LICENSE` (MIT)**

```
MIT License

Copyright (c) 2026 Sibin C Baby

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

- [ ] **Step 2: Write `CHANGELOG.md`**

```markdown
# Changelog

All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0] - 2026-06-16

### Added
- List Claude Code sessions for a folder and its subfolder projects.
- Recursive discovery with loose encoded pre-filter + strict `cwd` verification.
- Fuzzy search (`--search`), time filters (`--since`/`--until`/`--period`),
  sorting (`--sort`), `--limit`, `--global`.
- Table (default) and `--json` output.
```

- [ ] **Step 3: Write `README.md`**

```markdown
# csess

Fast, reliable CLI that lists [Claude Code](https://claude.com/claude-code)
sessions for a folder and its subfolder projects. Built for speed — designed to
be called by Claude itself with `--json`.

## Why

Claude stores each session as a `*.jsonl` file under
`~/.claude/projects/<encoded-cwd>/`. `csess` resolves those into a readable list
(name, last-active, message count, path), correctly handling nested projects and
folder names containing dashes.

## Install

### From source (Cargo)
```bash
cargo install --git https://github.com/sibincbaby/csess
```

### Prebuilt binary
Download the latest static binary from
[Releases](https://github.com/sibincbaby/csess/releases), then:
```bash
tar xzf csess-*-linux-musl.tar.gz
sudo mv csess /usr/local/bin/
```

### Install script
```bash
curl -fsSL https://raw.githubusercontent.com/sibincbaby/csess/main/install.sh | bash
```

## Usage

```bash
csess                      # sessions for the current dir + subfolders
csess /home/me/projects    # a specific folder tree
csess --global             # every Claude project on the machine
csess -s auth              # fuzzy search name/path
csess --period today       # only today's sessions
csess --since 7d --sort messages
csess --json               # machine-readable output (for scripts / AI)
```

### Options
| Flag | Description |
|------|-------------|
| `PATH` | Folder to scan (default: cwd) |
| `-g, --global` | All Claude projects |
| `-R, --no-recursive` | Exact folder only |
| `-s, --search TERM` | Fuzzy match on name/path |
| `--since` / `--until` | `2026-06-01` \| `7d` \| `24h` \| `30m` |
| `--period` | `today` \| `yesterday` \| `week` \| `month` |
| `--sort` | `active` \| `created` \| `name` \| `messages` \| `size` |
| `-r, --reverse` | Reverse order |
| `-n, --limit N` | Cap results |
| `--json` | JSON output |

### JSON fields
`session_id`, `short`, `name`, `cwd`, `last_active` (RFC3339), `created`,
`message_count`, `git_branch`, `version`, `size_bytes`, `file_path`.

## For AI agents
See [`SKILL.md`](./SKILL.md) — guidance for Claude Code and other agents on when
and how to call `csess` (prefer `--json`).

## License
MIT © Sibin C Baby
```

- [ ] **Step 4: Write `SKILL.md`**

```markdown
---
name: claude-session-lookup
description: Use when you need to find Claude Code sessions for a folder — list session names, last-active times, message counts, and paths. Fast; prefer --json for parsing.
---

# Looking up Claude sessions with `csess`

`csess` lists Claude Code sessions for a folder and its subfolder projects.

## When to use
- "What sessions exist for this project?"
- "Find my session about X" → `csess -s X --json`
- "Which session was most recently active?" → `csess --json -n 1`

## How to call
Always prefer `--json` when you will parse the result.

```bash
csess --json                 # current dir + subfolders
csess /path/to/proj --json   # a specific tree
csess --global --json        # all projects
csess -s "auth" --json       # fuzzy search
csess --period today --json  # time-filtered
```

## Output (JSON array)
Each item: `session_id`, `short`, `name`, `cwd`, `last_active`, `created`,
`message_count`, `git_branch`, `version`, `size_bytes`, `file_path`.

Sorted newest-first by default. Use `--sort` and `-n/--limit` to refine.

## Install
`cargo install --git https://github.com/sibincbaby/csess`
```

- [ ] **Step 5: Write `install.sh`**

```bash
#!/usr/bin/env bash
set -euo pipefail

REPO="sibincbaby/csess"
BIN="csess"
DEST="${DEST:-/usr/local/bin}"

tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep -oP '"tag_name":\s*"\K[^"]+')
url="https://github.com/${REPO}/releases/download/${tag}/${BIN}-${tag}-x86_64-linux-musl.tar.gz"

tmp=$(mktemp -d)
echo "Downloading ${BIN} ${tag}..."
curl -fsSL "$url" | tar xz -C "$tmp"
install -m 0755 "$tmp/${BIN}" "${DEST}/${BIN}"
rm -rf "$tmp"
echo "Installed ${BIN} to ${DEST}/${BIN}"
```

- [ ] **Step 6: Write `.github/workflows/ci.yml`**

```yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy
      - uses: Swatinem/rust-cache@v2
      - run: cargo fmt --check
      - run: cargo clippy --all-targets -- -D warnings
      - run: cargo test --all
```

- [ ] **Step 7: Write `.github/workflows/release.yml`**

```yaml
name: Release

on:
  push:
    tags: ["v*"]

permissions:
  contents: write

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        target:
          - x86_64-unknown-linux-musl
          - x86_64-unknown-linux-gnu
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - name: Install musl tools
        if: contains(matrix.target, 'musl')
        run: sudo apt-get update && sudo apt-get install -y musl-tools
      - run: cargo build --release --target ${{ matrix.target }}
      - name: Package
        run: |
          tag=${GITHUB_REF_NAME}
          label=$(echo "${{ matrix.target }}" | sed 's/x86_64-unknown-linux-/x86_64-linux-/')
          tar czf "csess-${tag}-${label}.tar.gz" -C "target/${{ matrix.target }}/release" csess
      - uses: softprops/action-gh-release@v2
        with:
          files: csess-*.tar.gz
          generate_release_notes: true
```

- [ ] **Step 8: Build release + verify files**

Run: `cargo build --release`
Expected: succeeds.
Run: `ls .github/workflows`
Expected: `ci.yml release.yml`.

- [ ] **Step 9: Commit**

```bash
git add README.md SKILL.md LICENSE CHANGELOG.md install.sh .github
git commit -m "docs: add README, SKILL, license, changelog, CI/release"
```

---

### Task 7: First release (manual, requires user)

**Goal:** Publish to GitHub under the personal account and cut `v0.1.0`.

> **DECIDE (at execution time):** Confirm the repo does not already exist under `sibincbaby`. The account is already decided (personal `sibincbaby`, HTTPS remote) — do not re-ask that. This task only runs once the user says "publish".

**Files:** none (git/gh operations).

**Acceptance Criteria:**
- [ ] `gh repo create sibincbaby/csess --public` succeeds with the local repo pushed.
- [ ] Tag `v0.1.0` pushed; release workflow produces binaries.

**Verify:** `gh release view v0.1.0` shows attached tarballs.

**Steps:**

- [ ] **Step 1: Ensure personal gh auth + HTTPS git**

```bash
gh auth status              # confirm active account = sibincbaby
gh auth setup-git
```

- [ ] **Step 2: Create repo and push**

```bash
cd /home/sibin/my-works/csess
gh repo create sibincbaby/csess --public --source=. --remote=origin --push
```

- [ ] **Step 3: Tag and push the release**

```bash
git tag v0.1.0
git push origin v0.1.0
```

- [ ] **Step 4: Verify the release**

```bash
gh release view v0.1.0
```
Expected: lists `csess-v0.1.0-x86_64-linux-musl.tar.gz` and the gnu tarball.

---

## Self-Review

**Spec coverage:** purpose (Tasks 1–5), path encode/decode + cwd verify (Task 1), Session fields incl. summary/first-prompt name (Task 2), search/filter/sort (Task 3), table + JSON (Task 4), CLI + scope + errors/exit codes (Task 5), distribution/versioning/CI/README/SKILL (Task 6), publish (Task 7). All spec sections map to a task.

**Placeholder scan:** No TBD/TODO; every code step shows complete code; no "add error handling" hand-waves (exit codes and skip-on-error are concrete in `run()`/`parse_session`).

**Type consistency:** `Session` field names match across `session.rs`, `filter.rs`, `output.rs`, and tests. `SortKey` defined once in `filter.rs`, imported by `cli.rs` and `main.rs`. Function signatures (`parse_session`, `dir_matches`, `path_under`, `encode_path`, `parse_when`, `period_bounds`, `time_filter`, `search`, `sort_sessions`, `render_table`, `render_json`) are referenced consistently.
