claude-code-tools 1.0.6__py3-none-any.whl → 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. claude_code_tools/__init__.py +1 -1
  2. claude_code_tools/action_rpc.py +16 -10
  3. claude_code_tools/aichat.py +793 -51
  4. claude_code_tools/claude_continue.py +4 -0
  5. claude_code_tools/codex_continue.py +48 -0
  6. claude_code_tools/export_session.py +9 -5
  7. claude_code_tools/find_claude_session.py +36 -12
  8. claude_code_tools/find_codex_session.py +33 -18
  9. claude_code_tools/find_session.py +30 -16
  10. claude_code_tools/gdoc2md.py +220 -0
  11. claude_code_tools/md2gdoc.py +549 -0
  12. claude_code_tools/search_index.py +83 -9
  13. claude_code_tools/session_menu_cli.py +1 -1
  14. claude_code_tools/session_utils.py +3 -3
  15. claude_code_tools/smart_trim.py +18 -8
  16. claude_code_tools/smart_trim_core.py +4 -2
  17. claude_code_tools/tmux_cli_controller.py +35 -25
  18. claude_code_tools/trim_session.py +28 -2
  19. claude_code_tools-1.4.1.dist-info/METADATA +1113 -0
  20. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/RECORD +30 -24
  21. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/entry_points.txt +2 -0
  22. docs/local-llm-setup.md +286 -0
  23. docs/reddit-aichat-resume-v2.md +80 -0
  24. docs/reddit-aichat-resume.md +29 -0
  25. docs/reddit-aichat.md +79 -0
  26. docs/rollover-details.md +67 -0
  27. node_ui/action_config.js +3 -3
  28. node_ui/menu.js +67 -113
  29. claude_code_tools/session_tui.py +0 -516
  30. claude_code_tools-1.0.6.dist-info/METADATA +0 -685
  31. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/WHEEL +0 -0
  32. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -192,6 +192,10 @@ def claude_continue(
192
192
  try:
193
193
  # Parse first line and add metadata fields
194
194
  first_line_data = json.loads(lines[0])
195
+ # Remove trim_metadata if present - a session is either continued
196
+ # OR trimmed, not both. continue_metadata.parent_session_file
197
+ # preserves ancestry.
198
+ first_line_data.pop("trim_metadata", None)
195
199
  first_line_data.update(metadata_fields)
196
200
  lines[0] = json.dumps(first_line_data) + "\n"
197
201
 
@@ -11,12 +11,14 @@ the context limit. It:
11
11
  """
12
12
 
13
13
  import argparse
14
+ import datetime
14
15
  import json
15
16
  import os
16
17
  import re
17
18
  import shlex
18
19
  import subprocess
19
20
  import sys
21
+ from datetime import timezone
20
22
  from pathlib import Path
21
23
  from typing import List, Optional
22
24
 
@@ -24,6 +26,7 @@ from claude_code_tools.export_codex_session import resolve_session_path
24
26
  from claude_code_tools.session_utils import (
25
27
  build_rollover_prompt,
26
28
  build_session_file_list,
29
+ get_session_uuid,
27
30
  )
28
31
 
29
32
 
@@ -177,6 +180,51 @@ def codex_continue(
177
180
  print(f"❌ Error: {e}", file=sys.stderr)
178
181
  sys.exit(1)
179
182
 
183
+ # Inject continue_metadata into new session file
184
+ try:
185
+ new_session_file = resolve_session_path(thread_id, codex_home)
186
+
187
+ # Create metadata
188
+ metadata_fields = {
189
+ "continue_metadata": {
190
+ "parent_session_id": get_session_uuid(session_file.name),
191
+ "parent_session_file": str(session_file.absolute()),
192
+ "continued_at": datetime.datetime.now(timezone.utc).isoformat(),
193
+ }
194
+ }
195
+
196
+ # Read the new session file and modify first line
197
+ if new_session_file.exists():
198
+ with open(new_session_file, "r") as f:
199
+ lines = f.readlines()
200
+
201
+ if lines:
202
+ try:
203
+ # Parse first line and add metadata fields
204
+ first_line_data = json.loads(lines[0])
205
+ # Remove trim_metadata if present - a session is either continued
206
+ # OR trimmed, not both. continue_metadata.parent_session_file
207
+ # preserves ancestry.
208
+ first_line_data.pop("trim_metadata", None)
209
+ first_line_data.update(metadata_fields)
210
+ lines[0] = json.dumps(first_line_data) + "\n"
211
+
212
+ # Write back the modified file
213
+ with open(new_session_file, "w") as f:
214
+ f.writelines(lines)
215
+
216
+ if verbose:
217
+ print(f"✅ Added continue_metadata to new session")
218
+ print()
219
+ except json.JSONDecodeError:
220
+ # If first line is malformed, skip adding metadata
221
+ if verbose:
222
+ print(f"⚠️ Could not add metadata (first line malformed)", file=sys.stderr)
223
+ except Exception as e:
224
+ # Don't fail the whole operation if metadata injection fails
225
+ if verbose:
226
+ print(f"⚠️ Could not add continue_metadata: {e}", file=sys.stderr)
227
+
180
228
  # Step 3: Resume in interactive mode - hand off to Codex
181
229
  # Resume with default model (not the mini model used for analysis)
182
230
  from claude_code_tools.config import codex_default_model
@@ -216,6 +216,11 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
216
216
  Returns:
217
217
  Dict with extracted metadata
218
218
  """
219
+ # Detect sidechain from filename pattern (agent-* prefix)
220
+ # This is more reliable than checking isSidechain field in JSON,
221
+ # which can be set on individual messages within main sessions
222
+ is_sidechain = session_file.name.startswith("agent-")
223
+
219
224
  metadata: dict[str, Any] = {
220
225
  "session_id": session_file.stem,
221
226
  "agent": agent,
@@ -223,7 +228,7 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
223
228
  "cwd": None,
224
229
  "branch": None,
225
230
  "derivation_type": None,
226
- "is_sidechain": False,
231
+ "is_sidechain": is_sidechain,
227
232
  "session_type": None, # "helper" for SDK/headless sessions
228
233
  "parent_session_id": None,
229
234
  "parent_session_file": None,
@@ -278,10 +283,6 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
278
283
  metadata["parent_session_id"] = cm.get("parent_session_id")
279
284
  metadata["parent_session_file"] = cm.get("parent_session_file")
280
285
 
281
- # Check if sidechain (sub-agent session)
282
- if "isSidechain" in data and data["isSidechain"] is True:
283
- metadata["is_sidechain"] = True
284
-
285
286
  # Extract sessionType (e.g., "helper" for SDK/headless sessions)
286
287
  if "sessionType" in data and metadata["session_type"] is None:
287
288
  metadata["session_type"] = data["sessionType"]
@@ -321,6 +322,9 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
321
322
  except (OSError, IOError):
322
323
  pass
323
324
 
325
+ # Note: customTitle extraction is done in search_index.py's _extract_session_content
326
+ # during the single-pass content extraction, to avoid an extra file scan here.
327
+
324
328
  # Get modified time from last JSONL entry's timestamp (reflects actual session
325
329
  # activity, portable across machines). Fall back to file mtime if not found.
326
330
  last_timestamp = _get_last_line_timestamp(session_file)
@@ -92,12 +92,6 @@ def prompt_post_action() -> str:
92
92
  return "back"
93
93
  return "exit"
94
94
 
95
- # Try to import TUI - it's optional
96
- try:
97
- from claude_code_tools.session_tui import run_session_tui
98
- TUI_AVAILABLE = True
99
- except ImportError:
100
- TUI_AVAILABLE = False
101
95
  from claude_code_tools.smart_trim_core import identify_trimmable_lines_cli
102
96
  from claude_code_tools.smart_trim import trim_lines
103
97
  from claude_code_tools.session_utils import (
@@ -833,6 +827,19 @@ def handle_suppress_resume_claude(
833
827
  print(f"❌ Error trimming session: {e}")
834
828
  return None
835
829
 
830
+ # Check if nothing to trim (savings below threshold)
831
+ from claude_code_tools.node_menu_ui import run_trim_confirm_ui
832
+ if result.get("nothing_to_trim"):
833
+ print(f" Savings too small ({result['tokens_saved']} tokens)")
834
+ action = run_trim_confirm_ui(
835
+ nothing_to_trim=True,
836
+ original_session_id=session_id,
837
+ )
838
+ if action == 'resume':
839
+ resume_session(session_id, project_path, claude_home=claude_home)
840
+ return None
841
+ return 'back'
842
+
836
843
  new_session_id = result["session_id"]
837
844
  new_session_file = Path(result["output_file"])
838
845
 
@@ -840,7 +847,6 @@ def handle_suppress_resume_claude(
840
847
  total_trimmed = result['num_tools_trimmed'] + result['num_assistant_trimmed']
841
848
 
842
849
  # Show confirmation UI
843
- from claude_code_tools.node_menu_ui import run_trim_confirm_ui
844
850
  action = run_trim_confirm_ui(
845
851
  new_session_id=new_session_id,
846
852
  lines_trimmed=total_trimmed,
@@ -1300,8 +1306,12 @@ def create_action_handler(claude_home: Optional[str] = None, nonlaunch_flag: Opt
1300
1306
  """
1301
1307
  def handle_session_action(
1302
1308
  session: Tuple, action: str, kwargs: Optional[dict] = None
1303
- ) -> None:
1304
- """Handle actions for a selected session."""
1309
+ ) -> str | None:
1310
+ """Handle actions for a selected session.
1311
+
1312
+ Returns:
1313
+ 'back' if the action wants to return to resume menu, None otherwise.
1314
+ """
1305
1315
  kwargs = kwargs or {}
1306
1316
 
1307
1317
  if isinstance(session, dict):
@@ -1320,13 +1330,13 @@ def create_action_handler(claude_home: Optional[str] = None, nonlaunch_flag: Opt
1320
1330
  if tools is None and threshold is None and trim_assistant is None:
1321
1331
  options = prompt_suppress_options()
1322
1332
  if not options:
1323
- return
1333
+ return None
1324
1334
  tools, threshold, trim_assistant = options
1325
- handle_suppress_resume_claude(
1335
+ return handle_suppress_resume_claude(
1326
1336
  session_id, project_path, tools, threshold or 500, trim_assistant, claude_home
1327
1337
  )
1328
1338
  elif action == "smart_trim_resume":
1329
- handle_smart_trim_resume_claude(session_id, project_path, claude_home)
1339
+ return handle_smart_trim_resume_claude(session_id, project_path, claude_home)
1330
1340
  elif action == "path":
1331
1341
  # handled in Node UI via RPC
1332
1342
  if nonlaunch_flag is not None:
@@ -1581,6 +1591,19 @@ To persist directory changes when resuming sessions:
1581
1591
  rpc_path = str(Path(__file__).parent / "action_rpc.py")
1582
1592
 
1583
1593
  if not args.simple_ui:
1594
+ from claude_code_tools.export_session import extract_session_metadata
1595
+
1596
+ def get_custom_title(session_id: str, cwd: str) -> str:
1597
+ """Extract custom title from session file if present."""
1598
+ try:
1599
+ fp = get_session_file_path(session_id, cwd, args.claude_home)
1600
+ if fp:
1601
+ meta = extract_session_metadata(Path(fp), "claude")
1602
+ return meta.get("customTitle", "")
1603
+ except Exception:
1604
+ pass
1605
+ return ""
1606
+
1584
1607
  limited = [
1585
1608
  {
1586
1609
  "agent": "claude",
@@ -1598,6 +1621,7 @@ To persist directory changes when resuming sessions:
1598
1621
  "is_trimmed": s[8] if len(s) > 8 else False,
1599
1622
  "derivation_type": None,
1600
1623
  "is_sidechain": s[9] if len(s) > 9 else False,
1624
+ "custom_title": get_custom_title(s[0], s[6]),
1601
1625
  }
1602
1626
  for s in matching_sessions[: args.num_matches]
1603
1627
  ]
@@ -65,6 +65,7 @@ from claude_code_tools.smart_trim_core import (
65
65
  from claude_code_tools.smart_trim import trim_lines
66
66
  from claude_code_tools.session_utils import (
67
67
  get_codex_home,
68
+ get_session_uuid,
68
69
  format_session_id_display,
69
70
  filter_sessions_by_time,
70
71
  )
@@ -78,14 +79,6 @@ try:
78
79
  except ImportError:
79
80
  RICH_AVAILABLE = False
80
81
 
81
- try:
82
- from claude_code_tools.session_tui import run_session_tui
83
-
84
- TUI_AVAILABLE = True
85
- except ImportError:
86
- TUI_AVAILABLE = False
87
-
88
-
89
82
  def extract_session_id_from_filename(filename: str) -> Optional[str]:
90
83
  """
91
84
  Extract session ID from Codex session filename.
@@ -585,6 +578,19 @@ def handle_suppress_resume_codex(
585
578
  print(f"❌ Error trimming session: {e}")
586
579
  return None
587
580
 
581
+ # Check if nothing to trim (savings below threshold)
582
+ from claude_code_tools.node_menu_ui import run_trim_confirm_ui
583
+ if result.get("nothing_to_trim"):
584
+ print(f" Savings too small ({result['tokens_saved']} tokens)")
585
+ action = run_trim_confirm_ui(
586
+ nothing_to_trim=True,
587
+ original_session_id=match["session_id"],
588
+ )
589
+ if action == 'resume':
590
+ resume_session(match["session_id"], match["cwd"])
591
+ return None
592
+ return 'back'
593
+
588
594
  new_session_id = result["session_id"]
589
595
  new_session_file = Path(result["output_file"])
590
596
 
@@ -592,7 +598,6 @@ def handle_suppress_resume_codex(
592
598
  total_trimmed = result['num_tools_trimmed'] + result['num_assistant_trimmed']
593
599
 
594
600
  # Show confirmation UI
595
- from claude_code_tools.node_menu_ui import run_trim_confirm_ui
596
601
  action = run_trim_confirm_ui(
597
602
  new_session_id=new_session_id,
598
603
  lines_trimmed=total_trimmed,
@@ -689,11 +694,17 @@ def handle_smart_trim_resume_codex(
689
694
  }
690
695
 
691
696
  # Generate new session ID (UUID only) and filename with Codex format
692
- timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
697
+ # New session goes in today's date folder (YYYY/MM/DD)
698
+ now = datetime.now()
699
+ timestamp = now.strftime("%Y-%m-%dT%H-%M-%S")
700
+ date_path = now.strftime("%Y/%m/%d")
693
701
  new_session_id = str(uuid.uuid4())
694
702
 
695
- # Create output path in same directory as original
696
- output_file = session_file.parent / f"rollout-{timestamp}-{new_session_id}.jsonl"
703
+ # Find sessions root by going up from input file (sessions/YYYY/MM/DD/file.jsonl)
704
+ sessions_root = session_file.parent.parent.parent.parent
705
+ output_dir = sessions_root / date_path
706
+ output_dir.mkdir(parents=True, exist_ok=True)
707
+ output_file = output_dir / f"rollout-{timestamp}-{new_session_id}.jsonl"
697
708
 
698
709
  # Perform trimming
699
710
  stats = trim_lines(
@@ -760,7 +771,7 @@ def handle_export_session(session_file_path: str, dest_override: str | None = No
760
771
 
761
772
  # Generate default export path using session's project directory
762
773
  session_file = Path(session_file_path)
763
- session_id = session_file.stem
774
+ session_id = get_session_uuid(session_file.name)
764
775
  today = datetime.now().strftime("%Y%m%d")
765
776
 
766
777
  # Infer project directory from session metadata
@@ -977,8 +988,12 @@ def resume_session(
977
988
 
978
989
  def create_action_handler(shell_mode: bool = False, codex_home: Optional[Path] = None, nonlaunch_flag: Optional[dict] = None):
979
990
  """Create an action handler for the TUI."""
980
- def action_handler(session, action: str, kwargs: Optional[dict] = None) -> None:
981
- """Handle actions from the TUI - session can be tuple or dict."""
991
+ def action_handler(session, action: str, kwargs: Optional[dict] = None) -> str | None:
992
+ """Handle actions from the TUI - session can be tuple or dict.
993
+
994
+ Returns:
995
+ 'back' if the action wants to return to resume menu, None otherwise.
996
+ """
982
997
  kwargs = kwargs or {}
983
998
  # Ensure session is a dict
984
999
  if not isinstance(session, dict):
@@ -999,14 +1014,14 @@ def create_action_handler(shell_mode: bool = False, codex_home: Optional[Path] =
999
1014
  if tools is None and threshold is None and trim_assistant is None:
1000
1015
  options = prompt_suppress_options()
1001
1016
  if not options:
1002
- return
1017
+ return None
1003
1018
  tools, threshold, trim_assistant = options
1004
- handle_suppress_resume_codex(
1019
+ return handle_suppress_resume_codex(
1005
1020
  session, tools, threshold or 500, trim_assistant, codex_home
1006
1021
  )
1007
1022
  elif action == "smart_trim_resume":
1008
1023
  # Smart trim using parallel agents
1009
- handle_smart_trim_resume_codex(session, codex_home)
1024
+ return handle_smart_trim_resume_codex(session, codex_home)
1010
1025
  elif action == "path":
1011
1026
  if nonlaunch_flag is not None:
1012
1027
  nonlaunch_flag["done"] = True
@@ -70,14 +70,6 @@ try:
70
70
  except ImportError:
71
71
  RICH_AVAILABLE = False
72
72
 
73
- try:
74
- from claude_code_tools.session_tui import run_session_tui
75
-
76
- TUI_AVAILABLE = True
77
- except ImportError:
78
- TUI_AVAILABLE = False
79
-
80
-
81
73
  @dataclass
82
74
  class AgentConfig:
83
75
  """Configuration for a coding agent."""
@@ -570,6 +562,18 @@ def handle_suppress_resume(
570
562
  print(f"❌ Error trimming session: {e}", file=output)
571
563
  return
572
564
 
565
+ # Check if nothing to trim (savings below threshold)
566
+ if result.get("nothing_to_trim"):
567
+ print(f"\n{'='*70}", file=output)
568
+ print(f"⚠️ NOTHING TO TRIM", file=output)
569
+ print(f"{'='*70}", file=output)
570
+ print(
571
+ f" Savings too small ({result['tokens_saved']} tokens) - "
572
+ f"no new session created",
573
+ file=output,
574
+ )
575
+ return
576
+
573
577
  new_session_id = result["session_id"]
574
578
  new_session_file = result["output_file"]
575
579
 
@@ -625,8 +629,12 @@ def create_action_handler(
625
629
  ):
626
630
  """Create an action handler for the TUI or Node UI."""
627
631
 
628
- def action_handler(session, action: str, kwargs: Optional[dict] = None) -> None:
629
- """Handle actions from the UI - session can be tuple or dict."""
632
+ def action_handler(session, action: str, kwargs: Optional[dict] = None) -> str | None:
633
+ """Handle actions from the UI - session can be tuple or dict.
634
+
635
+ Returns:
636
+ 'back' if the action wants to return to resume menu, None otherwise.
637
+ """
630
638
  # Convert session to dict if it's a tuple or dict-like
631
639
  if isinstance(session, dict):
632
640
  session_dict = session
@@ -634,7 +642,7 @@ def create_action_handler(
634
642
  # Shouldn't happen in unified find, but handle gracefully
635
643
  session_dict = {"session_id": str(session), "agent": "unknown"}
636
644
 
637
- handle_action(
645
+ result = handle_action(
638
646
  session_dict, action, shell_mode=shell_mode, action_kwargs=kwargs or {}
639
647
  )
640
648
 
@@ -642,13 +650,19 @@ def create_action_handler(
642
650
  nonlaunch_flag["done"] = True
643
651
  nonlaunch_flag["session_id"] = session_dict.get("session_id")
644
652
 
653
+ return result
654
+
645
655
  return action_handler
646
656
 
647
657
 
648
658
  def handle_action(
649
659
  session: dict, action: str, shell_mode: bool = False, action_kwargs: Optional[dict] = None
650
- ) -> None:
651
- """Handle the selected action based on agent type."""
660
+ ) -> str | None:
661
+ """Handle the selected action based on agent type.
662
+
663
+ Returns:
664
+ 'back' if the action wants to return to resume menu, None otherwise.
665
+ """
652
666
  agent = session["agent"]
653
667
  action_kwargs = action_kwargs or {}
654
668
 
@@ -675,14 +689,14 @@ def handle_action(
675
689
  if options:
676
690
  tools, threshold, trim_assistant = options
677
691
  else:
678
- return
692
+ return None
679
693
  handle_suppress_resume(
680
694
  session, tools, threshold or 500, trim_assistant, shell_mode
681
695
  )
682
696
 
683
697
  elif action == "smart_trim_resume":
684
698
  if agent == "claude":
685
- handle_smart_trim_resume_claude(
699
+ return handle_smart_trim_resume_claude(
686
700
  session["session_id"],
687
701
  session["cwd"],
688
702
  session.get("claude_home"),
@@ -690,7 +704,7 @@ def handle_action(
690
704
  elif agent == "codex":
691
705
  # Get file path for codex
692
706
  file_path = session.get("file_path", "")
693
- handle_smart_trim_resume_codex(file_path)
707
+ return handle_smart_trim_resume_codex(file_path)
694
708
 
695
709
  elif action == "path":
696
710
  if agent == "claude":
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ gdoc2md: Download Google Docs as Markdown files.
4
+
5
+ This tool uses the Google Drive API to export Google Docs as Markdown,
6
+ using Google's native markdown export (same as File → Download → Markdown).
7
+
8
+ Prerequisites:
9
+ - First run: Will open browser for OAuth authentication (one-time setup)
10
+ - Credentials stored in .gdoc-credentials.json (local) or ~/.config/md2gdoc/
11
+ """
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+
21
+ console = Console()
22
+
23
+ # Import shared utilities from md2gdoc
24
+ from claude_code_tools.md2gdoc import (
25
+ SCOPES,
26
+ check_dependencies,
27
+ get_credentials,
28
+ get_drive_service,
29
+ find_folder_id,
30
+ )
31
+
32
+
33
+ def find_doc_by_name(
34
+ service, folder_id: Optional[str], doc_name: str
35
+ ) -> Optional[dict]:
36
+ """Find a Google Doc by name in a folder. Returns file metadata or None."""
37
+ parent = folder_id if folder_id else "root"
38
+ query = (
39
+ f"name = '{doc_name}' and "
40
+ f"'{parent}' in parents and "
41
+ f"mimeType = 'application/vnd.google-apps.document' and "
42
+ f"trashed = false"
43
+ )
44
+ results = (
45
+ service.files()
46
+ .list(q=query, fields="files(id, name)", pageSize=1)
47
+ .execute()
48
+ )
49
+ files = results.get("files", [])
50
+ return files[0] if files else None
51
+
52
+
53
+ def list_docs_in_folder(service, folder_id: Optional[str]) -> list[dict]:
54
+ """List all Google Docs in a folder."""
55
+ parent = folder_id if folder_id else "root"
56
+ query = (
57
+ f"'{parent}' in parents and "
58
+ f"mimeType = 'application/vnd.google-apps.document' and "
59
+ f"trashed = false"
60
+ )
61
+ results = (
62
+ service.files()
63
+ .list(q=query, fields="files(id, name)", pageSize=100, orderBy="name")
64
+ .execute()
65
+ )
66
+ return results.get("files", [])
67
+
68
+
69
+ def download_doc_as_markdown(service, file_id: str) -> Optional[str]:
70
+ """Download a Google Doc as Markdown content."""
71
+ try:
72
+ # Export as markdown
73
+ content = (
74
+ service.files()
75
+ .export(fileId=file_id, mimeType="text/markdown")
76
+ .execute()
77
+ )
78
+ # Content is returned as bytes
79
+ if isinstance(content, bytes):
80
+ return content.decode("utf-8")
81
+ return content
82
+ except Exception as e:
83
+ console.print(f"[red]Export error:[/red] {e}")
84
+ return None
85
+
86
+
87
+ def main() -> None:
88
+ parser = argparse.ArgumentParser(
89
+ description="Download Google Docs as Markdown files.",
90
+ formatter_class=argparse.RawDescriptionHelpFormatter,
91
+ epilog="""
92
+ Examples:
93
+ gdoc2md "My Document" # Download from root
94
+ gdoc2md "My Document" --folder "OTA/Reports" # Download from folder
95
+ gdoc2md "My Document" -o report.md # Save with custom name
96
+ gdoc2md --list --folder OTA # List docs in folder
97
+
98
+ Credentials (in order of precedence):
99
+ 1. .gdoc-token.json in current directory (project-specific)
100
+ 2. ~/.config/md2gdoc/token.json (global)
101
+ 3. Application Default Credentials (gcloud)
102
+ """,
103
+ )
104
+
105
+ parser.add_argument(
106
+ "doc_name",
107
+ type=str,
108
+ nargs="?",
109
+ help="Name of the Google Doc to download",
110
+ )
111
+
112
+ parser.add_argument(
113
+ "--folder",
114
+ "-f",
115
+ type=str,
116
+ default="",
117
+ help="Folder in Google Drive (e.g., 'OTA/Reports')",
118
+ )
119
+
120
+ parser.add_argument(
121
+ "--output",
122
+ "-o",
123
+ type=str,
124
+ default="",
125
+ help="Output filename (default: <doc_name>.md)",
126
+ )
127
+
128
+ parser.add_argument(
129
+ "--list",
130
+ "-l",
131
+ action="store_true",
132
+ help="List Google Docs in the folder instead of downloading",
133
+ )
134
+
135
+ args = parser.parse_args()
136
+
137
+ # Check dependencies
138
+ if not check_dependencies():
139
+ sys.exit(1)
140
+
141
+ # Need either doc_name or --list
142
+ if not args.doc_name and not args.list:
143
+ parser.print_help()
144
+ sys.exit(1)
145
+
146
+ # Get Drive service
147
+ service = get_drive_service()
148
+ if not service:
149
+ sys.exit(1)
150
+
151
+ # Find folder if specified
152
+ folder_id = None
153
+ if args.folder:
154
+ console.print(f"[dim]Finding folder: {args.folder}[/dim]")
155
+ folder_id = find_folder_id(service, args.folder, create_if_missing=False)
156
+ if folder_id is None:
157
+ console.print(f"[red]Error:[/red] Folder not found: {args.folder}")
158
+ sys.exit(1)
159
+
160
+ # List mode
161
+ if args.list:
162
+ docs = list_docs_in_folder(service, folder_id)
163
+ if not docs:
164
+ console.print("[yellow]No Google Docs found in this folder.[/yellow]")
165
+ sys.exit(0)
166
+
167
+ console.print(f"\n[bold]Google Docs in {args.folder or 'My Drive'}:[/bold]\n")
168
+ for doc in docs:
169
+ console.print(f" • {doc['name']}")
170
+ console.print(f"\n[dim]Total: {len(docs)} document(s)[/dim]")
171
+ sys.exit(0)
172
+
173
+ # Download mode
174
+ console.print(f"[dim]Looking for: {args.doc_name}[/dim]")
175
+ doc = find_doc_by_name(service, folder_id, args.doc_name)
176
+
177
+ if not doc:
178
+ console.print(f"[red]Error:[/red] Document not found: {args.doc_name}")
179
+ # Suggest listing
180
+ console.print(f"[dim]Use --list to see available documents[/dim]")
181
+ sys.exit(1)
182
+
183
+ # Download as markdown
184
+ console.print(f"[cyan]Downloading[/cyan] {doc['name']} → Markdown...")
185
+ content = download_doc_as_markdown(service, doc["id"])
186
+
187
+ if content is None:
188
+ sys.exit(1)
189
+
190
+ # Determine output filename
191
+ if args.output:
192
+ output_path = Path(args.output)
193
+ else:
194
+ # Use doc name, sanitize for filesystem
195
+ safe_name = "".join(c if c.isalnum() or c in "._- " else "_" for c in doc["name"])
196
+ output_path = Path(f"{safe_name}.md")
197
+
198
+ # Check if file exists
199
+ if output_path.exists():
200
+ console.print(
201
+ f"[yellow]Warning:[/yellow] {output_path} already exists, overwriting"
202
+ )
203
+
204
+ # Write file
205
+ output_path.write_text(content, encoding="utf-8")
206
+
207
+ console.print()
208
+ console.print(
209
+ Panel(
210
+ f"[green]Successfully downloaded![/green]\n\n"
211
+ f"[dim]Document:[/dim] {doc['name']}\n"
212
+ f"[dim]Saved to:[/dim] {output_path}",
213
+ title="Done",
214
+ border_style="green",
215
+ )
216
+ )
217
+
218
+
219
+ if __name__ == "__main__":
220
+ main()