ai-cli-toolkit 0.2.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.
- ai_cli/__init__.py +3 -0
- ai_cli/__main__.py +6 -0
- ai_cli/bin/ai-mux-linux-x86_64 +0 -0
- ai_cli/bin/remote-tty-wrapper +153 -0
- ai_cli/ca.py +175 -0
- ai_cli/completion_gen.py +680 -0
- ai_cli/config.py +185 -0
- ai_cli/credentials.py +341 -0
- ai_cli/detached_cleanup.py +135 -0
- ai_cli/housekeeping.py +50 -0
- ai_cli/instructions.py +308 -0
- ai_cli/log.py +53 -0
- ai_cli/main.py +1516 -0
- ai_cli/main_helpers.py +553 -0
- ai_cli/prompt_editor_launcher.py +324 -0
- ai_cli/proxy.py +627 -0
- ai_cli/remote.py +669 -0
- ai_cli/remote_package.py +1111 -0
- ai_cli/session.py +1344 -0
- ai_cli/session_store.py +236 -0
- ai_cli/traffic.py +1510 -0
- ai_cli/traffic_db.py +118 -0
- ai_cli/tui.py +525 -0
- ai_cli/update.py +200 -0
- ai_cli_toolkit-0.2.0.dist-info/METADATA +17 -0
- ai_cli_toolkit-0.2.0.dist-info/RECORD +30 -0
- ai_cli_toolkit-0.2.0.dist-info/WHEEL +5 -0
- ai_cli_toolkit-0.2.0.dist-info/entry_points.txt +2 -0
- ai_cli_toolkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- ai_cli_toolkit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _slugify(value: str) -> str:
|
|
18
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
|
|
19
|
+
return slug[:48] or "project"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _project_identity(project_cwd: Path, remote_spec: str = "") -> tuple[str, str]:
|
|
23
|
+
if remote_spec.strip():
|
|
24
|
+
identity = f"remote:{remote_spec.strip()}::{project_cwd}"
|
|
25
|
+
label = f"{remote_spec.strip().split(':', 1)[0]} {project_cwd.name or 'project'}"
|
|
26
|
+
return identity, label
|
|
27
|
+
resolved = str(project_cwd.expanduser().resolve(strict=False))
|
|
28
|
+
return resolved, project_cwd.name or "project"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _project_prompt_path(project_cwd: Path, remote_spec: str = "") -> Path:
|
|
32
|
+
identity, label = _project_identity(project_cwd=project_cwd, remote_spec=remote_spec)
|
|
33
|
+
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:12]
|
|
34
|
+
dirname = f"{_slugify(label)}-{digest}"
|
|
35
|
+
return Path.home() / ".ai-cli" / "project-prompts" / dirname / "instructions.txt"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _tmux_current_path() -> Path | None:
|
|
39
|
+
pane = os.environ.get("TMUX_PANE", "").strip()
|
|
40
|
+
if not pane:
|
|
41
|
+
return None
|
|
42
|
+
proc = subprocess.run(
|
|
43
|
+
["tmux", "display-message", "-p", "-t", pane, "#{pane_current_path}"],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
if proc.returncode != 0:
|
|
49
|
+
return None
|
|
50
|
+
current = proc.stdout.strip()
|
|
51
|
+
if not current:
|
|
52
|
+
return None
|
|
53
|
+
return Path(current)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _target_path(target: str) -> str:
|
|
57
|
+
home = Path.home()
|
|
58
|
+
tool_name = os.environ.get("AI_CLI_TOOL", "codex")
|
|
59
|
+
remote_spec = os.environ.get("AI_CLI_REMOTE_SPEC", "")
|
|
60
|
+
workdir = Path(
|
|
61
|
+
os.environ.get("AI_CLI_WORKDIR")
|
|
62
|
+
or str(_tmux_current_path() or Path.cwd())
|
|
63
|
+
)
|
|
64
|
+
defaults = {
|
|
65
|
+
"global": str(home / ".ai-cli" / "system_instructions.txt"),
|
|
66
|
+
"base": str(home / ".ai-cli" / "base_instructions.txt"),
|
|
67
|
+
"tool": str(home / ".ai-cli" / "instructions" / f"{tool_name}.txt"),
|
|
68
|
+
"project": str(_project_prompt_path(workdir, remote_spec=remote_spec)),
|
|
69
|
+
}
|
|
70
|
+
env_map = {
|
|
71
|
+
"global": "AI_CLI_GLOBAL_PROMPT_FILE",
|
|
72
|
+
"base": "AI_CLI_BASE_PROMPT_FILE",
|
|
73
|
+
"tool": "AI_CLI_TOOL_PROMPT_FILE",
|
|
74
|
+
"project": "AI_CLI_PROJECT_PROMPT_FILE",
|
|
75
|
+
}
|
|
76
|
+
return os.environ.get(env_map[target], defaults[target])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_requested_file(file_arg: str | None, target: str | None) -> Path:
|
|
80
|
+
if file_arg:
|
|
81
|
+
return _resolve_file(file_arg)
|
|
82
|
+
if target:
|
|
83
|
+
return _resolve_file(_target_path(target))
|
|
84
|
+
raise RuntimeError("missing prompt target")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resolve_file(path_arg: str) -> Path:
|
|
88
|
+
return Path(path_arg).expanduser().resolve(strict=False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ensure_file(path: Path) -> None:
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
path.touch(exist_ok=True)
|
|
94
|
+
if path.name == "instructions.txt" and path.parent.parent.name == "project-prompts":
|
|
95
|
+
meta = path.parent / "meta.json"
|
|
96
|
+
if not meta.exists():
|
|
97
|
+
payload = {
|
|
98
|
+
"instructions_file": str(path),
|
|
99
|
+
"project_cwd": os.environ.get("AI_CLI_WORKDIR", str(path.parent)),
|
|
100
|
+
"remote_spec": os.environ.get("AI_CLI_REMOTE_SPEC", ""),
|
|
101
|
+
}
|
|
102
|
+
meta.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _lock_dir() -> Path:
|
|
106
|
+
return Path(
|
|
107
|
+
os.environ.get(
|
|
108
|
+
"AI_CLI_PROMPT_EDITOR_LOCK_DIR",
|
|
109
|
+
str(Path.home() / ".ai-cli" / "locks" / "prompt-editors"),
|
|
110
|
+
)
|
|
111
|
+
).expanduser()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _lock_path(target: Path) -> Path:
|
|
115
|
+
digest = hashlib.sha256(str(target).encode("utf-8")).hexdigest()
|
|
116
|
+
return _lock_dir() / f"{digest}.json"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _pid_alive(pid: int) -> bool:
|
|
120
|
+
if pid <= 0:
|
|
121
|
+
return False
|
|
122
|
+
try:
|
|
123
|
+
os.kill(pid, 0)
|
|
124
|
+
except OSError:
|
|
125
|
+
return False
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _read_lock(path: Path) -> dict[str, object] | None:
|
|
130
|
+
try:
|
|
131
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
132
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _write_lock(path: Path, payload: dict[str, object]) -> None:
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
139
|
+
try:
|
|
140
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
141
|
+
json.dump(payload, handle)
|
|
142
|
+
except Exception:
|
|
143
|
+
try:
|
|
144
|
+
path.unlink()
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _replace_stale_lock(path: Path, payload: dict[str, object]) -> bool:
|
|
151
|
+
current = _read_lock(path)
|
|
152
|
+
current_pid_raw = current.get("pid") if isinstance(current, dict) else None
|
|
153
|
+
if isinstance(current_pid_raw, (int, str)):
|
|
154
|
+
current_pid = int(current_pid_raw)
|
|
155
|
+
else:
|
|
156
|
+
current_pid = 0
|
|
157
|
+
if current and _pid_alive(current_pid):
|
|
158
|
+
return False
|
|
159
|
+
try:
|
|
160
|
+
path.unlink()
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
pass
|
|
163
|
+
except OSError:
|
|
164
|
+
return False
|
|
165
|
+
try:
|
|
166
|
+
_write_lock(path, payload)
|
|
167
|
+
except FileExistsError:
|
|
168
|
+
return False
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _acquire_lock(lock_path: Path, payload: dict[str, object]) -> bool:
|
|
173
|
+
try:
|
|
174
|
+
_write_lock(lock_path, payload)
|
|
175
|
+
return True
|
|
176
|
+
except FileExistsError:
|
|
177
|
+
return _replace_stale_lock(lock_path, payload)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _release_lock(lock_path: Path, token: str) -> None:
|
|
181
|
+
current = _read_lock(lock_path)
|
|
182
|
+
if isinstance(current, dict) and current.get("token") == token:
|
|
183
|
+
try:
|
|
184
|
+
lock_path.unlink()
|
|
185
|
+
except FileNotFoundError:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _tmux(socket_name: str | None, *args: str) -> subprocess.CompletedProcess[str]:
|
|
190
|
+
cmd = ["tmux"]
|
|
191
|
+
if socket_name:
|
|
192
|
+
cmd += ["-L", socket_name]
|
|
193
|
+
cmd += list(args)
|
|
194
|
+
return subprocess.run(cmd, check=False, text=True, capture_output=True)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _resolve_editor() -> list[str]:
|
|
198
|
+
configured = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
199
|
+
if configured:
|
|
200
|
+
return shlex.split(configured)
|
|
201
|
+
for candidate in ("nano", "vi", "vim"):
|
|
202
|
+
probe = subprocess.run(
|
|
203
|
+
["sh", "-lc", f"command -v {shlex.quote(candidate)} >/dev/null 2>&1"],
|
|
204
|
+
check=False,
|
|
205
|
+
)
|
|
206
|
+
if probe.returncode == 0:
|
|
207
|
+
return [candidate]
|
|
208
|
+
raise RuntimeError("No editor found (set VISUAL or EDITOR)")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _select_existing_window(socket_name: str | None, window_name: str) -> bool:
|
|
212
|
+
if not socket_name:
|
|
213
|
+
return False
|
|
214
|
+
proc = _tmux(socket_name, "select-window", "-t", window_name)
|
|
215
|
+
return proc.returncode == 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _display_message(socket_name: str | None, message: str) -> None:
|
|
219
|
+
if socket_name:
|
|
220
|
+
_tmux(socket_name, "display-message", message)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _self_command(args: argparse.Namespace, lock_path: Path, token: str) -> str:
|
|
224
|
+
python_bin = os.environ.get("AI_CLI_PYTHON") or sys.executable or "python3"
|
|
225
|
+
script_path = Path(__file__).resolve()
|
|
226
|
+
cmd = [
|
|
227
|
+
python_bin,
|
|
228
|
+
str(script_path),
|
|
229
|
+
"edit",
|
|
230
|
+
"--lock-file",
|
|
231
|
+
str(lock_path),
|
|
232
|
+
"--lock-token",
|
|
233
|
+
token,
|
|
234
|
+
"--window-name",
|
|
235
|
+
args.window_name,
|
|
236
|
+
]
|
|
237
|
+
if args.file:
|
|
238
|
+
cmd += ["--file", args.file]
|
|
239
|
+
if args.target:
|
|
240
|
+
cmd += ["--target", args.target]
|
|
241
|
+
if args.tmux_socket:
|
|
242
|
+
cmd += ["--tmux-socket", args.tmux_socket]
|
|
243
|
+
return " ".join(shlex.quote(part) for part in cmd)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _open_editor_window(args: argparse.Namespace) -> int:
|
|
247
|
+
target = _resolve_requested_file(args.file, args.target)
|
|
248
|
+
_ensure_file(target)
|
|
249
|
+
lock_path = _lock_path(target)
|
|
250
|
+
token = f"{time.time_ns()}-{os.getpid()}"
|
|
251
|
+
payload: dict[str, object] = {
|
|
252
|
+
"file": str(target),
|
|
253
|
+
"pid": os.getpid(),
|
|
254
|
+
"token": token,
|
|
255
|
+
"window_name": args.window_name,
|
|
256
|
+
}
|
|
257
|
+
if not _acquire_lock(lock_path, payload):
|
|
258
|
+
if not _select_existing_window(args.tmux_socket, args.window_name):
|
|
259
|
+
_display_message(args.tmux_socket, f"{target.name} already open")
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
cmd = ["new-window", "-n", args.window_name, "-c", str(target.parent), _self_command(args, lock_path, token)]
|
|
264
|
+
proc = _tmux(args.tmux_socket, *cmd)
|
|
265
|
+
if proc.returncode != 0:
|
|
266
|
+
raise RuntimeError(proc.stderr.strip() or "tmux new-window failed")
|
|
267
|
+
except Exception:
|
|
268
|
+
_release_lock(lock_path, token)
|
|
269
|
+
raise
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _edit_file(args: argparse.Namespace) -> int:
|
|
274
|
+
target = _resolve_requested_file(args.file, args.target)
|
|
275
|
+
_ensure_file(target)
|
|
276
|
+
lock_path = Path(args.lock_file).expanduser().resolve(strict=False)
|
|
277
|
+
current = _read_lock(lock_path)
|
|
278
|
+
if not isinstance(current, dict) or current.get("token") != args.lock_token:
|
|
279
|
+
_display_message(args.tmux_socket, f"{target.name} is already managed elsewhere")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
current["pid"] = os.getpid()
|
|
283
|
+
current["file"] = str(target)
|
|
284
|
+
current["window_name"] = args.window_name
|
|
285
|
+
lock_path.write_text(json.dumps(current), encoding="utf-8")
|
|
286
|
+
|
|
287
|
+
editor_cmd = _resolve_editor() + [str(target)]
|
|
288
|
+
try:
|
|
289
|
+
proc = subprocess.run(editor_cmd, cwd=str(target.parent), check=False)
|
|
290
|
+
return proc.returncode
|
|
291
|
+
finally:
|
|
292
|
+
_release_lock(lock_path, args.lock_token)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def main(argv: list[str] | None = None) -> int:
|
|
296
|
+
parser = argparse.ArgumentParser(description="Open ai-cli prompt editors safely inside tmux.")
|
|
297
|
+
subparsers = parser.add_subparsers(dest="action", required=True)
|
|
298
|
+
|
|
299
|
+
base = argparse.ArgumentParser(add_help=False)
|
|
300
|
+
base.add_argument("--file")
|
|
301
|
+
base.add_argument("--target", choices=["global", "base", "tool", "project"])
|
|
302
|
+
base.add_argument("--window-name", required=True)
|
|
303
|
+
base.add_argument("--tmux-socket")
|
|
304
|
+
|
|
305
|
+
open_parser = subparsers.add_parser("open", parents=[base])
|
|
306
|
+
|
|
307
|
+
edit_parser = subparsers.add_parser("edit", parents=[base])
|
|
308
|
+
edit_parser.add_argument("--lock-file", required=True)
|
|
309
|
+
edit_parser.add_argument("--lock-token", required=True)
|
|
310
|
+
|
|
311
|
+
args = parser.parse_args(argv)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
if args.action == "open":
|
|
315
|
+
return _open_editor_window(args)
|
|
316
|
+
return _edit_file(args)
|
|
317
|
+
except RuntimeError as exc:
|
|
318
|
+
_display_message(getattr(args, "tmux_socket", None), str(exc))
|
|
319
|
+
print(str(exc), file=sys.stderr)
|
|
320
|
+
return 1
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
raise SystemExit(main())
|