multi-forge 0.2.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.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,303 @@
1
+ """CLI commands for Forge runtime configuration.
2
+
3
+ Manages ~/.forge/config.yaml — global runtime preferences that affect
4
+ CLI and session behavior (not proxy routing).
5
+
6
+ Patterns:
7
+ - show: matches forge proxy show (syntax-highlighted YAML)
8
+ - set: matches forge proxy set (type coercion, atomic write)
9
+ - edit: matches forge proxy edit ($EDITOR + validation)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ from dataclasses import fields
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import click
24
+ from rich.console import Console
25
+ from rich.syntax import Syntax
26
+
27
+ from forge.core.paths import display_path
28
+ from forge.runtime_config import (
29
+ RuntimeConfig,
30
+ ensure_config,
31
+ get_config_path,
32
+ load_runtime_config,
33
+ reset_runtime_config,
34
+ write_runtime_config,
35
+ )
36
+
37
+
38
+ @click.group(invoke_without_command=True)
39
+ @click.pass_context
40
+ def config(ctx: click.Context) -> None:
41
+ """Manage Forge global configuration.
42
+
43
+ \b
44
+ Configuration file: ~/.forge/config.yaml
45
+ Auto-created with documented defaults on first access.
46
+
47
+ \b
48
+ Examples:
49
+ forge config # Show effective config
50
+ forge config set proxy_mode=sidecar
51
+ forge config edit # Open in $EDITOR
52
+ """
53
+ if ctx.invoked_subcommand is None:
54
+ ctx.invoke(show_cmd)
55
+
56
+
57
+ @config.command("show")
58
+ @click.option("--raw", is_flag=True, help="Output raw YAML without syntax highlighting")
59
+ def show_cmd(raw: bool = False) -> None:
60
+ """Show effective runtime configuration.
61
+
62
+ Displays current values (from file + defaults + env var overrides).
63
+ """
64
+ console = Console(width=200)
65
+ config_path = ensure_config()
66
+
67
+ rc = load_runtime_config()
68
+ env_sources: dict[str, str] = getattr(rc, "_env_sources", {})
69
+
70
+ import yaml
71
+
72
+ effective: dict[str, Any] = {}
73
+ for f in fields(RuntimeConfig):
74
+ effective[f.name] = getattr(rc, f.name)
75
+
76
+ content = yaml.dump(effective, default_flow_style=False, sort_keys=False)
77
+
78
+ if raw:
79
+ console.print(content, end="")
80
+ else:
81
+ console.print("[bold]Forge Runtime Config[/bold]")
82
+ console.print(f"[bold]Path:[/bold] {display_path(config_path)}")
83
+ if env_sources:
84
+ overrides = ", ".join(f"{v}={k}" for k, v in env_sources.items())
85
+ console.print(f"[bold]Env overrides:[/bold] {overrides}")
86
+ console.print()
87
+ syntax = Syntax(content, "yaml", theme="monokai")
88
+ console.print(syntax)
89
+
90
+
91
+ @config.command("set")
92
+ @click.argument("key_value")
93
+ def set_cmd(key_value: str) -> None:
94
+ """Set a configuration value.
95
+
96
+ \b
97
+ Examples:
98
+ forge config set proxy_mode=sidecar
99
+ forge config set status_timeout=0.5
100
+ forge config set context_limit=1000000
101
+ """
102
+ console = Console(width=200)
103
+
104
+ if "=" not in key_value:
105
+ console.print(f"[red]Error:[/red] Expected format: key=value (got: {key_value})")
106
+ sys.exit(1)
107
+
108
+ key, value = key_value.split("=", 1)
109
+
110
+ known_fields = {f.name: f for f in fields(RuntimeConfig)}
111
+ if key not in known_fields:
112
+ console.print(f"[red]Error:[/red] Unknown config key: '{key}'")
113
+ console.print(f"\n[dim]Available keys: {', '.join(sorted(known_fields))}[/dim]")
114
+ sys.exit(1)
115
+
116
+ coerced_value: Any = _coerce_value(key, value, known_fields[key])
117
+ if coerced_value is _COERCE_ERROR:
118
+ console.print(f"[red]Error:[/red] Invalid value for '{key}': {value}")
119
+ sys.exit(1)
120
+
121
+ config_path = get_config_path()
122
+ if config_path.is_file():
123
+ from ruamel.yaml import YAML
124
+
125
+ ruamel = YAML()
126
+ ruamel.preserve_quotes = True
127
+ with open(config_path) as f:
128
+ data = ruamel.load(f) or {}
129
+ else:
130
+ data = {}
131
+
132
+ data[key] = coerced_value
133
+
134
+ try:
135
+ RuntimeConfig(**{k: v for k, v in dict(data).items() if k in known_fields})
136
+ except (ValueError, TypeError) as e:
137
+ console.print(f"[red]Error:[/red] Invalid configuration: {e}")
138
+ sys.exit(1)
139
+
140
+ write_runtime_config(data)
141
+ console.print(f"[green]Set[/green] {key}={coerced_value}")
142
+
143
+
144
+ @config.command("edit")
145
+ def edit_cmd() -> None:
146
+ """Open runtime configuration in $EDITOR.
147
+
148
+ Creates the file with defaults if it doesn't exist.
149
+ Validates changes before applying.
150
+ """
151
+ console = Console(width=200)
152
+
153
+ config_path = ensure_config()
154
+ editor = os.environ.get("EDITOR", "vim")
155
+
156
+ if not shutil.which(editor):
157
+ console.print(f"[red]Error:[/red] Editor '{editor}' not found. Set $EDITOR to an available editor.")
158
+ sys.exit(1)
159
+
160
+ # Copy to temp file for safe editing
161
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
162
+ tmp.write(config_path.read_text())
163
+ tmp_path = Path(tmp.name)
164
+
165
+ success = False
166
+ try:
167
+ result = subprocess.run([editor, str(tmp_path)])
168
+ if result.returncode != 0:
169
+ console.print(f"[red]Error:[/red] Editor exited with code {result.returncode}")
170
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
171
+ sys.exit(1)
172
+
173
+ # Validate edited YAML (use ruamel for consistency with write path)
174
+ from ruamel.yaml import YAML
175
+
176
+ ruamel = YAML()
177
+ try:
178
+ with open(tmp_path) as f:
179
+ edited_data = ruamel.load(f)
180
+ except Exception as e:
181
+ console.print(f"[red]Error:[/red] Invalid YAML: {e}")
182
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
183
+ sys.exit(1)
184
+
185
+ if edited_data is None:
186
+ edited_data = {}
187
+
188
+ if not isinstance(edited_data, dict):
189
+ console.print("[red]Error:[/red] Config must be a YAML mapping")
190
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
191
+ sys.exit(1)
192
+
193
+ known_fields = {f.name for f in fields(RuntimeConfig)}
194
+ try:
195
+ RuntimeConfig(**{k: v for k, v in dict(edited_data).items() if k in known_fields})
196
+ except (ValueError, TypeError) as e:
197
+ console.print(f"[red]Error:[/red] Invalid configuration: {e}")
198
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
199
+ sys.exit(1)
200
+
201
+ write_runtime_config(dict(edited_data))
202
+ success = True
203
+ console.print("[green]Updated[/green] runtime configuration")
204
+
205
+ finally:
206
+ if success and tmp_path.exists():
207
+ try:
208
+ tmp_path.unlink()
209
+ except OSError:
210
+ pass
211
+
212
+
213
+ @config.command("reset")
214
+ @click.argument("key", required=False)
215
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
216
+ @click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
217
+ def reset_cmd(key: str | None = None, yes: bool = False, force: bool = False) -> None:
218
+ """Reset configuration to defaults.
219
+
220
+ With KEY: removes that key (reverts to built-in default).
221
+ Without KEY: deletes the entire config file.
222
+ """
223
+ yes = yes or force
224
+ console = Console(width=200)
225
+ config_path = get_config_path()
226
+
227
+ if not config_path.is_file():
228
+ console.print("[dim]No config file to reset (already using defaults).[/dim]")
229
+ return
230
+
231
+ if key is None:
232
+ if not yes:
233
+ if not click.confirm("Reset all configuration to defaults?"):
234
+ console.print("[dim]Cancelled.[/dim]")
235
+ return
236
+ config_path.unlink()
237
+ reset_runtime_config()
238
+ console.print("[green]Reset[/green] all configuration to defaults")
239
+ console.print(f"[dim]Removed {display_path(config_path)}[/dim]")
240
+ return
241
+
242
+ known_fields = {f.name for f in fields(RuntimeConfig)}
243
+ if key not in known_fields:
244
+ console.print(f"[red]Error:[/red] Unknown config key: '{key}'")
245
+ console.print(f"\n[dim]Available keys: {', '.join(sorted(known_fields))}[/dim]")
246
+ sys.exit(1)
247
+
248
+ from ruamel.yaml import YAML
249
+
250
+ ruamel = YAML()
251
+ ruamel.preserve_quotes = True
252
+ with open(config_path) as f:
253
+ data = ruamel.load(f) or {}
254
+
255
+ if key not in data:
256
+ console.print(f"[dim]Key '{key}' not in config (already using default).[/dim]")
257
+ return
258
+
259
+ del data[key]
260
+
261
+ if data:
262
+ write_runtime_config(dict(data))
263
+ else:
264
+ config_path.unlink()
265
+ reset_runtime_config()
266
+
267
+ default_val = getattr(RuntimeConfig(), key)
268
+ console.print(f"[green]Reset[/green] {key} (default: {default_val})")
269
+
270
+
271
+ # --- Helpers ---
272
+
273
+ _COERCE_ERROR = object()
274
+
275
+
276
+ def _coerce_value(key: str, value: str, field_info: Any) -> Any:
277
+ """Coerce string CLI value to the field's expected Python type."""
278
+ field_type = field_info.type
279
+
280
+ # Compare actual types (not string representations)
281
+ # With `from __future__ import annotations`, field.type is a string,
282
+ # so we need to resolve it
283
+ if field_type is int or field_type == "int":
284
+ try:
285
+ return int(value)
286
+ except ValueError:
287
+ return _COERCE_ERROR
288
+
289
+ if field_type is float or field_type == "float":
290
+ try:
291
+ return float(value)
292
+ except ValueError:
293
+ return _COERCE_ERROR
294
+
295
+ if field_type is bool or field_type == "bool":
296
+ if value.lower() in ("true", "1", "yes", "on"):
297
+ return True
298
+ if value.lower() in ("false", "0", "no", "off"):
299
+ return False
300
+ return _COERCE_ERROR
301
+
302
+ # String fields: pass through
303
+ return value