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.
- alpiecode-0.6.0.dist-info/METADATA +14 -0
- alpiecode-0.6.0.dist-info/RECORD +17 -0
- alpiecode-0.6.0.dist-info/WHEEL +5 -0
- alpiecode-0.6.0.dist-info/entry_points.txt +3 -0
- alpiecode-0.6.0.dist-info/top_level.txt +1 -0
- codeagent/__init__.py +1 -0
- codeagent/agent.py +989 -0
- codeagent/cli.py +215 -0
- codeagent/compaction.py +163 -0
- codeagent/config.py +195 -0
- codeagent/github.py +241 -0
- codeagent/guardian.py +160 -0
- codeagent/local_model.py +460 -0
- codeagent/media.py +286 -0
- codeagent/memory.py +130 -0
- codeagent/tools.py +718 -0
- codeagent/updater.py +126 -0
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
|