wikigen 1.0.0__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.
wikigen/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ WikiGen - Wiki for your codebase
3
+ """
4
+
5
+ from .metadata import AUTHOR_NAME
6
+
7
+ __author__ = AUTHOR_NAME
wikigen/cli.py ADDED
@@ -0,0 +1,690 @@
1
+ """
2
+ CLI entry point for WikiGen.
3
+ """
4
+
5
+ import sys
6
+ import os
7
+ import argparse
8
+ import time
9
+
10
+ from .config import (
11
+ init_config,
12
+ load_config,
13
+ merge_config_with_args,
14
+ check_config_exists,
15
+ save_config,
16
+ should_check_for_updates,
17
+ update_last_check_timestamp,
18
+ )
19
+ from .defaults import DEFAULT_INCLUDE_PATTERNS, DEFAULT_EXCLUDE_PATTERNS
20
+ from .flows.flow import create_wiki_flow
21
+ from .formatter.output_formatter import (
22
+ print_info,
23
+ print_final_success,
24
+ print_error_missing_api_key,
25
+ print_error_invalid_api_key,
26
+ print_error_rate_limit,
27
+ print_error_network,
28
+ print_error_general,
29
+ print_update_notification,
30
+ )
31
+ from .metadata.logo import print_logo
32
+ from .metadata import DESCRIPTION, CLI_ENTRY_POINT
33
+ from .metadata.version import get_version
34
+ from .formatter.help_formatter import print_enhanced_help
35
+ from .utils.version_check import check_for_update
36
+
37
+
38
+ def _is_url(source: str) -> bool:
39
+ """Detect if source is a URL (GitHub/GitLab) or local path."""
40
+ if not source:
41
+ return False
42
+ return (
43
+ source.startswith("http://")
44
+ or source.startswith("https://")
45
+ or source.startswith("git@")
46
+ or source.startswith("ssh://")
47
+ or "github.com" in source
48
+ or "gitlab.com" in source
49
+ or "bitbucket.org" in source
50
+ )
51
+
52
+
53
+ def _run_documentation_generation(repo_url, local_dir, args, config):
54
+ """Shared logic for running documentation generation."""
55
+ # Detect CI environment
56
+ is_ci = getattr(args, "ci", False) or os.environ.get("CI", "").lower() in (
57
+ "true",
58
+ "1",
59
+ "yes",
60
+ )
61
+
62
+ # Get GitHub token from argument, config, or environment variable
63
+ github_token = None
64
+ if repo_url:
65
+ github_token = (
66
+ args.token or config.get("github_token") or os.environ.get("GITHUB_TOKEN")
67
+ )
68
+ if not github_token and not is_ci:
69
+ print(
70
+ "⚠ Warning: No GitHub token provided.\n"
71
+ " • For public repos: Optional, but you may hit rate limits (60 requests/hour)\n"
72
+ " • For private repos: Required for access\n"
73
+ f" • To add a token: Run '{CLI_ENTRY_POINT} config update-github-token'"
74
+ )
75
+
76
+ # Merge config with CLI args (CLI takes precedence)
77
+ final_config = merge_config_with_args(config, args)
78
+
79
+ # Handle custom output path (for CI workflows)
80
+ output_dir = final_config["output_dir"]
81
+ if hasattr(args, "output_path") and args.output_path:
82
+ # Custom output path specified (e.g., 'docs/', 'documentation/')
83
+ output_dir = args.output_path
84
+
85
+ # Initialize the shared dictionary with inputs
86
+ shared = {
87
+ "repo_url": repo_url,
88
+ "local_dir": local_dir,
89
+ "project_name": args.name, # Can be None, FetchRepo will derive it
90
+ "github_token": github_token,
91
+ "output_dir": output_dir, # Base directory for CombineWiki output
92
+ # Add include/exclude patterns and max file size
93
+ "include_patterns": (
94
+ set(final_config["include_patterns"])
95
+ if final_config.get("include_patterns")
96
+ else DEFAULT_INCLUDE_PATTERNS
97
+ ),
98
+ "exclude_patterns": (
99
+ set(final_config["exclude_patterns"])
100
+ if final_config.get("exclude_patterns")
101
+ else DEFAULT_EXCLUDE_PATTERNS
102
+ ),
103
+ "max_file_size": final_config["max_file_size"],
104
+ # Add language for multi-language support
105
+ "language": final_config["language"],
106
+ # Add use_cache flag (inverse of no-cache flag)
107
+ "use_cache": final_config["use_cache"],
108
+ # Add max_abstraction_num parameter
109
+ "max_abstraction_num": final_config["max_abstractions"],
110
+ # Add documentation_mode parameter
111
+ "documentation_mode": final_config.get("documentation_mode", "minimal"),
112
+ # CI-specific flags
113
+ "ci_mode": is_ci,
114
+ "update_mode": getattr(args, "update", False),
115
+ "check_changes": getattr(args, "check_changes", False),
116
+ # Outputs will be populated by the nodes
117
+ "files": [],
118
+ "abstractions": [],
119
+ "relationships": {},
120
+ "component_order": [],
121
+ "components": [],
122
+ "final_output_dir": None,
123
+ }
124
+
125
+ # Display logo and starting message with repository/directory and language
126
+ if not is_ci:
127
+ print_logo()
128
+ print_info("Repository", repo_url or local_dir)
129
+ print_info("Language", final_config["language"].capitalize())
130
+ print_info("LLM caching", "Enabled" if final_config["use_cache"] else "Disabled")
131
+ if is_ci:
132
+ print_info("CI Mode", "Enabled")
133
+ if hasattr(args, "output_path") and args.output_path:
134
+ print_info("Output Path", args.output_path)
135
+
136
+ # Create the flow instance
137
+ wiki_flow = create_wiki_flow()
138
+
139
+ # Run the flow
140
+ start_time = time.time()
141
+ try:
142
+ wiki_flow.run(shared)
143
+ total_time = time.time() - start_time
144
+
145
+ # Print final success message
146
+ print_final_success(
147
+ "Success! Documents generated", total_time, shared["final_output_dir"]
148
+ )
149
+
150
+ # Check for updates (non-blocking, only if 24 hours have passed)
151
+ if not is_ci:
152
+ _check_for_updates_quietly()
153
+
154
+ # Handle change detection for CI
155
+ if shared.get("check_changes"):
156
+ # If docs were changed, exit with code 1
157
+ if shared.get("docs_changed", False):
158
+ sys.exit(1)
159
+ else:
160
+ sys.exit(0)
161
+ except ValueError as e:
162
+ # Handle missing/invalid API key
163
+ # Check for missing API key errors (provider-agnostic)
164
+ error_str = str(e).lower()
165
+ if "not found" in error_str and (
166
+ "api key" in error_str or "api_key" in error_str
167
+ ):
168
+ from .config import get_llm_provider
169
+ from .utils.llm_providers import get_display_name
170
+
171
+ try:
172
+ provider = get_llm_provider()
173
+ provider_display = get_display_name(provider)
174
+ print_error_missing_api_key(provider_display)
175
+ except Exception:
176
+ print_error_missing_api_key()
177
+ else:
178
+ print_error_general(e)
179
+ sys.exit(1)
180
+ except (IOError, OSError, ConnectionError, TimeoutError) as e:
181
+ # Check error type and show appropriate message
182
+ error_str = str(e).lower()
183
+ if (
184
+ "401" in error_str
185
+ or "unauthorized" in error_str
186
+ or "invalid api key" in error_str
187
+ ):
188
+ print_error_invalid_api_key()
189
+ elif "rate limit" in error_str or "429" in error_str:
190
+ print_error_rate_limit()
191
+ elif (
192
+ "connection" in error_str
193
+ or "timeout" in error_str
194
+ or "network" in error_str
195
+ ):
196
+ print_error_network()
197
+ else:
198
+ print_error_general(e)
199
+ sys.exit(1)
200
+
201
+
202
+ def main():
203
+ """Main CLI entry point."""
204
+ # Handle 'init' subcommand
205
+ if len(sys.argv) > 1 and sys.argv[1] == "init":
206
+ init_config()
207
+ return
208
+
209
+ # Handle 'config' subcommand
210
+ if len(sys.argv) > 1 and sys.argv[1] == "config":
211
+ handle_config_command()
212
+ return
213
+
214
+ # Handle 'mcp' subcommand
215
+ if len(sys.argv) > 1 and sys.argv[1] == "mcp":
216
+ from .mcp.server import run_mcp_server
217
+
218
+ run_mcp_server()
219
+ return
220
+
221
+ # Handle 'run' subcommand
222
+ if len(sys.argv) > 1 and sys.argv[1] == "run":
223
+ # Extract source argument (if provided, and not a flag)
224
+ source = None
225
+ remaining_args = []
226
+
227
+ if len(sys.argv) > 2 and not sys.argv[2].startswith("-"):
228
+ source = sys.argv[2]
229
+ remaining_args = sys.argv[3:] # Arguments after 'run source'
230
+ else:
231
+ remaining_args = sys.argv[2:] # Arguments after 'run' (no source)
232
+
233
+ # Determine repo_url or local_dir based on source
234
+ if source:
235
+ if _is_url(source):
236
+ repo_url = source
237
+ local_dir = None
238
+ else:
239
+ repo_url = None
240
+ local_dir = source
241
+ else:
242
+ # No source provided, use current directory
243
+ repo_url = None
244
+ local_dir = os.getcwd()
245
+
246
+ # Temporarily modify sys.argv to parse remaining arguments
247
+ original_argv = sys.argv[:]
248
+ try:
249
+ sys.argv = [sys.argv[0]] + remaining_args
250
+
251
+ # Check if config exists, if not, prompt user to run init
252
+ if not check_config_exists():
253
+ print("✘ WikiGen is not configured yet.")
254
+ print(
255
+ f"Please run '{CLI_ENTRY_POINT} init' to set up your configuration first."
256
+ )
257
+ sys.exit(1)
258
+
259
+ # Load saved configuration
260
+ config = load_config()
261
+
262
+ # Parse remaining arguments with enhanced help
263
+ parser = argparse.ArgumentParser(
264
+ description=DESCRIPTION,
265
+ formatter_class=argparse.RawDescriptionHelpFormatter,
266
+ add_help=False, # Disable default help to use our custom one
267
+ )
268
+
269
+ _add_common_arguments(parser, config)
270
+
271
+ args = parser.parse_args()
272
+
273
+ # Handle help display
274
+ if args.help:
275
+ print_enhanced_help()
276
+ sys.exit(0)
277
+
278
+ # Call shared function with categorized repo_url/local_dir
279
+ _run_documentation_generation(repo_url, local_dir, args, config)
280
+ finally:
281
+ # Always restore original sys.argv
282
+ sys.argv = original_argv
283
+ return
284
+
285
+ # Check if config exists, if not, prompt user to run init
286
+ if not check_config_exists():
287
+ print("✘ WikiGen is not configured yet.")
288
+ print(
289
+ f"Please run '{CLI_ENTRY_POINT} init' to set up your configuration first."
290
+ )
291
+ sys.exit(1)
292
+
293
+ # Load saved configuration
294
+ config = load_config()
295
+
296
+ # Parse arguments with enhanced help
297
+ parser = argparse.ArgumentParser(
298
+ description=DESCRIPTION,
299
+ formatter_class=argparse.RawDescriptionHelpFormatter,
300
+ add_help=False, # Disable default help to use our custom one
301
+ )
302
+
303
+ _add_common_arguments(parser, config)
304
+
305
+ # Create mutually exclusive group for source
306
+ source_group = parser.add_mutually_exclusive_group(required=False)
307
+ source_group.add_argument("--repo", help="URL of the public GitHub repository.")
308
+ source_group.add_argument("--dir", help="Path to local directory.")
309
+
310
+ args = parser.parse_args()
311
+
312
+ # Handle help display
313
+ if args.help:
314
+ print_enhanced_help()
315
+ sys.exit(0)
316
+
317
+ # Validate that either --repo or --dir is provided
318
+ if not args.repo and not args.dir:
319
+ print("Error: One of --repo or --dir is required.")
320
+ print("Use --help for more information.")
321
+ sys.exit(1)
322
+
323
+ # Call shared function with args.repo/args.dir
324
+ _run_documentation_generation(args.repo, args.dir, args, config)
325
+
326
+
327
+ def _add_common_arguments(parser, config):
328
+ """Add common arguments to the parser."""
329
+ # Add custom help option
330
+ parser.add_argument(
331
+ "-h",
332
+ "--help",
333
+ action="store_true",
334
+ help="Show enhanced help message and exit",
335
+ )
336
+
337
+ # Add version option
338
+ parser.add_argument(
339
+ "-v",
340
+ "--version",
341
+ action="version",
342
+ version=f"wikigen {get_version()}",
343
+ )
344
+
345
+ parser.add_argument(
346
+ "-n",
347
+ "--name",
348
+ help="Project name (optional, derived from repo/directory if omitted).",
349
+ )
350
+ parser.add_argument(
351
+ "-t",
352
+ "--token",
353
+ help="GitHub personal access token (optional, reads from GITHUB_TOKEN env var if not provided).",
354
+ )
355
+ parser.add_argument(
356
+ "-o",
357
+ "--output",
358
+ default=config.get("output_dir", "output"),
359
+ help="Base directory for output (default: from config).",
360
+ )
361
+ parser.add_argument(
362
+ "-i",
363
+ "--include",
364
+ nargs="+",
365
+ help="Include file patterns (e.g. '*.py' '*.js'). Defaults to common code files if not specified.",
366
+ )
367
+ parser.add_argument(
368
+ "-e",
369
+ "--exclude",
370
+ nargs="+",
371
+ help="Exclude file patterns (e.g. 'tests/*' 'docs/*'). Defaults to test/build directories if not specified.",
372
+ )
373
+ parser.add_argument(
374
+ "-s",
375
+ "--max-size",
376
+ type=int,
377
+ default=config.get("max_file_size", 100000),
378
+ help="Maximum file size in bytes (default: from config).",
379
+ )
380
+ # Add language parameter for multi-language support
381
+ parser.add_argument(
382
+ "--language",
383
+ default=config.get("language", "english"),
384
+ help="Language for the generated wiki (default: from config)",
385
+ )
386
+ # Add use_cache parameter to control LLM caching
387
+ parser.add_argument(
388
+ "--no-cache",
389
+ action="store_true",
390
+ help="Disable LLM response caching (default: caching enabled)",
391
+ )
392
+ # Add max_abstraction_num parameter to control the number of abstractions
393
+ parser.add_argument(
394
+ "--max-abstractions",
395
+ type=int,
396
+ default=config.get("max_abstractions", 10),
397
+ help="Maximum number of abstractions to identify (default: from config)",
398
+ )
399
+ # Add documentation mode parameter
400
+ parser.add_argument(
401
+ "--mode",
402
+ choices=["minimal", "comprehensive"],
403
+ default=None,
404
+ help="Documentation mode (default: from config)",
405
+ )
406
+ # CI/CD specific flags
407
+ parser.add_argument(
408
+ "--ci",
409
+ action="store_true",
410
+ help="Enable CI mode (non-interactive, uses defaults, better error messages)",
411
+ )
412
+ parser.add_argument(
413
+ "--update",
414
+ action="store_true",
415
+ help="Update existing documentation instead of overwriting (merges with existing docs)",
416
+ )
417
+ parser.add_argument(
418
+ "--output-path",
419
+ help="Custom output path for documentation (e.g., 'docs/', 'documentation/')",
420
+ )
421
+ parser.add_argument(
422
+ "--check-changes",
423
+ action="store_true",
424
+ help="Exit with code 1 if docs changed, 0 if unchanged (useful for conditional PR creation)",
425
+ )
426
+
427
+
428
+ def _check_for_updates_quietly():
429
+ """
430
+ Check for updates in the background without blocking the CLI.
431
+ Only checks if 24 hours have passed since last check.
432
+ Silently fails on any errors to not interrupt user workflow.
433
+ """
434
+ try:
435
+ # Only check if 24 hours have passed
436
+ if not should_check_for_updates():
437
+ return
438
+
439
+ current_version = get_version()
440
+ latest_version = check_for_update(current_version, timeout=5.0)
441
+
442
+ # Update timestamp after attempting check (prevents excessive API calls)
443
+ # Even if network fails, we update to avoid retrying immediately
444
+ update_last_check_timestamp()
445
+
446
+ # If update is available, show notification
447
+ if latest_version:
448
+ print_update_notification(current_version, latest_version)
449
+ except Exception:
450
+ # Silently fail - don't interrupt user workflow
451
+ # Catch all exceptions to ensure update checks never break the CLI
452
+ pass
453
+
454
+
455
+ def handle_config_command():
456
+ """Handle wikigen config commands."""
457
+ if len(sys.argv) < 3:
458
+ print("Usage: wikigen config <command>")
459
+ print("Commands:")
460
+ print(" show - Show current configuration")
461
+ print(" set <key> <value> - Set a configuration value")
462
+ print(
463
+ " update-api-key <provider> - Update API key for a provider (interactive)"
464
+ )
465
+ print(
466
+ " update-github-token [token] - Update GitHub token (interactive if no token provided)"
467
+ )
468
+ print("\nExamples:")
469
+ print(" wikigen config set llm-provider openai")
470
+ print(" wikigen config set llm-model gpt-4o-mini")
471
+ print(" wikigen config update-api-key gemini")
472
+ return
473
+
474
+ command = sys.argv[2]
475
+
476
+ if command == "show":
477
+ show_config()
478
+ elif command == "set":
479
+ if len(sys.argv) < 5:
480
+ print("Usage: wikigen config set <key> <value>")
481
+ print("Example: wikigen config set language spanish")
482
+ print("Example: wikigen config set llm-provider openai")
483
+ return
484
+ key = sys.argv[3]
485
+ value = sys.argv[4]
486
+ set_config_value(key, value)
487
+ elif command == "update-api-key":
488
+ if len(sys.argv) < 4:
489
+ print("Usage: wikigen config update-api-key <provider>")
490
+ print("Providers: gemini, openai, anthropic, openrouter")
491
+ return
492
+ provider = sys.argv[3]
493
+ update_api_key(provider)
494
+ elif command == "update-gemini-key":
495
+ # Legacy command, redirect to update-api-key
496
+ update_api_key("gemini")
497
+ elif command == "update-github-token":
498
+ if len(sys.argv) > 3:
499
+ # Token provided as argument
500
+ new_token = sys.argv[3]
501
+ update_github_token_direct(new_token)
502
+ else:
503
+ # Interactive mode
504
+ update_github_token()
505
+ else:
506
+ print(f"Unknown command: {command}")
507
+ print("Run 'wikigen config' to see available commands")
508
+
509
+
510
+ def show_config():
511
+ """Show current configuration."""
512
+ if not check_config_exists():
513
+ print(f"✘ No configuration found. Run '{CLI_ENTRY_POINT} init' first.")
514
+ return
515
+
516
+ config = load_config()
517
+ print(" Current WikiGen Configuration:")
518
+ print(f" LLM Provider: {config.get('llm_provider', 'Not set')}")
519
+ print(f" LLM Model: {config.get('llm_model', 'Not set')}")
520
+ print(f" Output Directory: {config.get('output_dir', 'Not set')}")
521
+ print(f" Language: {config.get('language', 'Not set')}")
522
+ print(f" Max Abstractions: {config.get('max_abstractions', 'Not set')}")
523
+ print(f" Max File Size: {config.get('max_file_size', 'Not set')}")
524
+ print(f" Use Cache: {config.get('use_cache', 'Not set')}")
525
+ print(f" Documentation Mode: {config.get('documentation_mode', 'Not set')}")
526
+
527
+ # Check if API keys are available
528
+ try:
529
+ from .config import get_api_key, get_github_token, get_llm_provider
530
+ from .utils.llm_providers import get_display_name
531
+
532
+ provider = get_llm_provider()
533
+ provider_display = get_display_name(provider)
534
+ api_key = get_api_key()
535
+ github_token = get_github_token()
536
+ print(f" {provider_display} API Key: {'✓ Set' if api_key else '✘ Not set'}")
537
+ print(f" GitHub Token: {'✓ Set' if github_token else '✘ Not set'}")
538
+ except (IOError, OSError, ValueError, ImportError) as e:
539
+ print(f" API Key: Unable to check ({e})")
540
+ print(f" GitHub Token: Unable to check ({e})")
541
+
542
+
543
+ def set_config_value(key, value):
544
+ """Set a configuration value."""
545
+ if not check_config_exists():
546
+ print(f"✘ No configuration found. Run '{CLI_ENTRY_POINT} init' first.")
547
+ return
548
+
549
+ config = load_config()
550
+
551
+ # Map CLI keys to config keys
552
+ key_mapping = {
553
+ "llm-provider": "llm_provider",
554
+ "llm-model": "llm_model",
555
+ }
556
+ config_key = key_mapping.get(key, key)
557
+
558
+ # Validate provider/model if setting those
559
+ if config_key == "llm_provider":
560
+ from .utils.llm_providers import get_provider_list
561
+
562
+ if value not in get_provider_list():
563
+ print(f"✘ Invalid provider: {value}")
564
+ print(f"Available providers: {', '.join(get_provider_list())}")
565
+ return
566
+
567
+ # Handle different value types
568
+ if config_key in ["max_abstractions", "max_file_size"]:
569
+ try:
570
+ value = int(value)
571
+ except ValueError:
572
+ print(f"✘ {key} must be a number")
573
+ return
574
+ elif config_key == "use_cache":
575
+ value = value.lower() in ["true", "1", "yes", "on"]
576
+ elif config_key in ["include_patterns", "exclude_patterns"]:
577
+ value = [pattern.strip() for pattern in value.split(",")]
578
+
579
+ config[config_key] = value
580
+ save_config(config)
581
+ print(f"✓ Updated {key} to {value}")
582
+
583
+
584
+ def _update_secret(
585
+ secret_key: str,
586
+ secret_value: str,
587
+ display_name: str,
588
+ allow_empty: bool = False,
589
+ ) -> None:
590
+ """
591
+ Common function to update a secret in keyring or config file.
592
+
593
+ Args:
594
+ secret_key: The keyring service/key name and config key (e.g., "gemini_api_key")
595
+ secret_value: The secret value to store (empty string removes it if allow_empty=True)
596
+ display_name: Human-readable name for messages (e.g., "Gemini API key")
597
+ allow_empty: If True, empty value removes the secret; if False, empty is invalid
598
+ """
599
+ try:
600
+ import keyring
601
+
602
+ KEYRING_AVAILABLE = True
603
+ except ImportError:
604
+ KEYRING_AVAILABLE = False
605
+
606
+ if KEYRING_AVAILABLE:
607
+ try:
608
+ if secret_value:
609
+ keyring.set_password("wikigen", secret_key, secret_value)
610
+ print(f"✓ {display_name} updated securely in keyring")
611
+ return
612
+ elif allow_empty:
613
+ keyring.delete_password("wikigen", secret_key)
614
+ print(f"✓ {display_name} removed from keyring")
615
+ return
616
+ else:
617
+ print(f"✘ {display_name} cannot be empty")
618
+ return
619
+ except (OSError, RuntimeError, AttributeError) as e:
620
+ print(f"✘ Failed to update keyring: {e}")
621
+ # Fall through to config file fallback
622
+
623
+ # Fallback to config file if keyring not available or failed
624
+ print("⚠ Keyring not available, updating config file (less secure)")
625
+ config = load_config()
626
+ if secret_value:
627
+ config[secret_key] = secret_value
628
+ save_config(config)
629
+ print(f"✓ {display_name} updated in config file")
630
+ elif allow_empty:
631
+ config.pop(secret_key, None)
632
+ save_config(config)
633
+ print(f"✓ {display_name} removed from config file")
634
+ else:
635
+ print(f"✘ {display_name} cannot be empty")
636
+
637
+
638
+ def update_api_key(provider: str):
639
+ """Update API key for a provider (interactive)."""
640
+ import getpass
641
+
642
+ from .utils.llm_providers import (
643
+ get_provider_info,
644
+ get_display_name,
645
+ requires_api_key,
646
+ )
647
+
648
+ try:
649
+ provider_info = get_provider_info(provider)
650
+ provider_display = get_display_name(provider)
651
+
652
+ if not requires_api_key(provider):
653
+ print(f"✘ {provider_display} does not require an API key")
654
+ return
655
+
656
+ keyring_key = provider_info.get("keyring_key")
657
+ key_name = f"{provider_display} API Key"
658
+
659
+ print(f"+ Update {key_name}")
660
+ new_key = getpass.getpass(f"Enter new {key_name}: ").strip()
661
+
662
+ if not new_key:
663
+ print("✘ API key cannot be empty")
664
+ return
665
+
666
+ _update_secret(keyring_key, new_key, key_name, allow_empty=False)
667
+ except ValueError as e:
668
+ print(f"✘ {e}")
669
+ print("Available providers: gemini, openai, anthropic, openrouter")
670
+
671
+
672
+ def update_github_token():
673
+ """Update GitHub token (interactive)."""
674
+ import getpass
675
+
676
+ print("+ Update GitHub Token")
677
+ new_token = getpass.getpass(
678
+ "Enter new GitHub token (or press Enter to remove): "
679
+ ).strip()
680
+
681
+ update_github_token_direct(new_token)
682
+
683
+
684
+ def update_github_token_direct(new_token: str) -> None:
685
+ """Update GitHub token directly."""
686
+ _update_secret("github_token", new_token, "GitHub token", allow_empty=True)
687
+
688
+
689
+ if __name__ == "__main__":
690
+ main()