devtorch-core 3.0.1__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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Installs DevTorch hooks for Claude Code, git, and Google Antigravity (AGENTS.md).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Canonical hooks.json configuration
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
# Hooks fire a process per event; scope them to mutating tools so read-heavy
|
|
19
|
+
# agent sessions don't pay ~1 spawn per file read.
|
|
20
|
+
MUTATING_TOOLS_MATCHER = "Write|Edit|MultiEdit|NotebookEdit|Bash"
|
|
21
|
+
|
|
22
|
+
# Commands DevTorch has shipped historically — replaced on re-install so
|
|
23
|
+
# users are upgraded to the current fast/detached commands in place.
|
|
24
|
+
_LEGACY_HOOK_COMMAND_PREFIXES = ("devtorch hooks run", "devtorch-hook", "sh -c 'devtorch-hook")
|
|
25
|
+
|
|
26
|
+
CLAUDE_HOOKS_CONFIG: dict = {
|
|
27
|
+
"hooks": {
|
|
28
|
+
"PreToolUse": [
|
|
29
|
+
{
|
|
30
|
+
"matcher": MUTATING_TOOLS_MATCHER,
|
|
31
|
+
"hooks": [
|
|
32
|
+
{"type": "command", "command": "devtorch-hook pre-tool-use"}
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"UserPromptSubmit": [
|
|
37
|
+
{
|
|
38
|
+
"hooks": [
|
|
39
|
+
{"type": "command", "command": "devtorch-hook user-prompt-submit"}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"PostToolUse": [
|
|
44
|
+
{
|
|
45
|
+
"matcher": MUTATING_TOOLS_MATCHER,
|
|
46
|
+
"hooks": [
|
|
47
|
+
# Detached: capture is write-only telemetry; the IDE never
|
|
48
|
+
# consumes its output, so don't make the user wait on it.
|
|
49
|
+
{"type": "command", "command": "sh -c 'devtorch-hook post-tool-use >/dev/null 2>&1 &'"}
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"Stop": [
|
|
54
|
+
{
|
|
55
|
+
"hooks": [
|
|
56
|
+
{"type": "command", "command": "devtorch-hook session-end"}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# OpenCode hook events use a flat per-entry { command, matcher? } shape
|
|
64
|
+
# (unlike Claude Code's nested { hooks: [{ type: "command", command: ... }] }).
|
|
65
|
+
OPENCODE_HOOKS_CONFIG: dict = {
|
|
66
|
+
"hooks": {
|
|
67
|
+
"PreUserPrompt": [
|
|
68
|
+
{
|
|
69
|
+
"command": "devtorch-hook open-code-pre-prompt",
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"PreToolUse": [
|
|
73
|
+
{
|
|
74
|
+
"matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
|
|
75
|
+
"command": "devtorch-hook pre-tool-use",
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"PostToolUse": [
|
|
79
|
+
{
|
|
80
|
+
"matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
|
|
81
|
+
"command": "sh -c 'devtorch-hook post-tool-use &'",
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"Stop": [
|
|
85
|
+
{
|
|
86
|
+
"command": "devtorch-hook session-end",
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Result dataclass
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class HookInstallResult:
|
|
100
|
+
claude_hooks_written: bool = False
|
|
101
|
+
claude_hooks_path: str = ""
|
|
102
|
+
git_hook_written: bool = False
|
|
103
|
+
git_hook_path: str = ""
|
|
104
|
+
# S26 git-capture hooks (post-commit, post-merge, post-checkout, post-rewrite)
|
|
105
|
+
git_capture_hooks_written: bool = False
|
|
106
|
+
git_capture_hooks: dict[str, str] = field(default_factory=dict)
|
|
107
|
+
errors: list[str] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Deep-merge helpers
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _deep_merge(base: dict, overlay: dict) -> dict:
|
|
116
|
+
"""
|
|
117
|
+
Recursively merge *overlay* into *base*.
|
|
118
|
+
|
|
119
|
+
- For dicts: recurse.
|
|
120
|
+
- For lists keyed under "hooks" event names: append overlay entries
|
|
121
|
+
whose commands are not already present.
|
|
122
|
+
- Scalar values: overlay wins.
|
|
123
|
+
"""
|
|
124
|
+
result = copy.deepcopy(base)
|
|
125
|
+
for key, val in overlay.items():
|
|
126
|
+
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
|
|
127
|
+
result[key] = _deep_merge(result[key], val)
|
|
128
|
+
elif key in result and isinstance(result[key], list) and isinstance(val, list):
|
|
129
|
+
# Merge lists without duplicating identical entries
|
|
130
|
+
existing_cmds = _extract_commands(result[key])
|
|
131
|
+
for item in val:
|
|
132
|
+
item_cmds = _extract_commands([item])
|
|
133
|
+
if not item_cmds or not item_cmds.issubset(existing_cmds):
|
|
134
|
+
result[key].append(copy.deepcopy(item))
|
|
135
|
+
else:
|
|
136
|
+
result[key] = copy.deepcopy(val)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _extract_commands(items: list) -> set:
|
|
141
|
+
"""Extract all command strings from a list of hook entries."""
|
|
142
|
+
cmds: set = set()
|
|
143
|
+
for item in items:
|
|
144
|
+
if isinstance(item, dict):
|
|
145
|
+
for hook in item.get("hooks", []):
|
|
146
|
+
if isinstance(hook, dict) and "command" in hook:
|
|
147
|
+
cmds.add(hook["command"])
|
|
148
|
+
return cmds
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _strip_devtorch_hook_entries(settings: dict) -> dict:
|
|
152
|
+
"""Remove hook entries whose every command is a DevTorch-shipped command,
|
|
153
|
+
so re-installation upgrades them instead of accumulating duplicates.
|
|
154
|
+
User-authored entries (any non-DevTorch command) are kept intact."""
|
|
155
|
+
result = copy.deepcopy(settings)
|
|
156
|
+
hooks = result.get("hooks")
|
|
157
|
+
if not isinstance(hooks, dict):
|
|
158
|
+
return result
|
|
159
|
+
for event, entries in hooks.items():
|
|
160
|
+
if not isinstance(entries, list):
|
|
161
|
+
continue
|
|
162
|
+
kept = []
|
|
163
|
+
for entry in entries:
|
|
164
|
+
cmds = _extract_commands([entry])
|
|
165
|
+
if cmds and all(
|
|
166
|
+
c.startswith(_LEGACY_HOOK_COMMAND_PREFIXES) for c in cmds
|
|
167
|
+
):
|
|
168
|
+
continue
|
|
169
|
+
kept.append(entry)
|
|
170
|
+
hooks[event] = kept
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# install_claude_hooks
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def install_claude_hooks(project_root: str) -> tuple[bool, str]:
|
|
180
|
+
"""
|
|
181
|
+
Merge DevTorch hooks into .claude/settings.json under *project_root*.
|
|
182
|
+
|
|
183
|
+
Claude Code only reads hooks from its settings files (settings.json /
|
|
184
|
+
settings.local.json) — a standalone hooks.json is never loaded.
|
|
185
|
+
|
|
186
|
+
- Creates .claude/ directory if it does not exist.
|
|
187
|
+
- Deep-merges DevTorch hooks into the existing settings.json without
|
|
188
|
+
removing existing keys (permissions, env, other hooks, ...).
|
|
189
|
+
- Migrates a legacy .claude/hooks.json written by older DevTorch versions
|
|
190
|
+
into settings.json and removes it.
|
|
191
|
+
- Returns (True, path) on success, (False, error_message) on failure.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
claude_dir = Path(project_root) / ".claude"
|
|
195
|
+
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
|
|
197
|
+
settings_path = claude_dir / "settings.json"
|
|
198
|
+
|
|
199
|
+
existing: dict = {}
|
|
200
|
+
if settings_path.exists():
|
|
201
|
+
try:
|
|
202
|
+
existing = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
203
|
+
except Exception:
|
|
204
|
+
existing = {}
|
|
205
|
+
|
|
206
|
+
legacy_path = claude_dir / "hooks.json"
|
|
207
|
+
legacy_migrated = False
|
|
208
|
+
if legacy_path.exists():
|
|
209
|
+
try:
|
|
210
|
+
legacy = json.loads(legacy_path.read_text(encoding="utf-8"))
|
|
211
|
+
except Exception:
|
|
212
|
+
legacy = None
|
|
213
|
+
if isinstance(legacy, dict) and isinstance(legacy.get("hooks"), dict):
|
|
214
|
+
existing = _deep_merge(existing, {"hooks": legacy["hooks"]})
|
|
215
|
+
legacy_migrated = True
|
|
216
|
+
# Unreadable or unexpected shape: leave the file in place — never
|
|
217
|
+
# delete config we could not migrate.
|
|
218
|
+
|
|
219
|
+
existing = _strip_devtorch_hook_entries(existing)
|
|
220
|
+
merged = _deep_merge(existing, CLAUDE_HOOKS_CONFIG)
|
|
221
|
+
settings_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
|
|
222
|
+
# Remove the legacy file only after its content is safely in settings.json.
|
|
223
|
+
if legacy_migrated:
|
|
224
|
+
legacy_path.unlink()
|
|
225
|
+
return True, str(settings_path)
|
|
226
|
+
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
return False, str(exc)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# install_git_commit_hook
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def install_git_commit_hook(repo_root: str) -> tuple[bool, str]:
|
|
237
|
+
"""
|
|
238
|
+
Install the git commit-msg hook in .git/hooks/commit-msg.
|
|
239
|
+
|
|
240
|
+
Calls devtorch_core.hooks.git_commit.install_hook(repo_root).
|
|
241
|
+
Returns (True, path) on success, (False, reason) when no .git found or
|
|
242
|
+
on any other error.
|
|
243
|
+
"""
|
|
244
|
+
git_dir = Path(repo_root) / ".git"
|
|
245
|
+
if not git_dir.is_dir():
|
|
246
|
+
return False, "no .git found"
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
from devtorch_core.hooks.git_commit import install_hook
|
|
250
|
+
|
|
251
|
+
install_hook(Path(repo_root))
|
|
252
|
+
hook_path = str(git_dir / "hooks" / "commit-msg")
|
|
253
|
+
return True, hook_path
|
|
254
|
+
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
return False, str(exc)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# install_git_capture_hooks
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def install_git_capture_hooks(repo_root: str) -> tuple[bool, dict[str, str]]:
|
|
265
|
+
"""
|
|
266
|
+
Install the S26 git capture hooks in .git/hooks/.
|
|
267
|
+
|
|
268
|
+
Returns (True, {hook_name: path}) on success, (False, {hook_name: error})
|
|
269
|
+
when no .git/hooks directory is found or on any other error.
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
from devtorch_core.hooks.git_capture import (
|
|
273
|
+
install_git_capture_hooks as _install_git_capture_hooks,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
ok, paths = _install_git_capture_hooks(Path(repo_root))
|
|
277
|
+
return ok, paths
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
return False, {"error": str(exc)}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
# install_all_hooks
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def install_all_hooks(project_root: str) -> HookInstallResult:
|
|
288
|
+
"""
|
|
289
|
+
Install all DevTorch hooks (Claude Code + git).
|
|
290
|
+
|
|
291
|
+
Never raises — all exceptions are captured into result.errors.
|
|
292
|
+
"""
|
|
293
|
+
result = HookInstallResult()
|
|
294
|
+
|
|
295
|
+
# Claude Code hooks
|
|
296
|
+
try:
|
|
297
|
+
ok, path = install_claude_hooks(project_root)
|
|
298
|
+
result.claude_hooks_written = ok
|
|
299
|
+
result.claude_hooks_path = path
|
|
300
|
+
if not ok:
|
|
301
|
+
result.errors.append(f"claude hooks: {path}")
|
|
302
|
+
except Exception as exc:
|
|
303
|
+
result.errors.append(f"claude hooks exception: {exc}")
|
|
304
|
+
|
|
305
|
+
# Git commit-msg hook
|
|
306
|
+
try:
|
|
307
|
+
ok, path = install_git_commit_hook(project_root)
|
|
308
|
+
result.git_hook_written = ok
|
|
309
|
+
result.git_hook_path = path
|
|
310
|
+
if not ok:
|
|
311
|
+
result.errors.append(f"git hook: {path}")
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
result.errors.append(f"git hook exception: {exc}")
|
|
314
|
+
|
|
315
|
+
# S26 git capture hooks
|
|
316
|
+
try:
|
|
317
|
+
ok, paths = install_git_capture_hooks(project_root)
|
|
318
|
+
result.git_capture_hooks_written = ok
|
|
319
|
+
result.git_capture_hooks = paths
|
|
320
|
+
if not ok:
|
|
321
|
+
result.errors.append(f"git capture hooks: {paths}")
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
result.errors.append(f"git capture hooks exception: {exc}")
|
|
324
|
+
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# AGENTS.md block for Google Antigravity
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
ANTIGRAVITY_AGENTS_MD_BLOCK = """
|
|
333
|
+
## DevTorch — Reasoning Capture
|
|
334
|
+
|
|
335
|
+
This project uses DevTorch for AI governance. These steps are mandatory for all agent actions.
|
|
336
|
+
|
|
337
|
+
### At the start of every task
|
|
338
|
+
|
|
339
|
+
Call `devtorch_context` (MCP tool) before reading files or writing code:
|
|
340
|
+
- Tool: `devtorch_context`
|
|
341
|
+
- Args: `{ "max_tokens": 2000 }`
|
|
342
|
+
|
|
343
|
+
Use the returned context to understand prior decisions before proceeding.
|
|
344
|
+
|
|
345
|
+
### Before writing or editing any code
|
|
346
|
+
|
|
347
|
+
Call `devtorch_commit` before the first file write:
|
|
348
|
+
- Tool: `devtorch_commit`
|
|
349
|
+
- Args: `{ "message": "<what you decided and WHY — include alternatives considered>", "concepts": ["<concept>"], "confidence": 0.0–1.0 }`
|
|
350
|
+
|
|
351
|
+
The message must explain the *why*, not just the *what*.
|
|
352
|
+
"Added login endpoint" is not acceptable.
|
|
353
|
+
"Chose JWT over sessions because the service is stateless" is.
|
|
354
|
+
|
|
355
|
+
### When touching sensitive areas
|
|
356
|
+
|
|
357
|
+
Call `devtorch_sensitivity_add` for auth, payments, database schema, secrets, PII, external APIs:
|
|
358
|
+
- Tool: `devtorch_sensitivity_add`
|
|
359
|
+
- Args: `{ "concept": "<auth|schema|payments|secrets|pii|api|security|config>", "confidence": 0.0–1.0, "disclosure_level": "PUBLIC|PROTECTED|PRIVATE" }`
|
|
360
|
+
|
|
361
|
+
| Level | Use when |
|
|
362
|
+
|---|---|
|
|
363
|
+
| PUBLIC | General design decisions |
|
|
364
|
+
| PROTECTED | Internal implementation, business logic |
|
|
365
|
+
| PRIVATE | Credentials, PII, secrets |
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# install_agents_md
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def install_agents_md(project_root: str) -> tuple[bool, str]:
|
|
375
|
+
"""
|
|
376
|
+
Write (or append to) AGENTS.md under *project_root* with the DevTorch
|
|
377
|
+
governance block for Google Antigravity.
|
|
378
|
+
|
|
379
|
+
- Creates AGENTS.md if it does not exist.
|
|
380
|
+
- If AGENTS.md already contains the marker ``## DevTorch``, does nothing
|
|
381
|
+
(idempotent).
|
|
382
|
+
- Returns (True, path) on success, (False, error_message) on failure.
|
|
383
|
+
"""
|
|
384
|
+
try:
|
|
385
|
+
import re
|
|
386
|
+
|
|
387
|
+
agents_md_path = Path(project_root) / "AGENTS.md"
|
|
388
|
+
# Any-level DevTorch heading counts as installed — projects may promote
|
|
389
|
+
# the block to an H1; appending our copy next to it would duplicate it.
|
|
390
|
+
marker_re = re.compile(r"^#{1,6}\s+DevTorch\b", re.MULTILINE)
|
|
391
|
+
|
|
392
|
+
if agents_md_path.exists():
|
|
393
|
+
existing = agents_md_path.read_text(encoding="utf-8")
|
|
394
|
+
if marker_re.search(existing):
|
|
395
|
+
return True, str(agents_md_path)
|
|
396
|
+
|
|
397
|
+
with agents_md_path.open("a", encoding="utf-8") as f:
|
|
398
|
+
f.write(ANTIGRAVITY_AGENTS_MD_BLOCK)
|
|
399
|
+
|
|
400
|
+
return True, str(agents_md_path)
|
|
401
|
+
|
|
402
|
+
except Exception as exc:
|
|
403
|
+
return False, str(exc)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# get_hooks_status
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def get_hooks_status(project_root: str) -> dict:
|
|
412
|
+
"""
|
|
413
|
+
Return the install status of DevTorch hooks without writing anything.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
{
|
|
417
|
+
"claude_hooks": bool,
|
|
418
|
+
"claude_hooks_path": str,
|
|
419
|
+
"git_hook": bool,
|
|
420
|
+
"git_hook_path": str,
|
|
421
|
+
"git_capture_hooks": dict[str, bool],
|
|
422
|
+
"git_capture_hooks_path": dict[str, str],
|
|
423
|
+
}
|
|
424
|
+
"""
|
|
425
|
+
settings_path = Path(project_root) / ".claude" / "settings.json"
|
|
426
|
+
claude_hooks_path = str(settings_path)
|
|
427
|
+
claude_hooks = False
|
|
428
|
+
if settings_path.exists():
|
|
429
|
+
try:
|
|
430
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
431
|
+
cmds: set = set()
|
|
432
|
+
for entries in (data.get("hooks") or {}).values():
|
|
433
|
+
if isinstance(entries, list):
|
|
434
|
+
cmds |= _extract_commands(entries)
|
|
435
|
+
claude_hooks = any(
|
|
436
|
+
c.startswith("devtorch-hook") or c.startswith("sh -c 'devtorch-hook")
|
|
437
|
+
for c in cmds
|
|
438
|
+
)
|
|
439
|
+
except Exception:
|
|
440
|
+
claude_hooks = False
|
|
441
|
+
|
|
442
|
+
git_hook_path = str(Path(project_root) / ".git" / "hooks" / "commit-msg")
|
|
443
|
+
git_hook = Path(git_hook_path).exists()
|
|
444
|
+
|
|
445
|
+
hooks_dir = Path(project_root) / ".git" / "hooks"
|
|
446
|
+
git_capture_hook_names = ["post-commit", "post-merge", "post-checkout", "post-rewrite"]
|
|
447
|
+
git_capture_hooks: dict[str, bool] = {}
|
|
448
|
+
git_capture_hooks_path: dict[str, str] = {}
|
|
449
|
+
for name in git_capture_hook_names:
|
|
450
|
+
hook_path = hooks_dir / name
|
|
451
|
+
exists = hook_path.exists()
|
|
452
|
+
git_capture_hooks[name] = exists
|
|
453
|
+
git_capture_hooks_path[name] = str(hook_path)
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
"claude_hooks": claude_hooks,
|
|
457
|
+
"claude_hooks_path": claude_hooks_path,
|
|
458
|
+
"git_hook": git_hook,
|
|
459
|
+
"git_hook_path": git_hook_path,
|
|
460
|
+
"git_capture_hooks": git_capture_hooks,
|
|
461
|
+
"git_capture_hooks_path": git_capture_hooks_path,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
# IDE / project-type detection
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
_IDE_DETECTION_MARKERS: dict[str, str] = {
|
|
470
|
+
".claude": "claude-code",
|
|
471
|
+
".cursor": "cursor",
|
|
472
|
+
".vscode": "vscode",
|
|
473
|
+
".kiro": "kiro",
|
|
474
|
+
".antigravity": "antigravity",
|
|
475
|
+
"mcp_config.json": "antigravity",
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _detect_ides(project_dir: str) -> list[str]:
|
|
480
|
+
"""
|
|
481
|
+
Detect installed IDE directories/files in *project_dir*.
|
|
482
|
+
|
|
483
|
+
Scans for ``.claude/``, ``.cursor/``, ``.vscode/``, ``.kiro/``,
|
|
484
|
+
``.antigravity/``, and ``mcp_config.json``. Returns a list of
|
|
485
|
+
IDE identifiers in the order they were found, with duplicates removed
|
|
486
|
+
(``mcp_config.json`` and ``.antigravity/`` both map to ``antigravity``).
|
|
487
|
+
"""
|
|
488
|
+
root = Path(project_dir)
|
|
489
|
+
found: list[str] = []
|
|
490
|
+
seen: set[str] = set()
|
|
491
|
+
for marker, ide_name in _IDE_DETECTION_MARKERS.items():
|
|
492
|
+
if (root / marker).exists() and ide_name not in seen:
|
|
493
|
+
found.append(ide_name)
|
|
494
|
+
seen.add(ide_name)
|
|
495
|
+
return found
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
_PROJECT_TYPE_MARKERS: dict[str, str] = {
|
|
499
|
+
"pyproject.toml": "python",
|
|
500
|
+
"requirements.txt": "python",
|
|
501
|
+
"setup.py": "python",
|
|
502
|
+
"go.mod": "go",
|
|
503
|
+
"composer.json": "php",
|
|
504
|
+
"package.json": "typescript",
|
|
505
|
+
"tsconfig.json": "typescript",
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _detect_project_type(project_dir: str) -> str:
|
|
510
|
+
"""
|
|
511
|
+
Detect the dominant project type in *project_dir*.
|
|
512
|
+
|
|
513
|
+
Scans for ``pyproject.toml``, ``package.json``, ``go.mod``,
|
|
514
|
+
``composer.json``, and related markers. Returns one of:
|
|
515
|
+
``python``, ``typescript``, ``react``, ``go``, ``php``, or ``generic``.
|
|
516
|
+
"""
|
|
517
|
+
root = Path(project_dir)
|
|
518
|
+
for marker, project_type in _PROJECT_TYPE_MARKERS.items():
|
|
519
|
+
if (root / marker).exists():
|
|
520
|
+
if marker == "package.json":
|
|
521
|
+
try:
|
|
522
|
+
data = json.loads((root / marker).read_text(encoding="utf-8"))
|
|
523
|
+
deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
|
|
524
|
+
if "react" in deps:
|
|
525
|
+
return "react"
|
|
526
|
+
except Exception:
|
|
527
|
+
pass
|
|
528
|
+
return project_type
|
|
529
|
+
return "generic"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
# Shared MCP server entry for Cursor / Antigravity
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
_MCP_SERVER_ENTRY = {
|
|
537
|
+
"devtorch": {
|
|
538
|
+
"command": "devtorch",
|
|
539
|
+
"args": ["mcp-server"],
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
# install_claude_mcp
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
# Claude Code reads project-scope MCP servers only from <root>/.mcp.json;
|
|
549
|
+
# a mcpServers key inside .claude/settings.json is silently ignored.
|
|
550
|
+
CLAUDE_MCP_SERVER_ENTRY = {
|
|
551
|
+
"devtorch": {
|
|
552
|
+
"type": "stdio",
|
|
553
|
+
"command": "devtorch",
|
|
554
|
+
"args": ["mcp-server"],
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _read_json_dict_or_backup(path: Path) -> dict:
|
|
560
|
+
"""
|
|
561
|
+
Parse *path* as a JSON object. If it is unparseable or not an object,
|
|
562
|
+
rename it to ``<name>.bak`` so hand-edited content is never silently
|
|
563
|
+
clobbered, and return an empty dict.
|
|
564
|
+
"""
|
|
565
|
+
try:
|
|
566
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
567
|
+
except Exception:
|
|
568
|
+
data = None
|
|
569
|
+
if isinstance(data, dict):
|
|
570
|
+
return data
|
|
571
|
+
path.rename(path.with_name(path.name + ".bak"))
|
|
572
|
+
return {}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _pop_legacy_settings_mcp_entry(root: Path) -> Optional[dict]:
|
|
576
|
+
"""
|
|
577
|
+
Remove a devtorch entry stranded in .claude/settings.json mcpServers
|
|
578
|
+
(where Claude Code never reads it) and return it, or None.
|
|
579
|
+
|
|
580
|
+
Other mcpServers entries are left untouched; the mcpServers key itself is
|
|
581
|
+
dropped when the devtorch entry was the only one.
|
|
582
|
+
"""
|
|
583
|
+
settings_path = root / ".claude" / "settings.json"
|
|
584
|
+
if not settings_path.exists():
|
|
585
|
+
return None
|
|
586
|
+
try:
|
|
587
|
+
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
588
|
+
except Exception:
|
|
589
|
+
return None
|
|
590
|
+
servers = settings.get("mcpServers")
|
|
591
|
+
if not isinstance(servers, dict) or "devtorch" not in servers:
|
|
592
|
+
return None
|
|
593
|
+
entry = servers.pop("devtorch")
|
|
594
|
+
if not servers:
|
|
595
|
+
del settings["mcpServers"]
|
|
596
|
+
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
|
597
|
+
return entry if isinstance(entry, dict) else None
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def install_claude_mcp(project_root: str) -> tuple[bool, str]:
|
|
601
|
+
"""
|
|
602
|
+
Write (or merge into) <project_root>/.mcp.json for Claude Code.
|
|
603
|
+
|
|
604
|
+
Adds the devtorch MCP server entry. Preserves any existing servers.
|
|
605
|
+
Idempotent — skips write if devtorch entry already present.
|
|
606
|
+
Migrates a devtorch entry written to .claude/settings.json by older
|
|
607
|
+
DevTorch versions, preserving its configured values.
|
|
608
|
+
Returns (True, path) on success, (False, error_message) on failure.
|
|
609
|
+
"""
|
|
610
|
+
try:
|
|
611
|
+
root = Path(project_root)
|
|
612
|
+
mcp_path = root / ".mcp.json"
|
|
613
|
+
|
|
614
|
+
existing: dict = {}
|
|
615
|
+
if mcp_path.exists():
|
|
616
|
+
existing = _read_json_dict_or_backup(mcp_path)
|
|
617
|
+
|
|
618
|
+
legacy_entry = _pop_legacy_settings_mcp_entry(root)
|
|
619
|
+
|
|
620
|
+
servers = existing.setdefault("mcpServers", {})
|
|
621
|
+
if "devtorch" not in servers:
|
|
622
|
+
servers["devtorch"] = legacy_entry or copy.deepcopy(
|
|
623
|
+
CLAUDE_MCP_SERVER_ENTRY["devtorch"]
|
|
624
|
+
)
|
|
625
|
+
mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
626
|
+
|
|
627
|
+
return True, str(mcp_path)
|
|
628
|
+
|
|
629
|
+
except Exception as exc:
|
|
630
|
+
return False, str(exc)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ---------------------------------------------------------------------------
|
|
634
|
+
# install_cursor_mcp
|
|
635
|
+
# ---------------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def install_opencode_hooks(project_root: str) -> tuple[bool, str]:
|
|
639
|
+
"""
|
|
640
|
+
Merge DevTorch hooks into ``<project_root>/opencode.json`` for OpenCode.
|
|
641
|
+
|
|
642
|
+
- Reads the existing opencode.json if it exists.
|
|
643
|
+
- Deep-merges DevTorch's OpenCode hooks into the ``hooks`` key.
|
|
644
|
+
- Strips previously-installed DevTorch hook entries so re-installation
|
|
645
|
+
upgrades them in-place instead of accumulating duplicates.
|
|
646
|
+
- Returns (True, path) on success, (False, error_message) on failure.
|
|
647
|
+
"""
|
|
648
|
+
try:
|
|
649
|
+
root = Path(project_root)
|
|
650
|
+
config_path = root / "opencode.json"
|
|
651
|
+
|
|
652
|
+
existing: dict = {}
|
|
653
|
+
if config_path.exists():
|
|
654
|
+
existing = _read_json_dict_or_backup(config_path)
|
|
655
|
+
|
|
656
|
+
hooks_existing: dict = existing.setdefault("hooks", {})
|
|
657
|
+
working = {"hooks": hooks_existing}
|
|
658
|
+
working = _strip_devtorch_hook_entries(working)
|
|
659
|
+
merged = _deep_merge(working, OPENCODE_HOOKS_CONFIG)
|
|
660
|
+
|
|
661
|
+
result_obj = dict(existing)
|
|
662
|
+
result_obj["hooks"] = merged.get("hooks", {})
|
|
663
|
+
config_path.write_text(json.dumps(result_obj, indent=2), encoding="utf-8")
|
|
664
|
+
|
|
665
|
+
return True, str(config_path)
|
|
666
|
+
except Exception as exc:
|
|
667
|
+
return False, str(exc)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def install_cursor_mcp(project_root: str) -> tuple[bool, str]:
|
|
671
|
+
"""
|
|
672
|
+
Write (or merge into) .cursor/mcp.json under *project_root*.
|
|
673
|
+
|
|
674
|
+
Adds the devtorch MCP server entry. Preserves any existing servers.
|
|
675
|
+
Idempotent — skips write if devtorch entry already present.
|
|
676
|
+
Returns (True, path) on success, (False, error_message) on failure.
|
|
677
|
+
"""
|
|
678
|
+
try:
|
|
679
|
+
cursor_dir = Path(project_root) / ".cursor"
|
|
680
|
+
cursor_dir.mkdir(parents=True, exist_ok=True)
|
|
681
|
+
mcp_path = cursor_dir / "mcp.json"
|
|
682
|
+
|
|
683
|
+
existing: dict = {}
|
|
684
|
+
if mcp_path.exists():
|
|
685
|
+
try:
|
|
686
|
+
existing = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
687
|
+
except Exception:
|
|
688
|
+
existing = {}
|
|
689
|
+
|
|
690
|
+
servers = existing.setdefault("mcpServers", {})
|
|
691
|
+
if "devtorch" not in servers:
|
|
692
|
+
servers["devtorch"] = _MCP_SERVER_ENTRY["devtorch"]
|
|
693
|
+
mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
694
|
+
|
|
695
|
+
return True, str(mcp_path)
|
|
696
|
+
|
|
697
|
+
except Exception as exc:
|
|
698
|
+
return False, str(exc)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# ---------------------------------------------------------------------------
|
|
702
|
+
# install_antigravity_mcp
|
|
703
|
+
# ---------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def install_antigravity_mcp(project_root: str) -> tuple[bool, str]:
|
|
707
|
+
"""
|
|
708
|
+
Write (or merge into) mcp_config.json at *project_root*.
|
|
709
|
+
|
|
710
|
+
Antigravity uses a project-level mcp_config.json for MCP server registration.
|
|
711
|
+
Adds the devtorch MCP server entry. Preserves any existing servers.
|
|
712
|
+
Idempotent — skips write if devtorch entry already present.
|
|
713
|
+
Returns (True, path) on success, (False, error_message) on failure.
|
|
714
|
+
"""
|
|
715
|
+
try:
|
|
716
|
+
mcp_path = Path(project_root) / "mcp_config.json"
|
|
717
|
+
|
|
718
|
+
existing: dict = {}
|
|
719
|
+
if mcp_path.exists():
|
|
720
|
+
try:
|
|
721
|
+
existing = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
722
|
+
except Exception:
|
|
723
|
+
existing = {}
|
|
724
|
+
|
|
725
|
+
servers = existing.setdefault("mcpServers", {})
|
|
726
|
+
if "devtorch" not in servers:
|
|
727
|
+
servers["devtorch"] = _MCP_SERVER_ENTRY["devtorch"]
|
|
728
|
+
mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
729
|
+
|
|
730
|
+
return True, str(mcp_path)
|
|
731
|
+
|
|
732
|
+
except Exception as exc:
|
|
733
|
+
return False, str(exc)
|