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
ai_cli/housekeeping.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Retention housekeeping for runtime artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ai_cli.log import append_log
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def prune_old_logs(log_dir: Path, max_age_days: int, log_path: Path | None = None) -> int:
|
|
13
|
+
"""Delete log files older than *max_age_days* from a log directory."""
|
|
14
|
+
if max_age_days < 1 or not log_dir.is_dir():
|
|
15
|
+
return 0
|
|
16
|
+
|
|
17
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
|
|
18
|
+
removed = 0
|
|
19
|
+
for path in log_dir.glob("*.log"):
|
|
20
|
+
try:
|
|
21
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
22
|
+
if mtime < cutoff:
|
|
23
|
+
path.unlink(missing_ok=True)
|
|
24
|
+
removed += 1
|
|
25
|
+
except OSError:
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
if removed and log_path is not None:
|
|
29
|
+
append_log(log_path, f"Retention: pruned {removed} old log files (>{max_age_days}d)")
|
|
30
|
+
return removed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def prune_old_traffic_rows(db_path: Path, max_age_days: int, log_path: Path | None = None) -> int:
|
|
34
|
+
"""Delete traffic rows older than *max_age_days* from the SQLite DB."""
|
|
35
|
+
if max_age_days < 1 or not db_path.is_file():
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=max_age_days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
39
|
+
try:
|
|
40
|
+
conn = sqlite3.connect(str(db_path))
|
|
41
|
+
cur = conn.execute("DELETE FROM traffic WHERE ts < ?", (cutoff,))
|
|
42
|
+
removed = cur.rowcount if cur.rowcount >= 0 else 0
|
|
43
|
+
conn.commit()
|
|
44
|
+
conn.close()
|
|
45
|
+
except sqlite3.Error:
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
if removed and log_path is not None:
|
|
49
|
+
append_log(log_path, f"Retention: pruned {removed} traffic rows (>{max_age_days}d)")
|
|
50
|
+
return removed
|
ai_cli/instructions.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Instructions file resolution, 5-layer composition, and editor launch.
|
|
2
|
+
|
|
3
|
+
Injection hierarchy (composed at runtime):
|
|
4
|
+
1. Canary rule — e.g. "CANARY RULE: Prefix every assistant response with: DEV:"
|
|
5
|
+
2. Base instructions — ~/.ai-cli/base_instructions.txt (generic gates/rules)
|
|
6
|
+
3. Per-tool — ~/.ai-cli/instructions/<tool>.txt (optional)
|
|
7
|
+
4. Project — ~/.ai-cli/project-prompts/<project>/instructions.txt
|
|
8
|
+
5. User custom — ~/.ai-cli/system_instructions.txt (free-form)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Defaults
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
DEFAULT_CANARY_RULE = "CANARY RULE: Prefix every assistant response with: DEV:"
|
|
26
|
+
DEFAULT_AI_CLI_DIR = "~/.ai-cli"
|
|
27
|
+
DEFAULT_INSTRUCTIONS_FILE = "system_instructions.txt"
|
|
28
|
+
BASE_INSTRUCTIONS_FILE = "base_instructions.txt"
|
|
29
|
+
PROJECT_PROMPTS_DIR = "project-prompts"
|
|
30
|
+
PROJECT_PROMPT_FILENAME = "instructions.txt"
|
|
31
|
+
PROJECT_PROMPT_META_FILENAME = "meta.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Low-level helpers
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def _read_text(path: Path) -> str:
|
|
39
|
+
"""Read a text file, returning empty string on error."""
|
|
40
|
+
try:
|
|
41
|
+
return path.read_text(encoding="utf-8").strip()
|
|
42
|
+
except OSError:
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ai_cli_dir() -> Path:
|
|
47
|
+
"""Return the resolved ~/.ai-cli directory."""
|
|
48
|
+
return Path(DEFAULT_AI_CLI_DIR).expanduser()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _shipped_base_instructions_path() -> Path:
|
|
52
|
+
"""Return the bundled base instructions template path."""
|
|
53
|
+
return Path(__file__).resolve().parent.parent / "templates" / BASE_INSTRUCTIONS_FILE
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _slugify(value: str) -> str:
|
|
57
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
|
|
58
|
+
return slug[:48] or "project"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _project_identity(project_cwd: str = "", remote_spec: str = "") -> tuple[str, str]:
|
|
62
|
+
if remote_spec.strip():
|
|
63
|
+
project_root = project_cwd.strip() or "."
|
|
64
|
+
identity = f"remote:{remote_spec.strip()}::{project_root}"
|
|
65
|
+
label = f"{remote_spec.strip().split(':', 1)[0]} {Path(project_root).name or 'project'}"
|
|
66
|
+
return identity, label
|
|
67
|
+
|
|
68
|
+
base = Path(project_cwd).expanduser() if project_cwd.strip() else Path.cwd()
|
|
69
|
+
resolved = str(base.resolve(strict=False))
|
|
70
|
+
return resolved, base.name or "project"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_project_prompt_dir(project_cwd: str = "", remote_spec: str = "") -> Path:
|
|
74
|
+
identity, label = _project_identity(project_cwd=project_cwd, remote_spec=remote_spec)
|
|
75
|
+
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:12]
|
|
76
|
+
return _ai_cli_dir() / PROJECT_PROMPTS_DIR / f"{_slugify(label)}-{digest}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_project_prompt_path(project_cwd: str = "", remote_spec: str = "") -> Path:
|
|
80
|
+
return resolve_project_prompt_dir(
|
|
81
|
+
project_cwd=project_cwd,
|
|
82
|
+
remote_spec=remote_spec,
|
|
83
|
+
) / PROJECT_PROMPT_FILENAME
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _legacy_project_instructions_path(project_cwd: str = "") -> Path:
|
|
87
|
+
base = Path(project_cwd).expanduser() if project_cwd.strip() else Path.cwd()
|
|
88
|
+
return base / ".ai-cli" / "project_instructions.txt"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ensure_project_instructions_file(project_cwd: str = "", remote_spec: str = "") -> str:
|
|
92
|
+
path = resolve_project_prompt_path(project_cwd=project_cwd, remote_spec=remote_spec)
|
|
93
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
legacy_path = _legacy_project_instructions_path(project_cwd=project_cwd)
|
|
95
|
+
if not path.exists():
|
|
96
|
+
if not remote_spec.strip() and legacy_path.is_file():
|
|
97
|
+
shutil.copy2(legacy_path, path)
|
|
98
|
+
else:
|
|
99
|
+
path.write_text("", encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
meta_path = path.parent / PROJECT_PROMPT_META_FILENAME
|
|
102
|
+
identity, _label = _project_identity(project_cwd=project_cwd, remote_spec=remote_spec)
|
|
103
|
+
payload = {
|
|
104
|
+
"identity": identity,
|
|
105
|
+
"instructions_file": str(path),
|
|
106
|
+
"project_cwd": project_cwd.strip() or str(Path.cwd()),
|
|
107
|
+
"remote_spec": remote_spec.strip(),
|
|
108
|
+
}
|
|
109
|
+
meta_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
110
|
+
return str(path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Instruction resolution
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def resolve_base_instructions() -> str:
|
|
118
|
+
"""Load the generic base instructions template.
|
|
119
|
+
|
|
120
|
+
Prefers ~/.ai-cli/base_instructions.txt so user edits stay out of the repo.
|
|
121
|
+
Falls back to the shipped template if the user file is missing or empty.
|
|
122
|
+
"""
|
|
123
|
+
user_template = _ai_cli_dir() / BASE_INSTRUCTIONS_FILE
|
|
124
|
+
user_text = _read_text(user_template)
|
|
125
|
+
if user_text:
|
|
126
|
+
return user_text
|
|
127
|
+
|
|
128
|
+
return _read_text(_shipped_base_instructions_path())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_base_instructions_path() -> Path:
|
|
132
|
+
"""Return the active base instructions file path."""
|
|
133
|
+
user_template = _ai_cli_dir() / BASE_INSTRUCTIONS_FILE
|
|
134
|
+
if _read_text(user_template):
|
|
135
|
+
return user_template
|
|
136
|
+
|
|
137
|
+
pkg_template = _shipped_base_instructions_path()
|
|
138
|
+
if _read_text(pkg_template):
|
|
139
|
+
return pkg_template
|
|
140
|
+
|
|
141
|
+
return user_template
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_tool_instructions(tool_name: str) -> str:
|
|
145
|
+
"""Load per-tool instruction overrides from ~/.ai-cli/instructions/<tool>.txt."""
|
|
146
|
+
path = _ai_cli_dir() / "instructions" / f"{tool_name}.txt"
|
|
147
|
+
return _read_text(path)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolve_project_instructions(project_cwd: str = "", remote_spec: str = "") -> str:
|
|
151
|
+
"""Load project-level instructions from ~/.ai-cli/project-prompts."""
|
|
152
|
+
path = resolve_project_prompt_path(project_cwd=project_cwd, remote_spec=remote_spec)
|
|
153
|
+
return _read_text(path)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def resolve_user_instructions(custom_path: str = "") -> str:
|
|
157
|
+
"""Load user's free-form custom instructions.
|
|
158
|
+
|
|
159
|
+
If *custom_path* is provided, uses that. Otherwise falls back to
|
|
160
|
+
~/.ai-cli/system_instructions.txt.
|
|
161
|
+
"""
|
|
162
|
+
if custom_path.strip():
|
|
163
|
+
return _read_text(Path(custom_path.strip()).expanduser())
|
|
164
|
+
return _read_text(_ai_cli_dir() / DEFAULT_INSTRUCTIONS_FILE)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def resolve_instructions_file(path_value: str = "") -> str:
|
|
168
|
+
"""Ensure the user instructions file exists, return its path as a string.
|
|
169
|
+
|
|
170
|
+
Creates the file (empty) if it doesn't exist.
|
|
171
|
+
"""
|
|
172
|
+
raw = path_value.strip()
|
|
173
|
+
if raw:
|
|
174
|
+
path = Path(raw).expanduser()
|
|
175
|
+
else:
|
|
176
|
+
path = _ai_cli_dir() / DEFAULT_INSTRUCTIONS_FILE
|
|
177
|
+
|
|
178
|
+
if not path.exists():
|
|
179
|
+
try:
|
|
180
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
path.write_text("", encoding="utf-8")
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
raise OSError(
|
|
184
|
+
f"Could not create instructions file at {path}: {exc}"
|
|
185
|
+
) from exc
|
|
186
|
+
|
|
187
|
+
return str(path)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Composition
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def compose_instructions(
|
|
195
|
+
canary_rule: str = DEFAULT_CANARY_RULE,
|
|
196
|
+
tool_name: str = "",
|
|
197
|
+
instructions_text: str | None = None,
|
|
198
|
+
instructions_file: str = "",
|
|
199
|
+
project_cwd: str = "",
|
|
200
|
+
remote_spec: str = "",
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Compose the full instruction text from all 5 layers.
|
|
203
|
+
|
|
204
|
+
If *instructions_text* is provided (inline), it replaces layer 5 (user).
|
|
205
|
+
Otherwise layer 5 is loaded from *instructions_file* or the default path.
|
|
206
|
+
|
|
207
|
+
Returns the combined text ready for injection.
|
|
208
|
+
"""
|
|
209
|
+
layers: list[str] = []
|
|
210
|
+
|
|
211
|
+
# Layer 1: Canary rule
|
|
212
|
+
canary = canary_rule.strip()
|
|
213
|
+
if canary:
|
|
214
|
+
layers.append(canary)
|
|
215
|
+
|
|
216
|
+
# Layer 2: Base instructions
|
|
217
|
+
base = resolve_base_instructions()
|
|
218
|
+
if base:
|
|
219
|
+
layers.append(base)
|
|
220
|
+
|
|
221
|
+
# Layer 3: Per-tool instructions
|
|
222
|
+
if tool_name:
|
|
223
|
+
tool_text = resolve_tool_instructions(tool_name)
|
|
224
|
+
if tool_text:
|
|
225
|
+
layers.append(tool_text)
|
|
226
|
+
|
|
227
|
+
# Layer 4: Project instructions
|
|
228
|
+
project_text = resolve_project_instructions(
|
|
229
|
+
project_cwd=project_cwd,
|
|
230
|
+
remote_spec=remote_spec,
|
|
231
|
+
)
|
|
232
|
+
if project_text:
|
|
233
|
+
layers.append(project_text)
|
|
234
|
+
|
|
235
|
+
# Layer 5: User custom instructions
|
|
236
|
+
if instructions_text is not None:
|
|
237
|
+
inline_text = instructions_text.strip()
|
|
238
|
+
if inline_text:
|
|
239
|
+
layers.append(inline_text)
|
|
240
|
+
else:
|
|
241
|
+
user_text = resolve_user_instructions(instructions_file)
|
|
242
|
+
if user_text:
|
|
243
|
+
layers.append(user_text)
|
|
244
|
+
|
|
245
|
+
return "\n\n".join(layers)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def compose_simple(base_text: str, canary_rule: str) -> str:
|
|
249
|
+
"""Simple 2-layer composition (canary + base). Used by addons directly."""
|
|
250
|
+
base = base_text.strip()
|
|
251
|
+
canary = canary_rule.strip()
|
|
252
|
+
if canary and base:
|
|
253
|
+
return f"{canary}\n\n{base}"
|
|
254
|
+
if canary:
|
|
255
|
+
return canary
|
|
256
|
+
return base
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def resolve_base_system_text(
|
|
260
|
+
inline_text: str,
|
|
261
|
+
file_path: str,
|
|
262
|
+
) -> tuple[str, str]:
|
|
263
|
+
"""Resolve the base system text from inline or file.
|
|
264
|
+
|
|
265
|
+
Returns (source_description, text).
|
|
266
|
+
"""
|
|
267
|
+
inline = inline_text.strip()
|
|
268
|
+
if inline:
|
|
269
|
+
return "inline text", inline
|
|
270
|
+
|
|
271
|
+
raw_path = file_path.strip()
|
|
272
|
+
if not raw_path:
|
|
273
|
+
return "inline text", ""
|
|
274
|
+
path = Path(raw_path).expanduser()
|
|
275
|
+
return f"file {path}", _read_text(path)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Editor launch
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def edit_instructions(instructions_file: str = "") -> int:
|
|
283
|
+
"""Open the instructions file in the user's editor.
|
|
284
|
+
|
|
285
|
+
Resolves the editor from $VISUAL, $EDITOR, or falls back to nano/vi/vim.
|
|
286
|
+
Runs the editor as a child process and waits for it to exit.
|
|
287
|
+
"""
|
|
288
|
+
path = Path(resolve_instructions_file(instructions_file))
|
|
289
|
+
|
|
290
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
291
|
+
if not editor:
|
|
292
|
+
for fallback in ("nano", "vi", "vim"):
|
|
293
|
+
if shutil.which(fallback):
|
|
294
|
+
editor = fallback
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
if not editor:
|
|
298
|
+
import sys
|
|
299
|
+
print(
|
|
300
|
+
f"No editor found. Set $VISUAL or $EDITOR, or edit manually: {path}",
|
|
301
|
+
file=sys.stderr,
|
|
302
|
+
)
|
|
303
|
+
return 1
|
|
304
|
+
|
|
305
|
+
# Handle editors with embedded args (e.g. EDITOR="code --wait")
|
|
306
|
+
parts = editor.split()
|
|
307
|
+
parts.append(str(path))
|
|
308
|
+
return subprocess.call(parts)
|
ai_cli/log.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shared timestamped append-logging for ai-cli.
|
|
2
|
+
|
|
3
|
+
Extracted from claude-dev.py logging utilities. All modules use these
|
|
4
|
+
functions for consistent, file-based logging with ISO timestamps.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def append_log(path: Path, message: str) -> None:
|
|
15
|
+
"""Append a timestamped log line to *path*, creating parent dirs if needed."""
|
|
16
|
+
try:
|
|
17
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
19
|
+
handle.write(
|
|
20
|
+
f"[{datetime.now().isoformat(timespec='seconds')}] {message}\n"
|
|
21
|
+
)
|
|
22
|
+
except OSError as exc:
|
|
23
|
+
print(f"ai-cli: logging failed at {path}: {exc}", file=sys.stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def append_log_str(path_value: str, message: str) -> None:
|
|
27
|
+
"""Convenience wrapper: skip if *path_value* is empty, else expand and log."""
|
|
28
|
+
if not path_value:
|
|
29
|
+
return
|
|
30
|
+
append_log(Path(path_value).expanduser(), message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tail_text(text: str, lines: int = 60) -> str:
|
|
34
|
+
"""Return the last *lines* lines of *text*."""
|
|
35
|
+
stripped = text.strip()
|
|
36
|
+
if not stripped:
|
|
37
|
+
return ""
|
|
38
|
+
parts = stripped.splitlines()
|
|
39
|
+
return "\n".join(parts[-lines:])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tail_file(path: Path, lines: int = 60) -> str:
|
|
43
|
+
"""Read *path* and return its last *lines* lines."""
|
|
44
|
+
try:
|
|
45
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
46
|
+
except OSError:
|
|
47
|
+
return ""
|
|
48
|
+
return tail_text(text, lines=lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fmt_cmd(cmd: list[str]) -> str:
|
|
52
|
+
"""Format a command list as a single shell-like string for log display."""
|
|
53
|
+
return " ".join(cmd)
|