switchforge 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.
- forge_core/__init__.py +3 -0
- forge_core/__main__.py +6 -0
- forge_core/ai/__init__.py +0 -0
- forge_core/ai/prompts.py +87 -0
- forge_core/ai/provider.py +121 -0
- forge_core/ai/structured.py +68 -0
- forge_core/auth.py +72 -0
- forge_core/cli.py +209 -0
- forge_core/config.py +118 -0
- forge_core/core/__init__.py +0 -0
- forge_core/core/agent_manager.py +111 -0
- forge_core/core/coverage.py +241 -0
- forge_core/core/file_manager.py +104 -0
- forge_core/models/__init__.py +0 -0
- forge_core/models/config.py +115 -0
- forge_core/models/dto.py +58 -0
- forge_core/models/project.py +72 -0
- forge_core/models/test_result.py +93 -0
- forge_core/orchestrator.py +233 -0
- forge_core/phases/__init__.py +0 -0
- forge_core/phases/analyze_project.py +149 -0
- forge_core/phases/audit_tests.py +35 -0
- forge_core/phases/compile_fix.py +79 -0
- forge_core/phases/coverage_report.py +19 -0
- forge_core/phases/detect_stack.py +66 -0
- forge_core/phases/exclusion_scan.py +74 -0
- forge_core/phases/fix_broken.py +83 -0
- forge_core/phases/generate_tests.py +218 -0
- forge_core/phases/journey_mapping.py +120 -0
- forge_core/phases/self_learn.py +93 -0
- forge_core/utils/__init__.py +0 -0
- forge_core/utils/logger.py +89 -0
- forge_core/utils/reporter.py +70 -0
- forge_core/utils/shell.py +93 -0
- forge_core/utils/tokens.py +67 -0
- switchforge-1.0.0.dist-info/METADATA +65 -0
- switchforge-1.0.0.dist-info/RECORD +39 -0
- switchforge-1.0.0.dist-info/WHEEL +4 -0
- switchforge-1.0.0.dist-info/entry_points.txt +2 -0
forge_core/__init__.py
ADDED
forge_core/__main__.py
ADDED
|
File without changes
|
forge_core/ai/prompts.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Load and template .md prompt files for AI consumption."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from forge_core.utils import logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_prompt(prompts_dir: Path, prompt_name: str) -> str:
|
|
11
|
+
"""Load a prompt .md file from the prompts directory.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
prompts_dir: Path to the prompts directory.
|
|
15
|
+
prompt_name: Name of the prompt file (e.g., 'detect-tech-stack').
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The prompt content as a string.
|
|
19
|
+
"""
|
|
20
|
+
file_path = prompts_dir / f"{prompt_name}.prompt.md"
|
|
21
|
+
if not file_path.exists():
|
|
22
|
+
logger.warn(f"Prompt not found: {file_path}")
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
content = file_path.read_text(encoding="utf-8")
|
|
26
|
+
# Strip YAML frontmatter if present
|
|
27
|
+
if content.startswith("---"):
|
|
28
|
+
parts = content.split("---", 2)
|
|
29
|
+
if len(parts) >= 3:
|
|
30
|
+
content = parts[2].strip()
|
|
31
|
+
|
|
32
|
+
return content
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def template_prompt(prompt: str, variables: dict[str, str]) -> str:
|
|
36
|
+
"""Replace template variables in a prompt.
|
|
37
|
+
|
|
38
|
+
Variables use {{variable_name}} syntax.
|
|
39
|
+
"""
|
|
40
|
+
result = prompt
|
|
41
|
+
for key, value in variables.items():
|
|
42
|
+
result = result.replace(f"{{{{{key}}}}}", value)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_file_context(files: dict[str, str], max_files: int = 50) -> str:
|
|
47
|
+
"""Build a file context string from a dict of {path: content}.
|
|
48
|
+
|
|
49
|
+
Formats each file with clear separators for the AI to parse.
|
|
50
|
+
"""
|
|
51
|
+
parts: list[str] = []
|
|
52
|
+
for i, (path, content) in enumerate(files.items()):
|
|
53
|
+
if i >= max_files:
|
|
54
|
+
parts.append(f"\n... and {len(files) - max_files} more files (truncated)")
|
|
55
|
+
break
|
|
56
|
+
parts.append(f"--- {path} ---\n{content}")
|
|
57
|
+
|
|
58
|
+
return "\n\n".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_learnings(central_path: str | None, local_path: Path | None) -> str:
|
|
62
|
+
"""Load LEARNINGS.md from central hub and/or local project."""
|
|
63
|
+
learnings_parts: list[str] = []
|
|
64
|
+
|
|
65
|
+
if central_path:
|
|
66
|
+
central_file = Path(central_path) / "LEARNINGS.md"
|
|
67
|
+
if central_file.exists():
|
|
68
|
+
learnings_parts.append(
|
|
69
|
+
f"=== Central Learnings ===\n{central_file.read_text(encoding='utf-8')}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if local_path:
|
|
73
|
+
local_file = local_path / "LEARNINGS.md"
|
|
74
|
+
if local_file.exists():
|
|
75
|
+
learnings_parts.append(
|
|
76
|
+
f"=== Project Learnings ===\n{local_file.read_text(encoding='utf-8')}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return "\n\n".join(learnings_parts)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_knowledge_pack(central_path: str, pack_name: str) -> str:
|
|
83
|
+
"""Load a specific knowledge pack (e.g., kotlin-ktor.md)."""
|
|
84
|
+
pack_path = Path(central_path) / "knowledge-packs" / f"{pack_name}.md"
|
|
85
|
+
if pack_path.exists():
|
|
86
|
+
return pack_path.read_text(encoding="utf-8")
|
|
87
|
+
return ""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""LiteLLM-based AI provider — unified interface for 100+ models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import litellm
|
|
8
|
+
|
|
9
|
+
from forge_core.models.config import AIConfig, AIProvider
|
|
10
|
+
from forge_core.utils import logger
|
|
11
|
+
from forge_core.utils.tokens import count_tokens
|
|
12
|
+
|
|
13
|
+
# Suppress LiteLLM's verbose logging
|
|
14
|
+
litellm.suppress_debug_info = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_model(config: AIConfig) -> str:
|
|
18
|
+
"""Resolve the model string for LiteLLM based on provider."""
|
|
19
|
+
if config.provider == AIProvider.OLLAMA:
|
|
20
|
+
return f"ollama/{config.model}"
|
|
21
|
+
if config.provider == AIProvider.AZURE:
|
|
22
|
+
return f"azure/{config.model}"
|
|
23
|
+
if config.provider == AIProvider.BEDROCK:
|
|
24
|
+
return f"bedrock/{config.model}"
|
|
25
|
+
return config.model
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def complete(
|
|
29
|
+
config: AIConfig,
|
|
30
|
+
system_prompt: str,
|
|
31
|
+
user_prompt: str,
|
|
32
|
+
json_mode: bool = False,
|
|
33
|
+
max_tokens: int | None = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Send a completion request to the configured AI provider.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
config: AI provider configuration.
|
|
39
|
+
system_prompt: System-level instructions.
|
|
40
|
+
user_prompt: User message with code/context.
|
|
41
|
+
json_mode: If True, request JSON response format.
|
|
42
|
+
max_tokens: Override max tokens for this call.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The AI's response text.
|
|
46
|
+
"""
|
|
47
|
+
model = _resolve_model(config)
|
|
48
|
+
messages = [
|
|
49
|
+
{"role": "system", "content": system_prompt},
|
|
50
|
+
{"role": "user", "content": user_prompt},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
kwargs: dict[str, Any] = {
|
|
54
|
+
"model": model,
|
|
55
|
+
"messages": messages,
|
|
56
|
+
"temperature": config.temperature,
|
|
57
|
+
"max_tokens": max_tokens or config.max_tokens,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if config.api_key:
|
|
61
|
+
kwargs["api_key"] = config.api_key
|
|
62
|
+
if config.base_url:
|
|
63
|
+
kwargs["api_base"] = config.base_url
|
|
64
|
+
if json_mode:
|
|
65
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
66
|
+
|
|
67
|
+
# Log token usage
|
|
68
|
+
input_tokens = count_tokens(system_prompt + user_prompt, config.model)
|
|
69
|
+
logger.info(f"AI call → {model} ({input_tokens} input tokens)")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
response = litellm.completion(**kwargs)
|
|
73
|
+
content = response.choices[0].message.content or ""
|
|
74
|
+
|
|
75
|
+
output_tokens = count_tokens(content, config.model)
|
|
76
|
+
logger.info(f"AI response ← {output_tokens} output tokens")
|
|
77
|
+
|
|
78
|
+
return content
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.error(f"AI call failed: {e}")
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def complete_with_fallback(
|
|
85
|
+
config: AIConfig,
|
|
86
|
+
system_prompt: str,
|
|
87
|
+
user_prompt: str,
|
|
88
|
+
fallback_models: list[str] | None = None,
|
|
89
|
+
json_mode: bool = False,
|
|
90
|
+
) -> str:
|
|
91
|
+
"""Try primary model, fall back to alternatives on failure.
|
|
92
|
+
|
|
93
|
+
Useful for complex classes where one model might fail.
|
|
94
|
+
"""
|
|
95
|
+
models = [_resolve_model(config)] + (fallback_models or [])
|
|
96
|
+
|
|
97
|
+
last_error = None
|
|
98
|
+
for model in models:
|
|
99
|
+
try:
|
|
100
|
+
kwargs: dict[str, Any] = {
|
|
101
|
+
"model": model,
|
|
102
|
+
"messages": [
|
|
103
|
+
{"role": "system", "content": system_prompt},
|
|
104
|
+
{"role": "user", "content": user_prompt},
|
|
105
|
+
],
|
|
106
|
+
"temperature": config.temperature,
|
|
107
|
+
"max_tokens": config.max_tokens,
|
|
108
|
+
}
|
|
109
|
+
if config.api_key:
|
|
110
|
+
kwargs["api_key"] = config.api_key
|
|
111
|
+
if json_mode:
|
|
112
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
113
|
+
|
|
114
|
+
response = litellm.completion(**kwargs)
|
|
115
|
+
return response.choices[0].message.content or ""
|
|
116
|
+
except Exception as e:
|
|
117
|
+
last_error = e
|
|
118
|
+
logger.warn(f"Model {model} failed, trying next: {e}")
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
raise RuntimeError(f"All models failed. Last error: {last_error}")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Structured AI outputs using instructor + pydantic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
import instructor
|
|
8
|
+
import litellm
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from forge_core.models.config import AIConfig
|
|
12
|
+
from forge_core.ai.provider import _resolve_model
|
|
13
|
+
from forge_core.utils import logger
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T", bound=BaseModel)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def extract(
|
|
19
|
+
config: AIConfig,
|
|
20
|
+
system_prompt: str,
|
|
21
|
+
user_prompt: str,
|
|
22
|
+
response_model: type[T],
|
|
23
|
+
max_retries: int = 2,
|
|
24
|
+
) -> T:
|
|
25
|
+
"""Extract structured data from AI using instructor.
|
|
26
|
+
|
|
27
|
+
Forces the AI to return data matching a pydantic model schema.
|
|
28
|
+
Retries on validation failure.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
config: AI provider configuration.
|
|
32
|
+
system_prompt: System-level instructions.
|
|
33
|
+
user_prompt: User message with code/context.
|
|
34
|
+
response_model: Pydantic model class to extract.
|
|
35
|
+
max_retries: Number of retries on validation failure.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
An instance of response_model populated by the AI.
|
|
39
|
+
"""
|
|
40
|
+
model = _resolve_model(config)
|
|
41
|
+
|
|
42
|
+
client = instructor.from_litellm(litellm.completion)
|
|
43
|
+
|
|
44
|
+
kwargs: dict[str, Any] = {
|
|
45
|
+
"model": model,
|
|
46
|
+
"messages": [
|
|
47
|
+
{"role": "system", "content": system_prompt},
|
|
48
|
+
{"role": "user", "content": user_prompt},
|
|
49
|
+
],
|
|
50
|
+
"temperature": config.temperature,
|
|
51
|
+
"max_retries": max_retries,
|
|
52
|
+
"response_model": response_model,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if config.api_key:
|
|
56
|
+
kwargs["api_key"] = config.api_key
|
|
57
|
+
if config.base_url:
|
|
58
|
+
kwargs["api_base"] = config.base_url
|
|
59
|
+
|
|
60
|
+
logger.info(f"Structured extraction → {model} → {response_model.__name__}")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
result = client.chat.completions.create(**kwargs)
|
|
64
|
+
logger.success(f"Extracted {response_model.__name__} successfully")
|
|
65
|
+
return result
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"Structured extraction failed: {e}")
|
|
68
|
+
raise
|
forge_core/auth.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""SaaS API authentication and license verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from forge_core.models.config import ForgeConfig, Plan, PlanLimits
|
|
9
|
+
from forge_core.utils import logger
|
|
10
|
+
from forge_core.utils.reporter import check_license
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def verify_license(config: ForgeConfig) -> ForgeConfig:
|
|
14
|
+
"""Verify license with SaaS API and update plan limits.
|
|
15
|
+
|
|
16
|
+
If no auth token is set, runs in free/offline mode.
|
|
17
|
+
"""
|
|
18
|
+
if not config.auth_token:
|
|
19
|
+
logger.info("Running in offline mode (no auth token)")
|
|
20
|
+
config.limits = PlanLimits.for_plan(Plan.FREE)
|
|
21
|
+
return config
|
|
22
|
+
|
|
23
|
+
# Check with SaaS API
|
|
24
|
+
logger.info("Verifying license with SaaS API...")
|
|
25
|
+
try:
|
|
26
|
+
license_info = asyncio.run(check_license(config))
|
|
27
|
+
except Exception:
|
|
28
|
+
license_info = {}
|
|
29
|
+
|
|
30
|
+
if not license_info:
|
|
31
|
+
logger.warn("Could not verify license — falling back to free tier limits")
|
|
32
|
+
config.limits = PlanLimits.for_plan(Plan.FREE)
|
|
33
|
+
return config
|
|
34
|
+
|
|
35
|
+
# Update config from license response
|
|
36
|
+
plan_str = license_info.get("plan", "free")
|
|
37
|
+
try:
|
|
38
|
+
plan = Plan(plan_str)
|
|
39
|
+
except ValueError:
|
|
40
|
+
plan = Plan.FREE
|
|
41
|
+
|
|
42
|
+
config.limits = PlanLimits.for_plan(plan)
|
|
43
|
+
config.tenant.org_id = license_info.get("org_id", config.tenant.org_id)
|
|
44
|
+
config.tenant.org_name = license_info.get("org_name", config.tenant.org_name)
|
|
45
|
+
|
|
46
|
+
# If Pro/Enterprise, use SaaS proxy for AI calls
|
|
47
|
+
if plan in (Plan.PRO, Plan.ENTERPRISE):
|
|
48
|
+
config.ai.use_saas_proxy = True
|
|
49
|
+
if not config.ai.api_key:
|
|
50
|
+
config.ai.api_key = config.auth_token # SaaS proxy uses auth token
|
|
51
|
+
config.ai.base_url = f"{config.saas_api_url}/api/v1/ai"
|
|
52
|
+
|
|
53
|
+
logger.success(f"License verified: {plan.value} plan ({config.tenant.org_name})")
|
|
54
|
+
return config
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_auth_token(token: str) -> None:
|
|
58
|
+
"""Save auth token to ~/.forge-core/auth."""
|
|
59
|
+
auth_dir = Path.home() / ".forge-core"
|
|
60
|
+
auth_dir.mkdir(exist_ok=True)
|
|
61
|
+
auth_file = auth_dir / "auth"
|
|
62
|
+
auth_file.write_text(token, encoding="utf-8")
|
|
63
|
+
auth_file.chmod(0o600)
|
|
64
|
+
logger.success("Auth token saved")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_auth_token() -> str:
|
|
68
|
+
"""Load auth token from ~/.forge-core/auth."""
|
|
69
|
+
auth_file = Path.home() / ".forge-core" / "auth"
|
|
70
|
+
if auth_file.exists():
|
|
71
|
+
return auth_file.read_text(encoding="utf-8").strip()
|
|
72
|
+
return ""
|
forge_core/cli.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Forge Core CLI — powered by typer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from forge_core import __version__
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="forge-core",
|
|
16
|
+
help="AI-powered backend test generation engine",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
)
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def run(
|
|
24
|
+
project_path: Path = typer.Argument(
|
|
25
|
+
".",
|
|
26
|
+
help="Path to the backend project to test",
|
|
27
|
+
exists=True,
|
|
28
|
+
dir_okay=True,
|
|
29
|
+
file_okay=False,
|
|
30
|
+
),
|
|
31
|
+
target_coverage: float = typer.Option(
|
|
32
|
+
90.0, "--target", "-t", help="Target line coverage percentage"
|
|
33
|
+
),
|
|
34
|
+
max_iterations: int = typer.Option(
|
|
35
|
+
10, "--max-iterations", "-i", help="Maximum generation iterations"
|
|
36
|
+
),
|
|
37
|
+
model: str = typer.Option(
|
|
38
|
+
"", "--model", "-m", help="AI model to use (e.g., gpt-4o, claude-sonnet-4-20250514)"
|
|
39
|
+
),
|
|
40
|
+
provider: str = typer.Option(
|
|
41
|
+
"", "--provider", "-p", help="AI provider (openai, anthropic, ollama, azure)"
|
|
42
|
+
),
|
|
43
|
+
api_key: str = typer.Option(
|
|
44
|
+
"", "--api-key", "-k", help="AI provider API key (or set OPENAI_API_KEY env var)"
|
|
45
|
+
),
|
|
46
|
+
mode: str = typer.Option(
|
|
47
|
+
"full", "--mode", help="Run mode: full, targeted, analyze_only, analyze_review"
|
|
48
|
+
),
|
|
49
|
+
files: Optional[list[str]] = typer.Option(
|
|
50
|
+
None, "--file", "-f", help="Specific files to test (targeted mode)"
|
|
51
|
+
),
|
|
52
|
+
):
|
|
53
|
+
"""Run Forge Core on a backend project to generate unit tests."""
|
|
54
|
+
from forge_core.auth import load_auth_token, verify_license
|
|
55
|
+
from forge_core.config import load_config
|
|
56
|
+
from forge_core.models.config import RunMode
|
|
57
|
+
from forge_core.orchestrator import Orchestrator
|
|
58
|
+
from forge_core.utils import logger
|
|
59
|
+
from forge_core.utils.reporter import upload_report
|
|
60
|
+
|
|
61
|
+
# Resolve project path
|
|
62
|
+
project_path = project_path.resolve()
|
|
63
|
+
|
|
64
|
+
# Load config
|
|
65
|
+
auth_token = load_auth_token()
|
|
66
|
+
config = load_config(
|
|
67
|
+
project_path=project_path,
|
|
68
|
+
api_key=api_key,
|
|
69
|
+
provider=provider,
|
|
70
|
+
model=model,
|
|
71
|
+
target_coverage=target_coverage,
|
|
72
|
+
auth_token=auth_token,
|
|
73
|
+
)
|
|
74
|
+
config.max_iterations = max_iterations
|
|
75
|
+
|
|
76
|
+
# Set mode
|
|
77
|
+
try:
|
|
78
|
+
config.mode = RunMode(mode)
|
|
79
|
+
except ValueError:
|
|
80
|
+
logger.error(f"Invalid mode: {mode}. Use: full, targeted, analyze_only, analyze_review")
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
if files:
|
|
84
|
+
config.mode = RunMode.TARGETED
|
|
85
|
+
config.target_files = list(files)
|
|
86
|
+
|
|
87
|
+
# Verify license
|
|
88
|
+
config = verify_license(config)
|
|
89
|
+
|
|
90
|
+
# Validate API key
|
|
91
|
+
if not config.ai.api_key:
|
|
92
|
+
console.print(
|
|
93
|
+
"[red]No API key found.[/red]\n\n"
|
|
94
|
+
"Set one of:\n"
|
|
95
|
+
" export OPENAI_API_KEY=sk-...\n"
|
|
96
|
+
" export ANTHROPIC_API_KEY=sk-ant-...\n"
|
|
97
|
+
" forge-core run --api-key sk-...\n"
|
|
98
|
+
" forge-core login (for Pro/Enterprise)\n"
|
|
99
|
+
)
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
# Run the pipeline
|
|
103
|
+
orchestrator = Orchestrator(config)
|
|
104
|
+
report = orchestrator.run()
|
|
105
|
+
|
|
106
|
+
# Upload report to SaaS (if authenticated)
|
|
107
|
+
if config.auth_token:
|
|
108
|
+
asyncio.run(upload_report(config, report.model_dump()))
|
|
109
|
+
|
|
110
|
+
# Exit with appropriate code
|
|
111
|
+
if report.production_files_changed > 0:
|
|
112
|
+
logger.error("SAFETY VIOLATION: Production files were changed!")
|
|
113
|
+
raise typer.Exit(2)
|
|
114
|
+
|
|
115
|
+
raise typer.Exit(0)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def login(
|
|
120
|
+
token: str = typer.Option(
|
|
121
|
+
"", "--token", "-t", help="Auth token from theswitchcompany.online"
|
|
122
|
+
),
|
|
123
|
+
):
|
|
124
|
+
"""Authenticate with TheSwitchCompany SaaS for Pro/Enterprise features."""
|
|
125
|
+
from forge_core.auth import save_auth_token, verify_license
|
|
126
|
+
from forge_core.config import load_config
|
|
127
|
+
from forge_core.utils import logger
|
|
128
|
+
|
|
129
|
+
if not token:
|
|
130
|
+
console.print(
|
|
131
|
+
"Get your auth token from:\n"
|
|
132
|
+
" [blue]https://theswitchcompany.online/dashboard/settings[/blue]\n\n"
|
|
133
|
+
"Then run:\n"
|
|
134
|
+
" forge-core login --token YOUR_TOKEN"
|
|
135
|
+
)
|
|
136
|
+
raise typer.Exit(0)
|
|
137
|
+
|
|
138
|
+
save_auth_token(token)
|
|
139
|
+
|
|
140
|
+
# Verify the token
|
|
141
|
+
config = load_config(Path("."), auth_token=token)
|
|
142
|
+
config = verify_license(config)
|
|
143
|
+
logger.success(f"Logged in as {config.tenant.org_name or 'user'}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def init(
|
|
148
|
+
project_path: Path = typer.Argument(".", help="Path to the project"),
|
|
149
|
+
):
|
|
150
|
+
"""Initialize Forge Core in a project (creates .github/agent-config.yml)."""
|
|
151
|
+
from forge_core.utils import logger
|
|
152
|
+
|
|
153
|
+
project_path = project_path.resolve()
|
|
154
|
+
config_dir = project_path / ".github"
|
|
155
|
+
config_dir.mkdir(exist_ok=True)
|
|
156
|
+
|
|
157
|
+
config_file = config_dir / "agent-config.yml"
|
|
158
|
+
if config_file.exists():
|
|
159
|
+
logger.info("agent-config.yml already exists")
|
|
160
|
+
raise typer.Exit(0)
|
|
161
|
+
|
|
162
|
+
config_file.write_text(
|
|
163
|
+
"# Forge Core configuration\n"
|
|
164
|
+
"# See: https://theswitchcompany.online/docs/forge-core\n\n"
|
|
165
|
+
"# Tenant (populated by SaaS or set manually)\n"
|
|
166
|
+
'org_id: ""\n'
|
|
167
|
+
'user_id: ""\n'
|
|
168
|
+
'project_id: ""\n\n'
|
|
169
|
+
"# Runtime\n"
|
|
170
|
+
'runtime: "auto"\n'
|
|
171
|
+
"max_parallel_agents: 4\n"
|
|
172
|
+
'cache_dir: ".forge-cache"\n',
|
|
173
|
+
encoding="utf-8",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
logger.success(f"Initialized Forge Core in {project_path}")
|
|
177
|
+
console.print("\nNext: [blue]forge-core run[/blue] to generate tests")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def version():
|
|
182
|
+
"""Show Forge Core version."""
|
|
183
|
+
console.print(f"Forge Core v{__version__}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command()
|
|
187
|
+
def status():
|
|
188
|
+
"""Show current configuration and license status."""
|
|
189
|
+
from forge_core.auth import load_auth_token, verify_license
|
|
190
|
+
from forge_core.config import load_config
|
|
191
|
+
|
|
192
|
+
auth_token = load_auth_token()
|
|
193
|
+
config = load_config(Path("."), auth_token=auth_token)
|
|
194
|
+
|
|
195
|
+
if auth_token:
|
|
196
|
+
config = verify_license(config)
|
|
197
|
+
|
|
198
|
+
console.print(f"\n[bold]Forge Core v{__version__}[/bold]\n")
|
|
199
|
+
console.print(f" Plan: {config.limits.plan.value}")
|
|
200
|
+
console.print(f" Org: {config.tenant.org_name or '(not set)'}")
|
|
201
|
+
console.print(f" Model: {config.ai.model}")
|
|
202
|
+
console.print(f" Provider: {config.ai.provider.value}")
|
|
203
|
+
console.print(f" API Key: {'***' + config.ai.api_key[-4:] if config.ai.api_key else '(not set)'}")
|
|
204
|
+
console.print(f" Auth: {'✓ logged in' if auth_token else '✗ offline'}")
|
|
205
|
+
console.print()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
app()
|
forge_core/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Configuration loader — merges agent-config.yml, env vars, and CLI args."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from forge_core.models.config import AIConfig, AIProvider, ForgeConfig, TenantInfo
|
|
11
|
+
from forge_core.utils import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_config(
|
|
15
|
+
project_path: Path,
|
|
16
|
+
api_key: str = "",
|
|
17
|
+
provider: str = "",
|
|
18
|
+
model: str = "",
|
|
19
|
+
target_coverage: float = 0,
|
|
20
|
+
auth_token: str = "",
|
|
21
|
+
) -> ForgeConfig:
|
|
22
|
+
"""Load configuration from agent-config.yml, env vars, and CLI overrides.
|
|
23
|
+
|
|
24
|
+
Priority: CLI args > env vars > agent-config.yml > defaults.
|
|
25
|
+
"""
|
|
26
|
+
config = ForgeConfig(project_path=project_path)
|
|
27
|
+
|
|
28
|
+
# 1. Load agent-config.yml from the project
|
|
29
|
+
yml_path = project_path / ".github" / "agent-config.yml"
|
|
30
|
+
if yml_path.exists():
|
|
31
|
+
_load_yml(config, yml_path)
|
|
32
|
+
|
|
33
|
+
# 2. Environment variables override
|
|
34
|
+
_load_env(config)
|
|
35
|
+
|
|
36
|
+
# 3. CLI args override
|
|
37
|
+
if api_key:
|
|
38
|
+
config.ai.api_key = api_key
|
|
39
|
+
if provider:
|
|
40
|
+
config.ai.provider = AIProvider(provider)
|
|
41
|
+
if model:
|
|
42
|
+
config.ai.model = model
|
|
43
|
+
if target_coverage > 0:
|
|
44
|
+
config.target_coverage = target_coverage
|
|
45
|
+
if auth_token:
|
|
46
|
+
config.auth_token = auth_token
|
|
47
|
+
|
|
48
|
+
# 4. Resolve prompts directory
|
|
49
|
+
config.prompts_dir = str(project_path / ".github" / "prompts")
|
|
50
|
+
|
|
51
|
+
return config
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_yml(config: ForgeConfig, yml_path: Path) -> None:
|
|
55
|
+
"""Load settings from agent-config.yml."""
|
|
56
|
+
try:
|
|
57
|
+
data = yaml.safe_load(yml_path.read_text(encoding="utf-8")) or {}
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.warn(f"Failed to parse {yml_path}: {e}")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
if "central_agent_path" in data:
|
|
63
|
+
config.central_agent_path = data["central_agent_path"]
|
|
64
|
+
if "knowledge_packs_dir" in data:
|
|
65
|
+
config.knowledge_packs_dir = data["knowledge_packs_dir"]
|
|
66
|
+
if "cache_dir" in data:
|
|
67
|
+
config.cache_dir = data["cache_dir"]
|
|
68
|
+
|
|
69
|
+
# Tenant info
|
|
70
|
+
config.tenant = TenantInfo(
|
|
71
|
+
org_id=data.get("org_id", ""),
|
|
72
|
+
org_name=data.get("org_name", ""),
|
|
73
|
+
user_id=data.get("user_id", ""),
|
|
74
|
+
project_id=data.get("project_id", ""),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Runtime config
|
|
78
|
+
if "runtime" in data and data["runtime"] != "auto":
|
|
79
|
+
try:
|
|
80
|
+
config.ai.provider = AIProvider(data["runtime"])
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
if "max_parallel_agents" in data:
|
|
84
|
+
config.limits.max_parallel_agents = int(data["max_parallel_agents"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_env(config: ForgeConfig) -> None:
|
|
88
|
+
"""Load settings from environment variables."""
|
|
89
|
+
env_map = {
|
|
90
|
+
"FORGE_API_KEY": "api_key",
|
|
91
|
+
"OPENAI_API_KEY": "api_key",
|
|
92
|
+
"ANTHROPIC_API_KEY": "api_key",
|
|
93
|
+
"FORGE_MODEL": "model",
|
|
94
|
+
"FORGE_PROVIDER": "provider",
|
|
95
|
+
"FORGE_AUTH_TOKEN": "auth_token",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for env_var, field in env_map.items():
|
|
99
|
+
value = os.environ.get(env_var, "")
|
|
100
|
+
if not value:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
if field == "api_key" and not config.ai.api_key:
|
|
104
|
+
config.ai.api_key = value
|
|
105
|
+
# Auto-detect provider from key prefix
|
|
106
|
+
if env_var == "ANTHROPIC_API_KEY" or value.startswith("sk-ant-"):
|
|
107
|
+
config.ai.provider = AIProvider.ANTHROPIC
|
|
108
|
+
if config.ai.model == "gpt-4o":
|
|
109
|
+
config.ai.model = "claude-sonnet-4-20250514"
|
|
110
|
+
elif field == "model":
|
|
111
|
+
config.ai.model = value
|
|
112
|
+
elif field == "provider":
|
|
113
|
+
try:
|
|
114
|
+
config.ai.provider = AIProvider(value)
|
|
115
|
+
except ValueError:
|
|
116
|
+
pass
|
|
117
|
+
elif field == "auth_token":
|
|
118
|
+
config.auth_token = value
|
|
File without changes
|