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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- 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()
|