forgeoptimizer 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.
- 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 +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -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 +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -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 +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -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 +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -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 +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -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 +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -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 +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -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 +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -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 +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -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 +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -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 +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
forgecli/core/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Typed exception hierarchy for ForgeCLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ForgeCLIError(Exception):
|
|
7
|
+
"""Base class for all ForgeCLI-specific errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(ForgeCLIError):
|
|
11
|
+
"""Raised when configuration cannot be loaded or validated."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProviderError(ForgeCLIError):
|
|
15
|
+
"""Raised on AI provider failures (network, auth, schema)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitError(ForgeCLIError):
|
|
19
|
+
"""Raised on Git-related failures (missing repo, bad ref, etc)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PluginError(ForgeCLIError):
|
|
23
|
+
"""Raised on plugin discovery or lifecycle failures."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PipelineError(ForgeCLIError):
|
|
27
|
+
"""Raised when a builder/review/planner pipeline cannot complete."""
|
forgecli/core/events.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""A tiny pub/sub event bus used for decoupled logging and lifecycle hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
Subscriber = Callable[["Event"], "None | Awaitable[None]"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Event:
|
|
17
|
+
"""An immutable event payload."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EventBus:
|
|
25
|
+
"""Asynchronous-first pub/sub bus.
|
|
26
|
+
|
|
27
|
+
Synchronous subscribers are invoked inline; async subscribers are
|
|
28
|
+
scheduled via :func:`asyncio.create_task`. The bus is intentionally
|
|
29
|
+
simple: subscribers cannot block the publisher and ordering is
|
|
30
|
+
best-effort.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._subscribers: dict[str, list[Subscriber]] = {}
|
|
35
|
+
self._pending_tasks: set[asyncio.Task[Any]] = set()
|
|
36
|
+
|
|
37
|
+
def subscribe(self, name: str, callback: Subscriber) -> None:
|
|
38
|
+
"""Register ``callback`` for events named ``name``."""
|
|
39
|
+
self._subscribers.setdefault(name, []).append(callback)
|
|
40
|
+
|
|
41
|
+
def unsubscribe(self, name: str, callback: Subscriber) -> None:
|
|
42
|
+
bucket = self._subscribers.get(name)
|
|
43
|
+
if not bucket:
|
|
44
|
+
return
|
|
45
|
+
with contextlib.suppress(ValueError):
|
|
46
|
+
bucket.remove(callback)
|
|
47
|
+
|
|
48
|
+
async def publish(self, name: str, **payload: Any) -> None:
|
|
49
|
+
"""Publish an event, dispatching to all registered subscribers."""
|
|
50
|
+
event = Event(name=name, payload=dict(payload))
|
|
51
|
+
for callback in list(self._subscribers.get(name, ())):
|
|
52
|
+
result = callback(event)
|
|
53
|
+
if asyncio.iscoroutine(result):
|
|
54
|
+
await result
|
|
55
|
+
|
|
56
|
+
def emit(self, name: str, **payload: Any) -> None:
|
|
57
|
+
"""Synchronous variant of :meth:`publish`; async subs are not awaited."""
|
|
58
|
+
event = Event(name=name, payload=dict(payload))
|
|
59
|
+
for callback in list(self._subscribers.get(name, ())):
|
|
60
|
+
result = callback(event)
|
|
61
|
+
if asyncio.iscoroutine(result):
|
|
62
|
+
# Fire-and-forget; require an event loop to be running.
|
|
63
|
+
with contextlib.suppress(RuntimeError):
|
|
64
|
+
loop = asyncio.get_running_loop()
|
|
65
|
+
task = loop.create_task(result)
|
|
66
|
+
self._pending_tasks.add(task)
|
|
67
|
+
task.add_done_callback(self._pending_tasks.discard)
|
forgecli/core/logging.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Logging configuration built on the standard library ``logging`` package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
_LOG_FORMAT: Final[str] = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
10
|
+
_configured = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def configure_logging(level: str = "INFO") -> None:
|
|
14
|
+
"""Configure root logging once for the lifetime of the process."""
|
|
15
|
+
global _configured
|
|
16
|
+
if _configured:
|
|
17
|
+
new_level = _coerce_level(level)
|
|
18
|
+
if logging.getLogger().level != logging.DEBUG or new_level == logging.DEBUG:
|
|
19
|
+
logging.getLogger().setLevel(new_level)
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
handler = logging.StreamHandler(stream=sys.stderr)
|
|
23
|
+
handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
|
24
|
+
|
|
25
|
+
root = logging.getLogger()
|
|
26
|
+
root.handlers.clear()
|
|
27
|
+
root.addHandler(handler)
|
|
28
|
+
root.setLevel(_coerce_level(level))
|
|
29
|
+
|
|
30
|
+
# Quiet noisy third-party loggers.
|
|
31
|
+
for noisy in ("httpx", "httpcore", "git", "urllib3"):
|
|
32
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
33
|
+
|
|
34
|
+
_configured = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_logger(name: str) -> logging.Logger:
|
|
38
|
+
"""Return a configured logger, configuring root logging lazily."""
|
|
39
|
+
if not _configured:
|
|
40
|
+
configure_logging()
|
|
41
|
+
return logging.getLogger(name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _coerce_level(level: str) -> int:
|
|
45
|
+
if level.isdigit():
|
|
46
|
+
return int(level)
|
|
47
|
+
return logging.getLevelName(level.upper())
|