calm-cli 0.3.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.
- calm/__init__.py +0 -0
- calm/__main__.py +4 -0
- calm/cli.py +529 -0
- calm/config.py +221 -0
- calm/platform_support.py +30 -0
- calm/service.py +240 -0
- calm_cli-0.3.0.dist-info/METADATA +174 -0
- calm_cli-0.3.0.dist-info/RECORD +19 -0
- calm_cli-0.3.0.dist-info/WHEEL +4 -0
- calm_cli-0.3.0.dist-info/entry_points.txt +4 -0
- calm_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- calmd/__init__.py +0 -0
- calmd/__main__.py +4 -0
- calmd/backend/__init__.py +0 -0
- calmd/backend/interface.py +30 -0
- calmd/backend/mlx_backend.py +286 -0
- calmd/daemon.py +802 -0
- calmd/prompts.py +126 -0
- calmd/protocol.py +33 -0
calm/__init__.py
ADDED
|
File without changes
|
calm/__main__.py
ADDED
calm/cli.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from calm.config import load_calm_cli_config
|
|
14
|
+
from calm.platform_support import ensure_supported_runtime
|
|
15
|
+
from calm.service import (
|
|
16
|
+
debug_enabled,
|
|
17
|
+
debug_log,
|
|
18
|
+
find_custom_service,
|
|
19
|
+
find_homebrew_service,
|
|
20
|
+
install_service,
|
|
21
|
+
managed_service_status,
|
|
22
|
+
start_service,
|
|
23
|
+
stop_service,
|
|
24
|
+
uninstall_service,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DANGEROUS_TOKENS = {
|
|
28
|
+
"rm",
|
|
29
|
+
"mkfs",
|
|
30
|
+
"dd",
|
|
31
|
+
"shutdown",
|
|
32
|
+
"reboot",
|
|
33
|
+
"poweroff",
|
|
34
|
+
"killall",
|
|
35
|
+
"chmod",
|
|
36
|
+
"chown",
|
|
37
|
+
">",
|
|
38
|
+
">>",
|
|
39
|
+
}
|
|
40
|
+
DAEMON_ACTIONS = ("install", "uninstall", "start", "stop", "offload")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_args() -> argparse.Namespace:
|
|
44
|
+
parser = argparse.ArgumentParser(
|
|
45
|
+
prog="calm",
|
|
46
|
+
description="[C]alm [A]nswers via (local) [L]anguage [M]odels. \
|
|
47
|
+
calm is a CLI tool that answers simple questions using a local language model. \
|
|
48
|
+
calm runs and communicates with the calmd LM server daemon.",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"-y", "--yolo", action="store_true", help="run command automatically"
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"-f",
|
|
55
|
+
"--force",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="allow dangerous commands; with -d stop, force shutdown",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"-c", "--command", action="store_true", help="force command output"
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"-a", "--analysis", action="store_true", help="force analysis/answer output"
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"-d",
|
|
67
|
+
"--daemon",
|
|
68
|
+
choices=DAEMON_ACTIONS,
|
|
69
|
+
metavar="ACTION",
|
|
70
|
+
help="manage calmd: install, uninstall, start, stop, offload",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument("query", nargs="?", help="question or task")
|
|
73
|
+
return parser.parse_args()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def detect_stdin() -> str | None:
|
|
77
|
+
if sys.stdin.isatty():
|
|
78
|
+
return None
|
|
79
|
+
data = sys.stdin.read()
|
|
80
|
+
return data if data.strip() else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_last_history_command() -> str | None:
|
|
84
|
+
shell_path = os.environ.get("SHELL", "")
|
|
85
|
+
home = Path.home()
|
|
86
|
+
|
|
87
|
+
if shell_path.endswith("zsh"):
|
|
88
|
+
path = home / ".zsh_history"
|
|
89
|
+
parser = _parse_zsh_history
|
|
90
|
+
elif shell_path.endswith("bash"):
|
|
91
|
+
path = home / ".bash_history"
|
|
92
|
+
parser = _parse_plain_history
|
|
93
|
+
else:
|
|
94
|
+
for candidate in (home / ".zsh_history", home / ".bash_history"):
|
|
95
|
+
if candidate.exists():
|
|
96
|
+
path = candidate
|
|
97
|
+
parser = (
|
|
98
|
+
_parse_zsh_history
|
|
99
|
+
if candidate.name == ".zsh_history"
|
|
100
|
+
else _parse_plain_history
|
|
101
|
+
)
|
|
102
|
+
break
|
|
103
|
+
else:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
if not path.exists():
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
111
|
+
except OSError:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
for line in reversed(lines):
|
|
115
|
+
cmd = parser(line)
|
|
116
|
+
if cmd:
|
|
117
|
+
return cmd
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_plain_history(line: str) -> str | None:
|
|
122
|
+
text = line.strip()
|
|
123
|
+
return text or None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_zsh_history(line: str) -> str | None:
|
|
127
|
+
line = line.strip()
|
|
128
|
+
if not line:
|
|
129
|
+
return None
|
|
130
|
+
if line.startswith(":") and ";" in line:
|
|
131
|
+
return line.split(";", 1)[1].strip() or None
|
|
132
|
+
return line
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def make_request(payload: dict, ensure_running: bool = True) -> dict:
|
|
136
|
+
config = load_calm_cli_config()
|
|
137
|
+
if ensure_running:
|
|
138
|
+
ensure_daemon_running()
|
|
139
|
+
|
|
140
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
141
|
+
client.connect(str(config.socket_path))
|
|
142
|
+
client.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
|
143
|
+
data = _recv_line(client)
|
|
144
|
+
|
|
145
|
+
return json.loads(data)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _recv_line(client: socket.socket) -> str:
|
|
149
|
+
chunks = []
|
|
150
|
+
while True:
|
|
151
|
+
chunk = client.recv(4096)
|
|
152
|
+
if not chunk:
|
|
153
|
+
break
|
|
154
|
+
chunks.append(chunk)
|
|
155
|
+
if b"\n" in chunk:
|
|
156
|
+
break
|
|
157
|
+
return b"".join(chunks).decode("utf-8", errors="replace").strip()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def is_dangerous(command: str) -> bool:
|
|
161
|
+
# Conservative static check for obvious destructive operations.
|
|
162
|
+
try:
|
|
163
|
+
tokens = shlex.split(command)
|
|
164
|
+
except ValueError:
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
token_set = set(tokens)
|
|
168
|
+
if token_set.intersection(DANGEROUS_TOKENS):
|
|
169
|
+
return True
|
|
170
|
+
if any(
|
|
171
|
+
token.startswith("/") and token in {"/", "/etc", "/usr", "/bin", "/sbin"}
|
|
172
|
+
for token in tokens
|
|
173
|
+
):
|
|
174
|
+
return True
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def execute_command(command: str) -> int:
|
|
179
|
+
proc = subprocess.run(command, shell=True)
|
|
180
|
+
return proc.returncode
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def ensure_daemon_running() -> None:
|
|
184
|
+
config = load_calm_cli_config()
|
|
185
|
+
health = _check_daemon_health()
|
|
186
|
+
if health and health.get("status") in ("ready", "warming_up"):
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
last_note = 0.0
|
|
190
|
+
last_status = ""
|
|
191
|
+
if health is None:
|
|
192
|
+
print("waiting for calmd (starting)...", file=sys.stderr)
|
|
193
|
+
last_note = time.time()
|
|
194
|
+
last_status = "starting"
|
|
195
|
+
start_calmd(skip_warmup=True)
|
|
196
|
+
|
|
197
|
+
deadline = time.time() + config.wait_timeout_secs
|
|
198
|
+
while time.time() < deadline:
|
|
199
|
+
health = _check_daemon_health()
|
|
200
|
+
if health and health.get("status") in ("ready", "warming_up"):
|
|
201
|
+
return
|
|
202
|
+
if health and health.get("status") == "error":
|
|
203
|
+
message = health.get("message", "calmd failed to initialize")
|
|
204
|
+
raise RuntimeError(f"calmd failed to initialize: {message}")
|
|
205
|
+
now = time.time()
|
|
206
|
+
status = health.get("status", "starting") if health else "starting"
|
|
207
|
+
if now - last_note >= 5.0 or status != last_status:
|
|
208
|
+
print(f"waiting for calmd ({status})...", file=sys.stderr)
|
|
209
|
+
last_note = now
|
|
210
|
+
last_status = status
|
|
211
|
+
time.sleep(0.05)
|
|
212
|
+
|
|
213
|
+
raise RuntimeError(
|
|
214
|
+
f"calmd not ready after {int(config.wait_timeout_secs)}s; "
|
|
215
|
+
"set CALMD_WAIT_TIMEOUT_SECS to increase wait duration"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def notify_if_daemon_offloaded() -> None:
|
|
220
|
+
health = _check_daemon_health()
|
|
221
|
+
if not health:
|
|
222
|
+
return
|
|
223
|
+
if health.get("status") != "ready":
|
|
224
|
+
return
|
|
225
|
+
if health.get("model_status") != "offloaded":
|
|
226
|
+
return
|
|
227
|
+
print("waking calmd (model was offloaded)...", file=sys.stderr)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _check_daemon_health() -> dict | None:
|
|
231
|
+
config = load_calm_cli_config()
|
|
232
|
+
if not config.socket_path.exists():
|
|
233
|
+
return None
|
|
234
|
+
try:
|
|
235
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
236
|
+
client.settimeout(1.0)
|
|
237
|
+
client.connect(str(config.socket_path))
|
|
238
|
+
client.sendall((json.dumps({"mode": "health"}) + "\n").encode("utf-8"))
|
|
239
|
+
raw = _recv_line(client)
|
|
240
|
+
response = json.loads(raw)
|
|
241
|
+
if isinstance(response, dict) and response.get("type") == "status":
|
|
242
|
+
return response
|
|
243
|
+
# Backward compatibility with older daemon versions.
|
|
244
|
+
return {"status": "ready", "message": "connected"}
|
|
245
|
+
except OSError:
|
|
246
|
+
return None
|
|
247
|
+
except json.JSONDecodeError:
|
|
248
|
+
return {"status": "initializing", "message": "invalid health response"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def start_calmd(skip_warmup: bool = False) -> None:
|
|
252
|
+
started_at = time.monotonic()
|
|
253
|
+
if debug_enabled():
|
|
254
|
+
debug_log(f"start_calmd entered skip_warmup={skip_warmup}")
|
|
255
|
+
service = find_homebrew_service() or find_custom_service()
|
|
256
|
+
if service is not None:
|
|
257
|
+
status, message = start_service(skip_warmup=skip_warmup, service=service)
|
|
258
|
+
if status == 0:
|
|
259
|
+
debug_log(
|
|
260
|
+
f"managed start completed elapsed_ms={int((time.monotonic() - started_at) * 1000)}"
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
print(
|
|
264
|
+
f"warning: failed to start managed calmd ({message}); falling back",
|
|
265
|
+
file=sys.stderr,
|
|
266
|
+
)
|
|
267
|
+
debug_log(f"managed start failed: {message}")
|
|
268
|
+
|
|
269
|
+
# Launch daemon as detached background process using the same Python env.
|
|
270
|
+
cmd = [sys.executable, "-m", "calmd"]
|
|
271
|
+
if "CALMD_SOCKET" in os.environ:
|
|
272
|
+
cmd.extend(["--socket", os.environ["CALMD_SOCKET"]])
|
|
273
|
+
env = os.environ.copy()
|
|
274
|
+
if skip_warmup:
|
|
275
|
+
env["CALMD_SKIP_WARMUP"] = "1"
|
|
276
|
+
|
|
277
|
+
subprocess.Popen(
|
|
278
|
+
cmd,
|
|
279
|
+
env=env,
|
|
280
|
+
stdin=subprocess.DEVNULL,
|
|
281
|
+
stdout=subprocess.DEVNULL,
|
|
282
|
+
stderr=subprocess.DEVNULL,
|
|
283
|
+
start_new_session=True,
|
|
284
|
+
close_fds=True,
|
|
285
|
+
)
|
|
286
|
+
debug_log(
|
|
287
|
+
f"unmanaged start launched cmd={cmd!r} elapsed_ms={int((time.monotonic() - started_at) * 1000)}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def daemon_is_running() -> bool:
|
|
292
|
+
health = _check_daemon_health()
|
|
293
|
+
return health is not None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def offload_daemon() -> int:
|
|
297
|
+
if not daemon_is_running():
|
|
298
|
+
print("calmd is not running", file=sys.stderr)
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
response = make_request(
|
|
303
|
+
{"mode": "control", "action": "offload"}, ensure_running=False
|
|
304
|
+
)
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
307
|
+
return 1
|
|
308
|
+
|
|
309
|
+
message = response.get("message", "calmd offloaded")
|
|
310
|
+
if response.get("status") == "error":
|
|
311
|
+
print(f"error: {message}", file=sys.stderr)
|
|
312
|
+
return 1
|
|
313
|
+
print(message)
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def stop_unmanaged_daemon(force: bool) -> int:
|
|
318
|
+
config = load_calm_cli_config()
|
|
319
|
+
if not daemon_is_running():
|
|
320
|
+
print("calmd is not running", file=sys.stderr)
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
if force:
|
|
324
|
+
return _send_shutdown_request(force=True)
|
|
325
|
+
|
|
326
|
+
status = _send_shutdown_request(force=False)
|
|
327
|
+
if status != 0:
|
|
328
|
+
return status
|
|
329
|
+
|
|
330
|
+
deadline = time.time() + config.shutdown_timeout_secs
|
|
331
|
+
while time.time() < deadline:
|
|
332
|
+
if not daemon_is_running():
|
|
333
|
+
print("calmd stopped")
|
|
334
|
+
return 0
|
|
335
|
+
time.sleep(0.05)
|
|
336
|
+
|
|
337
|
+
print("calmd did not stop gracefully; forcing shutdown", file=sys.stderr)
|
|
338
|
+
status = _send_shutdown_request(force=True)
|
|
339
|
+
if status != 0:
|
|
340
|
+
return status
|
|
341
|
+
|
|
342
|
+
deadline = time.time() + 1.0
|
|
343
|
+
while time.time() < deadline:
|
|
344
|
+
if not daemon_is_running():
|
|
345
|
+
print("calmd stopped")
|
|
346
|
+
return 0
|
|
347
|
+
time.sleep(0.05)
|
|
348
|
+
|
|
349
|
+
print("error: calmd is still running after forced shutdown", file=sys.stderr)
|
|
350
|
+
return 1
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def terminate_daemon(force: bool) -> int:
|
|
354
|
+
service, _loaded = managed_service_status()
|
|
355
|
+
if service is not None:
|
|
356
|
+
status, message = stop_service()
|
|
357
|
+
if status != 0:
|
|
358
|
+
print(f"error: {message}", file=sys.stderr)
|
|
359
|
+
return 1
|
|
360
|
+
print(message)
|
|
361
|
+
return 0
|
|
362
|
+
print(
|
|
363
|
+
"error: custom calmd LaunchAgent is not installed; `calm -d stop` only manages the LaunchAgent",
|
|
364
|
+
file=sys.stderr,
|
|
365
|
+
)
|
|
366
|
+
return 1
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _send_shutdown_request(force: bool) -> int:
|
|
370
|
+
try:
|
|
371
|
+
response = make_request(
|
|
372
|
+
{"mode": "control", "action": "shutdown", "force": force},
|
|
373
|
+
ensure_running=False,
|
|
374
|
+
)
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
if force and not daemon_is_running():
|
|
377
|
+
print("calmd stopped")
|
|
378
|
+
return 0
|
|
379
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
380
|
+
return 1
|
|
381
|
+
|
|
382
|
+
message = response.get("message", "calmd stopping")
|
|
383
|
+
if response.get("status") == "error":
|
|
384
|
+
print(f"error: {message}", file=sys.stderr)
|
|
385
|
+
return 1
|
|
386
|
+
print(message)
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def handle_daemon_action(action: str, force: bool) -> int:
|
|
391
|
+
if action == "install":
|
|
392
|
+
status, message = install_service()
|
|
393
|
+
print(message, file=sys.stderr if status != 0 else sys.stdout)
|
|
394
|
+
return status
|
|
395
|
+
if action == "uninstall":
|
|
396
|
+
status, message = uninstall_service()
|
|
397
|
+
print(message, file=sys.stderr if status != 0 else sys.stdout)
|
|
398
|
+
return status
|
|
399
|
+
if action == "start":
|
|
400
|
+
if find_homebrew_service() is not None:
|
|
401
|
+
print(
|
|
402
|
+
"error: homebrew service detected; use `brew services` instead",
|
|
403
|
+
file=sys.stderr,
|
|
404
|
+
)
|
|
405
|
+
return 1
|
|
406
|
+
service = find_custom_service()
|
|
407
|
+
if service is None:
|
|
408
|
+
print(
|
|
409
|
+
"error: custom calmd LaunchAgent is not installed; run `calm -d install` first",
|
|
410
|
+
file=sys.stderr,
|
|
411
|
+
)
|
|
412
|
+
return 1
|
|
413
|
+
if daemon_is_running() and managed_service_status()[1] is False:
|
|
414
|
+
print(
|
|
415
|
+
"stopping unmanaged calmd before starting LaunchAgent-managed daemon",
|
|
416
|
+
file=sys.stderr,
|
|
417
|
+
)
|
|
418
|
+
status = stop_unmanaged_daemon(force=force)
|
|
419
|
+
if status != 0:
|
|
420
|
+
return status
|
|
421
|
+
status, message = start_service(skip_warmup=False, service=service)
|
|
422
|
+
print(message, file=sys.stderr if status != 0 else sys.stdout)
|
|
423
|
+
return status
|
|
424
|
+
if action == "stop":
|
|
425
|
+
return terminate_daemon(force=force)
|
|
426
|
+
if action == "offload":
|
|
427
|
+
return offload_daemon()
|
|
428
|
+
print(f"error: unsupported daemon action: {action}", file=sys.stderr)
|
|
429
|
+
return 1
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def main() -> int:
|
|
433
|
+
args = parse_args()
|
|
434
|
+
if args.daemon and args.query:
|
|
435
|
+
print("error: cannot combine query with -d/--daemon", file=sys.stderr)
|
|
436
|
+
return 1
|
|
437
|
+
if not args.daemon and not args.query:
|
|
438
|
+
print("error: query is required", file=sys.stderr)
|
|
439
|
+
return 1
|
|
440
|
+
if not ensure_supported_runtime("calm"):
|
|
441
|
+
return 1
|
|
442
|
+
|
|
443
|
+
if args.daemon:
|
|
444
|
+
return handle_daemon_action(args.daemon, force=args.force)
|
|
445
|
+
|
|
446
|
+
stdin_text = detect_stdin()
|
|
447
|
+
notify_if_daemon_offloaded()
|
|
448
|
+
|
|
449
|
+
payload = {
|
|
450
|
+
"query": args.query,
|
|
451
|
+
"mode": "smart",
|
|
452
|
+
"stdin": stdin_text,
|
|
453
|
+
"history": read_last_history_command(),
|
|
454
|
+
"shell": os.path.basename(os.environ.get("SHELL", "")) or "unknown",
|
|
455
|
+
"cwd": os.getcwd(),
|
|
456
|
+
"os_name": os.uname().sysname,
|
|
457
|
+
"stdout_isatty": sys.stdout.isatty(),
|
|
458
|
+
"force_command": args.command,
|
|
459
|
+
"force_analysis": args.analysis,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
response = make_request(payload)
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
466
|
+
return 1
|
|
467
|
+
|
|
468
|
+
if response.get("type") == "status":
|
|
469
|
+
print(
|
|
470
|
+
f"error: calmd status={response.get('status')}: {response.get('message', '')}",
|
|
471
|
+
file=sys.stderr,
|
|
472
|
+
)
|
|
473
|
+
return 1
|
|
474
|
+
|
|
475
|
+
res_type = response.get("type", "analysis")
|
|
476
|
+
content = response.get("content", "").strip()
|
|
477
|
+
|
|
478
|
+
if res_type == "analysis":
|
|
479
|
+
if args.command:
|
|
480
|
+
print(f"error: no command generated; analysis: {content}", file=sys.stderr)
|
|
481
|
+
return 1
|
|
482
|
+
print(content)
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
if res_type != "command":
|
|
486
|
+
print("error: invalid daemon response type", file=sys.stderr)
|
|
487
|
+
return 1
|
|
488
|
+
|
|
489
|
+
if args.analysis:
|
|
490
|
+
print(f"error: no analysis generated; command: {content}", file=sys.stderr)
|
|
491
|
+
return 1
|
|
492
|
+
|
|
493
|
+
if not content:
|
|
494
|
+
print("error: empty command generated", file=sys.stderr)
|
|
495
|
+
return 1
|
|
496
|
+
|
|
497
|
+
# In a piped chain, we only want the clean output on stdout.
|
|
498
|
+
print(content)
|
|
499
|
+
|
|
500
|
+
runnable = bool(response.get("runnable", False))
|
|
501
|
+
if not runnable:
|
|
502
|
+
return 0
|
|
503
|
+
|
|
504
|
+
safe = bool(response.get("safe", True))
|
|
505
|
+
dangerous = is_dangerous(content)
|
|
506
|
+
|
|
507
|
+
if (dangerous or not safe) and not args.force:
|
|
508
|
+
reason = "dangerous" if dangerous else "potentially unsafe (flagged by model)"
|
|
509
|
+
print(f"Refusing {reason} command without --force", file=sys.stderr)
|
|
510
|
+
return 1
|
|
511
|
+
|
|
512
|
+
should_run = args.yolo
|
|
513
|
+
# Only prompt if stdout is a terminal (so we don't corrupt the pipe)
|
|
514
|
+
# AND stdin is a terminal (so we can actually read the user's y/n).
|
|
515
|
+
if not should_run and sys.stdout.isatty() and sys.stdin.isatty():
|
|
516
|
+
try:
|
|
517
|
+
answer = input("\nRun this command? [y/N] ").strip().lower()
|
|
518
|
+
should_run = answer in {"y", "yes"}
|
|
519
|
+
except EOFError:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
if should_run:
|
|
523
|
+
return execute_command(content)
|
|
524
|
+
|
|
525
|
+
return 0
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
if __name__ == "__main__":
|
|
529
|
+
raise SystemExit(main())
|