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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Tracking store for ~/.forge/installed.json.
|
|
2
|
+
|
|
3
|
+
Manages the persistent record of what Forge has installed, enabling
|
|
4
|
+
reversible update and uninstall operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import asdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import dacite
|
|
15
|
+
|
|
16
|
+
from forge.core.paths import get_forge_home
|
|
17
|
+
from forge.core.state import atomic_write_json, file_lock_for_target
|
|
18
|
+
|
|
19
|
+
from .exceptions import TrackingCorruptedError
|
|
20
|
+
from .models import (
|
|
21
|
+
TRACKING_VERSION,
|
|
22
|
+
Installation,
|
|
23
|
+
InstalledManifest,
|
|
24
|
+
make_installation_key,
|
|
25
|
+
parse_installation_key,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Constants
|
|
29
|
+
TRACKING_FILENAME = "installed.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_tracking_path() -> Path:
|
|
33
|
+
"""Get path to tracking file (~/.forge/installed.json)."""
|
|
34
|
+
return get_forge_home() / TRACKING_FILENAME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compute_checksum(path: Path) -> str:
|
|
38
|
+
"""Compute SHA256 checksum of a file.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
path: Path to the file to checksum.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Hex-encoded SHA256 hash of file contents.
|
|
45
|
+
"""
|
|
46
|
+
sha256 = hashlib.sha256()
|
|
47
|
+
with open(path, "rb") as f:
|
|
48
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
49
|
+
sha256.update(chunk)
|
|
50
|
+
return sha256.hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TrackingStore:
|
|
54
|
+
"""Manage the tracking manifest at ~/.forge/installed.json.
|
|
55
|
+
|
|
56
|
+
The tracking manifest records what Forge has installed so that:
|
|
57
|
+
- `forge update` updates only tracked items
|
|
58
|
+
- `forge uninstall` removes only tracked files and settings entries
|
|
59
|
+
|
|
60
|
+
Error handling:
|
|
61
|
+
- Missing file: Return empty manifest (not an error)
|
|
62
|
+
- Corrupted JSON: Raise TrackingCorruptedError (fail loudly to preserve safety)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, tracking_path: Path | None = None) -> None:
|
|
66
|
+
"""Initialize store.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
tracking_path: Override path to tracking file (for testing).
|
|
70
|
+
"""
|
|
71
|
+
self._path = tracking_path or get_tracking_path()
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def path(self) -> Path:
|
|
75
|
+
"""Return the full path to the tracking file."""
|
|
76
|
+
return self._path
|
|
77
|
+
|
|
78
|
+
def exists(self) -> bool:
|
|
79
|
+
"""Check if tracking file exists."""
|
|
80
|
+
return self._path.is_file()
|
|
81
|
+
|
|
82
|
+
def read(self) -> InstalledManifest:
|
|
83
|
+
"""Read tracking manifest.
|
|
84
|
+
|
|
85
|
+
Returns empty manifest if file doesn't exist.
|
|
86
|
+
Raises TrackingCorruptedError if file exists but is invalid.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The tracking manifest.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
TrackingCorruptedError: If file is corrupted or has invalid schema.
|
|
93
|
+
"""
|
|
94
|
+
if not self.exists():
|
|
95
|
+
return InstalledManifest()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
with open(self._path, encoding="utf-8") as f:
|
|
99
|
+
data = json.load(f)
|
|
100
|
+
except json.JSONDecodeError as e:
|
|
101
|
+
raise TrackingCorruptedError(str(self._path), f"invalid JSON: {e}")
|
|
102
|
+
except OSError as e:
|
|
103
|
+
raise TrackingCorruptedError(str(self._path), f"read error: {e}")
|
|
104
|
+
|
|
105
|
+
# Version check (no migration support)
|
|
106
|
+
version = data.get("version", 1)
|
|
107
|
+
if version != TRACKING_VERSION:
|
|
108
|
+
raise TrackingCorruptedError(
|
|
109
|
+
str(self._path),
|
|
110
|
+
f"incompatible version {version} (this Forge expects {TRACKING_VERSION}). "
|
|
111
|
+
f"Delete this file and run 'forge extension enable' again.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Guard: reject manifests from pre-OSS patching builds.
|
|
115
|
+
# patched_files was removed from the Installation dataclass; dacite
|
|
116
|
+
# strict=True rejects even "patched_files": []. Check raw JSON before
|
|
117
|
+
# deserialization so the error message is actionable.
|
|
118
|
+
installations = data.get("installations", {})
|
|
119
|
+
for inst in installations.values():
|
|
120
|
+
if isinstance(inst, dict) and "patched_files" in inst:
|
|
121
|
+
raise TrackingCorruptedError(
|
|
122
|
+
str(self._path),
|
|
123
|
+
"This Forge install manifest was created by a pre-OSS patching build. "
|
|
124
|
+
f"Remove {self._path} and run `forge extension enable` again. "
|
|
125
|
+
"If Claude Code was patched, run `claude update` or reinstall Claude Code.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
return dacite.from_dict(
|
|
130
|
+
data_class=InstalledManifest,
|
|
131
|
+
data=data,
|
|
132
|
+
config=dacite.Config(strict=True),
|
|
133
|
+
)
|
|
134
|
+
except (dacite.DaciteError, TypeError, KeyError) as e:
|
|
135
|
+
raise TrackingCorruptedError(str(self._path), f"deserialization error: {e}")
|
|
136
|
+
|
|
137
|
+
def write(self, manifest: InstalledManifest) -> None:
|
|
138
|
+
"""Write tracking manifest atomically.
|
|
139
|
+
|
|
140
|
+
Uses core.state.atomic_write_json for atomic writes.
|
|
141
|
+
Creates parent directory if needed.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
manifest: The manifest to write.
|
|
145
|
+
"""
|
|
146
|
+
data = asdict(manifest)
|
|
147
|
+
atomic_write_json(self._path, data)
|
|
148
|
+
|
|
149
|
+
def get_installation(self, scope: str, project_path: str | None = None) -> Installation | None:
|
|
150
|
+
"""Get installation for a specific scope and project.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
scope: The scope to look up ("user", "project", "local").
|
|
154
|
+
project_path: Project path (required for project/local scope).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The Installation record, or None if not installed.
|
|
158
|
+
"""
|
|
159
|
+
key = make_installation_key(scope, project_path)
|
|
160
|
+
manifest = self.read()
|
|
161
|
+
return manifest.installations.get(key)
|
|
162
|
+
|
|
163
|
+
def set_installation(self, scope: str, installation: Installation, project_path: str | None = None) -> None:
|
|
164
|
+
"""Set installation for a scope and project.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
scope: The scope to set.
|
|
168
|
+
installation: The installation record.
|
|
169
|
+
project_path: Project path (required for project/local scope).
|
|
170
|
+
"""
|
|
171
|
+
key = make_installation_key(scope, project_path)
|
|
172
|
+
installation.project_path = project_path
|
|
173
|
+
with file_lock_for_target(target_path=self._path, timeout_s=5.0):
|
|
174
|
+
manifest = self.read()
|
|
175
|
+
manifest.installations[key] = installation
|
|
176
|
+
self.write(manifest)
|
|
177
|
+
|
|
178
|
+
def remove_installation(self, scope: str, project_path: str | None = None) -> bool:
|
|
179
|
+
"""Remove installation for a scope and project.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
scope: The scope to remove.
|
|
183
|
+
project_path: Project path (required for project/local scope).
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
True if removed, False if didn't exist.
|
|
187
|
+
"""
|
|
188
|
+
key = make_installation_key(scope, project_path)
|
|
189
|
+
with file_lock_for_target(target_path=self._path, timeout_s=5.0):
|
|
190
|
+
manifest = self.read()
|
|
191
|
+
if key not in manifest.installations:
|
|
192
|
+
return False
|
|
193
|
+
del manifest.installations[key]
|
|
194
|
+
self.write(manifest)
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
def list_installations(self) -> list[tuple[str, str | None, Installation]]:
|
|
198
|
+
"""List all tracked installations.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
List of (scope, project_path, installation) tuples.
|
|
202
|
+
"""
|
|
203
|
+
manifest = self.read()
|
|
204
|
+
result = []
|
|
205
|
+
for key, installation in manifest.installations.items():
|
|
206
|
+
scope, project_path = parse_installation_key(key)
|
|
207
|
+
result.append((scope, project_path, installation))
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
def has_installation(self, scope: str, project_path: str | None = None) -> bool:
|
|
211
|
+
"""Check if an installation exists for the given scope and project.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
scope: The scope to check.
|
|
215
|
+
project_path: Project path (required for project/local scope).
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True if installation exists.
|
|
219
|
+
"""
|
|
220
|
+
return self.get_installation(scope, project_path) is not None
|
|
221
|
+
|
|
222
|
+
def is_forge_managed(self, path: str, scope: str, project_path: str | None = None) -> bool:
|
|
223
|
+
"""Check if a path is managed by Forge in the given scope.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
path: Absolute path to check.
|
|
227
|
+
scope: Scope to check within.
|
|
228
|
+
project_path: Project path (required for project/local scope).
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if the path is a Forge-managed file.
|
|
232
|
+
"""
|
|
233
|
+
installation = self.get_installation(scope, project_path)
|
|
234
|
+
if installation is None:
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
normalized = str(Path(path).resolve())
|
|
238
|
+
return any(str(Path(f.target_path).resolve()) == normalized for f in installation.files)
|
forge/install/version.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Claude Code minimum version detection and enforcement.
|
|
2
|
+
|
|
3
|
+
Forge requires a minimum Claude Code version to ensure hooks, policy enforcement,
|
|
4
|
+
and session features work correctly. This module provides cached version detection
|
|
5
|
+
and comparison utilities used by the installer and session launch flow.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from packaging.version import InvalidVersion, Version
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Minimum Claude Code version required by Forge.
|
|
20
|
+
# v2.1.78: hooks load in worktrees, StopFailure event, PreToolUse deny fix (v2.1.77),
|
|
21
|
+
# transcript_path correct for forked/resumed sessions (v2.1.72).
|
|
22
|
+
MIN_CLAUDE_CODE_VERSION = "2.1.78"
|
|
23
|
+
|
|
24
|
+
# Process-scoped cache to avoid running `claude --version` on every call.
|
|
25
|
+
_VERSION_CACHE_TTL_S = 300 # 5 minutes
|
|
26
|
+
_cached_version: tuple[float, str | None] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class VersionCheckResult:
|
|
31
|
+
"""Result of checking Claude Code version against the minimum."""
|
|
32
|
+
|
|
33
|
+
ok: bool
|
|
34
|
+
version: str | None
|
|
35
|
+
minimum: str
|
|
36
|
+
reason: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_claude_runtime_version() -> str | None:
|
|
40
|
+
"""Detect the installed Claude Code version via ``claude --version``.
|
|
41
|
+
|
|
42
|
+
Returns the version string (e.g. ``"2.1.78"``) or None if Claude Code
|
|
43
|
+
is not installed, times out, or produces unparseable output.
|
|
44
|
+
|
|
45
|
+
Results are cached for ``_VERSION_CACHE_TTL_S`` seconds to avoid
|
|
46
|
+
repeated subprocess calls within a single CLI invocation.
|
|
47
|
+
"""
|
|
48
|
+
global _cached_version # noqa: PLW0603 — module-level cache by design
|
|
49
|
+
|
|
50
|
+
now = time.monotonic()
|
|
51
|
+
if _cached_version is not None:
|
|
52
|
+
cached_at, cached_value = _cached_version
|
|
53
|
+
if now - cached_at < _VERSION_CACHE_TTL_S:
|
|
54
|
+
return cached_value
|
|
55
|
+
|
|
56
|
+
version = _run_claude_version()
|
|
57
|
+
_cached_version = (now, version)
|
|
58
|
+
return version
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_claude_version() -> str | None:
|
|
62
|
+
"""Run ``claude --version`` and parse the output."""
|
|
63
|
+
try:
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
["claude", "--version"],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
timeout=5,
|
|
69
|
+
)
|
|
70
|
+
if result.returncode != 0:
|
|
71
|
+
return None
|
|
72
|
+
raw = result.stdout.strip()
|
|
73
|
+
if not raw:
|
|
74
|
+
return None
|
|
75
|
+
# Output is like "2.1.78 (Claude Code)" — strip the suffix
|
|
76
|
+
if " (Claude Code)" in raw:
|
|
77
|
+
raw = raw.replace(" (Claude Code)", "")
|
|
78
|
+
return raw.split()[0] if raw else None
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
return None
|
|
81
|
+
except subprocess.TimeoutExpired:
|
|
82
|
+
return None
|
|
83
|
+
except Exception:
|
|
84
|
+
logger.debug("Unexpected error detecting Claude Code version", exc_info=True)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_minimum_version(version_str: str | None = None) -> VersionCheckResult:
|
|
89
|
+
"""Check whether the installed Claude Code meets the minimum version.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
version_str: Explicit version string (for testing). If None, detects
|
|
93
|
+
the runtime version via ``get_claude_runtime_version()``.
|
|
94
|
+
"""
|
|
95
|
+
minimum = MIN_CLAUDE_CODE_VERSION
|
|
96
|
+
|
|
97
|
+
if version_str is None:
|
|
98
|
+
version_str = get_claude_runtime_version()
|
|
99
|
+
|
|
100
|
+
if version_str is None:
|
|
101
|
+
return VersionCheckResult(
|
|
102
|
+
ok=False,
|
|
103
|
+
version=None,
|
|
104
|
+
minimum=minimum,
|
|
105
|
+
reason="Claude Code not found. Install it first: https://docs.anthropic.com/en/docs/claude-code",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
detected = Version(version_str)
|
|
110
|
+
except InvalidVersion:
|
|
111
|
+
return VersionCheckResult(
|
|
112
|
+
ok=False,
|
|
113
|
+
version=version_str,
|
|
114
|
+
minimum=minimum,
|
|
115
|
+
reason=f"Could not parse Claude Code version '{version_str}'.",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
required = Version(minimum)
|
|
119
|
+
if detected < required:
|
|
120
|
+
return VersionCheckResult(
|
|
121
|
+
ok=False,
|
|
122
|
+
version=version_str,
|
|
123
|
+
minimum=minimum,
|
|
124
|
+
reason=(
|
|
125
|
+
f"Claude Code {version_str} is below the minimum required "
|
|
126
|
+
f"version {minimum}. Run 'claude update' to upgrade."
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return VersionCheckResult(
|
|
131
|
+
ok=True,
|
|
132
|
+
version=version_str,
|
|
133
|
+
minimum=minimum,
|
|
134
|
+
reason="OK",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def reset_version_cache() -> None:
|
|
139
|
+
"""Clear the cached version (for testing)."""
|
|
140
|
+
global _cached_version # noqa: PLW0603
|
|
141
|
+
_cached_version = None
|
forge/proxy/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstract base class for all LLM provider clients.
|
|
3
|
+
|
|
4
|
+
This module defines the interface that all provider clients must implement,
|
|
5
|
+
ensuring consistent behavior across different LLM providers (OpenAI, Gemini, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Any, AsyncGenerator, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
# Import canonical error types from core.llm.errors
|
|
12
|
+
from forge.core.llm.errors import LLMError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AbstractLLMClient(ABC):
|
|
16
|
+
"""
|
|
17
|
+
Base class for all LLM provider clients.
|
|
18
|
+
|
|
19
|
+
All provider-specific clients must inherit from this class and implement
|
|
20
|
+
the required methods. This ensures a consistent interface across providers.
|
|
21
|
+
|
|
22
|
+
Methods accept and return data in OpenAI format for consistency, as it
|
|
23
|
+
serves as both a universal intermediate format and the native format for
|
|
24
|
+
OpenAI providers.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def create_completion(self, openai_request_dict: Dict[str, Any], request_id: str) -> Dict[str, Any]:
|
|
29
|
+
"""
|
|
30
|
+
Create a non-streaming completion.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
openai_request_dict: Request in OpenAI format containing messages,
|
|
34
|
+
tools, model parameters, etc.
|
|
35
|
+
request_id: Unique identifier for request tracking and logging
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Response in OpenAI format
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
AuthenticationError: When authentication fails
|
|
42
|
+
Exception: For other provider-specific errors
|
|
43
|
+
"""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def create_streaming_completion(
|
|
48
|
+
self, openai_request_dict: Dict[str, Any], request_id: str
|
|
49
|
+
) -> AsyncGenerator[str, None]:
|
|
50
|
+
"""
|
|
51
|
+
Create a streaming completion.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
openai_request_dict: Request in OpenAI format containing messages,
|
|
55
|
+
tools, model parameters, etc.
|
|
56
|
+
request_id: Unique identifier for request tracking and logging
|
|
57
|
+
|
|
58
|
+
Yields:
|
|
59
|
+
Server-sent events (SSE) in OpenAI streaming format
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
AuthenticationError: When authentication fails
|
|
63
|
+
Exception: For other provider-specific errors
|
|
64
|
+
"""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def count_tokens(
|
|
69
|
+
self,
|
|
70
|
+
messages: List[Dict[str, Any]],
|
|
71
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
72
|
+
) -> int:
|
|
73
|
+
"""
|
|
74
|
+
Count tokens for the given messages and optional tools.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
messages: List of messages in OpenAI format
|
|
78
|
+
tools: Optional list of tools in OpenAI format
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Total token count
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
Exception: If token counting fails
|
|
85
|
+
"""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ToolCallError(LLMError):
|
|
90
|
+
"""
|
|
91
|
+
Raised when a tool call fails, formatted for LLM consumption.
|
|
92
|
+
|
|
93
|
+
This error provides structured information that helps LLMs understand
|
|
94
|
+
and potentially fix tool-related issues.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, error_type: str, tool_name: str, details: Dict[str, Any]):
|
|
98
|
+
"""
|
|
99
|
+
Initialize a tool call error with LLM-friendly formatting.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
error_type: Category of error (MISSING_PARAM, SCHEMA_MISMATCH, etc.)
|
|
103
|
+
tool_name: Name of the tool that failed
|
|
104
|
+
details: Additional context including expected/actual values and suggestions
|
|
105
|
+
"""
|
|
106
|
+
self.error_type = error_type
|
|
107
|
+
self.tool_name = tool_name
|
|
108
|
+
self.details = details
|
|
109
|
+
|
|
110
|
+
# Build LLM-friendly message
|
|
111
|
+
message_parts = [f"Tool call failed [{error_type}]: {tool_name}"]
|
|
112
|
+
|
|
113
|
+
if error_type == "MISSING_PARAM":
|
|
114
|
+
if "param" in details:
|
|
115
|
+
message_parts.append(f"Missing required parameter: '{details['param']}'")
|
|
116
|
+
if "expected" in details:
|
|
117
|
+
message_parts.append(f"Expected structure: {details['expected']}")
|
|
118
|
+
if "actual" in details:
|
|
119
|
+
message_parts.append(f"Received: {details['actual']}")
|
|
120
|
+
|
|
121
|
+
elif error_type == "SCHEMA_MISMATCH":
|
|
122
|
+
if "message" in details:
|
|
123
|
+
message_parts.append(details["message"])
|
|
124
|
+
if "expected_type" in details:
|
|
125
|
+
message_parts.append(f"Expected type: {details['expected_type']}")
|
|
126
|
+
if "actual_type" in details:
|
|
127
|
+
message_parts.append(f"Actual type: {details['actual_type']}")
|
|
128
|
+
|
|
129
|
+
elif error_type == "INVALID_FORMAT":
|
|
130
|
+
if "message" in details:
|
|
131
|
+
message_parts.append(details["message"])
|
|
132
|
+
if "field" in details:
|
|
133
|
+
message_parts.append(f"Invalid field: '{details['field']}'")
|
|
134
|
+
|
|
135
|
+
# Add suggestion if available
|
|
136
|
+
if "suggestion" in details:
|
|
137
|
+
message_parts.append(f"Suggestion: {details['suggestion']}")
|
|
138
|
+
|
|
139
|
+
super().__init__("\n".join(message_parts))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ProxyStreamError(LLMError):
|
|
143
|
+
"""Raised during streaming when an error occurs.
|
|
144
|
+
|
|
145
|
+
This error carries structured information that allows the proxy server
|
|
146
|
+
to return appropriate HTTP status codes and OpenAI-compatible error responses
|
|
147
|
+
instead of generic 500 errors.
|
|
148
|
+
|
|
149
|
+
Common error types and their HTTP mappings:
|
|
150
|
+
- "authentication_error" -> 401
|
|
151
|
+
- "rate_limit_error" -> 429
|
|
152
|
+
- "invalid_request_error" -> 400
|
|
153
|
+
- "api_error" -> 500
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
# Standard error type to HTTP status code mapping
|
|
157
|
+
ERROR_STATUS_MAP = {
|
|
158
|
+
"authentication_error": 401,
|
|
159
|
+
"rate_limit_error": 429,
|
|
160
|
+
"invalid_request_error": 400,
|
|
161
|
+
"permission_error": 403,
|
|
162
|
+
"not_found_error": 404,
|
|
163
|
+
"api_error": 500,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
message: str,
|
|
169
|
+
error_type: str = "api_error",
|
|
170
|
+
status_code: int | None = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Initialize a proxy stream error.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
message: Human-readable error message.
|
|
176
|
+
error_type: OpenAI-compatible error type for client handling.
|
|
177
|
+
status_code: HTTP status code override. If None, derived from error_type.
|
|
178
|
+
"""
|
|
179
|
+
self.error_type = error_type
|
|
180
|
+
self.status_code = status_code or self.ERROR_STATUS_MAP.get(error_type, 500)
|
|
181
|
+
super().__init__(message)
|