deuscode 0.1.0__tar.gz → 0.2.0__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.
- deuscode-0.2.0/.gitignore +8 -0
- {deuscode-0.1.0 → deuscode-0.2.0}/PKG-INFO +6 -1
- {deuscode-0.1.0 → deuscode-0.2.0}/pyproject.toml +12 -1
- deuscode-0.2.0/src/deuscode/__init__.py +1 -0
- deuscode-0.2.0/src/deuscode/agent.py +81 -0
- deuscode-0.2.0/src/deuscode/config.py +42 -0
- deuscode-0.2.0/src/deuscode/main.py +35 -0
- deuscode-0.2.0/src/deuscode/repomap.py +69 -0
- deuscode-0.2.0/src/deuscode/tools.py +115 -0
- deuscode-0.2.0/src/deuscode/ui.py +30 -0
- deuscode-0.2.0/tests/__init__.py +0 -0
- deuscode-0.2.0/tests/test_repomap.py +59 -0
- deuscode-0.2.0/tests/test_tools.py +45 -0
- deuscode-0.1.0/.claude/settings.local.json +0 -10
- deuscode-0.1.0/src/deuscode/__init__.py +0 -1
- deuscode-0.1.0/src/deuscode/main.py +0 -15
- {deuscode-0.1.0 → deuscode-0.2.0}/LICENSE +0 -0
- {deuscode-0.1.0 → deuscode-0.2.0}/README.md +0 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deuscode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: AI-powered multi-agent CLI coding assistant for local LLMs
|
|
5
5
|
License: AGPL-3.0-or-later
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: httpx
|
|
9
|
+
Requires-Dist: pyyaml
|
|
8
10
|
Requires-Dist: rich
|
|
9
11
|
Requires-Dist: typer
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
10
15
|
Description-Content-Type: text/markdown
|
|
11
16
|
|
|
12
17
|
# deuscode
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "deuscode"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "AI-powered multi-agent CLI coding assistant for local LLMs"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "AGPL-3.0-or-later" }
|
|
@@ -12,6 +12,14 @@ requires-python = ">=3.12"
|
|
|
12
12
|
dependencies = [
|
|
13
13
|
"typer",
|
|
14
14
|
"rich",
|
|
15
|
+
"httpx",
|
|
16
|
+
"pyyaml",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest",
|
|
22
|
+
"pytest-asyncio",
|
|
15
23
|
]
|
|
16
24
|
|
|
17
25
|
[project.scripts]
|
|
@@ -19,3 +27,6 @@ deus = "deuscode.main:app"
|
|
|
19
27
|
|
|
20
28
|
[tool.hatch.build.targets.wheel]
|
|
21
29
|
packages = ["src/deuscode"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = "0.2.0"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from deuscode.config import Config
|
|
6
|
+
from deuscode.repomap import generate_repo_map
|
|
7
|
+
from deuscode import tools, ui
|
|
8
|
+
|
|
9
|
+
_SYSTEM_BASE = (
|
|
10
|
+
"You are Deus, an AI coding assistant. "
|
|
11
|
+
"You have access to tools to read/write files and run shell commands. "
|
|
12
|
+
"Always explain what you are doing before calling a tool."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def run(
|
|
17
|
+
prompt: str,
|
|
18
|
+
config: Config,
|
|
19
|
+
path: str = ".",
|
|
20
|
+
model_override: str | None = None,
|
|
21
|
+
no_map: bool = False,
|
|
22
|
+
) -> str:
|
|
23
|
+
model = model_override or config.model
|
|
24
|
+
system_prompt = _build_system_prompt(path, no_map)
|
|
25
|
+
messages = [
|
|
26
|
+
{"role": "system", "content": system_prompt},
|
|
27
|
+
{"role": "user", "content": prompt},
|
|
28
|
+
]
|
|
29
|
+
ui.thinking(model)
|
|
30
|
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
31
|
+
return await _loop(client, messages, model, config)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_system_prompt(path: str, no_map: bool) -> str:
|
|
35
|
+
if no_map:
|
|
36
|
+
return _SYSTEM_BASE
|
|
37
|
+
repo_map = generate_repo_map(path)
|
|
38
|
+
return f"{_SYSTEM_BASE}\n\n## Repo Map\n{repo_map}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _loop(client: httpx.AsyncClient, messages: list, model: str, config: Config) -> str:
|
|
42
|
+
while True:
|
|
43
|
+
data = await _chat(client, messages, model, config)
|
|
44
|
+
choice = data["choices"][0]
|
|
45
|
+
msg = choice["message"]
|
|
46
|
+
messages.append(msg)
|
|
47
|
+
|
|
48
|
+
tool_calls = msg.get("tool_calls") or []
|
|
49
|
+
if not tool_calls:
|
|
50
|
+
return msg.get("content") or ""
|
|
51
|
+
|
|
52
|
+
for tc in tool_calls:
|
|
53
|
+
result = await _execute_tool(tc)
|
|
54
|
+
messages.append({
|
|
55
|
+
"role": "tool",
|
|
56
|
+
"tool_call_id": tc["id"],
|
|
57
|
+
"content": result,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _chat(client: httpx.AsyncClient, messages: list, model: str, config: Config) -> dict:
|
|
62
|
+
response = await client.post(
|
|
63
|
+
f"{config.base_url.rstrip('/')}/chat/completions",
|
|
64
|
+
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
|
|
65
|
+
json={
|
|
66
|
+
"model": model,
|
|
67
|
+
"messages": messages,
|
|
68
|
+
"tools": tools.TOOL_SCHEMAS,
|
|
69
|
+
"max_tokens": config.max_tokens,
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
return response.json()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _execute_tool(tc: dict) -> str:
|
|
77
|
+
fn = tc["function"]
|
|
78
|
+
ui.tool_call(fn["name"], json.loads(fn.get("arguments", "{}")))
|
|
79
|
+
result = await tools.dispatch(fn["name"], fn.get("arguments", "{}"))
|
|
80
|
+
ui.tool_result(result[:500])
|
|
81
|
+
return result
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
CONFIG_PATH = Path.home() / ".deus" / "config.yaml"
|
|
7
|
+
|
|
8
|
+
_DEFAULTS = {
|
|
9
|
+
"base_url": "https://your-runpod-endpoint/v1",
|
|
10
|
+
"api_key": "your-key",
|
|
11
|
+
"model": "your-model-name",
|
|
12
|
+
"max_tokens": 8192,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Config:
|
|
18
|
+
base_url: str
|
|
19
|
+
api_key: str
|
|
20
|
+
model: str
|
|
21
|
+
max_tokens: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config() -> Config:
|
|
25
|
+
if not CONFIG_PATH.exists():
|
|
26
|
+
_create_default_config()
|
|
27
|
+
raise FileNotFoundError(
|
|
28
|
+
f"Config created at {CONFIG_PATH} — please fill in your endpoint details."
|
|
29
|
+
)
|
|
30
|
+
data = yaml.safe_load(CONFIG_PATH.read_text()) or {}
|
|
31
|
+
merged = {**_DEFAULTS, **data}
|
|
32
|
+
return Config(
|
|
33
|
+
base_url=merged["base_url"],
|
|
34
|
+
api_key=merged["api_key"],
|
|
35
|
+
model=merged["model"],
|
|
36
|
+
max_tokens=int(merged["max_tokens"]),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _create_default_config() -> None:
|
|
41
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
CONFIG_PATH.write_text(yaml.dump(_DEFAULTS, default_flow_style=False))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from deuscode import ui
|
|
7
|
+
from deuscode.config import load_config
|
|
8
|
+
from deuscode import agent
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Deus - AI-powered CLI coding assistant")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def main(
|
|
15
|
+
prompt: str = typer.Argument(..., help="What to ask Deus"),
|
|
16
|
+
path: str = typer.Option(".", "--path", help="Repo path to map"),
|
|
17
|
+
model: Optional[str] = typer.Option(None, "--model", help="Override config model"),
|
|
18
|
+
no_map: bool = typer.Option(False, "--no-map", help="Skip repo-map generation"),
|
|
19
|
+
) -> None:
|
|
20
|
+
try:
|
|
21
|
+
config = load_config()
|
|
22
|
+
except FileNotFoundError as e:
|
|
23
|
+
ui.error(str(e))
|
|
24
|
+
raise typer.Exit(1)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
result = asyncio.run(agent.run(prompt, config, path=path, model_override=model, no_map=no_map))
|
|
28
|
+
ui.final_answer(result)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
ui.error(str(e))
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
app()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
MAX_CHARS = 4000
|
|
6
|
+
SKIP_DIRS = {".git", "node_modules", "vendor", "__pycache__", ".venv", "dist", "build"}
|
|
7
|
+
SKIP_FILES = {".env"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generate_repo_map(path: str) -> str:
|
|
11
|
+
root = Path(path).resolve()
|
|
12
|
+
lines: list[str] = []
|
|
13
|
+
_walk(root, root, lines)
|
|
14
|
+
output = "\n".join(lines)
|
|
15
|
+
_SUFFIX = "\n... [truncated]"
|
|
16
|
+
if len(output) > MAX_CHARS:
|
|
17
|
+
output = output[: MAX_CHARS - len(_SUFFIX)] + _SUFFIX
|
|
18
|
+
return output
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _walk(root: Path, current: Path, lines: list[str], depth: int = 0) -> None:
|
|
22
|
+
indent = " " * depth
|
|
23
|
+
for item in sorted(current.iterdir()):
|
|
24
|
+
if item.name in SKIP_DIRS or item.name in SKIP_FILES:
|
|
25
|
+
continue
|
|
26
|
+
if item.name.startswith("."):
|
|
27
|
+
continue
|
|
28
|
+
if item.is_dir():
|
|
29
|
+
lines.append(f"{indent}{item.name}/")
|
|
30
|
+
_walk(root, item, lines, depth + 1)
|
|
31
|
+
elif item.is_file():
|
|
32
|
+
_append_file_entry(item, indent, lines)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _append_file_entry(path: Path, indent: str, lines: list[str]) -> None:
|
|
36
|
+
if path.suffix == ".py":
|
|
37
|
+
sigs = _extract_python_signatures(path)
|
|
38
|
+
lines.append(f"{indent}{path.name}")
|
|
39
|
+
lines.extend(f"{indent} {s}" for s in sigs)
|
|
40
|
+
elif path.suffix == ".php":
|
|
41
|
+
sigs = _extract_php_signatures(path)
|
|
42
|
+
lines.append(f"{indent}{path.name}")
|
|
43
|
+
lines.extend(f"{indent} {s}" for s in sigs)
|
|
44
|
+
else:
|
|
45
|
+
lines.append(f"{indent}{path.name}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_python_signatures(path: Path) -> list[str]:
|
|
49
|
+
try:
|
|
50
|
+
tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
|
|
51
|
+
except SyntaxError:
|
|
52
|
+
return []
|
|
53
|
+
sigs: list[str] = []
|
|
54
|
+
for node in ast.walk(tree):
|
|
55
|
+
if isinstance(node, ast.ClassDef):
|
|
56
|
+
sigs.append(f"class {node.name}")
|
|
57
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
58
|
+
args = [a.arg for a in node.args.args]
|
|
59
|
+
sigs.append(f"def {node.name}({', '.join(args)})")
|
|
60
|
+
return sigs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _extract_php_signatures(path: Path) -> list[str]:
|
|
64
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
65
|
+
classes = re.findall(r"class\s+(\w+)", text)
|
|
66
|
+
functions = re.findall(r"function\s+(\w+)\s*\(([^)]*)\)", text)
|
|
67
|
+
sigs: list[str] = [f"class {c}" for c in classes]
|
|
68
|
+
sigs += [f"function {n}({a.strip()})" for n, a in functions]
|
|
69
|
+
return sigs
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from deuscode import ui
|
|
6
|
+
|
|
7
|
+
_CWD = Path.cwd().resolve()
|
|
8
|
+
|
|
9
|
+
TOOL_SCHEMAS = [
|
|
10
|
+
{
|
|
11
|
+
"type": "function",
|
|
12
|
+
"function": {
|
|
13
|
+
"name": "read_file",
|
|
14
|
+
"description": "Read the contents of a file.",
|
|
15
|
+
"parameters": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {"path": {"type": "string", "description": "File path to read"}},
|
|
18
|
+
"required": ["path"],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "function",
|
|
24
|
+
"function": {
|
|
25
|
+
"name": "write_file",
|
|
26
|
+
"description": "Write content to a file after user confirmation.",
|
|
27
|
+
"parameters": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"path": {"type": "string", "description": "File path to write"},
|
|
31
|
+
"content": {"type": "string", "description": "Content to write"},
|
|
32
|
+
},
|
|
33
|
+
"required": ["path", "content"],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"type": "function",
|
|
39
|
+
"function": {
|
|
40
|
+
"name": "bash",
|
|
41
|
+
"description": "Run a shell command after user confirmation.",
|
|
42
|
+
"parameters": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {"command": {"type": "string", "description": "Shell command to run"}},
|
|
45
|
+
"required": ["command"],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _safe_path(path: str) -> Path | None:
|
|
53
|
+
resolved = Path(path).resolve()
|
|
54
|
+
if not str(resolved).startswith(str(_CWD)):
|
|
55
|
+
return None
|
|
56
|
+
return resolved
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def read_file(path: str) -> str:
|
|
60
|
+
target = _safe_path(path)
|
|
61
|
+
if target is None:
|
|
62
|
+
return f"Error: path '{path}' is outside the working directory."
|
|
63
|
+
if not target.exists():
|
|
64
|
+
return f"Error: '{path}' does not exist."
|
|
65
|
+
return target.read_text(encoding="utf-8", errors="replace")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def write_file(path: str, content: str) -> str:
|
|
69
|
+
target = _safe_path(path)
|
|
70
|
+
if target is None:
|
|
71
|
+
return f"Error: path '{path}' is outside the working directory."
|
|
72
|
+
if target.exists():
|
|
73
|
+
existing = target.read_text(encoding="utf-8", errors="replace")
|
|
74
|
+
_show_diff(existing, content, path)
|
|
75
|
+
if not ui.confirm(f"Write to [bold]{path}[/bold]?"):
|
|
76
|
+
return "Cancelled by user."
|
|
77
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
target.write_text(content, encoding="utf-8")
|
|
79
|
+
return f"Written: {path}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def bash(command: str) -> str:
|
|
83
|
+
ui.console.print(f"[bold yellow]Command:[/bold yellow] {command}")
|
|
84
|
+
if not ui.confirm("Run this command?"):
|
|
85
|
+
return "Cancelled by user."
|
|
86
|
+
result = subprocess.run(command, shell=True, capture_output=True, text=True)
|
|
87
|
+
output = result.stdout + result.stderr
|
|
88
|
+
return output.strip() or "(no output)"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _show_diff(old: str, new: str, path: str) -> None:
|
|
92
|
+
old_lines = old.splitlines()
|
|
93
|
+
new_lines = new.splitlines()
|
|
94
|
+
ui.console.print(f"[dim]Diff for {path}:[/dim]")
|
|
95
|
+
for line in old_lines:
|
|
96
|
+
if line not in new_lines:
|
|
97
|
+
ui.console.print(f"[red]- {line}[/red]")
|
|
98
|
+
for line in new_lines:
|
|
99
|
+
if line not in old_lines:
|
|
100
|
+
ui.console.print(f"[green]+ {line}[/green]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
TOOL_FUNCTIONS = {
|
|
104
|
+
"read_file": read_file,
|
|
105
|
+
"write_file": write_file,
|
|
106
|
+
"bash": bash,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def dispatch(name: str, args_json: str) -> str:
|
|
111
|
+
fn = TOOL_FUNCTIONS.get(name)
|
|
112
|
+
if fn is None:
|
|
113
|
+
return f"Error: unknown tool '{name}'"
|
|
114
|
+
args = json.loads(args_json)
|
|
115
|
+
return await fn(**args)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
from rich.prompt import Confirm
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def thinking(model: str) -> None:
|
|
9
|
+
console.print(f"[dim]Deus is thinking... ({model})[/dim]")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def tool_call(name: str, args: dict) -> None:
|
|
13
|
+
args_str = ", ".join(f"{k}={v!r}" for k, v in args.items())
|
|
14
|
+
console.print(f"[yellow]⚡ Calling: {name}({args_str})[/yellow]")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def tool_result(text: str) -> None:
|
|
18
|
+
console.print(f"[dim grey]{text}[/dim grey]")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def final_answer(text: str) -> None:
|
|
22
|
+
console.print(Panel(text, title="[bold cyan]Deus[/bold cyan]", border_style="cyan"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def error(text: str) -> None:
|
|
26
|
+
console.print(Panel(text, title="[bold red]Error[/bold red]", border_style="red"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def confirm(prompt: str) -> bool:
|
|
30
|
+
return Confirm.ask(prompt)
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import textwrap
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from deuscode.repomap import generate_repo_map
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _make_py(tmp_path: Path, name: str, src: str) -> Path:
|
|
10
|
+
f = tmp_path / name
|
|
11
|
+
f.write_text(textwrap.dedent(src))
|
|
12
|
+
return f
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_generates_tree(tmp_path):
|
|
16
|
+
_make_py(tmp_path, "alpha.py", "x = 1")
|
|
17
|
+
_make_py(tmp_path, "beta.py", "y = 2")
|
|
18
|
+
result = generate_repo_map(str(tmp_path))
|
|
19
|
+
assert "alpha.py" in result
|
|
20
|
+
assert "beta.py" in result
|
|
21
|
+
assert len(result) > 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_extracts_python_signatures(tmp_path):
|
|
25
|
+
_make_py(tmp_path, "module.py", """\
|
|
26
|
+
class Foo:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def bar(x, y):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
async def baz(z):
|
|
33
|
+
pass
|
|
34
|
+
""")
|
|
35
|
+
result = generate_repo_map(str(tmp_path))
|
|
36
|
+
assert "class Foo" in result
|
|
37
|
+
assert "def bar" in result
|
|
38
|
+
assert "def baz" in result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_skips_ignored_dirs(tmp_path):
|
|
42
|
+
(tmp_path / ".git").mkdir()
|
|
43
|
+
(tmp_path / ".git" / "config").write_text("gitconfig")
|
|
44
|
+
(tmp_path / "node_modules").mkdir()
|
|
45
|
+
(tmp_path / "node_modules" / "pkg.js").write_text("module.exports={}")
|
|
46
|
+
_make_py(tmp_path, "app.py", "pass")
|
|
47
|
+
result = generate_repo_map(str(tmp_path))
|
|
48
|
+
assert ".git" not in result
|
|
49
|
+
assert "node_modules" not in result
|
|
50
|
+
assert "app.py" in result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_truncates_large_repos(tmp_path):
|
|
54
|
+
for i in range(60):
|
|
55
|
+
src = "\n".join(f"def func_{j}(x): pass" for j in range(20))
|
|
56
|
+
(tmp_path / f"module_{i}.py").write_text(src)
|
|
57
|
+
result = generate_repo_map(str(tmp_path))
|
|
58
|
+
assert len(result) <= 4000
|
|
59
|
+
assert "truncated" in result
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import deuscode.tools as tool_module
|
|
8
|
+
from deuscode.tools import read_file, write_file
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(autouse=True)
|
|
12
|
+
def set_cwd(tmp_path, monkeypatch):
|
|
13
|
+
monkeypatch.chdir(tmp_path)
|
|
14
|
+
monkeypatch.setattr(tool_module, "_CWD", tmp_path.resolve())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.asyncio
|
|
18
|
+
async def test_read_file_success(tmp_path):
|
|
19
|
+
f = tmp_path / "hello.txt"
|
|
20
|
+
f.write_text("hello world")
|
|
21
|
+
result = await read_file(str(f))
|
|
22
|
+
assert result == "hello world"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_read_file_blocks_path_traversal(tmp_path):
|
|
27
|
+
result = await read_file("../../etc/passwd")
|
|
28
|
+
assert "Error" in result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_write_file_creates_file(tmp_path):
|
|
33
|
+
target = tmp_path / "output.txt"
|
|
34
|
+
with patch("deuscode.ui.confirm", return_value=True):
|
|
35
|
+
result = await write_file(str(target), "new content")
|
|
36
|
+
assert "Written" in result
|
|
37
|
+
assert target.read_text() == "new content"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_write_file_rejects_outside_cwd(tmp_path):
|
|
42
|
+
outside = tmp_path.parent / "evil.txt"
|
|
43
|
+
result = await write_file(str(outside), "bad")
|
|
44
|
+
assert "Error" in result
|
|
45
|
+
assert not outside.exists()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
version = "0.1.0"
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import typer
|
|
2
|
-
from rich.console import Console
|
|
3
|
-
|
|
4
|
-
app = typer.Typer()
|
|
5
|
-
console = Console()
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@app.command()
|
|
9
|
-
def main():
|
|
10
|
-
"""Deus - AI-powered multi-agent CLI coding assistant."""
|
|
11
|
-
console.print("[bold cyan]Deus v0.1.0[/bold cyan] [dim]- Coming soon[/dim]")
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if __name__ == "__main__":
|
|
15
|
-
app()
|
|
File without changes
|
|
File without changes
|