samcode-cli 1.0.1__tar.gz → 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ global-exclude .samcode/*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: samcode-cli
3
- Version: 1.0.1
3
+ Version: 1.0.3
4
4
  Summary: An autonomous AI coding agent that runs in your terminal.
5
5
  Author: Magra Houssem Eddine
6
6
  Description-Content-Type: text/markdown
@@ -0,0 +1,1060 @@
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
+
734
+ # --- AUTOMATIC GITIGNORE PROTECTION ---
735
+ gitignore_path = os.path.join(self.workspace_dir, ".gitignore")
736
+ ignore_entry = "\n.samcode/\n"
737
+
738
+ if os.path.exists(gitignore_path):
739
+ with open(gitignore_path, "r") as f:
740
+ content = f.read()
741
+ if ".samcode/" not in content:
742
+ with open(gitignore_path, "a") as f:
743
+ f.write(ignore_entry)
744
+ console.print("[green]✓ Automatically added .samcode/ to existing .gitignore[/green]\n")
745
+ else:
746
+ with open(gitignore_path, "w") as f:
747
+ f.write("# Automatically generated by SamCode CLI to protect API keys\n")
748
+ f.write(ignore_entry)
749
+ console.print("[green]✓ Automatically created .gitignore to protect .samcode/ folder[/green]\n")
750
+ # ----------------------------------------
751
+
752
+ try:
753
+ user_check = subprocess.run(["git", "config", "user.name"], cwd=self.workspace_dir, capture_output=True, text=True, timeout=5)
754
+ if not user_check.stdout.strip():
755
+ console.print("[yellow]Git user not configured. Let's set it up:[/yellow]\n")
756
+ user_name = Prompt.ask("[cyan]Your name[/cyan]"); user_email = Prompt.ask("[cyan]Your email[/cyan]")
757
+ subprocess.run(["git", "config", "user.name", user_name], cwd=self.workspace_dir, timeout=5)
758
+ subprocess.run(["git", "config", "user.email", user_email], cwd=self.workspace_dir, timeout=5)
759
+ console.print(f"[green]✓ Git user set to: {user_name} <{user_email}>[/green]\n")
760
+ except Exception: pass
761
+ console.print("[dim]You'll need a GitHub repository URL. Format: https://github.com/username/repo.git[/dim]\n")
762
+ repo_url = Prompt.ask("[cyan]Enter GitHub repository URL[/cyan]").strip()
763
+ if not repo_url: console.print("[yellow]Repository URL is required. Aborting.[/yellow]\n"); return
764
+ success, msg = self.git_manager.add_remote(repo_url)
765
+ if not success: console.print(f"[red]✗ {msg}[/red]\n"); return
766
+ console.print(f"[green]✓ {msg}[/green]\n")
767
+ console.print("[cyan]📦 Staging all files...[/cyan]")
768
+ success, msg = self.git_manager.add_all()
769
+ if not success: console.print(f"[red]✗ {msg}[/red]\n"); return
770
+ console.print(f"[green]✓ {msg}[/green]\n")
771
+ commit_msg = Prompt.ask("[cyan]Initial commit message[/cyan]", default="Initial commit")
772
+ success, msg = self.git_manager.commit(commit_msg)
773
+ if not success: console.print(f"[red]✗ {msg}[/red]\n"); return
774
+ console.print(f"[green]✓ Committed: {msg}[/green]\n")
775
+ console.print("[cyan]⬆️ Pushing to GitHub...[/cyan]")
776
+ if Confirm.ask("Push to remote now?", default=True):
777
+ success, msg = self.git_manager.push(force=True)
778
+ if success: console.print(f"[green]✓ Successfully pushed to GitHub![/green]\n"); console.print(f"[dim]Repository: {repo_url}[/dim]\n")
779
+ 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")
780
+ else: console.print("[yellow]Skipped push. Run /git → Push when ready.[/yellow]\n")
781
+
782
+ def _git_show_status(self, status: Dict):
783
+ console.print(f"\n[bold cyan]📊 Git Status - Branch: {status.get('branch', 'unknown')}[/bold cyan]\n")
784
+ if not status.get("has_changes"): console.print("[green]✓ Working tree clean. No changes to commit.[/green]\n"); return
785
+ if status.get("staged"):
786
+ console.print("[bold green]Staged (ready to commit):[/bold green]")
787
+ for item in status["staged"]: console.print(f" {item['file']} ({item['status']})")
788
+ console.print()
789
+ if status.get("unstaged"):
790
+ console.print("[bold yellow]Modified (not staged):[/bold yellow]")
791
+ for item in status["unstaged"]: console.print(f" {item['file']} ({item['status']})")
792
+ console.print()
793
+ if status.get("untracked"):
794
+ console.print("[bold magenta]Untracked (new files):[/bold magenta]")
795
+ for f in status["untracked"]: console.print(f" {f}")
796
+ console.print()
797
+
798
+ def _git_commit(self):
799
+ console.print("\n[bold cyan]📝 Commit Changes[/bold cyan]\n")
800
+ status = self.git_manager.get_status()
801
+ if "error" in status: console.print(f"[red]{status['error']}[/red]\n"); return
802
+ total_changes = len(status.get("staged", [])) + len(status.get("unstaged", [])) + len(status.get("untracked", []))
803
+ if total_changes == 0: console.print("[yellow]No changes to commit.[/yellow]\n"); return
804
+ console.print(f"Found {total_changes} change(s).")
805
+ if Confirm.ask("Stage all changes (git add -A)?", default=True):
806
+ success, msg = self.git_manager.add_all()
807
+ if not success: console.print(f"[red]✗ {msg}[/red]\n"); return
808
+ commit_msg = Prompt.ask("[cyan]Commit message[/cyan]")
809
+ if not commit_msg: console.print("[yellow]Commit message is required.[/yellow]\n"); return
810
+ success, msg = self.git_manager.commit(commit_msg)
811
+ if success: console.print(f"\n[green]✓ Committed successfully[/green]\n"); console.print(f"[dim]{msg}[/dim]\n")
812
+ else: console.print(f"\n[red]✗ {msg}[/red]\n")
813
+
814
+ def _git_push(self):
815
+ console.print("\n[bold cyan]⬆️ Push to Remote[/bold cyan]\n")
816
+ remote = self.git_manager.get_remote_url()
817
+ if not remote: console.print("[yellow]No remote configured. Use 'Change Remote URL' first.[/yellow]\n"); return
818
+ branch = self.git_manager.get_current_branch()
819
+ console.print(f"[dim]Branch: {branch} | Remote: {remote}[/dim]\n")
820
+ force = Confirm.ask("Force push? (⚠️ Overwrites remote history)", default=False)
821
+ console.print("[cyan]Pushing...[/cyan]")
822
+ success, msg = self.git_manager.push(branch, force)
823
+ if success: console.print(f"\n[green]✓ Successfully pushed to {remote}[/green]\n")
824
+ else: console.print(f"\n[red]✗ Push failed[/red]\n[dim]{msg}[/dim]\n")
825
+
826
+ def _git_pull(self):
827
+ console.print("\n[bold cyan]⬇️ Pull from Remote[/bold cyan]\n")
828
+ remote = self.git_manager.get_remote_url()
829
+ if not remote: console.print("[yellow]No remote configured.[/yellow]\n"); return
830
+ console.print("[cyan]Pulling latest changes...[/cyan]")
831
+ success, msg = self.git_manager.pull()
832
+ if success: console.print(f"\n[green]✓ Pull successful[/green]\n[dim]{msg}[/dim]\n")
833
+ else: console.print(f"\n[red]✗ Pull failed[/red]\n[dim]{msg}[/dim]\n")
834
+
835
+ def _git_branch_menu(self):
836
+ console.print("\n[bold cyan]🌿 Branch Operations[/bold cyan]\n")
837
+ branches = self.git_manager.get_branches(); current = self.git_manager.get_current_branch()
838
+ console.print(f"[dim]Current branch: [bold green]{current}[/bold green][/dim]\n")
839
+ console.print("[bold]Available branches:[/bold]")
840
+ for b in branches: console.print(f" {b}{' ← current' if b == current else ''}")
841
+ console.print()
842
+ actions = [Choice("Create new branch", "create"), Choice("Switch branch", "switch"), Choice("Delete branch", "delete")]
843
+ action = questionary.select("Branch action:", choices=actions).ask()
844
+ if not action: return
845
+ if action == "create":
846
+ name = Prompt.ask("[cyan]New branch name[/cyan]").strip()
847
+ if name:
848
+ success, msg = self.git_manager.create_branch(name)
849
+ if success: console.print(f"\n[green]✓ {msg}[/green]\n")
850
+ else: console.print(f"\n[red]✗ {msg}[/red]\n")
851
+ elif action == "switch":
852
+ name = Prompt.ask("[cyan]Branch to switch to[/cyan]", choices=branches if branches else None).strip()
853
+ if name:
854
+ success, msg = self.git_manager.switch_branch(name)
855
+ if success: console.print(f"\n[green]✓ {msg}[/green]\n")
856
+ else: console.print(f"\n[red]✗ {msg}[/red]\n")
857
+ elif action == "delete":
858
+ name = Prompt.ask("[cyan]Branch to delete[/cyan]").strip()
859
+ if name and name != current:
860
+ if Confirm.ask(f"Delete branch '{name}'?", default=False):
861
+ try:
862
+ result = subprocess.run(["git", "branch", "-D", name], cwd=self.workspace_dir, capture_output=True, text=True, timeout=10)
863
+ if result.returncode == 0: console.print(f"\n[green]✓ Deleted branch '{name}'[/green]\n")
864
+ else: console.print(f"\n[red]✗ {result.stderr.strip()}[/red]\n")
865
+ except Exception as e: console.print(f"\n[red]Error: {e}[/red]\n")
866
+ else: console.print("[yellow]Cannot delete the current branch.[/yellow]\n")
867
+
868
+ def _git_log(self):
869
+ console.print("\n[bold cyan]📜 Recent Commits[/bold cyan]\n")
870
+ log = self.git_manager.get_log(15)
871
+ console.print(Panel(f"[dim]{log}[/dim]", title="Git Log", border_style="cyan")); console.print()
872
+
873
+ def _git_diff(self):
874
+ console.print("\n[bold cyan]🔍 View Diff[/bold cyan]\n")
875
+ diff_type = questionary.select("Show diff for:", choices=[Choice("Unstaged changes", "unstaged"), Choice("Staged changes", "staged")]).ask()
876
+ if not diff_type: return
877
+ staged = diff_type == "staged"; diff = self.git_manager.get_diff(staged)
878
+ if diff and diff != "No changes to show":
879
+ syntax = Syntax(diff, "diff", theme="monokai", line_numbers=True)
880
+ title = "Staged Changes" if staged else "Unstaged Changes"
881
+ console.print(Panel(syntax, title=f"[bold cyan]{title}[/bold cyan]", border_style="cyan"))
882
+ else: console.print("[yellow]No changes to show.[/yellow]")
883
+ console.print()
884
+
885
+ def _git_stash_menu(self):
886
+ console.print("\n[bold cyan]📦 Stash Operations[/bold cyan]\n")
887
+ actions = [Choice("Stash current changes", "stash"), Choice("Pop latest stash", "pop")]
888
+ action = questionary.select("Stash action:", choices=actions).ask()
889
+ if not action: return
890
+ if action == "stash":
891
+ success, msg = self.git_manager.stash()
892
+ if success: console.print(f"\n[green]✓ {msg}[/green]\n")
893
+ else: console.print(f"\n[red]✗ {msg}[/red]\n")
894
+ elif action == "pop":
895
+ success, msg = self.git_manager.stash_pop()
896
+ if success: console.print(f"\n[green]✓ {msg}[/green]\n")
897
+ else: console.print(f"\n[red]✗ {msg}[/red]\n")
898
+
899
+ def _git_change_remote(self):
900
+ console.print("\n[bold cyan]🔗 Change Remote URL[/bold cyan]\n")
901
+ current = self.git_manager.get_remote_url()
902
+ if current: console.print(f"[dim]Current remote: {current}[/dim]\n")
903
+ new_url = Prompt.ask("[cyan]New remote URL[/cyan]").strip()
904
+ if new_url:
905
+ success, msg = self.git_manager.add_remote(new_url)
906
+ if success: console.print(f"\n[green]✓ {msg}[/green]\n")
907
+ else: console.print(f"\n[red]✗ {msg}[/red]\n")
908
+
909
+ def _git_check_auth(self):
910
+ console.print("\n[bold cyan]🔑 GitHub Authentication Status[/bold cyan]\n")
911
+ auth = self.git_manager.check_github_auth()
912
+ if auth["logged_in"]: console.print(f"[green]✓ Logged in via {auth['method']}[/green]\n[dim]User: {auth['user']}[/dim]\n")
913
+ else:
914
+ console.print("[red]✗ Not logged into GitHub[/red]\n")
915
+ 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")
916
+
917
+ def cmd_agent_ask(self, question: str):
918
+ client = self.get_client()
919
+ if not client or not self.api_key: console.print("[red]Configure AI first with /connect[/red]"); return
920
+ files = self.file_manager.scan_workspace()
921
+ workspace_tree = "\n".join(files) if files else "(Empty workspace)"
922
+ context_str = ""
923
+ if self.session_context:
924
+ context_str = "\n\nUPLOADED DOCUMENTS CONTEXT (The user has uploaded these files, you can reference them directly):\n"
925
+ for doc in self.session_context: context_str += f"=== {doc['filename']} ===\n{doc['content']}\n\n"
926
+ caveman_rules = ""
927
+ 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."
928
+ 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."
929
+ system_msg = f"""You are SamCode CLI, an expert autonomous AI coding agent.
930
+ You are currently working in the directory: {self.workspace_dir}
931
+
932
+ Here is the current project structure:
933
+ {workspace_tree}
934
+ {context_str}
935
+ You have access to the following tools to help you complete tasks:
936
+ [READ_FILE: <path>]
937
+ [WRITE_FILE: <path>]
938
+ <full content of the file here>
939
+ [END_WRITE_FILE]
940
+ [RUN_TERMINAL: <command>]
941
+ [SEARCH_CODE: <query>]
942
+
943
+ CRITICAL RULES:
944
+ 1. You have full access to the workspace. NEVER say you cannot access files.
945
+ 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.
946
+ 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.
947
+ 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.
948
+ 5. If you need to see a file's content, use [READ_FILE: <path>].
949
+ 6. If you need to create or modify a file, use [WRITE_FILE: <path>] followed by the COMPLETE file content and [END_WRITE_FILE].
950
+ 7. If you need to run a terminal command, use [RUN_TERMINAL: <command>].
951
+ 8. Once you have completed the task, provide your final answer to the user WITHOUT using any tools.{caveman_rules}"""
952
+ messages = [{"role": "system", "content": system_msg}, {"role": "user", "content": question}]
953
+ max_iterations = 20
954
+ console.print(f"\n[bold cyan]🤖 Agent Activated[/bold cyan]")
955
+ for i in range(max_iterations):
956
+ console.print(f"\n[dim]--- Agent Step {i+1} ---[/dim]")
957
+ console.print("[dim]🤖 Thinking...[/dim]")
958
+ try:
959
+ response = client.chat(messages, stream=True)
960
+ if not response or response.startswith("Error"): console.print(f"\n[red]{response}[/red]"); break
961
+ write_match = re.search(r'\[WRITE_FILE:\s*(.*?)\](.*?)\[END_WRITE_FILE\]', response, re.DOTALL)
962
+ single_match = re.search(r'\[(READ_FILE|RUN_TERMINAL|SEARCH_CODE):\s*(.*?)\]', response)
963
+ tool_executed = False
964
+ if write_match:
965
+ path = write_match.group(1).strip(); content = write_match.group(2).strip()
966
+ full_path = os.path.join(self.workspace_dir, path); ext = Path(path).suffix.lstrip('.') or 'text'
967
+ if os.path.exists(full_path):
968
+ old_content = self.file_manager.read_file(path) or ""
969
+ left_panel = Panel(Syntax(old_content, ext, theme="monokai", line_numbers=True), title="[bold red]Original[/bold red]", border_style="red")
970
+ right_panel = Panel(Syntax(content, ext, theme="monokai", line_numbers=True), title="[bold green]Proposed[/bold green]", border_style="green")
971
+ console.print("\n"); console.print(Columns([left_panel, right_panel], equal=True, expand=True))
972
+ if Confirm.ask(f"\n[bold]Apply changes to {path}?[/bold]", default=True):
973
+ self.file_manager.write_file(path, content); tool_result = f"Successfully updated {path}."
974
+ console.print(f"[green]✓ File updated: {path}[/green]")
975
+ 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."
976
+ else: tool_result = f"User rejected the changes to {path}."; console.print(f"[yellow]✗ Changes rejected.[/yellow]")
977
+ else:
978
+ 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")
979
+ console.print("\n"); console.print(new_file_panel)
980
+ self.file_manager.write_file(path, content); tool_result = f"Successfully created {path}."
981
+ console.print(f"[green]✓ File created: {path}[/green]")
982
+ 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."
983
+ tool_executed = True
984
+ elif single_match:
985
+ tool_name = single_match.group(1); tool_arg = single_match.group(2).strip()
986
+ if tool_name == "READ_FILE":
987
+ content = self.file_manager.read_file(tool_arg)
988
+ tool_result = f"Content of {tool_arg}:\n{content}" if content else f"Error: File '{tool_arg}' not found."
989
+ console.print(f"\n[blue]📖 Read file: {tool_arg}[/blue]"); tool_executed = True
990
+ elif tool_name == "SEARCH_CODE":
991
+ results = self.file_manager.search_in_files(tool_arg)
992
+ if not results: tool_result = "No matches found."
993
+ else: tool_result = "\n".join([f"{r['file']}: {' | '.join([m[1] for m in r['matches']])}" for r in results])
994
+ console.print(f"\n[blue]🔍 Searched code for: {tool_arg}[/blue]"); tool_executed = True
995
+ elif tool_name == "RUN_TERMINAL":
996
+ console.print(Panel(f"[yellow]{tool_arg}[/yellow]", title="[bold orange3]⚡ Terminal Command Request[/bold orange3]", border_style="orange3"))
997
+ if Confirm.ask("Execute this command?", default=True):
998
+ success, output = self.command_runner.run(tool_arg)
999
+ tool_result = f"Command output:\n{output}"
1000
+ console.print(Panel(output[:2000], title="Command Output", border_style="green" if success else "red"))
1001
+ 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."
1002
+ else: tool_result = "User rejected the command."; console.print("[yellow]✗ Command rejected.[/yellow]")
1003
+ tool_executed = True
1004
+ if tool_executed:
1005
+ messages.append({"role": "assistant", "content": response})
1006
+ messages.append({"role": "user", "content": f"Tool result:\n{tool_result}\n\nContinue your task."})
1007
+ else: console.print(f"\n[bold green]✓ Task completed.[/bold green]"); break
1008
+ except Exception as e: console.print(f"\n[red]Fatal Error in agent loop: {e}[/red]"); break
1009
+ else: console.print("\n[yellow]⚠️ Agent reached maximum iterations (20).[/yellow]")
1010
+
1011
+ def cmd_help(self):
1012
+ console.print("\n[bold cyan]📚 SamCode CLI Commands[/bold cyan]\n")
1013
+ 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"}
1014
+ for cmd, desc in commands.items(): console.print(f" [cyan]{cmd:<20}[/cyan] {desc}")
1015
+ console.print("\n[dim]Just type your request naturally to activate the autonomous agent![/dim]\n")
1016
+
1017
+ def run(self):
1018
+ self.show_main_header()
1019
+ if not self.api_key:
1020
+ console.print("[yellow]⚠️ No API key configured. Use /connect to set up.[/yellow]\n")
1021
+ if Confirm.ask("Configure now?", default=True): self.cmd_connect()
1022
+ self.show_main_header()
1023
+ while True:
1024
+ try:
1025
+ # FIXED: Using editing_mode=EditingMode.EMACS for standard shortcuts + custom kb
1026
+ user_input = pt_prompt(
1027
+ self.prompt_text,
1028
+ completer=self.command_completer,
1029
+ style=menu_style,
1030
+ key_bindings=kb,
1031
+ editing_mode=EditingMode.EMACS
1032
+ ).strip()
1033
+ if not user_input: continue
1034
+ if user_input.lower() in ["/exit", "/quit", "exit"]: console.print("\n[yellow]Goodbye![/yellow]\n"); break
1035
+ elif user_input.lower() in ["/help", "/h"]: self.cmd_help()
1036
+ elif user_input.lower() in ["/clear", "/cls"]: self.show_main_header()
1037
+ elif user_input.lower() in ["/connect", "/config"]: self.cmd_connect(); self.show_main_header()
1038
+ elif user_input.lower() in ["/models", "/model"]: self.cmd_models()
1039
+ elif user_input.lower() in ["/caveman"]: self.cmd_caveman()
1040
+ elif user_input.lower() in ["/aboutme", "/about"]: self.cmd_aboutme()
1041
+ elif user_input.lower() in ["/git", "/g"]: self.cmd_git()
1042
+ elif user_input.lower().startswith("/searchweb"):
1043
+ parts = user_input.split(maxsplit=1); query = parts[1] if len(parts) > 1 else ""; self.cmd_search_web(query)
1044
+ elif user_input.lower().startswith("/upload"):
1045
+ parts = user_input.split(maxsplit=1); path = parts[1] if len(parts) > 1 else ""; self.cmd_upload(path)
1046
+ elif user_input.lower() in ["/clear-uploads", "/clear-up"]: self.cmd_clear_uploads()
1047
+ else: self.cmd_agent_ask(user_input)
1048
+ except KeyboardInterrupt: console.print("\n[dim](Use /exit to quit)[/dim]")
1049
+ except EOFError: break
1050
+
1051
+ def main():
1052
+ try:
1053
+ agent = SamCodeCLI()
1054
+ agent.run()
1055
+ except Exception as e:
1056
+ console.print(f"[red]Fatal error: {e}[/red]")
1057
+ sys.exit(1)
1058
+
1059
+ if __name__ == "__main__":
1060
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: samcode-cli
3
- Version: 1.0.1
3
+ Version: 1.0.3
4
4
  Summary: An autonomous AI coding agent that runs in your terminal.
5
5
  Author: Magra Houssem Eddine
6
6
  Description-Content-Type: text/markdown
@@ -1,4 +1,6 @@
1
+ MANIFEST.in
1
2
  README.md
3
+ samcode.py
2
4
  setup.py
3
5
  samcode_cli.egg-info/PKG-INFO
4
6
  samcode_cli.egg-info/SOURCES.txt
@@ -1,18 +1,17 @@
1
1
  from setuptools import setup
2
2
  from pathlib import Path
3
3
 
4
- # Automatically read the contents of your README.md
5
4
  this_directory = Path(__file__).parent
6
5
  long_description = (this_directory / "README.md").read_text(encoding="utf-8")
7
6
 
8
7
  setup(
9
8
  name='samcode-cli',
10
- version='1.0.1', # IMPORTANT: You must bump the version number!
11
- description='An autonomous AI coding agent that runs in your terminal.', # Short description for PyPI search
12
- long_description=long_description, # The main body of your PyPI page
13
- long_description_content_type="text/markdown", # Tells PyPI it's Markdown
9
+ version='1.0.3', # IMPORTANT: Bumped version!
10
+ description='An autonomous AI coding agent that runs in your terminal.',
11
+ long_description=long_description,
12
+ long_description_content_type="text/markdown",
14
13
  author='Magra Houssem Eddine',
15
- py_modules=['samcode'],
14
+ py_modules=['samcode'], # This MUST match your filename: samcode.py
16
15
  install_requires=[
17
16
  'rich',
18
17
  'questionary',
File without changes
File without changes