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/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)