GuGa 1.0.3__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.
- guga/__init__.py +0 -0
- guga/__pycache__/__init__.cpython-314.pyc +0 -0
- guga/__pycache__/cli.cpython-314.pyc +0 -0
- guga/__pycache__/daemon.cpython-314.pyc +0 -0
- guga/alerter.py +174 -0
- guga/cli.py +295 -0
- guga/daemon.py +1025 -0
- guga/installer.py +458 -0
- guga/man/guga.1 +122 -0
- guga/man/guga.md +37 -0
- guga-1.0.3.dist-info/METADATA +123 -0
- guga-1.0.3.dist-info/RECORD +15 -0
- guga-1.0.3.dist-info/WHEEL +5 -0
- guga-1.0.3.dist-info/entry_points.txt +2 -0
- guga-1.0.3.dist-info/top_level.txt +1 -0
guga/__init__.py
ADDED
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
guga/alerter.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import asyncio.subprocess
|
|
3
|
+
import re
|
|
4
|
+
import aiohttp
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = os.path.expanduser("~/.guga")
|
|
11
|
+
env_path = os.path.join(CONFIG_DIR, '.env')
|
|
12
|
+
load_dotenv(dotenv_path=env_path)
|
|
13
|
+
|
|
14
|
+
# Enforce Linux-only restriction
|
|
15
|
+
if platform.system() != "Linux":
|
|
16
|
+
print("❌ ERROR: OS Notification monitoring only works on Linux (D-Bus required).")
|
|
17
|
+
print(f"Current OS: {platform.system()}")
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------
|
|
21
|
+
# Configuration
|
|
22
|
+
# ------------------------------------------------------------
|
|
23
|
+
SERVER_URL = os.getenv("ALERTER_SERVER_URL", "http://localhost:6769/send")
|
|
24
|
+
LOG_FILE = os.path.join(CONFIG_DIR, "alerter.log")
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------
|
|
27
|
+
# Text Cleaning (Stripping Formatting)
|
|
28
|
+
# ------------------------------------------------------------
|
|
29
|
+
def clean_text(text: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Strips HTML-like tags and common markdown formatting from text.
|
|
32
|
+
"""
|
|
33
|
+
if not text:
|
|
34
|
+
return ""
|
|
35
|
+
|
|
36
|
+
# 1. Strip HTML tags (e.g., <b>, <i>, <a href="...">)
|
|
37
|
+
text = re.sub(r'<[^>]+>', '', text)
|
|
38
|
+
|
|
39
|
+
# 2. Strip Markdown bold/italic/strike (e.g., **, *, __, _, ~~)
|
|
40
|
+
text = re.sub(r'(\*\*|__|\*|_|~~)', '', text)
|
|
41
|
+
|
|
42
|
+
# 3. Strip Markdown links [text](url) -> text
|
|
43
|
+
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
|
|
44
|
+
|
|
45
|
+
# 4. Strip excessive newlines and whitespace
|
|
46
|
+
text = re.sub(r'\n+', ' ', text)
|
|
47
|
+
text = text.strip()
|
|
48
|
+
|
|
49
|
+
return text
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------
|
|
52
|
+
# Forwarding Logic
|
|
53
|
+
# ------------------------------------------------------------
|
|
54
|
+
async def forward_to_server(app_name: str, title: str, body: str):
|
|
55
|
+
"""
|
|
56
|
+
Sends the cleaned notification to the local server's /send route.
|
|
57
|
+
"""
|
|
58
|
+
clean_title = clean_text(title)
|
|
59
|
+
clean_body = clean_text(body)
|
|
60
|
+
|
|
61
|
+
payload_msg = f"{clean_title}: {clean_body}"
|
|
62
|
+
if not clean_title and not clean_body:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
log_entry = f"Forwarding: {payload_msg}\n"
|
|
66
|
+
print(log_entry.strip())
|
|
67
|
+
with open(LOG_FILE, "a") as f:
|
|
68
|
+
f.write(log_entry)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
async with aiohttp.ClientSession() as session:
|
|
72
|
+
async with session.post(SERVER_URL, json={"message": payload_msg , "title":app_name}) as resp:
|
|
73
|
+
status_msg = f"Server Response: {resp.status}\n"
|
|
74
|
+
print(status_msg.strip())
|
|
75
|
+
with open(LOG_FILE, "a") as f:
|
|
76
|
+
f.write(status_msg)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
err_msg = f"Error forwarding: {e}\n"
|
|
79
|
+
print(err_msg.strip())
|
|
80
|
+
with open(LOG_FILE, "a") as f:
|
|
81
|
+
f.write(err_msg)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------
|
|
84
|
+
# D-Bus Monitor via Subprocess
|
|
85
|
+
# ------------------------------------------------------------
|
|
86
|
+
async def monitor_notifications():
|
|
87
|
+
"""
|
|
88
|
+
Runs dbus-monitor and parses its output to detect notifications.
|
|
89
|
+
"""
|
|
90
|
+
# Explicitly filter for method_call to avoid duplicates from signals
|
|
91
|
+
cmd = ["dbus-monitor", "type='method_call',interface='org.freedesktop.Notifications',member='Notify'"]
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
proc = await asyncio.create_subprocess_exec(
|
|
95
|
+
*cmd,
|
|
96
|
+
stdout=asyncio.subprocess.PIPE,
|
|
97
|
+
stderr=asyncio.subprocess.STDOUT
|
|
98
|
+
)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f"Failed to start dbus-monitor: {e}")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if not proc.stdout:
|
|
104
|
+
print("Failed to capture dbus-monitor stdout.")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
print("Monitoring D-Bus for notifications (method calls only)...")
|
|
108
|
+
|
|
109
|
+
# State tracking
|
|
110
|
+
state = "IDLE"
|
|
111
|
+
app_name = ""
|
|
112
|
+
summary = ""
|
|
113
|
+
body = ""
|
|
114
|
+
|
|
115
|
+
while True:
|
|
116
|
+
line_bytes = await proc.stdout.readline()
|
|
117
|
+
if not line_bytes:
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
line = line_bytes.decode(errors='replace').strip()
|
|
121
|
+
|
|
122
|
+
# Detect start of method call specifically
|
|
123
|
+
if "method call" in line and "interface=org.freedesktop.Notifications; member=Notify" in line:
|
|
124
|
+
state = "APP_NAME"
|
|
125
|
+
app_name = ""
|
|
126
|
+
summary = ""
|
|
127
|
+
body = ""
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
if state == "APP_NAME":
|
|
131
|
+
match = re.search(r'string\s+"(.*)"', line)
|
|
132
|
+
if match:
|
|
133
|
+
app_name = match.group(1)
|
|
134
|
+
state = "REPLACES_ID"
|
|
135
|
+
|
|
136
|
+
elif state == "REPLACES_ID":
|
|
137
|
+
if "uint32" in line:
|
|
138
|
+
state = "ICON"
|
|
139
|
+
|
|
140
|
+
elif state == "ICON":
|
|
141
|
+
match = re.search(r'string\s+"(.*)"', line)
|
|
142
|
+
if match:
|
|
143
|
+
state = "SUMMARY"
|
|
144
|
+
|
|
145
|
+
elif state == "SUMMARY":
|
|
146
|
+
match = re.search(r'string\s+"(.*)"', line)
|
|
147
|
+
if match:
|
|
148
|
+
summary = match.group(1)
|
|
149
|
+
state = "BODY"
|
|
150
|
+
|
|
151
|
+
elif state == "BODY":
|
|
152
|
+
# Body can be a string or we might see 'array [' if body is empty
|
|
153
|
+
match = re.search(r'string\s+"(.*)"', line)
|
|
154
|
+
if match:
|
|
155
|
+
body = match.group(1)
|
|
156
|
+
# Done capturing this notification
|
|
157
|
+
print(f"\n[Intercepted] App: {app_name}")
|
|
158
|
+
with open(LOG_FILE, "a") as f:
|
|
159
|
+
f.write(f"\n[Intercepted] App: {app_name}\nTitle: {summary}\nBody: {body}\n")
|
|
160
|
+
asyncio.create_task(forward_to_server(app_name, summary, body))
|
|
161
|
+
state = "IDLE"
|
|
162
|
+
elif "array [" in line:
|
|
163
|
+
# Body was empty, moving to actions
|
|
164
|
+
print(f"\n[Intercepted] App: {app_name} (No body)")
|
|
165
|
+
with open(LOG_FILE, "a") as f:
|
|
166
|
+
f.write(f"\n[Intercepted] App: {app_name}\nTitle: {summary}\nBody: (empty)\n")
|
|
167
|
+
asyncio.create_task(forward_to_server(app_name, summary, ""))
|
|
168
|
+
state = "IDLE"
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
try:
|
|
172
|
+
asyncio.run(monitor_notifications())
|
|
173
|
+
except KeyboardInterrupt:
|
|
174
|
+
print("\nStopping OS Notification Alerter...")
|
guga/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
guga - Send notifications to your Android via the GuGa server.
|
|
4
|
+
|
|
5
|
+
Auto-detected mode:
|
|
6
|
+
echo "msg" | guga
|
|
7
|
+
guga "Build finished"
|
|
8
|
+
guga python train.py
|
|
9
|
+
|
|
10
|
+
Explicit mode (overrides auto-detection):
|
|
11
|
+
guga --message "build done"
|
|
12
|
+
guga --run calc maintenance.cobol
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shlex
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
import urllib.request
|
|
24
|
+
import urllib.error
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def format_duration(seconds):
|
|
30
|
+
seconds = int(seconds)
|
|
31
|
+
if seconds < 60:
|
|
32
|
+
return f"{seconds}s"
|
|
33
|
+
elif seconds < 3600:
|
|
34
|
+
m, s = divmod(seconds, 60)
|
|
35
|
+
return f"{m}m {s}s"
|
|
36
|
+
else:
|
|
37
|
+
h, rem = divmod(seconds, 3600)
|
|
38
|
+
m, _ = divmod(rem, 60)
|
|
39
|
+
return f"{h}h {m}m"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_runnable(token):
|
|
43
|
+
return shutil.which(token) is not None or os.path.isfile(token)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def last_meaningful_line(text):
|
|
47
|
+
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
|
48
|
+
return lines[-1] if lines else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Core actions ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def send_notification(message, port, silent, title=None):
|
|
54
|
+
url = f"http://localhost:{port}/send"
|
|
55
|
+
|
|
56
|
+
payload = {"message": message}
|
|
57
|
+
if title:
|
|
58
|
+
payload["title"] = title
|
|
59
|
+
|
|
60
|
+
data = json.dumps(payload).encode("utf-8")
|
|
61
|
+
req = urllib.request.Request(
|
|
62
|
+
url,
|
|
63
|
+
data=data,
|
|
64
|
+
headers={"Content-Type": "application/json"},
|
|
65
|
+
method="POST",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
70
|
+
if not silent:
|
|
71
|
+
print(f"✅ Sent ({response.status}): {message}")
|
|
72
|
+
except urllib.error.URLError as e:
|
|
73
|
+
reason = getattr(e, "reason", str(e))
|
|
74
|
+
if not silent:
|
|
75
|
+
print(f"❌ Could not reach GuGa server on port {port}: {reason}", file=sys.stderr)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
if not silent:
|
|
79
|
+
print(f"❌ Unexpected error: {e}", file=sys.stderr)
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_command(cmd_args, port, silent, title):
|
|
84
|
+
cmd_label = " ".join(cmd_args)
|
|
85
|
+
start = time.time()
|
|
86
|
+
|
|
87
|
+
if not silent:
|
|
88
|
+
print(f"▶ guga watching: {cmd_label}\n")
|
|
89
|
+
|
|
90
|
+
captured_lines = []
|
|
91
|
+
try:
|
|
92
|
+
process = subprocess.Popen(
|
|
93
|
+
cmd_args,
|
|
94
|
+
stdout=subprocess.PIPE,
|
|
95
|
+
stderr=subprocess.STDOUT,
|
|
96
|
+
text=True,
|
|
97
|
+
bufsize=1,
|
|
98
|
+
)
|
|
99
|
+
for line in process.stdout:
|
|
100
|
+
print(line, end="")
|
|
101
|
+
captured_lines.append(line)
|
|
102
|
+
|
|
103
|
+
process.wait()
|
|
104
|
+
exit_code = process.returncode
|
|
105
|
+
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
print(f"❌ Command not found: {cmd_args[0]}", file=sys.stderr)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
elapsed = format_duration(time.time() - start)
|
|
111
|
+
send_notification(f"⚠️ {cmd_label} interrupted after {elapsed}", port, silent, title)
|
|
112
|
+
sys.exit(130)
|
|
113
|
+
|
|
114
|
+
elapsed = format_duration(time.time() - start)
|
|
115
|
+
last_line = last_meaningful_line("".join(captured_lines))
|
|
116
|
+
|
|
117
|
+
status, verb = ("✅", "done") if exit_code == 0 else ("❌", f"failed (exit {exit_code})")
|
|
118
|
+
parts = [f"{status} {cmd_label} {verb} — {elapsed}"]
|
|
119
|
+
if last_line:
|
|
120
|
+
parts.append(last_line)
|
|
121
|
+
|
|
122
|
+
send_notification("\n".join(parts), port, silent, title)
|
|
123
|
+
sys.exit(exit_code)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def parse_args():
|
|
129
|
+
parser = argparse.ArgumentParser(
|
|
130
|
+
prog="guga",
|
|
131
|
+
description="Send notifications to Android, or watch a command and notify on completion.",
|
|
132
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
133
|
+
epilog="""
|
|
134
|
+
mode flags (override auto-detection):
|
|
135
|
+
-m / --message Force message mode — treat all args as a plain string.
|
|
136
|
+
-r / --run Force run mode — treat all args as a command to execute.
|
|
137
|
+
|
|
138
|
+
auto-detection rules:
|
|
139
|
+
1. stdin piped, no args → message mode
|
|
140
|
+
2. single non-executable string → message mode
|
|
141
|
+
3. everything else → run mode
|
|
142
|
+
|
|
143
|
+
examples:
|
|
144
|
+
echo "Deploy done" | guga # auto: message via stdin
|
|
145
|
+
guga "Build finished" # auto: plain message
|
|
146
|
+
guga python train.py --epochs 100 # auto: run mode
|
|
147
|
+
guga calc maintenance.cobol # auto: run mode
|
|
148
|
+
|
|
149
|
+
guga -m "build done" # explicit: message
|
|
150
|
+
guga -m "python train.py" # sends the literal string, does not run
|
|
151
|
+
guga -r "sleep 5" # explicit: run (splits into tokens)
|
|
152
|
+
guga -r ./deploy.sh --title "Prod Server" # explicit: run + title
|
|
153
|
+
|
|
154
|
+
guga -r python train.py --silent --title "GPU" # run silently, labelled notification
|
|
155
|
+
|
|
156
|
+
setup & pairing:
|
|
157
|
+
guga --qr # show pairing QR code
|
|
158
|
+
guga --show-pin # show the latest pairing PIN
|
|
159
|
+
guga --install-service # initialise background service
|
|
160
|
+
|
|
161
|
+
for more details:
|
|
162
|
+
man guga
|
|
163
|
+
tldr guga
|
|
164
|
+
""",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"args",
|
|
169
|
+
nargs="*",
|
|
170
|
+
help="Message string, or command + arguments.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Proxy mode flags
|
|
174
|
+
proxy_mode = parser.add_mutually_exclusive_group()
|
|
175
|
+
proxy_mode.add_argument(
|
|
176
|
+
"--qr",
|
|
177
|
+
action="store_true",
|
|
178
|
+
help="Show the pairing QR code and exit.",
|
|
179
|
+
)
|
|
180
|
+
proxy_mode.add_argument(
|
|
181
|
+
"--show-pin",
|
|
182
|
+
action="store_true",
|
|
183
|
+
help="Show the most recent pairing PIN and exit.",
|
|
184
|
+
)
|
|
185
|
+
proxy_mode.add_argument(
|
|
186
|
+
"--install-service",
|
|
187
|
+
action="store_true",
|
|
188
|
+
help="Initializes the Linux background systemd service and components.",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Explicit mode flags — mutually exclusive
|
|
192
|
+
mode = parser.add_mutually_exclusive_group()
|
|
193
|
+
mode.add_argument(
|
|
194
|
+
"-m", "--message",
|
|
195
|
+
action="store_true",
|
|
196
|
+
help="Force message mode. Joins all positional args as a single string.",
|
|
197
|
+
)
|
|
198
|
+
mode.add_argument(
|
|
199
|
+
"-r", "--run",
|
|
200
|
+
action="store_true",
|
|
201
|
+
help="Force run mode. Executes positional args as a command.",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
parser.add_argument(
|
|
205
|
+
"--server",
|
|
206
|
+
type=int,
|
|
207
|
+
default=6769,
|
|
208
|
+
metavar="PORT",
|
|
209
|
+
help="GuGa server port (default: 6769).",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--silent",
|
|
213
|
+
action="store_true",
|
|
214
|
+
help="Suppress guga's own output.",
|
|
215
|
+
)
|
|
216
|
+
parser.add_argument(
|
|
217
|
+
"--title",
|
|
218
|
+
default=None,
|
|
219
|
+
metavar="LABEL",
|
|
220
|
+
help='Label shown in the notification, e.g. "GPU Server".',
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return parser.parse_args()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main():
|
|
227
|
+
args = parse_args()
|
|
228
|
+
|
|
229
|
+
# ── Proxy modes ───────────────────────────────────────────────────────────
|
|
230
|
+
if args.install_service or args.qr or args.show_pin:
|
|
231
|
+
from guga.installer import run_system_installer
|
|
232
|
+
run_system_installer(qr_only=args.qr, pin_only=args.show_pin, setup_only=args.install_service)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
positional = args.args
|
|
236
|
+
|
|
237
|
+
# ── Explicit message mode ─────────────────────────────────────────────────
|
|
238
|
+
if args.message:
|
|
239
|
+
if not positional:
|
|
240
|
+
# Fall back to stdin if no positional args given with --message
|
|
241
|
+
if not sys.stdin.isatty():
|
|
242
|
+
message = sys.stdin.read().strip()
|
|
243
|
+
else:
|
|
244
|
+
print("❌ --message requires a string argument.", file=sys.stderr)
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
else:
|
|
247
|
+
message = " ".join(positional) # join so --message hello world works too
|
|
248
|
+
send_notification(message, args.server, args.silent, args.title)
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
# ── Explicit run mode ─────────────────────────────────────────────────────
|
|
252
|
+
if args.run:
|
|
253
|
+
if not positional:
|
|
254
|
+
print("❌ --run requires a command to execute.", file=sys.stderr)
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
# If the user passed a single quoted string like "sleep 1" or "python train.py --lr 0.01",
|
|
257
|
+
# split it into proper tokens so subprocess can execute it correctly.
|
|
258
|
+
if len(positional) == 1:
|
|
259
|
+
positional = shlex.split(positional[0])
|
|
260
|
+
run_command(positional, args.server, args.silent, args.title)
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
# ── Auto-detection ────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
# 1. Stdin pipe with no positional args
|
|
266
|
+
if not sys.stdin.isatty() and not positional:
|
|
267
|
+
message = sys.stdin.read().strip()
|
|
268
|
+
if not message:
|
|
269
|
+
print("❌ Received empty stdin.", file=sys.stderr)
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
send_notification(message, args.server, args.silent, args.title)
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
# 2. Nothing at all
|
|
275
|
+
if not positional:
|
|
276
|
+
print("❌ No message or command provided.\n", file=sys.stderr)
|
|
277
|
+
print("Usage:", file=sys.stderr)
|
|
278
|
+
print(' echo "msg" | guga', file=sys.stderr)
|
|
279
|
+
print(' guga "msg"', file=sys.stderr)
|
|
280
|
+
print(' guga python train.py', file=sys.stderr)
|
|
281
|
+
print(' guga --message "msg" # explicit message mode', file=sys.stderr)
|
|
282
|
+
print(' guga --run python train.py # explicit run mode', file=sys.stderr)
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
|
|
285
|
+
# 3. Single non-executable string → message
|
|
286
|
+
if len(positional) == 1 and not is_runnable(positional[0]):
|
|
287
|
+
send_notification(positional[0], args.server, args.silent, args.title)
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
# 4. Otherwise → run mode
|
|
291
|
+
run_command(positional, args.server, args.silent, args.title)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
main()
|