"""CLI entry point for ai-chat-exporter.""" import importlib.metadata import logging import platform import shutil import sys import traceback from datetime import datetime from pathlib import Path import click from rich.console import Console from rich.table import Table from src.cache import Cache, CacheError from src.config import ConfigError from src.logging_config import setup_logging from src.providers.base import ProviderError console = Console() err_console = Console(stderr=True) logger = logging.getLogger(__name__) TOS_NOTICE = """\ ⚠️ IMPORTANT — TERMS OF SERVICE NOTICE This tool accesses ChatGPT and Claude using internal web API endpoints that are not officially supported or documented by OpenAI or Anthropic. This approach may conflict with their Terms of Service: • OpenAI ToS: https://openai.com/policies/terms-of-use • Anthropic ToS: https://www.anthropic.com/legal/consumer-terms By using this tool you accept that: - You are using it at your own risk - Your account could potentially be suspended for automated access - These APIs may break at any time without notice - This tool is for personal archival use only, not commercial use Type 'yes' to acknowledge and continue, or Ctrl+C to exit: \ """ # ────────────────────────────────────────────────────────────────────────────── # CLI group # ────────────────────────────────────────────────────────────────────────────── @click.group() @click.version_option( version=importlib.metadata.version("ai-chat-exporter"), prog_name="ai-chat-exporter", ) @click.option("--verbose", "-v", is_flag=True, help="Enable DEBUG output to console.") @click.option("--quiet", "-q", is_flag=True, help="Show WARNING and above only.") @click.option("--debug", is_flag=True, help="DEBUG + full tracebacks + redacted API bodies.") @click.option("--no-log-file", is_flag=True, help="Disable file logging.") @click.pass_context def cli(ctx: click.Context, verbose: bool, quiet: bool, debug: bool, no_log_file: bool) -> None: """Export ChatGPT and Claude conversations to Markdown for personal archival.""" ctx.ensure_object(dict) # Determine console log level if debug or verbose: level = logging.DEBUG elif quiet: level = logging.WARNING else: level = logging.INFO # Determine log file path from env (setup_logging handles "none") import os log_file = os.getenv("LOG_FILE", "./cache/logs/exporter.log") setup_logging(level=level, log_file=log_file, no_log_file=no_log_file) ctx.obj["debug"] = debug ctx.obj["verbose"] = verbose # Initialise cache (needed for ToS gate on every command) import os cache_dir = Path(os.getenv("CACHE_DIR", "./cache")).expanduser() try: cache = Cache(cache_dir) except CacheError as e: err_console.print(f"[red]Cache error: {e}[/red]") sys.exit(1) ctx.obj["cache"] = cache # ToS gate: must happen before any command executes if not cache.is_tos_acknowledged(): try: answer = click.prompt(TOS_NOTICE, default="", show_default=False).strip().lower() except (click.Abort, KeyboardInterrupt): console.print("\n[yellow]Exiting.[/yellow]") sys.exit(0) if answer != "yes": console.print("[yellow]You must type 'yes' to proceed. Exiting.[/yellow]") sys.exit(0) cache.acknowledge_tos() console.print("[green]Acknowledged. Proceeding.[/green]\n") # ────────────────────────────────────────────────────────────────────────────── # auth command # ────────────────────────────────────────────────────────────────────────────── @cli.command() @click.pass_context def auth(ctx: click.Context) -> None: """Interactive setup wizard for session tokens. Guides you through finding and saving your ChatGPT and Claude session tokens. Tokens are never echoed to the terminal. Token lifetimes: ChatGPT (__Secure-next-auth.session-token): ~7 days (JWT) Claude (sessionKey): ~30 days (opaque string) """ os_name = platform.system() console.print("\n[bold cyan]AI Chat Exporter — Token Setup Wizard[/bold cyan]\n") console.print("Session tokens let this tool access your chat history without your password.") console.print("They are stored in browser cookies and have limited lifetimes.\n") # Offer choice of provider to configure providers = click.prompt( "Which provider(s) do you want to configure? [chatgpt/claude/both]", default="both", ).strip().lower() configure_chatgpt = providers in ("chatgpt", "both") configure_claude = providers in ("claude", "both") if configure_chatgpt: _auth_chatgpt(os_name) if configure_claude: _auth_claude(os_name) console.print("\n[green]Done! Run 'ai-chat-exporter doctor' to verify your setup.[/green]") def _auth_chatgpt(os_name: str) -> None: import jwt as pyjwt console.print("\n[bold]─── ChatGPT ───[/bold]") console.print("1. Open [link=https://chatgpt.com]https://chatgpt.com[/link] and log in.") if os_name == "Darwin": console.print("2. Press [bold]Cmd+Option+I[/bold] to open DevTools → Application tab.") else: console.print("2. Press [bold]F12[/bold] to open DevTools → Application tab.") console.print("3. Expand [bold]Cookies[/bold] → [bold]https://chatgpt.com[/bold]") console.print("4. Find [bold]__Secure-next-auth.session-token[/bold] → copy the Value.") console.print(" (Token starts with 'eyJ...' — it is a long JWT string)") console.print("5. Paste it below (input is hidden).\n") token = click.prompt("ChatGPT session token", hide_input=True, default="", show_default=False).strip() if not token: console.print("[yellow]Skipped ChatGPT token.[/yellow]") return # Validate if not token.startswith("eyJ"): console.print("[yellow]Warning: token doesn't look like a JWT (expected 'eyJ...').[/yellow]") expiry_str = "" try: payload = pyjwt.decode(token, options={"verify_signature": False}) exp = payload.get("exp") if exp: from datetime import timezone expiry = datetime.fromtimestamp(exp, tz=timezone.utc) expiry_str = expiry.strftime("%Y-%m-%d %H:%M UTC") console.print(f"[green]Token decoded — expires: {expiry_str}[/green]") except Exception: console.print("[yellow]Could not decode token expiry.[/yellow]") # Live validation — exchange session token for an access token _valid = False _error: str | None = None with console.status("[dim]Validating token with ChatGPT API…[/dim]"): try: from src.providers.chatgpt import ChatGPTProvider _prov = ChatGPTProvider(session_token=token) _prov._fetch_access_token() _valid = True except ProviderError as e: _error = str(e.original) except Exception as e: _error = str(e) if _valid: console.print("[green]✓ Token verified — connected to ChatGPT API.[/green]") else: console.print(f"[red]✗ Token validation failed: {_error}[/red]") _write_token_to_env("CHATGPT_SESSION_TOKEN", token) # --- ChatGPT Projects --- console.print("\n[bold]ChatGPT Projects (optional)[/bold]") console.print( "Project conversations are stored separately and are not included in the\n" "default conversation listing. To export them, you need each project's ID.\n" ) console.print("How to find a project ID:") console.print(" 1. Open ChatGPT and click into a Project in the left sidebar.") console.print(" 2. Look at the browser URL — it will look like:") console.print(" [dim]https://chatgpt.com/g/[bold]g-p-68c2b2b3037c8191890036fb4ae3ed9f[/bold]-my-project/project[/dim]") console.print(" 3. Copy the part starting with [bold]g-p-[/bold] up to (but not including) the slug.") console.print(" Enter multiple IDs separated by commas. Leave blank to skip.\n") project_ids_raw = click.prompt( "ChatGPT project IDs (comma-separated, e.g. g-p-xxx,g-p-yyy)", default="", show_default=False, ).strip() if project_ids_raw: ids = [pid.strip() for pid in project_ids_raw.split(",") if pid.strip()] valid = [pid for pid in ids if pid.startswith("g-p-")] invalid = [pid for pid in ids if not pid.startswith("g-p-")] if invalid: console.print(f"[yellow]Warning: skipping IDs that don't start with 'g-p-': {invalid}[/yellow]") if valid: _write_token_to_env("CHATGPT_PROJECT_IDS", ",".join(valid)) console.print(f"[green]Saved {len(valid)} project ID(s).[/green]") else: console.print("[yellow]No valid project IDs — skipping.[/yellow]") else: console.print("[dim]Skipped project IDs.[/dim]") def _auth_claude(os_name: str) -> None: console.print("\n[bold]─── Claude ───[/bold]") console.print("1. Open [link=https://claude.ai]https://claude.ai[/link] and log in.") if os_name == "Darwin": console.print("2. Press [bold]Cmd+Option+I[/bold] to open DevTools → Application tab.") else: console.print("2. Press [bold]F12[/bold] to open DevTools → Application tab.") console.print("3. Expand [bold]Cookies[/bold] → [bold]https://claude.ai[/bold]") console.print("4. Find [bold]sessionKey[/bold] → copy the Value.") console.print(" (Note: Claude tokens expire after ~30 days; a 401 error is the only signal.)") console.print("5. Paste it below (input is hidden).\n") key = click.prompt("Claude session key", hide_input=True, default="", show_default=False).strip() if not key: console.print("[yellow]Skipped Claude token.[/yellow]") return # Live validation — fetch org ID (the first call any Claude operation makes) _valid = False _error: str | None = None with console.status("[dim]Validating token with Claude API…[/dim]"): try: from src.providers.claude import ClaudeProvider _prov = ClaudeProvider(session_key=key) _prov._get_org_id() _valid = True except ProviderError as e: _error = str(e.original) except Exception as e: _error = str(e) if _valid: console.print("[green]✓ Token verified — connected to Claude API.[/green]") else: console.print(f"[red]✗ Token validation failed: {_error}[/red]") _write_token_to_env("CLAUDE_SESSION_KEY", key) def _write_token_to_env(key: str, value: str) -> None: """Write or update a key in .env, offering to create the file if it doesn't exist.""" env_path = Path(".env") if click.confirm(f"Write {key} to .env?", default=True): if not env_path.exists(): # Create from example if available example = Path(".env.example") if example.exists(): import shutil as _shutil _shutil.copy2(example, env_path) console.print("[dim]Created .env from .env.example[/dim]") else: env_path.touch() lines = env_path.read_text(encoding="utf-8").splitlines(keepends=True) updated = False new_lines = [] for line in lines: if line.startswith(f"{key}=") or line.startswith(f"{key} ="): new_lines.append(f"{key}={value}\n") updated = True else: new_lines.append(line) if not updated: new_lines.append(f"\n{key}={value}\n") env_path.write_text("".join(new_lines), encoding="utf-8") import os os.chmod(env_path, 0o600) console.print(f"[green]{key} written to .env (permissions: 600)[/green]") # ────────────────────────────────────────────────────────────────────────────── # doctor command # ────────────────────────────────────────────────────────────────────────────── @cli.command() @click.pass_context def doctor(ctx: click.Context) -> None: """Validate configuration and API connectivity. Checks token presence, format, expiry, directory permissions, disk space, and live API reachability. Exits with code 1 if any checks fail. """ checks = _run_doctor_checks() _print_doctor_table(checks) if any(not c["pass"] for c in checks): sys.exit(1) def _run_doctor_checks() -> list[dict]: """Run all doctor checks and return results.""" import os import jwt as pyjwt from datetime import timezone from dotenv import load_dotenv # Load .env so doctor works without the user having to export vars manually load_dotenv(override=False) checks = [] def add(name: str, passed: bool, detail: str = "") -> None: checks.append({"name": name, "pass": passed, "detail": detail}) # Token presence chatgpt_token = os.getenv("CHATGPT_SESSION_TOKEN", "").strip() or None claude_key = os.getenv("CLAUDE_SESSION_KEY", "").strip() or None add("CHATGPT_SESSION_TOKEN set", bool(chatgpt_token), "" if chatgpt_token else "Set in .env or run 'auth'") add("CLAUDE_SESSION_KEY set", bool(claude_key), "" if claude_key else "Set in .env or run 'auth'") # ChatGPT token format + expiry if chatgpt_token: is_jwt = chatgpt_token.startswith("eyJ") add("ChatGPT token is valid JWT", is_jwt, "" if is_jwt else "Expected token starting with 'eyJ'") if is_jwt: try: payload = pyjwt.decode(chatgpt_token, options={"verify_signature": False}) exp = payload.get("exp") if exp: expiry = datetime.fromtimestamp(exp, tz=timezone.utc) now = datetime.now(tz=timezone.utc) delta = expiry - now detail = f"Expires {expiry.strftime('%Y-%m-%d %H:%M UTC')} ({delta.days}d)" ok = delta.total_seconds() > 0 add("ChatGPT token not expired", ok, detail) if ok and delta.total_seconds() < 86400: add("ChatGPT token expiry warning", False, "Expires in < 24h — refresh soon") else: add("ChatGPT token expiry", False, "JWT has no 'exp' claim") except pyjwt.exceptions.DecodeError: # JWE (encrypted JWT) — cannot decode without the server key. # This is normal for ChatGPT's current token format. Token is present and valid. add("ChatGPT token expiry", True, "Encrypted token (JWE) — expiry not decodable client-side") # Claude key if claude_key: add("Claude session key non-empty", True, "Expiry cannot be decoded (opaque token)") # Directories export_dir = Path(os.getenv("EXPORT_DIR", "./exports")).expanduser() cache_dir = Path(os.getenv("CACHE_DIR", "./cache")).expanduser() for label, dirpath in [("Export dir writable", export_dir), ("Cache dir writable", cache_dir)]: try: dirpath.mkdir(parents=True, exist_ok=True) test = dirpath / ".doctor_write_test" test.touch() test.unlink() add(label, True, str(dirpath)) except OSError as e: add(label, False, str(e)) # Disk space try: usage = shutil.disk_usage(export_dir if export_dir.exists() else Path(".")) free_mb = usage.free // (1024 * 1024) add("Disk space (≥100MB free)", free_mb >= 100, f"{free_mb}MB free") except OSError as e: add("Disk space check", False, str(e)) # API reachability if chatgpt_token: try: from src.providers.chatgpt import ChatGPTProvider p = ChatGPTProvider(chatgpt_token) results = p.list_conversations(offset=0, limit=1) add("ChatGPT API reachable", True, f"Got {len(results)} result(s)") except ProviderError as e: add("ChatGPT API reachable", False, str(e.original)[:80]) except Exception as e: add("ChatGPT API reachable", False, str(e)[:80]) if claude_key: try: from src.providers.claude import ClaudeProvider p = ClaudeProvider(claude_key) results = p.list_conversations(offset=0, limit=1) add("Claude API reachable", True, f"Got {len(results)} result(s)") except ProviderError as e: add("Claude API reachable", False, str(e.original)[:80]) except Exception as e: add("Claude API reachable", False, str(e)[:80]) return checks def _print_doctor_table(checks: list[dict]) -> None: table = Table(title="Doctor Check Results", show_header=True) table.add_column("Check", style="bold") table.add_column("Status", justify="center") table.add_column("Detail") for c in checks: status = "[green]✓ PASS[/green]" if c["pass"] else "[red]✗ FAIL[/red]" table.add_row(c["name"], status, c.get("detail", "")) console.print(table) # ────────────────────────────────────────────────────────────────────────────── # export command # ────────────────────────────────────────────────────────────────────────────── @cli.command() @click.option( "--provider", type=click.Choice(["chatgpt", "claude", "all"], case_sensitive=False), default="all", show_default=True, help="Which provider to export.", ) @click.option( "--format", "fmt", type=click.Choice(["markdown", "json", "both"], case_sensitive=False), default="markdown", show_default=True, help="Output format.", ) @click.option( "--output", "output_dir", default=None, type=click.Path(), help="Override EXPORT_DIR.", ) @click.option( "--since", default=None, help="Only export conversations updated after this date (YYYY-MM-DD).", ) @click.option( "--project", "project_filter", default=None, help=( "Only export conversations in a matching project (case-insensitive substring). " "Use 'none' for conversations outside any project." ), ) @click.option("--dry-run", is_flag=True, help="Show what would be exported without writing anything.") @click.pass_context def export( ctx: click.Context, provider: str, fmt: str, output_dir: str | None, since: str | None, project_filter: str | None, dry_run: bool, ) -> None: """Export new and updated conversations to Markdown or JSON. Every run is automatically resumable — only new or updated conversations are exported. Re-running the same command after an interruption will pick up exactly where it left off. """ debug = ctx.obj.get("debug", False) cache: Cache = ctx.obj["cache"] # Load config (may raise ConfigError) try: from src.config import load_config cfg = _load_config_or_exit(debug) except SystemExit: return # First-run: auto-doctor if not cache.last_run(): console.print("[dim]First run — checking configuration…[/dim]") checks = _run_doctor_checks() _print_doctor_table(checks) if any(not c["pass"] for c in checks): err_console.print( "[red]Doctor checks failed. Fix the issues above before exporting.[/red]" ) sys.exit(1) export_base = Path(output_dir).expanduser() if output_dir else cfg.export_dir structure = cfg.output_structure # Resolve since datetime since_dt: datetime | None = None if since: try: since_dt = datetime.fromisoformat(since) except ValueError: err_console.print(f"[red]Invalid --since date: '{since}'. Use YYYY-MM-DD.[/red]") sys.exit(1) # Determine which providers to run providers_to_run = _resolve_providers(provider, cfg) if not providers_to_run: err_console.print( "[red]No providers configured. Run 'ai-chat-exporter auth' to set up tokens.[/red]" ) sys.exit(1) # Build exporters from src.exporters.markdown import MarkdownExporter from src.exporters.json_export import JSONExporter md_exporter = MarkdownExporter(export_base, structure) if fmt in ("markdown", "both") else None json_exporter = JSONExporter(export_base, structure) if fmt in ("json", "both") else None # Summary counters summary: dict[str, dict[str, int]] = {} for prov_name, prov_instance in providers_to_run: summary[prov_name] = {"exported": 0, "skipped": 0, "failed": 0} console.print(f"\n[bold cyan][{prov_name.upper()}][/bold cyan] Fetching conversation list…") try: all_convs = prov_instance.fetch_all_conversations(since=since_dt) except ProviderError as e: _handle_provider_error(e, debug) summary[prov_name]["failed"] += len(all_convs) if "all_convs" in dir() else 0 continue if project_filter is not None: all_convs = _filter_by_project(all_convs, project_filter) console.print( f" [dim]--project filter '{project_filter}': {len(all_convs)} matching conversations.[/dim]" ) to_export = cache.get_new_or_updated(prov_name, all_convs) skipped = len(all_convs) - len(to_export) summary[prov_name]["skipped"] = skipped if dry_run: _print_dry_run_table(prov_name, to_export, prov_instance, export_base, structure, skipped) continue if not to_export: console.print(f" [dim]{skipped} conversations already up to date.[/dim]") continue console.print(f" [dim]{len(to_export)} to export, {skipped} already up to date.[/dim]") from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TaskProgressColumn(), console=console, ) as progress: task = progress.add_task(f"Exporting {prov_name}…", total=len(to_export)) for raw_conv in to_export: conv_id = raw_conv.get("id") or raw_conv.get("uuid", "unknown") try: full_raw = prov_instance.get_conversation(conv_id) # Propagate provider annotations from the listing summary # (e.g. _project_name set by ChatGPT project fetching) into # the full detail so normalize_conversation can use them. for key, val in raw_conv.items(): if key.startswith("_") and key not in full_raw: full_raw[key] = val normalized = prov_instance.normalize_conversation(full_raw) exported_path: Path | None = None if md_exporter: exported_path = md_exporter.export(normalized) if json_exporter: exported_path = json_exporter.export(normalized) # Write to cache immediately after successful export cache.mark_exported(prov_name, conv_id, { "title": normalized.get("title", ""), "project": normalized.get("project"), "updated_at": normalized.get("updated_at", ""), "file_path": str(exported_path) if exported_path else "", }) summary[prov_name]["exported"] += 1 progress.advance(task) except ProviderError as e: logger.error("Failed to export conversation %s: %s", conv_id[:8], e) summary[prov_name]["failed"] += 1 progress.advance(task) continue except OSError as e: logger.error("File write failed for conversation %s: %s", conv_id[:8], e) summary[prov_name]["failed"] += 1 progress.advance(task) continue if not dry_run: _print_export_summary(summary) def _resolve_providers(provider: str, cfg) -> list[tuple[str, object]]: """Return (name, instance) pairs for configured providers.""" result = [] def try_add(prov_name: str, token: str | None, provider_cls): if not token: if provider == prov_name or provider == "all": logging.getLogger(__name__).warning( "[%s] Skipping — token not configured.", prov_name ) return try: result.append((prov_name, provider_cls(token))) except ProviderError as e: logging.getLogger(__name__).warning( "[%s] Could not initialise provider: %s", prov_name, e ) from src.providers.chatgpt import ChatGPTProvider from src.providers.claude import ClaudeProvider if provider in ("chatgpt", "all"): if cfg.chatgpt_session_token: try: result.append(( "chatgpt", ChatGPTProvider( session_token=cfg.chatgpt_session_token, project_ids=cfg.chatgpt_project_ids, ), )) except ProviderError as e: logging.getLogger(__name__).warning( "[chatgpt] Could not initialise provider: %s", e ) elif provider == "chatgpt" or provider == "all": logging.getLogger(__name__).warning("[chatgpt] Skipping — token not configured.") if provider in ("claude", "all"): try_add("claude", cfg.claude_session_key, ClaudeProvider) return result def _print_dry_run_table(prov_name, to_export, prov_instance, export_base, structure, skipped): from src.utils import generate_filename, build_export_path table = Table(title=f"[DRY RUN] {prov_name.upper()} — Would export {len(to_export)} conversations") table.add_column("Title") table.add_column("Project") table.add_column("Destination") table.add_column("Updated") for conv in to_export[:50]: # cap display at 50 title = conv.get("title") or "Untitled" project = conv.get("project_title") or conv.get("project", {}) if isinstance(project, dict): project = project.get("name", "") project = project or None updated = (conv.get("updated_at") or conv.get("update_time") or "")[:10] created = conv.get("created_at") or conv.get("create_time") or "" conv_id = conv.get("id") or conv.get("uuid", "") filename = generate_filename(title, conv_id, created or "2000-01-01") dest = build_export_path(export_base, prov_name, project, created or "2000-01-01", filename, structure) table.add_row(title[:50], str(project or "no-project"), str(dest), updated) if len(to_export) > 50: table.add_row(f"… and {len(to_export) - 50} more", "", "", "") console.print(table) console.print(f" [dim]{skipped} conversations already cached (would be skipped).[/dim]") def _raw_project_name(conv: dict) -> str | None: """Extract the project name from a raw conversation summary dict. Handles both ChatGPT (annotated _project_name) and Claude (project dict). """ # ChatGPT: annotated during fetch_all_conversations if "_project_name" in conv: return conv["_project_name"] or None # Claude: project is a dict with a 'name' key, or a plain string project = conv.get("project") if isinstance(project, dict): return project.get("name") or None if isinstance(project, str): return project or None return None def _filter_by_project(convs: list[dict], project_filter: str) -> list[dict]: """Filter conversations by project name. project_filter='none' → keep only conversations with no project. Otherwise → case-insensitive substring match on the project name. """ want_none = project_filter.lower() == "none" needle = project_filter.lower() result = [] for conv in convs: name = _raw_project_name(conv) if want_none: if name is None: result.append(conv) else: if name and needle in name.lower(): result.append(conv) return result def _print_export_summary(summary: dict[str, dict[str, int]]) -> None: table = Table(title="Export Summary") table.add_column("Provider", style="bold") table.add_column("Exported", justify="right") table.add_column("Skipped", justify="right") table.add_column("Failed", justify="right") for prov, counts in summary.items(): table.add_row( prov.capitalize(), str(counts["exported"]), str(counts["skipped"]), f"[red]{counts['failed']}[/red]" if counts["failed"] else "0", ) console.print(table) # ────────────────────────────────────────────────────────────────────────────── # list command # ────────────────────────────────────────────────────────────────────────────── @cli.command(name="list") @click.option( "--provider", type=click.Choice(["chatgpt", "claude", "all"], case_sensitive=False), default="all", show_default=True, ) @click.option( "--project", "project_filter", default=None, help=( "Only list conversations in a matching project (case-insensitive substring). " "Use 'none' for conversations outside any project." ), ) @click.pass_context def list_conversations(ctx: click.Context, provider: str, project_filter: str | None) -> None: """List conversations without exporting them.""" debug = ctx.obj.get("debug", False) cfg = _load_config_or_exit(debug) providers_to_run = _resolve_providers(provider, cfg) for prov_name, prov_instance in providers_to_run: console.print(f"\n[bold cyan][{prov_name.upper()}][/bold cyan]") try: all_convs = prov_instance.fetch_all_conversations() except ProviderError as e: _handle_provider_error(e, debug) continue if project_filter is not None: all_convs = _filter_by_project(all_convs, project_filter) # no_wrap + overflow="ellipsis" prevents Rich from wrapping cells to # multiple lines on narrow terminals (e.g. Windows Command Prompt), # which can otherwise make the output look garbled. Widths are tuned # to fit within an 80-column terminal. # Total width budget for 80-column terminals: # borders (5) + padding (4 cols * 2) = 13 chars of overhead # remaining 67 chars split: 34 title + 15 project + 10 date + 8 id table = Table(show_lines=False, expand=False, padding=(0, 1)) table.add_column("Title", no_wrap=True, overflow="ellipsis", max_width=34) table.add_column("Project", no_wrap=True, overflow="ellipsis", max_width=15) table.add_column("Updated", no_wrap=True, min_width=10) table.add_column("ID", no_wrap=True, min_width=8) for conv in all_convs: # ChatGPT uses "title"; Claude uses "name". title = conv.get("title") or conv.get("name") or "Untitled" project = _raw_project_name(conv) or "" updated = (conv.get("updated_at") or conv.get("update_time") or "")[:10] conv_id = (conv.get("id") or conv.get("uuid") or "")[:8] table.add_row(title, project, updated, conv_id) console.print(table) console.print(f"Total: {len(all_convs)} conversations") # ────────────────────────────────────────────────────────────────────────────── # cache command # ────────────────────────────────────────────────────────────────────────────── @cli.command() @click.option("--show", is_flag=True, help="Show cache statistics.") @click.option("--clear", is_flag=True, help="Clear cached entries.") @click.option( "--provider", type=click.Choice(["chatgpt", "claude", "all"], case_sensitive=False), default="all", help="Provider to target (used with --clear).", ) @click.pass_context def cache(ctx: click.Context, show: bool, clear: bool, provider: str) -> None: """Manage the local export cache (sync manifest).""" cache_obj: Cache = ctx.obj["cache"] if show: stats = cache_obj.stats() table = Table(title="Cache Statistics") table.add_column("Provider", style="bold") table.add_column("Cached Conversations", justify="right") for prov, count in stats.items(): table.add_row(prov.capitalize(), str(count)) last = cache_obj.last_run() console.print(table) console.print(f"Last run: {last or 'never'}") if clear: prov_arg = None if provider == "all" else provider if click.confirm(f"Clear cache for {'all providers' if prov_arg is None else prov_arg}?"): cache_obj.clear(prov_arg) console.print("[green]Cache cleared.[/green]") if not show and not clear: console.print("Specify --show or --clear. Use --help for options.") # ────────────────────────────────────────────────────────────────────────────── # joplin command # ────────────────────────────────────────────────────────────────────────────── @cli.command() @click.option( "--provider", type=click.Choice(["chatgpt", "claude", "all"], case_sensitive=False), default="all", show_default=True, help="Which provider's conversations to sync to Joplin.", ) @click.option( "--project", "project_filter", default=None, help=( "Only sync conversations in a matching project (case-insensitive substring). " "Use 'none' for conversations outside any project." ), ) @click.option("--dry-run", is_flag=True, help="Show what would be synced without sending anything to Joplin.") @click.pass_context def joplin(ctx: click.Context, provider: str, project_filter: str | None, dry_run: bool) -> None: """Sync exported conversations to Joplin as notes. Reads the local export cache and pushes exported Markdown files to Joplin via its local REST API. Requires Joplin desktop to be running with the Web Clipper service enabled. Notebooks are created automatically based on provider and project: exports/chatgpt/my-project/ → "ChatGPT - My Project" notebook exports/claude/no-project/ → "Claude - No Project" notebook Re-running is safe: notes are updated (not duplicated) on subsequent runs. Setup: 1. Open Joplin desktop. 2. Go to Tools → Options → Web Clipper. 3. Enable the Web Clipper service. 4. Copy the Authorization token. 5. Set JOPLIN_API_TOKEN= in your .env file. """ debug = ctx.obj.get("debug", False) cache_obj: Cache = ctx.obj["cache"] cfg = _load_config_or_exit(debug) if not cfg.joplin_api_token: err_console.print( "[red]JOPLIN_API_TOKEN is not set.[/red]\n" " 1. Open Joplin → Tools → Options → Web Clipper.\n" " 2. Enable the Web Clipper service.\n" " 3. Copy the Authorization token.\n" " 4. Add [bold]JOPLIN_API_TOKEN=[/bold] to your .env file." ) sys.exit(1) from src.joplin import JoplinClient, JoplinError, notebook_title client = JoplinClient(cfg.joplin_api_url, cfg.joplin_api_token) if not dry_run: console.print(f"[dim]Connecting to Joplin at {cfg.joplin_api_url}…[/dim]") try: if not client.ping(): err_console.print( "[red]Joplin is not responding.[/red] " "Make sure Joplin desktop is open and Web Clipper is enabled." ) sys.exit(1) # Ping succeeded but doesn't validate the token — check auth separately client.validate_token() except JoplinError as e: err_console.print(f"[red]Joplin connection error:[/red] {e}") sys.exit(1) console.print("[green]Joplin connected and token validated.[/green]") # Determine which providers to process providers_to_sync: list[str] = [] if provider in ("chatgpt", "all"): providers_to_sync.append("chatgpt") if provider in ("claude", "all"): providers_to_sync.append("claude") summary: dict[str, dict[str, int]] = {} for prov_name in providers_to_sync: summary[prov_name] = {"created": 0, "updated": 0, "skipped": 0, "failed": 0} pending = cache_obj.get_joplin_pending(prov_name) logger.debug("[joplin] %s: %d pending before filter", prov_name, len(pending)) # Apply --project filter against the cached entry's project field if project_filter is not None: want_none = project_filter.lower() == "none" needle = project_filter.lower() filtered = [] for conv_id, entry in pending: proj = entry.get("project") or None if want_none: if proj is None or proj == "no-project": filtered.append((conv_id, entry)) else: if proj and needle in proj.lower(): filtered.append((conv_id, entry)) logger.debug( "[joplin] %s: --project %r filtered %d → %d", prov_name, project_filter, len(pending), len(filtered), ) pending = filtered if not pending: console.print(f"\n[bold cyan][{prov_name.upper()}][/bold cyan] All up to date — nothing to sync.") continue console.print( f"\n[bold cyan][{prov_name.upper()}][/bold cyan] " f"{len(pending)} conversation(s) to sync to Joplin." ) if dry_run: _print_joplin_dry_run_table(prov_name, pending) continue from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TaskProgressColumn(), console=console, ) as progress: task = progress.add_task(f"Syncing {prov_name}…", total=len(pending)) for conv_id, entry in pending: file_path = entry.get("file_path", "") title = entry.get("title") or "Untitled" project = entry.get("project") or None existing_note_id = entry.get("joplin_note_id") action = "update" if existing_note_id else "create" logger.debug( "[joplin] %s %s/%s: %s (file=%s)", action, prov_name, conv_id[:8], title[:60], file_path, ) try: # Read the exported Markdown file body = Path(file_path).read_text(encoding="utf-8") logger.debug("[joplin] Read %d chars from %s", len(body), file_path) # Get or create the notebook nb_title = notebook_title(prov_name, project) notebook_id = client.get_or_create_notebook(nb_title) if existing_note_id: client.update_note(existing_note_id, title, body) cache_obj.mark_joplin_synced(prov_name, conv_id, existing_note_id) summary[prov_name]["updated"] += 1 else: note_id = client.create_note(title, body, notebook_id) cache_obj.mark_joplin_synced(prov_name, conv_id, note_id) summary[prov_name]["created"] += 1 except FileNotFoundError: logger.warning( "[joplin] Skipping %s/%s — exported file not found: %s", prov_name, conv_id[:8], file_path, ) summary[prov_name]["skipped"] += 1 except JoplinError as e: logger.error( "[joplin] Failed to %s note for %s/%s: %s", action, prov_name, conv_id[:8], e, ) summary[prov_name]["failed"] += 1 except OSError as e: logger.error( "[joplin] File read error for %s/%s (%s): %s", prov_name, conv_id[:8], file_path, e, ) summary[prov_name]["failed"] += 1 finally: progress.advance(task) if not dry_run: _print_joplin_summary(summary) def _print_joplin_dry_run_table(prov_name: str, pending: list[tuple[str, dict]]) -> None: from src.joplin import notebook_title table = Table(title=f"[DRY RUN] {prov_name.upper()} — Would sync {len(pending)} conversation(s)") table.add_column("Title") table.add_column("Project") table.add_column("Notebook") table.add_column("Action") for conv_id, entry in pending[:50]: title = entry.get("title") or "Untitled" project = entry.get("project") or "no-project" nb = notebook_title(prov_name, entry.get("project")) action = "update" if entry.get("joplin_note_id") else "create" table.add_row(title[:50], project[:30], nb, action) if len(pending) > 50: table.add_row(f"… and {len(pending) - 50} more", "", "", "") console.print(table) def _print_joplin_summary(summary: dict[str, dict[str, int]]) -> None: table = Table(title="Joplin Sync Summary") table.add_column("Provider", style="bold") table.add_column("Created", justify="right") table.add_column("Updated", justify="right") table.add_column("Skipped", justify="right") table.add_column("Failed", justify="right") for prov, counts in summary.items(): table.add_row( prov.capitalize(), str(counts["created"]), str(counts["updated"]), str(counts["skipped"]), f"[red]{counts['failed']}[/red]" if counts["failed"] else "0", ) console.print(table) # ────────────────────────────────────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────────────────────────────────────── def _load_config_or_exit(debug: bool): """Load config, printing a clean error on failure.""" from src.config import load_config try: return load_config() except ConfigError as e: err_console.print(f"[red]Configuration error:[/red] {e}") if debug: traceback.print_exc() sys.exit(1) def _handle_provider_error(e: ProviderError, debug: bool) -> None: err_console.print(f"[red]Provider error ({e.provider_name}/{e.operation}):[/red] {e.original}") if debug: traceback.print_exc() if __name__ == "__main__": cli()