obvyr-cli 2.6.0__tar.gz → 2.8.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obvyr-cli
3
- Version: 2.6.0
3
+ Version: 2.8.0
4
4
  Summary: Command-line wrapper to allow Obvyr to capture evidence from any machine.
5
5
  License: Proprietary
6
6
  Author: Wyrd Technology Ltd
@@ -8,14 +8,14 @@ Requires-Python: >=3.14,<4.0
8
8
  Classifier: License :: Other/Proprietary License
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3.14
11
- Requires-Dist: click (==8.4.2)
11
+ Classifier: Programming Language :: Python :: 3.15
12
+ Requires-Dist: click (==8.5.0)
12
13
  Requires-Dist: httpx (==0.28.1)
13
- Requires-Dist: orjson (==3.11.9)
14
- Requires-Dist: pydantic (==2.13.4)
15
- Requires-Dist: pydantic-settings (==2.14.2)
16
- Requires-Dist: python-dotenv (==1.2.2)
14
+ Requires-Dist: orjson (==3.12.0)
15
+ Requires-Dist: pydantic (==2.13.5)
16
+ Requires-Dist: pydantic-settings (==2.15.0)
17
+ Requires-Dist: python-dotenv (==1.2.4)
17
18
  Requires-Dist: tenacity (==9.1.4)
18
- Requires-Dist: toml (==0.10.2)
19
19
  Requires-Dist: zstandard (==0.25.0)
20
20
  Project-URL: Homepage, https://wyrd-technology.com
21
21
  Description-Content-Type: text/markdown
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["poetry-core==2.4.1"]
2
+ requires = ["poetry-core==2.5.0"]
3
3
  build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "obvyr-cli"
7
- version = "2.6.0"
7
+ version = "2.8.0"
8
8
  description = "Command-line wrapper to allow Obvyr to capture evidence from any machine."
9
9
  authors = ["Wyrd Technology Ltd"]
10
10
  license = "Proprietary"
@@ -28,26 +28,24 @@ include = "obvyr_cli"
28
28
 
29
29
  [tool.poetry.dependencies]
30
30
  python = ">=3.14,<4.0"
31
- python-dotenv = "1.2.2"
32
- click = "8.4.2"
33
- pydantic = "2.13.4"
34
- pydantic-settings = "2.14.2"
31
+ python-dotenv = "1.2.4"
32
+ click = "8.5.0"
33
+ pydantic = "2.13.5"
34
+ pydantic-settings = "2.15.0"
35
35
  httpx = "0.28.1"
36
36
  tenacity = "9.1.4"
37
- toml = "0.10.2"
38
- orjson = "3.11.9"
37
+ orjson = "3.12.0"
39
38
  zstandard = "0.25.0"
40
39
 
41
40
  [tool.poetry.group.dev.dependencies]
42
- ruff = "0.15.20"
41
+ ruff = "0.16.10"
43
42
  mypy = "1.20.2"
44
43
  pytest = "9.1.1"
45
44
  pytest-cov = "7.1.0"
46
45
  black = "26.5.1"
47
- pyproject-fmt = "2.25.1"
46
+ pyproject-fmt = "2.29.4"
48
47
  pytest-subprocess = "1.6.0"
49
- types-toml = "^0.10.8.20240310"
50
- poethepoet = "0.47.1"
48
+ poethepoet = "0.48.0"
51
49
 
52
50
  [tool.poetry.scripts]
53
51
  obvyr = "obvyr_cli.cli:cli_run_process"
@@ -16,8 +16,6 @@ from obvyr_cli.logging_config import get_logger
16
16
 
17
17
  logger = get_logger("api_client")
18
18
 
19
- project_config = utils.get_project_config()
20
-
21
19
 
22
20
  class ObvyrAPIClient:
23
21
  """Client for sending execution data to the Obvyr API."""
@@ -43,7 +41,7 @@ class ObvyrAPIClient:
43
41
  """Generate headers for authentication."""
44
42
  return {
45
43
  "Authorization": (f"Bearer {self.api_key}"),
46
- "User-Agent": f"obvyr-cli/{project_config['version']}",
44
+ "User-Agent": f"obvyr-cli/{utils.get_version()}",
47
45
  }
48
46
 
49
47
  @retry(
@@ -204,6 +204,7 @@ def build_artifacts_tar_zst(
204
204
  attachment_paths: Optional[list[pathlib.Path]] = None,
205
205
  tmp_dir: Optional[pathlib.Path] = None,
206
206
  tags: Optional[list[str]] = None,
207
+ env: Optional[Dict[str, str]] = None,
207
208
  ) -> pathlib.Path:
208
209
  """
209
210
  Build artifacts.tar.zst archive from command execution data.
@@ -218,6 +219,7 @@ def build_artifacts_tar_zst(
218
219
  attachment_paths: Optional list of attachment files to include
219
220
  tmp_dir: Optional temporary directory for output file
220
221
  tags: Optional list of tags to include in command.json
222
+ env: Environment variables to include in command.json (default: none)
221
223
 
222
224
  Returns:
223
225
  Path to the created artifacts.tar.zst file
@@ -226,9 +228,6 @@ def build_artifacts_tar_zst(
226
228
  Invalid or oversized attachments are skipped with warning logs.
227
229
  Attachments are prioritized by value (XML/JSON > YAML/CSV > TXT/LOG).
228
230
  """
229
- if tmp_dir is None:
230
- tmp_dir = pathlib.Path(tempfile.gettempdir())
231
-
232
231
  # Select valid attachments with priority-based ordering
233
232
  selected_attachments: list[pathlib.Path] = []
234
233
  if attachment_paths:
@@ -236,9 +235,6 @@ def build_artifacts_tar_zst(
236
235
  attachment_paths
237
236
  )
238
237
 
239
- # Create output file path
240
- output_path = tmp_dir / "artifacts.tar.zst"
241
-
242
238
  # Prepare command.json data exactly as specified in the doc
243
239
  command_data = {
244
240
  "command": run_command_response.command,
@@ -246,7 +242,7 @@ def build_artifacts_tar_zst(
246
242
  "return_code": run_command_response.returncode,
247
243
  "execution_time_ms": round(run_command_response.execution_time * 1000),
248
244
  "executed": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
249
- "env": dict(os.environ),
245
+ "env": env or {},
250
246
  "tags": tags or [],
251
247
  }
252
248
 
@@ -287,8 +283,17 @@ def build_artifacts_tar_zst(
287
283
  compressor = zstd.ZstdCompressor(write_content_size=True)
288
284
  compressed_data = compressor.compress(tar_data)
289
285
 
290
- with open(output_path, "wb") as output_file:
291
- output_file.write(compressed_data)
286
+ # mkstemp creates a unique file readable and writable by the owner only
287
+ output_fd, output_name = tempfile.mkstemp(
288
+ prefix="obvyr-", suffix=".tar.zst", dir=tmp_dir
289
+ )
290
+ output_path = pathlib.Path(output_name)
291
+ try:
292
+ with os.fdopen(output_fd, "wb") as output_file:
293
+ output_file.write(compressed_data)
294
+ except Exception:
295
+ output_path.unlink(missing_ok=True)
296
+ raise
292
297
 
293
298
  return output_path
294
299
 
@@ -329,3 +334,45 @@ def summarize_archive(archive_path: pathlib.Path) -> ArchiveSummary:
329
334
  members[member.name] = {"bytes": member.size}
330
335
 
331
336
  return ArchiveSummary(archive_bytes=archive_bytes, members=members)
337
+
338
+
339
+ def render_archive_preview(archive_path: pathlib.Path) -> str:
340
+ """
341
+ Render the contents of an archive as text for a dry run.
342
+
343
+ Reads the archive that would be uploaded, so the preview is exactly what
344
+ would be sent. command.json is pretty-printed and output.txt is shown in
345
+ full. Attachments are listed with their sizes only.
346
+
347
+ Args:
348
+ archive_path: Path to the artifacts.tar.zst file
349
+
350
+ Returns:
351
+ Human-readable preview of the archive contents
352
+ """
353
+ sections: list[str] = []
354
+ decompressor = zstd.ZstdDecompressor()
355
+
356
+ with open(archive_path, "rb") as archive_file:
357
+ with decompressor.stream_reader(archive_file) as reader:
358
+ with tarfile.open(
359
+ fileobj=reader, mode="r|"
360
+ ) as tar: # pragma: no branch
361
+ for member in tar:
362
+ content = tar.extractfile(member)
363
+ assert content is not None # nosec: members are files
364
+ data = content.read()
365
+ if member.name == "command.json":
366
+ body = json.dumps(json.loads(data), indent=2)
367
+ sections.append(f"--- command.json ---\n{body}")
368
+ elif member.name == "output.txt":
369
+ sections.append(
370
+ f"--- output.txt ---\n{data.decode('utf-8')}"
371
+ )
372
+ else:
373
+ sections.append(
374
+ f"--- {member.name} ({member.size} bytes, "
375
+ "content not shown) ---"
376
+ )
377
+
378
+ return "\n\n".join(sections)
@@ -1,6 +1,7 @@
1
1
  import glob
2
2
  import logging
3
3
  import math
4
+ import os
4
5
  import pathlib
5
6
  import time
6
7
  from datetime import datetime, timezone
@@ -9,7 +10,10 @@ from typing import List
9
10
  import click
10
11
 
11
12
  from obvyr_cli.api_client import ObvyrAPIClient
12
- from obvyr_cli.archive_builder import build_artifacts_tar_zst
13
+ from obvyr_cli.archive_builder import (
14
+ build_artifacts_tar_zst,
15
+ render_archive_preview,
16
+ )
13
17
  from obvyr_cli.command_wrapper import run_command
14
18
  from obvyr_cli.config import ProfileSettings, Settings, get_settings
15
19
  from obvyr_cli.error_handling import (
@@ -18,6 +22,7 @@ from obvyr_cli.error_handling import (
18
22
  )
19
23
  from obvyr_cli.jwt_utils import get_jwt_expiry
20
24
  from obvyr_cli.logging_config import configure_logging, get_logger
25
+ from obvyr_cli.redaction import filter_environment, redact_response
21
26
  from obvyr_cli.schemas import (
22
27
  CommandExecutionConfig,
23
28
  OutputMode,
@@ -223,6 +228,51 @@ def display_output(response: RunCommandResponse) -> None:
223
228
  display_execution_summary(response)
224
229
 
225
230
 
231
+ def prepare_archive(
232
+ active_profile: ProfileSettings, data: RunCommandResponse
233
+ ) -> pathlib.Path:
234
+ """
235
+ Build the archive to upload, applying the profile's capture controls.
236
+
237
+ Only allow-listed environment variables are included, and secrets are
238
+ redacted from the environment, command arguments and output.
239
+
240
+ :param active_profile: Profile configuration controlling what is captured.
241
+ :param data: Command execution result to be archived.
242
+ :return: Path to the temporary archive file.
243
+ """
244
+ attachment_paths = None
245
+ if active_profile.ATTACHMENT_PATHS:
246
+ resolved_paths = resolve_attachment_globs(
247
+ active_profile.ATTACHMENT_PATHS
248
+ )
249
+ fresh_paths = []
250
+ for path in resolved_paths:
251
+ if is_attachment_fresh(
252
+ path, active_profile.ATTACHMENT_MAX_AGE_SECONDS
253
+ ):
254
+ fresh_paths.append(path)
255
+ logger.debug(f"Including fresh attachment: {path}")
256
+ else:
257
+ logger.debug(f"Skipping stale attachment: {path}")
258
+
259
+ # Only set attachment_paths if we have fresh files
260
+ if fresh_paths:
261
+ attachment_paths = fresh_paths
262
+
263
+ return build_artifacts_tar_zst(
264
+ redact_response(data, active_profile.REDACT_PATTERNS),
265
+ attachment_paths=attachment_paths,
266
+ tags=active_profile.TAGS,
267
+ env=filter_environment(
268
+ os.environ,
269
+ mode=active_profile.ENV_MODE,
270
+ include=active_profile.ENV_INCLUDE,
271
+ extra_patterns=active_profile.REDACT_PATTERNS,
272
+ ),
273
+ )
274
+
275
+
226
276
  def send_to_api(
227
277
  active_profile: ProfileSettings, data: RunCommandResponse
228
278
  ) -> None:
@@ -239,30 +289,7 @@ def send_to_api(
239
289
 
240
290
  archive_path = None
241
291
  try:
242
- # Create archive from command execution data
243
- attachment_paths = None
244
- if active_profile.ATTACHMENT_PATHS:
245
- resolved_paths = resolve_attachment_globs(
246
- active_profile.ATTACHMENT_PATHS
247
- )
248
- fresh_paths = []
249
- for path in resolved_paths:
250
- if is_attachment_fresh(
251
- path, active_profile.ATTACHMENT_MAX_AGE_SECONDS
252
- ):
253
- fresh_paths.append(path)
254
- logger.debug(f"Including fresh attachment: {path}")
255
- else:
256
- logger.debug(f"Skipping stale attachment: {path}")
257
-
258
- # Only set attachment_paths if we have fresh files
259
- if fresh_paths:
260
- attachment_paths = fresh_paths
261
-
262
- # Build archive with or without attachments
263
- archive_path = build_artifacts_tar_zst(
264
- data, attachment_paths=attachment_paths, tags=active_profile.TAGS
265
- )
292
+ archive_path = prepare_archive(active_profile, data)
266
293
 
267
294
  with ObvyrAPIClient(
268
295
  api_key=active_profile.API_KEY,
@@ -294,6 +321,23 @@ def send_to_api(
294
321
  archive_path.unlink()
295
322
 
296
323
 
324
+ def show_dry_run_payload(
325
+ active_profile: ProfileSettings, data: RunCommandResponse
326
+ ) -> None:
327
+ """
328
+ Print exactly what would be sent to the Obvyr API, without sending it.
329
+
330
+ :param active_profile: Profile configuration controlling what is captured.
331
+ :param data: Command execution result that would have been sent.
332
+ """
333
+ archive_path = prepare_archive(active_profile, data)
334
+ try:
335
+ click.echo("\nDry run: nothing was sent. Payload that would be sent:\n")
336
+ click.echo(render_archive_preview(archive_path))
337
+ finally:
338
+ archive_path.unlink(missing_ok=True)
339
+
340
+
297
341
  # ===================================
298
342
  # Click CLI
299
343
  # ===================================
@@ -351,6 +395,11 @@ def send_to_api(
351
395
  is_flag=True,
352
396
  help="Disable color output (don't set FORCE_COLOR environment variable).",
353
397
  )
398
+ @click.option(
399
+ "--dry-run",
400
+ is_flag=True,
401
+ help="Run the command, then print exactly what would be sent instead of sending it.",
402
+ )
354
403
  def cli_run_process(
355
404
  command: List[str],
356
405
  list_profiles: bool,
@@ -362,6 +411,7 @@ def cli_run_process(
362
411
  no_stream: bool,
363
412
  force_stream: bool,
364
413
  no_color: bool,
414
+ dry_run: bool,
365
415
  ) -> None:
366
416
  """
367
417
  Executes a system command while using the Obvyr CLI profile configuration.
@@ -404,7 +454,9 @@ def cli_run_process(
404
454
  config=execution_config,
405
455
  )
406
456
 
407
- if active_profile.API_URL and active_profile.API_KEY:
457
+ if dry_run:
458
+ show_dry_run_payload(active_profile, response)
459
+ elif active_profile.API_URL and active_profile.API_KEY:
408
460
  send_to_api(active_profile, response)
409
461
 
410
462
  display_execution_summary(response)
@@ -412,5 +464,7 @@ def cli_run_process(
412
464
  if response.returncode != 0:
413
465
  raise click.exceptions.Exit(response.returncode)
414
466
 
467
+ except click.exceptions.Exit:
468
+ raise
415
469
  except Exception as e:
416
470
  raise click.ClickException(str(e)) from e
@@ -6,8 +6,10 @@ from environment variables, allowing environmental overrides.
6
6
  """
7
7
 
8
8
  import logging
9
+ import re
9
10
  import secrets
10
- from typing import Dict
11
+ from typing import Dict, Literal
12
+ from urllib.parse import urlparse
11
13
 
12
14
  from dotenv import load_dotenv
13
15
  from pydantic import BaseModel, Field, field_validator, model_validator
@@ -28,23 +30,49 @@ class ProfileSettings(BaseModel):
28
30
  ATTACHMENT_MAX_AGE_SECONDS: int = 10
29
31
  TIMEOUT: float = 10.0
30
32
  VERIFY_SSL: bool = True
33
+ ALLOW_INSECURE_API_URL: bool = False
31
34
  TAGS: list[str] = Field(default_factory=list)
32
-
33
- @field_validator("TAGS", mode="before")
35
+ ENV_MODE: Literal["allowlist", "none"] = "allowlist"
36
+ ENV_INCLUDE: list[str] = Field(default_factory=list)
37
+ REDACT_PATTERNS: list[str] = Field(default_factory=list)
38
+
39
+ @field_validator(
40
+ "TAGS",
41
+ "ATTACHMENT_PATHS",
42
+ "ENV_INCLUDE",
43
+ "REDACT_PATTERNS",
44
+ mode="before",
45
+ )
34
46
  @classmethod
35
- def parse_tags(cls, value: str | list[str]) -> list[str]:
36
- """Parse tags from comma-separated string or return list as-is."""
47
+ def parse_comma_separated(cls, value: str | list[str]) -> list[str]:
48
+ """Parse a comma-separated string into a list, or return a list as-is."""
37
49
  if isinstance(value, str):
38
- return [tag.strip() for tag in value.split(",") if tag.strip()]
50
+ return [item.strip() for item in value.split(",") if item.strip()]
39
51
  return value or []
40
52
 
41
- @field_validator("ATTACHMENT_PATHS", mode="before")
53
+ @field_validator("REDACT_PATTERNS")
42
54
  @classmethod
43
- def parse_attachment_paths(cls, value: str | list[str]) -> list[str]:
44
- """Parse attachment paths from comma-separated string or return list as-is."""
45
- if isinstance(value, str):
46
- return [path.strip() for path in value.split(",") if path.strip()]
47
- return value or []
55
+ def validate_redact_patterns(cls, value: list[str]) -> list[str]:
56
+ """Fail fast on patterns that are not valid regular expressions."""
57
+ for pattern in value:
58
+ try:
59
+ re.compile(pattern)
60
+ except re.error as error:
61
+ raise ValueError(
62
+ f"Invalid REDACT_PATTERNS entry '{pattern}': {error}"
63
+ ) from error
64
+ return value
65
+
66
+ @model_validator(mode="after")
67
+ def require_https_api_url(self) -> "ProfileSettings":
68
+ """Fail fast on an API_URL that would send the token and archive in clear text."""
69
+ is_https = urlparse(self.API_URL).scheme.lower() == "https"
70
+ if self.API_URL and not is_https and not self.ALLOW_INSECURE_API_URL:
71
+ raise ValueError(
72
+ f"API_URL '{self.API_URL}' must use HTTPS. "
73
+ "Set ALLOW_INSECURE_API_URL to true to override."
74
+ )
75
+ return self
48
76
 
49
77
  @model_validator(mode="after")
50
78
  def migrate_attachment_path(self) -> "ProfileSettings":
@@ -0,0 +1,135 @@
1
+ """
2
+ Controls what the CLI sends: environment filtering and secret redaction.
3
+
4
+ All functions are pure. Callers pass in the environment and patterns
5
+ explicitly so behaviour is predictable and testable.
6
+ """
7
+
8
+ import re
9
+ from typing import Mapping, Sequence
10
+
11
+ from obvyr_cli.schemas import RunCommandResponse
12
+
13
+ # Exact names only. Prefix matching is deliberately avoided: CI_JOB_TOKEN
14
+ # and similar secrets share prefixes with harmless CI identifiers.
15
+ ENV_ALLOWLIST = frozenset(
16
+ {
17
+ "CI",
18
+ "CONTINUOUS_INTEGRATION",
19
+ # GitHub Actions
20
+ "GITHUB_ACTIONS",
21
+ "GITHUB_RUN_ID",
22
+ "GITHUB_RUN_NUMBER",
23
+ "GITHUB_WORKFLOW",
24
+ "GITHUB_REPOSITORY",
25
+ "GITHUB_SHA",
26
+ "GITHUB_REF_NAME",
27
+ # GitLab CI
28
+ "GITLAB_CI",
29
+ "CI_PIPELINE_ID",
30
+ "CI_JOB_ID",
31
+ "CI_COMMIT_SHA",
32
+ "CI_COMMIT_REF_NAME",
33
+ # Jenkins
34
+ "JENKINS_URL",
35
+ "BUILD_NUMBER",
36
+ # Buildkite
37
+ "BUILDKITE",
38
+ "BUILDKITE_BUILD_NUMBER",
39
+ # CircleCI
40
+ "CIRCLECI",
41
+ "CIRCLE_BUILD_NUM",
42
+ "CIRCLE_SHA1",
43
+ }
44
+ )
45
+
46
+
47
+ # The CLI's own settings (including its API key) are never sent.
48
+ ALWAYS_EXCLUDED_PREFIX = "OBVYR_"
49
+
50
+
51
+ REDACTED = "[REDACTED]"
52
+
53
+ # Variable and flag names that suggest the value is a secret.
54
+ SECRET_NAME_PATTERN = re.compile(
55
+ r"token|key|password|passwd|secret|credential", re.IGNORECASE
56
+ )
57
+
58
+ # Value shapes that are secrets wherever they appear.
59
+ SECRET_VALUE_PATTERNS = (
60
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
61
+ re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+"),
62
+ # name=value or name: value; the prefix group keeps the name visible.
63
+ re.compile(
64
+ r"(?i)(?P<prefix>\b[\w-]*(?:token|key|password|passwd|secret|credential)"
65
+ r"[\w-]*\s*[=:]\s*)\S+"
66
+ ),
67
+ )
68
+
69
+
70
+ def keep_prefix_and_redact(match: re.Match[str]) -> str:
71
+ """Replace a match with the redaction marker, keeping a named prefix group."""
72
+ return (match.groupdict().get("prefix") or "") + REDACTED
73
+
74
+
75
+ def redact_text(text: str, extra_patterns: Sequence[str] = ()) -> str:
76
+ """Replace anything in the text that matches a built-in or extra pattern."""
77
+ patterns = SECRET_VALUE_PATTERNS + tuple(
78
+ re.compile(pattern) for pattern in extra_patterns
79
+ )
80
+ for pattern in patterns:
81
+ text = pattern.sub(keep_prefix_and_redact, text)
82
+ return text
83
+
84
+
85
+ def redact_command(
86
+ command: Sequence[str], extra_patterns: Sequence[str] = ()
87
+ ) -> list[str]:
88
+ """Redact secrets in command arguments, including a flag's separate value."""
89
+ redacted: list[str] = []
90
+ previous_was_secret_flag = False
91
+ for argument in command:
92
+ if previous_was_secret_flag:
93
+ redacted.append(REDACTED)
94
+ else:
95
+ redacted.append(redact_text(argument, extra_patterns))
96
+ previous_was_secret_flag = (
97
+ argument.startswith("-")
98
+ and "=" not in argument
99
+ and SECRET_NAME_PATTERN.search(argument) is not None
100
+ )
101
+ return redacted
102
+
103
+
104
+ def redact_response(
105
+ response: RunCommandResponse, extra_patterns: Sequence[str] = ()
106
+ ) -> RunCommandResponse:
107
+ """Return a copy of the response with secrets removed from what is uploaded."""
108
+ output = response.output
109
+ return response.model_copy(
110
+ update={
111
+ "command": redact_command(response.command, extra_patterns),
112
+ "output": redact_text(output, extra_patterns) if output else output,
113
+ }
114
+ )
115
+
116
+
117
+ def filter_environment(
118
+ environ: Mapping[str, str],
119
+ mode: str,
120
+ include: Sequence[str] = (),
121
+ extra_patterns: Sequence[str] = (),
122
+ ) -> dict[str, str]:
123
+ """Return the subset of the environment that is safe to send."""
124
+ if mode == "none":
125
+ return {}
126
+ permitted = ENV_ALLOWLIST | set(include)
127
+ return {
128
+ k: (
129
+ REDACTED
130
+ if SECRET_NAME_PATTERN.search(k)
131
+ else redact_text(v, extra_patterns)
132
+ )
133
+ for k, v in environ.items()
134
+ if k in permitted and not k.startswith(ALWAYS_EXCLUDED_PREFIX)
135
+ }
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version
2
+
3
+
4
+ def get_version() -> str:
5
+ """Return the installed CLI version from package metadata."""
6
+ return version("obvyr-cli")
@@ -1,13 +0,0 @@
1
- from importlib.metadata import version
2
-
3
- import toml
4
-
5
-
6
- def get_project_config(pyproject_path: str = "pyproject.toml") -> dict:
7
- try:
8
- with open(pyproject_path, "r") as file:
9
- config = toml.load(file)
10
- return config["tool"]["poetry"]
11
- except (KeyError, FileNotFoundError):
12
- # Fall back to package metadata for installed versions
13
- return {"version": version("obvyr-cli")}
File without changes