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,141 @@
1
+ """Reads and atomically writes the versioned non-secret CLI configuration document.
2
+
3
+ Native state wins over legacy state, and neither is ever repaired in place: a document the
4
+ CLI cannot validate is reported, not overwritten, because a parse failure is far more often
5
+ a bug or a partial write than a file the user wanted destroyed.
6
+
7
+ Writes are read-modify-write, so `save` compares a digest of the file it is about to replace
8
+ against the one the caller read. Two shells running `config set` at once is rare, but the
9
+ losing write would silently discard the other's setting, and the check costs one read.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from pydantic import ValidationError
20
+
21
+ from ..errors.failures import (
22
+ ConfigInvalidBeforeWrite,
23
+ ConfigUnreadable,
24
+ ConfigWriteConflict,
25
+ InvalidConfigValue,
26
+ )
27
+ from .atomic import AtomicFileWriter
28
+ from .models import DEFAULT_PROFILE, ConfigDocument, ConfigField, ProfileConfig
29
+ from .paths import VidbytePaths
30
+
31
+ # Configuration is a handful of scalars; anything this large is a wrong or hostile file, and
32
+ # the bound keeps it from being read into memory before validation can reject it.
33
+ _MAX_CONFIG_BYTES = 1_000_000
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ConfigSnapshot:
38
+ """One validated document plus concurrency and provenance metadata."""
39
+
40
+ document: ConfigDocument
41
+ path: Path | None
42
+ digest: str | None
43
+ legacy: bool = False
44
+
45
+
46
+ class ConfigStore:
47
+ """Typed local store for non-secret profile configuration."""
48
+
49
+ def __init__(
50
+ self,
51
+ paths: VidbytePaths | None = None,
52
+ writer: AtomicFileWriter | None = None,
53
+ ) -> None:
54
+ self.paths = paths or VidbytePaths.default()
55
+ self._writer = writer or AtomicFileWriter()
56
+
57
+ def load(self) -> ConfigSnapshot:
58
+ # Native state wins. Legacy state stays a compatible read until migration runs, so a
59
+ # user who never logs in again keeps their settings.
60
+ native = self.paths.config_file()
61
+ if native.exists():
62
+ return self._read(native, legacy=False)
63
+ legacy = self.paths.legacy_config_file()
64
+ if legacy.exists():
65
+ return self._read(legacy, legacy=True)
66
+ return ConfigSnapshot(ConfigDocument(), None, None)
67
+
68
+ def get(self, field: ConfigField, profile: str = DEFAULT_PROFILE) -> object:
69
+ snapshot = self.load()
70
+ return getattr(self._select_profile(snapshot.document, profile), field.value)
71
+
72
+ def set(self, field: ConfigField, value: str, profile: str = DEFAULT_PROFILE) -> ProfileConfig:
73
+ snapshot = self.load()
74
+ document = snapshot.document
75
+ updated = self._updated_profile(self._select_profile(document, profile), field, value)
76
+ profiles = dict(document.profiles)
77
+ profiles[profile] = updated
78
+ active_profile = document.active_profile if document.profiles else profile
79
+ saved = ConfigDocument(active_profile=active_profile, profiles=profiles)
80
+ # The digest of the native file, not the snapshot's: a legacy read has no native
81
+ # file yet, and this write is what creates it.
82
+ self.save(saved, expected_digest=self._native_digest())
83
+ return updated
84
+
85
+ def save(self, document: ConfigDocument, *, expected_digest: str | None = None) -> None:
86
+ if self._native_digest() != expected_digest:
87
+ raise ConfigWriteConflict()
88
+ encoded = document.model_dump_json(indent=2).encode("utf-8") + b"\n"
89
+ self._writer.write(self.paths.config_file(), encoded)
90
+
91
+ def _read(self, path: Path, *, legacy: bool) -> ConfigSnapshot:
92
+ try:
93
+ raw = path.read_bytes()
94
+ if len(raw) > _MAX_CONFIG_BYTES:
95
+ raise ValueError("configuration exceeds the size limit")
96
+ parsed = json.loads(raw)
97
+ # Checked before validation so a future document reports its version rather than
98
+ # a list of fields this release happens not to know.
99
+ if isinstance(parsed, dict) and parsed.get("schema_version", 1) != 1:
100
+ raise ValueError("unsupported configuration schema")
101
+ document = ConfigDocument.model_validate(parsed)
102
+ except (OSError, UnicodeError, json.JSONDecodeError, ValidationError, ValueError) as error:
103
+ raise ConfigUnreadable(str(path), error) from error
104
+ return ConfigSnapshot(document, path, hashlib.sha256(raw).hexdigest(), legacy)
105
+
106
+ def _select_profile(self, document: ConfigDocument, profile: str) -> ProfileConfig:
107
+ # An unknown profile falls back rather than failing: a profile may exist only as a
108
+ # credential scope, with no stored settings of its own.
109
+ selected = document.profiles.get(profile)
110
+ if selected is not None:
111
+ return selected
112
+ return document.profiles.get(DEFAULT_PROFILE, ProfileConfig())
113
+
114
+ def _updated_profile(
115
+ self,
116
+ current: ProfileConfig,
117
+ field: ConfigField,
118
+ value: str,
119
+ ) -> ProfileConfig:
120
+ # Revalidating the whole profile keeps one validation path for command, environment,
121
+ # and persisted values, so `config set` cannot write what `load` would reject.
122
+ values = current.model_dump()
123
+ values[field.value] = value
124
+ try:
125
+ return ProfileConfig.model_validate(values)
126
+ except ValidationError as error:
127
+ raise InvalidConfigValue(field.value, error) from error
128
+
129
+ def _native_digest(self) -> str | None:
130
+ path = self.paths.config_file()
131
+ if not path.exists():
132
+ return None
133
+ try:
134
+ raw = path.read_bytes()
135
+ parsed = json.loads(raw)
136
+ if not isinstance(parsed, dict) or parsed.get("schema_version", 1) != 1:
137
+ raise ValueError("unsupported configuration schema")
138
+ ConfigDocument.model_validate(parsed)
139
+ except (OSError, json.JSONDecodeError, ValidationError, ValueError) as error:
140
+ raise ConfigInvalidBeforeWrite(str(path), error) from error
141
+ return hashlib.sha256(raw).hexdigest()
@@ -0,0 +1,81 @@
1
+ """Copies compatible `~/.vidbyte` state into the platform-native locations, and verifies it.
2
+
3
+ Two properties make this safe to run from any mutating command. It never deletes: the legacy
4
+ tree is left exactly as it was, so a user who downgrades still has working state. And it is
5
+ destination-preserving and idempotent: an existing native file always wins, so running it
6
+ twice does nothing the first run did not already do.
7
+
8
+ Every copy is read back and compared before it counts as migrated. A credential in
9
+ particular is only migrated once the keyring returns the same secret that was written.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass
16
+
17
+ from pydantic import ValidationError
18
+
19
+ from ..auth.credentials import Credentials
20
+ from ..auth.keyring_store import KeyringCredentialStore
21
+ from ..errors.failures import LegacyCredentialUnreadable, MigrationVerificationFailed
22
+ from .config import ConfigStore
23
+ from .models import DEFAULT_API_URL, DEFAULT_PROFILE
24
+ from .paths import VidbytePaths
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class MigrationResult:
29
+ """Safe migration facts that never contain copied content."""
30
+
31
+ config_copied: bool = False
32
+ credential_migrated: bool = False
33
+
34
+
35
+ class StateMigration:
36
+ """Idempotently copy and verify supported legacy state."""
37
+
38
+ def __init__(
39
+ self,
40
+ paths: VidbytePaths,
41
+ config: ConfigStore,
42
+ keyring: KeyringCredentialStore,
43
+ ) -> None:
44
+ self._paths = paths
45
+ self._config = config
46
+ self._keyring = keyring
47
+
48
+ def migrate_if_needed(self) -> MigrationResult:
49
+ return MigrationResult(
50
+ config_copied=self._migrate_config(),
51
+ credential_migrated=self._migrate_credential(),
52
+ )
53
+
54
+ def _migrate_config(self) -> bool:
55
+ if self._paths.config_file().exists() or not self._paths.legacy_config_file().exists():
56
+ return False
57
+ snapshot = self._config.load()
58
+ if not snapshot.legacy:
59
+ return False
60
+ # No native file exists yet, so the expected digest is None by construction.
61
+ self._config.save(snapshot.document, expected_digest=None)
62
+ verified = self._config.load()
63
+ if verified.document != snapshot.document or verified.legacy:
64
+ raise MigrationVerificationFailed()
65
+ return True
66
+
67
+ def _migrate_credential(self) -> bool:
68
+ source = self._paths.legacy_credentials_file()
69
+ if not source.exists() or not self._keyring.available():
70
+ return False
71
+ if self._keyring.read(DEFAULT_PROFILE, DEFAULT_API_URL) is not None:
72
+ return False
73
+ try:
74
+ credentials = Credentials.model_validate(json.loads(source.read_bytes()))
75
+ except (OSError, json.JSONDecodeError, ValidationError) as error:
76
+ raise LegacyCredentialUnreadable(error) from error
77
+ self._keyring.write(credentials, DEFAULT_PROFILE, DEFAULT_API_URL)
78
+ read_back = self._keyring.read(DEFAULT_PROFILE, DEFAULT_API_URL)
79
+ if read_back is None or read_back.secret_value() != credentials.secret_value():
80
+ raise MigrationVerificationFailed()
81
+ return True
@@ -0,0 +1,145 @@
1
+ """The versioned, allow-listed shape of everything the CLI is willing to persist.
2
+
3
+ `extra="forbid"` everywhere is deliberate: a typo in a hand-edited config file surfaces as a
4
+ rejection rather than a setting that silently does nothing. `schema_version` is a `Literal`
5
+ for the same reason — a future document is refused, never guessed at.
6
+
7
+ Nothing here reads the environment or the filesystem, and no field may hold a secret;
8
+ `ResolvedConfig` is the non-secret half of an invocation, `lib/auth` owns the other half.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ipaddress
14
+ from enum import StrEnum
15
+ from typing import Annotated, Literal
16
+ from urllib.parse import urlsplit, urlunsplit
17
+
18
+ from pydantic import AfterValidator, BaseModel, ConfigDict, Field, field_validator
19
+
20
+ from ..output import ColorMode, OutputFormat
21
+
22
+ DEFAULT_API_URL = "https://vidbyte-backend.onrender.com"
23
+ DEFAULT_PROFILE = "default"
24
+ ProfileName = Annotated[str, Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")]
25
+
26
+
27
+ class ConfigField(StrEnum):
28
+ """Public allow-list accepted by config get/set."""
29
+
30
+ API_URL = "api_url"
31
+ OUTPUT_FORMAT = "output_format"
32
+ COLOR = "color"
33
+ REQUEST_TIMEOUT_SECONDS = "request_timeout_seconds"
34
+
35
+
36
+ class ConfigSource(StrEnum):
37
+ """Stable provenance values for effective non-secret settings."""
38
+
39
+ COMMAND = "command"
40
+ ENVIRONMENT = "environment"
41
+ SELECTED_PROFILE = "selected_profile"
42
+ DEFAULT_PROFILE = "default_profile"
43
+ BUILT_IN = "built_in"
44
+
45
+
46
+ class ApiOrigin:
47
+ """The scheme/host/port an API base URL is allowed to be, parsed and re-rendered.
48
+
49
+ A base URL is an origin. Userinfo, a query, a fragment, or a path on one is either a
50
+ mistake or an attempt to redirect requests, so each is rejected rather than dropped — a
51
+ silently truncated URL sends credentials somewhere the caller never named.
52
+
53
+ Loopback detection lives here rather than beside it because the question is only ever
54
+ asked to decide whether cleartext HTTP is acceptable for this exact origin.
55
+ """
56
+
57
+ def __init__(self, scheme: str, host: str, port: int | None) -> None:
58
+ self.scheme = scheme
59
+ self.host = host
60
+ self.port = port
61
+
62
+ @classmethod
63
+ def parse(cls, value: str) -> ApiOrigin:
64
+ parsed = urlsplit(value.strip())
65
+ if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
66
+ raise ValueError("API URL must be an absolute HTTP or HTTPS origin")
67
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
68
+ raise ValueError("API URL cannot contain credentials, a query, or a fragment")
69
+ if parsed.path not in {"", "/"}:
70
+ raise ValueError("API URL cannot contain a path")
71
+ origin = cls(parsed.scheme, parsed.hostname.lower(), parsed.port)
72
+ if origin.scheme == "http" and not origin.is_loopback:
73
+ raise ValueError("HTTP API URLs are restricted to loopback development hosts")
74
+ return origin
75
+
76
+ @classmethod
77
+ def normalize(cls, value: str) -> str:
78
+ # The pydantic entry point: validating and canonicalizing are one operation.
79
+ return str(cls.parse(value))
80
+
81
+ @property
82
+ def is_loopback(self) -> bool:
83
+ if self.host == "localhost":
84
+ return True
85
+ try:
86
+ return ipaddress.ip_address(self.host).is_loopback
87
+ except ValueError:
88
+ return False
89
+
90
+ def __str__(self) -> str:
91
+ # urlsplit strips the brackets an IPv6 literal needs to sit in a netloc; put them back.
92
+ host = f"[{self.host}]" if ":" in self.host else self.host
93
+ netloc = f"{host}:{self.port}" if self.port is not None else host
94
+ return urlunsplit((self.scheme, netloc, "", "", ""))
95
+
96
+
97
+ NormalizedApiUrl = Annotated[str, AfterValidator(ApiOrigin.normalize)]
98
+
99
+
100
+ class ProfileConfig(BaseModel):
101
+ """Non-secret settings owned by one named CLI profile."""
102
+
103
+ model_config = ConfigDict(extra="forbid", frozen=True)
104
+
105
+ api_url: NormalizedApiUrl = DEFAULT_API_URL
106
+ output_format: OutputFormat = OutputFormat.HUMAN
107
+ color: ColorMode = ColorMode.AUTO
108
+ request_timeout_seconds: float = Field(default=30.0, ge=1.0, le=300.0)
109
+
110
+ @classmethod
111
+ def default_map(cls) -> dict[str, ProfileConfig]:
112
+ # A document always carries at least the default profile, so a fresh install
113
+ # resolves exactly like one that has been written to.
114
+ return {DEFAULT_PROFILE: cls()}
115
+
116
+
117
+ class ConfigDocument(BaseModel):
118
+ """Version-one persisted config document."""
119
+
120
+ model_config = ConfigDict(extra="forbid", frozen=True)
121
+
122
+ schema_version: Literal[1] = 1
123
+ active_profile: ProfileName = DEFAULT_PROFILE
124
+ profiles: dict[ProfileName, ProfileConfig] = Field(default_factory=ProfileConfig.default_map)
125
+
126
+ @field_validator("profiles")
127
+ @classmethod
128
+ def require_profiles(cls, value: dict[str, ProfileConfig]) -> dict[str, ProfileConfig]:
129
+ if not value:
130
+ raise ValueError("at least one profile is required")
131
+ return value
132
+
133
+
134
+ class ResolvedConfig(BaseModel):
135
+ """Effective non-secret policy and its source for one invocation."""
136
+
137
+ model_config = ConfigDict(extra="forbid", frozen=True)
138
+
139
+ profile: ProfileName
140
+ api_url: NormalizedApiUrl
141
+ output_format: OutputFormat
142
+ color: ColorMode
143
+ request_timeout_seconds: float = Field(ge=1.0, le=300.0)
144
+ provenance: dict[ConfigField, ConfigSource]
145
+ config_path: str | None = None
@@ -0,0 +1,66 @@
1
+ """Single source of truth for every path the CLI reads or writes.
2
+
3
+ Native locations come from platformdirs so config, cache, state, and data land where each
4
+ platform expects them. The historical `~/.vidbyte` tree stays exposed, but only for
5
+ compatible reads and verified migration — it is never the primary root again.
6
+
7
+ Resolution is side-effect free on purpose: an instance can be constructed for `--help` or
8
+ injected in a test without creating anything. Directories are created by the atomic writer
9
+ performing an authorized mutation, never by asking where a file lives.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from platformdirs import PlatformDirs
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class VidbytePaths:
22
+ """Platform-native CLI state roots plus the read-compatible legacy root."""
23
+
24
+ config_root: Path
25
+ cache_root: Path
26
+ state_root: Path
27
+ data_root: Path
28
+ legacy_root: Path
29
+
30
+ @classmethod
31
+ def default(cls) -> VidbytePaths:
32
+ # platformdirs handles Windows, macOS, and freedesktop conventions consistently.
33
+ # roaming=False keeps credentials off a Windows roaming profile.
34
+ directories = PlatformDirs("vidbyte-cli", "Vidbyte", roaming=False)
35
+ return cls(
36
+ config_root=Path(directories.user_config_path),
37
+ cache_root=Path(directories.user_cache_path),
38
+ state_root=Path(directories.user_state_path),
39
+ data_root=Path(directories.user_data_path),
40
+ legacy_root=Path.home() / ".vidbyte",
41
+ )
42
+
43
+ def config_file(self) -> Path:
44
+ return self.config_root / "config.json"
45
+
46
+ def credentials_file(self) -> Path:
47
+ # Data, not config: the fallback credential file must not be synced or shared as a
48
+ # settings file, and must never sit in the cache the CLI may clear.
49
+ return self.data_root / "credentials.json"
50
+
51
+ def operations_dir(self) -> Path:
52
+ return self.state_root / "operations"
53
+
54
+ def legacy_config_file(self) -> Path:
55
+ return self.legacy_root / "config.json"
56
+
57
+ def legacy_credentials_file(self) -> Path:
58
+ return self.legacy_root / "credentials.json"
59
+
60
+ def provider_credentials_file(self) -> Path:
61
+ # Data, not config: provider fallback keys must not be synced as settings.
62
+ return self.data_root / "provider-credentials.json"
63
+
64
+ def suggestions_dir(self) -> Path:
65
+ # Durable suggestion memory belongs beside other local user data, not cache state.
66
+ return self.data_root / "suggestions"
@@ -0,0 +1,137 @@
1
+ """Where one invocation's effective non-secret settings come from, and which layer won.
2
+
3
+ Precedence is command → environment → selected profile → default profile → built-in, and
4
+ every field records which of those supplied it. Provenance is not decoration: `config get`
5
+ reports the effective value, and without a source a user cannot tell a stored setting from
6
+ an environment variable they forgot was exported.
7
+
8
+ Resolution is read-only and deterministic for an injected environment — it never migrates,
9
+ never writes, and never touches a secret. `CredentialResolver` owns that separate order.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable, Mapping
15
+ from dataclasses import dataclass
16
+ from typing import TypeVar
17
+
18
+ from pydantic import ValidationError
19
+
20
+ from ..errors.failures import InvalidConfigOverride
21
+ from ..output import ColorMode, OutputFormat
22
+ from .config import ConfigStore
23
+ from .models import (
24
+ DEFAULT_API_URL,
25
+ DEFAULT_PROFILE,
26
+ ConfigField,
27
+ ConfigSource,
28
+ ProfileConfig,
29
+ ResolvedConfig,
30
+ )
31
+
32
+ _T = TypeVar("_T")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ConfigOverrides:
37
+ """Explicit root values that outrank environment and stored profiles."""
38
+
39
+ profile: str | None = None
40
+ api_url: str | None = None
41
+ output_format: OutputFormat | None = None
42
+ color: ColorMode | None = None
43
+ request_timeout_seconds: float | None = None
44
+
45
+
46
+ class ConfigResolver:
47
+ """Resolve typed invocation settings and retain how each value was chosen."""
48
+
49
+ def __init__(self, store: ConfigStore, environment: Mapping[str, str]) -> None:
50
+ self._store = store
51
+ self._environment = environment
52
+
53
+ def resolve(self, overrides: ConfigOverrides | None = None) -> ResolvedConfig:
54
+ explicit = overrides or ConfigOverrides()
55
+ snapshot = self._store.load()
56
+ document = snapshot.document
57
+ profile = explicit.profile or self._environment.get("VIDBYTE_PROFILE")
58
+ profile = profile or document.active_profile or DEFAULT_PROFILE
59
+ # With no file on disk the document is built-in defaults, so its profiles must not be
60
+ # reported as a stored source the user could go and edit.
61
+ stored = document.profiles if snapshot.path is not None else {}
62
+ selected = stored.get(profile)
63
+ default = stored.get(DEFAULT_PROFILE)
64
+ base = selected or default or ProfileConfig()
65
+ base_source = self._profile_source(selected, default)
66
+ try:
67
+ api_url, api_source = self._resolve_value(
68
+ explicit.api_url,
69
+ self._environment.get("VIDBYTE_API_URL"),
70
+ base.api_url,
71
+ base_source,
72
+ str,
73
+ )
74
+ output, output_source = self._resolve_value(
75
+ explicit.output_format,
76
+ self._environment.get("VIDBYTE_OUTPUT_FORMAT"),
77
+ base.output_format,
78
+ base_source,
79
+ OutputFormat,
80
+ )
81
+ color, color_source = self._resolve_value(
82
+ explicit.color,
83
+ self._environment.get("VIDBYTE_COLOR"),
84
+ base.color,
85
+ base_source,
86
+ ColorMode,
87
+ )
88
+ timeout, timeout_source = self._resolve_value(
89
+ explicit.request_timeout_seconds,
90
+ self._environment.get("VIDBYTE_REQUEST_TIMEOUT_SECONDS"),
91
+ base.request_timeout_seconds,
92
+ base_source,
93
+ float,
94
+ )
95
+ return ResolvedConfig(
96
+ profile=profile,
97
+ api_url=api_url or DEFAULT_API_URL,
98
+ output_format=output,
99
+ color=color,
100
+ request_timeout_seconds=timeout,
101
+ provenance={
102
+ ConfigField.API_URL: api_source,
103
+ ConfigField.OUTPUT_FORMAT: output_source,
104
+ ConfigField.COLOR: color_source,
105
+ ConfigField.REQUEST_TIMEOUT_SECONDS: timeout_source,
106
+ },
107
+ config_path=str(snapshot.path) if snapshot.path is not None else None,
108
+ )
109
+ except (ValidationError, ValueError) as error:
110
+ raise InvalidConfigOverride(error) from error
111
+
112
+ def _resolve_value(
113
+ self,
114
+ command: _T | None,
115
+ environment: str | None,
116
+ profile: _T,
117
+ profile_source: ConfigSource,
118
+ parser: Callable[[str], _T],
119
+ ) -> tuple[_T, ConfigSource]:
120
+ # An exported-but-empty variable is treated as unset; the alternative is a shell
121
+ # that silently forces built-in defaults on every invocation.
122
+ if command is not None:
123
+ return command, ConfigSource.COMMAND
124
+ if environment is not None and environment.strip():
125
+ return parser(environment.strip()), ConfigSource.ENVIRONMENT
126
+ return profile, profile_source
127
+
128
+ def _profile_source(
129
+ self,
130
+ selected: ProfileConfig | None,
131
+ default: ProfileConfig | None,
132
+ ) -> ConfigSource:
133
+ if selected is not None:
134
+ return ConfigSource.SELECTED_PROFILE
135
+ if default is not None:
136
+ return ConfigSource.DEFAULT_PROFILE
137
+ return ConfigSource.BUILT_IN
@@ -0,0 +1 @@
1
+ """Canonical enum constants shared by CLI runtime policy and execution."""