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.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. 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)