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,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")
|