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,321 @@
1
+ """Data models for Forge Installer.
2
+
3
+ Defines enums for installation options and dataclasses for tracking
4
+ what Forge has installed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+ # --- Timestamp helper (matches session/models.py pattern) ---
15
+
16
+
17
+ def now_iso() -> str:
18
+ """Return current UTC time as ISO8601 string."""
19
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
20
+
21
+
22
+ # --- Enums ---
23
+
24
+
25
+ class InstallScope(str, Enum):
26
+ """Installation scope (mirrors Claude Code's scope model).
27
+
28
+ - USER: ~/.claude/... + ~/.claude/settings.json (default)
29
+ - PROJECT: .claude/... + .claude/settings.json (checked in)
30
+ - LOCAL: .claude/... + .claude/settings.local.json (personal per-project)
31
+ """
32
+
33
+ USER = "user"
34
+ PROJECT = "project"
35
+ LOCAL = "local"
36
+
37
+
38
+ class InstallMode(str, Enum):
39
+ """Installation mode.
40
+
41
+ - COPY: Copy files to target (stable install)
42
+ - SYMLINK: Symlink files to source (development mode)
43
+ """
44
+
45
+ COPY = "copy"
46
+ SYMLINK = "symlink"
47
+
48
+
49
+ class InstallProfile(str, Enum):
50
+ """Predefined installation profiles.
51
+
52
+ - MINIMAL: commands only
53
+ - STANDARD: commands, agents, hooks, permissions (default)
54
+ - FULL: all modules including status-line
55
+ """
56
+
57
+ MINIMAL = "minimal"
58
+ STANDARD = "standard"
59
+ FULL = "full"
60
+
61
+
62
+ class InstallModule(str, Enum):
63
+ """Installable module types."""
64
+
65
+ COMMANDS = "commands"
66
+ AGENTS = "agents"
67
+ SKILLS = "skills"
68
+ HOOKS = "hooks"
69
+ STATUSLINE = "status-line"
70
+ PERMISSIONS = "permissions"
71
+
72
+
73
+ # Profile -> modules mapping
74
+ PROFILE_MODULES: dict[InstallProfile, set[InstallModule]] = {
75
+ InstallProfile.MINIMAL: {InstallModule.COMMANDS},
76
+ InstallProfile.STANDARD: {
77
+ InstallModule.COMMANDS,
78
+ InstallModule.AGENTS,
79
+ InstallModule.SKILLS,
80
+ InstallModule.HOOKS,
81
+ InstallModule.PERMISSIONS,
82
+ InstallModule.STATUSLINE,
83
+ },
84
+ InstallProfile.FULL: set(InstallModule),
85
+ }
86
+
87
+ # Profile ordering (for minimum-profile comparisons in skill filtering)
88
+ PROFILE_RANK: dict[InstallProfile, int] = {
89
+ InstallProfile.MINIMAL: 0,
90
+ InstallProfile.STANDARD: 1,
91
+ InstallProfile.FULL: 2,
92
+ }
93
+
94
+ # Skills requiring a minimum install profile. Unlisted skills install with
95
+ # any profile that includes the SKILLS module.
96
+ SKILL_PROFILE_REQUIREMENTS: dict[str, InstallProfile] = {
97
+ "qa": InstallProfile.FULL,
98
+ }
99
+
100
+
101
+ def get_gated_skills(profile: InstallProfile) -> list[tuple[str, InstallProfile]]:
102
+ """Return skills excluded by the given profile, with their required profile.
103
+
104
+ Only returns skills that need a higher profile than the one provided.
105
+ """
106
+ return sorted(
107
+ (name, req) for name, req in SKILL_PROFILE_REQUIREMENTS.items() if PROFILE_RANK[profile] < PROFILE_RANK[req]
108
+ )
109
+
110
+
111
+ # Module dependencies (installing X requires also installing Y's settings)
112
+ MODULE_DEPENDENCIES: dict[InstallModule, set[InstallModule]] = {}
113
+
114
+ # Modules that are file-based (vs settings-only)
115
+ FILE_MODULES: set[InstallModule] = {
116
+ InstallModule.COMMANDS,
117
+ InstallModule.AGENTS,
118
+ InstallModule.SKILLS,
119
+ }
120
+
121
+ # Modules that are settings-only (no files to install)
122
+ # HOOKS: All hooks are now `forge hook X` commands - no files to copy
123
+ # STATUSLINE: Now `forge status-line` command - no scripts to copy
124
+ SETTINGS_ONLY_MODULES: set[InstallModule] = {
125
+ InstallModule.PERMISSIONS,
126
+ InstallModule.HOOKS,
127
+ InstallModule.STATUSLINE,
128
+ }
129
+
130
+
131
+ # --- Tracking dataclasses ---
132
+
133
+
134
+ @dataclass
135
+ class InstalledFile:
136
+ """A file installed by Forge.
137
+
138
+ Attributes:
139
+ target_path: Absolute path where the file was installed.
140
+ source_path: Absolute path to the source file in forge repo.
141
+ checksum: SHA256 hash of source content (used to detect source changes).
142
+ For copy mode: compare to target checksum to detect if update needed.
143
+ For symlink mode: verify symlink points to expected source.
144
+ mode: "copy" or "symlink".
145
+ installed_at: ISO8601 timestamp when file was installed.
146
+ """
147
+
148
+ target_path: str
149
+ source_path: str
150
+ checksum: str
151
+ mode: str
152
+ installed_at: str
153
+
154
+
155
+ @dataclass
156
+ class InstalledSettingsEntry:
157
+ """A settings entry added by Forge.
158
+
159
+ Attributes:
160
+ key_path: Dot-notation path (e.g., "hooks.PreToolUse").
161
+ value: The added value (for reference/display).
162
+ merge_type: "append", "union", or "scalar".
163
+ stable_id: Stable identifier for value-based unmerge:
164
+ - For hooks: command path string
165
+ - For permissions: the entry value itself
166
+ - For scalars: the key_path
167
+ """
168
+
169
+ key_path: str
170
+ value: Any
171
+ merge_type: str
172
+ stable_id: str
173
+
174
+
175
+ @dataclass
176
+ class Installation:
177
+ """Installation record for a single scope.
178
+
179
+ Attributes:
180
+ scope: InstallScope value ("user", "project", "local").
181
+ project_path: Absolute path to project root (None for user scope).
182
+ Used to track multiple local/project installations.
183
+ mode: InstallMode value ("copy", "symlink").
184
+ profile: InstallProfile value ("minimal", "standard", "full").
185
+ modules_enabled: List of InstallModule values that were enabled.
186
+ files: List of InstalledFile records.
187
+ settings_entries: List of InstalledSettingsEntry records.
188
+ settings_backup_path: Path to settings backup file (if created).
189
+ installed_at: ISO8601 timestamp when first installed.
190
+ updated_at: ISO8601 timestamp when last updated.
191
+ """
192
+
193
+ scope: str
194
+ mode: str
195
+ profile: str
196
+ project_path: str | None = None
197
+ modules_enabled: list[str] = field(default_factory=list)
198
+ files: list[InstalledFile] = field(default_factory=list)
199
+ settings_entries: list[InstalledSettingsEntry] = field(default_factory=list)
200
+ settings_backup_path: str | None = None
201
+ installed_at: str = ""
202
+ updated_at: str = ""
203
+
204
+
205
+ def make_installation_key(scope: str, project_path: str | None = None) -> str:
206
+ """Create a unique key for an installation.
207
+
208
+ Args:
209
+ scope: The installation scope ("user", "project", "local").
210
+ project_path: Absolute path to project root (required for project/local).
211
+
212
+ Returns:
213
+ Unique key: "user" for user scope, "{scope}:{path}" for project/local.
214
+
215
+ Raises:
216
+ ValueError: If project_path missing for project/local scope.
217
+ """
218
+ if scope == InstallScope.USER.value:
219
+ return "user"
220
+ if not project_path:
221
+ raise ValueError(f"project_path required for {scope} scope")
222
+ return f"{scope}:{project_path}"
223
+
224
+
225
+ def parse_installation_key(key: str) -> tuple[str, str | None]:
226
+ """Parse an installation key back to scope and project_path.
227
+
228
+ Args:
229
+ key: Installation key from manifest.
230
+
231
+ Returns:
232
+ Tuple of (scope, project_path). project_path is None for user scope.
233
+ """
234
+ if key == "user":
235
+ return (InstallScope.USER.value, None)
236
+ if ":" in key:
237
+ scope, path = key.split(":", 1)
238
+ return (scope, path)
239
+ # v1 format: bare scope name without path (e.g., "project", "local")
240
+ return (key, None)
241
+
242
+
243
+ # Tracking manifest version
244
+ TRACKING_VERSION = 1
245
+
246
+
247
+ @dataclass
248
+ class InstalledManifest:
249
+ """Root tracking manifest at ~/.forge/installed.json.
250
+
251
+ Attributes:
252
+ version: Schema version for the tracking manifest.
253
+ installations: Dict mapping scope name to Installation record.
254
+ """
255
+
256
+ version: int = TRACKING_VERSION
257
+ installations: dict[str, Installation] = field(default_factory=dict)
258
+
259
+
260
+ # --- Plan dataclasses (for --dry-run) ---
261
+
262
+
263
+ @dataclass
264
+ class FilePlan:
265
+ """Plan for a single file operation.
266
+
267
+ Attributes:
268
+ action: "install", "update", "remove", or "skip".
269
+ target_path: Where the file will be installed.
270
+ source_path: Where the file comes from (None for remove).
271
+ reason: Explanation for skip/conflict actions.
272
+ """
273
+
274
+ action: str
275
+ target_path: str
276
+ source_path: str | None = None
277
+ reason: str | None = None
278
+
279
+
280
+ @dataclass
281
+ class SettingsPlan:
282
+ """Plan for settings modifications.
283
+
284
+ Attributes:
285
+ action: "merge", "unmerge", or "conflict".
286
+ key_path: Settings key being modified.
287
+ value: Value to be set (for merge).
288
+ current_value: Existing value (for conflict display).
289
+ reason: Explanation for conflict.
290
+ """
291
+
292
+ action: str
293
+ key_path: str
294
+ value: Any = None
295
+ current_value: Any = None
296
+ reason: str | None = None
297
+
298
+
299
+ @dataclass
300
+ class InstallPlan:
301
+ """Complete installation plan.
302
+
303
+ Attributes:
304
+ scope: Target scope for installation.
305
+ mode: Installation mode.
306
+ profile: Installation profile.
307
+ modules: List of modules being installed.
308
+ files: List of file operations.
309
+ settings: List of settings operations.
310
+ has_conflicts: True if any conflicts were detected.
311
+ conflicts: Human-readable conflict descriptions.
312
+ """
313
+
314
+ scope: str
315
+ mode: str
316
+ profile: str
317
+ modules: list[str] = field(default_factory=list)
318
+ files: list[FilePlan] = field(default_factory=list)
319
+ settings: list[SettingsPlan] = field(default_factory=list)
320
+ has_conflicts: bool = False
321
+ conflicts: list[str] = field(default_factory=list)
@@ -0,0 +1,272 @@
1
+ """Claude Code settings preset management.
2
+
3
+ The preset (~/.forge/claude.preset.json) defines what settings Forge merges
4
+ into Claude Code's settings.json on ``forge extensions enable``.
5
+
6
+ Built-in content contains only essential infrastructure:
7
+ - hooks: all 13 Forge-managed hook events wiring ``forge hook <name>`` commands
8
+ - statusLine: ``forge status-line`` command
9
+ - permissions: Write/Edit (required by handoff agent's ``claude -p`` subprocess)
10
+
11
+ Users customize additional permissions, env vars, etc. via ``forge claude preset edit``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from forge.core.paths import get_forge_home
22
+ from forge.core.state import atomic_write_text
23
+
24
+ PRESET_FILENAME = "claude.preset.json"
25
+
26
+
27
+ def get_preset_path() -> Path:
28
+ """Return the path to ~/.forge/claude.preset.json."""
29
+ return get_forge_home() / PRESET_FILENAME
30
+
31
+
32
+ def get_builtin_preset() -> dict[str, Any]:
33
+ """Return the built-in preset content (factory defaults).
34
+
35
+ Contains only essential Forge infrastructure:
36
+ - hooks: all 13 Forge-managed hook events
37
+ - statusLine: forge status-line command
38
+ - permissions: Write/Edit (handoff agent needs these for claude -p)
39
+ """
40
+ return {
41
+ "permissions": {
42
+ "allow": [
43
+ "Write",
44
+ "Edit",
45
+ ]
46
+ },
47
+ "hooks": {
48
+ "SessionStart": [
49
+ {
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "forge hook session-start",
54
+ }
55
+ ]
56
+ }
57
+ ],
58
+ "PreToolUse": [
59
+ {
60
+ "matcher": "Read",
61
+ "hooks": [
62
+ {
63
+ "type": "command",
64
+ "command": "forge hook read-hygiene",
65
+ "timeout": 5,
66
+ }
67
+ ],
68
+ },
69
+ {
70
+ "matcher": "ExitPlanMode",
71
+ "hooks": [
72
+ {
73
+ "type": "command",
74
+ "command": "forge hook exit-plan-mode",
75
+ }
76
+ ],
77
+ },
78
+ {
79
+ "matcher": "Write",
80
+ "hooks": [
81
+ {
82
+ "type": "command",
83
+ "command": "forge hook policy-check",
84
+ "timeout": 60,
85
+ }
86
+ ],
87
+ },
88
+ {
89
+ "matcher": "Edit",
90
+ "hooks": [
91
+ {
92
+ "type": "command",
93
+ "command": "forge hook policy-check",
94
+ "timeout": 60,
95
+ }
96
+ ],
97
+ },
98
+ ],
99
+ "PostToolUse": [
100
+ {
101
+ "matcher": "Write",
102
+ "hooks": [
103
+ {
104
+ "type": "command",
105
+ "command": "forge hook plan-write",
106
+ }
107
+ ],
108
+ }
109
+ ],
110
+ "Stop": [
111
+ {
112
+ "hooks": [
113
+ {
114
+ "type": "command",
115
+ "command": "forge hook stop",
116
+ }
117
+ ]
118
+ }
119
+ ],
120
+ "StopFailure": [
121
+ {
122
+ "hooks": [
123
+ {
124
+ "type": "command",
125
+ "command": "forge hook stop-failure",
126
+ }
127
+ ]
128
+ }
129
+ ],
130
+ "UserPromptSubmit": [
131
+ {
132
+ "hooks": [
133
+ {
134
+ "type": "command",
135
+ "command": "forge hook user-prompt-submit",
136
+ }
137
+ ]
138
+ }
139
+ ],
140
+ "PreCompact": [
141
+ {
142
+ "hooks": [
143
+ {
144
+ "type": "command",
145
+ "command": "forge hook pre-compact",
146
+ "timeout": 10,
147
+ }
148
+ ],
149
+ }
150
+ ],
151
+ "PostCompact": [
152
+ {
153
+ "hooks": [
154
+ {
155
+ "type": "command",
156
+ "command": "forge hook post-compact",
157
+ "timeout": 5,
158
+ }
159
+ ],
160
+ }
161
+ ],
162
+ "WorktreeCreate": [
163
+ {
164
+ "hooks": [
165
+ {
166
+ "type": "command",
167
+ "command": "forge hook worktree-create",
168
+ "timeout": 30,
169
+ }
170
+ ],
171
+ }
172
+ ],
173
+ "SubagentStop": [
174
+ {
175
+ "hooks": [
176
+ {
177
+ "type": "command",
178
+ "command": "forge hook subagent-stop",
179
+ "timeout": 10,
180
+ }
181
+ ],
182
+ }
183
+ ],
184
+ "TeammateIdle": [
185
+ {
186
+ "hooks": [
187
+ {
188
+ "type": "command",
189
+ "command": "forge hook teammate-idle",
190
+ "timeout": 60,
191
+ }
192
+ ]
193
+ }
194
+ ],
195
+ "TaskCompleted": [
196
+ {
197
+ "hooks": [
198
+ {
199
+ "type": "command",
200
+ "command": "forge hook task-completed",
201
+ "timeout": 60,
202
+ }
203
+ ]
204
+ }
205
+ ],
206
+ "SessionEnd": [
207
+ {
208
+ "hooks": [
209
+ {
210
+ "type": "command",
211
+ "command": "forge hook session-end",
212
+ "timeout": 5,
213
+ }
214
+ ]
215
+ }
216
+ ],
217
+ },
218
+ "statusLine": {
219
+ "type": "command",
220
+ "command": "forge status-line",
221
+ "padding": 0,
222
+ },
223
+ }
224
+
225
+
226
+ def get_builtin_preset_json() -> str:
227
+ """Return the built-in preset as formatted JSON."""
228
+ return json.dumps(get_builtin_preset(), indent=2) + "\n"
229
+
230
+
231
+ def ensure_preset() -> Path:
232
+ """Ensure the preset file exists, creating from built-in if missing.
233
+
234
+ Returns the path to the preset file. Idempotent — existing files
235
+ are never overwritten.
236
+ """
237
+ preset_path = get_preset_path()
238
+ if not preset_path.is_file():
239
+ preset_path.parent.mkdir(parents=True, exist_ok=True)
240
+ atomic_write_text(preset_path, get_builtin_preset_json())
241
+ os.chmod(str(preset_path), 0o600)
242
+ return preset_path
243
+
244
+
245
+ class PresetCorruptedError(Exception):
246
+ """Raised when the preset file contains invalid JSON or is not a dict."""
247
+
248
+
249
+ def load_preset() -> dict[str, Any]:
250
+ """Load the preset, auto-creating from built-in if missing.
251
+
252
+ Raises PresetCorruptedError with actionable message if the file
253
+ contains invalid JSON or is not a JSON object.
254
+ """
255
+ preset_path = ensure_preset()
256
+ try:
257
+ with open(preset_path, encoding="utf-8") as f:
258
+ data = json.load(f)
259
+ except json.JSONDecodeError as e:
260
+ raise PresetCorruptedError(
261
+ f"Preset file has invalid JSON: {preset_path}\n"
262
+ f" Error: {e}\n"
263
+ f" Fix with: forge claude preset edit\n"
264
+ f" Or reset: forge claude preset reset"
265
+ ) from e
266
+ if not isinstance(data, dict):
267
+ raise PresetCorruptedError(
268
+ f"Preset must be a JSON object, got {type(data).__name__}: {preset_path}\n"
269
+ f" Fix with: forge claude preset edit\n"
270
+ f" Or reset: forge claude preset reset"
271
+ )
272
+ return data