i-self 0.4.3

Personal developer-companion CLI: scans your repos, indexes code semantically, watches your activity, and moves AI-agent sessions between tools (Claude Code, Aider, Goose, OpenAI Codex CLI, Continue.dev, OpenCode).
//! Share agent sessions across providers.
//!
//! Lets a user enumerate transcripts from coding-agent tools they use locally
//! (Claude Code, Aider, …), render them to a portable format, optionally redact
//! secrets, and share via S3 presigned URL using the existing sync stack.
//!
//! Coverage by provider:
//!
//! | Provider     | Status   | Notes |
//! |--------------|----------|-------|
//! | Claude Code  | ✅       | Reads `~/.claude/projects/<encoded-cwd>/*.jsonl` |
//! | Aider        | ✅       | Reads `<project>/.aider.chat.history.md` |
//! | OpenCode     | 🟡 stub  | Skeleton — no public schema doc, list returns empty |
//! | GitHub Copilot Chat | ❌ | VS Code globalState; no readable on-disk format |
//! | Cursor       | ❌       | IndexedDB / SQLite blob; not addressable as files |
//!
//! "Out of scope" providers are not faked — calling them returns
//! `Err(ShareError::UnsupportedProvider)` rather than producing empty data
//! that misleadingly suggests a successful enumeration.

#![allow(dead_code)]

use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use thiserror::Error;

pub mod claude_code;
pub mod aider;
pub mod opencode;
pub mod goose;
pub mod codex;
pub mod continue_dev;
pub mod generic_openai;
pub mod redact;
pub mod render;
pub mod upload;
pub mod import_session;

// Re-export the importer trait so provider modules can refer to it via
// `super::SessionImporter` symmetrically with `super::SessionProvider`.
// `ImportOptions` is referenced via its full path (`super::import_session::
// ImportOptions`) inside the importer impls so there's nothing to re-export.
pub use import_session::SessionImporter;

#[derive(Error, Debug)]
pub enum ShareError {
    #[error("provider `{0}` is not supported (no readable on-disk format)")]
    UnsupportedProvider(String),
    #[error("session `{0}` not found")]
    NotFound(String),
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    #[error("parse error: {0}")]
    Parse(String),
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum MessageRole {
    User,
    Assistant,
    System,
    ToolUse,
    ToolResult,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMessage {
    pub role: MessageRole,
    /// Plain-text content, with structured blocks (tool calls, thinking) flattened
    /// to a readable form. Multimodal data (images) is dropped.
    pub content: String,
    pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
    /// Free-form metadata: "model", "tool_name", "tool_input", etc.
    /// Provider-specific keys; consumers should treat unknown keys as opaque.
    #[serde(default)]
    pub metadata: std::collections::HashMap<String, String>,
}

/// Lightweight summary returned by `list_sessions` — just enough to pick one
/// out without paying the cost of parsing every message.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionSummary {
    pub provider: String,
    pub id: String,
    pub project_path: Option<PathBuf>,
    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
    pub message_count: usize,
    /// First user message, truncated. Useful as a label in `share ls` output.
    pub title_hint: Option<String>,
    /// True if this session was created by `i-self share import`. Detected
    /// generically by looking for the importer's `[i-self import]` provenance
    /// prefix on the first user-visible message — set in `list_all_sessions`,
    /// not by individual providers, so the detection stays consistent.
    #[serde(default)]
    pub imported: bool,
}

/// Provenance marker that every importer prefixes onto the leading
/// synthetic user message. Defined here so providers and consumers stay in
/// sync — change this and `share ls` will fail to flag past imports.
pub const IMPORT_PROVENANCE_PREFIX: &str = "[i-self import]";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SharedSession {
    pub provider: String,
    pub id: String,
    pub project_path: Option<PathBuf>,
    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
    pub messages: Vec<SessionMessage>,
}

pub trait SessionProvider: Send + Sync {
    fn name(&self) -> &str;

    /// Enumerate every session this provider can find. Should never error on
    /// "no sessions exist" — return an empty vector. Reserve `Err` for things
    /// like permission failures or genuinely malformed state directories.
    fn list_sessions(&self) -> Result<Vec<SessionSummary>, ShareError>;

    /// Load a single session by its provider-specific id.
    fn load_session(&self, id: &str) -> Result<SharedSession, ShareError>;
}

/// Construct the registry of every provider compiled into the binary. The
/// order is significant for `find_session` / `share ls` ambiguity resolution
/// — providers earlier in the list win on id collisions, which never happens
/// in practice (Claude Code uses UUIDs, Aider uses project-path slugs).
pub fn registry() -> Vec<Box<dyn SessionProvider>> {
    vec![
        Box::new(claude_code::ClaudeCodeProvider::default()),
        Box::new(aider::AiderProvider::default()),
        Box::new(goose::GooseProvider::default()),
        Box::new(codex::CodexProvider::default()),
        Box::new(continue_dev::ContinueProvider::default()),
        Box::new(opencode::OpenCodeProvider::default()),
        Box::new(generic_openai::GenericOpenAIProvider::default()),
    ]
}

/// Find a session by id across every provider, returning the first match.
pub fn find_session(id: &str) -> Result<SharedSession, ShareError> {
    let mut last_err: Option<ShareError> = None;
    for provider in registry() {
        match provider.load_session(id) {
            Ok(s) => return Ok(s),
            Err(ShareError::NotFound(_)) => continue,
            Err(e) => last_err = Some(e),
        }
    }
    Err(last_err.unwrap_or_else(|| ShareError::NotFound(id.to_string())))
}

/// Convenience: enumerate every provider's sessions, flat list.
pub fn list_all_sessions() -> Vec<SessionSummary> {
    let mut out = Vec::new();
    for provider in registry() {
        match provider.list_sessions() {
            Ok(s) => out.extend(s),
            Err(e) => tracing::warn!("provider {} list failed: {}", provider.name(), e),
        }
    }
    // Tag sessions whose title hints carry our import-provenance prefix.
    // Done centrally so providers don't each need to know about the prefix.
    for s in &mut out {
        if let Some(t) = &s.title_hint {
            if t.contains(IMPORT_PROVENANCE_PREFIX) {
                s.imported = true;
            }
        }
    }
    // Sort newest-first for sensible CLI output.
    out.sort_by(|a, b| b.started_at.cmp(&a.started_at));
    out
}

/// Parse a relative duration string like `7d`, `24h`, `2w`, `30m`.
///
/// Format: integer + single-letter unit. Supported units: `m` (minutes),
/// `h` (hours), `d` (days), `w` (weeks). The implicit assumption is that
/// users want recency — months and years are deliberately omitted because
/// "30d" is more precise than "1mo" (which month? leap year?).
pub fn parse_duration(s: &str) -> Result<chrono::Duration, ShareError> {
    let s = s.trim();
    if s.is_empty() {
        return Err(ShareError::Parse("empty duration".to_string()));
    }
    let (num_part, unit) = s.split_at(s.len() - 1);
    let n: i64 = num_part
        .parse()
        .map_err(|_| ShareError::Parse(format!("invalid duration `{}`", s)))?;
    match unit {
        "m" => Ok(chrono::Duration::minutes(n)),
        "h" => Ok(chrono::Duration::hours(n)),
        "d" => Ok(chrono::Duration::days(n)),
        "w" => Ok(chrono::Duration::weeks(n)),
        other => Err(ShareError::Parse(format!(
            "unknown duration unit `{}` — use m / h / d / w",
            other
        ))),
    }
}

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

    #[test]
    fn parse_duration_supports_common_suffixes() {
        assert_eq!(parse_duration("7d").unwrap(), chrono::Duration::days(7));
        assert_eq!(parse_duration("24h").unwrap(), chrono::Duration::hours(24));
        assert_eq!(parse_duration("30m").unwrap(), chrono::Duration::minutes(30));
        assert_eq!(parse_duration("2w").unwrap(), chrono::Duration::weeks(2));
    }

    #[test]
    fn parse_duration_rejects_unknown_units() {
        assert!(parse_duration("1y").is_err());
        assert!(parse_duration("4mo").is_err()); // ambiguous; we don't support months
        assert!(parse_duration("").is_err());
        assert!(parse_duration("d").is_err());
        assert!(parse_duration("abc").is_err());
    }
}