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.
- vidbyte_cli/__init__.py +11 -0
- vidbyte_cli/__main__.py +6 -0
- vidbyte_cli/cli.py +18 -0
- vidbyte_cli/commands/__init__.py +86 -0
- vidbyte_cli/commands/agent_options.py +53 -0
- vidbyte_cli/commands/agents/__init__.py +31 -0
- vidbyte_cli/commands/agents/suggestion/__init__.py +38 -0
- vidbyte_cli/commands/agents/suggestion/prompts/__init__.py +3 -0
- vidbyte_cli/commands/agents/suggestion/prompts/all_categories.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/approaches.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/avoid.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/blockers.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/capability.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/categories.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/category.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/completed.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/constraint.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/context.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/count.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/critic_model.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/decision.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/dry_run.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/extra_compute.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_accept.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_group.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_project.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_reason.md +98 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_reject.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_suggestion.md +101 -0
- vidbyte_cli/commands/agents/suggestion/prompts/files.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/forbidden.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/goal.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/handoff.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/horizon.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/hypotheses.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/idea.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/in_progress.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/input.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/library.py +29 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_messages.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_output_tokens.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_total_tokens.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/mistakes.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/outcomes.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_create.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_description.md +98 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_group.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_key.md +96 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_list.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_title.md +99 -0
- vidbyte_cli/commands/agents/suggestion/prompts/provider.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/question.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/risks.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/rounds.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/run.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/success.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/timeout.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/trajectory.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/view.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/view_all.md +5 -0
- vidbyte_cli/commands/agents/suggestion/render.py +77 -0
- vidbyte_cli/commands/agents/suggestion/request_builder.py +240 -0
- vidbyte_cli/commands/agents/suggestion/suggest.py +154 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_categories.py +77 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_feedback.py +85 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_handoff.py +67 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_projects.py +95 -0
- vidbyte_cli/commands/auth/__init__.py +0 -0
- vidbyte_cli/commands/auth/login.py +100 -0
- vidbyte_cli/commands/auth/logout.py +41 -0
- vidbyte_cli/commands/auth/whoami.py +51 -0
- vidbyte_cli/commands/billing/__init__.py +20 -0
- vidbyte_cli/commands/billing/prompts/__init__.py +1 -0
- vidbyte_cli/commands/billing/prompts/confirm.md +16 -0
- vidbyte_cli/commands/billing/prompts/group.md +13 -0
- vidbyte_cli/commands/billing/prompts/idempotency_key.md +16 -0
- vidbyte_cli/commands/billing/prompts/library.py +18 -0
- vidbyte_cli/commands/billing/prompts/top_up.md +16 -0
- vidbyte_cli/commands/billing/top_up.py +65 -0
- vidbyte_cli/commands/config/__init__.py +0 -0
- vidbyte_cli/commands/config/get.py +45 -0
- vidbyte_cli/commands/config/set.py +39 -0
- vidbyte_cli/commands/provider/__init__.py +9 -0
- vidbyte_cli/commands/provider/login.py +102 -0
- vidbyte_cli/commands/provider/logout.py +45 -0
- vidbyte_cli/commands/provider/whoami.py +49 -0
- vidbyte_cli/commands/research/__init__.py +5 -0
- vidbyte_cli/commands/research/add.py +41 -0
- vidbyte_cli/commands/research/options.py +134 -0
- vidbyte_cli/commands/research/render.py +157 -0
- vidbyte_cli/commands/research/resume.py +37 -0
- vidbyte_cli/commands/research/start.py +37 -0
- vidbyte_cli/commands/research/status.py +32 -0
- vidbyte_cli/commands/research/thread.py +30 -0
- vidbyte_cli/commands/research/threads.py +35 -0
- vidbyte_cli/commands/research/watch.py +93 -0
- vidbyte_cli/commands/runtime/__init__.py +23 -0
- vidbyte_cli/commands/runtime/adversarial_team.py +41 -0
- vidbyte_cli/commands/runtime/doctor.py +44 -0
- vidbyte_cli/commands/runtime/list.py +47 -0
- vidbyte_cli/commands/runtime/persistence.py +141 -0
- vidbyte_cli/commands/runtime/same_host_ensemble.py +221 -0
- vidbyte_cli/commands/runtime/stages.py +553 -0
- vidbyte_cli/commands/runtime/task_board.py +1342 -0
- vidbyte_cli/commands/setup/__init__.py +0 -0
- vidbyte_cli/commands/setup/doctor.py +64 -0
- vidbyte_cli/commands/setup/prompts/__init__.py +1 -0
- vidbyte_cli/commands/setup/prompts/browser.md +7 -0
- vidbyte_cli/commands/setup/prompts/library.py +18 -0
- vidbyte_cli/commands/setup/prompts/star.md +13 -0
- vidbyte_cli/commands/setup/star.py +132 -0
- vidbyte_cli/lib/__init__.py +0 -0
- vidbyte_cli/lib/api/__init__.py +0 -0
- vidbyte_cli/lib/api/client.py +236 -0
- vidbyte_cli/lib/api/endpoints/__init__.py +0 -0
- vidbyte_cli/lib/api/endpoints/auth.py +24 -0
- vidbyte_cli/lib/api/endpoints/billing.py +36 -0
- vidbyte_cli/lib/api/endpoints/research.py +103 -0
- vidbyte_cli/lib/api/endpoints/runtime.py +113 -0
- vidbyte_cli/lib/api/problem.py +86 -0
- vidbyte_cli/lib/api/response.py +101 -0
- vidbyte_cli/lib/api/retry.py +116 -0
- vidbyte_cli/lib/api/runtime_payment.py +89 -0
- vidbyte_cli/lib/auth/__init__.py +16 -0
- vidbyte_cli/lib/auth/credentials.py +223 -0
- vidbyte_cli/lib/auth/input.py +59 -0
- vidbyte_cli/lib/auth/keyring_store.py +126 -0
- vidbyte_cli/lib/auth/provider_credentials.py +157 -0
- vidbyte_cli/lib/auth/provider_input.py +54 -0
- vidbyte_cli/lib/auth/provider_resolver.py +89 -0
- vidbyte_cli/lib/auth/provider_store.py +149 -0
- vidbyte_cli/lib/auth/provider_verifier.py +219 -0
- vidbyte_cli/lib/auth/resolver.py +63 -0
- vidbyte_cli/lib/auth/verifier.py +38 -0
- vidbyte_cli/lib/config/__init__.py +25 -0
- vidbyte_cli/lib/config/atomic.py +47 -0
- vidbyte_cli/lib/config/config.py +141 -0
- vidbyte_cli/lib/config/migration.py +81 -0
- vidbyte_cli/lib/config/models.py +145 -0
- vidbyte_cli/lib/config/paths.py +66 -0
- vidbyte_cli/lib/config/resolver.py +137 -0
- vidbyte_cli/lib/constants/__init__.py +1 -0
- vidbyte_cli/lib/constants/runtime.py +186 -0
- vidbyte_cli/lib/errors/__init__.py +12 -0
- vidbyte_cli/lib/errors/cli_error.py +86 -0
- vidbyte_cli/lib/errors/codes.py +43 -0
- vidbyte_cli/lib/errors/failures.py +2910 -0
- vidbyte_cli/lib/errors/handler.py +72 -0
- vidbyte_cli/lib/files/__init__.py +6 -0
- vidbyte_cli/lib/files/documents.py +94 -0
- vidbyte_cli/lib/files/store.py +156 -0
- vidbyte_cli/lib/io/__init__.py +16 -0
- vidbyte_cli/lib/io/attachments.py +129 -0
- vidbyte_cli/lib/io/codex_attachments.py +47 -0
- vidbyte_cli/lib/io/prompt.py +71 -0
- vidbyte_cli/lib/io/streams.py +37 -0
- vidbyte_cli/lib/io/terminal.py +66 -0
- vidbyte_cli/lib/output/__init__.py +7 -0
- vidbyte_cli/lib/output/formats.py +29 -0
- vidbyte_cli/lib/output/manager.py +95 -0
- vidbyte_cli/lib/output/models.py +49 -0
- vidbyte_cli/lib/runtime/__init__.py +9 -0
- vidbyte_cli/lib/runtime/application.py +134 -0
- vidbyte_cli/lib/runtime/context.py +250 -0
- vidbyte_cli/lib/runtime/options.py +159 -0
- vidbyte_cli/lib/runtime/version.py +30 -0
- vidbyte_cli/lib/runtime_primitives/__init__.py +28 -0
- vidbyte_cli/lib/runtime_primitives/executor.py +54 -0
- vidbyte_cli/lib/runtime_primitives/gate.py +105 -0
- vidbyte_cli/lib/runtime_primitives/hosts.py +45 -0
- vidbyte_cli/lib/runtime_primitives/planner.py +79 -0
- vidbyte_cli/lib/runtime_primitives/stages.py +338 -0
- vidbyte_cli/lib/runtime_primitives/task_board.py +982 -0
- vidbyte_cli/lib/runtime_primitives/task_board_checkpoints.py +473 -0
- vidbyte_cli/lib/runtime_primitives/task_board_system.md +33 -0
- vidbyte_cli/lib/runtime_primitives/task_board_tasks.py +111 -0
- vidbyte_cli/lib/runtime_primitives/verification.py +68 -0
- vidbyte_cli/services/__init__.py +5 -0
- vidbyte_cli/services/ensemble/__init__.py +7 -0
- vidbyte_cli/services/ensemble/prompts/__init__.py +6 -0
- vidbyte_cli/services/ensemble/prompts/implementer_system.md +546 -0
- vidbyte_cli/services/ensemble/prompts/implementer_turn.md +49 -0
- vidbyte_cli/services/ensemble/prompts/library.py +188 -0
- vidbyte_cli/services/ensemble/prompts/planner_system.md +728 -0
- vidbyte_cli/services/ensemble/prompts/planner_turn.md +6 -0
- vidbyte_cli/services/ensemble/prompts/role_system.md +104 -0
- vidbyte_cli/services/ensemble/prompts/role_turn.md +19 -0
- vidbyte_cli/services/ensemble/prompts/selector_final_turn.md +19 -0
- vidbyte_cli/services/ensemble/prompts/selector_round_turn.md +17 -0
- vidbyte_cli/services/ensemble/prompts/selector_system.md +89 -0
- vidbyte_cli/services/ensemble/runner.py +30 -0
- vidbyte_cli/services/ensemble/sdk.py +134 -0
- vidbyte_cli/services/ensemble/service.py +233 -0
- vidbyte_cli/services/ensemble/settings.py +60 -0
- vidbyte_cli/services/persistence/__init__.py +6 -0
- vidbyte_cli/services/persistence/prompts/__init__.py +6 -0
- vidbyte_cli/services/persistence/prompts/library.py +46 -0
- vidbyte_cli/services/persistence/prompts/persistence_system.md +1 -0
- vidbyte_cli/services/persistence/prompts/persistence_turn.md +3 -0
- vidbyte_cli/services/persistence/runner.py +35 -0
- vidbyte_cli/services/persistence/session.py +116 -0
- vidbyte_cli/services/suggestions/__init__.py +7 -0
- vidbyte_cli/services/suggestions/categories.py +270 -0
- vidbyte_cli/services/suggestions/context.py +222 -0
- vidbyte_cli/services/suggestions/extra_compute.py +53 -0
- vidbyte_cli/services/suggestions/handoff.py +173 -0
- vidbyte_cli/services/suggestions/message_tools.py +47 -0
- vidbyte_cli/services/suggestions/project.py +253 -0
- vidbyte_cli/services/suggestions/prompts/__init__.py +1 -0
- vidbyte_cli/services/suggestions/prompts/categories/__init__.py +3 -0
- vidbyte_cli/services/suggestions/prompts/categories/adjacent_opportunity.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/alternative.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/big_bets.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/bottleneck.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/business_growth.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/completion.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/continuation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/coordination.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/creative_exploration.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/cross_domain.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/delegation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/experiment.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/feedback.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/goal_clarification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/immediate_next_steps.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/investigation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/learning.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/leverage.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/long_term_directions.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/preparation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/prerequisite.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/prioritization.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/product_experience.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/quick_wins.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/reframing.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/risk_prevention.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/simplification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/stop_or_defer.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/strategy.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/technical_possibilities.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/verification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/critic.md +209 -0
- vidbyte_cli/services/suggestions/prompts/critic_message.md +8 -0
- vidbyte_cli/services/suggestions/prompts/generator.md +64 -0
- vidbyte_cli/services/suggestions/prompts/library.py +72 -0
- vidbyte_cli/services/suggestions/prompts/message_generator_argument.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_generator_tool.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_parent_argument.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_parent_tool.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_receipt.md +1 -0
- vidbyte_cli/services/suggestions/prompts/revision.md +8 -0
- vidbyte_cli/services/suggestions/sdk.py +228 -0
- vidbyte_cli/services/suggestions/selection.py +78 -0
- vidbyte_cli/services/suggestions/service.py +540 -0
- vidbyte_cli/types/__init__.py +0 -0
- vidbyte_cli/types/api.py +101 -0
- vidbyte_cli/types/attachments.py +79 -0
- vidbyte_cli/types/ensemble.py +189 -0
- vidbyte_cli/types/provider.py +62 -0
- vidbyte_cli/types/research.py +260 -0
- vidbyte_cli/types/runtime.py +1098 -0
- vidbyte_cli/types/suggestions.py +518 -0
- vidbyte_cli-0.1.0.dist-info/METADATA +335 -0
- vidbyte_cli-0.1.0.dist-info/RECORD +269 -0
- vidbyte_cli-0.1.0.dist-info/WHEEL +5 -0
- vidbyte_cli-0.1.0.dist-info/entry_points.txt +2 -0
- vidbyte_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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."""
|