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