vidbyte-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 (269) hide show
  1. vidbyte_cli/__init__.py +11 -0
  2. vidbyte_cli/__main__.py +6 -0
  3. vidbyte_cli/cli.py +18 -0
  4. vidbyte_cli/commands/__init__.py +86 -0
  5. vidbyte_cli/commands/agent_options.py +53 -0
  6. vidbyte_cli/commands/agents/__init__.py +31 -0
  7. vidbyte_cli/commands/agents/suggestion/__init__.py +38 -0
  8. vidbyte_cli/commands/agents/suggestion/prompts/__init__.py +3 -0
  9. vidbyte_cli/commands/agents/suggestion/prompts/all_categories.md +5 -0
  10. vidbyte_cli/commands/agents/suggestion/prompts/approaches.md +100 -0
  11. vidbyte_cli/commands/agents/suggestion/prompts/avoid.md +100 -0
  12. vidbyte_cli/commands/agents/suggestion/prompts/blockers.md +100 -0
  13. vidbyte_cli/commands/agents/suggestion/prompts/capability.md +100 -0
  14. vidbyte_cli/commands/agents/suggestion/prompts/categories.md +5 -0
  15. vidbyte_cli/commands/agents/suggestion/prompts/category.md +5 -0
  16. vidbyte_cli/commands/agents/suggestion/prompts/completed.md +100 -0
  17. vidbyte_cli/commands/agents/suggestion/prompts/constraint.md +100 -0
  18. vidbyte_cli/commands/agents/suggestion/prompts/context.md +100 -0
  19. vidbyte_cli/commands/agents/suggestion/prompts/count.md +5 -0
  20. vidbyte_cli/commands/agents/suggestion/prompts/critic_model.md +100 -0
  21. vidbyte_cli/commands/agents/suggestion/prompts/decision.md +100 -0
  22. vidbyte_cli/commands/agents/suggestion/prompts/dry_run.md +5 -0
  23. vidbyte_cli/commands/agents/suggestion/prompts/extra_compute.md +5 -0
  24. vidbyte_cli/commands/agents/suggestion/prompts/feedback_accept.md +7 -0
  25. vidbyte_cli/commands/agents/suggestion/prompts/feedback_group.md +7 -0
  26. vidbyte_cli/commands/agents/suggestion/prompts/feedback_project.md +100 -0
  27. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reason.md +98 -0
  28. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reject.md +7 -0
  29. vidbyte_cli/commands/agents/suggestion/prompts/feedback_suggestion.md +101 -0
  30. vidbyte_cli/commands/agents/suggestion/prompts/files.md +100 -0
  31. vidbyte_cli/commands/agents/suggestion/prompts/forbidden.md +100 -0
  32. vidbyte_cli/commands/agents/suggestion/prompts/goal.md +100 -0
  33. vidbyte_cli/commands/agents/suggestion/prompts/handoff.md +7 -0
  34. vidbyte_cli/commands/agents/suggestion/prompts/horizon.md +5 -0
  35. vidbyte_cli/commands/agents/suggestion/prompts/hypotheses.md +100 -0
  36. vidbyte_cli/commands/agents/suggestion/prompts/idea.md +100 -0
  37. vidbyte_cli/commands/agents/suggestion/prompts/in_progress.md +100 -0
  38. vidbyte_cli/commands/agents/suggestion/prompts/input.md +100 -0
  39. vidbyte_cli/commands/agents/suggestion/prompts/library.py +29 -0
  40. vidbyte_cli/commands/agents/suggestion/prompts/max_messages.md +5 -0
  41. vidbyte_cli/commands/agents/suggestion/prompts/max_output_tokens.md +5 -0
  42. vidbyte_cli/commands/agents/suggestion/prompts/max_total_tokens.md +5 -0
  43. vidbyte_cli/commands/agents/suggestion/prompts/mistakes.md +100 -0
  44. vidbyte_cli/commands/agents/suggestion/prompts/outcomes.md +100 -0
  45. vidbyte_cli/commands/agents/suggestion/prompts/project.md +100 -0
  46. vidbyte_cli/commands/agents/suggestion/prompts/project_create.md +7 -0
  47. vidbyte_cli/commands/agents/suggestion/prompts/project_description.md +98 -0
  48. vidbyte_cli/commands/agents/suggestion/prompts/project_group.md +7 -0
  49. vidbyte_cli/commands/agents/suggestion/prompts/project_key.md +96 -0
  50. vidbyte_cli/commands/agents/suggestion/prompts/project_list.md +7 -0
  51. vidbyte_cli/commands/agents/suggestion/prompts/project_title.md +99 -0
  52. vidbyte_cli/commands/agents/suggestion/prompts/provider.md +5 -0
  53. vidbyte_cli/commands/agents/suggestion/prompts/question.md +100 -0
  54. vidbyte_cli/commands/agents/suggestion/prompts/risks.md +100 -0
  55. vidbyte_cli/commands/agents/suggestion/prompts/rounds.md +5 -0
  56. vidbyte_cli/commands/agents/suggestion/prompts/run.md +5 -0
  57. vidbyte_cli/commands/agents/suggestion/prompts/success.md +100 -0
  58. vidbyte_cli/commands/agents/suggestion/prompts/timeout.md +5 -0
  59. vidbyte_cli/commands/agents/suggestion/prompts/trajectory.md +100 -0
  60. vidbyte_cli/commands/agents/suggestion/prompts/view.md +5 -0
  61. vidbyte_cli/commands/agents/suggestion/prompts/view_all.md +5 -0
  62. vidbyte_cli/commands/agents/suggestion/render.py +77 -0
  63. vidbyte_cli/commands/agents/suggestion/request_builder.py +240 -0
  64. vidbyte_cli/commands/agents/suggestion/suggest.py +154 -0
  65. vidbyte_cli/commands/agents/suggestion/suggestion_categories.py +77 -0
  66. vidbyte_cli/commands/agents/suggestion/suggestion_feedback.py +85 -0
  67. vidbyte_cli/commands/agents/suggestion/suggestion_handoff.py +67 -0
  68. vidbyte_cli/commands/agents/suggestion/suggestion_projects.py +95 -0
  69. vidbyte_cli/commands/auth/__init__.py +0 -0
  70. vidbyte_cli/commands/auth/login.py +100 -0
  71. vidbyte_cli/commands/auth/logout.py +41 -0
  72. vidbyte_cli/commands/auth/whoami.py +51 -0
  73. vidbyte_cli/commands/billing/__init__.py +20 -0
  74. vidbyte_cli/commands/billing/prompts/__init__.py +1 -0
  75. vidbyte_cli/commands/billing/prompts/confirm.md +16 -0
  76. vidbyte_cli/commands/billing/prompts/group.md +13 -0
  77. vidbyte_cli/commands/billing/prompts/idempotency_key.md +16 -0
  78. vidbyte_cli/commands/billing/prompts/library.py +18 -0
  79. vidbyte_cli/commands/billing/prompts/top_up.md +16 -0
  80. vidbyte_cli/commands/billing/top_up.py +65 -0
  81. vidbyte_cli/commands/config/__init__.py +0 -0
  82. vidbyte_cli/commands/config/get.py +45 -0
  83. vidbyte_cli/commands/config/set.py +39 -0
  84. vidbyte_cli/commands/provider/__init__.py +9 -0
  85. vidbyte_cli/commands/provider/login.py +102 -0
  86. vidbyte_cli/commands/provider/logout.py +45 -0
  87. vidbyte_cli/commands/provider/whoami.py +49 -0
  88. vidbyte_cli/commands/research/__init__.py +5 -0
  89. vidbyte_cli/commands/research/add.py +41 -0
  90. vidbyte_cli/commands/research/options.py +134 -0
  91. vidbyte_cli/commands/research/render.py +157 -0
  92. vidbyte_cli/commands/research/resume.py +37 -0
  93. vidbyte_cli/commands/research/start.py +37 -0
  94. vidbyte_cli/commands/research/status.py +32 -0
  95. vidbyte_cli/commands/research/thread.py +30 -0
  96. vidbyte_cli/commands/research/threads.py +35 -0
  97. vidbyte_cli/commands/research/watch.py +93 -0
  98. vidbyte_cli/commands/runtime/__init__.py +23 -0
  99. vidbyte_cli/commands/runtime/adversarial_team.py +41 -0
  100. vidbyte_cli/commands/runtime/doctor.py +44 -0
  101. vidbyte_cli/commands/runtime/list.py +47 -0
  102. vidbyte_cli/commands/runtime/persistence.py +141 -0
  103. vidbyte_cli/commands/runtime/same_host_ensemble.py +221 -0
  104. vidbyte_cli/commands/runtime/stages.py +553 -0
  105. vidbyte_cli/commands/runtime/task_board.py +1342 -0
  106. vidbyte_cli/commands/setup/__init__.py +0 -0
  107. vidbyte_cli/commands/setup/doctor.py +64 -0
  108. vidbyte_cli/commands/setup/prompts/__init__.py +1 -0
  109. vidbyte_cli/commands/setup/prompts/browser.md +7 -0
  110. vidbyte_cli/commands/setup/prompts/library.py +18 -0
  111. vidbyte_cli/commands/setup/prompts/star.md +13 -0
  112. vidbyte_cli/commands/setup/star.py +132 -0
  113. vidbyte_cli/lib/__init__.py +0 -0
  114. vidbyte_cli/lib/api/__init__.py +0 -0
  115. vidbyte_cli/lib/api/client.py +236 -0
  116. vidbyte_cli/lib/api/endpoints/__init__.py +0 -0
  117. vidbyte_cli/lib/api/endpoints/auth.py +24 -0
  118. vidbyte_cli/lib/api/endpoints/billing.py +36 -0
  119. vidbyte_cli/lib/api/endpoints/research.py +103 -0
  120. vidbyte_cli/lib/api/endpoints/runtime.py +113 -0
  121. vidbyte_cli/lib/api/problem.py +86 -0
  122. vidbyte_cli/lib/api/response.py +101 -0
  123. vidbyte_cli/lib/api/retry.py +116 -0
  124. vidbyte_cli/lib/api/runtime_payment.py +89 -0
  125. vidbyte_cli/lib/auth/__init__.py +16 -0
  126. vidbyte_cli/lib/auth/credentials.py +223 -0
  127. vidbyte_cli/lib/auth/input.py +59 -0
  128. vidbyte_cli/lib/auth/keyring_store.py +126 -0
  129. vidbyte_cli/lib/auth/provider_credentials.py +157 -0
  130. vidbyte_cli/lib/auth/provider_input.py +54 -0
  131. vidbyte_cli/lib/auth/provider_resolver.py +89 -0
  132. vidbyte_cli/lib/auth/provider_store.py +149 -0
  133. vidbyte_cli/lib/auth/provider_verifier.py +219 -0
  134. vidbyte_cli/lib/auth/resolver.py +63 -0
  135. vidbyte_cli/lib/auth/verifier.py +38 -0
  136. vidbyte_cli/lib/config/__init__.py +25 -0
  137. vidbyte_cli/lib/config/atomic.py +47 -0
  138. vidbyte_cli/lib/config/config.py +141 -0
  139. vidbyte_cli/lib/config/migration.py +81 -0
  140. vidbyte_cli/lib/config/models.py +145 -0
  141. vidbyte_cli/lib/config/paths.py +66 -0
  142. vidbyte_cli/lib/config/resolver.py +137 -0
  143. vidbyte_cli/lib/constants/__init__.py +1 -0
  144. vidbyte_cli/lib/constants/runtime.py +186 -0
  145. vidbyte_cli/lib/errors/__init__.py +12 -0
  146. vidbyte_cli/lib/errors/cli_error.py +86 -0
  147. vidbyte_cli/lib/errors/codes.py +43 -0
  148. vidbyte_cli/lib/errors/failures.py +2910 -0
  149. vidbyte_cli/lib/errors/handler.py +72 -0
  150. vidbyte_cli/lib/files/__init__.py +6 -0
  151. vidbyte_cli/lib/files/documents.py +94 -0
  152. vidbyte_cli/lib/files/store.py +156 -0
  153. vidbyte_cli/lib/io/__init__.py +16 -0
  154. vidbyte_cli/lib/io/attachments.py +129 -0
  155. vidbyte_cli/lib/io/codex_attachments.py +47 -0
  156. vidbyte_cli/lib/io/prompt.py +71 -0
  157. vidbyte_cli/lib/io/streams.py +37 -0
  158. vidbyte_cli/lib/io/terminal.py +66 -0
  159. vidbyte_cli/lib/output/__init__.py +7 -0
  160. vidbyte_cli/lib/output/formats.py +29 -0
  161. vidbyte_cli/lib/output/manager.py +95 -0
  162. vidbyte_cli/lib/output/models.py +49 -0
  163. vidbyte_cli/lib/runtime/__init__.py +9 -0
  164. vidbyte_cli/lib/runtime/application.py +134 -0
  165. vidbyte_cli/lib/runtime/context.py +250 -0
  166. vidbyte_cli/lib/runtime/options.py +159 -0
  167. vidbyte_cli/lib/runtime/version.py +30 -0
  168. vidbyte_cli/lib/runtime_primitives/__init__.py +28 -0
  169. vidbyte_cli/lib/runtime_primitives/executor.py +54 -0
  170. vidbyte_cli/lib/runtime_primitives/gate.py +105 -0
  171. vidbyte_cli/lib/runtime_primitives/hosts.py +45 -0
  172. vidbyte_cli/lib/runtime_primitives/planner.py +79 -0
  173. vidbyte_cli/lib/runtime_primitives/stages.py +338 -0
  174. vidbyte_cli/lib/runtime_primitives/task_board.py +982 -0
  175. vidbyte_cli/lib/runtime_primitives/task_board_checkpoints.py +473 -0
  176. vidbyte_cli/lib/runtime_primitives/task_board_system.md +33 -0
  177. vidbyte_cli/lib/runtime_primitives/task_board_tasks.py +111 -0
  178. vidbyte_cli/lib/runtime_primitives/verification.py +68 -0
  179. vidbyte_cli/services/__init__.py +5 -0
  180. vidbyte_cli/services/ensemble/__init__.py +7 -0
  181. vidbyte_cli/services/ensemble/prompts/__init__.py +6 -0
  182. vidbyte_cli/services/ensemble/prompts/implementer_system.md +546 -0
  183. vidbyte_cli/services/ensemble/prompts/implementer_turn.md +49 -0
  184. vidbyte_cli/services/ensemble/prompts/library.py +188 -0
  185. vidbyte_cli/services/ensemble/prompts/planner_system.md +728 -0
  186. vidbyte_cli/services/ensemble/prompts/planner_turn.md +6 -0
  187. vidbyte_cli/services/ensemble/prompts/role_system.md +104 -0
  188. vidbyte_cli/services/ensemble/prompts/role_turn.md +19 -0
  189. vidbyte_cli/services/ensemble/prompts/selector_final_turn.md +19 -0
  190. vidbyte_cli/services/ensemble/prompts/selector_round_turn.md +17 -0
  191. vidbyte_cli/services/ensemble/prompts/selector_system.md +89 -0
  192. vidbyte_cli/services/ensemble/runner.py +30 -0
  193. vidbyte_cli/services/ensemble/sdk.py +134 -0
  194. vidbyte_cli/services/ensemble/service.py +233 -0
  195. vidbyte_cli/services/ensemble/settings.py +60 -0
  196. vidbyte_cli/services/persistence/__init__.py +6 -0
  197. vidbyte_cli/services/persistence/prompts/__init__.py +6 -0
  198. vidbyte_cli/services/persistence/prompts/library.py +46 -0
  199. vidbyte_cli/services/persistence/prompts/persistence_system.md +1 -0
  200. vidbyte_cli/services/persistence/prompts/persistence_turn.md +3 -0
  201. vidbyte_cli/services/persistence/runner.py +35 -0
  202. vidbyte_cli/services/persistence/session.py +116 -0
  203. vidbyte_cli/services/suggestions/__init__.py +7 -0
  204. vidbyte_cli/services/suggestions/categories.py +270 -0
  205. vidbyte_cli/services/suggestions/context.py +222 -0
  206. vidbyte_cli/services/suggestions/extra_compute.py +53 -0
  207. vidbyte_cli/services/suggestions/handoff.py +173 -0
  208. vidbyte_cli/services/suggestions/message_tools.py +47 -0
  209. vidbyte_cli/services/suggestions/project.py +253 -0
  210. vidbyte_cli/services/suggestions/prompts/__init__.py +1 -0
  211. vidbyte_cli/services/suggestions/prompts/categories/__init__.py +3 -0
  212. vidbyte_cli/services/suggestions/prompts/categories/adjacent_opportunity.md +37 -0
  213. vidbyte_cli/services/suggestions/prompts/categories/alternative.md +38 -0
  214. vidbyte_cli/services/suggestions/prompts/categories/big_bets.md +37 -0
  215. vidbyte_cli/services/suggestions/prompts/categories/bottleneck.md +38 -0
  216. vidbyte_cli/services/suggestions/prompts/categories/business_growth.md +38 -0
  217. vidbyte_cli/services/suggestions/prompts/categories/completion.md +37 -0
  218. vidbyte_cli/services/suggestions/prompts/categories/continuation.md +37 -0
  219. vidbyte_cli/services/suggestions/prompts/categories/coordination.md +37 -0
  220. vidbyte_cli/services/suggestions/prompts/categories/creative_exploration.md +37 -0
  221. vidbyte_cli/services/suggestions/prompts/categories/cross_domain.md +37 -0
  222. vidbyte_cli/services/suggestions/prompts/categories/delegation.md +37 -0
  223. vidbyte_cli/services/suggestions/prompts/categories/experiment.md +37 -0
  224. vidbyte_cli/services/suggestions/prompts/categories/feedback.md +37 -0
  225. vidbyte_cli/services/suggestions/prompts/categories/goal_clarification.md +37 -0
  226. vidbyte_cli/services/suggestions/prompts/categories/immediate_next_steps.md +37 -0
  227. vidbyte_cli/services/suggestions/prompts/categories/investigation.md +37 -0
  228. vidbyte_cli/services/suggestions/prompts/categories/learning.md +37 -0
  229. vidbyte_cli/services/suggestions/prompts/categories/leverage.md +37 -0
  230. vidbyte_cli/services/suggestions/prompts/categories/long_term_directions.md +37 -0
  231. vidbyte_cli/services/suggestions/prompts/categories/preparation.md +37 -0
  232. vidbyte_cli/services/suggestions/prompts/categories/prerequisite.md +37 -0
  233. vidbyte_cli/services/suggestions/prompts/categories/prioritization.md +37 -0
  234. vidbyte_cli/services/suggestions/prompts/categories/product_experience.md +37 -0
  235. vidbyte_cli/services/suggestions/prompts/categories/quick_wins.md +37 -0
  236. vidbyte_cli/services/suggestions/prompts/categories/reframing.md +37 -0
  237. vidbyte_cli/services/suggestions/prompts/categories/risk_prevention.md +37 -0
  238. vidbyte_cli/services/suggestions/prompts/categories/simplification.md +37 -0
  239. vidbyte_cli/services/suggestions/prompts/categories/stop_or_defer.md +37 -0
  240. vidbyte_cli/services/suggestions/prompts/categories/strategy.md +37 -0
  241. vidbyte_cli/services/suggestions/prompts/categories/technical_possibilities.md +37 -0
  242. vidbyte_cli/services/suggestions/prompts/categories/verification.md +37 -0
  243. vidbyte_cli/services/suggestions/prompts/critic.md +209 -0
  244. vidbyte_cli/services/suggestions/prompts/critic_message.md +8 -0
  245. vidbyte_cli/services/suggestions/prompts/generator.md +64 -0
  246. vidbyte_cli/services/suggestions/prompts/library.py +72 -0
  247. vidbyte_cli/services/suggestions/prompts/message_generator_argument.md +1 -0
  248. vidbyte_cli/services/suggestions/prompts/message_generator_tool.md +1 -0
  249. vidbyte_cli/services/suggestions/prompts/message_parent_argument.md +1 -0
  250. vidbyte_cli/services/suggestions/prompts/message_parent_tool.md +1 -0
  251. vidbyte_cli/services/suggestions/prompts/message_receipt.md +1 -0
  252. vidbyte_cli/services/suggestions/prompts/revision.md +8 -0
  253. vidbyte_cli/services/suggestions/sdk.py +228 -0
  254. vidbyte_cli/services/suggestions/selection.py +78 -0
  255. vidbyte_cli/services/suggestions/service.py +540 -0
  256. vidbyte_cli/types/__init__.py +0 -0
  257. vidbyte_cli/types/api.py +101 -0
  258. vidbyte_cli/types/attachments.py +79 -0
  259. vidbyte_cli/types/ensemble.py +189 -0
  260. vidbyte_cli/types/provider.py +62 -0
  261. vidbyte_cli/types/research.py +260 -0
  262. vidbyte_cli/types/runtime.py +1098 -0
  263. vidbyte_cli/types/suggestions.py +518 -0
  264. vidbyte_cli-0.1.0.dist-info/METADATA +335 -0
  265. vidbyte_cli-0.1.0.dist-info/RECORD +269 -0
  266. vidbyte_cli-0.1.0.dist-info/WHEEL +5 -0
  267. vidbyte_cli-0.1.0.dist-info/entry_points.txt +2 -0
  268. vidbyte_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  269. vidbyte_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2910 @@
1
+ """One CliError subclass per failure the platform can raise, prose included.
2
+
3
+ This is where the agent-native `description` and `trace` text lives. Keeping it here rather
4
+ than at the raise site means a caller writes `raise EmptyPrompt()` in one line, two commands
5
+ that fail the same way cannot drift apart, and the wording is reviewable in one place.
6
+
7
+ Nothing in this file may embed a secret: `description` and `trace` are static authored text
8
+ and must stay independent of credentials, prompt bodies, and backend response content.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pydantic import JsonValue
14
+
15
+ from .cli_error import CliError
16
+ from .codes import CliErrorCode, ExitCode
17
+
18
+
19
+ class PersistenceHostFailed(CliError):
20
+ """The local Codex process did not complete a valid turn."""
21
+
22
+ code = CliErrorCode.OPERATION_FAILED
23
+ exit_status = ExitCode.OPERATIONAL_FAILURE
24
+
25
+ def __init__(self) -> None:
26
+ # Host diagnostics can contain secrets or task text, so this message is static.
27
+ super().__init__(
28
+ "Codex did not complete the persistence turn.",
29
+ description=(
30
+ "Codex failed, timed out, or returned an incomplete or mismatched conversation. "
31
+ "Continuation stopped immediately. The admission may already have been charged "
32
+ "and completed local work remains in the working directory."
33
+ ),
34
+ trace="PersistentCodexSession checked the SDK turn status and conversation identity.",
35
+ hint="Inspect local Codex session history and configuration before retrying.",
36
+ )
37
+
38
+
39
+ class TaskBoardHostFailed(CliError):
40
+ """The local Codex process did not complete a valid board task turn."""
41
+
42
+ code = CliErrorCode.OPERATION_FAILED
43
+ exit_status = ExitCode.OPERATIONAL_FAILURE
44
+
45
+ def __init__(self) -> None:
46
+ # Host diagnostics can contain secrets or task text, so this message is static.
47
+ super().__init__(
48
+ "Codex did not complete the task-board turn.",
49
+ description=(
50
+ "Codex failed, timed out, or returned an incomplete board task turn. "
51
+ "The board stopped or marked the task failed per its stop-on-error policy. "
52
+ "The admission may already have been charged and completed local work remains."
53
+ ),
54
+ trace="TaskBoardCodexSession checked the SDK turn status and thread identity.",
55
+ hint="Inspect local Codex session history and configuration before retrying.",
56
+ )
57
+
58
+
59
+ class TaskBoardInputInvalid(CliError):
60
+ """The board was given neither exactly one task source nor a usable one."""
61
+
62
+ code = CliErrorCode.INVALID_ARGUMENT
63
+ exit_status = ExitCode.USAGE
64
+
65
+ def __init__(self) -> None:
66
+ # Reports only the accepted shapes, never the submitted task text or file contents.
67
+ super().__init__(
68
+ "Supply either literal task arguments or one or more --task-file paths, not both.",
69
+ description=(
70
+ "A task board is built from exactly one kind of source: literal task strings "
71
+ "passed as arguments, or Markdown files where each whole file is one task. "
72
+ "Mixing the two, or supplying neither, leaves the board's order ambiguous, so "
73
+ "the invocation was rejected before credentials, payment, or host execution. "
74
+ "Re-run with only positional tasks, or only repeated --task-file paths."
75
+ ),
76
+ trace="TaskBoardCommand resolved the board source before building a launch plan.",
77
+ hint="Repeat --task-file once per task file; each file is one whole task.",
78
+ )
79
+
80
+
81
+ class TaskBoardTaskFileInvalid(CliError):
82
+ """One supplied task file is not a readable, non-empty Markdown file."""
83
+
84
+ code = CliErrorCode.INVALID_ARGUMENT
85
+ exit_status = ExitCode.USAGE
86
+
87
+ def __init__(self) -> None:
88
+ # File contents are task text, so neither the path nor the body is echoed here.
89
+ super().__init__(
90
+ "Every --task-file must be a readable, non-empty .md file.",
91
+ description=(
92
+ "A task file carries one whole task, so it has to be a Markdown file with "
93
+ "content: a non-.md suffix, an unreadable file, or a file that is empty after "
94
+ "trimming cannot become a board entry. The board was rejected before "
95
+ "credentials, payment, or host execution, and no file path or body is repeated "
96
+ "in this error. Check the suffix and contents of each --task-file and retry."
97
+ ),
98
+ trace="TaskBoardCommand read each task file while resolving the board source.",
99
+ hint="One task per .md file; use positional arguments for short literal tasks.",
100
+ )
101
+
102
+
103
+ class TaskBoardCheckpointMissing(CliError):
104
+ """A resume asked for steps whose checkpoint files are absent from disk."""
105
+
106
+ code = CliErrorCode.INVALID_ARGUMENT
107
+ exit_status = ExitCode.USAGE
108
+
109
+ def __init__(self, index: int) -> None:
110
+ # Names only the step number; task text and directory layout stay out of prose.
111
+ super().__init__(
112
+ f"No checkpoint exists for board step {index}.",
113
+ description=(
114
+ "Resuming reuses stored steps instead of re-running them, so every step "
115
+ f"before the resume point must exist on disk and step {index} has no file. "
116
+ "The board ran nothing: no admission was bought and no agent started. "
117
+ "Resume from an earlier step, or run the board fresh without --from."
118
+ ),
119
+ trace="TaskBoardCheckpointer.load_prefix read the step files in order.",
120
+ hint="Check .vidbyte/task-board for the board directory matching this run.",
121
+ )
122
+
123
+
124
+ class TaskBoardCheckpointMismatch(CliError):
125
+ """Stored checkpoints do not match the board or settings of this invocation."""
126
+
127
+ code = CliErrorCode.INVALID_ARGUMENT
128
+ exit_status = ExitCode.USAGE
129
+
130
+ def __init__(self, detail: str) -> None:
131
+ # Carries only a fixed mismatch category, never task text or file bodies.
132
+ super().__init__(
133
+ "Stored checkpoints do not match this board invocation.",
134
+ description=(
135
+ "A resume or replay replays stored prompts exactly, so the task list and "
136
+ f"the window, context, and summary settings must match the stored run: {detail} "
137
+ "differs. The board ran nothing: no admission was bought and no agent "
138
+ "started. Re-run with the original board and settings, or start fresh."
139
+ ),
140
+ trace="TaskBoardCheckpointer.validate_manifest compared the stored manifest.",
141
+ hint="Use --checkpoint-id to resume a different stored board explicitly.",
142
+ )
143
+
144
+
145
+ class TaskBoardBoardNotFound(CliError):
146
+ """No checkpointed board with the requested id exists under the checkpoint root."""
147
+
148
+ code = CliErrorCode.INVALID_ARGUMENT
149
+ exit_status = ExitCode.USAGE
150
+
151
+ def __init__(self, board_id: str) -> None:
152
+ # Names only the requested id; the stored task text never enters an error message.
153
+ super().__init__(
154
+ f"No checkpointed board {board_id} exists under this checkpoint root.",
155
+ description=(
156
+ "Reading a board loads its board.json manifest from the directory named by "
157
+ f"the board id, and no readable manifest was found for {board_id}. Nothing "
158
+ "was read, written, or charged. List the boards under this root first, or "
159
+ "pass --checkpoint-root if the board was saved somewhere other than the "
160
+ "default directory."
161
+ ),
162
+ trace="TaskBoardCheckpointer.read_manifest resolved the board directory.",
163
+ hint="Run `runtime task-board list` to see every stored board and its path.",
164
+ )
165
+
166
+
167
+ class TaskBoardTaskListInvalid(CliError):
168
+ """A board task-list file is missing, unreadable, or not a supported board format."""
169
+
170
+ code = CliErrorCode.INVALID_ARGUMENT
171
+ exit_status = ExitCode.USAGE
172
+
173
+ def __init__(self) -> None:
174
+ # File contents are task text, so neither the path nor the body is echoed here.
175
+ super().__init__(
176
+ "A board task list must be a readable .md file of level-two sections or a "
177
+ ".json array of task strings.",
178
+ description=(
179
+ "A task list carries a whole board in one reviewable file, so it has to be "
180
+ "one of exactly two shapes: Markdown whose level-two headings separate the "
181
+ "tasks, or JSON holding a flat array of non-empty strings. The file was "
182
+ "unreadable, had an unsupported suffix, held a different JSON shape, or "
183
+ "produced no tasks at all. No board was built and nothing was charged."
184
+ ),
185
+ trace="TaskBoardTaskFiles.read_task_list parsed the supplied board file.",
186
+ hint="Export an existing board with `runtime task-board export-tasks` to see "
187
+ "the expected shape.",
188
+ )
189
+
190
+
191
+ class TaskBoardBoardIdInvalid(CliError):
192
+ """A board id is not a single safe directory name inside the checkpoint root."""
193
+
194
+ code = CliErrorCode.INVALID_ARGUMENT
195
+ exit_status = ExitCode.USAGE
196
+
197
+ def __init__(self, option: str) -> None:
198
+ # Names the option, never the rejected value, which may be an arbitrary path.
199
+ super().__init__(
200
+ f"{option} must be 1 to 64 letters, digits, dots, underscores, or hyphens, and "
201
+ "not only dots.",
202
+ description=(
203
+ "A board id names exactly one directory directly inside the checkpoint root, "
204
+ "so it may not contain a path separator or be made only of dots, which would "
205
+ f"address a directory outside that root. The value given for {option} broke "
206
+ "that rule, so nothing was read, written, or charged. Copy the id from a run "
207
+ "result or from `runtime task-board list` instead of passing a path."
208
+ ),
209
+ trace="TaskBoardCommand checked the board id before building any board directory.",
210
+ hint="Pass the directory to --checkpoint-root and only the board's name as the id.",
211
+ )
212
+
213
+
214
+ class TaskBoardCheckpointRootUnreadable(CliError):
215
+ """The checkpoint root cannot be resolved or scanned for boards."""
216
+
217
+ code = CliErrorCode.OPERATION_FAILED
218
+ exit_status = ExitCode.OPERATIONAL_FAILURE
219
+
220
+ def __init__(self, reason: str) -> None:
221
+ # The caller passed the root themselves, so the option name is enough to act on.
222
+ super().__init__(
223
+ f"The checkpoint root could not be read: {reason}.",
224
+ description=(
225
+ "Every board lives in a subdirectory of the checkpoint root, so a root that "
226
+ f"cannot be resolved or listed hides every board inside it; here {reason}. "
227
+ "Nothing was read, written, or charged. Retrying unchanged fails the same way; "
228
+ "fix the directory's permissions or pass a different --checkpoint-root."
229
+ ),
230
+ trace="TaskBoardCommand resolved --checkpoint-root and scanned it for boards.",
231
+ hint="Pass --checkpoint-root pointing at the readable directory holding your boards.",
232
+ )
233
+
234
+
235
+ class TaskBoardManifestUnreadable(CliError):
236
+ """A board directory holds a board.json manifest that cannot be used."""
237
+
238
+ code = CliErrorCode.OPERATION_FAILED
239
+ exit_status = ExitCode.OPERATIONAL_FAILURE
240
+
241
+ def __init__(self, board_id: str, reason: str) -> None:
242
+ # Names the board and a fixed reason category; the manifest body is task text.
243
+ super().__init__(
244
+ f"Board {board_id} has an unusable board.json manifest: {reason}.",
245
+ description=(
246
+ "The manifest is what makes a board addressable by id: it records the task "
247
+ f"list and the settings every stored prompt depends on, and here {reason}. "
248
+ "Nothing was changed and nothing was charged, and the step files beside it "
249
+ "are untouched. Fix the file's permissions if that is the reason; a corrupt "
250
+ "or pre-version-2 manifest cannot be resumed, so start a fresh board instead."
251
+ ),
252
+ trace="TaskBoardCheckpointer.read_manifest loaded board.json from the board directory.",
253
+ hint="Fix board.json permissions, or rerun the board under a new --checkpoint-id.",
254
+ )
255
+
256
+
257
+ class TaskBoardStepUnreadable(CliError):
258
+ """A stored step file exists but cannot be read or does not hold a valid step record."""
259
+
260
+ code = CliErrorCode.OPERATION_FAILED
261
+ exit_status = ExitCode.OPERATIONAL_FAILURE
262
+
263
+ def __init__(self, board_id: str, index: int, reason: str) -> None:
264
+ # Names the board, the step number, and a fixed reason; never the stored prompt.
265
+ super().__init__(
266
+ f"Board {board_id} step {index} is stored but unreadable: {reason}.",
267
+ description=(
268
+ f"step-{index}.json exists in the board directory, but {reason}, so it can "
269
+ "neither be reported nor reused as context for later steps. Nothing was "
270
+ "changed or charged. Fix the file's permissions if that is the reason; "
271
+ "otherwise re-run just that step with `runtime task-board run "
272
+ f"--checkpoint-id {board_id} --replay-task {index}`, which rewrites the file "
273
+ "and buys one admission."
274
+ ),
275
+ trace="TaskBoardCheckpointer.read_step read and validated one step file.",
276
+ hint=f"Replay step {index} to rebuild its checkpoint file.",
277
+ )
278
+
279
+
280
+ class TaskBoardStepNotStored(CliError):
281
+ """show-step asked for a step index that has no checkpoint file on this board."""
282
+
283
+ code = CliErrorCode.INVALID_ARGUMENT
284
+ exit_status = ExitCode.USAGE
285
+
286
+ def __init__(self, board_id: str, index: int, task_count: int) -> None:
287
+ # Tells an out-of-range index apart from a step that simply has not run yet.
288
+ where = (
289
+ f"the board only has {task_count} tasks, so valid indices are 0 to {task_count - 1}"
290
+ if index >= task_count
291
+ else "that step has not run yet, or its file was removed"
292
+ )
293
+ super().__init__(
294
+ f"Board {board_id} has no stored step {index}.",
295
+ description=(
296
+ f"show-step reads step-{index}.json from the board directory and that file "
297
+ f"does not exist: {where}. Nothing was read, written, or charged. Run "
298
+ "`runtime task-board status` to see the completed and failed indices that do "
299
+ "have stored records, and pass one of those to --index."
300
+ ),
301
+ trace="TaskBoardCommand.execute_show_step read one step file from the board.",
302
+ hint="Run `runtime task-board status --checkpoint-id` first to list stored indices.",
303
+ )
304
+
305
+
306
+ class TaskBoardForkTargetExists(CliError):
307
+ """fork was asked to write into a board id that already has a directory."""
308
+
309
+ code = CliErrorCode.INVALID_ARGUMENT
310
+ exit_status = ExitCode.USAGE
311
+
312
+ def __init__(self, into: str) -> None:
313
+ # Refusing is the whole point: a fork over a board would destroy that board's history.
314
+ super().__init__(
315
+ f"Board {into} already exists under this checkpoint root.",
316
+ description=(
317
+ "A fork writes a copied prefix and a new manifest into its own directory, and "
318
+ f"a directory named {into} is already there. Writing over it would destroy "
319
+ "whatever history it holds, so nothing was copied, written, or charged. Pick "
320
+ "an unused --into id, or remove that directory yourself if it is a leftover."
321
+ ),
322
+ trace="TaskBoardCommand.execute_fork checked the target directory before copying.",
323
+ hint="Run `runtime task-board list` to see which board ids are already taken.",
324
+ )
325
+
326
+
327
+ class TaskBoardForkPrefixIncomplete(CliError):
328
+ """fork --at asks for more leading steps than the source board has stored."""
329
+
330
+ code = CliErrorCode.INVALID_ARGUMENT
331
+ exit_status = ExitCode.USAGE
332
+
333
+ def __init__(
334
+ self, board_id: str, at_index: int, available: int, task_count: int, missing: int
335
+ ) -> None:
336
+ # `available` is the length of the unbroken stored prefix, i.e. the largest valid --at;
337
+ # `missing` is the board index of the first absent step, which differs on a DAG board.
338
+ where = (
339
+ f"the board only has {task_count} tasks"
340
+ if at_index > task_count
341
+ else f"step {missing} has no stored checkpoint"
342
+ )
343
+ super().__init__(
344
+ f"Board {board_id} cannot be forked at {at_index}: {where}.",
345
+ description=(
346
+ "A fork copies the first --at steps in the order the board runs them, which is "
347
+ "board order unless the board is a DAG, and every one of them must "
348
+ f"already be stored, but {where}. Nothing was copied, written, or charged, so "
349
+ "no partial board was created. The largest --at this board supports right "
350
+ f"now is {available}; fork there, or finish the board first."
351
+ ),
352
+ trace="TaskBoardCheckpointer.fork_into checked the stored prefix before copying.",
353
+ hint=f"Retry with --at {available} or lower.",
354
+ )
355
+
356
+
357
+ class TaskBoardForkFailed(CliError):
358
+ """Copying a fork's prefix or writing its manifest failed on the filesystem."""
359
+
360
+ code = CliErrorCode.OPERATION_FAILED
361
+ exit_status = ExitCode.OPERATIONAL_FAILURE
362
+
363
+ def __init__(self, into: str, reason: str) -> None:
364
+ # The manifest is written last, so a failure leaves a directory no verb treats as a board.
365
+ super().__init__(
366
+ f"Forking into board {into} failed: {reason}.",
367
+ description=(
368
+ f"The copy stopped partway because {reason}. The source board is untouched, "
369
+ f"but the {into} directory may now hold copied step files with no manifest, "
370
+ "which list ignores and which blocks reusing that id. Nothing was charged. "
371
+ f"Fix the cause, delete the {into} directory, and run the same fork again."
372
+ ),
373
+ trace="TaskBoardCheckpointer.fork_into copied step files, then wrote the manifest.",
374
+ hint=f"Delete the partial {into} directory before retrying the same --into.",
375
+ )
376
+
377
+
378
+ class TaskBoardOutputWriteFailed(CliError):
379
+ """A file a task-board verb was asked to write could not be written."""
380
+
381
+ code = CliErrorCode.OPERATION_FAILED
382
+ exit_status = ExitCode.OPERATIONAL_FAILURE
383
+
384
+ def __init__(self, option: str, reason: str) -> None:
385
+ # Names the option that chose the destination, since that is what the caller changes.
386
+ super().__init__(
387
+ f"The file named by {option} could not be written: {reason}.",
388
+ description=(
389
+ f"The board itself was read successfully, but writing the {option} "
390
+ f"destination failed because {reason}. The write goes through a temporary "
391
+ "sibling, so any previous file at that path is intact. Nothing was charged; "
392
+ f"pass a {option} path inside a writable directory and run the command again."
393
+ ),
394
+ trace="TaskBoardCommand rendered the output and handed it to LocalFileStore.",
395
+ hint=f"Choose a {option} path in a directory you can write to.",
396
+ )
397
+
398
+
399
+ class TaskBoardExportDestinationInvalid(CliError):
400
+ """export-tasks was given a destination whose suffix names no supported format."""
401
+
402
+ code = CliErrorCode.INVALID_ARGUMENT
403
+ exit_status = ExitCode.USAGE
404
+
405
+ def __init__(self) -> None:
406
+ # The suffix picks the format, so an unknown suffix has no well-defined output.
407
+ super().__init__(
408
+ "The export-tasks destination must end in .md, .markdown, or .json.",
409
+ description=(
410
+ "The destination's suffix chooses the format: Markdown gets one level-two "
411
+ "section per task and JSON gets a flat array of task strings, and both reload "
412
+ "through --task-list. Any other suffix names a format this command cannot "
413
+ "write, so nothing was written and nothing was charged. Rename the --to path."
414
+ ),
415
+ trace="TaskBoardTaskFiles.write_task_list chose a format from the suffix.",
416
+ hint="Use --to board.md or --to board.json.",
417
+ )
418
+
419
+
420
+ class TaskBoardDependencyInvalid(CliError):
421
+ """One supplied dependency link is malformed or the link set is not a DAG."""
422
+
423
+ code = CliErrorCode.INVALID_ARGUMENT
424
+ exit_status = ExitCode.USAGE
425
+
426
+ def __init__(self) -> None:
427
+ # Reports only the accepted grammar, never the submitted indices or task text.
428
+ super().__init__(
429
+ "Dependency links must be CHILD:PARENT pairs forming an acyclic graph.",
430
+ description=(
431
+ "A link names 0-based board positions as CHILD:PARENT with comma-separated "
432
+ "parents, so --depends-on 8:2 means task 8 reads task 2 and runs after it. "
433
+ "Links need --type dag, must stay inside the board, allow no self-links, "
434
+ "duplicates, or cycles, and are rejected before credentials, payment, or "
435
+ "host execution. The board order and task text are never repeated here."
436
+ ),
437
+ trace="TaskBoardCommand parsed dependency links before building a launch plan.",
438
+ hint="Repeat --depends-on once per link, e.g. --type dag --depends-on 8:2.",
439
+ )
440
+
441
+
442
+ class TaskBoardDecomposeInvalid(CliError):
443
+ """Decomposition was combined with a feature that requires stable board indices."""
444
+
445
+ code = CliErrorCode.INVALID_ARGUMENT
446
+ exit_status = ExitCode.USAGE
447
+
448
+ def __init__(self, conflict: str) -> None:
449
+ # Names the conflicting option without repeating task text or other local input.
450
+ super().__init__(
451
+ f"--allow-decompose cannot be combined with {conflict}.",
452
+ description=(
453
+ "Decomposition replaces one task with subtasks at the same index, so every "
454
+ "later task shifts while the board runs. Checkpoints store steps by board "
455
+ "index and DAG links name board indices, so neither can describe a board that "
456
+ f"reshapes itself, and {conflict} was requested. The flags were rejected before "
457
+ "credentials, payment, or host execution, so nothing was charged. Checkpointing "
458
+ "is on by default, so a plain --allow-decompose run hits this until "
459
+ "--no-checkpoint is added as well. A decomposing board therefore runs as linear "
460
+ "and unchecked: it cannot be resumed, repaired, replayed, or forked afterwards, "
461
+ "so keep --allow-decompose off for any board you may need to continue later."
462
+ ),
463
+ trace="TaskBoardCommand validated decompose flags before building a launch plan.",
464
+ hint="Re-run with --allow-decompose --no-checkpoint and without --type dag.",
465
+ )
466
+
467
+
468
+ class InvalidCommandUsage(CliError):
469
+ """Click rejected the invocation before any command body ran."""
470
+
471
+ code = CliErrorCode.INVALID_ARGUMENT
472
+ exit_status = ExitCode.USAGE
473
+
474
+ def __init__(self, message: str, command_path: str, exit_code: int, cause: Exception) -> None:
475
+ super().__init__(
476
+ message,
477
+ description=(
478
+ "The command line did not match the declared command tree, so parsing stopped "
479
+ "before any command ran. The wording comes from Click and names only parser "
480
+ "values, never user data. No backend request was made and no local state "
481
+ "changed. Correct the invocation and run it again."
482
+ ),
483
+ trace=(
484
+ "CliApplication.run built the command tree and delegated to Click, which "
485
+ "rejected the arguments while resolving the command and its parameters."
486
+ ),
487
+ exit_code=exit_code,
488
+ hint=f"Run '{command_path} --help' for usage.",
489
+ cause=cause,
490
+ )
491
+
492
+
493
+ class ConflictingOutputFormat(CliError):
494
+ """`--json` was combined with an incompatible `--format` value."""
495
+
496
+ code = CliErrorCode.INVALID_ARGUMENT
497
+ exit_status = ExitCode.USAGE
498
+
499
+ def __init__(self) -> None:
500
+ super().__init__(
501
+ "--json conflicts with the selected --format value.",
502
+ description=(
503
+ "`--json` is exactly an alias for `--format json`, so pairing it with any other "
504
+ "format asks for two different result encodings on one stream. The CLI refuses "
505
+ "rather than silently picking a winner, because a machine consumer cannot "
506
+ "detect the substitution. Pass one of the two flags."
507
+ ),
508
+ trace=(
509
+ "CliApplication.run resolved root output policy through "
510
+ "RootOptionInspector/_resolve_output_format before constructing any service."
511
+ ),
512
+ hint="Remove --json or use --format json.",
513
+ )
514
+
515
+
516
+ class OperationInterrupted(CliError):
517
+ """The invocation was cancelled by the user or an abort."""
518
+
519
+ code = CliErrorCode.INTERRUPTED
520
+ exit_status = ExitCode.INTERRUPTED
521
+
522
+ def __init__(self) -> None:
523
+ super().__init__(
524
+ "Operation interrupted.",
525
+ description=(
526
+ "Ctrl-C or a Click abort ended the invocation before it completed. Work already "
527
+ "submitted to the Vidbyte backend is not cancelled by this signal and may still "
528
+ "be running. Status 130 is the conventional shell code for an interrupt, so a "
529
+ "caller can tell cancellation apart from failure."
530
+ ),
531
+ trace=(
532
+ "The interrupt reached the CliApplication.run boundary from wherever the "
533
+ "invocation was waiting — argument parsing, prompting, or command execution."
534
+ ),
535
+ )
536
+
537
+
538
+ class UnexpectedInternalError(CliError):
539
+ """An unclassified exception reached the application boundary."""
540
+
541
+ code = CliErrorCode.INTERNAL_ERROR
542
+ exit_status = ExitCode.SOFTWARE
543
+
544
+ def __init__(self, cause: Exception | None) -> None:
545
+ super().__init__(
546
+ "An unexpected internal error occurred.",
547
+ description=(
548
+ "An exception escaped without being classified by the code that raised it, so "
549
+ "this is a CLI defect rather than an invocation mistake. The original exception "
550
+ "value is withheld because it may quote a credential, a prompt, or a backend "
551
+ "response body. The command's effect is unknown; verify state before retrying."
552
+ ),
553
+ trace=(
554
+ "ErrorHandler.handle received an exception that matched neither CliError, nor a "
555
+ "Click exception, nor an interrupt, at the CliApplication.run boundary."
556
+ ),
557
+ hint="Retry with --debug for a redacted stack trace.",
558
+ cause=cause,
559
+ )
560
+
561
+
562
+ class AuthenticationRequired(CliError):
563
+ """A command needing the Vidbyte API reached a machine with no stored credential."""
564
+
565
+ code = CliErrorCode.AUTH_REQUIRED
566
+ exit_status = ExitCode.AUTHENTICATION
567
+
568
+ def __init__(self) -> None:
569
+ super().__init__(
570
+ "Authentication is required.",
571
+ description=(
572
+ "The command needs a Vidbyte API key and the credential store held none for "
573
+ "this machine. Authentication is checked before repository inspection and "
574
+ "before any network call, so nothing was read or submitted. Log in once and "
575
+ "the key is reused by every later invocation."
576
+ ),
577
+ trace=(
578
+ "A command requiring the Vidbyte API — a research command through "
579
+ "ApplicationContext.api_client, or WhoamiCommand.execute directly — reached "
580
+ "CredentialResolver.resolve, which found no key in the environment, the "
581
+ "keyring, or the restricted file for this profile and host."
582
+ ),
583
+ hint="Run 'vidbyte-cli login' first.",
584
+ )
585
+
586
+
587
+ class RuntimeHostUnavailable(CliError):
588
+ """The selected native coding-agent executable is absent from PATH."""
589
+
590
+ code = CliErrorCode.CONFIG_INVALID
591
+ exit_status = ExitCode.OPERATIONAL_FAILURE
592
+
593
+ def __init__(self, host: str) -> None:
594
+ # Describes the missing executable without inspecting host configuration.
595
+ super().__init__(
596
+ f"No available local runtime host matched '{host}'.",
597
+ description=(
598
+ "The runtime requires an installed Codex, Claude Code, or OpenCode executable "
599
+ "and PATH discovery did not find the requested host. No host process started, "
600
+ "no Vidbyte request was sent, and no credits were spent. Install the host or "
601
+ "select one that runtime doctor reports as available."
602
+ ),
603
+ trace=(
604
+ "RuntimeLaunchPlanner asked RuntimeHostRegistry to resolve the requested "
605
+ "native host before paid admission."
606
+ ),
607
+ hint="Run 'vidbyte-cli runtime doctor' to inspect supported hosts.",
608
+ )
609
+
610
+
611
+ class RuntimeWorkingDirectoryInvalid(CliError):
612
+ """The current path cannot be used as a native agent working directory."""
613
+
614
+ code = CliErrorCode.CONFIG_INVALID
615
+ exit_status = ExitCode.OPERATIONAL_FAILURE
616
+
617
+ def __init__(self) -> None:
618
+ # Keeps the actual path out of failure prose because it may identify private projects.
619
+ super().__init__(
620
+ "The local runtime working directory is unavailable.",
621
+ description=(
622
+ "The current path did not resolve to a readable directory suitable for a native "
623
+ "agent process. The runtime stopped before authentication, payment, or process "
624
+ "creation, so no credits were spent and no local command ran. Change to the "
625
+ "intended project directory and retry."
626
+ ),
627
+ trace=(
628
+ "RuntimeLaunchPlanner validated the current working directory before "
629
+ "building a launch plan."
630
+ ),
631
+ )
632
+
633
+
634
+ class RuntimeTaskInvalid(CliError):
635
+ """The local runtime task is blank or above its bounded input limit."""
636
+
637
+ code = CliErrorCode.INVALID_ARGUMENT
638
+ exit_status = ExitCode.USAGE
639
+
640
+ def __init__(self) -> None:
641
+ # Reports only the contract, never the submitted task text.
642
+ super().__init__(
643
+ "The runtime task must contain 1 to 20,000 characters.",
644
+ description=(
645
+ "The supplied task was blank after trimming or exceeded the local runtime's "
646
+ "bounded input contract. It was rejected before credentials, payment, or host "
647
+ "execution, and the task is not repeated in this error. Shorten the task or "
648
+ "supply non-whitespace instructions and retry."
649
+ ),
650
+ trace=(
651
+ "RuntimeLaunchPlanner validated task length before selecting a host or "
652
+ "building a plan."
653
+ ),
654
+ )
655
+
656
+
657
+ class _AttachmentFailure(CliError):
658
+ """Shared safe envelope for explicit attachment validation failures."""
659
+
660
+ code = CliErrorCode.INVALID_ARGUMENT
661
+ exit_status = ExitCode.USAGE
662
+
663
+ def __init__(self, message: str, description: str, hint: str) -> None:
664
+ # Keeps attachment paths, bodies, and provider details out of public error fields.
665
+ super().__init__(
666
+ message,
667
+ description=description,
668
+ trace="AttachmentResolver validated explicit files before agent execution.",
669
+ hint=hint,
670
+ )
671
+
672
+
673
+ class AttachmentInputInvalid(_AttachmentFailure):
674
+ """The programmatic attachment option value was not a tuple of paths."""
675
+
676
+ def __init__(self) -> None:
677
+ # Tells library callers to pass the same shape Click produces.
678
+ super().__init__(
679
+ "Attachment values must be a tuple of paths.",
680
+ "The attachment option received a value outside its typed programmatic contract, "
681
+ "so resolution stopped before filesystem access or agent execution. Pass zero or "
682
+ "more pathlib.Path values as the command parser does. No credentials, payment, "
683
+ "or model call was started.",
684
+ "Use the repeatable --attach PATH option or pass a tuple of Path values.",
685
+ )
686
+
687
+
688
+ class AttachmentFileNotFound(_AttachmentFailure):
689
+ """An explicit attachment path does not exist."""
690
+
691
+ def __init__(self) -> None:
692
+ # Reports the repair without echoing a private path.
693
+ super().__init__(
694
+ "An attached file was not found.",
695
+ "One explicit attachment path did not resolve to an existing file, so the bundle "
696
+ "was rejected before any agent work began. The missing path is not repeated because "
697
+ "it may identify a private project. Check each --attach value and retry.",
698
+ "Check that every --attach path exists, then retry.",
699
+ )
700
+
701
+
702
+ class AttachmentDirectory(_AttachmentFailure):
703
+ """An explicit attachment path identifies a directory."""
704
+
705
+ def __init__(self) -> None:
706
+ # Directories are rejected because attachment expansion is intentionally explicit.
707
+ super().__init__(
708
+ "Attached paths must identify files, not directories.",
709
+ "An explicit attachment path identified a directory, and the shared resolver never "
710
+ "scans or expands directories implicitly. Resolution stopped before credentials, "
711
+ "payment, or model execution. Supply each file explicitly and retry.",
712
+ "Replace directory paths with explicit file paths.",
713
+ )
714
+
715
+
716
+ class AttachmentUnreadable(_AttachmentFailure):
717
+ """An explicit attachment could not be read."""
718
+
719
+ def __init__(self) -> None:
720
+ # Distinguishes filesystem access failure from unsupported file content.
721
+ super().__init__(
722
+ "An attached file could not be read.",
723
+ "The resolver could not read one explicit attachment because the path or its "
724
+ "permissions prevented access. The bundle was rejected before any agent work began, "
725
+ "and no file body is included in this error. Fix the local permissions or path and "
726
+ "retry.",
727
+ "Verify local read permissions for every --attach path.",
728
+ )
729
+
730
+
731
+ class AttachmentUnsupported(_AttachmentFailure):
732
+ """An attachment is neither a supported image nor UTF-8 text."""
733
+
734
+ def __init__(self) -> None:
735
+ # Rejects binary data instead of silently turning it into corrupted prompt text.
736
+ super().__init__(
737
+ "The attached file type is unsupported.",
738
+ "The resolver accepts UTF-8 text and the native local-image formats only. One "
739
+ "attachment was neither, so the bundle was rejected before agent execution rather "
740
+ "than being decoded incorrectly. Convert the file to a supported form and retry.",
741
+ "Use a UTF-8 text file or a supported local image.",
742
+ )
743
+
744
+
745
+ class AttachmentDuplicate(_AttachmentFailure):
746
+ """Two attachment arguments resolve to the same file."""
747
+
748
+ def __init__(self) -> None:
749
+ # Duplicate paths would make manifests and downstream context ambiguous.
750
+ super().__init__(
751
+ "The same file was attached more than once.",
752
+ "Two explicit attachment arguments resolved to one file, so the bundle was rejected "
753
+ "instead of silently changing its order or multiplying its context. No agent work "
754
+ "started. Remove the duplicate path and retry.",
755
+ "Pass each resolved file only once with --attach.",
756
+ )
757
+
758
+
759
+ class AttachmentEmpty(_AttachmentFailure):
760
+ """An attachment contains no bytes."""
761
+
762
+ def __init__(self) -> None:
763
+ # Empty context cannot provide evidence and usually indicates a caller mistake.
764
+ super().__init__(
765
+ "Attached files must not be empty.",
766
+ "One explicit attachment contained zero bytes, so it could not provide usable task "
767
+ "context. Resolution stopped before credentials, payment, or model execution. Add "
768
+ "content or remove the empty path and retry.",
769
+ "Attach a non-empty file or omit the empty path.",
770
+ )
771
+
772
+
773
+ class AttachmentTooLarge(_AttachmentFailure):
774
+ """One attachment exceeds the per-file byte boundary."""
775
+
776
+ def __init__(self) -> None:
777
+ # Enforces the per-file bound before decoding or provider work.
778
+ super().__init__(
779
+ "An attached file exceeds the per-file size limit.",
780
+ "One explicit attachment is larger than the shared per-file byte boundary, so the "
781
+ "resolver rejected it before decoding or agent execution. This protects prompt size "
782
+ "and keeps the input contract predictable. Split or reduce the file and retry.",
783
+ "Reduce the file below the per-file attachment limit and retry.",
784
+ )
785
+
786
+
787
+ class AttachmentLimitExceeded(_AttachmentFailure):
788
+ """The attachment bundle exceeds its file-count or total-byte boundary."""
789
+
790
+ def __init__(self) -> None:
791
+ # Enforces aggregate bounds before a future agent can multiply the context cost.
792
+ super().__init__(
793
+ "The attachment bundle exceeds its shared limit.",
794
+ "The explicit attachment set exceeded the maximum file count or total byte budget, "
795
+ "so resolution stopped before agent execution. Aggregate limits keep multi-agent "
796
+ "commands from multiplying an unbounded context snapshot. Remove files or reduce "
797
+ "their size and retry.",
798
+ "Attach fewer or smaller files and retry.",
799
+ )
800
+
801
+
802
+ class RuntimeAdmissionNotVerified(CliError):
803
+ """The layered admission gate did not admit the local runtime execution."""
804
+
805
+ code = CliErrorCode.OPERATION_FAILED
806
+ exit_status = ExitCode.OPERATIONAL_FAILURE
807
+
808
+ def __init__(self, reason: str | None = None) -> None:
809
+ # Reports only the safe reason category, never a grant token or raw key.
810
+ detail = f" Reason: {reason}." if reason else ""
811
+ super().__init__(
812
+ "Runtime admission is not verified.",
813
+ description=(
814
+ "The layered runtime gate validates a typed grant, a signed grant_token, and its "
815
+ f"expiry before any local agent is spawned.{detail} No agent ran and no additional "
816
+ "credits beyond the admission attempt were spent. Provide a valid grant for the "
817
+ "requested capability or retry admission with the same idempotency key if the "
818
+ "previous attempt succeeded."
819
+ ),
820
+ trace=(
821
+ "RuntimeAdmissionGate.verify was called with the launch plan and admission grant "
822
+ "before RuntimeExecutor could run, and at least one layer did not admit."
823
+ ),
824
+ )
825
+
826
+
827
+ class RuntimeExecutionNotImplemented(CliError):
828
+ """The command scaffold reached the deliberately absent runtime executor."""
829
+
830
+ code = CliErrorCode.NOT_IMPLEMENTED
831
+ exit_status = ExitCode.OPERATIONAL_FAILURE
832
+
833
+ def __init__(self) -> None:
834
+ # Makes the no-charge boundary explicit for human and agent callers.
835
+ super().__init__(
836
+ "The adversarial-team runtime executor is not implemented yet.",
837
+ description=(
838
+ "The CLI validated the task, working directory, and native host, but this release "
839
+ "contains only the runtime platform scaffold. It stopped before requesting a paid "
840
+ "Vidbyte admission and before launching any local agent, so no credits were spent "
841
+ "and no machine state changed. Retrying cannot proceed until the executor ships."
842
+ ),
843
+ trace=(
844
+ "AdversarialTeamCommand built a RuntimeLaunchPlan and reached the "
845
+ "intentionally inert RuntimeExecutor."
846
+ ),
847
+ hint="Use 'vidbyte-cli runtime list' for published capability metadata.",
848
+ )
849
+
850
+
851
+ class PersistenceExecutionNotImplemented(CliError):
852
+ """The command scaffold reached the deliberately absent persistence executor."""
853
+
854
+ code = CliErrorCode.NOT_IMPLEMENTED
855
+ exit_status = ExitCode.OPERATIONAL_FAILURE
856
+
857
+ def __init__(self) -> None:
858
+ # Makes the no-charge boundary explicit for human and agent callers.
859
+ super().__init__(
860
+ "The persistence runtime executor is not implemented yet.",
861
+ description=(
862
+ "The CLI validated the task, working directory, native host, and strength "
863
+ "tier, but this release contains only the runtime platform scaffold. It "
864
+ "stopped before requesting a paid Vidbyte admission and before launching any "
865
+ "local agent, so no credits were spent and no machine state changed. Retrying "
866
+ "cannot proceed until the executor ships."
867
+ ),
868
+ trace=(
869
+ "PersistenceCommand built a RuntimeLaunchPlan and PersistenceSettings, then "
870
+ "reached the intentionally inert RuntimeExecutor."
871
+ ),
872
+ hint="Use 'vidbyte-cli runtime list' for published capability metadata.",
873
+ )
874
+
875
+
876
+ class AmbiguousPromptSource(CliError):
877
+ """More than one prompt source was supplied for a single run."""
878
+
879
+ code = CliErrorCode.INVALID_ARGUMENT
880
+ exit_status = ExitCode.USAGE
881
+
882
+ def __init__(self) -> None:
883
+ super().__init__(
884
+ "Provide exactly one prompt source.",
885
+ description=(
886
+ "A positional prompt and --prompt-file were both supplied, and the CLI will not "
887
+ "guess which one the caller meant. Silently preferring one would make the same "
888
+ "command line mean different things across releases. Neither source was read, "
889
+ "so no file was opened and stdin was not consumed."
890
+ ),
891
+ trace=(
892
+ "The command adapter called PromptInputResolver.resolve, which checks source "
893
+ "exclusivity before touching any external input."
894
+ ),
895
+ hint="Use a positional prompt, --prompt-file PATH, or '-' for stdin.",
896
+ )
897
+
898
+
899
+ class MissingPrompt(CliError):
900
+ """No prompt source was supplied and prompting is not implied by redirection."""
901
+
902
+ code = CliErrorCode.INVALID_ARGUMENT
903
+ exit_status = ExitCode.USAGE
904
+
905
+ def __init__(self) -> None:
906
+ super().__init__(
907
+ "A prompt is required.",
908
+ description=(
909
+ "The command needs prompt text and none of the three accepted sources was "
910
+ "supplied. Stdin is read only when '-' is passed explicitly, so a command whose "
911
+ "input happens to be redirected fails here instead of hanging on a read that "
912
+ "will never complete. Nothing was submitted to the backend."
913
+ ),
914
+ trace=(
915
+ "The command adapter called PromptInputResolver.resolve, which found neither a "
916
+ "positional value nor --prompt-file."
917
+ ),
918
+ hint="Pass a positional prompt, --prompt-file PATH, or '-' for stdin.",
919
+ )
920
+
921
+
922
+ class PromptFileUnreadable(CliError):
923
+ """The path given to --prompt-file could not be opened or read."""
924
+
925
+ code = CliErrorCode.INVALID_ARGUMENT
926
+ exit_status = ExitCode.USAGE
927
+
928
+ def __init__(self, path: str, cause: Exception) -> None:
929
+ super().__init__(
930
+ f"Unable to read prompt file '{path}'.",
931
+ description=(
932
+ "The path passed to --prompt-file could not be opened for reading. It is "
933
+ "typically missing, a directory, or blocked by filesystem permissions. The "
934
+ "operating system's own message is withheld because it can expose unrelated "
935
+ "filesystem layout. Nothing was submitted to the backend."
936
+ ),
937
+ trace=(
938
+ "PromptInputResolver.resolve selected the file source and _read_file attempted "
939
+ "to open the path in UTF-8 text mode."
940
+ ),
941
+ hint="Check that the path exists and is a readable file.",
942
+ cause=cause,
943
+ )
944
+
945
+
946
+ class PromptFileNotUtf8(CliError):
947
+ """The prompt file exists but is not valid UTF-8 text."""
948
+
949
+ code = CliErrorCode.INVALID_ARGUMENT
950
+ exit_status = ExitCode.USAGE
951
+
952
+ def __init__(self, cause: Exception) -> None:
953
+ super().__init__(
954
+ "The prompt file must contain valid UTF-8 text.",
955
+ description=(
956
+ "The file opened but its bytes did not decode as UTF-8. The CLI pins UTF-8 "
957
+ "rather than following the host locale so the same file produces the same run "
958
+ "on every machine. Re-save the file as UTF-8, or pass the prompt positionally. "
959
+ "No decoded content is echoed, because the file may hold sensitive text."
960
+ ),
961
+ trace=(
962
+ "PromptInputResolver.resolve selected the file source and _read_file decoded "
963
+ "the opened handle as UTF-8."
964
+ ),
965
+ cause=cause,
966
+ )
967
+
968
+
969
+ class StandardInputNotText(CliError):
970
+ """Explicit stdin was requested but could not be decoded as text."""
971
+
972
+ code = CliErrorCode.INVALID_ARGUMENT
973
+ exit_status = ExitCode.USAGE
974
+
975
+ def __init__(self, cause: Exception) -> None:
976
+ super().__init__(
977
+ "Standard input must contain valid text.",
978
+ description=(
979
+ "'-' selected stdin as the prompt source, but the bytes arriving on that stream "
980
+ "did not decode as text. This usually means binary content was piped in, or the "
981
+ "producing process used an incompatible encoding. No content is echoed back, "
982
+ "because piped input may hold sensitive text."
983
+ ),
984
+ trace=(
985
+ "PromptInputResolver.resolve matched the '-' stdin marker and _read_stdin read "
986
+ "a bounded amount from the injected stdin stream."
987
+ ),
988
+ cause=cause,
989
+ )
990
+
991
+
992
+ class EmptyPrompt(CliError):
993
+ """The resolved prompt held no non-whitespace content."""
994
+
995
+ code = CliErrorCode.INVALID_ARGUMENT
996
+ exit_status = ExitCode.USAGE
997
+
998
+ def __init__(self) -> None:
999
+ super().__init__(
1000
+ "The prompt must not be empty.",
1001
+ description=(
1002
+ "The prompt source resolved successfully but contained only whitespace. An "
1003
+ "empty prompt would consume credits for a run that cannot produce a useful "
1004
+ "result, so it is rejected before submission. A file source that looks correct "
1005
+ "in an editor may be empty because it was written but never flushed."
1006
+ ),
1007
+ trace=(
1008
+ "PromptInputResolver.resolve read the selected source and passed the value to "
1009
+ "_validate, which checks content before length."
1010
+ ),
1011
+ )
1012
+
1013
+
1014
+ class PromptTooLong(CliError):
1015
+ """The resolved prompt exceeded the CLI's bounded input limit."""
1016
+
1017
+ code = CliErrorCode.INVALID_ARGUMENT
1018
+ exit_status = ExitCode.USAGE
1019
+
1020
+ def __init__(self, limit: int) -> None:
1021
+ super().__init__(
1022
+ f"The prompt exceeds the {limit}-character limit.",
1023
+ description=(
1024
+ f"The resolved prompt was longer than the {limit}-character bound the CLI reads "
1025
+ "from any source. The limit exists so a large or runaway file cannot be read "
1026
+ "into memory in full before validation. Only the length is reported; the prompt "
1027
+ "itself is never quoted back. Shorten the prompt or split the work into runs."
1028
+ ),
1029
+ trace=(
1030
+ "PromptInputResolver.resolve read one character past the limit from the selected "
1031
+ "source and passed the value to _validate."
1032
+ ),
1033
+ )
1034
+
1035
+
1036
+ class ConfigUnreadable(CliError):
1037
+ """The stored configuration file could not be parsed against the version-one schema."""
1038
+
1039
+ code = CliErrorCode.CONFIG_INVALID
1040
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1041
+
1042
+ def __init__(self, path: str, cause: Exception) -> None:
1043
+ super().__init__(
1044
+ "CLI configuration is invalid or uses an unsupported schema.",
1045
+ description=(
1046
+ "The configuration file exists but did not validate: it is unreadable, is not "
1047
+ "JSON, exceeds the size bound, or declares a schema version this release does "
1048
+ "not implement. The CLI never repairs or overwrites a file it cannot "
1049
+ "understand, so nothing was changed and no command ran."
1050
+ ),
1051
+ trace=(
1052
+ "ConfigStore.load selected the native or legacy configuration path and _read "
1053
+ "decoded its bytes and validated them as a ConfigDocument."
1054
+ ),
1055
+ hint=f"Repair or move the configuration file at {path}.",
1056
+ cause=cause,
1057
+ )
1058
+
1059
+
1060
+ class ConfigInvalidBeforeWrite(CliError):
1061
+ """The configuration on disk stopped validating between this command's read and write."""
1062
+
1063
+ code = CliErrorCode.CONFIG_INVALID
1064
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1065
+
1066
+ def __init__(self, path: str, cause: Exception) -> None:
1067
+ super().__init__(
1068
+ "CLI configuration changed to an invalid or unsupported schema.",
1069
+ description=(
1070
+ "The configuration validated when this command read it and no longer does, so "
1071
+ "another process or a manual edit replaced it mid-command. The write was "
1072
+ "abandoned before touching the file: the new content is intact and the setting "
1073
+ "you asked for was not applied."
1074
+ ),
1075
+ trace=(
1076
+ "ConfigStore.save called _native_digest to re-read the current file for "
1077
+ "conflict detection, and that re-read failed validation."
1078
+ ),
1079
+ hint=f"Repair or move the configuration file at {path}.",
1080
+ cause=cause,
1081
+ )
1082
+
1083
+
1084
+ class ConfigWriteConflict(CliError):
1085
+ """The configuration file changed after this command read it."""
1086
+
1087
+ code = CliErrorCode.CONFIG_WRITE_CONFLICT
1088
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1089
+ retryable = True
1090
+
1091
+ def __init__(self) -> None:
1092
+ super().__init__(
1093
+ "CLI configuration changed during this command.",
1094
+ description=(
1095
+ "Writing a setting is a read-modify-write, and the file no longer matches what "
1096
+ "this command read. Completing the write would silently discard whatever the "
1097
+ "other writer stored. Nothing was written, so the other change is intact and "
1098
+ "re-running against the current file is safe."
1099
+ ),
1100
+ trace=(
1101
+ "ConfigStore.save compared the digest captured by the caller's read against a "
1102
+ "fresh digest of the native configuration file and found them different."
1103
+ ),
1104
+ hint="Run the command again against the latest configuration.",
1105
+ )
1106
+
1107
+
1108
+ class InvalidConfigValue(CliError):
1109
+ """`config set` was given a value the field's schema rejects."""
1110
+
1111
+ code = CliErrorCode.INVALID_ARGUMENT
1112
+ exit_status = ExitCode.USAGE
1113
+
1114
+ def __init__(self, field: str, cause: Exception) -> None:
1115
+ super().__init__(
1116
+ f"Invalid value for configuration field '{field}'.",
1117
+ description=(
1118
+ f"The value supplied for '{field}' failed the same validation the persisted "
1119
+ "document uses, so accepting it would write a profile the CLI could not load "
1120
+ "back. Nothing was written. The underlying validation detail is withheld "
1121
+ "because it quotes the value, which may have been mistyped from a secret."
1122
+ ),
1123
+ trace=(
1124
+ "ConfigSetCommand.execute called ConfigStore.set, whose _updated_profile "
1125
+ "revalidated the whole profile with the new field value applied."
1126
+ ),
1127
+ hint="Run 'vidbyte-cli config get' to inspect the current profile value.",
1128
+ cause=cause,
1129
+ )
1130
+
1131
+
1132
+ class InvalidConfigOverride(CliError):
1133
+ """An environment or command-line configuration value could not be parsed."""
1134
+
1135
+ code = CliErrorCode.CONFIG_INVALID
1136
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1137
+
1138
+ def __init__(self, cause: Exception) -> None:
1139
+ super().__init__(
1140
+ "An environment or command configuration value is invalid.",
1141
+ description=(
1142
+ "Resolution combines command options, VIDBYTE_* variables, and the selected "
1143
+ "profile, and one supplied value did not parse into its declared type. The "
1144
+ "offending value is not echoed, because variables in the same namespace hold "
1145
+ "credentials. No command ran and no local state changed."
1146
+ ),
1147
+ trace=(
1148
+ "ConfigResolver.resolve applied command → environment → profile precedence per "
1149
+ "field and constructed the ResolvedConfig from the winning values."
1150
+ ),
1151
+ hint="Check VIDBYTE_* settings and the selected CLI profile.",
1152
+ cause=cause,
1153
+ )
1154
+
1155
+
1156
+ class StateWriteThroughSymlink(CliError):
1157
+ """A local state path the CLI was about to replace is a symbolic link."""
1158
+
1159
+ code = CliErrorCode.CONFIG_INVALID
1160
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1161
+
1162
+ def __init__(self) -> None:
1163
+ super().__init__(
1164
+ "Refusing to write CLI state through a symbolic link.",
1165
+ description=(
1166
+ "The target path is a symbolic link, and the CLI writes state by replacing a "
1167
+ "file atomically. Whether that replaces the link or the file it points at "
1168
+ "varies by platform, which is not a strong enough guarantee for a path that "
1169
+ "may hold a credential. Nothing was written and the link is untouched."
1170
+ ),
1171
+ trace=(
1172
+ "A store encoded its document and called AtomicFileWriter.write, which checks "
1173
+ "the destination for a symbolic link before creating its temporary sibling."
1174
+ ),
1175
+ hint="Replace the symbolic link with a regular file and retry.",
1176
+ )
1177
+
1178
+
1179
+ class StateWriteFailed(CliError):
1180
+ """The operating system refused a local state write."""
1181
+
1182
+ code = CliErrorCode.OPERATION_FAILED
1183
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1184
+ retryable = True
1185
+
1186
+ def __init__(self, cause: Exception) -> None:
1187
+ super().__init__(
1188
+ "CLI state could not be saved.",
1189
+ description=(
1190
+ "Creating, flushing, or replacing the state file failed at the filesystem "
1191
+ "level — typically permissions, a read-only or full volume, or a parent "
1192
+ "directory the CLI may not create. The temporary sibling was removed, so the "
1193
+ "previous file is intact and the operation had no partial effect."
1194
+ ),
1195
+ trace=(
1196
+ "AtomicFileWriter.write created a randomized sibling file, wrote and fsynced "
1197
+ "it, and attempted os.replace onto the destination."
1198
+ ),
1199
+ hint="Check directory ownership and available disk space, then retry.",
1200
+ cause=cause,
1201
+ )
1202
+
1203
+
1204
+ class LocalFileReadFailed(CliError):
1205
+ """A local file exists but could not be read, decoded, or parsed."""
1206
+
1207
+ code = CliErrorCode.OPERATION_FAILED
1208
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1209
+
1210
+ def __init__(self, name: str, reason: str, cause: Exception) -> None:
1211
+ # The reason is a fixed category chosen by LocalFileStore, never the OS message, so
1212
+ # callers can re-raise it with their own context without leaking absolute paths.
1213
+ super().__init__(
1214
+ f"Could not read {name}: {reason}.",
1215
+ description=(
1216
+ "The file exists, so this is not a missing-file case: opening, decoding, or "
1217
+ f"parsing it failed because {reason}. Nothing was changed on disk. Retrying "
1218
+ "without changing anything will fail the same way, so repair the file or its "
1219
+ "permissions first, or point the command at a different location."
1220
+ ),
1221
+ trace="LocalFileStore read the file as UTF-8 text and, for JSON, parsed it.",
1222
+ hint="Check the file's permissions and contents, then retry.",
1223
+ cause=cause,
1224
+ )
1225
+ self.reason = reason
1226
+
1227
+
1228
+ class LocalFileWriteFailed(CliError):
1229
+ """The operating system refused to create, replace, append to, or copy a local file."""
1230
+
1231
+ code = CliErrorCode.OPERATION_FAILED
1232
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1233
+
1234
+ def __init__(self, name: str, reason: str, cause: Exception) -> None:
1235
+ # Same fixed-category contract as LocalFileReadFailed, for the write direction.
1236
+ super().__init__(
1237
+ f"Could not write {name}: {reason}.",
1238
+ description=(
1239
+ f"Writing the file failed at the filesystem level because {reason}. Replacing "
1240
+ "writes go through a temporary sibling, so the previous version of the file, "
1241
+ "if any, is intact and no partial file was left behind. Retrying helps only "
1242
+ "after the cause is fixed, such as freeing space or choosing a writable path."
1243
+ ),
1244
+ trace="LocalFileStore created parent directories and wrote through a temp sibling.",
1245
+ hint="Check directory permissions and free space, or choose another destination.",
1246
+ cause=cause,
1247
+ )
1248
+ self.reason = reason
1249
+
1250
+
1251
+ class LocalDocumentInvalid(CliError):
1252
+ """A local JSON document was readable but did not satisfy the caller's document model."""
1253
+
1254
+ code = CliErrorCode.OPERATION_FAILED
1255
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1256
+
1257
+ def __init__(self, name: str, reason: str, cause: Exception | None = None) -> None:
1258
+ # Same fixed-category contract as LocalFileReadFailed: the reason is authored text chosen
1259
+ # by LocalDocumentStore, never a validator message that could quote document contents.
1260
+ super().__init__(
1261
+ f"Local document {name} is invalid: {reason}.",
1262
+ description=(
1263
+ "The file could be addressed, but its contents are not a document the command "
1264
+ f"can use because {reason}. Nothing was changed on disk, and the document was "
1265
+ "not replaced with an empty default. Retrying without repairing or removing the "
1266
+ "document will fail the same way."
1267
+ ),
1268
+ trace="LocalDocumentStore bounded, parsed, and validated the document against a model.",
1269
+ hint="Repair the document so it matches its schema, or remove it, then retry.",
1270
+ cause=cause,
1271
+ )
1272
+ self.reason = reason
1273
+
1274
+
1275
+ class MigrationVerificationFailed(CliError):
1276
+ """Legacy state was copied but the copy did not read back identically."""
1277
+
1278
+ code = CliErrorCode.OPERATION_FAILED
1279
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1280
+
1281
+ def __init__(self) -> None:
1282
+ super().__init__(
1283
+ "Legacy CLI state could not be verified after migration.",
1284
+ description=(
1285
+ "Migration copies each item and reads it back to prove the copy is identical, "
1286
+ "and one read-back disagreed. Migration never deletes, so the legacy tree under "
1287
+ "~/.vidbyte is exactly as it was. The native location may hold a partial copy, "
1288
+ "which later runs overwrite rather than trust."
1289
+ ),
1290
+ trace=(
1291
+ "StateMigration.migrate_if_needed copied the legacy configuration or the "
1292
+ "legacy credential and compared each destination against its source."
1293
+ ),
1294
+ hint="The legacy files were preserved; retry after checking local storage.",
1295
+ )
1296
+
1297
+
1298
+ class CredentialStoreUnavailable(CliError):
1299
+ """The operating-system keyring could not be used for this operation."""
1300
+
1301
+ code = CliErrorCode.CREDENTIAL_STORE_UNAVAILABLE
1302
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1303
+
1304
+ def __init__(self, cause: Exception | None = None) -> None:
1305
+ super().__init__(
1306
+ "The operating-system credential store is unavailable.",
1307
+ description=(
1308
+ "No keyring backend reported itself usable, or the backend rejected the "
1309
+ "operation — a locked keychain, a headless session with no agent, or a write "
1310
+ "that did not read back as written. The backend's own message is withheld "
1311
+ "because it can quote account identifiers. No credential was stored or read."
1312
+ ),
1313
+ trace=(
1314
+ "KeyringCredentialStore resolved the active backend and performed a scoped "
1315
+ "get, set, or delete against the 'vidbyte-cli' service."
1316
+ ),
1317
+ hint="Check the system keyring, or explicitly approve the restricted-file fallback.",
1318
+ cause=cause,
1319
+ )
1320
+
1321
+
1322
+ class NoApprovedCredentialStore(CliError):
1323
+ """No keyring exists and restricted-file storage was not approved."""
1324
+
1325
+ code = CliErrorCode.CREDENTIAL_STORE_UNAVAILABLE
1326
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1327
+
1328
+ def __init__(self) -> None:
1329
+ super().__init__(
1330
+ "No usable OS credential store is available.",
1331
+ description=(
1332
+ "The key verified successfully but has nowhere approved to live. This machine "
1333
+ "exposes no working keyring, and the restricted-file fallback keeps the key "
1334
+ "readable to your account, so the CLI will not select it on your behalf. "
1335
+ "Nothing was written; the verified key was discarded with this process."
1336
+ ),
1337
+ trace=(
1338
+ "LoginCommand.execute verified the credential and called CredentialStore.write, "
1339
+ "which found no available keyring and no file-fallback consent."
1340
+ ),
1341
+ hint="Retry login and explicitly approve the restricted-file fallback.",
1342
+ )
1343
+
1344
+
1345
+ class StoredCredentialUnreadable(CliError):
1346
+ """The restricted fallback credential file could not be parsed."""
1347
+
1348
+ code = CliErrorCode.CREDENTIAL_STORE_UNAVAILABLE
1349
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1350
+
1351
+ def __init__(self, cause: Exception) -> None:
1352
+ super().__init__(
1353
+ "Stored CLI credentials are invalid or unavailable.",
1354
+ description=(
1355
+ "The fallback credential file exists but is unreadable, is not JSON, exceeds "
1356
+ "the size bound, or declares an unsupported schema version. The CLI will not "
1357
+ "rewrite a credential file it cannot understand, because that could destroy a "
1358
+ "key it merely failed to parse. No credential was resolved."
1359
+ ),
1360
+ trace=(
1361
+ "FileCredentialStore._load read the restricted credential file and validated "
1362
+ "it as a version-one CredentialDocument."
1363
+ ),
1364
+ hint="Move the fallback credential file and run 'vidbyte-cli login' again.",
1365
+ cause=cause,
1366
+ )
1367
+
1368
+
1369
+ class LegacyCredentialUnreadable(CliError):
1370
+ """The ~/.vidbyte credential file exists but could not be validated."""
1371
+
1372
+ code = CliErrorCode.CREDENTIAL_STORE_UNAVAILABLE
1373
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1374
+
1375
+ def __init__(self, cause: Exception) -> None:
1376
+ super().__init__(
1377
+ "Legacy CLI credentials are invalid or unavailable.",
1378
+ description=(
1379
+ "A credential file remains at the historical ~/.vidbyte location and did not "
1380
+ "validate, so it can be neither read nor migrated. It is reported rather than "
1381
+ "skipped, because ignoring it would leave a file that looks like a working "
1382
+ "login. It was not modified or removed."
1383
+ ),
1384
+ trace=(
1385
+ "A credential read or migration reached the legacy credential path and decoded "
1386
+ "its bytes as a Credentials document."
1387
+ ),
1388
+ hint="Run 'vidbyte-cli login' to replace the stored credential.",
1389
+ cause=cause,
1390
+ )
1391
+
1392
+
1393
+ class LegacyCredentialSymlink(CliError):
1394
+ """Logout was asked to remove a legacy credential file that is a symbolic link."""
1395
+
1396
+ code = CliErrorCode.CREDENTIAL_STORE_UNAVAILABLE
1397
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1398
+
1399
+ def __init__(self) -> None:
1400
+ super().__init__(
1401
+ "Refusing to remove legacy credentials through a symbolic link.",
1402
+ description=(
1403
+ "Logout deletes the legacy credential file so a stale key cannot become active "
1404
+ "again, but that path is a symbolic link: unlinking it would remove the link "
1405
+ "and leave whatever it points at. The CLI will not guess which one was meant. "
1406
+ "The scoped keyring and fallback entries were still cleared."
1407
+ ),
1408
+ trace=(
1409
+ "LogoutCommand.execute called CredentialStore.clear, whose clear_legacy step "
1410
+ "checked the legacy credential path before unlinking it."
1411
+ ),
1412
+ hint="Remove the symbolic link manually after verifying its target.",
1413
+ )
1414
+
1415
+
1416
+ class LegacyCredentialRemovalFailed(CliError):
1417
+ """The legacy credential file could not be deleted during logout."""
1418
+
1419
+ code = CliErrorCode.CREDENTIAL_STORE_UNAVAILABLE
1420
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1421
+ retryable = True
1422
+
1423
+ def __init__(self, cause: Exception) -> None:
1424
+ super().__init__(
1425
+ "Legacy credentials could not be removed.",
1426
+ description=(
1427
+ "The legacy credential file exists and the filesystem refused to unlink it, "
1428
+ "usually because of directory permissions or another process holding it open. "
1429
+ "This is a failure rather than a skip, because the file can still authenticate "
1430
+ "later invocations. Scoped credentials were already cleared."
1431
+ ),
1432
+ trace=(
1433
+ "LogoutCommand.execute called CredentialStore.clear, whose clear_legacy step "
1434
+ "attempted to unlink the legacy credential path."
1435
+ ),
1436
+ hint="Check the legacy file permissions and retry logout.",
1437
+ cause=cause,
1438
+ )
1439
+
1440
+
1441
+ class InvalidEnvironmentApiKey(CliError):
1442
+ """VIDBYTE_API_KEY is set but holds nothing usable."""
1443
+
1444
+ code = CliErrorCode.AUTH_REQUIRED
1445
+ exit_status = ExitCode.AUTHENTICATION
1446
+
1447
+ def __init__(self) -> None:
1448
+ super().__init__(
1449
+ "VIDBYTE_API_KEY is empty or invalid.",
1450
+ description=(
1451
+ "The environment variable is set to an empty, whitespace-only, or oversized "
1452
+ "value. Because it is set it outranks the keyring and the fallback file, so "
1453
+ "falling through to stored credentials would authenticate as somebody the "
1454
+ "caller did not ask for. The value is never echoed back."
1455
+ ),
1456
+ trace=(
1457
+ "CredentialResolver.resolve read VIDBYTE_API_KEY from the injected environment "
1458
+ "and bounds-checked it before any store was consulted."
1459
+ ),
1460
+ hint="Set a valid key or remove the variable to use stored credentials.",
1461
+ )
1462
+
1463
+
1464
+ class EnvironmentApiKeyNotLive(CliError):
1465
+ """VIDBYTE_API_KEY holds a well-formed value that is not a live Vidbyte key."""
1466
+
1467
+ code = CliErrorCode.AUTH_REQUIRED
1468
+ exit_status = ExitCode.AUTHENTICATION
1469
+
1470
+ def __init__(self) -> None:
1471
+ super().__init__(
1472
+ "VIDBYTE_API_KEY is not a live Vidbyte API key.",
1473
+ description=(
1474
+ "The variable holds a syntactically usable value that does not begin with the "
1475
+ "live-key prefix. The backend extracts only live keys, so such a value produces "
1476
+ "no credential at all and every request would be answered as unauthenticated "
1477
+ "rather than as a bad key. It is rejected here so the cause is visible instead "
1478
+ "of surfacing later as an unexplained login prompt. The value is never echoed."
1479
+ ),
1480
+ trace=(
1481
+ "CredentialResolver.resolve read VIDBYTE_API_KEY from the injected environment "
1482
+ "and checked its format before any credential store was consulted."
1483
+ ),
1484
+ hint="Export a live key from the Vidbyte dashboard, or unset the variable.",
1485
+ )
1486
+
1487
+
1488
+ class NoninteractiveLoginRequiresToken(CliError):
1489
+ """Login had no terminal to prompt on and no explicit token source."""
1490
+
1491
+ code = CliErrorCode.INVALID_ARGUMENT
1492
+ exit_status = ExitCode.USAGE
1493
+
1494
+ def __init__(self) -> None:
1495
+ super().__init__(
1496
+ "Login requires explicit token input in noninteractive mode.",
1497
+ description=(
1498
+ "The hidden prompt needs an interactive terminal, and this invocation has none "
1499
+ "or passed --no-input. Stdin is read only when --with-token says so, so a "
1500
+ "redirected stream is never consumed by accident and the command cannot hang "
1501
+ "on a read that will not complete. No credential was stored."
1502
+ ),
1503
+ trace=(
1504
+ "LoginCommand.execute called CredentialInput.read without the stdin marker, "
1505
+ "which checked --no-input and the detected terminal capabilities."
1506
+ ),
1507
+ hint="Pipe the token to 'vidbyte-cli login --with-token'.",
1508
+ )
1509
+
1510
+
1511
+ class InvalidApiKeyInput(CliError):
1512
+ """The token supplied to login was empty or beyond the input bound."""
1513
+
1514
+ code = CliErrorCode.INVALID_ARGUMENT
1515
+ exit_status = ExitCode.USAGE
1516
+
1517
+ def __init__(self) -> None:
1518
+ super().__init__(
1519
+ "The supplied Vidbyte API key is empty or too large.",
1520
+ description=(
1521
+ "The value read from the prompt or from stdin was blank after trimming, or "
1522
+ "longer than the 4096-character bound the CLI reads from any secret source. "
1523
+ "That bound keeps an accidental file redirect from being pulled into memory as "
1524
+ "a key. The value is never echoed, and nothing was verified or stored."
1525
+ ),
1526
+ trace=(
1527
+ "CredentialInput.read consumed the selected input channel and bounds-checked "
1528
+ "the trimmed value before constructing Credentials."
1529
+ ),
1530
+ hint="Paste the key from the Vidbyte dashboard, or pipe it with --with-token.",
1531
+ )
1532
+
1533
+
1534
+ class ApiKeyNotLiveFormat(CliError):
1535
+ """The token supplied to login is not a live Vidbyte API key."""
1536
+
1537
+ code = CliErrorCode.INVALID_ARGUMENT
1538
+ exit_status = ExitCode.USAGE
1539
+
1540
+ def __init__(self) -> None:
1541
+ super().__init__(
1542
+ "The supplied value is not a live Vidbyte API key.",
1543
+ description=(
1544
+ "The value read from the prompt or from stdin does not begin with the live-key "
1545
+ "prefix. The backend extracts only live keys, so storing this one would leave "
1546
+ "every later command answered as unauthenticated with nothing to point at. It "
1547
+ "is refused before verification, so nothing was sent and nothing was stored. "
1548
+ "The value is never echoed, because a mistyped secret is still a secret."
1549
+ ),
1550
+ trace=(
1551
+ "CredentialInput.read consumed the selected input channel, bounds-checked the "
1552
+ "trimmed value, and asked Credentials.is_live_format about its prefix."
1553
+ ),
1554
+ hint="Copy a live API key from the Vidbyte dashboard and run login again.",
1555
+ )
1556
+
1557
+
1558
+ class FileFallbackNotApproved(CliError):
1559
+ """Login could not ask for fallback consent and none was given on the command line."""
1560
+
1561
+ code = CliErrorCode.INVALID_ARGUMENT
1562
+ exit_status = ExitCode.USAGE
1563
+
1564
+ def __init__(self) -> None:
1565
+ super().__init__(
1566
+ "No OS keyring is available and file storage was not approved.",
1567
+ description=(
1568
+ "Storing the key would require the restricted-file fallback, which the CLI uses "
1569
+ "only with explicit consent. This invocation is noninteractive, so the "
1570
+ "confirmation prompt cannot be shown and consent has to arrive as a flag. "
1571
+ "Nothing was stored."
1572
+ ),
1573
+ trace=(
1574
+ "LoginCommand.execute called _fallback_consent, which found no available "
1575
+ "keyring, no --allow-file-fallback, and no interactive terminal."
1576
+ ),
1577
+ hint="Retry with --allow-file-fallback after reviewing the storage warning.",
1578
+ )
1579
+
1580
+
1581
+ class ApiRouteMisconfigured(CliError):
1582
+ """A CLI-owned route string was not a usable relative API path."""
1583
+
1584
+ code = CliErrorCode.INTERNAL_ERROR
1585
+ exit_status = ExitCode.SOFTWARE
1586
+
1587
+ def __init__(self) -> None:
1588
+ super().__init__(
1589
+ "An invalid API route was configured in the CLI.",
1590
+ description=(
1591
+ "Every request path is built from constants this CLI ships, and one of them was "
1592
+ "not a single relative path rooted at '/'. A path carrying its own scheme or "
1593
+ "host would send the API key to an origin the caller never configured, so the "
1594
+ "request is refused rather than sent. This is a CLI defect, not an invocation "
1595
+ "mistake, and no arguments can work around it."
1596
+ ),
1597
+ trace=(
1598
+ "An endpoint group called ApiClient.request, whose _url rejected the path "
1599
+ "before any connection was opened."
1600
+ ),
1601
+ hint="Report this with the command you ran; no argument change will help.",
1602
+ )
1603
+
1604
+
1605
+ class ApiResponseUnsupported(CliError):
1606
+ """The backend replied with something this CLI cannot decode."""
1607
+
1608
+ code = CliErrorCode.API_PROTOCOL_ERROR
1609
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1610
+
1611
+ def __init__(self, request_id: str | None = None, cause: Exception | None = None) -> None:
1612
+ super().__init__(
1613
+ "The Vidbyte API returned an unsupported response.",
1614
+ description=(
1615
+ "The request succeeded at the HTTP level, but the reply was not JSON of the "
1616
+ "shape this CLI release expects: a wrong content type, an empty or oversized "
1617
+ "body, or fields that failed validation. The body is not quoted back, because a "
1618
+ "response may carry account content. A newer backend usually means this CLI is "
1619
+ "out of date; retrying an unchanged command will produce the same result."
1620
+ ),
1621
+ trace=(
1622
+ "ApiClient received a success status and handed the response to "
1623
+ "ResponseDecoder, which bounded and validated it before returning typed data."
1624
+ ),
1625
+ hint="Upgrade the CLI. If it is current, report the request ID.",
1626
+ request_id=request_id,
1627
+ cause=cause,
1628
+ )
1629
+
1630
+
1631
+ class ApiUnreachable(CliError):
1632
+ """The Vidbyte API could not be contacted at all."""
1633
+
1634
+ code = CliErrorCode.API_UNAVAILABLE
1635
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1636
+ retryable = True
1637
+
1638
+ def __init__(self, cause: Exception) -> None:
1639
+ super().__init__(
1640
+ "The Vidbyte API could not be reached.",
1641
+ description=(
1642
+ "Connecting to the configured API host failed, or the connection was lost "
1643
+ "before a reply arrived — typically no network route, DNS failure, a proxy, or "
1644
+ "a timeout. The transport error is withheld because it quotes the URL and may "
1645
+ "quote proxy configuration. Safe requests were already retried, so the failure "
1646
+ "persisted across every attempt."
1647
+ ),
1648
+ trace=(
1649
+ "ApiClient._send exhausted RetryPolicy and raised through "
1650
+ "ApiProblemMapper.from_transport without receiving any HTTP response."
1651
+ ),
1652
+ hint="Check connectivity and 'vidbyte-cli config get api_url', then retry.",
1653
+ cause=cause,
1654
+ )
1655
+
1656
+
1657
+ class ApiRouteMissing(CliError):
1658
+ """The configured host does not serve the route the CLI asked for."""
1659
+
1660
+ code = CliErrorCode.API_UNAVAILABLE
1661
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1662
+
1663
+ def __init__(self, request_id: str | None = None) -> None:
1664
+ super().__init__(
1665
+ "The Vidbyte API does not serve this route.",
1666
+ description=(
1667
+ "The host answered but has no handler at the requested path, so it is either "
1668
+ "not the Vidbyte API or is running a release older than this CLI expects. This "
1669
+ "says nothing about your API key, which was never evaluated. Nothing was "
1670
+ "stored. Changing the key will not help; the configured API URL or the "
1671
+ "deployed backend has to change."
1672
+ ),
1673
+ trace=(
1674
+ "ApiClient.post_direct received a response outside the success range and "
1675
+ "ApiProblemMapper classified its 404 status as a missing route."
1676
+ ),
1677
+ hint="Check 'vidbyte-cli config get api_url', then upgrade the CLI if it is correct.",
1678
+ request_id=request_id,
1679
+ )
1680
+
1681
+
1682
+ class ApiCredentialsRejected(CliError):
1683
+ """The backend refused the API key this invocation presented."""
1684
+
1685
+ code = CliErrorCode.AUTH_REQUIRED
1686
+ exit_status = ExitCode.AUTHENTICATION
1687
+
1688
+ def __init__(self, request_id: str | None = None) -> None:
1689
+ super().__init__(
1690
+ "The Vidbyte API rejected the current credentials.",
1691
+ description=(
1692
+ "A key was found and sent, and the backend did not accept it as an "
1693
+ "authenticated caller. The key is usually revoked, expired, or issued for a "
1694
+ "different host than the one configured for this profile. Nothing was submitted "
1695
+ "and no credits were spent. The key value is never echoed."
1696
+ ),
1697
+ trace=(
1698
+ "ApiClient sent the resolved credential in the x-api-key header and the "
1699
+ "backend answered with an unauthenticated status."
1700
+ ),
1701
+ hint="Run 'vidbyte-cli login' for the selected profile, then retry.",
1702
+ request_id=request_id,
1703
+ )
1704
+
1705
+
1706
+ class ApiPermissionDenied(CliError):
1707
+ """The key authenticated but lacks the scope this route requires."""
1708
+
1709
+ code = CliErrorCode.AUTH_REQUIRED
1710
+ exit_status = ExitCode.AUTHENTICATION
1711
+
1712
+ def __init__(self, request_id: str | None = None) -> None:
1713
+ super().__init__(
1714
+ "The API key is missing the scope this command requires.",
1715
+ description=(
1716
+ "The backend recognized the key and then refused the route, which means the "
1717
+ "credential is valid but not authorized for it. Research reads need the "
1718
+ "'research:read' scope and research mutations need 'research:write'. Logging in "
1719
+ "again will not help, because the same key would be stored; the scope has to be "
1720
+ "granted where the key was issued. Nothing was submitted and no credits were "
1721
+ "spent."
1722
+ ),
1723
+ trace=(
1724
+ "ApiClient sent an authenticated request and the backend gatekeeper rejected "
1725
+ "it after identifying the caller but before running the route."
1726
+ ),
1727
+ hint="Grant the required scope to this key in the Vidbyte dashboard.",
1728
+ request_id=request_id,
1729
+ )
1730
+
1731
+
1732
+ class ApiCreditExhausted(CliError):
1733
+ """The account cannot fund the requested operation."""
1734
+
1735
+ code = CliErrorCode.CREDIT_EXHAUSTED
1736
+ exit_status = ExitCode.CREDIT_EXHAUSTED
1737
+
1738
+ def __init__(
1739
+ self,
1740
+ request_id: str | None = None,
1741
+ remediation: dict[str, JsonValue] | None = None,
1742
+ ) -> None:
1743
+ super().__init__(
1744
+ "The Vidbyte account does not have enough API balance for this operation.",
1745
+ description=(
1746
+ "Vidbyte could not reserve admission or extend the API-balance budget for this "
1747
+ "agent operation. If this happened before start, no agent was started and no "
1748
+ "new Vidbyte usage was charged. If a run was already in progress, earlier work "
1749
+ "may already have been charged, so do not duplicate it blindly. This is a "
1750
+ "Vidbyte API-balance problem, not a shortage in the model provider account. "
1751
+ "First stop retrying this operation or sending more turns. Then get explicit "
1752
+ "approval from the user before spending money. Use the same authenticated "
1753
+ "Vidbyte API key that made the failed request, create one fresh idempotency key "
1754
+ "for the top-up, and send POST /agent/topup. Follow the returned payment "
1755
+ "challenge and authorize only that exact challenge with x402 or MPP; do not "
1756
+ "invent a different amount, route, or recipient. Resend the identical top-up "
1757
+ "request with the payment authorization. Confirm that the response reports "
1758
+ "credited_cents and available_balance_cents, and that the available balance "
1759
+ "covers the original or remaining operation. If it does not, obtain approval "
1760
+ "before another top-up. After the balance is confirmed, retry or resume the "
1761
+ "original operation and reuse its original idempotency key when one was supplied. "
1762
+ "If the agent cannot make the payment, ask the user to add API balance at "
1763
+ "https://vidbyte.pro/settings/api. Never expose the API key or payment private "
1764
+ "key in a prompt, log, error report, or agent task. Status 5 is distinct from "
1765
+ "an ordinary failure so a caller can branch on this recovery path."
1766
+ ),
1767
+ trace=(
1768
+ "ApiClient submitted a priced mutation and the backend refused admission at "
1769
+ "the wallet rail before reserving usage."
1770
+ ),
1771
+ hint=(
1772
+ "Get user approval, run `vidbyte-cli billing top-up --confirm`, verify the "
1773
+ "credited balance, then retry the original operation."
1774
+ ),
1775
+ request_id=request_id,
1776
+ remediation=remediation,
1777
+ )
1778
+
1779
+
1780
+ class BillingTopUpApprovalRequired(CliError):
1781
+ """The explicit top-up command was not given its payment confirmation."""
1782
+
1783
+ code = CliErrorCode.INVALID_ARGUMENT
1784
+ exit_status = ExitCode.USAGE
1785
+
1786
+ def __init__(self) -> None:
1787
+ super().__init__(
1788
+ "Confirm before purchasing Vidbyte API balance.",
1789
+ description=(
1790
+ "The billing top-up command creates a paid x402 authorization and credits "
1791
+ "the authenticated Vidbyte account. It will not spend money unless the "
1792
+ "caller explicitly supplies --confirm, so an agent must obtain user approval "
1793
+ "before invoking it."
1794
+ ),
1795
+ trace=(
1796
+ "BillingTopUpCommand stopped before resolving the payment signer or "
1797
+ "sending a request."
1798
+ ),
1799
+ hint="Obtain user approval, then rerun `vidbyte-cli billing top-up --confirm`.",
1800
+ )
1801
+
1802
+
1803
+ class ApiResourceNotFound(CliError):
1804
+ """The addressed resource does not exist for this caller."""
1805
+
1806
+ code = CliErrorCode.OPERATION_FAILED
1807
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1808
+
1809
+ def __init__(self, request_id: str | None = None) -> None:
1810
+ super().__init__(
1811
+ "The requested Vidbyte resource was not found.",
1812
+ description=(
1813
+ "No record with that identifier is visible to this account. Reads are scoped to "
1814
+ "the caller, so a resource owned by somebody else is indistinguishable from one "
1815
+ "that never existed, and a deleted thread reads the same way. Check the "
1816
+ "identifier came from this CLI's own output rather than from a browser URL or "
1817
+ "an internal tool."
1818
+ ),
1819
+ trace=(
1820
+ "ApiClient sent an owner-scoped read or mutation and the backend found no "
1821
+ "matching record for the authenticated caller."
1822
+ ),
1823
+ hint="Run 'vidbyte-cli research threads' to list the identifiers you can use.",
1824
+ request_id=request_id,
1825
+ )
1826
+
1827
+
1828
+ class ApiRequestRejected(CliError):
1829
+ """The backend refused the request as invalid."""
1830
+
1831
+ code = CliErrorCode.INVALID_ARGUMENT
1832
+ exit_status = ExitCode.USAGE
1833
+
1834
+ def __init__(self, request_id: str | None = None) -> None:
1835
+ super().__init__(
1836
+ "The Vidbyte API rejected the request.",
1837
+ description=(
1838
+ "One or more supplied values failed the backend's own validation, so the "
1839
+ "operation never started and nothing was charged. The backend's field-level "
1840
+ "detail is withheld because it quotes the submitted values, which may include "
1841
+ "the prompt. The usual causes are a domain filter the backend has not reviewed, "
1842
+ "a date outside the accepted range, or a bound the CLI does not yet enforce "
1843
+ "locally."
1844
+ ),
1845
+ trace=(
1846
+ "ApiClient submitted the encoded request body and the backend answered with a "
1847
+ "client-side validation status before the operation ran."
1848
+ ),
1849
+ hint="Re-run with fewer options to isolate the rejected value.",
1850
+ request_id=request_id,
1851
+ )
1852
+
1853
+
1854
+ class ApiRequestConflicted(CliError):
1855
+ """The resource is not in a state that permits this operation."""
1856
+
1857
+ code = CliErrorCode.INVALID_ARGUMENT
1858
+ exit_status = ExitCode.USAGE
1859
+
1860
+ def __init__(self, request_id: str | None = None) -> None:
1861
+ super().__init__(
1862
+ "The run is not in a state that can be continued.",
1863
+ description=(
1864
+ "Continuing a run is legal only after it has already settled badly — partial, "
1865
+ "failed, or out of credit. A run that is still admitting or running has not "
1866
+ "finished, and a run that completed has nothing left to continue. Nothing was "
1867
+ "admitted and no credits were spent. Read the run first and continue it only "
1868
+ "once its status is one of the three resumable ones."
1869
+ ),
1870
+ trace=(
1871
+ "ApiClient submitted a continuation and the backend compared the stored run "
1872
+ "status against its terminal-resumable set before admitting any work."
1873
+ ),
1874
+ hint="Run 'vidbyte-cli research status <run_id>' to see the current status.",
1875
+ request_id=request_id,
1876
+ )
1877
+
1878
+
1879
+ class ApiRateLimited(CliError):
1880
+ """The account's request budget for this window is spent."""
1881
+
1882
+ code = CliErrorCode.API_UNAVAILABLE
1883
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1884
+ retryable = True
1885
+
1886
+ def __init__(self, request_id: str | None = None, retry_after: int | None = None) -> None:
1887
+ wait = (
1888
+ f"Wait {retry_after} seconds before retrying."
1889
+ if retry_after is not None
1890
+ else "Wait a minute before retrying, and avoid polling faster than every 10s."
1891
+ )
1892
+ super().__init__(
1893
+ "The Vidbyte API request budget for this key is exhausted.",
1894
+ description=(
1895
+ "Requests are rate limited per key on a weighted per-minute budget, and "
1896
+ "starting a research run costs far more of that budget than reading one. The "
1897
+ "CLI already retried within its own bound and the limit still applied, so "
1898
+ "nothing was submitted. Waiting a minute is usually enough; polling in a tight "
1899
+ "loop is what exhausts the budget in the first place."
1900
+ ),
1901
+ trace=(
1902
+ "ApiClient exhausted RetryPolicy against a rate-limited status and raised "
1903
+ "through ApiProblemMapper.from_response."
1904
+ ),
1905
+ hint=wait,
1906
+ request_id=request_id,
1907
+ )
1908
+
1909
+
1910
+ class ApiUnavailable(CliError):
1911
+ """The backend failed to serve the request."""
1912
+
1913
+ code = CliErrorCode.API_UNAVAILABLE
1914
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1915
+ retryable = True
1916
+
1917
+ def __init__(self, request_id: str | None = None) -> None:
1918
+ super().__init__(
1919
+ "The Vidbyte API is temporarily unavailable.",
1920
+ description=(
1921
+ "The backend accepted the connection and then failed while handling the "
1922
+ "request. Safe requests were already retried within this invocation and the "
1923
+ "failure persisted. Whether the operation took effect is unknown for a "
1924
+ "mutation, so re-run it with the same --idempotency-key rather than a fresh "
1925
+ "one if you need to be certain it happens exactly once."
1926
+ ),
1927
+ trace=(
1928
+ "ApiClient exhausted RetryPolicy against a server-error status and raised "
1929
+ "through ApiProblemMapper.from_response."
1930
+ ),
1931
+ hint="Retry after a short delay; reuse --idempotency-key for a mutation.",
1932
+ request_id=request_id,
1933
+ )
1934
+
1935
+
1936
+ class ApiOperationFailed(CliError):
1937
+ """The backend answered with a status this CLI does not classify."""
1938
+
1939
+ code = CliErrorCode.OPERATION_FAILED
1940
+ exit_status = ExitCode.OPERATIONAL_FAILURE
1941
+
1942
+ def __init__(self, request_id: str | None = None) -> None:
1943
+ super().__init__(
1944
+ "The Vidbyte API could not complete the operation.",
1945
+ description=(
1946
+ "The reply carried a failure status that this CLI release has no specific "
1947
+ "handling for, so it is reported without guessing at a cause. The response body "
1948
+ "is not quoted, because it may carry account content. Whether the operation "
1949
+ "took effect is unknown; read the resource before retrying a mutation."
1950
+ ),
1951
+ trace=(
1952
+ "ApiClient received a non-success status that matched no specific arm of "
1953
+ "ApiProblemMapper.from_response."
1954
+ ),
1955
+ hint="Retry once, then report the request ID if the problem continues.",
1956
+ request_id=request_id,
1957
+ )
1958
+
1959
+
1960
+ class ResearchThreadIdInvalid(CliError):
1961
+ """A supplied thread identifier is not a public thread share token."""
1962
+
1963
+ code = CliErrorCode.INVALID_ARGUMENT
1964
+ exit_status = ExitCode.USAGE
1965
+
1966
+ def __init__(self) -> None:
1967
+ super().__init__(
1968
+ "That is not a valid research thread ID.",
1969
+ description=(
1970
+ "A research thread is addressed only by the public share token the API "
1971
+ "publishes, which is a UUID. The value supplied does not have that shape, so it "
1972
+ "is refused here rather than spent on a request the backend would reject during "
1973
+ "path validation. The most common cause is pasting an internal identifier from "
1974
+ "another tool. Nothing was submitted and no credits were spent."
1975
+ ),
1976
+ trace=(
1977
+ "The research command parsed its arguments and called ThreadId.parse before "
1978
+ "resolving credentials or constructing an API client."
1979
+ ),
1980
+ hint="Run 'vidbyte-cli research threads' and copy the ID it prints.",
1981
+ )
1982
+
1983
+
1984
+ class ResearchIdempotencyKeyInvalid(CliError):
1985
+ """An explicit --idempotency-key was outside the accepted format."""
1986
+
1987
+ code = CliErrorCode.INVALID_ARGUMENT
1988
+ exit_status = ExitCode.USAGE
1989
+
1990
+ def __init__(self) -> None:
1991
+ super().__init__(
1992
+ "The idempotency key must be 8-128 URL-safe characters.",
1993
+ description=(
1994
+ "An explicit idempotency key is what lets a priced mutation be retried without "
1995
+ "being charged twice, so it has to survive a URL and an HTTP header unchanged. "
1996
+ "Letters, digits, period, underscore, colon, and hyphen are accepted. The "
1997
+ "supplied value is refused before the request is built, so nothing was "
1998
+ "submitted. Omit the option entirely to have one generated."
1999
+ ),
2000
+ trace=(
2001
+ "The command called IdempotencyKey.create with the explicit "
2002
+ "--idempotency-key value before any credential was resolved."
2003
+ ),
2004
+ hint="Pass a UUID, or omit --idempotency-key to generate one.",
2005
+ )
2006
+
2007
+
2008
+ class StoredProviderCredentialUnreadable(CliError):
2009
+ """The provider fallback credential file could not be parsed."""
2010
+
2011
+ code = CliErrorCode.CREDENTIAL_STORE_UNAVAILABLE
2012
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2013
+
2014
+ def __init__(self, cause: Exception) -> None:
2015
+ super().__init__(
2016
+ "Stored provider credentials are invalid or unavailable.",
2017
+ description=(
2018
+ "The provider fallback file exists but is unreadable, is not JSON, exceeds "
2019
+ "the size bound, or declares an unsupported schema version. The CLI will not "
2020
+ "rewrite a file it cannot understand. No provider credential was resolved."
2021
+ ),
2022
+ trace=(
2023
+ "FileProviderStore._load read the provider credential file and validated "
2024
+ "it as a version-one ProviderDocument."
2025
+ ),
2026
+ hint="Move the fallback file and run 'vidbyte-cli provider login <provider>' again.",
2027
+ cause=cause,
2028
+ )
2029
+
2030
+
2031
+ class ProviderStoreUnavailable(CliError):
2032
+ """The OS keyring for provider keys could not be used."""
2033
+
2034
+ code = CliErrorCode.CREDENTIAL_STORE_UNAVAILABLE
2035
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2036
+
2037
+ def __init__(self, cause: Exception | None = None) -> None:
2038
+ super().__init__(
2039
+ "The provider credential store is unavailable.",
2040
+ description=(
2041
+ "No keyring backend reported itself usable, or the backend rejected the "
2042
+ "operation — a locked keychain or a headless session with no agent. No "
2043
+ "provider credential was stored or read."
2044
+ ),
2045
+ trace=(
2046
+ "KeyringProviderStore performed a scoped get, set, or delete against "
2047
+ "the 'vidbyte-cli-provider' service."
2048
+ ),
2049
+ hint="Check the system keyring, or approve the restricted-file fallback.",
2050
+ cause=cause,
2051
+ )
2052
+
2053
+
2054
+ class NoApprovedProviderStore(CliError):
2055
+ """No keyring and file fallback was not approved for provider storage."""
2056
+
2057
+ code = CliErrorCode.CREDENTIAL_STORE_UNAVAILABLE
2058
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2059
+
2060
+ def __init__(self, provider: str) -> None:
2061
+ super().__init__(
2062
+ f"No approved storage for provider '{provider}'.",
2063
+ description=(
2064
+ "The provider key verified successfully but has nowhere approved to live. "
2065
+ "This machine exposes no working keyring, and the restricted-file fallback "
2066
+ "requires explicit consent. Nothing was written; the verified key was "
2067
+ "discarded with this process."
2068
+ ),
2069
+ trace=(
2070
+ "ProviderLoginCommand verified the credential and called "
2071
+ "ProviderCredentialStore.write, which found no available keyring and no "
2072
+ "file-fallback consent."
2073
+ ),
2074
+ hint="Retry with --allow-file-fallback.",
2075
+ )
2076
+
2077
+
2078
+ class ProviderCredentialsRejected(CliError):
2079
+ """The provider rejected the supplied API key."""
2080
+
2081
+ code = CliErrorCode.AUTH_REQUIRED
2082
+ exit_status = ExitCode.AUTHENTICATION
2083
+
2084
+ def __init__(self, provider: str) -> None:
2085
+ super().__init__(
2086
+ f"The {provider} provider rejected the API key.",
2087
+ description=(
2088
+ "A provider key was sent and the provider answered 401 or 403. The key is "
2089
+ "revoked, expired, or malformed. Nothing was stored and no Vidbyte credits "
2090
+ "were spent. The key value is never echoed."
2091
+ ),
2092
+ trace=(
2093
+ "ProviderVerifier sent the key to the provider's /v1/models and the "
2094
+ "provider answered with an unauthenticated status."
2095
+ ),
2096
+ hint=f"Run 'vidbyte-cli provider login {provider}' with a valid key.",
2097
+ )
2098
+
2099
+
2100
+ class ProviderApiUnreachable(CliError):
2101
+ """The provider API could not be contacted at all."""
2102
+
2103
+ code = CliErrorCode.API_UNAVAILABLE
2104
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2105
+ retryable = True
2106
+
2107
+ def __init__(self, provider: str, cause: Exception | None = None) -> None:
2108
+ super().__init__(
2109
+ f"The {provider} provider API could not be reached.",
2110
+ description=(
2111
+ "Connecting to the provider failed, or the connection was lost before a reply "
2112
+ "arrived. Safe to retry after checking connectivity. Nothing was stored."
2113
+ ),
2114
+ trace=(
2115
+ "ProviderVerifier probed the provider's /v1/models and httpx raised an HTTPError."
2116
+ ),
2117
+ hint="Check connectivity and retry.",
2118
+ cause=cause,
2119
+ )
2120
+
2121
+
2122
+ class ProviderApiProtocolError(CliError):
2123
+ """The provider returned an undecodable success response."""
2124
+
2125
+ code = CliErrorCode.API_PROTOCOL_ERROR
2126
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2127
+
2128
+ def __init__(self, provider: str, cause: Exception | None = None) -> None:
2129
+ super().__init__(
2130
+ f"The {provider} provider returned an unsupported response.",
2131
+ description=(
2132
+ "The reply succeeded at HTTP level but was not JSON of the expected shape: "
2133
+ "wrong body, empty, or failed validation. The body is not quoted. Retry may "
2134
+ "produce the same result if the provider changed shape."
2135
+ ),
2136
+ trace=(
2137
+ "ProviderVerifier received a 2xx and handed the body to its JSON decoder "
2138
+ "and shape validator."
2139
+ ),
2140
+ hint="Retry once; if current, report with --debug.",
2141
+ cause=cause,
2142
+ )
2143
+
2144
+
2145
+ class ProviderRateLimited(CliError):
2146
+ """The provider rate-limited the verification probe."""
2147
+
2148
+ code = CliErrorCode.API_UNAVAILABLE
2149
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2150
+ retryable = True
2151
+
2152
+ def __init__(self, provider: str, retry_after: int | None = None) -> None:
2153
+ wait = (
2154
+ f"Wait {retry_after} seconds before retrying."
2155
+ if retry_after is not None
2156
+ else "Wait before retrying."
2157
+ )
2158
+ super().__init__(
2159
+ f"The {provider} provider rate-limited the request.",
2160
+ description=(
2161
+ "The provider answered 429 for the /v1/models probe. Nothing was stored. "
2162
+ "Waiting before retrying is required."
2163
+ ),
2164
+ trace=(
2165
+ "ProviderVerifier probed the provider and classified the 429 status as "
2166
+ "rate-limited."
2167
+ ),
2168
+ hint=wait,
2169
+ )
2170
+
2171
+
2172
+ class ProviderRequestRejected(CliError):
2173
+ """The provider rejected the request as invalid."""
2174
+
2175
+ code = CliErrorCode.INVALID_ARGUMENT
2176
+ exit_status = ExitCode.USAGE
2177
+
2178
+ def __init__(self, provider: str) -> None:
2179
+ super().__init__(
2180
+ f"The {provider} provider rejected the request.",
2181
+ description=(
2182
+ "The provider answered 400/404/422 for the probe, meaning the request was "
2183
+ "malformed. Nothing was stored. The provider body is not echoed."
2184
+ ),
2185
+ trace=(
2186
+ "ProviderVerifier probed the provider and mapped the non-success status "
2187
+ "to a request error."
2188
+ ),
2189
+ hint="Check the key and retry with a fresh token.",
2190
+ )
2191
+
2192
+
2193
+ class InvalidProviderApiKeyInput(CliError):
2194
+ """The token for a provider was empty or too large."""
2195
+
2196
+ code = CliErrorCode.INVALID_ARGUMENT
2197
+ exit_status = ExitCode.USAGE
2198
+
2199
+ def __init__(self, provider: str) -> None:
2200
+ super().__init__(
2201
+ f"The supplied {provider} API key is empty or too large.",
2202
+ description=(
2203
+ "The value after trimming was blank or longer than 4096 characters. Nothing "
2204
+ "was verified or stored."
2205
+ ),
2206
+ trace=(
2207
+ "ProviderCredentialInput.read bounds-checked the trimmed value before "
2208
+ "constructing ProviderCredentials."
2209
+ ),
2210
+ hint="Paste the key from the provider dashboard, or pipe with --with-token.",
2211
+ )
2212
+
2213
+
2214
+ class ProviderKeyNotLiveFormat(CliError):
2215
+ """The token does not match the expected provider prefix."""
2216
+
2217
+ code = CliErrorCode.INVALID_ARGUMENT
2218
+ exit_status = ExitCode.USAGE
2219
+
2220
+ def __init__(self, provider: str) -> None:
2221
+ super().__init__(
2222
+ f"The value is not a {provider} API key.",
2223
+ description=(
2224
+ "The value does not begin with the expected prefix for this provider. "
2225
+ "OpenAI keys must start with 'sk-' and Claude keys with 'sk-ant-'. "
2226
+ "Nothing was verified or stored."
2227
+ ),
2228
+ trace=(
2229
+ "ProviderCredentialInput.read asked ProviderCredentials.is_live_format "
2230
+ "about the prefix."
2231
+ ),
2232
+ hint="Copy the correct provider key and retry.",
2233
+ )
2234
+
2235
+
2236
+ class NoninteractiveProviderLoginRequiresToken(CliError):
2237
+ """Provider login had no terminal and no stdin marker."""
2238
+
2239
+ code = CliErrorCode.INVALID_ARGUMENT
2240
+ exit_status = ExitCode.USAGE
2241
+
2242
+ def __init__(self, provider: str) -> None:
2243
+ super().__init__(
2244
+ "Provider login requires explicit token input in noninteractive mode.",
2245
+ description=(
2246
+ "The hidden prompt needs an interactive terminal, and this invocation has "
2247
+ "none or passed --no-input. Stdin is read only with --with-token."
2248
+ ),
2249
+ trace=("ProviderCredentialInput.read checked --no-input and terminal capabilities."),
2250
+ hint=f"Pipe the token to 'vidbyte-cli provider login {provider} --with-token'.",
2251
+ )
2252
+
2253
+
2254
+ class InvalidProviderEnvironmentKey(CliError):
2255
+ """A provider env var is set but unusable."""
2256
+
2257
+ code = CliErrorCode.AUTH_REQUIRED
2258
+ exit_status = ExitCode.AUTHENTICATION
2259
+
2260
+ def __init__(self, provider: str, var_name: str) -> None:
2261
+ super().__init__(
2262
+ f"{var_name} for '{provider}' is empty or invalid.",
2263
+ description=(
2264
+ "The provider environment variable is set to an empty, whitespace-only, "
2265
+ "oversized, or wrong-prefix value. Because it is set it outranks stored "
2266
+ "credentials, so falling through would authenticate as someone the caller "
2267
+ "did not ask for."
2268
+ ),
2269
+ trace=(
2270
+ "ProviderResolver.resolve read the provider env var and bounds/prefix "
2271
+ "checked it before any store."
2272
+ ),
2273
+ hint=f"Set a valid key in {var_name} or unset it to use stored credentials.",
2274
+ )
2275
+
2276
+
2277
+ class ProviderAuthenticationRequired(CliError):
2278
+ """A provider command needs a stored key but none exists."""
2279
+
2280
+ code = CliErrorCode.AUTH_REQUIRED
2281
+ exit_status = ExitCode.AUTHENTICATION
2282
+
2283
+ def __init__(self, provider: str) -> None:
2284
+ super().__init__(
2285
+ f"Authentication for provider '{provider}' is required.",
2286
+ description=(
2287
+ "The command needs a provider API key and the store held none for this "
2288
+ "machine/profile. No provider probe was made."
2289
+ ),
2290
+ trace=(
2291
+ "ProviderWhoamiCommand resolved via ProviderResolver, which found no key "
2292
+ "in environment, keyring, or file."
2293
+ ),
2294
+ hint=f"Run 'vidbyte-cli provider login {provider}' first.",
2295
+ )
2296
+
2297
+
2298
+ class FileFallbackNotApprovedForProvider(CliError):
2299
+ """Provider login could not ask for fallback consent."""
2300
+
2301
+ code = CliErrorCode.INVALID_ARGUMENT
2302
+ exit_status = ExitCode.USAGE
2303
+
2304
+ def __init__(self, typed_name: str = "provider") -> None:
2305
+ _ = typed_name
2306
+ super().__init__(
2307
+ "No OS keyring is available and provider file storage was not approved.",
2308
+ description=(
2309
+ "Storing the provider key would require the restricted-file fallback, which "
2310
+ "needs explicit consent. This invocation is noninteractive so the prompt "
2311
+ "cannot be shown. Nothing was stored."
2312
+ ),
2313
+ trace=(
2314
+ "ProviderLoginCommand._fallback_consent found no available keyring, no "
2315
+ "--allow-file-fallback, and no interactive terminal."
2316
+ ),
2317
+ hint="Retry with --allow-file-fallback.",
2318
+ )
2319
+
2320
+
2321
+ class ResearchWatchTimedOut(CliError):
2322
+ """The local wait expired while the run was still in progress."""
2323
+
2324
+ code = CliErrorCode.OPERATION_FAILED
2325
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2326
+ retryable = True
2327
+
2328
+ def __init__(self, run_id: str) -> None:
2329
+ super().__init__(
2330
+ "The local wait expired before the research run finished.",
2331
+ description=(
2332
+ "Only the local wait ended. The run is still executing on the backend and will "
2333
+ "settle on its own, so nothing was cancelled and no credits were refunded or "
2334
+ "re-spent. Research runs routinely outlast a short --timeout. Read the run "
2335
+ "again later, or watch it again with a longer timeout."
2336
+ ),
2337
+ trace=(
2338
+ "ResearchWatchCommand polled the run status until the elapsed time reached the "
2339
+ "--timeout bound without observing a terminal status."
2340
+ ),
2341
+ hint=f"Run 'vidbyte-cli research status {run_id}' to check on it.",
2342
+ )
2343
+
2344
+
2345
+ class StagesHostFailed(CliError):
2346
+ """A stages turn did not complete with a usable Codex reply."""
2347
+
2348
+ code = CliErrorCode.OPERATION_FAILED
2349
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2350
+
2351
+ def __init__(self) -> None:
2352
+ # Host diagnostics can quote task text, so this message stays static.
2353
+ super().__init__(
2354
+ "Codex did not complete the stages turn.",
2355
+ description=(
2356
+ "Codex failed, timed out, or returned an incomplete reply for one stage. "
2357
+ "Execution stopped immediately and later stages never started. The admission "
2358
+ "may already have been charged and completed local work remains in the "
2359
+ "working directory."
2360
+ ),
2361
+ trace="StagesCodexSession checked the SDK turn status and reply identity.",
2362
+ hint="Inspect local Codex configuration before retrying the stages run.",
2363
+ )
2364
+
2365
+
2366
+ class StagesSettingsInvalid(CliError):
2367
+ """The supplied stage options did not validate against the stages contract."""
2368
+
2369
+ code = CliErrorCode.INVALID_ARGUMENT
2370
+ exit_status = ExitCode.USAGE
2371
+
2372
+ def __init__(self) -> None:
2373
+ # Reports only the contract, never the submitted stage text.
2374
+ super().__init__(
2375
+ "The run must describe 1 to 25 valid stages.",
2376
+ description=(
2377
+ "No stage was given, more than 25 were given, or a stage carried a blank "
2378
+ "name, prompt, or system prompt. It was rejected before credentials, "
2379
+ "payment, or host execution, so no credits were spent. Give each stage at "
2380
+ "least a --stage-prompt and a --stage-system-prompt and retry."
2381
+ ),
2382
+ trace="StagesOptions validated the stage options before any launch plan was built.",
2383
+ hint="Run 'vidbyte-cli runtime stages describe' for every accepted stage option.",
2384
+ )
2385
+
2386
+
2387
+ class StagesOptionCountMismatch(CliError):
2388
+ """Per-stage options were repeated a different number of times than the stages."""
2389
+
2390
+ code = CliErrorCode.INVALID_ARGUMENT
2391
+ exit_status = ExitCode.USAGE
2392
+
2393
+ def __init__(self) -> None:
2394
+ # Names the alignment contract without quoting any submitted stage text.
2395
+ super().__init__(
2396
+ "Every repeated --stage-* option must be given once per stage, or not at all.",
2397
+ description=(
2398
+ "Stages are assembled by position: the first --stage-prompt, the first "
2399
+ "--stage-system-prompt, and the first occurrence of every other --stage-* "
2400
+ "option describe stage 1, the second occurrences describe stage 2, and so "
2401
+ "on. One option repeated a different number of times than --stage-prompt "
2402
+ "leaves the run ambiguous, so it is rejected before payment rather than "
2403
+ "guessed at. An option omitted entirely is not a mismatch: every stage then "
2404
+ "takes that setting's default."
2405
+ ),
2406
+ trace="StagesOptions compared each repeated option list against the prompt count.",
2407
+ hint="Repeat each --stage-* option exactly as many times as --stage-prompt.",
2408
+ )
2409
+
2410
+
2411
+ class EnsembleInputsInvalid(CliError):
2412
+ """The supplied ensemble options failed their validated input contract."""
2413
+
2414
+ code = CliErrorCode.INVALID_ARGUMENT
2415
+ exit_status = ExitCode.USAGE
2416
+
2417
+ def __init__(self, cause: Exception) -> None:
2418
+ # Reports the contract, never the submitted values: a task can be sensitive.
2419
+ super().__init__(
2420
+ "The ensemble options are outside their accepted bounds.",
2421
+ description=(
2422
+ "One or more options failed validation before anything else ran. The task must "
2423
+ "hold 1 to 20,000 characters and --roles must be 3 to 100. Nothing was "
2424
+ "submitted, no admission was requested, and no agent started, so correcting "
2425
+ "the option and retrying is safe."
2426
+ ),
2427
+ trace=(
2428
+ "SameHostEnsembleCommand built EnsembleInputs from the parsed options before "
2429
+ "planning a launch."
2430
+ ),
2431
+ hint="Check --roles (3-100), then retry.",
2432
+ cause=cause,
2433
+ )
2434
+
2435
+
2436
+ class EnsembleHostUnsupported(CliError):
2437
+ """The resolved host is not the one host this primitive can run on."""
2438
+
2439
+ code = CliErrorCode.CONFIG_INVALID
2440
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2441
+
2442
+ def __init__(self, host: str) -> None:
2443
+ # Auto mode can still resolve to a host this primitive never offers explicitly.
2444
+ super().__init__(
2445
+ f"'{host}' is not a supported host for same-host-ensemble.",
2446
+ description=(
2447
+ "This primitive runs only on Codex, the sole native host with verified thread "
2448
+ "forking and per-fork sandbox control, which is what keeps proposal roles from "
2449
+ "writing to the workspace. No admission was requested and no process started."
2450
+ ),
2451
+ trace=(
2452
+ "SameHostEnsembleCommand checked the resolved RuntimeLaunchPlan's host before "
2453
+ "calling the executor."
2454
+ ),
2455
+ hint="Install Codex, then run 'vidbyte-cli runtime doctor' to confirm discovery.",
2456
+ )
2457
+
2458
+
2459
+ class EnsembleSdkUnavailable(CliError):
2460
+ """The Vidbyte SDK is missing, or predates its Codex integration."""
2461
+
2462
+ code = CliErrorCode.CONFIG_INVALID
2463
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2464
+
2465
+ def __init__(self, cause: Exception) -> None:
2466
+ # Raised before admission, so an unmet dependency never costs the caller anything.
2467
+ super().__init__(
2468
+ "The ensemble requires the Vidbyte SDK's Codex integration.",
2469
+ description=(
2470
+ "The Codex agent this primitive drives could not be imported. Either the SDK is "
2471
+ "not installed, or the installed release predates the Codex integration and so "
2472
+ "imports without the required symbols. This check runs before paid admission, "
2473
+ "so nothing was charged. Install the extra and retry."
2474
+ ),
2475
+ trace="SameHostEnsembleCommand loaded the SDK before requesting admission.",
2476
+ hint="Install with: pip install 'vidbyte-cli[codex]'",
2477
+ cause=cause,
2478
+ )
2479
+
2480
+
2481
+ class EnsembleRolePlanInvalid(CliError):
2482
+ """The planner turn did not return a usable roster of generated roles."""
2483
+
2484
+ code = CliErrorCode.API_PROTOCOL_ERROR
2485
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2486
+
2487
+ def __init__(self, expected: int) -> None:
2488
+ # The roster is generated, so a bad plan is a host failure rather than caller error.
2489
+ super().__init__(
2490
+ f"The planner did not return {expected} usable roles.",
2491
+ description=(
2492
+ "The first ensemble stage returned no structured role plan, returned a "
2493
+ "different number of roles than requested, or reused a role name. Every later "
2494
+ "stage forks from that plan and every candidate is labeled by its role, so the "
2495
+ "run stopped here rather than fanning out to an ambiguous roster. Admission "
2496
+ "was already granted for this attempt."
2497
+ ),
2498
+ trace=(
2499
+ "EnsembleService._plan_roles read the planner reply's structured output before "
2500
+ "forking any proposal role."
2501
+ ),
2502
+ hint="Retry, or lower --roles if the host struggles to differentiate that many.",
2503
+ )
2504
+
2505
+
2506
+ class EnsembleProposalUnusable(CliError):
2507
+ """One role returned no structured proposal, so that branch produced nothing."""
2508
+
2509
+ code = CliErrorCode.API_PROTOCOL_ERROR
2510
+ exit_status = ExitCode.PARTIAL_OUTCOME
2511
+
2512
+ def __init__(self, role: str) -> None:
2513
+ # Recorded as a per-role failure; it aborts the run only if every role fails.
2514
+ super().__init__(
2515
+ f"Role '{role}' returned no usable proposals.",
2516
+ description=(
2517
+ "This role's fork completed without producing structured output matching the "
2518
+ "proposal contract, which requires 5 to 10 approaches with pros and cons on "
2519
+ "each. The role is recorded as failed and the run continues with whichever "
2520
+ "roles did succeed, because a partial ensemble still beats a single agent. "
2521
+ "Only an empty slate stops the run."
2522
+ ),
2523
+ trace=(
2524
+ "EnsembleService._propose validated one fork's structured reply before the "
2525
+ "fan-in stage collected it."
2526
+ ),
2527
+ hint="Inspect the failures list in the result document for the affected roles.",
2528
+ )
2529
+
2530
+
2531
+ class EnsembleAllRolesFailed(CliError):
2532
+ """Every proposal role failed, so there is nothing to hand the implementer."""
2533
+
2534
+ code = CliErrorCode.OPERATION_FAILED
2535
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2536
+
2537
+ def __init__(self, failed: int) -> None:
2538
+ # Running the implementer on an empty proposal set would just be a solo agent.
2539
+ super().__init__(
2540
+ f"All {failed} ensemble roles failed.",
2541
+ description=(
2542
+ "No role produced a usable slate, through timeout, host failure, or invalid "
2543
+ "output. The selection and implementer stages were not started, because "
2544
+ "running them with no candidates would be an expensive way to run one ordinary "
2545
+ "agent. No file in the workspace was modified; admission for this attempt was "
2546
+ "already granted."
2547
+ ),
2548
+ trace=(
2549
+ "EnsembleService._orchestrate partitioned the gathered role outcomes and found "
2550
+ "no surviving proposal."
2551
+ ),
2552
+ hint="Retry, or check 'vidbyte-cli runtime doctor' for host health.",
2553
+ )
2554
+
2555
+
2556
+ class EnsembleHostFailed(CliError):
2557
+ """The native host failed during a stage whose failure ends the run."""
2558
+
2559
+ code = CliErrorCode.OPERATION_FAILED
2560
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2561
+
2562
+ def __init__(self, stage: str, cause: Exception) -> None:
2563
+ # The provider message is withheld: it can carry workspace paths and prompt text.
2564
+ super().__init__(
2565
+ f"The native host failed during the ensemble {stage} stage.",
2566
+ description=(
2567
+ "Codex raised while running this stage. The provider's own message is withheld "
2568
+ "because it can quote workspace paths and prompt text. Planner and selector "
2569
+ "failures end the run, unlike a single role's failure, because every later "
2570
+ "stage depends on them. No partial work was applied by this stage."
2571
+ ),
2572
+ trace=(
2573
+ "EnsembleService drove one Codex turn or fork through the Vidbyte SDK's Codex "
2574
+ "agent and the SDK raised."
2575
+ ),
2576
+ hint="Run 'vidbyte-cli runtime doctor', then retry.",
2577
+ cause=cause,
2578
+ )
2579
+
2580
+
2581
+ class EnsembleSelectionInvalid(CliError):
2582
+ """A narrowing round did not return the survivors it was told to return."""
2583
+
2584
+ code = CliErrorCode.API_PROTOCOL_ERROR
2585
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2586
+
2587
+ def __init__(self, expected: int) -> None:
2588
+ # The ladder is computed locally, so a round that ignores its target is unrecoverable.
2589
+ super().__init__(
2590
+ f"The selector did not keep exactly {expected} candidate(s).",
2591
+ description=(
2592
+ "One narrowing round returned no structured output, kept a number of "
2593
+ "candidates other than the one it was given, repeated a candidate, or named an "
2594
+ "identifier that was not on the slate for that round. Each round's survivors "
2595
+ "decide the next round's slate, so the run stopped rather than continuing from "
2596
+ "an unverifiable set. No workspace file was modified; admission for this "
2597
+ "attempt was already granted."
2598
+ ),
2599
+ trace=(
2600
+ "EnsembleService._round validated one selection round's structured reply "
2601
+ "against the candidate identifiers that round was offered."
2602
+ ),
2603
+ hint="Retry, or lower --roles so the first round weighs fewer candidates.",
2604
+ )
2605
+
2606
+
2607
+ class EnsembleImplementerFailed(CliError):
2608
+ """The write-enabled implementer stage failed after an approach was selected."""
2609
+
2610
+ code = CliErrorCode.OPERATION_FAILED
2611
+ exit_status = ExitCode.PARTIAL_OUTCOME
2612
+
2613
+ def __init__(self, cause: Exception) -> None:
2614
+ # The one stage that may write, so a failure here can leave partial edits behind.
2615
+ super().__init__(
2616
+ "The ensemble implementer stage failed.",
2617
+ description=(
2618
+ "An approach was proposed and selected successfully, but the write-enabled "
2619
+ "implementer fork failed. This is the only stage permitted to modify the "
2620
+ "workspace, so it may have applied some changes before failing. Review the "
2621
+ "working tree before retrying, because a retry starts the task from the "
2622
+ "beginning."
2623
+ ),
2624
+ trace=(
2625
+ "EnsembleService._implement forked the root thread with write access and ran "
2626
+ "the implementation turn."
2627
+ ),
2628
+ hint="Check 'git status' for partial edits before retrying.",
2629
+ cause=cause,
2630
+ )
2631
+
2632
+
2633
+ class RuntimePaymentFailed(CliError):
2634
+ """Explicit x402 admission could not be safely authorized or completed."""
2635
+
2636
+ code = CliErrorCode.OPERATION_FAILED
2637
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2638
+
2639
+ def __init__(self, reason: str) -> None:
2640
+ # Exposes only a fixed reason category, never payer credentials or response bodies.
2641
+ super().__init__(
2642
+ "Runtime x402 payment could not be completed.",
2643
+ description=(
2644
+ "Set VIDBYTE_X402_PRIVATE_KEY and the supported VIDBYTE_X402_NETWORK for "
2645
+ "explicit x402 payment. No local agent was launched. If payment was already "
2646
+ "attempted, recover with the same idempotency key; do not authorize a new "
2647
+ f"purchase to resolve an uncertain outcome. Reason: {reason}."
2648
+ ),
2649
+ trace="Runtime payment validation failed before the database-verified launch gate.",
2650
+ )
2651
+
2652
+
2653
+ class SuggestionInputInvalid(CliError):
2654
+ """The suggestion request was missing, mixed, or malformed."""
2655
+
2656
+ code = CliErrorCode.INVALID_ARGUMENT
2657
+ exit_status = ExitCode.USAGE
2658
+
2659
+ def __init__(self) -> None:
2660
+ # Never echoes goal text or file bodies, which can carry private task data.
2661
+ super().__init__(
2662
+ "Supply a nonempty goal and valid suggestion settings.",
2663
+ description=(
2664
+ "A suggestion run receives its goal and settings from the declared command "
2665
+ "flags, and those values must form one strict request. An empty goal, an "
2666
+ "out-of-range setting, or a malformed value fails here. Nothing was read "
2667
+ "from providers and no model was called."
2668
+ ),
2669
+ trace="SuggestRunCommand validated the request before building context.",
2670
+ hint="Run with --goal 'outcome' and keep --count between 2 and 15.",
2671
+ )
2672
+
2673
+
2674
+ class SuggestionCategoryUnknown(CliError):
2675
+ """An unknown suggestion category was requested."""
2676
+
2677
+ code = CliErrorCode.INVALID_ARGUMENT
2678
+ exit_status = ExitCode.USAGE
2679
+
2680
+ def __init__(self, detail: str) -> None:
2681
+ # Carries only the unknown ids, never goal or context content.
2682
+ super().__init__(
2683
+ f"Unknown suggestion category: {detail}.",
2684
+ description=(
2685
+ "Category ids come from a fixed registry shared by validation, prompts, and "
2686
+ "schemas. An unknown id fails before files are read or models run, so a typo "
2687
+ "cannot silently widen generation. List valid ids without credentials or "
2688
+ "network access."
2689
+ ),
2690
+ trace="SuggestRunCommand checked categories against the owned registry.",
2691
+ hint="Run 'vidbyte-cli agents suggest categories' to list valid ids.",
2692
+ )
2693
+
2694
+
2695
+ class SuggestionContextUnreadable(CliError):
2696
+ """An explicit context file could not be read or parsed."""
2697
+
2698
+ code = CliErrorCode.INVALID_ARGUMENT
2699
+ exit_status = ExitCode.USAGE
2700
+
2701
+ def __init__(self, detail: str) -> None:
2702
+ # Omits paths and bodies, which may name private files or task data.
2703
+ super().__init__(
2704
+ f"Could not read suggestion context: {detail}.",
2705
+ description=(
2706
+ "Only explicitly supplied files are read, and each must be UTF-8 text, "
2707
+ "Markdown, or documented JSON with a supported schema version. Missing "
2708
+ "files, directories, malformed JSON, and unsupported types fail here. No "
2709
+ "model was called and no provider configuration was required."
2710
+ ),
2711
+ trace="SuggestionContextBuilder read one explicit context path.",
2712
+ hint="Check the path suffix, encoding, and JSON schema version.",
2713
+ )
2714
+
2715
+
2716
+ class SuggestionLimitExceeded(CliError):
2717
+ """A generation limit was unusable for this run."""
2718
+
2719
+ code = CliErrorCode.INVALID_ARGUMENT
2720
+ exit_status = ExitCode.USAGE
2721
+
2722
+ def __init__(self) -> None:
2723
+ # Counts and bounds are CLI-validated, so this is a narrow guard.
2724
+ super().__init__(
2725
+ "A suggestion limit is outside its supported range.",
2726
+ description=(
2727
+ "Counts, rounds, token bounds, and timeouts are stopping thresholds for "
2728
+ "the whole workflow including revisions. Out-of-range values fail before "
2729
+ "any model call so callers get usage errors rather than partial runs. No "
2730
+ "provider work started."
2731
+ ),
2732
+ trace="SuggestRunCommand validated generation controls before the service.",
2733
+ hint="Use --count 2-15, --rounds 1-3, and positive token/timeout values.",
2734
+ )
2735
+
2736
+
2737
+ class SuggestionProviderFailed(CliError):
2738
+ """The model provider failed during generation or critique."""
2739
+
2740
+ code = CliErrorCode.OPERATION_FAILED
2741
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2742
+
2743
+ def __init__(self, cause: Exception) -> None:
2744
+ # Withholds provider text, which can quote prompts and workspace paths.
2745
+ super().__init__(
2746
+ "The suggestion provider did not complete the run.",
2747
+ description=(
2748
+ "The provider raised, timed out, or returned output that did not validate "
2749
+ "during generation or critique. This is reported as a provider failure, never "
2750
+ "as no useful suggestions, so callers do not misread an outage as a verdict. "
2751
+ "Partial reviewed batches are preserved where the service kept them."
2752
+ ),
2753
+ trace="SuggestionService drove one generator or critic turn via the SDK.",
2754
+ hint=(
2755
+ "Retry the provider run, or use --dry-run to validate the request without a model."
2756
+ ),
2757
+ cause=cause,
2758
+ )
2759
+
2760
+
2761
+ class SuggestionSdkUnavailable(CliError):
2762
+ """The installed SDK predates the integration the suggestion agent needs."""
2763
+
2764
+ code = CliErrorCode.OPERATION_FAILED
2765
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2766
+
2767
+ def __init__(self, cause: Exception) -> None:
2768
+ # Static text only; the missing symbol path is an install fact, not a secret.
2769
+ super().__init__(
2770
+ "The installed vidbyte-sdk does not expose the suggestion integration.",
2771
+ description=(
2772
+ "Suggestion SDK symbols resolve lazily so help and category listing work "
2773
+ "without them. A run that explicitly requires provider execution fails here "
2774
+ "when the pinned SDK revision is absent or predates the integration. Category "
2775
+ "inspection and request validation remain available without provider flags."
2776
+ ),
2777
+ trace="SuggestionSdk resolved Codex agent symbols at call time.",
2778
+ hint="Install the SDK revision pinned in pyproject.toml.",
2779
+ cause=cause,
2780
+ )
2781
+
2782
+
2783
+ class SuggestionProjectInvalid(CliError):
2784
+ """A project key, title, or description did not satisfy the local project contract."""
2785
+
2786
+ code = CliErrorCode.INVALID_ARGUMENT
2787
+ exit_status = ExitCode.USAGE
2788
+
2789
+ def __init__(self, detail: str) -> None:
2790
+ # Reports validation guidance without echoing descriptions or feedback bodies.
2791
+ super().__init__(
2792
+ f"Invalid suggestion project: {detail}.",
2793
+ description=(
2794
+ "A project key must be 1-64 lowercase letters, numbers, hyphens, or "
2795
+ "underscores beginning with a letter or number, and a new project also needs a "
2796
+ "nonempty title of at most 200 characters and description of at most 4000. The "
2797
+ "request was rejected before any project file was read or written. Existing "
2798
+ "projects and their feedback were not changed."
2799
+ ),
2800
+ trace="SuggestionProject validated the key and fields in its input dataclass.",
2801
+ hint="Use --key with lowercase letters, numbers, hyphens, or underscores.",
2802
+ )
2803
+
2804
+
2805
+ class SuggestionProjectExists(CliError):
2806
+ """A project create request used a key already present in local state."""
2807
+
2808
+ code = CliErrorCode.INVALID_ARGUMENT
2809
+ exit_status = ExitCode.USAGE
2810
+
2811
+ def __init__(self, key: str) -> None:
2812
+ # Keeps duplicate creation separate from malformed project input.
2813
+ super().__init__(
2814
+ f"Suggestion project already exists: {key}.",
2815
+ description=(
2816
+ "The project key is already registered in the local suggestion catalog. "
2817
+ "No catalog or memory file was replaced, so the existing project remains "
2818
+ "unchanged. Choose another key or use the existing project key for feedback "
2819
+ "and suggestion runs."
2820
+ ),
2821
+ trace="SuggestionProject found a duplicate key before its first write.",
2822
+ hint="Run 'vidbyte-cli agents suggest project list' to inspect existing keys.",
2823
+ )
2824
+
2825
+
2826
+ class SuggestionProjectNotFound(CliError):
2827
+ """A project-dependent command addressed a key absent from local state."""
2828
+
2829
+ code = CliErrorCode.INVALID_ARGUMENT
2830
+ exit_status = ExitCode.USAGE
2831
+
2832
+ def __init__(self, key: str) -> None:
2833
+ # Names the missing selector so an agent can repair the invocation directly.
2834
+ super().__init__(
2835
+ f"Suggestion project was not found: {key}.",
2836
+ description=(
2837
+ "The requested project key is not present in the local suggestion catalog. "
2838
+ "The command did not create a project implicitly and did not modify any "
2839
+ "memory file. Create the project first or choose a key from the project list."
2840
+ ),
2841
+ trace="SuggestionProject looked up an exact project key in projects.json.",
2842
+ hint="Run 'vidbyte-cli agents suggest project list' or create the project first.",
2843
+ )
2844
+
2845
+
2846
+ class SuggestionProjectStateUnreadable(CliError):
2847
+ """The local project catalog or linked memory file is malformed or inaccessible."""
2848
+
2849
+ code = CliErrorCode.OPERATION_FAILED
2850
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2851
+
2852
+ def __init__(self, detail: str, cause: Exception | None = None) -> None:
2853
+ # Withholds file contents and absolute paths from normal agent-facing output.
2854
+ super().__init__(
2855
+ "Suggestion project state could not be read.",
2856
+ description=(
2857
+ "An existing project catalog or memory file was missing, malformed, "
2858
+ "unsupported, or inaccessible. No model call was made and no valid project "
2859
+ "memory was silently replaced. Repair or remove only the invalid suggestion "
2860
+ "state, then retry the command."
2861
+ ),
2862
+ trace=f"SuggestionProject rejected local JSON state: {detail}.",
2863
+ hint="Inspect projects.json and the linked project memory file for schema version 1.",
2864
+ cause=cause,
2865
+ )
2866
+
2867
+
2868
+ class SuggestionProjectWriteFailed(CliError):
2869
+ """The operating system refused to persist a project or feedback document."""
2870
+
2871
+ code = CliErrorCode.OPERATION_FAILED
2872
+ exit_status = ExitCode.OPERATIONAL_FAILURE
2873
+
2874
+ def __init__(self, cause: Exception) -> None:
2875
+ # States that were already valid remain on disk because writes are atomic.
2876
+ super().__init__(
2877
+ "Suggestion project state could not be written.",
2878
+ description=(
2879
+ "The local filesystem refused an atomic project or feedback replacement. "
2880
+ "The prior valid document was preserved, and the command did not report a "
2881
+ "successful mutation. Check the data directory permissions and available "
2882
+ "space before retrying."
2883
+ ),
2884
+ trace="SuggestionProject failed while replacing a local JSON document.",
2885
+ hint="Check the Vidbyte data directory and retry the command.",
2886
+ cause=cause,
2887
+ )
2888
+
2889
+
2890
+ class SuggestionFeedbackInvalid(CliError):
2891
+ """An accepted or rejected feedback request was incomplete or malformed."""
2892
+
2893
+ code = CliErrorCode.INVALID_ARGUMENT
2894
+ exit_status = ExitCode.USAGE
2895
+
2896
+ def __init__(self, detail: str) -> None:
2897
+ # Keeps feedback validation separate from project lookup and file errors.
2898
+ super().__init__(
2899
+ f"Invalid suggestion feedback: {detail}.",
2900
+ description=(
2901
+ "Feedback requires an existing project and nonempty suggestion text of at most "
2902
+ "8192 characters, with an optional reason of the same bound. The request was "
2903
+ "rejected before the memory file changed. The CLI never infers acceptance or "
2904
+ "rejection from an ambiguous user response."
2905
+ ),
2906
+ trace="SuggestionFeedbackInput validated explicit feedback before persistence.",
2907
+ hint=(
2908
+ "Pass --project and --suggestion, and call the command only for explicit feedback."
2909
+ ),
2910
+ )