alpiecode 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codeagent/github.py ADDED
@@ -0,0 +1,241 @@
1
+ """
2
+ GitHub integration for AlpieCode — browse repos, read issues, search code.
3
+
4
+ Uses the GitHub REST API (v3) via urllib (zero extra dependencies).
5
+ Supports unauthenticated access (60 req/hr) or authenticated via
6
+ GITHUB_TOKEN env var (5000 req/hr).
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import urllib.request
13
+ import urllib.error
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+
18
+ # ── API helpers ───────────────────────────────────────────────────────
19
+
20
+ API_BASE = "https://api.github.com"
21
+
22
+
23
+ def _github_headers() -> dict:
24
+ """Build request headers, using GITHUB_TOKEN if available."""
25
+ headers = {
26
+ "Accept": "application/vnd.github.v3+json",
27
+ "User-Agent": "AlpieCode/0.5.0",
28
+ }
29
+ token = os.environ.get("GITHUB_TOKEN")
30
+ if token:
31
+ headers["Authorization"] = f"token {token}"
32
+ return headers
33
+
34
+
35
+ def _api_get(endpoint: str, params: dict = None) -> Any:
36
+ """Make a GET request to the GitHub API."""
37
+ url = f"{API_BASE}{endpoint}"
38
+ if params:
39
+ query = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
40
+ if query:
41
+ url += f"?{query}"
42
+
43
+ req = urllib.request.Request(url, headers=_github_headers())
44
+ try:
45
+ with urllib.request.urlopen(req, timeout=15) as resp:
46
+ return json.loads(resp.read().decode("utf-8"))
47
+ except urllib.error.HTTPError as e:
48
+ error_body = e.read().decode("utf-8", errors="replace")
49
+ if e.code == 403 and "rate limit" in error_body.lower():
50
+ return {"error": "GitHub API rate limit exceeded. Set GITHUB_TOKEN env var for 5000 req/hr."}
51
+ if e.code == 404:
52
+ return {"error": f"Not found: {endpoint}"}
53
+ return {"error": f"GitHub API error {e.code}: {error_body[:300]}"}
54
+ except Exception as e:
55
+ return {"error": f"Request failed: {e}"}
56
+
57
+
58
+ # ── Repository info ───────────────────────────────────────────────────
59
+
60
+ def fetch_repo_info(owner: str, repo: str) -> str:
61
+ """Fetch basic repository information."""
62
+ data = _api_get(f"/repos/{owner}/{repo}")
63
+ if isinstance(data, dict) and "error" in data:
64
+ return json.dumps(data)
65
+
66
+ info = {
67
+ "full_name": data.get("full_name"),
68
+ "description": data.get("description"),
69
+ "language": data.get("language"),
70
+ "stars": data.get("stargazers_count"),
71
+ "forks": data.get("forks_count"),
72
+ "open_issues": data.get("open_issues_count"),
73
+ "default_branch": data.get("default_branch"),
74
+ "topics": data.get("topics", []),
75
+ "license": data.get("license", {}).get("spdx_id") if data.get("license") else None,
76
+ "url": data.get("html_url"),
77
+ }
78
+ return json.dumps(info, indent=2)
79
+
80
+
81
+ def fetch_repo_tree(owner: str, repo: str, path: str = "") -> str:
82
+ """Fetch directory listing from a GitHub repo."""
83
+ endpoint = f"/repos/{owner}/{repo}/contents/{path}" if path else f"/repos/{owner}/{repo}/contents"
84
+ data = _api_get(endpoint)
85
+ if isinstance(data, dict) and "error" in data:
86
+ return json.dumps(data)
87
+
88
+ if isinstance(data, list):
89
+ entries = []
90
+ for item in data[:100]: # Cap at 100 entries
91
+ entry = {
92
+ "name": item.get("name"),
93
+ "type": item.get("type"), # "file" or "dir"
94
+ "size": item.get("size"),
95
+ "path": item.get("path"),
96
+ }
97
+ entries.append(entry)
98
+ return json.dumps(entries, indent=2)
99
+
100
+ # Single file — return content
101
+ if isinstance(data, dict) and data.get("content"):
102
+ import base64
103
+ content = base64.b64decode(data["content"]).decode("utf-8", errors="replace")
104
+ return f"File: {data.get('path')}\nSize: {data.get('size')} bytes\n\n{content}"
105
+
106
+ return json.dumps(data, indent=2)
107
+
108
+
109
+ # ── Issues & Pull Requests ────────────────────────────────────────────
110
+
111
+ def fetch_issues(owner: str, repo: str, state: str = "open",
112
+ max_results: int = 10) -> str:
113
+ """Fetch issues list from a GitHub repo."""
114
+ data = _api_get(f"/repos/{owner}/{repo}/issues", {
115
+ "state": state,
116
+ "per_page": min(max_results, 30),
117
+ "sort": "updated",
118
+ "direction": "desc",
119
+ })
120
+ if isinstance(data, dict) and "error" in data:
121
+ return json.dumps(data)
122
+
123
+ issues = []
124
+ for item in data[:max_results]:
125
+ issue = {
126
+ "number": item.get("number"),
127
+ "title": item.get("title"),
128
+ "state": item.get("state"),
129
+ "labels": [l.get("name") for l in item.get("labels", [])],
130
+ "user": item.get("user", {}).get("login"),
131
+ "comments": item.get("comments"),
132
+ "created_at": item.get("created_at"),
133
+ "updated_at": item.get("updated_at"),
134
+ "is_pull_request": "pull_request" in item,
135
+ "url": item.get("html_url"),
136
+ }
137
+ issues.append(issue)
138
+ return json.dumps(issues, indent=2)
139
+
140
+
141
+ def fetch_issue_detail(owner: str, repo: str, issue_number: int) -> str:
142
+ """Fetch full issue details including body and comments."""
143
+ # Fetch issue body
144
+ data = _api_get(f"/repos/{owner}/{repo}/issues/{issue_number}")
145
+ if isinstance(data, dict) and "error" in data:
146
+ return json.dumps(data)
147
+
148
+ result = {
149
+ "number": data.get("number"),
150
+ "title": data.get("title"),
151
+ "state": data.get("state"),
152
+ "user": data.get("user", {}).get("login"),
153
+ "labels": [l.get("name") for l in data.get("labels", [])],
154
+ "body": data.get("body", "")[:3000], # Cap body at 3000 chars
155
+ "created_at": data.get("created_at"),
156
+ "url": data.get("html_url"),
157
+ }
158
+
159
+ # Fetch comments
160
+ if data.get("comments", 0) > 0:
161
+ comments_data = _api_get(f"/repos/{owner}/{repo}/issues/{issue_number}/comments", {
162
+ "per_page": 10,
163
+ })
164
+ if isinstance(comments_data, list):
165
+ result["comments"] = [
166
+ {
167
+ "user": c.get("user", {}).get("login"),
168
+ "body": c.get("body", "")[:1000], # Cap each comment
169
+ "created_at": c.get("created_at"),
170
+ }
171
+ for c in comments_data[:10]
172
+ ]
173
+
174
+ return json.dumps(result, indent=2)
175
+
176
+
177
+ # ── Search ────────────────────────────────────────────────────────────
178
+
179
+ def search_repos(query: str, max_results: int = 5) -> str:
180
+ """Search GitHub repositories by keyword."""
181
+ data = _api_get("/search/repositories", {
182
+ "q": query,
183
+ "sort": "stars",
184
+ "order": "desc",
185
+ "per_page": min(max_results, 10),
186
+ })
187
+ if isinstance(data, dict) and "error" in data:
188
+ return json.dumps(data)
189
+
190
+ repos = []
191
+ for item in data.get("items", [])[:max_results]:
192
+ repos.append({
193
+ "full_name": item.get("full_name"),
194
+ "description": item.get("description"),
195
+ "stars": item.get("stargazers_count"),
196
+ "language": item.get("language"),
197
+ "url": item.get("html_url"),
198
+ })
199
+ return json.dumps(repos, indent=2)
200
+
201
+
202
+ # ── Clone ─────────────────────────────────────────────────────────────
203
+
204
+ def clone_repo(repo_url: str, workdir: Path, branch: str = None) -> str:
205
+ """Clone a GitHub repository into the working directory."""
206
+ # Normalize URL
207
+ if not repo_url.startswith("http"):
208
+ repo_url = f"https://github.com/{repo_url}.git"
209
+ elif not repo_url.endswith(".git"):
210
+ repo_url = repo_url.rstrip("/") + ".git"
211
+
212
+ # Extract repo name for the directory
213
+ repo_name = repo_url.split("/")[-1].replace(".git", "")
214
+ clone_dir = workdir / repo_name
215
+
216
+ if clone_dir.exists():
217
+ return f"Repository already exists at {repo_name}/. Use read_file and list_files to explore it."
218
+
219
+ cmd = ["git", "clone", "--depth", "1"]
220
+ if branch:
221
+ cmd.extend(["--branch", branch])
222
+ cmd.extend([repo_url, str(clone_dir)])
223
+
224
+ try:
225
+ result = subprocess.run(
226
+ cmd, capture_output=True, text=True, timeout=120,
227
+ )
228
+ if result.returncode == 0:
229
+ # Get a quick summary of what was cloned
230
+ file_count = sum(1 for _ in clone_dir.rglob("*") if _.is_file())
231
+ return (
232
+ f"Successfully cloned {repo_url} into {repo_name}/\n"
233
+ f"Files: {file_count}\n"
234
+ f"Use list_files and read_file to explore the repository."
235
+ )
236
+ else:
237
+ return f"Clone failed: {result.stderr[:500]}"
238
+ except subprocess.TimeoutExpired:
239
+ return "Clone timed out after 120s. The repository may be too large."
240
+ except Exception as e:
241
+ return f"Clone error: {e}"
codeagent/guardian.py ADDED
@@ -0,0 +1,160 @@
1
+ """
2
+ Guardian — lightweight command safety gate for AlpieCode.
3
+
4
+ Classifies bash commands into risk levels before execution:
5
+ - SAFE: read-only commands (ls, cat, grep, git status, python -m pytest, etc.)
6
+ - WARNING: potentially destructive but common (rm, pip install, chmod, etc.)
7
+ - DANGEROUS: extremely risky commands that require explicit user confirmation
8
+
9
+ This is a pattern-matching heuristic, not an LLM call — fast and deterministic.
10
+ """
11
+
12
+ import re
13
+ from enum import Enum
14
+ from typing import Tuple
15
+
16
+ try:
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ console = Console()
20
+ HAS_RICH = True
21
+ except ImportError:
22
+ HAS_RICH = False
23
+ console = None
24
+
25
+
26
+ class RiskLevel(Enum):
27
+ SAFE = "safe"
28
+ WARNING = "warning"
29
+ DANGEROUS = "dangerous"
30
+
31
+
32
+ # ── Pattern definitions ───────────────────────────────────────────────
33
+
34
+ # Commands that are always safe (read-only operations)
35
+ SAFE_PREFIXES = [
36
+ "ls", "cat", "head", "tail", "wc", "file", "stat", "du", "df",
37
+ "pwd", "whoami", "uname", "date", "echo", "printf", "which", "where",
38
+ "find", "locate", "grep", "egrep", "fgrep", "rg", "ag",
39
+ "git status", "git log", "git diff", "git show", "git branch",
40
+ "git remote", "git tag", "git stash list", "git rev-parse",
41
+ "python --version", "python3 --version", "node --version",
42
+ "npm --version", "pip --version", "pip3 --version",
43
+ "python -m pytest", "python3 -m pytest", "pytest", "npm test",
44
+ "npm run test", "make test", "cargo test", "go test",
45
+ "python -c", "python3 -c", "node -e",
46
+ "tree", "sort", "uniq", "cut", "awk", "sed -n", "diff",
47
+ "env", "printenv", "set",
48
+ "type", "command -v",
49
+ "uv pip install", "uv pip", "uv venv", "uv run", "uv sync",
50
+ ]
51
+
52
+ # Commands that are potentially destructive but commonly used
53
+ WARNING_PATTERNS = [
54
+ r"\brm\b(?!\s+-rf\s+[/~])", # rm but not rm -rf / or ~
55
+ r"\bchmod\b", r"\bchown\b",
56
+ r"\bpip install\b", r"\bpip3 install\b",
57
+ r"\bnpm install\b", r"\byarn add\b", r"\bpnpm add\b",
58
+ r"\bgit add\b", r"\bgit commit\b", r"\bgit push\b",
59
+ r"\bgit checkout\b", r"\bgit reset\b", r"\bgit rebase\b",
60
+ r"\bgit merge\b", r"\bgit stash\b",
61
+ r"\bmv\b", r"\bcp\b",
62
+ r"\bmkdir\b", r"\btouch\b",
63
+ r"\bkill\b", r"\bkillall\b",
64
+ r"\bapt install\b", r"\bapt-get install\b",
65
+ r"\bbrew install\b",
66
+ r"\bdocker\b",
67
+ ]
68
+
69
+ # Commands that should be blocked without explicit confirmation
70
+ DANGEROUS_PATTERNS = [
71
+ r"\brm\s+-rf\s+[/~]", # rm -rf / or rm -rf ~
72
+ r"\brm\s+-rf\s+\*", # rm -rf *
73
+ r"\bsudo\b", # anything with sudo
74
+ r"\bmkfs\b", r"\bfdisk\b", # filesystem manipulation
75
+ r"\bdd\s+if=", r"\bdd\s+of=", # raw disk operations
76
+ r":\(\)\s*\{", r"fork\s*bomb", # fork bombs
77
+ r"\bcurl\b.*\|\s*(?:ba)?sh", # curl | bash (piped execution)
78
+ r"\bwget\b.*\|\s*(?:ba)?sh", # wget | bash
79
+ r"\b>\s*/dev/sd", # write to raw devices
80
+ r"\bshutdown\b", r"\breboot\b", # system control
81
+ r"\binit\s+[0-6]\b", # runlevel changes
82
+ r"\bsystemctl\s+(?:stop|disable|mask)\b",
83
+ r"\bchmod\s+777\s+/", # world-writable root
84
+ r"\beval\b.*\$\(", # eval with command substitution
85
+ r">\s*/etc/", # overwrite system configs
86
+ r"\bexport\s+.*(?:KEY|SECRET|TOKEN|PASSWORD)", # leaking secrets
87
+ ]
88
+
89
+
90
+ def classify_command(command: str) -> Tuple[RiskLevel, str]:
91
+ """
92
+ Classify a shell command by risk level.
93
+
94
+ Returns:
95
+ Tuple of (RiskLevel, reason_string)
96
+ """
97
+ cmd_lower = command.strip().lower()
98
+
99
+ # Check dangerous first (highest priority)
100
+ for pattern in DANGEROUS_PATTERNS:
101
+ if re.search(pattern, cmd_lower):
102
+ return RiskLevel.DANGEROUS, f"Matches dangerous pattern: {pattern}"
103
+
104
+ # Check safe prefixes
105
+ for prefix in SAFE_PREFIXES:
106
+ if cmd_lower.startswith(prefix):
107
+ return RiskLevel.SAFE, f"Read-only command: {prefix}"
108
+
109
+ # Check warning patterns
110
+ for pattern in WARNING_PATTERNS:
111
+ if re.search(pattern, cmd_lower):
112
+ return RiskLevel.WARNING, f"Potentially destructive: {pattern}"
113
+
114
+ # Default: treat unknown commands as warning
115
+ return RiskLevel.WARNING, "Unknown command — proceeding with caution"
116
+
117
+
118
+ def gate_command(command: str, auto_approve: bool = False) -> bool:
119
+ """
120
+ Gate a command through the safety system.
121
+
122
+ Args:
123
+ command: The shell command to evaluate
124
+ auto_approve: If True, auto-approve SAFE and WARNING (skip prompts)
125
+
126
+ Returns:
127
+ True if the command should be executed, False if blocked
128
+ """
129
+ risk, reason = classify_command(command)
130
+
131
+ if risk == RiskLevel.SAFE:
132
+ return True
133
+
134
+ if risk == RiskLevel.WARNING:
135
+ if auto_approve:
136
+ if HAS_RICH:
137
+ console.print(f" ⚠️ [yellow]{reason}[/yellow]", highlight=False)
138
+ return True
139
+ # In interactive mode, show warning but proceed
140
+ if HAS_RICH:
141
+ console.print(f" ⚠️ [yellow]{reason}[/yellow]", highlight=False)
142
+ return True
143
+
144
+ if risk == RiskLevel.DANGEROUS:
145
+ if HAS_RICH:
146
+ console.print(Panel(
147
+ f"[bold red]🛑 BLOCKED — Dangerous Command[/bold red]\n\n"
148
+ f"Command: [cyan]{command}[/cyan]\n"
149
+ f"Reason: {reason}\n\n"
150
+ f"This command has been blocked for safety.\n"
151
+ f"If you need to run it, do so manually in your terminal.",
152
+ border_style="red",
153
+ ))
154
+ else:
155
+ print(f"\n🛑 BLOCKED — Dangerous Command")
156
+ print(f" Command: {command}")
157
+ print(f" Reason: {reason}")
158
+ return False
159
+
160
+ return True