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.
- claude_code_tools/__init__.py +1 -1
- claude_code_tools/action_rpc.py +16 -10
- claude_code_tools/aichat.py +793 -51
- claude_code_tools/claude_continue.py +4 -0
- claude_code_tools/codex_continue.py +48 -0
- claude_code_tools/export_session.py +9 -5
- claude_code_tools/find_claude_session.py +36 -12
- claude_code_tools/find_codex_session.py +33 -18
- claude_code_tools/find_session.py +30 -16
- claude_code_tools/gdoc2md.py +220 -0
- claude_code_tools/md2gdoc.py +549 -0
- claude_code_tools/search_index.py +83 -9
- claude_code_tools/session_menu_cli.py +1 -1
- claude_code_tools/session_utils.py +3 -3
- claude_code_tools/smart_trim.py +18 -8
- claude_code_tools/smart_trim_core.py +4 -2
- claude_code_tools/tmux_cli_controller.py +35 -25
- claude_code_tools/trim_session.py +28 -2
- claude_code_tools-1.4.1.dist-info/METADATA +1113 -0
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/RECORD +30 -24
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/entry_points.txt +2 -0
- docs/local-llm-setup.md +286 -0
- docs/reddit-aichat-resume-v2.md +80 -0
- docs/reddit-aichat-resume.md +29 -0
- docs/reddit-aichat.md +79 -0
- docs/rollover-details.md +67 -0
- node_ui/action_config.js +3 -3
- node_ui/menu.js +67 -113
- claude_code_tools/session_tui.py +0 -516
- claude_code_tools-1.0.6.dist-info/METADATA +0 -685
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/WHEEL +0 -0
- {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":
|
|
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
|
-
|
|
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
|
-
#
|
|
696
|
-
|
|
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.
|
|
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()
|