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
forge/cli/logs.py ADDED
@@ -0,0 +1,406 @@
1
+ """Show log file locations and status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import click
11
+ from rich.console import Console
12
+
13
+ from forge.core.logging import get_effective_log_level
14
+ from forge.core.paths import display_path, get_forge_home
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ console = Console()
19
+
20
+ # Descriptions for known log subdirectories (display only).
21
+ # Unknown subdirectories are auto-discovered and shown without a description.
22
+ _LOG_DIR_DESCRIPTIONS: dict[str, str] = {
23
+ "proxy": "Proxy server logs",
24
+ "backend": "Backend process logs (LiteLLM)",
25
+ "hooks": "Hook logs",
26
+ "cli": "CLI command logs",
27
+ "tool_failures": "Tool failure telemetry (proxy, opt-in)",
28
+ "tool_events": "Tool event recordings (proxy, debug-only)",
29
+ "requests": "Raw request/response logs (proxy, debug-only)",
30
+ }
31
+
32
+
33
+ def _discover_log_dirs(logs_root: Path) -> list[tuple[str, str, bool]]:
34
+ """Discover all log subdirectories under logs_root.
35
+
36
+ Returns known dirs (with descriptions) first — always included even if
37
+ they don't exist yet — then any unknown dirs that exist on disk.
38
+
39
+ Returns:
40
+ List of (name, description, exists_on_disk).
41
+ """
42
+ actual_dirs: set[str] = set()
43
+ if logs_root.is_dir():
44
+ actual_dirs = {d.name for d in logs_root.iterdir() if d.is_dir()}
45
+
46
+ result: list[tuple[str, str, bool]] = []
47
+ # Known dirs first (stable display order, always shown)
48
+ for name, desc in _LOG_DIR_DESCRIPTIONS.items():
49
+ result.append((name, desc, name in actual_dirs))
50
+ actual_dirs.discard(name)
51
+
52
+ # Unknown dirs that exist on disk (auto-discovered, alphabetical)
53
+ for name in sorted(actual_dirs):
54
+ result.append((name, "", True))
55
+
56
+ return result
57
+
58
+
59
+ def _count_files(directory: Path) -> tuple[int, int]:
60
+ """Count files and total size in a directory.
61
+
62
+ Returns:
63
+ (file_count, total_bytes)
64
+ """
65
+ if not directory.is_dir():
66
+ return 0, 0
67
+ total_size = 0
68
+ count = 0
69
+ for f in directory.iterdir():
70
+ if f.is_file():
71
+ count += 1
72
+ try:
73
+ total_size += f.stat().st_size
74
+ except OSError:
75
+ pass
76
+ return count, total_size
77
+
78
+
79
+ def _format_size(size_bytes: int) -> str:
80
+ if size_bytes == 0:
81
+ return "0 B"
82
+ size = float(size_bytes)
83
+ for unit in ("B", "KB", "MB", "GB"):
84
+ if size < 1024:
85
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
86
+ size /= 1024
87
+ return f"{size:.1f} TB"
88
+
89
+
90
+ def _file_age_days(path: Path) -> float:
91
+ """Return file age in days based on mtime."""
92
+ return (time.time() - path.stat().st_mtime) / 86400
93
+
94
+
95
+ def _is_older_than(path: Path, days: int) -> bool:
96
+ """Check if a file's mtime is older than the given number of days."""
97
+ try:
98
+ return _file_age_days(path) > days
99
+ except OSError:
100
+ return False
101
+
102
+
103
+ def _extract_pid(filename: str) -> int | None:
104
+ """Extract PID from log filename.
105
+
106
+ Handles patterns: 'proxy.12345.log', 'proxy.12345.log.1' (rotated),
107
+ '20260327_proxy.12345.jsonl'.
108
+ """
109
+ stem = filename
110
+ # Strip rotation suffix (.log.1, .log.2, etc.)
111
+ if ".log." in stem:
112
+ stem = stem[: stem.index(".log.") + 4] # keep up to '.log'
113
+ parts = stem.rsplit(".", 2)
114
+ if len(parts) >= 3:
115
+ try:
116
+ return int(parts[-2])
117
+ except ValueError:
118
+ pass
119
+ return None
120
+
121
+
122
+ def _is_process_alive(pid: int) -> bool:
123
+ """Check if a process with the given PID is running."""
124
+ try:
125
+ os.kill(pid, 0)
126
+ return True
127
+ except ProcessLookupError:
128
+ return False
129
+ except PermissionError:
130
+ return True # exists but we can't signal it
131
+
132
+
133
+ def _is_active_log_file(path: Path) -> bool:
134
+ """Check if a log file belongs to a currently running process."""
135
+ pid = _extract_pid(path.name)
136
+ if pid is None:
137
+ return False
138
+ return _is_process_alive(pid)
139
+
140
+
141
+ def _is_log_file(path: Path) -> bool:
142
+ """Check if a file has a known log extension (.log, .log.N, .jsonl)."""
143
+ name = path.name
144
+ if name.endswith(".jsonl"):
145
+ return True
146
+ if name.endswith(".log"):
147
+ return True
148
+ # Rotated logs: .log.1 through .log.5
149
+ if ".log." in name:
150
+ suffix = name[name.index(".log.") + 5 :]
151
+ return suffix.isdigit()
152
+ return False
153
+
154
+
155
+ def _oldest_file_age_days(logs_root: Path) -> float | None:
156
+ """Find the age (in days) of the oldest log file across all subdirectories."""
157
+ oldest: float | None = None
158
+ if not logs_root.is_dir():
159
+ return None
160
+
161
+ def _update_oldest(f: Path) -> None:
162
+ nonlocal oldest
163
+ try:
164
+ age = _file_age_days(f)
165
+ if oldest is None or age > oldest:
166
+ oldest = age
167
+ except OSError:
168
+ pass
169
+
170
+ for subdir, _, exists in _discover_log_dirs(logs_root):
171
+ if not exists:
172
+ continue
173
+ for f in (logs_root / subdir).iterdir():
174
+ if f.is_file():
175
+ _update_oldest(f)
176
+
177
+ return oldest
178
+
179
+
180
+ def _remove_files(logs_root: Path, older_than_days: int | None = None) -> tuple[int, int, int]:
181
+ """Remove log files from all subdirectories.
182
+
183
+ Skips files that belong to a running process (PID extracted from filename)
184
+ to avoid deleting logs out from under an active proxy or backend.
185
+
186
+ Args:
187
+ logs_root: Root logs directory.
188
+ older_than_days: If set, only remove files older than this many days.
189
+ None means remove all files.
190
+
191
+ Returns:
192
+ (removed_count, failed_count, skipped_active_count)
193
+ """
194
+ if not logs_root.is_dir():
195
+ return 0, 0, 0
196
+
197
+ removed = 0
198
+ failed = 0
199
+ skipped_active = 0
200
+
201
+ def _try_remove(f: Path) -> None:
202
+ nonlocal removed, failed, skipped_active
203
+ if older_than_days is not None and not _is_older_than(f, older_than_days):
204
+ return
205
+ if _is_active_log_file(f):
206
+ skipped_active += 1
207
+ return
208
+ try:
209
+ f.unlink()
210
+ removed += 1
211
+ except OSError:
212
+ failed += 1
213
+
214
+ for subdir, _, exists in _discover_log_dirs(logs_root):
215
+ if not exists:
216
+ continue
217
+ dir_path = logs_root / subdir
218
+ for f in dir_path.iterdir():
219
+ if f.is_file() and _is_log_file(f):
220
+ _try_remove(f)
221
+
222
+ return removed, failed, skipped_active
223
+
224
+
225
+ def auto_clean_old_logs() -> None:
226
+ """Auto-prune old logs based on log_retention_days config.
227
+
228
+ Called opportunistically on CLI startup. Best-effort: swallows all
229
+ exceptions to avoid breaking CLI commands.
230
+ """
231
+ try:
232
+ from forge.runtime_config import get_runtime_config
233
+
234
+ rc = get_runtime_config()
235
+ if rc.log_retention_days <= 0:
236
+ return
237
+
238
+ logs_root = get_forge_home() / "logs"
239
+ removed, _, _ = _remove_files(logs_root, older_than_days=rc.log_retention_days)
240
+ if removed:
241
+ logger.debug("Auto-cleaned %d log file(s) older than %d days", removed, rc.log_retention_days)
242
+ except Exception as e:
243
+ logger.debug("Log auto-cleanup error (non-fatal): %s", e)
244
+
245
+
246
+ @click.command("logs")
247
+ @click.option("--clean", is_flag=True, help="Remove log files")
248
+ @click.option(
249
+ "--older-than",
250
+ type=int,
251
+ default=None,
252
+ metavar="DAYS",
253
+ help="Only remove files older than DAYS days (requires --clean)",
254
+ )
255
+ def logs_cmd(clean: bool, older_than: int | None) -> None:
256
+ """Show log file locations and status.
257
+
258
+ \b
259
+ Examples:
260
+ forge logs # Show log locations and file counts
261
+ forge logs --clean # Remove all log files
262
+ forge logs --clean --older-than 7 # Remove logs older than 7 days
263
+ """
264
+ if older_than is not None and not clean:
265
+ console.print("[red]Error:[/red] --older-than requires --clean")
266
+ raise SystemExit(1)
267
+
268
+ if older_than is not None and older_than < 1:
269
+ console.print("[red]Error:[/red] --older-than must be >= 1")
270
+ raise SystemExit(1)
271
+
272
+ logs_root = get_forge_home() / "logs"
273
+
274
+ if clean:
275
+ _clean_logs(logs_root, older_than_days=older_than)
276
+ return
277
+
278
+ _show_logs(logs_root)
279
+
280
+
281
+ def _show_logs(logs_root: Path) -> None:
282
+ """Display log directory status."""
283
+ level = get_effective_log_level()
284
+ console.print(f"\n[bold]Log directory:[/bold] {display_path(logs_root)}")
285
+ console.print(f"[bold]Log level:[/bold] {level}")
286
+
287
+ # Show retention config if set
288
+ try:
289
+ from forge.runtime_config import get_runtime_config
290
+
291
+ rc = get_runtime_config()
292
+ if rc.log_retention_days > 0:
293
+ console.print(f"[bold]Retention:[/bold] {rc.log_retention_days} days (auto-cleanup on startup)")
294
+ else:
295
+ console.print("[bold]Retention:[/bold] unlimited")
296
+ except Exception:
297
+ pass
298
+
299
+ console.print()
300
+
301
+ total_files = 0
302
+ total_bytes = 0
303
+ for subdir, description, exists in _discover_log_dirs(logs_root):
304
+ dir_path = logs_root / subdir
305
+ count, size = _count_files(dir_path) if exists else (0, 0)
306
+ total_files += count
307
+ total_bytes += size
308
+ if count > 0:
309
+ console.print(f" [cyan]{subdir}/[/cyan] {count} files ({_format_size(size)})")
310
+ else:
311
+ console.print(f" [cyan]{subdir}/[/cyan] [dim](empty)[/dim]")
312
+ if description:
313
+ console.print(f" [dim]{description}[/dim]")
314
+ console.print(f" [dim]{display_path(dir_path)}[/dim]")
315
+ console.print()
316
+
317
+ # Summary with oldest file age
318
+ if total_files > 0:
319
+ oldest = _oldest_file_age_days(logs_root)
320
+ age_str = f", oldest {oldest:.0f}d ago" if oldest is not None and oldest >= 1 else ""
321
+ console.print(f" [bold]Total:[/bold] {total_files} files ({_format_size(total_bytes)}{age_str})")
322
+ console.print()
323
+
324
+ if level == "off":
325
+ console.print("[dim]Tip: Enable debug logging with:[/dim]")
326
+ console.print("[dim] forge config set log_level=debug # persistent[/dim]")
327
+ console.print("[dim] FORGE_DEBUG=1 forge <command> # one-off[/dim]")
328
+ else:
329
+ console.print("[dim]Tip: Disable debug logging with:[/dim]")
330
+ console.print("[dim] forge config set log_level=off[/dim]")
331
+
332
+ # Cleanup tips when there are files to manage
333
+ if total_files > 0:
334
+ try:
335
+ from forge.runtime_config import get_runtime_config as _get_rc
336
+
337
+ retention = _get_rc().log_retention_days
338
+ except Exception:
339
+ retention = 0
340
+ if retention <= 0:
341
+ console.print("\n[dim]Tip: Clean up old logs:[/dim]")
342
+ console.print("[dim] forge logs --clean # remove all[/dim]")
343
+ console.print("[dim] forge logs --clean --older-than 30 # older than 30 days[/dim]")
344
+ console.print("[dim] forge config set log_retention_days=30 # auto-cleanup on startup[/dim]")
345
+ else:
346
+ console.print("\n[dim]Tip: forge logs --clean --older-than 7 # manual one-off cleanup[/dim]")
347
+
348
+ # Tip about tool failure telemetry when it's not enabled
349
+ tool_failures_dir = logs_root / "tool_failures"
350
+ tool_failures_empty = not tool_failures_dir.is_dir() or not any(tool_failures_dir.iterdir())
351
+ if tool_failures_empty:
352
+ try:
353
+ from forge.runtime_config import get_runtime_config as _get_rc2
354
+
355
+ if not _get_rc2().log_tool_failures:
356
+ console.print("\n[dim]Tip: Log non-Claude model tool misuse (e.g., invalid Read parameters):[/dim]")
357
+ console.print("[dim] forge config set log_tool_failures=true[/dim]")
358
+ except Exception:
359
+ pass
360
+
361
+ # Warn about adopted proxies that won't have Forge logs.
362
+ # Show regardless of whether proxy/ has files — old log files from a
363
+ # previously managed proxy don't help diagnose a current adopted one.
364
+ if level != "off":
365
+ try:
366
+ from forge.proxy.proxies import ProxyRegistryStore
367
+
368
+ store = ProxyRegistryStore()
369
+ registry = store.read()
370
+ adopted = [e for e in registry.proxies.values() if e.pid is None and e.status == "healthy"]
371
+ if adopted:
372
+ names = ", ".join(e.proxy_id for e in adopted[:3])
373
+ suffix = f" (+{len(adopted) - 3} more)" if len(adopted) > 3 else ""
374
+ console.print(
375
+ f"[yellow]Note:[/yellow] {len(adopted)} adopted proxy(ies) "
376
+ f"({names}{suffix}) were not started by Forge and have no log files."
377
+ )
378
+ console.print("[dim]Tip: Delete and recreate proxies for full Forge logging.[/dim]")
379
+ except Exception:
380
+ pass
381
+
382
+
383
+ def _clean_logs(logs_root: Path, older_than_days: int | None = None) -> None:
384
+ """Remove log files from all subdirectories."""
385
+ removed, failed, skipped_active = _remove_files(logs_root, older_than_days=older_than_days)
386
+
387
+ if removed == 0 and failed == 0 and skipped_active == 0:
388
+ if older_than_days is not None:
389
+ console.print(f"[dim]No log files older than {older_than_days} days found.[/dim]")
390
+ else:
391
+ console.print("[dim]No log files found.[/dim]")
392
+ return
393
+
394
+ if older_than_days is not None:
395
+ console.print(f"Removed {removed} log file{'s' if removed != 1 else ''} older than {older_than_days} days.")
396
+ else:
397
+ console.print(f"Removed {removed} log file{'s' if removed != 1 else ''}.")
398
+ if skipped_active:
399
+ console.print(
400
+ f"[dim]Kept {skipped_active} file{'s' if skipped_active != 1 else ''}"
401
+ f" belonging to running process(es).[/dim]"
402
+ )
403
+ if failed:
404
+ console.print(
405
+ f"[yellow]Skipped {failed} file{'s' if failed != 1 else ''} (locked or permission denied).[/yellow]"
406
+ )
forge/cli/main.py ADDED
@@ -0,0 +1,292 @@
1
+ """Root CLI command group for Forge.
2
+
3
+ This module defines the `forge` command group and registers subcommands.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ import click
11
+ from dotenv import load_dotenv
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Load .env early before any config access
16
+ load_dotenv()
17
+
18
+ from forge.install.cli import info_cmd # noqa: E402
19
+
20
+ from .auth import auth # noqa: E402
21
+ from .backend import backend # noqa: E402
22
+ from .claude import claude # noqa: E402
23
+ from .config_cmd import config as config_cmd # noqa: E402
24
+ from .extensions import extensions # noqa: E402
25
+ from .guard import guard # noqa: E402
26
+ from .handoff import handoff # noqa: E402
27
+ from .hooks import hooks # noqa: E402
28
+ from .proxy import proxy # noqa: E402
29
+ from .search import search_cmd # noqa: E402
30
+ from .session import session # noqa: E402
31
+ from .status_line import status_line # noqa: E402
32
+ from .workflow import workflow_cmd # noqa: E402
33
+
34
+ # Subcommands that should NOT trigger pending-work processing or auto file logging.
35
+ # Hooks and status-line are latency-sensitive; logs is exempt so it can inspect/clean
36
+ # log files without creating a fresh "logs.*.log" file as a side effect.
37
+ _EXEMPT_SUBCOMMANDS = frozenset({"hook", "status-line", "logs", "clean"})
38
+
39
+ # Session auto-cleanup is also exempt for session subcommands so that
40
+ # inspection commands (list, clean --dry-run, show) are side-effect-free.
41
+ # Auto-cleanup still fires on every other forge command.
42
+ _SESSION_CLEANUP_EXEMPT = frozenset({"hook", "status-line", "logs", "session", "clean"})
43
+
44
+ _ALIASES: dict[str, str] = {
45
+ "auth": "authentication",
46
+ "ext": "extension",
47
+ "extensions": "extension", # backward compat (renamed from plural)
48
+ "sess": "session",
49
+ }
50
+ # Display aliases: canonical -> preferred short alias (shown in help)
51
+ _DISPLAY_ALIASES: dict[str, str] = {
52
+ "authentication": "auth",
53
+ "extension": "ext",
54
+ "session": "sess",
55
+ }
56
+
57
+
58
+ class AliasGroup(click.Group):
59
+ """Click group that resolves short aliases to canonical command names."""
60
+
61
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
62
+ rv = super().get_command(ctx, cmd_name)
63
+ if rv is not None:
64
+ return rv
65
+ canonical = _ALIASES.get(cmd_name)
66
+ if canonical:
67
+ return super().get_command(ctx, canonical)
68
+ return None
69
+
70
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
71
+ """Show aliases inline with command help text."""
72
+ commands: list[tuple[str, str]] = []
73
+ for subcommand in self.list_commands(ctx):
74
+ cmd = self.get_command(ctx, subcommand)
75
+ if cmd is None or cmd.hidden:
76
+ continue
77
+ help_text = cmd.get_short_help_str(limit=150)
78
+ alias = _DISPLAY_ALIASES.get(subcommand)
79
+ if alias:
80
+ subcommand = f"{subcommand} ({alias})"
81
+ commands.append((subcommand, help_text))
82
+
83
+ if commands:
84
+ with formatter.section("Commands"):
85
+ formatter.write_dl(commands)
86
+
87
+
88
+ def _process_pending_work_best_effort() -> None:
89
+ """Process pending-work queue opportunistically.
90
+
91
+ Best-effort: swallows all exceptions to avoid breaking CLI commands.
92
+ Fast path: no-op if queue is empty.
93
+
94
+ Handlers are assembled here (CLI assembly layer) and passed explicitly
95
+ to avoid global registry coupling.
96
+ """
97
+ try:
98
+ from forge.core.workqueue import Marker, WorkHandler, process_pending_work
99
+
100
+ def _noop_stop_handler(marker: Marker) -> None:
101
+ """No-op stop handler. Marker is deleted on success by the processor."""
102
+
103
+ def _index_handler(marker: Marker) -> None:
104
+ """Index a transcript for search.
105
+
106
+ Extracts content, decomposes into three stores (metadata, BM25 index,
107
+ content), then marks as indexed. All store operations are idempotent
108
+ upserts, so work queue retries produce correct state.
109
+ """
110
+ from pathlib import Path
111
+
112
+ from forge.search.bm25_store import BM25IndexStore
113
+ from forge.search.content_store import ContentStore
114
+ from forge.search.extractor import decompose_document, extract_document
115
+ from forge.search.index_state import IndexStateStore
116
+ from forge.search.store import SearchDocumentStore
117
+ from forge.session.artifacts import resolve_forge_root
118
+
119
+ payload = marker.payload
120
+ worktree_path = Path(payload["worktree_path"])
121
+ transcript_rel = payload["transcript_snapshot_rel"]
122
+
123
+ marker_forge_root = payload.get("forge_root")
124
+ forge_root = Path(marker_forge_root) if marker_forge_root else resolve_forge_root(worktree_path)
125
+ transcript_abs = (forge_root / transcript_rel).resolve()
126
+
127
+ # Validate path containment to prevent path traversal
128
+ if not transcript_abs.is_relative_to(forge_root.resolve()):
129
+ raise ValueError(f"Transcript path escapes forge root: {transcript_rel}")
130
+
131
+ if not transcript_abs.is_file():
132
+ raise FileNotFoundError(f"Transcript not found: {transcript_abs}")
133
+
134
+ doc = extract_document(
135
+ transcript_path=transcript_abs,
136
+ session_name=payload["session_name"],
137
+ session_id=payload["session_id"],
138
+ worktree_path=str(worktree_path),
139
+ )
140
+
141
+ meta, term_freq, doc_len, content = decompose_document(doc)
142
+
143
+ # Three idempotent upserts — safe for retries
144
+ doc_store = SearchDocumentStore(forge_root=forge_root)
145
+ doc_store.add(meta)
146
+
147
+ bm25_store = BM25IndexStore(forge_root=forge_root)
148
+ bm25_store.upsert_document(doc.transcript_path, term_freq, doc_len)
149
+
150
+ content_store = ContentStore(forge_root=forge_root)
151
+ content_store.add(doc.transcript_path, content)
152
+
153
+ index_store = IndexStateStore(forge_root=forge_root)
154
+ index_store.mark_indexed(transcript_abs)
155
+
156
+ def _handoff_handler(marker: Marker) -> None:
157
+ """Spawn a detached background process to run the handoff agent.
158
+
159
+ The handler returns immediately (fast path for CLI startup).
160
+ The actual handoff work happens in the background subprocess.
161
+
162
+ Fire-and-forget: if the background process fails, the marker is
163
+ already deleted. This is intentionally weaker reliability than
164
+ indexing — acceptable because project-state.md is a convenience
165
+ doc and the next session creates a new marker with fresh data.
166
+ """
167
+ import subprocess
168
+
169
+ payload = marker.payload
170
+ cmd = [
171
+ "forge",
172
+ "handoff",
173
+ "run",
174
+ "--session-name",
175
+ payload["session_name"],
176
+ "--worktree-path",
177
+ payload["worktree_path"],
178
+ "--transcript-rel",
179
+ payload["transcript_snapshot_rel"],
180
+ ]
181
+ subprocess_proxy = payload.get("subprocess_proxy")
182
+ if subprocess_proxy:
183
+ cmd.extend(["--subprocess-proxy", subprocess_proxy])
184
+ marker_forge_root = payload.get("forge_root")
185
+ if marker_forge_root:
186
+ cmd.extend(["--root", marker_forge_root])
187
+
188
+ subprocess.Popen(
189
+ cmd,
190
+ start_new_session=True,
191
+ stdout=subprocess.DEVNULL,
192
+ stderr=subprocess.DEVNULL,
193
+ stdin=subprocess.DEVNULL,
194
+ )
195
+
196
+ handlers: dict[str, WorkHandler] = {
197
+ "stop": _noop_stop_handler,
198
+ "index": _index_handler,
199
+ "handoff": _handoff_handler,
200
+ }
201
+
202
+ # Limit to 5 items per startup to avoid blocking CLI when many
203
+ # index markers are pending (each involves file I/O + JSON parsing)
204
+ process_pending_work(max_items=5, timeout_s=0.05, handlers=handlers)
205
+ except Exception as e:
206
+ logger.debug("Queue processing error (non-fatal): %s", e)
207
+
208
+
209
+ def _auto_clean_logs_best_effort() -> None:
210
+ """Auto-prune old logs based on log_retention_days config.
211
+
212
+ Best-effort: swallows all exceptions to avoid breaking CLI commands.
213
+ """
214
+ try:
215
+ from forge.cli.logs import auto_clean_old_logs
216
+
217
+ auto_clean_old_logs()
218
+ except Exception as e:
219
+ logger.debug("Log auto-cleanup error (non-fatal): %s", e)
220
+
221
+
222
+ def _auto_clean_sessions_best_effort() -> None:
223
+ """Auto-prune old sessions based on session_retention_days config.
224
+
225
+ Best-effort: swallows all exceptions to avoid breaking CLI commands.
226
+ """
227
+ try:
228
+ from forge.session.cleanup import auto_clean_old_sessions
229
+
230
+ auto_clean_old_sessions()
231
+ except Exception as e:
232
+ logger.debug("Session auto-cleanup error (non-fatal): %s", e)
233
+
234
+
235
+ @click.group(
236
+ cls=AliasGroup,
237
+ context_settings={"help_option_names": ["-h", "--help"]},
238
+ invoke_without_command=True,
239
+ )
240
+ @click.version_option(None, "-V", "--version", package_name="multi-forge", prog_name="forge")
241
+ @click.pass_context
242
+ def main(ctx: click.Context) -> None:
243
+ """Multi-Forge - Multi-runtime agent toolkit.
244
+
245
+ Proxy routing, cost control, session management, policy enforcement,
246
+ and workflow orchestration for coding agents.
247
+ """
248
+ # Configure file logging for non-exempt subcommands.
249
+ # Hooks configure their own logging (hooks/ subdirectory).
250
+ # Status-line is exempt to avoid log spam (runs on every poll cycle).
251
+ if ctx.invoked_subcommand not in _EXEMPT_SUBCOMMANDS:
252
+ from forge.core.logging import configure_debug_logging
253
+
254
+ configure_debug_logging(component=ctx.invoked_subcommand or "forge", subdirectory="cli")
255
+
256
+ if ctx.invoked_subcommand is None:
257
+ click.echo(ctx.get_help())
258
+ return
259
+
260
+ # Process pending-work queue opportunistically on CLI startup
261
+ # Skip for exempt subcommands (hooks, status-line) to preserve low latency
262
+ if ctx.invoked_subcommand not in _EXEMPT_SUBCOMMANDS:
263
+ _process_pending_work_best_effort()
264
+ _auto_clean_logs_best_effort()
265
+ if ctx.invoked_subcommand not in _SESSION_CLEANUP_EXEMPT:
266
+ _auto_clean_sessions_best_effort()
267
+
268
+
269
+ main.add_command(auth, name="authentication")
270
+ main.add_command(backend)
271
+ main.add_command(session)
272
+ main.add_command(proxy)
273
+ main.add_command(guard)
274
+ main.add_command(handoff)
275
+ main.add_command(claude)
276
+ main.add_command(config_cmd, name="config")
277
+ main.add_command(hooks)
278
+ main.add_command(extensions, name="extension")
279
+ main.add_command(status_line)
280
+ main.add_command(info_cmd, name="info")
281
+ main.add_command(workflow_cmd, name="workflow")
282
+ main.add_command(search_cmd, name="search")
283
+
284
+ from forge.cli.gc import clean_cmd # noqa: E402
285
+ from forge.cli.logs import logs_cmd # noqa: E402
286
+
287
+ main.add_command(clean_cmd, name="clean")
288
+ main.add_command(logs_cmd, name="logs")
289
+
290
+
291
+ if __name__ == "__main__":
292
+ main()