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.
Files changed (156) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +137 -0
  19. forgecli/cli/commands_wrappers.py +63 -0
  20. forgecli/cli/main.py +122 -0
  21. forgecli/cli/ui.py +56 -0
  22. forgecli/config/__init__.py +28 -0
  23. forgecli/config/loader.py +85 -0
  24. forgecli/config/settings.py +204 -0
  25. forgecli/config/writer.py +90 -0
  26. forgecli/core/__init__.py +35 -0
  27. forgecli/core/container.py +68 -0
  28. forgecli/core/context.py +54 -0
  29. forgecli/core/credentials.py +114 -0
  30. forgecli/core/errors.py +27 -0
  31. forgecli/core/events.py +67 -0
  32. forgecli/core/logging.py +47 -0
  33. forgecli/core/models.py +159 -0
  34. forgecli/core/plugins.py +56 -0
  35. forgecli/core/service.py +22 -0
  36. forgecli/docs/__init__.py +5 -0
  37. forgecli/docs/generator.py +119 -0
  38. forgecli/engine/__init__.py +105 -0
  39. forgecli/engine/context.py +171 -0
  40. forgecli/engine/defaults.py +89 -0
  41. forgecli/engine/events.py +185 -0
  42. forgecli/engine/execution.py +526 -0
  43. forgecli/engine/plugins.py +146 -0
  44. forgecli/engine/runner.py +155 -0
  45. forgecli/engine/stages/__init__.py +33 -0
  46. forgecli/engine/stages/caveman_optimizer.py +47 -0
  47. forgecli/engine/stages/context_optimizer.py +44 -0
  48. forgecli/engine/stages/execution_engine_stage.py +108 -0
  49. forgecli/engine/stages/git_engine.py +30 -0
  50. forgecli/engine/stages/intent_analyzer.py +38 -0
  51. forgecli/engine/stages/model_router.py +66 -0
  52. forgecli/engine/stages/planning_engine.py +45 -0
  53. forgecli/engine/stages/repository_analyzer.py +54 -0
  54. forgecli/engine/stages/validation_engine.py +89 -0
  55. forgecli/git/__init__.py +9 -0
  56. forgecli/git/repo.py +55 -0
  57. forgecli/git/service.py +45 -0
  58. forgecli/graph/__init__.py +56 -0
  59. forgecli/graph/backend_graphify.py +453 -0
  60. forgecli/graph/edge.py +19 -0
  61. forgecli/graph/graph.py +85 -0
  62. forgecli/graph/graphify.py +412 -0
  63. forgecli/graph/indexer.py +82 -0
  64. forgecli/graph/node.py +47 -0
  65. forgecli/graph/repository.py +164 -0
  66. forgecli/memory/__init__.py +12 -0
  67. forgecli/memory/cache.py +61 -0
  68. forgecli/memory/history.py +138 -0
  69. forgecli/memory/store.py +87 -0
  70. forgecli/optimizer/__init__.py +14 -0
  71. forgecli/optimizer/caveman/__init__.py +173 -0
  72. forgecli/optimizer/caveman/cli.py +64 -0
  73. forgecli/optimizer/caveman/decorator.py +67 -0
  74. forgecli/optimizer/caveman/factory.py +51 -0
  75. forgecli/optimizer/caveman/ruleset.py +156 -0
  76. forgecli/optimizer/caveman/state.py +52 -0
  77. forgecli/optimizer/chunker.py +85 -0
  78. forgecli/optimizer/optimizer.py +81 -0
  79. forgecli/optimizer/ponytail/__init__.py +183 -0
  80. forgecli/optimizer/ponytail/cli.py +168 -0
  81. forgecli/optimizer/ponytail/decorator.py +70 -0
  82. forgecli/optimizer/ponytail/factory.py +51 -0
  83. forgecli/optimizer/ponytail/ruleset.py +168 -0
  84. forgecli/optimizer/ponytail/state.py +53 -0
  85. forgecli/optimizer/ranker.py +37 -0
  86. forgecli/optimizer/summarizer.py +44 -0
  87. forgecli/orchestrator/__init__.py +707 -0
  88. forgecli/planner/__init__.py +56 -0
  89. forgecli/planner/agent.py +61 -0
  90. forgecli/planner/plan.py +65 -0
  91. forgecli/planner/planner.py +17 -0
  92. forgecli/planner/render.py +271 -0
  93. forgecli/planner/serialize.py +106 -0
  94. forgecli/planner/software.py +834 -0
  95. forgecli/platform/__init__.py +91 -0
  96. forgecli/platform/core.py +201 -0
  97. forgecli/platform/deps.py +354 -0
  98. forgecli/platform/paths.py +236 -0
  99. forgecli/platform/shell.py +176 -0
  100. forgecli/platform/update.py +252 -0
  101. forgecli/plugins/__init__.py +251 -0
  102. forgecli/prompts/__init__.py +11 -0
  103. forgecli/prompts/loader.py +28 -0
  104. forgecli/prompts/registry.py +32 -0
  105. forgecli/prompts/renderer.py +23 -0
  106. forgecli/providers/__init__.py +39 -0
  107. forgecli/providers/anthropic.py +206 -0
  108. forgecli/providers/base.py +206 -0
  109. forgecli/providers/builtin.py +26 -0
  110. forgecli/providers/conversation.py +78 -0
  111. forgecli/providers/google.py +295 -0
  112. forgecli/providers/http_base.py +211 -0
  113. forgecli/providers/mock.py +113 -0
  114. forgecli/providers/openai.py +202 -0
  115. forgecli/providers/openai_compatible.py +728 -0
  116. forgecli/providers/router.py +345 -0
  117. forgecli/providers/router_state.py +89 -0
  118. forgecli/review/__init__.py +45 -0
  119. forgecli/review/analyzer.py +124 -0
  120. forgecli/review/analyzers/__init__.py +1 -0
  121. forgecli/review/analyzers/architecture.py +258 -0
  122. forgecli/review/analyzers/complexity.py +167 -0
  123. forgecli/review/analyzers/dead_code.py +262 -0
  124. forgecli/review/analyzers/duplicates.py +163 -0
  125. forgecli/review/analyzers/performance.py +165 -0
  126. forgecli/review/analyzers/security.py +251 -0
  127. forgecli/review/finding.py +68 -0
  128. forgecli/review/report.py +311 -0
  129. forgecli/review/repository.py +131 -0
  130. forgecli/review/suggestions.py +98 -0
  131. forgecli/runtime/__init__.py +6 -0
  132. forgecli/runtime/cache_store.py +77 -0
  133. forgecli/runtime/prepare.py +199 -0
  134. forgecli/runtime/wrappers.py +152 -0
  135. forgecli/sdk/__init__.py +132 -0
  136. forgecli/sdk/events.py +206 -0
  137. forgecli/sdk/interfaces.py +282 -0
  138. forgecli/sdk/loader.py +243 -0
  139. forgecli/sdk/manager.py +693 -0
  140. forgecli/sdk/manifest.py +397 -0
  141. forgecli/sdk/sandbox.py +197 -0
  142. forgecli/sdk/version.py +316 -0
  143. forgecli/templates/__init__.py +9 -0
  144. forgecli/templates/engine.py +37 -0
  145. forgecli/templates/registry.py +32 -0
  146. forgecli/utils/__init__.py +19 -0
  147. forgecli/utils/fs.py +91 -0
  148. forgecli/utils/ids.py +11 -0
  149. forgecli/utils/io.py +27 -0
  150. forgecli/utils/paths.py +70 -0
  151. forgecli/utils/timing.py +25 -0
  152. forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
  153. forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
  154. forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
  155. forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
  156. 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()
@@ -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)