seedcode-cli 6.1.5__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 (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,347 @@
1
+ """Pydantic data models for Seed Code.
2
+
3
+ All persisted and in-memory structured data flows through these models so that
4
+ validation happens in one place and the rest of the app can rely on typed data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any, Literal
11
+
12
+ from pydantic import BaseModel, Field, model_validator
13
+
14
+ Role = Literal["system", "user", "assistant", "tool"]
15
+
16
+ # Safe completion budget sent with chat requests when the user has not
17
+ # overridden it. Free-tier accounts are rejected (HTTP 402) when the requested
18
+ # budget exceeds what their credits could cover, so the default stays small.
19
+ DEFAULT_MAX_TOKENS = 1024
20
+
21
+ # Agent turns write code across files, so they get a higher ceiling than the
22
+ # plain-chat clamp (providers that cannot afford it still cap via the
23
+ # ``:free`` rule in :meth:`AppConfig.effective_max_tokens`).
24
+ AGENT_MAX_TOKENS = 8192
25
+
26
+ # The five supported backends (kept as a Literal so bad config fails loudly).
27
+ ProviderId = Literal[
28
+ "openrouter", "freemodel_claude", "freemodel_codex", "aerolink", "ollama"
29
+ ]
30
+
31
+
32
+ class ToolCallRecord(BaseModel):
33
+ """One native tool invocation recorded on an assistant message.
34
+
35
+ ``id`` is the provider-issued call id ("" for text-protocol calls, which
36
+ have none); providers convert records to their own wire format when
37
+ serialising history.
38
+ """
39
+
40
+ id: str = ""
41
+ name: str
42
+ arguments: dict[str, Any] = Field(default_factory=dict)
43
+
44
+
45
+ class Message(BaseModel):
46
+ """A single chat message in a conversation."""
47
+
48
+ role: Role
49
+ content: str
50
+ timestamp: float = Field(default_factory=time.time)
51
+ # Base64 PNG attachments (desktop screenshots). Only providers that
52
+ # support vision read these; ``to_api`` stays text-only so plain
53
+ # backends are never sent a shape they cannot handle.
54
+ images: list[str] = Field(default_factory=list)
55
+ # Native tool calling. ``tool_calls`` is set on assistant messages that
56
+ # invoked tools; ``tool_call_id``/``tool_name`` are set on role=="tool"
57
+ # result messages. All default so old histories load unchanged, and
58
+ # providers that never learned these roles simply never see them (the
59
+ # agent downgrades native-era history before a text-only request).
60
+ tool_calls: list[ToolCallRecord] = Field(default_factory=list)
61
+ tool_call_id: str = ""
62
+ tool_name: str = ""
63
+
64
+ def to_api(self) -> dict[str, str]:
65
+ """Return the minimal shape chat-completions style APIs expect."""
66
+ return {"role": self.role, "content": self.content}
67
+
68
+
69
+ class ProviderConfig(BaseModel):
70
+ """Per-provider settings: each backend keeps its own key and model.
71
+
72
+ Switching providers never touches another provider's entry, so keys and
73
+ model choices are always remembered. Ollama simply leaves ``api_key``
74
+ empty (it does not use one). ``options`` holds provider-specific extras
75
+ (e.g. OpenRouter's free/pro mode, FreeModel's claude/codex backend) so
76
+ new providers can add settings without schema changes.
77
+ """
78
+
79
+ api_key: str = ""
80
+ model: str = ""
81
+ options: dict[str, str] = Field(default_factory=dict)
82
+
83
+
84
+ _ALL_PROVIDERS = (
85
+ "openrouter", "freemodel_claude", "freemodel_codex", "aerolink", "ollama"
86
+ )
87
+
88
+
89
+ def _default_providers() -> dict[str, ProviderConfig]:
90
+ return {pid: ProviderConfig() for pid in _ALL_PROVIDERS}
91
+
92
+
93
+ class AppConfig(BaseModel):
94
+ """Persisted application configuration.
95
+
96
+ Stored shape (config.json)::
97
+
98
+ active_provider: "openrouter" | "freemodel_claude" | "freemodel_codex"
99
+ | "aerolink" | "ollama"
100
+ providers:
101
+ openrouter: {api_key, model}
102
+ freemodel_claude: {api_key, model}
103
+ freemodel_codex: {api_key, model}
104
+ aerolink: {api_key, model}
105
+ ollama: {api_key(unused), model}
106
+
107
+ Models are never hardcoded — each provider's ``model`` starts empty and
108
+ the user selects one from the live catalogue. Older config formats
109
+ (v0.x flat ``api_key``, v1.x ``api_keys``/``models`` maps, v2.x single
110
+ ``freemodel`` entry with claude/codex sub-backends) migrate
111
+ automatically on load.
112
+ """
113
+
114
+ active_provider: ProviderId = "freemodel_claude"
115
+ providers: dict[str, ProviderConfig] = Field(default_factory=_default_providers)
116
+ ollama_host: str = "http://localhost:11434"
117
+ theme: str = "seed"
118
+ username: str = "You"
119
+ stream: bool = True
120
+ # Completion-token budget for chat requests. Users may override in
121
+ # config.json; the value is clamped before every request.
122
+ max_tokens: int = DEFAULT_MAX_TOKENS
123
+ # Agent mode: when on, the model may act on the project through the tool
124
+ # engine. permission_mode bounds what it may touch (see seedcode.tools).
125
+ agent_mode: bool = False
126
+ # Single hierarchical permission level (see seedcode.tools.permissions):
127
+ # read_only < workspace < desktop < full_system. Desktop automation is a
128
+ # capability of the ``desktop``/``full_system`` levels — there is no
129
+ # separate desktop toggle. ``full_access`` is accepted as a legacy alias
130
+ # for ``full_system`` and migrated on load.
131
+ permission_mode: Literal[
132
+ "read_only", "workspace", "desktop", "full_system"
133
+ ] = "workspace"
134
+
135
+ @model_validator(mode="before")
136
+ @classmethod
137
+ def _migrate_legacy(cls, data: Any) -> Any:
138
+ """Accept pre-3.x config files and keyword shorthand.
139
+
140
+ Handles: v0.x (flat ``api_key`` string, display-name provider),
141
+ v1.x (``provider``/``model`` fields plus ``api_keys``/``models``
142
+ maps), v2.x (single ``freemodel`` provider with claude/codex
143
+ sub-backends), and constructor convenience (``AppConfig(model=...)``).
144
+ """
145
+ if not isinstance(data, dict):
146
+ return data
147
+ data = dict(data) # never mutate the caller's dict
148
+
149
+ def norm(pid: str) -> str:
150
+ """Provider id normalisation (OpenRouter is a first-class backend)."""
151
+ return pid.strip().lower()
152
+
153
+ # Normalise nested provider entries to plain dicts we can merge into.
154
+ providers: dict[str, dict] = {}
155
+ for pid, entry in (data.get("providers") or {}).items():
156
+ if isinstance(entry, ProviderConfig):
157
+ providers[norm(pid)] = entry.model_dump()
158
+ elif isinstance(entry, dict):
159
+ providers[norm(pid)] = dict(entry)
160
+
161
+ # Active provider: new field, or legacy "provider" (any casing).
162
+ # Sanitised AFTER the legacy-freemodel split below, which may map it.
163
+ raw_active = data.pop("provider", None) or data.get("active_provider")
164
+ if isinstance(raw_active, str):
165
+ data["active_provider"] = norm(raw_active)
166
+
167
+ # v1.x per-provider maps.
168
+ for pid, key in (data.pop("api_keys", None) or {}).items():
169
+ providers.setdefault(norm(pid), {})["api_key"] = key
170
+ for pid, model in (data.pop("models", None) or {}).items():
171
+ providers.setdefault(norm(pid), {}).setdefault("model", model)
172
+
173
+ # v1.x top-level model belongs to the active provider.
174
+ top_model = data.pop("model", None)
175
+ if top_model:
176
+ active = data.get("active_provider", "freemodel_claude")
177
+ providers.setdefault(active, {})["model"] = top_model
178
+
179
+ # v0.x: single "api_key" string belonged to OpenRouter.
180
+ legacy_key = data.pop("api_key", None)
181
+ if legacy_key:
182
+ providers.setdefault("openrouter", {}).setdefault("api_key", legacy_key)
183
+
184
+ # v2.x: one "freemodel" provider with claude/codex sub-backends
185
+ # splits into the two first-class providers. The shared key goes to
186
+ # both; each backend's stashed model goes to its own entry.
187
+ legacy_fm = providers.pop("freemodel", None)
188
+ if legacy_fm is not None:
189
+ options = dict(legacy_fm.get("options") or {})
190
+ backend = (options.pop("backend", "") or "codex").strip().lower()
191
+ key = legacy_fm.get("api_key", "")
192
+ models_by_backend = {
193
+ "claude": options.pop("model_claude", ""),
194
+ "codex": options.pop("model_codex", ""),
195
+ }
196
+ if legacy_fm.get("model"):
197
+ models_by_backend[backend if backend in models_by_backend else "codex"] = (
198
+ legacy_fm["model"]
199
+ )
200
+ for suffix in ("claude", "codex"):
201
+ entry = providers.setdefault(f"freemodel_{suffix}", {})
202
+ entry.setdefault("api_key", key)
203
+ entry.setdefault("model", models_by_backend[suffix])
204
+ if data.get("active_provider") == "freemodel":
205
+ data["active_provider"] = (
206
+ "freemodel_claude" if backend == "claude" else "freemodel_codex"
207
+ )
208
+
209
+ # Anything still unknown falls back to the default provider.
210
+ if "active_provider" in data and data["active_provider"] not in _ALL_PROVIDERS:
211
+ data["active_provider"] = "freemodel_claude"
212
+
213
+ # vNext: the standalone ``desktop_mode`` flag folded into the unified
214
+ # permission level. A legacy config with desktop_mode=true elevates a
215
+ # workspace level to ``desktop``; ``full_access`` renames to
216
+ # ``full_system``. ``desktop_mode`` is dropped from the model.
217
+ legacy_desktop = data.pop("desktop_mode", None)
218
+ raw_perm = str(data.get("permission_mode", "") or "").strip().lower()
219
+ if raw_perm in ("full_access", "fullaccess", "full", "system"):
220
+ data["permission_mode"] = "full_system"
221
+ raw_perm = "full_system"
222
+ if legacy_desktop and raw_perm in ("", "read_only", "workspace"):
223
+ # Desktop was on: grant at least the desktop level.
224
+ data["permission_mode"] = "desktop"
225
+
226
+ if providers or "providers" in data:
227
+ data["providers"] = providers
228
+ return data
229
+
230
+ @model_validator(mode="after")
231
+ def _ensure_all_providers(self) -> "AppConfig":
232
+ """Every supported provider always has an entry."""
233
+ for pid in _ALL_PROVIDERS:
234
+ if pid not in self.providers:
235
+ self.providers[pid] = ProviderConfig()
236
+ return self
237
+
238
+ # --- desktop capability (derived from the permission level) --------------
239
+ @property
240
+ def desktop_mode(self) -> bool:
241
+ """Whether the current permission level allows desktop automation.
242
+
243
+ Compatibility shim: desktop is no longer a separate flag but a
244
+ capability of the ``desktop``/``full_system`` levels. Reads derive from
245
+ ``permission_mode``; assigning True/False raises or lowers the level so
246
+ older call sites keep working.
247
+ """
248
+ from ..tools.permissions import PermissionLevel
249
+
250
+ return PermissionLevel.parse(self.permission_mode).allows_desktop
251
+
252
+ @desktop_mode.setter
253
+ def desktop_mode(self, value: bool) -> None:
254
+ from ..tools.permissions import PermissionLevel
255
+
256
+ current = PermissionLevel.parse(self.permission_mode)
257
+ if value and not current.allows_desktop:
258
+ self.permission_mode = "desktop" # type: ignore[assignment]
259
+ elif not value and current.allows_desktop:
260
+ # Drop desktop capability but keep the ability to edit the project.
261
+ self.permission_mode = "workspace" # type: ignore[assignment]
262
+
263
+ # --- active provider/model (compatibility + convenience) ----------------
264
+ @property
265
+ def provider(self) -> str:
266
+ """Id of the active provider."""
267
+ return self.active_provider
268
+
269
+ @provider.setter
270
+ def provider(self, value: str) -> None:
271
+ self.active_provider = value # type: ignore[assignment]
272
+
273
+ @property
274
+ def model(self) -> str:
275
+ """Model selected for the ACTIVE provider ('' if none yet)."""
276
+ return self.providers[self.active_provider].model
277
+
278
+ @model.setter
279
+ def model(self, value: str) -> None:
280
+ self.providers[self.active_provider].model = value
281
+
282
+ # --- key management -----------------------------------------------------
283
+ def get_api_key(self, provider_id: str | None = None) -> str:
284
+ """Key for ``provider_id`` (default: the active provider)."""
285
+ pid = (provider_id or self.active_provider).lower()
286
+ entry = self.providers.get(pid)
287
+ return entry.api_key if entry else ""
288
+
289
+ def set_api_key(self, provider_id: str, key: str) -> None:
290
+ pid = provider_id.lower()
291
+ if pid not in self.providers:
292
+ self.providers[pid] = ProviderConfig()
293
+ self.providers[pid].api_key = key
294
+
295
+ def provider_options(self, provider_id: str) -> dict[str, str]:
296
+ """Mutable provider-specific options dict for ``provider_id``."""
297
+ pid = provider_id.lower()
298
+ if pid not in self.providers:
299
+ self.providers[pid] = ProviderConfig()
300
+ return self.providers[pid].options
301
+
302
+ def is_configured(self) -> bool:
303
+ """True when the active provider is usable and a model is chosen.
304
+
305
+ Ollama needs no key; the other providers need one.
306
+ """
307
+ if not self.model:
308
+ return False
309
+ if self.active_provider == "ollama":
310
+ return True
311
+ return bool(self.get_api_key().strip())
312
+
313
+ def remember_model(self) -> None:
314
+ """Compatibility no-op: models are stored per provider already."""
315
+
316
+ def recall_model(self) -> str:
317
+ """Model saved for the active provider ('' if none yet)."""
318
+ return self.model
319
+
320
+ def effective_max_tokens(self) -> int:
321
+ """Completion budget to send with a request, clamped to a safe range.
322
+
323
+ Plain chat clamps to [1, 4096] so an oversized config value can never
324
+ trigger the gateway's 402 "more credits or fewer max_tokens"
325
+ rejection. Agent mode raises the ceiling to ``AGENT_MAX_TOKENS``
326
+ (multi-file code generation needs room). Free models (``*:free``) are
327
+ further capped at ``DEFAULT_MAX_TOKENS`` since their credit ceiling
328
+ is lowest.
329
+ """
330
+ ceiling = AGENT_MAX_TOKENS if self.agent_mode else 4096
331
+ max_tokens = max(1, min(self.max_tokens, ceiling))
332
+ # Agent turns generate whole files; the small chat default would
333
+ # truncate them. A user who explicitly set a different budget keeps it.
334
+ if self.agent_mode and self.max_tokens == DEFAULT_MAX_TOKENS:
335
+ max_tokens = AGENT_MAX_TOKENS
336
+ if self.model.endswith(":free"):
337
+ max_tokens = min(max_tokens, DEFAULT_MAX_TOKENS)
338
+ return max_tokens
339
+
340
+ def masked_key(self, provider_id: str | None = None) -> str:
341
+ """Return the API key with the middle obscured for safe display."""
342
+ key = self.get_api_key(provider_id).strip()
343
+ if not key:
344
+ return "(not set)"
345
+ if len(key) <= 12:
346
+ return "*" * len(key)
347
+ return f"{key[:8]}...{key[-4:]}"
@@ -0,0 +1,96 @@
1
+ """Project awareness: detect what kind of project the workspace holds.
2
+
3
+ Runs once at agent-engine construction and feeds a short summary into the
4
+ system prompt, so the model starts oriented (project type, git branch,
5
+ top-level layout) without spending tool calls on discovery. Detection is
6
+ marker-file based and touches only the top level — no recursive walk, no
7
+ subprocesses — so construction stays instant even in huge repositories.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ # Marker file/dir -> human-readable project kind. Checked in order; every
16
+ # match is reported (a repo can be several things at once).
17
+ _MARKERS: tuple[tuple[str, str], ...] = (
18
+ ("pyproject.toml", "Python (pyproject.toml)"),
19
+ ("setup.py", "Python (setup.py)"),
20
+ ("package.json", "Node.js (package.json)"),
21
+ ("Cargo.toml", "Rust (Cargo.toml)"),
22
+ ("go.mod", "Go (go.mod)"),
23
+ ("pom.xml", "Java (Maven)"),
24
+ ("build.gradle", "Java/Kotlin (Gradle)"),
25
+ ("build.gradle.kts", "Kotlin (Gradle)"),
26
+ ("CMakeLists.txt", "C/C++ (CMake)"),
27
+ ("Gemfile", "Ruby (Gemfile)"),
28
+ ("composer.json", "PHP (Composer)"),
29
+ )
30
+
31
+ # Top-level entries that add noise, not orientation.
32
+ _LISTING_SKIP = {
33
+ ".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv",
34
+ "dist", "build", ".mypy_cache", ".ruff_cache", ".idea", ".vscode",
35
+ }
36
+ _MAX_LISTING = 40
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class ProjectInfo:
41
+ """What was detected about the workspace."""
42
+
43
+ kinds: list[str]
44
+ summary: str
45
+
46
+
47
+ def _git_branch(workspace: Path) -> str:
48
+ """Current branch read straight from .git/HEAD (no subprocess)."""
49
+ head = workspace / ".git" / "HEAD"
50
+ try:
51
+ content = head.read_text(encoding="utf-8", errors="replace").strip()
52
+ except OSError:
53
+ return ""
54
+ if content.startswith("ref: refs/heads/"):
55
+ return content[len("ref: refs/heads/"):]
56
+ return content[:12] if content else "" # detached HEAD: short hash
57
+
58
+
59
+ def detect_project(workspace: Path) -> ProjectInfo:
60
+ """Detect project kinds and build the system-prompt summary block."""
61
+ kinds: list[str] = []
62
+
63
+ if (workspace / ".git").is_dir():
64
+ branch = _git_branch(workspace)
65
+ kinds.append(f"Git repository (branch: {branch})" if branch else "Git repository")
66
+
67
+ for marker, kind in _MARKERS:
68
+ if (workspace / marker).is_file():
69
+ kinds.append(kind)
70
+
71
+ if any(workspace.glob("*.csproj")):
72
+ kinds.append("C# (.csproj)")
73
+
74
+ lines: list[str] = []
75
+ if kinds:
76
+ lines.append("Detected: " + ", ".join(kinds))
77
+ lines.append("Top-level entries:")
78
+ try:
79
+ entries = sorted(
80
+ workspace.iterdir(), key=lambda p: (p.is_file(), p.name.lower())
81
+ )
82
+ except OSError:
83
+ entries = []
84
+ shown = 0
85
+ for entry in entries:
86
+ if entry.name in _LISTING_SKIP or entry.name.startswith("."):
87
+ continue
88
+ if shown >= _MAX_LISTING:
89
+ lines.append(" ... (more entries; use list_dir/project_index)")
90
+ break
91
+ lines.append(f" {entry.name}{'/' if entry.is_dir() else ''}")
92
+ shown += 1
93
+ if shown == 0:
94
+ lines.append(" (empty workspace)")
95
+
96
+ return ProjectInfo(kinds=kinds, summary="\n".join(lines))
@@ -0,0 +1,58 @@
1
+ """Provider registry: the five supported AI backends.
2
+
3
+ The registry is the single source of truth for which providers exist:
4
+ OpenRouter, FreeModel Claude, FreeModel Codex, AeroLink, and Ollama. Each
5
+ is fully independent — own key slot, base URL, catalogue, client, and
6
+ connection status — sharing only the Provider chat contract. Everything
7
+ else (engine, commands, onboarding, menu) resolves providers through
8
+ :func:`get_provider` / :data:`PROVIDERS`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .aerolink import AeroLinkProvider
14
+ from .base import ModelInfo, Provider, ProviderError, ValidationResult
15
+ from .freemodel import FreeModelClaudeProvider, FreeModelCodexProvider
16
+ from .ollama import OllamaProvider
17
+ from .openrouter import OpenRouterProvider
18
+
19
+ # Instantiated once; per-provider state (status, client cache) lives on the
20
+ # instance and is session-only. Insertion order IS the /provider menu order.
21
+ PROVIDERS: dict[str, Provider] = {
22
+ p.id: p
23
+ for p in (
24
+ OpenRouterProvider(),
25
+ FreeModelClaudeProvider(),
26
+ FreeModelCodexProvider(),
27
+ AeroLinkProvider(),
28
+ OllamaProvider(),
29
+ )
30
+ }
31
+
32
+
33
+ def get_provider(provider_id: str) -> Provider:
34
+ """Resolve a provider by id, raising a friendly error for unknown ids."""
35
+ provider = PROVIDERS.get((provider_id or "").lower())
36
+ if provider is None:
37
+ known = ", ".join(sorted(PROVIDERS))
38
+ raise ProviderError(
39
+ f"Unknown provider '{provider_id}'. Choose one of: {known} (see /provider)."
40
+ )
41
+ return provider
42
+
43
+
44
+ def provider_label(provider_id: str) -> str:
45
+ """Display label for a provider id; safe on unset/unknown ids."""
46
+ provider = PROVIDERS.get((provider_id or "").lower())
47
+ return provider.label if provider else (provider_id or "(not set)")
48
+
49
+
50
+ __all__ = [
51
+ "ModelInfo",
52
+ "PROVIDERS",
53
+ "Provider",
54
+ "ProviderError",
55
+ "ValidationResult",
56
+ "get_provider",
57
+ "provider_label",
58
+ ]