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.
@@ -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())