luckyd-code 1.2.2__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 (127) hide show
  1. luckyd_code/__init__.py +54 -0
  2. luckyd_code/__main__.py +5 -0
  3. luckyd_code/_agent_loop.py +551 -0
  4. luckyd_code/_data_dir.py +73 -0
  5. luckyd_code/agent.py +38 -0
  6. luckyd_code/analytics/__init__.py +18 -0
  7. luckyd_code/analytics/reporter.py +195 -0
  8. luckyd_code/analytics/scanner.py +443 -0
  9. luckyd_code/analytics/smells.py +316 -0
  10. luckyd_code/analytics/trends.py +303 -0
  11. luckyd_code/api.py +473 -0
  12. luckyd_code/audit_daemon.py +845 -0
  13. luckyd_code/autonomous_fixer.py +473 -0
  14. luckyd_code/background.py +159 -0
  15. luckyd_code/backup.py +237 -0
  16. luckyd_code/brain/__init__.py +84 -0
  17. luckyd_code/brain/assembler.py +100 -0
  18. luckyd_code/brain/chunker.py +345 -0
  19. luckyd_code/brain/constants.py +73 -0
  20. luckyd_code/brain/embedder.py +163 -0
  21. luckyd_code/brain/graph.py +311 -0
  22. luckyd_code/brain/indexer.py +316 -0
  23. luckyd_code/brain/parser.py +140 -0
  24. luckyd_code/brain/retriever.py +234 -0
  25. luckyd_code/cli.py +894 -0
  26. luckyd_code/cli_commands/__init__.py +1 -0
  27. luckyd_code/cli_commands/audit.py +120 -0
  28. luckyd_code/cli_commands/background.py +83 -0
  29. luckyd_code/cli_commands/brain.py +87 -0
  30. luckyd_code/cli_commands/config.py +75 -0
  31. luckyd_code/cli_commands/dispatcher.py +695 -0
  32. luckyd_code/cli_commands/sessions.py +41 -0
  33. luckyd_code/cli_entry.py +147 -0
  34. luckyd_code/cli_utils.py +112 -0
  35. luckyd_code/config.py +205 -0
  36. luckyd_code/context.py +214 -0
  37. luckyd_code/cost_tracker.py +209 -0
  38. luckyd_code/error_reporter.py +508 -0
  39. luckyd_code/exceptions.py +39 -0
  40. luckyd_code/export.py +126 -0
  41. luckyd_code/feedback_analyzer.py +290 -0
  42. luckyd_code/file_watcher.py +258 -0
  43. luckyd_code/git/__init__.py +11 -0
  44. luckyd_code/git/auto_commit.py +157 -0
  45. luckyd_code/git/tools.py +85 -0
  46. luckyd_code/hooks.py +236 -0
  47. luckyd_code/indexer.py +280 -0
  48. luckyd_code/init.py +39 -0
  49. luckyd_code/keybindings.py +77 -0
  50. luckyd_code/log.py +55 -0
  51. luckyd_code/mcp/__init__.py +6 -0
  52. luckyd_code/mcp/client.py +184 -0
  53. luckyd_code/memory/__init__.py +19 -0
  54. luckyd_code/memory/manager.py +339 -0
  55. luckyd_code/metrics/__init__.py +5 -0
  56. luckyd_code/model_registry.py +131 -0
  57. luckyd_code/orchestrator.py +204 -0
  58. luckyd_code/permissions/__init__.py +1 -0
  59. luckyd_code/permissions/manager.py +103 -0
  60. luckyd_code/planner.py +361 -0
  61. luckyd_code/plugins.py +91 -0
  62. luckyd_code/py.typed +0 -0
  63. luckyd_code/retry.py +57 -0
  64. luckyd_code/router.py +417 -0
  65. luckyd_code/sandbox.py +156 -0
  66. luckyd_code/self_critique.py +2 -0
  67. luckyd_code/self_improve.py +274 -0
  68. luckyd_code/sessions.py +114 -0
  69. luckyd_code/settings.py +72 -0
  70. luckyd_code/skills/__init__.py +8 -0
  71. luckyd_code/skills/review.py +22 -0
  72. luckyd_code/skills/security.py +17 -0
  73. luckyd_code/tasks/__init__.py +1 -0
  74. luckyd_code/tasks/manager.py +102 -0
  75. luckyd_code/templates/icon-192.png +0 -0
  76. luckyd_code/templates/icon-512.png +0 -0
  77. luckyd_code/templates/index.html +1965 -0
  78. luckyd_code/templates/manifest.json +14 -0
  79. luckyd_code/templates/src/app.js +694 -0
  80. luckyd_code/templates/src/body.html +767 -0
  81. luckyd_code/templates/src/cdn.txt +2 -0
  82. luckyd_code/templates/src/style.css +474 -0
  83. luckyd_code/templates/sw.js +31 -0
  84. luckyd_code/templates/test.html +6 -0
  85. luckyd_code/themes.py +48 -0
  86. luckyd_code/tools/__init__.py +97 -0
  87. luckyd_code/tools/agent_tools.py +65 -0
  88. luckyd_code/tools/bash.py +360 -0
  89. luckyd_code/tools/brain_tools.py +137 -0
  90. luckyd_code/tools/browser.py +369 -0
  91. luckyd_code/tools/datetime_tool.py +34 -0
  92. luckyd_code/tools/dockerfile_gen.py +212 -0
  93. luckyd_code/tools/file_ops.py +381 -0
  94. luckyd_code/tools/game_gen.py +360 -0
  95. luckyd_code/tools/git_tools.py +130 -0
  96. luckyd_code/tools/git_worktree.py +63 -0
  97. luckyd_code/tools/path_validate.py +64 -0
  98. luckyd_code/tools/project_gen.py +187 -0
  99. luckyd_code/tools/readme_gen.py +227 -0
  100. luckyd_code/tools/registry.py +157 -0
  101. luckyd_code/tools/shell_detect.py +109 -0
  102. luckyd_code/tools/web.py +89 -0
  103. luckyd_code/tools/youtube.py +187 -0
  104. luckyd_code/tools_bridge.py +144 -0
  105. luckyd_code/undo.py +126 -0
  106. luckyd_code/update.py +60 -0
  107. luckyd_code/verify.py +360 -0
  108. luckyd_code/web_app.py +176 -0
  109. luckyd_code/web_routes/__init__.py +23 -0
  110. luckyd_code/web_routes/background.py +73 -0
  111. luckyd_code/web_routes/brain.py +109 -0
  112. luckyd_code/web_routes/cost.py +12 -0
  113. luckyd_code/web_routes/files.py +133 -0
  114. luckyd_code/web_routes/memories.py +94 -0
  115. luckyd_code/web_routes/misc.py +67 -0
  116. luckyd_code/web_routes/project.py +48 -0
  117. luckyd_code/web_routes/review.py +20 -0
  118. luckyd_code/web_routes/sessions.py +44 -0
  119. luckyd_code/web_routes/settings.py +43 -0
  120. luckyd_code/web_routes/static.py +70 -0
  121. luckyd_code/web_routes/update.py +19 -0
  122. luckyd_code/web_routes/ws.py +237 -0
  123. luckyd_code-1.2.2.dist-info/METADATA +297 -0
  124. luckyd_code-1.2.2.dist-info/RECORD +127 -0
  125. luckyd_code-1.2.2.dist-info/WHEEL +4 -0
  126. luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
  127. luckyd_code-1.2.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,41 @@
1
+ """Handle /sessions commands."""
2
+
3
+
4
+ def handle_sessions_command(repl, args):
5
+ """Handle /sessions list|save|load|delete commands."""
6
+ from ..cli_utils import console
7
+ from ..sessions import save_session, load_session, list_sessions, delete_session
8
+
9
+ if not args:
10
+ console.print("[yellow]Usage: /sessions list|save <name>|load <name>|delete <name>[/yellow]")
11
+ return
12
+
13
+ sub = args[0].lower()
14
+
15
+ if sub == "list":
16
+ result = list_sessions()
17
+ console.print(result)
18
+
19
+ elif sub == "save":
20
+ name = " ".join(args[1:]) if len(args) > 1 else "unnamed"
21
+ result = save_session(name, repl.context)
22
+ console.print(f"[green]{result}[/green]")
23
+
24
+ elif sub == "load":
25
+ if len(args) < 2:
26
+ console.print("[yellow]Usage: /sessions load <name>[/yellow]")
27
+ return
28
+ name = " ".join(args[1:])
29
+ result = load_session(name, repl.context)
30
+ console.print(f"[cyan]{result}[/cyan]")
31
+
32
+ elif sub == "delete":
33
+ if len(args) < 2:
34
+ console.print("[yellow]Usage: /sessions delete <name>[/yellow]")
35
+ return
36
+ name = " ".join(args[1:])
37
+ result = delete_session(name)
38
+ console.print(f"[yellow]{result}[/yellow]")
39
+
40
+ else:
41
+ console.print(f"[red]Unknown: /sessions {sub}[/red]")
@@ -0,0 +1,147 @@
1
+ """CLI entry point — argument parsing, first-run wizard, and main dispatch."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ # Patch asyncio to allow nested event loops. This is needed because
9
+ # BackgroundAgent / KnowledgeGraph initialisation may start a loop before
10
+ # prompt_toolkit's synchronous session.prompt() runs, which would otherwise
11
+ # raise: RuntimeError: asyncio.run() cannot be called from a running event loop
12
+ try:
13
+ import nest_asyncio
14
+ nest_asyncio.apply()
15
+ except ImportError:
16
+ pass # Installed below; safe to ignore on first import before pip install
17
+
18
+ from .config import Config
19
+
20
+
21
+ def parse_args(argv=None):
22
+ parser = argparse.ArgumentParser(
23
+ description="LuckyD Code — AI coding assistant in your terminal",
24
+ )
25
+ parser.add_argument("--model", default=None, help="Model to use (default: deepseek-v4-flash)")
26
+ parser.add_argument(
27
+ "--provider", default=None, choices=["deepseek"],
28
+ help="API provider (default: deepseek)",
29
+ )
30
+ parser.add_argument("--dir", default=None, help="Working directory (default: current)")
31
+ parser.add_argument("--temperature", type=float, default=None, help="Model temperature (default: 0.7)")
32
+ parser.add_argument("--system-prompt", default=None, help="Path to custom system prompt file")
33
+ parser.add_argument("--version", action="store_true", help="Show version and exit")
34
+ parser.add_argument("--update", action="store_true", help="Update to latest version and exit")
35
+ parser.add_argument("--web", action="store_true", help="Launch web UI server")
36
+ parser.add_argument("--port", type=int, default=8000, help="Web UI port (default: 8000)")
37
+ parser.add_argument("--host", default="0.0.0.0", help="Web UI host (default: 0.0.0.0)")
38
+ parser.add_argument(
39
+ "--daemon", action="store_true",
40
+ help="Start the background audit daemon alongside the REPL",
41
+ )
42
+ return parser.parse_args(argv)
43
+
44
+
45
+ def main(argv=None):
46
+ args = parse_args(argv)
47
+
48
+ # Lazy imports — only load the heavy CLI/web modules when actually running
49
+ from .update import get_version, do_update
50
+
51
+ if args.version:
52
+ print(f"LuckyD Code v{get_version()}")
53
+ return 0
54
+
55
+ if args.update:
56
+ print("Updating LuckyD Code...")
57
+ print(do_update())
58
+ return 0
59
+
60
+ if args.web:
61
+ from .web_app import run_web
62
+ try:
63
+ run_web(host=args.host, port=args.port)
64
+ except ValueError as e:
65
+ print(f"Error: {e}", file=sys.stderr)
66
+ return 1
67
+ return 0
68
+
69
+ if args.dir:
70
+ os.chdir(args.dir)
71
+
72
+ config = Config.from_args(args)
73
+
74
+ if args.system_prompt:
75
+ try:
76
+ with open(args.system_prompt, encoding="utf-8") as f:
77
+ custom = f.read().strip()
78
+ if custom:
79
+ config.system_prompt = custom
80
+ except Exception as e:
81
+ print(f"Warning: could not load system prompt from {args.system_prompt}: {e}", file=sys.stderr)
82
+
83
+ # First-run wizard: interactive API key setup
84
+ if not config.api_key:
85
+ print()
86
+ print("=" * 50)
87
+ print(" Welcome to LuckyD Code!")
88
+ print("=" * 50)
89
+ print()
90
+ print("No API key found. Let's get you set up.")
91
+ print()
92
+ print("You'll need a DeepSeek API key.")
93
+ print(" Get one at: https://platform.deepseek.com")
94
+ print()
95
+ print("Paste your API key below (it will be saved to .env):")
96
+ try:
97
+ key = input(" DEEPSEEK_API_KEY = ").strip()
98
+ except (EOFError, KeyboardInterrupt):
99
+ print("\n\nSetup cancelled. Run again when you have an API key.")
100
+ return 1
101
+ if key:
102
+ env_path = Path(__file__).resolve().parent.parent / ".env"
103
+ if "DEEPSEEK_API_KEY=" in (env_path.read_text() if env_path.exists() else ""):
104
+ lines = env_path.read_text().splitlines()
105
+ new_lines = []
106
+ for line in lines:
107
+ if line.startswith("DEEPSEEK_API_KEY="):
108
+ new_lines.append(f"DEEPSEEK_API_KEY={key}")
109
+ else:
110
+ new_lines.append(line)
111
+ env_path.write_text("\n".join(new_lines) + "\n")
112
+ else:
113
+ with env_path.open("a") as f:
114
+ f.write(f"\nDEEPSEEK_API_KEY={key}\n")
115
+ config.api_key = key
116
+ print("✓ API key saved to .env")
117
+ else:
118
+ print("No key entered. Set DEEPSEEK_API_KEY in .env and try again.")
119
+ return 1
120
+ print()
121
+
122
+ try:
123
+ config.validate()
124
+ except ValueError as e:
125
+ print(f"Error: {e}", file=sys.stderr)
126
+ return 1
127
+
128
+ from .cli import Repl
129
+ daemon_enabled = getattr(args, "daemon", False)
130
+ repl = Repl(config, daemon=daemon_enabled)
131
+ try:
132
+ repl.run()
133
+ except KeyboardInterrupt:
134
+ print("\nGoodbye!")
135
+ return 0
136
+ except Exception as exc:
137
+ # ── Error Reporter: safe, opt-in issue creation ──────────
138
+ # This only fires for *unexpected* exceptions (not KeyboardInterrupt).
139
+ # The user is prompted before anything is sent. See error_reporter.py.
140
+ from .error_reporter import capture_unhandled
141
+ capture_unhandled(exc)
142
+ raise # re-raise so the process exits with a non-zero code
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
@@ -0,0 +1,112 @@
1
+ """Shared I/O utilities for the CLI REPL."""
2
+
3
+ import logging
4
+ import sys
5
+ import time as _time
6
+ from typing import Optional
7
+
8
+ from rich.console import Console
9
+
10
+ from . import settings as cfg
11
+
12
+ console = Console()
13
+ _logger = logging.getLogger("luckyd_code.cli_utils")
14
+
15
+
16
+ def play_completion_sound(success: bool = True):
17
+ """Play a notification sound when a response completes."""
18
+ try:
19
+ settings = cfg.load_settings()
20
+ enabled = settings.get("completion_sound", True)
21
+ if not enabled:
22
+ return
23
+
24
+ if sys.platform == "win32":
25
+ import winsound
26
+ if success:
27
+ winsound.PlaySound("SystemExclamation", winsound.SND_ALIAS | winsound.SND_ASYNC | winsound.SND_NODEFAULT)
28
+ else:
29
+ winsound.PlaySound("SystemHand", winsound.SND_ALIAS | winsound.SND_ASYNC | winsound.SND_NODEFAULT)
30
+ else:
31
+ if success:
32
+ sys.stdout.write("\a")
33
+ else:
34
+ for _ in range(3):
35
+ sys.stdout.write("\a")
36
+ _time.sleep(0.08)
37
+ sys.stdout.flush()
38
+ except Exception:
39
+ _logger.debug("Completion sound failed, falling back to terminal bell", exc_info=True)
40
+ try:
41
+ if success:
42
+ sys.stdout.write("\a")
43
+ else:
44
+ for _ in range(3):
45
+ sys.stdout.write("\a")
46
+ _time.sleep(0.08)
47
+ sys.stdout.flush()
48
+ except Exception:
49
+ _logger.debug("Terminal bell also failed")
50
+
51
+
52
+ def init_prompt_session():
53
+ """Create prompt session with custom keybindings."""
54
+ from prompt_toolkit import PromptSession
55
+ from prompt_toolkit.history import FileHistory
56
+ from .keybindings import apply_keybindings
57
+
58
+ try:
59
+ return PromptSession(
60
+ history=FileHistory(".luckyd_history"),
61
+ key_bindings=apply_keybindings(),
62
+ multiline=True,
63
+ )
64
+ except Exception:
65
+ _logger.debug("PromptSession creation failed, trying fallback", exc_info=True)
66
+
67
+ try:
68
+ from prompt_toolkit.output.vt100 import Vt100_Output
69
+ from prompt_toolkit.data_structures import Size
70
+ import shutil
71
+
72
+ def _get_size():
73
+ ts = shutil.get_terminal_size()
74
+ return Size(rows=ts.lines, columns=ts.columns)
75
+
76
+ return PromptSession(
77
+ history=FileHistory(".luckyd_history"),
78
+ key_bindings=apply_keybindings(),
79
+ output=Vt100_Output(sys.stdout, get_size=_get_size),
80
+ multiline=True,
81
+ )
82
+ except Exception:
83
+ _logger.warning("Failed to create prompt session", exc_info=True)
84
+ return None
85
+
86
+
87
+ def read_input(session) -> Optional[str]:
88
+ """Read a line of input from the user."""
89
+ if session:
90
+ try:
91
+ return session.prompt(">>> ")
92
+ except KeyboardInterrupt:
93
+ return None
94
+ except EOFError:
95
+ return "__EOF__"
96
+
97
+ try:
98
+ lines = []
99
+ while True:
100
+ line = input(">>> " if not lines else "... ")
101
+ if line.rstrip().endswith("\\"):
102
+ lines.append(line.rstrip()[:-1])
103
+ else:
104
+ lines.append(line)
105
+ break
106
+ return "\n".join(lines)
107
+ except EOFError:
108
+ if lines:
109
+ return "\n".join(lines)
110
+ return "__EOF__"
111
+ except KeyboardInterrupt:
112
+ return None
luckyd_code/config.py ADDED
@@ -0,0 +1,205 @@
1
+ """Configuration management with validation and file persistence."""
2
+
3
+ import os
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .log import get_logger
9
+ from ._data_dir import data_path, legacy_path
10
+
11
+ DEFAULT_SYSTEM_PROMPT = """You are LuckyD Code, an AI coding assistant in a terminal.
12
+
13
+ You can answer ANY question — coding, general knowledge, everyday questions, or anything else the user asks. You are a helpful general-purpose assistant who happens to specialise in code.
14
+
15
+ ## Core Rules
16
+ - For coding tasks: use Bash/Read/Write/Edit/Glob/Grep tools as needed, working directory is the project root
17
+ - For general questions: answer directly and concisely from your knowledge
18
+ - Think step by step, but only show the final answer
19
+ - No unnecessary padding, greetings, or filler — just useful output
20
+
21
+ ## Pre-Edit Checklist (MANDATORY before any Write/Edit)
22
+ 1. **Read the file first** — Never edit a file you haven't read this turn
23
+ 2. **Understand the context** — Check how the code is used elsewhere (Grep for callers/imports)
24
+ 3. **Match existing patterns** — Follow the project's conventions, naming, style
25
+ 4. **Plan the minimal change** — Only touch what's necessary; no refactoring sprees
26
+ 5. **Preview mentally** — What will break? What edge cases exist?
27
+
28
+ ## Post-Edit Verification
29
+ After every Write or Edit:
30
+ - Your changes will be automatically verified (syntax, consistency, tests)
31
+ - If verification fails, you MUST fix the issues — don't ignore them
32
+ - Tests failing after your change? Fix them immediately
33
+
34
+ ## Self-Critique
35
+ Before presenting your final answer, review it:
36
+ - Did I actually solve the user's problem?
37
+ - Could this be done more simply?
38
+ - Are there edge cases I missed?
39
+ - Is the code safe (no injection, no leaked secrets)?
40
+
41
+ ## Quality Standards
42
+ - **Correctness over cleverness** — Working code beats elegant code
43
+ - **Minimal diffs** — Small, focused changes are easier to review and revert
44
+ - **Error handling** — Don't let errors pass silently
45
+ - **No dead code** — Remove unused imports, variables, functions
46
+ - **Type safety** — Use type hints where the project uses them"""
47
+
48
+ CONFIG_FILE = data_path("config.json")
49
+ _LEGACY_CONFIG_FILE = legacy_path("config.json")
50
+
51
+
52
+ def load_config_file() -> dict[str, Any]:
53
+ """Load persistent config from the data directory.
54
+
55
+ Checks the primary location first, then falls back to the legacy
56
+ location for backward compatibility.
57
+ """
58
+ for path in (CONFIG_FILE, _LEGACY_CONFIG_FILE):
59
+ if path.exists():
60
+ try:
61
+ return json.loads(path.read_text(encoding="utf-8"))
62
+ except (json.JSONDecodeError, OSError) as e:
63
+ get_logger().warning(f"Could not load config file: {e}")
64
+ return {}
65
+
66
+
67
+ def save_config_file(config: dict):
68
+ """Save config to the data directory."""
69
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
70
+ try:
71
+ CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
72
+ except OSError as e:
73
+ get_logger().warning(f"Could not save config file: {e}")
74
+
75
+
76
+ class Config:
77
+ """Application configuration with validation."""
78
+
79
+ def __init__(self):
80
+ saved = load_config_file()
81
+
82
+ self.provider: str = saved.get("provider", "deepseek")
83
+ self.base_url: str = saved.get("base_url", "https://api.deepseek.com/v1")
84
+ self.api_key: str = self._resolve_api_key()
85
+ self.model: str = saved.get("model", "deepseek-v4-flash")
86
+ self.max_tokens: int = saved.get("max_tokens", 4096)
87
+ self.temperature: float = saved.get("temperature", 0.7)
88
+ self.system_prompt: str = saved.get("system_prompt", DEFAULT_SYSTEM_PROMPT)
89
+ self.working_directory: str = saved.get("working_directory", os.getcwd())
90
+ self.max_context_messages: int = saved.get("max_context_messages", 100)
91
+ self.log_level: str = saved.get("log_level", "INFO")
92
+
93
+ def _resolve_api_key(self) -> str:
94
+ provider_env = f"{self.provider.upper()}_API_KEY"
95
+ for path in (Path(__file__).parent.parent / ".env", Path(".env")):
96
+ if path.exists():
97
+ try:
98
+ lines = path.read_text().splitlines()
99
+ for line in lines:
100
+ line = line.strip()
101
+ # Match both DEEPSEEK_API_KEY and <PROVIDER>_API_KEY
102
+ if line.startswith(f"{provider_env}="):
103
+ return line.split("=", 1)[1].strip("\"'")
104
+ if line.startswith("DEEPSEEK_API_KEY=") and self.provider == "deepseek":
105
+ return line.split("=", 1)[1].strip("\"'")
106
+ except Exception:
107
+ get_logger().warning("Could not read .env file: %s", path, exc_info=True)
108
+
109
+ # Fallback: environment variables (provider-specific first, then legacy)
110
+ key = os.environ.get(provider_env) or os.environ.get("DEEPSEEK_API_KEY")
111
+ if key:
112
+ return key
113
+ return ""
114
+
115
+ def validate(self):
116
+ """Validate config and raise ValueError with clear message on failure."""
117
+ errors = []
118
+
119
+ # Supported providers (must match _provider_urls in from_args)
120
+ _valid_providers = {"deepseek"}
121
+ if self.provider not in _valid_providers:
122
+ errors.append(
123
+ f"provider must be one of {sorted(_valid_providers)} (got: {self.provider})"
124
+ )
125
+
126
+ if not self.api_key:
127
+ key_env = f"{self.provider.upper()}_API_KEY"
128
+ errors.append(
129
+ f"{key_env} is not set. "
130
+ "Set it as an environment variable or in a .env file."
131
+ )
132
+
133
+ if not self.base_url:
134
+ errors.append("base_url is not set")
135
+ elif not self.base_url.startswith(("http://", "https://")):
136
+ errors.append(f"base_url must start with http:// or https:// (got: {self.base_url})")
137
+
138
+ if self.max_tokens < 1 or self.max_tokens > 32000:
139
+ errors.append(f"max_tokens must be between 1 and 32000 (got: {self.max_tokens})")
140
+
141
+ if self.temperature < 0 or self.temperature > 2:
142
+ errors.append(f"temperature must be between 0 and 2 (got: {self.temperature})")
143
+
144
+ if self.max_context_messages < 2:
145
+ errors.append(f"max_context_messages must be at least 2 (got: {self.max_context_messages})")
146
+
147
+ if errors:
148
+ raise ValueError("\n".join(errors))
149
+
150
+ def to_dict(self) -> dict:
151
+ """Export config as dict (excluding API key)."""
152
+ return {
153
+ "provider": self.provider,
154
+ "base_url": self.base_url,
155
+ "model": self.model,
156
+ "max_tokens": self.max_tokens,
157
+ "temperature": self.temperature,
158
+ "max_context_messages": self.max_context_messages,
159
+ "log_level": self.log_level,
160
+ }
161
+
162
+ def save(self):
163
+ """Persist current config (excluding API key) to config file."""
164
+ save_config_file(self.to_dict())
165
+
166
+ @classmethod
167
+ def from_args(cls, args=None):
168
+ cfg = cls()
169
+ if args:
170
+ if hasattr(args, "model") and args.model:
171
+ cfg.model = args.model
172
+ if hasattr(args, "temperature") and args.temperature is not None:
173
+ cfg.temperature = args.temperature
174
+ if hasattr(args, "system_prompt") and args.system_prompt:
175
+ cfg.system_prompt = args.system_prompt
176
+ if hasattr(args, "dir") and args.dir:
177
+ cfg.working_directory = args.dir
178
+ if hasattr(args, "provider") and args.provider:
179
+ cfg.provider = args.provider
180
+ # Only override base_url if none was persisted — derive it
181
+ # from the provider name rather than hardcoding DeepSeek.
182
+ if "base_url" not in load_config_file():
183
+ _provider_urls = {
184
+ "deepseek": "https://api.deepseek.com/v1",
185
+ }
186
+ cfg.base_url = _provider_urls.get(
187
+ cfg.provider, cfg.base_url
188
+ )
189
+ # Re-resolve key now that provider is set from args
190
+ cfg.api_key = cfg._resolve_api_key()
191
+ return cfg
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Module-level convenience helpers (used by generator tools)
196
+ # ---------------------------------------------------------------------------
197
+
198
+ def get_api_key() -> str:
199
+ """Return the resolved DeepSeek API key."""
200
+ return Config()._resolve_api_key()
201
+
202
+
203
+ def get_base_url() -> str:
204
+ """Return the configured API base URL."""
205
+ return Config().base_url