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 ADDED
@@ -0,0 +1,3 @@
1
+ """bund — Telegram pings for coding agents. Approve from your phone."""
2
+
3
+ __version__ = "0.1.0"
bund/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bund = bund.cli:main
@@ -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