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
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ md2gdoc: Upload Markdown files to Google Drive as native Google Docs.
4
+
5
+ This tool uses the Google Drive API to upload markdown files with native
6
+ conversion to Google Docs format - the same conversion that happens when
7
+ you manually upload a .md file and click "Open in Google Docs".
8
+
9
+ Prerequisites:
10
+ - First run: Will open browser for OAuth authentication (one-time setup)
11
+ - Credentials stored in ~/.config/md2gdoc/
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from rich.console import Console
23
+ from rich.panel import Panel
24
+ from rich.prompt import Prompt
25
+ from rich.text import Text
26
+
27
+ console = Console()
28
+
29
+ # Google Drive API scopes
30
+ # Using 'drive' scope to access all files (including shortcuts/shared folders)
31
+ # 'drive.file' only sees files created by this app
32
+ SCOPES = ["https://www.googleapis.com/auth/drive"]
33
+
34
+ # Config directory for storing credentials (global fallback)
35
+ CONFIG_DIR = Path.home() / ".config" / "md2gdoc"
36
+
37
+ # Local (project-specific) credential files - checked first
38
+ LOCAL_TOKEN_FILE = Path(".gdoc-token.json")
39
+ LOCAL_CREDENTIALS_FILE = Path(".gdoc-credentials.json")
40
+
41
+ # Global credential files - fallback
42
+ GLOBAL_TOKEN_FILE = CONFIG_DIR / "token.json"
43
+ GLOBAL_CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
44
+
45
+
46
+ def get_token_file() -> Path:
47
+ """Get token file path - local first, then global."""
48
+ if LOCAL_TOKEN_FILE.exists():
49
+ return LOCAL_TOKEN_FILE
50
+ return GLOBAL_TOKEN_FILE
51
+
52
+
53
+ def get_credentials_file() -> Path:
54
+ """Get credentials file path - local first, then global."""
55
+ if LOCAL_CREDENTIALS_FILE.exists():
56
+ return LOCAL_CREDENTIALS_FILE
57
+ if GLOBAL_CREDENTIALS_FILE.exists():
58
+ return GLOBAL_CREDENTIALS_FILE
59
+ # Return local path for error messages (preferred location)
60
+ return LOCAL_CREDENTIALS_FILE
61
+
62
+ # Default OAuth client credentials (for CLI tools)
63
+ # Users can override by placing their own credentials.json in CONFIG_DIR
64
+ DEFAULT_CLIENT_CONFIG = {
65
+ "installed": {
66
+ "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
67
+ "project_id": "md2gdoc",
68
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
69
+ "token_uri": "https://oauth2.googleapis.com/token",
70
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
71
+ "client_secret": "YOUR_CLIENT_SECRET",
72
+ "redirect_uris": ["http://localhost"],
73
+ }
74
+ }
75
+
76
+
77
+ def check_dependencies() -> bool:
78
+ """Check if required Google API packages are installed."""
79
+ try:
80
+ from google.oauth2.credentials import Credentials # noqa: F401
81
+ from google_auth_oauthlib.flow import InstalledAppFlow # noqa: F401
82
+ from googleapiclient.discovery import build # noqa: F401
83
+
84
+ return True
85
+ except ImportError:
86
+ console.print(
87
+ Panel(
88
+ "[red]Missing dependencies:[/red] google-api-python-client, "
89
+ "google-auth-oauthlib\n\n"
90
+ "Install with:\n"
91
+ "[cyan]pip install google-api-python-client google-auth-oauthlib[/cyan]",
92
+ title="Dependencies Required",
93
+ border_style="red",
94
+ )
95
+ )
96
+ return False
97
+
98
+
99
+ def get_credentials():
100
+ """
101
+ Get OAuth credentials. Tries in order:
102
+ 1. Saved token from previous run (local .gdoc-token.json, then global)
103
+ 2. Application Default Credentials (from gcloud)
104
+ 3. Manual OAuth flow using credentials.json (local, then global)
105
+ """
106
+ from google.auth.transport.requests import Request
107
+ from google.oauth2.credentials import Credentials
108
+
109
+ creds = None
110
+ token_file = get_token_file()
111
+ credentials_file = get_credentials_file()
112
+
113
+ # Option 1: Load existing token if available
114
+ if token_file.exists():
115
+ try:
116
+ creds = Credentials.from_authorized_user_file(str(token_file), SCOPES)
117
+ if creds and creds.valid:
118
+ console.print(f"[dim]Using token: {token_file}[/dim]")
119
+ return creds
120
+ if creds and creds.expired and creds.refresh_token:
121
+ creds.refresh(Request())
122
+ # Save refreshed token
123
+ with open(token_file, "w") as f:
124
+ f.write(creds.to_json())
125
+ console.print(f"[dim]Using token: {token_file}[/dim]")
126
+ return creds
127
+ except Exception:
128
+ pass # Fall through to other methods
129
+
130
+ # Option 2: Try Application Default Credentials (gcloud auth)
131
+ try:
132
+ import google.auth
133
+
134
+ creds, _ = google.auth.default(scopes=SCOPES)
135
+ if creds and creds.valid:
136
+ console.print("[dim]Using Application Default Credentials[/dim]")
137
+ return creds
138
+ except Exception:
139
+ pass # Fall through to OAuth flow
140
+
141
+ # Option 3: Manual OAuth flow
142
+ from google_auth_oauthlib.flow import InstalledAppFlow
143
+
144
+ if not credentials_file.exists():
145
+ console.print(
146
+ Panel(
147
+ "[red]OAuth credentials not found![/red]\n\n"
148
+ "[bold]Option A - Project-specific (recommended):[/bold]\n"
149
+ f"Save OAuth client JSON as: [cyan].gdoc-credentials.json[/cyan]\n"
150
+ "(in current directory)\n\n"
151
+ "[bold]Option B - Global:[/bold]\n"
152
+ f"Save as: [cyan]{GLOBAL_CREDENTIALS_FILE}[/cyan]\n\n"
153
+ "[bold]To get the OAuth client JSON:[/bold]\n"
154
+ "1. Go to console.cloud.google.com\n"
155
+ "2. APIs & Services → Credentials\n"
156
+ "3. Create Credentials → OAuth client ID → Desktop app\n"
157
+ "4. Download JSON",
158
+ title="Setup Required",
159
+ border_style="yellow",
160
+ )
161
+ )
162
+ return None
163
+
164
+ console.print(f"[dim]Using credentials: {credentials_file}[/dim]")
165
+ flow = InstalledAppFlow.from_client_secrets_file(
166
+ str(credentials_file), SCOPES
167
+ )
168
+ console.print("[cyan]Opening browser for Google authentication...[/cyan]")
169
+ creds = flow.run_local_server(port=0)
170
+
171
+ # Save token locally (in current directory)
172
+ with open(LOCAL_TOKEN_FILE, "w") as token:
173
+ token.write(creds.to_json())
174
+ console.print(f"[green]Token saved to {LOCAL_TOKEN_FILE}[/green]")
175
+
176
+ return creds
177
+
178
+
179
+ def get_drive_service():
180
+ """Get authenticated Google Drive service."""
181
+ from googleapiclient.discovery import build
182
+
183
+ creds = get_credentials()
184
+ if not creds:
185
+ return None
186
+
187
+ return build("drive", "v3", credentials=creds)
188
+
189
+
190
+ def find_folder_id(service, folder_path: str, create_if_missing: bool = True) -> Optional[str]:
191
+ """Find folder by path (following shortcuts), optionally create if missing."""
192
+ if not folder_path:
193
+ return None # Root folder
194
+
195
+ parts = folder_path.strip("/").split("/")
196
+ parent_id = "root"
197
+
198
+ for part in parts:
199
+ # Search for folder OR shortcut with this name under parent
200
+ query = (
201
+ f"name = '{part}' and "
202
+ f"'{parent_id}' in parents and "
203
+ f"(mimeType = 'application/vnd.google-apps.folder' or "
204
+ f"mimeType = 'application/vnd.google-apps.shortcut') and "
205
+ f"trashed = false"
206
+ )
207
+ console.print(f"[dim]Looking for '{part}' in parent={parent_id}[/dim]")
208
+ results = (
209
+ service.files()
210
+ .list(q=query, fields="files(id, name, mimeType, shortcutDetails)", pageSize=10)
211
+ .execute()
212
+ )
213
+ files = results.get("files", [])
214
+ console.print(f"[dim]Found {len(files)} matches: {[(f['name'], f['mimeType']) for f in files]}[/dim]")
215
+
216
+ if files:
217
+ file = files[0]
218
+ if file.get("mimeType") == "application/vnd.google-apps.shortcut":
219
+ # Follow the shortcut to get target folder ID
220
+ shortcut_details = file.get("shortcutDetails", {})
221
+ target_id = shortcut_details.get("targetId")
222
+ if target_id:
223
+ console.print(f"[dim]Following shortcut: {part}[/dim]")
224
+ parent_id = target_id
225
+ else:
226
+ console.print(f"[yellow]Warning: Shortcut {part} has no target[/yellow]")
227
+ return None
228
+ else:
229
+ parent_id = file["id"]
230
+ else:
231
+ if not create_if_missing:
232
+ return None
233
+ # Folder doesn't exist - create it
234
+ file_metadata = {
235
+ "name": part,
236
+ "mimeType": "application/vnd.google-apps.folder",
237
+ "parents": [parent_id],
238
+ }
239
+ folder = (
240
+ service.files().create(body=file_metadata, fields="id").execute()
241
+ )
242
+ parent_id = folder["id"]
243
+ console.print(f"[dim]Created folder: {part}[/dim]")
244
+
245
+ return parent_id
246
+
247
+
248
+ def check_file_exists(service, folder_id: Optional[str], filename: str) -> bool:
249
+ """Check if a file with this name exists in the folder."""
250
+ parent = folder_id if folder_id else "root"
251
+ query = (
252
+ f"name = '{filename}' and "
253
+ f"'{parent}' in parents and "
254
+ f"mimeType = 'application/vnd.google-apps.document' and "
255
+ f"trashed = false"
256
+ )
257
+ console.print(f"[dim]Checking for existing file: {filename}[/dim]")
258
+ results = (
259
+ service.files()
260
+ .list(q=query, fields="files(id, name)", pageSize=10)
261
+ .execute()
262
+ )
263
+ files = results.get("files", [])
264
+ if files:
265
+ console.print(f"[dim]Found {len(files)} existing file(s): {[f['name'] for f in files]}[/dim]")
266
+ return len(files) > 0
267
+
268
+
269
+ def list_existing_versions(
270
+ service, folder_id: Optional[str], base_name: str
271
+ ) -> list[str]:
272
+ """List existing files that match the base name pattern."""
273
+ parent = folder_id if folder_id else "root"
274
+ query = (
275
+ f"name contains '{base_name}' and "
276
+ f"'{parent}' in parents and "
277
+ f"mimeType = 'application/vnd.google-apps.document' and "
278
+ f"trashed = false"
279
+ )
280
+ results = (
281
+ service.files()
282
+ .list(q=query, fields="files(id, name)", pageSize=100)
283
+ .execute()
284
+ )
285
+ return [f["name"] for f in results.get("files", [])]
286
+
287
+
288
+ def get_next_version_name(
289
+ service, folder_id: Optional[str], base_name: str
290
+ ) -> str:
291
+ """Get the next available version name (e.g., report-1, report-2)."""
292
+ existing = list_existing_versions(service, folder_id, base_name)
293
+
294
+ if not existing:
295
+ return f"{base_name}-1"
296
+
297
+ # Find the highest version number
298
+ max_version = 0
299
+ for f in existing:
300
+ match = re.match(rf"^{re.escape(base_name)}-(\d+)$", f)
301
+ if match:
302
+ version = int(match.group(1))
303
+ max_version = max(max_version, version)
304
+
305
+ return f"{base_name}-{max_version + 1}"
306
+
307
+
308
+ def delete_file(service, folder_id: Optional[str], filename: str) -> bool:
309
+ """Delete a file by name (for overwrite)."""
310
+ parent = folder_id if folder_id else "root"
311
+ query = (
312
+ f"name = '{filename}' and "
313
+ f"'{parent}' in parents and "
314
+ f"mimeType = 'application/vnd.google-apps.document' and "
315
+ f"trashed = false"
316
+ )
317
+ results = (
318
+ service.files()
319
+ .list(q=query, fields="files(id)", pageSize=1)
320
+ .execute()
321
+ )
322
+ files = results.get("files", [])
323
+ if files:
324
+ service.files().delete(fileId=files[0]["id"]).execute()
325
+ return True
326
+ return False
327
+
328
+
329
+ def prompt_for_conflict(existing_name: str, versioned_name: str) -> Optional[str]:
330
+ """
331
+ Prompt user for action when file already exists.
332
+
333
+ Returns:
334
+ - "version": use versioned name
335
+ - "overwrite": overwrite existing
336
+ - None: user cancelled
337
+ """
338
+ console.print()
339
+ console.print(
340
+ Panel(
341
+ f"[yellow]File already exists:[/yellow] [bold]{existing_name}[/bold]",
342
+ title="Conflict Detected",
343
+ border_style="yellow",
344
+ )
345
+ )
346
+ console.print()
347
+
348
+ # Show options
349
+ options_text = Text()
350
+ options_text.append(" [Enter] ", style="cyan bold")
351
+ options_text.append("Add version suffix → ", style="dim")
352
+ options_text.append(f"{versioned_name}\n", style="green")
353
+ options_text.append(" [YES] ", style="red bold")
354
+ options_text.append("Overwrite existing file", style="dim")
355
+
356
+ console.print(options_text)
357
+ console.print()
358
+
359
+ choice = Prompt.ask(
360
+ "Your choice",
361
+ default="",
362
+ show_default=False,
363
+ )
364
+
365
+ if choice == "":
366
+ return "version"
367
+ elif choice.upper() == "YES":
368
+ return "overwrite"
369
+ else:
370
+ console.print("[dim]Invalid choice. Cancelling.[/dim]")
371
+ return None
372
+
373
+
374
+ def upload_markdown(
375
+ service,
376
+ md_path: Path,
377
+ folder_id: Optional[str],
378
+ filename: str,
379
+ ) -> Optional[str]:
380
+ """
381
+ Upload markdown file to Google Drive with native conversion to Google Docs.
382
+
383
+ Returns the file ID on success, None on failure.
384
+ """
385
+ from googleapiclient.http import MediaFileUpload
386
+
387
+ file_metadata = {
388
+ "name": filename,
389
+ "mimeType": "application/vnd.google-apps.document",
390
+ }
391
+ if folder_id:
392
+ file_metadata["parents"] = [folder_id]
393
+
394
+ # Upload with text/markdown MIME type - Google converts natively
395
+ media = MediaFileUpload(
396
+ str(md_path),
397
+ mimetype="text/markdown",
398
+ resumable=True,
399
+ )
400
+
401
+ try:
402
+ with console.status("[cyan]Uploading to Google Drive...[/cyan]"):
403
+ file = (
404
+ service.files()
405
+ .create(body=file_metadata, media_body=media, fields="id,webViewLink")
406
+ .execute()
407
+ )
408
+ return file.get("webViewLink")
409
+ except Exception as e:
410
+ console.print(f"[red]Upload error:[/red] {e}")
411
+ return None
412
+
413
+
414
+ def main() -> None:
415
+ parser = argparse.ArgumentParser(
416
+ description="Upload Markdown files to Google Drive as native Google Docs.",
417
+ formatter_class=argparse.RawDescriptionHelpFormatter,
418
+ epilog="""
419
+ Examples:
420
+ md2gdoc report.md # Upload to root of Drive
421
+ md2gdoc report.md --folder "OTA/Reports" # Upload to specific folder
422
+ md2gdoc report.md --name "Q4 Summary" # Upload with custom name
423
+ md2gdoc report.md --on-existing version # Auto-version if exists (no prompt)
424
+
425
+ Credentials (in order of precedence):
426
+ 1. .gdoc-token.json in current directory (project-specific)
427
+ 2. ~/.config/md2gdoc/token.json (global)
428
+ 3. Application Default Credentials (gcloud)
429
+
430
+ First run opens browser for OAuth (one-time per project).
431
+ """,
432
+ )
433
+
434
+ parser.add_argument(
435
+ "markdown_file",
436
+ type=Path,
437
+ help="Path to the Markdown file to upload",
438
+ )
439
+
440
+ parser.add_argument(
441
+ "--folder",
442
+ "-f",
443
+ type=str,
444
+ default="",
445
+ help="Target folder in Google Drive (e.g., 'OTA/Reports')",
446
+ )
447
+
448
+ parser.add_argument(
449
+ "--name",
450
+ "-n",
451
+ type=str,
452
+ default="",
453
+ help="Name for the Google Doc (default: markdown filename without extension)",
454
+ )
455
+
456
+ parser.add_argument(
457
+ "--on-existing",
458
+ type=str,
459
+ choices=["ask", "version", "overwrite"],
460
+ default="ask",
461
+ help="Action when file exists: ask (prompt), version (auto-increment), "
462
+ "overwrite (replace). Default: ask",
463
+ )
464
+
465
+ args = parser.parse_args()
466
+
467
+ # Check dependencies
468
+ if not check_dependencies():
469
+ sys.exit(1)
470
+
471
+ # Validate input file
472
+ if not args.markdown_file.exists():
473
+ console.print(f"[red]Error:[/red] File not found: {args.markdown_file}")
474
+ sys.exit(1)
475
+
476
+ if args.markdown_file.suffix.lower() not in [".md", ".markdown"]:
477
+ console.print(
478
+ "[yellow]Warning:[/yellow] "
479
+ "File doesn't have .md extension, proceeding anyway"
480
+ )
481
+
482
+ # Get Drive service
483
+ service = get_drive_service()
484
+ if not service:
485
+ sys.exit(1)
486
+
487
+ # Find or create target folder
488
+ folder_id = None
489
+ if args.folder:
490
+ console.print(f"[dim]Finding folder: {args.folder}[/dim]")
491
+ folder_id = find_folder_id(service, args.folder)
492
+
493
+ # Determine the target filename
494
+ if args.name:
495
+ target_name = args.name
496
+ else:
497
+ target_name = args.markdown_file.stem
498
+
499
+ # Check if file already exists
500
+ file_exists = check_file_exists(service, folder_id, target_name)
501
+
502
+ final_name = target_name
503
+ if file_exists:
504
+ versioned_name = get_next_version_name(service, folder_id, target_name)
505
+
506
+ # Determine action based on --on-existing flag
507
+ on_existing = getattr(args, "on_existing", "ask")
508
+ if on_existing == "ask":
509
+ action = prompt_for_conflict(target_name, versioned_name)
510
+ if action is None:
511
+ console.print("[dim]Upload cancelled.[/dim]")
512
+ sys.exit(0)
513
+ else:
514
+ action = on_existing
515
+
516
+ if action == "version":
517
+ final_name = versioned_name
518
+ console.print(f"[dim]File exists, using versioned name: {final_name}[/dim]")
519
+ elif action == "overwrite":
520
+ console.print(f"[dim]Deleting existing file: {target_name}[/dim]")
521
+ delete_file(service, folder_id, target_name)
522
+
523
+ # Upload with native markdown conversion
524
+ console.print(
525
+ f"[cyan]Uploading[/cyan] {args.markdown_file.name} → Google Docs..."
526
+ )
527
+ web_link = upload_markdown(service, args.markdown_file, folder_id, final_name)
528
+
529
+ if not web_link:
530
+ sys.exit(1)
531
+
532
+ # Success message
533
+ location = f"{args.folder}/{final_name}" if args.folder else final_name
534
+
535
+ console.print()
536
+ console.print(
537
+ Panel(
538
+ f"[green]Successfully uploaded![/green]\n\n"
539
+ f"[dim]Name:[/dim] {final_name}\n"
540
+ f"[dim]Location:[/dim] {location}\n"
541
+ f"[dim]Link:[/dim] {web_link}",
542
+ title="Done",
543
+ border_style="green",
544
+ )
545
+ )
546
+
547
+
548
+ if __name__ == "__main__":
549
+ main()