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,831 @@
|
|
|
1
|
+
"""Settings merge logic for Claude Code settings.json.
|
|
2
|
+
|
|
3
|
+
Handles merging Forge settings into user's Claude Code settings with:
|
|
4
|
+
- hooks.*: append + dedupe by command path
|
|
5
|
+
- permissions.allow/deny: union unique entries
|
|
6
|
+
- statusLine: scalar (conflict unless --force)
|
|
7
|
+
|
|
8
|
+
Also handles unmerge (removing only Forge-added entries) for uninstall.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from forge.core.state import atomic_write_text
|
|
20
|
+
from forge.session.claude.paths import get_claude_home
|
|
21
|
+
|
|
22
|
+
from .exceptions import SettingsConflictError
|
|
23
|
+
from .models import InstalledSettingsEntry, InstallScope
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_timestamp() -> str:
|
|
27
|
+
"""Get current timestamp for file naming (YYYYMMDD-HHMMSS format)."""
|
|
28
|
+
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_settings_path(scope: InstallScope, project_root: Path | None = None) -> Path:
|
|
32
|
+
"""Get settings file path for a scope.
|
|
33
|
+
|
|
34
|
+
Per design.md:
|
|
35
|
+
- USER: ~/.claude/settings.json
|
|
36
|
+
- PROJECT: .claude/settings.json
|
|
37
|
+
- LOCAL: .claude/settings.local.json
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
scope: The installation scope.
|
|
41
|
+
project_root: Project root directory (required for PROJECT/LOCAL).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Path to the settings file.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If project_root is required but not provided.
|
|
48
|
+
"""
|
|
49
|
+
if scope == InstallScope.USER:
|
|
50
|
+
return get_claude_home() / "settings.json"
|
|
51
|
+
elif scope == InstallScope.PROJECT:
|
|
52
|
+
if project_root is None:
|
|
53
|
+
raise ValueError("project_root required for PROJECT scope")
|
|
54
|
+
return project_root / ".claude" / "settings.json"
|
|
55
|
+
elif scope == InstallScope.LOCAL:
|
|
56
|
+
if project_root is None:
|
|
57
|
+
raise ValueError("project_root required for LOCAL scope")
|
|
58
|
+
return project_root / ".claude" / "settings.local.json"
|
|
59
|
+
raise ValueError(f"unknown scope: {scope}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_settings(path: Path) -> dict[str, Any]:
|
|
63
|
+
"""Read settings file.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: Path to settings file.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Settings dict, or empty dict if file doesn't exist.
|
|
70
|
+
"""
|
|
71
|
+
if not path.is_file():
|
|
72
|
+
return {}
|
|
73
|
+
with open(path, encoding="utf-8") as f:
|
|
74
|
+
return json.load(f)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_settings(path: Path, settings: dict[str, Any]) -> None:
|
|
78
|
+
"""Write settings file atomically.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
path: Path to settings file.
|
|
82
|
+
settings: Settings dict to write.
|
|
83
|
+
"""
|
|
84
|
+
json_str = json.dumps(settings, indent=2) + "\n"
|
|
85
|
+
atomic_write_text(path, json_str)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_forge_file_base(settings_path: Path) -> str:
|
|
89
|
+
"""Get base name for forge backup/added files.
|
|
90
|
+
|
|
91
|
+
Converts:
|
|
92
|
+
- settings.json -> .settings.json.forge
|
|
93
|
+
- settings.local.json -> .settings.local.json.forge
|
|
94
|
+
"""
|
|
95
|
+
return f".{settings_path.name}.forge"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_backup_path(settings_path: Path, timestamp: str | None = None) -> Path:
|
|
99
|
+
"""Get path for forge backup file (hidden, timestamped).
|
|
100
|
+
|
|
101
|
+
Pattern: .settings.json.forge.backup.{timestamp}
|
|
102
|
+
"""
|
|
103
|
+
ts = timestamp or _get_timestamp()
|
|
104
|
+
base = _get_forge_file_base(settings_path)
|
|
105
|
+
return settings_path.parent / f"{base}.backup.{ts}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_added_path(settings_path: Path, timestamp: str | None = None) -> Path:
|
|
109
|
+
"""Get path for forge added file (hidden, timestamped).
|
|
110
|
+
|
|
111
|
+
Pattern: .settings.json.forge.added.{timestamp}
|
|
112
|
+
"""
|
|
113
|
+
ts = timestamp or _get_timestamp()
|
|
114
|
+
base = _get_forge_file_base(settings_path)
|
|
115
|
+
return settings_path.parent / f"{base}.added.{ts}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def find_backup_files(settings_path: Path) -> list[Path]:
|
|
119
|
+
"""Find all forge backup files for a settings file (newest first)."""
|
|
120
|
+
base = _get_forge_file_base(settings_path)
|
|
121
|
+
pattern = f"{base}.backup.*"
|
|
122
|
+
return sorted(settings_path.parent.glob(pattern), reverse=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def find_added_files(settings_path: Path) -> list[Path]:
|
|
126
|
+
"""Find all forge added files for a settings file (newest first)."""
|
|
127
|
+
base = _get_forge_file_base(settings_path)
|
|
128
|
+
pattern = f"{base}.added.*"
|
|
129
|
+
return sorted(settings_path.parent.glob(pattern), reverse=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def backup_settings(path: Path) -> Path | None:
|
|
133
|
+
"""Create backup of settings file (hidden, timestamped).
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
path: Path to settings file.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Path to backup file, or None if settings file doesn't exist.
|
|
140
|
+
"""
|
|
141
|
+
if not path.is_file():
|
|
142
|
+
return None
|
|
143
|
+
backup_path = get_backup_path(path)
|
|
144
|
+
shutil.copy2(path, backup_path)
|
|
145
|
+
return backup_path
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def restore_settings_backup(path: Path) -> bool:
|
|
149
|
+
"""Restore settings from most recent backup.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
path: Path to settings file.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if restored, False if no backup exists.
|
|
156
|
+
"""
|
|
157
|
+
backups = find_backup_files(path)
|
|
158
|
+
if not backups:
|
|
159
|
+
return False
|
|
160
|
+
shutil.copy2(backups[0], path) # Most recent
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def save_added_settings(settings_path: Path, added: dict[str, Any]) -> Path:
|
|
165
|
+
"""Save the added settings structure.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
settings_path: Path to main settings file.
|
|
169
|
+
added: The settings structure containing what Forge added.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Path to the added file.
|
|
173
|
+
"""
|
|
174
|
+
added_path = get_added_path(settings_path)
|
|
175
|
+
write_settings(added_path, added)
|
|
176
|
+
return added_path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def load_added_settings(settings_path: Path) -> dict[str, Any]:
|
|
180
|
+
"""Load the most recent added settings structure.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
settings_path: Path to main settings file.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Added settings dict, or empty dict if no added file exists.
|
|
187
|
+
"""
|
|
188
|
+
added_files = find_added_files(settings_path)
|
|
189
|
+
if not added_files:
|
|
190
|
+
return {}
|
|
191
|
+
return read_settings(added_files[0]) # Most recent
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def entries_to_added_structure(entries: list[InstalledSettingsEntry]) -> dict[str, Any]:
|
|
195
|
+
"""Convert tracking entries list to a settings-like structure for .forge-added.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
entries: List of InstalledSettingsEntry from merge.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Settings dict structure containing exactly what was added.
|
|
202
|
+
"""
|
|
203
|
+
added: dict[str, Any] = {}
|
|
204
|
+
|
|
205
|
+
for entry in entries:
|
|
206
|
+
if entry.key_path.startswith("hooks."):
|
|
207
|
+
hook_type = entry.key_path.split(".", 1)[1]
|
|
208
|
+
hooks = added.setdefault("hooks", {})
|
|
209
|
+
hook_list = hooks.setdefault(hook_type, [])
|
|
210
|
+
hook_list.append(entry.value)
|
|
211
|
+
elif entry.key_path.startswith("permissions."):
|
|
212
|
+
perm_type = entry.key_path.split(".", 1)[1]
|
|
213
|
+
perms = added.setdefault("permissions", {})
|
|
214
|
+
perm_list = perms.setdefault(perm_type, [])
|
|
215
|
+
perm_list.append(entry.value)
|
|
216
|
+
elif entry.key_path.startswith("env."):
|
|
217
|
+
env_key = entry.key_path.split(".", 1)[1]
|
|
218
|
+
env = added.setdefault("env", {})
|
|
219
|
+
env[env_key] = entry.value
|
|
220
|
+
elif entry.merge_type == "scalar":
|
|
221
|
+
# Top-level scalar like statusLine
|
|
222
|
+
added[entry.key_path] = entry.value
|
|
223
|
+
|
|
224
|
+
return added
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _deep_equals(a: Any, b: Any) -> bool:
|
|
228
|
+
"""Deep equality check for settings values."""
|
|
229
|
+
if type(a) is not type(b):
|
|
230
|
+
return False
|
|
231
|
+
if isinstance(a, dict):
|
|
232
|
+
if set(a.keys()) != set(b.keys()):
|
|
233
|
+
return False
|
|
234
|
+
return all(_deep_equals(a[k], b[k]) for k in a)
|
|
235
|
+
if isinstance(a, list):
|
|
236
|
+
if len(a) != len(b):
|
|
237
|
+
return False
|
|
238
|
+
return all(_deep_equals(x, y) for x, y in zip(a, b))
|
|
239
|
+
return a == b
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_empty_value(value: Any) -> bool:
|
|
243
|
+
"""Check if a value is empty (for cleanup purposes)."""
|
|
244
|
+
if value is None:
|
|
245
|
+
return True
|
|
246
|
+
if isinstance(value, (list, dict, str)):
|
|
247
|
+
return len(value) == 0
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def smart_unmerge(
|
|
252
|
+
current: dict[str, Any],
|
|
253
|
+
backup: dict[str, Any],
|
|
254
|
+
added: dict[str, Any],
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
"""Smart unmerge: remove what we added, preserve user changes.
|
|
257
|
+
|
|
258
|
+
For each thing in `added`:
|
|
259
|
+
- Hooks: remove by command-path identity (same as merge dedupe logic)
|
|
260
|
+
- Permissions: remove by value equality
|
|
261
|
+
- Scalars: if current == added, restore backup value (or delete if not in backup)
|
|
262
|
+
- If user modified our value, leave their modification
|
|
263
|
+
|
|
264
|
+
Note: Does NOT restore backup entries that user deleted. If user removed
|
|
265
|
+
something while Forge was installed, we respect that deletion.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
current: Current settings dict.
|
|
269
|
+
backup: Settings before Forge install (empty dict if didn't exist).
|
|
270
|
+
added: What Forge added (from .forge-added).
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
New settings dict with Forge additions removed but user changes preserved.
|
|
274
|
+
"""
|
|
275
|
+
import copy
|
|
276
|
+
|
|
277
|
+
result = copy.deepcopy(current)
|
|
278
|
+
|
|
279
|
+
# Process hooks - use full-entry equality (matches merge dedupe logic)
|
|
280
|
+
if "hooks" in added:
|
|
281
|
+
result_hooks = result.get("hooks")
|
|
282
|
+
# Defensive: skip if hooks is not a dict (corrupted settings)
|
|
283
|
+
if not isinstance(result_hooks, dict):
|
|
284
|
+
result_hooks = {}
|
|
285
|
+
|
|
286
|
+
for hook_type, added_entries in added["hooks"].items():
|
|
287
|
+
if hook_type not in result_hooks:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
current_list = result_hooks.get(hook_type)
|
|
291
|
+
# Defensive: skip if not a list
|
|
292
|
+
if not isinstance(current_list, list):
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
added_canonical: set[str] = set()
|
|
296
|
+
for added_entry in added_entries:
|
|
297
|
+
if isinstance(added_entry, dict):
|
|
298
|
+
added_canonical.add(_canonical_json(added_entry))
|
|
299
|
+
|
|
300
|
+
new_list = []
|
|
301
|
+
for item in current_list:
|
|
302
|
+
if not isinstance(item, dict):
|
|
303
|
+
new_list.append(item)
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
if _canonical_json(item) not in added_canonical:
|
|
307
|
+
new_list.append(item)
|
|
308
|
+
|
|
309
|
+
result_hooks[hook_type] = new_list
|
|
310
|
+
|
|
311
|
+
if "permissions" in added:
|
|
312
|
+
result_perms = result.get("permissions")
|
|
313
|
+
# Defensive: skip if permissions is not a dict
|
|
314
|
+
if not isinstance(result_perms, dict):
|
|
315
|
+
result_perms = {}
|
|
316
|
+
|
|
317
|
+
for perm_type, added_entries in added["permissions"].items():
|
|
318
|
+
if perm_type not in result_perms:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
current_list = result_perms.get(perm_type)
|
|
322
|
+
# Defensive: skip if not a list
|
|
323
|
+
if not isinstance(current_list, list):
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
new_list = [item for item in current_list if item not in added_entries]
|
|
327
|
+
|
|
328
|
+
result_perms[perm_type] = new_list
|
|
329
|
+
|
|
330
|
+
if "env" in added:
|
|
331
|
+
result_env = result.get("env")
|
|
332
|
+
# Defensive: skip if env is not a dict
|
|
333
|
+
if isinstance(result_env, dict):
|
|
334
|
+
backup_env = backup.get("env", {})
|
|
335
|
+
for env_key, added_value in added["env"].items():
|
|
336
|
+
if env_key not in result_env:
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
current_value = result_env[env_key]
|
|
340
|
+
backup_value = backup_env.get(env_key)
|
|
341
|
+
|
|
342
|
+
if current_value == added_value:
|
|
343
|
+
# User hasn't modified our value - restore or delete
|
|
344
|
+
if backup_value is not None:
|
|
345
|
+
result_env[env_key] = backup_value
|
|
346
|
+
else:
|
|
347
|
+
del result_env[env_key]
|
|
348
|
+
# else: user modified, leave their value
|
|
349
|
+
|
|
350
|
+
for key, added_value in added.items():
|
|
351
|
+
if key in ("hooks", "permissions", "env"):
|
|
352
|
+
continue # Already handled
|
|
353
|
+
|
|
354
|
+
if key in result:
|
|
355
|
+
current_value = result[key]
|
|
356
|
+
backup_value = backup.get(key)
|
|
357
|
+
|
|
358
|
+
if _deep_equals(current_value, added_value):
|
|
359
|
+
# User hasn't modified our value - restore or delete
|
|
360
|
+
if backup_value is not None:
|
|
361
|
+
result[key] = backup_value
|
|
362
|
+
else:
|
|
363
|
+
del result[key]
|
|
364
|
+
# else: user modified, leave their value
|
|
365
|
+
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cleanup_empty_settings(settings: dict[str, Any]) -> dict[str, Any]:
|
|
370
|
+
"""Remove empty arrays and objects from settings.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
settings: Settings dict to clean.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Cleaned settings dict.
|
|
377
|
+
"""
|
|
378
|
+
import copy
|
|
379
|
+
|
|
380
|
+
result = copy.deepcopy(settings)
|
|
381
|
+
|
|
382
|
+
if "hooks" in result:
|
|
383
|
+
result["hooks"] = {k: v for k, v in result["hooks"].items() if v}
|
|
384
|
+
if not result["hooks"]:
|
|
385
|
+
del result["hooks"]
|
|
386
|
+
|
|
387
|
+
if "permissions" in result:
|
|
388
|
+
result["permissions"] = {k: v for k, v in result["permissions"].items() if v}
|
|
389
|
+
if not result["permissions"]:
|
|
390
|
+
del result["permissions"]
|
|
391
|
+
|
|
392
|
+
if "env" in result:
|
|
393
|
+
result["env"] = {k: v for k, v in result["env"].items() if v}
|
|
394
|
+
if not result["env"]:
|
|
395
|
+
del result["env"]
|
|
396
|
+
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def settings_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
|
401
|
+
"""Check if two settings dicts are equivalent.
|
|
402
|
+
|
|
403
|
+
Handles the case where one has empty arrays/objects and the other doesn't.
|
|
404
|
+
"""
|
|
405
|
+
cleaned_a = cleanup_empty_settings(a)
|
|
406
|
+
cleaned_b = cleanup_empty_settings(b)
|
|
407
|
+
return _deep_equals(cleaned_a, cleaned_b)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# --- Pre-check functions (for planning phase) ---
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def hooks_already_present(
|
|
414
|
+
current_settings: dict[str, Any],
|
|
415
|
+
hook_type: str,
|
|
416
|
+
entries: list[dict[str, Any]],
|
|
417
|
+
) -> bool:
|
|
418
|
+
"""Check if all hook entries are already present in current settings.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
current_settings: Current settings dict.
|
|
422
|
+
hook_type: Hook type (e.g., "PreToolUse", "PostToolUse").
|
|
423
|
+
entries: Hook entries to check.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
True if ALL entries are already present (nothing would be added).
|
|
427
|
+
"""
|
|
428
|
+
existing = current_settings.get("hooks", {}).get(hook_type, [])
|
|
429
|
+
|
|
430
|
+
existing_canonical: set[str] = {_canonical_json(e) for e in existing}
|
|
431
|
+
|
|
432
|
+
for entry in entries:
|
|
433
|
+
if _canonical_json(entry) not in existing_canonical:
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
return True
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def permissions_already_present(
|
|
440
|
+
current_settings: dict[str, Any],
|
|
441
|
+
perm_type: str,
|
|
442
|
+
entries: list[str],
|
|
443
|
+
) -> bool:
|
|
444
|
+
"""Check if all permission entries are already present in current settings.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
current_settings: Current settings dict.
|
|
448
|
+
perm_type: Permission type ("allow" or "deny").
|
|
449
|
+
entries: Permission entries to check.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
True if ALL entries are already present (nothing would be added).
|
|
453
|
+
"""
|
|
454
|
+
existing = set(current_settings.get("permissions", {}).get(perm_type, []))
|
|
455
|
+
return all(entry in existing for entry in entries)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def scalar_already_set(
|
|
459
|
+
current_settings: dict[str, Any],
|
|
460
|
+
key: str,
|
|
461
|
+
value: Any,
|
|
462
|
+
) -> bool:
|
|
463
|
+
"""Check if a scalar value is already set to the expected value.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
current_settings: Current settings dict.
|
|
467
|
+
key: Setting key (e.g., "statusLine").
|
|
468
|
+
value: Expected value.
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
True if the key is already set to exactly this value.
|
|
472
|
+
"""
|
|
473
|
+
return current_settings.get(key) == value
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# --- Merge operations ---
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _extract_command_paths(entry: dict[str, Any]) -> set[str]:
|
|
480
|
+
"""Extract command paths from a hook entry for deduplication.
|
|
481
|
+
|
|
482
|
+
System boundary: reads Claude Code settings.json which may contain
|
|
483
|
+
either format depending on when the user last ran forge extensions sync.
|
|
484
|
+
- Current: {"hooks": [{"type": "command", "command": "..."}]}
|
|
485
|
+
- Pre-sync: {"type": "command", "command": "..."} at entry level
|
|
486
|
+
"""
|
|
487
|
+
commands = set()
|
|
488
|
+
# Pre-sync format: command at entry level
|
|
489
|
+
if cmd := entry.get("command"):
|
|
490
|
+
commands.add(cmd)
|
|
491
|
+
# Current format: nested hooks array
|
|
492
|
+
for hook in entry.get("hooks", []):
|
|
493
|
+
if cmd := hook.get("command"):
|
|
494
|
+
commands.add(cmd)
|
|
495
|
+
return commands
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _canonical_json(entry: dict[str, Any]) -> str:
|
|
499
|
+
"""Serialize a hook entry to a canonical JSON string for equality comparison."""
|
|
500
|
+
import json
|
|
501
|
+
|
|
502
|
+
return json.dumps(entry, sort_keys=True, separators=(",", ":"))
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def merge_hooks(
|
|
506
|
+
settings: dict[str, Any],
|
|
507
|
+
hook_type: str,
|
|
508
|
+
entries: list[dict[str, Any]],
|
|
509
|
+
) -> list[InstalledSettingsEntry]:
|
|
510
|
+
"""Merge hook entries: append + dedupe by full JSON entry equality.
|
|
511
|
+
|
|
512
|
+
Two entries are duplicates only if they are structurally identical
|
|
513
|
+
(same command, matcher, and all other fields). This ensures hooks
|
|
514
|
+
with the same command but different matchers are preserved (e.g.,
|
|
515
|
+
policy-check for Write vs Edit).
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
settings: Current settings dict (modified in place).
|
|
519
|
+
hook_type: Hook type (e.g., "PreToolUse", "PostToolUse").
|
|
520
|
+
entries: Hook entries to add.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
List of InstalledSettingsEntry for tracking.
|
|
524
|
+
"""
|
|
525
|
+
hooks = settings.setdefault("hooks", {})
|
|
526
|
+
existing = hooks.setdefault(hook_type, [])
|
|
527
|
+
|
|
528
|
+
existing_canonical: set[str] = {_canonical_json(e) for e in existing if isinstance(e, dict)}
|
|
529
|
+
|
|
530
|
+
added: list[InstalledSettingsEntry] = []
|
|
531
|
+
for entry in entries:
|
|
532
|
+
canonical = _canonical_json(entry)
|
|
533
|
+
|
|
534
|
+
if canonical not in existing_canonical:
|
|
535
|
+
existing.append(entry)
|
|
536
|
+
existing_canonical.add(canonical)
|
|
537
|
+
added.append(
|
|
538
|
+
InstalledSettingsEntry(
|
|
539
|
+
key_path=f"hooks.{hook_type}",
|
|
540
|
+
value=entry,
|
|
541
|
+
merge_type="append",
|
|
542
|
+
stable_id=canonical,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
return added
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def merge_permissions(
|
|
550
|
+
settings: dict[str, Any],
|
|
551
|
+
permission_type: str,
|
|
552
|
+
entries: list[str],
|
|
553
|
+
) -> list[InstalledSettingsEntry]:
|
|
554
|
+
"""Merge permission entries: union unique.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
settings: Current settings dict (modified in place).
|
|
558
|
+
permission_type: Permission type ("allow" or "deny").
|
|
559
|
+
entries: Permission entries to add.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
List of InstalledSettingsEntry for tracking.
|
|
563
|
+
"""
|
|
564
|
+
permissions = settings.setdefault("permissions", {})
|
|
565
|
+
existing = permissions.setdefault(permission_type, [])
|
|
566
|
+
existing_set = set(existing)
|
|
567
|
+
|
|
568
|
+
added: list[InstalledSettingsEntry] = []
|
|
569
|
+
for entry in entries:
|
|
570
|
+
if entry not in existing_set:
|
|
571
|
+
existing.append(entry)
|
|
572
|
+
existing_set.add(entry)
|
|
573
|
+
added.append(
|
|
574
|
+
InstalledSettingsEntry(
|
|
575
|
+
key_path=f"permissions.{permission_type}",
|
|
576
|
+
value=entry,
|
|
577
|
+
merge_type="union",
|
|
578
|
+
stable_id=entry, # Entry value is the stable_id
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
return added
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def merge_env(
|
|
586
|
+
settings: dict[str, Any],
|
|
587
|
+
forge_env: dict[str, str],
|
|
588
|
+
) -> list[InstalledSettingsEntry]:
|
|
589
|
+
"""Merge env vars: Forge values override on conflicts.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
settings: Current settings dict (modified in place).
|
|
593
|
+
forge_env: Environment variables to set.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
List of InstalledSettingsEntry for tracking.
|
|
597
|
+
"""
|
|
598
|
+
current_env = settings.setdefault("env", {})
|
|
599
|
+
|
|
600
|
+
added: list[InstalledSettingsEntry] = []
|
|
601
|
+
for key, value in sorted(forge_env.items()):
|
|
602
|
+
current_env[key] = value
|
|
603
|
+
added.append(
|
|
604
|
+
InstalledSettingsEntry(
|
|
605
|
+
key_path=f"env.{key}",
|
|
606
|
+
value=value,
|
|
607
|
+
merge_type="env",
|
|
608
|
+
stable_id=key,
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
return added
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def check_scalar_conflict(
|
|
616
|
+
settings: dict[str, Any],
|
|
617
|
+
key: str,
|
|
618
|
+
forge_value: Any,
|
|
619
|
+
) -> bool:
|
|
620
|
+
"""Check if scalar key has conflicting value.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
settings: Current settings dict.
|
|
624
|
+
key: Settings key to check.
|
|
625
|
+
forge_value: Value Forge wants to set.
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
True if conflict exists, False otherwise.
|
|
629
|
+
"""
|
|
630
|
+
current = settings.get(key)
|
|
631
|
+
if current is None:
|
|
632
|
+
return False
|
|
633
|
+
return current != forge_value
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def set_scalar(
|
|
637
|
+
settings: dict[str, Any],
|
|
638
|
+
key: str,
|
|
639
|
+
value: Any,
|
|
640
|
+
force: bool = False,
|
|
641
|
+
) -> InstalledSettingsEntry | None:
|
|
642
|
+
"""Set a scalar value.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
settings: Current settings dict (modified in place).
|
|
646
|
+
key: Settings key to set.
|
|
647
|
+
value: Value to set.
|
|
648
|
+
force: If True, override existing value.
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
InstalledSettingsEntry if value was set, None if no change needed.
|
|
652
|
+
|
|
653
|
+
Raises:
|
|
654
|
+
SettingsConflictError: If conflict and not force.
|
|
655
|
+
"""
|
|
656
|
+
current = settings.get(key)
|
|
657
|
+
if current is not None and current != value and not force:
|
|
658
|
+
raise SettingsConflictError(key, current, value)
|
|
659
|
+
|
|
660
|
+
if current == value:
|
|
661
|
+
return None # No change needed
|
|
662
|
+
|
|
663
|
+
settings[key] = value
|
|
664
|
+
return InstalledSettingsEntry(
|
|
665
|
+
key_path=key,
|
|
666
|
+
value=value,
|
|
667
|
+
merge_type="scalar",
|
|
668
|
+
stable_id=key,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# --- Full merge/unmerge ---
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def merge(
|
|
676
|
+
settings: dict[str, Any],
|
|
677
|
+
forge_settings: dict[str, Any],
|
|
678
|
+
*,
|
|
679
|
+
force: bool = False,
|
|
680
|
+
include_statusline: bool = False,
|
|
681
|
+
include_hooks: bool = True,
|
|
682
|
+
include_permissions: bool = True,
|
|
683
|
+
) -> list[InstalledSettingsEntry]:
|
|
684
|
+
"""Full settings merge.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
settings: Current settings dict (modified in place).
|
|
688
|
+
forge_settings: Forge settings template to merge.
|
|
689
|
+
force: If True, override scalar conflicts.
|
|
690
|
+
include_statusline: If True, include statusLine setting.
|
|
691
|
+
include_hooks: If True, merge hook entries.
|
|
692
|
+
include_permissions: If True, merge permission entries.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
List of InstalledSettingsEntry for all changes made.
|
|
696
|
+
|
|
697
|
+
Raises:
|
|
698
|
+
SettingsConflictError: If scalar conflict and not force.
|
|
699
|
+
"""
|
|
700
|
+
entries: list[InstalledSettingsEntry] = []
|
|
701
|
+
|
|
702
|
+
if include_hooks:
|
|
703
|
+
forge_hooks = forge_settings.get("hooks", {})
|
|
704
|
+
for hook_type, hook_entries in sorted(forge_hooks.items()):
|
|
705
|
+
entries.extend(merge_hooks(settings, hook_type, hook_entries))
|
|
706
|
+
|
|
707
|
+
if include_permissions:
|
|
708
|
+
forge_perms = forge_settings.get("permissions", {})
|
|
709
|
+
if allow := forge_perms.get("allow"):
|
|
710
|
+
entries.extend(merge_permissions(settings, "allow", allow))
|
|
711
|
+
if deny := forge_perms.get("deny"):
|
|
712
|
+
entries.extend(merge_permissions(settings, "deny", deny))
|
|
713
|
+
|
|
714
|
+
# Merge statusLine (only if opted in)
|
|
715
|
+
if include_statusline and "statusLine" in forge_settings:
|
|
716
|
+
entry = set_scalar(
|
|
717
|
+
settings,
|
|
718
|
+
"statusLine",
|
|
719
|
+
forge_settings["statusLine"],
|
|
720
|
+
force=force,
|
|
721
|
+
)
|
|
722
|
+
if entry:
|
|
723
|
+
entries.append(entry)
|
|
724
|
+
|
|
725
|
+
if forge_env := forge_settings.get("env"):
|
|
726
|
+
entries.extend(merge_env(settings, forge_env))
|
|
727
|
+
|
|
728
|
+
return entries
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def unmerge(
|
|
732
|
+
settings: dict[str, Any],
|
|
733
|
+
tracking_entries: list[InstalledSettingsEntry],
|
|
734
|
+
) -> None:
|
|
735
|
+
"""Remove Forge-added entries from settings.
|
|
736
|
+
|
|
737
|
+
Uses stable_id for value-based matching (not index-based).
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
settings: Current settings dict (modified in place).
|
|
741
|
+
tracking_entries: List of entries to remove.
|
|
742
|
+
"""
|
|
743
|
+
# Group by key_path for efficient processing
|
|
744
|
+
by_key: dict[str, list[InstalledSettingsEntry]] = {}
|
|
745
|
+
for entry in tracking_entries:
|
|
746
|
+
by_key.setdefault(entry.key_path, []).append(entry)
|
|
747
|
+
|
|
748
|
+
hooks = settings.get("hooks", {})
|
|
749
|
+
for key_path, entries in by_key.items():
|
|
750
|
+
if key_path.startswith("hooks."):
|
|
751
|
+
hook_type = key_path.split(".", 1)[1]
|
|
752
|
+
if hook_type not in hooks:
|
|
753
|
+
continue
|
|
754
|
+
|
|
755
|
+
canonical_to_remove: set[str] = set()
|
|
756
|
+
for e in entries:
|
|
757
|
+
if e.value and isinstance(e.value, dict):
|
|
758
|
+
canonical_to_remove.add(_canonical_json(e.value))
|
|
759
|
+
|
|
760
|
+
hooks[hook_type] = [
|
|
761
|
+
h for h in hooks[hook_type] if not isinstance(h, dict) or _canonical_json(h) not in canonical_to_remove
|
|
762
|
+
]
|
|
763
|
+
|
|
764
|
+
permissions = settings.get("permissions", {})
|
|
765
|
+
for key_path, entries in by_key.items():
|
|
766
|
+
if key_path.startswith("permissions."):
|
|
767
|
+
perm_type = key_path.split(".", 1)[1]
|
|
768
|
+
if perm_type not in permissions:
|
|
769
|
+
continue
|
|
770
|
+
|
|
771
|
+
values_to_remove = {e.stable_id for e in entries}
|
|
772
|
+
permissions[perm_type] = [p for p in permissions[perm_type] if p not in values_to_remove]
|
|
773
|
+
|
|
774
|
+
env = settings.get("env", {})
|
|
775
|
+
for key_path, entries in by_key.items():
|
|
776
|
+
if key_path.startswith("env."):
|
|
777
|
+
env_key = key_path.split(".", 1)[1]
|
|
778
|
+
if env_key in env:
|
|
779
|
+
del env[env_key]
|
|
780
|
+
if "env" in settings and not settings["env"]:
|
|
781
|
+
del settings["env"]
|
|
782
|
+
|
|
783
|
+
for key_path, entries in by_key.items():
|
|
784
|
+
if entries and entries[0].merge_type == "scalar":
|
|
785
|
+
if key_path in settings:
|
|
786
|
+
del settings[key_path]
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
# --- Template path resolution ---
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def resolve_template_paths(
|
|
793
|
+
settings: dict[str, Any],
|
|
794
|
+
target_root: Path,
|
|
795
|
+
) -> dict[str, Any]:
|
|
796
|
+
"""Replace {{PLACEHOLDER}} with actual paths.
|
|
797
|
+
|
|
798
|
+
Used to resolve template placeholders in settings.template.json to
|
|
799
|
+
actual target paths based on installation scope.
|
|
800
|
+
|
|
801
|
+
Note: statusLine now uses `forge status-line` command directly (no path substitution).
|
|
802
|
+
This function is kept for any future path placeholders.
|
|
803
|
+
|
|
804
|
+
Args:
|
|
805
|
+
settings: Settings dict with placeholders.
|
|
806
|
+
target_root: Target .claude directory (e.g., ~/.claude or .claude).
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
New settings dict with placeholders resolved.
|
|
810
|
+
"""
|
|
811
|
+
import copy
|
|
812
|
+
|
|
813
|
+
result = copy.deepcopy(settings)
|
|
814
|
+
|
|
815
|
+
placeholders: dict[str, str] = {
|
|
816
|
+
# No path placeholders currently needed - hooks and status-line
|
|
817
|
+
# are now `forge <command>` invocations, not installed scripts.
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
def replace_placeholders(obj: Any) -> Any:
|
|
821
|
+
if isinstance(obj, str):
|
|
822
|
+
for placeholder, value in placeholders.items():
|
|
823
|
+
obj = obj.replace(placeholder, value)
|
|
824
|
+
return obj
|
|
825
|
+
elif isinstance(obj, dict):
|
|
826
|
+
return {k: replace_placeholders(v) for k, v in obj.items()}
|
|
827
|
+
elif isinstance(obj, list):
|
|
828
|
+
return [replace_placeholders(item) for item in obj]
|
|
829
|
+
return obj
|
|
830
|
+
|
|
831
|
+
return replace_placeholders(result)
|