subcortex 0.3.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.
- subcortex/__init__.py +3 -0
- subcortex/__main__.py +3 -0
- subcortex/adapters/__init__.py +48 -0
- subcortex/adapters/base.py +230 -0
- subcortex/adapters/claude_family.py +133 -0
- subcortex/adapters/codex.py +87 -0
- subcortex/adapters/copilot.py +60 -0
- subcortex/adapters/cursor.py +36 -0
- subcortex/adapters/docker_agent.py +115 -0
- subcortex/adapters/gemini_family.py +60 -0
- subcortex/adapters/grok.py +98 -0
- subcortex/adapters/kimi_code.py +138 -0
- subcortex/adapters/letta_vibe.py +96 -0
- subcortex/adapters/openhands.py +153 -0
- subcortex/auth.py +59 -0
- subcortex/backends/__init__.py +23 -0
- subcortex/backends/base.py +22 -0
- subcortex/backends/jev.py +460 -0
- subcortex/backends/laya.py +149 -0
- subcortex/cli.py +809 -0
- subcortex/client.py +77 -0
- subcortex/config.py +263 -0
- subcortex/daemon.py +502 -0
- subcortex/evalset.py +241 -0
- subcortex/hook.py +254 -0
- subcortex/installers/__init__.py +62 -0
- subcortex/installers/amp.py +39 -0
- subcortex/installers/base.py +874 -0
- subcortex/installers/claude_family.py +229 -0
- subcortex/installers/codex.py +110 -0
- subcortex/installers/copilot.py +65 -0
- subcortex/installers/crush.py +36 -0
- subcortex/installers/cursor.py +79 -0
- subcortex/installers/gemini_family.py +83 -0
- subcortex/installers/goose.py +186 -0
- subcortex/installers/kimi_code.py +71 -0
- subcortex/installers/mcp_only.py +111 -0
- subcortex/installers/more_hooks.py +184 -0
- subcortex/installers/opencode.py +66 -0
- subcortex/installers/openhands.py +84 -0
- subcortex/installers/pi_cline.py +53 -0
- subcortex/ledger.py +92 -0
- subcortex/localhttp.py +59 -0
- subcortex/mcp_server.py +187 -0
- subcortex/metrics.py +56 -0
- subcortex/plugins/amp/subcortex.ts +258 -0
- subcortex/plugins/cline/subcortex.ts +340 -0
- subcortex/plugins/opencode/subcortex.ts +265 -0
- subcortex/plugins/pi/subcortex.ts +292 -0
- subcortex/policy.py +341 -0
- subcortex/presets.py +163 -0
- subcortex/provision.py +188 -0
- subcortex/service.py +149 -0
- subcortex/state.py +137 -0
- subcortex/transcript.py +211 -0
- subcortex/tuis.py +51 -0
- subcortex/ui.py +319 -0
- subcortex/verdicts.py +233 -0
- subcortex/wizard.py +474 -0
- subcortex-0.3.0.dist-info/METADATA +287 -0
- subcortex-0.3.0.dist-info/RECORD +64 -0
- subcortex-0.3.0.dist-info/WHEEL +5 -0
- subcortex-0.3.0.dist-info/entry_points.txt +3 -0
- subcortex-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Installers for Claude-Code-style hook files.
|
|
2
|
+
|
|
3
|
+
All of them write ``{"<Event>": [{"matcher": ..., "hooks": [{"type": "command",
|
|
4
|
+
"command": ..., "timeout": N}]}]}`` groups — under a top-level ``hooks`` key or
|
|
5
|
+
(Droid's hooks.json) at the top level — for exactly the events the TUI's
|
|
6
|
+
adapter handles.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from ..adapters import get_adapter
|
|
16
|
+
from .base import Installer, Target, add_grouped, json_target, mcp_json_target, remove_grouped
|
|
17
|
+
|
|
18
|
+
SESSION = "3f6c0e1a-5b7d-4c2e-9a18-6d0f4b2c7e91"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClaudeStyleInstaller(Installer):
|
|
22
|
+
seam = "hooks"
|
|
23
|
+
wrapper: Optional[str] = "hooks" # None: events at the file's top level
|
|
24
|
+
shell_tool = "Bash" # PostToolUse matcher
|
|
25
|
+
timeout = 10 # seconds
|
|
26
|
+
|
|
27
|
+
def settings_path(self) -> Path:
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
def mcp_target(self) -> Optional[Target]:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
# -- events -----------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _adapter(self):
|
|
36
|
+
return get_adapter(self.name)
|
|
37
|
+
|
|
38
|
+
def hook_events(self) -> List[str]:
|
|
39
|
+
order = ["UserPromptSubmit", "PostToolUse", "PreCompact", "SessionStart"]
|
|
40
|
+
order += [e for e in self._adapter().events if e not in order]
|
|
41
|
+
return [e for e in order if e in self._adapter().events]
|
|
42
|
+
|
|
43
|
+
def matcher(self, event: str) -> Optional[str]:
|
|
44
|
+
return {"PostToolUse": self.shell_tool, "SessionStart": "compact"}.get(event)
|
|
45
|
+
|
|
46
|
+
def entry(self, event: str) -> Dict[str, Any]:
|
|
47
|
+
return {"type": "command", "command": self.command(event), "timeout": self.timeout}
|
|
48
|
+
|
|
49
|
+
# -- file edits ---------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def _table(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
52
|
+
if self.wrapper is None:
|
|
53
|
+
return data
|
|
54
|
+
table = data.get(self.wrapper)
|
|
55
|
+
if not isinstance(table, dict):
|
|
56
|
+
table = data[self.wrapper] = {}
|
|
57
|
+
return table
|
|
58
|
+
|
|
59
|
+
def targets(self) -> List[Target]:
|
|
60
|
+
def add(data: Dict[str, Any]) -> None:
|
|
61
|
+
table = self._table(data)
|
|
62
|
+
for event in self.hook_events():
|
|
63
|
+
add_grouped(table, event, self.matcher(event), self.entry(event))
|
|
64
|
+
|
|
65
|
+
def remove(data: Dict[str, Any]) -> None:
|
|
66
|
+
table = data if self.wrapper is None else data.get(self.wrapper)
|
|
67
|
+
if isinstance(table, dict):
|
|
68
|
+
remove_grouped(table)
|
|
69
|
+
if self.wrapper is not None and not table:
|
|
70
|
+
del data[self.wrapper]
|
|
71
|
+
|
|
72
|
+
def installed(data: Dict[str, Any]) -> bool:
|
|
73
|
+
table = data if self.wrapper is None else data.get(self.wrapper)
|
|
74
|
+
if not isinstance(table, dict):
|
|
75
|
+
return False
|
|
76
|
+
probe = {k: [dict(g) for g in v if isinstance(g, dict)]
|
|
77
|
+
for k, v in table.items() if isinstance(v, list)}
|
|
78
|
+
return bool(remove_grouped(probe))
|
|
79
|
+
|
|
80
|
+
targets = [json_target(self.settings_path(), add, remove, installed)]
|
|
81
|
+
mcp = self.mcp_target() if self.mcp else None
|
|
82
|
+
if mcp is not None:
|
|
83
|
+
targets.append(mcp)
|
|
84
|
+
return targets
|
|
85
|
+
|
|
86
|
+
# -- self-test ------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def sample_payload(self, event: str) -> Dict[str, Any]:
|
|
89
|
+
common = {"session_id": SESSION, "transcript_path": "{transcript}", "cwd": "/tmp",
|
|
90
|
+
"hook_event_name": event, "permission_mode": "default"}
|
|
91
|
+
if event == "UserPromptSubmit":
|
|
92
|
+
return {**common, "prompt": "what does ls -la do?"}
|
|
93
|
+
if event == "PostToolUse":
|
|
94
|
+
return {**common, "tool_name": self.shell_tool, "tool_use_id": "toolu_01",
|
|
95
|
+
"tool_input": {"command": "make build"},
|
|
96
|
+
"tool_response": {"stdout": "{big_output}", "stderr": "",
|
|
97
|
+
"interrupted": False, "isImage": False}}
|
|
98
|
+
if event == "PreCompact":
|
|
99
|
+
return {**common, "trigger": "auto", "custom_instructions": ""}
|
|
100
|
+
return {**common, "source": "compact"}
|
|
101
|
+
|
|
102
|
+
def expects_output(self, event: str) -> bool:
|
|
103
|
+
if event == "PostToolUse":
|
|
104
|
+
return bool(getattr(self._adapter(), "replaces_output", False))
|
|
105
|
+
if event == "SessionStart":
|
|
106
|
+
return "PreCompact" in self.hook_events()
|
|
107
|
+
return event == "UserPromptSubmit"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _home_dir(env: str, default: Path) -> Path:
|
|
111
|
+
override = os.environ.get(env, "").strip()
|
|
112
|
+
return Path(override).expanduser() if override else default
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ClaudeCodeInstaller(ClaudeStyleInstaller):
|
|
116
|
+
name = "claude-code"
|
|
117
|
+
display_name = "Claude Code"
|
|
118
|
+
binaries = ("claude",)
|
|
119
|
+
docs = "https://code.claude.com/docs/en/hooks"
|
|
120
|
+
post_install = ("new Claude Code sessions pick this up; optional on-demand tools: "
|
|
121
|
+
"claude mcp add --scope user subcortex -- subcortex mcp")
|
|
122
|
+
|
|
123
|
+
def settings_path(self) -> Path:
|
|
124
|
+
return _home_dir("CLAUDE_CONFIG_DIR", Path.home() / ".claude") / "settings.json"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class QoderInstaller(ClaudeStyleInstaller):
|
|
128
|
+
name = "qoder"
|
|
129
|
+
display_name = "Qoder CLI"
|
|
130
|
+
binaries = ("qodercli",)
|
|
131
|
+
docs = "https://docs.qoder.com/cli/hooks"
|
|
132
|
+
supports_mcp = True
|
|
133
|
+
|
|
134
|
+
def qoder_dir(self) -> Path:
|
|
135
|
+
# Qoder's own resolution: QODER_CONFIG_DIR, else (QODER_CLI_HOME |
|
|
136
|
+
# GEMINI_CLI_HOME | ~)/(QODER_CONFIG_DIR_NAME | .qoder).
|
|
137
|
+
explicit = os.environ.get("QODER_CONFIG_DIR", "").strip()
|
|
138
|
+
if explicit:
|
|
139
|
+
return Path(explicit).expanduser()
|
|
140
|
+
home = (os.environ.get("QODER_CLI_HOME", "").strip() or os.environ.get("GEMINI_CLI_HOME", "").strip())
|
|
141
|
+
name = os.environ.get("QODER_CONFIG_DIR_NAME", "").strip() or ".qoder"
|
|
142
|
+
return (Path(home).expanduser() if home else Path.home()) / name
|
|
143
|
+
|
|
144
|
+
def settings_path(self) -> Path:
|
|
145
|
+
return self.qoder_dir() / "settings.json"
|
|
146
|
+
|
|
147
|
+
def mcp_target(self) -> Optional[Target]:
|
|
148
|
+
return mcp_json_target(self.settings_path(), self.mcp_command())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class CodeBuddyInstaller(ClaudeStyleInstaller):
|
|
152
|
+
name = "codebuddy"
|
|
153
|
+
display_name = "CodeBuddy Code"
|
|
154
|
+
binaries = ("codebuddy",)
|
|
155
|
+
docs = "https://www.codebuddy.ai/docs/cli/hooks"
|
|
156
|
+
supports_mcp = True
|
|
157
|
+
|
|
158
|
+
def codebuddy_dir(self) -> Path:
|
|
159
|
+
return _home_dir("CODEBUDDY_CONFIG_DIR", Path.home() / ".codebuddy")
|
|
160
|
+
|
|
161
|
+
def settings_path(self) -> Path:
|
|
162
|
+
return self.codebuddy_dir() / "settings.json"
|
|
163
|
+
|
|
164
|
+
def mcp_target(self) -> Optional[Target]:
|
|
165
|
+
return mcp_json_target(self.codebuddy_dir() / ".mcp.json", self.mcp_command())
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class DroidInstaller(ClaudeStyleInstaller):
|
|
169
|
+
name = "droid"
|
|
170
|
+
display_name = "Factory Droid"
|
|
171
|
+
binaries = ("droid",)
|
|
172
|
+
docs = "https://docs.factory.ai/reference/hooks-reference"
|
|
173
|
+
wrapper = None # ~/.factory/hooks.json is keyed by event directly
|
|
174
|
+
shell_tool = "Execute"
|
|
175
|
+
supports_mcp = True
|
|
176
|
+
post_install = ("hooks are snapshotted at startup: restart droid. Hints reach interactive and "
|
|
177
|
+
"SDK/stream sessions; one-shot `droid exec` skips the prompt hook")
|
|
178
|
+
|
|
179
|
+
def factory_dir(self) -> Path:
|
|
180
|
+
return _home_dir("FACTORY_HOME_OVERRIDE", Path.home()) / ".factory"
|
|
181
|
+
|
|
182
|
+
def settings_path(self) -> Path:
|
|
183
|
+
return self.factory_dir() / "hooks.json"
|
|
184
|
+
|
|
185
|
+
def mcp_target(self) -> Optional[Target]:
|
|
186
|
+
return mcp_json_target(self.factory_dir() / "mcp.json", self.mcp_command(),
|
|
187
|
+
extra={"disabled": False})
|
|
188
|
+
|
|
189
|
+
def sample_payload(self, event: str) -> Dict[str, Any]:
|
|
190
|
+
payload = super().sample_payload(event)
|
|
191
|
+
if event == "SessionStart": # compaction rotates the session id
|
|
192
|
+
payload["previous_session_id"] = payload["session_id"]
|
|
193
|
+
payload["session_id"] = "a1d4e7f0-2c93-4b6e-9f15-7e0c3b8a2d41"
|
|
194
|
+
return payload
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class JunieInstaller(ClaudeStyleInstaller):
|
|
198
|
+
name = "junie"
|
|
199
|
+
display_name = "Junie CLI"
|
|
200
|
+
binaries = ("junie",)
|
|
201
|
+
docs = "https://junie.jetbrains.com/docs/junie-cli-hooks.html"
|
|
202
|
+
supports_mcp = True
|
|
203
|
+
post_install = "hints apply to interactive Junie sessions (batch mode doesn't run hooks)"
|
|
204
|
+
|
|
205
|
+
def junie_dir(self) -> Path:
|
|
206
|
+
return _home_dir("JUNIE_HOME", Path.home() / ".junie")
|
|
207
|
+
|
|
208
|
+
def settings_path(self) -> Path:
|
|
209
|
+
return self.junie_dir() / "config.json"
|
|
210
|
+
|
|
211
|
+
def mcp_target(self) -> Optional[Target]:
|
|
212
|
+
return mcp_json_target(self.junie_dir() / "mcp" / "mcp.json", self.mcp_command())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class DevinInstaller(ClaudeStyleInstaller):
|
|
216
|
+
name = "devin"
|
|
217
|
+
display_name = "Devin CLI"
|
|
218
|
+
binaries = ("devin",)
|
|
219
|
+
docs = "https://docs.devin.ai/cli/extensibility/hooks"
|
|
220
|
+
supports_mcp = True
|
|
221
|
+
|
|
222
|
+
def devin_dir(self) -> Path:
|
|
223
|
+
return _home_dir("XDG_CONFIG_HOME", Path.home() / ".config") / "devin"
|
|
224
|
+
|
|
225
|
+
def settings_path(self) -> Path:
|
|
226
|
+
return self.devin_dir() / "config.json"
|
|
227
|
+
|
|
228
|
+
def mcp_target(self) -> Optional[Target]:
|
|
229
|
+
return mcp_json_target(self.devin_dir() / "mcp_config.json", self.mcp_command())
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Codex CLI (``$CODEX_HOME/hooks.json``) and Open Interpreter (``~/.openinterpreter/hooks.json``).
|
|
2
|
+
|
|
3
|
+
``hooks.json`` rather than inline ``[hooks]`` in ``config.toml``: Codex
|
|
4
|
+
writes its own hook-trust state into ``config.toml``, so a JSON file we merge
|
|
5
|
+
into is the exact-uninstall target. ``config.toml`` is still touched for two
|
|
6
|
+
things: removing the (malformed) block subcortex 0.1.0 wrote there, and, with
|
|
7
|
+
``--mcp``, a marked ``[mcp_servers.subcortex]`` block.
|
|
8
|
+
|
|
9
|
+
Codex only runs non-managed hooks after the user trusts them (``/hooks``);
|
|
10
|
+
until then they are skipped, which is fail-safe.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from .base import (InstallError, Target, append_block, has_block, strip_block, toml_str)
|
|
20
|
+
from .claude_family import SESSION, ClaudeStyleInstaller
|
|
21
|
+
|
|
22
|
+
TRUST = ("run /hooks inside {name} once and trust the subcortex hooks (hooks are hash-verified; "
|
|
23
|
+
"they are skipped until trusted)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _toml_target(path: Path, body: Optional[str], tui: str) -> Target:
|
|
27
|
+
"""config.toml: drop any subcortex block (0.1.0 hooks included); add ``body`` if given."""
|
|
28
|
+
import tomllib
|
|
29
|
+
|
|
30
|
+
def check(text: str) -> str:
|
|
31
|
+
try:
|
|
32
|
+
tomllib.loads(text)
|
|
33
|
+
except tomllib.TOMLDecodeError as exc:
|
|
34
|
+
raise InstallError(f"{path}: result would not be valid TOML ({exc}); refusing to write") from exc
|
|
35
|
+
return text
|
|
36
|
+
|
|
37
|
+
def merge(text: str) -> str:
|
|
38
|
+
if body is None:
|
|
39
|
+
return check(strip_block(text)) if has_block(text) else text
|
|
40
|
+
return check(append_block(text, body, tui))
|
|
41
|
+
|
|
42
|
+
def unmerge(text: str) -> str:
|
|
43
|
+
return check(strip_block(text)) if has_block(text) else text
|
|
44
|
+
|
|
45
|
+
return Target(path, merge, unmerge, has_block)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _CodexEngineInstaller(ClaudeStyleInstaller):
|
|
49
|
+
supports_mcp = True
|
|
50
|
+
|
|
51
|
+
def home(self) -> Path:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def settings_path(self) -> Path:
|
|
55
|
+
return self.home() / "hooks.json"
|
|
56
|
+
|
|
57
|
+
def matcher(self, event: str) -> Optional[str]:
|
|
58
|
+
return {"PostToolUse": "^Bash$", "SessionStart": "^compact$"}.get(event)
|
|
59
|
+
|
|
60
|
+
def targets(self) -> List[Target]:
|
|
61
|
+
targets = [t for t in super().targets() if t.path == self.settings_path()]
|
|
62
|
+
body = None
|
|
63
|
+
if self.mcp:
|
|
64
|
+
argv = self.mcp_command()
|
|
65
|
+
body = "\n".join([
|
|
66
|
+
"[mcp_servers.subcortex]",
|
|
67
|
+
f"command = {toml_str(argv[0])}",
|
|
68
|
+
f"args = [{', '.join(toml_str(a) for a in argv[1:])}]",
|
|
69
|
+
"startup_timeout_sec = 10",
|
|
70
|
+
]) + "\n"
|
|
71
|
+
targets.append(_toml_target(self.home() / "config.toml", body, self.name))
|
|
72
|
+
return targets
|
|
73
|
+
|
|
74
|
+
def sample_payload(self, event: str) -> Dict[str, Any]:
|
|
75
|
+
common = {"session_id": SESSION, "transcript_path": "{transcript}", "cwd": "/tmp",
|
|
76
|
+
"hook_event_name": event, "model": "gpt-5.5-codex"}
|
|
77
|
+
if event == "UserPromptSubmit":
|
|
78
|
+
return {**common, "turn_id": "0", "permission_mode": "default", "prompt": "what does ls -la do?"}
|
|
79
|
+
if event == "PostToolUse":
|
|
80
|
+
return {**common, "turn_id": "3", "permission_mode": "default", "tool_name": "Bash",
|
|
81
|
+
"tool_use_id": "call_1", "tool_input": {"command": "make build"},
|
|
82
|
+
"tool_response": "{big_output}"}
|
|
83
|
+
if event == "PreCompact":
|
|
84
|
+
return {**common, "turn_id": "7", "trigger": "auto"}
|
|
85
|
+
return {**common, "permission_mode": "default", "source": "compact"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CodexInstaller(_CodexEngineInstaller):
|
|
89
|
+
name = "codex"
|
|
90
|
+
display_name = "Codex CLI"
|
|
91
|
+
binaries = ("codex",)
|
|
92
|
+
docs = "https://developers.openai.com/codex/hooks"
|
|
93
|
+
min_version = "0.133.0"
|
|
94
|
+
post_install = TRUST.format(name="codex")
|
|
95
|
+
|
|
96
|
+
def home(self) -> Path:
|
|
97
|
+
override = os.environ.get("CODEX_HOME", "").strip()
|
|
98
|
+
return Path(override) if override else Path.home() / ".codex"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OpenInterpreterInstaller(_CodexEngineInstaller):
|
|
102
|
+
name = "open-interpreter"
|
|
103
|
+
display_name = "Open Interpreter"
|
|
104
|
+
binaries = ("interpreter",)
|
|
105
|
+
docs = "https://github.com/openinterpreter/openinterpreter/blob/main/docs/hooks.md"
|
|
106
|
+
post_install = TRUST.format(name="interpreter")
|
|
107
|
+
|
|
108
|
+
def home(self) -> Path:
|
|
109
|
+
override = os.environ.get("INTERPRETER_HOME", "").strip()
|
|
110
|
+
return Path(override).expanduser() if override else Path.home() / ".openinterpreter"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""GitHub Copilot CLI: a hooks file we own, ``$COPILOT_HOME/hooks/subcortex.json``.
|
|
2
|
+
|
|
3
|
+
Copilot loads every ``*.json`` in its user hooks directory, so subcortex gets
|
|
4
|
+
its own file (uninstall deletes it) and never touches ``settings.json``.
|
|
5
|
+
Hooks are read at startup: restart copilot after installing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from .base import Installer, Target, mcp_json_target, owned_file_target
|
|
16
|
+
|
|
17
|
+
EVENTS = {"userPromptSubmitted": (None, 5), "postToolUse": ("bash|powershell", 10), "preCompact": (None, 10)}
|
|
18
|
+
SESSION = "8c5d7b2e-3f41-4d0a-9c6b-1e2f3a4b5c6d"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def copilot_home() -> Path:
|
|
22
|
+
override = os.environ.get("COPILOT_HOME", "").strip()
|
|
23
|
+
return Path(override) if override else Path.home() / ".copilot"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CopilotInstaller(Installer):
|
|
27
|
+
name = "copilot"
|
|
28
|
+
display_name = "GitHub Copilot CLI"
|
|
29
|
+
seam = "hooks"
|
|
30
|
+
binaries = ("copilot",)
|
|
31
|
+
docs = "https://docs.github.com/en/copilot/reference/hooks-reference"
|
|
32
|
+
min_version = "1.0.67"
|
|
33
|
+
supports_mcp = True
|
|
34
|
+
post_install = "restart copilot to load the hooks"
|
|
35
|
+
|
|
36
|
+
def _content(self) -> str:
|
|
37
|
+
hooks: Dict[str, List[Dict[str, Any]]] = {}
|
|
38
|
+
for event, (matcher, timeout) in EVENTS.items():
|
|
39
|
+
entry: Dict[str, Any] = {"type": "command", "bash": self.command(event), "timeoutSec": timeout}
|
|
40
|
+
if matcher:
|
|
41
|
+
entry["matcher"] = matcher
|
|
42
|
+
hooks[event] = [entry]
|
|
43
|
+
return json.dumps({"version": 1, "hooks": hooks}, indent=2) + "\n"
|
|
44
|
+
|
|
45
|
+
def targets(self) -> List[Target]:
|
|
46
|
+
targets = [owned_file_target(copilot_home() / "hooks" / "subcortex.json", self._content())]
|
|
47
|
+
if self.mcp:
|
|
48
|
+
targets.append(mcp_json_target(copilot_home() / "mcp-config.json", self.mcp_command(),
|
|
49
|
+
extra={"type": "local", "env": {}, "tools": ["*"]}))
|
|
50
|
+
return targets
|
|
51
|
+
|
|
52
|
+
def hook_events(self) -> List[str]:
|
|
53
|
+
return list(EVENTS)
|
|
54
|
+
|
|
55
|
+
def sample_payload(self, event: str) -> Dict[str, Any]:
|
|
56
|
+
common = {"sessionId": SESSION, "timestamp": 1790019371523, "cwd": "/tmp"}
|
|
57
|
+
if event == "userPromptSubmitted":
|
|
58
|
+
return {**common, "prompt": "what does ls -la do?"}
|
|
59
|
+
if event == "postToolUse":
|
|
60
|
+
return {**common, "toolName": "bash", "toolArgs": {"command": "make build"},
|
|
61
|
+
"toolResult": {"resultType": "success", "textResultForLlm": "{big_output}"}}
|
|
62
|
+
return {**common, "transcriptPath": "{transcript}", "trigger": "auto", "customInstructions": ""}
|
|
63
|
+
|
|
64
|
+
def expects_output(self, event: str) -> bool:
|
|
65
|
+
return event in ("userPromptSubmitted", "postToolUse")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Crush: register the ``subcortex mcp`` server in the global ``crush.json``.
|
|
2
|
+
|
|
3
|
+
Crush (>= v0.64) only has a ``PreToolUse`` hook — no prompt, post-tool or
|
|
4
|
+
compaction events — and it already truncates bash output itself, so its seam
|
|
5
|
+
is MCP. Crush refuses to start on invalid JSON (comments included), so the
|
|
6
|
+
file is only ever rewritten as strict JSON, and refused if it isn't.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
from .base import Installer, Target, mcp_json_target
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def crush_config_dir() -> Path:
|
|
19
|
+
override = os.environ.get("CRUSH_GLOBAL_CONFIG", "").strip()
|
|
20
|
+
if override:
|
|
21
|
+
return Path(override)
|
|
22
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
23
|
+
return (Path(xdg) if xdg else Path.home() / ".config") / "crush"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CrushInstaller(Installer):
|
|
27
|
+
name = "crush"
|
|
28
|
+
display_name = "Crush"
|
|
29
|
+
seam = "mcp"
|
|
30
|
+
binaries = ("crush",)
|
|
31
|
+
docs = "https://github.com/charmbracelet/crush#mcps"
|
|
32
|
+
post_install = "restart crush to load the MCP server (tools appear as mcp_subcortex_*)"
|
|
33
|
+
|
|
34
|
+
def targets(self) -> List[Target]:
|
|
35
|
+
return [mcp_json_target(crush_config_dir() / "crush.json", self.mcp_command(), key="mcp",
|
|
36
|
+
extra={"type": "stdio", "timeout": 10})]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Cursor CLI: ``~/.cursor/hooks.json`` (flat, versioned) + optional ``~/.cursor/mcp.json``.
|
|
2
|
+
|
|
3
|
+
Two ways to disable *every* hook in that file, both avoided here: an unknown
|
|
4
|
+
event key, and any ``//`` inside a string (Cursor strips comments naively).
|
|
5
|
+
The path is hard-coded to the real home directory by Cursor
|
|
6
|
+
(``CURSOR_CONFIG_DIR`` does not move it).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from .base import InstallError, Installer, Target, add_flat, json_target, mcp_json_target, remove_flat
|
|
16
|
+
|
|
17
|
+
EVENTS = {"beforeSubmitPrompt": 5, "preCompact": 10}
|
|
18
|
+
CONVERSATION = "5b0d6c1e-8a4f-4f1e-9d8e-2b7f1c3a9e10"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CursorInstaller(Installer):
|
|
22
|
+
name = "cursor"
|
|
23
|
+
display_name = "Cursor CLI"
|
|
24
|
+
seam = "hooks"
|
|
25
|
+
binaries = ("agent", "cursor-agent")
|
|
26
|
+
docs = "https://cursor.com/docs/hooks"
|
|
27
|
+
supports_mcp = True
|
|
28
|
+
post_install = "prompt hints fire in interactive sessions (Cursor skips beforeSubmitPrompt in -p mode)"
|
|
29
|
+
|
|
30
|
+
def cursor_dir(self) -> Path:
|
|
31
|
+
return Path.home() / ".cursor"
|
|
32
|
+
|
|
33
|
+
def _entry(self, event: str) -> Dict[str, Any]:
|
|
34
|
+
command = self.command(event)
|
|
35
|
+
if "//" in command:
|
|
36
|
+
raise InstallError("the hook command contains '//', which Cursor's hooks.json parser "
|
|
37
|
+
f"would treat as a comment: {command}")
|
|
38
|
+
return {"command": command, "timeout": EVENTS[event]}
|
|
39
|
+
|
|
40
|
+
def targets(self) -> List[Target]:
|
|
41
|
+
def add(data: Dict[str, Any]) -> None:
|
|
42
|
+
data.setdefault("version", 1)
|
|
43
|
+
table = data.get("hooks")
|
|
44
|
+
if not isinstance(table, dict):
|
|
45
|
+
table = data["hooks"] = {}
|
|
46
|
+
for event in EVENTS:
|
|
47
|
+
add_flat(table, event, self._entry(event))
|
|
48
|
+
|
|
49
|
+
def remove(data: Dict[str, Any]) -> None:
|
|
50
|
+
table = data.get("hooks")
|
|
51
|
+
if isinstance(table, dict):
|
|
52
|
+
remove_flat(table)
|
|
53
|
+
if data == {"version": 1, "hooks": {}}:
|
|
54
|
+
data.clear() # only ever held our entries
|
|
55
|
+
|
|
56
|
+
def installed(data: Dict[str, Any]) -> bool:
|
|
57
|
+
table = data.get("hooks")
|
|
58
|
+
return isinstance(table, dict) and bool(remove_flat(json.loads(json.dumps(table))))
|
|
59
|
+
|
|
60
|
+
targets = [json_target(self.cursor_dir() / "hooks.json", add, remove, installed)]
|
|
61
|
+
if self.mcp:
|
|
62
|
+
targets.append(mcp_json_target(self.cursor_dir() / "mcp.json", self.mcp_command(),
|
|
63
|
+
extra={"type": "stdio", "env": {}}))
|
|
64
|
+
return targets
|
|
65
|
+
|
|
66
|
+
def hook_events(self) -> List[str]:
|
|
67
|
+
return list(EVENTS)
|
|
68
|
+
|
|
69
|
+
def sample_payload(self, event: str) -> Dict[str, Any]:
|
|
70
|
+
common = {"conversation_id": CONVERSATION, "generation_id": "f2a9c0d4", "model": "composer-2.5",
|
|
71
|
+
"session_id": CONVERSATION, "hook_event_name": event,
|
|
72
|
+
"cursor_version": "2026.09.18-9a7762b", "workspace_roots": ["/tmp"],
|
|
73
|
+
"transcript_path": "{transcript}"}
|
|
74
|
+
if event == "beforeSubmitPrompt":
|
|
75
|
+
return {**common, "prompt": "what does ls -la do?", "attachments": []}
|
|
76
|
+
return {**common, "trigger": "auto", "context_usage_percent": 91, "message_count": 146}
|
|
77
|
+
|
|
78
|
+
def expects_output(self, event: str) -> bool:
|
|
79
|
+
return event == "beforeSubmitPrompt"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Gemini CLI (``~/.gemini/settings.json``) and Qwen Code (``~/.qwen/settings.json``).
|
|
2
|
+
|
|
3
|
+
Entries are tagged ``name: "subcortex:<Event>"`` (Gemini's dedupe key and the
|
|
4
|
+
``/hooks disable`` handle) plus ``description: "managed-by=subcortex"``.
|
|
5
|
+
Timeouts are milliseconds in Gemini; Qwen reads any value >= 1000 as ms on
|
|
6
|
+
every version, so both get ms.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from .base import Target, mcp_json_target
|
|
16
|
+
from .claude_family import ClaudeStyleInstaller
|
|
17
|
+
|
|
18
|
+
TIMEOUT_MS = 10000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _GeminiStyleInstaller(ClaudeStyleInstaller):
|
|
22
|
+
supports_mcp = True
|
|
23
|
+
|
|
24
|
+
def entry(self, event: str) -> Dict[str, Any]:
|
|
25
|
+
return {"type": "command", "name": f"subcortex:{event}",
|
|
26
|
+
"description": "managed-by=subcortex",
|
|
27
|
+
"command": self.command(event), "timeout": TIMEOUT_MS}
|
|
28
|
+
|
|
29
|
+
def mcp_target(self) -> Optional[Target]:
|
|
30
|
+
return mcp_json_target(self.settings_path(), self.mcp_command(),
|
|
31
|
+
extra={"description": "managed-by=subcortex"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GeminiCliInstaller(_GeminiStyleInstaller):
|
|
35
|
+
name = "gemini-cli"
|
|
36
|
+
display_name = "Gemini CLI"
|
|
37
|
+
binaries = ("gemini",)
|
|
38
|
+
docs = "https://geminicli.com/docs/hooks/"
|
|
39
|
+
min_version = "0.27.0"
|
|
40
|
+
post_install = ("restart gemini; hooks (even user-level ones) only run in trusted folders, "
|
|
41
|
+
"and the compaction snapshot is taken on /compress")
|
|
42
|
+
|
|
43
|
+
def settings_path(self) -> Path:
|
|
44
|
+
home = os.environ.get("GEMINI_CLI_HOME", "").strip()
|
|
45
|
+
return (Path(home) if home else Path.home()) / ".gemini" / "settings.json"
|
|
46
|
+
|
|
47
|
+
def matcher(self, event: str) -> Optional[str]:
|
|
48
|
+
# PreCompress fires before *every* model request; lifecycle matchers are
|
|
49
|
+
# exact strings, so this runs us only for a real /compress.
|
|
50
|
+
return "manual" if event == "PreCompress" else None
|
|
51
|
+
|
|
52
|
+
def sample_payload(self, event: str) -> Dict[str, Any]:
|
|
53
|
+
common = {"session_id": "3f0c2a7e-5b1d-4c8e-9a41-2d7e6b0f9c13", "transcript_path": "{transcript}",
|
|
54
|
+
"cwd": "/tmp", "hook_event_name": event, "timestamp": "2026-09-21T14:02:11.512Z"}
|
|
55
|
+
if event == "BeforeAgent":
|
|
56
|
+
return {**common, "prompt": "what does ls -la do?"}
|
|
57
|
+
return {**common, "trigger": "manual"}
|
|
58
|
+
|
|
59
|
+
def expects_output(self, event: str) -> bool:
|
|
60
|
+
return event == "BeforeAgent"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class QwenCodeInstaller(_GeminiStyleInstaller):
|
|
64
|
+
name = "qwen-code"
|
|
65
|
+
display_name = "Qwen Code"
|
|
66
|
+
binaries = ("qwen",)
|
|
67
|
+
docs = "https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/"
|
|
68
|
+
min_version = "0.16.0"
|
|
69
|
+
post_install = "restart qwen (or open /hooks to reload)"
|
|
70
|
+
|
|
71
|
+
def settings_path(self) -> Path:
|
|
72
|
+
home = os.environ.get("QWEN_HOME", "").strip()
|
|
73
|
+
return (Path(home) if home else Path.home() / ".qwen") / "settings.json"
|
|
74
|
+
|
|
75
|
+
def matcher(self, event: str) -> Optional[str]:
|
|
76
|
+
return "^compact$" if event == "SessionStart" else None
|
|
77
|
+
|
|
78
|
+
def sample_payload(self, event: str) -> Dict[str, Any]:
|
|
79
|
+
payload = super().sample_payload(event)
|
|
80
|
+
payload["timestamp"] = "2026-09-21T14:02:11.512Z"
|
|
81
|
+
if event == "UserPromptSubmit":
|
|
82
|
+
payload["submitted_prompt"] = payload["prompt"]
|
|
83
|
+
return payload
|