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.
- devobin/__init__.py +4 -0
- devobin/app.py +42 -0
- devobin/cli/__init__.py +1 -0
- devobin/cli/chat.py +831 -0
- devobin/cli/commands.py +362 -0
- devobin/cli/slash_menu.py +74 -0
- devobin/cli/terminal.py +155 -0
- devobin/cli/ui.py +129 -0
- devobin/config/__init__.py +1 -0
- devobin/config/settings.py +124 -0
- devobin/core/__init__.py +1 -0
- devobin/core/analyzer.py +166 -0
- devobin/core/executor.py +244 -0
- devobin/core/memory.py +78 -0
- devobin/core/optimizer.py +106 -0
- devobin/core/prompt_builder.py +513 -0
- devobin/core/researcher.py +283 -0
- devobin/core/rtl.py +93 -0
- devobin/core/scanner.py +206 -0
- devobin/core/tools.py +283 -0
- devobin/core/validator.py +235 -0
- devobin/main.py +33 -0
- devobin/output/__init__.py +1 -0
- devobin/providers/__init__.py +85 -0
- devobin/providers/anthropic_provider.py +72 -0
- devobin/providers/google_provider.py +90 -0
- devobin/providers/ollama_provider.py +81 -0
- devobin/providers/openai_provider.py +102 -0
- devobin/storage/__init__.py +1 -0
- devobin/storage/database.py +207 -0
- devobin/ui/__init__.py +1 -0
- devobin/ui/ask_modal.py +96 -0
- devobin/ui/chat_screen.py +1029 -0
- devobin/ui/command_palette.py +131 -0
- devobin/ui/connect_modal.py +166 -0
- devobin/ui/export_modal.py +159 -0
- devobin/ui/history_modal.py +137 -0
- devobin/ui/memory_modal.py +88 -0
- devobin/ui/model_modal.py +143 -0
- devobin/ui/permission_modal.py +92 -0
- devobin/ui/project_modal.py +133 -0
- devobin/ui/settings_modal.py +89 -0
- devobin-1.0.0.dist-info/METADATA +364 -0
- devobin-1.0.0.dist-info/RECORD +47 -0
- devobin-1.0.0.dist-info/WHEEL +4 -0
- devobin-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
devobin/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DevObin core package."""
|
devobin/core/analyzer.py
ADDED
|
@@ -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
|
+
)
|
devobin/core/executor.py
ADDED
|
@@ -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
|