aizen-ai-cli 2.2.2__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.
- aizen/__init__.py +4 -0
- aizen/commands.py +694 -0
- aizen/config.py +363 -0
- aizen/context.py +171 -0
- aizen/exceptions.py +46 -0
- aizen/logging_config.py +65 -0
- aizen/main.py +616 -0
- aizen/mcp.py +110 -0
- aizen/plugins.py +63 -0
- aizen/retry.py +133 -0
- aizen/session.py +137 -0
- aizen/tools.py +1035 -0
- aizen/utils.py +339 -0
- aizen_ai_cli-2.2.2.dist-info/METADATA +267 -0
- aizen_ai_cli-2.2.2.dist-info/RECORD +18 -0
- aizen_ai_cli-2.2.2.dist-info/WHEEL +5 -0
- aizen_ai_cli-2.2.2.dist-info/entry_points.txt +2 -0
- aizen_ai_cli-2.2.2.dist-info/top_level.txt +1 -0
aizen/utils.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import time
|
|
6
|
+
import urllib.request
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from .config import BACKUPS_DIR
|
|
12
|
+
from .logging_config import logger
|
|
13
|
+
|
|
14
|
+
# ─── Optional tiktoken for accurate token counting ─────────────────────────────
|
|
15
|
+
|
|
16
|
+
_tiktoken_encoding = None
|
|
17
|
+
|
|
18
|
+
def _get_tiktoken_encoding():
|
|
19
|
+
"""Lazily load tiktoken for accurate token counting. Falls back to estimation."""
|
|
20
|
+
global _tiktoken_encoding
|
|
21
|
+
if _tiktoken_encoding is not None:
|
|
22
|
+
return _tiktoken_encoding
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import tiktoken # pyrefly: ignore[missing-import]
|
|
26
|
+
# cl100k_base covers GPT-4, Claude, and most modern models
|
|
27
|
+
_tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
|
|
28
|
+
except ImportError:
|
|
29
|
+
_tiktoken_encoding = False # Mark as unavailable
|
|
30
|
+
return _tiktoken_encoding
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Struct:
|
|
34
|
+
"""Simple namespace for converting dicts to attribute-access objects."""
|
|
35
|
+
def __init__(self, **entries):
|
|
36
|
+
self.__dict__.update(entries)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TokenTracker:
|
|
40
|
+
"""Track token usage and session statistics with accurate or estimated counting."""
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.total_input_tokens: int = 0
|
|
44
|
+
self.total_output_tokens: int = 0
|
|
45
|
+
self.api_reported_input: int = 0
|
|
46
|
+
self.api_reported_output: int = 0
|
|
47
|
+
self.message_count: int = 0
|
|
48
|
+
self.start_time: datetime = datetime.now()
|
|
49
|
+
self._using_api_usage: bool = False
|
|
50
|
+
|
|
51
|
+
def add_usage(self, input_tokens: int, output_tokens: int):
|
|
52
|
+
"""Add estimated token usage for a single exchange."""
|
|
53
|
+
self.total_input_tokens += input_tokens
|
|
54
|
+
self.total_output_tokens += output_tokens
|
|
55
|
+
self.message_count += 1
|
|
56
|
+
|
|
57
|
+
def add_api_usage(self, prompt_tokens: int, completion_tokens: int):
|
|
58
|
+
"""
|
|
59
|
+
Add API-reported token usage (more accurate than estimation).
|
|
60
|
+
When API usage is available, it takes precedence in the summary.
|
|
61
|
+
"""
|
|
62
|
+
self.api_reported_input += prompt_tokens
|
|
63
|
+
self.api_reported_output += completion_tokens
|
|
64
|
+
self._using_api_usage = True
|
|
65
|
+
self.message_count += 1
|
|
66
|
+
|
|
67
|
+
def estimate_tokens(self, text: str) -> int:
|
|
68
|
+
"""
|
|
69
|
+
Estimate token count for a given text.
|
|
70
|
+
Uses tiktoken if available, falls back to word-based heuristic.
|
|
71
|
+
"""
|
|
72
|
+
if not text:
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
enc = _get_tiktoken_encoding()
|
|
76
|
+
if enc and enc is not False:
|
|
77
|
+
try:
|
|
78
|
+
return len(enc.encode(text))
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.debug("tiktoken encoding failed: %s", e)
|
|
81
|
+
|
|
82
|
+
# Fallback: ~1.3 tokens per word (rough but usable)
|
|
83
|
+
return max(1, int(len(text.split()) * 1.3))
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def total_tokens(self) -> int:
|
|
87
|
+
if self._using_api_usage:
|
|
88
|
+
return self.api_reported_input + self.api_reported_output
|
|
89
|
+
return self.total_input_tokens + self.total_output_tokens
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def input_tokens(self) -> int:
|
|
93
|
+
if self._using_api_usage:
|
|
94
|
+
return self.api_reported_input
|
|
95
|
+
return self.total_input_tokens
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def output_tokens(self) -> int:
|
|
99
|
+
if self._using_api_usage:
|
|
100
|
+
return self.api_reported_output
|
|
101
|
+
return self.total_output_tokens
|
|
102
|
+
|
|
103
|
+
def get_estimated_cost(self, active_model: str) -> float:
|
|
104
|
+
"""Returns the estimated cost in USD for the current session."""
|
|
105
|
+
return get_model_cost(active_model, self.input_tokens, self.output_tokens)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def session_duration(self) -> str:
|
|
109
|
+
delta = datetime.now() - self.start_time
|
|
110
|
+
minutes = int(delta.total_seconds() // 60)
|
|
111
|
+
seconds = int(delta.total_seconds() % 60)
|
|
112
|
+
if minutes > 0:
|
|
113
|
+
return f"{minutes}m {seconds}s"
|
|
114
|
+
return f"{seconds}s"
|
|
115
|
+
|
|
116
|
+
def get_summary_table(self, active_model: str = "") -> Table:
|
|
117
|
+
source = "API-reported" if self._using_api_usage else "Estimated"
|
|
118
|
+
enc = _get_tiktoken_encoding()
|
|
119
|
+
method = "tiktoken" if (enc and enc is not False and not self._using_api_usage) else "heuristic"
|
|
120
|
+
|
|
121
|
+
table = Table(title="📊 Session Usage", border_style="magenta", show_header=True)
|
|
122
|
+
table.add_column("Metric", style="cyan")
|
|
123
|
+
table.add_column("Value", style="white")
|
|
124
|
+
table.add_row("Messages", str(self.message_count))
|
|
125
|
+
table.add_row("Input Tokens", f"{self.input_tokens:,}")
|
|
126
|
+
table.add_row("Output Tokens", f"{self.output_tokens:,}")
|
|
127
|
+
table.add_row("Total Tokens", f"{self.total_tokens:,}")
|
|
128
|
+
|
|
129
|
+
if active_model:
|
|
130
|
+
cost = get_model_cost(active_model, self.input_tokens, self.output_tokens)
|
|
131
|
+
if cost > 0:
|
|
132
|
+
table.add_row("Est. Cost", f"${cost:.4f} USD")
|
|
133
|
+
else:
|
|
134
|
+
table.add_row("Est. Cost", "Unknown (Pricing not in DB)")
|
|
135
|
+
|
|
136
|
+
table.add_row("Counting Method", f"{source} ({method})")
|
|
137
|
+
table.add_row("Session Duration", self.session_duration)
|
|
138
|
+
return table
|
|
139
|
+
|
|
140
|
+
# Pricing per 1,000,000 tokens (input, output) in USD
|
|
141
|
+
MODEL_PRICING = {
|
|
142
|
+
# Anthropic
|
|
143
|
+
"anthropic/claude-sonnet-4": (3.00, 15.00),
|
|
144
|
+
"anthropic/claude-3.5-sonnet": (3.00, 15.00),
|
|
145
|
+
"anthropic/claude-3.7-sonnet": (3.00, 15.00),
|
|
146
|
+
"anthropic/claude-3-opus": (15.00, 75.00),
|
|
147
|
+
"anthropic/claude-3.5-haiku": (0.80, 4.00),
|
|
148
|
+
"anthropic/claude-4-opus": (15.00, 75.00),
|
|
149
|
+
# Google
|
|
150
|
+
"google/gemini-2.5-pro": (1.25, 10.00),
|
|
151
|
+
"google/gemini-2.5-flash": (0.15, 0.60),
|
|
152
|
+
"google/gemini-2.0-flash": (0.10, 0.40),
|
|
153
|
+
# OpenAI
|
|
154
|
+
"openai/gpt-4o": (2.50, 10.00),
|
|
155
|
+
"openai/gpt-4o-mini": (0.15, 0.60),
|
|
156
|
+
"openai/gpt-4.1": (2.00, 8.00),
|
|
157
|
+
"openai/gpt-4.1-mini": (0.40, 1.60),
|
|
158
|
+
"openai/gpt-4.1-nano": (0.10, 0.40),
|
|
159
|
+
"openai/o1": (15.00, 60.00),
|
|
160
|
+
"openai/o3": (10.00, 40.00),
|
|
161
|
+
"openai/o3-mini": (1.10, 4.40),
|
|
162
|
+
"openai/o4-mini": (1.10, 4.40),
|
|
163
|
+
# DeepSeek
|
|
164
|
+
"deepseek/deepseek-chat-v3": (0.27, 1.10),
|
|
165
|
+
"deepseek/deepseek-chat": (0.14, 0.28),
|
|
166
|
+
"deepseek/deepseek-r1": (0.55, 2.19),
|
|
167
|
+
# Meta
|
|
168
|
+
"meta-llama/llama-4-maverick": (0.20, 0.60),
|
|
169
|
+
"meta-llama/llama-3.3-70b-instruct": (0.10, 0.25),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
def get_model_cost(model_name: str, input_tokens: int, output_tokens: int) -> float:
|
|
173
|
+
"""Calculate the estimated cost for a given model and token count."""
|
|
174
|
+
for known_model, (in_price, out_price) in MODEL_PRICING.items():
|
|
175
|
+
if known_model in model_name.lower():
|
|
176
|
+
return (input_tokens * in_price / 1_000_000) + (output_tokens * out_price / 1_000_000)
|
|
177
|
+
return 0.0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class BackupManager:
|
|
181
|
+
"""Manage file backups for undo operations."""
|
|
182
|
+
|
|
183
|
+
def __init__(self):
|
|
184
|
+
os.makedirs(BACKUPS_DIR, exist_ok=True)
|
|
185
|
+
self.undo_stack: list[tuple[str, str, str]] = [] # (original_path, backup_path, timestamp)
|
|
186
|
+
|
|
187
|
+
def backup(self, filepath: str) -> str | None:
|
|
188
|
+
"""Create a backup before modification. Returns backup path."""
|
|
189
|
+
if not os.path.exists(filepath):
|
|
190
|
+
return None
|
|
191
|
+
try:
|
|
192
|
+
abs_path = os.path.abspath(filepath)
|
|
193
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
194
|
+
basename = os.path.basename(filepath)
|
|
195
|
+
backup_name = f"{timestamp}_{basename}"
|
|
196
|
+
backup_path = os.path.join(BACKUPS_DIR, backup_name)
|
|
197
|
+
shutil.copy2(abs_path, backup_path)
|
|
198
|
+
self.undo_stack.append((abs_path, backup_path, timestamp))
|
|
199
|
+
return backup_path
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.debug("Failed to backup file %s: %s", filepath, e)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
def undo(self) -> str:
|
|
205
|
+
"""Undo the last file modification."""
|
|
206
|
+
if not self.undo_stack:
|
|
207
|
+
return "Nothing to undo."
|
|
208
|
+
|
|
209
|
+
original_path, backup_path, _ = self.undo_stack.pop()
|
|
210
|
+
|
|
211
|
+
if not os.path.exists(backup_path):
|
|
212
|
+
return f"Backup file not found: {backup_path}"
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
shutil.copy2(backup_path, original_path)
|
|
216
|
+
os.remove(backup_path)
|
|
217
|
+
return f"✓ Restored {os.path.basename(original_path)} to its previous state."
|
|
218
|
+
except Exception as e:
|
|
219
|
+
return f"Error restoring file: {e}"
|
|
220
|
+
|
|
221
|
+
def cleanup(self, max_age_hours: int = 24):
|
|
222
|
+
"""Remove backups older than max_age_hours."""
|
|
223
|
+
try:
|
|
224
|
+
now = time.time()
|
|
225
|
+
for f in os.listdir(BACKUPS_DIR):
|
|
226
|
+
path = os.path.join(BACKUPS_DIR, f)
|
|
227
|
+
if os.path.isfile(path):
|
|
228
|
+
age_hours = (now - os.path.getmtime(path)) / 3600
|
|
229
|
+
if age_hours > max_age_hours:
|
|
230
|
+
os.remove(path)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logger.debug("Cleanup backups failed: %s", e)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def truncate_output(text: str, max_chars: int = 4000) -> str:
|
|
236
|
+
if len(text) <= max_chars:
|
|
237
|
+
return text
|
|
238
|
+
half = max_chars // 2
|
|
239
|
+
return (
|
|
240
|
+
f"{text[:half]}\n\n"
|
|
241
|
+
f"[... TRUNCATED {len(text) - max_chars} chars ...]\n\n"
|
|
242
|
+
f"{text[-half:]}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def load_gitignore_patterns() -> list:
|
|
247
|
+
patterns = [
|
|
248
|
+
".git/", "node_modules/", "__pycache__/", "venv/", ".env",
|
|
249
|
+
"dist/", "build/", "*.egg-info/", ".DS_Store",
|
|
250
|
+
]
|
|
251
|
+
if os.path.exists(".gitignore"):
|
|
252
|
+
try:
|
|
253
|
+
with open(".gitignore", encoding="utf-8", errors="ignore") as f:
|
|
254
|
+
for line in f:
|
|
255
|
+
line = line.strip()
|
|
256
|
+
if line and not line.startswith("#"):
|
|
257
|
+
patterns.append(line)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.debug("Failed to load .gitignore: %s", e)
|
|
260
|
+
return patterns
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def should_ignore(path: str, patterns: list) -> bool:
|
|
264
|
+
path = os.path.normpath(path)
|
|
265
|
+
parts = path.split(os.sep)
|
|
266
|
+
for pattern in patterns:
|
|
267
|
+
clean_pattern = pattern.rstrip("/")
|
|
268
|
+
for part in parts:
|
|
269
|
+
if fnmatch.fnmatch(part, clean_pattern):
|
|
270
|
+
return True
|
|
271
|
+
if fnmatch.fnmatch(path, clean_pattern):
|
|
272
|
+
return True
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def fetch_url_content(url: str, timeout: int = 10) -> str:
|
|
277
|
+
"""Fetch content from a URL and strip HTML tags if it's a webpage."""
|
|
278
|
+
try:
|
|
279
|
+
req = urllib.request.Request(
|
|
280
|
+
url,
|
|
281
|
+
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AizenAgent'}
|
|
282
|
+
)
|
|
283
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
284
|
+
content_type = response.headers.get_content_type()
|
|
285
|
+
charset = response.headers.get_content_charset() or 'utf-8'
|
|
286
|
+
raw_data = response.read().decode(charset, errors='replace')
|
|
287
|
+
|
|
288
|
+
if 'html' in content_type:
|
|
289
|
+
# Basic HTML stripping
|
|
290
|
+
# Remove script and style elements
|
|
291
|
+
text = re.sub(r'<(script|style)[^>]*>.*?</\1>', '', raw_data, flags=re.IGNORECASE | re.DOTALL)
|
|
292
|
+
# Remove HTML tags
|
|
293
|
+
text = re.sub(r'<[^>]+>', ' ', text)
|
|
294
|
+
# Unescape common HTML entities
|
|
295
|
+
text = text.replace(' ', ' ').replace('<', '<').replace('>', '>').replace('&', '&').replace('"', '"')
|
|
296
|
+
# Collapse whitespace
|
|
297
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
298
|
+
return text
|
|
299
|
+
|
|
300
|
+
return raw_data
|
|
301
|
+
except Exception as e:
|
|
302
|
+
return f"Error fetching URL: {e}"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def generate_directory_tree(path: str) -> str:
|
|
306
|
+
"""Generate a formatted text tree of a directory, respecting .gitignore."""
|
|
307
|
+
ignore_patterns = load_gitignore_patterns()
|
|
308
|
+
|
|
309
|
+
lines = []
|
|
310
|
+
|
|
311
|
+
def _walk(current_path: str, prefix: str = ""):
|
|
312
|
+
try:
|
|
313
|
+
entries = sorted(os.listdir(current_path))
|
|
314
|
+
except PermissionError:
|
|
315
|
+
lines.append(f"{prefix}[Permission Denied]")
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
# Filter entries
|
|
319
|
+
valid_entries = []
|
|
320
|
+
for entry in entries:
|
|
321
|
+
full_path = os.path.join(current_path, entry)
|
|
322
|
+
if not should_ignore(full_path, ignore_patterns):
|
|
323
|
+
valid_entries.append((entry, full_path))
|
|
324
|
+
|
|
325
|
+
for i, (entry, full_path) in enumerate(valid_entries):
|
|
326
|
+
is_last = i == (len(valid_entries) - 1)
|
|
327
|
+
connector = "└── " if is_last else "├── "
|
|
328
|
+
|
|
329
|
+
if os.path.isdir(full_path):
|
|
330
|
+
lines.append(f"{prefix}{connector}{entry}/")
|
|
331
|
+
new_prefix = prefix + (" " if is_last else "│ ")
|
|
332
|
+
_walk(full_path, new_prefix)
|
|
333
|
+
else:
|
|
334
|
+
lines.append(f"{prefix}{connector}{entry}")
|
|
335
|
+
|
|
336
|
+
base_name = os.path.basename(os.path.abspath(path)) or path
|
|
337
|
+
lines.append(f"{base_name}/")
|
|
338
|
+
_walk(path)
|
|
339
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aizen-ai-cli
|
|
3
|
+
Version: 2.2.2
|
|
4
|
+
Summary: Aizen AI Agent — A professional-grade AI coding assistant for your terminal.
|
|
5
|
+
Author: Irtaza Malik
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/irtaza302/aizen-agent
|
|
8
|
+
Project-URL: Repository, https://github.com/irtaza302/aizen-agent
|
|
9
|
+
Project-URL: Issues, https://github.com/irtaza302/aizen-agent/issues
|
|
10
|
+
Keywords: ai,cli,coding-assistant,terminal,openrouter,llm
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: openai>=1.0
|
|
26
|
+
Requires-Dist: python-dotenv>=1.0
|
|
27
|
+
Requires-Dist: rich>=13.0
|
|
28
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
29
|
+
Requires-Dist: mcp>=1.0.0
|
|
30
|
+
Provides-Extra: tiktoken
|
|
31
|
+
Requires-Dist: tiktoken>=0.5; extra == "tiktoken"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
37
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
38
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
39
|
+
|
|
40
|
+
# Aizen AI Agent 🚀
|
|
41
|
+
|
|
42
|
+
[](https://github.com/irtaza302/aizen-agent/actions/workflows/ci.yml)
|
|
43
|
+
|
|
44
|
+
A professional-grade AI coding assistant that runs directly in your terminal. Aizen reads your code, writes files with surgical precision, runs commands safely, and helps you build faster — all from a beautifully designed CLI.
|
|
45
|
+
|
|
46
|
+
## ✨ Features
|
|
47
|
+
|
|
48
|
+
### Core
|
|
49
|
+
- **Asynchronous Architecture** — Fully asynchronous operations leveraging `asyncio` and `AsyncOpenAI` for concurrent processing, parallel tool runs, and streaming.
|
|
50
|
+
- **Rich Markdown Rendering** — AI responses are rendered with full Markdown formatting (headers, code blocks, lists, bold/italic) via Rich's live display.
|
|
51
|
+
- **Streaming with Live Preview** — Watch responses render in real-time inside a styled panel with an animated thinking spinner.
|
|
52
|
+
- **Surgical File Editing** — The `edit_file` tool makes precise search-and-replace edits with color-coded diff previews, instead of rewriting entire files.
|
|
53
|
+
- **SQLite Session Persistence** — Session storage is powered by a SQLite database (`~/.aizen_sessions/aizen.db`), auto-migrating older JSON sessions.
|
|
54
|
+
- **Project-Specific Rules** — Customizes agent behavior per repository by auto-loading `.aizen_rules` or `.cursorrules` from the current working directory.
|
|
55
|
+
- **Smart Autocomplete** — `@`-mention files with Tab completion that respects `.gitignore` and supports directory traversal.
|
|
56
|
+
|
|
57
|
+
### Tools
|
|
58
|
+
Aizen has 9 built-in tools the AI can use:
|
|
59
|
+
|
|
60
|
+
| Tool | Description |
|
|
61
|
+
|------|-------------|
|
|
62
|
+
| `read_file` | Read file contents before making changes |
|
|
63
|
+
| `write_file` | Create new files (with preview) |
|
|
64
|
+
| `edit_file` | Surgical search-and-replace on existing files (with diff preview) |
|
|
65
|
+
| `run_command` | Execute shell commands (supports background execution; safe commands auto-run, dangerous ones require approval) |
|
|
66
|
+
| `check_background_task` | Check the status and read recent output of a command running in the background |
|
|
67
|
+
| `kill_background_task` | Kill a running background task |
|
|
68
|
+
| `list_directory` | List files/folders with sizes, respecting `.gitignore` |
|
|
69
|
+
| `grep_search` | Search for text or regex patterns across the codebase |
|
|
70
|
+
| `find_files` | Find files by glob pattern (e.g., `*.py`, `Dockerfile`) |
|
|
71
|
+
|
|
72
|
+
### Commands
|
|
73
|
+
|
|
74
|
+
| Command | Description |
|
|
75
|
+
|---------|-------------|
|
|
76
|
+
| `/help` | Show all available commands |
|
|
77
|
+
| `/model [name]` | View or switch the active AI model (saves as default) |
|
|
78
|
+
| `/clear` | Clear conversation history |
|
|
79
|
+
| `/drop` | Drop attached files/URLs/commands from history to save tokens |
|
|
80
|
+
| `/save [name]` | Save current conversation to SQLite database |
|
|
81
|
+
| `/load [name]` | Load a previously saved conversation |
|
|
82
|
+
| `/checkpoint [name]` | Save a conversation snapshot to memory |
|
|
83
|
+
| `/restore [name]` | Restore a saved conversation checkpoint |
|
|
84
|
+
| `/usage` | Show token usage, estimated session cost (USD), and statistics |
|
|
85
|
+
| `/commit` | Auto-generate a commit message for staged/unstaged changes and commit them |
|
|
86
|
+
| `/diff` | Show all uncommitted changes (staged, unstaged, untracked) |
|
|
87
|
+
| `/compact` | Summarize older messages using AI (fallback to text-summarization) to save tokens |
|
|
88
|
+
| `/undo` | Undo the last file modification |
|
|
89
|
+
| `/retry` | Retry the last message |
|
|
90
|
+
| `/copy` | Copy last AI response to clipboard |
|
|
91
|
+
| `/export [file]` | Export conversation to a Markdown file |
|
|
92
|
+
| `/config` | View current configuration |
|
|
93
|
+
| `/mcp` | View configured MCP servers and their connection status |
|
|
94
|
+
|
|
95
|
+
### Safety & UX
|
|
96
|
+
- **Command Safety** — Read-only commands (`ls`, `cat`, `git status`, etc.) auto-execute. Destructive commands (`rm`, `sudo`, etc.) always require confirmation.
|
|
97
|
+
- **`--yolo` Mode** — Auto-approve all operations for power users.
|
|
98
|
+
- **Background Tasks** — Run builds, tests, or other long-running tasks asynchronously while continuing to interact with Aizen.
|
|
99
|
+
- **File Backups** — Every file modification creates a backup. Use `/undo` to restore.
|
|
100
|
+
- **Multi-line Input** — End a line with `\` to continue on the next line.
|
|
101
|
+
- **Session Persistence** — Conversations auto-save on exit to SQLite. Use `/save` and `/load` to manage.
|
|
102
|
+
- **Cost Tracking & Token Usage** — Live tracking of input/output tokens, session duration, and estimated session cost in USD.
|
|
103
|
+
- **Structured Logging** — Rotated file logging at `~/.aizen_logs/aizen.log` plus verbose console debugging logs via `--verbose`.
|
|
104
|
+
- **Graceful Error Recovery** — Helpful hints for common API errors (invalid key, rate limits, timeouts).
|
|
105
|
+
|
|
106
|
+
## Dependencies
|
|
107
|
+
|
|
108
|
+
- `openai` — OpenAI-compatible API client
|
|
109
|
+
- `python-dotenv` — Environment variable management
|
|
110
|
+
- `rich` — Rich text, Markdown rendering, panels, tables, and live display
|
|
111
|
+
- `prompt_toolkit` — Interactive command line with autocomplete
|
|
112
|
+
|
|
113
|
+
## Installation
|
|
114
|
+
|
|
115
|
+
### 1. Python (pip / pipx) — Recommended
|
|
116
|
+
```bash
|
|
117
|
+
pipx install aizen-ai-cli
|
|
118
|
+
# Or:
|
|
119
|
+
pip install aizen-ai-cli
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 2. NPM (Node.js)
|
|
123
|
+
```bash
|
|
124
|
+
npm install -g aizen-ai-cli
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 3. Homebrew (macOS)
|
|
128
|
+
```bash
|
|
129
|
+
brew tap irtaza302/aizen
|
|
130
|
+
brew install aizen
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 4. Local Development
|
|
134
|
+
```bash
|
|
135
|
+
git clone https://github.com/irtaza302/aizen-agent.git
|
|
136
|
+
cd aizen-agent
|
|
137
|
+
pip install -r requirements.txt
|
|
138
|
+
python aizen.py
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Usage
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
aizen
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
On first launch, you'll be prompted for your [OpenRouter API key](https://openrouter.ai/keys). It's saved securely to `~/.aizen_config.json`.
|
|
148
|
+
|
|
149
|
+
### Command Line Arguments
|
|
150
|
+
|
|
151
|
+
| Flag | Description |
|
|
152
|
+
|------|-------------|
|
|
153
|
+
| `--version` | Show version |
|
|
154
|
+
| `--model <name>` | Override the default model for this session |
|
|
155
|
+
| `--reset-key` | Clear and re-enter your API key |
|
|
156
|
+
| `--set-base-url <url>` | Set custom API base URL (e.g., `http://localhost:11434/v1` for Ollama) |
|
|
157
|
+
| `--yolo` | Auto-approve all file writes and command executions |
|
|
158
|
+
| `--verbose` | Enable verbose logging output to the console |
|
|
159
|
+
|
|
160
|
+
### Attaching Context (`@`)
|
|
161
|
+
|
|
162
|
+
Type `@` followed by a filename, directory, web URL, or command to give Aizen context. Autocomplete filters out `.gitignore`d files:
|
|
163
|
+
|
|
164
|
+
- **Files:** `@aizen.py` attaches the file contents.
|
|
165
|
+
- **Directories:** `@tests/` generates and attaches a visual directory tree respecting `.gitignore`.
|
|
166
|
+
- **URLs:** `@https://docs.python.org/...` fetches the webpage, converts it to markdown, and attaches it.
|
|
167
|
+
- **Commands:** `@cmd:"pytest"` or `@cmd:ls` securely runs the command in the background and injects its `stdout` and `stderr` directly into the prompt.
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
👤 You
|
|
171
|
+
❯ Can you refactor @aizen.py to use async?
|
|
172
|
+
|
|
173
|
+
👤 You
|
|
174
|
+
❯ Explain this output: @cmd:"npm run build"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Multi-line Input
|
|
178
|
+
|
|
179
|
+
End a line with `\` to continue typing on the next line:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
👤 You
|
|
183
|
+
❯ Write a function that \
|
|
184
|
+
⋮ takes a list of numbers \
|
|
185
|
+
⋮ and returns the sorted unique values
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Configuration
|
|
189
|
+
|
|
190
|
+
Aizen stores its config in `~/.aizen_config.json`:
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"OPENROUTER_API_KEY": "sk-or-...",
|
|
195
|
+
"API_BASE_URL": "https://openrouter.ai/api/v1",
|
|
196
|
+
"DEFAULT_MODEL": "anthropic/claude-sonnet-4"
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Model Context Protocol (MCP) Support
|
|
201
|
+
|
|
202
|
+
Aizen supports integrating with external [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers to extend its capabilities (e.g. connecting to local databases, searching the web, or accessing custom APIs).
|
|
203
|
+
|
|
204
|
+
To configure MCP servers, add an `"mcp_servers"` block to your `~/.aizen_config.json`:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"mcp_servers": {
|
|
209
|
+
"sqlite": {
|
|
210
|
+
"command": "uvx",
|
|
211
|
+
"args": ["mcp-server-sqlite", "--db-path", "~/test.db"]
|
|
212
|
+
},
|
|
213
|
+
"everything": {
|
|
214
|
+
"command": "npx",
|
|
215
|
+
"args": ["-y", "@modelcontextprotocol/server-everything"]
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
When you start Aizen, it will automatically connect to these servers and make their tools available to the AI.
|
|
222
|
+
|
|
223
|
+
Sessions are saved in a SQLite database at `~/.aizen_sessions/aizen.db`, and file backups are placed in `~/.aizen_backups/`.
|
|
224
|
+
|
|
225
|
+
### 📂 Project-Specific Rules
|
|
226
|
+
|
|
227
|
+
Aizen supports loading custom, project-specific rules files (such as `.aizen_rules` or `.cursorrules`) from the root of your project directory.
|
|
228
|
+
When Aizen starts, it checks for these files in the current working directory in the following order:
|
|
229
|
+
1. `.aizen_rules`
|
|
230
|
+
2. `.cursorrules`
|
|
231
|
+
|
|
232
|
+
If one is found, Aizen automatically appends its contents to the system prompt. This allows you to enforce codebase-specific styling guidelines, coding standards, or project rules without editing Aizen's global configuration.
|
|
233
|
+
|
|
234
|
+
### 🔄 Background Task Management
|
|
235
|
+
|
|
236
|
+
For long-running processes (e.g., running test suites, starting local dev servers, or building bundles), you can run commands in the background asynchronously:
|
|
237
|
+
- Aizen's `run_command` tool supports a boolean `background` parameter. If set to `true`, the tool immediately returns a unique `task_id` (e.g., `bg_a1b2c3d4`).
|
|
238
|
+
- You can inspect the status and read the recent stdout/stderr output of a background task using the `check_background_task` tool.
|
|
239
|
+
- You can terminate any active background task using the `kill_background_task` tool.
|
|
240
|
+
|
|
241
|
+
This allows you to continue discussing other topics or refactoring files with Aizen while your tests or builds run in parallel.
|
|
242
|
+
|
|
243
|
+
### 💰 Cost Tracking
|
|
244
|
+
|
|
245
|
+
Aizen dynamically estimates session costs in USD for known models based on token usage:
|
|
246
|
+
- Input and output tokens are tracked in real-time.
|
|
247
|
+
- The estimated session cost is displayed in the CLI status bar and summary tables (via the `/usage` command).
|
|
248
|
+
- The cost calculations support popular models from Anthropic (Claude 3.5/3.7 Sonnet, Opus, Haiku), Google (Gemini 2.5 Pro/Flash), and OpenAI (GPT-4o, o1, o3-mini).
|
|
249
|
+
|
|
250
|
+
### 📌 Session Checkpoints & Restoring
|
|
251
|
+
|
|
252
|
+
You can save and restore conversation snapshots at any point during your session:
|
|
253
|
+
- `/checkpoint [name]`: Save the current conversation messages history as a named snapshot in memory.
|
|
254
|
+
- `/restore [name]`: Revert the conversation history to the specified checkpoint. If run without a name, it lists all currently active checkpoints.
|
|
255
|
+
|
|
256
|
+
This is extremely useful when experimenting with different implementation approaches or when recovering from an unintended direction.
|
|
257
|
+
|
|
258
|
+
### 📝 Structured Logging
|
|
259
|
+
|
|
260
|
+
All internal activities, tool calls, and API events are written to a rotating file logger:
|
|
261
|
+
- Logs are located at `~/.aizen_logs/aizen.log`.
|
|
262
|
+
- Up to three rotated log files are kept (5 MB per file limit).
|
|
263
|
+
- You can run Aizen with the `--verbose` flag to mirror log output directly to the console stderr stream.
|
|
264
|
+
|
|
265
|
+
## Publishing & Development
|
|
266
|
+
|
|
267
|
+
Use the included `publish.sh` script to build and publish across all platforms (PyPI, NPM, and PyInstaller binaries).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
aizen/__init__.py,sha256=V9ejtMhPwGtednFQutzq536YgPC4HtU66mtM0imGDdo,76
|
|
2
|
+
aizen/commands.py,sha256=rMhrmTCNXy7OcH8QU8FzyLnhIiFLnuvMV0m8dp05xKQ,29957
|
|
3
|
+
aizen/config.py,sha256=DA5wyyOjT9ML4w5-eWSlaZUtzcTHYB6yVTHkzFc3UW8,14103
|
|
4
|
+
aizen/context.py,sha256=xnuKQ7bX9NhbjcdtOdnejiPU89UX68yCZqAnnulFH2U,5871
|
|
5
|
+
aizen/exceptions.py,sha256=2RTYNDDSQ3l-JCuWT2opM4IEKQgoQCNtFpqKVbVxDEE,1065
|
|
6
|
+
aizen/logging_config.py,sha256=FWmiEK070F2E9yFPcqah1pRa9_pslXAU4I0yQyjMOzM,2176
|
|
7
|
+
aizen/main.py,sha256=-xuhfG0hNzo79142gCaWbIdUU1Sx4XlUhgzaamgfMr8,26026
|
|
8
|
+
aizen/mcp.py,sha256=ssAI2F9HoarbtUK4oGaFefQcQ-eoVzH_0J7I-j3LE-U,4407
|
|
9
|
+
aizen/plugins.py,sha256=V1hLPFand8tYEibdwY07k3Ky8n0ZpX9wtMp6Q6vjNqg,2623
|
|
10
|
+
aizen/retry.py,sha256=3XXGOfhUnuzUeGuMuwhoPMdmh4w1_3vWkKEWgO-kdEQ,4985
|
|
11
|
+
aizen/session.py,sha256=jRNFlCDIyK8au6HZFKVWypU5WUmfewuq629ic7i-UmU,4335
|
|
12
|
+
aizen/tools.py,sha256=926qeogPm-YWwjLvh7aWfocOmkG0mbzg01nTUtXlZEA,39581
|
|
13
|
+
aizen/utils.py,sha256=_1Rs8yBE0dlTOa-lPZM_UcQlieLcPkUFR1xZWXk55uI,12568
|
|
14
|
+
aizen_ai_cli-2.2.2.dist-info/METADATA,sha256=KkV5cPl5Bmce7FrA8JUJhs8ppWorHW1sh8evGqs0jNA,12100
|
|
15
|
+
aizen_ai_cli-2.2.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
aizen_ai_cli-2.2.2.dist-info/entry_points.txt,sha256=d_9Sa5GfuASiNgNAbTVGCUWI7ZONCXJhE-kcqU3b5QI,42
|
|
17
|
+
aizen_ai_cli-2.2.2.dist-info/top_level.txt,sha256=enJCkUp4c9xiYl8hVbkEB2VhhFiLQOOPxmGXjIhqW0A,6
|
|
18
|
+
aizen_ai_cli-2.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aizen
|