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
File without changes
@@ -0,0 +1,64 @@
1
+ """`vidbyte-cli doctor` — checks the CLI environment and credentials.
2
+
3
+ Diagnosis is read-only: doctor never migrates or repairs, because a user running it to find
4
+ out what is wrong should not have the answer change underneath them. It reports whether a
5
+ credential exists and where it came from, never its value. API identity and repository
6
+ checks arrive with the HTTP platform.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import click
12
+ from pydantic import JsonValue
13
+
14
+ from ...lib.auth.credentials import Credentials
15
+ from ...lib.output import OutputDocument
16
+ from ...lib.runtime.context import ApplicationContext
17
+
18
+
19
+ class DoctorCommand:
20
+ def register(self, parent: click.Group) -> None:
21
+ @parent.command(name="doctor", help="Diagnose local CLI configuration and credentials")
22
+ @click.pass_obj
23
+ def _run(context: ApplicationContext) -> None:
24
+ self.execute(context)
25
+
26
+ def execute(self, context: ApplicationContext) -> None:
27
+ config = context.resolved_config()
28
+ store = context.credential_store()
29
+ credential = context.credential_resolver().resolve(config.profile, config.api_url)
30
+ keyring_available = store.keyring.available()
31
+ legacy_present = context.paths().legacy_root.exists()
32
+ # A credential written by hand rather than by login never crossed the input guard, so
33
+ # the format is reported: the backend ignores a non-live key, which otherwise looks
34
+ # exactly like being logged out.
35
+ live_format = (
36
+ Credentials.is_live_format(credential.credentials.secret_value())
37
+ if credential is not None
38
+ else None
39
+ )
40
+ data: dict[str, JsonValue] = {
41
+ "profile": config.profile,
42
+ "api_url": config.api_url,
43
+ "config_path": config.config_path,
44
+ "credential_present": credential is not None,
45
+ "credential_source": credential.source.value if credential is not None else None,
46
+ "credential_live_format": live_format,
47
+ "keyring_available": keyring_available,
48
+ "legacy_state_present": legacy_present,
49
+ }
50
+ credential_status = (
51
+ f"present ({credential.source.value})" if credential is not None else "not found"
52
+ )
53
+ if live_format is False:
54
+ credential_status += " - not a live key, the backend will ignore it"
55
+ human = "\n".join(
56
+ (
57
+ f"Profile: {config.profile}",
58
+ f"API URL: {config.api_url}",
59
+ f"Credentials: {credential_status}",
60
+ f"OS keyring: {'available' if keyring_available else 'unavailable'}",
61
+ f"Legacy state: {'present' if legacy_present else 'not found'}",
62
+ )
63
+ )
64
+ context.output().result(OutputDocument(kind="doctor.local", data=data), human)
@@ -0,0 +1 @@
1
+ """Markdown assets used by setup command help."""
@@ -0,0 +1,7 @@
1
+ # `--browser`
2
+
3
+ ## Purpose
4
+ This flag skips the GitHub CLI and opens the public repository page in the default browser instead. Use it when you prefer to click Star yourself, or when the GitHub CLI is logged in to an account you do not want to star from. With this flag, the command never invokes the GitHub CLI, so no GitHub API request is made. The repository URL is printed as well, so it still works on a machine without a browser. The resulting record reports the method as `browser` and the star as unconfirmed.
5
+
6
+ ## Default
7
+ The flag is off by default, which tries the GitHub CLI first and falls back to the browser only when that fails. Leaving it off is the faster path for people who already use the GitHub CLI. Turning it on is the right choice in shared or borrowed environments where the logged-in GitHub account is not your own. The flag takes no value and has no environment-variable equivalent.
@@ -0,0 +1,18 @@
1
+ """Loads caller-facing setup help from packaged Markdown assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import resources
6
+
7
+
8
+ class SetupHelpLibrary:
9
+ """Reads each setup help description from its named Markdown asset."""
10
+
11
+ def __init__(self) -> None:
12
+ self._cache: dict[str, str] = {}
13
+
14
+ def load(self, name: str) -> str:
15
+ if name not in self._cache:
16
+ asset = resources.files(__package__).joinpath(f"{name}.md")
17
+ self._cache[name] = asset.read_text(encoding="utf-8").strip()
18
+ return self._cache[name]
@@ -0,0 +1,13 @@
1
+ # Star the Vidbyte CLI
2
+
3
+ ## Purpose
4
+ This command stars the public Vidbyte CLI repository on GitHub for the person running it. It exists so a user who finds the CLI useful can show support without leaving the terminal. It never runs on its own: no other command, hint, or error path invokes it. An agent driving this CLI must never run it on a user's behalf, because starring is a personal endorsement and GitHub forbids inauthentic interactions. Run it only when the user has asked to star the project.
5
+
6
+ ## How it stars
7
+ When the GitHub CLI (`gh`) is installed and logged in, the command asks it to star the repository through the GitHub API. The GitHub CLI owns the login, so this package never sees, stores, or prompts for a GitHub token. Starring is idempotent, so running the command again on an already-starred repository succeeds without any change. If the GitHub CLI is missing, logged out, or fails, the command opens the repository page in the default browser instead. The repository URL is always printed so it can be opened by hand when no browser is available.
8
+
9
+ ## Output
10
+ On success, the human result names the starred repository, and machine formats emit one `setup.star` record on stdout. That record carries the repository, its URL, the method used (`gh` or `browser`), and whether the star was confirmed. A star made through the browser cannot be confirmed by the CLI, so that record reports it as unconfirmed. When the GitHub CLI path fails, the reason is written to stderr as a diagnostic before the browser fallback runs. Neither path changes local CLI configuration or Vidbyte credentials.
11
+
12
+ ## Examples
13
+ Run `vidbyte-cli star` to star through the GitHub CLI when it is logged in. Run `vidbyte-cli star --browser` to skip the GitHub CLI and click Star on the repository page yourself. Run `gh auth login` first if you want the terminal path and the GitHub CLI is not yet logged in. Removing a star is done on the repository page, since this command only ever adds one.
@@ -0,0 +1,132 @@
1
+ """`vidbyte-cli star` — stars the public Vidbyte CLI repository on GitHub.
2
+
3
+ It runs only because a person typed it: starring on someone's behalf breaks GitHub's rules on
4
+ inauthentic interactions, so no other command, hint, or error path may call it. The GitHub CLI
5
+ owns authentication when it is present, so this package never holds a GitHub token; without
6
+ it, the repository page opens and the person clicks Star themselves.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING
16
+
17
+ import click
18
+ from pydantic import JsonValue
19
+
20
+ from ...lib.output import OutputDocument
21
+ from .prompts.library import SetupHelpLibrary
22
+
23
+ if TYPE_CHECKING:
24
+ from ...lib.runtime.context import ApplicationContext
25
+
26
+ # The public front-door repository, not the private source repository. It must name the same
27
+ # repository as `Repository` under [project.urls]; scripts/test-star-command.py enforces that.
28
+ REPOSITORY = "cerredz/vidbyte-cli"
29
+ REPOSITORY_URL = f"https://github.com/{REPOSITORY}"
30
+ _GH_TIMEOUT_SECONDS = 30
31
+
32
+ _STAR_HELP = SetupHelpLibrary().load("star")
33
+ _BROWSER_HELP = SetupHelpLibrary().load("browser")
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class GhAttempt:
38
+ """Whether the GitHub CLI starred the repository, and why not when it did not."""
39
+
40
+ starred: bool
41
+ detail: str
42
+
43
+
44
+ class GitHubCliStarrer:
45
+ """Stars a repository through the GitHub CLI's own login."""
46
+
47
+ def __init__(
48
+ self,
49
+ which: Callable[[str], str | None] = shutil.which,
50
+ run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
51
+ ) -> None:
52
+ self._which = which
53
+ self._run = run
54
+
55
+ def star(self, repository: str) -> GhAttempt:
56
+ executable = self._which("gh")
57
+ if executable is None:
58
+ return GhAttempt(starred=False, detail="the GitHub CLI (gh) is not installed")
59
+ # stdin is closed so a logged-out gh fails fast instead of waiting on a prompt.
60
+ command = [executable, "api", "--method", "PUT", f"/user/starred/{repository}", "--silent"]
61
+ try:
62
+ completed = self._run(
63
+ command,
64
+ stdin=subprocess.DEVNULL,
65
+ capture_output=True,
66
+ text=True,
67
+ check=False,
68
+ timeout=_GH_TIMEOUT_SECONDS,
69
+ )
70
+ except (OSError, subprocess.TimeoutExpired) as error:
71
+ return GhAttempt(starred=False, detail=f"the GitHub CLI could not run: {error}")
72
+ if completed.returncode == 0:
73
+ return GhAttempt(starred=True, detail="starred through the GitHub CLI")
74
+ reason = (completed.stderr or "").strip().splitlines()
75
+ detail = reason[-1] if reason else f"gh exited with status {completed.returncode}"
76
+ return GhAttempt(starred=False, detail=detail)
77
+
78
+
79
+ class StarCommand:
80
+ """Registers the one command that stars the public repository, and only on request."""
81
+
82
+ def __init__(
83
+ self,
84
+ starrer: GitHubCliStarrer | None = None,
85
+ launch: Callable[[str], int] = click.launch,
86
+ ) -> None:
87
+ self._starrer = starrer or GitHubCliStarrer()
88
+ self._launch = launch
89
+
90
+ def register(self, parent: click.Group) -> None:
91
+ @parent.command(name="star", help=_STAR_HELP)
92
+ @click.option("--browser", is_flag=True, help=_BROWSER_HELP)
93
+ @click.pass_obj
94
+ def _run(context: ApplicationContext, browser: bool) -> None:
95
+ self.execute(context, browser)
96
+
97
+ def execute(self, context: ApplicationContext, browser: bool) -> None:
98
+ output = context.output()
99
+ if not browser:
100
+ attempt = self._starrer.star(REPOSITORY)
101
+ if attempt.starred:
102
+ output.result(
103
+ self._document("gh", starred=True),
104
+ f"Starred {REPOSITORY} on GitHub. Thank you!",
105
+ )
106
+ return
107
+ output.diagnostic(f"Could not star through the GitHub CLI: {attempt.detail}.")
108
+ opened = self._open_browser()
109
+ action = "Opened" if opened else "Open"
110
+ output.result(
111
+ self._document("browser", starred=False, browser_opened=opened),
112
+ f"{action} {REPOSITORY_URL} and click Star at the top right. Thank you!",
113
+ )
114
+
115
+ def _open_browser(self) -> bool:
116
+ # A headless machine has no browser; the printed URL is the fallback, not an error.
117
+ try:
118
+ return self._launch(REPOSITORY_URL) == 0
119
+ except OSError:
120
+ return False
121
+
122
+ @staticmethod
123
+ def _document(method: str, starred: bool, browser_opened: bool | None = None) -> OutputDocument:
124
+ data: dict[str, JsonValue] = {
125
+ "repository": REPOSITORY,
126
+ "url": REPOSITORY_URL,
127
+ "method": method,
128
+ "starred": starred,
129
+ }
130
+ if browser_opened is not None:
131
+ data["browser_opened"] = browser_opened
132
+ return OutputDocument(kind="setup.star", data=data)
File without changes
File without changes
@@ -0,0 +1,236 @@
1
+ """Typed HTTP client for the Vidbyte public API.
2
+
3
+ Owns the base URL, API-key header injection, bounded response decoding, and status-driven
4
+ failure classification. Commands never call httpx directly — all HTTP goes through a typed
5
+ endpoint group built on this client.
6
+
7
+ Settings and the credential arrive already resolved, so this client never reads the
8
+ environment: doing so would let a stale variable outrank an explicit option and would skip
9
+ the origin validation that `ResolvedConfig.api_url` has already passed.
10
+
11
+ One request is serialized once and re-issued byte-identically under the same idempotency
12
+ key, because every retry of a priced mutation must be the same mutation.
13
+ Classification is by HTTP status only. The backend serves several different error-body
14
+ shapes, so no single `code` field is a platform contract, and a generic client that knew one
15
+ route's spelling would be wrong for the next one.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from typing import TYPE_CHECKING, TypeVar
22
+
23
+ from pydantic import BaseModel
24
+
25
+ from ..auth.credentials import Credentials
26
+ from ..config import ResolvedConfig
27
+ from ..errors.failures import ApiRouteMisconfigured
28
+ from ..runtime.version import current_version
29
+ from .response import ResponseDecoder, ResponseShape
30
+
31
+ if TYPE_CHECKING:
32
+ import httpx
33
+
34
+ from .retry import RequestOutcome
35
+ from .runtime_payment import RuntimePayment
36
+
37
+ # The backend reads this header first and accepts `Authorization: Bearer` only as a fallback,
38
+ # so the key travels here and nowhere else. Sending both would be worse than sending one: the
39
+ # gatekeeper stops at a malformed `x-api-key` instead of falling through to the bearer value.
40
+ API_KEY_HEADER_NAME = "x-api-key"
41
+ _MAX_CONNECTIONS = 20
42
+ _MAX_KEEPALIVE_CONNECTIONS = 10
43
+
44
+ TModel = TypeVar("TModel", bound=BaseModel)
45
+
46
+
47
+ class ApiClient:
48
+ def __init__(self, config: ResolvedConfig, credentials: Credentials) -> None:
49
+ # Takes the invocation's resolved host, timeout, and secret; resolves nothing itself.
50
+ self.base_url = config.api_url.rstrip("/")
51
+ self.timeout_seconds = config.request_timeout_seconds
52
+ # The key is a secret: it is held here only to set the auth header, never logged.
53
+ self._credentials = credentials
54
+ # Built on first send, so constructing a client opens no socket and `--help` is free.
55
+ self._transport: httpx.Client | None = None
56
+ from .problem import ApiProblemMapper
57
+ from .retry import RetryPolicy
58
+
59
+ self._retry = RetryPolicy()
60
+ self._decoder = ResponseDecoder()
61
+ self._problems = ApiProblemMapper()
62
+
63
+ def auth_headers(self) -> dict[str, str]:
64
+ # The single point where the stored secret is unwrapped for transmission.
65
+ return {API_KEY_HEADER_NAME: self._credentials.secret_value()}
66
+
67
+ def get(
68
+ self,
69
+ path: str,
70
+ model: type[TModel],
71
+ *,
72
+ shape: ResponseShape = ResponseShape.ENVELOPE,
73
+ ) -> TModel:
74
+ # Performs an authenticated GET, unwraps the declared shape, validates into `model`.
75
+ return self.request("GET", path, response_model=model, response_shape=shape)
76
+
77
+ def post_direct(self, path: str, model: type[TModel]) -> TModel:
78
+ # POSTs with no body and validates the response object itself, not an envelope's `data`.
79
+ response = self._send("POST", path, None, None, route_not_found=True)
80
+ return self._decoder.one(response, model, ResponseShape.DIRECT)
81
+
82
+ def post(
83
+ self,
84
+ path: str,
85
+ body: BaseModel,
86
+ model: type[TModel],
87
+ *,
88
+ shape: ResponseShape = ResponseShape.ENVELOPE,
89
+ idempotency_key: str | None = None,
90
+ ) -> TModel:
91
+ # Performs an authenticated POST, unwraps the declared shape, validates into `model`.
92
+ return self.request(
93
+ "POST",
94
+ path,
95
+ response_model=model,
96
+ response_shape=shape,
97
+ body=body,
98
+ idempotency_key=idempotency_key,
99
+ )
100
+
101
+ def request(
102
+ self,
103
+ method: str,
104
+ path: str,
105
+ *,
106
+ response_model: type[TModel],
107
+ response_shape: ResponseShape,
108
+ body: BaseModel | None = None,
109
+ idempotency_key: str | None = None,
110
+ ) -> TModel:
111
+ # The one path every typed request takes: send with retries, then decode.
112
+ response = self._send(method, path, body, idempotency_key)
113
+ return self._decoder.one(response, response_model, response_shape)
114
+
115
+ def post_runtime_payment(
116
+ self, path: str, body: BaseModel, key: str, payer: RuntimePayment
117
+ ) -> httpx.Response:
118
+ # Probe or recover first, then authorize at most one exact x402 payment.
119
+ import httpx
120
+
121
+ from ..errors.failures import RuntimePaymentFailed
122
+
123
+ url = self._url(path)
124
+ content = self._content(body)
125
+ headers = self._headers(key, True)
126
+ outcome = self._retry_request("POST", url, content, headers)
127
+ if isinstance(outcome, httpx.Response) and outcome.status_code == 402:
128
+ headers.update(payer.headers(outcome))
129
+ outcome = self._retry_request("POST", url, content, headers)
130
+ if isinstance(outcome, httpx.Response) and outcome.status_code == 402:
131
+ raise RuntimePaymentFailed("payment_rejected")
132
+ return self._settle(outcome)
133
+
134
+ def close(self) -> None:
135
+ # Releases the connection pool; safe to call when no request was ever made.
136
+ if self._transport is not None:
137
+ self._transport.close()
138
+ self._transport = None
139
+
140
+ def _send(
141
+ self,
142
+ method: str,
143
+ path: str,
144
+ body: BaseModel | None,
145
+ idempotency_key: str | None,
146
+ *,
147
+ route_not_found: bool = False,
148
+ ) -> httpx.Response:
149
+ # Re-issues one identical request while the retry policy allows, then classifies.
150
+ url = self._url(path)
151
+ content = self._content(body)
152
+ headers = self._headers(idempotency_key, body is not None)
153
+ outcome = self._retry_request(method, url, content, headers)
154
+ return self._settle(outcome, route_not_found=route_not_found)
155
+
156
+ def _retry_request(
157
+ self, method: str, url: str, data: bytes | None, headers: dict[str, str]
158
+ ) -> RequestOutcome:
159
+ # Reuse identical signed bytes and request identity throughout bounded retries.
160
+ from .retry import RequestMetadata
161
+
162
+ metadata = RequestMetadata(method.upper(), "Idempotency-Key" in headers)
163
+ attempt = 1
164
+ while True:
165
+ outcome = self._attempt(method, url, data, headers)
166
+ decision = self._retry.decide(metadata, attempt, outcome)
167
+ if not decision.retry:
168
+ return outcome
169
+ time.sleep(decision.delay_seconds)
170
+ attempt += 1
171
+
172
+ def _attempt(
173
+ self,
174
+ method: str,
175
+ url: str,
176
+ content: bytes | None,
177
+ headers: dict[str, str],
178
+ ) -> RequestOutcome:
179
+ # One attempt is either a reply or a transport failure, never both and never neither.
180
+ import httpx
181
+
182
+ try:
183
+ return self._client().request(method, url, content=content, headers=headers)
184
+ except httpx.HTTPError as error:
185
+ return error
186
+
187
+ def _settle(self, outcome: RequestOutcome, *, route_not_found: bool = False) -> httpx.Response:
188
+ # The retry policy has given up, so this outcome is the caller's answer.
189
+ import httpx
190
+
191
+ if isinstance(outcome, httpx.HTTPError):
192
+ raise self._problems.from_transport(outcome) from outcome
193
+ if not 200 <= outcome.status_code < 300:
194
+ raise self._problems.from_response(outcome, route_not_found=route_not_found)
195
+ return outcome
196
+
197
+ def _client(self) -> httpx.Client:
198
+ # Built once per invocation so a watch loop reuses one pool across many polls.
199
+ import httpx
200
+
201
+ if self._transport is None:
202
+ self._transport = httpx.Client(
203
+ timeout=httpx.Timeout(self.timeout_seconds),
204
+ follow_redirects=False,
205
+ limits=httpx.Limits(
206
+ max_connections=_MAX_CONNECTIONS,
207
+ max_keepalive_connections=_MAX_KEEPALIVE_CONNECTIONS,
208
+ ),
209
+ )
210
+ return self._transport
211
+
212
+ def _content(self, body: BaseModel | None) -> bytes | None:
213
+ # Serialized once so every attempt is byte-identical, and `exclude_none` is what
214
+ # keeps unset options out of a request DTO the backend validates with extra="forbid".
215
+ if body is None:
216
+ return None
217
+ return body.model_dump_json(exclude_none=True, by_alias=True).encode("utf-8")
218
+
219
+ def _url(self, path: str) -> str:
220
+ # A route carrying its own scheme or host would send the key to an unconfigured origin.
221
+ if not path.startswith("/") or path.startswith("//") or "://" in path:
222
+ raise ApiRouteMisconfigured()
223
+ return f"{self.base_url}{path}"
224
+
225
+ def _headers(self, idempotency_key: str | None, has_body: bool) -> dict[str, str]:
226
+ # Identity, content negotiation, and the credential; nothing derived from user input.
227
+ headers = {
228
+ "Accept": "application/json",
229
+ "User-Agent": f"vidbyte-cli/{current_version()}",
230
+ **self.auth_headers(),
231
+ }
232
+ if has_body:
233
+ headers["Content-Type"] = "application/json"
234
+ if idempotency_key is not None:
235
+ headers["Idempotency-Key"] = idempotency_key
236
+ return headers
File without changes
@@ -0,0 +1,24 @@
1
+ """The one authentication route the CLI calls, and the identity it returns.
2
+
3
+ `validate` is the only permission-free liveness check the backend serves. It is chosen over a
4
+ cheap authenticated resource read because the API grants read and write scopes separately: a
5
+ key scoped only to write is perfectly valid, and a read-based check would refuse to log it in.
6
+ Capability belongs at the point of use, where a rejection can name the missing scope.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ....types.api import KeyIdentity
12
+ from ..client import ApiClient
13
+
14
+ AUTH_VALIDATE_PATH = "/api/skills/auth/validate"
15
+
16
+
17
+ class AuthEndpoints:
18
+ def __init__(self, client: ApiClient) -> None:
19
+ # Binds the endpoint group to a configured ApiClient.
20
+ self._client = client
21
+
22
+ def validate(self) -> KeyIdentity:
23
+ # Proves the configured key is live and returns its non-secret identity.
24
+ return self._client.post_direct(AUTH_VALIDATE_PATH, KeyIdentity)
@@ -0,0 +1,36 @@
1
+ """Authenticated billing operations exposed by the Vidbyte API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+ from uuid import uuid4
7
+
8
+ from ....types.api import BillingTopUpResult
9
+ from ....types.runtime import RuntimeAdmissionRequest, RuntimeHost
10
+ from ..client import ApiClient
11
+ from ..response import ResponseDecoder, ResponseShape
12
+
13
+ if TYPE_CHECKING:
14
+ from ..runtime_payment import RuntimePayment
15
+
16
+ TOP_UP_PATH = "/agent/topup"
17
+ TOP_UP_CENTS = 500
18
+
19
+
20
+ class BillingEndpoints:
21
+ """Billing operations bound to one authenticated API client."""
22
+
23
+ def __init__(self, client: ApiClient) -> None:
24
+ # Retains the invocation-owned client without opening a connection.
25
+ self._client = client
26
+
27
+ def top_up(self, key: str | None, payer: RuntimePayment) -> BillingTopUpResult:
28
+ # Pays one exact top-up challenge and validates the credited balance response.
29
+ idempotency_key = key or str(uuid4())
30
+ response = self._client.post_runtime_payment(
31
+ TOP_UP_PATH,
32
+ RuntimeAdmissionRequest(host=RuntimeHost.CODEX),
33
+ idempotency_key,
34
+ payer,
35
+ )
36
+ return ResponseDecoder().one(response, BillingTopUpResult, ResponseShape.DIRECT)
@@ -0,0 +1,103 @@
1
+ """Typed wrappers for the /api/v1/research/* public API routes.
2
+
3
+ These six are the entire surface an API key may call. There is no sources, artifacts,
4
+ capabilities, exports, or run-listing route, so there is no method here for one.
5
+
6
+ Every route returns its DTO directly rather than inside the `{success, data}` envelope the
7
+ older public resources use, which is why each call names `ResponseShape.DIRECT` explicitly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from urllib.parse import quote, urlencode
13
+
14
+ from ....types.research import (
15
+ ResearchPortfolioPage,
16
+ ResearchRunAccepted,
17
+ ResearchRunCreateRequest,
18
+ ResearchRunStatus,
19
+ ResearchThreadDetail,
20
+ )
21
+ from ..client import ApiClient
22
+ from ..response import ResponseShape
23
+
24
+ _RESEARCH_ROOT = "/api/v1/research"
25
+
26
+
27
+ class ResearchEndpoints:
28
+ def __init__(self, client: ApiClient) -> None:
29
+ # Binds the endpoint group to a configured ApiClient.
30
+ self._client = client
31
+
32
+ def create_run(
33
+ self,
34
+ request: ResearchRunCreateRequest,
35
+ idempotency_key: str,
36
+ ) -> ResearchRunAccepted:
37
+ # POST /api/v1/research/run — opens a new thread and admits its first run.
38
+ return self._client.post(
39
+ f"{_RESEARCH_ROOT}/run",
40
+ request,
41
+ ResearchRunAccepted,
42
+ shape=ResponseShape.DIRECT,
43
+ idempotency_key=idempotency_key,
44
+ )
45
+
46
+ def append_run(
47
+ self,
48
+ thread_id: str,
49
+ request: ResearchRunCreateRequest,
50
+ idempotency_key: str,
51
+ ) -> ResearchRunAccepted:
52
+ # POST /api/v1/research/threads/{id}/run — admits another prompt into one thread.
53
+ return self._client.post(
54
+ f"{_RESEARCH_ROOT}/threads/{self._segment(thread_id)}/run",
55
+ request,
56
+ ResearchRunAccepted,
57
+ shape=ResponseShape.DIRECT,
58
+ idempotency_key=idempotency_key,
59
+ )
60
+
61
+ def continue_run(self, run_id: str, idempotency_key: str) -> ResearchRunAccepted:
62
+ # POST /api/v1/research/runs/{id}/continue — reopens one terminal-resumable run.
63
+ # The route takes no body: a continuation replays the original prompt, never a new one.
64
+ return self._client.request(
65
+ "POST",
66
+ f"{_RESEARCH_ROOT}/runs/{self._segment(run_id)}/continue",
67
+ response_model=ResearchRunAccepted,
68
+ response_shape=ResponseShape.DIRECT,
69
+ idempotency_key=idempotency_key,
70
+ )
71
+
72
+ def get_run(self, run_id: str) -> ResearchRunStatus:
73
+ # GET /api/v1/research/runs/{id} — one run's coarse durable status.
74
+ return self._client.get(
75
+ f"{_RESEARCH_ROOT}/runs/{self._segment(run_id)}",
76
+ ResearchRunStatus,
77
+ shape=ResponseShape.DIRECT,
78
+ )
79
+
80
+ def get_portfolio(self, cursor: str | None, limit: int | None) -> ResearchPortfolioPage:
81
+ # GET /api/v1/research/portfolio — one bounded page of the caller's threads.
82
+ return self._client.get(
83
+ self._paged(f"{_RESEARCH_ROOT}/portfolio", cursor, limit),
84
+ ResearchPortfolioPage,
85
+ shape=ResponseShape.DIRECT,
86
+ )
87
+
88
+ def get_thread(self, thread_id: str) -> ResearchThreadDetail:
89
+ # GET /api/v1/research/threads/{id} — one owned thread and its rollup counters.
90
+ return self._client.get(
91
+ f"{_RESEARCH_ROOT}/threads/{self._segment(thread_id)}",
92
+ ResearchThreadDetail,
93
+ shape=ResponseShape.DIRECT,
94
+ )
95
+
96
+ def _segment(self, value: str) -> str:
97
+ # Encoding an opaque identifier means it can never alter the route's structure.
98
+ return quote(value, safe="")
99
+
100
+ def _paged(self, path: str, cursor: str | None, limit: int | None) -> str:
101
+ # Unset parameters are omitted so the server keeps ownership of both defaults.
102
+ query = {name: value for name, value in (("cursor", cursor), ("limit", limit)) if value}
103
+ return f"{path}?{urlencode(query)}" if query else path