devobin 1.0.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.
Files changed (47) hide show
  1. devobin/__init__.py +4 -0
  2. devobin/app.py +42 -0
  3. devobin/cli/__init__.py +1 -0
  4. devobin/cli/chat.py +831 -0
  5. devobin/cli/commands.py +362 -0
  6. devobin/cli/slash_menu.py +74 -0
  7. devobin/cli/terminal.py +155 -0
  8. devobin/cli/ui.py +129 -0
  9. devobin/config/__init__.py +1 -0
  10. devobin/config/settings.py +124 -0
  11. devobin/core/__init__.py +1 -0
  12. devobin/core/analyzer.py +166 -0
  13. devobin/core/executor.py +244 -0
  14. devobin/core/memory.py +78 -0
  15. devobin/core/optimizer.py +106 -0
  16. devobin/core/prompt_builder.py +513 -0
  17. devobin/core/researcher.py +283 -0
  18. devobin/core/rtl.py +93 -0
  19. devobin/core/scanner.py +206 -0
  20. devobin/core/tools.py +283 -0
  21. devobin/core/validator.py +235 -0
  22. devobin/main.py +33 -0
  23. devobin/output/__init__.py +1 -0
  24. devobin/providers/__init__.py +85 -0
  25. devobin/providers/anthropic_provider.py +72 -0
  26. devobin/providers/google_provider.py +90 -0
  27. devobin/providers/ollama_provider.py +81 -0
  28. devobin/providers/openai_provider.py +102 -0
  29. devobin/storage/__init__.py +1 -0
  30. devobin/storage/database.py +207 -0
  31. devobin/ui/__init__.py +1 -0
  32. devobin/ui/ask_modal.py +96 -0
  33. devobin/ui/chat_screen.py +1029 -0
  34. devobin/ui/command_palette.py +131 -0
  35. devobin/ui/connect_modal.py +166 -0
  36. devobin/ui/export_modal.py +159 -0
  37. devobin/ui/history_modal.py +137 -0
  38. devobin/ui/memory_modal.py +88 -0
  39. devobin/ui/model_modal.py +143 -0
  40. devobin/ui/permission_modal.py +92 -0
  41. devobin/ui/project_modal.py +133 -0
  42. devobin/ui/settings_modal.py +89 -0
  43. devobin-1.0.0.dist-info/METADATA +364 -0
  44. devobin-1.0.0.dist-info/RECORD +47 -0
  45. devobin-1.0.0.dist-info/WHEEL +4 -0
  46. devobin-1.0.0.dist-info/entry_points.txt +2 -0
  47. devobin-1.0.0.dist-info/licenses/LICENSE +21 -0
devobin/cli/ui.py ADDED
@@ -0,0 +1,129 @@
1
+ """Rich UI components for DevObin AI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+ from rich.markdown import Markdown
10
+ from rich.theme import Theme
11
+ from rich.tree import Tree
12
+ from rich import box
13
+
14
+ DEVOBIN_THEME = Theme({
15
+ "info": "cyan",
16
+ "success": "green",
17
+ "warning": "yellow",
18
+ "error": "bold red",
19
+ "accent": "bold magenta",
20
+ "dim": "dim white",
21
+ })
22
+
23
+ console = Console(theme=DEVOBIN_THEME)
24
+
25
+
26
+ def print_status(message: str, style: str = "info") -> None:
27
+ """Print a status message."""
28
+ console.print(f" {message}", style=style)
29
+
30
+
31
+ def print_success(message: str) -> None:
32
+ console.print(f" ✓ {message}", style="success")
33
+
34
+
35
+ def print_error(message: str) -> None:
36
+ console.print(f" ✗ {message}", style="error")
37
+
38
+
39
+ def print_warning(message: str) -> None:
40
+ console.print(f" ! {message}", style="warning")
41
+
42
+
43
+ def print_detected(techs: list[str], label: str = "Detected") -> None:
44
+ """Print detected technologies in a tree."""
45
+ if not techs:
46
+ return
47
+ tree = Tree(f"[bold cyan]{label}[/]")
48
+ for tech in techs:
49
+ tree.add(f"[green]✓[/] {tech}")
50
+ console.print(tree)
51
+
52
+
53
+ def print_research_status(tech: str) -> None:
54
+ console.print(f" [dim]Researching {tech}...[/]", end="\r")
55
+
56
+
57
+ def print_provider_table(providers: dict) -> Table:
58
+ """Print configured providers as a table."""
59
+ table = Table(title="Configured Providers", border_style="cyan", box=box.ROUNDED)
60
+ table.add_column("#", style="dim", width=3)
61
+ table.add_column("Name", style="bold")
62
+ table.add_column("Provider", style="cyan")
63
+ table.add_column("Model", style="green")
64
+ table.add_column("Base URL", style="dim")
65
+
66
+ for i, (name, cfg) in enumerate(providers.items(), 1):
67
+ table.add_row(
68
+ str(i),
69
+ name,
70
+ cfg.get("type", name),
71
+ cfg.get("model", "-"),
72
+ cfg.get("base_url", "-"),
73
+ )
74
+
75
+ console.print(table)
76
+ return table
77
+
78
+
79
+ def print_model_table(models: list[str], provider: str) -> Table:
80
+ """Print available models as a table."""
81
+ table = Table(title=f"Available Models ({provider})", border_style="green", box=box.ROUNDED)
82
+ table.add_column("#", style="dim", width=3)
83
+ table.add_column("Model", style="bold")
84
+
85
+ for i, model in enumerate(models, 1):
86
+ table.add_row(str(i), model)
87
+
88
+ console.print(table)
89
+ return table
90
+
91
+
92
+ def print_help() -> None:
93
+ """Print the help screen as a beautiful panel."""
94
+ table = Table(show_header=False, show_edge=False, box=None, padding=(0, 2))
95
+ table.add_column("Command", style="bold cyan", width=15)
96
+ table.add_column("Description", style="white")
97
+
98
+ commands = [
99
+ ("/help", "Show this help message"),
100
+ ("/connect", "Connect or configure an AI provider"),
101
+ ("/models", "List available models from current provider"),
102
+ ("/model <name>", "Switch to a different model"),
103
+ ("/history", "Show previous chat sessions"),
104
+ ("/memory", "View saved memories"),
105
+ ("/project create <name>", "Create a new project"),
106
+ ("/project switch <name>", "Switch to a project"),
107
+ ("/project list", "List all projects"),
108
+ ("/export", "Generate project_prompt.md"),
109
+ ("/settings", "View or modify settings"),
110
+ ("/clear", "Clear current conversation"),
111
+ ("/quit", "Exit DevObin"),
112
+ ]
113
+
114
+ for cmd, desc in commands:
115
+ table.add_row(cmd, desc)
116
+
117
+ console.print(Panel(
118
+ table,
119
+ title="[bold cyan]DevObin Commands[/]",
120
+ border_style="cyan",
121
+ box=box.ROUNDED,
122
+ padding=(1, 2),
123
+ ))
124
+
125
+
126
+ def render_markdown(text: str) -> None:
127
+ """Render markdown text with Rich."""
128
+ md = Markdown(text)
129
+ console.print(md)
@@ -0,0 +1 @@
1
+ """DevObin config package."""
@@ -0,0 +1,124 @@
1
+ """Configuration management for DevObin AI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from platformdirs import user_config_dir, user_data_dir
10
+
11
+ APP_NAME = "devobin"
12
+ CONFIG_DIR = Path(user_config_dir(APP_NAME))
13
+ DATA_DIR = Path(user_data_dir(APP_NAME))
14
+
15
+
16
+ def local_data_dir() -> Path:
17
+ """Return the .devobin directory inside the current working directory.
18
+
19
+ Chat history, memory, and project-specific data live here so the user
20
+ can find everything where they launched the CLI.
21
+ """
22
+ d = Path.cwd() / ".devobin"
23
+ d.mkdir(parents=True, exist_ok=True)
24
+ return d
25
+
26
+
27
+ def ensure_dirs() -> None:
28
+ """Create all required directories."""
29
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
30
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
31
+ (DATA_DIR / "projects").mkdir(parents=True, exist_ok=True)
32
+ (DATA_DIR / "prompts").mkdir(parents=True, exist_ok=True)
33
+
34
+
35
+ class ConfigManager:
36
+ """Manages application configuration and provider settings."""
37
+
38
+ def __init__(self) -> None:
39
+ ensure_dirs()
40
+ self._settings_path = CONFIG_DIR / "settings.json"
41
+ self._providers_path = CONFIG_DIR / "providers.json"
42
+ self._settings: dict[str, Any] = self._load(self._settings_path)
43
+ self._providers: dict[str, Any] = self._load(self._providers_path)
44
+
45
+ def _load(self, path: Path) -> dict[str, Any]:
46
+ if path.exists():
47
+ return json.loads(path.read_text(encoding="utf-8"))
48
+ return {}
49
+
50
+ def _save(self, path: Path, data: dict[str, Any]) -> None:
51
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
52
+
53
+ # ── General Settings ──────────────────────────────────────────
54
+
55
+ def get(self, key: str, default: Any = None) -> Any:
56
+ return self._settings.get(key, default)
57
+
58
+ def set(self, key: str, value: Any) -> None:
59
+ self._settings[key] = value
60
+ self._save(self._settings_path, self._settings)
61
+
62
+ @property
63
+ def active_provider(self) -> str | None:
64
+ return self._settings.get("active_provider")
65
+
66
+ @active_provider.setter
67
+ def active_provider(self, name: str) -> None:
68
+ self._settings["active_provider"] = name
69
+ self._save(self._settings_path, self._settings)
70
+
71
+ @property
72
+ def active_model(self) -> str | None:
73
+ return self._settings.get("active_model")
74
+
75
+ @active_model.setter
76
+ def active_model(self, model: str) -> None:
77
+ self._settings["active_model"] = model
78
+ self._save(self._settings_path, self._settings)
79
+
80
+ # ── Provider Management ───────────────────────────────────────
81
+
82
+ def list_providers(self) -> dict[str, Any]:
83
+ return self._providers
84
+
85
+ def get_provider(self, name: str) -> dict[str, Any] | None:
86
+ return self._providers.get(name)
87
+
88
+ def add_provider(self, name: str, config: dict[str, Any]) -> None:
89
+ self._providers[name] = config
90
+ self._save(self._providers_path, self._providers)
91
+
92
+ def remove_provider(self, name: str) -> bool:
93
+ if name in self._providers:
94
+ del self._providers[name]
95
+ self._save(self._providers_path, self._providers)
96
+ return True
97
+ return False
98
+
99
+ # ── Project Paths ─────────────────────────────────────────────
100
+
101
+ def projects_dir(self) -> Path:
102
+ d = DATA_DIR / "projects"
103
+ d.mkdir(parents=True, exist_ok=True)
104
+ return d
105
+
106
+ def project_dir(self, name: str) -> Path:
107
+ d = self.projects_dir() / name
108
+ d.mkdir(parents=True, exist_ok=True)
109
+ return d
110
+
111
+ def prompts_dir(self) -> Path:
112
+ d = DATA_DIR / "prompts"
113
+ d.mkdir(parents=True, exist_ok=True)
114
+ return d
115
+
116
+
117
+ _config: ConfigManager | None = None
118
+
119
+
120
+ def get_config() -> ConfigManager:
121
+ global _config
122
+ if _config is None:
123
+ _config = ConfigManager()
124
+ return _config
@@ -0,0 +1 @@
1
+ """DevObin core package."""
@@ -0,0 +1,166 @@
1
+ """Project analyzer - detects technologies and requirements from user input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class TechSignal:
11
+ """A detected technology with confidence and category."""
12
+
13
+ name: str
14
+ category: str # framework, database, frontend, language, tool, cloud
15
+ confidence: float = 1.0
16
+ keywords: list[str] = field(default_factory=list)
17
+
18
+
19
+ # ── Keyword-based detection patterns ──────────────────────────────
20
+
21
+ TECH_PATTERNS: list[TechSignal] = [
22
+ # Backend Frameworks
23
+ TechSignal("Django", "framework", keywords=["django", "djangorestframework", "drf"]),
24
+ TechSignal("FastAPI", "framework", keywords=["fastapi", "fast api"]),
25
+ TechSignal("Flask", "framework", keywords=["flask"]),
26
+ TechSignal("Express.js", "framework", keywords=["express", "expressjs", "express.js"]),
27
+ TechSignal("NestJS", "framework", keywords=["nestjs", "nest js"]),
28
+ TechSignal("Laravel", "framework", keywords=["laravel"]),
29
+ TechSignal("Ruby on Rails", "framework", keywords=["rails", "ruby on rails"]),
30
+ TechSignal("Spring Boot", "framework", keywords=["spring boot", "springboot"]),
31
+ TechSignal("ASP.NET", "framework", keywords=["asp.net", "dotnet", ".net core"]),
32
+ TechSignal("Gin", "framework", keywords=["gin", "gin-gonic"]),
33
+ TechSignal("Echo", "framework", keywords=["echo framework"]),
34
+ TechSignal("Actix", "framework", keywords=["actix", "actix-web"]),
35
+ TechSignal("Axum", "framework", keywords=["axum"]),
36
+ # Frontend
37
+ TechSignal("React", "frontend", keywords=["react", "reactjs", "react.js", "nextjs", "next.js"]),
38
+ TechSignal("Vue.js", "frontend", keywords=["vue", "vuejs", "vue.js", "nuxt", "nuxtjs"]),
39
+ TechSignal("Angular", "frontend", keywords=["angular", "angularjs"]),
40
+ TechSignal("Svelte", "frontend", keywords=["svelte", "sveltekit"]),
41
+ TechSignal("HTMX", "frontend", keywords=["htmx"]),
42
+ TechSignal("Tailwind CSS", "frontend", keywords=["tailwind", "tailwindcss"]),
43
+ TechSignal("Bootstrap", "frontend", keywords=["bootstrap"]),
44
+ TechSignal("shadcn/ui", "frontend", keywords=["shadcn", "shadcnui"]),
45
+ # Languages
46
+ TechSignal("Python", "language", keywords=["python", "py"]),
47
+ TechSignal("JavaScript", "language", keywords=["javascript", "js"]),
48
+ TechSignal("TypeScript", "language", keywords=["typescript", "ts"]),
49
+ TechSignal("Go", "language", keywords=["golang"]),
50
+ TechSignal("Rust", "language", keywords=["rust"]),
51
+ TechSignal("Java", "language", keywords=["java", " spring"]),
52
+ TechSignal("C#", "language", keywords=["c#", "csharp", "c sharp"]),
53
+ TechSignal("PHP", "language", keywords=["php"]),
54
+ TechSignal("Ruby", "language", keywords=["ruby"]),
55
+ # Databases
56
+ TechSignal("PostgreSQL", "database", keywords=["postgresql", "postgres", "psql"]),
57
+ TechSignal("MySQL", "database", keywords=["mysql", "mariadb"]),
58
+ TechSignal("SQLite", "database", keywords=["sqlite"]),
59
+ TechSignal("MongoDB", "database", keywords=["mongodb", "mongo"]),
60
+ TechSignal("Redis", "database", keywords=["redis"]),
61
+ TechSignal("Elasticsearch", "database", keywords=["elasticsearch", "elastic"]),
62
+ TechSignal("DynamoDB", "database", keywords=["dynamodb", "dynamo"]),
63
+ TechSignal("Supabase", "database", keywords=["supabase"]),
64
+ TechSignal("PlanetScale", "database", keywords=["planetscale"]),
65
+ # Tools & Services
66
+ TechSignal("Docker", "tool", keywords=["docker", "container", "containers"]),
67
+ TechSignal("Kubernetes", "tool", keywords=["kubernetes", "k8s"]),
68
+ TechSignal("Celery", "tool", keywords=["celery"]),
69
+ TechSignal("RabbitMQ", "tool", keywords=["rabbitmq", "rabbit"]),
70
+ TechSignal("Kafka", "tool", keywords=["kafka"]),
71
+ TechSignal("Nginx", "tool", keywords=["nginx"]),
72
+ TechSignal("Stripe", "tool", keywords=["stripe", "payment"]),
73
+ TechSignal("AWS", "cloud", keywords=["aws", "amazon web services"]),
74
+ TechSignal("GCP", "cloud", keywords=["gcp", "google cloud"]),
75
+ TechSignal("Azure", "cloud", keywords=["azure", "microsoft azure"]),
76
+ TechSignal("Vercel", "cloud", keywords=["vercel"]),
77
+ TechSignal("Cloudflare", "cloud", keywords=["cloudflare"]),
78
+ # Patterns
79
+ TechSignal("REST API", "pattern", keywords=["rest api", "restful", "rest"]),
80
+ TechSignal("GraphQL", "pattern", keywords=["graphql"]),
81
+ TechSignal("WebSocket", "pattern", keywords=["websocket", "real-time", "realtime"]),
82
+ TechSignal("Microservices", "pattern", keywords=["microservice", "microservices"]),
83
+ TechSignal("Serverless", "pattern", keywords=["serverless", "lambda"]),
84
+ TechSignal("Authentication", "pattern", keywords=["auth", "authentication", "login", "jwt", "oauth"]),
85
+ TechSignal("E-commerce", "domain", keywords=["ecommerce", "e-commerce", "marketplace", "shop", "store"]),
86
+ TechSignal("SaaS", "domain", keywords=["saas", "software as a service"]),
87
+ TechSignal("Blog", "domain", keywords=["blog", "blogging", "cms"]),
88
+ TechSignal("Chat", "domain", keywords=["chat", "messaging", "instant message"]),
89
+ TechSignal("Social Network", "domain", keywords=["social", "social network", "social media"]),
90
+ ]
91
+
92
+
93
+ @dataclass
94
+ class AnalysisResult:
95
+ """Result of project analysis."""
96
+
97
+ description: str
98
+ detected_techs: list[TechSignal] = field(default_factory=list)
99
+ project_type: str = ""
100
+ requirements: list[str] = field(default_factory=list)
101
+
102
+ @property
103
+ def frameworks(self) -> list[TechSignal]:
104
+ return [t for t in self.detected_techs if t.category == "framework"]
105
+
106
+ @property
107
+ def databases(self) -> list[TechSignal]:
108
+ return [t for t in self.detected_techs if t.category == "database"]
109
+
110
+ @property
111
+ def frontends(self) -> list[TechSignal]:
112
+ return [t for t in self.detected_techs if t.category == "frontend"]
113
+
114
+ @property
115
+ def patterns(self) -> list[TechSignal]:
116
+ return [t for t in self.detected_techs if t.category == "pattern"]
117
+
118
+ @property
119
+ def all_tech_names(self) -> list[str]:
120
+ return [t.name for t in self.detected_techs]
121
+
122
+
123
+ def analyze_input(text: str) -> AnalysisResult:
124
+ """Analyze user input and detect technologies mentioned."""
125
+ text_lower = text.lower()
126
+ detected: list[TechSignal] = []
127
+
128
+ for signal in TECH_PATTERNS:
129
+ for kw in signal.keywords:
130
+ if kw in text_lower:
131
+ detected.append(TechSignal(
132
+ name=signal.name,
133
+ category=signal.category,
134
+ confidence=signal.confidence,
135
+ ))
136
+ break
137
+
138
+ # Infer project type
139
+ project_type = "general"
140
+ if any(t.category == "domain" for t in detected):
141
+ domain_techs = [t for t in detected if t.category == "domain"]
142
+ project_type = domain_techs[0].name.lower()
143
+ elif any(t.category == "pattern" for t in detected):
144
+ project_type = detected[0].name.lower()
145
+
146
+ # Infer requirements
147
+ requirements = []
148
+ if any(t.name == "Authentication" for t in detected):
149
+ requirements.append("user authentication and authorization")
150
+ if any(t.category == "database" for t in detected):
151
+ requirements.append("database design and persistence")
152
+ if any(t.category == "frontend" for t in detected):
153
+ requirements.append("frontend user interface")
154
+ if any(t.name == "Docker" for t in detected):
155
+ requirements.append("containerized deployment")
156
+ if any(t.name in ("Stripe",) for t in detected):
157
+ requirements.append("payment processing")
158
+ if any(t.name in ("WebSocket", "Chat") for t in detected):
159
+ requirements.append("real-time communication")
160
+
161
+ return AnalysisResult(
162
+ description=text,
163
+ detected_techs=detected,
164
+ project_type=project_type,
165
+ requirements=requirements,
166
+ )
@@ -0,0 +1,244 @@
1
+ """Shell execution and file operations for DevObin AI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import shutil
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+
12
+ @dataclass
13
+ class ExecResult:
14
+ """Result of a shell command execution."""
15
+
16
+ command: str
17
+ stdout: str = ""
18
+ stderr: str = ""
19
+ return_code: int = 0
20
+ success: bool = True
21
+ timed_out: bool = False
22
+
23
+ @property
24
+ def output(self) -> str:
25
+ """Combined output for display."""
26
+ parts = []
27
+ if self.stdout.strip():
28
+ parts.append(self.stdout.strip())
29
+ if self.stderr.strip():
30
+ parts.append(f"[stderr]\n{self.stderr.strip()}")
31
+ if self.timed_out:
32
+ parts.append("[timed out after 30s]")
33
+ if not parts:
34
+ parts.append("(no output)")
35
+ return "\n".join(parts)
36
+
37
+
38
+ @dataclass
39
+ class FileResult:
40
+ """Result of a file operation."""
41
+
42
+ path: str
43
+ operation: str # read, write, create, delete, list, exists
44
+ success: bool = True
45
+ content: str = ""
46
+ error: str = ""
47
+ files: list[str] = field(default_factory=list)
48
+ size: int = 0
49
+
50
+ @property
51
+ def output(self) -> str:
52
+ """Formatted output for display."""
53
+ if not self.success:
54
+ return f"[red]Error:[/] {self.error}"
55
+
56
+ if self.operation == "read":
57
+ lines = self.content.count("\n") + 1
58
+ return f"[dim]{self.path}[/] ({lines} lines, {self.size} bytes)\n\n{self.content}"
59
+
60
+ if self.operation in ("write", "create"):
61
+ return f"[green]✓[/] {self.operation.title()}: [bold]{self.path}[/] ({self.size} bytes)"
62
+
63
+ if self.operation == "delete":
64
+ return f"[green]✓[/] Deleted: [bold]{self.path}[/]"
65
+
66
+ if self.operation == "list":
67
+ if not self.files:
68
+ return f"[dim]No files in {self.path}[/]"
69
+ header = f"[bold]{self.path}[/] ({len(self.files)} items):"
70
+ items = "\n".join(f" {'📁' if f.endswith('/') else '📄'} {f}" for f in self.files)
71
+ return f"{header}\n{items}"
72
+
73
+ if self.operation == "exists":
74
+ status = "[green]exists[/]" if self.success else "[red]not found[/]"
75
+ return f"{self.path}: {status}"
76
+
77
+ return ""
78
+
79
+
80
+ class ShellExecutor:
81
+ """Execute shell commands safely."""
82
+
83
+ def __init__(self, cwd: str | Path | None = None) -> None:
84
+ self.cwd = str(cwd) if cwd else os.getcwd()
85
+ self.timeout = 30 # seconds
86
+
87
+ def run(self, command: str) -> ExecResult:
88
+ """Execute a shell command and return the result."""
89
+ if not command.strip():
90
+ return ExecResult(command=command, success=False, stderr="Empty command")
91
+
92
+ try:
93
+ result = subprocess.run(
94
+ command,
95
+ shell=True,
96
+ capture_output=True,
97
+ text=True,
98
+ timeout=self.timeout,
99
+ cwd=self.cwd,
100
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"},
101
+ )
102
+ return ExecResult(
103
+ command=command,
104
+ stdout=result.stdout,
105
+ stderr=result.stderr,
106
+ return_code=result.returncode,
107
+ success=(result.returncode == 0),
108
+ )
109
+ except subprocess.TimeoutExpired:
110
+ return ExecResult(
111
+ command=command,
112
+ success=False,
113
+ timed_out=True,
114
+ stderr=f"Command timed out after {self.timeout}s",
115
+ )
116
+ except Exception as e:
117
+ return ExecResult(
118
+ command=command,
119
+ success=False,
120
+ stderr=str(e),
121
+ )
122
+
123
+
124
+ class FileManager:
125
+ """Read, write, and manage files."""
126
+
127
+ def __init__(self, base_dir: str | Path | None = None) -> None:
128
+ self.base_dir = Path(base_dir) if base_dir else Path.cwd()
129
+
130
+ def _resolve(self, path: str) -> Path:
131
+ """Resolve path relative to base_dir, preventing escapes."""
132
+ p = Path(path)
133
+ if p.is_absolute():
134
+ return p.resolve()
135
+ return (self.base_dir / p).resolve()
136
+
137
+ def read(self, path: str, max_lines: int = 0) -> FileResult:
138
+ """Read a file's contents.
139
+
140
+ ``max_lines=0`` (default) reads the whole file — DevObin explores real
141
+ projects, so no artificial truncation. Pass a positive value only when a
142
+ short preview is explicitly wanted.
143
+ """
144
+ resolved = self._resolve(path)
145
+ if not resolved.exists():
146
+ return FileResult(path=str(resolved), operation="read", success=False,
147
+ error=f"File not found: {resolved}")
148
+ if not resolved.is_file():
149
+ return FileResult(path=str(resolved), operation="read", success=False,
150
+ error=f"Not a file: {resolved}")
151
+
152
+ try:
153
+ content = resolved.read_text(encoding="utf-8")
154
+ if max_lines and max_lines > 0:
155
+ lines = content.split("\n")
156
+ if len(lines) > max_lines:
157
+ content = "\n".join(lines[:max_lines])
158
+ content += f"\n\n... ({len(lines) - max_lines} more lines truncated)"
159
+ return FileResult(
160
+ path=str(resolved),
161
+ operation="read",
162
+ content=content,
163
+ size=resolved.stat().st_size,
164
+ )
165
+ except Exception as e:
166
+ return FileResult(path=str(resolved), operation="read", success=False, error=str(e))
167
+
168
+ def write(self, path: str, content: str, mkdir: bool = True) -> FileResult:
169
+ """Write content to a file, creating directories if needed."""
170
+ resolved = self._resolve(path)
171
+ try:
172
+ if mkdir:
173
+ resolved.parent.mkdir(parents=True, exist_ok=True)
174
+ resolved.write_text(content, encoding="utf-8")
175
+ return FileResult(
176
+ path=str(resolved),
177
+ operation="create" if not Path(path).exists() else "write",
178
+ size=len(content.encode("utf-8")),
179
+ )
180
+ except Exception as e:
181
+ return FileResult(path=str(resolved), operation="write", success=False, error=str(e))
182
+
183
+ def list_dir(self, path: str = ".") -> FileResult:
184
+ """List directory contents."""
185
+ resolved = self._resolve(path)
186
+ if not resolved.exists():
187
+ return FileResult(path=str(resolved), operation="list", success=False,
188
+ error=f"Directory not found: {resolved}")
189
+ if not resolved.is_dir():
190
+ return FileResult(path=str(resolved), operation="list", success=False,
191
+ error=f"Not a directory: {resolved}")
192
+
193
+ try:
194
+ entries = sorted(
195
+ (e.name + "/" if e.is_dir() else e.name)
196
+ for e in resolved.iterdir()
197
+ if not e.name.startswith(".")
198
+ )
199
+ return FileResult(path=str(resolved), operation="list", files=entries)
200
+ except Exception as e:
201
+ return FileResult(path=str(resolved), operation="list", success=False, error=str(e))
202
+
203
+ def exists(self, path: str) -> FileResult:
204
+ """Check if a file or directory exists."""
205
+ resolved = self._resolve(path)
206
+ return FileResult(
207
+ path=str(resolved),
208
+ operation="exists",
209
+ success=resolved.exists(),
210
+ )
211
+
212
+ def delete(self, path: str) -> FileResult:
213
+ """Delete a file."""
214
+ resolved = self._resolve(path)
215
+ if not resolved.exists():
216
+ return FileResult(path=str(resolved), operation="delete", success=False,
217
+ error=f"Not found: {resolved}")
218
+ try:
219
+ if resolved.is_dir():
220
+ shutil.rmtree(resolved)
221
+ else:
222
+ resolved.unlink()
223
+ return FileResult(path=str(resolved), operation="delete")
224
+ except Exception as e:
225
+ return FileResult(path=str(resolved), operation="delete", success=False, error=str(e))
226
+
227
+
228
+ # Singleton instances
229
+ _shell: ShellExecutor | None = None
230
+ _files: FileManager | None = None
231
+
232
+
233
+ def get_executor(cwd: str | Path | None = None) -> ShellExecutor:
234
+ global _shell
235
+ if _shell is None or cwd is not None:
236
+ _shell = ShellExecutor(cwd)
237
+ return _shell
238
+
239
+
240
+ def get_file_manager(base_dir: str | Path | None = None) -> FileManager:
241
+ global _files
242
+ if _files is None or base_dir is not None:
243
+ _files = FileManager(base_dir)
244
+ return _files