samcode-cli 1.0.0__py3-none-any.whl → 1.0.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.
- samcode.py +1041 -0
- samcode_cli-1.0.2.dist-info/METADATA +123 -0
- samcode_cli-1.0.2.dist-info/RECORD +6 -0
- samcode_cli-1.0.0.dist-info/METADATA +0 -13
- samcode_cli-1.0.0.dist-info/RECORD +0 -5
- {samcode_cli-1.0.0.dist-info → samcode_cli-1.0.2.dist-info}/WHEEL +0 -0
- {samcode_cli-1.0.0.dist-info → samcode_cli-1.0.2.dist-info}/entry_points.txt +0 -0
- {samcode_cli-1.0.0.dist-info → samcode_cli-1.0.2.dist-info}/top_level.txt +0 -0
samcode.py
ADDED
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
╔══════════════════════════════════════════════════════════════════════════════╗
|
|
4
|
+
║ S A M C O D E C L I ║
|
|
5
|
+
║ Autonomous Coding Agent v5.4 ║
|
|
6
|
+
║ Similar to Claude Code & GitHub Copilot Workspace ║
|
|
7
|
+
╚══════════════════════════════════════════════════════════════════════════════╝
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import webbrowser
|
|
18
|
+
from typing import List, Dict, Tuple, Optional, Any
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
# Rich UI Components
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.panel import Panel
|
|
26
|
+
from rich.syntax import Syntax
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
from rich.prompt import Prompt, Confirm
|
|
29
|
+
from rich.columns import Columns
|
|
30
|
+
from rich.text import Text
|
|
31
|
+
|
|
32
|
+
# Interactive Selection & Autocomplete
|
|
33
|
+
try:
|
|
34
|
+
import questionary
|
|
35
|
+
from questionary import Choice
|
|
36
|
+
from prompt_toolkit import prompt as pt_prompt
|
|
37
|
+
from prompt_toolkit.completion import WordCompleter
|
|
38
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
39
|
+
from prompt_toolkit.styles import Style
|
|
40
|
+
from prompt_toolkit.enums import EditingMode
|
|
41
|
+
except ImportError:
|
|
42
|
+
subprocess.run([sys.executable, "-m", "pip", "install", "questionary", "-q"], capture_output=True)
|
|
43
|
+
import questionary
|
|
44
|
+
from questionary import Choice
|
|
45
|
+
from prompt_toolkit import prompt as pt_prompt
|
|
46
|
+
from prompt_toolkit.completion import WordCompleter
|
|
47
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
48
|
+
from prompt_toolkit.styles import Style
|
|
49
|
+
from prompt_toolkit.enums import EditingMode
|
|
50
|
+
|
|
51
|
+
# HTTP Client
|
|
52
|
+
try:
|
|
53
|
+
import requests
|
|
54
|
+
except ImportError:
|
|
55
|
+
subprocess.run([sys.executable, "-m", "pip", "install", "requests", "-q"], capture_output=True)
|
|
56
|
+
import requests
|
|
57
|
+
|
|
58
|
+
# Document Processing & Web Parsing Libraries Auto-Install
|
|
59
|
+
LIBS_TO_INSTALL = {
|
|
60
|
+
'pdfplumber': 'pdfplumber', 'docx': 'python-docx', 'openpyxl': 'openpyxl',
|
|
61
|
+
'pptx': 'python-pptx', 'PIL': 'Pillow', 'bs4': 'beautifulsoup4'
|
|
62
|
+
}
|
|
63
|
+
for module, package in LIBS_TO_INSTALL.items():
|
|
64
|
+
try:
|
|
65
|
+
__import__(module)
|
|
66
|
+
except ImportError:
|
|
67
|
+
subprocess.run([sys.executable, "-m", "pip", "install", package, "-q"], capture_output=True)
|
|
68
|
+
|
|
69
|
+
console = Console()
|
|
70
|
+
|
|
71
|
+
menu_style = Style.from_dict({
|
|
72
|
+
'completion-menu': 'bg:#1e1e1e',
|
|
73
|
+
'completion-menu.completion': 'fg:#00d7d7 bg:#1e1e1e',
|
|
74
|
+
'completion-menu.completion.current': 'fg:#ffffff bg:#00af00 bold',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
78
|
+
# KEYBOARD SHORTCUTS FOR PROMPT (Only custom ones)
|
|
79
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
80
|
+
|
|
81
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
82
|
+
|
|
83
|
+
kb = KeyBindings()
|
|
84
|
+
|
|
85
|
+
@kb.add('c-w')
|
|
86
|
+
def _(event):
|
|
87
|
+
"Delete word backward (Ctrl+W)"
|
|
88
|
+
b = event.app.current_buffer
|
|
89
|
+
pos = b.document.find_start_of_previous_word()
|
|
90
|
+
if pos is not None:
|
|
91
|
+
b.delete_before_cursor(-pos)
|
|
92
|
+
|
|
93
|
+
@kb.add('c-u')
|
|
94
|
+
def _(event):
|
|
95
|
+
"Clear line backward (Ctrl+U)"
|
|
96
|
+
event.app.current_buffer.delete_before_cursor(event.app.current_buffer.cursor_position)
|
|
97
|
+
|
|
98
|
+
@kb.add('c-k')
|
|
99
|
+
def _(event):
|
|
100
|
+
"Delete to end of line (Ctrl+K)"
|
|
101
|
+
b = event.app.current_buffer
|
|
102
|
+
b.delete(len(b.text) - b.cursor_position)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DocumentReader:
|
|
106
|
+
@staticmethod
|
|
107
|
+
def extract_text(filepath: str) -> str:
|
|
108
|
+
ext = Path(filepath).suffix.lower()
|
|
109
|
+
try:
|
|
110
|
+
if ext == '.pdf':
|
|
111
|
+
import pdfplumber
|
|
112
|
+
text = []
|
|
113
|
+
with pdfplumber.open(filepath) as pdf:
|
|
114
|
+
for page in pdf.pages:
|
|
115
|
+
page_text = page.extract_text()
|
|
116
|
+
if page_text: text.append(page_text)
|
|
117
|
+
return "\n".join(text) if text else "[No extractable text found in PDF]"
|
|
118
|
+
elif ext == '.docx':
|
|
119
|
+
import docx
|
|
120
|
+
doc = docx.Document(filepath)
|
|
121
|
+
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
|
122
|
+
for table in doc.tables:
|
|
123
|
+
for row in table.rows:
|
|
124
|
+
row_text = " | ".join([cell.text.strip() for cell in row.cells])
|
|
125
|
+
paragraphs.append(row_text)
|
|
126
|
+
return "\n".join(paragraphs)
|
|
127
|
+
elif ext in ['.xlsx', '.xls']:
|
|
128
|
+
import openpyxl
|
|
129
|
+
wb = openpyxl.load_workbook(filepath, data_only=True)
|
|
130
|
+
text = []
|
|
131
|
+
for sheet in wb.sheetnames:
|
|
132
|
+
text.append(f"\n--- Sheet: {sheet} ---")
|
|
133
|
+
for row in wb[sheet].iter_rows(values_only=True):
|
|
134
|
+
text.append(" | ".join([str(cell) if cell is not None else "" for cell in row]))
|
|
135
|
+
return "\n".join(text)
|
|
136
|
+
elif ext == '.pptx':
|
|
137
|
+
import pptx
|
|
138
|
+
prs = pptx.Presentation(filepath)
|
|
139
|
+
text = []
|
|
140
|
+
for i, slide in enumerate(prs.slides):
|
|
141
|
+
text.append(f"\n--- Slide {i+1} ---")
|
|
142
|
+
for shape in slide.shapes:
|
|
143
|
+
if hasattr(shape, "text") and shape.text.strip():
|
|
144
|
+
text.append(shape.text)
|
|
145
|
+
return "\n".join(text)
|
|
146
|
+
elif ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff']:
|
|
147
|
+
from PIL import Image
|
|
148
|
+
img = Image.open(filepath)
|
|
149
|
+
return f"[Image: {Path(filepath).name}]\nFormat: {img.format}\nDimensions: {img.size[0]}x{img.size[1]}\nMode: {img.mode}\n(Note: Visual content converted to metadata for text-only model compatibility)"
|
|
150
|
+
else:
|
|
151
|
+
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
152
|
+
return f.read()
|
|
153
|
+
except Exception as e:
|
|
154
|
+
return f"[Error extracting {filepath}: {str(e)}]"
|
|
155
|
+
|
|
156
|
+
def search_web(query: str, max_results: int = 5) -> str:
|
|
157
|
+
try:
|
|
158
|
+
from bs4 import BeautifulSoup
|
|
159
|
+
session = requests.Session()
|
|
160
|
+
session.headers.update({
|
|
161
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
162
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
|
163
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
164
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
165
|
+
'Connection': 'keep-alive',
|
|
166
|
+
'Upgrade-Insecure-Requests': '1',
|
|
167
|
+
'Sec-Fetch-Dest': 'document',
|
|
168
|
+
'Sec-Fetch-Mode': 'navigate',
|
|
169
|
+
'Sec-Fetch-Site': 'none',
|
|
170
|
+
'Sec-Fetch-User': '?1',
|
|
171
|
+
})
|
|
172
|
+
url = f"https://html.duckduckgo.com/html/?q={urllib.parse.quote(query)}"
|
|
173
|
+
resp = session.get(url, timeout=10)
|
|
174
|
+
|
|
175
|
+
if resp.status_code != 200:
|
|
176
|
+
return f"[SCRAPE_FAILED: Status {resp.status_code}]"
|
|
177
|
+
|
|
178
|
+
soup = BeautifulSoup(resp.text, 'html.parser')
|
|
179
|
+
results = []
|
|
180
|
+
for a in soup.find_all('a', class_='result__snippet', limit=max_results):
|
|
181
|
+
text = a.get_text(strip=True)
|
|
182
|
+
if text: results.append(f"Result: {text}")
|
|
183
|
+
|
|
184
|
+
if not results:
|
|
185
|
+
for div in soup.find_all('div', class_='result__body', limit=max_results):
|
|
186
|
+
snippet = div.find('a', class_='result__snippet')
|
|
187
|
+
if snippet: results.append(f"Result: {snippet.get_text(strip=True)}")
|
|
188
|
+
|
|
189
|
+
if not results:
|
|
190
|
+
return "[SCRAPE_FAILED: No text found]"
|
|
191
|
+
return "\n---\n".join(results)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
return f"[SCRAPE_FAILED: {str(e)}]"
|
|
194
|
+
|
|
195
|
+
class CavemanMode(Enum):
|
|
196
|
+
OFF = 0; BASIC = 1; ULTRA = 2
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class ProviderConfig:
|
|
200
|
+
name: str; base_url: str; models_url: str; default_model: str; auth_type: str; supports_vision: bool = False
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class ModelInfo:
|
|
204
|
+
id: str; name: str; provider: str; context_window: int = 0
|
|
205
|
+
|
|
206
|
+
class ProviderRegistry:
|
|
207
|
+
BUILT_IN_PROVIDERS = {
|
|
208
|
+
"openai": ProviderConfig("OpenAI", "https://api.openai.com/v1", "https://api.openai.com/v1/models", "gpt-4o", "bearer", True),
|
|
209
|
+
"anthropic": ProviderConfig("Anthropic", "https://api.anthropic.com/v1", "https://api.anthropic.com/v1/models", "claude-3-5-sonnet-20241022", "bearer", True),
|
|
210
|
+
"google": ProviderConfig("Google Gemini", "https://generativelanguage.googleapis.com/v1beta", "https://generativelanguage.googleapis.com/v1beta/models", "gemini-1.5-flash", "api_key", True),
|
|
211
|
+
"deepseek": ProviderConfig("DeepSeek", "https://api.deepseek.com/v1", "https://api.deepseek.com/v1/models", "deepseek-chat", "bearer", False),
|
|
212
|
+
"ollama": ProviderConfig("Ollama (Local)", "http://localhost:11434/v1", "http://localhost:11434/api/tags", "llama3.2", "none", False),
|
|
213
|
+
"groq": ProviderConfig("Groq", "https://api.groq.com/openai/v1", "https://api.groq.com/openai/v1/models", "llama-3.3-70b-versatile", "bearer", False),
|
|
214
|
+
"mistral": ProviderConfig("Mistral AI", "https://api.mistral.ai/v1", "https://api.mistral.ai/v1/models", "mistral-large-latest", "bearer", True),
|
|
215
|
+
"openrouter": ProviderConfig("OpenRouter", "https://openrouter.ai/api/v1", "https://openrouter.ai/api/v1/models", "anthropic/claude-3.5-sonnet", "bearer", True),
|
|
216
|
+
"qwen": ProviderConfig("Qwen (Alibaba)", "https://dashscope.aliyuncs.com/compatible-mode/v1", "https://dashscope.aliyuncs.com/compatible-mode/v1/models", "qwen-turbo", "bearer", True),
|
|
217
|
+
"custom": ProviderConfig("Custom Provider", "", "", "custom-model", "bearer", True)
|
|
218
|
+
}
|
|
219
|
+
def __init__(self): self.providers = self.BUILT_IN_PROVIDERS.copy()
|
|
220
|
+
def get_provider(self, name: str) -> Optional[ProviderConfig]: return self.providers.get(name.lower())
|
|
221
|
+
def list_providers(self) -> List[Tuple[str, ProviderConfig]]: return [(name, config) for name, config in self.providers.items()]
|
|
222
|
+
|
|
223
|
+
class FileSystemManager:
|
|
224
|
+
def __init__(self, workspace_dir: str):
|
|
225
|
+
self.workspace_dir = workspace_dir
|
|
226
|
+
self.ignored_patterns = [".git", "__pycache__", "node_modules", ".venv", "venv", ".egg-info", "dist", "build", ".DS_Store", ".idea", ".vscode"]
|
|
227
|
+
def should_ignore(self, path: str) -> bool: return any(pattern in path for pattern in self.ignored_patterns)
|
|
228
|
+
def scan_workspace(self, max_files: int = 300) -> List[str]:
|
|
229
|
+
files = []
|
|
230
|
+
for root, dirs, filenames in os.walk(self.workspace_dir):
|
|
231
|
+
dirs[:] = [d for d in dirs if not self.should_ignore(os.path.join(root, d))]
|
|
232
|
+
for filename in filenames:
|
|
233
|
+
if self.should_ignore(filename): continue
|
|
234
|
+
rel_path = os.path.relpath(os.path.join(root, filename), self.workspace_dir)
|
|
235
|
+
files.append(rel_path)
|
|
236
|
+
if len(files) >= max_files: break
|
|
237
|
+
if len(files) >= max_files: break
|
|
238
|
+
return sorted(files)
|
|
239
|
+
def read_file(self, filepath: str) -> Optional[str]:
|
|
240
|
+
full_path = os.path.join(self.workspace_dir, filepath)
|
|
241
|
+
if not os.path.exists(full_path): return None
|
|
242
|
+
try:
|
|
243
|
+
with open(full_path, "r", encoding="utf-8") as f: return f.read()
|
|
244
|
+
except Exception: return None
|
|
245
|
+
def write_file(self, filepath: str, content: str) -> bool:
|
|
246
|
+
full_path = os.path.join(self.workspace_dir, filepath)
|
|
247
|
+
try:
|
|
248
|
+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
249
|
+
with open(full_path, "w", encoding="utf-8") as f: f.write(content)
|
|
250
|
+
return True
|
|
251
|
+
except Exception as e:
|
|
252
|
+
console.print(f"[red]Error writing {filepath}: {e}[/red]")
|
|
253
|
+
return False
|
|
254
|
+
def search_in_files(self, query: str) -> List[Dict]:
|
|
255
|
+
results = []
|
|
256
|
+
for root, dirs, files in os.walk(self.workspace_dir):
|
|
257
|
+
dirs[:] = [d for d in dirs if not self.should_ignore(os.path.join(root, d))]
|
|
258
|
+
for filename in files:
|
|
259
|
+
if self.should_ignore(filename): continue
|
|
260
|
+
full_path = os.path.join(root, filename)
|
|
261
|
+
try:
|
|
262
|
+
with open(full_path, 'r', encoding='utf-8', errors='ignore') as f: content = f.read()
|
|
263
|
+
if query.lower() in content.lower():
|
|
264
|
+
lines = content.splitlines()
|
|
265
|
+
matches = [(i+1, line.strip()) for i, line in enumerate(lines) if query.lower() in line.lower()][:3]
|
|
266
|
+
results.append({'file': os.path.relpath(full_path, self.workspace_dir), 'matches': matches})
|
|
267
|
+
except Exception: continue
|
|
268
|
+
return results[:10]
|
|
269
|
+
|
|
270
|
+
class CommandRunner:
|
|
271
|
+
def __init__(self, workspace_dir: str):
|
|
272
|
+
self.workspace_dir = workspace_dir
|
|
273
|
+
self.dangerous_commands = ["rm -rf /", "sudo rm -rf", "mkfs", "dd if="]
|
|
274
|
+
def is_dangerous(self, cmd: str) -> bool: return any(dangerous in cmd for dangerous in self.dangerous_commands)
|
|
275
|
+
def run(self, cmd: str, timeout: int = 120) -> Tuple[bool, str]:
|
|
276
|
+
try:
|
|
277
|
+
result = subprocess.run(cmd, shell=True, cwd=self.workspace_dir, capture_output=True, text=True, timeout=timeout)
|
|
278
|
+
output = result.stdout + (f"\n[STDERR]\n{result.stderr}" if result.stderr else "")
|
|
279
|
+
return result.returncode == 0, output
|
|
280
|
+
except subprocess.TimeoutExpired: return False, "Command timed out"
|
|
281
|
+
except Exception as e: return False, f"Error: {str(e)}"
|
|
282
|
+
|
|
283
|
+
class GitManager:
|
|
284
|
+
def __init__(self, workspace_dir: str, console: Console):
|
|
285
|
+
self.workspace_dir = workspace_dir; self.console = console; self.git_dir = os.path.join(workspace_dir, ".git")
|
|
286
|
+
def is_git_installed(self) -> bool:
|
|
287
|
+
try: subprocess.run(["git", "--version"], capture_output=True, check=True); return True
|
|
288
|
+
except (subprocess.CalledProcessError, FileNotFoundError): return False
|
|
289
|
+
def is_repo(self) -> bool: return os.path.exists(self.git_dir)
|
|
290
|
+
def get_current_branch(self) -> str:
|
|
291
|
+
try: return subprocess.run(["git", "branch", "--show-current"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10).stdout.strip() or "main"
|
|
292
|
+
except Exception: return "main"
|
|
293
|
+
def get_remote_url(self) -> str:
|
|
294
|
+
try: return subprocess.run(["git", "remote", "get-url", "origin"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10).stdout.strip()
|
|
295
|
+
except Exception: return ""
|
|
296
|
+
def check_github_auth(self) -> Dict[str, Any]:
|
|
297
|
+
result = {"logged_in": False, "user": "", "method": ""}
|
|
298
|
+
try:
|
|
299
|
+
gh_result = subprocess.run(["gh", "auth", "status"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10)
|
|
300
|
+
if gh_result.returncode == 0:
|
|
301
|
+
result["logged_in"] = True; result["method"] = "GitHub CLI (gh)"
|
|
302
|
+
for line in gh_result.stdout.split("\n"):
|
|
303
|
+
if "Logged in to" in line or "github.com" in line:
|
|
304
|
+
parts = line.split()
|
|
305
|
+
for i, part in enumerate(parts):
|
|
306
|
+
if part == "as" and i + 1 < len(parts): result["user"] = parts[i + 1].strip("()"); break
|
|
307
|
+
return result
|
|
308
|
+
except FileNotFoundError: pass
|
|
309
|
+
try:
|
|
310
|
+
user_result = subprocess.run(["git", "config", "user.name"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10)
|
|
311
|
+
email_result = subprocess.run(["git", "config", "user.email"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10)
|
|
312
|
+
if user_result.stdout.strip():
|
|
313
|
+
result["logged_in"] = True; result["user"] = f"{user_result.stdout.strip()} <{email_result.stdout.strip()}>"; result["method"] = "Git credentials"
|
|
314
|
+
except Exception: pass
|
|
315
|
+
return result
|
|
316
|
+
def get_status(self) -> Dict[str, Any]:
|
|
317
|
+
if not self.is_repo(): return {"error": "Not a git repository"}
|
|
318
|
+
try:
|
|
319
|
+
status_result = subprocess.run(["git", "status", "--porcelain"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10)
|
|
320
|
+
lines = [l for l in status_result.stdout.strip().split("\n") if l]
|
|
321
|
+
staged, unstaged, untracked = [], [], []
|
|
322
|
+
for line in lines:
|
|
323
|
+
status_code, filepath = line[:2], line[3:].strip()
|
|
324
|
+
if status_code[0] != ' ' and status_code[0] != '?': staged.append({"file": filepath, "status": status_code[0]})
|
|
325
|
+
if status_code[1] != ' ' and status_code[1] != '?': unstaged.append({"file": filepath, "status": status_code[1]})
|
|
326
|
+
if status_code == '??': untracked.append(filepath)
|
|
327
|
+
return {"branch": self.get_current_branch(), "remote": self.get_remote_url(), "staged": staged, "unstaged": unstaged, "untracked": untracked, "has_changes": len(lines) > 0}
|
|
328
|
+
except Exception as e: return {"error": str(e)}
|
|
329
|
+
def get_diff(self, staged: bool = False) -> str:
|
|
330
|
+
if not self.is_repo(): return "Not a git repository"
|
|
331
|
+
try:
|
|
332
|
+
cmd = ["git", "diff", "--cached"] if staged else ["git", "diff"]
|
|
333
|
+
return subprocess.run(cmd, cwd=self.workspace_dir, capture_output=True, text=True, timeout=30).stdout or "No changes to show"
|
|
334
|
+
except Exception as e: return f"Error: {str(e)}"
|
|
335
|
+
def get_log(self, count: int = 10) -> str:
|
|
336
|
+
if not self.is_repo(): return "Not a git repository"
|
|
337
|
+
try: return subprocess.run(["git", "log", f"-{count}", "--format=%h %s (%an, %ar)"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10).stdout or "No commits yet"
|
|
338
|
+
except Exception as e: return f"Error: {str(e)}"
|
|
339
|
+
def get_branches(self) -> List[str]:
|
|
340
|
+
if not self.is_repo(): return []
|
|
341
|
+
try: return [b.strip().lstrip("* ") for b in subprocess.run(["git", "branch"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10).stdout.strip().split("\n") if b.strip()]
|
|
342
|
+
except Exception: return []
|
|
343
|
+
def init_repo(self) -> Tuple[bool, str]:
|
|
344
|
+
try:
|
|
345
|
+
subprocess.run(["git", "init"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10, check=True)
|
|
346
|
+
subprocess.run(["git", "checkout", "-b", "main"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10)
|
|
347
|
+
return True, "Repository initialized with 'main' branch"
|
|
348
|
+
except Exception as e: return False, f"Error initializing: {str(e)}"
|
|
349
|
+
def add_all(self) -> Tuple[bool, str]:
|
|
350
|
+
try: subprocess.run(["git", "add", "-A"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=30, check=True); return True, "All changes staged"
|
|
351
|
+
except Exception as e: return False, f"Error staging: {str(e)}"
|
|
352
|
+
def commit(self, message: str) -> Tuple[bool, str]:
|
|
353
|
+
try:
|
|
354
|
+
result = subprocess.run(["git", "commit", "-m", message], cwd=self.workspace_dir, capture_output=True, text=True, timeout=30)
|
|
355
|
+
return (True, result.stdout.strip()) if result.returncode == 0 else (False, result.stderr.strip() or "Nothing to commit")
|
|
356
|
+
except Exception as e: return False, f"Error committing: {str(e)}"
|
|
357
|
+
def add_remote(self, url: str) -> Tuple[bool, str]:
|
|
358
|
+
try:
|
|
359
|
+
subprocess.run(["git", "remote", "remove", "origin"], cwd=self.workspace_dir, capture_output=True, timeout=10)
|
|
360
|
+
subprocess.run(["git", "remote", "add", "origin", url], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10, check=True)
|
|
361
|
+
return True, f"Remote 'origin' set to {url}"
|
|
362
|
+
except Exception as e: return False, f"Error adding remote: {str(e)}"
|
|
363
|
+
def push(self, branch: str = None, force: bool = False) -> Tuple[bool, str]:
|
|
364
|
+
if not branch: branch = self.get_current_branch()
|
|
365
|
+
try:
|
|
366
|
+
cmd = ["git", "push", "-u", "origin", branch]
|
|
367
|
+
if force: cmd.insert(2, "--force")
|
|
368
|
+
result = subprocess.run(cmd, cwd=self.workspace_dir, capture_output=True, text=True, timeout=120)
|
|
369
|
+
return (True, result.stdout.strip()) if result.returncode == 0 else (False, result.stderr.strip())
|
|
370
|
+
except Exception as e: return False, f"Error pushing: {str(e)}"
|
|
371
|
+
def pull(self) -> Tuple[bool, str]:
|
|
372
|
+
try:
|
|
373
|
+
result = subprocess.run(["git", "pull"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=120)
|
|
374
|
+
return (True, result.stdout.strip()) if result.returncode == 0 else (False, result.stderr.strip())
|
|
375
|
+
except Exception as e: return False, f"Error pulling: {str(e)}"
|
|
376
|
+
def create_branch(self, name: str) -> Tuple[bool, str]:
|
|
377
|
+
try: subprocess.run(["git", "checkout", "-b", name], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10, check=True); return True, f"Created and switched to branch '{name}'"
|
|
378
|
+
except Exception as e: return False, f"Error creating branch: {str(e)}"
|
|
379
|
+
def switch_branch(self, name: str) -> Tuple[bool, str]:
|
|
380
|
+
try: subprocess.run(["git", "checkout", name], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10, check=True); return True, f"Switched to branch '{name}'"
|
|
381
|
+
except Exception as e: return False, f"Error switching branch: {str(e)}"
|
|
382
|
+
def stash(self) -> Tuple[bool, str]:
|
|
383
|
+
try:
|
|
384
|
+
result = subprocess.run(["git", "stash"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10)
|
|
385
|
+
return (True, result.stdout.strip()) if result.returncode == 0 else (False, result.stderr.strip() or "Nothing to stash")
|
|
386
|
+
except Exception as e: return False, f"Error stashing: {str(e)}"
|
|
387
|
+
def stash_pop(self) -> Tuple[bool, str]:
|
|
388
|
+
try:
|
|
389
|
+
result = subprocess.run(["git", "stash", "pop"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10)
|
|
390
|
+
return (True, result.stdout.strip()) if result.returncode == 0 else (False, result.stderr.strip())
|
|
391
|
+
except Exception as e: return False, f"Error popping stash: {str(e)}"
|
|
392
|
+
|
|
393
|
+
class AIModelClient:
|
|
394
|
+
def __init__(self, provider: str, api_key: str, base_url: str, model: str):
|
|
395
|
+
self.provider = provider; self.api_key = api_key; self.base_url = base_url; self.model = model; self.session = requests.Session()
|
|
396
|
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
397
|
+
max_retries = 3
|
|
398
|
+
for attempt in range(max_retries):
|
|
399
|
+
resp = self.session.get(url, **kwargs) if method.upper() == 'GET' else self.session.post(url, **kwargs)
|
|
400
|
+
if resp.status_code == 429:
|
|
401
|
+
retry_delay = 60
|
|
402
|
+
try:
|
|
403
|
+
data = resp.json()
|
|
404
|
+
if "error" in data and "details" in data["error"]:
|
|
405
|
+
for detail in data["error"]["details"]:
|
|
406
|
+
if "retryDelay" in detail: retry_delay = int(''.join(filter(str.isdigit, detail["retryDelay"]))); break
|
|
407
|
+
elif "Retry-After" in resp.headers: retry_delay = int(resp.headers["Retry-After"])
|
|
408
|
+
except Exception: pass
|
|
409
|
+
console.print(f"\n[yellow]⚠️ Rate limit (429). Waiting {retry_delay}s before retry ({attempt + 1}/{max_retries})...[/yellow]")
|
|
410
|
+
time.sleep(retry_delay + 1); continue
|
|
411
|
+
return resp
|
|
412
|
+
return resp
|
|
413
|
+
def list_models(self) -> List[ModelInfo]:
|
|
414
|
+
models = []
|
|
415
|
+
try:
|
|
416
|
+
if self.provider in ["openai", "openrouter", "deepseek", "mistral", "groq", "qwen"]:
|
|
417
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
418
|
+
resp = self._request('GET', f"{self.base_url.rstrip('/')}/models", headers=headers, timeout=15)
|
|
419
|
+
if resp.ok:
|
|
420
|
+
for m in resp.json().get("data", []): models.append(ModelInfo(id=m.get("id", ""), name=m.get("id", ""), provider=self.provider))
|
|
421
|
+
elif self.provider == "anthropic":
|
|
422
|
+
resp = self._request('GET', "https://api.anthropic.com/v1/models", headers={"x-api-key": self.api_key, "anthropic-version": "2023-06-01"}, timeout=15)
|
|
423
|
+
if resp.ok:
|
|
424
|
+
for m in resp.json().get("data", []): models.append(ModelInfo(id=m.get("id", ""), name=m.get("display_name", m.get("id", "")), provider=self.provider))
|
|
425
|
+
elif self.provider == "google":
|
|
426
|
+
resp = self._request('GET', f"https://generativelanguage.googleapis.com/v1beta/models?key={self.api_key}", timeout=15)
|
|
427
|
+
if resp.ok:
|
|
428
|
+
for m in resp.json().get("models", []):
|
|
429
|
+
if "generateContent" in m.get("supportedGenerationMethods", []): models.append(ModelInfo(id=m.get("name", "").replace("models/", ""), name=m.get("displayName", ""), provider=self.provider))
|
|
430
|
+
elif self.provider == "ollama":
|
|
431
|
+
base = self.base_url.replace("/v1", "").rstrip('/')
|
|
432
|
+
resp = self._request('GET', f"{base}/api/tags", timeout=15)
|
|
433
|
+
if resp.ok:
|
|
434
|
+
for m in resp.json().get("models", []): models.append(ModelInfo(id=m.get("name", ""), name=m.get("name", ""), provider=self.provider))
|
|
435
|
+
except Exception: pass
|
|
436
|
+
if not models:
|
|
437
|
+
provider_config = ProviderRegistry().get_provider(self.provider)
|
|
438
|
+
models.append(ModelInfo(provider_config.default_model if provider_config else self.model, self.model, self.provider))
|
|
439
|
+
return models
|
|
440
|
+
def chat(self, messages: List[Dict], stream: bool = True) -> str:
|
|
441
|
+
if self.provider == "anthropic": return self._anthropic_chat(messages, stream)
|
|
442
|
+
elif self.provider == "google": return self._google_chat(messages, stream)
|
|
443
|
+
elif self.provider == "ollama": return self._ollama_chat(messages, stream)
|
|
444
|
+
return self._openai_style_chat(messages, stream)
|
|
445
|
+
def _openai_style_chat(self, messages: List[Dict], stream: bool) -> str:
|
|
446
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
447
|
+
payload = {"model": self.model, "messages": messages, "stream": stream}
|
|
448
|
+
try:
|
|
449
|
+
resp = self._request('POST', f"{self.base_url.rstrip('/')}/chat/completions", headers=headers, json=payload, stream=stream, timeout=180)
|
|
450
|
+
if not resp.ok: return f"Error: {resp.status_code} - {resp.text}"
|
|
451
|
+
return self._handle_stream(resp) if stream else resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
452
|
+
except Exception as e: return f"Error: {str(e)}"
|
|
453
|
+
def _anthropic_chat(self, messages: List[Dict], stream: bool) -> str:
|
|
454
|
+
system_msg = next((m["content"] for m in messages if m["role"] == "system"), "")
|
|
455
|
+
anthropic_messages = [{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"]
|
|
456
|
+
headers = {"x-api-key": self.api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json"}
|
|
457
|
+
payload = {"model": self.model, "messages": anthropic_messages, "max_tokens": 4096, "system": system_msg, "stream": stream}
|
|
458
|
+
try:
|
|
459
|
+
resp = self._request('POST', f"{self.base_url.rstrip('/')}/messages", headers=headers, json=payload, stream=stream, timeout=180)
|
|
460
|
+
if not resp.ok: return f"Error: {resp.status_code} - {resp.text}"
|
|
461
|
+
return self._handle_stream(resp) if stream else resp.json().get("content", [{}])[0].get("text", "")
|
|
462
|
+
except Exception as e: return f"Error: {str(e)}"
|
|
463
|
+
def _google_chat(self, messages: List[Dict], stream: bool) -> str:
|
|
464
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:streamGenerateContent?alt=sse&key={self.api_key}" if stream else f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent?key={self.api_key}"
|
|
465
|
+
contents = [{"role": "user" if m["role"] == "user" else "model", "parts": [{"text": m["content"]}]} for m in messages if m["role"] != "system"]
|
|
466
|
+
sys_msg = next((m["content"] for m in messages if m["role"] == "system"), "")
|
|
467
|
+
payload = {"contents": contents}
|
|
468
|
+
if sys_msg: payload["system_instruction"] = {"parts": [{"text": sys_msg}]}
|
|
469
|
+
try:
|
|
470
|
+
resp = self._request('POST', url, json=payload, timeout=180, stream=stream)
|
|
471
|
+
if not resp.ok: return f"Error: {resp.status_code} - {resp.text}"
|
|
472
|
+
return self._handle_stream(resp) if stream else resp.json().get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
|
|
473
|
+
except Exception as e: return f"Error: {str(e)}"
|
|
474
|
+
def _ollama_chat(self, messages: List[Dict], stream: bool) -> str:
|
|
475
|
+
base = self.base_url.replace("/v1", "").rstrip('/')
|
|
476
|
+
try:
|
|
477
|
+
resp = self._request('POST', f"{base}/api/chat", json={"model": self.model, "messages": messages, "stream": stream}, stream=stream, timeout=180)
|
|
478
|
+
if not resp.ok: return f"Error: {resp.status_code} - {resp.text}"
|
|
479
|
+
return self._handle_stream(resp) if stream else resp.json().get("message", {}).get("content", "")
|
|
480
|
+
except Exception as e: return f"Error: {str(e)}"
|
|
481
|
+
def _handle_stream(self, resp) -> str:
|
|
482
|
+
full_response = ""
|
|
483
|
+
try:
|
|
484
|
+
for line in resp.iter_lines():
|
|
485
|
+
if not line: continue
|
|
486
|
+
line_str = line.decode('utf-8').strip()
|
|
487
|
+
if not line_str: continue
|
|
488
|
+
if line_str.startswith('data: '):
|
|
489
|
+
json_str = line_str[6:]
|
|
490
|
+
if json_str.strip() == '[DONE]': break
|
|
491
|
+
try: data = json.loads(json_str)
|
|
492
|
+
except: continue
|
|
493
|
+
else:
|
|
494
|
+
try: data = json.loads(line_str)
|
|
495
|
+
except: continue
|
|
496
|
+
content = None
|
|
497
|
+
if "choices" in data and data["choices"]: content = data["choices"][0].get("delta", {}).get("content")
|
|
498
|
+
elif data.get("type") == "content_block_delta": content = data.get("delta", {}).get("text")
|
|
499
|
+
elif "candidates" in data and data["candidates"]:
|
|
500
|
+
parts = data["candidates"][0].get("content", {}).get("parts", [])
|
|
501
|
+
if parts and "text" in parts[0]: content = parts[0]["text"]
|
|
502
|
+
elif "message" in data: content = data["message"].get("content")
|
|
503
|
+
if content is None:
|
|
504
|
+
for key in ["content", "text", "response"]:
|
|
505
|
+
if key in data and isinstance(data[key], str): content = data[key]; break
|
|
506
|
+
if content:
|
|
507
|
+
console.print(content, end="", markup=False, highlight=False)
|
|
508
|
+
full_response += content
|
|
509
|
+
except Exception as e: console.print(f"\n[red]Stream error: {str(e)}[/red]")
|
|
510
|
+
console.print()
|
|
511
|
+
return full_response
|
|
512
|
+
|
|
513
|
+
class SamCodeCLI:
|
|
514
|
+
def __init__(self):
|
|
515
|
+
self.workspace_dir = os.getcwd()
|
|
516
|
+
self.config_dir = os.path.join(self.workspace_dir, ".samcode")
|
|
517
|
+
self.config_path = os.path.join(self.config_dir, "config.json")
|
|
518
|
+
os.makedirs(self.config_dir, exist_ok=True)
|
|
519
|
+
self.provider_registry = ProviderRegistry()
|
|
520
|
+
self.active_provider = "openai"
|
|
521
|
+
self.active_model = "gpt-4o"
|
|
522
|
+
self.api_key = ""
|
|
523
|
+
self.custom_base_url = ""
|
|
524
|
+
self.caveman_mode = CavemanMode.OFF
|
|
525
|
+
self.session_context = []
|
|
526
|
+
self.file_manager = FileSystemManager(self.workspace_dir)
|
|
527
|
+
self.command_runner = CommandRunner(self.workspace_dir)
|
|
528
|
+
self.git_manager = GitManager(self.workspace_dir, console)
|
|
529
|
+
self.command_completer = WordCompleter(['/connect', '/models', '/upload', '/clear-uploads', '/caveman', '/clear', '/exit', '/help', '/aboutme', '/searchweb', '/git'], sentence=True)
|
|
530
|
+
self.prompt_text = FormattedText([('ansicyan bold', '❯ ')])
|
|
531
|
+
self.load_configuration()
|
|
532
|
+
|
|
533
|
+
def load_configuration(self):
|
|
534
|
+
if os.path.exists(self.config_path):
|
|
535
|
+
try:
|
|
536
|
+
with open(self.config_path, "r") as f:
|
|
537
|
+
data = json.load(f)
|
|
538
|
+
self.active_provider = data.get("provider", self.active_provider)
|
|
539
|
+
self.active_model = data.get("model", self.active_model)
|
|
540
|
+
self.api_key = data.get("api_key", "")
|
|
541
|
+
self.custom_base_url = data.get("custom_base_url", "")
|
|
542
|
+
caveman_val = data.get("caveman_mode", 0)
|
|
543
|
+
self.caveman_mode = CavemanMode(caveman_val) if caveman_val in [0, 1, 2] else CavemanMode.OFF
|
|
544
|
+
except: pass
|
|
545
|
+
|
|
546
|
+
def save_configuration(self):
|
|
547
|
+
with open(self.config_path, "w") as f:
|
|
548
|
+
json.dump({"provider": self.active_provider, "model": self.active_model, "api_key": self.api_key, "custom_base_url": self.custom_base_url, "caveman_mode": self.caveman_mode.value}, f, indent=2)
|
|
549
|
+
|
|
550
|
+
def get_client(self) -> Optional[AIModelClient]:
|
|
551
|
+
provider_config = self.provider_registry.get_provider(self.active_provider)
|
|
552
|
+
if not provider_config: return None
|
|
553
|
+
base_url = self.custom_base_url if self.custom_base_url else provider_config.base_url
|
|
554
|
+
return AIModelClient(self.active_provider, self.api_key, base_url, self.active_model)
|
|
555
|
+
|
|
556
|
+
def show_main_header(self):
|
|
557
|
+
print("\033[2J\033[H", end="")
|
|
558
|
+
header_table = Table(show_header=False, box=None, padding=(0, 1))
|
|
559
|
+
header_table.add_column(style="bold cyan", justify="left")
|
|
560
|
+
header_table.add_column(style="dim", justify="center")
|
|
561
|
+
header_table.add_column(style="bold green", justify="right")
|
|
562
|
+
caveman_indicator = f" | [red]🦴 {self.caveman_mode.name}[/red]" if self.caveman_mode != CavemanMode.OFF else ""
|
|
563
|
+
upload_indicator = f" | [magenta]📎 {len(self.session_context)} Docs[/magenta]" if self.session_context else ""
|
|
564
|
+
git_indicator = ""
|
|
565
|
+
if self.git_manager.is_repo():
|
|
566
|
+
branch = self.git_manager.get_current_branch()
|
|
567
|
+
git_indicator = f" | [yellow]🌿 {branch}[/yellow]"
|
|
568
|
+
header_table.add_row("⚡ SamCode CLI", "Autonomous Coding Agent", f"{self.active_provider} | {self.active_model[:20]}{caveman_indicator}{upload_indicator}{git_indicator}")
|
|
569
|
+
console.print(Panel(header_table, border_style="bright_black", padding=(0, 1)))
|
|
570
|
+
console.print("[dim]Type your request naturally, or use /help for commands.[/dim]\n")
|
|
571
|
+
|
|
572
|
+
def cmd_connect(self):
|
|
573
|
+
console.print("\n[bold cyan]🔗 Provider Configuration[/bold cyan]\n")
|
|
574
|
+
providers = self.provider_registry.list_providers()
|
|
575
|
+
selected = questionary.select("Select AI Provider:", choices=[Choice(f"{config.name} ({name})", name) for name, config in providers]).ask()
|
|
576
|
+
if not selected: return
|
|
577
|
+
self.active_provider = selected
|
|
578
|
+
provider_config = self.provider_registry.get_provider(selected)
|
|
579
|
+
if provider_config.auth_type != "none":
|
|
580
|
+
self.api_key = Prompt.ask(f"[cyan]Enter API Key for {provider_config.name}[/cyan]", password=True)
|
|
581
|
+
if selected == "custom":
|
|
582
|
+
self.custom_base_url = Prompt.ask("[cyan]Enter Custom Base URL[/cyan]")
|
|
583
|
+
self.save_configuration()
|
|
584
|
+
console.print(f"\n[green]✓ Connected to {provider_config.name}[/green]\n")
|
|
585
|
+
if Confirm.ask("Select a model?", default=True): self.cmd_models()
|
|
586
|
+
|
|
587
|
+
def cmd_models(self):
|
|
588
|
+
console.print("\n[bold cyan]📋 Model Selector[/bold cyan]\n")
|
|
589
|
+
client = self.get_client()
|
|
590
|
+
if not client or not self.api_key: console.print("[red]Configure provider first with /connect[/red]"); return
|
|
591
|
+
console.print("[cyan]Fetching models from API...[/cyan]")
|
|
592
|
+
models = client.list_models()
|
|
593
|
+
if not models: console.print("[red]Could not fetch models[/red]"); return
|
|
594
|
+
console.print(f"\n[green]✓ Found {len(models)} model(s)[/green]\n")
|
|
595
|
+
models_per_page = 30; total_pages = (len(models) + models_per_page - 1) // models_per_page; current_page = 0
|
|
596
|
+
while True:
|
|
597
|
+
start_idx = current_page * models_per_page; end_idx = min(start_idx + models_per_page, len(models))
|
|
598
|
+
console.print(f"[bold cyan]📋 Model Selector[/bold cyan] | [dim]Page {current_page + 1}/{total_pages} | Showing {start_idx + 1}-{end_idx} of {len(models)}[/dim]")
|
|
599
|
+
page_models = models[start_idx:end_idx]; items = []
|
|
600
|
+
for i, m in enumerate(page_models):
|
|
601
|
+
actual_idx = start_idx + i + 1; name = m.id if len(m.id) <= 35 else m.id[:32] + "..."
|
|
602
|
+
items.append(f"[cyan]{actual_idx:<4}[/cyan] {name}")
|
|
603
|
+
num_cols = max(1, console.width // 40)
|
|
604
|
+
if num_cols > 4: num_cols = 4
|
|
605
|
+
table = Table(show_header=False, box=None, padding=(0, 2), expand=True)
|
|
606
|
+
for _ in range(num_cols): table.add_column(ratio=1, overflow="fold")
|
|
607
|
+
for i in range(0, len(items), num_cols):
|
|
608
|
+
row = items[i:i+num_cols]
|
|
609
|
+
while len(row) < num_cols: row.append("")
|
|
610
|
+
table.add_row(*row)
|
|
611
|
+
console.print(table)
|
|
612
|
+
console.print("[dim]Type a number to select, 'more' for next, 'less' for previous.[/dim]")
|
|
613
|
+
while True:
|
|
614
|
+
user_input = Prompt.ask("[bold cyan]❯[/bold cyan]").strip().lower()
|
|
615
|
+
if user_input == "more":
|
|
616
|
+
if current_page < total_pages - 1: current_page += 1; print("\033[2J\033[H", end=""); break
|
|
617
|
+
else: console.print("[yellow]⚠️ Already on the last page.[/yellow]")
|
|
618
|
+
elif user_input in ["less", "prev", "back"]:
|
|
619
|
+
if current_page > 0: current_page -= 1; print("\033[2J\033[H", end=""); break
|
|
620
|
+
else: console.print("[yellow]️ Already on the first page.[/yellow]")
|
|
621
|
+
else:
|
|
622
|
+
try:
|
|
623
|
+
idx = int(user_input) - 1
|
|
624
|
+
if 0 <= idx < len(models):
|
|
625
|
+
self.active_model = models[idx].id; self.save_configuration(); print("\033[2J\033[H", end=""); console.print(f"[green]✓ Model set to: {self.active_model}[/green]\n"); return
|
|
626
|
+
else: console.print("[red]⚠️ Number out of range. Please try again.[/red]")
|
|
627
|
+
except ValueError: console.print("[yellow]⚠️ Please choose a model by number, or type 'more'/'less'.[/yellow]")
|
|
628
|
+
|
|
629
|
+
def cmd_caveman(self):
|
|
630
|
+
current_val = self.caveman_mode.value; next_val = (current_val + 1) % 3; self.caveman_mode = CavemanMode(next_val); self.save_configuration()
|
|
631
|
+
if self.caveman_mode == CavemanMode.OFF: console.print("[green]🔊 Caveman Mode OFF. Normal verbosity.[/green]")
|
|
632
|
+
elif self.caveman_mode == CavemanMode.BASIC: console.print("[yellow]🔉 Caveman Mode BASIC. Concise, no fluff.[/yellow]")
|
|
633
|
+
elif self.caveman_mode == CavemanMode.ULTRA: console.print("[red]🔇 Caveman Mode ULTRA. Maximum token saving. Grunt-like brevity.[/red]")
|
|
634
|
+
self.show_main_header()
|
|
635
|
+
|
|
636
|
+
def cmd_upload(self, filepath: str = ""):
|
|
637
|
+
if not filepath: filepath = Prompt.ask("[cyan]Enter file path to upload[/cyan]").strip()
|
|
638
|
+
if not filepath: return
|
|
639
|
+
filepath = filepath.strip('\'"')
|
|
640
|
+
if not os.path.exists(filepath): console.print(f"[red]✗ File not found: {filepath}[/red]"); return
|
|
641
|
+
filename = Path(filepath).name; console.print(f"[cyan]📄 Processing {filename}...[/cyan]")
|
|
642
|
+
extracted_text = DocumentReader.extract_text(filepath)
|
|
643
|
+
if extracted_text.startswith("[Error"): console.print(f"[red]{extracted_text}[/red]"); return
|
|
644
|
+
if len(extracted_text) > 20000: extracted_text = extracted_text[:20000] + "\n\n... [CONTENT TRUNCATED DUE TO LENGTH] ..."
|
|
645
|
+
self.session_context.append({"filename": filename, "content": extracted_text})
|
|
646
|
+
preview = extracted_text[:200].replace('\n', ' ') + "..." if len(extracted_text) > 200 else extracted_text.replace('\n', ' ')
|
|
647
|
+
panel = Panel(f"[dim]{preview}[/dim]", title=f"[bold green]✓ Uploaded: {filename}[/bold green]", border_style="green", padding=(1, 2))
|
|
648
|
+
console.print(panel); console.print(f"[dim]Added to session context. The agent can now see this document.[/dim]\n"); self.show_main_header()
|
|
649
|
+
|
|
650
|
+
def cmd_clear_uploads(self):
|
|
651
|
+
self.session_context = []; console.print("[yellow]🗑️ All uploaded documents cleared from session context.[/yellow]\n"); self.show_main_header()
|
|
652
|
+
|
|
653
|
+
def cmd_aboutme(self):
|
|
654
|
+
console.print()
|
|
655
|
+
about_text = Text()
|
|
656
|
+
about_text.append("SamCode CLI", style="bold cyan"); about_text.append(" is an advanced autonomous coding agent.\n\n")
|
|
657
|
+
about_text.append("👨💻 Developed by: ", style="bold"); about_text.append("Magra Houssem Eddine\n", style="bold green")
|
|
658
|
+
about_text.append("\n🚀 Other Projects:\n", style="bold"); about_text.append(" • ", style="dim"); about_text.append("Sam Code IDE", style="bold yellow"); about_text.append(" - A powerful integrated development environment.\n", style="dim")
|
|
659
|
+
about_text.append(" • ", style="dim"); about_text.append("Sam Agent", style="bold yellow"); about_text.append(" - My twin brother! Another autonomous agent.\n", style="dim")
|
|
660
|
+
about_text.append("\n🌐 Official Website: ", style="bold"); about_text.append("https://samcode-26.web.app\n", style="bold blue underline")
|
|
661
|
+
panel = Panel(about_text, title="[bold magenta]✨ About SamCode ✨[/bold magenta]", border_style="bright_magenta", padding=(1, 2), expand=False)
|
|
662
|
+
console.print(panel, justify="center"); console.print()
|
|
663
|
+
|
|
664
|
+
def cmd_search_web(self, query: str = ""):
|
|
665
|
+
if not query: query = Prompt.ask("[cyan]Enter search query[/cyan]").strip()
|
|
666
|
+
if not query: return
|
|
667
|
+
search_url = f"https://duckduckgo.com/?q={urllib.parse.quote(query)}"
|
|
668
|
+
console.print(f"\n[cyan]🌐 Opening default browser to: {search_url}[/cyan]")
|
|
669
|
+
try: webbrowser.open(search_url)
|
|
670
|
+
except Exception: console.print("[yellow]Could not open browser automatically.[/yellow]")
|
|
671
|
+
console.print(f"[cyan]📡 Fetching text snippets for AI synthesis...[/cyan]")
|
|
672
|
+
search_results = search_web(query)
|
|
673
|
+
client = self.get_client()
|
|
674
|
+
if not client or not self.api_key: console.print("[red]Configure AI first with /connect[/red]"); return
|
|
675
|
+
if search_results.startswith("[SCRAPE_FAILED"):
|
|
676
|
+
console.print("[yellow]⚠️ Couldn't automatically scrape text (DuckDuckGo blocked the bot).[/yellow]")
|
|
677
|
+
console.print("[dim]Please look at your browser for the results. I will answer based on my general knowledge.[/dim]\n")
|
|
678
|
+
ai_prompt = f"""The user wants to know about: "{query}"
|
|
679
|
+
I tried to search the web, but the search engine blocked my automated request.
|
|
680
|
+
Please answer the user's query to the best of your ability using your existing knowledge.
|
|
681
|
+
If you don't know, tell the user to check the browser tab that just opened."""
|
|
682
|
+
else:
|
|
683
|
+
console.print(f"[green]✓ Found search results. Asking AI to synthesize...[/green]\n")
|
|
684
|
+
ai_prompt = f"""The user wants to know about: "{query}"
|
|
685
|
+
I have searched the web and found the following top results:
|
|
686
|
+
{search_results}
|
|
687
|
+
Based ONLY on the search results above, provide a comprehensive, accurate, and well-structured answer. Do not make up information."""
|
|
688
|
+
|
|
689
|
+
messages = [
|
|
690
|
+
{"role": "system", "content": "You are an expert research assistant. Provide a clear, accurate, and helpful answer. Use markdown formatting."},
|
|
691
|
+
{"role": "user", "content": ai_prompt}
|
|
692
|
+
]
|
|
693
|
+
|
|
694
|
+
console.print(f"\n[bold cyan]🤖 AI Synthesizing Results...[/bold cyan]\n")
|
|
695
|
+
client.chat(messages, stream=True)
|
|
696
|
+
console.print()
|
|
697
|
+
|
|
698
|
+
def cmd_git(self):
|
|
699
|
+
console.print("\n[bold cyan]🌿 Git Operations[/bold cyan]\n")
|
|
700
|
+
if not self.git_manager.is_git_installed(): console.print("[red]✗ Git is not installed on your system.[/red]"); console.print("[dim]Please install Git from https://git-scm.com/[/dim]\n"); return
|
|
701
|
+
auth_status = self.git_manager.check_github_auth()
|
|
702
|
+
if auth_status["logged_in"]: console.print(f"[green]✓ GitHub Auth:[/green] {auth_status['method']} - {auth_status['user']}\n")
|
|
703
|
+
else: console.print("[yellow]⚠️ GitHub Auth:[/yellow] Not logged in\n"); console.print("[dim]To log in, run: gh auth login[/dim]\n")
|
|
704
|
+
if not self.git_manager.is_repo():
|
|
705
|
+
console.print("[yellow]⚠️ No Git repository found in this workspace.[/yellow]\n")
|
|
706
|
+
if Confirm.ask("Would you like to initialize a new Git repository?", default=True): self._initialize_git_repo()
|
|
707
|
+
return
|
|
708
|
+
status = self.git_manager.get_status()
|
|
709
|
+
if "error" in status: console.print(f"[red]{status['error']}[/red]\n"); return
|
|
710
|
+
branch = status.get("branch", "unknown"); remote = status.get("remote", "none")
|
|
711
|
+
total_changes = len(status.get("staged", [])) + len(status.get("unstaged", [])) + len(status.get("untracked", []))
|
|
712
|
+
console.print(f"[dim]Branch: [bold]{branch}[/bold] | Remote: [bold]{remote or 'none'}[/bold] | Changes: [bold]{total_changes}[/bold][/dim]\n")
|
|
713
|
+
actions = [Choice("📊 View Status (see all changes)", "status"), Choice("📝 Commit Changes", "commit"), Choice("⬆️ Push to Remote", "push"), Choice("⬇️ Pull from Remote", "pull"), Choice("🌿 Branches (list/create/switch)", "branch"), Choice("📜 Recent Commits (log)", "log"), Choice("🔍 View Diff (detailed changes)", "diff"), Choice("📦 Stash Changes (save temporarily)", "stash"), Choice("🔗 Change Remote URL", "remote"), Choice("🔑 Check GitHub Login", "auth")]
|
|
714
|
+
action = questionary.select("Select Git Operation:", choices=actions).ask()
|
|
715
|
+
if not action: return
|
|
716
|
+
if action == "status": self._git_show_status(status)
|
|
717
|
+
elif action == "commit": self._git_commit()
|
|
718
|
+
elif action == "push": self._git_push()
|
|
719
|
+
elif action == "pull": self._git_pull()
|
|
720
|
+
elif action == "branch": self._git_branch_menu()
|
|
721
|
+
elif action == "log": self._git_log()
|
|
722
|
+
elif action == "diff": self._git_diff()
|
|
723
|
+
elif action == "stash": self._git_stash_menu()
|
|
724
|
+
elif action == "remote": self._git_change_remote()
|
|
725
|
+
elif action == "auth": self._git_check_auth()
|
|
726
|
+
self.show_main_header()
|
|
727
|
+
|
|
728
|
+
def _initialize_git_repo(self):
|
|
729
|
+
console.print("\n[bold cyan]🚀 Initialize Git Repository[/bold cyan]\n")
|
|
730
|
+
success, msg = self.git_manager.init_repo()
|
|
731
|
+
if not success: console.print(f"[red]✗ {msg}[/red]\n"); return
|
|
732
|
+
console.print(f"[green]✓ {msg}[/green]\n")
|
|
733
|
+
try:
|
|
734
|
+
user_check = subprocess.run(["git", "config", "user.name"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=5)
|
|
735
|
+
if not user_check.stdout.strip():
|
|
736
|
+
console.print("[yellow]Git user not configured. Let's set it up:[/yellow]\n")
|
|
737
|
+
user_name = Prompt.ask("[cyan]Your name[/cyan]"); user_email = Prompt.ask("[cyan]Your email[/cyan]")
|
|
738
|
+
subprocess.run(["git", "config", "user.name", user_name], cwd=self.workspace_dir, timeout=5)
|
|
739
|
+
subprocess.run(["git", "config", "user.email", user_email], cwd=self.workspace_dir, timeout=5)
|
|
740
|
+
console.print(f"[green]✓ Git user set to: {user_name} <{user_email}>[/green]\n")
|
|
741
|
+
except Exception: pass
|
|
742
|
+
console.print("[dim]You'll need a GitHub repository URL. Format: https://github.com/username/repo.git[/dim]\n")
|
|
743
|
+
repo_url = Prompt.ask("[cyan]Enter GitHub repository URL[/cyan]").strip()
|
|
744
|
+
if not repo_url: console.print("[yellow]Repository URL is required. Aborting.[/yellow]\n"); return
|
|
745
|
+
success, msg = self.git_manager.add_remote(repo_url)
|
|
746
|
+
if not success: console.print(f"[red]✗ {msg}[/red]\n"); return
|
|
747
|
+
console.print(f"[green]✓ {msg}[/green]\n")
|
|
748
|
+
console.print("[cyan]📦 Staging all files...[/cyan]")
|
|
749
|
+
success, msg = self.git_manager.add_all()
|
|
750
|
+
if not success: console.print(f"[red]✗ {msg}[/red]\n"); return
|
|
751
|
+
console.print(f"[green]✓ {msg}[/green]\n")
|
|
752
|
+
commit_msg = Prompt.ask("[cyan]Initial commit message[/cyan]", default="Initial commit")
|
|
753
|
+
success, msg = self.git_manager.commit(commit_msg)
|
|
754
|
+
if not success: console.print(f"[red]✗ {msg}[/red]\n"); return
|
|
755
|
+
console.print(f"[green]✓ Committed: {msg}[/green]\n")
|
|
756
|
+
console.print("[cyan]⬆️ Pushing to GitHub...[/cyan]")
|
|
757
|
+
if Confirm.ask("Push to remote now?", default=True):
|
|
758
|
+
success, msg = self.git_manager.push(force=True)
|
|
759
|
+
if success: console.print(f"[green]✓ Successfully pushed to GitHub![/green]\n"); console.print(f"[dim]Repository: {repo_url}[/dim]\n")
|
|
760
|
+
else: console.print(f"[red]✗ Push failed: {msg}[/red]"); console.print("[dim]This might be due to authentication. Try running: gh auth login[/dim]\n")
|
|
761
|
+
else: console.print("[yellow]Skipped push. Run /git → Push when ready.[/yellow]\n")
|
|
762
|
+
|
|
763
|
+
def _git_show_status(self, status: Dict):
|
|
764
|
+
console.print(f"\n[bold cyan]📊 Git Status - Branch: {status.get('branch', 'unknown')}[/bold cyan]\n")
|
|
765
|
+
if not status.get("has_changes"): console.print("[green]✓ Working tree clean. No changes to commit.[/green]\n"); return
|
|
766
|
+
if status.get("staged"):
|
|
767
|
+
console.print("[bold green]Staged (ready to commit):[/bold green]")
|
|
768
|
+
for item in status["staged"]: console.print(f" {item['file']} ({item['status']})")
|
|
769
|
+
console.print()
|
|
770
|
+
if status.get("unstaged"):
|
|
771
|
+
console.print("[bold yellow]Modified (not staged):[/bold yellow]")
|
|
772
|
+
for item in status["unstaged"]: console.print(f" {item['file']} ({item['status']})")
|
|
773
|
+
console.print()
|
|
774
|
+
if status.get("untracked"):
|
|
775
|
+
console.print("[bold magenta]Untracked (new files):[/bold magenta]")
|
|
776
|
+
for f in status["untracked"]: console.print(f" {f}")
|
|
777
|
+
console.print()
|
|
778
|
+
|
|
779
|
+
def _git_commit(self):
|
|
780
|
+
console.print("\n[bold cyan]📝 Commit Changes[/bold cyan]\n")
|
|
781
|
+
status = self.git_manager.get_status()
|
|
782
|
+
if "error" in status: console.print(f"[red]{status['error']}[/red]\n"); return
|
|
783
|
+
total_changes = len(status.get("staged", [])) + len(status.get("unstaged", [])) + len(status.get("untracked", []))
|
|
784
|
+
if total_changes == 0: console.print("[yellow]No changes to commit.[/yellow]\n"); return
|
|
785
|
+
console.print(f"Found {total_changes} change(s).")
|
|
786
|
+
if Confirm.ask("Stage all changes (git add -A)?", default=True):
|
|
787
|
+
success, msg = self.git_manager.add_all()
|
|
788
|
+
if not success: console.print(f"[red]✗ {msg}[/red]\n"); return
|
|
789
|
+
commit_msg = Prompt.ask("[cyan]Commit message[/cyan]")
|
|
790
|
+
if not commit_msg: console.print("[yellow]Commit message is required.[/yellow]\n"); return
|
|
791
|
+
success, msg = self.git_manager.commit(commit_msg)
|
|
792
|
+
if success: console.print(f"\n[green]✓ Committed successfully[/green]\n"); console.print(f"[dim]{msg}[/dim]\n")
|
|
793
|
+
else: console.print(f"\n[red]✗ {msg}[/red]\n")
|
|
794
|
+
|
|
795
|
+
def _git_push(self):
|
|
796
|
+
console.print("\n[bold cyan]⬆️ Push to Remote[/bold cyan]\n")
|
|
797
|
+
remote = self.git_manager.get_remote_url()
|
|
798
|
+
if not remote: console.print("[yellow]No remote configured. Use 'Change Remote URL' first.[/yellow]\n"); return
|
|
799
|
+
branch = self.git_manager.get_current_branch()
|
|
800
|
+
console.print(f"[dim]Branch: {branch} | Remote: {remote}[/dim]\n")
|
|
801
|
+
force = Confirm.ask("Force push? (⚠️ Overwrites remote history)", default=False)
|
|
802
|
+
console.print("[cyan]Pushing...[/cyan]")
|
|
803
|
+
success, msg = self.git_manager.push(branch, force)
|
|
804
|
+
if success: console.print(f"\n[green]✓ Successfully pushed to {remote}[/green]\n")
|
|
805
|
+
else: console.print(f"\n[red]✗ Push failed[/red]\n[dim]{msg}[/dim]\n")
|
|
806
|
+
|
|
807
|
+
def _git_pull(self):
|
|
808
|
+
console.print("\n[bold cyan]⬇️ Pull from Remote[/bold cyan]\n")
|
|
809
|
+
remote = self.git_manager.get_remote_url()
|
|
810
|
+
if not remote: console.print("[yellow]No remote configured.[/yellow]\n"); return
|
|
811
|
+
console.print("[cyan]Pulling latest changes...[/cyan]")
|
|
812
|
+
success, msg = self.git_manager.pull()
|
|
813
|
+
if success: console.print(f"\n[green]✓ Pull successful[/green]\n[dim]{msg}[/dim]\n")
|
|
814
|
+
else: console.print(f"\n[red]✗ Pull failed[/red]\n[dim]{msg}[/dim]\n")
|
|
815
|
+
|
|
816
|
+
def _git_branch_menu(self):
|
|
817
|
+
console.print("\n[bold cyan]🌿 Branch Operations[/bold cyan]\n")
|
|
818
|
+
branches = self.git_manager.get_branches(); current = self.git_manager.get_current_branch()
|
|
819
|
+
console.print(f"[dim]Current branch: [bold green]{current}[/bold green][/dim]\n")
|
|
820
|
+
console.print("[bold]Available branches:[/bold]")
|
|
821
|
+
for b in branches: console.print(f" {b}{' ← current' if b == current else ''}")
|
|
822
|
+
console.print()
|
|
823
|
+
actions = [Choice("Create new branch", "create"), Choice("Switch branch", "switch"), Choice("Delete branch", "delete")]
|
|
824
|
+
action = questionary.select("Branch action:", choices=actions).ask()
|
|
825
|
+
if not action: return
|
|
826
|
+
if action == "create":
|
|
827
|
+
name = Prompt.ask("[cyan]New branch name[/cyan]").strip()
|
|
828
|
+
if name:
|
|
829
|
+
success, msg = self.git_manager.create_branch(name)
|
|
830
|
+
if success: console.print(f"\n[green]✓ {msg}[/green]\n")
|
|
831
|
+
else: console.print(f"\n[red]✗ {msg}[/red]\n")
|
|
832
|
+
elif action == "switch":
|
|
833
|
+
name = Prompt.ask("[cyan]Branch to switch to[/cyan]", choices=branches if branches else None).strip()
|
|
834
|
+
if name:
|
|
835
|
+
success, msg = self.git_manager.switch_branch(name)
|
|
836
|
+
if success: console.print(f"\n[green]✓ {msg}[/green]\n")
|
|
837
|
+
else: console.print(f"\n[red]✗ {msg}[/red]\n")
|
|
838
|
+
elif action == "delete":
|
|
839
|
+
name = Prompt.ask("[cyan]Branch to delete[/cyan]").strip()
|
|
840
|
+
if name and name != current:
|
|
841
|
+
if Confirm.ask(f"Delete branch '{name}'?", default=False):
|
|
842
|
+
try:
|
|
843
|
+
result = subprocess.run(["git", "branch", "-D", name], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10)
|
|
844
|
+
if result.returncode == 0: console.print(f"\n[green]✓ Deleted branch '{name}'[/green]\n")
|
|
845
|
+
else: console.print(f"\n[red]✗ {result.stderr.strip()}[/red]\n")
|
|
846
|
+
except Exception as e: console.print(f"\n[red]Error: {e}[/red]\n")
|
|
847
|
+
else: console.print("[yellow]Cannot delete the current branch.[/yellow]\n")
|
|
848
|
+
|
|
849
|
+
def _git_log(self):
|
|
850
|
+
console.print("\n[bold cyan]📜 Recent Commits[/bold cyan]\n")
|
|
851
|
+
log = self.git_manager.get_log(15)
|
|
852
|
+
console.print(Panel(f"[dim]{log}[/dim]", title="Git Log", border_style="cyan")); console.print()
|
|
853
|
+
|
|
854
|
+
def _git_diff(self):
|
|
855
|
+
console.print("\n[bold cyan]🔍 View Diff[/bold cyan]\n")
|
|
856
|
+
diff_type = questionary.select("Show diff for:", choices=[Choice("Unstaged changes", "unstaged"), Choice("Staged changes", "staged")]).ask()
|
|
857
|
+
if not diff_type: return
|
|
858
|
+
staged = diff_type == "staged"; diff = self.git_manager.get_diff(staged)
|
|
859
|
+
if diff and diff != "No changes to show":
|
|
860
|
+
syntax = Syntax(diff, "diff", theme="monokai", line_numbers=True)
|
|
861
|
+
title = "Staged Changes" if staged else "Unstaged Changes"
|
|
862
|
+
console.print(Panel(syntax, title=f"[bold cyan]{title}[/bold cyan]", border_style="cyan"))
|
|
863
|
+
else: console.print("[yellow]No changes to show.[/yellow]")
|
|
864
|
+
console.print()
|
|
865
|
+
|
|
866
|
+
def _git_stash_menu(self):
|
|
867
|
+
console.print("\n[bold cyan]📦 Stash Operations[/bold cyan]\n")
|
|
868
|
+
actions = [Choice("Stash current changes", "stash"), Choice("Pop latest stash", "pop")]
|
|
869
|
+
action = questionary.select("Stash action:", choices=actions).ask()
|
|
870
|
+
if not action: return
|
|
871
|
+
if action == "stash":
|
|
872
|
+
success, msg = self.git_manager.stash()
|
|
873
|
+
if success: console.print(f"\n[green]✓ {msg}[/green]\n")
|
|
874
|
+
else: console.print(f"\n[red]✗ {msg}[/red]\n")
|
|
875
|
+
elif action == "pop":
|
|
876
|
+
success, msg = self.git_manager.stash_pop()
|
|
877
|
+
if success: console.print(f"\n[green]✓ {msg}[/green]\n")
|
|
878
|
+
else: console.print(f"\n[red]✗ {msg}[/red]\n")
|
|
879
|
+
|
|
880
|
+
def _git_change_remote(self):
|
|
881
|
+
console.print("\n[bold cyan]🔗 Change Remote URL[/bold cyan]\n")
|
|
882
|
+
current = self.git_manager.get_remote_url()
|
|
883
|
+
if current: console.print(f"[dim]Current remote: {current}[/dim]\n")
|
|
884
|
+
new_url = Prompt.ask("[cyan]New remote URL[/cyan]").strip()
|
|
885
|
+
if new_url:
|
|
886
|
+
success, msg = self.git_manager.add_remote(new_url)
|
|
887
|
+
if success: console.print(f"\n[green]✓ {msg}[/green]\n")
|
|
888
|
+
else: console.print(f"\n[red]✗ {msg}[/red]\n")
|
|
889
|
+
|
|
890
|
+
def _git_check_auth(self):
|
|
891
|
+
console.print("\n[bold cyan]🔑 GitHub Authentication Status[/bold cyan]\n")
|
|
892
|
+
auth = self.git_manager.check_github_auth()
|
|
893
|
+
if auth["logged_in"]: console.print(f"[green]✓ Logged in via {auth['method']}[/green]\n[dim]User: {auth['user']}[/dim]\n")
|
|
894
|
+
else:
|
|
895
|
+
console.print("[red]✗ Not logged into GitHub[/red]\n")
|
|
896
|
+
console.print("[bold]To log in:[/bold]\n 1. Install GitHub CLI: https://cli.github.com/\n 2. Run: gh auth login\n 3. Follow the prompts\n")
|
|
897
|
+
|
|
898
|
+
def cmd_agent_ask(self, question: str):
|
|
899
|
+
client = self.get_client()
|
|
900
|
+
if not client or not self.api_key: console.print("[red]Configure AI first with /connect[/red]"); return
|
|
901
|
+
files = self.file_manager.scan_workspace()
|
|
902
|
+
workspace_tree = "\n".join(files) if files else "(Empty workspace)"
|
|
903
|
+
context_str = ""
|
|
904
|
+
if self.session_context:
|
|
905
|
+
context_str = "\n\nUPLOADED DOCUMENTS CONTEXT (The user has uploaded these files, you can reference them directly):\n"
|
|
906
|
+
for doc in self.session_context: context_str += f"=== {doc['filename']} ===\n{doc['content']}\n\n"
|
|
907
|
+
caveman_rules = ""
|
|
908
|
+
if self.caveman_mode == CavemanMode.BASIC: caveman_rules = "\n9. CAVEMAN MODE (BASIC) IS ACTIVE: Be extremely concise. No pleasantries, no fluff. Use short sentences. Get straight to the point."
|
|
909
|
+
elif self.caveman_mode == CavemanMode.ULTRA: caveman_rules = "\n9. CAVEMAN MODE (ULTRA) IS ACTIVE: MAXIMUM TOKEN SAVING. Output ONLY code or absolute minimum words. No explanations. No greetings. Grunt-like brevity."
|
|
910
|
+
system_msg = f"""You are SamCode CLI, an expert autonomous AI coding agent.
|
|
911
|
+
You are currently working in the directory: {self.workspace_dir}
|
|
912
|
+
|
|
913
|
+
Here is the current project structure:
|
|
914
|
+
{workspace_tree}
|
|
915
|
+
{context_str}
|
|
916
|
+
You have access to the following tools to help you complete tasks:
|
|
917
|
+
[READ_FILE: <path>]
|
|
918
|
+
[WRITE_FILE: <path>]
|
|
919
|
+
<full content of the file here>
|
|
920
|
+
[END_WRITE_FILE]
|
|
921
|
+
[RUN_TERMINAL: <command>]
|
|
922
|
+
[SEARCH_CODE: <query>]
|
|
923
|
+
|
|
924
|
+
CRITICAL RULES:
|
|
925
|
+
1. You have full access to the workspace. NEVER say you cannot access files.
|
|
926
|
+
2. DO NOT use tools unless the user explicitly asks you to modify, create, or analyze specific files/code, or if you absolutely need to read a file to answer a technical question.
|
|
927
|
+
3. If the user greets you, asks a general question, or gives a simple instruction that doesn't require file access, respond directly with text ONLY.
|
|
928
|
+
4. If you MUST use a tool, output the tool call clearly. You may include brief reasoning before the tool call, but ensure the tool syntax is exact.
|
|
929
|
+
5. If you need to see a file's content, use [READ_FILE: <path>].
|
|
930
|
+
6. If you need to create or modify a file, use [WRITE_FILE: <path>] followed by the COMPLETE file content and [END_WRITE_FILE].
|
|
931
|
+
7. If you need to run a terminal command, use [RUN_TERMINAL: <command>].
|
|
932
|
+
8. Once you have completed the task, provide your final answer to the user WITHOUT using any tools.{caveman_rules}"""
|
|
933
|
+
messages = [{"role": "system", "content": system_msg}, {"role": "user", "content": question}]
|
|
934
|
+
max_iterations = 20
|
|
935
|
+
console.print(f"\n[bold cyan]🤖 Agent Activated[/bold cyan]")
|
|
936
|
+
for i in range(max_iterations):
|
|
937
|
+
console.print(f"\n[dim]--- Agent Step {i+1} ---[/dim]")
|
|
938
|
+
console.print("[dim]🤖 Thinking...[/dim]")
|
|
939
|
+
try:
|
|
940
|
+
response = client.chat(messages, stream=True)
|
|
941
|
+
if not response or response.startswith("Error"): console.print(f"\n[red]{response}[/red]"); break
|
|
942
|
+
write_match = re.search(r'\[WRITE_FILE:\s*(.*?)\](.*?)\[END_WRITE_FILE\]', response, re.DOTALL)
|
|
943
|
+
single_match = re.search(r'\[(READ_FILE|RUN_TERMINAL|SEARCH_CODE):\s*(.*?)\]', response)
|
|
944
|
+
tool_executed = False
|
|
945
|
+
if write_match:
|
|
946
|
+
path = write_match.group(1).strip(); content = write_match.group(2).strip()
|
|
947
|
+
full_path = os.path.join(self.workspace_dir, path); ext = Path(path).suffix.lstrip('.') or 'text'
|
|
948
|
+
if os.path.exists(full_path):
|
|
949
|
+
old_content = self.file_manager.read_file(path) or ""
|
|
950
|
+
left_panel = Panel(Syntax(old_content, ext, theme="monokai", line_numbers=True), title="[bold red]Original[/bold red]", border_style="red")
|
|
951
|
+
right_panel = Panel(Syntax(content, ext, theme="monokai", line_numbers=True), title="[bold green]Proposed[/bold green]", border_style="green")
|
|
952
|
+
console.print("\n"); console.print(Columns([left_panel, right_panel], equal=True, expand=True))
|
|
953
|
+
if Confirm.ask(f"\n[bold]Apply changes to {path}?[/bold]", default=True):
|
|
954
|
+
self.file_manager.write_file(path, content); tool_result = f"Successfully updated {path}."
|
|
955
|
+
console.print(f"[green]✓ File updated: {path}[/green]")
|
|
956
|
+
tool_result += f"\n\n[SYSTEM INSTRUCTION - SELF REVIEW]: You must now review the file you just modified. Use [READ_FILE: {path}] to check your work. If you find any syntax errors, logical bugs, or missing requirements, fix them immediately using [WRITE_FILE]. Only stop when the code is flawless."
|
|
957
|
+
else: tool_result = f"User rejected the changes to {path}."; console.print(f"[yellow]✗ Changes rejected.[/yellow]")
|
|
958
|
+
else:
|
|
959
|
+
new_file_panel = Panel(Syntax(content, ext, theme="monokai", line_numbers=True), title=f"[bold green]✨ Creating New File: {path}[/bold green]", border_style="green")
|
|
960
|
+
console.print("\n"); console.print(new_file_panel)
|
|
961
|
+
self.file_manager.write_file(path, content); tool_result = f"Successfully created {path}."
|
|
962
|
+
console.print(f"[green]✓ File created: {path}[/green]")
|
|
963
|
+
tool_result += f"\n\n[SYSTEM INSTRUCTION - SELF REVIEW]: You must now review the file you just created. Use [READ_FILE: {path}] to check your work. If you find any syntax errors, logical bugs, or missing requirements, fix them immediately using [WRITE_FILE]. Only stop when the code is flawless."
|
|
964
|
+
tool_executed = True
|
|
965
|
+
elif single_match:
|
|
966
|
+
tool_name = single_match.group(1); tool_arg = single_match.group(2).strip()
|
|
967
|
+
if tool_name == "READ_FILE":
|
|
968
|
+
content = self.file_manager.read_file(tool_arg)
|
|
969
|
+
tool_result = f"Content of {tool_arg}:\n{content}" if content else f"Error: File '{tool_arg}' not found."
|
|
970
|
+
console.print(f"\n[blue]📖 Read file: {tool_arg}[/blue]"); tool_executed = True
|
|
971
|
+
elif tool_name == "SEARCH_CODE":
|
|
972
|
+
results = self.file_manager.search_in_files(tool_arg)
|
|
973
|
+
if not results: tool_result = "No matches found."
|
|
974
|
+
else: tool_result = "\n".join([f"{r['file']}: {' | '.join([m[1] for m in r['matches']])}" for r in results])
|
|
975
|
+
console.print(f"\n[blue]🔍 Searched code for: {tool_arg}[/blue]"); tool_executed = True
|
|
976
|
+
elif tool_name == "RUN_TERMINAL":
|
|
977
|
+
console.print(Panel(f"[yellow]{tool_arg}[/yellow]", title="[bold orange3]⚡ Terminal Command Request[/bold orange3]", border_style="orange3"))
|
|
978
|
+
if Confirm.ask("Execute this command?", default=True):
|
|
979
|
+
success, output = self.command_runner.run(tool_arg)
|
|
980
|
+
tool_result = f"Command output:\n{output}"
|
|
981
|
+
console.print(Panel(output[:2000], title="Command Output", border_style="green" if success else "red"))
|
|
982
|
+
if not success or "[STDERR]" in output: tool_result += "\n\n[SYSTEM INSTRUCTION - AUTO-FIX]: This command FAILED. You MUST analyze the error output above and use [WRITE_FILE] or [RUN_TERMINAL] to fix the issue immediately. Do not ask the user for help, fix it yourself."
|
|
983
|
+
else: tool_result = "User rejected the command."; console.print("[yellow]✗ Command rejected.[/yellow]")
|
|
984
|
+
tool_executed = True
|
|
985
|
+
if tool_executed:
|
|
986
|
+
messages.append({"role": "assistant", "content": response})
|
|
987
|
+
messages.append({"role": "user", "content": f"Tool result:\n{tool_result}\n\nContinue your task."})
|
|
988
|
+
else: console.print(f"\n[bold green]✓ Task completed.[/bold green]"); break
|
|
989
|
+
except Exception as e: console.print(f"\n[red]Fatal Error in agent loop: {e}[/red]"); break
|
|
990
|
+
else: console.print("\n[yellow]⚠️ Agent reached maximum iterations (20).[/yellow]")
|
|
991
|
+
|
|
992
|
+
def cmd_help(self):
|
|
993
|
+
console.print("\n[bold cyan]📚 SamCode CLI Commands[/bold cyan]\n")
|
|
994
|
+
commands = {"/connect": "Configure AI provider and API key", "/models": "Select AI model dynamically", "/upload <path>": "Upload & extract documents (PDF, DOCX, XLSX, PPTX, Images)", "/clear-uploads": "Clear uploaded documents from session context", "/searchweb <query>": "Search the web (opens browser) & get AI-synthesized answer", "/git": "Native Git operations (commit, push, pull, branch, etc.)", "/caveman": "Cycle token-saving modes (OFF ➔ BASIC ➔ ULTRA)", "/aboutme": "About the developer and SamCode", "/clear": "Clear the screen", "/exit": "Exit SamCode CLI"}
|
|
995
|
+
for cmd, desc in commands.items(): console.print(f" [cyan]{cmd:<20}[/cyan] {desc}")
|
|
996
|
+
console.print("\n[dim]Just type your request naturally to activate the autonomous agent![/dim]\n")
|
|
997
|
+
|
|
998
|
+
def run(self):
|
|
999
|
+
self.show_main_header()
|
|
1000
|
+
if not self.api_key:
|
|
1001
|
+
console.print("[yellow]⚠️ No API key configured. Use /connect to set up.[/yellow]\n")
|
|
1002
|
+
if Confirm.ask("Configure now?", default=True): self.cmd_connect()
|
|
1003
|
+
self.show_main_header()
|
|
1004
|
+
while True:
|
|
1005
|
+
try:
|
|
1006
|
+
# FIXED: Using editing_mode=EditingMode.EMACS for standard shortcuts + custom kb
|
|
1007
|
+
user_input = pt_prompt(
|
|
1008
|
+
self.prompt_text,
|
|
1009
|
+
completer=self.command_completer,
|
|
1010
|
+
style=menu_style,
|
|
1011
|
+
key_bindings=kb,
|
|
1012
|
+
editing_mode=EditingMode.EMACS
|
|
1013
|
+
).strip()
|
|
1014
|
+
if not user_input: continue
|
|
1015
|
+
if user_input.lower() in ["/exit", "/quit", "exit"]: console.print("\n[yellow]Goodbye![/yellow]\n"); break
|
|
1016
|
+
elif user_input.lower() in ["/help", "/h"]: self.cmd_help()
|
|
1017
|
+
elif user_input.lower() in ["/clear", "/cls"]: self.show_main_header()
|
|
1018
|
+
elif user_input.lower() in ["/connect", "/config"]: self.cmd_connect(); self.show_main_header()
|
|
1019
|
+
elif user_input.lower() in ["/models", "/model"]: self.cmd_models()
|
|
1020
|
+
elif user_input.lower() in ["/caveman"]: self.cmd_caveman()
|
|
1021
|
+
elif user_input.lower() in ["/aboutme", "/about"]: self.cmd_aboutme()
|
|
1022
|
+
elif user_input.lower() in ["/git", "/g"]: self.cmd_git()
|
|
1023
|
+
elif user_input.lower().startswith("/searchweb"):
|
|
1024
|
+
parts = user_input.split(maxsplit=1); query = parts[1] if len(parts) > 1 else ""; self.cmd_search_web(query)
|
|
1025
|
+
elif user_input.lower().startswith("/upload"):
|
|
1026
|
+
parts = user_input.split(maxsplit=1); path = parts[1] if len(parts) > 1 else ""; self.cmd_upload(path)
|
|
1027
|
+
elif user_input.lower() in ["/clear-uploads", "/clear-up"]: self.cmd_clear_uploads()
|
|
1028
|
+
else: self.cmd_agent_ask(user_input)
|
|
1029
|
+
except KeyboardInterrupt: console.print("\n[dim](Use /exit to quit)[/dim]")
|
|
1030
|
+
except EOFError: break
|
|
1031
|
+
|
|
1032
|
+
def main():
|
|
1033
|
+
try:
|
|
1034
|
+
agent = SamCodeCLI()
|
|
1035
|
+
agent.run()
|
|
1036
|
+
except Exception as e:
|
|
1037
|
+
console.print(f"[red]Fatal error: {e}[/red]")
|
|
1038
|
+
sys.exit(1)
|
|
1039
|
+
|
|
1040
|
+
if __name__ == "__main__":
|
|
1041
|
+
main()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: samcode-cli
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: An autonomous AI coding agent that runs in your terminal.
|
|
5
|
+
Author: Magra Houssem Eddine
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: rich
|
|
8
|
+
Requires-Dist: questionary
|
|
9
|
+
Requires-Dist: prompt_toolkit
|
|
10
|
+
Requires-Dist: requests
|
|
11
|
+
Requires-Dist: beautifulsoup4
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: description
|
|
14
|
+
Dynamic: description-content-type
|
|
15
|
+
Dynamic: requires-dist
|
|
16
|
+
Dynamic: summary
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# SamCode CLI
|
|
20
|
+
|
|
21
|
+
[](https://pypi.org/project/samcode-cli/)
|
|
22
|
+
[](https://pypi.org/project/samcode-cli/)
|
|
23
|
+
[](https://opensource.org/licenses/MIT)
|
|
24
|
+
|
|
25
|
+
**SamCode CLI** is a powerful, autonomous AI coding agent that runs directly in your terminal. Similar to Claude Code and GitHub Copilot Workspace, it reads your project structure, writes and edits code, executes terminal commands, and fixes its own errors—all while keeping your workflow secure and efficient.
|
|
26
|
+
|
|
27
|
+
## ✨ Key Features
|
|
28
|
+
|
|
29
|
+
- 🤖 **Autonomous Agentic Loop**: The agent thinks in steps. It reads files, writes code, runs commands, and automatically fixes terminal errors or reviews its own code for bugs.
|
|
30
|
+
- 🌐 **Universal AI Support**: Connect to 15+ providers (OpenAI, Anthropic, Google Gemini, Ollama, Groq, etc.) with dynamic model fetching.
|
|
31
|
+
- 📄 **Universal Document Reader**: Upload and analyze PDFs, Word docs, Excel sheets, PowerPoint presentations, and images directly in the chat.
|
|
32
|
+
- 🔍 **Live Web Search**: Use `/searchweb` to open your browser and let the AI synthesize answers from live search results.
|
|
33
|
+
- **Project Scaffolding**: Use `/init` to instantly scaffold projects for React, Next.js, Django, Spring Boot, Flutter, Rust, and more.
|
|
34
|
+
- 🌿 **Native Git Integration**: A full interactive Git workflow (`/git`) to commit, push, pull, branch, and stash without leaving the agent.
|
|
35
|
+
- 📦 **Auto Dependency Installation**: Automatically detects missing packages and installs them with your approval.
|
|
36
|
+
- 🔒 **Path Sandboxing**: The agent is restricted to your workspace and asks for explicit permission before accessing files outside the current directory.
|
|
37
|
+
- ⌨️ **Advanced Shortcuts**: Emacs-style navigation (Ctrl+A, Ctrl+W, Home/End) in the prompt for fast typing.
|
|
38
|
+
- **Caveman Mode**: Save tokens with ultra-concise, grunt-like AI responses.
|
|
39
|
+
|
|
40
|
+
## 🚀 Installation
|
|
41
|
+
|
|
42
|
+
Install SamCode CLI globally from PyPI using pip:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install samcode-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Commands Reference
|
|
49
|
+
|
|
50
|
+
### 🤖 Core & AI
|
|
51
|
+
|
|
52
|
+
| **Command** | **Description** |
|
|
53
|
+
| ----------------- | ------------------------------------------------------------------- |
|
|
54
|
+
| `/connect` | **Configure AI provider and API key interactively.** |
|
|
55
|
+
| `/models` | **Dynamically fetch and select models from your provider.** |
|
|
56
|
+
| `/caveman` | **Cycle through token-saving modes (OFF ➔ BASIC ➔ ULTRA).** |
|
|
57
|
+
| `/deps` | **Toggle auto-approve for dependency installations.** |
|
|
58
|
+
| `/aboutme` | **Information about the developer and SamCode.** |
|
|
59
|
+
|
|
60
|
+
### 📂 Files & Documents
|
|
61
|
+
|
|
62
|
+
| **Command** | **Description** |
|
|
63
|
+
| --------------------- | --------------------------------------------------------------------- |
|
|
64
|
+
| `/upload <path>` | **Upload & extract documents (PDF, DOCX, XLSX, PPTX, Images).** |
|
|
65
|
+
| `/clear-uploads` | **Clear uploaded documents from the session context.** |
|
|
66
|
+
| `/read-file <path>` | **Read any file using the universal document reader.** |
|
|
67
|
+
|
|
68
|
+
### 🌐 Web & Scaffolding
|
|
69
|
+
|
|
70
|
+
| **Command** | **Description** |
|
|
71
|
+
| ---------------------- | ------------------------------------------------------------------------ |
|
|
72
|
+
| `/searchweb <query>` | **Search the web (opens browser) & get an AI-synthesized answer.** |
|
|
73
|
+
| `/init <name>` | **Scaffold a new project (React, Django, Spring, Flutter, etc.).** |
|
|
74
|
+
|
|
75
|
+
### 🌿 Git Operations
|
|
76
|
+
|
|
77
|
+
| **Command** | **Description** |
|
|
78
|
+
| ----------------- | -------------------------------------------------------------------------------------------- |
|
|
79
|
+
| `/git` | **Open the interactive Git menu (Status, Commit, Push, Pull, Branches, Diff, Stash).** |
|
|
80
|
+
|
|
81
|
+
### ⚙️ System
|
|
82
|
+
|
|
83
|
+
| **Command** | **Description** |
|
|
84
|
+
| ----------------- | -------------------------------------- |
|
|
85
|
+
| `/help` | **Show all available commands.** |
|
|
86
|
+
| `/clear` | **Clear the terminal screen.** |
|
|
87
|
+
| `/exit` | **Exit SamCode CLI.** |
|
|
88
|
+
|
|
89
|
+
## ⌨️ Keyboard Shortcuts
|
|
90
|
+
|
|
91
|
+
**SamCode CLI uses **`prompt_toolkit` to provide a rich input experience. The following shortcuts work directly in the prompt:
|
|
92
|
+
|
|
93
|
+
* **Ctrl + A** **: Select all text**
|
|
94
|
+
* **Ctrl + W** **: Delete word backward**
|
|
95
|
+
* **Ctrl + U** **: Clear line backward**
|
|
96
|
+
* **Ctrl + K** **: Delete to end of line**
|
|
97
|
+
* **Home / End** **: Jump to start/end of line**
|
|
98
|
+
* **Shift + Home/End** **: Select to start/end of line**
|
|
99
|
+
* **Ctrl + Left/Right** **: Jump by word**
|
|
100
|
+
|
|
101
|
+
## 🛡️ Safety & Security
|
|
102
|
+
|
|
103
|
+
* **Path Sandboxing** **: By default, the agent cannot read, write, or execute commands outside the directory where you launched **`samcode`. If it needs to, it will pause and ask for your explicit permission.
|
|
104
|
+
* **User Approval** **: All file modifications, terminal commands, and dependency installations require your confirmation before execution (unless auto-approve is enabled via **`/deps`).
|
|
105
|
+
* **Self-Review** **: After writing code, the agent automatically reviews its own work to catch syntax errors or logical bugs before moving on.**
|
|
106
|
+
|
|
107
|
+
## Requirements
|
|
108
|
+
|
|
109
|
+
* **Python 3.8 or higher**
|
|
110
|
+
* **An API key for your chosen AI provider (OpenAI, Anthropic, Google, etc.)**
|
|
111
|
+
* **Internet connection (except when using local Ollama models)**
|
|
112
|
+
|
|
113
|
+
## 📝 License
|
|
114
|
+
|
|
115
|
+
**This project is licensed under the MIT License. See the **[LICENSE]() file for details.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
*Developed by Magra Houssem Eddine*
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
samcode.py,sha256=gaWxvW_FSYVkB3uOcIRdi09iWYxZnH1cG6N7v5DagQg,69136
|
|
2
|
+
samcode_cli-1.0.2.dist-info/METADATA,sha256=R9B67J-JjuG_LsoqzA8ssWkGzQToyjgPaBEeclQpMhw,6269
|
|
3
|
+
samcode_cli-1.0.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
4
|
+
samcode_cli-1.0.2.dist-info/entry_points.txt,sha256=pTSNSMG9LYqLdbOR0TZGIQS0I17ZEUdXLEapLCYmyqo,41
|
|
5
|
+
samcode_cli-1.0.2.dist-info/top_level.txt,sha256=ie3RFdU_m6daHft-jFl_UKNkKAA25CItx4-gyRRHbJY,8
|
|
6
|
+
samcode_cli-1.0.2.dist-info/RECORD,,
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: samcode-cli
|
|
3
|
-
Version: 1.0.0
|
|
4
|
-
Summary: SamCode CLI - Autonomous Coding Agent
|
|
5
|
-
Author: Magra Houssem Eddine
|
|
6
|
-
Requires-Dist: rich
|
|
7
|
-
Requires-Dist: questionary
|
|
8
|
-
Requires-Dist: prompt_toolkit
|
|
9
|
-
Requires-Dist: requests
|
|
10
|
-
Requires-Dist: beautifulsoup4
|
|
11
|
-
Dynamic: author
|
|
12
|
-
Dynamic: requires-dist
|
|
13
|
-
Dynamic: summary
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
samcode_cli-1.0.0.dist-info/METADATA,sha256=YNuqqmF_PzG9yb3oe9_zpO2IX3QkiE-NR2OF6kjrfpA,331
|
|
2
|
-
samcode_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
3
|
-
samcode_cli-1.0.0.dist-info/entry_points.txt,sha256=pTSNSMG9LYqLdbOR0TZGIQS0I17ZEUdXLEapLCYmyqo,41
|
|
4
|
-
samcode_cli-1.0.0.dist-info/top_level.txt,sha256=ie3RFdU_m6daHft-jFl_UKNkKAA25CItx4-gyRRHbJY,8
|
|
5
|
-
samcode_cli-1.0.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|