kuzywrap 0.1.72__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.
Files changed (30) hide show
  1. kuzywrap-0.1.72.data/data/lib/kuzywrap/antigravity.py +631 -0
  2. kuzywrap-0.1.72.data/data/lib/kuzywrap/claude-runtime +17 -0
  3. kuzywrap-0.1.72.data/data/lib/kuzywrap/codewhale-app-server +536 -0
  4. kuzywrap-0.1.72.data/data/lib/kuzywrap/codex-app-server +33 -0
  5. kuzywrap-0.1.72.data/data/lib/kuzywrap/deepseek-app-server +458 -0
  6. kuzywrap-0.1.72.data/data/lib/kuzywrap/opencode-app-server +797 -0
  7. kuzywrap-0.1.72.data/data/share/kuzywrap/etc/claude/default.env +9 -0
  8. kuzywrap-0.1.72.data/data/share/kuzywrap/etc/codewhale/default.env +9 -0
  9. kuzywrap-0.1.72.data/data/share/kuzywrap/etc/codex/default.env +8 -0
  10. kuzywrap-0.1.72.data/data/share/kuzywrap/etc/deepseek/default.env +9 -0
  11. kuzywrap-0.1.72.data/data/share/kuzywrap/etc/opencode/default.env +3 -0
  12. kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-claude@.service +21 -0
  13. kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-codewhale@.service +22 -0
  14. kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-codex@.service +22 -0
  15. kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-deepseek@.service +22 -0
  16. kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-opencode@.service +22 -0
  17. kuzywrap-0.1.72.data/scripts/kuzywrap-antigravity +7 -0
  18. kuzywrap-0.1.72.data/scripts/kuzywrap-claude +3038 -0
  19. kuzywrap-0.1.72.data/scripts/kuzywrap-codewhale +1458 -0
  20. kuzywrap-0.1.72.data/scripts/kuzywrap-codex +3647 -0
  21. kuzywrap-0.1.72.data/scripts/kuzywrap-deepseek +1285 -0
  22. kuzywrap-0.1.72.data/scripts/kuzywrap-opencode +1444 -0
  23. kuzywrap-0.1.72.dist-info/METADATA +225 -0
  24. kuzywrap-0.1.72.dist-info/RECORD +30 -0
  25. kuzywrap-0.1.72.dist-info/WHEEL +5 -0
  26. kuzywrap-0.1.72.dist-info/licenses/LICENSE +661 -0
  27. kuzywrap-0.1.72.dist-info/top_level.txt +3 -0
  28. kuzywrap_claude_doctor.py +288 -0
  29. kuzywrap_service.py +474 -0
  30. kuzywrap_service_specs.py +139 -0
@@ -0,0 +1,631 @@
1
+ """Tmux-backed Antigravity wrapper (Option 1).
2
+
3
+ Runs Google's native `agy` CLI inside a dedicated tmux session
4
+ (`kuzywrap-antigravity-<target>`). Managed chat attaches directly to tmux,
5
+ giving the operator the full native Antigravity TUI (Markdown, colored diffs,
6
+ thinking blocks, interactive tool approvals, slash commands).
7
+
8
+ Inject submits prompts via a private tmux paste buffer and Enter key,
9
+ recording all turns in a local SQLite3 journal for durability and auditability.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from contextlib import contextmanager
15
+ import hashlib
16
+ import json
17
+ import math
18
+ import os
19
+ from pathlib import Path
20
+ import re
21
+ import shlex
22
+ import shutil
23
+ import sqlite3
24
+ import subprocess
25
+ import sys
26
+ import tarfile
27
+ import tempfile
28
+ import time
29
+ import urllib.request
30
+ import uuid
31
+
32
+ RUNTIME = "antigravity"
33
+ TMUX_SESSION_PREFIX = "kuzywrap-antigravity"
34
+ DEFAULT_EFFORT = "high"
35
+ MAX_LINE = 4 * 1024 * 1024
36
+
37
+
38
+ class Error(Exception):
39
+ def __init__(self, code: str, message: str, *, retryable: bool = False, details: dict | None = None):
40
+ super().__init__(message)
41
+ self.code, self.retryable, self.details = code, retryable, details or {}
42
+
43
+ def payload(self) -> dict:
44
+ data = {
45
+ "ok": False,
46
+ "status": "failed",
47
+ "runtime": RUNTIME,
48
+ "error_code": self.code,
49
+ "message": str(self),
50
+ "retryable": self.retryable,
51
+ "error": {"code": self.code, "message": str(self)},
52
+ }
53
+ if self.details:
54
+ data["error"]["details"] = self.details
55
+ return data
56
+
57
+
58
+ def safe_target(value: str) -> str:
59
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,47}", value):
60
+ raise argparse.ArgumentTypeError("Target must be 1-48 letters, digits, dots, dashes or underscores.")
61
+ return value
62
+
63
+
64
+ def session_id(value: str) -> str:
65
+ if not re.fullmatch(r"[a-zA-Z0-9-]{32,128}", value):
66
+ raise argparse.ArgumentTypeError("Expected a native Antigravity conversation ID (32-128 letters/digits/dashes).")
67
+ return value
68
+
69
+
70
+ def positive_timeout(value: str) -> float:
71
+ result = float(value)
72
+ if not math.isfinite(result) or result <= 0:
73
+ raise argparse.ArgumentTypeError("Timeout must be finite and positive.")
74
+ return result
75
+
76
+
77
+ def config_root() -> Path:
78
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "kuzywrap" / RUNTIME
79
+
80
+
81
+ def state_root() -> Path:
82
+ return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) / "kuzywrap" / RUNTIME
83
+
84
+
85
+ def paths(target: str) -> dict[str, Path]:
86
+ state = state_root() / target
87
+ return {
88
+ "config": config_root() / "targets" / f"{target}.json",
89
+ "state": state,
90
+ "log": state / "runtime.log",
91
+ "journal": state / "journal.sqlite3",
92
+ }
93
+
94
+
95
+ def load_config(target: str) -> dict:
96
+ path = paths(target)["config"]
97
+ if not path.exists():
98
+ raise Error("target_missing", f"Configure target first: kuzywrap-antigravity target set {target} --current-user --cd PATH")
99
+ return json.loads(path.read_text(encoding="utf-8"))
100
+
101
+
102
+ def tmux_session_name(target: str) -> str:
103
+ return f"{TMUX_SESSION_PREFIX}-{target}"
104
+
105
+
106
+ def _tmux_run(args: list[str], *, input: str | None = None) -> subprocess.CompletedProcess[str]:
107
+ return subprocess.run(
108
+ ["tmux", *args],
109
+ input=input,
110
+ capture_output=True,
111
+ text=True,
112
+ )
113
+
114
+
115
+ def tmux_has_session(session: str) -> bool:
116
+ res = _tmux_run(["has-session", "-t", session])
117
+ return res.returncode == 0
118
+
119
+
120
+ class Journal:
121
+ def __init__(self, path: Path):
122
+ self.path = path
123
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
124
+ with sqlite3.connect(self.path) as db:
125
+ db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
126
+ db.execute("""CREATE TABLE IF NOT EXISTS turns (
127
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
128
+ request_id TEXT UNIQUE NOT NULL,
129
+ prompt TEXT NOT NULL,
130
+ source TEXT NOT NULL,
131
+ status TEXT NOT NULL,
132
+ response TEXT,
133
+ error TEXT,
134
+ submitted_at REAL NOT NULL,
135
+ updated_at REAL NOT NULL
136
+ )""")
137
+ db.execute("""CREATE TABLE IF NOT EXISTS events (
138
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
139
+ turn_id INTEGER,
140
+ status TEXT NOT NULL,
141
+ timestamp REAL NOT NULL
142
+ )""")
143
+
144
+ def record_turn(self, *, request_id: str, prompt: str, source: str, status: str = "accepted") -> int:
145
+ now = time.time()
146
+ with sqlite3.connect(self.path) as db:
147
+ cursor = db.cursor()
148
+ cursor.execute(
149
+ "INSERT OR REPLACE INTO turns (request_id, prompt, source, status, submitted_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
150
+ (request_id, prompt, source, status, now, now),
151
+ )
152
+ turn_id = cursor.lastrowid
153
+ cursor.execute("INSERT INTO events (turn_id, status, timestamp) VALUES (?, ?, ?)", (turn_id, status, now))
154
+ db.commit()
155
+ return turn_id
156
+
157
+ def events(self, after: int = 0, limit: int = 100) -> list[dict]:
158
+ with sqlite3.connect(self.path) as db:
159
+ cursor = db.cursor()
160
+ cursor.execute("""
161
+ SELECT e.seq, e.turn_id, e.status, e.timestamp, t.request_id, t.prompt, t.source, t.response, t.error
162
+ FROM events e JOIN turns t ON e.turn_id = t.id
163
+ WHERE e.seq > ? ORDER BY e.seq ASC LIMIT ?
164
+ """, (after, limit))
165
+ rows = cursor.fetchall()
166
+ return [
167
+ {
168
+ "seq": r[0], "id": r[1], "event_status": r[2], "timestamp": r[3],
169
+ "request_id": r[4], "prompt": r[5], "source": r[6], "response": r[7], "error": r[8]
170
+ }
171
+ for r in rows
172
+ ]
173
+
174
+ def last_seq(self) -> int:
175
+ with sqlite3.connect(self.path) as db:
176
+ cursor = db.cursor()
177
+ cursor.execute("SELECT COALESCE(MAX(seq), 0) FROM events")
178
+ row = cursor.fetchone()
179
+ return row[0] if row else 0
180
+
181
+ def get_meta(self, key: str) -> str | None:
182
+ with sqlite3.connect(self.path) as db:
183
+ cursor = db.cursor()
184
+ cursor.execute("SELECT value FROM meta WHERE key = ?", (key,))
185
+ row = cursor.fetchone()
186
+ return row[0] if row else None
187
+
188
+ def set_meta(self, key: str, value: str) -> None:
189
+ with sqlite3.connect(self.path) as db:
190
+ db.execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, str(value)))
191
+
192
+
193
+ def build_agy_cmd(config: dict) -> list[str]:
194
+ binary = config.get("binary") or shutil.which("agy") or str(Path.home() / ".local/bin/agy")
195
+ cmd = [binary]
196
+ session = config.get("session_id")
197
+ if session:
198
+ cmd += ["--conversation", session]
199
+ else:
200
+ cmd += ["--continue"]
201
+
202
+ model = config.get("model")
203
+ effort = config.get("effort")
204
+ if model:
205
+ cmd += ["--model", model]
206
+ if not effort and model.startswith("gemini"):
207
+ effort = DEFAULT_EFFORT
208
+ if effort:
209
+ cmd += ["--effort", effort]
210
+
211
+ if config.get("agent"):
212
+ cmd += ["--agent", config["agent"]]
213
+ if config.get("skip_permissions"):
214
+ cmd += ["--dangerously-skip-permissions"]
215
+
216
+ return cmd
217
+
218
+
219
+ def start(target: str) -> dict:
220
+ config = load_config(target)
221
+ session = tmux_session_name(target)
222
+ if tmux_has_session(session):
223
+ return {
224
+ "ok": True,
225
+ "runtime": RUNTIME,
226
+ "target": target,
227
+ "state": "running",
228
+ "tmux_session": session,
229
+ "session_id": config.get("session_id"),
230
+ "already_running": True,
231
+ }
232
+
233
+ loc = paths(target)
234
+ loc["state"].mkdir(parents=True, exist_ok=True, mode=0o700)
235
+ workdir = Path(config.get("workdir", Path.cwd())).resolve()
236
+ if not workdir.is_dir():
237
+ raise Error("invalid_workdir", f"Working directory does not exist: {workdir}")
238
+
239
+ binary = config.get("binary") or shutil.which("agy") or str(Path.home() / ".local/bin/agy")
240
+ if not Path(binary).is_file():
241
+ raise Error("binary_missing", f"No upstream binary at {binary}. Install Antigravity CLI first.")
242
+
243
+ cmd = build_agy_cmd(config)
244
+ inner = " ".join(shlex.quote(p) for p in cmd)
245
+
246
+ spawn = _tmux_run(["new-session", "-d", "-s", session, "-c", str(workdir), inner])
247
+ if spawn.returncode != 0:
248
+ err = spawn.stderr.strip() or spawn.stdout.strip()
249
+ raise Error("startup_failed", f"Failed to start tmux session {session}: {err}")
250
+
251
+ _tmux_run(["pipe-pane", "-t", session, "-o", f"cat >> {shlex.quote(str(loc['log']))}"])
252
+
253
+ deadline = time.monotonic() + 3.0
254
+ alive = False
255
+ while time.monotonic() < deadline:
256
+ if tmux_has_session(session):
257
+ alive = True
258
+ break
259
+ time.sleep(0.1)
260
+
261
+ if not alive:
262
+ log_tail = ""
263
+ if loc["log"].exists():
264
+ log_tail = loc["log"].read_text(encoding="utf-8")[-400:]
265
+ raise Error("startup_failed", f"Runtime died immediately after startup. Log: {log_tail}")
266
+
267
+ return {
268
+ "ok": True,
269
+ "runtime": RUNTIME,
270
+ "target": target,
271
+ "state": "running",
272
+ "session_id": config.get("session_id"),
273
+ "tmux_session": session,
274
+ "capabilities": {
275
+ "inject": True,
276
+ "steer": True,
277
+ "native_tui_attach": True,
278
+ },
279
+ }
280
+
281
+
282
+ def stop(target: str) -> dict:
283
+ session = tmux_session_name(target)
284
+ if tmux_has_session(session):
285
+ _tmux_run(["kill-session", "-t", session])
286
+ return {
287
+ "ok": True,
288
+ "runtime": RUNTIME,
289
+ "target": target,
290
+ "state": "stopped",
291
+ "stopping": True,
292
+ }
293
+
294
+
295
+ def status(target: str) -> dict:
296
+ session = tmux_session_name(target)
297
+ running = tmux_has_session(session)
298
+ config = {}
299
+ try:
300
+ config = load_config(target)
301
+ except Exception:
302
+ pass
303
+ return {
304
+ "ok": True,
305
+ "runtime": RUNTIME,
306
+ "target": target,
307
+ "state": "running" if running else "stopped",
308
+ "tmux_session": session,
309
+ "session_id": config.get("session_id"),
310
+ }
311
+
312
+
313
+ def chat(target: str, *, session_id: str | None = None, cd: str | None = None) -> int:
314
+ config = load_config(target)
315
+ session = tmux_session_name(target)
316
+ if cd and str(Path(cd).resolve()) != config.get("workdir"):
317
+ raise Error("workdir_mismatch", "Chat directory differs from configured target.")
318
+ if session_id and config.get("session_id") and session_id != config.get("session_id"):
319
+ raise Error("session_mismatch", "Chat anchor differs from configured target.")
320
+
321
+ if not tmux_has_session(session):
322
+ start(target)
323
+
324
+ res = subprocess.run(["tmux", "attach-session", "-t", session])
325
+ return res.returncode
326
+
327
+
328
+ def inject(
329
+ target: str,
330
+ prompt: str,
331
+ *,
332
+ wait_mode: str = "accepted",
333
+ timeout: float = 30.0,
334
+ request_id: str | None = None,
335
+ prompt_source: str = "argv",
336
+ ) -> dict:
337
+ session = tmux_session_name(target)
338
+ if not tmux_has_session(session):
339
+ raise Error(
340
+ "runtime_inactive",
341
+ f"No tmux session {session!r} running for target {target!r}. Start it first with 'kuzywrap-antigravity start {target}'.",
342
+ )
343
+
344
+ if not prompt.strip():
345
+ raise Error("prompt_empty", "Prompt cannot be empty.")
346
+
347
+ buf_name = f"kuzywrap-antigravity-{target}-{os.getpid()}-{int(time.time() * 1000)}"
348
+ prompt_to_paste = prompt.rstrip("\r\n")
349
+ load_res = _tmux_run(["load-buffer", "-b", buf_name, "-"], input=prompt_to_paste)
350
+ if load_res.returncode != 0:
351
+ raise Error("buffer_load_failed", f"tmux load-buffer failed: {load_res.stderr.strip()}")
352
+
353
+ paste_res = _tmux_run(["paste-buffer", "-p", "-r", "-d", "-b", buf_name, "-t", session])
354
+ if paste_res.returncode != 0:
355
+ _tmux_run(["delete-buffer", "-b", buf_name])
356
+ raise Error("buffer_paste_failed", f"tmux paste-buffer failed: {paste_res.stderr.strip()}")
357
+
358
+ enter_res = _tmux_run(["send-keys", "-t", session, "Enter"])
359
+ if enter_res.returncode != 0:
360
+ raise Error("send_keys_failed", f"tmux send-keys failed: {enter_res.stderr.strip()}")
361
+
362
+ loc = paths(target)
363
+ journal = Journal(loc["journal"])
364
+ req_id = request_id or uuid.uuid4().hex
365
+ turn_id = journal.record_turn(request_id=req_id, prompt=prompt, source=prompt_source, status="accepted")
366
+
367
+ config = {}
368
+ try:
369
+ config = load_config(target)
370
+ except Exception:
371
+ pass
372
+
373
+ return {
374
+ "ok": True,
375
+ "runtime": RUNTIME,
376
+ "target": target,
377
+ "status": "accepted",
378
+ "wait_mode": wait_mode,
379
+ "delivery_mode": "tmux_paste_buffer",
380
+ "tmux_session": session,
381
+ "injection_id": turn_id,
382
+ "request_id": req_id,
383
+ "session_id": config.get("session_id"),
384
+ "submitted_at": time.time(),
385
+ }
386
+
387
+
388
+ def install_upstream(directory: str, *, dry_run: bool = False) -> dict:
389
+ arch = {"x86_64": "amd64", "aarch64": "arm64"}.get(os.uname().machine)
390
+ if sys.platform != "linux" or not arch:
391
+ raise Error("unsupported_platform", "This installer supports Linux x86_64 and aarch64 only.")
392
+ suffix = "_musl" if any(Path("/lib").glob("libc.musl-*.so.1")) else ""
393
+ url = f"https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/linux_{arch}{suffix}.json"
394
+ destination = Path(directory).expanduser().resolve() / "agy"
395
+ if dry_run:
396
+ return {"manifest": url, "destination": str(destination), "verification": "SHA-512", "shell_profiles_modified": False}
397
+ destination.parent.mkdir(parents=True, exist_ok=True)
398
+ if destination.exists() or destination.is_symlink():
399
+ raise Error("already_installed", f"Refusing to overwrite {destination}. Use upgrade for an existing installation.")
400
+ with urllib.request.urlopen(url, timeout=30) as response:
401
+ manifest = json.load(response)
402
+ download = manifest["url"]
403
+ expected = manifest["sha512"]
404
+ if not download.startswith("https://storage.googleapis.com/antigravity-public/antigravity-cli/") or not re.fullmatch(r"[0-9a-f]{128}", expected):
405
+ raise Error("invalid_manifest", "Unexpected download origin or checksum in official manifest.")
406
+ with tempfile.TemporaryDirectory(prefix="agy-install-", dir=destination.parent) as tmp:
407
+ archive = Path(tmp) / "archive"
408
+ digest = hashlib.sha512()
409
+ with urllib.request.urlopen(download, timeout=60) as response, archive.open("wb") as output:
410
+ while chunk := response.read(1024 * 1024):
411
+ digest.update(chunk)
412
+ output.write(chunk)
413
+ if digest.hexdigest() != expected:
414
+ raise Error("checksum_mismatch", "Downloaded Antigravity archive failed SHA-512 verification.")
415
+ staged = Path(tmp) / "agy"
416
+ with tarfile.open(archive, "r:gz") as package:
417
+ member = package.getmember("antigravity")
418
+ member.name = "agy"
419
+ package.extract(member, tmp)
420
+ staged.chmod(0o755)
421
+ staged.replace(destination)
422
+ return {"ok": True, "installed": str(destination), "manifest": url}
423
+
424
+
425
+ def parser() -> argparse.ArgumentParser:
426
+ root = argparse.ArgumentParser(prog="kuzywrap-antigravity", description="Tmux-backed Google Antigravity stream wrapper.")
427
+ sub = root.add_subparsers(dest="command", required=True)
428
+
429
+ install = sub.add_parser("install")
430
+ install.add_argument("dir", default=str(Path.home() / ".local/bin"), nargs="?")
431
+ install.add_argument("--dry-run", action="store_true")
432
+
433
+ target = sub.add_parser("target").add_subparsers(dest="action", required=True)
434
+ setting = target.add_parser("set")
435
+ setting.add_argument("target", type=safe_target)
436
+ setting.add_argument("--current-user", action="store_true", required=True)
437
+ setting.add_argument("--cd")
438
+ setting.add_argument("--binary")
439
+ setting.add_argument("--session-id", type=session_id)
440
+ setting.add_argument("--model")
441
+ setting.add_argument("--agent")
442
+ setting.add_argument("--display-name")
443
+ setting.add_argument("--effort", choices=["low", "medium", "high", "xhigh", "max"])
444
+ setting.add_argument("--dangerously-skip-permissions", action="store_true", default=None)
445
+
446
+ for action in ("show", "list", "unset"):
447
+ p = target.add_parser(action)
448
+ if action in ("show", "unset"):
449
+ p.add_argument("target", type=safe_target)
450
+ p.add_argument("--json", action="store_true")
451
+
452
+ for action in ("start", "stop", "restart", "status", "paths", "chat", "sync-status", "verify", "doctor"):
453
+ p = sub.add_parser(action)
454
+ p.add_argument("target", type=safe_target)
455
+ p.add_argument("--json", action="store_true")
456
+ if action == "chat":
457
+ p.add_argument("--session-id", type=session_id)
458
+ p.add_argument("--cd")
459
+ if action == "sync-status":
460
+ p.add_argument("--after", type=int)
461
+ p.add_argument("--limit", type=int, default=100)
462
+ p.add_argument("--ack", type=int, metavar="CURSOR")
463
+
464
+ inject_parser = sub.add_parser("inject")
465
+ inject_parser.add_argument("target", type=safe_target)
466
+ inject_parser.add_argument("prompt", nargs="+")
467
+ inject_parser.add_argument("--wait", choices=["accepted", "completed"], default="accepted")
468
+ inject_parser.add_argument("--no-wait", action="store_true", help="Return immediately after submission.")
469
+ inject_parser.add_argument("--timeout", type=positive_timeout)
470
+ inject_parser.add_argument("--request-id", help="Stable idempotency key.")
471
+ inject_parser.add_argument("--session-id", type=session_id)
472
+
473
+ sub.add_parser("version")
474
+ sub.add_parser("upgrade")
475
+ return root
476
+
477
+
478
+ def execute(args: argparse.Namespace):
479
+ command = args.command
480
+ if command == "install":
481
+ return install_upstream(args.dir, dry_run=args.dry_run)
482
+ if command in ("version", "upgrade"):
483
+ binary = shutil.which("agy") or str(Path.home() / ".local/bin/agy")
484
+ if not shutil.which("agy") and not Path(binary).exists():
485
+ raise Error("runtime_missing", "Antigravity binary not found. Install it first: kuzywrap-antigravity install")
486
+ return subprocess.call([binary, "--version" if command == "version" else "update"])
487
+
488
+ if command == "target":
489
+ if args.action == "list":
490
+ configs = []
491
+ targets_dir = config_root() / "targets"
492
+ if targets_dir.exists():
493
+ for p in sorted(targets_dir.glob("*.json")):
494
+ try:
495
+ configs.append(json.loads(p.read_text(encoding="utf-8")))
496
+ except Exception:
497
+ pass
498
+ return {"targets": configs}
499
+
500
+ if args.action == "show":
501
+ return load_config(args.target)
502
+
503
+ if args.action == "unset":
504
+ path = paths(args.target)["config"]
505
+ if path.exists():
506
+ path.unlink()
507
+ return {"ok": True, "unset": args.target}
508
+
509
+ # target set
510
+ path = paths(args.target)["config"]
511
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
512
+ old = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
513
+ record = dict(old, target=args.target, kind="current-user")
514
+ record.setdefault("binary", shutil.which("agy") or str(Path.home() / ".local/bin/agy"))
515
+ record.setdefault("workdir", str(Path.cwd()))
516
+ for flag, key in (
517
+ ("cd", "workdir"),
518
+ ("binary", "binary"),
519
+ ("session_id", "session_id"),
520
+ ("model", "model"),
521
+ ("agent", "agent"),
522
+ ("effort", "effort"),
523
+ ("display_name", "display_name"),
524
+ ("dangerously_skip_permissions", "skip_permissions"),
525
+ ):
526
+ value = getattr(args, flag, None)
527
+ if value is not None:
528
+ record[key] = str(Path(value).expanduser().resolve()) if key in ("workdir", "binary") else value
529
+
530
+ if not Path(record["workdir"]).is_dir():
531
+ raise Error("invalid_workdir", "Working directory must already exist.")
532
+
533
+ path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
534
+ path.chmod(0o600)
535
+ return record
536
+
537
+ target = getattr(args, "target", None)
538
+ if command == "start":
539
+ return start(target)
540
+ if command == "stop":
541
+ return stop(target)
542
+ if command == "restart":
543
+ stop(target)
544
+ return start(target)
545
+ if command == "status":
546
+ return status(target)
547
+ if command == "paths":
548
+ return {k: str(v) for k, v in paths(target).items()}
549
+
550
+ if command == "chat":
551
+ return chat(target, session_id=args.session_id, cd=args.cd)
552
+
553
+ if command == "inject":
554
+ stdin = args.prompt == ["-"]
555
+ prompt = sys.stdin.read(MAX_LINE // 2) if stdin else " ".join(args.prompt)
556
+ mode = "accepted" if args.no_wait else args.wait
557
+ timeout = args.timeout or 30.0
558
+ return inject(
559
+ target,
560
+ prompt,
561
+ wait_mode=mode,
562
+ timeout=timeout,
563
+ request_id=args.request_id,
564
+ prompt_source="stdin" if stdin else "argv",
565
+ )
566
+
567
+ if command == "sync-status":
568
+ loc = paths(target)
569
+ journal = Journal(loc["journal"])
570
+ if args.ack is not None:
571
+ journal.set_meta("ack_cursor", str(args.ack))
572
+ return {"ok": True, "ack_cursor": args.ack}
573
+ after = args.after
574
+ if after is None:
575
+ after = int(journal.get_meta("ack_cursor") or 0)
576
+ evs = journal.events(after=after, limit=args.limit)
577
+ cfg = {}
578
+ try:
579
+ cfg = load_config(target)
580
+ except Exception:
581
+ pass
582
+ return {
583
+ "ok": True,
584
+ "events": evs,
585
+ "cursor": evs[-1]["seq"] if evs else after,
586
+ "last_seq": journal.last_seq(),
587
+ "session_id": cfg.get("session_id"),
588
+ }
589
+
590
+ if command in ("verify", "doctor"):
591
+ cfg = {}
592
+ target_ok = False
593
+ try:
594
+ cfg = load_config(target)
595
+ target_ok = True
596
+ except Exception:
597
+ pass
598
+ tmux_ok = shutil.which("tmux") is not None
599
+ agy_ok = shutil.which("agy") is not None or Path(cfg.get("binary", "")).is_file()
600
+ running = tmux_has_session(tmux_session_name(target)) if target_ok else False
601
+ return {
602
+ "ok": tmux_ok and agy_ok and target_ok,
603
+ "target": target,
604
+ "configured": target_ok,
605
+ "running": running,
606
+ "tmux_available": tmux_ok,
607
+ "agy_available": agy_ok,
608
+ "runtime": RUNTIME,
609
+ }
610
+
611
+ raise Error("unknown_command", command)
612
+
613
+
614
+ def main(argv=None) -> int:
615
+ os.umask(0o077)
616
+ args = parser().parse_args(argv)
617
+ try:
618
+ result = execute(args)
619
+ except Error as exc:
620
+ result = exc.payload()
621
+ except (OSError, ValueError, KeyError, sqlite3.Error) as exc:
622
+ result = Error("runtime_error", str(exc)).payload()
623
+
624
+ if isinstance(result, int):
625
+ return result
626
+ print(json.dumps(result, indent=2))
627
+ return 0 if result.get("ok", True) else 1
628
+
629
+
630
+ if __name__ == "__main__":
631
+ raise SystemExit(main())
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ # kuzywrap-claude persistent runtime launcher.
5
+ #
6
+ # Step 1 of the Claude-side scope (kuzys/process/CLAUDE_SIDE_SCOPE.md) ships
7
+ # this helper as an explicit stub. The systemd unit kuzywrap-claude@.service
8
+ # is installed for packaging symmetry with kuzywrap-codex, but starting it
9
+ # fails until Step 2 decides how to keep a Remote-Control-aware claude session
10
+ # alive under systemd (TTY allocation, login persistence, JWT refresh, etc).
11
+
12
+ agent="${1:?agent instance name required}"
13
+
14
+ echo "kuzywrap-claude: persistent service launch is not yet implemented." >&2
15
+ echo "agent=${agent}" >&2
16
+ echo "See: /usr/share/doc/kuzywrap-claude/README.md" >&2
17
+ exit 78