Claude's list endpoint returns conversations with a `name` field rather than `title`, so every Claude row was falling through to "Untitled". Also set no_wrap + ellipsis overflow and tune column widths so the table renders one row per conversation in Windows Command Prompt (80 cols). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1127 lines
45 KiB
Python
1127 lines
45 KiB
Python
"""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=<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=<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()
|