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,72 @@
|
|
|
1
|
+
"""The single boundary where an exception becomes safe output and a process status.
|
|
2
|
+
|
|
3
|
+
One match statement classifies everything that can reach `CliApplication.run`, so the set of
|
|
4
|
+
exception kinds the CLI understands is readable in one place. Anything unmatched is a CLI
|
|
5
|
+
defect, not a user mistake, and is reported without quoting the exception value — a foreign
|
|
6
|
+
exception string may hold a credential, a prompt, or a backend response body.
|
|
7
|
+
|
|
8
|
+
Debug mode adds stack frames only. Never locals, never exception values, never causes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import traceback
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from ..output.manager import OutputManager
|
|
18
|
+
from .cli_error import CliError
|
|
19
|
+
from .codes import ExitCode
|
|
20
|
+
from .failures import InvalidCommandUsage, OperationInterrupted, UnexpectedInternalError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ErrorHandler:
|
|
24
|
+
"""Central conversion from boundary exceptions to safe CLI failures."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, output: OutputManager, debug: bool = False) -> None:
|
|
27
|
+
self._output = output
|
|
28
|
+
self._debug = debug
|
|
29
|
+
|
|
30
|
+
def handle(self, error: BaseException) -> int:
|
|
31
|
+
# UsageError precedes ClickException because it is the more specific subclass.
|
|
32
|
+
match error:
|
|
33
|
+
case BrokenPipeError():
|
|
34
|
+
# A closed downstream pipe is a normal shell outcome, not a failure.
|
|
35
|
+
return int(ExitCode.SUCCESS)
|
|
36
|
+
case CliError():
|
|
37
|
+
return self._report(error, error)
|
|
38
|
+
case click.UsageError():
|
|
39
|
+
context = error.ctx
|
|
40
|
+
path = context.command_path if context is not None else "vidbyte-cli"
|
|
41
|
+
return self._report(
|
|
42
|
+
InvalidCommandUsage(error.format_message(), path, error.exit_code, error),
|
|
43
|
+
error,
|
|
44
|
+
)
|
|
45
|
+
case click.ClickException():
|
|
46
|
+
return self._report(
|
|
47
|
+
InvalidCommandUsage(
|
|
48
|
+
error.format_message(), "vidbyte-cli", error.exit_code, error
|
|
49
|
+
),
|
|
50
|
+
error,
|
|
51
|
+
)
|
|
52
|
+
case click.exceptions.Abort() | KeyboardInterrupt():
|
|
53
|
+
return self._report(OperationInterrupted(), error)
|
|
54
|
+
case _:
|
|
55
|
+
cause = error if isinstance(error, Exception) else None
|
|
56
|
+
return self._report(UnexpectedInternalError(cause), error)
|
|
57
|
+
|
|
58
|
+
def _report(self, mapped: CliError, original: BaseException) -> int:
|
|
59
|
+
# Render once, then optionally add frames from whichever exception carries them.
|
|
60
|
+
self._output.error(mapped)
|
|
61
|
+
if self._debug:
|
|
62
|
+
self._render_redacted_trace(mapped.cause or original)
|
|
63
|
+
return mapped.exit_code
|
|
64
|
+
|
|
65
|
+
def _render_redacted_trace(self, error: BaseException) -> None:
|
|
66
|
+
# Format stack frames only; exception values and chained causes may hold secrets.
|
|
67
|
+
self._output.diagnostic("Debug traceback (exception values redacted):")
|
|
68
|
+
if error.__traceback__ is None:
|
|
69
|
+
self._output.diagnostic(f" {type(error).__module__}.{type(error).__qualname__}")
|
|
70
|
+
return
|
|
71
|
+
for line in traceback.format_tb(error.__traceback__):
|
|
72
|
+
self._output.diagnostic(line.rstrip())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Versioned JSON documents validated by a caller-supplied pydantic model.
|
|
2
|
+
|
|
3
|
+
A command that keeps small structured records on disk — catalogs, indexes, per-item memory —
|
|
4
|
+
reads and writes them here, so bounding, parsing, model validation, containment of linked
|
|
5
|
+
names, and deterministic serialization are decided once. Bytes still move through
|
|
6
|
+
`LocalFileStore`; which model a document uses and what its fields mean stay with the caller.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ValidationError
|
|
16
|
+
|
|
17
|
+
from ..errors.failures import LocalDocumentInvalid, LocalFileReadFailed, LocalFileWriteFailed
|
|
18
|
+
from .store import LocalFileStore
|
|
19
|
+
|
|
20
|
+
DocumentT = TypeVar("DocumentT", bound=BaseModel)
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_DOCUMENT_BYTES = 1_000_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LocalDocumentStore:
|
|
26
|
+
"""Reads, validates, atomically writes, and removes JSON documents under one root."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, root: Path, *, max_bytes: int = DEFAULT_MAX_DOCUMENT_BYTES) -> None:
|
|
29
|
+
# The byte cap is per store so a caller holding tiny indexes can refuse a runaway file
|
|
30
|
+
# before it is decoded, while the file store underneath keeps paths absolute.
|
|
31
|
+
self._files = LocalFileStore(root)
|
|
32
|
+
self._max_bytes = max_bytes
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def root(self) -> Path:
|
|
36
|
+
# The absolute directory every document name resolves against.
|
|
37
|
+
return self._files.root
|
|
38
|
+
|
|
39
|
+
def path_of(self, name: str | Path) -> Path:
|
|
40
|
+
# Names often come from another document (a catalog linking to a record), so a name
|
|
41
|
+
# that is absolute, climbs with `..`, or resolves outside the root through a link is
|
|
42
|
+
# refused rather than trusted.
|
|
43
|
+
relative = Path(name)
|
|
44
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
45
|
+
raise LocalDocumentInvalid(relative.name, "its name leaves the document directory")
|
|
46
|
+
candidate = self._files.path_of(relative).resolve()
|
|
47
|
+
if not candidate.is_relative_to(self.root):
|
|
48
|
+
raise LocalDocumentInvalid(relative.name, "its name leaves the document directory")
|
|
49
|
+
return candidate
|
|
50
|
+
|
|
51
|
+
def exists(self, name: str | Path) -> bool:
|
|
52
|
+
# Presence of a regular file or of a link at that name, since either would be replaced.
|
|
53
|
+
path = self.path_of(name)
|
|
54
|
+
return path.is_file() or path.is_symlink()
|
|
55
|
+
|
|
56
|
+
def read(self, name: str | Path, model: type[DocumentT]) -> DocumentT | None:
|
|
57
|
+
# None means absent and nothing else. A present document that is oversized, not JSON,
|
|
58
|
+
# or not an instance of the model raises, so broken state is never read as empty state.
|
|
59
|
+
path = self.path_of(name)
|
|
60
|
+
try:
|
|
61
|
+
size = path.stat().st_size
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
return None
|
|
64
|
+
except OSError as error:
|
|
65
|
+
raise LocalFileReadFailed(path.name, LocalFileStore.reason_for(error), error) from error
|
|
66
|
+
if size > self._max_bytes:
|
|
67
|
+
raise LocalDocumentInvalid(path.name, f"it exceeds {self._max_bytes} bytes")
|
|
68
|
+
parsed = self._files.read_json(path)
|
|
69
|
+
if parsed is None:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
return model.model_validate(parsed)
|
|
73
|
+
except ValidationError as error:
|
|
74
|
+
raise LocalDocumentInvalid(
|
|
75
|
+
path.name, f"it does not match the {model.__name__} schema", error
|
|
76
|
+
) from error
|
|
77
|
+
|
|
78
|
+
def write(self, name: str | Path, document: BaseModel) -> Path:
|
|
79
|
+
# Sorted, indented, newline-terminated output keeps documents diffable by hand and
|
|
80
|
+
# byte-stable across runs; the file store replaces the target atomically.
|
|
81
|
+
path = self.path_of(name)
|
|
82
|
+
body = json.dumps(document.model_dump(mode="json"), indent=2, sort_keys=True) + "\n"
|
|
83
|
+
return self._files.write_text(path, body)
|
|
84
|
+
|
|
85
|
+
def remove(self, name: str | Path) -> None:
|
|
86
|
+
# Used to roll back a document written earlier in the same mutation; an already absent
|
|
87
|
+
# document is the desired end state, not a failure.
|
|
88
|
+
path = self.path_of(name)
|
|
89
|
+
try:
|
|
90
|
+
path.unlink(missing_ok=True)
|
|
91
|
+
except OSError as error:
|
|
92
|
+
raise LocalFileWriteFailed(
|
|
93
|
+
path.name, LocalFileStore.reason_for(error), error
|
|
94
|
+
) from error
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Product-neutral file I/O behind one store rooted at one directory.
|
|
2
|
+
|
|
3
|
+
Any command or runtime primitive that keeps local files — checkpoints, logs, reports,
|
|
4
|
+
exported documents — reads and writes them through this class, so atomicity, encoding, and
|
|
5
|
+
path resolution are decided once instead of at each call site. It knows paths and bytes only:
|
|
6
|
+
which file a record belongs in, and what its contents mean, is always the caller's decision.
|
|
7
|
+
Owner-only CLI state such as config and credentials goes through `lib/config/atomic.py`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import errno
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..errors.failures import LocalFileReadFailed, LocalFileWriteFailed
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LocalFileStore:
|
|
23
|
+
"""Resolves, reads, and atomically writes files under one root directory."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, root: Path) -> None:
|
|
26
|
+
# Resolving once at construction is what makes every path this store returns absolute,
|
|
27
|
+
# so a file a result reports stays addressable from another working directory.
|
|
28
|
+
self._root = root.expanduser().resolve()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def root(self) -> Path:
|
|
32
|
+
# The absolute directory every relative name in this store resolves against.
|
|
33
|
+
return self._root
|
|
34
|
+
|
|
35
|
+
def path_of(self, name: str | Path) -> Path:
|
|
36
|
+
# One join site. An absolute name passes through unchanged, which is how a caller
|
|
37
|
+
# writes a destination it was handed without building a second store for it.
|
|
38
|
+
return self._root / name
|
|
39
|
+
|
|
40
|
+
def exists(self, name: str | Path) -> bool:
|
|
41
|
+
# Presence check for a regular file; a directory of the same name is not a file.
|
|
42
|
+
return self.path_of(name).is_file()
|
|
43
|
+
|
|
44
|
+
def subdirectories(self) -> tuple[Path, ...]:
|
|
45
|
+
# Sorted so a listing is stable across runs and filesystems. An absent root holds
|
|
46
|
+
# nothing, while a root that exists but cannot be scanned is a failure worth naming.
|
|
47
|
+
if not self._root.is_dir():
|
|
48
|
+
return ()
|
|
49
|
+
try:
|
|
50
|
+
return tuple(sorted(child for child in self._root.iterdir() if child.is_dir()))
|
|
51
|
+
except OSError as error:
|
|
52
|
+
raise LocalFileReadFailed(self._root.name, self.reason_for(error), error) from error
|
|
53
|
+
|
|
54
|
+
def read_text(self, name: str | Path) -> str | None:
|
|
55
|
+
# None means absent and nothing else; a file that exists but cannot be read or
|
|
56
|
+
# decoded raises, so a caller never mistakes a permission problem for a missing file.
|
|
57
|
+
path = self.path_of(name)
|
|
58
|
+
try:
|
|
59
|
+
return path.read_text(encoding="utf-8")
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
return None
|
|
62
|
+
except (OSError, UnicodeError) as error:
|
|
63
|
+
raise LocalFileReadFailed(path.name, self.reason_for(error), error) from error
|
|
64
|
+
|
|
65
|
+
def read_json(self, name: str | Path) -> object | None:
|
|
66
|
+
# Same absent-versus-broken contract as read_text, with invalid JSON counted as broken.
|
|
67
|
+
body = self.read_text(name)
|
|
68
|
+
if body is None:
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
parsed: object = json.loads(body)
|
|
72
|
+
except ValueError as error:
|
|
73
|
+
path = self.path_of(name)
|
|
74
|
+
raise LocalFileReadFailed(path.name, self.reason_for(error), error) from error
|
|
75
|
+
return parsed
|
|
76
|
+
|
|
77
|
+
def write_json(self, name: str | Path, payload: object) -> Path:
|
|
78
|
+
# Serializes first so a malformed payload fails before the target file is touched.
|
|
79
|
+
return self.write_text(name, json.dumps(payload))
|
|
80
|
+
|
|
81
|
+
def write_text(self, name: str | Path, body: str) -> Path:
|
|
82
|
+
# Writes through a temp sibling plus os.replace, so a reader never sees a partial file
|
|
83
|
+
# and a crash mid-write leaves the previous version intact rather than a truncated one.
|
|
84
|
+
target = self.path_of(name)
|
|
85
|
+
temporary: str | None = None
|
|
86
|
+
try:
|
|
87
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
handle, temporary = tempfile.mkstemp(
|
|
89
|
+
dir=str(target.parent), prefix=f".{target.name}.", suffix=".tmp"
|
|
90
|
+
)
|
|
91
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
92
|
+
stream.write(body)
|
|
93
|
+
os.replace(temporary, target)
|
|
94
|
+
return target
|
|
95
|
+
except (OSError, UnicodeError) as error:
|
|
96
|
+
self._discard(temporary)
|
|
97
|
+
raise LocalFileWriteFailed(target.name, self.reason_for(error), error) from error
|
|
98
|
+
|
|
99
|
+
def append_line(self, name: str | Path, body: str) -> Path:
|
|
100
|
+
# Append is deliberately not atomic: an append-only log is read by outside tools that
|
|
101
|
+
# tail it, and rewriting it per record would break every reader holding an offset.
|
|
102
|
+
path = self.path_of(name)
|
|
103
|
+
try:
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
with path.open("a", encoding="utf-8") as stream:
|
|
106
|
+
stream.write(f"{body}\n")
|
|
107
|
+
except (OSError, UnicodeError) as error:
|
|
108
|
+
raise LocalFileWriteFailed(path.name, self.reason_for(error), error) from error
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
def copy_into(self, name: str | Path, destination: Path) -> Path:
|
|
112
|
+
# Duplicates rather than moves or links, so the source stays readable and unchanged
|
|
113
|
+
# whatever the copy goes on to become.
|
|
114
|
+
try:
|
|
115
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
shutil.copyfile(self.path_of(name), destination)
|
|
117
|
+
except OSError as error:
|
|
118
|
+
raise LocalFileWriteFailed(destination.name, self.reason_for(error), error) from error
|
|
119
|
+
return destination
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def reason_for(error: BaseException) -> str:
|
|
123
|
+
# A fixed category rather than the operating system's own message, so no absolute
|
|
124
|
+
# path or platform wording reaches an error, and the category alone names the repair.
|
|
125
|
+
# UnicodeError precedes ValueError because it is the more specific subclass.
|
|
126
|
+
match error:
|
|
127
|
+
case FileNotFoundError():
|
|
128
|
+
return "the file or one of its parent directories does not exist"
|
|
129
|
+
case PermissionError():
|
|
130
|
+
return "permission was denied"
|
|
131
|
+
case IsADirectoryError():
|
|
132
|
+
return "the path names a directory, not a file"
|
|
133
|
+
case NotADirectoryError() | FileExistsError():
|
|
134
|
+
return "a parent of the path is a file, not a directory"
|
|
135
|
+
case UnicodeError():
|
|
136
|
+
return "the content is not valid UTF-8 text"
|
|
137
|
+
case ValueError():
|
|
138
|
+
return "the content is not valid JSON"
|
|
139
|
+
case OSError() if error.errno == errno.ENOSPC:
|
|
140
|
+
return "the volume is full"
|
|
141
|
+
case OSError() if error.errno == errno.EROFS:
|
|
142
|
+
return "the volume is read-only"
|
|
143
|
+
case RuntimeError():
|
|
144
|
+
return "the path could not be resolved, such as an unexpandable ~ or a link loop"
|
|
145
|
+
case _:
|
|
146
|
+
return "the operating system refused the operation"
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _discard(temporary: str | None) -> None:
|
|
150
|
+
# Only this call's own temp sibling is ever removed, never the destination.
|
|
151
|
+
if temporary is None:
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
os.unlink(temporary)
|
|
155
|
+
except OSError:
|
|
156
|
+
pass
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Public process-I/O contracts: streams, terminal capabilities, prompts, and attachments."""
|
|
2
|
+
|
|
3
|
+
from .attachments import AttachmentResolver
|
|
4
|
+
from .codex_attachments import CodexAttachmentInputBuilder
|
|
5
|
+
from .prompt import PromptInputResolver
|
|
6
|
+
from .streams import IOStreams
|
|
7
|
+
from .terminal import TerminalCapabilities, TerminalPolicy
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AttachmentResolver",
|
|
11
|
+
"CodexAttachmentInputBuilder",
|
|
12
|
+
"IOStreams",
|
|
13
|
+
"PromptInputResolver",
|
|
14
|
+
"TerminalCapabilities",
|
|
15
|
+
"TerminalPolicy",
|
|
16
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Resolves explicit local agent attachments into bounded immutable snapshots.
|
|
2
|
+
|
|
3
|
+
This module owns generic filesystem access for future agent commands. It does not attach an
|
|
4
|
+
option to a command, launch a provider, or expand directories and globs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ...types.attachments import AgentAttachment, AttachmentBundle, AttachmentKind
|
|
13
|
+
from ..constants.runtime import AttachmentImageSuffix, AttachmentLimit
|
|
14
|
+
from ..errors.failures import (
|
|
15
|
+
AttachmentDirectory,
|
|
16
|
+
AttachmentDuplicate,
|
|
17
|
+
AttachmentEmpty,
|
|
18
|
+
AttachmentFileNotFound,
|
|
19
|
+
AttachmentInputInvalid,
|
|
20
|
+
AttachmentLimitExceeded,
|
|
21
|
+
AttachmentTooLarge,
|
|
22
|
+
AttachmentUnreadable,
|
|
23
|
+
AttachmentUnsupported,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AttachmentResolver:
|
|
28
|
+
"""Reads an ordered, explicit set of files into one validated attachment bundle."""
|
|
29
|
+
|
|
30
|
+
def resolve(self, paths: tuple[Path, ...]) -> AttachmentBundle:
|
|
31
|
+
# Validates collection shape and limits before touching any caller path.
|
|
32
|
+
self._validate_input(paths)
|
|
33
|
+
if not paths:
|
|
34
|
+
return AttachmentBundle()
|
|
35
|
+
items: list[AgentAttachment] = []
|
|
36
|
+
seen: set[Path] = set()
|
|
37
|
+
total_bytes = 0
|
|
38
|
+
for path in paths:
|
|
39
|
+
item = self._resolve_one(path, seen, total_bytes)
|
|
40
|
+
total_bytes += item.size_bytes
|
|
41
|
+
if total_bytes > int(AttachmentLimit.MAX_TOTAL_BYTES):
|
|
42
|
+
raise AttachmentLimitExceeded()
|
|
43
|
+
items.append(item)
|
|
44
|
+
return AttachmentBundle(items=tuple(items), total_bytes=total_bytes)
|
|
45
|
+
|
|
46
|
+
def _validate_input(self, paths: tuple[Path, ...]) -> None:
|
|
47
|
+
# Rejects values outside Click's tuple-of-Path contract before accidental iteration.
|
|
48
|
+
if not isinstance(paths, tuple) or any(not isinstance(path, Path) for path in paths):
|
|
49
|
+
raise AttachmentInputInvalid()
|
|
50
|
+
if len(paths) > int(AttachmentLimit.MAX_FILES):
|
|
51
|
+
raise AttachmentLimitExceeded()
|
|
52
|
+
|
|
53
|
+
def _resolve_one(self, path: Path, seen: set[Path], total_bytes: int) -> AgentAttachment:
|
|
54
|
+
# Resolves one file, hashes its exact bytes, and classifies it without a second read.
|
|
55
|
+
resolved = self._resolved_path(path)
|
|
56
|
+
if resolved in seen:
|
|
57
|
+
raise AttachmentDuplicate()
|
|
58
|
+
seen.add(resolved)
|
|
59
|
+
raw = self._read_bytes(resolved)
|
|
60
|
+
size_bytes = len(raw)
|
|
61
|
+
if size_bytes == 0:
|
|
62
|
+
raise AttachmentEmpty()
|
|
63
|
+
if size_bytes > int(AttachmentLimit.MAX_FILE_BYTES):
|
|
64
|
+
raise AttachmentTooLarge()
|
|
65
|
+
if total_bytes + size_bytes > int(AttachmentLimit.MAX_TOTAL_BYTES):
|
|
66
|
+
raise AttachmentLimitExceeded()
|
|
67
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
68
|
+
if self._is_image(resolved):
|
|
69
|
+
return AgentAttachment(
|
|
70
|
+
supplied_path=path,
|
|
71
|
+
resolved_path=resolved,
|
|
72
|
+
name=path.name,
|
|
73
|
+
kind=AttachmentKind.IMAGE,
|
|
74
|
+
size_bytes=size_bytes,
|
|
75
|
+
sha256=digest,
|
|
76
|
+
)
|
|
77
|
+
content = self._decode_text(raw)
|
|
78
|
+
return AgentAttachment(
|
|
79
|
+
supplied_path=path,
|
|
80
|
+
resolved_path=resolved,
|
|
81
|
+
name=path.name,
|
|
82
|
+
kind=AttachmentKind.TEXT,
|
|
83
|
+
size_bytes=size_bytes,
|
|
84
|
+
sha256=digest,
|
|
85
|
+
content=content,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _resolved_path(self, path: Path) -> Path:
|
|
89
|
+
# Converts path errors into a stable typed failure without exposing private paths.
|
|
90
|
+
try:
|
|
91
|
+
resolved = path.expanduser().resolve(strict=False)
|
|
92
|
+
except (OSError, RuntimeError) as error:
|
|
93
|
+
raise AttachmentUnreadable() from error
|
|
94
|
+
try:
|
|
95
|
+
if not resolved.exists():
|
|
96
|
+
raise AttachmentFileNotFound()
|
|
97
|
+
if resolved.is_dir():
|
|
98
|
+
raise AttachmentDirectory()
|
|
99
|
+
if not resolved.is_file():
|
|
100
|
+
raise AttachmentUnreadable()
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise AttachmentUnreadable() from error
|
|
103
|
+
return resolved
|
|
104
|
+
|
|
105
|
+
def _read_bytes(self, path: Path) -> bytes:
|
|
106
|
+
# Reads one exact snapshot and hides filesystem details from the public failure.
|
|
107
|
+
try:
|
|
108
|
+
return path.read_bytes()
|
|
109
|
+
except OSError as error:
|
|
110
|
+
raise AttachmentUnreadable() from error
|
|
111
|
+
|
|
112
|
+
def _is_image(self, path: Path) -> bool:
|
|
113
|
+
# Uses the closed suffix enum so provider-native image support cannot drift silently.
|
|
114
|
+
return path.suffix.lower() in {item.value for item in AttachmentImageSuffix}
|
|
115
|
+
|
|
116
|
+
def _decode_text(self, raw: bytes) -> str:
|
|
117
|
+
# Accepts UTF-8 with an optional BOM and rejects binary-looking text with NUL bytes.
|
|
118
|
+
try:
|
|
119
|
+
content = raw.decode("utf-8-sig")
|
|
120
|
+
except UnicodeDecodeError as error:
|
|
121
|
+
raise AttachmentUnsupported() from error
|
|
122
|
+
if "\x00" in content:
|
|
123
|
+
raise AttachmentUnsupported()
|
|
124
|
+
if not content:
|
|
125
|
+
raise AttachmentEmpty()
|
|
126
|
+
return content
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = ["AttachmentResolver"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Converts provider-neutral attachment snapshots into native Codex run inputs.
|
|
2
|
+
|
|
3
|
+
The SDK stays behind a lazy import boundary so importing the shared attachment layer never
|
|
4
|
+
starts provider work or makes `--help` depend on Codex being installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ...types.attachments import AgentAttachment, AttachmentBundle, AttachmentKind
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CodexAttachmentInputBuilder:
|
|
15
|
+
"""Builds one CodexRunInput from a prompt and an already-resolved attachment bundle."""
|
|
16
|
+
|
|
17
|
+
def build(self, prompt: str, bundle: AttachmentBundle) -> Any:
|
|
18
|
+
# Imports occur only when a future Codex service actually starts an agent turn.
|
|
19
|
+
from vidbyte.context.primitives import FileContextItem
|
|
20
|
+
from vidbyte.lib.dataclasses.codex import (
|
|
21
|
+
CodexLocalImageInput,
|
|
22
|
+
CodexRunInput,
|
|
23
|
+
CodexTextInput,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
inputs: list[Any] = [CodexTextInput(prompt)]
|
|
27
|
+
context_items: list[Any] = []
|
|
28
|
+
for attachment in bundle.items:
|
|
29
|
+
if attachment.kind is AttachmentKind.IMAGE:
|
|
30
|
+
inputs.append(CodexLocalImageInput(str(attachment.resolved_path)))
|
|
31
|
+
else:
|
|
32
|
+
context_items.append(self._file_context(FileContextItem, attachment))
|
|
33
|
+
return CodexRunInput(items=tuple(inputs), context_items=tuple(context_items))
|
|
34
|
+
|
|
35
|
+
def _file_context(self, file_type: type[Any], attachment: AgentAttachment) -> Any:
|
|
36
|
+
# Reuses the resolver's bytes and hash rather than reading a changed file again.
|
|
37
|
+
return file_type(
|
|
38
|
+
path=str(attachment.supplied_path),
|
|
39
|
+
absolute_path=str(attachment.resolved_path),
|
|
40
|
+
size_bytes=attachment.size_bytes,
|
|
41
|
+
content=attachment.content,
|
|
42
|
+
language=attachment.resolved_path.suffix.lstrip(".") or None,
|
|
43
|
+
metadata={"sha256": attachment.sha256},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["CodexAttachmentInputBuilder"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Exactly one prompt source per run: a positional value, a UTF-8 file, or explicit stdin.
|
|
2
|
+
|
|
3
|
+
Two rules drive the shape. Sources are exclusive and checked before anything is opened, so
|
|
4
|
+
an ambiguous command line can never quietly resolve one way. And stdin is read only for the
|
|
5
|
+
literal `-` marker, so a command whose input happens to be redirected fails fast instead of
|
|
6
|
+
blocking on a read that will never complete.
|
|
7
|
+
|
|
8
|
+
Reads take one character past the limit: that detects oversized input without pulling an
|
|
9
|
+
arbitrarily large file into memory. Error prose reports lengths, never prompt content.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..errors.failures import (
|
|
17
|
+
AmbiguousPromptSource,
|
|
18
|
+
EmptyPrompt,
|
|
19
|
+
MissingPrompt,
|
|
20
|
+
PromptFileNotUtf8,
|
|
21
|
+
PromptFileUnreadable,
|
|
22
|
+
PromptTooLong,
|
|
23
|
+
StandardInputNotText,
|
|
24
|
+
)
|
|
25
|
+
from .streams import IOStreams
|
|
26
|
+
|
|
27
|
+
_DEFAULT_MAX_CHARS = 20_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PromptInputResolver:
|
|
31
|
+
"""Bounded resolver for explicit text, file, or stdin prompt input."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, streams: IOStreams, max_chars: int = _DEFAULT_MAX_CHARS) -> None:
|
|
34
|
+
self._streams = streams
|
|
35
|
+
self._max_chars = max_chars
|
|
36
|
+
|
|
37
|
+
def resolve(self, positional: str | None, prompt_file: str | None) -> str:
|
|
38
|
+
# Reject ambiguity before reading either external source.
|
|
39
|
+
if positional is not None and prompt_file is not None:
|
|
40
|
+
raise AmbiguousPromptSource()
|
|
41
|
+
if positional is None and prompt_file is None:
|
|
42
|
+
raise MissingPrompt()
|
|
43
|
+
if prompt_file is not None:
|
|
44
|
+
return self._read_file(Path(prompt_file))
|
|
45
|
+
if positional == "-":
|
|
46
|
+
return self._read_stdin()
|
|
47
|
+
return self._validate(positional or "")
|
|
48
|
+
|
|
49
|
+
def _read_file(self, path: Path) -> str:
|
|
50
|
+
# UTF-8 is explicit so machine behavior does not depend on the host locale.
|
|
51
|
+
try:
|
|
52
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
53
|
+
return self._validate(handle.read(self._max_chars + 1))
|
|
54
|
+
except UnicodeDecodeError as error:
|
|
55
|
+
raise PromptFileNotUtf8(error) from error
|
|
56
|
+
except (OSError, ValueError) as error:
|
|
57
|
+
raise PromptFileUnreadable(str(path), error) from error
|
|
58
|
+
|
|
59
|
+
def _read_stdin(self) -> str:
|
|
60
|
+
# Explicit stdin remains bounded and decoding failures stay safe usage errors.
|
|
61
|
+
try:
|
|
62
|
+
return self._validate(self._streams.stdin.read(self._max_chars + 1))
|
|
63
|
+
except UnicodeError as error:
|
|
64
|
+
raise StandardInputNotText(error) from error
|
|
65
|
+
|
|
66
|
+
def _validate(self, value: str) -> str:
|
|
67
|
+
if not value.strip():
|
|
68
|
+
raise EmptyPrompt()
|
|
69
|
+
if len(value) > self._max_chars:
|
|
70
|
+
raise PromptTooLong(self._max_chars)
|
|
71
|
+
return value
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The process channels one CLI invocation writes to, as an injected dependency.
|
|
2
|
+
|
|
3
|
+
Reusable code never touches `sys.stdout`/`sys.stderr` directly, so the CLI can be embedded,
|
|
4
|
+
redirected, or captured without monkey-patching `sys`. Formatting policy (human vs JSON,
|
|
5
|
+
color) is deliberately NOT here — `lib/output` owns it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TextIO
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class IOStreams:
|
|
17
|
+
"""The stdin/stdout/stderr triple owned by one invocation."""
|
|
18
|
+
|
|
19
|
+
stdin: TextIO
|
|
20
|
+
stdout: TextIO
|
|
21
|
+
stderr: TextIO
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def system(cls) -> IOStreams:
|
|
25
|
+
# Bound at invocation time, not import time, so redirection set up by an embedding
|
|
26
|
+
# caller after `import vidbyte_cli` is still honoured.
|
|
27
|
+
return cls(stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
|
|
28
|
+
|
|
29
|
+
def write_output(self, message: str) -> None:
|
|
30
|
+
# Results go to stdout so shell pipelines stay dependable.
|
|
31
|
+
self.stdout.write(f"{message}\n")
|
|
32
|
+
self.stdout.flush()
|
|
33
|
+
|
|
34
|
+
def write_error(self, message: str) -> None:
|
|
35
|
+
# Diagnostics go to stderr so a failure can't corrupt structured stdout.
|
|
36
|
+
self.stderr.write(f"{message}\n")
|
|
37
|
+
self.stderr.flush()
|