@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.
Files changed (60) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE +9 -0
  3. package/README.md +435 -0
  4. package/claude-any-menu.py +1851 -0
  5. package/claude-any-tool-guard.py +440 -0
  6. package/claude_any.py +6039 -0
  7. package/docs/README.ja.md +372 -0
  8. package/docs/README.ko.md +373 -0
  9. package/docs/README.zh.md +352 -0
  10. package/docs/assets/claude-any-base-url.en.png +0 -0
  11. package/docs/assets/claude-any-base-url.ja.png +0 -0
  12. package/docs/assets/claude-any-base-url.ko.png +0 -0
  13. package/docs/assets/claude-any-base-url.png +0 -0
  14. package/docs/assets/claude-any-base-url.zh.png +0 -0
  15. package/docs/assets/claude-any-demo.en.gif +0 -0
  16. package/docs/assets/claude-any-demo.en.mp4 +0 -0
  17. package/docs/assets/claude-any-demo.gif +0 -0
  18. package/docs/assets/claude-any-demo.ja.gif +0 -0
  19. package/docs/assets/claude-any-demo.ja.mp4 +0 -0
  20. package/docs/assets/claude-any-demo.ko.gif +0 -0
  21. package/docs/assets/claude-any-demo.ko.mp4 +0 -0
  22. package/docs/assets/claude-any-demo.mp4 +0 -0
  23. package/docs/assets/claude-any-demo.zh.gif +0 -0
  24. package/docs/assets/claude-any-demo.zh.mp4 +0 -0
  25. package/docs/assets/claude-any-main.en.png +0 -0
  26. package/docs/assets/claude-any-main.ja.png +0 -0
  27. package/docs/assets/claude-any-main.ko.png +0 -0
  28. package/docs/assets/claude-any-main.png +0 -0
  29. package/docs/assets/claude-any-main.zh.png +0 -0
  30. package/docs/assets/claude-any-model.en.png +0 -0
  31. package/docs/assets/claude-any-model.ja.png +0 -0
  32. package/docs/assets/claude-any-model.ko.png +0 -0
  33. package/docs/assets/claude-any-model.png +0 -0
  34. package/docs/assets/claude-any-model.zh.png +0 -0
  35. package/docs/assets/claude-any-nvidia-nim.gif +0 -0
  36. package/docs/assets/claude-any-ollama-cloud.gif +0 -0
  37. package/docs/assets/claude-any-options.en.png +0 -0
  38. package/docs/assets/claude-any-options.ja.png +0 -0
  39. package/docs/assets/claude-any-options.ko.png +0 -0
  40. package/docs/assets/claude-any-options.png +0 -0
  41. package/docs/assets/claude-any-options.zh.png +0 -0
  42. package/docs/assets/claude-any-provider.en.png +0 -0
  43. package/docs/assets/claude-any-provider.ja.png +0 -0
  44. package/docs/assets/claude-any-provider.ko.png +0 -0
  45. package/docs/assets/claude-any-provider.png +0 -0
  46. package/docs/assets/claude-any-provider.zh.png +0 -0
  47. package/docs/assets/claude-any-test.en.png +0 -0
  48. package/docs/assets/claude-any-test.ja.png +0 -0
  49. package/docs/assets/claude-any-test.ko.png +0 -0
  50. package/docs/assets/claude-any-test.png +0 -0
  51. package/docs/assets/claude-any-test.zh.png +0 -0
  52. package/docs/github-descriptions.md +235 -0
  53. package/docs/manual.md +496 -0
  54. package/install.ps1 +24 -0
  55. package/install.sh +19 -0
  56. package/npm-bin/claude-any-stop.js +6 -0
  57. package/npm-bin/claude-any.js +5 -0
  58. package/npm-bin/claude-anyctl.js +5 -0
  59. package/npm-bin/run-claude-any.js +51 -0
  60. 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())