obvyr-cli 1.0.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.
- obvyr_cli/__init__.py +0 -0
- obvyr_cli/api_client.py +134 -0
- obvyr_cli/archive_builder.py +147 -0
- obvyr_cli/cli.py +352 -0
- obvyr_cli/command_wrapper.py +302 -0
- obvyr_cli/config.py +88 -0
- obvyr_cli/error_handling.py +164 -0
- obvyr_cli/logging_config.py +162 -0
- obvyr_cli/schemas.py +94 -0
- obvyr_cli/utils.py +13 -0
- obvyr_cli-1.0.0.dist-info/METADATA +49 -0
- obvyr_cli-1.0.0.dist-info/RECORD +14 -0
- obvyr_cli-1.0.0.dist-info/WHEEL +4 -0
- obvyr_cli-1.0.0.dist-info/entry_points.txt +3 -0
obvyr_cli/__init__.py
ADDED
|
File without changes
|
obvyr_cli/api_client.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import pathlib
|
|
3
|
+
from typing import Any, BinaryIO, Dict, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from tenacity import (
|
|
7
|
+
retry,
|
|
8
|
+
retry_if_exception_type,
|
|
9
|
+
stop_after_attempt,
|
|
10
|
+
wait_fixed,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from obvyr_cli import utils
|
|
14
|
+
from obvyr_cli.error_handling import handle_api_error, handle_network_error
|
|
15
|
+
from obvyr_cli.logging_config import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger("api_client")
|
|
18
|
+
|
|
19
|
+
project_config = utils.get_project_config()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ObvyrAPIClient:
|
|
23
|
+
"""Client for sending execution data to the Obvyr API."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_key: str,
|
|
28
|
+
base_url: str,
|
|
29
|
+
timeout: float = 5.0,
|
|
30
|
+
verify_ssl: bool = True,
|
|
31
|
+
http_client: Optional[httpx.Client] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Initialize API client with optional settings injection."""
|
|
34
|
+
self.api_key = api_key
|
|
35
|
+
self.verify_ssl = verify_ssl
|
|
36
|
+
self.client: httpx.Client = http_client or httpx.Client(
|
|
37
|
+
base_url=base_url, timeout=timeout, verify=self.verify_ssl
|
|
38
|
+
)
|
|
39
|
+
self._closed = False
|
|
40
|
+
atexit.register(self.close)
|
|
41
|
+
|
|
42
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
43
|
+
"""Generate headers for authentication."""
|
|
44
|
+
return {
|
|
45
|
+
"Authorization": (f"Bearer {self.api_key}"),
|
|
46
|
+
"User-Agent": f"obvyr-cli/{project_config['version']}",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@retry(
|
|
50
|
+
stop=stop_after_attempt(3),
|
|
51
|
+
wait=wait_fixed(2),
|
|
52
|
+
retry=retry_if_exception_type(
|
|
53
|
+
(httpx.RequestError, httpx.HTTPStatusError)
|
|
54
|
+
),
|
|
55
|
+
reraise=True,
|
|
56
|
+
)
|
|
57
|
+
def send_data(
|
|
58
|
+
self,
|
|
59
|
+
endpoint: str,
|
|
60
|
+
data: Dict[str, Any],
|
|
61
|
+
file: Optional[Tuple[str, BinaryIO]] = None,
|
|
62
|
+
) -> Optional[Dict[str, Any]]:
|
|
63
|
+
"""Send execution data to the API with retries on transient failures."""
|
|
64
|
+
headers = self._get_headers()
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
optional_parameters: Dict[str, Any] = {}
|
|
68
|
+
|
|
69
|
+
if file:
|
|
70
|
+
optional_parameters = {
|
|
71
|
+
**optional_parameters,
|
|
72
|
+
"files": {"attachment": (file[0], file[1])},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
response = self.client.post(
|
|
76
|
+
endpoint, headers=headers, data=data, **optional_parameters
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
|
|
81
|
+
return response.json()
|
|
82
|
+
except httpx.HTTPStatusError as e:
|
|
83
|
+
return handle_api_error(e)
|
|
84
|
+
except (httpx.RequestError, httpx.TimeoutException) as e:
|
|
85
|
+
handle_network_error(e)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
@retry(
|
|
89
|
+
stop=stop_after_attempt(3),
|
|
90
|
+
wait=wait_fixed(2),
|
|
91
|
+
retry=retry_if_exception_type(
|
|
92
|
+
(httpx.RequestError, httpx.HTTPStatusError)
|
|
93
|
+
),
|
|
94
|
+
reraise=True,
|
|
95
|
+
)
|
|
96
|
+
def send_archive(
|
|
97
|
+
self, endpoint: str, archive_path: pathlib.Path
|
|
98
|
+
) -> Optional[Dict[str, Any]]:
|
|
99
|
+
"""Send archive file to the API with retries on transient failures."""
|
|
100
|
+
if not archive_path.exists():
|
|
101
|
+
raise FileNotFoundError(f"Archive file not found: {archive_path}")
|
|
102
|
+
|
|
103
|
+
headers = self._get_headers()
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
with open(archive_path, "rb") as archive_file:
|
|
107
|
+
files = {"archive": ("artifacts.tar.zst", archive_file)}
|
|
108
|
+
response = self.client.post(
|
|
109
|
+
endpoint, headers=headers, files=files
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
|
|
114
|
+
return response.json()
|
|
115
|
+
except httpx.HTTPStatusError as e:
|
|
116
|
+
return handle_api_error(e)
|
|
117
|
+
except (httpx.RequestError, httpx.TimeoutException) as e:
|
|
118
|
+
handle_network_error(e)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
"""Close the HTTP connection pool."""
|
|
123
|
+
if not self._closed:
|
|
124
|
+
self.client.close()
|
|
125
|
+
self._closed = True
|
|
126
|
+
logger.debug("Closed API client connection.")
|
|
127
|
+
|
|
128
|
+
def __enter__(self) -> "ObvyrAPIClient":
|
|
129
|
+
"""Enable use of `with ObvyrAPIClient() as client`."""
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def __exit__(self, *args: object, **kwargs: object) -> None:
|
|
133
|
+
"""Ensure the HTTP client is closed when exiting context."""
|
|
134
|
+
self.close()
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Archive builder for creating artifacts.tar.zst files.
|
|
3
|
+
|
|
4
|
+
This module creates compressed tar archives containing command execution data
|
|
5
|
+
and optional attachments in the format required by the /collect API endpoint.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import tarfile
|
|
13
|
+
import tempfile
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Dict, Optional
|
|
16
|
+
|
|
17
|
+
import zstandard as zstd
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from obvyr_cli.schemas import RunCommandResponse
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ArchiveSummary(BaseModel):
|
|
24
|
+
"""Summary of archive contents and sizes."""
|
|
25
|
+
|
|
26
|
+
archive_bytes: int
|
|
27
|
+
members: Dict[str, Dict[str, int]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_artifacts_tar_zst(
|
|
31
|
+
run_command_response: RunCommandResponse,
|
|
32
|
+
attachment_paths: Optional[list[pathlib.Path]] = None,
|
|
33
|
+
tmp_dir: Optional[pathlib.Path] = None,
|
|
34
|
+
tags: Optional[list[str]] = None,
|
|
35
|
+
) -> pathlib.Path:
|
|
36
|
+
"""
|
|
37
|
+
Build artifacts.tar.zst archive from command execution data.
|
|
38
|
+
|
|
39
|
+
Creates a compressed tar archive containing:
|
|
40
|
+
- /command.json (required)
|
|
41
|
+
- /output.txt (optional; UTF-8 mixed stdout/stderr)
|
|
42
|
+
- /attachment/<filename> (optional)
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
run_command_response: Command execution response containing metadata and output
|
|
46
|
+
attachment_paths: Optional list of attachment files to include
|
|
47
|
+
tmp_dir: Optional temporary directory for output file
|
|
48
|
+
tags: Optional list of tags to include in command.json
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Path to the created artifacts.tar.zst file
|
|
52
|
+
"""
|
|
53
|
+
if tmp_dir is None:
|
|
54
|
+
tmp_dir = pathlib.Path(tempfile.gettempdir())
|
|
55
|
+
|
|
56
|
+
# Create output file path
|
|
57
|
+
output_path = tmp_dir / "artifacts.tar.zst"
|
|
58
|
+
|
|
59
|
+
# Prepare command.json data exactly as specified in the doc
|
|
60
|
+
command_data = {
|
|
61
|
+
"command": run_command_response.command,
|
|
62
|
+
"user": run_command_response.user,
|
|
63
|
+
"return_code": run_command_response.returncode,
|
|
64
|
+
"execution_time_ms": round(run_command_response.execution_time * 1000),
|
|
65
|
+
"executed": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
|
66
|
+
"env": dict(os.environ),
|
|
67
|
+
"tags": tags or [],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Create tar archive in memory first
|
|
71
|
+
tar_buffer = io.BytesIO()
|
|
72
|
+
|
|
73
|
+
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
|
|
74
|
+
# Add command.json (required)
|
|
75
|
+
command_json_bytes = json.dumps(
|
|
76
|
+
command_data, separators=(",", ":")
|
|
77
|
+
).encode("utf-8")
|
|
78
|
+
command_info = tarfile.TarInfo("command.json")
|
|
79
|
+
command_info.size = len(command_json_bytes)
|
|
80
|
+
tar.addfile(command_info, io.BytesIO(command_json_bytes))
|
|
81
|
+
|
|
82
|
+
# Add output.txt if present (optional; mixed stdout/stderr)
|
|
83
|
+
if run_command_response.output:
|
|
84
|
+
output_bytes = run_command_response.output.encode("utf-8")
|
|
85
|
+
output_info = tarfile.TarInfo("output.txt")
|
|
86
|
+
output_info.size = len(output_bytes)
|
|
87
|
+
tar.addfile(output_info, io.BytesIO(output_bytes))
|
|
88
|
+
|
|
89
|
+
# Add attachments if provided (optional)
|
|
90
|
+
if attachment_paths:
|
|
91
|
+
for attachment_path in attachment_paths:
|
|
92
|
+
if not attachment_path.exists():
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# Use attachment/<filename> structure as specified
|
|
96
|
+
arcname = f"attachment/{attachment_path.name}"
|
|
97
|
+
|
|
98
|
+
# Stream file without loading into memory
|
|
99
|
+
with open(attachment_path, "rb") as f:
|
|
100
|
+
attachment_info = tarfile.TarInfo(arcname)
|
|
101
|
+
attachment_info.size = attachment_path.stat().st_size
|
|
102
|
+
tar.addfile(attachment_info, f)
|
|
103
|
+
|
|
104
|
+
# Compress tar with zstd
|
|
105
|
+
tar_buffer.seek(0)
|
|
106
|
+
tar_data = tar_buffer.read()
|
|
107
|
+
|
|
108
|
+
compressor = zstd.ZstdCompressor(write_content_size=True)
|
|
109
|
+
compressed_data = compressor.compress(tar_data)
|
|
110
|
+
|
|
111
|
+
with open(output_path, "wb") as output_file:
|
|
112
|
+
output_file.write(compressed_data)
|
|
113
|
+
|
|
114
|
+
return output_path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def summarize_archive(archive_path: pathlib.Path) -> ArchiveSummary:
|
|
118
|
+
"""
|
|
119
|
+
Summarise contents of an artifacts.tar.zst archive.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
archive_path: Path to the artifacts.tar.zst file
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
ArchiveSummary containing archive size and member information
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
FileNotFoundError: If archive file doesn't exist
|
|
129
|
+
"""
|
|
130
|
+
if not archive_path.exists():
|
|
131
|
+
raise FileNotFoundError(f"Archive not found: {archive_path}")
|
|
132
|
+
|
|
133
|
+
# Get archive size
|
|
134
|
+
archive_bytes = archive_path.stat().st_size
|
|
135
|
+
|
|
136
|
+
# Extract and examine tar contents
|
|
137
|
+
decompressor = zstd.ZstdDecompressor()
|
|
138
|
+
members: Dict[str, Dict[str, int]] = {}
|
|
139
|
+
|
|
140
|
+
with open(archive_path, "rb") as archive_file:
|
|
141
|
+
with decompressor.stream_reader(archive_file) as reader:
|
|
142
|
+
with tarfile.open(fileobj=reader, mode="r|") as tar:
|
|
143
|
+
for member in tar:
|
|
144
|
+
if member.isfile():
|
|
145
|
+
members[member.name] = {"bytes": member.size}
|
|
146
|
+
|
|
147
|
+
return ArchiveSummary(archive_bytes=archive_bytes, members=members)
|
obvyr_cli/cli.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import time
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from obvyr_cli.api_client import ObvyrAPIClient
|
|
8
|
+
from obvyr_cli.archive_builder import build_artifacts_tar_zst
|
|
9
|
+
from obvyr_cli.command_wrapper import run_command
|
|
10
|
+
from obvyr_cli.config import AgentSettings, Settings, get_settings
|
|
11
|
+
from obvyr_cli.error_handling import handle_archive_error
|
|
12
|
+
from obvyr_cli.logging_config import configure_logging, get_logger
|
|
13
|
+
from obvyr_cli.schemas import (
|
|
14
|
+
CommandExecutionConfig,
|
|
15
|
+
OutputMode,
|
|
16
|
+
RunCommandResponse,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = get_logger("cli")
|
|
20
|
+
|
|
21
|
+
# ===================================
|
|
22
|
+
# CLI Support Functions
|
|
23
|
+
# ===================================
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_output_mode(
|
|
27
|
+
output_mode: str, no_stream: bool, force_stream: bool
|
|
28
|
+
) -> OutputMode:
|
|
29
|
+
"""
|
|
30
|
+
Resolve output mode from CLI flags with priority handling.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
output_mode: Base output mode from --output-mode flag.
|
|
34
|
+
no_stream: Whether --no-stream flag was used.
|
|
35
|
+
force_stream: Whether --force-stream flag was used.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Resolved OutputMode.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
click.UsageError: If conflicting flags are provided.
|
|
42
|
+
"""
|
|
43
|
+
# Check for conflicting flags
|
|
44
|
+
if no_stream and force_stream:
|
|
45
|
+
raise click.UsageError(
|
|
46
|
+
"Cannot use both --no-stream and --force-stream flags together."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Priority: specific flags override --output-mode
|
|
50
|
+
if no_stream:
|
|
51
|
+
return OutputMode.BATCH
|
|
52
|
+
if force_stream:
|
|
53
|
+
return OutputMode.STREAM
|
|
54
|
+
|
|
55
|
+
# Use specified output mode
|
|
56
|
+
return OutputMode(output_mode.lower())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_attachment_fresh(
|
|
60
|
+
attachment_path: pathlib.Path, max_age_seconds: int = 10
|
|
61
|
+
) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Check if an attachment file is fresh enough to be included.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
attachment_path: Path to the attachment file
|
|
67
|
+
max_age_seconds: Maximum age in seconds for file to be considered fresh
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True if file exists and was modified within max_age_seconds, False otherwise
|
|
71
|
+
"""
|
|
72
|
+
if not attachment_path.exists():
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
if max_age_seconds <= 0:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
current_time = time.time()
|
|
79
|
+
file_mtime = attachment_path.stat().st_mtime
|
|
80
|
+
file_age_seconds = current_time - file_mtime
|
|
81
|
+
|
|
82
|
+
return file_age_seconds < max_age_seconds
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_available_agents(settings: Settings) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Lists all available agents.
|
|
88
|
+
"""
|
|
89
|
+
agents = settings.list_agents()
|
|
90
|
+
|
|
91
|
+
if len(agents) == 0:
|
|
92
|
+
click.echo("\nNo agents available.\n")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
click.echo("\nAvailable agents:\n")
|
|
96
|
+
for agent in agents:
|
|
97
|
+
click.echo(f" - {agent}")
|
|
98
|
+
click.echo("")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def show_agent_config(
|
|
102
|
+
settings: Settings, agent_name: str | None = None
|
|
103
|
+
) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Shows the configuration for the specified or active agent.
|
|
106
|
+
"""
|
|
107
|
+
config = settings.show_config(agent_name)
|
|
108
|
+
|
|
109
|
+
agent_display = agent_name or "DEFAULT"
|
|
110
|
+
click.echo(f"\nAgent '{agent_display}' configuration:\n")
|
|
111
|
+
for key, value in config.items():
|
|
112
|
+
click.echo(f" {key}: {value}")
|
|
113
|
+
click.echo("")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def has_handled_initial_options(
|
|
117
|
+
command: List[str],
|
|
118
|
+
list_agents: bool,
|
|
119
|
+
show_config: bool,
|
|
120
|
+
settings: Settings,
|
|
121
|
+
agent: str | None = None,
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""Handle initial options for listing agents or showing configuration."""
|
|
124
|
+
if command:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
if list_agents:
|
|
128
|
+
list_available_agents(settings)
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
if show_config:
|
|
132
|
+
show_agent_config(settings, agent)
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
raise click.UsageError(
|
|
136
|
+
"\n".join(
|
|
137
|
+
(
|
|
138
|
+
"No command provided.",
|
|
139
|
+
"Usage: obvyr-cli <command> [arguments]",
|
|
140
|
+
"Try 'obvyr-cli --help' for more information.",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def fetch_active_agent(
|
|
147
|
+
settings: Settings, agent_name: str | None = None
|
|
148
|
+
) -> AgentSettings:
|
|
149
|
+
"""Retrieve the active agent from settings."""
|
|
150
|
+
active_agent = settings.get_agent(agent_name)
|
|
151
|
+
agent_display_name = agent_name or "DEFAULT"
|
|
152
|
+
logger.debug(f"Using agent: {agent_display_name}")
|
|
153
|
+
return active_agent
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def display_execution_summary(response: RunCommandResponse) -> None:
|
|
157
|
+
"""Display the execution summary after streaming output."""
|
|
158
|
+
output = (
|
|
159
|
+
f"\nExecuted by {click.style(response.user, fg='green')} "
|
|
160
|
+
f"in {click.style(f'{response.execution_time:.2f}s', fg='blue')}\n"
|
|
161
|
+
)
|
|
162
|
+
click.echo(output)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def display_output(response: RunCommandResponse) -> None:
|
|
166
|
+
"""Display the command's output (legacy function for backward compatibility)."""
|
|
167
|
+
if response.output:
|
|
168
|
+
click.echo(f"\n{response.output}")
|
|
169
|
+
display_execution_summary(response)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def send_to_api(active_agent: AgentSettings, data: RunCommandResponse) -> None:
|
|
173
|
+
"""
|
|
174
|
+
Sends execution data to the Obvyr API.
|
|
175
|
+
|
|
176
|
+
:param active_agent: Agent configuration to use for API submission.
|
|
177
|
+
:param data: Command execution result to be sent to the API.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
if not active_agent.API_KEY:
|
|
181
|
+
logger.debug("API submission disabled: No API key configured.")
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
archive_path = None
|
|
185
|
+
try:
|
|
186
|
+
# Create archive from command execution data
|
|
187
|
+
attachment_paths = None
|
|
188
|
+
if active_agent.ATTACHMENT_PATH:
|
|
189
|
+
attachment_path = pathlib.Path(active_agent.ATTACHMENT_PATH)
|
|
190
|
+
# Only include attachment if it's fresh
|
|
191
|
+
if is_attachment_fresh(
|
|
192
|
+
attachment_path, active_agent.ATTACHMENT_MAX_AGE_SECONDS
|
|
193
|
+
):
|
|
194
|
+
attachment_paths = [attachment_path]
|
|
195
|
+
logger.debug(f"Including fresh attachment: {attachment_path}")
|
|
196
|
+
else:
|
|
197
|
+
logger.debug(f"Skipping stale attachment: {attachment_path}")
|
|
198
|
+
|
|
199
|
+
# Build archive with or without attachments
|
|
200
|
+
archive_path = build_artifacts_tar_zst(
|
|
201
|
+
data, attachment_paths=attachment_paths, tags=active_agent.TAGS
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
with ObvyrAPIClient(
|
|
205
|
+
api_key=active_agent.API_KEY,
|
|
206
|
+
base_url=active_agent.API_URL,
|
|
207
|
+
timeout=active_agent.TIMEOUT,
|
|
208
|
+
verify_ssl=active_agent.VERIFY_SSL,
|
|
209
|
+
) as client:
|
|
210
|
+
start_time = time.time()
|
|
211
|
+
response = client.send_archive("/collect", archive_path)
|
|
212
|
+
end_time = time.time()
|
|
213
|
+
|
|
214
|
+
logger.debug(f"API request time: {end_time - start_time:.2f}s")
|
|
215
|
+
|
|
216
|
+
if response:
|
|
217
|
+
logger.debug(f"Successfully sent data to API: {response}")
|
|
218
|
+
else:
|
|
219
|
+
logger.warning(
|
|
220
|
+
"Failed to send data to API. Check your configuration."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
except OSError as e:
|
|
224
|
+
handle_archive_error(e)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
# Other errors are already handled by the API client's centralised error handling
|
|
227
|
+
logger.error(f"Unexpected error during API submission: {e}")
|
|
228
|
+
finally:
|
|
229
|
+
# Always clean up the temporary archive file
|
|
230
|
+
if archive_path and archive_path.exists():
|
|
231
|
+
archive_path.unlink()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ===================================
|
|
235
|
+
# Click CLI
|
|
236
|
+
# ===================================
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@click.command(context_settings={"ignore_unknown_options": True})
|
|
240
|
+
@click.argument("command", nargs=-1, required=False, type=click.UNPROCESSED)
|
|
241
|
+
@click.option(
|
|
242
|
+
"--list-agents",
|
|
243
|
+
"list_agents",
|
|
244
|
+
is_flag=True,
|
|
245
|
+
help="List all available agents.",
|
|
246
|
+
)
|
|
247
|
+
@click.option(
|
|
248
|
+
"--show-config",
|
|
249
|
+
"show_config",
|
|
250
|
+
is_flag=True,
|
|
251
|
+
help="Show config for active agent.",
|
|
252
|
+
)
|
|
253
|
+
@click.option(
|
|
254
|
+
"--verbose",
|
|
255
|
+
"-v",
|
|
256
|
+
is_flag=True,
|
|
257
|
+
help="Enable verbose logging (debug mode).",
|
|
258
|
+
)
|
|
259
|
+
@click.option(
|
|
260
|
+
"--quiet",
|
|
261
|
+
"-q",
|
|
262
|
+
is_flag=True,
|
|
263
|
+
help="Enable quiet mode (errors only).",
|
|
264
|
+
)
|
|
265
|
+
@click.option(
|
|
266
|
+
"--agent",
|
|
267
|
+
"-a",
|
|
268
|
+
help="Specify which agent configuration to use.",
|
|
269
|
+
)
|
|
270
|
+
@click.option(
|
|
271
|
+
"--output-mode",
|
|
272
|
+
type=click.Choice(["auto", "stream", "batch"], case_sensitive=False),
|
|
273
|
+
default="auto",
|
|
274
|
+
help="Output mode: auto (CI detection), stream (force streaming), batch (force batch).",
|
|
275
|
+
)
|
|
276
|
+
@click.option(
|
|
277
|
+
"--no-stream",
|
|
278
|
+
is_flag=True,
|
|
279
|
+
help="Disable streaming output (equivalent to --output-mode=batch).",
|
|
280
|
+
)
|
|
281
|
+
@click.option(
|
|
282
|
+
"--force-stream",
|
|
283
|
+
is_flag=True,
|
|
284
|
+
help="Force streaming output (equivalent to --output-mode=stream).",
|
|
285
|
+
)
|
|
286
|
+
@click.option(
|
|
287
|
+
"--no-color",
|
|
288
|
+
is_flag=True,
|
|
289
|
+
help="Disable color output (don't set FORCE_COLOR environment variable).",
|
|
290
|
+
)
|
|
291
|
+
def cli_run_process(
|
|
292
|
+
command: List[str],
|
|
293
|
+
list_agents: bool,
|
|
294
|
+
show_config: bool,
|
|
295
|
+
verbose: bool,
|
|
296
|
+
quiet: bool,
|
|
297
|
+
agent: str | None,
|
|
298
|
+
output_mode: str,
|
|
299
|
+
no_stream: bool,
|
|
300
|
+
force_stream: bool,
|
|
301
|
+
no_color: bool,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""
|
|
304
|
+
Executes a system command while using the Obvyr agent configuration.
|
|
305
|
+
"""
|
|
306
|
+
# Configure logging based on CLI flags
|
|
307
|
+
configure_logging(verbose=verbose, quiet=quiet)
|
|
308
|
+
|
|
309
|
+
# Resolve output mode from flags
|
|
310
|
+
resolved_output_mode = _resolve_output_mode(
|
|
311
|
+
output_mode, no_stream, force_stream
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Create command execution configuration
|
|
315
|
+
# force_color=True by default, --no-color flag disables it
|
|
316
|
+
execution_config = CommandExecutionConfig(
|
|
317
|
+
output_mode=resolved_output_mode,
|
|
318
|
+
force_color=not no_color, # Default True, disabled by --no-color flag
|
|
319
|
+
preserve_ansi=True,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
settings = get_settings()
|
|
324
|
+
|
|
325
|
+
if has_handled_initial_options(
|
|
326
|
+
command, list_agents, show_config, settings, agent
|
|
327
|
+
):
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
active_agent = fetch_active_agent(settings, agent)
|
|
331
|
+
|
|
332
|
+
# Create streaming callback for real-time output display
|
|
333
|
+
def stream_output(line: str) -> None:
|
|
334
|
+
"""Stream output line-by-line to console."""
|
|
335
|
+
click.echo(line)
|
|
336
|
+
|
|
337
|
+
response: RunCommandResponse = run_command(
|
|
338
|
+
list(command),
|
|
339
|
+
stream_callback=stream_output,
|
|
340
|
+
config=execution_config,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if active_agent.API_URL and active_agent.API_KEY:
|
|
344
|
+
send_to_api(active_agent, response)
|
|
345
|
+
|
|
346
|
+
display_execution_summary(response)
|
|
347
|
+
|
|
348
|
+
if response.returncode != 0:
|
|
349
|
+
raise click.exceptions.Exit(response.returncode)
|
|
350
|
+
|
|
351
|
+
except Exception as e:
|
|
352
|
+
raise click.ClickException(str(e)) from e
|