forgex-cli 0.1.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.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
forgex/cli/session.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Interactive session state management for FORGEX REPL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from forgex.cli.context_memory import ConversationContext
|
|
13
|
+
from forgex.cli.ui import UI, Output
|
|
14
|
+
from forgex.config import ForgexConfig, load_config
|
|
15
|
+
from forgex.config.loader import load_env_files
|
|
16
|
+
from forgex.orchestration.state import StateStore, TaskStore
|
|
17
|
+
from forgex.project import ProjectDetection, detect_project
|
|
18
|
+
from forgex.schemas.project import ProjectState
|
|
19
|
+
from forgex.schemas.task import Task
|
|
20
|
+
from forgex.utils.paths import find_project_root
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Session:
|
|
25
|
+
"""Encapsulates persistent state during an interactive FORGEX terminal session."""
|
|
26
|
+
|
|
27
|
+
project_root: Path | None = None
|
|
28
|
+
config: ForgexConfig = field(default_factory=ForgexConfig)
|
|
29
|
+
ui: UI = field(default_factory=lambda: UI(mode=Output.RICH))
|
|
30
|
+
console: Console = field(default_factory=Console)
|
|
31
|
+
history: list[str] = field(default_factory=list)
|
|
32
|
+
context: ConversationContext = field(default_factory=ConversationContext)
|
|
33
|
+
_router: Any = None
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
self.refresh()
|
|
37
|
+
|
|
38
|
+
def refresh(self) -> None:
|
|
39
|
+
"""Refresh project root, environment, and configuration from disk."""
|
|
40
|
+
self.project_root = find_project_root()
|
|
41
|
+
load_env_files(self.project_root)
|
|
42
|
+
self.config = load_config(self.project_root)
|
|
43
|
+
self._router = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_attached(self) -> bool:
|
|
47
|
+
"""True if the session is currently attached to an initialized project."""
|
|
48
|
+
return self.project_root is not None and (self.project_root / ".forgex").is_dir()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def project_name(self) -> str:
|
|
52
|
+
"""Name of the attached project directory, or 'unattached'."""
|
|
53
|
+
if self.project_root is not None:
|
|
54
|
+
return self.project_root.name
|
|
55
|
+
return "unattached"
|
|
56
|
+
|
|
57
|
+
def detect(self) -> ProjectDetection | None:
|
|
58
|
+
"""Run project detector on current directory or attached project."""
|
|
59
|
+
target = self.project_root or Path.cwd()
|
|
60
|
+
return detect_project(target)
|
|
61
|
+
|
|
62
|
+
def get_state(self) -> ProjectState | None:
|
|
63
|
+
"""Load current ProjectState if attached."""
|
|
64
|
+
if not self.is_attached or self.project_root is None:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
return StateStore(self.project_root).load()
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def get_tasks(self) -> list[Task]:
|
|
72
|
+
"""Load project tasks if attached."""
|
|
73
|
+
if not self.is_attached or self.project_root is None:
|
|
74
|
+
return []
|
|
75
|
+
try:
|
|
76
|
+
return TaskStore(self.project_root).load()
|
|
77
|
+
except Exception:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def get_router(self) -> Any:
|
|
81
|
+
"""Lazily instantiate and return the ModelRouter."""
|
|
82
|
+
if self._router is None:
|
|
83
|
+
from forgex.models.router import ModelRouter
|
|
84
|
+
|
|
85
|
+
self._router = ModelRouter(self.config)
|
|
86
|
+
return self._router
|
|
87
|
+
|
|
88
|
+
def change_directory(self, target: Path | str) -> bool:
|
|
89
|
+
"""Change working directory and refresh session."""
|
|
90
|
+
dest = Path(target).expanduser().resolve()
|
|
91
|
+
if not dest.is_dir():
|
|
92
|
+
return False
|
|
93
|
+
os.chdir(dest)
|
|
94
|
+
self.refresh()
|
|
95
|
+
return True
|
forgex/cli/ui.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Terminal output adapter (spec section 23).
|
|
2
|
+
|
|
3
|
+
All user-facing output goes through :class:`UI` so the modes required by
|
|
4
|
+
spec section 23 (``--plain`` / ``--json`` / ``--quiet``) behave
|
|
5
|
+
consistently across every command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Annotated, Any
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.prompt import Confirm
|
|
18
|
+
from rich.theme import Theme
|
|
19
|
+
|
|
20
|
+
_THEME = Theme(
|
|
21
|
+
{
|
|
22
|
+
"forgex.title": "bold cyan",
|
|
23
|
+
"forgex.ok": "green",
|
|
24
|
+
"forgex.warn": "yellow",
|
|
25
|
+
"forgex.err": "red",
|
|
26
|
+
"forgex.muted": "dim",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Output(str, Enum):
|
|
32
|
+
RICH = "rich"
|
|
33
|
+
PLAIN = "plain"
|
|
34
|
+
JSON = "json"
|
|
35
|
+
QUIET = "quiet"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
JsonFlag = Annotated[bool, typer.Option("--json", help="Machine-readable JSON output.")]
|
|
39
|
+
PlainFlag = Annotated[bool, typer.Option("--plain", help="Plain output (CI/SSH safe).")]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UI:
|
|
43
|
+
def __init__(self, mode: Output, console: Console | None = None) -> None:
|
|
44
|
+
self.mode = mode
|
|
45
|
+
if console is not None:
|
|
46
|
+
self.console = console
|
|
47
|
+
elif mode is Output.JSON:
|
|
48
|
+
self.console = Console(theme=_THEME, soft_wrap=True, highlight=False)
|
|
49
|
+
else:
|
|
50
|
+
self.console = Console(theme=_THEME)
|
|
51
|
+
|
|
52
|
+
def ok(self, message: str) -> None:
|
|
53
|
+
if self.mode is Output.QUIET or self.mode is Output.JSON:
|
|
54
|
+
return
|
|
55
|
+
if self.mode is Output.RICH:
|
|
56
|
+
self.console.print(f"[green]✓[/green] {message}")
|
|
57
|
+
else:
|
|
58
|
+
self.console.print(f"OK: {message}")
|
|
59
|
+
|
|
60
|
+
def warn(self, message: str) -> None:
|
|
61
|
+
if self.mode is Output.QUIET or self.mode is Output.JSON:
|
|
62
|
+
return
|
|
63
|
+
if self.mode is Output.RICH:
|
|
64
|
+
self.console.print(f"[yellow]![/yellow] {message}")
|
|
65
|
+
else:
|
|
66
|
+
self.console.print(f"WARN: {message}")
|
|
67
|
+
|
|
68
|
+
def error(self, message: str) -> None:
|
|
69
|
+
if self.mode is Output.JSON:
|
|
70
|
+
return
|
|
71
|
+
if self.mode is Output.RICH:
|
|
72
|
+
self.console.print(f"[red]✗[/red] {message}")
|
|
73
|
+
else:
|
|
74
|
+
self.console.print(f"ERROR: {message}")
|
|
75
|
+
|
|
76
|
+
def text(self, message: str) -> None:
|
|
77
|
+
if self.mode is Output.QUIET or self.mode is Output.JSON:
|
|
78
|
+
return
|
|
79
|
+
self.console.print(message)
|
|
80
|
+
|
|
81
|
+
def data(self, payload: dict[str, Any]) -> None:
|
|
82
|
+
if self.mode is Output.JSON:
|
|
83
|
+
self.console.print(json.dumps(payload, indent=2, default=str), soft_wrap=True, highlight=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ui_from(
|
|
87
|
+
ctx: typer.Context,
|
|
88
|
+
*,
|
|
89
|
+
json_out: bool = False,
|
|
90
|
+
plain: bool = False,
|
|
91
|
+
) -> UI:
|
|
92
|
+
obj = ctx.obj or {}
|
|
93
|
+
if json_out or obj.get("json"):
|
|
94
|
+
mode = Output.JSON
|
|
95
|
+
elif plain or obj.get("plain"):
|
|
96
|
+
mode = Output.PLAIN
|
|
97
|
+
elif obj.get("quiet"):
|
|
98
|
+
mode = Output.QUIET
|
|
99
|
+
else:
|
|
100
|
+
mode = Output.RICH
|
|
101
|
+
|
|
102
|
+
return UI(mode=mode)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def make_approval(ui: UI):
|
|
106
|
+
"""Interactive approval callback (spec section 33)."""
|
|
107
|
+
|
|
108
|
+
def approve(description: str) -> bool:
|
|
109
|
+
if ui.mode is not Output.RICH or not sys.stdin.isatty():
|
|
110
|
+
return False
|
|
111
|
+
return Confirm.ask(f"FORGEX wants to: {description}\nAllow?", default=False)
|
|
112
|
+
|
|
113
|
+
return approve
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Configuration package: layered loading with Pydantic validation."""
|
|
2
|
+
|
|
3
|
+
from forgex.config.loader import (
|
|
4
|
+
CONFIG_FILE_NAME,
|
|
5
|
+
ENV_FILE_NAME,
|
|
6
|
+
FORGEX_DIR_NAME,
|
|
7
|
+
global_config_path,
|
|
8
|
+
load_config,
|
|
9
|
+
load_env_files,
|
|
10
|
+
parse_env_file,
|
|
11
|
+
project_config_path,
|
|
12
|
+
save_config,
|
|
13
|
+
set_config_value,
|
|
14
|
+
store_secret,
|
|
15
|
+
)
|
|
16
|
+
from forgex.config.schema import (
|
|
17
|
+
AgentModelBinding,
|
|
18
|
+
EnsembleConfig,
|
|
19
|
+
FallbackConfig,
|
|
20
|
+
ForgexConfig,
|
|
21
|
+
ModelsConfig,
|
|
22
|
+
PermissionsConfig,
|
|
23
|
+
ProviderConfig,
|
|
24
|
+
ResearchConfig,
|
|
25
|
+
SandboxConfig,
|
|
26
|
+
TerminalConfig,
|
|
27
|
+
WorkflowConfig,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CONFIG_FILE_NAME",
|
|
32
|
+
"ENV_FILE_NAME",
|
|
33
|
+
"FORGEX_DIR_NAME",
|
|
34
|
+
"AgentModelBinding",
|
|
35
|
+
"EnsembleConfig",
|
|
36
|
+
"FallbackConfig",
|
|
37
|
+
"ForgexConfig",
|
|
38
|
+
"ModelsConfig",
|
|
39
|
+
"PermissionsConfig",
|
|
40
|
+
"ProviderConfig",
|
|
41
|
+
"ResearchConfig",
|
|
42
|
+
"SandboxConfig",
|
|
43
|
+
"TerminalConfig",
|
|
44
|
+
"WorkflowConfig",
|
|
45
|
+
"global_config_path",
|
|
46
|
+
"load_config",
|
|
47
|
+
"load_env_files",
|
|
48
|
+
"parse_env_file",
|
|
49
|
+
"project_config_path",
|
|
50
|
+
"save_config",
|
|
51
|
+
"set_config_value",
|
|
52
|
+
"store_secret",
|
|
53
|
+
]
|
forgex/config/loader.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Layered configuration loading, saving, and dotted-key editing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import tomli_w
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from forgex.config.schema import ForgexConfig
|
|
15
|
+
from forgex.schemas.error import ConfigError
|
|
16
|
+
|
|
17
|
+
FORGEX_DIR_NAME = ".forgex"
|
|
18
|
+
CONFIG_FILE_NAME = "config.toml"
|
|
19
|
+
ENV_FILE_NAME = ".env"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def global_config_path() -> Path:
|
|
23
|
+
return Path.home() / FORGEX_DIR_NAME / CONFIG_FILE_NAME
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def project_config_path(project_root: Path) -> Path:
|
|
27
|
+
return project_root / FORGEX_DIR_NAME / CONFIG_FILE_NAME
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
return {}
|
|
33
|
+
try:
|
|
34
|
+
with path.open("rb") as fh:
|
|
35
|
+
return tomllib.load(fh)
|
|
36
|
+
except tomllib.TOMLDecodeError as exc:
|
|
37
|
+
msg = f"Invalid TOML in {path}: {exc}"
|
|
38
|
+
raise ConfigError(msg) from exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
42
|
+
merged = dict(base)
|
|
43
|
+
for key, value in override.items():
|
|
44
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
45
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
46
|
+
else:
|
|
47
|
+
merged[key] = value
|
|
48
|
+
return merged
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config(
|
|
52
|
+
project_root: Path | None = None,
|
|
53
|
+
overrides: dict[str, Any] | None = None,
|
|
54
|
+
) -> ForgexConfig:
|
|
55
|
+
"""Load merged configuration: defaults < global < project < overrides."""
|
|
56
|
+
data: dict[str, Any] = ForgexConfig().model_dump()
|
|
57
|
+
data = _deep_merge(data, _read_toml(global_config_path()))
|
|
58
|
+
if project_root is not None:
|
|
59
|
+
data = _deep_merge(data, _read_toml(project_config_path(project_root)))
|
|
60
|
+
if overrides:
|
|
61
|
+
data = _deep_merge(data, overrides)
|
|
62
|
+
try:
|
|
63
|
+
config = ForgexConfig.model_validate(data)
|
|
64
|
+
except ValidationError as exc:
|
|
65
|
+
msg = f"Invalid configuration: {exc.error_count()} error(s): {exc}"
|
|
66
|
+
raise ConfigError(msg) from exc
|
|
67
|
+
|
|
68
|
+
if os.environ.get("FORGEX_OFFLINE") == "1":
|
|
69
|
+
# Offline mode (§73): web research off; local providers still allowed.
|
|
70
|
+
config.research.enabled = False
|
|
71
|
+
return config
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_config(config: ForgexConfig, path: Path) -> None:
|
|
75
|
+
"""Write a full config to *path* as TOML (None values omitted — TOML has no null)."""
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
text = tomli_w.dumps(config.model_dump(mode="json", exclude_none=True))
|
|
78
|
+
path.write_text(text, encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _coerce(raw: Any) -> Any:
|
|
82
|
+
"""Convert a CLI string into bool/int/float/JSON when unambiguous."""
|
|
83
|
+
if not isinstance(raw, str):
|
|
84
|
+
return raw
|
|
85
|
+
if raw.lower() in ("true", "false"):
|
|
86
|
+
return raw.lower() == "true"
|
|
87
|
+
if raw and raw[0] in "[{":
|
|
88
|
+
try:
|
|
89
|
+
return json.loads(raw)
|
|
90
|
+
except ValueError:
|
|
91
|
+
pass
|
|
92
|
+
for cast in (int, float):
|
|
93
|
+
try:
|
|
94
|
+
return cast(raw)
|
|
95
|
+
except ValueError:
|
|
96
|
+
pass
|
|
97
|
+
return raw
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def set_config_value(path: Path, dotted_key: str, value: Any) -> ForgexConfig:
|
|
101
|
+
"""Set a dotted key in config at path, validate with Pydantic, and save.
|
|
102
|
+
|
|
103
|
+
Refuses to write if the resulting configuration would be invalid.
|
|
104
|
+
"""
|
|
105
|
+
raw_dict = _read_toml(path) if path.is_file() else {}
|
|
106
|
+
coerced_value = _coerce(value)
|
|
107
|
+
|
|
108
|
+
# Insert into nested dict
|
|
109
|
+
parts = dotted_key.split(".")
|
|
110
|
+
curr = raw_dict
|
|
111
|
+
for part in parts[:-1]:
|
|
112
|
+
if part not in curr or not isinstance(curr[part], dict):
|
|
113
|
+
curr[part] = {}
|
|
114
|
+
curr = curr[part]
|
|
115
|
+
curr[parts[-1]] = coerced_value
|
|
116
|
+
|
|
117
|
+
# Merge onto defaults to validate full schema
|
|
118
|
+
data = ForgexConfig().model_dump()
|
|
119
|
+
data = _deep_merge(data, raw_dict)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
validated = ForgexConfig.model_validate(data)
|
|
123
|
+
except ValidationError as exc:
|
|
124
|
+
msg = f"Invalid configuration for {dotted_key}={value}: {exc}"
|
|
125
|
+
raise ConfigError(msg) from exc
|
|
126
|
+
|
|
127
|
+
# Write validated TOML
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
# Save the minimal raw_dict or full validated config
|
|
130
|
+
save_config(validated, path)
|
|
131
|
+
return validated
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
135
|
+
"""Minimal .env parser: comments, optional 'export ', KEY=VALUE, quotes."""
|
|
136
|
+
values: dict[str, str] = {}
|
|
137
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
138
|
+
line = line.strip()
|
|
139
|
+
if not line or line.startswith("#"):
|
|
140
|
+
continue
|
|
141
|
+
line = line.removeprefix("export ")
|
|
142
|
+
if "=" not in line:
|
|
143
|
+
continue
|
|
144
|
+
key, _, value = line.partition("=")
|
|
145
|
+
key, value = key.strip(), value.strip()
|
|
146
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
147
|
+
value = value[1:-1]
|
|
148
|
+
if key:
|
|
149
|
+
values[key] = value
|
|
150
|
+
return values
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_env_files(project_root: Path | None = None) -> list[Path]:
|
|
154
|
+
"""Load ~/.forgex/.env then <project>/.env into os.environ with setdefault.
|
|
155
|
+
|
|
156
|
+
Priority: real environment always wins; project .env beats global .env.
|
|
157
|
+
"""
|
|
158
|
+
paths = []
|
|
159
|
+
if project_root is not None:
|
|
160
|
+
paths.append(Path(project_root) / ENV_FILE_NAME)
|
|
161
|
+
paths.append(Path.home() / FORGEX_DIR_NAME / ENV_FILE_NAME)
|
|
162
|
+
loaded: list[Path] = []
|
|
163
|
+
for path in paths:
|
|
164
|
+
if not path.is_file():
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
values = parse_env_file(path)
|
|
168
|
+
except OSError:
|
|
169
|
+
continue
|
|
170
|
+
for key, value in values.items():
|
|
171
|
+
os.environ.setdefault(key, value)
|
|
172
|
+
loaded.append(path)
|
|
173
|
+
return loaded
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def store_secret(env_var: str, value: str, *, global_dir: Path | None = None) -> Path:
|
|
177
|
+
"""Persist a secret to ~/.forgex/.env (chmod 600). Never into config files."""
|
|
178
|
+
target = (global_dir or Path.home() / FORGEX_DIR_NAME) / ENV_FILE_NAME
|
|
179
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
existing: dict[str, str] = parse_env_file(target) if target.is_file() else {}
|
|
181
|
+
existing[env_var] = value
|
|
182
|
+
lines = [f"{key}={val}" for key, val in existing.items()]
|
|
183
|
+
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
184
|
+
if os.name == "posix":
|
|
185
|
+
try:
|
|
186
|
+
os.chmod(target, 0o600)
|
|
187
|
+
except OSError:
|
|
188
|
+
pass
|
|
189
|
+
return target
|
forgex/config/schema.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Pydantic models describing FORGEX configuration.
|
|
2
|
+
|
|
3
|
+
Priority (highest wins), per spec section 27:
|
|
4
|
+
CLI arguments > environment (secrets) > project .forgex/config.toml
|
|
5
|
+
> global ~/.forgex/config.toml > schema defaults.
|
|
6
|
+
|
|
7
|
+
Secrets NEVER live in these files — providers store only the *name* of
|
|
8
|
+
the environment variable holding the key (``api_key_env``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from forgex.models.capabilities import ModelCapabilities
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WorkflowConfig(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
max_iterations: int = Field(default=5, ge=1, le=20)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PermissionsConfig(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
|
|
29
|
+
mode: Literal["auto", "ask", "safe"] = "ask"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProviderConfig(BaseModel):
|
|
33
|
+
"""A provider endpoint description. Contains no secrets."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
type: Literal["openai_compatible", "groq", "local"] = "openai_compatible"
|
|
38
|
+
base_url: str | None = None
|
|
39
|
+
api_key_env: str | None = None
|
|
40
|
+
default_model: str | None = None
|
|
41
|
+
timeout_seconds: float = 60.0
|
|
42
|
+
max_retries: int = 2
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AgentModelBinding(BaseModel):
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
provider: str = "groq"
|
|
49
|
+
model: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ModelsConfig(BaseModel):
|
|
53
|
+
model_config = ConfigDict(extra="forbid")
|
|
54
|
+
|
|
55
|
+
architect: AgentModelBinding = Field(default_factory=AgentModelBinding)
|
|
56
|
+
builder: AgentModelBinding = Field(default_factory=AgentModelBinding)
|
|
57
|
+
expert_coder: AgentModelBinding = Field(default_factory=AgentModelBinding)
|
|
58
|
+
supervisor: AgentModelBinding = Field(default_factory=AgentModelBinding)
|
|
59
|
+
capabilities: dict[str, ModelCapabilities] = Field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FallbackConfig(BaseModel):
|
|
63
|
+
model_config = ConfigDict(extra="forbid")
|
|
64
|
+
|
|
65
|
+
enabled: bool = False
|
|
66
|
+
providers: list[str] = Field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TerminalConfig(BaseModel):
|
|
70
|
+
"""Command execution policy knobs (spec sections 19, 45)."""
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(extra="forbid")
|
|
73
|
+
|
|
74
|
+
allowed: list[str] = Field(default_factory=list)
|
|
75
|
+
denied: list[str] = Field(default_factory=list)
|
|
76
|
+
timeout_seconds: int = 120
|
|
77
|
+
max_output_bytes: int = 1_000_000
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SandboxConfig(BaseModel):
|
|
81
|
+
"""Docker sandbox (spec section 20). Default: restricted."""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(extra="forbid")
|
|
84
|
+
|
|
85
|
+
enabled: bool = False
|
|
86
|
+
network: bool = False
|
|
87
|
+
image: str = "python:3.12-slim"
|
|
88
|
+
cpu_limit: str | None = None
|
|
89
|
+
memory_limit: str | None = None
|
|
90
|
+
read_only: bool = True
|
|
91
|
+
user: str | None = None
|
|
92
|
+
timeout_seconds: int = 120
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ResearchConfig(BaseModel):
|
|
96
|
+
"""Web research toggle (spec sections 8, 73)."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(extra="forbid")
|
|
99
|
+
|
|
100
|
+
enabled: bool = True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class EnsembleConfig(BaseModel):
|
|
104
|
+
"""Multi-model ensemble review (spec section 15)."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(extra="forbid")
|
|
107
|
+
|
|
108
|
+
enabled: bool = False
|
|
109
|
+
reviewers: int = Field(default=2, ge=2, le=4)
|
|
110
|
+
bindings: list[AgentModelBinding] = Field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _default_providers() -> dict[str, ProviderConfig]:
|
|
114
|
+
return {
|
|
115
|
+
"groq": ProviderConfig(
|
|
116
|
+
type="groq",
|
|
117
|
+
base_url="https://api.groq.com/openai/v1",
|
|
118
|
+
api_key_env="FORGEX_GROQ_API_KEY",
|
|
119
|
+
default_model="llama-3.3-70b-versatile",
|
|
120
|
+
),
|
|
121
|
+
"openai_compatible": ProviderConfig(
|
|
122
|
+
type="openai_compatible",
|
|
123
|
+
base_url="https://api.openai.com/v1",
|
|
124
|
+
api_key_env="FORGEX_OPENAI_API_KEY",
|
|
125
|
+
default_model="gpt-4o-mini",
|
|
126
|
+
),
|
|
127
|
+
"local": ProviderConfig(
|
|
128
|
+
type="local",
|
|
129
|
+
base_url="http://localhost:11434/v1",
|
|
130
|
+
),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ForgexConfig(BaseModel):
|
|
135
|
+
model_config = ConfigDict(extra="forbid")
|
|
136
|
+
|
|
137
|
+
workflow: WorkflowConfig = Field(default_factory=WorkflowConfig)
|
|
138
|
+
permissions: PermissionsConfig = Field(default_factory=PermissionsConfig)
|
|
139
|
+
terminal: TerminalConfig = Field(default_factory=TerminalConfig)
|
|
140
|
+
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
|
|
141
|
+
research: ResearchConfig = Field(default_factory=ResearchConfig)
|
|
142
|
+
ensemble: EnsembleConfig = Field(default_factory=EnsembleConfig)
|
|
143
|
+
fallback: FallbackConfig = Field(default_factory=FallbackConfig)
|
|
144
|
+
models: ModelsConfig = Field(default_factory=ModelsConfig)
|
|
145
|
+
providers: dict[str, ProviderConfig] = Field(default_factory=_default_providers)
|