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,55 @@
|
|
|
1
|
+
"""Forge installer for Claude Code extensions.
|
|
2
|
+
|
|
3
|
+
Provides `forge init` / `forge update` / `forge uninstall` / `forge status` commands
|
|
4
|
+
to manage installation of commands, agents, hooks, skills, and settings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
ConflictError,
|
|
11
|
+
FileConflictError,
|
|
12
|
+
ForgeInstallError,
|
|
13
|
+
NotInstalledError,
|
|
14
|
+
SettingsConflictError,
|
|
15
|
+
SourceNotFoundError,
|
|
16
|
+
TrackingCorruptedError,
|
|
17
|
+
)
|
|
18
|
+
from .models import (
|
|
19
|
+
FilePlan,
|
|
20
|
+
Installation,
|
|
21
|
+
InstalledFile,
|
|
22
|
+
InstalledManifest,
|
|
23
|
+
InstalledSettingsEntry,
|
|
24
|
+
InstallMode,
|
|
25
|
+
InstallModule,
|
|
26
|
+
InstallPlan,
|
|
27
|
+
InstallProfile,
|
|
28
|
+
InstallScope,
|
|
29
|
+
SettingsPlan,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Enums
|
|
34
|
+
"InstallScope",
|
|
35
|
+
"InstallMode",
|
|
36
|
+
"InstallProfile",
|
|
37
|
+
"InstallModule",
|
|
38
|
+
# Tracking dataclasses
|
|
39
|
+
"InstalledFile",
|
|
40
|
+
"InstalledSettingsEntry",
|
|
41
|
+
"Installation",
|
|
42
|
+
"InstalledManifest",
|
|
43
|
+
# Plan dataclasses
|
|
44
|
+
"FilePlan",
|
|
45
|
+
"SettingsPlan",
|
|
46
|
+
"InstallPlan",
|
|
47
|
+
# Exceptions
|
|
48
|
+
"ForgeInstallError",
|
|
49
|
+
"ConflictError",
|
|
50
|
+
"FileConflictError",
|
|
51
|
+
"SettingsConflictError",
|
|
52
|
+
"TrackingCorruptedError",
|
|
53
|
+
"NotInstalledError",
|
|
54
|
+
"SourceNotFoundError",
|
|
55
|
+
]
|
forge/install/cli.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""CLI command for forge info (global installation information).
|
|
2
|
+
|
|
3
|
+
The info command remains at top-level for quick diagnostics.
|
|
4
|
+
Other installation lifecycle commands have moved to `forge extensions` group.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from forge.core.paths import display_path
|
|
14
|
+
|
|
15
|
+
from .tracking import TrackingStore
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- Info Command ---
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.command("info")
|
|
24
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
25
|
+
@click.option("--sessions", "-s", "max_sessions", default=5, help="Max recent sessions to show")
|
|
26
|
+
def info_cmd(as_json: bool, max_sessions: int) -> None:
|
|
27
|
+
"""Show global Forge installation information.
|
|
28
|
+
|
|
29
|
+
Displays comprehensive system status including:
|
|
30
|
+
- Forge and Claude Code versions
|
|
31
|
+
- Active session
|
|
32
|
+
- Tracked installations
|
|
33
|
+
- Registered proxies
|
|
34
|
+
- Recent sessions
|
|
35
|
+
|
|
36
|
+
\b
|
|
37
|
+
Examples:
|
|
38
|
+
forge info # Full dashboard
|
|
39
|
+
forge info --json # JSON output for scripting
|
|
40
|
+
forge info --sessions 10 # Show more recent sessions
|
|
41
|
+
"""
|
|
42
|
+
info_data = _gather_info_data(max_sessions)
|
|
43
|
+
|
|
44
|
+
if as_json:
|
|
45
|
+
console.print_json(data=info_data)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
_print_info_human(info_data)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _gather_info_data(max_sessions: int) -> dict:
|
|
52
|
+
"""Gather all info data into a dict (for both JSON and human output)."""
|
|
53
|
+
import shutil
|
|
54
|
+
import subprocess
|
|
55
|
+
|
|
56
|
+
from .models import parse_installation_key
|
|
57
|
+
|
|
58
|
+
data: dict = {}
|
|
59
|
+
|
|
60
|
+
# Forge info
|
|
61
|
+
try:
|
|
62
|
+
from importlib.metadata import version
|
|
63
|
+
|
|
64
|
+
data["forge_version"] = version("multi-forge")
|
|
65
|
+
except Exception:
|
|
66
|
+
data["forge_version"] = "unknown"
|
|
67
|
+
|
|
68
|
+
from forge.core.paths import get_forge_home
|
|
69
|
+
|
|
70
|
+
data["forge_home"] = str(get_forge_home())
|
|
71
|
+
|
|
72
|
+
# Claude Code info
|
|
73
|
+
claude_path = shutil.which("claude")
|
|
74
|
+
data["claude_code"] = {
|
|
75
|
+
"path": claude_path,
|
|
76
|
+
"version": None,
|
|
77
|
+
}
|
|
78
|
+
if claude_path:
|
|
79
|
+
try:
|
|
80
|
+
result = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=5)
|
|
81
|
+
if result.returncode == 0:
|
|
82
|
+
version_str = result.stdout.strip()
|
|
83
|
+
if " (Claude Code)" in version_str:
|
|
84
|
+
version_str = version_str.replace(" (Claude Code)", "")
|
|
85
|
+
data["claude_code"]["version"] = version_str
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
# Python/uv versions
|
|
90
|
+
try:
|
|
91
|
+
import sys
|
|
92
|
+
|
|
93
|
+
data["python_version"] = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
94
|
+
except Exception:
|
|
95
|
+
data["python_version"] = "unknown"
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = subprocess.run(["uv", "--version"], capture_output=True, text=True, timeout=5)
|
|
99
|
+
if result.returncode == 0:
|
|
100
|
+
uv_ver = result.stdout.strip().replace("uv ", "")
|
|
101
|
+
# Strip git hash suffix if present (e.g., "0.6.14 (a4cec56dc 2025-04-09)")
|
|
102
|
+
if " (" in uv_ver:
|
|
103
|
+
uv_ver = uv_ver.split(" (")[0]
|
|
104
|
+
data["uv_version"] = uv_ver
|
|
105
|
+
except Exception:
|
|
106
|
+
data["uv_version"] = "unknown"
|
|
107
|
+
|
|
108
|
+
# Installations
|
|
109
|
+
tracking = TrackingStore()
|
|
110
|
+
try:
|
|
111
|
+
manifest = tracking.read()
|
|
112
|
+
except Exception as e:
|
|
113
|
+
data["tracking_file"] = str(tracking.path)
|
|
114
|
+
data["tracking_error"] = str(e)
|
|
115
|
+
data["installations"] = []
|
|
116
|
+
data["proxies"] = []
|
|
117
|
+
data["sessions"] = []
|
|
118
|
+
return data
|
|
119
|
+
data["tracking_file"] = str(tracking.path)
|
|
120
|
+
data["installations"] = []
|
|
121
|
+
for key, inst in manifest.installations.items():
|
|
122
|
+
scope, project_path = parse_installation_key(key)
|
|
123
|
+
data["installations"].append(
|
|
124
|
+
{
|
|
125
|
+
"key": key,
|
|
126
|
+
"scope": scope,
|
|
127
|
+
"project_path": project_path,
|
|
128
|
+
"profile": inst.profile,
|
|
129
|
+
"mode": inst.mode,
|
|
130
|
+
"files_count": len(inst.files),
|
|
131
|
+
"settings_count": len(inst.settings_entries),
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Proxies
|
|
136
|
+
data["proxies"] = []
|
|
137
|
+
try:
|
|
138
|
+
from forge.proxy.proxies import ProxyRegistryStore
|
|
139
|
+
|
|
140
|
+
proxy_store = ProxyRegistryStore()
|
|
141
|
+
proxy_registry = proxy_store.read()
|
|
142
|
+
for proxy_id, proxy_entry in proxy_registry.proxies.items():
|
|
143
|
+
data["proxies"].append(
|
|
144
|
+
{
|
|
145
|
+
"proxy_id": proxy_id,
|
|
146
|
+
"base_url": proxy_entry.base_url,
|
|
147
|
+
"template": proxy_entry.template,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
# Recent sessions
|
|
154
|
+
data["sessions"] = []
|
|
155
|
+
try:
|
|
156
|
+
from forge.session import SessionManager
|
|
157
|
+
|
|
158
|
+
manager = SessionManager()
|
|
159
|
+
sessions = manager.list_sessions(include_incognito=False)
|
|
160
|
+
for name, entry in sessions[:max_sessions]:
|
|
161
|
+
data["sessions"].append(
|
|
162
|
+
{
|
|
163
|
+
"name": name,
|
|
164
|
+
"worktree": entry.worktree_path,
|
|
165
|
+
"last_accessed": entry.last_accessed_at,
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
return data
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _print_info_human(data: dict) -> None:
|
|
175
|
+
"""Print info in human-readable format."""
|
|
176
|
+
# Header
|
|
177
|
+
console.print("\n[bold cyan]Forge Info[/bold cyan]")
|
|
178
|
+
console.print("[cyan]" + "─" * 50 + "[/cyan]")
|
|
179
|
+
|
|
180
|
+
# System info
|
|
181
|
+
console.print("\n[bold]System[/bold]")
|
|
182
|
+
console.print(f" Forge: {data.get('forge_version', 'unknown')}")
|
|
183
|
+
console.print(f" Install Path: {display_path(data.get('forge_home', 'unknown'))}")
|
|
184
|
+
|
|
185
|
+
cc = data.get("claude_code", {})
|
|
186
|
+
if cc.get("version"):
|
|
187
|
+
cc_info = cc["version"]
|
|
188
|
+
elif cc.get("path"):
|
|
189
|
+
cc_info = f"at {display_path(cc['path'])}"
|
|
190
|
+
else:
|
|
191
|
+
cc_info = "[dim]not found[/dim]"
|
|
192
|
+
console.print(f" Claude Code: {cc_info}")
|
|
193
|
+
|
|
194
|
+
console.print(f" Python: {data.get('python_version', 'unknown')}")
|
|
195
|
+
console.print(f" uv: {data.get('uv_version', 'unknown')}")
|
|
196
|
+
|
|
197
|
+
# Tracking errors (e.g., stale pre-OSS manifest)
|
|
198
|
+
if "tracking_error" in data:
|
|
199
|
+
console.print("\n[bold red]Tracking Error[/bold red]")
|
|
200
|
+
console.print(f" {data['tracking_error']}")
|
|
201
|
+
|
|
202
|
+
# Installations
|
|
203
|
+
installations = data.get("installations", [])
|
|
204
|
+
console.print(f"\n[bold]Installations[/bold] ({len(installations)})")
|
|
205
|
+
if installations:
|
|
206
|
+
table = Table(
|
|
207
|
+
show_header=True,
|
|
208
|
+
header_style="bold",
|
|
209
|
+
box=None,
|
|
210
|
+
expand=False,
|
|
211
|
+
padding=(0, 1),
|
|
212
|
+
)
|
|
213
|
+
table.add_column("SCOPE", width=8)
|
|
214
|
+
table.add_column("PATH", overflow="fold", no_wrap=False)
|
|
215
|
+
table.add_column("PROFILE", width=10)
|
|
216
|
+
table.add_column("MODE", width=8)
|
|
217
|
+
|
|
218
|
+
for inst in installations:
|
|
219
|
+
raw_path = inst.get("project_path")
|
|
220
|
+
path_display = display_path(raw_path) if raw_path else "[dim]~/.claude[/dim]"
|
|
221
|
+
table.add_row(
|
|
222
|
+
inst.get("scope", ""),
|
|
223
|
+
path_display,
|
|
224
|
+
inst.get("profile", ""),
|
|
225
|
+
inst.get("mode", ""),
|
|
226
|
+
)
|
|
227
|
+
console.print(table)
|
|
228
|
+
else:
|
|
229
|
+
console.print(" [dim](none)[/dim]")
|
|
230
|
+
|
|
231
|
+
# Proxies
|
|
232
|
+
proxies = data.get("proxies", [])
|
|
233
|
+
console.print(f"\n[bold]Proxies[/bold] ({len(proxies)})")
|
|
234
|
+
if proxies:
|
|
235
|
+
table = Table(
|
|
236
|
+
show_header=True,
|
|
237
|
+
header_style="bold",
|
|
238
|
+
box=None,
|
|
239
|
+
expand=False,
|
|
240
|
+
padding=(0, 1),
|
|
241
|
+
)
|
|
242
|
+
table.add_column("PROXY ID", width=25)
|
|
243
|
+
table.add_column("TEMPLATE", width=20)
|
|
244
|
+
table.add_column("BASE URL", overflow="fold")
|
|
245
|
+
|
|
246
|
+
for proxy in proxies:
|
|
247
|
+
table.add_row(
|
|
248
|
+
proxy.get("proxy_id", ""),
|
|
249
|
+
proxy.get("template") or "[dim]-[/dim]",
|
|
250
|
+
proxy.get("base_url", ""),
|
|
251
|
+
)
|
|
252
|
+
console.print(table)
|
|
253
|
+
else:
|
|
254
|
+
console.print(" [dim](none)[/dim]")
|
|
255
|
+
|
|
256
|
+
# Recent sessions
|
|
257
|
+
sessions = data.get("sessions", [])
|
|
258
|
+
console.print(f"\n[bold]Recent Sessions[/bold] ({len(sessions)} shown)")
|
|
259
|
+
if sessions:
|
|
260
|
+
table = Table(
|
|
261
|
+
show_header=True,
|
|
262
|
+
header_style="bold",
|
|
263
|
+
box=None,
|
|
264
|
+
expand=False,
|
|
265
|
+
padding=(0, 1),
|
|
266
|
+
)
|
|
267
|
+
table.add_column("NAME", width=25)
|
|
268
|
+
table.add_column("LAST ACCESSED", width=20)
|
|
269
|
+
|
|
270
|
+
for sess in sessions:
|
|
271
|
+
# Format timestamp
|
|
272
|
+
last_accessed = sess.get("last_accessed", "")
|
|
273
|
+
if last_accessed:
|
|
274
|
+
# Truncate to date+time
|
|
275
|
+
last_accessed = last_accessed[:19].replace("T", " ")
|
|
276
|
+
table.add_row(sess.get("name", ""), last_accessed)
|
|
277
|
+
console.print(table)
|
|
278
|
+
else:
|
|
279
|
+
console.print(" [dim](none)[/dim]")
|
|
280
|
+
|
|
281
|
+
console.print()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Exceptions for Forge Installer.
|
|
2
|
+
|
|
3
|
+
Follows the pattern from session/exceptions.py: specific exception types
|
|
4
|
+
with context fields for debugging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ForgeInstallError(Exception):
|
|
13
|
+
"""Base exception for install module."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConflictError(ForgeInstallError):
|
|
17
|
+
"""Base for conflict errors."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileConflictError(ConflictError):
|
|
21
|
+
"""Raised when a file conflict is detected.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
path: The conflicting file path.
|
|
25
|
+
reason: Why the conflict occurred.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, path: str, reason: str) -> None:
|
|
29
|
+
self.path = path
|
|
30
|
+
self.reason = reason
|
|
31
|
+
super().__init__(f"file conflict at '{path}': {reason}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SettingsConflictError(ConflictError):
|
|
35
|
+
"""Raised when a settings conflict is detected.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
key_path: The conflicting settings key (dot-notation).
|
|
39
|
+
current_value: The existing value in settings.
|
|
40
|
+
forge_value: The value Forge wants to set.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, key_path: str, current_value: Any, forge_value: Any) -> None:
|
|
44
|
+
self.key_path = key_path
|
|
45
|
+
self.current_value = current_value
|
|
46
|
+
self.forge_value = forge_value
|
|
47
|
+
super().__init__(f"settings conflict at '{key_path}': " f"current={current_value!r}, forge={forge_value!r}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TrackingCorruptedError(ForgeInstallError):
|
|
51
|
+
"""Raised when tracking file cannot be parsed.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
path: Path to the problematic tracking file.
|
|
55
|
+
reason: What went wrong during parsing.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, path: str, reason: str) -> None:
|
|
59
|
+
self.path = path
|
|
60
|
+
self.reason = reason
|
|
61
|
+
super().__init__(f"tracking file at '{path}': {reason}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotInstalledError(ForgeInstallError):
|
|
65
|
+
"""Raised when trying to update/uninstall with no installation.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
scope: The scope that has no installation.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, scope: str) -> None:
|
|
72
|
+
self.scope = scope
|
|
73
|
+
super().__init__(f"no Forge installation found for scope '{scope}'")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SourceNotFoundError(ForgeInstallError):
|
|
77
|
+
"""Raised when source extension files are missing.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
module: The module whose source is missing.
|
|
81
|
+
path: Expected path to the source.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, module: str, path: str) -> None:
|
|
85
|
+
self.module = module
|
|
86
|
+
self.path = path
|
|
87
|
+
super().__init__(f"source for module '{module}' not found at '{path}'")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class NestedClaudeDirectoryError(ForgeInstallError):
|
|
91
|
+
"""Raised when project_root is inside a .claude directory.
|
|
92
|
+
|
|
93
|
+
This prevents creating nested .claude/.claude directories which can
|
|
94
|
+
happen if `forge init --project` is run from within a .claude directory.
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
project_root: The problematic project root path.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, project_root: str) -> None:
|
|
101
|
+
self.project_root = project_root
|
|
102
|
+
super().__init__(
|
|
103
|
+
f"project root '{project_root}' is inside a .claude directory; "
|
|
104
|
+
"this would create nested .claude/.claude directories. "
|
|
105
|
+
"Run from the project root, not from within .claude/"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NoClaudeDirectoryError(ForgeInstallError):
|
|
110
|
+
"""Raised when no .claude directory is found walking up from cwd.
|
|
111
|
+
|
|
112
|
+
This indicates Forge is being run outside of a Claude Code project,
|
|
113
|
+
and the user hasn't explicitly specified a scope.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
start_path: The directory where the search started.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, start_path: str) -> None:
|
|
120
|
+
self.start_path = start_path
|
|
121
|
+
super().__init__(
|
|
122
|
+
f"no .claude directory found walking up from '{start_path}'. "
|
|
123
|
+
"Run from within a Claude Code project, or use '--scope user' for global install."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class NoForgeInstallationError(ForgeInstallError):
|
|
128
|
+
"""Raised when no Forge installation is found walking up from cwd.
|
|
129
|
+
|
|
130
|
+
Different from NotInstalledError: this is for auto-detection when no
|
|
131
|
+
scope is specified, whereas NotInstalledError is for a specific scope.
|
|
132
|
+
|
|
133
|
+
Attributes:
|
|
134
|
+
start_path: The directory where the search started.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, start_path: str) -> None:
|
|
138
|
+
self.start_path = start_path
|
|
139
|
+
super().__init__(
|
|
140
|
+
f"no Forge installation found walking up from '{start_path}'. "
|
|
141
|
+
"Run 'forge init' first, or specify a scope explicitly."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PathBoundaryViolationError(ForgeInstallError):
|
|
146
|
+
"""Raised when a path is outside its expected boundary.
|
|
147
|
+
|
|
148
|
+
This is a security check to prevent malicious tracking file modifications
|
|
149
|
+
from causing deletion of arbitrary system files.
|
|
150
|
+
|
|
151
|
+
Attributes:
|
|
152
|
+
path: The offending path.
|
|
153
|
+
expected_base: The expected parent directory.
|
|
154
|
+
operation: What was being attempted (e.g., "delete").
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, path: str, expected_base: str, operation: str = "access") -> None:
|
|
158
|
+
self.path = path
|
|
159
|
+
self.expected_base = expected_base
|
|
160
|
+
self.operation = operation
|
|
161
|
+
super().__init__(
|
|
162
|
+
f"security violation: refusing to {operation} '{path}' - " f"not within expected boundary '{expected_base}'"
|
|
163
|
+
)
|
forge/install/hooks.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Hook installation detection.
|
|
2
|
+
|
|
3
|
+
Checks whether Forge hooks are installed in Claude Code settings,
|
|
4
|
+
used by CLI commands to warn when features depend on hooks that aren't present.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _find_claude_dir(start: Path) -> Path | None:
|
|
14
|
+
"""Walk up from start to find the nearest .claude/ directory.
|
|
15
|
+
|
|
16
|
+
Returns the directory containing .claude/, or None if not found
|
|
17
|
+
before reaching the filesystem root.
|
|
18
|
+
"""
|
|
19
|
+
current = start.resolve()
|
|
20
|
+
for _ in range(50): # safety bound
|
|
21
|
+
if (current / ".claude").is_dir():
|
|
22
|
+
return current
|
|
23
|
+
parent = current.parent
|
|
24
|
+
if parent == current:
|
|
25
|
+
return None
|
|
26
|
+
current = parent
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _settings_paths(worktree_path: Path) -> list[Path]:
|
|
31
|
+
"""Return settings files to scan in priority order (local > project > user).
|
|
32
|
+
|
|
33
|
+
Walks up from worktree_path to find the nearest .claude/ directory,
|
|
34
|
+
so detection works correctly from subdirectories.
|
|
35
|
+
"""
|
|
36
|
+
from forge.session.claude.paths import get_claude_home
|
|
37
|
+
|
|
38
|
+
project_root = _find_claude_dir(worktree_path) or worktree_path
|
|
39
|
+
return [
|
|
40
|
+
project_root / ".claude" / "settings.local.json",
|
|
41
|
+
project_root / ".claude" / "settings.json",
|
|
42
|
+
get_claude_home() / "settings.local.json",
|
|
43
|
+
get_claude_home() / "settings.json",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _entry_has_command(entry: dict, needle: str) -> bool:
|
|
48
|
+
"""Check if a hook entry contains a command matching the needle.
|
|
49
|
+
|
|
50
|
+
System boundary: reads Claude Code settings.json which may contain
|
|
51
|
+
either format depending on when the user last ran forge extensions sync.
|
|
52
|
+
- Current: {"hooks": [{"type": "command", "command": "..."}]}
|
|
53
|
+
- Pre-sync: {"type": "command", "command": "..."}
|
|
54
|
+
"""
|
|
55
|
+
# Pre-sync format: command at entry top level
|
|
56
|
+
cmd = entry.get("command")
|
|
57
|
+
if isinstance(cmd, str) and needle in cmd:
|
|
58
|
+
return True
|
|
59
|
+
# Current format: nested hooks array
|
|
60
|
+
for hook in entry.get("hooks", []):
|
|
61
|
+
if not isinstance(hook, dict):
|
|
62
|
+
continue
|
|
63
|
+
cmd = hook.get("command", "")
|
|
64
|
+
if isinstance(cmd, str) and needle in cmd:
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def has_forge_hook(worktree_path: Path, hook_type: str, command_needle: str = "forge hook") -> bool:
|
|
70
|
+
"""Check if a specific Forge hook type is installed in any settings scope.
|
|
71
|
+
|
|
72
|
+
Scans local, project, and user settings files for a hook entry whose
|
|
73
|
+
command contains *command_needle*. The default needle ``"forge hook"``
|
|
74
|
+
matches any Forge hook; pass a more specific string like
|
|
75
|
+
``"forge hook policy-check"`` to require a particular handler.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
worktree_path: Project/worktree root to resolve local/project settings.
|
|
79
|
+
hook_type: Claude Code hook event name (e.g., "SessionStart", "PreToolUse", "Stop").
|
|
80
|
+
command_needle: Substring to look for in the command string.
|
|
81
|
+
"""
|
|
82
|
+
for settings_path in _settings_paths(worktree_path):
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(settings_path.read_text())
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
continue
|
|
87
|
+
hooks = data.get("hooks")
|
|
88
|
+
if not isinstance(hooks, dict):
|
|
89
|
+
continue
|
|
90
|
+
hook_entries = hooks.get(hook_type)
|
|
91
|
+
if not hook_entries or not isinstance(hook_entries, list):
|
|
92
|
+
continue
|
|
93
|
+
for entry in hook_entries:
|
|
94
|
+
if not isinstance(entry, dict):
|
|
95
|
+
continue
|
|
96
|
+
if _entry_has_command(entry, command_needle):
|
|
97
|
+
return True
|
|
98
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError, AttributeError):
|
|
99
|
+
continue
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def has_forge_hooks(worktree_path: Path) -> bool:
|
|
104
|
+
"""Check if any Forge hooks are installed.
|
|
105
|
+
|
|
106
|
+
Uses SessionStart as the sentinel — it's included in all Forge
|
|
107
|
+
installations and is the minimum viable hook for session tracking.
|
|
108
|
+
"""
|
|
109
|
+
return has_forge_hook(worktree_path, "SessionStart")
|