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,186 @@
1
+ """Goose: register ``subcortex mcp`` as a stdio extension in ``config.yaml``.
2
+
3
+ Goose (>= v1.34) hooks are observation-only for everything but PreToolUse/Stop
4
+ (which can block, so we never register them), PostToolUse payloads carry no
5
+ output, and there is no compaction event — so Goose's seam is an MCP
6
+ extension. The stdlib has no YAML writer, so the entry is inserted line-wise
7
+ under the top-level ``extensions:`` mapping between marker comments, and
8
+ removed the same way; when PyYAML is importable the result is also parsed and
9
+ checked. Anything we can't edit unambiguously is refused.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ from pathlib import Path
18
+ from typing import List, Optional, Tuple
19
+
20
+ from .base import InstallError, Installer, Target
21
+
22
+ BEGIN = "# >>> subcortex (managed; remove with: subcortex uninstall goose)"
23
+ END = "# <<< subcortex"
24
+ _TOP_KEY = re.compile(r"^extensions:\s*(?P<rest>.*?)\s*(#.*)?$")
25
+
26
+
27
+ def goose_config() -> Path:
28
+ root = os.environ.get("GOOSE_PATH_ROOT", "").strip()
29
+ if root and os.path.isabs(root):
30
+ return Path(root) / "config" / "config.yaml"
31
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
32
+ return (Path(xdg) if xdg else Path.home() / ".config") / "goose" / "config.yaml"
33
+
34
+
35
+ def _entry_lines(argv: List[str], indent: str) -> List[str]:
36
+ q = json.dumps # JSON strings are valid YAML flow scalars
37
+ inner = indent * 2
38
+ return [
39
+ f"{indent}{BEGIN}",
40
+ f"{indent}subcortex:",
41
+ f"{inner}enabled: true",
42
+ f"{inner}type: stdio",
43
+ f"{inner}name: subcortex",
44
+ f"{inner}description: {q('subcortex: local millisecond decisions (prompt triage, output judging)')}",
45
+ f"{inner}cmd: {q(argv[0])}",
46
+ f"{inner}args: [{', '.join(q(a) for a in argv[1:])}]",
47
+ f"{inner}envs: {{}}",
48
+ f"{inner}timeout: 300",
49
+ f"{inner}bundled: false",
50
+ f"{indent}{END}",
51
+ ]
52
+
53
+
54
+ def _unmarked_entry(text: str) -> Optional[Tuple[int, int]]:
55
+ """Line range of an ``extensions.subcortex`` entry without our markers.
56
+
57
+ Goose rewrites config.yaml with serde_yaml (plugin discovery, ``goose
58
+ configure``, toggling an extension), which drops comments, so an entry we
59
+ wrote can outlive its markers. A second ``subcortex:`` key next to it would
60
+ make the file invalid for Goose, which then starts from an empty config.
61
+ """
62
+ lines = text.splitlines()
63
+ in_extensions = False
64
+ for i, line in enumerate(lines):
65
+ if line.strip() and not line[0].isspace() and not line.startswith("#"):
66
+ in_extensions = line.split(":", 1)[0].strip() == "extensions"
67
+ continue
68
+ match = re.match(r"^([ \t]+)subcortex:\s*(#.*)?$", line)
69
+ if in_extensions and match:
70
+ indent = len(match.group(1))
71
+ end = i + 1
72
+ while end < len(lines) and (not lines[end].strip()
73
+ or len(lines[end]) - len(lines[end].lstrip()) > indent):
74
+ end += 1
75
+ # Ours if it runs subcortex: in `cmd`, an inline `args: [...]`, or a
76
+ # block list item (serde_yaml writes `args:` then `- subcortex`).
77
+ if any(re.match(r"^\s+(?:cmd|args):.*\bsubcortex\b", l)
78
+ or re.match(r"""^\s+-\s+['"]?subcortex['"]?\s*$""", l) for l in lines[i + 1:end]):
79
+ return i, end
80
+ return None
81
+
82
+
83
+ def strip_entry(text: str) -> str:
84
+ unmarked = _unmarked_entry(text) if "# >>> subcortex" not in text else None
85
+ if unmarked:
86
+ lines = text.splitlines(keepends=True)
87
+ text = "".join(lines[:unmarked[0]] + lines[unmarked[1]:])
88
+ lines_in = text.splitlines()
89
+ begins = [i for i, l in enumerate(lines_in) if l.strip().startswith("# >>> subcortex")]
90
+ if begins and not any(l.strip().startswith(END) for l in lines_in[begins[0] + 1:]):
91
+ # Removing "to the end of the file" could delete the user's own settings.
92
+ raise InstallError(f"the subcortex block in the goose config has no end marker ({END}); "
93
+ "remove it by hand, then retry")
94
+ out, inside = [], False
95
+ for line in text.splitlines(keepends=True):
96
+ stripped = line.strip()
97
+ if stripped.startswith("# >>> subcortex"):
98
+ inside = True
99
+ continue
100
+ if inside and stripped.startswith(END):
101
+ inside = False
102
+ continue
103
+ if not inside:
104
+ out.append(line)
105
+ lines = out
106
+ # An `extensions:` key we left without children becomes an empty mapping.
107
+ for i, line in enumerate(lines):
108
+ if line.rstrip("\n") == "extensions:":
109
+ nxt = next((l for l in lines[i + 1:] if l.strip() and not l.lstrip().startswith("#")), "")
110
+ if not nxt.startswith((" ", "\t")):
111
+ lines[i] = "extensions: {}\n"
112
+ return "".join(lines)
113
+
114
+
115
+ def insert_entry(text: str, argv: List[str]) -> str:
116
+ text = strip_entry(text)
117
+ lines = text.splitlines(keepends=True)
118
+ if lines and not lines[-1].endswith("\n"):
119
+ lines[-1] += "\n"
120
+ for i, line in enumerate(lines):
121
+ match = _TOP_KEY.match(line.rstrip("\n"))
122
+ if not match:
123
+ continue
124
+ rest = match.group("rest")
125
+ if rest in ("{}", "null", "~", ""):
126
+ indent = " "
127
+ for later in lines[i + 1:]:
128
+ if later.strip() and not later.lstrip().startswith("#"):
129
+ lead = later[:len(later) - len(later.lstrip())]
130
+ if lead and rest == "":
131
+ indent = lead
132
+ break
133
+ new = ["extensions:\n"] + [l + "\n" for l in _entry_lines(argv, indent)]
134
+ return "".join(lines[:i] + new + lines[i + 1:])
135
+ raise InstallError(f"{goose_config()}: `extensions:` uses inline syntax ({rest!r}); "
136
+ "add the entry shown by --dry-run by hand")
137
+ # No `extensions:` key yet: the key itself goes inside our markers so
138
+ # uninstall restores the original bytes.
139
+ entry = _entry_lines(argv, " ")
140
+ block = [BEGIN + "\n", "extensions:\n"] + [l + "\n" for l in entry[1:-1]] + [END + "\n"]
141
+ return "".join(lines + block)
142
+
143
+
144
+ def _validate(text: str, expect_ours: bool) -> str:
145
+ try:
146
+ import yaml # type: ignore
147
+ except ImportError:
148
+ return text
149
+ try:
150
+ data = yaml.safe_load(text) or {}
151
+ except yaml.YAMLError as exc:
152
+ raise InstallError(f"{goose_config()}: result would not be valid YAML ({exc})") from exc
153
+ has = isinstance(data, dict) and isinstance(data.get("extensions"), dict) \
154
+ and "subcortex" in data["extensions"]
155
+ if has != expect_ours:
156
+ raise InstallError(f"{goose_config()}: could not edit `extensions` unambiguously; "
157
+ "add the entry shown by --dry-run by hand")
158
+ return text
159
+
160
+
161
+ class GooseInstaller(Installer):
162
+ name = "goose"
163
+ display_name = "Goose"
164
+ seam = "mcp"
165
+ binaries = ("goose",)
166
+ docs = "https://block.github.io/goose/docs/getting-started/using-extensions"
167
+ min_version = "1.34.0"
168
+ post_install = "applies to new goose sessions (tools appear as subcortex__*)"
169
+
170
+ def targets(self) -> List[Target]:
171
+ argv = self.mcp_command()
172
+
173
+ def merge(text: str) -> str:
174
+ if text.strip():
175
+ _validate(strip_entry(text), expect_ours=False)
176
+ return _validate(insert_entry(text, argv), expect_ours=True)
177
+
178
+ def unmerge(text: str) -> str:
179
+ if not installed(text):
180
+ return text
181
+ return _validate(strip_entry(text), expect_ours=False)
182
+
183
+ def installed(text: str) -> bool:
184
+ return "# >>> subcortex" in text or _unmarked_entry(text) is not None
185
+
186
+ return [Target(goose_config(), merge, unmerge, installed)]
@@ -0,0 +1,71 @@
1
+ """Kimi Code CLI: ``[[hooks]]`` in ``$KIMI_CODE_HOME/config.toml`` (+ optional ``mcp.json``).
2
+
3
+ Kimi validates hook entries with a strict schema — one unknown key or bad
4
+ event name silently drops the *whole* hooks section, the user's own hooks
5
+ included — so each entry carries exactly ``event``/``command``/``timeout``.
6
+ Hooks load at startup: restart kimi after installing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from ..adapters.kimi_code import kimi_home
14
+ from .base import Installer, Target, mcp_json_target, toml_block_target, toml_str
15
+
16
+ EVENTS = {"UserPromptSubmit": 5, "PreCompact": 10, "PostCompact": 5}
17
+
18
+ SESSION = "session_4f0c2a8e-9b1d-4c3e-8a77-2f5d0e6b1c90"
19
+
20
+
21
+ class KimiCodeInstaller(Installer):
22
+ name = "kimi-code"
23
+ display_name = "Kimi Code CLI"
24
+ seam = "hooks"
25
+ binaries = ("kimi",)
26
+ docs = "https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html"
27
+ min_version = "0.33.0"
28
+ supports_mcp = True
29
+ post_install = "restart kimi so it loads the new hooks"
30
+
31
+ def version_problem(self, version_output: str) -> Optional[str]:
32
+ # "kimi-cli <v>" is the dist-info of the legacy Python CLI (installed_version).
33
+ if version_output.strip().lower().replace("_", "-").startswith(("kimi, version", "kimi-cli ")):
34
+ return ("this `kimi` is the legacy Python kimi-cli (~/.kimi), not Kimi Code CLI; "
35
+ "install @moonshot-ai/kimi-code")
36
+ return super().version_problem(version_output)
37
+
38
+ def _body(self) -> str:
39
+ tables = []
40
+ for event, timeout in EVENTS.items():
41
+ tables.append("\n".join([
42
+ "[[hooks]]",
43
+ f"event = {toml_str(event)}",
44
+ f"command = {toml_str(self.command(event))}",
45
+ f"timeout = {timeout}",
46
+ ]))
47
+ return "\n\n".join(tables) + "\n"
48
+
49
+ def targets(self) -> List[Target]:
50
+ home = kimi_home()
51
+ targets = [toml_block_target(home / "config.toml", self._body(), self.name)]
52
+ if self.mcp:
53
+ targets.append(mcp_json_target(home / "mcp.json", self.mcp_command(),
54
+ extra={"startupTimeoutMs": 10000}))
55
+ return targets
56
+
57
+ def hook_events(self) -> List[str]:
58
+ return list(EVENTS)
59
+
60
+ def sample_payload(self, event: str) -> Dict[str, Any]:
61
+ common = {"hook_event_name": event, "session_id": SESSION, "cwd": "/tmp",
62
+ "client_type": "kimi_code_cli"}
63
+ if event == "UserPromptSubmit":
64
+ return {**common, "prompt": [{"type": "text", "text": "what does ls -la do?"}],
65
+ "is_steer": False}
66
+ if event == "PreCompact":
67
+ return {**common, "trigger": "auto", "token_count": 241337}
68
+ return {**common, "trigger": "auto", "estimated_token_count": 18422}
69
+
70
+ def expects_output(self, event: str) -> bool:
71
+ return event == "UserPromptSubmit"
@@ -0,0 +1,111 @@
1
+ """MCP-only integrations: TUIs whose extension seam is an MCP server entry.
2
+
3
+ None of these offers a hook that can inject context or replace tool output,
4
+ so subcortex registers ``subcortex mcp`` (on-demand decision tools). Every
5
+ target file is plain JSON; files with comments are refused, never rewritten.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List
14
+
15
+ from .base import Installer, Target, json_target, mcp_json_target
16
+
17
+
18
+ class _McpInstaller(Installer):
19
+ seam = "mcp"
20
+ extra: Dict[str, Any] = {}
21
+
22
+ def mcp_path(self) -> Path:
23
+ raise NotImplementedError
24
+
25
+ def targets(self) -> List[Target]:
26
+ return [mcp_json_target(self.mcp_path(), self.mcp_command(), extra=dict(self.extra))]
27
+
28
+
29
+ class WarpInstaller(_McpInstaller):
30
+ name = "warp"
31
+ display_name = "Warp"
32
+ binaries = ("warp", "warp-terminal")
33
+ docs = "https://docs.warp.dev/agents/capabilities/mcp"
34
+ post_install = "global MCP servers start automatically in Warp's agent"
35
+
36
+ def mcp_path(self) -> Path:
37
+ return Path.home() / ".warp" / ".mcp.json"
38
+
39
+
40
+ class AuggieInstaller(_McpInstaller):
41
+ name = "auggie"
42
+ display_name = "Augment Auggie CLI"
43
+ binaries = ("auggie",)
44
+ docs = "https://docs.augmentcode.com/cli/integrations"
45
+
46
+ def mcp_path(self) -> Path:
47
+ return Path.home() / ".augment" / "settings.json"
48
+
49
+
50
+ def cline_mcp_path() -> Path:
51
+ explicit = os.environ.get("CLINE_MCP_SETTINGS_PATH", "").strip()
52
+ if explicit:
53
+ return Path(explicit)
54
+ data = os.environ.get("CLINE_DATA_DIR", "").strip()
55
+ if data:
56
+ return Path(data) / "settings" / "cline_mcp_settings.json"
57
+ return cline_dir() / "data" / "settings" / "cline_mcp_settings.json"
58
+
59
+
60
+ def cline_dir() -> Path:
61
+ base = os.environ.get("CLINE_DIR", "").strip()
62
+ return Path(base) if base else Path.home() / ".cline"
63
+
64
+
65
+ class KiroInstaller(_McpInstaller):
66
+ name = "kiro"
67
+ display_name = "Kiro CLI"
68
+ binaries = ("kiro-cli",)
69
+ docs = "https://kiro.dev/docs/mcp/configuration"
70
+ extra = {"disabled": False}
71
+ post_install = "custom Kiro agents only see it with \"includeMcpJson\": true"
72
+
73
+ def mcp_path(self) -> Path:
74
+ home = os.environ.get("KIRO_HOME", "").strip()
75
+ return (Path(home) if home else Path.home() / ".kiro") / "settings" / "mcp.json"
76
+
77
+
78
+ class ZedInstaller(Installer):
79
+ """Zed's settings.json is JSONC; its sibling global_settings.json (merged
80
+ under user settings, never written by Zed) holds our entry instead."""
81
+
82
+ name = "zed"
83
+ display_name = "Zed (agent panel)"
84
+ seam = "mcp"
85
+ binaries = ("zed",)
86
+ docs = "https://zed.dev/docs/ai/mcp"
87
+ post_install = "Zed asks for confirmation the first time a subcortex tool runs"
88
+
89
+ def config_dir(self) -> Path:
90
+ if sys.platform.startswith("linux"):
91
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
92
+ return (Path(xdg) if xdg else Path.home() / ".config") / "zed"
93
+ return Path.home() / ".config" / "zed"
94
+
95
+ def targets(self) -> List[Target]:
96
+ argv = self.mcp_command()
97
+
98
+ def add(data: Dict[str, Any]) -> None:
99
+ servers = data.get("context_servers")
100
+ if not isinstance(servers, dict):
101
+ servers = data["context_servers"] = {}
102
+ servers["subcortex"] = {"command": argv[0], "args": argv[1:], "env": {}}
103
+
104
+ def remove(data: Dict[str, Any]) -> None:
105
+ servers = data.get("context_servers")
106
+ if isinstance(servers, dict) and servers.pop("subcortex", None) is not None and not servers:
107
+ del data["context_servers"]
108
+
109
+ return [json_target(self.config_dir() / "global_settings.json", add, remove,
110
+ lambda d: isinstance(d.get("context_servers"), dict)
111
+ and "subcortex" in d["context_servers"])]
@@ -0,0 +1,184 @@
1
+ """Installers for Grok Build, Docker Agent, Letta Code and Mistral Vibe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from ..adapters.docker_agent import config_dir as docker_config_dir
11
+ from ..adapters.grok import grok_home
12
+ from .base import Installer, Target, owned_file_target, toml_block_target, toml_str
13
+ from .claude_family import SESSION, ClaudeStyleInstaller
14
+
15
+
16
+ class GrokBuildInstaller(Installer):
17
+ """``$GROK_HOME/hooks/subcortex.json`` — a Claude-format hooks file we own."""
18
+
19
+ name = "grok-build"
20
+ display_name = "Grok Build"
21
+ seam = "hooks"
22
+ binaries = ("grok",)
23
+ docs = "https://x.ai/cli"
24
+ supports_mcp = True
25
+ post_install = "start a new grok session (or /hooks → r) to load the hooks"
26
+ # UserPromptSubmit output is discarded by Grok; the hook only records the
27
+ # request, which the output judge needs as evidence.
28
+ EVENTS = {"UserPromptSubmit": (None, 5), "PostToolUse": ("Bash|bash", 10),
29
+ "PreCompact": (None, 10), "PostCompact": (None, 5)}
30
+
31
+ def _content(self) -> str:
32
+ hooks: Dict[str, Any] = {}
33
+ for event, (matcher, timeout) in self.EVENTS.items():
34
+ group: Dict[str, Any] = {"matcher": matcher} if matcher else {}
35
+ group["hooks"] = [{"type": "command", "command": self.command(event), "timeout": timeout}]
36
+ hooks[event] = [group]
37
+ return json.dumps({"_subcortex": {"managed": True, "schema": 1}, "hooks": hooks}, indent=2) + "\n"
38
+
39
+ def targets(self) -> List[Target]:
40
+ targets = [owned_file_target(grok_home() / "hooks" / "subcortex.json", self._content())]
41
+ if self.mcp:
42
+ argv = self.mcp_command()
43
+ body = "\n".join(["[mcp_servers.subcortex]", f"command = {toml_str(argv[0])}",
44
+ f"args = [{', '.join(toml_str(a) for a in argv[1:])}]", "enabled = true"]) + "\n"
45
+ targets.append(toml_block_target(grok_home() / "config.toml", body, self.name))
46
+ return targets
47
+
48
+ def hook_events(self) -> List[str]:
49
+ return list(self.EVENTS)
50
+
51
+ def sample_payload(self, event: str) -> Dict[str, Any]:
52
+ common = {"hookEventName": event.lower(), "sessionId": SESSION, "cwd": "/tmp", "workspaceRoot": "/tmp",
53
+ "transcriptPath": "{transcript}", "permissionMode": "default", "hook_event_name": event}
54
+ if event == "UserPromptSubmit":
55
+ return {**common, "hookEventName": "user_prompt_submit", "promptId": "p1",
56
+ "prompt": "why does make build take so long?"}
57
+ if event == "PostToolUse":
58
+ return {**common, "toolName": "run_terminal_command", "toolUseId": "call_1",
59
+ "toolInput": {"command": "make build"}, "toolInputTruncated": False,
60
+ "toolResultTruncated": False,
61
+ "toolResult": {"type": "Bash", "output": [], "output_for_prompt": "{big_output}",
62
+ "exit_code": 0, "command": "make build", "truncated": False, "signal": None,
63
+ "timed_out": False, "current_dir": "/tmp", "output_file": "", "total_bytes": 0}}
64
+ return {**common, "source": "manual"}
65
+
66
+ def expects_output(self, event: str) -> bool:
67
+ return event == "PostToolUse"
68
+
69
+
70
+ class DockerAgentInstaller(Installer):
71
+ """``<config dir>/hooks.d/50-subcortex.yaml`` — a drop-in we own."""
72
+
73
+ name = "docker-agent"
74
+ display_name = "Docker Agent"
75
+ seam = "hooks"
76
+ binaries = ("docker-agent", "cagent")
77
+ docs = "https://github.com/docker/docker-agent/blob/main/docs/configuration/hooks/index.md"
78
+ min_version = "1.137.0"
79
+ post_install = "applies to new `docker-agent run` sessions"
80
+ EVENTS = {"user_prompt_submit": 5, "user_steering_messages_submit": 5, "user_followup_submit": 5,
81
+ "tool_response_transform": 10, "before_compaction": 10, "after_compaction": 5}
82
+
83
+ def _content(self) -> str:
84
+ q = json.dumps # JSON strings are valid YAML double-quoted scalars
85
+ lines = ["# Managed by subcortex (subcortex install docker-agent). Do not edit.",
86
+ "# Uninstall: subcortex uninstall docker-agent"]
87
+ for event, timeout in self.EVENTS.items():
88
+ hook = [f"name: {q('subcortex:' + event)}", "type: command",
89
+ f"command: {q(self.command(event))}", f"timeout: {timeout}", "on_error: ignore"]
90
+ lines.append(f"{event}:")
91
+ if event == "tool_response_transform":
92
+ lines += [' - matcher: "shell"', " hooks:", f" - {hook[0]}"]
93
+ lines += [f" {h}" for h in hook[1:]]
94
+ else:
95
+ lines.append(f" - {hook[0]}")
96
+ lines += [f" {h}" for h in hook[1:]]
97
+ return "\n".join(lines) + "\n"
98
+
99
+ def targets(self) -> List[Target]:
100
+ return [owned_file_target(docker_config_dir() / "hooks.d" / "50-subcortex.yaml", self._content())]
101
+
102
+ def hook_events(self) -> List[str]:
103
+ return list(self.EVENTS)
104
+
105
+ def sample_payload(self, event: str) -> Dict[str, Any]:
106
+ common = {"session_id": SESSION, "cwd": "/tmp", "hook_event_name": event, "agent_name": "root"}
107
+ if event == "user_prompt_submit":
108
+ return {**common, "prompt": "what does ls -la do?"}
109
+ if event == "user_steering_messages_submit":
110
+ return {**common, "steering_messages": ["also, what does ls -a do?"]}
111
+ if event == "user_followup_submit":
112
+ return {**common, "prompt": "and ls -l?"}
113
+ if event == "tool_response_transform":
114
+ return {**common, "tool_category": "shell", "tool_name": "shell", "tool_use_id": "call_1",
115
+ "tool_input": {"cmd": "make build"}, "tool_response": "{big_output}"}
116
+ return {**common, "compaction_reason": "manual", "input_tokens": 90000}
117
+
118
+ def expects_output(self, event: str) -> bool:
119
+ return event in ("user_prompt_submit", "user_steering_messages_submit", "user_followup_submit",
120
+ "tool_response_transform")
121
+
122
+
123
+ class LettaInstaller(ClaudeStyleInstaller):
124
+ """``~/.letta/settings.json`` → ``hooks`` (timeouts in ms, ``quiet`` so hints aren't echoed)."""
125
+
126
+ name = "letta"
127
+ display_name = "Letta Code"
128
+ binaries = ("letta",)
129
+ docs = "https://docs.letta.com/letta-code/hooks"
130
+ post_install = ("restart letta (and don't edit hooks via /hooks in a session started before this "
131
+ "install); hints apply to interactive sessions (letta -p skips the prompt hook)")
132
+
133
+ def settings_path(self) -> Path:
134
+ return Path.home() / ".letta" / "settings.json"
135
+
136
+ def entry(self, event: str) -> Dict[str, Any]:
137
+ return {"type": "command", "command": self.command(event), "timeout": 5000, "quiet": True}
138
+
139
+ def matcher(self, event: str) -> Optional[str]:
140
+ return None
141
+
142
+ def sample_payload(self, event: str) -> Dict[str, Any]:
143
+ return {"event_type": "UserPromptSubmit", "working_directory": "/tmp", "prompt": "what does ls -la do?",
144
+ "is_command": False, "agent_id": "agent-1", "conversation_id": "conv-1"}
145
+
146
+
147
+ class VibeInstaller(Installer):
148
+ """A marked ``[[hooks]]`` block in ``$VIBE_HOME/hooks.toml`` (Vibe never rewrites that file)."""
149
+
150
+ name = "vibe"
151
+ display_name = "Mistral Vibe"
152
+ seam = "hooks"
153
+ binaries = ("vibe",)
154
+ docs = "https://github.com/mistralai/mistral-vibe#hooks"
155
+ min_version = "2.25.5"
156
+ post_install = "restart vibe to load the hook"
157
+
158
+ def vibe_home(self) -> Path:
159
+ override = os.environ.get("VIBE_HOME", "").strip()
160
+ return Path(override).expanduser() if override else Path.home() / ".vibe"
161
+
162
+ def targets(self) -> List[Target]:
163
+ body = "\n".join([
164
+ "[[hooks]]",
165
+ 'name = "subcortex-post-tool"',
166
+ 'type = "post_tool"',
167
+ 'match = "bash"',
168
+ f"command = {toml_str(self.command('post_tool'))}",
169
+ "timeout = 10.0",
170
+ 'description = "subcortex: trim large, disposable shell output"',
171
+ ]) + "\n"
172
+ return [toml_block_target(self.vibe_home() / "hooks.toml", body, self.name)]
173
+
174
+ def hook_events(self) -> List[str]:
175
+ return ["post_tool"]
176
+
177
+ def sample_payload(self, event: str) -> Dict[str, Any]:
178
+ return {"session_id": SESSION, "transcript_path": "{transcript}", "cwd": "/tmp",
179
+ "hook_event_name": "post_tool", "tool_name": "bash", "tool_call_id": "call_1",
180
+ "tool_input": {"command": "make build"}, "tool_status": "success",
181
+ "tool_output_text": "{big_output}", "tool_error": None}
182
+
183
+ def expects_output(self, event: str) -> bool:
184
+ return True
@@ -0,0 +1,66 @@
1
+ """OpenCode and Kilo Code CLI: one Bun plugin file (+ optional MCP entry for OpenCode).
2
+
3
+ Both load every ``*.ts`` in their global plugin directory at startup with no
4
+ trust step; the file name is the tag, so uninstall deletes exactly our file.
5
+ Kilo is an OpenCode fork with its own directories (it doesn't read OpenCode's).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List
13
+
14
+ from .base import Installer, Target, rendered_plugin, json_target, plugin_file_target
15
+
16
+
17
+ def _config_home() -> Path:
18
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
19
+ return Path(xdg) if xdg else Path.home() / ".config"
20
+
21
+
22
+ class OpenCodeInstaller(Installer):
23
+ name = "opencode"
24
+ display_name = "OpenCode"
25
+ seam = "plugin"
26
+ binaries = ("opencode",)
27
+ docs = "https://opencode.ai/docs/plugins/"
28
+ min_version = "1.1.62"
29
+ supports_mcp = True
30
+ post_install = "restart opencode to load the plugin"
31
+ plugin_dir = ("opencode", "plugins")
32
+
33
+ def plugin_path(self) -> Path:
34
+ return _config_home().joinpath(*self.plugin_dir) / "subcortex.ts"
35
+
36
+ def targets(self) -> List[Target]:
37
+ content = rendered_plugin("opencode")
38
+ targets = [plugin_file_target(self.plugin_path(), content)]
39
+ if self.mcp:
40
+ argv = self.mcp_command()
41
+
42
+ def add(data: Dict[str, Any]) -> None:
43
+ servers = data.get("mcp")
44
+ if not isinstance(servers, dict):
45
+ servers = data["mcp"] = {}
46
+ servers["subcortex"] = {"type": "local", "command": argv, "enabled": True}
47
+
48
+ def remove(data: Dict[str, Any]) -> None:
49
+ servers = data.get("mcp")
50
+ if isinstance(servers, dict) and servers.pop("subcortex", None) is not None and not servers:
51
+ del data["mcp"]
52
+
53
+ targets.append(json_target(_config_home() / "opencode" / "opencode.json", add, remove,
54
+ lambda d: isinstance(d.get("mcp"), dict) and "subcortex" in d["mcp"]))
55
+ return targets
56
+
57
+
58
+ class KiloInstaller(OpenCodeInstaller):
59
+ name = "kilo"
60
+ display_name = "Kilo Code CLI"
61
+ binaries = ("kilo",)
62
+ docs = "https://kilo.ai/docs/cli"
63
+ min_version = None
64
+ supports_mcp = False
65
+ post_install = "restart kilo to load the plugin"
66
+ plugin_dir = ("kilo", "plugin")