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.
Files changed (64) hide show
  1. subcortex/__init__.py +3 -0
  2. subcortex/__main__.py +3 -0
  3. subcortex/adapters/__init__.py +48 -0
  4. subcortex/adapters/base.py +230 -0
  5. subcortex/adapters/claude_family.py +133 -0
  6. subcortex/adapters/codex.py +87 -0
  7. subcortex/adapters/copilot.py +60 -0
  8. subcortex/adapters/cursor.py +36 -0
  9. subcortex/adapters/docker_agent.py +115 -0
  10. subcortex/adapters/gemini_family.py +60 -0
  11. subcortex/adapters/grok.py +98 -0
  12. subcortex/adapters/kimi_code.py +138 -0
  13. subcortex/adapters/letta_vibe.py +96 -0
  14. subcortex/adapters/openhands.py +153 -0
  15. subcortex/auth.py +59 -0
  16. subcortex/backends/__init__.py +23 -0
  17. subcortex/backends/base.py +22 -0
  18. subcortex/backends/jev.py +460 -0
  19. subcortex/backends/laya.py +149 -0
  20. subcortex/cli.py +809 -0
  21. subcortex/client.py +77 -0
  22. subcortex/config.py +263 -0
  23. subcortex/daemon.py +502 -0
  24. subcortex/evalset.py +241 -0
  25. subcortex/hook.py +254 -0
  26. subcortex/installers/__init__.py +62 -0
  27. subcortex/installers/amp.py +39 -0
  28. subcortex/installers/base.py +874 -0
  29. subcortex/installers/claude_family.py +229 -0
  30. subcortex/installers/codex.py +110 -0
  31. subcortex/installers/copilot.py +65 -0
  32. subcortex/installers/crush.py +36 -0
  33. subcortex/installers/cursor.py +79 -0
  34. subcortex/installers/gemini_family.py +83 -0
  35. subcortex/installers/goose.py +186 -0
  36. subcortex/installers/kimi_code.py +71 -0
  37. subcortex/installers/mcp_only.py +111 -0
  38. subcortex/installers/more_hooks.py +184 -0
  39. subcortex/installers/opencode.py +66 -0
  40. subcortex/installers/openhands.py +84 -0
  41. subcortex/installers/pi_cline.py +53 -0
  42. subcortex/ledger.py +92 -0
  43. subcortex/localhttp.py +59 -0
  44. subcortex/mcp_server.py +187 -0
  45. subcortex/metrics.py +56 -0
  46. subcortex/plugins/amp/subcortex.ts +258 -0
  47. subcortex/plugins/cline/subcortex.ts +340 -0
  48. subcortex/plugins/opencode/subcortex.ts +265 -0
  49. subcortex/plugins/pi/subcortex.ts +292 -0
  50. subcortex/policy.py +341 -0
  51. subcortex/presets.py +163 -0
  52. subcortex/provision.py +188 -0
  53. subcortex/service.py +149 -0
  54. subcortex/state.py +137 -0
  55. subcortex/transcript.py +211 -0
  56. subcortex/tuis.py +51 -0
  57. subcortex/ui.py +319 -0
  58. subcortex/verdicts.py +233 -0
  59. subcortex/wizard.py +474 -0
  60. subcortex-0.3.0.dist-info/METADATA +287 -0
  61. subcortex-0.3.0.dist-info/RECORD +64 -0
  62. subcortex-0.3.0.dist-info/WHEEL +5 -0
  63. subcortex-0.3.0.dist-info/entry_points.txt +3 -0
  64. 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