horncall 0.5.13__tar.gz
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.
- horncall-0.5.13/LICENSE +21 -0
- horncall-0.5.13/PKG-INFO +113 -0
- horncall-0.5.13/README.md +90 -0
- horncall-0.5.13/plugin/approver/__init__.py +4 -0
- horncall-0.5.13/plugin/approver/__main__.py +3 -0
- horncall-0.5.13/plugin/approver/agents.py +185 -0
- horncall-0.5.13/plugin/approver/cli.py +335 -0
- horncall-0.5.13/plugin/approver/config.py +154 -0
- horncall-0.5.13/plugin/approver/crypto.py +95 -0
- horncall-0.5.13/plugin/approver/entry.py +43 -0
- horncall-0.5.13/plugin/approver/hook.py +273 -0
- horncall-0.5.13/plugin/approver/install.py +405 -0
- horncall-0.5.13/plugin/approver/link.py +73 -0
- horncall-0.5.13/plugin/approver/phones.py +38 -0
- horncall-0.5.13/plugin/approver/protocol.py +307 -0
- horncall-0.5.13/plugin/approver/setup.py +265 -0
- horncall-0.5.13/plugin/approver/statusline.py +65 -0
- horncall-0.5.13/plugin/approver/transport.py +107 -0
- horncall-0.5.13/plugin/approver/update.py +131 -0
- horncall-0.5.13/plugin/approver/vitals.py +233 -0
- horncall-0.5.13/plugin/horncall.egg-info/PKG-INFO +113 -0
- horncall-0.5.13/plugin/horncall.egg-info/SOURCES.txt +35 -0
- horncall-0.5.13/plugin/horncall.egg-info/dependency_links.txt +1 -0
- horncall-0.5.13/plugin/horncall.egg-info/entry_points.txt +2 -0
- horncall-0.5.13/plugin/horncall.egg-info/requires.txt +2 -0
- horncall-0.5.13/plugin/horncall.egg-info/top_level.txt +1 -0
- horncall-0.5.13/pyproject.toml +41 -0
- horncall-0.5.13/setup.cfg +4 -0
- horncall-0.5.13/tests/test_agents.py +217 -0
- horncall-0.5.13/tests/test_config.py +27 -0
- horncall-0.5.13/tests/test_crypto.py +58 -0
- horncall-0.5.13/tests/test_hook.py +269 -0
- horncall-0.5.13/tests/test_install_print.py +138 -0
- horncall-0.5.13/tests/test_protocol.py +148 -0
- horncall-0.5.13/tests/test_setup.py +76 -0
- horncall-0.5.13/tests/test_update.py +89 -0
- horncall-0.5.13/tests/test_vitals.py +88 -0
horncall-0.5.13/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Horncall contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
horncall-0.5.13/PKG-INFO
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: horncall
|
|
3
|
+
Version: 0.5.13
|
|
4
|
+
Summary: Approve AI coding agents' permission requests (Claude Code, Gemini CLI, Copilot CLI, Codex CLI) from your Android phone.
|
|
5
|
+
Author-email: bitlionaire <bitlionaire.me@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://horncall.bitlionaire.me
|
|
8
|
+
Project-URL: Source, https://github.com/bitlionaire/horncall
|
|
9
|
+
Project-URL: Issues, https://github.com/bitlionaire/horncall/issues
|
|
10
|
+
Keywords: claude-code,gemini-cli,copilot-cli,codex,permissions,hooks,android,approval
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: cryptography>=42
|
|
21
|
+
Requires-Dist: qrcode>=7.4
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Horncall
|
|
25
|
+
|
|
26
|
+
**One app to approve them all.** Answer your AI coding agents' permission requests from your Android
|
|
27
|
+
phone — Claude Code, Gemini CLI, GitHub Copilot CLI and OpenAI Codex CLI, on any number of computers.
|
|
28
|
+
|
|
29
|
+
This repository is the **PC add-on** (open source, MIT). It hooks into the agents' own, documented
|
|
30
|
+
hook systems and forwards a permission question to the phone only while you switch it **on**
|
|
31
|
+
(you're away from the desk). At the desk, everything works as before.
|
|
32
|
+
|
|
33
|
+
- Guide: **https://horncall.bitlionaire.me** · phone app: Android (see the guide)
|
|
34
|
+
- End-to-end encrypted: AES-256-GCM with a key made on your computer and handed to the phone only by
|
|
35
|
+
QR code. The relay sees ciphertext.
|
|
36
|
+
- The phone **signs every decision** (Android Keystore, ECDSA P-256); the computer accepts only
|
|
37
|
+
answers signed by the phone it paired with. A stolen key file can read orders, but approve nothing.
|
|
38
|
+
- Dangerous commands (`rm -rf`, force push, `DROP TABLE`, edits to secrets, hooks or CI…) get a red
|
|
39
|
+
card with the reasons and need a long press.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
Pick one. All of them end with the same pairing step (`setup` shows a QR code to scan in the app).
|
|
44
|
+
|
|
45
|
+
**Claude Code plugin** (Claude Code keeps it up to date):
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
/plugin marketplace add bitlionaire/horncall
|
|
49
|
+
/plugin install horncall@horncall
|
|
50
|
+
/horncall:approver setup
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**pipx / uv** (a `horncall` command; hooks for every agent, Claude Code via its `settings.json`):
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
pipx install horncall # or: uv tool install horncall
|
|
57
|
+
horncall setup
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**ZIP with a wizard** (no Python tooling needed beyond Python 3.10+): download `horncall-plugin.zip`
|
|
61
|
+
from the guide, unpack anywhere and run `setup.cmd` (macOS/Linux: `sh setup.sh`). It copies itself to
|
|
62
|
+
the per-user program folder (`%LOCALAPPDATA%\Horncall\app`, `~/Library/Application Support/Horncall/app`,
|
|
63
|
+
`~/.local/share/horncall/app`); the unpacked folder can be deleted.
|
|
64
|
+
|
|
65
|
+
**By hand**: see exactly what would be added, then paste it yourself or let it write:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
horncall install gemini --print # claude | gemini | copilot | codex | claude-statusline
|
|
69
|
+
horncall install gemini
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## What the install changes
|
|
73
|
+
|
|
74
|
+
| Where | What |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `~/.horncall` | pairing key, trusted phones, on/off mode, log (plugin/ZIP installs also a private `venv`) |
|
|
77
|
+
| Claude Code | the `horncall` plugin, or (pipx) four hooks in `~/.claude/settings.json`; optionally a status line for plan limits |
|
|
78
|
+
| `~/.gemini/settings.json`, `~/.codex/hooks.json`, `~/.copilot/hooks/horncall.json` | Horncall hook entries, only for the agents you pick |
|
|
79
|
+
|
|
80
|
+
Other settings stay untouched; the first change to a file leaves a `*.horncall-backup` copy. The setup
|
|
81
|
+
wizard lists the files and asks before writing. No services, autostart entries or admin rights.
|
|
82
|
+
Remove everything: `horncall uninstall all` (in Claude: `/horncall:approver uninstall all`).
|
|
83
|
+
|
|
84
|
+
## Every day
|
|
85
|
+
|
|
86
|
+
| Command | |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `horncall on` / `/horncall:approver on` | leaving the desk — questions go to the phone (`--hours 2` switches off by itself) |
|
|
89
|
+
| `horncall off` | back — normal questions on the computer |
|
|
90
|
+
| `horncall status` | pairing, mode, server, trusted phones, connected agents |
|
|
91
|
+
| `horncall name "Shop backend"` | label this project's orders on the phone |
|
|
92
|
+
|
|
93
|
+
Gemini CLI asks on its own first — start it with `gemini --yolo` (same as `--approval-mode yolo`) so only the phone asks.
|
|
94
|
+
Codex CLI: open `/hooks` and enable the three Horncall hooks.
|
|
95
|
+
|
|
96
|
+
## How it works
|
|
97
|
+
|
|
98
|
+
The agent runs a hook (`horncall hook <agent>`) on a permission request. While Horncall is off it
|
|
99
|
+
exits at once and the agent shows its normal dialog. While on, it encrypts the request, publishes it
|
|
100
|
+
to an [ntfy](https://ntfy.sh) relay (ours by default, any ntfy server works: `horncall pair --server`)
|
|
101
|
+
and waits for the phone's signed answer, which it returns to the agent as allow or deny. Answering
|
|
102
|
+
on the computer instead removes the card from the phone.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
python -m pip install -r plugin/requirements.txt pytest
|
|
108
|
+
python -m pytest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Issues and pull requests are welcome. Horncall is an independent project, not affiliated with
|
|
112
|
+
Anthropic, OpenAI, Google or GitHub; their product names only describe compatibility.
|
|
113
|
+
Contact: bitlionaire.me@gmail.com
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Horncall
|
|
2
|
+
|
|
3
|
+
**One app to approve them all.** Answer your AI coding agents' permission requests from your Android
|
|
4
|
+
phone — Claude Code, Gemini CLI, GitHub Copilot CLI and OpenAI Codex CLI, on any number of computers.
|
|
5
|
+
|
|
6
|
+
This repository is the **PC add-on** (open source, MIT). It hooks into the agents' own, documented
|
|
7
|
+
hook systems and forwards a permission question to the phone only while you switch it **on**
|
|
8
|
+
(you're away from the desk). At the desk, everything works as before.
|
|
9
|
+
|
|
10
|
+
- Guide: **https://horncall.bitlionaire.me** · phone app: Android (see the guide)
|
|
11
|
+
- End-to-end encrypted: AES-256-GCM with a key made on your computer and handed to the phone only by
|
|
12
|
+
QR code. The relay sees ciphertext.
|
|
13
|
+
- The phone **signs every decision** (Android Keystore, ECDSA P-256); the computer accepts only
|
|
14
|
+
answers signed by the phone it paired with. A stolen key file can read orders, but approve nothing.
|
|
15
|
+
- Dangerous commands (`rm -rf`, force push, `DROP TABLE`, edits to secrets, hooks or CI…) get a red
|
|
16
|
+
card with the reasons and need a long press.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
Pick one. All of them end with the same pairing step (`setup` shows a QR code to scan in the app).
|
|
21
|
+
|
|
22
|
+
**Claude Code plugin** (Claude Code keeps it up to date):
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
/plugin marketplace add bitlionaire/horncall
|
|
26
|
+
/plugin install horncall@horncall
|
|
27
|
+
/horncall:approver setup
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**pipx / uv** (a `horncall` command; hooks for every agent, Claude Code via its `settings.json`):
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pipx install horncall # or: uv tool install horncall
|
|
34
|
+
horncall setup
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**ZIP with a wizard** (no Python tooling needed beyond Python 3.10+): download `horncall-plugin.zip`
|
|
38
|
+
from the guide, unpack anywhere and run `setup.cmd` (macOS/Linux: `sh setup.sh`). It copies itself to
|
|
39
|
+
the per-user program folder (`%LOCALAPPDATA%\Horncall\app`, `~/Library/Application Support/Horncall/app`,
|
|
40
|
+
`~/.local/share/horncall/app`); the unpacked folder can be deleted.
|
|
41
|
+
|
|
42
|
+
**By hand**: see exactly what would be added, then paste it yourself or let it write:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
horncall install gemini --print # claude | gemini | copilot | codex | claude-statusline
|
|
46
|
+
horncall install gemini
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## What the install changes
|
|
50
|
+
|
|
51
|
+
| Where | What |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `~/.horncall` | pairing key, trusted phones, on/off mode, log (plugin/ZIP installs also a private `venv`) |
|
|
54
|
+
| Claude Code | the `horncall` plugin, or (pipx) four hooks in `~/.claude/settings.json`; optionally a status line for plan limits |
|
|
55
|
+
| `~/.gemini/settings.json`, `~/.codex/hooks.json`, `~/.copilot/hooks/horncall.json` | Horncall hook entries, only for the agents you pick |
|
|
56
|
+
|
|
57
|
+
Other settings stay untouched; the first change to a file leaves a `*.horncall-backup` copy. The setup
|
|
58
|
+
wizard lists the files and asks before writing. No services, autostart entries or admin rights.
|
|
59
|
+
Remove everything: `horncall uninstall all` (in Claude: `/horncall:approver uninstall all`).
|
|
60
|
+
|
|
61
|
+
## Every day
|
|
62
|
+
|
|
63
|
+
| Command | |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `horncall on` / `/horncall:approver on` | leaving the desk — questions go to the phone (`--hours 2` switches off by itself) |
|
|
66
|
+
| `horncall off` | back — normal questions on the computer |
|
|
67
|
+
| `horncall status` | pairing, mode, server, trusted phones, connected agents |
|
|
68
|
+
| `horncall name "Shop backend"` | label this project's orders on the phone |
|
|
69
|
+
|
|
70
|
+
Gemini CLI asks on its own first — start it with `gemini --yolo` (same as `--approval-mode yolo`) so only the phone asks.
|
|
71
|
+
Codex CLI: open `/hooks` and enable the three Horncall hooks.
|
|
72
|
+
|
|
73
|
+
## How it works
|
|
74
|
+
|
|
75
|
+
The agent runs a hook (`horncall hook <agent>`) on a permission request. While Horncall is off it
|
|
76
|
+
exits at once and the agent shows its normal dialog. While on, it encrypts the request, publishes it
|
|
77
|
+
to an [ntfy](https://ntfy.sh) relay (ours by default, any ntfy server works: `horncall pair --server`)
|
|
78
|
+
and waits for the phone's signed answer, which it returns to the agent as allow or deny. Answering
|
|
79
|
+
on the computer instead removes the card from the phone.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
python -m pip install -r plugin/requirements.txt pytest
|
|
85
|
+
python -m pytest
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Issues and pull requests are welcome. Horncall is an independent project, not affiliated with
|
|
89
|
+
Anthropic, OpenAI, Google or GitHub; their product names only describe compatibility.
|
|
90
|
+
Contact: bitlionaire.me@gmail.com
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Adapters for the AI coding agents Horncall can answer for.
|
|
2
|
+
|
|
3
|
+
Each agent calls our hook with its own JSON on stdin and expects its own JSON on stdout. An adapter
|
|
4
|
+
turns the input into the common shape `protocol.build_request` takes (Claude Code's: tool_name,
|
|
5
|
+
tool_input, cwd, session_id) and the phone's decision back into the agent's answer. Printing nothing
|
|
6
|
+
always means "no opinion": the agent then asks the user its normal way.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from . import protocol
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class HookInput:
|
|
19
|
+
tool: str # our tool name: Bash, Write, Edit, … (Claude Code's vocabulary)
|
|
20
|
+
tool_input: dict
|
|
21
|
+
cwd: str | None
|
|
22
|
+
session_id: str | None
|
|
23
|
+
|
|
24
|
+
def as_claude(self) -> dict:
|
|
25
|
+
return {"tool_name": self.tool, "tool_input": self.tool_input, "cwd": self.cwd, "session_id": self.session_id}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Agent:
|
|
29
|
+
name = "?"
|
|
30
|
+
title = "?"
|
|
31
|
+
# The agent kills the hook after its configured timeout; wait a bit less than that.
|
|
32
|
+
max_wait_s = 1850
|
|
33
|
+
|
|
34
|
+
def parse(self, raw: dict) -> HookInput | None:
|
|
35
|
+
"""None = not something to ask the phone about (let the agent decide)."""
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def allow(self) -> dict:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
def deny(self, message: str) -> dict:
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Claude(Agent):
|
|
46
|
+
"""Claude Code `PermissionRequest` hook (the plugin's hooks.json)."""
|
|
47
|
+
name, title = "claude", "Claude Code"
|
|
48
|
+
|
|
49
|
+
def parse(self, raw):
|
|
50
|
+
tool_input = raw.get("tool_input") or {}
|
|
51
|
+
if not isinstance(tool_input, dict):
|
|
52
|
+
tool_input = {"input": tool_input}
|
|
53
|
+
return HookInput(str(raw.get("tool_name", "?")), tool_input, raw.get("cwd"), raw.get("session_id"))
|
|
54
|
+
|
|
55
|
+
def allow(self):
|
|
56
|
+
return {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "allow"}}}
|
|
57
|
+
|
|
58
|
+
def deny(self, message):
|
|
59
|
+
return {"hookSpecificOutput": {"hookEventName": "PermissionRequest",
|
|
60
|
+
"decision": {"behavior": "deny", "message": message}}}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Gemini(Agent):
|
|
64
|
+
"""Gemini CLI `BeforeTool` hook. It fires for every tool, so only risky ones go to the phone."""
|
|
65
|
+
name, title = "gemini", "Gemini CLI"
|
|
66
|
+
|
|
67
|
+
# Gemini tool → (our tool, how to map its arguments)
|
|
68
|
+
TOOLS = {
|
|
69
|
+
"run_shell_command": "Bash",
|
|
70
|
+
"write_file": "Write",
|
|
71
|
+
"replace": "Edit",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def parse(self, raw):
|
|
75
|
+
tool = str(raw.get("tool_name", ""))
|
|
76
|
+
ours = self.TOOLS.get(tool)
|
|
77
|
+
if ours is None:
|
|
78
|
+
return None # reads, searches, web fetches: Gemini's own rules decide
|
|
79
|
+
args = raw.get("tool_input") or {}
|
|
80
|
+
if not isinstance(args, dict):
|
|
81
|
+
args = {"input": args}
|
|
82
|
+
if ours == "Bash":
|
|
83
|
+
tool_input = {"command": args.get("command", ""), "description": args.get("description")}
|
|
84
|
+
if args.get("dir_path") or args.get("directory"):
|
|
85
|
+
tool_input["description"] = (tool_input["description"] or "") + f" (in {args.get('dir_path') or args.get('directory')})"
|
|
86
|
+
elif ours == "Write":
|
|
87
|
+
tool_input = {"file_path": args.get("file_path") or args.get("path", ""), "content": args.get("content", "")}
|
|
88
|
+
else:
|
|
89
|
+
tool_input = {"file_path": args.get("file_path") or args.get("path", ""),
|
|
90
|
+
"old_string": args.get("old_string", ""), "new_string": args.get("new_string", "")}
|
|
91
|
+
tool_input = {k: v for k, v in tool_input.items() if v is not None}
|
|
92
|
+
return HookInput(ours, tool_input, raw.get("cwd"), raw.get("session_id"))
|
|
93
|
+
|
|
94
|
+
def allow(self):
|
|
95
|
+
return {"decision": "allow"}
|
|
96
|
+
|
|
97
|
+
def deny(self, message):
|
|
98
|
+
return {"decision": "deny", "reason": message}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Copilot(Agent):
|
|
102
|
+
"""GitHub Copilot CLI `permissionRequest` hook (runs only when Copilot would ask the user).
|
|
103
|
+
|
|
104
|
+
Its input schema isn't documented; this accepts the camelCase and snake_case shapes Copilot uses
|
|
105
|
+
for its other hooks. `hook.py` logs the input's field NAMES (never values) so a real sample can
|
|
106
|
+
confirm the mapping.
|
|
107
|
+
"""
|
|
108
|
+
name, title = "copilot", "GitHub Copilot CLI"
|
|
109
|
+
|
|
110
|
+
SHELL = {"bash", "shell", "powershell", "run", "terminal", "run_in_terminal"}
|
|
111
|
+
WRITE = {"write", "create", "create_file", "write_file"}
|
|
112
|
+
EDIT = {"edit", "str_replace", "str_replace_editor", "replace", "edit_file"}
|
|
113
|
+
|
|
114
|
+
def parse(self, raw):
|
|
115
|
+
tool = str(raw.get("toolName") or raw.get("tool_name") or raw.get("kind") or raw.get("tool") or "?")
|
|
116
|
+
args = (raw.get("toolInput") or raw.get("toolArgs") or raw.get("tool_input") or raw.get("arguments")
|
|
117
|
+
or raw.get("args") or {})
|
|
118
|
+
if isinstance(args, str):
|
|
119
|
+
try:
|
|
120
|
+
args = json.loads(args)
|
|
121
|
+
except ValueError:
|
|
122
|
+
args = {"command": args}
|
|
123
|
+
if not isinstance(args, dict):
|
|
124
|
+
args = {"input": args}
|
|
125
|
+
low = tool.lower()
|
|
126
|
+
if low in self.SHELL or "command" in args and low not in self.WRITE | self.EDIT:
|
|
127
|
+
ours = "PowerShell" if low == "powershell" else "Bash"
|
|
128
|
+
tool_input = {"command": args.get("command") or args.get("fullCommandText") or "",
|
|
129
|
+
"description": args.get("description")}
|
|
130
|
+
elif low in self.WRITE:
|
|
131
|
+
ours, tool_input = "Write", {"file_path": args.get("path") or args.get("file_path", ""),
|
|
132
|
+
"content": args.get("content") or args.get("file_text", "")}
|
|
133
|
+
elif low in self.EDIT:
|
|
134
|
+
ours, tool_input = "Edit", {"file_path": args.get("path") or args.get("file_path", ""),
|
|
135
|
+
"old_string": args.get("old_str") or args.get("old_string", ""),
|
|
136
|
+
"new_string": args.get("new_str") or args.get("new_string", "")}
|
|
137
|
+
else:
|
|
138
|
+
ours, tool_input = tool, args
|
|
139
|
+
tool_input = {k: v for k, v in tool_input.items() if v is not None}
|
|
140
|
+
return HookInput(ours, tool_input, raw.get("cwd"), raw.get("sessionId") or raw.get("session_id"))
|
|
141
|
+
|
|
142
|
+
def allow(self):
|
|
143
|
+
return {"behavior": "allow"}
|
|
144
|
+
|
|
145
|
+
def deny(self, message):
|
|
146
|
+
return {"behavior": "deny", "message": message}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Codex(Claude):
|
|
150
|
+
"""OpenAI Codex CLI `PermissionRequest` hook (~/.codex/hooks.json): Claude Code's wire format.
|
|
151
|
+
|
|
152
|
+
Its shell tool may pass the command as an argv list, and edits come as one apply_patch text.
|
|
153
|
+
"""
|
|
154
|
+
name, title = "codex", "OpenAI Codex CLI"
|
|
155
|
+
# Its hooks.json keeps timeout 600: changing a trusted hook makes the user re-approve it in /hooks.
|
|
156
|
+
max_wait_s = 580
|
|
157
|
+
|
|
158
|
+
SHELL = {"shell", "bash", "exec_command", "local_shell", "container.exec", "unified_exec"}
|
|
159
|
+
|
|
160
|
+
def parse(self, raw):
|
|
161
|
+
hi = super().parse(raw)
|
|
162
|
+
low = hi.tool.lower()
|
|
163
|
+
args = hi.tool_input
|
|
164
|
+
if low in self.SHELL or isinstance(args.get("command"), list):
|
|
165
|
+
command = args.get("command") or args.get("cmd") or ""
|
|
166
|
+
if isinstance(command, list):
|
|
167
|
+
# ["bash", "-lc", "npm test"] → the script; anything else → joined argv
|
|
168
|
+
command = command[-1] if len(command) >= 3 and command[1] in ("-lc", "-c") else " ".join(map(str, command))
|
|
169
|
+
hi = HookInput("Bash", {"command": str(command), "description": args.get("justification")},
|
|
170
|
+
hi.cwd, hi.session_id)
|
|
171
|
+
elif low in ("apply_patch", "applypatch"):
|
|
172
|
+
patch = str(args.get("input") or args.get("patch") or args.get("command") or json.dumps(args))
|
|
173
|
+
files, deletes = protocol.patch_files(patch, hi.cwd)
|
|
174
|
+
hi = HookInput("Edit", {"file_path": files[0] if files else protocol.PATCH_UNKNOWN, "old_string": "",
|
|
175
|
+
"new_string": patch, "patch_files": files, "patch_deletes": deletes},
|
|
176
|
+
hi.cwd, hi.session_id)
|
|
177
|
+
hi.tool_input = {k: v for k, v in hi.tool_input.items() if v is not None}
|
|
178
|
+
return hi
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
AGENTS: dict[str, Agent] = {a.name: a for a in (Claude(), Gemini(), Copilot(), Codex())}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get(name: str | None) -> Agent:
|
|
185
|
+
return AGENTS.get((name or "claude").lower(), AGENTS["claude"])
|