codeframe-ai 0.9.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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
codeframe/core/config.py
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
"""Configuration management for CodeFRAME.
|
|
2
|
+
|
|
3
|
+
This module provides two configuration systems:
|
|
4
|
+
1. Legacy v1 config (JSON-based): ProjectConfig, GlobalConfig
|
|
5
|
+
2. v2 environment config (YAML-based): EnvironmentConfig
|
|
6
|
+
|
|
7
|
+
v2 environment config is stored in .codeframe/config.yaml and controls:
|
|
8
|
+
- Package manager (uv, pip, poetry, npm, etc.)
|
|
9
|
+
- Language versions (Python, Node)
|
|
10
|
+
- Test framework (pytest, jest, vitest)
|
|
11
|
+
- Lint tools (ruff, eslint, prettier)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass, field as dataclass_field, asdict
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
from pydantic import BaseModel, Field, field_validator
|
|
22
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
23
|
+
from dotenv import load_dotenv
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# v2 Environment Configuration (YAML-based)
|
|
28
|
+
# =============================================================================
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PackageManager(str, Enum):
|
|
32
|
+
"""Supported package managers."""
|
|
33
|
+
|
|
34
|
+
UV = "uv"
|
|
35
|
+
PIP = "pip"
|
|
36
|
+
POETRY = "poetry"
|
|
37
|
+
NPM = "npm"
|
|
38
|
+
PNPM = "pnpm"
|
|
39
|
+
YARN = "yarn"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestFramework(str, Enum):
|
|
43
|
+
"""Supported test frameworks."""
|
|
44
|
+
|
|
45
|
+
PYTEST = "pytest"
|
|
46
|
+
JEST = "jest"
|
|
47
|
+
VITEST = "vitest"
|
|
48
|
+
UNITTEST = "unittest"
|
|
49
|
+
MOCHA = "mocha"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LintTool(str, Enum):
|
|
53
|
+
"""Supported lint tools."""
|
|
54
|
+
|
|
55
|
+
RUFF = "ruff"
|
|
56
|
+
PYLINT = "pylint"
|
|
57
|
+
FLAKE8 = "flake8"
|
|
58
|
+
MYPY = "mypy"
|
|
59
|
+
ESLINT = "eslint"
|
|
60
|
+
PRETTIER = "prettier"
|
|
61
|
+
BIOME = "biome"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class LLMConfig:
|
|
66
|
+
"""Per-workspace LLM provider configuration.
|
|
67
|
+
|
|
68
|
+
Stored under the ``llm:`` key in ``.codeframe/config.yaml``.
|
|
69
|
+
Priority (lowest → highest): config file → env var → CLI flag.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
provider: Optional[str] = None # e.g. "anthropic", "openai", "ollama"
|
|
73
|
+
model: Optional[str] = None # e.g. "gpt-4o", "qwen2.5-coder:7b"
|
|
74
|
+
base_url: Optional[str] = None # e.g. "http://localhost:11434/v1"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ContextConfig:
|
|
79
|
+
"""Context loading configuration."""
|
|
80
|
+
|
|
81
|
+
max_files: int = 20
|
|
82
|
+
max_file_size: int = 5000 # lines
|
|
83
|
+
max_total_tokens: int = 50000
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class AgentBudgetConfig:
|
|
88
|
+
"""Agent iteration budget configuration."""
|
|
89
|
+
base_iterations: int = 30
|
|
90
|
+
min_iterations: int = 15
|
|
91
|
+
max_iterations: int = 100
|
|
92
|
+
auto_fix_enabled: bool = True
|
|
93
|
+
early_termination_enabled: bool = True
|
|
94
|
+
stall_timeout_s: int = 300
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class BatchConfig:
|
|
99
|
+
"""Batch execution configuration."""
|
|
100
|
+
|
|
101
|
+
max_parallel: int = 4
|
|
102
|
+
max_parallel_by_status: dict[str, int] = dataclass_field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class HooksConfig:
|
|
107
|
+
"""Workspace lifecycle hooks configuration.
|
|
108
|
+
|
|
109
|
+
Hooks are shell commands executed at specific lifecycle points.
|
|
110
|
+
Template variables (e.g., {{task_id}}) are rendered via Jinja2.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
after_init: Optional[str] = None
|
|
114
|
+
before_task: Optional[str] = None
|
|
115
|
+
after_task_success: Optional[str] = None
|
|
116
|
+
after_task_failure: Optional[str] = None
|
|
117
|
+
before_remove: Optional[str] = None
|
|
118
|
+
hook_timeout: int = 60
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class EnvironmentConfig:
|
|
123
|
+
"""v2 project environment configuration.
|
|
124
|
+
|
|
125
|
+
Stored in .codeframe/config.yaml. Controls how the agent
|
|
126
|
+
interacts with the project's development environment.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
# Package management
|
|
130
|
+
package_manager: str = "uv"
|
|
131
|
+
python_version: Optional[str] = None # e.g., "3.11"
|
|
132
|
+
node_version: Optional[str] = None # e.g., "18"
|
|
133
|
+
|
|
134
|
+
# Testing
|
|
135
|
+
test_framework: str = "pytest"
|
|
136
|
+
test_command: Optional[str] = None # Override, e.g., "pytest -v tests/"
|
|
137
|
+
|
|
138
|
+
# Linting
|
|
139
|
+
lint_tools: list[str] = dataclass_field(default_factory=lambda: ["ruff"])
|
|
140
|
+
lint_command: Optional[str] = None # Override, e.g., "ruff check ."
|
|
141
|
+
|
|
142
|
+
# Context loading
|
|
143
|
+
context: ContextConfig = dataclass_field(default_factory=ContextConfig)
|
|
144
|
+
|
|
145
|
+
# Agent budget
|
|
146
|
+
agent_budget: AgentBudgetConfig = dataclass_field(default_factory=AgentBudgetConfig)
|
|
147
|
+
|
|
148
|
+
# Batch execution
|
|
149
|
+
batch: BatchConfig = dataclass_field(default_factory=BatchConfig)
|
|
150
|
+
|
|
151
|
+
# Workspace lifecycle hooks
|
|
152
|
+
hooks: HooksConfig = dataclass_field(default_factory=HooksConfig)
|
|
153
|
+
|
|
154
|
+
# Reconciliation during batch execution
|
|
155
|
+
reconciliation_interval_seconds: int = 30
|
|
156
|
+
|
|
157
|
+
# Execution engine
|
|
158
|
+
engine: str = "react"
|
|
159
|
+
|
|
160
|
+
# Custom command overrides
|
|
161
|
+
custom_commands: dict[str, str] = dataclass_field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
# LLM provider config (workspace-level default; overridden by env vars and CLI flags)
|
|
164
|
+
llm: Optional[LLMConfig] = None
|
|
165
|
+
|
|
166
|
+
# Settings page (issue #554) — UI-managed agent settings
|
|
167
|
+
max_cost_usd: Optional[float] = None
|
|
168
|
+
agent_type_models: dict[str, str] = dataclass_field(default_factory=dict)
|
|
169
|
+
|
|
170
|
+
def validate(self) -> list[str]:
|
|
171
|
+
"""Validate configuration values.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
List of validation error messages (empty if valid)
|
|
175
|
+
"""
|
|
176
|
+
errors = []
|
|
177
|
+
|
|
178
|
+
# Validate package manager
|
|
179
|
+
valid_pkg_managers = [pm.value for pm in PackageManager]
|
|
180
|
+
if self.package_manager not in valid_pkg_managers:
|
|
181
|
+
errors.append(
|
|
182
|
+
f"Invalid package_manager '{self.package_manager}'. "
|
|
183
|
+
f"Must be one of: {', '.join(valid_pkg_managers)}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Validate test framework
|
|
187
|
+
valid_test_frameworks = [tf.value for tf in TestFramework]
|
|
188
|
+
if self.test_framework not in valid_test_frameworks:
|
|
189
|
+
errors.append(
|
|
190
|
+
f"Invalid test_framework '{self.test_framework}'. "
|
|
191
|
+
f"Must be one of: {', '.join(valid_test_frameworks)}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Validate lint tools
|
|
195
|
+
valid_lint_tools = [lt.value for lt in LintTool]
|
|
196
|
+
for tool in self.lint_tools:
|
|
197
|
+
if tool not in valid_lint_tools:
|
|
198
|
+
errors.append(
|
|
199
|
+
f"Invalid lint tool '{tool}'. "
|
|
200
|
+
f"Must be one of: {', '.join(valid_lint_tools)}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Validate engine
|
|
204
|
+
from codeframe.core.engine_registry import VALID_ENGINES
|
|
205
|
+
if self.engine not in VALID_ENGINES:
|
|
206
|
+
errors.append(
|
|
207
|
+
f"Invalid engine '{self.engine}'. "
|
|
208
|
+
f"Must be one of: {', '.join(sorted(VALID_ENGINES))}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Validate agent budget
|
|
212
|
+
budget = self.agent_budget
|
|
213
|
+
if any(v <= 0 for v in (budget.base_iterations, budget.min_iterations, budget.max_iterations)):
|
|
214
|
+
errors.append("agent_budget iterations must be positive integers")
|
|
215
|
+
if budget.min_iterations > budget.max_iterations:
|
|
216
|
+
errors.append("agent_budget.min_iterations cannot exceed max_iterations")
|
|
217
|
+
if not (budget.min_iterations <= budget.base_iterations <= budget.max_iterations):
|
|
218
|
+
errors.append(
|
|
219
|
+
"agent_budget.base_iterations must be between min_iterations and max_iterations"
|
|
220
|
+
)
|
|
221
|
+
if budget.stall_timeout_s < 0:
|
|
222
|
+
errors.append("agent_budget.stall_timeout_s must be >= 0 (0 = disabled)")
|
|
223
|
+
|
|
224
|
+
if self.max_cost_usd is not None and self.max_cost_usd < 0:
|
|
225
|
+
errors.append("max_cost_usd must be >= 0")
|
|
226
|
+
|
|
227
|
+
valid_agent_types = {"claude_code", "codex", "opencode", "react"}
|
|
228
|
+
invalid_agent_types = sorted(set(self.agent_type_models) - valid_agent_types)
|
|
229
|
+
if invalid_agent_types:
|
|
230
|
+
errors.append(
|
|
231
|
+
"agent_type_models contains unsupported agent types: "
|
|
232
|
+
+ ", ".join(invalid_agent_types)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return errors
|
|
236
|
+
|
|
237
|
+
def get_install_command(self, package: str) -> str:
|
|
238
|
+
"""Get the install command for a package.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
package: Package name to install
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Full install command string
|
|
245
|
+
"""
|
|
246
|
+
if self.package_manager == "uv":
|
|
247
|
+
return f"uv pip install {package}"
|
|
248
|
+
elif self.package_manager == "pip":
|
|
249
|
+
return f"pip install {package}"
|
|
250
|
+
elif self.package_manager == "poetry":
|
|
251
|
+
return f"poetry add {package}"
|
|
252
|
+
elif self.package_manager in ("npm", "pnpm", "yarn"):
|
|
253
|
+
return f"{self.package_manager} install {package}"
|
|
254
|
+
else:
|
|
255
|
+
return f"pip install {package}" # fallback
|
|
256
|
+
|
|
257
|
+
def get_test_command(self) -> str:
|
|
258
|
+
"""Get the test command for this project.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Test command string
|
|
262
|
+
"""
|
|
263
|
+
if self.test_command:
|
|
264
|
+
return self.test_command
|
|
265
|
+
|
|
266
|
+
if self.test_framework == "pytest":
|
|
267
|
+
return "pytest"
|
|
268
|
+
elif self.test_framework == "jest":
|
|
269
|
+
return "npm test" if self.package_manager == "npm" else "jest"
|
|
270
|
+
elif self.test_framework == "vitest":
|
|
271
|
+
return "npm test" if self.package_manager == "npm" else "vitest"
|
|
272
|
+
elif self.test_framework == "unittest":
|
|
273
|
+
return "python -m unittest discover"
|
|
274
|
+
elif self.test_framework == "mocha":
|
|
275
|
+
return "npm test" if self.package_manager == "npm" else "mocha"
|
|
276
|
+
else:
|
|
277
|
+
return "pytest" # fallback
|
|
278
|
+
|
|
279
|
+
def get_lint_command(self) -> str:
|
|
280
|
+
"""Get the lint command for this project.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Lint command string
|
|
284
|
+
"""
|
|
285
|
+
if self.lint_command:
|
|
286
|
+
return self.lint_command
|
|
287
|
+
|
|
288
|
+
if not self.lint_tools:
|
|
289
|
+
return "ruff check ." # default
|
|
290
|
+
|
|
291
|
+
# Use the first lint tool
|
|
292
|
+
tool = self.lint_tools[0]
|
|
293
|
+
if tool == "ruff":
|
|
294
|
+
return "ruff check ."
|
|
295
|
+
elif tool == "pylint":
|
|
296
|
+
return "pylint ."
|
|
297
|
+
elif tool == "flake8":
|
|
298
|
+
return "flake8 ."
|
|
299
|
+
elif tool == "mypy":
|
|
300
|
+
return "mypy ."
|
|
301
|
+
elif tool == "eslint":
|
|
302
|
+
return "eslint ."
|
|
303
|
+
elif tool == "prettier":
|
|
304
|
+
return "prettier --check ."
|
|
305
|
+
elif tool == "biome":
|
|
306
|
+
return "biome check ."
|
|
307
|
+
else:
|
|
308
|
+
return "ruff check ." # fallback
|
|
309
|
+
|
|
310
|
+
def to_dict(self) -> dict[str, Any]:
|
|
311
|
+
"""Convert to dictionary for YAML serialization."""
|
|
312
|
+
data = asdict(self)
|
|
313
|
+
# Convert ContextConfig to dict
|
|
314
|
+
if isinstance(data.get("context"), dict):
|
|
315
|
+
pass # already a dict from asdict
|
|
316
|
+
return data
|
|
317
|
+
|
|
318
|
+
@classmethod
|
|
319
|
+
def from_dict(cls, data: dict[str, Any]) -> "EnvironmentConfig":
|
|
320
|
+
"""Create from dictionary (YAML deserialization)."""
|
|
321
|
+
# Handle nested ContextConfig
|
|
322
|
+
if "context" in data and isinstance(data["context"], dict):
|
|
323
|
+
data["context"] = ContextConfig(**data["context"])
|
|
324
|
+
if "agent_budget" in data and isinstance(data["agent_budget"], dict):
|
|
325
|
+
data["agent_budget"] = AgentBudgetConfig(**data["agent_budget"])
|
|
326
|
+
if "batch" in data and isinstance(data["batch"], dict):
|
|
327
|
+
data["batch"] = BatchConfig(**data["batch"])
|
|
328
|
+
if "hooks" in data and isinstance(data["hooks"], dict):
|
|
329
|
+
data["hooks"] = HooksConfig(**data["hooks"])
|
|
330
|
+
if "llm" in data and isinstance(data["llm"], dict):
|
|
331
|
+
data["llm"] = LLMConfig(**data["llm"])
|
|
332
|
+
return cls(**data)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# Environment config file name
|
|
336
|
+
ENV_CONFIG_FILE = "config.yaml"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def load_environment_config(workspace_path: Path) -> Optional[EnvironmentConfig]:
|
|
340
|
+
"""Load environment configuration from workspace.
|
|
341
|
+
|
|
342
|
+
Checks .codeframe/config.yaml first. If not found, falls back to
|
|
343
|
+
reading CODEFRAME.md front matter and converting it to EnvironmentConfig.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
workspace_path: Path to the workspace root
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
EnvironmentConfig if configuration is found, None otherwise
|
|
350
|
+
"""
|
|
351
|
+
# Try .codeframe/config.yaml first (existing behavior)
|
|
352
|
+
config_file = workspace_path / ".codeframe" / ENV_CONFIG_FILE
|
|
353
|
+
if config_file.exists():
|
|
354
|
+
with open(config_file) as f:
|
|
355
|
+
data = yaml.safe_load(f)
|
|
356
|
+
|
|
357
|
+
if data is None:
|
|
358
|
+
return EnvironmentConfig() # empty file = defaults
|
|
359
|
+
|
|
360
|
+
return EnvironmentConfig.from_dict(data)
|
|
361
|
+
|
|
362
|
+
# Fallback: try CODEFRAME.md front matter
|
|
363
|
+
from codeframe.core.agents_config import get_codeframe_config
|
|
364
|
+
|
|
365
|
+
cf_config = get_codeframe_config(workspace_path)
|
|
366
|
+
if cf_config is not None and _has_meaningful_config(cf_config):
|
|
367
|
+
return _codeframe_config_to_env_config(cf_config)
|
|
368
|
+
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _has_meaningful_config(cf_config: Any) -> bool:
|
|
373
|
+
"""Check if a CodeframeConfig has any non-default settings.
|
|
374
|
+
|
|
375
|
+
Returns False when the CODEFRAME.md exists but has no YAML front matter
|
|
376
|
+
(all fields are None or empty).
|
|
377
|
+
"""
|
|
378
|
+
return bool(
|
|
379
|
+
cf_config.engine
|
|
380
|
+
or cf_config.tech_stack
|
|
381
|
+
or cf_config.gates
|
|
382
|
+
or cf_config.hooks
|
|
383
|
+
or cf_config.batch
|
|
384
|
+
or cf_config.agent
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _codeframe_config_to_env_config(cf_config: Any) -> EnvironmentConfig:
|
|
389
|
+
"""Convert CodeframeConfig (from CODEFRAME.md) to EnvironmentConfig.
|
|
390
|
+
|
|
391
|
+
Maps structured fields from CODEFRAME.md front matter into the
|
|
392
|
+
EnvironmentConfig dataclass used throughout the runtime.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
cf_config: A CodeframeConfig instance from agents_config module.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Populated EnvironmentConfig.
|
|
399
|
+
"""
|
|
400
|
+
config = EnvironmentConfig()
|
|
401
|
+
|
|
402
|
+
# Map engine directly
|
|
403
|
+
if cf_config.engine:
|
|
404
|
+
config.engine = cf_config.engine
|
|
405
|
+
|
|
406
|
+
# Map tech_stack to package_manager/test_framework heuristics
|
|
407
|
+
if cf_config.tech_stack:
|
|
408
|
+
ts = cf_config.tech_stack.lower()
|
|
409
|
+
if "uv" in ts:
|
|
410
|
+
config.package_manager = "uv"
|
|
411
|
+
elif "poetry" in ts:
|
|
412
|
+
config.package_manager = "poetry"
|
|
413
|
+
elif "npm" in ts:
|
|
414
|
+
config.package_manager = "npm"
|
|
415
|
+
elif "pnpm" in ts:
|
|
416
|
+
config.package_manager = "pnpm"
|
|
417
|
+
elif "yarn" in ts:
|
|
418
|
+
config.package_manager = "yarn"
|
|
419
|
+
|
|
420
|
+
if "pytest" in ts:
|
|
421
|
+
config.test_framework = "pytest"
|
|
422
|
+
elif "jest" in ts:
|
|
423
|
+
config.test_framework = "jest"
|
|
424
|
+
elif "vitest" in ts:
|
|
425
|
+
config.test_framework = "vitest"
|
|
426
|
+
|
|
427
|
+
# Map gates to lint_tools (filter to lint-related gates only)
|
|
428
|
+
if cf_config.gates:
|
|
429
|
+
lint_gate_names = {"ruff", "pylint", "eslint", "prettier", "flake8", "mypy", "biome"}
|
|
430
|
+
config.lint_tools = [g for g in cf_config.gates if g in lint_gate_names]
|
|
431
|
+
|
|
432
|
+
# Map hooks
|
|
433
|
+
if cf_config.hooks:
|
|
434
|
+
valid_hook_fields = {f.name for f in HooksConfig.__dataclass_fields__.values()}
|
|
435
|
+
filtered = {k: v for k, v in cf_config.hooks.items() if k in valid_hook_fields}
|
|
436
|
+
config.hooks = HooksConfig(**filtered)
|
|
437
|
+
|
|
438
|
+
# Map batch
|
|
439
|
+
if cf_config.batch:
|
|
440
|
+
batch_kwargs: dict[str, Any] = {}
|
|
441
|
+
if "max_parallel" in cf_config.batch:
|
|
442
|
+
batch_kwargs["max_parallel"] = cf_config.batch["max_parallel"]
|
|
443
|
+
if batch_kwargs:
|
|
444
|
+
config.batch = BatchConfig(**batch_kwargs)
|
|
445
|
+
|
|
446
|
+
# Map agent budget
|
|
447
|
+
if cf_config.agent:
|
|
448
|
+
budget_kwargs: dict[str, Any] = {}
|
|
449
|
+
if "max_iterations" in cf_config.agent:
|
|
450
|
+
budget_kwargs["max_iterations"] = cf_config.agent["max_iterations"]
|
|
451
|
+
# Also set base_iterations to match (they're conceptually the same)
|
|
452
|
+
budget_kwargs["base_iterations"] = cf_config.agent["max_iterations"]
|
|
453
|
+
if budget_kwargs:
|
|
454
|
+
config.agent_budget = AgentBudgetConfig(**budget_kwargs)
|
|
455
|
+
|
|
456
|
+
return config
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def save_environment_config(workspace_path: Path, config: EnvironmentConfig) -> None:
|
|
460
|
+
"""Save environment configuration to workspace.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
workspace_path: Path to the workspace root
|
|
464
|
+
config: Configuration to save
|
|
465
|
+
"""
|
|
466
|
+
config_dir = workspace_path / ".codeframe"
|
|
467
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
468
|
+
|
|
469
|
+
config_file = config_dir / ENV_CONFIG_FILE
|
|
470
|
+
|
|
471
|
+
with open(config_file, "w") as f:
|
|
472
|
+
yaml.dump(
|
|
473
|
+
config.to_dict(),
|
|
474
|
+
f,
|
|
475
|
+
default_flow_style=False,
|
|
476
|
+
sort_keys=False,
|
|
477
|
+
allow_unicode=True,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def get_default_environment_config() -> EnvironmentConfig:
|
|
482
|
+
"""Get default environment configuration.
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
EnvironmentConfig with sensible defaults
|
|
486
|
+
"""
|
|
487
|
+
return EnvironmentConfig()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# =============================================================================
|
|
491
|
+
# v1 Legacy Configuration (JSON-based)
|
|
492
|
+
# =============================================================================
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class ProviderConfig(BaseModel):
|
|
496
|
+
"""LLM provider configuration."""
|
|
497
|
+
|
|
498
|
+
lead_agent: str = "claude"
|
|
499
|
+
backend_agent: str = "claude"
|
|
500
|
+
frontend_agent: str = "gpt4"
|
|
501
|
+
test_agent: str = "claude"
|
|
502
|
+
review_agent: str = "gpt4"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class AgentPolicyConfig(BaseModel):
|
|
506
|
+
"""Global agent management policies."""
|
|
507
|
+
|
|
508
|
+
require_review_below_maturity: str = "supporting"
|
|
509
|
+
allow_full_autonomy: bool = False
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class InterruptionConfig(BaseModel):
|
|
513
|
+
"""Interruption mode configuration."""
|
|
514
|
+
|
|
515
|
+
enabled: bool = True
|
|
516
|
+
sync_blockers: list[str] = Field(default_factory=lambda: ["requirement", "security"])
|
|
517
|
+
async_blockers: list[str] = Field(default_factory=lambda: ["technical", "external"])
|
|
518
|
+
auto_continue: bool = True
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class NotificationChannelConfig(BaseModel):
|
|
522
|
+
"""Notification channel configuration."""
|
|
523
|
+
|
|
524
|
+
enabled: bool = True
|
|
525
|
+
channels: list[str] = Field(default_factory=list)
|
|
526
|
+
webhook_url: Optional[str] = None
|
|
527
|
+
batch_interval: Optional[int] = None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class NotificationsConfig(BaseModel):
|
|
531
|
+
"""Multi-channel notification configuration."""
|
|
532
|
+
|
|
533
|
+
sync_blockers: NotificationChannelConfig = Field(default_factory=NotificationChannelConfig)
|
|
534
|
+
async_blockers: NotificationChannelConfig = Field(default_factory=NotificationChannelConfig)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class CheckpointConfig(BaseModel):
|
|
538
|
+
"""Checkpoint configuration."""
|
|
539
|
+
|
|
540
|
+
auto_save_interval: int = 1800 # seconds
|
|
541
|
+
pre_compactification: bool = True
|
|
542
|
+
per_task_completion: bool = True
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class ProjectConfig(BaseModel):
|
|
546
|
+
"""Project-specific configuration."""
|
|
547
|
+
|
|
548
|
+
project_name: str
|
|
549
|
+
project_type: str = "python"
|
|
550
|
+
providers: ProviderConfig = Field(default_factory=ProviderConfig)
|
|
551
|
+
agent_policy: AgentPolicyConfig = Field(default_factory=AgentPolicyConfig)
|
|
552
|
+
interruption_mode: InterruptionConfig = Field(default_factory=InterruptionConfig)
|
|
553
|
+
notifications: NotificationsConfig = Field(default_factory=NotificationsConfig)
|
|
554
|
+
checkpoints: CheckpointConfig = Field(default_factory=CheckpointConfig)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class GlobalConfig(BaseSettings):
|
|
558
|
+
"""Global CodeFRAME configuration loaded from environment variables."""
|
|
559
|
+
|
|
560
|
+
# API Keys (REQUIRED for Sprint 1)
|
|
561
|
+
anthropic_api_key: Optional[str] = Field(None, alias="ANTHROPIC_API_KEY")
|
|
562
|
+
openai_api_key: Optional[str] = Field(None, alias="OPENAI_API_KEY")
|
|
563
|
+
|
|
564
|
+
# Notification services (optional, for future sprints)
|
|
565
|
+
twilio_account_sid: Optional[str] = Field(None, alias="TWILIO_ACCOUNT_SID")
|
|
566
|
+
twilio_auth_token: Optional[str] = Field(None, alias="TWILIO_AUTH_TOKEN")
|
|
567
|
+
|
|
568
|
+
# Blocker webhook notifications (Sprint 6 - Human in the Loop)
|
|
569
|
+
blocker_webhook_url: Optional[str] = Field(None, alias="BLOCKER_WEBHOOK_URL")
|
|
570
|
+
|
|
571
|
+
# Database configuration
|
|
572
|
+
database_path: str = Field(".codeframe/state.db", alias="DATABASE_PATH")
|
|
573
|
+
|
|
574
|
+
# Status Server configuration
|
|
575
|
+
api_host: str = Field("0.0.0.0", alias="API_HOST")
|
|
576
|
+
api_port: int = Field(8080, alias="API_PORT")
|
|
577
|
+
cors_origins: str = Field("http://localhost:3000,http://localhost:5173", alias="CORS_ORIGINS")
|
|
578
|
+
|
|
579
|
+
# Logging configuration
|
|
580
|
+
log_level: str = Field("INFO", alias="LOG_LEVEL")
|
|
581
|
+
log_file: Optional[str] = Field(".codeframe/logs/codeframe.log", alias="LOG_FILE")
|
|
582
|
+
|
|
583
|
+
# Development flags
|
|
584
|
+
debug: bool = Field(False, alias="DEBUG")
|
|
585
|
+
hot_reload: bool = Field(False, alias="HOT_RELOAD")
|
|
586
|
+
|
|
587
|
+
# Provider defaults
|
|
588
|
+
default_provider: str = "claude"
|
|
589
|
+
default_model: str = "claude-sonnet-4"
|
|
590
|
+
|
|
591
|
+
# GitHub Integration (Sprint 11 - PR Management)
|
|
592
|
+
github_token: Optional[str] = Field(None, alias="GITHUB_TOKEN")
|
|
593
|
+
github_repo: Optional[str] = Field(None, alias="GITHUB_REPO") # Format: "owner/repo"
|
|
594
|
+
|
|
595
|
+
# Rate Limiting Configuration
|
|
596
|
+
rate_limit_enabled: bool = Field(True, alias="RATE_LIMIT_ENABLED")
|
|
597
|
+
rate_limit_storage: str = Field("memory", alias="RATE_LIMIT_STORAGE")
|
|
598
|
+
redis_url: Optional[str] = Field(None, alias="REDIS_URL")
|
|
599
|
+
rate_limit_auth: str = Field("10/minute", alias="RATE_LIMIT_AUTH")
|
|
600
|
+
rate_limit_standard: str = Field("100/minute", alias="RATE_LIMIT_STANDARD")
|
|
601
|
+
rate_limit_ai: str = Field("20/minute", alias="RATE_LIMIT_AI")
|
|
602
|
+
rate_limit_websocket: str = Field("30/minute", alias="RATE_LIMIT_WEBSOCKET")
|
|
603
|
+
# Comma-separated list of trusted proxy IPs/CIDRs (e.g., "10.0.0.0/8,172.16.0.0/12")
|
|
604
|
+
rate_limit_trusted_proxies: str = Field("", alias="RATE_LIMIT_TRUSTED_PROXIES")
|
|
605
|
+
|
|
606
|
+
model_config = SettingsConfigDict(
|
|
607
|
+
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
@field_validator("log_level")
|
|
611
|
+
@classmethod
|
|
612
|
+
def validate_log_level(cls, v: str) -> str:
|
|
613
|
+
"""Validate log level is one of the allowed values."""
|
|
614
|
+
allowed = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
615
|
+
v_upper = v.upper()
|
|
616
|
+
if v_upper not in allowed:
|
|
617
|
+
raise ValueError(f"LOG_LEVEL must be one of {allowed}, got: {v}")
|
|
618
|
+
return v_upper
|
|
619
|
+
|
|
620
|
+
@field_validator("api_port")
|
|
621
|
+
@classmethod
|
|
622
|
+
def validate_port(cls, v: int) -> int:
|
|
623
|
+
"""Validate port is in valid range."""
|
|
624
|
+
if not (1 <= v <= 65535):
|
|
625
|
+
raise ValueError(f"API_PORT must be between 1 and 65535, got: {v}")
|
|
626
|
+
return v
|
|
627
|
+
|
|
628
|
+
@field_validator("rate_limit_storage")
|
|
629
|
+
@classmethod
|
|
630
|
+
def validate_rate_limit_storage(cls, v: str) -> str:
|
|
631
|
+
"""Validate rate limit storage is valid."""
|
|
632
|
+
allowed = ["memory", "redis"]
|
|
633
|
+
if v not in allowed:
|
|
634
|
+
raise ValueError(f"RATE_LIMIT_STORAGE must be one of {allowed}, got: {v}")
|
|
635
|
+
return v
|
|
636
|
+
|
|
637
|
+
def get_cors_origins_list(self) -> list[str]:
|
|
638
|
+
"""Parse CORS origins from comma-separated string."""
|
|
639
|
+
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
|
640
|
+
|
|
641
|
+
def validate_required_for_sprint(self, sprint: int = 1) -> None:
|
|
642
|
+
"""Validate that required configuration is present for a given sprint.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
sprint: Sprint number (1-8)
|
|
646
|
+
|
|
647
|
+
Raises:
|
|
648
|
+
ValueError: If required configuration is missing
|
|
649
|
+
"""
|
|
650
|
+
errors = []
|
|
651
|
+
|
|
652
|
+
if sprint >= 1:
|
|
653
|
+
# Sprint 1 requires Anthropic API key for Lead Agent
|
|
654
|
+
if not self.anthropic_api_key:
|
|
655
|
+
errors.append(
|
|
656
|
+
"ANTHROPIC_API_KEY is required for Sprint 1 (Lead Agent with Claude).\n"
|
|
657
|
+
" Get your API key at: https://console.anthropic.com/\n"
|
|
658
|
+
" Then add it to your .env file (see .env.example)"
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
if sprint >= 4:
|
|
662
|
+
# Sprint 4+ may require OpenAI for multi-agent
|
|
663
|
+
if not self.openai_api_key and not self.anthropic_api_key:
|
|
664
|
+
errors.append(
|
|
665
|
+
"At least one AI provider API key is required.\n"
|
|
666
|
+
" ANTHROPIC_API_KEY or OPENAI_API_KEY must be set."
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
if sprint >= 5:
|
|
670
|
+
# Sprint 5+ may require notification services
|
|
671
|
+
pass # Notifications are optional, will use webhook fallback
|
|
672
|
+
|
|
673
|
+
if errors:
|
|
674
|
+
error_msg = "\n\n".join(errors)
|
|
675
|
+
raise ValueError(
|
|
676
|
+
f"\n{'='*70}\nCONFIGURATION ERROR\n{'='*70}\n\n{error_msg}\n\n{'='*70}\n"
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
def ensure_directories(self) -> None:
|
|
680
|
+
"""Ensure required directories exist."""
|
|
681
|
+
# Create database directory
|
|
682
|
+
db_path = Path(self.database_path)
|
|
683
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
684
|
+
|
|
685
|
+
# Create log directory if log file is specified
|
|
686
|
+
if self.log_file:
|
|
687
|
+
log_path = Path(self.log_file)
|
|
688
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def load_environment(env_file: str = ".env") -> None:
|
|
692
|
+
"""Load environment variables from .env file.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
env_file: Path to .env file (default: .env in current directory)
|
|
696
|
+
"""
|
|
697
|
+
env_path = Path(env_file)
|
|
698
|
+
if env_path.exists():
|
|
699
|
+
load_dotenv(env_path)
|
|
700
|
+
# Also try project root .env if we're in a subdirectory
|
|
701
|
+
if not env_path.is_absolute():
|
|
702
|
+
root_env = Path.cwd() / ".env"
|
|
703
|
+
if root_env.exists() and root_env != env_path.absolute():
|
|
704
|
+
load_dotenv(root_env)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class Config:
|
|
708
|
+
"""Configuration manager for CodeFRAME."""
|
|
709
|
+
|
|
710
|
+
def __init__(self, project_dir: Path):
|
|
711
|
+
self.project_dir = project_dir
|
|
712
|
+
self.config_dir = project_dir / ".codeframe"
|
|
713
|
+
self.config_file = self.config_dir / "config.json"
|
|
714
|
+
self._project_config: Optional[ProjectConfig] = None
|
|
715
|
+
self._global_config: Optional[GlobalConfig] = None
|
|
716
|
+
|
|
717
|
+
# Load environment variables
|
|
718
|
+
load_environment()
|
|
719
|
+
|
|
720
|
+
def load(self) -> ProjectConfig:
|
|
721
|
+
"""Load project configuration."""
|
|
722
|
+
if self._project_config:
|
|
723
|
+
return self._project_config
|
|
724
|
+
|
|
725
|
+
if not self.config_file.exists():
|
|
726
|
+
raise FileNotFoundError(f"Config not found: {self.config_file}")
|
|
727
|
+
|
|
728
|
+
with open(self.config_file) as f:
|
|
729
|
+
data = json.load(f)
|
|
730
|
+
self._project_config = ProjectConfig(**data)
|
|
731
|
+
|
|
732
|
+
return self._project_config
|
|
733
|
+
|
|
734
|
+
def save(self, config: ProjectConfig) -> None:
|
|
735
|
+
"""Save project configuration."""
|
|
736
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
737
|
+
with open(self.config_file, "w") as f:
|
|
738
|
+
json.dump(config.model_dump(), f, indent=2)
|
|
739
|
+
self._project_config = config
|
|
740
|
+
|
|
741
|
+
def get_global(self) -> GlobalConfig:
|
|
742
|
+
"""Load global configuration from environment variables.
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
GlobalConfig instance with values from environment
|
|
746
|
+
|
|
747
|
+
Raises:
|
|
748
|
+
ValueError: If required configuration is missing
|
|
749
|
+
"""
|
|
750
|
+
if not self._global_config:
|
|
751
|
+
self._global_config = GlobalConfig()
|
|
752
|
+
return self._global_config
|
|
753
|
+
|
|
754
|
+
def validate_for_sprint(self, sprint: int = 1) -> None:
|
|
755
|
+
"""Validate configuration for a specific sprint.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
sprint: Sprint number to validate for
|
|
759
|
+
|
|
760
|
+
Raises:
|
|
761
|
+
ValueError: If required configuration is missing
|
|
762
|
+
"""
|
|
763
|
+
global_config = self.get_global()
|
|
764
|
+
global_config.validate_required_for_sprint(sprint)
|
|
765
|
+
global_config.ensure_directories()
|
|
766
|
+
|
|
767
|
+
def set(self, key: str, value: Any) -> None:
|
|
768
|
+
"""Set configuration value using dot notation."""
|
|
769
|
+
config = self.load()
|
|
770
|
+
keys = key.split(".")
|
|
771
|
+
obj = config.model_dump()
|
|
772
|
+
|
|
773
|
+
# Navigate to the correct nested dict
|
|
774
|
+
current = obj
|
|
775
|
+
for k in keys[:-1]:
|
|
776
|
+
if k not in current:
|
|
777
|
+
current[k] = {}
|
|
778
|
+
current = current[k]
|
|
779
|
+
|
|
780
|
+
# Set the value
|
|
781
|
+
current[keys[-1]] = value
|
|
782
|
+
|
|
783
|
+
# Reload and save
|
|
784
|
+
self._project_config = ProjectConfig(**obj)
|
|
785
|
+
self.save(self._project_config)
|
|
786
|
+
|
|
787
|
+
def get(self, key: str) -> Any:
|
|
788
|
+
"""Get configuration value using dot notation."""
|
|
789
|
+
config = self.load()
|
|
790
|
+
keys = key.split(".")
|
|
791
|
+
obj = config.model_dump()
|
|
792
|
+
|
|
793
|
+
current = obj
|
|
794
|
+
for k in keys:
|
|
795
|
+
if k not in current:
|
|
796
|
+
return None
|
|
797
|
+
current = current[k]
|
|
798
|
+
|
|
799
|
+
return current
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
# Module-level singleton for GlobalConfig
|
|
803
|
+
_global_config: Optional[GlobalConfig] = None
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def get_global_config() -> GlobalConfig:
|
|
807
|
+
"""Get the global configuration singleton.
|
|
808
|
+
|
|
809
|
+
Loads from environment variables on first call, cached thereafter.
|
|
810
|
+
This is the recommended way to access GlobalConfig for most use cases.
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
GlobalConfig instance with values from environment
|
|
814
|
+
"""
|
|
815
|
+
global _global_config
|
|
816
|
+
if _global_config is None:
|
|
817
|
+
_global_config = GlobalConfig()
|
|
818
|
+
return _global_config
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def reset_global_config() -> None:
|
|
822
|
+
"""Reset the global configuration singleton.
|
|
823
|
+
|
|
824
|
+
Useful for testing to ensure clean state between tests.
|
|
825
|
+
"""
|
|
826
|
+
global _global_config
|
|
827
|
+
_global_config = None
|