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,223 @@
1
+ """Secret-safe credential models and the keyring-first store the rest of the CLI talks to.
2
+
3
+ The key is a `SecretStr`, so it stays out of reprs, logs, and model dumps by default;
4
+ `secret_value()` and `FileCredentialStore._encode` are the only two deliberate unwrappings.
5
+
6
+ The restricted file is a fallback, never a default. It stores a key readable by anything
7
+ running as this user, so `CredentialStore.write` refuses it unless the caller passes explicit
8
+ consent — a decision that belongs to the person logging in, not to this layer.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from enum import StrEnum
15
+ from typing import Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator
18
+
19
+ from ..config.atomic import AtomicFileWriter
20
+ from ..config.models import DEFAULT_API_URL, DEFAULT_PROFILE
21
+ from ..config.paths import VidbytePaths
22
+ from ..errors.failures import (
23
+ LegacyCredentialRemovalFailed,
24
+ LegacyCredentialSymlink,
25
+ LegacyCredentialUnreadable,
26
+ NoApprovedCredentialStore,
27
+ StoredCredentialUnreadable,
28
+ )
29
+ from .keyring_store import CredentialScope, KeyringCredentialStore
30
+
31
+ # Bounds a hostile or accidentally-redirected file before it is parsed into memory.
32
+ _MAX_CREDENTIAL_BYTES = 1_000_000
33
+ _MAX_KEY_CHARACTERS = 4096
34
+ # The only prefix the backend gatekeeper extracts. A key without it yields no candidate at
35
+ # all, so the request is not rejected as malformed — it is treated as unauthenticated.
36
+ LIVE_API_KEY_PREFIX = "vb_live_"
37
+
38
+
39
+ class Credentials(BaseModel):
40
+ """One opaque Vidbyte API token with secret-safe representation."""
41
+
42
+ model_config = ConfigDict(extra="forbid", frozen=True)
43
+
44
+ api_key: SecretStr
45
+
46
+ @field_validator("api_key")
47
+ @classmethod
48
+ def validate_api_key(cls, value: SecretStr) -> SecretStr:
49
+ if not 1 <= len(value.get_secret_value()) <= _MAX_KEY_CHARACTERS:
50
+ raise ValueError("API key must contain between 1 and 4096 characters")
51
+ return value
52
+
53
+ @classmethod
54
+ def from_value(cls, value: str) -> Credentials:
55
+ return cls(api_key=SecretStr(value))
56
+
57
+ @classmethod
58
+ def is_live_format(cls, value: str) -> bool:
59
+ # Asked at the input boundaries only. Making it a field invariant would make a stored
60
+ # non-live key unreadable, and so unremovable by the logout that exists to clear it.
61
+ return value.startswith(LIVE_API_KEY_PREFIX)
62
+
63
+ def secret_value(self) -> str:
64
+ return self.api_key.get_secret_value()
65
+
66
+
67
+ class CredentialStorage(StrEnum):
68
+ """Durable storage selected for a verified login."""
69
+
70
+ KEYRING = "keyring"
71
+ RESTRICTED_FILE = "restricted_file"
72
+
73
+
74
+ class CredentialDocument(BaseModel):
75
+ """Version-one restricted fallback file."""
76
+
77
+ model_config = ConfigDict(extra="forbid", frozen=True)
78
+
79
+ schema_version: Literal[1] = 1
80
+ entries: dict[str, SecretStr] = Field(default_factory=dict)
81
+
82
+
83
+ class FileCredentialStore:
84
+ """Permission-restricted fallback store, never an automatic first choice."""
85
+
86
+ def __init__(self, paths: VidbytePaths, writer: AtomicFileWriter | None = None) -> None:
87
+ self._paths = paths
88
+ self._writer = writer or AtomicFileWriter()
89
+
90
+ def read(self, profile: str, api_url: str) -> Credentials | None:
91
+ secret = self._load().entries.get(CredentialScope(profile, api_url).account)
92
+ if secret is not None:
93
+ return Credentials(api_key=secret)
94
+ return self._read_legacy_unscoped(profile, api_url)
95
+
96
+ def write(self, credentials: Credentials, profile: str, api_url: str) -> None:
97
+ document = self._load()
98
+ entries = dict(document.entries)
99
+ entries[CredentialScope(profile, api_url).account] = credentials.api_key
100
+ self._writer.write(
101
+ self._paths.credentials_file(),
102
+ self._encode(CredentialDocument(entries=entries)),
103
+ )
104
+
105
+ def clear(self, profile: str, api_url: str) -> bool:
106
+ document = self._load()
107
+ account = CredentialScope(profile, api_url).account
108
+ if account not in document.entries:
109
+ return False
110
+ entries = dict(document.entries)
111
+ del entries[account]
112
+ self._writer.write(
113
+ self._paths.credentials_file(),
114
+ self._encode(CredentialDocument(entries=entries)),
115
+ )
116
+ return True
117
+
118
+ def clear_legacy(self, profile: str, api_url: str) -> bool:
119
+ # Logout is an explicit destructive request. Unlike migration, which preserves the
120
+ # legacy tree, it must ensure an old unscoped token cannot become effective again
121
+ # once the scoped stores are empty.
122
+ if profile != DEFAULT_PROFILE or api_url != DEFAULT_API_URL:
123
+ return False
124
+ path = self._paths.legacy_credentials_file()
125
+ if not path.exists():
126
+ return False
127
+ if path.is_symlink():
128
+ raise LegacyCredentialSymlink()
129
+ try:
130
+ path.unlink()
131
+ except OSError as error:
132
+ raise LegacyCredentialRemovalFailed(error) from error
133
+ return True
134
+
135
+ def _load(self) -> CredentialDocument:
136
+ path = self._paths.credentials_file()
137
+ if not path.exists():
138
+ return CredentialDocument()
139
+ try:
140
+ raw = path.read_bytes()
141
+ if len(raw) > _MAX_CREDENTIAL_BYTES:
142
+ raise ValueError("credential file exceeds the size limit")
143
+ parsed = json.loads(raw)
144
+ if isinstance(parsed, dict) and parsed.get("schema_version", 1) != 1:
145
+ raise ValueError("unsupported credential schema")
146
+ return CredentialDocument.model_validate(parsed)
147
+ except (OSError, json.JSONDecodeError, ValidationError, ValueError) as error:
148
+ raise StoredCredentialUnreadable(error) from error
149
+
150
+ def _read_legacy_unscoped(self, profile: str, api_url: str) -> Credentials | None:
151
+ # The legacy file predates scoping, so it can only answer for the default scope.
152
+ if profile != DEFAULT_PROFILE or api_url != DEFAULT_API_URL:
153
+ return None
154
+ path = self._paths.legacy_credentials_file()
155
+ if not path.exists():
156
+ return None
157
+ try:
158
+ raw = path.read_bytes()
159
+ if len(raw) > _MAX_CREDENTIAL_BYTES:
160
+ raise ValueError("legacy credential file exceeds the size limit")
161
+ return Credentials.model_validate(json.loads(raw))
162
+ except (OSError, json.JSONDecodeError, ValidationError, ValueError) as error:
163
+ raise LegacyCredentialUnreadable(error) from error
164
+
165
+ def _encode(self, document: CredentialDocument) -> bytes:
166
+ # SecretStr's JSON serializer redacts values, which is right everywhere except here:
167
+ # this is the one intentional unwrapping point for the restricted credential file.
168
+ payload = {
169
+ "schema_version": document.schema_version,
170
+ "entries": {
171
+ account: secret.get_secret_value() for account, secret in document.entries.items()
172
+ },
173
+ }
174
+ return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + b"\n"
175
+
176
+
177
+ class CredentialStore:
178
+ """Keyring-first durable credential facade."""
179
+
180
+ def __init__(
181
+ self,
182
+ keyring_store: KeyringCredentialStore | None = None,
183
+ file_store: FileCredentialStore | None = None,
184
+ paths: VidbytePaths | None = None,
185
+ ) -> None:
186
+ resolved_paths = paths or VidbytePaths.default()
187
+ self.keyring = keyring_store or KeyringCredentialStore()
188
+ self.file = file_store or FileCredentialStore(resolved_paths)
189
+
190
+ def read(
191
+ self,
192
+ profile: str = DEFAULT_PROFILE,
193
+ api_url: str = DEFAULT_API_URL,
194
+ ) -> Credentials | None:
195
+ return self.keyring.read(profile, api_url) or self.file.read(profile, api_url)
196
+
197
+ def write(
198
+ self,
199
+ credentials: Credentials,
200
+ profile: str = DEFAULT_PROFILE,
201
+ api_url: str = DEFAULT_API_URL,
202
+ *,
203
+ allow_file_fallback: bool = False,
204
+ ) -> CredentialStorage:
205
+ if self.keyring.available():
206
+ self.keyring.write(credentials, profile, api_url)
207
+ return CredentialStorage.KEYRING
208
+ if not allow_file_fallback:
209
+ raise NoApprovedCredentialStore()
210
+ self.file.write(credentials, profile, api_url)
211
+ return CredentialStorage.RESTRICTED_FILE
212
+
213
+ def clear(
214
+ self,
215
+ profile: str = DEFAULT_PROFILE,
216
+ api_url: str = DEFAULT_API_URL,
217
+ ) -> bool:
218
+ # Every store is cleared, not just the first that answers: a stale entry left in one
219
+ # of them would keep authenticating after the user asked to be logged out.
220
+ keyring_cleared = self.keyring.clear(profile, api_url)
221
+ file_cleared = self.file.clear(profile, api_url)
222
+ legacy_cleared = self.file.clear_legacy(profile, api_url)
223
+ return keyring_cleared or file_cleared or legacy_cleared
@@ -0,0 +1,59 @@
1
+ """The two channels a login token may arrive on: a hidden prompt, or explicit stdin.
2
+
3
+ There is deliberately no `--api-key` option. An argv secret is visible in `ps`, the shell
4
+ history file, and any CI log that echoes its command line, and no amount of care at the
5
+ raise sites takes it back out of those.
6
+
7
+ Stdin is read only when `--with-token` asks for it, so a command whose input happens to be
8
+ redirected fails with a usage error instead of consuming a stream meant for something else.
9
+ The environment is absent on purpose: login persists only what a person explicitly entered.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import getpass
15
+
16
+ from ..errors.failures import (
17
+ ApiKeyNotLiveFormat,
18
+ InvalidApiKeyInput,
19
+ NoninteractiveLoginRequiresToken,
20
+ )
21
+ from ..io import IOStreams
22
+ from ..io.terminal import TerminalCapabilities
23
+ from .credentials import Credentials
24
+
25
+ _MAX_TOKEN_CHARACTERS = 4096
26
+
27
+
28
+ class CredentialInput:
29
+ """Acquire one token through an explicit secret-safe channel."""
30
+
31
+ def __init__(
32
+ self,
33
+ streams: IOStreams,
34
+ terminal: TerminalCapabilities,
35
+ *,
36
+ no_input: bool,
37
+ ) -> None:
38
+ self._streams = streams
39
+ self._terminal = terminal
40
+ self._no_input = no_input
41
+
42
+ def read(self, *, from_stdin: bool) -> Credentials:
43
+ if from_stdin:
44
+ # One past the bound, so an oversized stream is rejected rather than truncated
45
+ # into a token that looks plausible.
46
+ value = self._streams.stdin.read(_MAX_TOKEN_CHARACTERS + 1)
47
+ else:
48
+ if self._no_input or not self._terminal.interactive:
49
+ raise NoninteractiveLoginRequiresToken()
50
+ # The prompt goes to stderr so `login` stays usable inside a pipeline.
51
+ value = getpass.getpass("Vidbyte API key: ", stream=self._streams.stderr)
52
+ token = value.strip()
53
+ if not token or len(token) > _MAX_TOKEN_CHARACTERS:
54
+ raise InvalidApiKeyInput()
55
+ # Bounds run first, so an accidental file redirect is reported as oversized rather
56
+ # than prefix-tested in full.
57
+ if not Credentials.is_live_format(token):
58
+ raise ApiKeyNotLiveFormat()
59
+ return Credentials.from_value(token)
@@ -0,0 +1,126 @@
1
+ """The system keyring, adapted to profile- and API-origin-scoped Vidbyte credentials.
2
+
3
+ Every stored secret is scoped, so one profile cannot read another's key and a staging login
4
+ cannot authenticate against production. A backend failure is translated, never propagated:
5
+ keyring exceptions routinely quote account identifiers and backend internals.
6
+
7
+ A write is not believed until the same value reads back. Some backends accept a write and
8
+ store nothing, and a login that reports success while storing nothing is worse than failing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Protocol, cast
14
+
15
+ import keyring
16
+ from keyring.errors import KeyringError
17
+ from pydantic import ValidationError
18
+
19
+ from ..config.models import ApiOrigin
20
+ from ..errors.failures import CredentialStoreUnavailable
21
+
22
+ if TYPE_CHECKING:
23
+ from .credentials import Credentials
24
+
25
+ _SERVICE_NAME = "vidbyte-cli"
26
+ # keyring's own null and fail backends sit below 1; anything at or above it is a real store.
27
+ _MINIMUM_BACKEND_PRIORITY = 1
28
+
29
+
30
+ class KeyringBackend(Protocol):
31
+ """Small structural surface needed from an installed keyring backend."""
32
+
33
+ priority: float
34
+
35
+ def get_password(self, service: str, username: str) -> str | None: ...
36
+
37
+ def set_password(self, service: str, username: str, password: str) -> None: ...
38
+
39
+ def delete_password(self, service: str, username: str) -> None: ...
40
+
41
+
42
+ class CredentialScope:
43
+ """The profile and API origin that one stored secret belongs to."""
44
+
45
+ def __init__(self, profile: str, api_url: str) -> None:
46
+ self.profile = profile
47
+ self.origin = ApiOrigin.parse(api_url)
48
+
49
+ @property
50
+ def account(self) -> str:
51
+ # The default port is omitted so `https://example.test` and `https://example.test:443`
52
+ # resolve to one entry rather than two credentials that silently disagree.
53
+ port = self.origin.port
54
+ scheme = self.origin.scheme
55
+ is_default_port = port is None or (scheme, port) in {("https", 443), ("http", 80)}
56
+ host = self.origin.host if is_default_port else f"{self.origin.host}:{port}"
57
+ return f"{self.profile}@{host}"
58
+
59
+
60
+ class KeyringCredentialStore:
61
+ """Secret store backed by the active system keyring."""
62
+
63
+ def __init__(self, backend: KeyringBackend | None = None) -> None:
64
+ self._backend = backend
65
+
66
+ def available(self) -> bool:
67
+ # Availability is a question, not a failure: callers branch on it to choose the
68
+ # fallback path, so a broken backend answers False instead of raising.
69
+ try:
70
+ return float(self._get_backend().priority) >= _MINIMUM_BACKEND_PRIORITY
71
+ except (KeyringError, RuntimeError, TypeError, ValueError):
72
+ return False
73
+
74
+ def read(self, profile: str, api_url: str) -> Credentials | None:
75
+ if not self.available():
76
+ return None
77
+ try:
78
+ value = self._get_backend().get_password(
79
+ _SERVICE_NAME,
80
+ CredentialScope(profile, api_url).account,
81
+ )
82
+ except KeyringError as error:
83
+ raise CredentialStoreUnavailable(error) from error
84
+ if value is None:
85
+ return None
86
+ # Deferred: importing at module scope would cycle, since credentials.py needs this
87
+ # module's scope type to address its own entries.
88
+ from .credentials import Credentials
89
+
90
+ try:
91
+ return Credentials.from_value(value)
92
+ except ValidationError as error:
93
+ raise CredentialStoreUnavailable(error) from error
94
+
95
+ def write(self, credentials: Credentials, profile: str, api_url: str) -> None:
96
+ if not self.available():
97
+ raise CredentialStoreUnavailable()
98
+ account = CredentialScope(profile, api_url).account
99
+ try:
100
+ backend = self._get_backend()
101
+ backend.set_password(_SERVICE_NAME, account, credentials.secret_value())
102
+ read_back = backend.get_password(_SERVICE_NAME, account)
103
+ except KeyringError as error:
104
+ raise CredentialStoreUnavailable(error) from error
105
+ if read_back != credentials.secret_value():
106
+ raise CredentialStoreUnavailable()
107
+
108
+ def clear(self, profile: str, api_url: str) -> bool:
109
+ if not self.available():
110
+ return False
111
+ account = CredentialScope(profile, api_url).account
112
+ try:
113
+ backend = self._get_backend()
114
+ if backend.get_password(_SERVICE_NAME, account) is None:
115
+ return False
116
+ backend.delete_password(_SERVICE_NAME, account)
117
+ return True
118
+ except KeyringError as error:
119
+ raise CredentialStoreUnavailable(error) from error
120
+
121
+ def _get_backend(self) -> KeyringBackend:
122
+ # Resolved on first use and cached: backend discovery can open a keychain session,
123
+ # which must not happen while the command tree is being built.
124
+ if self._backend is None:
125
+ self._backend = cast(KeyringBackend, keyring.get_keyring())
126
+ return self._backend
@@ -0,0 +1,157 @@
1
+ """Secret-safe provider credential model and file document.
2
+
3
+ The key is a SecretStr so it stays out of reprs and dumps by default.
4
+ Scopes are profile@provider, not profile@host, so Vidbyte and provider keys never collide.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from enum import StrEnum
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator
14
+
15
+ from ...types.provider import PROVIDER_KEY_PREFIXES, Provider
16
+ from ..config.atomic import AtomicFileWriter
17
+ from ..config.paths import VidbytePaths
18
+ from ..errors.failures import StoredProviderCredentialUnreadable
19
+ from .keyring_store import CredentialScope # noqa: F401 - re-export scope concept reference
20
+
21
+ _MAX_CREDENTIAL_BYTES = 1_000_000
22
+ _MAX_KEY_CHARACTERS = 4096
23
+
24
+
25
+ class ProviderCredentials(BaseModel):
26
+ """One provider API token with secret-safe representation."""
27
+
28
+ model_config = ConfigDict(extra="forbid", frozen=True)
29
+
30
+ provider: Provider
31
+ api_key: SecretStr
32
+
33
+ @field_validator("api_key")
34
+ @classmethod
35
+ def validate_api_key(cls, value: SecretStr) -> SecretStr:
36
+ # Bounds before any network or storage write.
37
+ if not 1 <= len(value.get_secret_value()) <= _MAX_KEY_CHARACTERS:
38
+ raise ValueError("API key must contain between 1 and 4096 characters")
39
+ return value
40
+
41
+ @classmethod
42
+ def is_live_format(cls, provider: Provider, value: str) -> bool:
43
+ # Prefix: openai/deepseek sk- not sk-ant-; claude sk-ant-; muse LLM|; gemini AIza.
44
+ if provider == Provider.CLAUDE:
45
+ return value.startswith("sk-ant-")
46
+ if provider in (Provider.OPENAI, Provider.DEEPSEEK):
47
+ return value.startswith("sk-") and not value.startswith("sk-ant-")
48
+ if provider == Provider.MUSE:
49
+ return value.startswith("LLM|")
50
+ if provider == Provider.GEMINI:
51
+ return value.startswith("AIza")
52
+ if provider in (Provider.GROK, Provider.GLM):
53
+ return bool(value)
54
+ prefixes = PROVIDER_KEY_PREFIXES.get(provider, ())
55
+ if not prefixes:
56
+ return bool(value)
57
+ return any(value.startswith(prefix) for prefix in prefixes)
58
+
59
+ def secret_value(self) -> str:
60
+ # Single deliberate unwrapping point outside the file encoder.
61
+ return self.api_key.get_secret_value()
62
+
63
+
64
+ class ProviderStorage(StrEnum):
65
+ """Durable storage selected for a verified provider login."""
66
+
67
+ KEYRING = "keyring"
68
+ RESTRICTED_FILE = "restricted_file"
69
+
70
+
71
+ class ProviderDocument(BaseModel):
72
+ """Version-one restricted fallback file for provider keys."""
73
+
74
+ model_config = ConfigDict(extra="forbid", frozen=True)
75
+
76
+ schema_version: Literal[1] = 1
77
+ entries: dict[str, SecretStr] = Field(default_factory=dict)
78
+
79
+
80
+ class ProviderScope:
81
+ """The profile and provider that one stored secret belongs to."""
82
+
83
+ def __init__(self, profile: str, provider: Provider) -> None:
84
+ # Minimal scope; provider is already a closed StrEnum.
85
+ self.profile = profile
86
+ self.provider = provider
87
+
88
+ @property
89
+ def account(self) -> str:
90
+ # Mirrors CredentialScope but keyed by provider name.
91
+ return f"{self.profile}@{self.provider.value}"
92
+
93
+
94
+ class FileProviderStore:
95
+ """Permission-restricted fallback store for provider keys."""
96
+
97
+ def __init__(self, paths: VidbytePaths, writer: AtomicFileWriter | None = None) -> None:
98
+ # Paths injected so tests can point at a temp directory.
99
+ self._paths = paths
100
+ self._writer = writer or AtomicFileWriter()
101
+
102
+ def read(self, profile: str, provider: Provider) -> ProviderCredentials | None:
103
+ # Looks up one entry; missing file means no stored key.
104
+ secret = self._load().entries.get(ProviderScope(profile, provider).account)
105
+ if secret is None:
106
+ return None
107
+ return ProviderCredentials(provider=provider, api_key=secret)
108
+
109
+ def write(self, credentials: ProviderCredentials, profile: str, provider: Provider) -> None:
110
+ # Inserts or replaces one entry and rewrites the whole file atomically.
111
+ document = self._load()
112
+ entries = dict(document.entries)
113
+ entries[ProviderScope(profile, provider).account] = credentials.api_key
114
+ self._writer.write(
115
+ self._paths.provider_credentials_file(),
116
+ self._encode(ProviderDocument(entries=entries)),
117
+ )
118
+
119
+ def clear(self, profile: str, provider: Provider) -> bool:
120
+ # Removes one entry if present; no-op otherwise.
121
+ document = self._load()
122
+ account = ProviderScope(profile, provider).account
123
+ if account not in document.entries:
124
+ return False
125
+ entries = dict(document.entries)
126
+ del entries[account]
127
+ self._writer.write(
128
+ self._paths.provider_credentials_file(),
129
+ self._encode(ProviderDocument(entries=entries)),
130
+ )
131
+ return True
132
+
133
+ def _load(self) -> ProviderDocument:
134
+ # Reads and validates the whole file, bounded before parsing.
135
+ path = self._paths.provider_credentials_file()
136
+ if not path.exists():
137
+ return ProviderDocument()
138
+ try:
139
+ raw = path.read_bytes()
140
+ if len(raw) > _MAX_CREDENTIAL_BYTES:
141
+ raise ValueError("credential file exceeds the size limit")
142
+ parsed = json.loads(raw)
143
+ if isinstance(parsed, dict) and parsed.get("schema_version", 1) != 1:
144
+ raise ValueError("unsupported credential schema")
145
+ return ProviderDocument.model_validate(parsed)
146
+ except (OSError, json.JSONDecodeError, ValidationError, ValueError) as error:
147
+ raise StoredProviderCredentialUnreadable(error) from error
148
+
149
+ def _encode(self, document: ProviderDocument) -> bytes:
150
+ # One intentional unwrapping point for the restricted file.
151
+ payload = {
152
+ "schema_version": document.schema_version,
153
+ "entries": {
154
+ account: secret.get_secret_value() for account, secret in document.entries.items()
155
+ },
156
+ }
157
+ return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + b"\n"
@@ -0,0 +1,54 @@
1
+ """Acquisition of provider tokens via hidden prompt or stdin.
2
+
3
+ Mirrors CredentialInput but parameterizes by provider so prefix checks are exact.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import getpass
9
+
10
+ from ...types.provider import Provider
11
+ from ..errors.failures import (
12
+ InvalidProviderApiKeyInput,
13
+ NoninteractiveProviderLoginRequiresToken,
14
+ ProviderKeyNotLiveFormat,
15
+ )
16
+ from ..io import IOStreams
17
+ from ..io.terminal import TerminalCapabilities
18
+ from .provider_credentials import ProviderCredentials
19
+
20
+ _MAX_TOKEN_CHARACTERS = 4096
21
+
22
+
23
+ class ProviderCredentialInput:
24
+ """Acquire one provider token through an explicit secret-safe channel."""
25
+
26
+ def __init__(
27
+ self,
28
+ streams: IOStreams,
29
+ terminal: TerminalCapabilities,
30
+ *,
31
+ no_input: bool,
32
+ provider: Provider,
33
+ ) -> None:
34
+ # No_input and provider are fixed per command construction.
35
+ self._streams = streams
36
+ self._terminal = terminal
37
+ self._no_input = no_input
38
+ self._provider = provider
39
+
40
+ def read(self, *, from_stdin: bool) -> ProviderCredentials:
41
+ # Bounded read, then prefix check, so an accidental file redirect fails clearly.
42
+ if from_stdin:
43
+ value = self._streams.stdin.read(_MAX_TOKEN_CHARACTERS + 1)
44
+ else:
45
+ if self._no_input or not self._terminal.interactive:
46
+ raise NoninteractiveProviderLoginRequiresToken(self._provider.value)
47
+ prompt = f"{self._provider.value} API key: "
48
+ value = getpass.getpass(prompt, stream=self._streams.stderr)
49
+ token = value.strip()
50
+ if not token or len(token) > _MAX_TOKEN_CHARACTERS:
51
+ raise InvalidProviderApiKeyInput(self._provider.value)
52
+ if not ProviderCredentials.is_live_format(self._provider, token):
53
+ raise ProviderKeyNotLiveFormat(self._provider.value)
54
+ return ProviderCredentials(provider=self._provider, api_key=token) # type: ignore[arg-type]