holt-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. holt/__init__.py +0 -0
  2. holt/agent/__init__.py +0 -0
  3. holt/agent/entry.py +86 -0
  4. holt/agent/findings.py +49 -0
  5. holt/agent/landing.py +154 -0
  6. holt/agent/pipeline.py +244 -0
  7. holt/agent/progression.py +408 -0
  8. holt/agent/signals.py +220 -0
  9. holt/agent/stages.py +533 -0
  10. holt/agent/verdict.py +226 -0
  11. holt/agent/verify.py +140 -0
  12. holt/baseline.py +89 -0
  13. holt/baseline_matched.py +116 -0
  14. holt/cli.py +616 -0
  15. holt/discover.py +497 -0
  16. holt/evidence/__init__.py +3 -0
  17. holt/evidence/fixtures.py +154 -0
  18. holt/evidence/github_graphql.py +538 -0
  19. holt/evidence/provider.py +77 -0
  20. holt/evidence/redact.py +79 -0
  21. holt/issues.py +41 -0
  22. holt/model.py +516 -0
  23. holt/profile.py +126 -0
  24. holt/report.py +157 -0
  25. holt/tui/__init__.py +0 -0
  26. holt/tui/animation.py +84 -0
  27. holt/tui/app.py +294 -0
  28. holt/tui/clipboard.py +89 -0
  29. holt/tui/commands.py +134 -0
  30. holt/tui/discovery.py +305 -0
  31. holt/tui/env.py +49 -0
  32. holt/tui/events.py +245 -0
  33. holt/tui/mascot.py +121 -0
  34. holt/tui/models.py +590 -0
  35. holt/tui/observe.py +297 -0
  36. holt/tui/screens/__init__.py +35 -0
  37. holt/tui/screens/assessment.py +337 -0
  38. holt/tui/screens/confirm.py +62 -0
  39. holt/tui/screens/discover.py +444 -0
  40. holt/tui/screens/home.py +519 -0
  41. holt/tui/screens/inspector.py +106 -0
  42. holt/tui/screens/live.py +335 -0
  43. holt/tui/screens/models.py +393 -0
  44. holt/tui/screens/next_steps.py +425 -0
  45. holt/tui/screens/profile.py +129 -0
  46. holt/tui/session.py +711 -0
  47. holt/tui/store.py +458 -0
  48. holt/tui/theme.py +479 -0
  49. holt/tui/visual.py +33 -0
  50. holt/tui/widgets/__init__.py +0 -0
  51. holt/tui/widgets/candidates.py +78 -0
  52. holt/tui/widgets/claims.py +59 -0
  53. holt/tui/widgets/disclosure.py +121 -0
  54. holt/tui/widgets/evidence.py +121 -0
  55. holt/tui/widgets/masthead.py +122 -0
  56. holt/tui/widgets/recent.py +167 -0
  57. holt/tui/widgets/scrolling.py +38 -0
  58. holt/tui/widgets/stages.py +232 -0
  59. holt/types.py +48 -0
  60. holt_cli-0.1.0.dist-info/METADATA +198 -0
  61. holt_cli-0.1.0.dist-info/RECORD +65 -0
  62. holt_cli-0.1.0.dist-info/WHEEL +4 -0
  63. holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
  64. holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  65. holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
holt/tui/commands.py ADDED
@@ -0,0 +1,134 @@
1
+ """What ctrl+p offers.
2
+
3
+ Textual gives every app a command palette for free, and out of the box it is
4
+ entirely the framework's own: Keys, Maximize, Screenshot, Theme. Useful while
5
+ building a terminal app, and nothing to do with assessing a repository — someone
6
+ who opened it looking for holt's features found a menu about the toolkit holt
7
+ happens to be written in.
8
+
9
+ So holt puts its own commands in front of those. Two kinds, and the second is
10
+ the reason this file is worth having:
11
+
12
+ * **The things the keybindings already do** — assess, discover, models, profile.
13
+ A palette that lists them is how you find a feature whose key you have not
14
+ memorised, which is every feature until you have used it a few times.
15
+ * **The things that are true right now** — the runs in flight, and the
16
+ assessments already stored. Those cannot be keybindings, because they are
17
+ data. `stop home-assistant/core` is only offerable while that run exists, and
18
+ the palette is the only surface in the interface that can grow a command for
19
+ it and then lose it again.
20
+
21
+ The framework's commands are kept, appended after holt's rather than removed:
22
+ they cost a line each and someone who wants a screenshot of a verdict should be
23
+ able to take one.
24
+
25
+ They are yielded from *this* provider rather than registered alongside it.
26
+ Textual runs every registered provider concurrently and collects hits as they
27
+ arrive, and `App.COMMANDS` is a set, so two providers produce an order that
28
+ changes between launches — holt's commands on top one time and Textual's the
29
+ next. One provider, yielding in a written order, is the only way to say which
30
+ comes first and mean it.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from typing import Callable, Iterable
36
+
37
+ from textual.command import DiscoveryHit, Hit, Hits, Provider
38
+
39
+ from holt.tui import store
40
+
41
+
42
+ class HoltCommands(Provider):
43
+ """holt's own entries in the command palette."""
44
+
45
+ async def discover(self) -> Hits:
46
+ """What the palette shows before anything is typed."""
47
+ for name, help_text, callback in self._commands():
48
+ yield DiscoveryHit(name, callback, help=help_text)
49
+
50
+ async def search(self, query: str) -> Hits:
51
+ matcher = self.matcher(query)
52
+ for name, help_text, callback in self._commands():
53
+ score = matcher.match(name)
54
+ if score > 0:
55
+ yield Hit(score, matcher.highlight(name), callback, help=help_text)
56
+
57
+ # ─── the commands themselves ────────────────────────────────────────────
58
+
59
+ def _commands(self) -> Iterable[tuple[str, str, Callable[[], None]]]:
60
+ """Name, one-line help, and what it does.
61
+
62
+ Rebuilt on every open rather than cached, because half of these are
63
+ about state — a run that has finished must stop being offered as one
64
+ you can stop.
65
+ """
66
+ app = self.app
67
+ yield from self._runs(app)
68
+ yield from self._stored(app)
69
+ yield (
70
+ "assess a repository",
71
+ "type an owner/name and press enter",
72
+ app.go_home,
73
+ )
74
+ yield (
75
+ "find a repository",
76
+ "screen candidates you have not named yet",
77
+ lambda: app.push_screen("discover"),
78
+ )
79
+ yield (
80
+ "models",
81
+ "which model answers each stage, and whether it can be reached",
82
+ lambda: app.push_screen("models"),
83
+ )
84
+ yield (
85
+ "profile",
86
+ "the languages and the time you have, used to rank what to read",
87
+ lambda: app.push_screen("profile"),
88
+ )
89
+ yield ("quit holt", "asks first if a run is still in flight", app.action_quit)
90
+ yield from self._framework(app)
91
+
92
+ def _framework(self, app) -> Iterable[tuple[str, str, Callable[[], None]]]:
93
+ """Textual's own commands, last, in the framework's own order."""
94
+ try:
95
+ system = list(app.get_system_commands(self.screen))
96
+ except Exception: # noqa: BLE001 - the palette opens even if these do not
97
+ return
98
+ for entry in system:
99
+ name, help_text, callback = entry[0], entry[1], entry[2]
100
+ # `discover` is the framework's own flag for "list this before
101
+ # anything is typed". Honoured rather than overridden.
102
+ if len(entry) > 3 and not entry[3]:
103
+ continue
104
+ yield (name, help_text, callback)
105
+
106
+ def _runs(self, app) -> Iterable[tuple[str, str, Callable[[], None]]]:
107
+ """One pair of commands per run in flight. First, because they are live."""
108
+ for session in getattr(app, "in_flight", []):
109
+ repo = session.options.repo
110
+ yield (
111
+ f"watch {repo}",
112
+ f"rejoin the assessment in progress · {session.options.mode}",
113
+ lambda s=session: app.watch_run(s),
114
+ )
115
+ yield (
116
+ f"stop {repo}",
117
+ "ends the run and keeps nothing it had computed",
118
+ lambda s=session: app.confirm_stop(s),
119
+ )
120
+
121
+ def _stored(self, app) -> Iterable[tuple[str, str, Callable[[], None]]]:
122
+ """Assessments already produced, newest first. Reading one costs nothing."""
123
+ try:
124
+ entries = app.store.all()
125
+ except Exception: # noqa: BLE001 - a palette must not raise over a bad store
126
+ return
127
+ for entry in entries:
128
+ verdict = getattr(entry.assessment.verdict, "value", "")
129
+ age = store.describe_age(entry.age_seconds)
130
+ yield (
131
+ f"open {entry.repo}",
132
+ f"{verdict.replace('_', ' ')} · assessed {age} · {entry.mode}",
133
+ lambda e=entry: app.open_stored(e),
134
+ )
holt/tui/discovery.py ADDED
@@ -0,0 +1,305 @@
1
+ """Finding candidates, live or from a recording, as rows rather than markdown.
2
+
3
+ Two ways in, and the default matters. **Live** is what the feature is for: a
4
+ GitHub search over what you said you wanted, screened for free as each candidate
5
+ comes back. **Replaying a recording** is a demo of that — the same screening over
6
+ committed fixtures, no token, no network — and it is now something you ask for
7
+ rather than the thing the screen opens on. A page of repositories from a search
8
+ somebody else ran in June is not a result about you, and presenting it as the
9
+ first thing `ctrl+f` does made the feature look like a canned list.
10
+
11
+ Screening is free in both directions: `screen_records` runs no model, so a live
12
+ sweep spends GitHub API quota and a minute of waiting, and nothing else. The
13
+ model cost lives where it always did — pressing enter on a row starts an
14
+ ordinary assessment run.
15
+
16
+ The engine returns rendered markdown, which is the right output for a
17
+ file and the wrong one for a screen you navigate: the interface needs the slug
18
+ under the cursor so that pressing enter can assess it. Parsing the markdown back
19
+ into data would make the interface depend on how the report is worded, which is
20
+ exactly the coupling the whole TUI has avoided.
21
+
22
+ So the screening step is reassembled here from `discover`'s own public parts —
23
+ the session manifest, `screen_root`, and `screen_records`, which is where the
24
+ rule actually lives. Nothing about *what survives* is decided in this file. It
25
+ walks the same candidates in the same order and asks the same function.
26
+
27
+ The full analysis of survivors is deliberately not reproduced. Assessing a
28
+ repository already has a path through this interface, and a second one would be
29
+ the duplicate way of running a stage that this feature must not become: pressing
30
+ enter on a row starts an ordinary run.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import threading
38
+ from dataclasses import dataclass, field
39
+ from datetime import datetime
40
+ from typing import Any
41
+
42
+ from holt import discover
43
+ from holt.evidence.fixtures import FixtureProvider
44
+ from holt.profile import Profile
45
+ from holt.types import Window
46
+
47
+ #: The session that ships in the repository. Replays with no token and no key.
48
+ #: Reachable on request; never what the screen opens on.
49
+ DEFAULT_SESSION = "demo"
50
+
51
+ #: How many candidates a live search sources. The same default `holt discover`
52
+ #: uses, so the two produce comparable sweeps.
53
+ DEFAULT_LIMIT = 25
54
+
55
+ #: What a screening category means in words, since the engine's labels are
56
+ #: short. A category added later falls through to its own label rather than
57
+ #: being dropped, so a new reason still says something.
58
+ CATEGORY_WORDS = {
59
+ "registry": "a catalogue, not software",
60
+ "inactive": "not active enough",
61
+ "no_outsiders": "no outsider has landed work",
62
+ "unclear": "not enough evidence",
63
+ "archived": "archived",
64
+ "mirror": "a mirror of work done elsewhere",
65
+ }
66
+
67
+
68
+ def cut_reason(category: str) -> str:
69
+ return CATEGORY_WORDS.get(category, category.replace("_", " "))
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class Row:
74
+ """One candidate the recorded search turned up, and what screening said."""
75
+
76
+ slug: str
77
+ description: str
78
+ language: str
79
+ stars: int
80
+ verdict: str
81
+ #: `None` when the candidate survived screening; otherwise why it was cut.
82
+ category: str | None
83
+ reason: str
84
+
85
+ @property
86
+ def survived(self) -> bool:
87
+ return self.category is None
88
+
89
+
90
+ def row_for(candidate: Any, screened: Any) -> Row:
91
+ """One screened candidate as a row. The only place a `Row` is built.
92
+
93
+ Live and replay reach screening by different routes and must not describe
94
+ it differently, so both come through here.
95
+ """
96
+ return Row(
97
+ slug=candidate.slug,
98
+ description=(candidate.description or "").strip(),
99
+ language=candidate.language or "",
100
+ stars=candidate.stars,
101
+ verdict=getattr(screened.verdict, "value", str(screened.verdict)),
102
+ category=screened.category,
103
+ reason=screened.trace[0] if screened.trace else "",
104
+ )
105
+
106
+
107
+ @dataclass(slots=True)
108
+ class Session:
109
+ name: str
110
+ profile_description: str
111
+ queries: list[str]
112
+ as_of: datetime
113
+ rows: list[Row]
114
+ #: Candidates in the manifest with no recorded evidence. Listed, never
115
+ #: silently dropped — a search that quietly loses candidates is a search
116
+ #: whose count means nothing.
117
+ skipped: list[str]
118
+
119
+ @property
120
+ def survivors(self) -> list[Row]:
121
+ return [r for r in self.rows if r.survived]
122
+
123
+
124
+ def missing_token() -> str | None:
125
+ """What a live search needs, or None. Checked before a thread starts.
126
+
127
+ Only `GITHUB_TOKEN`: screening calls no model, so an OpenAI key is not
128
+ required to find candidates — only to assess one afterwards. Saying
129
+ otherwise would turn a free feature into one that looks paid.
130
+ """
131
+ if os.environ.get("GITHUB_TOKEN"):
132
+ return None
133
+ return (
134
+ "GITHUB_TOKEN is not set. A live search reads GitHub directly; "
135
+ "export a token, or put it in .env, and try again."
136
+ )
137
+
138
+
139
+ @dataclass
140
+ class Search:
141
+ """A live search, running on a worker thread.
142
+
143
+ The same shape as `holt.tui.session.Session`, for the same reason: sourcing
144
+ twenty-five repositories and reading a page of pull-request threads from
145
+ each is a minute of network, and an interface that blocks on it looks
146
+ broken. The worker appends; the screen reads what it has not drawn yet.
147
+ There is no message schema and no lock — one writer, append-only, and a
148
+ cursor on the reading side, which is how the run stream already works.
149
+
150
+ Rows arrive in the order the search returned them, cut ones included. They
151
+ are deliberately not re-sorted as they land: screening says a candidate is
152
+ worth a closer look, not that it is better than another one, and a list
153
+ that rearranged itself under the cursor would be asserting an order the
154
+ engine never computed.
155
+ """
156
+
157
+ profile: Profile
158
+ limit: int = DEFAULT_LIMIT
159
+ rows: list[Row] = field(default_factory=list)
160
+ #: The searches GitHub was actually asked, filled in before any row lands.
161
+ queries: list[str] = field(default_factory=list)
162
+ as_of: datetime | None = None
163
+ #: How many candidates the search sourced. Zero until sourcing returns.
164
+ total: int = 0
165
+ #: Candidates GitHub would not let us read. Listed, never counted as
166
+ #: rejections — "we could not look" is not "we looked and it failed".
167
+ skipped: list[str] = field(default_factory=list)
168
+ error: str | None = None
169
+ finished: bool = False
170
+ cancelled: bool = False
171
+ _thread: threading.Thread | None = None
172
+ _cancel: threading.Event = field(default_factory=threading.Event)
173
+
174
+ # ─── lifecycle ──────────────────────────────────────────────────────────
175
+
176
+ def start(self) -> None:
177
+ if self._thread is not None:
178
+ raise RuntimeError("search already started")
179
+ self._thread = threading.Thread(target=self._run, daemon=True)
180
+ self._thread.start()
181
+
182
+ def cancel(self) -> None:
183
+ """Stop after the candidate in flight. Rows already screened are kept:
184
+ they were free, and they are still true."""
185
+ self._cancel.set()
186
+
187
+ @property
188
+ def running(self) -> bool:
189
+ return self._thread is not None and self._thread.is_alive()
190
+
191
+ @property
192
+ def survivors(self) -> list[Row]:
193
+ return [r for r in self.rows if r.survived]
194
+
195
+ @property
196
+ def screened(self) -> int:
197
+ return len(self.rows)
198
+
199
+ def wait(self, timeout: float | None = None) -> None:
200
+ """Block until the worker exits. Used by tests."""
201
+ if self._thread is not None:
202
+ self._thread.join(timeout)
203
+
204
+ def describe(self) -> str:
205
+ """One line of progress, true at every point in the sweep."""
206
+ if self.error:
207
+ return self.error
208
+ if not self.total:
209
+ return "searching GitHub for candidates…"
210
+ head = f"screened {self.screened} of {self.total} at no model cost"
211
+ head += f" · {len(self.survivors)} worth a closer look"
212
+ cut = self.screened - len(self.survivors)
213
+ if cut:
214
+ head += f" · {cut} cut"
215
+ if self.skipped:
216
+ head += f" · {len(self.skipped)} could not be read"
217
+ if self.cancelled:
218
+ head += " · stopped"
219
+ return head
220
+
221
+ # ─── the worker ─────────────────────────────────────────────────────────
222
+
223
+ def _run(self) -> None:
224
+ try:
225
+ search = discover.source_live(self.profile, self.limit)
226
+ # Set before the first row so a screen drawing progress can say
227
+ # what was searched for while the slow half is still running.
228
+ self.queries = list(search.queries)
229
+ self.as_of = search.as_of
230
+ self.total = len(search.candidates)
231
+ for step in search.screen(self._cancel.is_set):
232
+ if step.result is None:
233
+ self.skipped.append(step.candidate.slug)
234
+ continue
235
+ self.rows.append(row_for(step.candidate, step.result))
236
+ self.cancelled = self._cancel.is_set()
237
+ except Exception as exc: # noqa: BLE001 - reported, never a traceback
238
+ from holt.tui.session import readable
239
+
240
+ self.error = readable(exc)
241
+ finally:
242
+ self.finished = True
243
+
244
+
245
+ def manifest_path_exists(name: str = DEFAULT_SESSION) -> bool:
246
+ """Whether a recorded session is on disk. Used to skip, never to guess."""
247
+ try:
248
+ return discover.manifest_path(name).is_file()
249
+ except OSError:
250
+ return False
251
+
252
+
253
+ def available() -> list[str]:
254
+ """Recorded session names, newest first by name."""
255
+ try:
256
+ return sorted(p.stem for p in discover.DISCOVER_ROOT.glob("*.json"))
257
+ except OSError:
258
+ return []
259
+
260
+
261
+ def load(name: str = DEFAULT_SESSION, days: int | None = None) -> Session:
262
+ """Replay a recorded session's screening step. No model, no network.
263
+
264
+ Raises `FileNotFoundError` when the session is not on disk, which the screen
265
+ turns into a sentence rather than a traceback.
266
+ """
267
+ manifest: dict[str, Any] = json.loads(discover.manifest_path(name).read_text())
268
+ as_of = datetime.fromisoformat(manifest["as_of"])
269
+
270
+ from holt.profile import Profile
271
+
272
+ profile = Profile(**manifest["profile"])
273
+ if days:
274
+ profile.days = days
275
+
276
+ provider = FixtureProvider(
277
+ Window.PRE_T, root=discover.screen_root(name), cutoff=as_of
278
+ )
279
+
280
+ rows: list[Row] = []
281
+ skipped: list[str] = []
282
+ for raw in manifest["candidates"]:
283
+ candidate = discover.Candidate(**raw)
284
+ try:
285
+ records = provider.fetch(candidate.slug)
286
+ except FileNotFoundError:
287
+ skipped.append(candidate.slug)
288
+ continue
289
+ # The rule lives in the engine. This asks it; it does not restate it.
290
+ screened = discover.screen_records(candidate, records, profile.days)
291
+ rows.append(row_for(candidate, screened))
292
+
293
+ # Survivors first: they are the ones you can act on. Within each group the
294
+ # search's own order is kept, because reordering would be a claim about
295
+ # which candidate is better and screening does not make one.
296
+ rows.sort(key=lambda r: not r.survived)
297
+
298
+ return Session(
299
+ name=name,
300
+ profile_description=profile.describe(),
301
+ queries=list(manifest.get("queries", [])),
302
+ as_of=as_of,
303
+ rows=rows,
304
+ skipped=skipped,
305
+ )
holt/tui/env.py ADDED
@@ -0,0 +1,49 @@
1
+ """Reading `.env`, for the interface only.
2
+
3
+ `holt analyze` deliberately does not do this: the reproduction path should need
4
+ nothing but `--replay`, and a command whose behaviour depends on an untracked
5
+ file in the working directory is harder to reason about, not easier. The
6
+ interface is the one place where a person is sitting down to do several live
7
+ runs in a row, and re-exporting two variables each time is friction with no
8
+ purpose.
9
+
10
+ Values already in the environment win. `.env` fills gaps; it never overrides
11
+ something the user set deliberately for one run.
12
+
13
+ The file is in `.gitignore`. Nothing here writes to it, prints its contents, or
14
+ puts a value in an event, a log line or the interface.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from pathlib import Path
21
+
22
+
23
+ def load(path: str | Path = ".env") -> list[str]:
24
+ """Set any variables `.env` defines that are not already set.
25
+
26
+ Returns the names it filled in — names only, never values — so a caller can
27
+ say where a credential came from without disclosing it.
28
+ """
29
+ file = Path(path)
30
+ if not file.is_file():
31
+ return []
32
+
33
+ filled: list[str] = []
34
+ for raw in file.read_text().splitlines():
35
+ line = raw.strip()
36
+ if not line or line.startswith("#"):
37
+ continue
38
+ if line.startswith("export "):
39
+ line = line[len("export ") :].lstrip()
40
+ name, sep, value = line.partition("=")
41
+ if not sep:
42
+ continue
43
+ name = name.strip()
44
+ value = value.strip().strip('"').strip("'")
45
+ if not name or not value or os.environ.get(name):
46
+ continue
47
+ os.environ[name] = value
48
+ filled.append(name)
49
+ return filled