forgeoptimizer 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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Configuration loading and validation.
|
|
2
|
+
|
|
3
|
+
Public names are exposed lazily to avoid import cycles with
|
|
4
|
+
:mod:`forgecli.core.context`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
__all__ = ["ConfigLoader", "ForgeSettings"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
15
|
+
from forgecli.config.loader import ConfigLoader
|
|
16
|
+
from forgecli.config.settings import ForgeSettings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def __getattr__(name: str) -> Any:
|
|
20
|
+
if name == "ConfigLoader":
|
|
21
|
+
from forgecli.config.loader import ConfigLoader
|
|
22
|
+
|
|
23
|
+
return ConfigLoader
|
|
24
|
+
if name == "ForgeSettings":
|
|
25
|
+
from forgecli.config.settings import ForgeSettings
|
|
26
|
+
|
|
27
|
+
return ForgeSettings
|
|
28
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Load configuration from TOML files and merge with environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib # py3.11+
|
|
10
|
+
except ModuleNotFoundError: # pragma: no cover - fallback for older runtimes
|
|
11
|
+
import tomli as tomllib # type: ignore[no-redef,import-not-found]
|
|
12
|
+
|
|
13
|
+
from forgecli.config.settings import ForgeSettings
|
|
14
|
+
from forgecli.core.errors import ConfigError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigLoader:
|
|
18
|
+
"""Resolve and load configuration from disk and the environment."""
|
|
19
|
+
|
|
20
|
+
DEFAULT_CANDIDATES: tuple[Path, ...] = (
|
|
21
|
+
Path("./forgecli.toml"),
|
|
22
|
+
Path("./.forgecli.toml"),
|
|
23
|
+
Path("./pyproject.toml"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def __init__(self, *user_paths: Path) -> None:
|
|
27
|
+
self._user_paths: tuple[Path, ...] = user_paths
|
|
28
|
+
self._cached: ForgeSettings | None = None
|
|
29
|
+
|
|
30
|
+
def load(self, *, force: bool = False) -> ForgeSettings:
|
|
31
|
+
"""Load the configuration, caching the result for subsequent calls."""
|
|
32
|
+
if self._cached is not None and not force:
|
|
33
|
+
return self._cached
|
|
34
|
+
|
|
35
|
+
data: dict[str, Any] = {}
|
|
36
|
+
for path in self._candidate_paths():
|
|
37
|
+
if not path.exists():
|
|
38
|
+
continue
|
|
39
|
+
data = self._merge(data, self._read_toml(path))
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
settings = ForgeSettings(**data)
|
|
43
|
+
except Exception as exc: # pragma: no cover - delegated validation
|
|
44
|
+
raise ConfigError(f"Invalid configuration: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
self._cached = settings
|
|
47
|
+
return settings
|
|
48
|
+
|
|
49
|
+
def invalidate(self) -> None:
|
|
50
|
+
"""Clear the in-memory cache so the next ``load`` re-reads files."""
|
|
51
|
+
self._cached = None
|
|
52
|
+
|
|
53
|
+
def _candidate_paths(self) -> list[Path]:
|
|
54
|
+
if self._user_paths:
|
|
55
|
+
return list(self._user_paths)
|
|
56
|
+
return list(self.DEFAULT_CANDIDATES)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
60
|
+
if path.name == "pyproject.toml":
|
|
61
|
+
try:
|
|
62
|
+
payload = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
raise ConfigError(f"Unable to read {path}: {exc}") from exc
|
|
65
|
+
tool = payload.get("tool", {}).get("forgecli")
|
|
66
|
+
return dict(tool) if isinstance(tool, dict) else {}
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
raise ConfigError(f"Unable to read {path}: {exc}") from exc
|
|
72
|
+
except tomllib.TOMLDecodeError as exc:
|
|
73
|
+
raise ConfigError(f"Malformed TOML in {path}: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
77
|
+
"""Recursively merge two dictionaries, ``override`` winning on conflict."""
|
|
78
|
+
result: dict[str, Any] = dict(base)
|
|
79
|
+
for key, value in override.items():
|
|
80
|
+
existing = result.get(key)
|
|
81
|
+
if isinstance(existing, dict) and isinstance(value, dict):
|
|
82
|
+
result[key] = ConfigLoader._merge(existing, value)
|
|
83
|
+
else:
|
|
84
|
+
result[key] = value
|
|
85
|
+
return result
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Pydantic models describing the typed ForgeCLI configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
9
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppSection(BaseModel):
|
|
13
|
+
"""Top-level application metadata."""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(extra="allow")
|
|
16
|
+
|
|
17
|
+
name: str = "forgecli"
|
|
18
|
+
version: str = "0.1.0"
|
|
19
|
+
log_level: str = "INFO"
|
|
20
|
+
data_dir: Path = Field(default_factory=lambda: Path("~/.local/share/forgecli"))
|
|
21
|
+
plugins_dir: Path = Field(default_factory=lambda: Path("~/.config/forgecli/plugins"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProviderSection(BaseModel):
|
|
25
|
+
"""Configuration for a single AI provider."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(extra="allow")
|
|
28
|
+
|
|
29
|
+
api_key_env: str | None = None
|
|
30
|
+
base_url: str | None = None
|
|
31
|
+
base_url_env: str | None = None
|
|
32
|
+
default_model: str | None = None
|
|
33
|
+
max_tokens: int = 8192
|
|
34
|
+
temperature: float = 0.2
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProvidersConfig(BaseModel):
|
|
39
|
+
"""Provider registry configuration."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="allow")
|
|
42
|
+
|
|
43
|
+
default: str | None = None
|
|
44
|
+
default_model: str | None = None
|
|
45
|
+
allowed: list[str] = Field(default_factory=list)
|
|
46
|
+
providers: dict[str, ProviderSection] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GitSection(BaseModel):
|
|
50
|
+
"""Git automation settings."""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(extra="allow")
|
|
53
|
+
|
|
54
|
+
default_branch: str = "main"
|
|
55
|
+
user_name: str = ""
|
|
56
|
+
user_email: str = ""
|
|
57
|
+
auto_fetch: bool = True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OptimizerSection(BaseModel):
|
|
61
|
+
"""Context optimizer settings."""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(extra="allow")
|
|
64
|
+
|
|
65
|
+
max_context_tokens: int = 200_000
|
|
66
|
+
chunk_size: int = 4000
|
|
67
|
+
chunk_overlap: int = 200
|
|
68
|
+
strategy: str = "auto" # auto | sliding | map-reduce | graph
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PromptOptimizerSection(BaseModel):
|
|
72
|
+
"""Ponytail prompt-optimizer settings."""
|
|
73
|
+
|
|
74
|
+
model_config = ConfigDict(extra="allow")
|
|
75
|
+
|
|
76
|
+
enabled: bool = True
|
|
77
|
+
intensity: str = "lite" # off | lite | full | ultra
|
|
78
|
+
backend: str = "ruleset" # ruleset | cli | auto
|
|
79
|
+
binary: str = "ponytail" # only used when backend = "cli"
|
|
80
|
+
timeout_seconds: float = 30.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CavemanSection(BaseModel):
|
|
84
|
+
"""Caveman token-optimizer settings."""
|
|
85
|
+
|
|
86
|
+
model_config = ConfigDict(extra="allow")
|
|
87
|
+
|
|
88
|
+
enabled: bool = False
|
|
89
|
+
intensity: str = "off" # off | lite | full | ultra | wenyan
|
|
90
|
+
backend: str = "ruleset" # ruleset | cli | auto
|
|
91
|
+
binary: str = "caveman" # only used when backend = "cli"
|
|
92
|
+
timeout_seconds: float = 30.0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class GraphSection(BaseModel):
|
|
96
|
+
"""Repository graph settings."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(extra="allow")
|
|
99
|
+
|
|
100
|
+
enabled: bool = True
|
|
101
|
+
languages: list[str] = Field(default_factory=lambda: ["python", "typescript"])
|
|
102
|
+
max_depth: int = 8
|
|
103
|
+
ignore_patterns: list[str] = Field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MemorySection(BaseModel):
|
|
107
|
+
"""Local memory store settings."""
|
|
108
|
+
|
|
109
|
+
model_config = ConfigDict(extra="allow")
|
|
110
|
+
|
|
111
|
+
db_path: Path = Field(default_factory=lambda: Path("~/.local/share/forgecli/history.db"))
|
|
112
|
+
history_limit: int = 1000
|
|
113
|
+
embedding_provider: str = "mock"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ReviewSection(BaseModel):
|
|
117
|
+
"""Code review settings."""
|
|
118
|
+
|
|
119
|
+
model_config = ConfigDict(extra="allow")
|
|
120
|
+
|
|
121
|
+
enabled: bool = True
|
|
122
|
+
auto_review: bool = False
|
|
123
|
+
max_diff_lines: int = 2000
|
|
124
|
+
lint_first: bool = True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BuilderSection(BaseModel):
|
|
128
|
+
"""Builder/pipeline settings."""
|
|
129
|
+
|
|
130
|
+
model_config = ConfigDict(extra="allow")
|
|
131
|
+
|
|
132
|
+
auto_format: bool = True
|
|
133
|
+
formatter: str = "auto"
|
|
134
|
+
test_command: str = ""
|
|
135
|
+
max_iterations: int = 3
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PlannerSection(BaseModel):
|
|
139
|
+
"""Planner/agent settings."""
|
|
140
|
+
|
|
141
|
+
model_config = ConfigDict(extra="allow")
|
|
142
|
+
|
|
143
|
+
default_strategy: str = "react"
|
|
144
|
+
max_steps: int = 20
|
|
145
|
+
allow_shell: bool = False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class PromptsSection(BaseModel):
|
|
149
|
+
"""Prompt template settings."""
|
|
150
|
+
|
|
151
|
+
model_config = ConfigDict(extra="allow")
|
|
152
|
+
|
|
153
|
+
dir: Path = Field(default_factory=lambda: Path("~/.config/forgecli/prompts"))
|
|
154
|
+
render_backend: str = "jinja"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TelemetrySection(BaseModel):
|
|
158
|
+
"""Telemetry and analytics settings."""
|
|
159
|
+
|
|
160
|
+
model_config = ConfigDict(extra="allow")
|
|
161
|
+
|
|
162
|
+
enabled: bool = False
|
|
163
|
+
endpoint: str = ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ForgeSettings(BaseSettings):
|
|
167
|
+
"""Root settings model combining file config and environment variables."""
|
|
168
|
+
|
|
169
|
+
model_config = SettingsConfigDict(
|
|
170
|
+
env_prefix="FORGECLI_",
|
|
171
|
+
env_file=".env",
|
|
172
|
+
env_file_encoding="utf-8",
|
|
173
|
+
env_nested_delimiter="__",
|
|
174
|
+
extra="ignore",
|
|
175
|
+
case_sensitive=False,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
app: AppSection = Field(default_factory=AppSection)
|
|
179
|
+
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
|
|
180
|
+
git: GitSection = Field(default_factory=GitSection)
|
|
181
|
+
optimizer: OptimizerSection = Field(default_factory=OptimizerSection)
|
|
182
|
+
prompt_optimizer: PromptOptimizerSection = Field(default_factory=PromptOptimizerSection)
|
|
183
|
+
graph: GraphSection = Field(default_factory=GraphSection)
|
|
184
|
+
memory: MemorySection = Field(default_factory=MemorySection)
|
|
185
|
+
review: ReviewSection = Field(default_factory=ReviewSection)
|
|
186
|
+
builder: BuilderSection = Field(default_factory=BuilderSection)
|
|
187
|
+
planner: PlannerSection = Field(default_factory=PlannerSection)
|
|
188
|
+
prompts: PromptsSection = Field(default_factory=PromptsSection)
|
|
189
|
+
telemetry: TelemetrySection = Field(default_factory=TelemetrySection)
|
|
190
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
191
|
+
|
|
192
|
+
@field_validator(
|
|
193
|
+
"app",
|
|
194
|
+
"providers",
|
|
195
|
+
"git",
|
|
196
|
+
"optimizer",
|
|
197
|
+
"prompt_optimizer",
|
|
198
|
+
"graph",
|
|
199
|
+
mode="before",
|
|
200
|
+
)
|
|
201
|
+
@classmethod
|
|
202
|
+
def _coerce_none(cls, value: Any) -> Any:
|
|
203
|
+
"""Allow empty sections to be passed as ``None``."""
|
|
204
|
+
return value or {}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""TOML writer helper to update ForgeCLI config settings on the fly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib # py3.11+
|
|
11
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
12
|
+
import tomli as tomllib # type: ignore[no-redef,import-not-found]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def dump_toml(data: dict[str, Any]) -> str:
|
|
16
|
+
"""Serialize a dictionary to a basic TOML string (handles flat tables and primitive values)."""
|
|
17
|
+
lines = []
|
|
18
|
+
# Write top-level key-values first
|
|
19
|
+
for k, v in sorted(data.items()):
|
|
20
|
+
if not isinstance(v, dict):
|
|
21
|
+
if isinstance(v, bool):
|
|
22
|
+
lines.append(f"{k} = {str(v).lower()}")
|
|
23
|
+
elif isinstance(v, (int, float)):
|
|
24
|
+
lines.append(f"{k} = {v}")
|
|
25
|
+
elif isinstance(v, str):
|
|
26
|
+
lines.append(f'{k} = "{v}"')
|
|
27
|
+
elif isinstance(v, list):
|
|
28
|
+
items = ", ".join(f'"{x}"' if isinstance(x, str) else str(x) for x in v)
|
|
29
|
+
lines.append(f"{k} = [{items}]")
|
|
30
|
+
|
|
31
|
+
# Write tables
|
|
32
|
+
for section_name, section_val in sorted(data.items()):
|
|
33
|
+
if isinstance(section_val, dict):
|
|
34
|
+
lines.append(f"\n[{section_name}]")
|
|
35
|
+
for k, v in sorted(section_val.items()):
|
|
36
|
+
if isinstance(v, bool):
|
|
37
|
+
lines.append(f"{k} = {str(v).lower()}")
|
|
38
|
+
elif isinstance(v, (int, float)):
|
|
39
|
+
lines.append(f"{k} = {v}")
|
|
40
|
+
elif isinstance(v, str):
|
|
41
|
+
lines.append(f'{k} = "{v}"')
|
|
42
|
+
elif isinstance(v, list):
|
|
43
|
+
items = ", ".join(f'"{x}"' if isinstance(x, str) else str(x) for x in v)
|
|
44
|
+
lines.append(f"{k} = [{items}]")
|
|
45
|
+
|
|
46
|
+
return "\n".join(lines).strip() + "\n"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def update_config(
|
|
50
|
+
default_provider: str | None = None,
|
|
51
|
+
default_model: str | None = None,
|
|
52
|
+
clear_provider: bool = False,
|
|
53
|
+
clear_model: bool = False,
|
|
54
|
+
) -> Path:
|
|
55
|
+
"""Update defaults in the active forgecli.toml configuration file."""
|
|
56
|
+
from forgecli.config.loader import ConfigLoader
|
|
57
|
+
|
|
58
|
+
loader = ConfigLoader()
|
|
59
|
+
# Find existing writeable candidate config file
|
|
60
|
+
candidates = loader._candidate_paths()
|
|
61
|
+
target_path = None
|
|
62
|
+
for p in candidates:
|
|
63
|
+
if p.exists() and p.name != "pyproject.toml":
|
|
64
|
+
target_path = p
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
if not target_path:
|
|
68
|
+
target_path = Path("./forgecli.toml")
|
|
69
|
+
|
|
70
|
+
data: dict[str, Any] = {}
|
|
71
|
+
if target_path.exists():
|
|
72
|
+
with contextlib.suppress(Exception):
|
|
73
|
+
data = tomllib.loads(target_path.read_text(encoding="utf-8"))
|
|
74
|
+
|
|
75
|
+
if "providers" not in data:
|
|
76
|
+
data["providers"] = {}
|
|
77
|
+
|
|
78
|
+
if clear_provider:
|
|
79
|
+
data["providers"].pop("default", None)
|
|
80
|
+
elif default_provider is not None:
|
|
81
|
+
data["providers"]["default"] = default_provider
|
|
82
|
+
|
|
83
|
+
if clear_model:
|
|
84
|
+
data["providers"].pop("default_model", None)
|
|
85
|
+
elif default_model is not None:
|
|
86
|
+
data["providers"]["default_model"] = default_model
|
|
87
|
+
|
|
88
|
+
content = dump_toml(data)
|
|
89
|
+
target_path.write_text(content, encoding="utf-8")
|
|
90
|
+
return target_path
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Core orchestration primitives: app context, DI container, events."""
|
|
2
|
+
|
|
3
|
+
from forgecli.core.container import Container
|
|
4
|
+
from forgecli.core.context import AppContext
|
|
5
|
+
from forgecli.core.errors import (
|
|
6
|
+
ConfigError,
|
|
7
|
+
ForgeCLIError,
|
|
8
|
+
GitError,
|
|
9
|
+
PipelineError,
|
|
10
|
+
PluginError,
|
|
11
|
+
ProviderError,
|
|
12
|
+
)
|
|
13
|
+
from forgecli.core.events import Event, EventBus
|
|
14
|
+
from forgecli.core.logging import configure_logging, get_logger
|
|
15
|
+
from forgecli.core.plugins import Plugin, discover_plugins, install_plugins
|
|
16
|
+
from forgecli.core.service import Service
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AppContext",
|
|
20
|
+
"ConfigError",
|
|
21
|
+
"Container",
|
|
22
|
+
"Event",
|
|
23
|
+
"EventBus",
|
|
24
|
+
"ForgeCLIError",
|
|
25
|
+
"GitError",
|
|
26
|
+
"PipelineError",
|
|
27
|
+
"Plugin",
|
|
28
|
+
"PluginError",
|
|
29
|
+
"ProviderError",
|
|
30
|
+
"Service",
|
|
31
|
+
"configure_logging",
|
|
32
|
+
"discover_plugins",
|
|
33
|
+
"get_logger",
|
|
34
|
+
"install_plugins",
|
|
35
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Lightweight dependency-injection container."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Container:
|
|
12
|
+
"""A minimal service container with factory and singleton registrations.
|
|
13
|
+
|
|
14
|
+
The container intentionally avoids any global state; an instance is
|
|
15
|
+
owned by :class:`forgecli.core.context.AppContext` and created per process.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._factories: dict[type, Callable[[Container], Any]] = {}
|
|
20
|
+
self._singletons: dict[type, Any] = {}
|
|
21
|
+
self._singleton_flags: dict[type, bool] = {}
|
|
22
|
+
|
|
23
|
+
def register(
|
|
24
|
+
self,
|
|
25
|
+
interface: type[T],
|
|
26
|
+
factory: Callable[[Container], T],
|
|
27
|
+
*,
|
|
28
|
+
singleton: bool = True,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Register a factory for ``interface``.
|
|
31
|
+
|
|
32
|
+
When ``singleton`` is True, the first call to :meth:`resolve` will
|
|
33
|
+
cache the result and return it for all subsequent calls.
|
|
34
|
+
"""
|
|
35
|
+
self._factories[interface] = factory
|
|
36
|
+
self._singleton_flags[interface] = singleton
|
|
37
|
+
if not singleton:
|
|
38
|
+
self._singletons.pop(interface, None)
|
|
39
|
+
|
|
40
|
+
def register_instance(self, interface: type[T], instance: T) -> None:
|
|
41
|
+
"""Bind a pre-constructed instance to ``interface``."""
|
|
42
|
+
self._factories[interface] = lambda _c: instance
|
|
43
|
+
self._singletons[interface] = instance
|
|
44
|
+
self._singleton_flags[interface] = True
|
|
45
|
+
|
|
46
|
+
def resolve(self, interface: type[T]) -> T:
|
|
47
|
+
"""Resolve an instance for ``interface``."""
|
|
48
|
+
if interface in self._singletons:
|
|
49
|
+
return self._singletons[interface] # type: ignore[return-value]
|
|
50
|
+
if interface not in self._factories:
|
|
51
|
+
raise KeyError(f"No registration for {interface!r}")
|
|
52
|
+
instance = self._factories[interface](self)
|
|
53
|
+
if self._is_singleton(interface):
|
|
54
|
+
self._singletons[interface] = instance
|
|
55
|
+
return instance # type: ignore[return-value]
|
|
56
|
+
|
|
57
|
+
def _is_singleton(self, interface: type) -> bool:
|
|
58
|
+
"""Return True if ``interface`` was registered as a singleton."""
|
|
59
|
+
return self._singleton_flags.get(interface, True)
|
|
60
|
+
|
|
61
|
+
def has(self, interface: type) -> bool:
|
|
62
|
+
return interface in self._factories or interface in self._singletons
|
|
63
|
+
|
|
64
|
+
def clear(self) -> None:
|
|
65
|
+
"""Drop all registrations and cached singletons."""
|
|
66
|
+
self._factories.clear()
|
|
67
|
+
self._singletons.clear()
|
|
68
|
+
self._singleton_flags.clear()
|
forgecli/core/context.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Application context: shared state object passed across the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from forgecli.core.container import Container
|
|
10
|
+
from forgecli.core.events import EventBus
|
|
11
|
+
from forgecli.utils.paths import ProjectPaths
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
14
|
+
from forgecli.config.loader import ConfigLoader
|
|
15
|
+
from forgecli.config.settings import ForgeSettings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AppContext:
|
|
20
|
+
"""Shared, mutable context for one CLI invocation.
|
|
21
|
+
|
|
22
|
+
The context is created in :mod:`forgecli.cli.main` and explicitly
|
|
23
|
+
passed to services that need it. It owns the configuration loader,
|
|
24
|
+
the DI container, the event bus, and resolved filesystem paths.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
paths: ProjectPaths
|
|
28
|
+
loader: ConfigLoader
|
|
29
|
+
settings: ForgeSettings | None = None
|
|
30
|
+
container: Container = field(default_factory=Container)
|
|
31
|
+
event_bus: EventBus = field(default_factory=EventBus)
|
|
32
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def resolve_settings(self, *, force: bool = False) -> ForgeSettings:
|
|
35
|
+
"""Load (or return cached) settings."""
|
|
36
|
+
if self.settings is None or force:
|
|
37
|
+
self.settings = self.loader.load(force=force)
|
|
38
|
+
return self.settings
|
|
39
|
+
|
|
40
|
+
def with_overrides(self, **values: Any) -> AppContext:
|
|
41
|
+
"""Return a shallow copy with extra values merged into ``extras``."""
|
|
42
|
+
new = AppContext(
|
|
43
|
+
paths=self.paths,
|
|
44
|
+
loader=self.loader,
|
|
45
|
+
settings=self.settings,
|
|
46
|
+
container=self.container,
|
|
47
|
+
event_bus=self.event_bus,
|
|
48
|
+
extras={**self.extras, **values},
|
|
49
|
+
)
|
|
50
|
+
return new
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def cwd(self) -> Path:
|
|
54
|
+
return self.paths.cwd
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Secure storage for API keys with OS keychain (keyring) and encrypted fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
import getpass
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import keyring
|
|
15
|
+
from cryptography.fernet import Fernet
|
|
16
|
+
|
|
17
|
+
from forgecli.utils.paths import ProjectPaths
|
|
18
|
+
|
|
19
|
+
KEYRING_SERVICE = "forgectx"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_credentials_file() -> Path:
|
|
23
|
+
paths = ProjectPaths.from_env()
|
|
24
|
+
paths.config_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
return paths.config_dir / "credentials.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_encryption_key() -> bytes:
|
|
29
|
+
try:
|
|
30
|
+
host_info = f"{uuid.getnode()}:{getpass.getuser()}:{sys.platform}"
|
|
31
|
+
except Exception:
|
|
32
|
+
host_info = "fallback-forgectx-key-salt"
|
|
33
|
+
key_hash = hashlib.sha256(host_info.encode("utf-8")).digest()
|
|
34
|
+
return base64.urlsafe_b64encode(key_hash)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_encrypted_file() -> dict[str, str]:
|
|
38
|
+
path = _get_credentials_file()
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return {}
|
|
41
|
+
try:
|
|
42
|
+
raw_data = path.read_bytes()
|
|
43
|
+
fernet = Fernet(_get_encryption_key())
|
|
44
|
+
decrypted = fernet.decrypt(raw_data)
|
|
45
|
+
return json.loads(decrypted.decode("utf-8"))
|
|
46
|
+
except Exception:
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _write_encrypted_file(data: dict[str, str]) -> None:
|
|
51
|
+
path = _get_credentials_file()
|
|
52
|
+
fernet = Fernet(_get_encryption_key())
|
|
53
|
+
encrypted = fernet.encrypt(json.dumps(data).encode("utf-8"))
|
|
54
|
+
path.write_bytes(encrypted)
|
|
55
|
+
with contextlib.suppress(OSError):
|
|
56
|
+
path.chmod(0o600)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_api_key(provider: str) -> str | None:
|
|
60
|
+
"""Retrieve the API key for a provider."""
|
|
61
|
+
provider = provider.lower().strip()
|
|
62
|
+
try:
|
|
63
|
+
val = keyring.get_password(KEYRING_SERVICE, provider)
|
|
64
|
+
if val is not None:
|
|
65
|
+
return val
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
data = _read_encrypted_file()
|
|
69
|
+
return data.get(provider)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_api_key(provider: str, api_key: str) -> None:
|
|
73
|
+
"""Store an API key in the OS keychain, falling back to encrypted disk storage."""
|
|
74
|
+
provider = provider.lower().strip()
|
|
75
|
+
api_key = api_key.strip()
|
|
76
|
+
if not provider or not api_key:
|
|
77
|
+
raise ValueError("provider and api_key are required")
|
|
78
|
+
try:
|
|
79
|
+
keyring.set_password(KEYRING_SERVICE, provider, api_key)
|
|
80
|
+
return
|
|
81
|
+
except Exception:
|
|
82
|
+
data = _read_encrypted_file()
|
|
83
|
+
data[provider] = api_key
|
|
84
|
+
_write_encrypted_file(data)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def delete_api_key(provider: str) -> None:
|
|
88
|
+
"""Remove a stored API key."""
|
|
89
|
+
provider = provider.lower().strip()
|
|
90
|
+
with contextlib.suppress(Exception):
|
|
91
|
+
keyring.delete_password(KEYRING_SERVICE, provider)
|
|
92
|
+
data = _read_encrypted_file()
|
|
93
|
+
if provider in data:
|
|
94
|
+
del data[provider]
|
|
95
|
+
_write_encrypted_file(data)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def list_stored_providers() -> list[str]:
|
|
99
|
+
"""Return provider names with stored credentials."""
|
|
100
|
+
providers = set(_read_encrypted_file())
|
|
101
|
+
for name in (
|
|
102
|
+
"openai",
|
|
103
|
+
"anthropic",
|
|
104
|
+
"google",
|
|
105
|
+
"openrouter",
|
|
106
|
+
"groq",
|
|
107
|
+
"together",
|
|
108
|
+
):
|
|
109
|
+
try:
|
|
110
|
+
if keyring.get_password(KEYRING_SERVICE, name):
|
|
111
|
+
providers.add(name)
|
|
112
|
+
except Exception:
|
|
113
|
+
continue
|
|
114
|
+
return sorted(providers)
|