@oneciel-ai/claude-any 0.1.24
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.
- package/LICENSE +22 -0
- package/NOTICE +9 -0
- package/README.md +435 -0
- package/claude-any-menu.py +1851 -0
- package/claude-any-tool-guard.py +440 -0
- package/claude_any.py +6039 -0
- package/docs/README.ja.md +372 -0
- package/docs/README.ko.md +373 -0
- package/docs/README.zh.md +352 -0
- package/docs/assets/claude-any-base-url.en.png +0 -0
- package/docs/assets/claude-any-base-url.ja.png +0 -0
- package/docs/assets/claude-any-base-url.ko.png +0 -0
- package/docs/assets/claude-any-base-url.png +0 -0
- package/docs/assets/claude-any-base-url.zh.png +0 -0
- package/docs/assets/claude-any-demo.en.gif +0 -0
- package/docs/assets/claude-any-demo.en.mp4 +0 -0
- package/docs/assets/claude-any-demo.gif +0 -0
- package/docs/assets/claude-any-demo.ja.gif +0 -0
- package/docs/assets/claude-any-demo.ja.mp4 +0 -0
- package/docs/assets/claude-any-demo.ko.gif +0 -0
- package/docs/assets/claude-any-demo.ko.mp4 +0 -0
- package/docs/assets/claude-any-demo.mp4 +0 -0
- package/docs/assets/claude-any-demo.zh.gif +0 -0
- package/docs/assets/claude-any-demo.zh.mp4 +0 -0
- package/docs/assets/claude-any-main.en.png +0 -0
- package/docs/assets/claude-any-main.ja.png +0 -0
- package/docs/assets/claude-any-main.ko.png +0 -0
- package/docs/assets/claude-any-main.png +0 -0
- package/docs/assets/claude-any-main.zh.png +0 -0
- package/docs/assets/claude-any-model.en.png +0 -0
- package/docs/assets/claude-any-model.ja.png +0 -0
- package/docs/assets/claude-any-model.ko.png +0 -0
- package/docs/assets/claude-any-model.png +0 -0
- package/docs/assets/claude-any-model.zh.png +0 -0
- package/docs/assets/claude-any-nvidia-nim.gif +0 -0
- package/docs/assets/claude-any-ollama-cloud.gif +0 -0
- package/docs/assets/claude-any-options.en.png +0 -0
- package/docs/assets/claude-any-options.ja.png +0 -0
- package/docs/assets/claude-any-options.ko.png +0 -0
- package/docs/assets/claude-any-options.png +0 -0
- package/docs/assets/claude-any-options.zh.png +0 -0
- package/docs/assets/claude-any-provider.en.png +0 -0
- package/docs/assets/claude-any-provider.ja.png +0 -0
- package/docs/assets/claude-any-provider.ko.png +0 -0
- package/docs/assets/claude-any-provider.png +0 -0
- package/docs/assets/claude-any-provider.zh.png +0 -0
- package/docs/assets/claude-any-test.en.png +0 -0
- package/docs/assets/claude-any-test.ja.png +0 -0
- package/docs/assets/claude-any-test.ko.png +0 -0
- package/docs/assets/claude-any-test.png +0 -0
- package/docs/assets/claude-any-test.zh.png +0 -0
- package/docs/github-descriptions.md +235 -0
- package/docs/manual.md +496 -0
- package/install.ps1 +24 -0
- package/install.sh +19 -0
- package/npm-bin/claude-any-stop.js +6 -0
- package/npm-bin/claude-any.js +5 -0
- package/npm-bin/claude-anyctl.js +5 -0
- package/npm-bin/run-claude-any.js +51 -0
- package/package.json +45 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
NON_NATIVE_PROVIDERS = {"ollama", "ollama-cloud", "vllm", "nvidia-hosted", "self-hosted-nim"}
|
|
13
|
+
TASK_STATUS = {"pending", "in_progress", "completed", "deleted"}
|
|
14
|
+
DESCRIPTION_OK = {"Bash", "TaskCreate", "TaskUpdate"}
|
|
15
|
+
DROP_DESCRIPTION = {"Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "LS"}
|
|
16
|
+
BASH_KEYS = {"command", "description", "timeout", "run_in_background"}
|
|
17
|
+
READ_KEYS = {"file_path", "offset", "limit"}
|
|
18
|
+
WRITE_KEYS = {"file_path", "content"}
|
|
19
|
+
EDIT_KEYS = {"file_path", "old_string", "new_string", "replace_all"}
|
|
20
|
+
MULTIEDIT_KEYS = {"file_path", "edits"}
|
|
21
|
+
GLOB_KEYS = {"pattern", "path"}
|
|
22
|
+
GREP_KEYS = {"pattern", "path", "glob", "type", "output_mode", "-A", "-B", "-C", "head_limit", "multiline"}
|
|
23
|
+
LS_KEYS = {"path", "ignore"}
|
|
24
|
+
TASKLIST_KEYS: set[str] = set()
|
|
25
|
+
TASKUPDATE_KEYS = {"taskId", "status"}
|
|
26
|
+
STRICT_KEYS = {
|
|
27
|
+
"Bash": BASH_KEYS,
|
|
28
|
+
"Read": READ_KEYS,
|
|
29
|
+
"Write": WRITE_KEYS,
|
|
30
|
+
"Edit": EDIT_KEYS,
|
|
31
|
+
"MultiEdit": MULTIEDIT_KEYS,
|
|
32
|
+
"Glob": GLOB_KEYS,
|
|
33
|
+
"Grep": GREP_KEYS,
|
|
34
|
+
"LS": LS_KEYS,
|
|
35
|
+
"TaskList": TASKLIST_KEYS,
|
|
36
|
+
"TaskUpdate": TASKUPDATE_KEYS,
|
|
37
|
+
}
|
|
38
|
+
REQUIRED_KEYS = {
|
|
39
|
+
"Bash": {"command"},
|
|
40
|
+
"Read": {"file_path"},
|
|
41
|
+
"Write": {"file_path", "content"},
|
|
42
|
+
"Edit": {"file_path", "old_string", "new_string"},
|
|
43
|
+
"MultiEdit": {"file_path", "edits"},
|
|
44
|
+
"Glob": {"pattern"},
|
|
45
|
+
"Grep": {"pattern"},
|
|
46
|
+
"TaskUpdate": {"taskId", "status"},
|
|
47
|
+
}
|
|
48
|
+
TOOL_HINTS = {
|
|
49
|
+
"Bash": "Use Bash with command, description, timeout, and run_in_background only.",
|
|
50
|
+
"Read": "Use Read with file_path, offset, and limit only.",
|
|
51
|
+
"Write": "Use Write with file_path and content only.",
|
|
52
|
+
"Edit": "Use Edit with file_path, old_string, new_string, and replace_all only.",
|
|
53
|
+
"MultiEdit": "Use MultiEdit with file_path and edits only.",
|
|
54
|
+
"Glob": "Use Glob with pattern and optional path only.",
|
|
55
|
+
"Grep": "Use Grep with pattern, path, glob, type, output_mode, context, head_limit, or multiline only.",
|
|
56
|
+
"TaskUpdate": "Use TaskUpdate with taskId and status.",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def active() -> bool:
|
|
61
|
+
provider = os.environ.get("CLAUDE_ANY_PROVIDER", "").strip()
|
|
62
|
+
return provider in NON_NATIVE_PROVIDERS
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def emit(obj: dict[str, Any]) -> None:
|
|
66
|
+
print(json.dumps(obj, ensure_ascii=False, separators=(",", ":")))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def log_event(message: str) -> None:
|
|
70
|
+
try:
|
|
71
|
+
path = cache_dir() / "events.log"
|
|
72
|
+
if path.exists() and path.stat().st_size > 300_000:
|
|
73
|
+
path.replace(path.with_suffix(".log.1"))
|
|
74
|
+
with path.open("a", encoding="utf-8") as f:
|
|
75
|
+
f.write(f"{int(time.time())} {message}\n")
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def log_json_event(event: dict[str, Any], result: dict[str, Any] | None = None) -> None:
|
|
81
|
+
try:
|
|
82
|
+
path = cache_dir() / "tool-events.jsonl"
|
|
83
|
+
if path.exists() and path.stat().st_size > 2_000_000:
|
|
84
|
+
path.replace(path.with_suffix(".jsonl.1"))
|
|
85
|
+
record = {
|
|
86
|
+
"time": int(time.time()),
|
|
87
|
+
"hook_event_name": event.get("hook_event_name"),
|
|
88
|
+
"tool_name": event.get("tool_name"),
|
|
89
|
+
"tool_input": event.get("tool_input"),
|
|
90
|
+
}
|
|
91
|
+
if result is not None:
|
|
92
|
+
record["guard_result"] = result
|
|
93
|
+
with path.open("a", encoding="utf-8") as f:
|
|
94
|
+
f.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pre_allow(updated: dict[str, Any], reason: str, context: str = "") -> None:
|
|
100
|
+
out: dict[str, Any] = {
|
|
101
|
+
"hookSpecificOutput": {
|
|
102
|
+
"hookEventName": "PreToolUse",
|
|
103
|
+
"permissionDecision": "allow",
|
|
104
|
+
"permissionDecisionReason": reason,
|
|
105
|
+
"updatedInput": updated,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if context:
|
|
109
|
+
out["hookSpecificOutput"]["additionalContext"] = context
|
|
110
|
+
log_json_event({"hook_event_name": "PreToolUse", "tool_input": updated}, out)
|
|
111
|
+
emit(out)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def pre_deny(reason: str, context: str = "") -> None:
|
|
115
|
+
out: dict[str, Any] = {
|
|
116
|
+
"hookSpecificOutput": {
|
|
117
|
+
"hookEventName": "PreToolUse",
|
|
118
|
+
"permissionDecision": "deny",
|
|
119
|
+
"permissionDecisionReason": reason,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if context:
|
|
123
|
+
out["hookSpecificOutput"]["additionalContext"] = context
|
|
124
|
+
log_json_event({"hook_event_name": "PreToolUse"}, out)
|
|
125
|
+
emit(out)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def post_failure_context(message: str) -> None:
|
|
129
|
+
emit({"hookSpecificOutput": {"hookEventName": "PostToolUseFailure", "additionalContext": message}})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cache_dir() -> Path:
|
|
133
|
+
path = Path.home() / ".claude" / "claude-any-tool-guard"
|
|
134
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
return path
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def task_cache_path() -> Path:
|
|
139
|
+
return cache_dir() / "tasks.json"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_tasks() -> dict[str, Any]:
|
|
143
|
+
path = task_cache_path()
|
|
144
|
+
if not path.exists():
|
|
145
|
+
return {}
|
|
146
|
+
try:
|
|
147
|
+
data = json.loads(path.read_text(errors="ignore"))
|
|
148
|
+
return data if isinstance(data, dict) else {}
|
|
149
|
+
except Exception:
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def save_tasks(data: dict[str, Any]) -> None:
|
|
154
|
+
path = task_cache_path()
|
|
155
|
+
tmp = path.with_suffix(".tmp")
|
|
156
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
157
|
+
tmp.replace(path)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def known_tasks(session_id: str | None) -> dict[str, Any]:
|
|
161
|
+
data = load_tasks()
|
|
162
|
+
if not session_id:
|
|
163
|
+
return {}
|
|
164
|
+
session = data.get(session_id)
|
|
165
|
+
return session if isinstance(session, dict) else {}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def record_task_created(event: dict[str, Any]) -> None:
|
|
169
|
+
session_id = str(event.get("session_id") or "")
|
|
170
|
+
task_id = str(event.get("task_id") or "")
|
|
171
|
+
if not session_id or not task_id:
|
|
172
|
+
return
|
|
173
|
+
data = load_tasks()
|
|
174
|
+
session = data.setdefault(session_id, {})
|
|
175
|
+
session[task_id] = {
|
|
176
|
+
"subject": event.get("task_subject"),
|
|
177
|
+
"description": event.get("task_description"),
|
|
178
|
+
"created_at": int(time.time()),
|
|
179
|
+
}
|
|
180
|
+
save_tasks(data)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def record_task_completed(event: dict[str, Any]) -> None:
|
|
184
|
+
session_id = str(event.get("session_id") or "")
|
|
185
|
+
task_id = str(event.get("task_id") or "")
|
|
186
|
+
if not session_id or not task_id:
|
|
187
|
+
return
|
|
188
|
+
data = load_tasks()
|
|
189
|
+
session = data.setdefault(session_id, {})
|
|
190
|
+
info = session.setdefault(task_id, {})
|
|
191
|
+
if isinstance(info, dict):
|
|
192
|
+
info["completed_at"] = int(time.time())
|
|
193
|
+
info["status"] = "completed"
|
|
194
|
+
save_tasks(data)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def normalize_aliases(tool: str, tool_input: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
|
198
|
+
updated = dict(tool_input)
|
|
199
|
+
changed: list[str] = []
|
|
200
|
+
|
|
201
|
+
def alias(target: str, *names: str) -> None:
|
|
202
|
+
if target in updated:
|
|
203
|
+
return
|
|
204
|
+
for name in names:
|
|
205
|
+
value = updated.get(name)
|
|
206
|
+
if value not in (None, ""):
|
|
207
|
+
updated[target] = value
|
|
208
|
+
changed.append(f"{name}->{target}")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
if tool == "Bash":
|
|
212
|
+
alias("command", "cmd", "content", "script")
|
|
213
|
+
elif tool in {"Read", "Write", "Edit", "MultiEdit"}:
|
|
214
|
+
alias("file_path", "path", "file", "filename")
|
|
215
|
+
elif tool == "Glob":
|
|
216
|
+
alias("pattern", "glob", "path_pattern")
|
|
217
|
+
elif tool == "Grep":
|
|
218
|
+
alias("pattern", "query", "search", "regex")
|
|
219
|
+
elif tool == "LS":
|
|
220
|
+
alias("path", "file_path", "directory")
|
|
221
|
+
elif tool == "TaskUpdate":
|
|
222
|
+
alias("taskId", "task_id", "id")
|
|
223
|
+
return updated, changed
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def missing_required_keys(tool: str, tool_input: dict[str, Any]) -> list[str]:
|
|
227
|
+
required = REQUIRED_KEYS.get(tool, set())
|
|
228
|
+
missing: list[str] = []
|
|
229
|
+
for key in sorted(required):
|
|
230
|
+
value = tool_input.get(key)
|
|
231
|
+
if value is None or value == "":
|
|
232
|
+
missing.append(key)
|
|
233
|
+
return missing
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def strip_unknown_keys(tool: str, tool_input: dict[str, Any]) -> tuple[dict[str, Any], list[str], list[str]]:
|
|
237
|
+
tool_input, changed = normalize_aliases(tool, tool_input)
|
|
238
|
+
allowed = STRICT_KEYS.get(tool)
|
|
239
|
+
if not allowed:
|
|
240
|
+
updated = dict(tool_input)
|
|
241
|
+
dropped: list[str] = []
|
|
242
|
+
if tool in DROP_DESCRIPTION and "description" in updated:
|
|
243
|
+
updated.pop("description", None)
|
|
244
|
+
dropped.append("description")
|
|
245
|
+
return updated, dropped, changed
|
|
246
|
+
updated = {k: v for k, v in tool_input.items() if k in allowed}
|
|
247
|
+
dropped = [k for k in tool_input if k not in allowed]
|
|
248
|
+
return updated, dropped, changed
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def handle_pre_tool(event: dict[str, Any]) -> None:
|
|
252
|
+
tool = str(event.get("tool_name") or "")
|
|
253
|
+
if tool.startswith("mcp__"):
|
|
254
|
+
return
|
|
255
|
+
log_json_event(event)
|
|
256
|
+
raw = event.get("tool_input")
|
|
257
|
+
if not isinstance(raw, dict):
|
|
258
|
+
pre_deny(
|
|
259
|
+
f"{tool} tool input must be a JSON object.",
|
|
260
|
+
"Regenerate the tool call with a valid JSON object matching the Claude Code tool schema.",
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
if tool == "TaskUpdate":
|
|
265
|
+
task_id = raw.get("taskId")
|
|
266
|
+
status = raw.get("status")
|
|
267
|
+
if not isinstance(task_id, str) or not task_id.strip():
|
|
268
|
+
tasks = known_tasks(str(event.get("session_id") or ""))
|
|
269
|
+
known = ", ".join(f"{tid} ({info.get('subject')})" for tid, info in sorted(tasks.items())[:8] if isinstance(info, dict))
|
|
270
|
+
context = "TaskUpdate requires a string taskId. Regenerate the call with the exact taskId from the task you intend to update."
|
|
271
|
+
if known:
|
|
272
|
+
context += f" Known task ids for this session: {known}."
|
|
273
|
+
pre_deny("TaskUpdate requires parameter taskId.", context)
|
|
274
|
+
return
|
|
275
|
+
if not isinstance(status, str) or status not in TASK_STATUS:
|
|
276
|
+
pre_deny(
|
|
277
|
+
"TaskUpdate status must be one of pending, in_progress, completed, or deleted.",
|
|
278
|
+
"Regenerate TaskUpdate with a valid status enum and preserve the taskId.",
|
|
279
|
+
)
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
updated, dropped, changed = strip_unknown_keys(tool, raw)
|
|
283
|
+
missing = missing_required_keys(tool, updated)
|
|
284
|
+
if missing:
|
|
285
|
+
log_event(f"PreToolUse denied tool={tool} missing={missing} keys={list(raw.keys())}")
|
|
286
|
+
pre_deny(
|
|
287
|
+
f"{tool} tool input is missing required parameter(s): {', '.join(missing)}.",
|
|
288
|
+
TOOL_HINTS.get(tool, "Regenerate the tool call with the documented Claude Code tool schema."),
|
|
289
|
+
)
|
|
290
|
+
return
|
|
291
|
+
if dropped or changed:
|
|
292
|
+
reason_parts = []
|
|
293
|
+
if dropped:
|
|
294
|
+
reason_parts.append(f"removed unsupported parameter(s): {', '.join(dropped)}")
|
|
295
|
+
if changed:
|
|
296
|
+
reason_parts.append(f"normalized parameter name(s): {', '.join(changed)}")
|
|
297
|
+
reason = "; ".join(reason_parts)
|
|
298
|
+
log_event(f"PreToolUse sanitized tool={tool} dropped={dropped} changed={changed} keys={list(raw.keys())}")
|
|
299
|
+
pre_allow(
|
|
300
|
+
updated,
|
|
301
|
+
f"Claude Any {reason} for {tool}.",
|
|
302
|
+
f"{tool} was generated with non-standard parameter(s). The guard normalized the input before execution.",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def handle_post_failure(event: dict[str, Any]) -> None:
|
|
307
|
+
log_json_event(event)
|
|
308
|
+
tool = str(event.get("tool_name") or "")
|
|
309
|
+
error = str(event.get("error") or "")
|
|
310
|
+
raw = event.get("tool_input")
|
|
311
|
+
hint = ""
|
|
312
|
+
if "Unrecognized key" in error or "unexpected parameter" in error or "unrecognized_keys" in error:
|
|
313
|
+
hint = (
|
|
314
|
+
f"The {tool} tool rejected unsupported parameters. Retry using only the documented Claude Code schema. "
|
|
315
|
+
"Do not add descriptive fields unless the tool explicitly supports them."
|
|
316
|
+
)
|
|
317
|
+
elif "taskId" in error and tool == "TaskUpdate":
|
|
318
|
+
hint = "TaskUpdate failed because taskId was missing or invalid. Retry with the exact taskId from the task being updated."
|
|
319
|
+
elif "status" in error and tool == "TaskUpdate":
|
|
320
|
+
hint = "TaskUpdate status must be one of pending, in_progress, completed, or deleted."
|
|
321
|
+
if hint:
|
|
322
|
+
log_event(f"PostToolUseFailure tool={tool} error={error[:240]}")
|
|
323
|
+
if isinstance(raw, dict):
|
|
324
|
+
hint += f" Previous invalid input was: {json.dumps(raw, ensure_ascii=False)[:1000]}"
|
|
325
|
+
post_failure_context(hint)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
OBSERVE_ONLY_EVENTS = {
|
|
329
|
+
"PostToolUse",
|
|
330
|
+
"PostToolBatch",
|
|
331
|
+
"PermissionRequest",
|
|
332
|
+
"PermissionDenied",
|
|
333
|
+
"SessionStart",
|
|
334
|
+
"SessionEnd",
|
|
335
|
+
"Setup",
|
|
336
|
+
"UserPromptSubmit",
|
|
337
|
+
"UserPromptExpansion",
|
|
338
|
+
"Stop",
|
|
339
|
+
"StopFailure",
|
|
340
|
+
"InstructionsLoaded",
|
|
341
|
+
"ConfigChange",
|
|
342
|
+
"CwdChanged",
|
|
343
|
+
"Notification",
|
|
344
|
+
"SubagentStart",
|
|
345
|
+
"SubagentStop",
|
|
346
|
+
"TeammateIdle",
|
|
347
|
+
"PreCompact",
|
|
348
|
+
"PostCompact",
|
|
349
|
+
"Elicitation",
|
|
350
|
+
"ElicitationResult",
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def handle_worktree_create(event: dict[str, Any]) -> int:
|
|
355
|
+
"""
|
|
356
|
+
Emit a worktreePath for Claude Code's Agent isolation. In non-git
|
|
357
|
+
directories, Claude Code errors with 'Cannot create agent worktree: not in a
|
|
358
|
+
git repository and no WorktreeCreate hooks are configured'. Returning the
|
|
359
|
+
base_path as worktreePath lets the subagent proceed in the same directory
|
|
360
|
+
(no real isolation, but execution is not blocked).
|
|
361
|
+
"""
|
|
362
|
+
base_path = ""
|
|
363
|
+
for key in ("base_path", "cwd", "worktree_path"):
|
|
364
|
+
candidate = event.get(key)
|
|
365
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
366
|
+
base_path = candidate.strip()
|
|
367
|
+
break
|
|
368
|
+
if not base_path:
|
|
369
|
+
log_event("WorktreeCreate received without base_path; emitting empty path")
|
|
370
|
+
emit({
|
|
371
|
+
"hookSpecificOutput": {
|
|
372
|
+
"hookEventName": "WorktreeCreate",
|
|
373
|
+
"worktreePath": "",
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
return 0
|
|
377
|
+
log_event(f"WorktreeCreate stub worktreePath={base_path}")
|
|
378
|
+
emit({
|
|
379
|
+
"hookSpecificOutput": {
|
|
380
|
+
"hookEventName": "WorktreeCreate",
|
|
381
|
+
"worktreePath": base_path,
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def handle_worktree_remove(event: dict[str, Any]) -> int:
|
|
388
|
+
path = str(event.get("worktree_path") or "").strip()
|
|
389
|
+
if path:
|
|
390
|
+
log_event(f"WorktreeRemove noop path={path}")
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def main() -> int:
|
|
395
|
+
try:
|
|
396
|
+
event = json.loads(sys.stdin.read() or "{}")
|
|
397
|
+
except Exception:
|
|
398
|
+
return 0
|
|
399
|
+
name = str(event.get("hook_event_name") or "")
|
|
400
|
+
|
|
401
|
+
# Worktree handlers always run, regardless of provider, so the non-git
|
|
402
|
+
# worktree fallback works whenever the hook is installed at all.
|
|
403
|
+
if name == "WorktreeCreate":
|
|
404
|
+
return handle_worktree_create(event)
|
|
405
|
+
if name == "WorktreeRemove":
|
|
406
|
+
return handle_worktree_remove(event)
|
|
407
|
+
|
|
408
|
+
# Lightweight observation for events we do not act on. Skip when inactive
|
|
409
|
+
# to avoid touching disk on every event.
|
|
410
|
+
if name in OBSERVE_ONLY_EVENTS:
|
|
411
|
+
if active():
|
|
412
|
+
try:
|
|
413
|
+
log_json_event(event)
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
return 0
|
|
417
|
+
|
|
418
|
+
# Tool/task events: keep existing provider gating.
|
|
419
|
+
provider = os.environ.get("CLAUDE_ANY_PROVIDER", "").strip()
|
|
420
|
+
if not active():
|
|
421
|
+
if provider:
|
|
422
|
+
log_event(f"inactive provider={provider}")
|
|
423
|
+
return 0
|
|
424
|
+
if name == "PreToolUse":
|
|
425
|
+
tool = str(event.get("tool_name") or "")
|
|
426
|
+
raw = event.get("tool_input")
|
|
427
|
+
keys = list(raw.keys()) if isinstance(raw, dict) else []
|
|
428
|
+
log_event(f"PreToolUse seen provider={provider} tool={tool} keys={keys}")
|
|
429
|
+
handle_pre_tool(event)
|
|
430
|
+
elif name == "PostToolUseFailure":
|
|
431
|
+
handle_post_failure(event)
|
|
432
|
+
elif name == "TaskCreated":
|
|
433
|
+
record_task_created(event)
|
|
434
|
+
elif name == "TaskCompleted":
|
|
435
|
+
record_task_completed(event)
|
|
436
|
+
return 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
if __name__ == "__main__":
|
|
440
|
+
raise SystemExit(main())
|