Skip to content

Commit 4ca2f19

Browse files
authored
Merge pull request #66 from ryanmac/refactor-setup-modular-structure
2 parents 785de23 + 42933c9 commit 4ca2f19

File tree

9 files changed

+1972
-15
lines changed

9 files changed

+1972
-15
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""High-performance cache for all setup operations."""
2+
3+
import json
4+
import hashlib
5+
import time
6+
from pathlib import Path
7+
from typing import Any, Callable, Optional, Dict
8+
9+
10+
class SetupCache:
11+
"""Cache detection results and API calls for speed."""
12+
13+
def __init__(self):
14+
self.cache_dir = Path.home() / ".conductor" / ".cache"
15+
self.cache_dir.mkdir(parents=True, exist_ok=True)
16+
self.memory_cache: Dict[str, Dict[str, Any]] = {}
17+
18+
def get(self, key: str) -> Optional[Any]:
19+
"""Get from memory cache first, then disk."""
20+
# Memory cache (fastest)
21+
if key in self.memory_cache:
22+
entry = self.memory_cache[key]
23+
if time.time() - entry["timestamp"] < entry["ttl"]:
24+
return entry["value"]
25+
26+
# Disk cache (fast)
27+
cache_file = self.cache_dir / f"{key}.json"
28+
if cache_file.exists():
29+
try:
30+
data = json.loads(cache_file.read_text())
31+
if time.time() - data["timestamp"] < data["ttl"]:
32+
# Populate memory cache
33+
self.memory_cache[key] = data
34+
return data["value"]
35+
except Exception:
36+
# Invalid cache file, ignore
37+
pass
38+
39+
return None
40+
41+
def set(self, key: str, value: Any, ttl: int = 3600) -> None:
42+
"""Set in both memory and disk cache."""
43+
entry = {"value": value, "timestamp": time.time(), "ttl": ttl}
44+
45+
# Memory cache
46+
self.memory_cache[key] = entry
47+
48+
# Disk cache
49+
cache_file = self.cache_dir / f"{key}.json"
50+
try:
51+
cache_file.write_text(json.dumps(entry, indent=2))
52+
except Exception:
53+
# Ignore cache write failures
54+
pass
55+
56+
def get_or_compute(self, key: str, compute_fn: Callable, ttl: int = 3600) -> Any:
57+
"""Get from cache or compute and cache result."""
58+
cached = self.get(key)
59+
if cached is not None:
60+
return cached
61+
62+
value = compute_fn()
63+
self.set(key, value, ttl)
64+
return value
65+
66+
def clear(self) -> None:
67+
"""Clear all caches."""
68+
self.memory_cache.clear()
69+
try:
70+
for cache_file in self.cache_dir.glob("*.json"):
71+
cache_file.unlink()
72+
except Exception:
73+
# Ignore cache clear failures
74+
pass
75+
76+
def get_project_hash(self, project_root: Path) -> str:
77+
"""Generate unique hash for project state."""
78+
key_files = [
79+
"package.json",
80+
"pyproject.toml",
81+
"Cargo.toml",
82+
"go.mod",
83+
"requirements.txt",
84+
"Gemfile",
85+
"pom.xml",
86+
"build.gradle",
87+
]
88+
hasher = hashlib.md5()
89+
90+
# Hash key files that define dependencies
91+
for file_name in key_files:
92+
file_path = project_root / file_name
93+
if file_path.exists():
94+
try:
95+
hasher.update(file_path.read_bytes())
96+
except Exception:
97+
# Ignore read errors
98+
pass
99+
100+
# Hash directory structure for better cache invalidation
101+
try:
102+
for p in sorted(project_root.rglob("*")):
103+
if p.is_file() and not any(
104+
skip in str(p) for skip in [".git", "__pycache__", "node_modules"]
105+
):
106+
hasher.update(str(p.relative_to(project_root)).encode())
107+
except Exception:
108+
# Ignore traversal errors
109+
pass
110+
111+
return hasher.hexdigest()[:12]
112+
113+
114+
# Global cache instance
115+
_cache = None
116+
117+
118+
def get_cache() -> SetupCache:
119+
"""Get global cache instance (singleton)."""
120+
global _cache
121+
if _cache is None:
122+
_cache = SetupCache()
123+
return _cache

.conductor/conductor_setup/config_manager.py

Lines changed: 237 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,117 @@
77
from pathlib import Path
88
from typing import Dict, Any, List, Optional
99

10+
from .cache_manager import get_cache
11+
from .ui_manager import UIManager
12+
13+
14+
# Express configurations for common project types
15+
EXPRESS_CONFIGS = {
16+
"react-typescript": {
17+
"patterns": ["react", "typescript", "tsx", "jsx"],
18+
"roles": {"default": "dev", "specialized": ["frontend", "code-reviewer"]},
19+
"github_integration": {"issue_to_task": True, "pr_reviews": True},
20+
"build_validation": ["npm test", "npm run build"],
21+
"suggested_tasks": [
22+
{
23+
"title": "Set up component testing with React Testing Library",
24+
"labels": ["conductor:task", "testing", "frontend"],
25+
},
26+
{
27+
"title": "Add Storybook for component development",
28+
"labels": ["conductor:task", "enhancement", "frontend"],
29+
},
30+
{
31+
"title": "Configure ESLint and Prettier",
32+
"labels": ["conductor:task", "code-quality", "dev-experience"],
33+
},
34+
],
35+
},
36+
"python-fastapi": {
37+
"patterns": ["fastapi", "python", "uvicorn", "pydantic"],
38+
"roles": {"default": "dev", "specialized": ["backend", "code-reviewer"]},
39+
"github_integration": {"issue_to_task": True, "pr_reviews": True},
40+
"build_validation": ["pytest", "black --check ."],
41+
"suggested_tasks": [
42+
{
43+
"title": "Add API documentation with OpenAPI",
44+
"labels": ["conductor:task", "documentation", "backend"],
45+
},
46+
{
47+
"title": "Set up database migrations with Alembic",
48+
"labels": ["conductor:task", "database", "backend"],
49+
},
50+
{
51+
"title": "Add integration tests for endpoints",
52+
"labels": ["conductor:task", "testing", "backend"],
53+
},
54+
],
55+
},
56+
"nextjs-fullstack": {
57+
"patterns": ["next", "react", "vercel"],
58+
"roles": {
59+
"default": "dev",
60+
"specialized": ["frontend", "backend", "code-reviewer"],
61+
},
62+
"github_integration": {"issue_to_task": True, "pr_reviews": True},
63+
"build_validation": ["npm test", "npm run build", "npm run lint"],
64+
"suggested_tasks": [
65+
{
66+
"title": "Set up authentication with NextAuth.js",
67+
"labels": ["conductor:task", "auth", "fullstack"],
68+
},
69+
{
70+
"title": "Configure Prisma for database access",
71+
"labels": ["conductor:task", "database", "backend"],
72+
},
73+
{
74+
"title": "Add E2E tests with Playwright",
75+
"labels": ["conductor:task", "testing", "e2e"],
76+
},
77+
],
78+
},
79+
"vue-javascript": {
80+
"patterns": ["vue", "nuxt", "vite"],
81+
"roles": {"default": "dev", "specialized": ["frontend", "code-reviewer"]},
82+
"github_integration": {"issue_to_task": True, "pr_reviews": True},
83+
"build_validation": ["npm test", "npm run build"],
84+
"suggested_tasks": [
85+
{
86+
"title": "Set up Pinia for state management",
87+
"labels": ["conductor:task", "state-management", "frontend"],
88+
},
89+
{
90+
"title": "Add component testing with Vitest",
91+
"labels": ["conductor:task", "testing", "frontend"],
92+
},
93+
{
94+
"title": "Configure Vue Router for navigation",
95+
"labels": ["conductor:task", "routing", "frontend"],
96+
},
97+
],
98+
},
99+
"python-django": {
100+
"patterns": ["django", "python", "wsgi"],
101+
"roles": {"default": "dev", "specialized": ["backend", "code-reviewer"]},
102+
"github_integration": {"issue_to_task": True, "pr_reviews": True},
103+
"build_validation": ["python manage.py test", "black --check ."],
104+
"suggested_tasks": [
105+
{
106+
"title": "Set up Django REST framework",
107+
"labels": ["conductor:task", "api", "backend"],
108+
},
109+
{
110+
"title": "Configure Celery for async tasks",
111+
"labels": ["conductor:task", "async", "backend"],
112+
},
113+
{
114+
"title": "Add Django Debug Toolbar",
115+
"labels": ["conductor:task", "dev-experience", "backend"],
116+
},
117+
],
118+
},
119+
}
120+
10121

11122
class ConfigurationManager:
12123
"""Manages project configuration through interactive or automatic setup"""
@@ -18,17 +129,141 @@ def __init__(
18129
self.auto_mode = auto_mode
19130
self.debug = debug
20131
self.config = {}
132+
self.cache = get_cache()
21133

22134
def gather_configuration(
23-
self, detected_stack: List[Dict[str, Any]]
135+
self,
136+
detected_stack: List[Dict[str, Any]],
137+
enhanced_stack: Optional[Dict[str, Any]] = None,
138+
ui: Optional[UIManager] = None,
24139
) -> Dict[str, Any]:
25-
"""Gather configuration through interactive prompts or auto-configuration"""
140+
"""Gather configuration with express-by-default approach"""
141+
# Try express config first if we have enhanced stack info
142+
if enhanced_stack and ui:
143+
express_config = self.get_express_config(enhanced_stack)
144+
if express_config:
145+
return self.apply_express_config(express_config, enhanced_stack, ui)
146+
147+
# Fall back to legacy modes
26148
if self.auto_mode:
27149
self._auto_configure(detected_stack)
28150
else:
29151
self._interactive_configure(detected_stack)
30152
return self.config
31153

154+
def get_express_config(
155+
self, stack_info: Dict[str, Any]
156+
) -> Optional[Dict[str, Any]]:
157+
"""Match detected stack to express config"""
158+
# Use the primary stack from summary if available
159+
if stack_info.get("summary", {}).get("primary_stack"):
160+
stack_name = stack_info["summary"]["primary_stack"]
161+
if stack_name in EXPRESS_CONFIGS:
162+
return EXPRESS_CONFIGS[stack_name]
163+
164+
# Otherwise try pattern matching
165+
detected_items = set()
166+
detected_items.update(stack_info.get("frameworks", []))
167+
detected_items.update(stack_info.get("summary", {}).get("languages", []))
168+
detected_items.update(stack_info.get("summary", {}).get("tools", []))
169+
170+
# Add items from modern tools
171+
modern = stack_info.get("modern_tools", {})
172+
if modern.get("framework"):
173+
detected_items.add(modern["framework"])
174+
if modern.get("build_tool"):
175+
detected_items.add(modern["build_tool"])
176+
177+
# Find best match
178+
best_match = None
179+
best_score = 0
180+
181+
for stack_name, config in EXPRESS_CONFIGS.items():
182+
score = len(detected_items.intersection(config["patterns"]))
183+
if score > best_score:
184+
best_match = stack_name
185+
best_score = score
186+
187+
return (
188+
EXPRESS_CONFIGS.get(best_match) if best_match and best_score > 0 else None
189+
)
190+
191+
def apply_express_config(
192+
self, express_config: Dict[str, Any], stack_info: Dict[str, Any], ui: UIManager
193+
) -> Dict[str, Any]:
194+
"""Apply express configuration without prompts"""
195+
primary_stack = stack_info.get("summary", {}).get("primary_stack", "project")
196+
ui.console.print(
197+
f"\nDetected {primary_stack} - applying optimal configuration..."
198+
)
199+
200+
with ui.create_progress() as progress:
201+
task = progress.add_task("Configuring", total=4)
202+
203+
progress.update(task, advance=1, description="Setting project defaults...")
204+
self.config["project_name"] = self._infer_project_name()
205+
self.config["docs_directory"] = self._infer_docs_directory()
206+
207+
progress.update(task, advance=1, description="Configuring agent roles...")
208+
self.config["roles"] = express_config["roles"]
209+
210+
progress.update(task, advance=1, description="Enabling integrations...")
211+
self.config["github_integration"] = express_config["github_integration"]
212+
self.config["task_management"] = "github-issues"
213+
self.config["max_concurrent_agents"] = 5
214+
215+
progress.update(task, advance=1, description="Preparing starter tasks...")
216+
self.config["suggested_tasks"] = express_config["suggested_tasks"]
217+
self.config["build_validation"] = express_config.get("build_validation", [])
218+
219+
# Add metadata
220+
self.config["setup_mode"] = "express"
221+
self.config["stack_info"] = stack_info
222+
self.config["stack_summary"] = stack_info.get("summary", {}).get(
223+
"primary_stack", "Unknown"
224+
)
225+
self.config["task_count"] = len(express_config["suggested_tasks"])
226+
227+
return self.config
228+
229+
def _infer_project_name(self) -> str:
230+
"""Infer project name from directory or package files"""
231+
# Try package.json first
232+
if (self.project_root / "package.json").exists():
233+
try:
234+
import json
235+
236+
package = json.loads((self.project_root / "package.json").read_text())
237+
if package.get("name"):
238+
return package["name"]
239+
except Exception:
240+
pass
241+
242+
# Try pyproject.toml
243+
if (self.project_root / "pyproject.toml").exists():
244+
try:
245+
content = (self.project_root / "pyproject.toml").read_text()
246+
for line in content.split("\n"):
247+
if line.strip().startswith("name"):
248+
name = line.split("=")[1].strip().strip("\"'")
249+
if name:
250+
return name
251+
except Exception:
252+
pass
253+
254+
# Default to directory name
255+
return self.project_root.name
256+
257+
def _infer_docs_directory(self) -> str:
258+
"""Infer documentation directory"""
259+
if (self.project_root / "docs").exists():
260+
return "docs"
261+
elif (self.project_root / "documentation").exists():
262+
return "documentation"
263+
elif (self.project_root / "doc").exists():
264+
return "doc"
265+
return "docs"
266+
32267
def _safe_input(self, prompt: str, default: Optional[str] = None) -> str:
33268
"""Safe input with error handling"""
34269
try:

0 commit comments

Comments
 (0)