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.
- kuzywrap-0.1.72.data/data/lib/kuzywrap/antigravity.py +631 -0
- kuzywrap-0.1.72.data/data/lib/kuzywrap/claude-runtime +17 -0
- kuzywrap-0.1.72.data/data/lib/kuzywrap/codewhale-app-server +536 -0
- kuzywrap-0.1.72.data/data/lib/kuzywrap/codex-app-server +33 -0
- kuzywrap-0.1.72.data/data/lib/kuzywrap/deepseek-app-server +458 -0
- kuzywrap-0.1.72.data/data/lib/kuzywrap/opencode-app-server +797 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/etc/claude/default.env +9 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/etc/codewhale/default.env +9 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/etc/codex/default.env +8 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/etc/deepseek/default.env +9 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/etc/opencode/default.env +3 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-claude@.service +21 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-codewhale@.service +22 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-codex@.service +22 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-deepseek@.service +22 -0
- kuzywrap-0.1.72.data/data/share/kuzywrap/systemd/kuzywrap-opencode@.service +22 -0
- kuzywrap-0.1.72.data/scripts/kuzywrap-antigravity +7 -0
- kuzywrap-0.1.72.data/scripts/kuzywrap-claude +3038 -0
- kuzywrap-0.1.72.data/scripts/kuzywrap-codewhale +1458 -0
- kuzywrap-0.1.72.data/scripts/kuzywrap-codex +3647 -0
- kuzywrap-0.1.72.data/scripts/kuzywrap-deepseek +1285 -0
- kuzywrap-0.1.72.data/scripts/kuzywrap-opencode +1444 -0
- kuzywrap-0.1.72.dist-info/METADATA +225 -0
- kuzywrap-0.1.72.dist-info/RECORD +30 -0
- kuzywrap-0.1.72.dist-info/WHEEL +5 -0
- kuzywrap-0.1.72.dist-info/licenses/LICENSE +661 -0
- kuzywrap-0.1.72.dist-info/top_level.txt +3 -0
- kuzywrap_claude_doctor.py +288 -0
- kuzywrap_service.py +474 -0
- 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
|