froid-loop 0.11.1__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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""Declarative CLI profiles for the generic tmux adapter.
|
|
2
|
+
|
|
3
|
+
A profile captures everything that differs between coding CLIs that share the
|
|
4
|
+
tmux-injection + hook-signal transport: binary name, how the canonical
|
|
5
|
+
"/skill args" prompt is rendered, bypass flags, hook registration (a config
|
|
6
|
+
dialect + an event-name map), and which usage parser reads the transcript.
|
|
7
|
+
|
|
8
|
+
Built-in profiles ship as packaged TOML (froid_loop/data/profiles/*.toml) and
|
|
9
|
+
project-local TOML files in <project>/.froid-loop/profiles/*.toml overlay them
|
|
10
|
+
(same name overrides, new names extend) — adding a CLI that clones an
|
|
11
|
+
existing hook dialect needs no Python.
|
|
12
|
+
|
|
13
|
+
An out-of-tree package advertises additional profiles under the
|
|
14
|
+
``froid_loop.profiles`` entry-point group (:func:`load_profiles` scans it): the
|
|
15
|
+
companion to the ``froid_loop.adapters`` registry (:mod:`~.registry`), so a
|
|
16
|
+
co-installed adapter package ships both its class and the profile that selects
|
|
17
|
+
it with zero project config. Precedence is packaged < entry-point < project (a
|
|
18
|
+
project TOML always wins). A broken entry point degrades to a recorded reason
|
|
19
|
+
(:func:`external_profile_errors`), never a crash — one per failing distribution,
|
|
20
|
+
kept even when two of them advertise the same name (:mod:`~.entrypoints`).
|
|
21
|
+
|
|
22
|
+
Which adapter *class* drives a profile is the ``adapter`` field, resolved against
|
|
23
|
+
the :mod:`~.registry` — it is read here but intentionally **not** checked against
|
|
24
|
+
the set of registered kinds at parse time (an unknown kind is caught at
|
|
25
|
+
construction and by ``validate``, against the live registry, never a hardcoded
|
|
26
|
+
set). Its *shape* is still enforced here, like every other field.
|
|
27
|
+
|
|
28
|
+
Both routes into the profile map — the TOML parser and the entry-point scan —
|
|
29
|
+
converge on :func:`_validate_profile`, so a Python package cannot install a
|
|
30
|
+
profile state a TOML author would have been refused.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import importlib.metadata
|
|
36
|
+
import tomllib
|
|
37
|
+
from dataclasses import dataclass, field, replace
|
|
38
|
+
from importlib import resources
|
|
39
|
+
from importlib.resources.abc import Traversable
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
import regex
|
|
43
|
+
|
|
44
|
+
from ..platform_util import (
|
|
45
|
+
has_parent_ref,
|
|
46
|
+
is_absolute_path,
|
|
47
|
+
names_tree_root,
|
|
48
|
+
names_win32_alias,
|
|
49
|
+
)
|
|
50
|
+
from .entrypoints import record_load_error
|
|
51
|
+
|
|
52
|
+
USAGE_PARSERS = {"claude-jsonl", "codex-rollout", "gemini-chat", "copilot-events", "none"}
|
|
53
|
+
HOOK_DIALECTS = {
|
|
54
|
+
"claude-settings-json",
|
|
55
|
+
"codex-hooks-json",
|
|
56
|
+
"gemini-settings-json",
|
|
57
|
+
"copilot-settings-json",
|
|
58
|
+
"antigravity-hooks-json",
|
|
59
|
+
# hookless: the adapter observes completion itself (HTTP/SSE transport) —
|
|
60
|
+
# no hook config is ever written, so config_path/events must stay empty.
|
|
61
|
+
"none",
|
|
62
|
+
}
|
|
63
|
+
CANONICAL_EVENTS = {"SessionStart", "Stop", "SessionEnd", "PreCompact"}
|
|
64
|
+
USER_PROFILES_REL = Path(".froid-loop") / "profiles"
|
|
65
|
+
|
|
66
|
+
# legacy adapter names from older policy.toml files, plus friendly short names
|
|
67
|
+
ALIASES = {"claude-code-tmux": "claude", "opencode": "opencode-http"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ProfileError(Exception):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Every fault a raw coercion over a `tomllib` value can raise — the set is CLOSED,
|
|
75
|
+
# not a list of the ones seen so far, which is what makes funnelling on it a funnel
|
|
76
|
+
# rather than another round of enumeration. `tomllib` yields exactly nine types
|
|
77
|
+
# (str, int, float, bool, datetime, date, time, list, dict) and all nine were run
|
|
78
|
+
# through this module's coercions: `int()`/`float()` answer TypeError for the five
|
|
79
|
+
# non-numerics, ValueError for a non-numeric str and for `nan`, and **OverflowError**
|
|
80
|
+
# for `inf`/`-inf` (int) and for an int too large to be a float — `tomllib` accepts
|
|
81
|
+
# arbitrary-precision integers, so that second one is reachable from a config file.
|
|
82
|
+
# Iteration and `.items()` add TypeError and AttributeError and nothing new.
|
|
83
|
+
# `plugins/manifest.py` funnels on the same set for the same reason.
|
|
84
|
+
CONVERSION_FAULTS = (AttributeError, OverflowError, TypeError, ValueError)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class HookSpec:
|
|
89
|
+
dialect: str
|
|
90
|
+
config_path: str # project-relative, e.g. ".claude/settings.json"
|
|
91
|
+
events: dict[str, str] # native event name -> canonical event name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class CLIProfile:
|
|
96
|
+
name: str
|
|
97
|
+
binary: str
|
|
98
|
+
hooks: HookSpec
|
|
99
|
+
# Which adapter *class* drives this CLI — a key resolved against the adapter
|
|
100
|
+
# registry (adapters/registry.py), not a hardcoded enum. "generic" = the
|
|
101
|
+
# bundled tmux-injection + hook-signal adapter; "opencode-http" = the bundled
|
|
102
|
+
# HTTP/SSE adapter; an out-of-tree package registers its own. Membership is NOT
|
|
103
|
+
# checked at parse time: an unknown kind fails loud at construction and as a
|
|
104
|
+
# `validate` finding, both against the live registry. A hookless HTTP profile
|
|
105
|
+
# (hooks.dialect = "none") MUST set this to its HTTP adapter kind — the
|
|
106
|
+
# transport (hookless) and the driving class are now decoupled axes.
|
|
107
|
+
#
|
|
108
|
+
# That MUST binds the two routes differently, which is why the default below
|
|
109
|
+
# is not the whole story. A TOML profile that OMITS the key is read as
|
|
110
|
+
# predating the field and keeps the old dialect-based dispatch
|
|
111
|
+
# (`_legacy_adapter_default`), so a hookless file still lands on the HTTP
|
|
112
|
+
# kind. A provider that constructs this dataclass directly takes the default
|
|
113
|
+
# verbatim — nothing infers a kind for it — so an entry-point profile really
|
|
114
|
+
# must set the field, and a hookless one that leaves it at "generic" resolves
|
|
115
|
+
# to a mux-driving kind that will never see a Stop hook.
|
|
116
|
+
adapter: str = "generic"
|
|
117
|
+
# project-relative tree this CLI reads skills from, e.g. ".claude/skills"
|
|
118
|
+
# (claude) or ".agents/skills" (codex/gemini); `froid-loop init` installs the
|
|
119
|
+
# bundled froid-loop-* skills here.
|
|
120
|
+
skill_tree: str = ".claude/skills"
|
|
121
|
+
prompt_template: str = "{prompt}"
|
|
122
|
+
launch_args: tuple[str, ...] = ()
|
|
123
|
+
bypass_args: tuple[str, ...] = ()
|
|
124
|
+
model_flag: str = "--model"
|
|
125
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
126
|
+
usage_parser: str = "none"
|
|
127
|
+
# seconds to keep polling the transcript for token usage after the session
|
|
128
|
+
# ends. 0 = read once (the totals are already there). CLIs that flush their
|
|
129
|
+
# token totals only on shutdown (Copilot writes modelMetrics in the trailing
|
|
130
|
+
# session.shutdown line, ~1s after the turn-end hook) need a small grace so
|
|
131
|
+
# read_usage doesn't sample the transcript before the totals land.
|
|
132
|
+
usage_grace_s: float = 0.0
|
|
133
|
+
# per-adapter floor for Stop-without-result nudges; None = use the global
|
|
134
|
+
# limits.stop_without_result_nudges. CLIs that fire a turn-end hook PER
|
|
135
|
+
# response turn (Copilot's agentStop) end a parallel-subagent phase across
|
|
136
|
+
# several turns, so the global default of 1 declares them stalled too early.
|
|
137
|
+
stop_without_result_nudges: int | None = None
|
|
138
|
+
# Some CLIs (Copilot) fire the turn-end hook for EVERY subagent turn too, with
|
|
139
|
+
# an empty transcriptPath and a tool-use session id (toolu_…) — not the main
|
|
140
|
+
# session's turn-end. When true, a Stop carrying no transcript_path is treated
|
|
141
|
+
# as a subagent stop and ignored, so the main session's real turn-end drives
|
|
142
|
+
# completion (and supplies the transcript for usage tallying). Without this a
|
|
143
|
+
# subagent's premature Stop reads as a result-less completion -> false stall.
|
|
144
|
+
subagent_stop_without_transcript: bool = False
|
|
145
|
+
first_run_note: str = ""
|
|
146
|
+
# project-relative gitignored configs (MCP/CLI settings) this CLI needs but
|
|
147
|
+
# that a `git worktree add` checkout omits; provision_worktree copies them in
|
|
148
|
+
# from the main repo so isolated dev/review sessions can reach the MCP server.
|
|
149
|
+
seed_files: tuple[str, ...] = ()
|
|
150
|
+
# Patterns matched line-by-line against the ANSI-stripped tail of a
|
|
151
|
+
# non-completed session's log to classify a provider/transport *environment
|
|
152
|
+
# fault* (#194) — an "API Error … Connection refused" or a "usage limit
|
|
153
|
+
# reached" the CLI printed while idling out the session clock. Which log is
|
|
154
|
+
# scanned is per-adapter, named by EnvFaultMixin.ENV_FAULT_LOG_SUFFIX: the
|
|
155
|
+
# tmux adapters' pane capture logs/<task_id>.log, or logs/<task_id>.server.out
|
|
156
|
+
# for opencode-http (whose .log is a model-written transcript). Compiled and
|
|
157
|
+
# validated at parse time (an invalid regex is a profile error); empty = inert.
|
|
158
|
+
#
|
|
159
|
+
# A pattern is only sound against a log the model cannot write to. A pane
|
|
160
|
+
# capture is not one: it carries the model's own output, so a bare `quota`
|
|
161
|
+
# or `429` matches a story that merely *implements* rate limiting, and an
|
|
162
|
+
# error-shaped token joined to a cause on the SAME line is precisely what a
|
|
163
|
+
# story writing ABOUT the error emits — the doctrine that used to live here,
|
|
164
|
+
# withdrawn because it classified 44 of this repo's own tracked lines (#507).
|
|
165
|
+
# So a pane-capture profile's pattern must reproduce one COMPLETE error
|
|
166
|
+
# sentence its CLI was captured printing, and must not match its own line in
|
|
167
|
+
# the profile file — hence the shipped single-character classes (`t[o]`,
|
|
168
|
+
# `respons[e]`), which change what the source line matches and nothing else.
|
|
169
|
+
# Both halves are pinned in tests/test_env_fault_patterns.py, the second
|
|
170
|
+
# across every tracked file. Override/extend via a project profile in
|
|
171
|
+
# .froid-loop/profiles/.
|
|
172
|
+
env_fault_patterns: tuple[str, ...] = ()
|
|
173
|
+
# Did this profile ship INSIDE the package (froid_loop/data/profiles/*.toml)?
|
|
174
|
+
# Provenance, not configuration: it is stamped by `load_profiles` at the one
|
|
175
|
+
# place that knows which directory a file came from, and no TOML key sets it —
|
|
176
|
+
# a project overlay declaring `packaged = true` is read as an unknown key, not
|
|
177
|
+
# as a promotion. Default False, so the untrusted answer is the one you get by
|
|
178
|
+
# forgetting: an entry-point profile constructing this dataclass directly is
|
|
179
|
+
# not packaged either, because "installed alongside us" is not the same claim
|
|
180
|
+
# as "shipped by us".
|
|
181
|
+
#
|
|
182
|
+
# The one consumer is `validate`'s liveness probe (#294), which EXECUTES the
|
|
183
|
+
# binary. `binary` is project-controlled end to end — policy.toml picks the
|
|
184
|
+
# profile and .froid-loop/profiles/*.toml supplies its fields, both arriving
|
|
185
|
+
# with a clone — so the probe needs a trust boundary that no spelling of
|
|
186
|
+
# `binary` can talk its way through: gating on the string (rejecting "./tool")
|
|
187
|
+
# still runs a bare "pwn" whenever a checkout-local directory is on PATH.
|
|
188
|
+
# Provenance is that boundary; it answers "who wrote this", which is the
|
|
189
|
+
# question actually being asked.
|
|
190
|
+
packaged: bool = False
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def hookless(self) -> bool:
|
|
194
|
+
"""True for profiles whose adapter observes completion itself (HTTP/SSE)
|
|
195
|
+
instead of via hook scripts — no hook config exists to register, merge,
|
|
196
|
+
validate, or git-exclude."""
|
|
197
|
+
return self.hooks.dialect == "none"
|
|
198
|
+
|
|
199
|
+
def render_prompt(self, prompt: str) -> str:
|
|
200
|
+
"""Render the engine's canonical "/skill args" prompt for this CLI.
|
|
201
|
+
|
|
202
|
+
Placeholders: {prompt} = the canonical string, {skill} = the leading
|
|
203
|
+
slash-command name without "/", {args} = everything after it.
|
|
204
|
+
"""
|
|
205
|
+
skill, args = "", prompt
|
|
206
|
+
if prompt.startswith("/"):
|
|
207
|
+
head, _, rest = prompt[1:].partition(" ")
|
|
208
|
+
skill, args = head, rest.strip()
|
|
209
|
+
return self.prompt_template.format(prompt=prompt, skill=skill, args=args)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _validate_profile(profile: CLIProfile, source: str) -> None:
|
|
213
|
+
"""Enforce every value-level invariant a ``CLIProfile`` must satisfy, whatever
|
|
214
|
+
built it.
|
|
215
|
+
|
|
216
|
+
Two routes reach the profile map and only one has a parser in front of it:
|
|
217
|
+
:func:`_parse_profile` coerces a TOML document, while a ``froid_loop.profiles``
|
|
218
|
+
entry point hands over an already-constructed instance. Holding the invariants
|
|
219
|
+
in one function is what stops those routes drifting — the failure it closes is
|
|
220
|
+
a Python package installing a state the TOML parser would have refused, and
|
|
221
|
+
the sharpest instance is an ``env_fault_patterns`` entry that is not a valid
|
|
222
|
+
regex: unchecked, it trades a compile error at LOAD time for one at MATCH
|
|
223
|
+
time, inside a session's env-fault classification, where the caller degrades
|
|
224
|
+
rather than raises and the pattern silently never fires.
|
|
225
|
+
|
|
226
|
+
Scope is semantic, not type-level. A package that constructs a ``CLIProfile``
|
|
227
|
+
with the wrong runtime type in a field (a list where a ``str`` belongs) is a
|
|
228
|
+
bug this deliberately does not chase: reaching here already ran that package's
|
|
229
|
+
code in-process, so this is not a security boundary and hardening it as one
|
|
230
|
+
would invite it being trusted as one. What it does catch is the well-typed and
|
|
231
|
+
wrong profile — exactly what a TOML author is told about at parse time."""
|
|
232
|
+
|
|
233
|
+
def fail(msg: str) -> ProfileError:
|
|
234
|
+
return ProfileError(f"profile {source}: {msg}")
|
|
235
|
+
|
|
236
|
+
if not profile.name.strip() or not profile.binary.strip():
|
|
237
|
+
raise fail("'name' and 'binary' are required")
|
|
238
|
+
|
|
239
|
+
hooks = profile.hooks
|
|
240
|
+
if hooks.dialect not in HOOK_DIALECTS:
|
|
241
|
+
raise fail(f"hooks.dialect must be one of {sorted(HOOK_DIALECTS)}: got {hooks.dialect!r}")
|
|
242
|
+
if hooks.dialect == "none":
|
|
243
|
+
# hookless: nothing is ever registered, so a config_path or events map
|
|
244
|
+
# is a contradiction — reject rather than silently ignore.
|
|
245
|
+
if hooks.config_path or hooks.events:
|
|
246
|
+
raise fail('hookless profiles (dialect = "none") must not set hooks.config_path/events')
|
|
247
|
+
else:
|
|
248
|
+
if (
|
|
249
|
+
names_tree_root(hooks.config_path)
|
|
250
|
+
or is_absolute_path(hooks.config_path)
|
|
251
|
+
or has_parent_ref(hooks.config_path)
|
|
252
|
+
):
|
|
253
|
+
# `names_tree_root("")` is True, so this arm also carries the
|
|
254
|
+
# "a real dialect must name a config_path at all" case.
|
|
255
|
+
raise fail("hooks.config_path must be a project-relative path")
|
|
256
|
+
if names_win32_alias(hooks.config_path):
|
|
257
|
+
raise fail(
|
|
258
|
+
"hooks.config_path must not name a Windows device or end a component "
|
|
259
|
+
"in a period or space"
|
|
260
|
+
)
|
|
261
|
+
if not hooks.events:
|
|
262
|
+
raise fail("hooks.events must map native event names to canonical ones")
|
|
263
|
+
bad = sorted(set(hooks.events.values()) - CANONICAL_EVENTS)
|
|
264
|
+
if bad:
|
|
265
|
+
raise fail(
|
|
266
|
+
f"hooks.events values must be canonical {sorted(CANONICAL_EVENTS)}: got {bad}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Shape only — membership against the registered kinds is deliberately NOT
|
|
270
|
+
# checked here (see the module docstring): that set is open-ended and lives in
|
|
271
|
+
# adapters/registry.py, which importing from here would make a cycle.
|
|
272
|
+
# `.strip()` because the TOML route strips before it gets here: testing the raw
|
|
273
|
+
# value would refuse `adapter = " "` from a file while admitting it from an
|
|
274
|
+
# entry-point provider, which is exactly the two-routes divergence this
|
|
275
|
+
# function exists to prevent.
|
|
276
|
+
if not profile.adapter.strip():
|
|
277
|
+
raise fail("adapter must be a non-empty string naming an adapter kind")
|
|
278
|
+
|
|
279
|
+
# The emptiness tests above use `.strip()` because the TOML route CANONICALIZES
|
|
280
|
+
# before it gets here — `_parse_profile` strips exactly these three fields — so
|
|
281
|
+
# testing the raw value would refuse `adapter = " "` from a file while
|
|
282
|
+
# admitting it from a provider. But validating a stripped COPY while the frozen
|
|
283
|
+
# original is what gets installed leaves the other half of that same divergence
|
|
284
|
+
# open: `" acme "` is content `_parse_profile` cannot produce, and every
|
|
285
|
+
# consumer keys on the exact string. The profile lands under a map key `--cli
|
|
286
|
+
# acme` never finds, a `binary` `shutil.which` never resolves, and an `adapter`
|
|
287
|
+
# `get_adapter_kind` reports as an unknown kind — while the provider that
|
|
288
|
+
# shipped it is recorded as perfectly fine.
|
|
289
|
+
#
|
|
290
|
+
# Refused, not normalized: this function validates and does not rewrite, and a
|
|
291
|
+
# frozen dataclass rebuilt here would leave the caller holding the original
|
|
292
|
+
# anyway. Refusing is also the louder half — the provider is dropped WITH a
|
|
293
|
+
# reason naming the field, which is what the recorded-degrade contract owes an
|
|
294
|
+
# operator. Ordered after the emptiness tests so `" "` still reads as empty
|
|
295
|
+
# rather than as non-canonical.
|
|
296
|
+
for label, value in (
|
|
297
|
+
("name", profile.name),
|
|
298
|
+
("binary", profile.binary),
|
|
299
|
+
("adapter", profile.adapter),
|
|
300
|
+
):
|
|
301
|
+
if value != value.strip():
|
|
302
|
+
raise fail(
|
|
303
|
+
f"{label} must not carry leading/trailing whitespace: {value!r} "
|
|
304
|
+
"(the TOML route strips it; a provider must hand over the "
|
|
305
|
+
"canonical value so both routes install the same profile)"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# The one coherence rule between the two axes, and the only place both routes
|
|
309
|
+
# pass through. `generic` is the bundled tmux adapter: it injects into a window
|
|
310
|
+
# and completes on a Stop hook (or the window dying), so pairing it with
|
|
311
|
+
# dialect = "none" — which means nothing ever registers that hook — describes a
|
|
312
|
+
# session that can only wait out `session_timeout_min` against an interactive
|
|
313
|
+
# CLI that never exits. Both routes could reach it: a TOML file naming the pair
|
|
314
|
+
# outright, and an entry-point provider that builds a hookless `HookSpec` while
|
|
315
|
+
# leaving `adapter` at its dataclass default. (The absent-key TOML case cannot:
|
|
316
|
+
# `_legacy_adapter_default` sends a hookless file to the HTTP kind.)
|
|
317
|
+
#
|
|
318
|
+
# This is the membership check's opposite, not an instance of it: naming ONE
|
|
319
|
+
# bundled kind is a fact about that adapter's completion contract, which this
|
|
320
|
+
# package owns, and the same latitude `validate`'s httpx check takes. Hookless
|
|
321
|
+
# on any OTHER kind stays legal — that decoupling is what the registry is for,
|
|
322
|
+
# and an out-of-tree kind's completion contract is its own to state.
|
|
323
|
+
from .registry import GENERIC
|
|
324
|
+
|
|
325
|
+
if profile.hookless and profile.adapter.strip() == GENERIC:
|
|
326
|
+
raise fail(
|
|
327
|
+
f'hookless profiles (dialect = "none") cannot select the {GENERIC!r} adapter: '
|
|
328
|
+
"it completes on a Stop hook a hookless profile never registers, so the "
|
|
329
|
+
"session would wait out session_timeout_min. Name the adapter kind that "
|
|
330
|
+
"drives this CLI over its own transport."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if profile.usage_parser not in USAGE_PARSERS:
|
|
334
|
+
raise fail(
|
|
335
|
+
f"usage_parser must be one of {sorted(USAGE_PARSERS)}: got {profile.usage_parser!r}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if profile.usage_grace_s < 0:
|
|
339
|
+
raise fail(f"usage_grace_s must be >= 0: got {profile.usage_grace_s}")
|
|
340
|
+
|
|
341
|
+
nudges = profile.stop_without_result_nudges
|
|
342
|
+
if nudges is not None and nudges < 0:
|
|
343
|
+
raise fail(f"stop_without_result_nudges must be >= 0: got {nudges}")
|
|
344
|
+
|
|
345
|
+
if (
|
|
346
|
+
names_tree_root(profile.skill_tree)
|
|
347
|
+
or is_absolute_path(profile.skill_tree)
|
|
348
|
+
or has_parent_ref(profile.skill_tree)
|
|
349
|
+
):
|
|
350
|
+
raise fail("skill_tree must be a project-relative path")
|
|
351
|
+
|
|
352
|
+
if names_win32_alias(profile.skill_tree):
|
|
353
|
+
raise fail(
|
|
354
|
+
"skill_tree must not name a Windows device or end a component in a period or space"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# `names_tree_root` subsumes the emptiness check it replaced. These entries feed
|
|
358
|
+
# provision_worktree's seed loop, where any spelling of the root ("", ".", "./",
|
|
359
|
+
# ".\") resolves src to the repo root and dst to the worktree — both pass the
|
|
360
|
+
# loop's containment checks, so the whole repo is copied in.
|
|
361
|
+
for seed in profile.seed_files:
|
|
362
|
+
if names_tree_root(seed) or is_absolute_path(seed) or has_parent_ref(seed):
|
|
363
|
+
raise fail(f"seed_files entries must be project-relative paths: got {seed!r}")
|
|
364
|
+
if names_win32_alias(seed):
|
|
365
|
+
raise fail(
|
|
366
|
+
"seed_files entries must not name a Windows device or end a component "
|
|
367
|
+
f"in a period or space: got {seed!r}"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
for pattern in profile.env_fault_patterns:
|
|
371
|
+
try:
|
|
372
|
+
regex.compile(pattern) # same engine the adapter matches with (timeout-guarded)
|
|
373
|
+
except regex.error as e:
|
|
374
|
+
raise fail(f"env_fault_patterns entry is not a valid regex: {pattern!r} ({e})") from e
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _legacy_adapter_default(dialect: str) -> str:
|
|
378
|
+
"""The adapter kind a TOML profile that predates the ``adapter`` field meant.
|
|
379
|
+
|
|
380
|
+
Before the registry, ``hooks.dialect`` WAS the class selector: ``make_adapters``
|
|
381
|
+
sent every hookless profile to the opencode HTTP adapters and everything else to
|
|
382
|
+
the generic ones. Project overlays are a documented customization point
|
|
383
|
+
(``<project>/.froid-loop/profiles/*.toml``, same name overrides), and the way to
|
|
384
|
+
tweak the opencode profile's ``binary``/``env``/``model`` was to copy the
|
|
385
|
+
packaged one — which carried no ``adapter`` key, because the key did not exist.
|
|
386
|
+
Taking the dataclass default for those files would silently move a working
|
|
387
|
+
hookless run onto tmux, where it launches the CLI in a window and waits out
|
|
388
|
+
``session_timeout_min`` for a ``Stop`` hook a hookless profile never registers —
|
|
389
|
+
and ``validate`` stays green, because every check it would trip keys on
|
|
390
|
+
``hookless`` too. So reproduce the old dispatch instead of defaulting.
|
|
391
|
+
|
|
392
|
+
Only the absent key takes this path; an explicit ``adapter`` is always honored,
|
|
393
|
+
including the now-legal hookless-but-not-``opencode-http`` combination the axes
|
|
394
|
+
were decoupled to allow. Naming the two bundled kinds here is a fact about what
|
|
395
|
+
the *old* dispatch did, not a valid-kinds set — that set is only ever
|
|
396
|
+
``registry.known_adapter_kinds()``. Imported inside the function so this module
|
|
397
|
+
keeps no import-time dependency on the registry."""
|
|
398
|
+
from .registry import GENERIC, OPENCODE_HTTP
|
|
399
|
+
|
|
400
|
+
return OPENCODE_HTTP if dialect == "none" else GENERIC
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parse_profile(doc: dict, source: str) -> CLIProfile:
|
|
404
|
+
"""Coerce a TOML document into a :class:`CLIProfile`.
|
|
405
|
+
|
|
406
|
+
SHAPE only: the container and element types a TOML document can get wrong and
|
|
407
|
+
a constructed dataclass cannot. Every value-level invariant lives in
|
|
408
|
+
:func:`_validate_profile`, called on the result — so the entry-point route,
|
|
409
|
+
which has no document to coerce, enforces exactly the same set."""
|
|
410
|
+
|
|
411
|
+
def fail(msg: str) -> ProfileError:
|
|
412
|
+
return ProfileError(f"profile {source}: {msg}")
|
|
413
|
+
|
|
414
|
+
def str_list(key: str) -> tuple[str, ...]:
|
|
415
|
+
# TOML arrays parse as list; reject a bare string (which would iterate to
|
|
416
|
+
# per-character entries) or a scalar (a raw TypeError) with a friendly error.
|
|
417
|
+
raw = doc.get(key, [])
|
|
418
|
+
if not isinstance(raw, list) or not all(isinstance(x, str) for x in raw):
|
|
419
|
+
raise fail(f"{key} must be a list of strings")
|
|
420
|
+
return tuple(raw)
|
|
421
|
+
|
|
422
|
+
hooks_d = doc.get("hooks")
|
|
423
|
+
if not isinstance(hooks_d, dict):
|
|
424
|
+
raise fail("missing [hooks] table")
|
|
425
|
+
events_d = hooks_d.get("events", {})
|
|
426
|
+
if not isinstance(events_d, dict):
|
|
427
|
+
raise fail("hooks.events must map native event names to canonical ones")
|
|
428
|
+
|
|
429
|
+
# A dedicated shape check rather than the `str()` coercion the neighbouring
|
|
430
|
+
# scalars get, because `adapter` has no parse-time membership test to land in
|
|
431
|
+
# afterwards: `str(["x"])` would coerce a TOML array to the literal `"['x']"`
|
|
432
|
+
# and carry it all the way to `get_adapter_kind`, which would then name that
|
|
433
|
+
# nonsense as the unknown kind. #384's rule — a malformed value funnels into
|
|
434
|
+
# ProfileError at the boundary, never a silent coercion.
|
|
435
|
+
raw_adapter = doc.get("adapter")
|
|
436
|
+
if raw_adapter is None:
|
|
437
|
+
raw_adapter = _legacy_adapter_default(str(hooks_d.get("dialect", "")))
|
|
438
|
+
if not isinstance(raw_adapter, str):
|
|
439
|
+
raise fail(f"adapter must be a string: got {type(raw_adapter).__name__}")
|
|
440
|
+
|
|
441
|
+
profile = CLIProfile(
|
|
442
|
+
name=str(doc.get("name", "")).strip(),
|
|
443
|
+
binary=str(doc.get("binary", "")).strip(),
|
|
444
|
+
hooks=HookSpec(
|
|
445
|
+
dialect=str(hooks_d.get("dialect", "")),
|
|
446
|
+
config_path=str(hooks_d.get("config_path", "")),
|
|
447
|
+
events={str(k): str(v) for k, v in events_d.items()},
|
|
448
|
+
),
|
|
449
|
+
adapter=raw_adapter.strip(),
|
|
450
|
+
skill_tree=str(doc.get("skill_tree", ".claude/skills")),
|
|
451
|
+
prompt_template=str(doc.get("prompt_template", "{prompt}")),
|
|
452
|
+
launch_args=str_list("launch_args"),
|
|
453
|
+
bypass_args=str_list("bypass_args"),
|
|
454
|
+
model_flag=str(doc.get("model_flag", "--model")),
|
|
455
|
+
env={str(k): str(v) for k, v in doc.get("env", {}).items()},
|
|
456
|
+
usage_parser=str(doc.get("usage_parser", "none")),
|
|
457
|
+
# `float()`/`int()` are the raw coercions `_load_toml`'s CONVERSION_FAULTS
|
|
458
|
+
# funnel exists for: they answer OverflowError for `inf` and for an integer
|
|
459
|
+
# too large to be a float, both of which are legal TOML.
|
|
460
|
+
usage_grace_s=float(doc.get("usage_grace_s", 0.0)),
|
|
461
|
+
stop_without_result_nudges=(
|
|
462
|
+
None if (raw := doc.get("stop_without_result_nudges")) is None else int(raw)
|
|
463
|
+
),
|
|
464
|
+
subagent_stop_without_transcript=bool(doc.get("subagent_stop_without_transcript", False)),
|
|
465
|
+
first_run_note=str(doc.get("first_run_note", "")),
|
|
466
|
+
seed_files=str_list("seed_files"),
|
|
467
|
+
env_fault_patterns=str_list("env_fault_patterns"),
|
|
468
|
+
)
|
|
469
|
+
_validate_profile(profile, source)
|
|
470
|
+
return profile
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _read_profile_text(entry: Traversable | Path, source: str) -> str:
|
|
474
|
+
"""Read a profile TOML as text, converting a read fault into ProfileError.
|
|
475
|
+
|
|
476
|
+
Not part of `_load_toml`'s CONVERSION_FAULTS funnel and not reachable by
|
|
477
|
+
widening it: that funnel wraps `_parse_profile`, and the decode happens in
|
|
478
|
+
the *argument expression* at both of `load_profiles`' call sites, before
|
|
479
|
+
`_load_toml` is entered. So a non-UTF-8 overlay escaped as a raw
|
|
480
|
+
`UnicodeDecodeError` (a ValueError) while every consumer keys its fault
|
|
481
|
+
handling on ProfileError — `validate`'s role loop crashed before printing
|
|
482
|
+
its `--json` document instead of reporting an `adapter.profile` failure
|
|
483
|
+
naming the file, and `get_profile` backs every adapter resolution, so the
|
|
484
|
+
same escape reached run/sweep preflight (#473). Both-arms precedent:
|
|
485
|
+
`stories.py`'s manifest read; the decode-only twin is `policy.load`.
|
|
486
|
+
|
|
487
|
+
Takes the packaged built-ins too. They are trusted, but a corrupt package
|
|
488
|
+
is a packaging bug and should say so with a typed error rather than a
|
|
489
|
+
traceback. One helper covers both call shapes: an `importlib.resources`
|
|
490
|
+
Traversable and a `Path` each expose ``read_text(encoding=...)``.
|
|
491
|
+
"""
|
|
492
|
+
try:
|
|
493
|
+
return entry.read_text(encoding="utf-8")
|
|
494
|
+
except UnicodeDecodeError as e:
|
|
495
|
+
raise ProfileError(f"profile {source}: not valid UTF-8: {e}") from e
|
|
496
|
+
except OSError as e:
|
|
497
|
+
# A profile that is present but cannot be read — permissions, an I/O
|
|
498
|
+
# error, a dead mount. The overlay's `is_dir()` + glob rule out ABSENCE
|
|
499
|
+
# and nothing else, so this escaped as a bare OSError.
|
|
500
|
+
raise ProfileError(f"profile {source}: unreadable: {e}") from e
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _load_toml(text: str, source: str) -> CLIProfile:
|
|
504
|
+
try:
|
|
505
|
+
doc = tomllib.loads(text)
|
|
506
|
+
except tomllib.TOMLDecodeError as e:
|
|
507
|
+
raise ProfileError(f"profile {source}: invalid TOML: {e}") from e
|
|
508
|
+
try:
|
|
509
|
+
return _parse_profile(doc, source)
|
|
510
|
+
except ProfileError:
|
|
511
|
+
raise # intent: a domain error is never re-wrapped (it is not a CONVERSION_FAULT)
|
|
512
|
+
except CONVERSION_FAULTS as e:
|
|
513
|
+
# A funnel, not per-field guards: `_parse_profile`'s raw conversions
|
|
514
|
+
# (`float()`, `int()`, `.items()`) raise bare conversion errors on
|
|
515
|
+
# TOML-legal values of the wrong type, and every consumer keys its
|
|
516
|
+
# fault handling on ProfileError — `validate`'s role loop reports an
|
|
517
|
+
# `adapter.profile` failure, `_require_base_skills` skips the one
|
|
518
|
+
# profile, `install` prints FAIL. A bare escape crashed `validate`
|
|
519
|
+
# before any document was printed.
|
|
520
|
+
raise ProfileError(f"profile {source}: malformed field value: {e}") from e
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# The entry-point group an out-of-tree package advertises extra profiles under —
|
|
524
|
+
# the companion to adapters/registry.py's `froid_loop.adapters` group. Each entry
|
|
525
|
+
# point loads to a provider: a callable returning an iterable of CLIProfile (or an
|
|
526
|
+
# iterable directly), e.g. one built from the package's own bundled TOML. Scanned
|
|
527
|
+
# once per process (a third-party import failure is not transient); the resulting
|
|
528
|
+
# profiles are process-global (project-independent), so only the project overlay
|
|
529
|
+
# is re-read per load_profiles call. A broken entry point is recorded, not raised.
|
|
530
|
+
PROFILES_GROUP = "froid_loop.profiles"
|
|
531
|
+
_EXTERNALS_LOADED = False
|
|
532
|
+
_EXTERNAL_PROFILES: dict[str, CLIProfile] = {}
|
|
533
|
+
_PROFILE_LOAD_ERRORS: dict[str, str] = {}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _coerce_profiles(produced: object, ep_name: str) -> list[CLIProfile]:
|
|
537
|
+
"""A provider may return a callable's result or an iterable directly; either
|
|
538
|
+
way it must yield CLIProfile instances that satisfy the same invariants a TOML
|
|
539
|
+
profile does. Anything else is the package's bug — reported (per
|
|
540
|
+
:func:`external_profile_errors`), never trusted into the map.
|
|
541
|
+
|
|
542
|
+
The :func:`_validate_profile` call is the point of this function: without it a
|
|
543
|
+
Python provider is the one route into the profile map with no parser in front
|
|
544
|
+
of it, and it could install a state ``_parse_profile`` would refuse."""
|
|
545
|
+
try:
|
|
546
|
+
items = list(produced) # pyright: ignore[reportArgumentType] — TypeError is the check
|
|
547
|
+
except TypeError as exc:
|
|
548
|
+
raise ProfileError(
|
|
549
|
+
f"{ep_name}: profile provider must return an iterable of CLIProfile"
|
|
550
|
+
) from exc
|
|
551
|
+
for item in items:
|
|
552
|
+
if not isinstance(item, CLIProfile):
|
|
553
|
+
raise ProfileError(
|
|
554
|
+
f"{ep_name}: profile provider yielded {type(item).__name__}, not CLIProfile"
|
|
555
|
+
)
|
|
556
|
+
_validate_profile(item, f"entry point {ep_name}")
|
|
557
|
+
return items
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _load_external_profiles() -> dict[str, CLIProfile]:
|
|
561
|
+
"""Import every ``froid_loop.profiles`` entry point and collect the profiles it
|
|
562
|
+
provides, first-registration-wins on a name collision. Scan-once; failures are
|
|
563
|
+
recorded in ``_PROFILE_LOAD_ERRORS`` (surfaced via
|
|
564
|
+
:func:`external_profile_errors`), never raised — a broken adapter package must
|
|
565
|
+
not break profile loading for everything else.
|
|
566
|
+
|
|
567
|
+
A provider is rejected WHOLE: one invalid profile in the returned batch drops
|
|
568
|
+
the batch, because ``_coerce_profiles`` raises before any of them is recorded.
|
|
569
|
+
Deliberate — a provider is one package's declaration, and half-installing it
|
|
570
|
+
would leave an operator with a profile set no error message accounts for.
|
|
571
|
+
|
|
572
|
+
Entry points are visited in (name, distribution) order, so which provider wins
|
|
573
|
+
a name collision is a property of the packages rather than of ``sys.path``
|
|
574
|
+
ordering. The distribution is part of the key because the name alone is not a
|
|
575
|
+
total order — ``entry_points(group=...)`` does not dedup across distributions,
|
|
576
|
+
and ``sorted`` being stable would resolve a same-name tie back into discovery
|
|
577
|
+
order (the adapter scan sorts on the same key, for the same reason)."""
|
|
578
|
+
global _EXTERNALS_LOADED
|
|
579
|
+
if _EXTERNALS_LOADED:
|
|
580
|
+
return _EXTERNAL_PROFILES
|
|
581
|
+
_EXTERNALS_LOADED = True
|
|
582
|
+
try:
|
|
583
|
+
eps = sorted(
|
|
584
|
+
importlib.metadata.entry_points(group=PROFILES_GROUP),
|
|
585
|
+
key=lambda e: (e.name, getattr(e.dist, "name", "") or ""),
|
|
586
|
+
)
|
|
587
|
+
except Exception as exc: # noqa: BLE001 — diagnostics path, never crash loading
|
|
588
|
+
_PROFILE_LOAD_ERRORS["<entry-point scan>"] = f"{type(exc).__name__}: {exc}"
|
|
589
|
+
return _EXTERNAL_PROFILES
|
|
590
|
+
for ep in eps:
|
|
591
|
+
try:
|
|
592
|
+
provider = ep.load()
|
|
593
|
+
produced = provider() if callable(provider) else provider
|
|
594
|
+
for profile in _coerce_profiles(produced, ep.name):
|
|
595
|
+
_EXTERNAL_PROFILES.setdefault(profile.name, profile)
|
|
596
|
+
except Exception as exc: # noqa: BLE001 — one bad package must not hide the rest
|
|
597
|
+
record_load_error(_PROFILE_LOAD_ERRORS, ep, exc)
|
|
598
|
+
return _EXTERNAL_PROFILES
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def external_profile_errors() -> dict[str, str]:
|
|
602
|
+
"""Entry-point name -> failure reason(s) for every external profile provider
|
|
603
|
+
that failed to load this process (empty when all loaded). For diagnostics
|
|
604
|
+
surfaces.
|
|
605
|
+
|
|
606
|
+
One value may carry MORE than one reason, ``"; "``-joined: two distributions
|
|
607
|
+
may advertise the same entry-point name, and each of their failures is kept
|
|
608
|
+
(see :func:`~.entrypoints.record_load_error`). Each reason is labelled with
|
|
609
|
+
its distribution whenever one is resolvable.
|
|
610
|
+
|
|
611
|
+
Performs the scan rather than assuming a neighbouring call already did. The
|
|
612
|
+
only other trigger is :func:`load_profiles`, which ``validate`` reaches through
|
|
613
|
+
``get_profile`` — inside the block that a ``PolicyError`` aborts. Reading a map
|
|
614
|
+
nothing had populated would report a broken profile package as absent for a
|
|
615
|
+
reason having nothing to do with that package, exactly when an operator is
|
|
616
|
+
already looking at a broken config. Scan-once still holds."""
|
|
617
|
+
_load_external_profiles()
|
|
618
|
+
return dict(_PROFILE_LOAD_ERRORS)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def load_profiles(project: Path | None = None) -> dict[str, CLIProfile]:
|
|
622
|
+
"""Packaged built-ins, overlaid by ``froid_loop.profiles`` entry-point
|
|
623
|
+
profiles, overlaid by <project>/.froid-loop/profiles/*.toml.
|
|
624
|
+
|
|
625
|
+
Precedence is packaged < entry-point < project: a co-installed adapter package
|
|
626
|
+
extends (or overrides) the bundled set, and a project TOML always wins."""
|
|
627
|
+
profiles: dict[str, CLIProfile] = {}
|
|
628
|
+
packaged = resources.files("froid_loop.data").joinpath("profiles")
|
|
629
|
+
for entry in sorted(packaged.iterdir(), key=lambda e: e.name):
|
|
630
|
+
if entry.name.endswith(".toml"):
|
|
631
|
+
profile = _load_toml(_read_profile_text(entry, entry.name), entry.name)
|
|
632
|
+
# Stamped HERE and nowhere else: this loop is the only code that knows
|
|
633
|
+
# a profile came out of the package rather than off a project's disk.
|
|
634
|
+
profiles[profile.name] = replace(profile, packaged=True)
|
|
635
|
+
profiles.update(_load_external_profiles())
|
|
636
|
+
if project is not None:
|
|
637
|
+
user_dir = project / USER_PROFILES_REL
|
|
638
|
+
if user_dir.is_dir():
|
|
639
|
+
for path in sorted(user_dir.glob("*.toml")):
|
|
640
|
+
profile = _load_toml(_read_profile_text(path, str(path)), str(path))
|
|
641
|
+
profiles[profile.name] = profile
|
|
642
|
+
return profiles
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def get_profile(name: str, project: Path | None = None) -> CLIProfile:
|
|
646
|
+
profiles = load_profiles(project)
|
|
647
|
+
profile = profiles.get(ALIASES.get(name, name))
|
|
648
|
+
if profile is None:
|
|
649
|
+
raise ProfileError(f"unknown CLI profile: {name!r} (available: {sorted(profiles)})")
|
|
650
|
+
return profile
|