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 ADDED
File without changes
calm/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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())