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 ADDED
File without changes
@@ -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