caudate-cli 0.1.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 (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
core/file_refs.py ADDED
@@ -0,0 +1,119 @@
1
+ """@file references — inline or attach files mentioned in user prompts.
2
+
3
+ Two modes, picked per call site:
4
+
5
+ 1. **Inline** (default for short prompts) — the file is read and pasted
6
+ into the message inside a fenced code block. Preserves the user's
7
+ literal text but expands the @path token to file contents.
8
+
9
+ 2. **Attach** (when `file_store` is provided) — the file is uploaded
10
+ to the FileStore and the @path token is replaced with a small
11
+ `[file:<id>]` marker. The returned attachment list goes through
12
+ the same multimodal path as `chat(attachments=...)` so PDFs and
13
+ images work end-to-end.
14
+
15
+ Tokens follow this grammar:
16
+ - `@path/to/file.py` — relative or absolute, no spaces
17
+ - `@"path with spaces.txt"` — quoted form for paths with spaces
18
+ - `@./local.md`, `@~/home.md` — both supported
19
+
20
+ Globs (`@src/**/*.py`) are NOT auto-expanded — too easy to flood the
21
+ context window. The user can use the Glob tool if they need that.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import re
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ # Match either `@"quoted path"` or `@unquoted_path`. The unquoted form
35
+ # stops at whitespace; we strip a trailing punctuation char (.,;:!?) so
36
+ # `@README.md.` resolves to README.md without the trailing period.
37
+ _REF_RE = re.compile(
38
+ r'@(?:"([^"]+)"|([^\s"]+))'
39
+ )
40
+ _TRAILING_PUNCT = ".,;:!?)]}"
41
+
42
+
43
+ def find_refs(text: str) -> list[str]:
44
+ """Return all @-referenced paths in `text`, preserving order, deduped."""
45
+ seen: dict[str, None] = {}
46
+ for match in _REF_RE.finditer(text):
47
+ raw = match.group(1) or match.group(2) or ""
48
+ if not match.group(1):
49
+ while raw and raw[-1] in _TRAILING_PUNCT:
50
+ raw = raw[:-1]
51
+ if raw:
52
+ seen.setdefault(raw, None)
53
+ return list(seen.keys())
54
+
55
+
56
+ def _resolve(path_str: str) -> Path | None:
57
+ p = Path(path_str).expanduser()
58
+ if not p.is_absolute():
59
+ p = Path.cwd() / p
60
+ return p if p.exists() and p.is_file() else None
61
+
62
+
63
+ def expand_inline(text: str, max_chars_per_file: int = 8000) -> str:
64
+ """Replace each @path with the file's contents in a fenced block."""
65
+ def _sub(match: re.Match) -> str:
66
+ raw = match.group(1) or match.group(2) or ""
67
+ trailing = ""
68
+ if not match.group(1):
69
+ while raw and raw[-1] in _TRAILING_PUNCT:
70
+ trailing = raw[-1] + trailing
71
+ raw = raw[:-1]
72
+ path = _resolve(raw)
73
+ if path is None:
74
+ return match.group(0) # leave the original token in place
75
+ try:
76
+ content = path.read_text(errors="ignore")
77
+ except Exception as e:
78
+ logger.debug(f"@file read failed for {path}: {e}")
79
+ return match.group(0)
80
+ truncated = content[:max_chars_per_file]
81
+ suffix = ""
82
+ if len(content) > max_chars_per_file:
83
+ suffix = f"\n... (truncated at {max_chars_per_file} chars)"
84
+ lang = path.suffix.lstrip(".") or ""
85
+ return f"\n\n```{lang} {raw}\n{truncated}{suffix}\n```\n" + trailing
86
+ return _REF_RE.sub(_sub, text)
87
+
88
+
89
+ def expand_with_attachments(
90
+ text: str,
91
+ file_store: Any,
92
+ ) -> tuple[str, list[str]]:
93
+ """Upload each @path to `file_store` and replace with [file:<id>] markers.
94
+
95
+ Returns (rewritten_text, [file_id, ...]). The returned list is
96
+ intended to be passed straight to `agent.chat(..., attachments=...)`.
97
+ """
98
+ attachments: list[str] = []
99
+
100
+ def _sub(match: re.Match) -> str:
101
+ raw = match.group(1) or match.group(2) or ""
102
+ trailing = ""
103
+ if not match.group(1):
104
+ while raw and raw[-1] in _TRAILING_PUNCT:
105
+ trailing = raw[-1] + trailing
106
+ raw = raw[:-1]
107
+ path = _resolve(raw)
108
+ if path is None:
109
+ return match.group(0)
110
+ try:
111
+ record = file_store.upload(path, filename=path.name)
112
+ except Exception as e:
113
+ logger.debug(f"@file upload failed for {path}: {e}")
114
+ return match.group(0)
115
+ attachments.append(record.id)
116
+ return f"[file:{record.id} {path.name}]" + trailing
117
+
118
+ rewritten = _REF_RE.sub(_sub, text)
119
+ return rewritten, attachments
core/files.py ADDED
@@ -0,0 +1,199 @@
1
+ """Files API — store, reference, and extract content from uploaded files.
2
+
3
+ Local equivalent of Anthropic's Files API. A file is registered in the
4
+ FileStore (copied to `data/files/`), gets a stable `file_id`, and can be
5
+ referenced in subsequent `chat()` calls. At LLM call time, the attachment
6
+ is materialized:
7
+
8
+ - PDFs → text extraction via pypdf, prepended as a block
9
+ - Text → read as UTF-8, prepended as a block
10
+ - Images → base64-encoded into a multimodal content block (the model must
11
+ be vision-capable, else the image is represented as a path ref)
12
+
13
+ The store is intentionally simple — flat directory, JSON manifest. No
14
+ multi-tenancy, no TTL, no cleanup job. Delete the file to remove it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import json
22
+ import logging
23
+ import mimetypes
24
+ import shutil
25
+ import uuid
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from pydantic import BaseModel, Field
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # Kinds the store recognizes. Anything else is "binary" and rendered as a
36
+ # file-path reference (agents can still `Read` or run tools against it).
37
+ KIND_TEXT = "text"
38
+ KIND_PDF = "pdf"
39
+ KIND_IMAGE = "image"
40
+ KIND_AUDIO = "audio"
41
+ KIND_BINARY = "binary"
42
+
43
+
44
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
45
+ TEXT_EXTS = {".txt", ".md", ".py", ".js", ".ts", ".json", ".yaml", ".yml",
46
+ ".toml", ".csv", ".html", ".xml", ".rst", ".log"}
47
+ AUDIO_EXTS = {".wav", ".mp3", ".ogg", ".flac", ".m4a"}
48
+
49
+
50
+ def _classify(path: Path) -> str:
51
+ ext = path.suffix.lower()
52
+ if ext == ".pdf":
53
+ return KIND_PDF
54
+ if ext in IMAGE_EXTS:
55
+ return KIND_IMAGE
56
+ if ext in AUDIO_EXTS:
57
+ return KIND_AUDIO
58
+ if ext in TEXT_EXTS:
59
+ return KIND_TEXT
60
+ return KIND_BINARY
61
+
62
+
63
+ class FileRecord(BaseModel):
64
+ id: str
65
+ filename: str
66
+ path: str # absolute path under FILES_DIR
67
+ kind: str
68
+ mime_type: str = ""
69
+ size_bytes: int = 0
70
+ sha256: str = ""
71
+ created_at: datetime = Field(default_factory=datetime.utcnow)
72
+ metadata: dict[str, Any] = Field(default_factory=dict)
73
+
74
+
75
+ class FileStore:
76
+ """Local file store with stable IDs and a manifest."""
77
+
78
+ def __init__(self, root: Path):
79
+ self.root = root
80
+ self.root.mkdir(parents=True, exist_ok=True)
81
+ self._manifest_path = self.root / "manifest.json"
82
+ self._records: dict[str, FileRecord] = {}
83
+ self._load()
84
+
85
+ # ------------------------------------------------------------------
86
+ # CRUD
87
+ # ------------------------------------------------------------------
88
+
89
+ def upload(self, source_path: Path, filename: str | None = None) -> FileRecord:
90
+ """Copy a file into the store, return its FileRecord."""
91
+ src = Path(source_path).expanduser().resolve()
92
+ if not src.exists() or not src.is_file():
93
+ raise FileNotFoundError(f"Not a file: {src}")
94
+
95
+ name = filename or src.name
96
+ file_id = str(uuid.uuid4())
97
+ dest = self.root / f"{file_id}{src.suffix}"
98
+ shutil.copy2(src, dest)
99
+
100
+ data = dest.read_bytes()
101
+ record = FileRecord(
102
+ id=file_id,
103
+ filename=name,
104
+ path=str(dest),
105
+ kind=_classify(dest),
106
+ mime_type=mimetypes.guess_type(src.name)[0] or "application/octet-stream",
107
+ size_bytes=len(data),
108
+ sha256=hashlib.sha256(data).hexdigest(),
109
+ )
110
+ self._records[file_id] = record
111
+ self._save()
112
+ logger.info(f"FileStore upload: {name} ({record.kind}, {record.size_bytes}B) → {file_id}")
113
+ return record
114
+
115
+ def get(self, file_id: str) -> FileRecord | None:
116
+ return self._records.get(file_id)
117
+
118
+ def delete(self, file_id: str) -> bool:
119
+ rec = self._records.pop(file_id, None)
120
+ if rec is None:
121
+ return False
122
+ Path(rec.path).unlink(missing_ok=True)
123
+ self._save()
124
+ return True
125
+
126
+ def list(self) -> list[FileRecord]:
127
+ return sorted(self._records.values(), key=lambda r: r.created_at, reverse=True)
128
+
129
+ # ------------------------------------------------------------------
130
+ # Content extraction
131
+ # ------------------------------------------------------------------
132
+
133
+ def extract_text(self, file_id: str, max_chars: int = 100_000) -> str:
134
+ """Return the textual content of a file (empty string for images etc.)."""
135
+ rec = self.get(file_id)
136
+ if rec is None:
137
+ return ""
138
+ path = Path(rec.path)
139
+
140
+ if rec.kind == KIND_TEXT:
141
+ return path.read_text(errors="ignore")[:max_chars]
142
+
143
+ if rec.kind == KIND_PDF:
144
+ try:
145
+ import pypdf
146
+ except ImportError:
147
+ logger.warning("pypdf not installed — PDF extraction unavailable")
148
+ return f"[PDF file at {path} — install pypdf to extract text]"
149
+ try:
150
+ reader = pypdf.PdfReader(str(path))
151
+ pages = [p.extract_text() or "" for p in reader.pages]
152
+ text = "\n\n".join(pages)
153
+ return text[:max_chars]
154
+ except Exception as e:
155
+ logger.warning(f"PDF extraction failed for {path}: {e}")
156
+ return f"[PDF extraction failed: {e}]"
157
+
158
+ return "" # images/audio/binary handled separately
159
+
160
+ def to_image_content(self, file_id: str) -> dict[str, Any] | None:
161
+ """Return a multimodal image content block ready for a chat message.
162
+
163
+ Shape matches OpenAI/LiteLLM multimodal format; Anthropic receives
164
+ equivalent via LiteLLM translation.
165
+ """
166
+ rec = self.get(file_id)
167
+ if rec is None or rec.kind != KIND_IMAGE:
168
+ return None
169
+ data = Path(rec.path).read_bytes()
170
+ b64 = base64.b64encode(data).decode("ascii")
171
+ return {
172
+ "type": "image_url",
173
+ "image_url": {
174
+ "url": f"data:{rec.mime_type};base64,{b64}",
175
+ },
176
+ }
177
+
178
+ # ------------------------------------------------------------------
179
+ # Manifest persistence
180
+ # ------------------------------------------------------------------
181
+
182
+ def _load(self) -> None:
183
+ if not self._manifest_path.exists():
184
+ return
185
+ try:
186
+ raw = json.loads(self._manifest_path.read_text())
187
+ except Exception as e:
188
+ logger.warning(f"Failed to load file manifest: {e}")
189
+ return
190
+ for entry in raw.get("files", []):
191
+ try:
192
+ rec = FileRecord.model_validate(entry)
193
+ self._records[rec.id] = rec
194
+ except Exception as e:
195
+ logger.debug(f"Skipping bad manifest entry {entry}: {e}")
196
+
197
+ def _save(self) -> None:
198
+ payload = {"files": [r.model_dump(mode="json") for r in self._records.values()]}
199
+ self._manifest_path.write_text(json.dumps(payload, indent=2, default=str))
core/hooks.py ADDED
@@ -0,0 +1,209 @@
1
+ """Hooks system — event-driven extensibility for the agentic loop.
2
+
3
+ Events are fired at key points in the loop (see HookEvent). Each event can
4
+ have zero or more handlers. Handlers are either Python callables or shell
5
+ commands loaded from a JSON config file.
6
+
7
+ Handler signature: async def handler(event: str, payload: dict) -> dict | None
8
+ - Return a dict with "block": True to short-circuit (e.g. PRE_TOOL_USE)
9
+ - Return a dict with "modified_args": {...} to mutate tool args (PRE_TOOL_USE)
10
+ - Return None for pure side-effect handlers (logging, audit, etc.)
11
+
12
+ Shell-command handlers receive the payload via stdin as JSON and may emit
13
+ the same response dict as JSON on stdout.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import re
22
+ from enum import Enum
23
+ from pathlib import Path
24
+ from typing import Any, Awaitable, Callable
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class HookEvent(str, Enum):
30
+ PRE_TOOL_USE = "pre_tool_use"
31
+ POST_TOOL_USE = "post_tool_use"
32
+ POST_TOOL_USE_FAILURE = "post_tool_use_failure"
33
+ USER_PROMPT_SUBMIT = "user_prompt_submit"
34
+ STOP = "stop"
35
+ SUBAGENT_START = "subagent_start"
36
+ SUBAGENT_STOP = "subagent_stop"
37
+ PRE_COMPACT = "pre_compact"
38
+ NOTIFICATION = "notification"
39
+
40
+
41
+ HookHandler = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any] | None] | dict[str, Any] | None]
42
+
43
+
44
+ class HookMatcher:
45
+ """A matcher selects which tool/payloads a handler fires on."""
46
+
47
+ def __init__(
48
+ self,
49
+ tool: str | None = None,
50
+ pattern: str | None = None,
51
+ ):
52
+ self.tool = tool
53
+ self.pattern = re.compile(pattern) if pattern else None
54
+
55
+ def matches(self, payload: dict[str, Any]) -> bool:
56
+ if self.tool:
57
+ tool = payload.get("tool") or payload.get("tool_name")
58
+ if tool != self.tool and self.tool != "*":
59
+ return False
60
+ if self.pattern:
61
+ try:
62
+ serialized = json.dumps(payload, ensure_ascii=False, default=str)
63
+ except Exception:
64
+ serialized = str(payload)
65
+ return bool(self.pattern.search(serialized))
66
+ return True
67
+
68
+
69
+ class HookManager:
70
+ """Registers and dispatches hook handlers."""
71
+
72
+ def __init__(self):
73
+ self._handlers: dict[HookEvent, list[tuple[HookMatcher, HookHandler]]] = {
74
+ e: [] for e in HookEvent
75
+ }
76
+
77
+ def register(
78
+ self,
79
+ event: HookEvent,
80
+ handler: HookHandler,
81
+ matcher: HookMatcher | None = None,
82
+ ) -> None:
83
+ """Register a handler for an event."""
84
+ self._handlers[event].append((matcher or HookMatcher(), handler))
85
+
86
+ def register_shell(
87
+ self,
88
+ event: HookEvent,
89
+ command: str,
90
+ matcher: HookMatcher | None = None,
91
+ ) -> None:
92
+ """Register a shell command as a hook handler."""
93
+ async def _shell_handler(evt: str, payload: dict[str, Any]):
94
+ return await _run_shell_hook(command, evt, payload)
95
+
96
+ self.register(event, _shell_handler, matcher)
97
+
98
+ async def emit(self, event: HookEvent, payload: dict[str, Any]) -> dict[str, Any]:
99
+ """Fire all handlers for an event. Returns the aggregated response dict.
100
+
101
+ If any handler returns {"block": True}, subsequent handlers still run
102
+ (for audit), but the combined response will preserve block=True.
103
+ If any handler returns {"modified_args": ...}, the latest such value
104
+ wins (later handlers override earlier ones).
105
+ """
106
+ combined: dict[str, Any] = {}
107
+ for matcher, handler in self._handlers.get(event, []):
108
+ if not matcher.matches(payload):
109
+ continue
110
+ try:
111
+ result = handler(event.value, payload)
112
+ if asyncio.iscoroutine(result):
113
+ result = await result
114
+ except Exception as e:
115
+ logger.warning(f"Hook {event.value} handler failed: {e}")
116
+ continue
117
+ if isinstance(result, dict):
118
+ if result.get("block"):
119
+ combined["block"] = True
120
+ if "reason" in result:
121
+ combined["reason"] = result["reason"]
122
+ if "modified_args" in result:
123
+ combined["modified_args"] = result["modified_args"]
124
+ return combined
125
+
126
+ # ------------------------------------------------------------------
127
+ # Config loading
128
+ # ------------------------------------------------------------------
129
+
130
+ def load_from_file(self, path: Path) -> int:
131
+ """Load hook definitions from a JSON config file. Returns count loaded.
132
+
133
+ Format:
134
+ {
135
+ "hooks": [
136
+ {
137
+ "event": "pre_tool_use",
138
+ "command": "/path/to/script.sh",
139
+ "matcher": {"tool": "Bash", "pattern": "rm "}
140
+ }
141
+ ]
142
+ }
143
+ """
144
+ if not path.exists():
145
+ return 0
146
+ try:
147
+ data = json.loads(path.read_text())
148
+ except Exception as e:
149
+ logger.warning(f"Failed to load hooks from {path}: {e}")
150
+ return 0
151
+
152
+ count = 0
153
+ for h in data.get("hooks", []):
154
+ try:
155
+ event = HookEvent(h["event"])
156
+ except (KeyError, ValueError):
157
+ logger.warning(f"Invalid hook event: {h.get('event')}")
158
+ continue
159
+ matcher_cfg = h.get("matcher") or {}
160
+ matcher = HookMatcher(
161
+ tool=matcher_cfg.get("tool"),
162
+ pattern=matcher_cfg.get("pattern"),
163
+ )
164
+ if "command" in h:
165
+ self.register_shell(event, h["command"], matcher)
166
+ count += 1
167
+ # Python-callable hooks can't be declared in JSON; would require
168
+ # an import path. For now, only shell hooks are supported via file.
169
+ return count
170
+
171
+
172
+ async def _run_shell_hook(
173
+ command: str,
174
+ event: str,
175
+ payload: dict[str, Any],
176
+ ) -> dict[str, Any] | None:
177
+ """Run a shell hook, piping the payload as JSON on stdin."""
178
+ try:
179
+ payload_json = json.dumps({"event": event, **payload}, default=str)
180
+ except Exception:
181
+ payload_json = json.dumps({"event": event})
182
+
183
+ try:
184
+ proc = await asyncio.create_subprocess_shell(
185
+ command,
186
+ stdin=asyncio.subprocess.PIPE,
187
+ stdout=asyncio.subprocess.PIPE,
188
+ stderr=asyncio.subprocess.PIPE,
189
+ )
190
+ stdout, stderr = await asyncio.wait_for(
191
+ proc.communicate(payload_json.encode()),
192
+ timeout=30,
193
+ )
194
+ except Exception as e:
195
+ logger.warning(f"Shell hook {command!r} failed: {e}")
196
+ return None
197
+
198
+ if proc.returncode != 0:
199
+ logger.debug(f"Shell hook {command!r} exit={proc.returncode}: {stderr.decode(errors='ignore')[:200]}")
200
+ return None
201
+
202
+ out = stdout.decode(errors="ignore").strip()
203
+ if not out:
204
+ return None
205
+ try:
206
+ return json.loads(out)
207
+ except json.JSONDecodeError:
208
+ logger.debug(f"Shell hook {command!r} returned non-JSON output")
209
+ return None