bund 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.
- bund/__init__.py +3 -0
- bund/__main__.py +3 -0
- bund/ask.py +35 -0
- bund/cli.py +118 -0
- bund/config.py +51 -0
- bund/hook.py +189 -0
- bund/install.py +125 -0
- bund/poller.py +171 -0
- bund/rules.py +54 -0
- bund/setup_chat.py +149 -0
- bund/telegram.py +78 -0
- bund/templates/plain.json +37 -0
- bund/templates/sarcasm.json +50 -0
- bund/templates.py +47 -0
- bund-0.1.0.dist-info/METADATA +183 -0
- bund-0.1.0.dist-info/RECORD +20 -0
- bund-0.1.0.dist-info/WHEEL +5 -0
- bund-0.1.0.dist-info/entry_points.txt +2 -0
- bund-0.1.0.dist-info/licenses/LICENSE +21 -0
- bund-0.1.0.dist-info/top_level.txt +1 -0
bund/__init__.py
ADDED
bund/__main__.py
ADDED
bund/ask.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import secrets
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from . import config, hook, templates
|
|
6
|
+
from .telegram import TelegramError, send
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ask(question: str, timeout: int = None) -> int:
|
|
10
|
+
"""Universal protocol: prints allow|deny|timeout, exit 0/1/2."""
|
|
11
|
+
cfg = config.load()
|
|
12
|
+
if not config.ready(cfg):
|
|
13
|
+
print("bund is not configured. Run: bund set --token <token> && bund set --chat auto",
|
|
14
|
+
file=sys.stderr)
|
|
15
|
+
print("timeout", flush=True)
|
|
16
|
+
return 2
|
|
17
|
+
request_id = secrets.token_hex(6)
|
|
18
|
+
text = f"{templates.say('permission', {'tool': 'a question', 'command': question})}\n\n<pre>{html.escape(question)}</pre>"
|
|
19
|
+
rows = [[("✅ Approve", f"{request_id}:a"), ("❌ Deny", f"{request_id}:d")]]
|
|
20
|
+
try:
|
|
21
|
+
send(text, rows, cfg=cfg)
|
|
22
|
+
except TelegramError as e:
|
|
23
|
+
print(f"Telegram error: {e}", file=sys.stderr)
|
|
24
|
+
print("timeout", flush=True)
|
|
25
|
+
return 2
|
|
26
|
+
dec = hook.wait_for(request_id, cfg, timeout or config.timeout_sec(cfg))
|
|
27
|
+
action = (dec or {}).get("action")
|
|
28
|
+
if action in ("approve", "approve_all"):
|
|
29
|
+
print("allow", flush=True)
|
|
30
|
+
return 0
|
|
31
|
+
if action == "deny":
|
|
32
|
+
print("deny", flush=True)
|
|
33
|
+
return 1
|
|
34
|
+
print("timeout", flush=True)
|
|
35
|
+
return 2
|
bund/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from . import __version__, config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main(argv=None) -> None:
|
|
8
|
+
parser = argparse.ArgumentParser(
|
|
9
|
+
prog="bund",
|
|
10
|
+
description="Telegram pings for coding agents. Approve from your phone.",
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument("-m", "--message", metavar="TEXT", help="send a Telegram message")
|
|
13
|
+
parser.add_argument("--version", action="version", version=f"bund {__version__}")
|
|
14
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
15
|
+
|
|
16
|
+
p_set = sub.add_parser("set", help="configure token, chat, or tone")
|
|
17
|
+
group = p_set.add_mutually_exclusive_group(required=True)
|
|
18
|
+
group.add_argument("--token", metavar="BOT_TOKEN", help="Telegram bot token from @BotFather")
|
|
19
|
+
group.add_argument("--chat", metavar="ID|auto", help="chat id, or 'auto' to pick yourself")
|
|
20
|
+
group.add_argument("--tone", choices=["sarcasm", "plain"], help="message tone")
|
|
21
|
+
|
|
22
|
+
sub.add_parser("install", help="add bund hooks to ~/.claude/settings.json")
|
|
23
|
+
sub.add_parser("uninstall", help="remove only bund's hooks")
|
|
24
|
+
sub.add_parser("test", help="send a test message to your chat")
|
|
25
|
+
|
|
26
|
+
p_ask = sub.add_parser("ask", help="ask a question over Telegram; prints allow|deny|timeout")
|
|
27
|
+
p_ask.add_argument("question", nargs="*", help="the question to ask")
|
|
28
|
+
p_ask.add_argument("--timeout", type=int, default=None, help="seconds to wait (default 600)")
|
|
29
|
+
|
|
30
|
+
sub.add_parser("hook", help="internal: called by agent hooks, reads JSON from stdin")
|
|
31
|
+
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
|
|
34
|
+
if args.message:
|
|
35
|
+
sys.exit(_send_message(args.message))
|
|
36
|
+
|
|
37
|
+
if args.cmd == "set":
|
|
38
|
+
sys.exit(_cmd_set(args))
|
|
39
|
+
if args.cmd == "install":
|
|
40
|
+
from . import install as install_mod
|
|
41
|
+
sys.exit(install_mod.install())
|
|
42
|
+
if args.cmd == "uninstall":
|
|
43
|
+
from . import install as install_mod
|
|
44
|
+
sys.exit(install_mod.uninstall())
|
|
45
|
+
if args.cmd == "test":
|
|
46
|
+
sys.exit(_cmd_test())
|
|
47
|
+
if args.cmd == "ask":
|
|
48
|
+
from . import ask as ask_mod
|
|
49
|
+
question = " ".join(args.question).strip()
|
|
50
|
+
if not question and not sys.stdin.isatty():
|
|
51
|
+
question = sys.stdin.read().strip()
|
|
52
|
+
if not question:
|
|
53
|
+
print("Give a question: bund ask \"deploy to prod?\"", file=sys.stderr)
|
|
54
|
+
sys.exit(2)
|
|
55
|
+
sys.exit(ask_mod.ask(question, args.timeout))
|
|
56
|
+
if args.cmd == "hook":
|
|
57
|
+
from . import hook as hook_mod
|
|
58
|
+
hook_mod.main()
|
|
59
|
+
|
|
60
|
+
parser.print_help()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cmd_set(args) -> int:
|
|
64
|
+
from . import setup_chat
|
|
65
|
+
|
|
66
|
+
cfg = config.load()
|
|
67
|
+
if args.token is not None:
|
|
68
|
+
return setup_chat.set_token(cfg, args.token)
|
|
69
|
+
if args.chat is not None:
|
|
70
|
+
if args.chat.strip().lower() == "auto":
|
|
71
|
+
return setup_chat.set_chat_auto(cfg)
|
|
72
|
+
return setup_chat.set_chat_id(cfg, args.chat)
|
|
73
|
+
if args.tone:
|
|
74
|
+
cfg["tone"] = args.tone
|
|
75
|
+
config.save(cfg)
|
|
76
|
+
from . import templates
|
|
77
|
+
print(f"Tone set to {args.tone}. Sample:")
|
|
78
|
+
print(" " + templates.say("permission", {"tool": "Bash", "project": "my-project"}))
|
|
79
|
+
return 0
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _cmd_test() -> int:
|
|
84
|
+
from .telegram import TelegramError, send
|
|
85
|
+
|
|
86
|
+
cfg = config.load()
|
|
87
|
+
if not config.ready(cfg):
|
|
88
|
+
print("Not configured yet. Run: bund set --token <token> && bund set --chat auto")
|
|
89
|
+
return 1
|
|
90
|
+
try:
|
|
91
|
+
from . import templates
|
|
92
|
+
|
|
93
|
+
send(templates.say("test", {}), cfg=cfg)
|
|
94
|
+
except TelegramError as e:
|
|
95
|
+
print(f"Telegram error: {e}")
|
|
96
|
+
return 1
|
|
97
|
+
print(f"Test message sent to chat {cfg['chatId']}.")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _send_message(text: str) -> int:
|
|
102
|
+
from .telegram import TelegramError, send
|
|
103
|
+
|
|
104
|
+
cfg = config.load()
|
|
105
|
+
if not config.ready(cfg):
|
|
106
|
+
print("Not configured yet. Run: bund set --token <token> && bund set --chat auto")
|
|
107
|
+
return 1
|
|
108
|
+
try:
|
|
109
|
+
send(text, cfg=cfg)
|
|
110
|
+
except TelegramError as e:
|
|
111
|
+
print(f"Telegram error: {e}")
|
|
112
|
+
return 1
|
|
113
|
+
print("Sent.")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
bund/config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import stat
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
DEFAULT_TIMEOUT_SEC = 600
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def config_dir() -> Path:
|
|
10
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(str(Path.home()), ".config")
|
|
11
|
+
return Path(base) / "bund"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config_path() -> Path:
|
|
15
|
+
return config_dir() / "config.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load() -> dict:
|
|
19
|
+
try:
|
|
20
|
+
with open(config_path(), encoding="utf-8") as f:
|
|
21
|
+
data = json.load(f)
|
|
22
|
+
return data if isinstance(data, dict) else {}
|
|
23
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save(cfg: dict) -> dict:
|
|
28
|
+
d = config_dir()
|
|
29
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
p = config_path()
|
|
31
|
+
tmp = p.with_name("config.json.tmp")
|
|
32
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
33
|
+
json.dump(cfg, f, indent=2)
|
|
34
|
+
f.write("\n")
|
|
35
|
+
os.replace(tmp, p)
|
|
36
|
+
try:
|
|
37
|
+
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
return cfg
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ready(cfg: dict) -> bool:
|
|
44
|
+
return bool(cfg.get("token") and cfg.get("chatId"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def timeout_sec(cfg: dict) -> int:
|
|
48
|
+
try:
|
|
49
|
+
return int(cfg.get("timeoutSec") or DEFAULT_TIMEOUT_SEC)
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return DEFAULT_TIMEOUT_SEC
|
bund/hook.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from . import config, poller, rules, templates
|
|
9
|
+
from .telegram import TelegramError, edit, send
|
|
10
|
+
|
|
11
|
+
MAX_CMD_CHARS = 3500
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
try:
|
|
16
|
+
_run()
|
|
17
|
+
except Exception:
|
|
18
|
+
pass # universal fallback: silent exit 0 -> terminal prompt
|
|
19
|
+
sys.exit(0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run() -> None:
|
|
23
|
+
raw = sys.stdin.read()
|
|
24
|
+
try:
|
|
25
|
+
event = json.loads(raw)
|
|
26
|
+
except (json.JSONDecodeError, ValueError):
|
|
27
|
+
return
|
|
28
|
+
if not isinstance(event, dict):
|
|
29
|
+
return
|
|
30
|
+
name = event.get("hook_event_name")
|
|
31
|
+
if name == "PermissionRequest":
|
|
32
|
+
permission(event)
|
|
33
|
+
elif name == "Stop":
|
|
34
|
+
stop(event)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _truncate(text: str) -> str:
|
|
38
|
+
if len(text) <= MAX_CMD_CHARS:
|
|
39
|
+
return text
|
|
40
|
+
return text[:MAX_CMD_CHARS] + "\n… (truncated, deny if you can't read it all)"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _display(event: dict) -> str:
|
|
44
|
+
tool = event.get("tool_name") or "?"
|
|
45
|
+
tool_input = event.get("tool_input")
|
|
46
|
+
return _truncate(rules.command_text(tool, tool_input))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_decision(action: str, tool_name: str = None, rule=None) -> dict:
|
|
50
|
+
"""Claude Code PermissionRequest output. Shared with tests."""
|
|
51
|
+
decision = {}
|
|
52
|
+
if action == "approve":
|
|
53
|
+
decision["behavior"] = "allow"
|
|
54
|
+
elif action == "approve_all":
|
|
55
|
+
decision["behavior"] = "allow"
|
|
56
|
+
entry = {"type": "addRules", "behavior": "allow", "destination": "localSettings"}
|
|
57
|
+
if rule:
|
|
58
|
+
tool, pattern = rule
|
|
59
|
+
r = {"toolName": tool}
|
|
60
|
+
if pattern:
|
|
61
|
+
r["ruleContent"] = pattern
|
|
62
|
+
entry["rules"] = [r]
|
|
63
|
+
else:
|
|
64
|
+
entry["rules"] = [{"toolName": tool_name or "?"}]
|
|
65
|
+
decision["updatedPermissions"] = [entry]
|
|
66
|
+
else: # deny
|
|
67
|
+
decision["behavior"] = "deny"
|
|
68
|
+
decision["message"] = "Denied from phone"
|
|
69
|
+
return {
|
|
70
|
+
"hookSpecificOutput": {
|
|
71
|
+
"hookEventName": "PermissionRequest",
|
|
72
|
+
"decision": decision,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def wait_for(request_id: str, cfg: dict, timeout: int):
|
|
78
|
+
"""Watch decision file; poll Telegram when holding the lock. Returns dict or None."""
|
|
79
|
+
deadline = time.time() + timeout
|
|
80
|
+
while time.time() < deadline:
|
|
81
|
+
dec = poller.read_decision(request_id)
|
|
82
|
+
if dec:
|
|
83
|
+
poller.drop_decision(request_id)
|
|
84
|
+
return dec
|
|
85
|
+
if poller.try_acquire():
|
|
86
|
+
try:
|
|
87
|
+
remaining = deadline - time.time()
|
|
88
|
+
if remaining > 1:
|
|
89
|
+
poller.poll_round(cfg, max_wait=min(poller.POLL_TIMEOUT, int(remaining)))
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
finally:
|
|
93
|
+
poller.release()
|
|
94
|
+
else:
|
|
95
|
+
time.sleep(0.5)
|
|
96
|
+
dec = poller.read_decision(request_id)
|
|
97
|
+
if dec:
|
|
98
|
+
poller.drop_decision(request_id)
|
|
99
|
+
return dec
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def permission(event: dict) -> None:
|
|
104
|
+
cfg = config.load()
|
|
105
|
+
if not config.ready(cfg):
|
|
106
|
+
return
|
|
107
|
+
tool = event.get("tool_name") or "?"
|
|
108
|
+
tool_input = event.get("tool_input")
|
|
109
|
+
project = os.path.basename(str(event.get("cwd") or ".")) or "."
|
|
110
|
+
display = _display(event)
|
|
111
|
+
dangerous = rules.is_dangerous(tool, tool_input)
|
|
112
|
+
rule = None if dangerous else rules.rule_for(tool, tool_input)
|
|
113
|
+
|
|
114
|
+
request_id = secrets.token_hex(6)
|
|
115
|
+
lines = [
|
|
116
|
+
templates.say(
|
|
117
|
+
"permission", {"tool": tool, "command": display, "project": project}
|
|
118
|
+
)
|
|
119
|
+
]
|
|
120
|
+
if dangerous:
|
|
121
|
+
lines.append(templates.say("dangerous", {}))
|
|
122
|
+
lines.append(f"<pre>{html.escape(display)}</pre>")
|
|
123
|
+
text = "\n\n".join(lines)
|
|
124
|
+
|
|
125
|
+
rows = [[("✅ Approve", f"{request_id}:a"), ("❌ Deny", f"{request_id}:d")]]
|
|
126
|
+
if rule:
|
|
127
|
+
label = f"♾ Approve all similar: {rule[0]}({rule[1]})" if rule[1] else f"♾ Approve all similar: {rule[0]}"
|
|
128
|
+
rows.append([(label, f"{request_id}:s")])
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
msg = send(text, rows, cfg=cfg)
|
|
132
|
+
except TelegramError:
|
|
133
|
+
return
|
|
134
|
+
message_id = msg.get("message_id")
|
|
135
|
+
chat_id = msg.get("chat", {}).get("id", cfg.get("chatId"))
|
|
136
|
+
|
|
137
|
+
timeout = config.timeout_sec(cfg)
|
|
138
|
+
dec = wait_for(request_id, cfg, timeout)
|
|
139
|
+
|
|
140
|
+
if dec is None:
|
|
141
|
+
_outcome(chat_id, message_id, templates.say("timeout", {}), cfg)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
action = dec.get("action", "deny")
|
|
145
|
+
outcome_line = templates.say(
|
|
146
|
+
{"approve": "approved", "deny": "denied", "approve_all": "approve_all"}.get(
|
|
147
|
+
action, "denied"
|
|
148
|
+
),
|
|
149
|
+
{},
|
|
150
|
+
)
|
|
151
|
+
_outcome(chat_id, message_id, outcome_line, cfg)
|
|
152
|
+
print(
|
|
153
|
+
json.dumps(build_decision(action, tool, rule)),
|
|
154
|
+
flush=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _outcome(chat_id, message_id: int, line: str, cfg: dict) -> None:
|
|
159
|
+
try:
|
|
160
|
+
edit(chat_id, message_id, line, None, cfg=cfg)
|
|
161
|
+
except TelegramError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def stop(event: dict) -> None:
|
|
166
|
+
cfg = config.load()
|
|
167
|
+
if not config.ready(cfg):
|
|
168
|
+
return
|
|
169
|
+
session = str(event.get("session_id") or "")
|
|
170
|
+
project = os.path.basename(str(event.get("cwd") or ".")) or "."
|
|
171
|
+
state = poller.load_state()
|
|
172
|
+
last = state.setdefault("lastStop", {})
|
|
173
|
+
now = time.time()
|
|
174
|
+
if session and session in last:
|
|
175
|
+
try:
|
|
176
|
+
if now - float(last[session]) < 20:
|
|
177
|
+
return
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
pass
|
|
180
|
+
if session:
|
|
181
|
+
last[session] = now
|
|
182
|
+
if len(last) > 50:
|
|
183
|
+
for key in [k for k, v in last.items() if isinstance(v, (int, float)) and now - v > 3600]:
|
|
184
|
+
last.pop(key, None)
|
|
185
|
+
poller.save_state(state)
|
|
186
|
+
send(
|
|
187
|
+
templates.say("done", {"project": project, "time": time.strftime("%H:%M")}),
|
|
188
|
+
cfg=cfg,
|
|
189
|
+
)
|
bund/install.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
7
|
+
BACKUP = Path(str(SETTINGS) + ".bund-backup")
|
|
8
|
+
|
|
9
|
+
HOOK_TIMEOUT = 900
|
|
10
|
+
EVENTS = ("PermissionRequest", "Stop")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _entry() -> dict:
|
|
14
|
+
return {
|
|
15
|
+
"type": "command",
|
|
16
|
+
"command": sys.executable,
|
|
17
|
+
"args": ["-m", "bund", "hook"],
|
|
18
|
+
"timeout": HOOK_TIMEOUT,
|
|
19
|
+
"statusMessage": "waiting for your phone…",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_bund_handler(handler) -> bool:
|
|
24
|
+
if not isinstance(handler, dict):
|
|
25
|
+
return False
|
|
26
|
+
joined = (str(handler.get("command", "")) + " " + " ".join(
|
|
27
|
+
str(a) for a in (handler.get("args") or [])
|
|
28
|
+
)).lower()
|
|
29
|
+
return "bund" in joined and "hook" in joined
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read() -> dict:
|
|
33
|
+
if not SETTINGS.exists():
|
|
34
|
+
return {}
|
|
35
|
+
try:
|
|
36
|
+
with open(SETTINGS, encoding="utf-8") as f:
|
|
37
|
+
data = json.load(f)
|
|
38
|
+
except (json.JSONDecodeError, OSError):
|
|
39
|
+
print(f"ERROR: {SETTINGS} is not valid JSON. Fix it first; bund won't overwrite it.")
|
|
40
|
+
raise SystemExit(1)
|
|
41
|
+
if not isinstance(data, dict):
|
|
42
|
+
raise SystemExit(f"ERROR: {SETTINGS} has unexpected content. Not touching it.")
|
|
43
|
+
if not BACKUP.exists():
|
|
44
|
+
shutil.copy2(SETTINGS, BACKUP)
|
|
45
|
+
print(f"Backed up existing settings to {BACKUP.name}")
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _write(settings: dict) -> None:
|
|
50
|
+
SETTINGS.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
tmp = SETTINGS.with_name("settings.json.tmp")
|
|
52
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
53
|
+
json.dump(settings, f, indent=2)
|
|
54
|
+
f.write("\n")
|
|
55
|
+
shutil.move(str(tmp), str(SETTINGS))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def install() -> int:
|
|
59
|
+
settings = _read()
|
|
60
|
+
hooks = settings.setdefault("hooks", {})
|
|
61
|
+
if not isinstance(hooks, dict):
|
|
62
|
+
print(f"ERROR: {SETTINGS} has a 'hooks' value of the wrong type. Not touching it.")
|
|
63
|
+
return 1
|
|
64
|
+
added = 0
|
|
65
|
+
for event in EVENTS:
|
|
66
|
+
groups = hooks.setdefault(event, [])
|
|
67
|
+
if not isinstance(groups, list):
|
|
68
|
+
continue
|
|
69
|
+
if any(
|
|
70
|
+
is_bund_handler(h)
|
|
71
|
+
for g in groups
|
|
72
|
+
if isinstance(g, dict)
|
|
73
|
+
for h in (g.get("hooks") or [])
|
|
74
|
+
):
|
|
75
|
+
continue
|
|
76
|
+
groups.append({"hooks": [_entry()]})
|
|
77
|
+
added += 1
|
|
78
|
+
if not added:
|
|
79
|
+
print("bund hooks are already installed.")
|
|
80
|
+
return 0
|
|
81
|
+
_write(settings)
|
|
82
|
+
print(f"Installed {added} hook(s) into {SETTINGS}")
|
|
83
|
+
print("Claude Code will now ping your phone for permissions.")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def uninstall() -> int:
|
|
88
|
+
if not SETTINGS.exists():
|
|
89
|
+
print("Nothing to remove (no settings file).")
|
|
90
|
+
return 0
|
|
91
|
+
settings = _read()
|
|
92
|
+
hooks = settings.get("hooks")
|
|
93
|
+
if not isinstance(hooks, dict):
|
|
94
|
+
print("Nothing to remove.")
|
|
95
|
+
return 0
|
|
96
|
+
removed = 0
|
|
97
|
+
for event in list(hooks.keys()):
|
|
98
|
+
groups = hooks.get(event)
|
|
99
|
+
if not isinstance(groups, list):
|
|
100
|
+
continue
|
|
101
|
+
kept_groups = []
|
|
102
|
+
for g in groups:
|
|
103
|
+
if not isinstance(g, dict):
|
|
104
|
+
kept_groups.append(g)
|
|
105
|
+
continue
|
|
106
|
+
handlers = g.get("hooks") or []
|
|
107
|
+
kept = [h for h in handlers if not is_bund_handler(h)]
|
|
108
|
+
removed += len(handlers) - len(kept)
|
|
109
|
+
if kept:
|
|
110
|
+
g["hooks"] = kept
|
|
111
|
+
kept_groups.append(g)
|
|
112
|
+
elif not handlers:
|
|
113
|
+
kept_groups.append(g)
|
|
114
|
+
if kept_groups:
|
|
115
|
+
hooks[event] = kept_groups
|
|
116
|
+
else:
|
|
117
|
+
hooks.pop(event, None)
|
|
118
|
+
if not hooks:
|
|
119
|
+
settings.pop("hooks", None)
|
|
120
|
+
_write(settings)
|
|
121
|
+
if removed:
|
|
122
|
+
print(f"Removed {removed} bund hook(s). Everything else was left alone.")
|
|
123
|
+
else:
|
|
124
|
+
print("No bund hooks found.")
|
|
125
|
+
return 0
|
bund/poller.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import config
|
|
8
|
+
from .telegram import TelegramError, answer, call
|
|
9
|
+
|
|
10
|
+
LOCK_STALE_SEC = 60
|
|
11
|
+
DECISION_TTL_SEC = 3600
|
|
12
|
+
POLL_TIMEOUT = 25
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _state_path() -> Path:
|
|
16
|
+
return config.config_dir() / "state.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_state() -> dict:
|
|
20
|
+
try:
|
|
21
|
+
with open(_state_path(), encoding="utf-8") as f:
|
|
22
|
+
data = json.load(f)
|
|
23
|
+
return data if isinstance(data, dict) else {}
|
|
24
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save_state(state: dict) -> None:
|
|
29
|
+
d = config.config_dir()
|
|
30
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
p = _state_path()
|
|
32
|
+
tmp = p.with_name("state.json.tmp")
|
|
33
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
34
|
+
json.dump(state, f)
|
|
35
|
+
os.replace(tmp, p)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def decisions_dir() -> Path:
|
|
39
|
+
d = config.config_dir() / "decisions"
|
|
40
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return d
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lock_dir() -> Path:
|
|
45
|
+
return config.config_dir() / "poller.lock"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def try_acquire() -> bool:
|
|
49
|
+
config.config_dir().mkdir(parents=True, exist_ok=True)
|
|
50
|
+
ld = lock_dir()
|
|
51
|
+
for _ in range(2):
|
|
52
|
+
try:
|
|
53
|
+
ld.mkdir()
|
|
54
|
+
_touch_heartbeat(ld)
|
|
55
|
+
return True
|
|
56
|
+
except FileExistsError:
|
|
57
|
+
if _is_stale(ld):
|
|
58
|
+
shutil.rmtree(ld, ignore_errors=True)
|
|
59
|
+
continue
|
|
60
|
+
return False
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_stale(ld: Path) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
return (time.time() - (ld / "heartbeat").stat().st_mtime) > LOCK_STALE_SEC
|
|
67
|
+
except OSError:
|
|
68
|
+
try:
|
|
69
|
+
return (time.time() - ld.stat().st_mtime) > LOCK_STALE_SEC
|
|
70
|
+
except OSError:
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _touch_heartbeat(ld: Path = None) -> None:
|
|
75
|
+
try:
|
|
76
|
+
hb = (ld or lock_dir()) / "heartbeat"
|
|
77
|
+
hb.write_text(str(time.time()), encoding="utf-8")
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def release() -> None:
|
|
83
|
+
shutil.rmtree(lock_dir(), ignore_errors=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_decision(request_id: str):
|
|
87
|
+
p = decisions_dir() / f"{request_id}.json"
|
|
88
|
+
try:
|
|
89
|
+
with open(p, encoding="utf-8") as f:
|
|
90
|
+
data = json.load(f)
|
|
91
|
+
return data if isinstance(data, dict) else None
|
|
92
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def drop_decision(request_id: str) -> None:
|
|
97
|
+
try:
|
|
98
|
+
(decisions_dir() / f"{request_id}.json").unlink()
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write_decision(request_id: str, action: str) -> bool:
|
|
104
|
+
p = decisions_dir() / f"{request_id}.json"
|
|
105
|
+
if p.exists():
|
|
106
|
+
return False
|
|
107
|
+
tmp = p.with_name(f".{request_id}.{os.getpid()}.tmp")
|
|
108
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
109
|
+
json.dump({"id": request_id, "action": action, "at": time.time()}, f)
|
|
110
|
+
os.replace(tmp, p)
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cleanup_decisions() -> None:
|
|
115
|
+
now = time.time()
|
|
116
|
+
try:
|
|
117
|
+
for p in decisions_dir().glob("*.json"):
|
|
118
|
+
try:
|
|
119
|
+
if now - p.stat().st_mtime > DECISION_TTL_SEC:
|
|
120
|
+
p.unlink()
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
except OSError:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_ACTIONS = {"a": "approve", "d": "deny", "s": "approve_all"}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _handle_callback(cq: dict, cfg: dict) -> None:
|
|
131
|
+
from_id = (cq.get("from") or {}).get("id")
|
|
132
|
+
chat_id = ((cq.get("message") or {}).get("chat") or {}).get("id")
|
|
133
|
+
owner = str(cfg.get("chatId"))
|
|
134
|
+
if str(from_id) != owner and str(chat_id) != owner:
|
|
135
|
+
return
|
|
136
|
+
data = cq.get("callback_data") or ""
|
|
137
|
+
try:
|
|
138
|
+
request_id, code = data.split(":", 1)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return
|
|
141
|
+
action = _ACTIONS.get(code)
|
|
142
|
+
if not action or not request_id:
|
|
143
|
+
return
|
|
144
|
+
created = write_decision(request_id, action)
|
|
145
|
+
try:
|
|
146
|
+
answer(cq.get("id", ""), "Already handled" if not created else "Got it", cfg=cfg)
|
|
147
|
+
except TelegramError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def poll_round(cfg: dict, max_wait: int = POLL_TIMEOUT) -> int:
|
|
152
|
+
"""One getUpdates round. Writes decision files for ALL waiting requests."""
|
|
153
|
+
cleanup_decisions()
|
|
154
|
+
state = load_state()
|
|
155
|
+
offset = int(state.get("updateOffset") or 0)
|
|
156
|
+
updates = call(
|
|
157
|
+
cfg.get("token"),
|
|
158
|
+
"getUpdates",
|
|
159
|
+
{"offset": offset, "timeout": max_wait, "allowed_updates": ["callback_query"]},
|
|
160
|
+
timeout=max_wait + 10,
|
|
161
|
+
)
|
|
162
|
+
for upd in updates or []:
|
|
163
|
+
try:
|
|
164
|
+
state["updateOffset"] = int(upd["update_id"]) + 1
|
|
165
|
+
except (KeyError, TypeError, ValueError):
|
|
166
|
+
continue
|
|
167
|
+
cq = upd.get("callback_query")
|
|
168
|
+
if isinstance(cq, dict):
|
|
169
|
+
_handle_callback(cq, cfg)
|
|
170
|
+
save_state(state)
|
|
171
|
+
return len(updates or [])
|
bund/rules.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
DANGER_PATTERNS = [
|
|
5
|
+
r"\brm\s+-\w*[rR]\w*[fF]\b",
|
|
6
|
+
r"\brm\s+-\w*[fF]\w*[rR]\b",
|
|
7
|
+
r"\bsudo\b",
|
|
8
|
+
r"git\s+push\b.*(\s-f\b|--force\b)",
|
|
9
|
+
r"(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da|fi)?sh\b",
|
|
10
|
+
r"\bchmod\s+-[a-zA-Z]*[rR]",
|
|
11
|
+
r"\bDROP\s+TABLE\b",
|
|
12
|
+
r"\bmkfs(\.\w+)?\b",
|
|
13
|
+
r"\bdd\s+if=",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
_COMPLEX = re.compile(r"&&|;|\||\$\(|`")
|
|
17
|
+
|
|
18
|
+
_FILE_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "Read"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def command_text(tool_name: str, tool_input) -> str:
|
|
22
|
+
if isinstance(tool_input, dict):
|
|
23
|
+
cmd = tool_input.get("command")
|
|
24
|
+
if isinstance(cmd, str) and cmd:
|
|
25
|
+
return cmd
|
|
26
|
+
for key in ("file_path", "path", "pattern", "plan"):
|
|
27
|
+
val = tool_input.get(key)
|
|
28
|
+
if isinstance(val, str) and val:
|
|
29
|
+
return val
|
|
30
|
+
try:
|
|
31
|
+
return json.dumps(tool_input, ensure_ascii=False)
|
|
32
|
+
except (TypeError, ValueError):
|
|
33
|
+
return str(tool_input)
|
|
34
|
+
return str(tool_input)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_dangerous(tool_name: str, tool_input) -> bool:
|
|
38
|
+
text = command_text(tool_name, tool_input)
|
|
39
|
+
return any(re.search(p, text, re.IGNORECASE) for p in DANGER_PATTERNS)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rule_for(tool_name: str, tool_input):
|
|
43
|
+
"""Return (toolName, ruleContent|None) for 'approve all similar', or None."""
|
|
44
|
+
if not tool_name:
|
|
45
|
+
return None
|
|
46
|
+
if tool_name == "Bash":
|
|
47
|
+
cmd = command_text("Bash", tool_input).strip()
|
|
48
|
+
if not cmd or _COMPLEX.search(cmd):
|
|
49
|
+
return None
|
|
50
|
+
words = cmd.split()
|
|
51
|
+
return ("Bash", " ".join(words[:2]) + " *")
|
|
52
|
+
if tool_name in _FILE_TOOLS or tool_name.startswith("mcp__"):
|
|
53
|
+
return (tool_name, None)
|
|
54
|
+
return None
|
bund/setup_chat.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from . import config, templates
|
|
6
|
+
from .telegram import TelegramError, call, send
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def set_token(cfg: dict, token: str) -> int:
|
|
10
|
+
token = token.strip()
|
|
11
|
+
try:
|
|
12
|
+
me = call(token, "getMe", timeout=10)
|
|
13
|
+
except TelegramError as e:
|
|
14
|
+
print(f"Telegram rejected that token: {e}")
|
|
15
|
+
print("Get a fresh one from @BotFather and try again.")
|
|
16
|
+
return 1
|
|
17
|
+
cfg["token"] = token
|
|
18
|
+
config.save(cfg)
|
|
19
|
+
name = me.get("username") or "your bot"
|
|
20
|
+
print(f"Token saved. Bot: @{name}")
|
|
21
|
+
if not cfg.get("chatId"):
|
|
22
|
+
print("Next: bund set --chat auto")
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def set_chat_id(cfg: dict, chat_id: str) -> int:
|
|
27
|
+
chat_id = chat_id.strip()
|
|
28
|
+
if not chat_id.lstrip("-").isdigit():
|
|
29
|
+
print("Chat id must be numeric. Tip: bund set --chat auto does it for you.")
|
|
30
|
+
return 1
|
|
31
|
+
if not cfg.get("token"):
|
|
32
|
+
print("Set the token first: bund set --token <botfather-token>")
|
|
33
|
+
return 1
|
|
34
|
+
try:
|
|
35
|
+
send(
|
|
36
|
+
"bund connected ✅. This is your approval chat now.",
|
|
37
|
+
chat_id=int(chat_id),
|
|
38
|
+
cfg=cfg,
|
|
39
|
+
)
|
|
40
|
+
except TelegramError:
|
|
41
|
+
print("Telegram refused to message that chat.")
|
|
42
|
+
print("Open the bot and press Start first. Bots can't message people who haven't said hi.")
|
|
43
|
+
return 1
|
|
44
|
+
cfg["chatId"] = int(chat_id)
|
|
45
|
+
config.save(cfg)
|
|
46
|
+
print(f"Chat saved: {chat_id}")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def set_chat_auto(cfg: dict) -> int:
|
|
51
|
+
if not cfg.get("token"):
|
|
52
|
+
print("Set the token first: bund set --token <botfather-token>")
|
|
53
|
+
return 1
|
|
54
|
+
while True:
|
|
55
|
+
choice = _pick_chat(cfg)
|
|
56
|
+
if choice is None:
|
|
57
|
+
return 1
|
|
58
|
+
if choice == "retry":
|
|
59
|
+
continue
|
|
60
|
+
cfg["chatId"] = choice
|
|
61
|
+
config.save(cfg)
|
|
62
|
+
try:
|
|
63
|
+
send(templates.say("setup_ok", {}), chat_id=choice, cfg=cfg)
|
|
64
|
+
except TelegramError:
|
|
65
|
+
pass
|
|
66
|
+
print(f"Chat saved: {choice}")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _pick_chat(cfg: dict):
|
|
71
|
+
try:
|
|
72
|
+
me = call(cfg["token"], "getMe", timeout=10)
|
|
73
|
+
except TelegramError as e:
|
|
74
|
+
print(f"Telegram error: {e}")
|
|
75
|
+
return None
|
|
76
|
+
username = me.get("username") or ""
|
|
77
|
+
print(f"Open https://t.me/{username} and send the bot any message.")
|
|
78
|
+
print("Waiting up to 2 minutes. Press Enter when done waiting.")
|
|
79
|
+
|
|
80
|
+
stop = threading.Event()
|
|
81
|
+
|
|
82
|
+
def _wait_enter():
|
|
83
|
+
try:
|
|
84
|
+
input()
|
|
85
|
+
except (EOFError, OSError):
|
|
86
|
+
pass
|
|
87
|
+
stop.set()
|
|
88
|
+
|
|
89
|
+
threading.Thread(target=_wait_enter, daemon=True).start()
|
|
90
|
+
|
|
91
|
+
senders = {}
|
|
92
|
+
offset = 0
|
|
93
|
+
try:
|
|
94
|
+
stale = call(cfg["token"], "getUpdates", {"timeout": 0}, timeout=10)
|
|
95
|
+
for upd in stale or []:
|
|
96
|
+
offset = max(offset, int(upd.get("update_id", 0)) + 1)
|
|
97
|
+
except TelegramError as e:
|
|
98
|
+
print(f"Telegram error: {e}")
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
deadline = time.time() + 120
|
|
102
|
+
while time.time() < deadline and not stop.is_set():
|
|
103
|
+
try:
|
|
104
|
+
updates = call(
|
|
105
|
+
cfg["token"],
|
|
106
|
+
"getUpdates",
|
|
107
|
+
{"offset": offset, "timeout": 5, "allowed_updates": ["message"]},
|
|
108
|
+
timeout=10,
|
|
109
|
+
)
|
|
110
|
+
except TelegramError:
|
|
111
|
+
time.sleep(1)
|
|
112
|
+
continue
|
|
113
|
+
for upd in updates or []:
|
|
114
|
+
try:
|
|
115
|
+
offset = int(upd["update_id"]) + 1
|
|
116
|
+
except (KeyError, TypeError, ValueError):
|
|
117
|
+
continue
|
|
118
|
+
msg = upd.get("message") or {}
|
|
119
|
+
chat = msg.get("chat") or {}
|
|
120
|
+
if chat.get("type") not in (None, "private"):
|
|
121
|
+
continue
|
|
122
|
+
sender = msg.get("from") or {}
|
|
123
|
+
sid = sender.get("id") or chat.get("id")
|
|
124
|
+
if sid is None or sid in senders:
|
|
125
|
+
continue
|
|
126
|
+
name = sender.get("first_name") or sender.get("username") or "Unknown"
|
|
127
|
+
uname = f" (@{sender['username']})" if sender.get("username") else ""
|
|
128
|
+
senders[sid] = f"{name}{uname}, id {sid}"
|
|
129
|
+
print(f"{len(senders)}) {senders[sid]}")
|
|
130
|
+
|
|
131
|
+
if not senders:
|
|
132
|
+
print("No messages received. Is the bot the one you messaged?")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
answer = input(f"Which one is you? [1-{len(senders)}, or n to retry]: ").strip()
|
|
137
|
+
except (EOFError, OSError):
|
|
138
|
+
return None
|
|
139
|
+
if answer.lower() in ("n", "no", "retry"):
|
|
140
|
+
return "retry"
|
|
141
|
+
try:
|
|
142
|
+
idx = int(answer)
|
|
143
|
+
except ValueError:
|
|
144
|
+
print("That's not a number.")
|
|
145
|
+
return None
|
|
146
|
+
if not 1 <= idx <= len(senders):
|
|
147
|
+
print(f"Pick a number between 1 and {len(senders)}.")
|
|
148
|
+
return None
|
|
149
|
+
return list(senders.keys())[idx - 1]
|
bund/telegram.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.error
|
|
3
|
+
import urllib.parse
|
|
4
|
+
import urllib.request
|
|
5
|
+
|
|
6
|
+
API = "https://api.telegram.org/bot{token}/{method}"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TelegramError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def call(token: str, method: str, params: dict = None, timeout: float = 15):
|
|
14
|
+
url = API.format(token=urllib.parse.quote(token), method=method)
|
|
15
|
+
data = json.dumps(params or {}).encode("utf-8")
|
|
16
|
+
req = urllib.request.Request(
|
|
17
|
+
url, data=data, headers={"Content-Type": "application/json"}
|
|
18
|
+
)
|
|
19
|
+
try:
|
|
20
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
21
|
+
body = json.load(resp)
|
|
22
|
+
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
|
23
|
+
raise TelegramError(f"{method} failed: {e}") from e
|
|
24
|
+
if not isinstance(body, dict) or not body.get("ok"):
|
|
25
|
+
desc = body.get("description", "unknown error") if isinstance(body, dict) else "bad response"
|
|
26
|
+
raise TelegramError(f"{method}: {desc}")
|
|
27
|
+
return body.get("result")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def keyboard(rows):
|
|
31
|
+
"""rows: [[(text, callback_data), ...], ...] -> Telegram inline_keyboard"""
|
|
32
|
+
if not rows:
|
|
33
|
+
return None
|
|
34
|
+
return {
|
|
35
|
+
"inline_keyboard": [
|
|
36
|
+
[{"text": text, "callback_data": data} for text, data in row] for row in rows
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def send(text: str, rows=None, chat_id=None, cfg: dict = None) -> dict:
|
|
42
|
+
cfg = cfg or {}
|
|
43
|
+
params = {
|
|
44
|
+
"chat_id": chat_id if chat_id is not None else cfg.get("chatId"),
|
|
45
|
+
"text": text,
|
|
46
|
+
"parse_mode": "HTML",
|
|
47
|
+
"disable_web_page_preview": True,
|
|
48
|
+
}
|
|
49
|
+
kb = keyboard(rows)
|
|
50
|
+
if kb:
|
|
51
|
+
params["reply_markup"] = kb
|
|
52
|
+
return call(cfg.get("token"), "sendMessage", params)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def edit(chat_id, message_id: int, text: str, rows=None, cfg: dict = None) -> dict:
|
|
56
|
+
cfg = cfg or {}
|
|
57
|
+
params = {
|
|
58
|
+
"chat_id": chat_id,
|
|
59
|
+
"message_id": message_id,
|
|
60
|
+
"text": text,
|
|
61
|
+
"parse_mode": "HTML",
|
|
62
|
+
"disable_web_page_preview": True,
|
|
63
|
+
}
|
|
64
|
+
kb = keyboard(rows)
|
|
65
|
+
if kb:
|
|
66
|
+
params["reply_markup"] = kb
|
|
67
|
+
else:
|
|
68
|
+
params["reply_markup"] = {"inline_keyboard": []}
|
|
69
|
+
return call(cfg.get("token"), "editMessageText", params)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def answer(callback_query_id: str, text: str = None, cfg: dict = None) -> dict:
|
|
73
|
+
cfg = cfg or {}
|
|
74
|
+
params = {"callback_query_id": callback_query_id}
|
|
75
|
+
if text:
|
|
76
|
+
params["text"] = text
|
|
77
|
+
params["show_alert"] = False
|
|
78
|
+
return call(cfg.get("token"), "answerCallbackQuery", params)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permission": [
|
|
3
|
+
"{tool} needs your approval in {project}.",
|
|
4
|
+
"Permission request: {tool} ({project}).",
|
|
5
|
+
"Claude wants to use {tool}. Awaiting your decision."
|
|
6
|
+
],
|
|
7
|
+
"done": [
|
|
8
|
+
"Task finished in {project}.",
|
|
9
|
+
"Done: {project} at {time}."
|
|
10
|
+
],
|
|
11
|
+
"approved": [
|
|
12
|
+
"Approved.",
|
|
13
|
+
"Allowed."
|
|
14
|
+
],
|
|
15
|
+
"denied": [
|
|
16
|
+
"Denied.",
|
|
17
|
+
"Rejected."
|
|
18
|
+
],
|
|
19
|
+
"approve_all": [
|
|
20
|
+
"Rule saved. Similar commands will be allowed."
|
|
21
|
+
],
|
|
22
|
+
"timeout": [
|
|
23
|
+
"No answer. The prompt fell back to the terminal."
|
|
24
|
+
],
|
|
25
|
+
"setup_ok": [
|
|
26
|
+
"bund connected ✅."
|
|
27
|
+
],
|
|
28
|
+
"dangerous": [
|
|
29
|
+
"⚠️ This command can delete or overwrite things permanently. Read it carefully."
|
|
30
|
+
],
|
|
31
|
+
"pick_user": [
|
|
32
|
+
"Select yourself from the list below."
|
|
33
|
+
],
|
|
34
|
+
"test": [
|
|
35
|
+
"bund test message. Setup works."
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permission": [
|
|
3
|
+
"🙄 Your AI intern wants to run {tool}. Please supervise.",
|
|
4
|
+
"{tool} needs approval again. It's almost like permissions exist.",
|
|
5
|
+
"Claude would like to use {tool} in {project}. What could possibly happen?",
|
|
6
|
+
"Alert: {tool} in {project}. Your input is requested (finally).",
|
|
7
|
+
"The robot wants {tool}. You're the human. Decide, human.",
|
|
8
|
+
"Permission prompt in {project}. The agent has been staring at this for {time}.",
|
|
9
|
+
"Break whatever you're doing. {tool} can't proceed without you."
|
|
10
|
+
],
|
|
11
|
+
"done": [
|
|
12
|
+
"✅ Claude finished in {project}. You can stop pretending you were in the meeting.",
|
|
13
|
+
"{project} is done. The agent survived without you.",
|
|
14
|
+
"Task complete in {project} at {time}. Slow clap.",
|
|
15
|
+
"All done in {project}. Your phone may now return to being a phone."
|
|
16
|
+
],
|
|
17
|
+
"approved": [
|
|
18
|
+
"✅ Approved. Hope you actually read it.",
|
|
19
|
+
"✅ Allowed. Bold move.",
|
|
20
|
+
"✅ Go ahead. The commit history remembers everything."
|
|
21
|
+
],
|
|
22
|
+
"denied": [
|
|
23
|
+
"❌ Denied. Claude is sulking quietly.",
|
|
24
|
+
"❌ Nope. It will try something else in 3… 2…",
|
|
25
|
+
"❌ Denied. You're the reason it can't have nice things."
|
|
26
|
+
],
|
|
27
|
+
"approve_all": [
|
|
28
|
+
"♾ Rule saved. What could go wrong?",
|
|
29
|
+
"♾ Approved forever. Past-you trusts present-you a lot.",
|
|
30
|
+
"♾ Never ask again for this. Live dangerously."
|
|
31
|
+
],
|
|
32
|
+
"timeout": [
|
|
33
|
+
"⌛ No answer, so Claude is back in your terminal, waiting.",
|
|
34
|
+
"⌛ You ghosted it. Claude returned to the terminal.",
|
|
35
|
+
"⌛ Timeout. The agent resurfaces in your terminal."
|
|
36
|
+
],
|
|
37
|
+
"setup_ok": [
|
|
38
|
+
"✅ bund connected. You can now be bothered from anywhere.",
|
|
39
|
+
"✅ Connected. Your phone now belongs to the agent."
|
|
40
|
+
],
|
|
41
|
+
"dangerous": [
|
|
42
|
+
"⚠️ This command can delete or overwrite things permanently. Read it carefully."
|
|
43
|
+
],
|
|
44
|
+
"pick_user": [
|
|
45
|
+
"Pick yourself from the list below. Strangers don't get to approve anything."
|
|
46
|
+
],
|
|
47
|
+
"test": [
|
|
48
|
+
"📡 bund test message. If you can read this, your phone is officially part of the pipeline."
|
|
49
|
+
]
|
|
50
|
+
}
|
bund/templates.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import random
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from . import config
|
|
6
|
+
|
|
7
|
+
PKG_DIR = Path(__file__).resolve().parent / "templates"
|
|
8
|
+
|
|
9
|
+
_FALLBACK = {"permission": ["{tool} needs approval."], "done": ["Done in {project}."]}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_pool(tone: str) -> dict:
|
|
13
|
+
pool = {}
|
|
14
|
+
try:
|
|
15
|
+
with open(PKG_DIR / f"{tone}.json", encoding="utf-8") as f:
|
|
16
|
+
data = json.load(f)
|
|
17
|
+
if isinstance(data, dict):
|
|
18
|
+
pool.update(data)
|
|
19
|
+
except (OSError, json.JSONDecodeError):
|
|
20
|
+
pass
|
|
21
|
+
try:
|
|
22
|
+
with open(config.config_dir() / "templates.json", encoding="utf-8") as f:
|
|
23
|
+
overrides = json.load(f)
|
|
24
|
+
if isinstance(overrides, dict):
|
|
25
|
+
for key, val in overrides.items():
|
|
26
|
+
if isinstance(val, list) and val:
|
|
27
|
+
pool[key] = val
|
|
28
|
+
elif isinstance(val, str):
|
|
29
|
+
pool[key] = [val]
|
|
30
|
+
except (OSError, json.JSONDecodeError):
|
|
31
|
+
pass
|
|
32
|
+
if not pool:
|
|
33
|
+
pool = dict(_FALLBACK)
|
|
34
|
+
return pool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def say(event: str, vars: dict = None, tone: str = None) -> str:
|
|
38
|
+
if tone is None:
|
|
39
|
+
tone = (config.load().get("tone") or "sarcasm").strip()
|
|
40
|
+
if tone not in ("sarcasm", "plain"):
|
|
41
|
+
tone = "sarcasm"
|
|
42
|
+
pool = _load_pool(tone)
|
|
43
|
+
lines = pool.get(event) or _FALLBACK.get(event) or [event]
|
|
44
|
+
line = random.choice(lines)
|
|
45
|
+
for key, val in (vars or {}).items():
|
|
46
|
+
line = line.replace("{" + str(key) + "}", str(val))
|
|
47
|
+
return line
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bund
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Telegram pings for coding agents. Approve from your phone. Free. Zero dependencies.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: telegram,claude,coding-agent,hooks,notifications,approval
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
<h1 align="center">bund</h1>
|
|
20
|
+
|
|
21
|
+
<p align="center"><b>Your AI agent is waiting for you. Again.</b></p>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
<img src="https://api.memegen.link/images/fine/claude_has_been_waiting_for_2_hours/this_is_fine.png?width=450" width="450" alt="this is fine">
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
Telegram pings for coding agents.<br>
|
|
29
|
+
Approve from your phone. Free. Zero dependencies.
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## The problem
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<img src="https://api.memegen.link/images/db/you/agent_needing_approval/just_one_more_meeting.png?width=450" width="450" alt="distracted boyfriend">
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
<p align="center">
|
|
43
|
+
<img src="https://api.memegen.link/images/gru/claude_asks_permission/bund_pings_your_telegram/you_tap_approve_from_anywhere/claude_keeps_working.png?width=450" width="450" alt="gru plan">
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
## Setup (2 minutes)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install bund
|
|
50
|
+
|
|
51
|
+
bund set --token <botfather-token>
|
|
52
|
+
bund set --chat auto
|
|
53
|
+
bund install
|
|
54
|
+
bund test
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`bund install` wires Claude Code's `PermissionRequest` and `Stop` hooks. On any
|
|
58
|
+
other agent or script, use `bund ask` (below) — no install needed.
|
|
59
|
+
|
|
60
|
+
<p align="center">
|
|
61
|
+
<img src="https://api.memegen.link/images/success/set_up_in_two_minutes/phone_buzzed_immediately.png?width=400" width="400" alt="success kid">
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
## What you get
|
|
65
|
+
|
|
66
|
+
| Button | Does |
|
|
67
|
+
|---|---|
|
|
68
|
+
| ✅ Approve | Lets it run, once |
|
|
69
|
+
| ❌ Deny | Nope |
|
|
70
|
+
| ♾ Approve all similar | Never asks again for that kind of command |
|
|
71
|
+
|
|
72
|
+
And a ping when the task is done:
|
|
73
|
+
|
|
74
|
+
<p align="center">
|
|
75
|
+
<img src="https://api.memegen.link/images/fry/not_sure_if_agent_finished/or_waiting_for_my_approval.png?width=450" width="450" alt="fry">
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
Now you know which one.
|
|
79
|
+
|
|
80
|
+
## Sample message
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
🙄 Your AI intern wants to run Bash. Please supervise.
|
|
84
|
+
|
|
85
|
+
📁 my-project
|
|
86
|
+
npm run build
|
|
87
|
+
|
|
88
|
+
[ ✅ Approve ] [ ❌ Deny ]
|
|
89
|
+
[ ♾ Approve all similar: Bash(npm run *) ]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The joke is on top. The real command is always shown in full below it.
|
|
93
|
+
|
|
94
|
+
## Works with any agent
|
|
95
|
+
|
|
96
|
+
`bund ask` is the universal protocol: any CLI, script, or agent asks a
|
|
97
|
+
question, your phone answers.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
bund ask "deploy to prod?" && echo let's go
|
|
101
|
+
# stdout: allow | deny | timeout
|
|
102
|
+
# exit: 0 1 2
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import subprocess
|
|
107
|
+
r = subprocess.run(["bund", "ask", "run the migration?"], capture_output=True, text=True)
|
|
108
|
+
if r.stdout.strip() == "allow":
|
|
109
|
+
...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
No config files to wire. If bund isn't configured, `bund ask` fails fast with
|
|
113
|
+
exit code 2 instead of hanging your agent.
|
|
114
|
+
|
|
115
|
+
## Safety
|
|
116
|
+
|
|
117
|
+
<p align="center">
|
|
118
|
+
<img src="https://api.memegen.link/images/drake/approve_all_for_rm_rf/no_button_for_dangerous_commands.png?width=400" width="400" alt="drake">
|
|
119
|
+
</p>
|
|
120
|
+
|
|
121
|
+
- Only **your** Telegram chat can approve anything
|
|
122
|
+
- Every button works once (random request ids, no replaying old taps)
|
|
123
|
+
- Dangerous commands (`rm -rf`, `sudo`, force pushes, `curl | sh`, …) get a
|
|
124
|
+
serious warning and **no** "approve all" button
|
|
125
|
+
- No answer? The agent falls back to the normal terminal prompt
|
|
126
|
+
- bund can never block your work — any error means silent fallback
|
|
127
|
+
- Token and chat id live in `~/.config/bund/config.json` with `chmod 600`
|
|
128
|
+
|
|
129
|
+
## Dependencies
|
|
130
|
+
|
|
131
|
+
<p align="center">
|
|
132
|
+
<img src="https://api.memegen.link/images/buzz/zero_dependencies/zero_dependencies_everywhere.png?width=400" width="400" alt="buzz">
|
|
133
|
+
</p>
|
|
134
|
+
|
|
135
|
+
Python 3.9+. Standard library only. That's it.
|
|
136
|
+
|
|
137
|
+
## Cost
|
|
138
|
+
|
|
139
|
+
<p align="center">
|
|
140
|
+
<img src="https://api.memegen.link/images/oprah/you_get_a_free_bot/and_you_get_free_approvals.gif?width=400" width="400" alt="oprah">
|
|
141
|
+
</p>
|
|
142
|
+
|
|
143
|
+
## Tone
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
bund set --tone sarcasm # default
|
|
147
|
+
bund set --tone plain # for people who have no joy
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Add your own lines in `~/.config/bund/templates.json`.
|
|
151
|
+
|
|
152
|
+
## Also
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
bund -m "build finished, come back"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Works with any script or agent.
|
|
159
|
+
|
|
160
|
+
<p align="center">
|
|
161
|
+
<img src="https://api.memegen.link/images/pigeon/claude/_/is_this_a_permission_prompt~q.png?width=400" width="400" alt="is this a pigeon">
|
|
162
|
+
</p>
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
**Commands**
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
bund set --token <token> # botfather token
|
|
170
|
+
bund set --chat auto|<id> # pick your chat
|
|
171
|
+
bund set --tone sarcasm|plain # message tone
|
|
172
|
+
bund install # add Claude Code hooks
|
|
173
|
+
bund uninstall # remove only bund's hooks
|
|
174
|
+
bund test # send a test message
|
|
175
|
+
bund ask "<question>" # ask anything (allow|deny|timeout)
|
|
176
|
+
bund -m "<text>" # send any message
|
|
177
|
+
bund hook # internal, called by hooks
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
<p align="center">
|
|
181
|
+
<sub>Memes by <a href="https://memegen.link">memegen.link</a>. If they don't load, that's on them, not us.</sub><br>
|
|
182
|
+
<sub>MIT License</sub>
|
|
183
|
+
</p>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
bund/__init__.py,sha256=7y4S6U5UpUBcVTsjI7GdnTGCexXbW0qFIcIf0hp4sL4,97
|
|
2
|
+
bund/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
bund/ask.py,sha256=3AF3hUCtA_m3U7m7dp_vBbFuZhACBBxX2F2jGpaacHg,1242
|
|
4
|
+
bund/cli.py,sha256=X912kzHB6PKls1OL1orh646M-Y4XY5X45d8G4MQavBs,3976
|
|
5
|
+
bund/config.py,sha256=kELcky5OfarF3cBblMmGJZBhYwVnPXBLcCduQJsxSOo,1220
|
|
6
|
+
bund/hook.py,sha256=Pimz6Mv6_X1YKu4bO5qp8lp8mVn85oQ46kkg4u3BYjY,5637
|
|
7
|
+
bund/install.py,sha256=VGqhoWa2tQ8tDKcE0zIHRQlXSZfDtyOP56ACKuPCg7s,3746
|
|
8
|
+
bund/poller.py,sha256=jiyEwotwrS3LYTbpNbf0DTN53Tz14SRdKTGJIvzGijk,4622
|
|
9
|
+
bund/rules.py,sha256=KVINVv5uhgTeJ-FZIO8d4NvIYQji9Cb8589DGim0mCA,1678
|
|
10
|
+
bund/setup_chat.py,sha256=NrsNwUA8GROf87qijTExctY15ajFBjDBFXMzCMriPM4,4662
|
|
11
|
+
bund/telegram.py,sha256=VHDmAPRmGzDkGKmM6gCWIYemK3MdQYyKcdISerplTEw,2446
|
|
12
|
+
bund/templates.py,sha256=XyBWDDt91kdAC-Pc7_VDKQgVZxjZISYj6T_4so2QCdM,1462
|
|
13
|
+
bund/templates/plain.json,sha256=AE5q9DsOyTHwwmsdP8lzvMgzLwZfzKtAmML6z1oo-V4,796
|
|
14
|
+
bund/templates/sarcasm.json,sha256=qtauLqeHti-OGf1Tit_VZHrYrz_zqXVhXT6TV-zb-6c,2041
|
|
15
|
+
bund-0.1.0.dist-info/licenses/LICENSE,sha256=TKcQSMayTxQN86iL-m-AzTxuQEke5UX4VLoZ6-AVSwQ,1074
|
|
16
|
+
bund-0.1.0.dist-info/METADATA,sha256=aoOQvekq2iyqtEq4WKtv0I5ZAXTF-efVxU_ft1X2l8Q,5461
|
|
17
|
+
bund-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
bund-0.1.0.dist-info/entry_points.txt,sha256=Kz9ZDSSGhTMgGqHFenS4nDyzo2qO0bjTHmu8G3guPSc,39
|
|
19
|
+
bund-0.1.0.dist-info/top_level.txt,sha256=DiVbXwtj2f-zGCxX0I6b5RXHInHR0oRuKPkKgSdZqmc,5
|
|
20
|
+
bund-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bund 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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bund
|