techtree 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 (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,212 @@
1
+ """``techtree withdraw``. Decisions document 0038.
2
+
3
+ The founder chose to implement withdrawal rather than to promise it: a public
4
+ promise with no executable path would be worse than neither. This is the path.
5
+
6
+ *An entry is addressed by its bundle digest.* That is how the log addresses a
7
+ run — ``/runs/sha256:…`` — so it is what a person copies off the page they are
8
+ looking at, and it works whether or not the run is still on this machine.
9
+
10
+ *The request is signed with the key that signed the run.* The identity store
11
+ holds it and it is the only signing key this machine has. The network verifies
12
+ it against the participant key inside the publication it already accepted, which
13
+ is why nothing here sends a public key: a key that travelled with the request
14
+ would be a key the requester chose.
15
+
16
+ *There is no reason field.* Nothing a submitter writes appears on the site, and
17
+ a free-text reason attached to a public entry would be the one string that did.
18
+
19
+ *Withdrawn is not deleted.* The entry stays where it is, marked, and what comes
20
+ back names where it still lives. The wording says that rather than implying an
21
+ erasure this release does not perform.
22
+
23
+ Somebody has to say so. Withdrawal changes a public page, so it is asked for the
24
+ same way publishing is asked for, and where nobody can be asked the command
25
+ stops and names the flag instead of deciding on their behalf.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Annotated, Final
31
+
32
+ import typer
33
+ from rich.console import Console
34
+
35
+ from techtree.canonical import validate_digest
36
+ from techtree.cli.confirm import confirmed
37
+ from techtree.cli.context import CliContext, cli_context
38
+ from techtree.cli.invoke import CommandResult, invoke_command
39
+ from techtree.cli.output import human_console, render_pairs
40
+ from techtree.drafts.store import utc_now
41
+ from techtree.errors import UsageError
42
+ from techtree.identity.service import IdentityService
43
+ from techtree.identity.store import IdentityStore
44
+ from techtree.models.base import Digest, NonEmptyString, ProtocolModel, UtcDateTime
45
+ from techtree.models.cli import CliMessage, MessageLevel
46
+ from techtree.publication.coordinates import packaged_publication_coordinates
47
+ from techtree.publication.transport import (
48
+ HttpsPublicationTransport,
49
+ resolved_endpoint,
50
+ )
51
+ from techtree.publication.withdraw import WithdrawalService
52
+
53
+ __all__ = [
54
+ "WITHDRAWAL_CONFIRMATION_REQUIRED",
55
+ "WITHDRAW_COMMAND",
56
+ "WithdrawalPayload",
57
+ "build_withdrawal_service",
58
+ "withdraw_run_command",
59
+ "withdrawal_review_lines",
60
+ ]
61
+
62
+ WITHDRAW_COMMAND: Final = "withdraw"
63
+
64
+ #: Stable error code for "nobody said to withdraw this".
65
+ WITHDRAWAL_CONFIRMATION_REQUIRED: Final = "withdrawal_confirmation_required"
66
+
67
+ _WITHDRAW_PROMPT: Final = "Withdraw this entry from the public log?"
68
+
69
+ #: What withdrawal is and is not, said where somebody is deciding. It is the
70
+ #: honest claim rather than the comfortable one: the entry stays, marked, and
71
+ #: nothing about backups is implied.
72
+ _WHAT_WITHDRAWAL_DOES: Final = (
73
+ "Withdrawing marks the entry withdrawn. It is not a deletion: the entry "
74
+ "stays at the address it already has, the log records the withdrawal as "
75
+ "another event, and anyone who already has the proof still has it."
76
+ )
77
+
78
+
79
+ class WithdrawalPayload(ProtocolModel):
80
+ """What ``techtree withdraw`` returns once the log has marked an entry."""
81
+
82
+ bundle_digest: Digest
83
+ endpoint: NonEmptyString
84
+ entry_url: NonEmptyString
85
+ withdrawn_at: UtcDateTime
86
+ #: The participant key the request was signed with. It is the identity the
87
+ #: log checked against the publication it accepted, so a person can see that
88
+ #: the entry was withdrawn by the identity that published it.
89
+ key_id: NonEmptyString
90
+
91
+
92
+ def withdraw_run_command(
93
+ ctx: typer.Context,
94
+ bundle_digest: Annotated[
95
+ str,
96
+ typer.Argument(
97
+ metavar="BUNDLE_DIGEST",
98
+ help="The published entry's bundle digest, as the log addresses it.",
99
+ ),
100
+ ],
101
+ yes: Annotated[
102
+ bool,
103
+ typer.Option(
104
+ "--yes",
105
+ help=(
106
+ "Withdraw without being asked. For an operator running Techtree "
107
+ "where nobody can answer a prompt."
108
+ ),
109
+ ),
110
+ ] = False,
111
+ ) -> None:
112
+ """Withdraw a published run from the public run log."""
113
+ context = cli_context(ctx)
114
+
115
+ def action() -> CommandResult[WithdrawalPayload]:
116
+ digest = validate_digest(bundle_digest)
117
+ service = build_withdrawal_service(context)
118
+ if not yes:
119
+ _require_withdrawal_confirmation(context, digest, service.endpoint)
120
+
121
+ outcome = service.withdraw(digest)
122
+ payload = WithdrawalPayload(
123
+ bundle_digest=outcome.bundle_digest,
124
+ endpoint=service.endpoint,
125
+ entry_url=outcome.entry_url,
126
+ withdrawn_at=outcome.withdrawn_at,
127
+ key_id=outcome.key_id,
128
+ )
129
+ return CommandResult(
130
+ data=payload,
131
+ messages=[
132
+ CliMessage(
133
+ level=MessageLevel.INFO,
134
+ code="entry_withdrawn",
135
+ text=(
136
+ f"The entry at {payload.entry_url} is marked withdrawn. "
137
+ "It stays where it is: the log appended the withdrawal "
138
+ "rather than removing anything."
139
+ ),
140
+ )
141
+ ],
142
+ )
143
+
144
+ invoke_command(context, WITHDRAW_COMMAND, action, render_data=_render)
145
+
146
+
147
+ def build_withdrawal_service(context: CliContext) -> WithdrawalService:
148
+ """Construct the service this command withdraws through.
149
+
150
+ The same pinned coordinates and the same development override publishing
151
+ uses, and for the same reasons — one address for the whole of this feature,
152
+ and one key whose countersignature is worth having.
153
+ """
154
+ coordinates = packaged_publication_coordinates()
155
+ return WithdrawalService(
156
+ coordinates=coordinates,
157
+ endpoint=resolved_endpoint(coordinates, context.settings.publication_endpoint),
158
+ identity=IdentityService(IdentityStore(context.paths)),
159
+ transport=HttpsPublicationTransport(),
160
+ clock=utc_now,
161
+ )
162
+
163
+
164
+ def _require_withdrawal_confirmation(
165
+ context: CliContext, bundle_digest: Digest, endpoint: str
166
+ ) -> None:
167
+ """Show what would happen, take the answer, or send nothing."""
168
+ if context.no_input:
169
+ raise UsageError(
170
+ "withdrawing changes a public page, so somebody has to agree to "
171
+ "it. Nothing here can be asked, so say so with --yes",
172
+ code=WITHDRAWAL_CONFIRMATION_REQUIRED,
173
+ details={"bundle_digest": bundle_digest},
174
+ )
175
+
176
+ console = human_console(no_color=context.no_color)
177
+ for line in withdrawal_review_lines(bundle_digest, endpoint):
178
+ console.print(line)
179
+ console.print()
180
+ if not confirmed(_WITHDRAW_PROMPT):
181
+ raise UsageError(
182
+ f"entry {bundle_digest} was not withdrawn; no request left this machine",
183
+ code=WITHDRAWAL_CONFIRMATION_REQUIRED,
184
+ details={"bundle_digest": bundle_digest},
185
+ )
186
+
187
+
188
+ def withdrawal_review_lines(bundle_digest: Digest, endpoint: str) -> list[str]:
189
+ """Return what a person reads before they answer."""
190
+ return [
191
+ f"Withdrawing entry {bundle_digest}",
192
+ "",
193
+ f"A signed request goes to {endpoint}. It is signed with this "
194
+ "machine's own key, which is the key that signed the run.",
195
+ "",
196
+ _WHAT_WITHDRAWAL_DOES,
197
+ ]
198
+
199
+
200
+ def _render(data: object, console: Console) -> None:
201
+ if not isinstance(data, WithdrawalPayload):
202
+ return
203
+
204
+ render_pairs(
205
+ [
206
+ ("Entry", data.entry_url),
207
+ ("Proof", data.bundle_digest),
208
+ ("Withdrawn", data.withdrawn_at.isoformat()),
209
+ ("Signed by", data.key_id),
210
+ ],
211
+ console,
212
+ )
@@ -0,0 +1,47 @@
1
+ """Asking a person a yes-or-no question. Spec section 12.3.
2
+
3
+ Every mutation this CLI performs on somebody's behalf is confirmed before it
4
+ happens, and there are two ways that confirmation can fail to arrive. A person
5
+ can read the question and say no. Or nobody can be there to read it: the
6
+ command was piped from ``/dev/null``, run from a cron entry, or launched by a
7
+ host agent that gave it no terminal, and the prompt reaches end of input
8
+ without ever being seen. A person can also press ``Ctrl-C`` in the middle of
9
+ reading it, which is a third way of not answering.
10
+
11
+ All three are the same outcome, and it is a usage outcome rather than a defect.
12
+ Nothing went wrong inside the product: it asked, and it was not told to go
13
+ ahead. Reported as a defect — which is what an unhandled ``Abort`` escaping to
14
+ the CLI's error boundary becomes — an unanswerable prompt tells a caller that
15
+ Techtree is broken and gives it an exit code that means so, when the true
16
+ answer is that it needs a flag.
17
+
18
+ That is the whole of what lives here. This module asks the question and says
19
+ whether it was told to go ahead; what a "no" means, which error it is, and what
20
+ the person should be told belong to the command that had something to confirm,
21
+ because only that command knows what was about to happen.
22
+
23
+ The default is always no. A prompt whose default is yes turns a stray newline
24
+ into consent, and nothing in this product is worth confirming by accident.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import typer
30
+
31
+ __all__ = ["confirmed"]
32
+
33
+
34
+ def confirmed(question: str) -> bool:
35
+ """Ask ``question`` and return whether the answer was an explicit yes.
36
+
37
+ Returns ``False`` for every way of not being told to go ahead: a person who
38
+ typed no, a prompt that reached end of input because nothing was there to
39
+ answer it, and a person who interrupted it. A caller that must distinguish
40
+ "nobody can be asked" from "somebody said no" checks that before it asks —
41
+ ``CliContext.no_input`` is that check — because by the time a prompt has
42
+ been written there is nothing left to distinguish.
43
+ """
44
+ try:
45
+ return typer.confirm(question, default=False)
46
+ except typer.Abort:
47
+ return False
@@ -0,0 +1,96 @@
1
+ """What every command is allowed to assume. Spec section 12.1.
2
+
3
+ One object carries the four things a command needs before it can do anything:
4
+ where local state lives, what the user configured, whether the caller is a
5
+ person or a machine, and how much detail to put on stderr. It is built once, in
6
+ the root callback, and handed down through Typer's context.
7
+
8
+ Two rules are enforced here rather than repeated in every command.
9
+
10
+ Machine mode is derived, not merely requested. ``--json`` turns it on, and so
11
+ does an ``output_mode`` of ``json`` in settings, because a caller that has
12
+ configured machine output has already said which side of the boundary it is on.
13
+
14
+ Machine mode implies ``--no-input``. A prompt written to a host agent is a
15
+ hang, not a question, so the flags cannot disagree: asking for JSON is asking
16
+ for a CLI that never waits for a human.
17
+
18
+ No command-specific service is created here. This module knows about paths and
19
+ settings and nothing else.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ import typer
28
+
29
+ from techtree.errors import PrerequisiteError, TechtreeError
30
+ from techtree.paths import (
31
+ TechtreePaths,
32
+ default_paths,
33
+ ensure_path_layout,
34
+ paths_from_root,
35
+ )
36
+ from techtree.settings import Settings, resolved_settings
37
+
38
+ __all__ = ["CliContext", "build_cli_context", "cli_context"]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class CliContext:
43
+ """Everything a command may assume about its invocation."""
44
+
45
+ paths: TechtreePaths
46
+ settings: Settings
47
+ json_output: bool
48
+ no_color: bool
49
+ no_input: bool
50
+ debug: bool
51
+
52
+
53
+ def build_cli_context(
54
+ *,
55
+ home: Path | None,
56
+ json_output: bool,
57
+ no_color: bool,
58
+ no_input: bool,
59
+ debug: bool,
60
+ ) -> CliContext:
61
+ """Resolve paths, create layout, load settings, and return context."""
62
+ paths = (
63
+ default_paths() if home is None else paths_from_root(Path(home).expanduser())
64
+ )
65
+
66
+ try:
67
+ ensure_path_layout(paths)
68
+ except OSError as error:
69
+ raise PrerequisiteError(
70
+ f"cannot create the Techtree home directory {paths.root}: {error.strerror}",
71
+ code="techtree_home_unusable",
72
+ details={"path": str(paths.root)},
73
+ ) from error
74
+
75
+ settings = resolved_settings(paths)
76
+ machine = json_output or settings.output_mode == "json"
77
+
78
+ return CliContext(
79
+ paths=paths,
80
+ settings=settings,
81
+ json_output=machine,
82
+ no_color=no_color or machine,
83
+ no_input=no_input or machine,
84
+ debug=debug,
85
+ )
86
+
87
+
88
+ def cli_context(ctx: typer.Context) -> CliContext:
89
+ """Return the context the root callback attached to this invocation."""
90
+ context = ctx.obj
91
+ if not isinstance(context, CliContext):
92
+ raise TechtreeError(
93
+ "the CLI context was never built for this invocation",
94
+ code="internal_error",
95
+ )
96
+ return context
techtree/cli/invoke.py ADDED
@@ -0,0 +1,220 @@
1
+ """The error boundary every command runs inside. Spec section 12.3.
2
+
3
+ One invariant governs this module: an invocation that starts a command emits
4
+ exactly one envelope and exits with the code that envelope implies. ``ok`` and
5
+ the exit status never disagree — zero means ``ok`` is true, anything else means
6
+ ``ok`` is false and an error is present. A host agent can therefore branch on
7
+ the exit code alone and only parse output when it wants the detail.
8
+
9
+ Three kinds of outcome funnel through :func:`invoke_command`.
10
+
11
+ A command can succeed, and say what it produced and what could sensibly happen
12
+ next. A command can fail by raising a typed :class:`~techtree.errors.
13
+ TechtreeError`, which carries its own code, exit status, and repair actions —
14
+ the code that knows why something failed is the code that says what to do about
15
+ it. And a command can *diagnose* a failure while still having a complete answer
16
+ to give: Doctor finds a broken environment, and the checks it ran are the most
17
+ useful thing it could return. That is what ``CommandResult.error`` is for, and
18
+ it is why a failing envelope may still carry data.
19
+
20
+ Anything else escaping a command is a defect, not a user error. It becomes an
21
+ ``internal_error`` with a sanitized message and exit code 1; the traceback goes
22
+ to stderr under ``--debug``, never into the contract.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import traceback
28
+ from collections.abc import Callable, Sequence
29
+ from dataclasses import dataclass, field
30
+ from typing import NoReturn
31
+
32
+ import typer
33
+
34
+ from techtree.cli.context import CliContext
35
+ from techtree.cli.output import (
36
+ DataRenderer,
37
+ emit_envelope,
38
+ stderr_log,
39
+ write_envelope,
40
+ )
41
+ from techtree.constants import CLI_SCHEMA_VERSION
42
+ from techtree.errors import (
43
+ EXIT_ERROR,
44
+ EXIT_OK,
45
+ TechtreeError,
46
+ error_to_cli_error,
47
+ exit_code_for,
48
+ stable_exception_message,
49
+ )
50
+ from techtree.models.cli import MAX_NEXT_ACTIONS, CliEnvelope, CliMessage, NextAction
51
+
52
+ __all__ = [
53
+ "CommandResult",
54
+ "emit_boundary_failure",
55
+ "failure_envelope",
56
+ "invoke_command",
57
+ "not_implemented_error",
58
+ "success_envelope",
59
+ ]
60
+
61
+
62
+ @dataclass
63
+ class CommandResult[T]:
64
+ """What a command produced, in the CLI's own vocabulary.
65
+
66
+ ``error`` is set by a command that completed its work and still has to
67
+ report failure. Raising is the ordinary way to fail; this is for the case
68
+ where the diagnosis *is* the payload.
69
+ """
70
+
71
+ data: T
72
+ messages: list[CliMessage] = field(default_factory=list)
73
+ warnings: list[CliMessage] = field(default_factory=list)
74
+ next_actions: list[NextAction] = field(default_factory=list)
75
+ error: TechtreeError | None = None
76
+
77
+
78
+ def success_envelope[T](
79
+ *,
80
+ command: str,
81
+ data: T,
82
+ messages: list[CliMessage] | None = None,
83
+ warnings: list[CliMessage] | None = None,
84
+ next_actions: list[NextAction] | None = None,
85
+ ) -> CliEnvelope[T]:
86
+ """Construct a successful envelope."""
87
+ return CliEnvelope[T](
88
+ schema_version=CLI_SCHEMA_VERSION,
89
+ ok=True,
90
+ command=command,
91
+ data=data,
92
+ messages=list(messages or ()),
93
+ warnings=list(warnings or ()),
94
+ next_actions=_capped(next_actions or ()),
95
+ error=None,
96
+ )
97
+
98
+
99
+ def failure_envelope[T](
100
+ *,
101
+ command: str,
102
+ error: TechtreeError,
103
+ data: T | None = None,
104
+ messages: list[CliMessage] | None = None,
105
+ warnings: list[CliMessage] | None = None,
106
+ next_actions: list[NextAction] | None = None,
107
+ ) -> CliEnvelope[T]:
108
+ """Construct a failed envelope.
109
+
110
+ Repair actions default to the ones the error itself carries, so a raising
111
+ call site never has to reach the CLI to be helpful.
112
+ """
113
+ actions = error.next_actions if next_actions is None else next_actions
114
+ return CliEnvelope[T](
115
+ schema_version=CLI_SCHEMA_VERSION,
116
+ ok=False,
117
+ command=command,
118
+ data=data,
119
+ messages=list(messages or ()),
120
+ warnings=list(warnings or ()),
121
+ next_actions=_capped(actions),
122
+ error=error_to_cli_error(error),
123
+ )
124
+
125
+
126
+ def invoke_command[T](
127
+ context: CliContext,
128
+ command: str,
129
+ action: Callable[[], CommandResult[T]],
130
+ *,
131
+ render_data: DataRenderer | None = None,
132
+ ) -> NoReturn:
133
+ """Execute, emit exactly one envelope, and exit correctly."""
134
+ if context.debug:
135
+ stderr_log(f"techtree: running {command}")
136
+
137
+ envelope: CliEnvelope[T]
138
+ try:
139
+ result = action()
140
+ except TechtreeError as error:
141
+ envelope = failure_envelope(command=command, error=error)
142
+ exit_code = exit_code_for(error)
143
+ except Exception as unexpected:
144
+ if context.debug:
145
+ stderr_log(traceback.format_exc().rstrip())
146
+ envelope = failure_envelope(command=command, error=_internal(unexpected))
147
+ exit_code = EXIT_ERROR
148
+ else:
149
+ if result.error is None:
150
+ envelope = success_envelope(
151
+ command=command,
152
+ data=result.data,
153
+ messages=result.messages,
154
+ warnings=result.warnings,
155
+ next_actions=result.next_actions,
156
+ )
157
+ exit_code = EXIT_OK
158
+ else:
159
+ envelope = failure_envelope(
160
+ command=command,
161
+ error=result.error,
162
+ data=result.data,
163
+ messages=result.messages,
164
+ warnings=result.warnings,
165
+ next_actions=result.next_actions,
166
+ )
167
+ exit_code = exit_code_for(result.error)
168
+
169
+ emit_envelope(context, envelope, render_data=render_data)
170
+
171
+ if context.debug:
172
+ stderr_log(f"techtree: {command} exited with code {exit_code}")
173
+ raise typer.Exit(exit_code)
174
+
175
+
176
+ def emit_boundary_failure(
177
+ *,
178
+ json_output: bool,
179
+ no_color: bool,
180
+ command: str,
181
+ error: TechtreeError,
182
+ ) -> NoReturn:
183
+ """Report a failure that happened before any command could start."""
184
+ write_envelope(
185
+ failure_envelope(command=command, error=error),
186
+ json_output=json_output,
187
+ no_color=no_color,
188
+ )
189
+ raise typer.Exit(exit_code_for(error))
190
+
191
+
192
+ def not_implemented_error(command: str) -> TechtreeError:
193
+ """Return the error a registered but unbuilt command reports.
194
+
195
+ The command name is part of the CLI surface already, so a caller can
196
+ discover it, script it, and be told plainly that this build does not
197
+ implement it yet — which is more useful than the command not existing.
198
+ """
199
+ return TechtreeError(
200
+ f"`techtree {command}` is not implemented in this build",
201
+ code="not_implemented",
202
+ details={"command": command},
203
+ )
204
+
205
+
206
+ def _internal(error: Exception) -> TechtreeError:
207
+ return TechtreeError(
208
+ stable_exception_message(error),
209
+ code="internal_error",
210
+ details={"exception_type": type(error).__name__},
211
+ )
212
+
213
+
214
+ def _capped(actions: Sequence[NextAction]) -> list[NextAction]:
215
+ """Keep the highest-priority actions and drop the rest.
216
+
217
+ Callers order actions by usefulness. Truncating here means a caller can
218
+ offer everything it thought of without having to know the ceiling.
219
+ """
220
+ return list(actions[:MAX_NEXT_ACTIONS])