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,1037 @@
|
|
|
1
|
+
"""Core installer logic.
|
|
2
|
+
|
|
3
|
+
Provides plan(), init(), update(), and uninstall() operations for
|
|
4
|
+
managing Claude Code extensions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from copy import deepcopy
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# Import for CLAUDE_HOME support
|
|
18
|
+
from forge.session.claude.paths import get_claude_home
|
|
19
|
+
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
NoClaudeDirectoryError,
|
|
22
|
+
NoForgeInstallationError,
|
|
23
|
+
NotInstalledError,
|
|
24
|
+
PathBoundaryViolationError,
|
|
25
|
+
)
|
|
26
|
+
from .models import (
|
|
27
|
+
MODULE_DEPENDENCIES,
|
|
28
|
+
PROFILE_MODULES,
|
|
29
|
+
PROFILE_RANK,
|
|
30
|
+
SETTINGS_ONLY_MODULES,
|
|
31
|
+
SKILL_PROFILE_REQUIREMENTS,
|
|
32
|
+
FilePlan,
|
|
33
|
+
Installation,
|
|
34
|
+
InstalledFile,
|
|
35
|
+
InstallMode,
|
|
36
|
+
InstallModule,
|
|
37
|
+
InstallPlan,
|
|
38
|
+
InstallProfile,
|
|
39
|
+
InstallScope,
|
|
40
|
+
SettingsPlan,
|
|
41
|
+
now_iso,
|
|
42
|
+
)
|
|
43
|
+
from .settings_merge import (
|
|
44
|
+
backup_settings,
|
|
45
|
+
cleanup_empty_settings,
|
|
46
|
+
entries_to_added_structure,
|
|
47
|
+
find_added_files,
|
|
48
|
+
find_backup_files,
|
|
49
|
+
get_settings_path,
|
|
50
|
+
hooks_already_present,
|
|
51
|
+
load_added_settings,
|
|
52
|
+
merge,
|
|
53
|
+
permissions_already_present,
|
|
54
|
+
read_settings,
|
|
55
|
+
save_added_settings,
|
|
56
|
+
scalar_already_set,
|
|
57
|
+
settings_equal,
|
|
58
|
+
smart_unmerge,
|
|
59
|
+
unmerge,
|
|
60
|
+
write_settings,
|
|
61
|
+
)
|
|
62
|
+
from .tracking import TrackingStore, compute_checksum
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_EXTENSION_MODULE_NAMES = ("skills", "agents", "commands")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_forge_source_root() -> Path:
|
|
71
|
+
"""Get the forge repo source root (for git-tracked file filtering).
|
|
72
|
+
|
|
73
|
+
Returns the repo root when running from a checkout; returns a
|
|
74
|
+
best-effort path otherwise (git operations will gracefully fail).
|
|
75
|
+
"""
|
|
76
|
+
return Path(__file__).parent.parent.parent.parent
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_repo_checkout(forge_source: Path) -> bool:
|
|
80
|
+
"""Return True if forge_source looks like the Forge dev repo.
|
|
81
|
+
|
|
82
|
+
Requires both the Python package (src/forge/) AND at least one extension
|
|
83
|
+
directory to be present. The two-signal check rules out false positives
|
|
84
|
+
like a user project that happens to have src/skills/ but isn't a Forge
|
|
85
|
+
checkout.
|
|
86
|
+
"""
|
|
87
|
+
src = forge_source / "src"
|
|
88
|
+
if not (src / "forge").is_dir():
|
|
89
|
+
return False
|
|
90
|
+
return any((src / name).is_dir() for name in _EXTENSION_MODULE_NAMES)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _get_bundled_extensions_path() -> Path:
|
|
94
|
+
"""Return the path to bundled extensions inside the installed package.
|
|
95
|
+
|
|
96
|
+
Uses importlib.resources to locate package data — robust against
|
|
97
|
+
zip imports and namespace package layouts. Extracted as a separate
|
|
98
|
+
function so tests can mock it cleanly.
|
|
99
|
+
"""
|
|
100
|
+
return Path(str(files("forge") / "_extensions"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_extensions_root() -> Path:
|
|
104
|
+
"""Get the directory containing extension modules (skills, agents, commands).
|
|
105
|
+
|
|
106
|
+
Tries repo checkout first (editable/dev install), then falls back
|
|
107
|
+
to bundled extensions inside the wheel (forge/_extensions/).
|
|
108
|
+
"""
|
|
109
|
+
forge_source = get_forge_source_root()
|
|
110
|
+
if _is_repo_checkout(forge_source):
|
|
111
|
+
return forge_source / "src"
|
|
112
|
+
|
|
113
|
+
bundled = _get_bundled_extensions_path()
|
|
114
|
+
if bundled.is_dir():
|
|
115
|
+
return bundled
|
|
116
|
+
|
|
117
|
+
raise FileNotFoundError("Extension source files not found. Reinstall Forge or run from a repo checkout.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_EXCLUDED_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
|
|
121
|
+
_EXCLUDED_EXTENSIONS = {".pyc", ".pyo"}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_installable(path: Path) -> bool:
|
|
125
|
+
"""Return False for build artifacts that should never be installed."""
|
|
126
|
+
if path.name.startswith("."):
|
|
127
|
+
return False
|
|
128
|
+
if path.suffix in _EXCLUDED_EXTENSIONS:
|
|
129
|
+
return False
|
|
130
|
+
if _EXCLUDED_DIR_NAMES & set(path.parts):
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _get_git_tracked_files(repo_root: Path) -> set[Path] | None:
|
|
136
|
+
"""Return the set of git-tracked files under repo_root, or None if unavailable."""
|
|
137
|
+
try:
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
140
|
+
cwd=repo_root,
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
timeout=5,
|
|
144
|
+
)
|
|
145
|
+
if result.returncode != 0:
|
|
146
|
+
return None
|
|
147
|
+
return {repo_root / line for line in result.stdout.splitlines() if line}
|
|
148
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_target_root(scope: InstallScope, project_root: Path | None = None) -> Path:
|
|
153
|
+
"""Get target directory for extensions.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
scope: Installation scope.
|
|
157
|
+
project_root: Project root (required for PROJECT/LOCAL).
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Path to target .claude directory.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
ValueError: If project_root required but not provided.
|
|
164
|
+
NestedClaudeDirectoryError: If project_root is inside a .claude directory.
|
|
165
|
+
"""
|
|
166
|
+
if scope == InstallScope.USER:
|
|
167
|
+
return get_claude_home()
|
|
168
|
+
else:
|
|
169
|
+
if project_root is None:
|
|
170
|
+
raise ValueError("project_root required for PROJECT/LOCAL scope")
|
|
171
|
+
|
|
172
|
+
# Guard against nested .claude directories (e.g., running from .claude/)
|
|
173
|
+
resolved = project_root.resolve()
|
|
174
|
+
if ".claude" in resolved.parts:
|
|
175
|
+
from .exceptions import NestedClaudeDirectoryError
|
|
176
|
+
|
|
177
|
+
raise NestedClaudeDirectoryError(str(project_root))
|
|
178
|
+
|
|
179
|
+
return project_root / ".claude"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def validate_path_within_boundary(
|
|
183
|
+
path: Path,
|
|
184
|
+
boundary: Path,
|
|
185
|
+
operation: str = "delete",
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Validate that a path is within the expected boundary.
|
|
188
|
+
|
|
189
|
+
Security check to prevent malicious tracking file modifications
|
|
190
|
+
from causing deletion of arbitrary system files.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
path: The path to validate.
|
|
194
|
+
boundary: The expected parent directory.
|
|
195
|
+
operation: Description of the operation (for error messages).
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
PathBoundaryViolationError: If path is not within boundary.
|
|
199
|
+
"""
|
|
200
|
+
# Always use parent.resolve() / name to get the absolute path of the entry
|
|
201
|
+
# itself, without following symlinks on the final component. This:
|
|
202
|
+
# 1. Handles symlinks correctly (checks location, not target)
|
|
203
|
+
# 2. Handles non-existent paths consistently (is_symlink() returns False
|
|
204
|
+
# for non-existent paths, so we'd otherwise fall back to resolve())
|
|
205
|
+
# 3. Still canonicalizes any symlink directories in the parent chain
|
|
206
|
+
resolved_path = path.parent.resolve() / path.name
|
|
207
|
+
resolved_boundary = boundary.resolve()
|
|
208
|
+
|
|
209
|
+
if not resolved_path.is_relative_to(resolved_boundary):
|
|
210
|
+
raise PathBoundaryViolationError(str(path), str(boundary), operation)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def find_claude_root(
|
|
214
|
+
start: Path | None = None,
|
|
215
|
+
*,
|
|
216
|
+
max_depth: int = 100,
|
|
217
|
+
) -> tuple[InstallScope, Path | None]:
|
|
218
|
+
"""Find the nearest .claude directory walking up from start.
|
|
219
|
+
|
|
220
|
+
Used by `forge init` to auto-detect scope. Walks up from start directory
|
|
221
|
+
looking for a .claude/ directory. If found, returns LOCAL scope at that
|
|
222
|
+
project. If reaching home directory (~), returns USER scope.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
start: Starting directory. Defaults to cwd.
|
|
226
|
+
max_depth: Maximum directory levels to traverse (safety limit).
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Tuple of (scope, project_root). For USER scope, project_root is None.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
NoClaudeDirectoryError: If no .claude found and didn't reach home,
|
|
233
|
+
or if max_depth is exceeded.
|
|
234
|
+
"""
|
|
235
|
+
if start is None:
|
|
236
|
+
start = Path.cwd()
|
|
237
|
+
|
|
238
|
+
current = start.resolve()
|
|
239
|
+
home = Path.home().resolve()
|
|
240
|
+
|
|
241
|
+
for _ in range(max_depth):
|
|
242
|
+
claude_dir = current / ".claude"
|
|
243
|
+
if claude_dir.is_dir():
|
|
244
|
+
if current == home:
|
|
245
|
+
return (InstallScope.USER, None)
|
|
246
|
+
return (InstallScope.LOCAL, current)
|
|
247
|
+
|
|
248
|
+
if current == home:
|
|
249
|
+
# Special case: at home, use USER scope
|
|
250
|
+
return (InstallScope.USER, None)
|
|
251
|
+
|
|
252
|
+
parent = current.parent
|
|
253
|
+
if parent == current:
|
|
254
|
+
raise NoClaudeDirectoryError(str(start))
|
|
255
|
+
|
|
256
|
+
current = parent
|
|
257
|
+
|
|
258
|
+
# Safety limit exceeded (symlink loop, permission issues, etc.)
|
|
259
|
+
raise NoClaudeDirectoryError(f"{start} (exceeded max traversal depth of {max_depth})")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def find_forge_installation(
|
|
263
|
+
start: Path | None = None,
|
|
264
|
+
tracking: "TrackingStore | None" = None,
|
|
265
|
+
) -> tuple[InstallScope, Path | None]:
|
|
266
|
+
"""Find the nearest Forge installation walking up from start.
|
|
267
|
+
|
|
268
|
+
Used by `forge uninstall`, `forge update`, etc. to auto-detect scope.
|
|
269
|
+
Walks up from start directory, checking LOCAL then PROJECT at each level,
|
|
270
|
+
then USER at home.
|
|
271
|
+
|
|
272
|
+
Detection is based on file evidence (.settings.*.json.forge.* files)
|
|
273
|
+
which works across multiple projects, not just tracking store state.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
start: Starting directory. Defaults to cwd.
|
|
277
|
+
tracking: TrackingStore instance. Created if not provided.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Tuple of (scope, project_root). For USER scope, project_root is None.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
NoForgeInstallationError: If no installation found.
|
|
284
|
+
"""
|
|
285
|
+
if start is None:
|
|
286
|
+
start = Path.cwd()
|
|
287
|
+
if tracking is None:
|
|
288
|
+
tracking = TrackingStore()
|
|
289
|
+
|
|
290
|
+
current = start.resolve()
|
|
291
|
+
home = Path.home().resolve()
|
|
292
|
+
|
|
293
|
+
while True:
|
|
294
|
+
claude_dir = current / ".claude"
|
|
295
|
+
if claude_dir.is_dir():
|
|
296
|
+
# Check LOCAL installation first (most specific) - file-based detection
|
|
297
|
+
local_settings = claude_dir / "settings.local.json"
|
|
298
|
+
local_backups = find_backup_files(local_settings)
|
|
299
|
+
local_added = find_added_files(local_settings)
|
|
300
|
+
if local_backups or local_added:
|
|
301
|
+
return (InstallScope.LOCAL, current)
|
|
302
|
+
|
|
303
|
+
project_settings = claude_dir / "settings.json"
|
|
304
|
+
project_backups = find_backup_files(project_settings)
|
|
305
|
+
project_added = find_added_files(project_settings)
|
|
306
|
+
# Only check project at non-home locations (home uses USER scope)
|
|
307
|
+
if current != home and (project_backups or project_added):
|
|
308
|
+
return (InstallScope.PROJECT, current)
|
|
309
|
+
|
|
310
|
+
if current == home:
|
|
311
|
+
user_settings = home / ".claude" / "settings.json"
|
|
312
|
+
user_backups = find_backup_files(user_settings)
|
|
313
|
+
user_added = find_added_files(user_settings)
|
|
314
|
+
if user_backups or user_added:
|
|
315
|
+
return (InstallScope.USER, None)
|
|
316
|
+
# Fallback to tracking store for USER (no project_path for user scope)
|
|
317
|
+
if tracking.get_installation(InstallScope.USER.value, None):
|
|
318
|
+
return (InstallScope.USER, None)
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
parent = current.parent
|
|
322
|
+
if parent == current:
|
|
323
|
+
break
|
|
324
|
+
|
|
325
|
+
current = parent
|
|
326
|
+
|
|
327
|
+
# No installation found
|
|
328
|
+
raise NoForgeInstallationError(str(start))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def resolve_modules(
|
|
332
|
+
profile: InstallProfile,
|
|
333
|
+
with_modules: set[InstallModule] | None = None,
|
|
334
|
+
without_modules: set[InstallModule] | None = None,
|
|
335
|
+
) -> set[InstallModule]:
|
|
336
|
+
"""Resolve final module set from profile and toggles.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
profile: Base profile.
|
|
340
|
+
with_modules: Modules to add.
|
|
341
|
+
without_modules: Modules to remove.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Final set of modules to install.
|
|
345
|
+
"""
|
|
346
|
+
modules = PROFILE_MODULES[profile].copy()
|
|
347
|
+
|
|
348
|
+
if with_modules:
|
|
349
|
+
modules |= with_modules
|
|
350
|
+
|
|
351
|
+
if without_modules:
|
|
352
|
+
modules -= without_modules
|
|
353
|
+
|
|
354
|
+
for module in list(modules):
|
|
355
|
+
if deps := MODULE_DEPENDENCIES.get(module):
|
|
356
|
+
modules |= deps
|
|
357
|
+
|
|
358
|
+
return modules
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def get_module_source_dir(module: InstallModule) -> str:
|
|
362
|
+
"""Get source directory name for a module.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
module: The module.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Directory name (e.g., "commands", "agents").
|
|
369
|
+
"""
|
|
370
|
+
return module.value
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class Installer:
|
|
374
|
+
"""Main installer for Forge extensions.
|
|
375
|
+
|
|
376
|
+
Handles plan, init, update, and uninstall operations.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def __init__(
|
|
380
|
+
self,
|
|
381
|
+
scope: InstallScope = InstallScope.USER,
|
|
382
|
+
project_root: Path | None = None,
|
|
383
|
+
tracking_store: TrackingStore | None = None,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Initialize installer.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
scope: Installation scope.
|
|
389
|
+
project_root: Project root (required for PROJECT/LOCAL).
|
|
390
|
+
tracking_store: Override tracking store (for testing).
|
|
391
|
+
"""
|
|
392
|
+
self._scope = scope
|
|
393
|
+
self._project_root = project_root
|
|
394
|
+
self._tracking = tracking_store or TrackingStore()
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def _project_path_str(self) -> str | None:
|
|
398
|
+
"""Get project path as string for tracking (None for user scope)."""
|
|
399
|
+
if self._scope == InstallScope.USER:
|
|
400
|
+
return None
|
|
401
|
+
return str(self._project_root) if self._project_root else None
|
|
402
|
+
|
|
403
|
+
def plan(
|
|
404
|
+
self,
|
|
405
|
+
profile: InstallProfile = InstallProfile.STANDARD,
|
|
406
|
+
mode: InstallMode = InstallMode.COPY,
|
|
407
|
+
with_modules: set[InstallModule] | None = None,
|
|
408
|
+
without_modules: set[InstallModule] | None = None,
|
|
409
|
+
force: bool = False,
|
|
410
|
+
*,
|
|
411
|
+
_modules_override: set[InstallModule] | None = None,
|
|
412
|
+
) -> InstallPlan:
|
|
413
|
+
"""Compute installation plan without making changes.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
profile: Installation profile.
|
|
417
|
+
mode: Installation mode.
|
|
418
|
+
with_modules: Modules to add.
|
|
419
|
+
without_modules: Modules to remove.
|
|
420
|
+
force: If True, override conflicts.
|
|
421
|
+
_modules_override: Internal. If provided, use exactly these modules
|
|
422
|
+
instead of resolving from profile. Used by update() to ensure
|
|
423
|
+
only tracked modules are touched.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
InstallPlan describing what would be done.
|
|
427
|
+
"""
|
|
428
|
+
if _modules_override is not None:
|
|
429
|
+
modules = _modules_override
|
|
430
|
+
else:
|
|
431
|
+
modules = resolve_modules(profile, with_modules, without_modules)
|
|
432
|
+
|
|
433
|
+
# Sort modules for deterministic output
|
|
434
|
+
sorted_modules = sorted(m.value for m in modules)
|
|
435
|
+
|
|
436
|
+
plan = InstallPlan(
|
|
437
|
+
scope=self._scope.value,
|
|
438
|
+
mode=mode.value,
|
|
439
|
+
profile=profile.value,
|
|
440
|
+
modules=sorted_modules,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
source_root = get_extensions_root()
|
|
444
|
+
target_root = get_target_root(self._scope, self._project_root)
|
|
445
|
+
existing = self._tracking.get_installation(self._scope.value, self._project_path_str)
|
|
446
|
+
|
|
447
|
+
# Only filter by git when extensions come from a repo checkout. When
|
|
448
|
+
# running from a wheel install, source_root is forge/_extensions/ inside
|
|
449
|
+
# site-packages — typically gitignored, so a git-tracked filter would
|
|
450
|
+
# exclude every file. _is_installable() handles the wheel-install case.
|
|
451
|
+
forge_source = get_forge_source_root()
|
|
452
|
+
git_tracked = _get_git_tracked_files(forge_source) if _is_repo_checkout(forge_source) else None
|
|
453
|
+
|
|
454
|
+
# Precompute installed skill names from manifest (skill-level, not file-level)
|
|
455
|
+
# so that update keeps the entire skill coherent when new files are added
|
|
456
|
+
installed_skills: set[str] = set()
|
|
457
|
+
if existing:
|
|
458
|
+
skills_prefix = str(target_root / "skills") + "/"
|
|
459
|
+
for f in existing.files:
|
|
460
|
+
if f.target_path.startswith(skills_prefix):
|
|
461
|
+
suffix = f.target_path[len(skills_prefix) :]
|
|
462
|
+
if "/" in suffix:
|
|
463
|
+
installed_skills.add(suffix.split("/", 1)[0])
|
|
464
|
+
|
|
465
|
+
for module in sorted(modules, key=lambda m: m.value):
|
|
466
|
+
if module in SETTINGS_ONLY_MODULES:
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
source_dir = source_root / get_module_source_dir(module)
|
|
470
|
+
if not source_dir.is_dir():
|
|
471
|
+
# Source not yet in allowlist - silently skip
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
target_dir = target_root / get_module_source_dir(module)
|
|
475
|
+
|
|
476
|
+
# Find installable source files (sorted for determinism)
|
|
477
|
+
# _is_installable excludes __pycache__/.pyc unconditionally (works in Docker
|
|
478
|
+
# where .git/ is absent and _get_git_tracked_files returns None).
|
|
479
|
+
source_files = sorted(
|
|
480
|
+
f
|
|
481
|
+
for f in source_dir.rglob("*")
|
|
482
|
+
if f.is_file() and _is_installable(f) and (git_tracked is None or f in git_tracked)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
for source_file in source_files:
|
|
486
|
+
rel_path = source_file.relative_to(source_dir)
|
|
487
|
+
target_file = target_dir / rel_path
|
|
488
|
+
|
|
489
|
+
# Per-skill profile gating: skip skills that require a higher profile,
|
|
490
|
+
# unless the skill is already installed (update keeps entire skill coherent)
|
|
491
|
+
if module == InstallModule.SKILLS and rel_path.parts:
|
|
492
|
+
skill_name = rel_path.parts[0]
|
|
493
|
+
required = SKILL_PROFILE_REQUIREMENTS.get(skill_name)
|
|
494
|
+
if required and PROFILE_RANK[profile] < PROFILE_RANK[required]:
|
|
495
|
+
if skill_name not in installed_skills:
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
file_plan = self._plan_file(source_file, target_file, mode, existing, force)
|
|
499
|
+
plan.files.append(file_plan)
|
|
500
|
+
if file_plan.action == "conflict":
|
|
501
|
+
plan.has_conflicts = True
|
|
502
|
+
plan.conflicts.append(f"File: {file_plan.target_path} - {file_plan.reason}")
|
|
503
|
+
|
|
504
|
+
# Sort files for deterministic output
|
|
505
|
+
plan.files.sort(key=lambda f: f.target_path)
|
|
506
|
+
|
|
507
|
+
settings_plans = self._plan_settings(modules, force)
|
|
508
|
+
plan.settings.extend(settings_plans)
|
|
509
|
+
for sp in settings_plans:
|
|
510
|
+
if sp.action == "conflict":
|
|
511
|
+
plan.has_conflicts = True
|
|
512
|
+
plan.conflicts.append(f"Setting: {sp.key_path} - {sp.reason}")
|
|
513
|
+
|
|
514
|
+
# Sort settings for determinism
|
|
515
|
+
plan.settings.sort(key=lambda s: (s.key_path, str(s.value)))
|
|
516
|
+
|
|
517
|
+
return plan
|
|
518
|
+
|
|
519
|
+
def _plan_file(
|
|
520
|
+
self,
|
|
521
|
+
source: Path,
|
|
522
|
+
target: Path,
|
|
523
|
+
mode: InstallMode,
|
|
524
|
+
existing: Installation | None,
|
|
525
|
+
force: bool,
|
|
526
|
+
) -> FilePlan:
|
|
527
|
+
"""Plan a single file operation.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
source: Source file path.
|
|
531
|
+
target: Target file path.
|
|
532
|
+
mode: Installation mode.
|
|
533
|
+
existing: Existing installation (if any).
|
|
534
|
+
force: If True, override conflicts.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
FilePlan for this file.
|
|
538
|
+
"""
|
|
539
|
+
if not target.exists() and not target.is_symlink():
|
|
540
|
+
return FilePlan(
|
|
541
|
+
action="install",
|
|
542
|
+
target_path=str(target),
|
|
543
|
+
source_path=str(source),
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
is_managed = existing is not None and any(
|
|
547
|
+
Path(f.target_path).resolve() == target.resolve() for f in existing.files
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if is_managed:
|
|
551
|
+
if mode == InstallMode.SYMLINK:
|
|
552
|
+
if target.is_symlink() and target.resolve() == source.resolve():
|
|
553
|
+
return FilePlan(
|
|
554
|
+
action="skip",
|
|
555
|
+
target_path=str(target),
|
|
556
|
+
source_path=str(source),
|
|
557
|
+
reason="symlink already correct",
|
|
558
|
+
)
|
|
559
|
+
else:
|
|
560
|
+
if target.is_file():
|
|
561
|
+
source_checksum = compute_checksum(source)
|
|
562
|
+
target_checksum = compute_checksum(target)
|
|
563
|
+
if source_checksum == target_checksum:
|
|
564
|
+
return FilePlan(
|
|
565
|
+
action="skip",
|
|
566
|
+
target_path=str(target),
|
|
567
|
+
source_path=str(source),
|
|
568
|
+
reason="file unchanged",
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
return FilePlan(
|
|
572
|
+
action="update",
|
|
573
|
+
target_path=str(target),
|
|
574
|
+
source_path=str(source),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if force:
|
|
578
|
+
return FilePlan(
|
|
579
|
+
action="install",
|
|
580
|
+
target_path=str(target),
|
|
581
|
+
source_path=str(source),
|
|
582
|
+
reason="force overwrite",
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
return FilePlan(
|
|
586
|
+
action="conflict",
|
|
587
|
+
target_path=str(target),
|
|
588
|
+
source_path=str(source),
|
|
589
|
+
reason="file exists and is not Forge-managed",
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
def _plan_settings(
|
|
593
|
+
self,
|
|
594
|
+
modules: set[InstallModule],
|
|
595
|
+
force: bool,
|
|
596
|
+
) -> list[SettingsPlan]:
|
|
597
|
+
"""Plan settings merge operations.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
modules: Modules being installed.
|
|
601
|
+
force: If True, override scalar conflicts.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
List of SettingsPlan.
|
|
605
|
+
"""
|
|
606
|
+
plans: list[SettingsPlan] = []
|
|
607
|
+
|
|
608
|
+
settings_path = get_settings_path(self._scope, self._project_root)
|
|
609
|
+
current_settings = read_settings(settings_path)
|
|
610
|
+
|
|
611
|
+
forge_settings = self._load_forge_settings()
|
|
612
|
+
|
|
613
|
+
include_statusline = InstallModule.STATUSLINE in modules
|
|
614
|
+
if include_statusline and "statusLine" in forge_settings:
|
|
615
|
+
current = current_settings.get("statusLine")
|
|
616
|
+
forge_value = forge_settings["statusLine"]
|
|
617
|
+
if scalar_already_set(current_settings, "statusLine", forge_value):
|
|
618
|
+
plans.append(
|
|
619
|
+
SettingsPlan(
|
|
620
|
+
action="skip",
|
|
621
|
+
key_path="statusLine",
|
|
622
|
+
value=forge_value,
|
|
623
|
+
reason="already set",
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
elif current is not None and current != forge_value and not force:
|
|
627
|
+
plans.append(
|
|
628
|
+
SettingsPlan(
|
|
629
|
+
action="conflict",
|
|
630
|
+
key_path="statusLine",
|
|
631
|
+
value=forge_value,
|
|
632
|
+
current_value=current,
|
|
633
|
+
reason="statusLine already set to different value",
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
else:
|
|
637
|
+
plans.append(
|
|
638
|
+
SettingsPlan(
|
|
639
|
+
action="merge",
|
|
640
|
+
key_path="statusLine",
|
|
641
|
+
value=forge_value,
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# Hooks and permissions don't conflict (append/union)
|
|
646
|
+
if InstallModule.HOOKS in modules:
|
|
647
|
+
forge_hooks = forge_settings.get("hooks", {})
|
|
648
|
+
for hook_type in sorted(forge_hooks):
|
|
649
|
+
# Skip empty arrays (no entries to add)
|
|
650
|
+
if not forge_hooks[hook_type]:
|
|
651
|
+
continue
|
|
652
|
+
if hooks_already_present(current_settings, hook_type, forge_hooks[hook_type]):
|
|
653
|
+
plans.append(
|
|
654
|
+
SettingsPlan(
|
|
655
|
+
action="skip",
|
|
656
|
+
key_path=f"hooks.{hook_type}",
|
|
657
|
+
value="(already present)",
|
|
658
|
+
reason="hooks already installed",
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
else:
|
|
662
|
+
plans.append(
|
|
663
|
+
SettingsPlan(
|
|
664
|
+
action="merge",
|
|
665
|
+
key_path=f"hooks.{hook_type}",
|
|
666
|
+
value="(append + dedupe)",
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
if InstallModule.PERMISSIONS in modules:
|
|
671
|
+
for perm_type in ["allow", "deny"]:
|
|
672
|
+
forge_perms = forge_settings.get("permissions", {}).get(perm_type)
|
|
673
|
+
if forge_perms:
|
|
674
|
+
if permissions_already_present(current_settings, perm_type, forge_perms):
|
|
675
|
+
plans.append(
|
|
676
|
+
SettingsPlan(
|
|
677
|
+
action="skip",
|
|
678
|
+
key_path=f"permissions.{perm_type}",
|
|
679
|
+
value="(already present)",
|
|
680
|
+
reason="permissions already installed",
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
else:
|
|
684
|
+
plans.append(
|
|
685
|
+
SettingsPlan(
|
|
686
|
+
action="merge",
|
|
687
|
+
key_path=f"permissions.{perm_type}",
|
|
688
|
+
value="(union unique)",
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Env vars (dict merge - Forge overrides)
|
|
693
|
+
if forge_env := forge_settings.get("env"):
|
|
694
|
+
for key in sorted(forge_env):
|
|
695
|
+
if scalar_already_set(current_settings.get("env", {}), key, forge_env[key]):
|
|
696
|
+
plans.append(
|
|
697
|
+
SettingsPlan(
|
|
698
|
+
action="skip",
|
|
699
|
+
key_path=f"env.{key}",
|
|
700
|
+
value=forge_env[key],
|
|
701
|
+
reason="already set",
|
|
702
|
+
)
|
|
703
|
+
)
|
|
704
|
+
else:
|
|
705
|
+
plans.append(
|
|
706
|
+
SettingsPlan(
|
|
707
|
+
action="merge",
|
|
708
|
+
key_path=f"env.{key}",
|
|
709
|
+
value=forge_env[key],
|
|
710
|
+
)
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
return plans
|
|
714
|
+
|
|
715
|
+
def _load_forge_settings(self) -> dict[str, Any]:
|
|
716
|
+
"""Load settings from the user-editable preset.
|
|
717
|
+
|
|
718
|
+
Reads ~/.forge/claude.preset.json (auto-created from built-in defaults
|
|
719
|
+
on first access). Users customize via ``forge claude preset edit``.
|
|
720
|
+
|
|
721
|
+
Hooks are Forge-managed infrastructure, so they always come from the
|
|
722
|
+
built-in preset regardless of preset file content. This ensures
|
|
723
|
+
upgraded installs pick up new hooks even when the user's preset file
|
|
724
|
+
predates them. Infrastructure permissions (Write/Edit) are also
|
|
725
|
+
backfilled from the built-in preset. User-added permissions and env
|
|
726
|
+
vars are preserved.
|
|
727
|
+
"""
|
|
728
|
+
from forge.install.preset import get_builtin_preset, load_preset
|
|
729
|
+
|
|
730
|
+
settings = load_preset()
|
|
731
|
+
builtin = get_builtin_preset()
|
|
732
|
+
|
|
733
|
+
# Hooks are Forge-managed infrastructure, not user-customizable preset state.
|
|
734
|
+
settings["hooks"] = deepcopy(builtin.get("hooks", {}))
|
|
735
|
+
|
|
736
|
+
# Backfill infrastructure permissions from builtin (upgrade path)
|
|
737
|
+
builtin_allow = builtin.get("permissions", {}).get("allow", [])
|
|
738
|
+
if builtin_allow:
|
|
739
|
+
current_allow = settings.setdefault("permissions", {}).setdefault("allow", [])
|
|
740
|
+
for perm in builtin_allow:
|
|
741
|
+
if perm not in current_allow:
|
|
742
|
+
current_allow.append(perm)
|
|
743
|
+
return settings
|
|
744
|
+
|
|
745
|
+
def init(
|
|
746
|
+
self,
|
|
747
|
+
profile: InstallProfile = InstallProfile.STANDARD,
|
|
748
|
+
mode: InstallMode = InstallMode.COPY,
|
|
749
|
+
with_modules: set[InstallModule] | None = None,
|
|
750
|
+
without_modules: set[InstallModule] | None = None,
|
|
751
|
+
force: bool = False,
|
|
752
|
+
*,
|
|
753
|
+
_modules_override: set[InstallModule] | None = None,
|
|
754
|
+
) -> InstallPlan:
|
|
755
|
+
"""Install extensions.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
profile: Installation profile.
|
|
759
|
+
mode: Installation mode.
|
|
760
|
+
with_modules: Modules to add.
|
|
761
|
+
without_modules: Modules to remove.
|
|
762
|
+
force: If True, override conflicts.
|
|
763
|
+
_modules_override: Internal. If provided, use exactly these modules.
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
The executed plan.
|
|
767
|
+
"""
|
|
768
|
+
plan = self.plan(
|
|
769
|
+
profile,
|
|
770
|
+
mode,
|
|
771
|
+
with_modules,
|
|
772
|
+
without_modules,
|
|
773
|
+
force,
|
|
774
|
+
_modules_override=_modules_override,
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
if plan.has_conflicts and not force:
|
|
778
|
+
return plan # Don't execute if conflicts
|
|
779
|
+
|
|
780
|
+
settings_path = get_settings_path(self._scope, self._project_root)
|
|
781
|
+
backup_path = backup_settings(settings_path)
|
|
782
|
+
|
|
783
|
+
installed_files: list[InstalledFile] = []
|
|
784
|
+
for file_plan in plan.files:
|
|
785
|
+
if file_plan.action in ("install", "update"):
|
|
786
|
+
installed_file = self._execute_file(file_plan, mode)
|
|
787
|
+
installed_files.append(installed_file)
|
|
788
|
+
|
|
789
|
+
if _modules_override is not None:
|
|
790
|
+
modules = _modules_override
|
|
791
|
+
else:
|
|
792
|
+
modules = resolve_modules(profile, with_modules, without_modules)
|
|
793
|
+
settings = read_settings(settings_path)
|
|
794
|
+
forge_settings = self._load_forge_settings()
|
|
795
|
+
entries = merge(
|
|
796
|
+
settings,
|
|
797
|
+
forge_settings,
|
|
798
|
+
force=force,
|
|
799
|
+
include_statusline=InstallModule.STATUSLINE in modules,
|
|
800
|
+
)
|
|
801
|
+
write_settings(settings_path, settings)
|
|
802
|
+
|
|
803
|
+
# Save what we added for smart uninstall
|
|
804
|
+
added_structure = entries_to_added_structure(entries)
|
|
805
|
+
save_added_settings(settings_path, added_structure)
|
|
806
|
+
|
|
807
|
+
# Merge newly installed files with existing tracked files (for idempotent re-runs)
|
|
808
|
+
now = now_iso()
|
|
809
|
+
existing = self._tracking.get_installation(self._scope.value, self._project_path_str)
|
|
810
|
+
|
|
811
|
+
# All targets the current source scan knows about (installed, skipped, or conflicted)
|
|
812
|
+
planned_targets = {f.target_path for f in plan.files}
|
|
813
|
+
|
|
814
|
+
# Remove stale tracked files whose source no longer exists (e.g., after renames).
|
|
815
|
+
# A file is stale if it was tracked in the previous installation but isn't in the
|
|
816
|
+
# current plan's target set — meaning no source file maps to that target anymore.
|
|
817
|
+
# Only auto-delete if ownership is verified (symlink target or checksum matches);
|
|
818
|
+
# otherwise drop from manifest silently — the user may have repurposed the path.
|
|
819
|
+
base_dir = get_target_root(self._scope, self._project_root)
|
|
820
|
+
dirs_to_clean: set[Path] = set()
|
|
821
|
+
if existing:
|
|
822
|
+
for existing_file in existing.files:
|
|
823
|
+
if existing_file.target_path not in planned_targets:
|
|
824
|
+
target = Path(existing_file.target_path)
|
|
825
|
+
try:
|
|
826
|
+
validate_path_within_boundary(target, base_dir, "remove stale file")
|
|
827
|
+
except PathBoundaryViolationError:
|
|
828
|
+
continue
|
|
829
|
+
if not self._is_forge_owned(target, existing_file):
|
|
830
|
+
logger.debug("Stale target not Forge-owned, dropping from manifest: %s", target)
|
|
831
|
+
continue
|
|
832
|
+
try:
|
|
833
|
+
target.unlink(missing_ok=True)
|
|
834
|
+
logger.debug("Removed stale tracked file: %s", target)
|
|
835
|
+
except OSError:
|
|
836
|
+
logger.debug("Could not remove stale target: %s", target)
|
|
837
|
+
continue
|
|
838
|
+
# Collect parent dirs for empty-directory cleanup
|
|
839
|
+
parent = target.parent
|
|
840
|
+
while parent != base_dir and parent.is_relative_to(base_dir):
|
|
841
|
+
dirs_to_clean.add(parent)
|
|
842
|
+
parent = parent.parent
|
|
843
|
+
|
|
844
|
+
# Clean up empty directories left by stale file removal (deepest first)
|
|
845
|
+
for dir_path in sorted(dirs_to_clean, key=lambda p: len(p.parts), reverse=True):
|
|
846
|
+
try:
|
|
847
|
+
dir_path.rmdir()
|
|
848
|
+
except OSError:
|
|
849
|
+
pass # Not empty or doesn't exist
|
|
850
|
+
|
|
851
|
+
# Build final files list: start with newly installed, add existing tracked files
|
|
852
|
+
# that were skipped (not re-installed this run) AND still in the plan
|
|
853
|
+
installed_paths = {f.target_path for f in installed_files}
|
|
854
|
+
final_files = list(installed_files)
|
|
855
|
+
if existing:
|
|
856
|
+
for existing_file in existing.files:
|
|
857
|
+
if existing_file.target_path not in installed_paths:
|
|
858
|
+
if existing_file.target_path in planned_targets:
|
|
859
|
+
# Keep existing tracked file that was skipped (source still exists)
|
|
860
|
+
final_files.append(existing_file)
|
|
861
|
+
|
|
862
|
+
entry_ids = {(e.key_path, e.stable_id) for e in entries}
|
|
863
|
+
final_entries = list(entries)
|
|
864
|
+
if existing:
|
|
865
|
+
for existing_entry in existing.settings_entries:
|
|
866
|
+
if (existing_entry.key_path, existing_entry.stable_id) not in entry_ids:
|
|
867
|
+
final_entries.append(existing_entry)
|
|
868
|
+
|
|
869
|
+
installation = Installation(
|
|
870
|
+
scope=self._scope.value,
|
|
871
|
+
mode=mode.value,
|
|
872
|
+
profile=profile.value,
|
|
873
|
+
modules_enabled=[m.value for m in sorted(modules, key=lambda m: m.value)],
|
|
874
|
+
files=final_files,
|
|
875
|
+
settings_entries=final_entries,
|
|
876
|
+
settings_backup_path=str(backup_path) if backup_path else None,
|
|
877
|
+
installed_at=existing.installed_at if existing else now,
|
|
878
|
+
updated_at=now,
|
|
879
|
+
)
|
|
880
|
+
self._tracking.set_installation(self._scope.value, installation, self._project_path_str)
|
|
881
|
+
|
|
882
|
+
return plan
|
|
883
|
+
|
|
884
|
+
def _execute_file(self, file_plan: FilePlan, mode: InstallMode) -> InstalledFile:
|
|
885
|
+
"""Execute a file operation.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
file_plan: Plan for the file.
|
|
889
|
+
mode: Installation mode.
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
InstalledFile record.
|
|
893
|
+
"""
|
|
894
|
+
source = Path(file_plan.source_path) # type: ignore[arg-type] # source_path is always non-None in execute context
|
|
895
|
+
target = Path(file_plan.target_path)
|
|
896
|
+
|
|
897
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
898
|
+
|
|
899
|
+
if target.exists() or target.is_symlink():
|
|
900
|
+
target.unlink()
|
|
901
|
+
|
|
902
|
+
if mode == InstallMode.SYMLINK:
|
|
903
|
+
target.symlink_to(source)
|
|
904
|
+
else:
|
|
905
|
+
shutil.copy2(source, target)
|
|
906
|
+
|
|
907
|
+
return InstalledFile(
|
|
908
|
+
target_path=str(target),
|
|
909
|
+
source_path=str(source),
|
|
910
|
+
checksum=compute_checksum(source),
|
|
911
|
+
mode=mode.value,
|
|
912
|
+
installed_at=now_iso(),
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
@staticmethod
|
|
916
|
+
def _is_forge_owned(target: Path, record: InstalledFile) -> bool:
|
|
917
|
+
"""Check if a stale target still matches Forge ownership expectations.
|
|
918
|
+
|
|
919
|
+
Returns True only if the on-disk object was clearly installed by Forge
|
|
920
|
+
(symlink pointing to the recorded source, or copy with matching checksum).
|
|
921
|
+
Returns False if the target was replaced by the user or doesn't exist.
|
|
922
|
+
"""
|
|
923
|
+
if not target.exists() and not target.is_symlink():
|
|
924
|
+
return False
|
|
925
|
+
if record.mode == "symlink":
|
|
926
|
+
if not target.is_symlink():
|
|
927
|
+
return False
|
|
928
|
+
try:
|
|
929
|
+
return target.resolve() == Path(record.source_path).resolve()
|
|
930
|
+
except OSError:
|
|
931
|
+
return False
|
|
932
|
+
else:
|
|
933
|
+
# Copy mode: checksum must match what Forge installed
|
|
934
|
+
if not target.is_file() or target.is_symlink():
|
|
935
|
+
return False
|
|
936
|
+
try:
|
|
937
|
+
return compute_checksum(target) == record.checksum
|
|
938
|
+
except OSError:
|
|
939
|
+
return False
|
|
940
|
+
|
|
941
|
+
def update(self, force: bool = False) -> InstallPlan:
|
|
942
|
+
"""Update existing installation.
|
|
943
|
+
|
|
944
|
+
Uses the exact modules from the existing installation (not re-resolved
|
|
945
|
+
from profile) to ensure only tracked items are touched.
|
|
946
|
+
|
|
947
|
+
Args:
|
|
948
|
+
force: If True, override conflicts.
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
The executed plan.
|
|
952
|
+
|
|
953
|
+
Raises:
|
|
954
|
+
NotInstalledError: If no existing installation.
|
|
955
|
+
"""
|
|
956
|
+
existing = self._tracking.get_installation(self._scope.value, self._project_path_str)
|
|
957
|
+
if existing is None:
|
|
958
|
+
raise NotInstalledError(self._scope.value)
|
|
959
|
+
|
|
960
|
+
# Use exact modules from existing installation
|
|
961
|
+
existing_modules = {InstallModule(m) for m in existing.modules_enabled}
|
|
962
|
+
|
|
963
|
+
return self.init(
|
|
964
|
+
profile=InstallProfile(existing.profile),
|
|
965
|
+
mode=InstallMode(existing.mode),
|
|
966
|
+
force=force,
|
|
967
|
+
_modules_override=existing_modules,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
def uninstall(self) -> None:
|
|
971
|
+
"""Remove Forge installation.
|
|
972
|
+
|
|
973
|
+
Raises:
|
|
974
|
+
NotInstalledError: If no existing installation.
|
|
975
|
+
"""
|
|
976
|
+
existing = self._tracking.get_installation(self._scope.value, self._project_path_str)
|
|
977
|
+
if existing is None:
|
|
978
|
+
raise NotInstalledError(self._scope.value)
|
|
979
|
+
|
|
980
|
+
dirs_to_clean: set[Path] = set()
|
|
981
|
+
base_dir = get_target_root(self._scope, self._project_root)
|
|
982
|
+
|
|
983
|
+
for file_record in existing.files:
|
|
984
|
+
target = Path(file_record.target_path)
|
|
985
|
+
# Security: validate path is within expected boundary
|
|
986
|
+
validate_path_within_boundary(target, base_dir, "delete file")
|
|
987
|
+
if target.exists() or target.is_symlink():
|
|
988
|
+
target.unlink()
|
|
989
|
+
parent = target.parent
|
|
990
|
+
while parent != base_dir and parent.is_relative_to(base_dir):
|
|
991
|
+
dirs_to_clean.add(parent)
|
|
992
|
+
parent = parent.parent
|
|
993
|
+
|
|
994
|
+
# Clean up empty directories (deepest first)
|
|
995
|
+
for dir_path in sorted(dirs_to_clean, key=lambda p: len(p.parts), reverse=True):
|
|
996
|
+
try:
|
|
997
|
+
dir_path.rmdir()
|
|
998
|
+
except OSError:
|
|
999
|
+
pass # Directory not empty or doesn't exist
|
|
1000
|
+
|
|
1001
|
+
settings_path = get_settings_path(self._scope, self._project_root)
|
|
1002
|
+
backup_files = find_backup_files(settings_path)
|
|
1003
|
+
added_files = find_added_files(settings_path)
|
|
1004
|
+
|
|
1005
|
+
current = read_settings(settings_path)
|
|
1006
|
+
backup = read_settings(backup_files[0]) if backup_files else {}
|
|
1007
|
+
added = load_added_settings(settings_path) # Already finds most recent
|
|
1008
|
+
|
|
1009
|
+
if added:
|
|
1010
|
+
# Use smart unmerge: removes our additions, preserves user changes
|
|
1011
|
+
result = smart_unmerge(current, backup, added)
|
|
1012
|
+
result = cleanup_empty_settings(result)
|
|
1013
|
+
|
|
1014
|
+
backup_cleaned = cleanup_empty_settings(backup)
|
|
1015
|
+
if settings_equal(result, backup_cleaned):
|
|
1016
|
+
if backup_files and backup_cleaned:
|
|
1017
|
+
# Had content before, restore it (use cleaned for consistency)
|
|
1018
|
+
write_settings(settings_path, backup_cleaned)
|
|
1019
|
+
elif settings_path.is_file():
|
|
1020
|
+
# Was empty/non-existent before, delete
|
|
1021
|
+
# Security: validate settings path is within expected boundary
|
|
1022
|
+
validate_path_within_boundary(settings_path, base_dir, "delete settings")
|
|
1023
|
+
settings_path.unlink()
|
|
1024
|
+
else:
|
|
1025
|
+
write_settings(settings_path, result)
|
|
1026
|
+
else:
|
|
1027
|
+
# Fallback to old unmerge if no .forge-added file
|
|
1028
|
+
unmerge(current, existing.settings_entries)
|
|
1029
|
+
write_settings(settings_path, current)
|
|
1030
|
+
|
|
1031
|
+
# Clean up .forge.added files only (keep .forge.backup files for history)
|
|
1032
|
+
for added_file in added_files:
|
|
1033
|
+
# Security: validate added file path is within expected boundary
|
|
1034
|
+
validate_path_within_boundary(added_file, base_dir, "delete added file")
|
|
1035
|
+
added_file.unlink()
|
|
1036
|
+
|
|
1037
|
+
self._tracking.remove_installation(self._scope.value, self._project_path_str)
|