familiar-cli 0.5.1__tar.gz → 0.6.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.

Potentially problematic release.


This version of familiar-cli might be problematic. Click here for more details.

Files changed (54) hide show
  1. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/PKG-INFO +14 -5
  2. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/pyproject.toml +2 -2
  3. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/readme.md +12 -3
  4. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/__init__.py +1 -1
  5. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/_plugins.py +11 -9
  6. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/agents.py +16 -15
  7. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/cli.py +18 -21
  8. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/lint.py +167 -85
  9. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/render.py +68 -32
  10. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/PKG-INFO +14 -5
  11. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/requires.txt +1 -1
  12. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/tests/test_agents.py +5 -4
  13. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/tests/test_cli.py +108 -95
  14. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/tests/test_integration.py +20 -3
  15. familiar_cli-0.6.0/tests/test_lint.py +703 -0
  16. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/tests/test_render.py +140 -2
  17. familiar_cli-0.5.1/tests/test_lint.py +0 -398
  18. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/LICENSE +0 -0
  19. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/setup.cfg +0 -0
  20. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/conjurings/core.md +0 -0
  21. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/conjurings/data.md +0 -0
  22. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/conjurings/docs.md +0 -0
  23. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/conjurings/frontend.md +0 -0
  24. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/conjurings/infra.md +0 -0
  25. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/conjurings/python.md +0 -0
  26. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/conjurings/rust.md +0 -0
  27. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/conjurings/sec.md +0 -0
  28. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/__noop__.md +0 -0
  29. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/add-ci.md +0 -0
  30. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/add-tests.md +0 -0
  31. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/audit.md +0 -0
  32. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/bootstrap-python.md +0 -0
  33. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
  34. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/code-review.md +0 -0
  35. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/explain.md +0 -0
  36. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/implement-feature.md +0 -0
  37. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/infra-change.md +0 -0
  38. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/performance.md +0 -0
  39. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/refactor.md +0 -0
  40. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/release.md +0 -0
  41. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/invocations/security-review.md +0 -0
  42. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/snippets/node/github-ci.yml +0 -0
  43. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
  44. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/snippets/python/github-ci.yml +0 -0
  45. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
  46. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/snippets/python/pyproject.toml +0 -0
  47. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
  48. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
  49. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
  50. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar/py.typed +0 -0
  51. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
  52. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
  53. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/entry_points.txt +0 -0
  54. {familiar_cli-0.5.1 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: familiar-cli
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: compose and invoke ai agent prompts from reusable templates
5
5
  Author-email: cyberwitchery lab <contact@cyberwitchery.com>
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Provides-Extra: dev
23
- Requires-Dist: ruff; extra == "dev"
23
+ Requires-Dist: ruff==0.16.1; extra == "dev"
24
24
  Requires-Dist: mypy; extra == "dev"
25
25
  Requires-Dist: pytest; extra == "dev"
26
26
  Requires-Dist: pytest-cov; extra == "dev"
@@ -46,18 +46,21 @@ pip install familiar-cli
46
46
  ## usage
47
47
 
48
48
  ```
49
- usage: familiar [-h] {conjure,invoke,list} ...
49
+ usage: familiar [-h] [--version] [--debug] {conjure,invoke,list,lint} ...
50
50
 
51
51
  conjure and invoke familiars
52
52
 
53
53
  positional arguments:
54
- {conjure,invoke,list}
54
+ {conjure,invoke,list,lint}
55
55
  conjure compose system instructions for an agent
56
56
  invoke render an invocation and run the agent
57
- list list available conjurings or invocations
57
+ list list available conjurings and invocations
58
+ lint lint conjurings and invocations
58
59
 
59
60
  options:
60
61
  -h, --help show this help message and exit
62
+ --version show program's version number and exit
63
+ --debug show full traceback on error
61
64
  ```
62
65
 
63
66
  conjure conjurings to create system instructions for an agent:
@@ -98,6 +101,12 @@ list available conjurings and invocations:
98
101
  familiar list
99
102
  ```
100
103
 
104
+ lint conjurings and invocations, including local `.familiar/` overrides:
105
+
106
+ ```
107
+ familiar lint
108
+ ```
109
+
101
110
  ## customization
102
111
 
103
112
  add your own conjurings and invocations by creating files in `.familiar/` in your repo:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "familiar-cli"
3
- version = "0.5.1"
3
+ version = "0.6.0"
4
4
  description = "compose and invoke ai agent prompts from reusable templates"
5
5
  readme = "readme.md"
6
6
  license = "MIT"
@@ -36,7 +36,7 @@ claude = "familiar.agents:ClaudeAgent"
36
36
  package-dir = {"" = "src"}
37
37
 
38
38
  [project.optional-dependencies]
39
- dev = ["ruff", "mypy", "pytest", "pytest-cov", "bandit"]
39
+ dev = ["ruff==0.16.1", "mypy", "pytest", "pytest-cov", "bandit"]
40
40
 
41
41
  [tool.setuptools.package-data]
42
42
  "familiar.data.conjurings" = ["*.md"]
@@ -17,18 +17,21 @@ pip install familiar-cli
17
17
  ## usage
18
18
 
19
19
  ```
20
- usage: familiar [-h] {conjure,invoke,list} ...
20
+ usage: familiar [-h] [--version] [--debug] {conjure,invoke,list,lint} ...
21
21
 
22
22
  conjure and invoke familiars
23
23
 
24
24
  positional arguments:
25
- {conjure,invoke,list}
25
+ {conjure,invoke,list,lint}
26
26
  conjure compose system instructions for an agent
27
27
  invoke render an invocation and run the agent
28
- list list available conjurings or invocations
28
+ list list available conjurings and invocations
29
+ lint lint conjurings and invocations
29
30
 
30
31
  options:
31
32
  -h, --help show this help message and exit
33
+ --version show program's version number and exit
34
+ --debug show full traceback on error
32
35
  ```
33
36
 
34
37
  conjure conjurings to create system instructions for an agent:
@@ -69,6 +72,12 @@ list available conjurings and invocations:
69
72
  familiar list
70
73
  ```
71
74
 
75
+ lint conjurings and invocations, including local `.familiar/` overrides:
76
+
77
+ ```
78
+ familiar lint
79
+ ```
80
+
72
81
  ## customization
73
82
 
74
83
  add your own conjurings and invocations by creating files in `.familiar/` in your repo:
@@ -2,6 +2,6 @@
2
2
 
3
3
  from importlib.metadata import version
4
4
 
5
- __all__ = ["agents", "render", "cli"]
5
+ __all__ = ["agents", "cli", "render"]
6
6
 
7
7
  __version__ = version("familiar-cli")
@@ -1,10 +1,11 @@
1
- """Shared plugin loading utilities."""
1
+ """shared plugin loading utilities."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import warnings
6
+ from collections.abc import Callable
6
7
  from importlib.metadata import entry_points
7
- from typing import Any, Callable
8
+ from typing import Any
8
9
 
9
10
 
10
11
  def load_plugins(
@@ -14,16 +15,16 @@ def load_plugins(
14
15
  label: str,
15
16
  invalid_msg: str = "validation failed",
16
17
  ) -> list[Any]:
17
- """Load plugins from entry points with validation.
18
+ """load plugins from entry points with validation.
18
19
 
19
20
  Args:
20
- group: Entry point group name.
21
- validate: Predicate that returns True for valid loaded objects.
22
- label: Human-readable label for warning messages (e.g. "linter plugin").
23
- invalid_msg: Message when validation fails.
21
+ group: entry point group name.
22
+ validate: predicate that returns True for valid loaded objects.
23
+ label: human-readable label for warning messages (e.g. "linter plugin").
24
+ invalid_msg: message when validation fails.
24
25
 
25
26
  Returns:
26
- List of loaded and validated plugin objects.
27
+ list of loaded and validated plugin objects.
27
28
  """
28
29
  results: list[Any] = []
29
30
  for ep in entry_points(group=group):
@@ -36,7 +37,8 @@ def load_plugins(
36
37
  )
37
38
  continue
38
39
  results.append(obj)
39
- except Exception as e:
40
+ # a broken third-party entry point must not take down the cli
41
+ except Exception as e: # noqa: BLE001
40
42
  warnings.warn(
41
43
  f"failed to load {label} '{ep.name}': {e}",
42
44
  stacklevel=2,
@@ -1,4 +1,4 @@
1
- """Agent implementations for familiar."""
1
+ """agent implementations for familiar."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -11,7 +11,7 @@ from ._plugins import load_plugins
11
11
 
12
12
 
13
13
  class Agent(ABC):
14
- """Base class for AI coding agents."""
14
+ """base class for AI coding agents."""
15
15
 
16
16
  name: str
17
17
  output_file: str
@@ -24,24 +24,24 @@ class Agent(ABC):
24
24
  def run(
25
25
  self, repo_root: Path, prompt: str, headless: bool, auto: bool = False
26
26
  ) -> int:
27
- """Run the agent with the given prompt."""
27
+ """run the agent with the given prompt."""
28
28
 
29
29
  def supports_skills(self) -> bool:
30
- """Whether this agent supports reusable skill files."""
30
+ """whether this agent supports reusable skill files."""
31
31
  return self.skill_dir is not None
32
32
 
33
33
  def skill_path(self, repo_root: Path, skill_name: str) -> Path:
34
- """Build the skill path for a given skill name."""
34
+ """build the skill path for a given skill name."""
35
35
  if self.skill_dir is None:
36
36
  raise ValueError(f"agent does not support skills: {self.name}")
37
37
  return repo_root / self.skill_dir / skill_name / self.skill_file
38
38
 
39
39
  def supports_subagents(self) -> bool:
40
- """Whether this agent supports reusable subagent files."""
40
+ """whether this agent supports reusable subagent files."""
41
41
  return self.subagent_dir is not None
42
42
 
43
43
  def subagent_path(self, repo_root: Path, subagent_name: str) -> Path:
44
- """Build the subagent path for a given subagent name."""
44
+ """build the subagent path for a given subagent name."""
45
45
  if self.subagent_dir is None:
46
46
  raise ValueError(f"agent does not support subagents: {self.name}")
47
47
  return repo_root / self.subagent_dir / subagent_name / self.subagent_file
@@ -61,7 +61,7 @@ class CodexAgent(Agent):
61
61
  if auto:
62
62
  cmd.append("--full-auto")
63
63
  cmd.extend(["exec", "--skip-git-repo-check", "-C", str(repo_root), "-"])
64
- proc = subprocess.run(cmd, input=prompt, text=True)
64
+ proc = subprocess.run(cmd, input=prompt, text=True, check=False)
65
65
  return proc.returncode
66
66
  else:
67
67
  cmd = ["codex"]
@@ -91,11 +91,11 @@ class ClaudeAgent(Agent):
91
91
 
92
92
 
93
93
  def load_agents() -> dict[str, Agent]:
94
- """Load all registered agent plugins via entry points.
94
+ """load all registered agent plugins via entry points.
95
95
 
96
96
  Returns:
97
- Dictionary mapping agent names to Agent instances.
98
- Plugins that fail to load are skipped with a warning.
97
+ dictionary mapping agent names to Agent instances.
98
+ plugins that fail to load are skipped with a warning.
99
99
  """
100
100
  plugins = load_plugins(
101
101
  "familiar.agents",
@@ -108,7 +108,8 @@ def load_agents() -> dict[str, Agent]:
108
108
  try:
109
109
  instance = cls()
110
110
  agents[instance.name] = instance
111
- except Exception as e:
111
+ # a broken agent plugin must not take down the cli
112
+ except Exception as e: # noqa: BLE001
112
113
  warnings.warn(
113
114
  f"failed to load agent plugin '{cls.__name__}': {e}",
114
115
  stacklevel=2,
@@ -120,9 +121,9 @@ _agents_cache: dict[str, Agent] | None = None
120
121
 
121
122
 
122
123
  def get_agents() -> dict[str, Agent]:
123
- """Get all available agents.
124
+ """get all available agents.
124
125
 
125
- Returns a cached dictionary of agent name -> Agent instance.
126
+ returns a cached dictionary of agent name -> Agent instance.
126
127
  """
127
128
  global _agents_cache
128
129
  if _agents_cache is None:
@@ -131,7 +132,7 @@ def get_agents() -> dict[str, Agent]:
131
132
 
132
133
 
133
134
  def get_agent(name: str) -> Agent:
134
- """Get an agent by name.
135
+ """get an agent by name.
135
136
 
136
137
  Raises:
137
138
  KeyError: if the agent name is not recognized.
@@ -1,4 +1,4 @@
1
- """Command-line interface for familiar."""
1
+ """command-line interface for familiar."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -14,7 +14,7 @@ import warnings
14
14
  from importlib.metadata import version
15
15
  from pathlib import Path
16
16
 
17
- from .agents import Agent, get_agents, get_agent
17
+ from .agents import Agent, get_agent, get_agents
18
18
  from .lint import lint_all
19
19
  from .render import (
20
20
  NotFoundError,
@@ -24,7 +24,6 @@ from .render import (
24
24
  render_invocation,
25
25
  )
26
26
 
27
- # exit codes
28
27
  EXIT_SUCCESS = 0
29
28
  EXIT_ERROR = 1 # general error (agent failed, etc.)
30
29
  EXIT_USAGE = 2 # usage error (bad args, missing files, etc.)
@@ -43,12 +42,12 @@ class CliError(Exception):
43
42
 
44
43
 
45
44
  def _agent_hint() -> str:
46
- """Return a hint string listing valid agent names."""
45
+ """return a hint string listing valid agent names."""
47
46
  return f"valid agents: {', '.join(get_agents().keys())}"
48
47
 
49
48
 
50
- def _resolve_agent(name: str) -> "Agent":
51
- """Look up an agent by name, raising CliError on unknown names."""
49
+ def _resolve_agent(name: str) -> Agent:
50
+ """look up an agent by name, raising CliError on unknown names."""
52
51
  try:
53
52
  return get_agent(name)
54
53
  except KeyError as e:
@@ -56,7 +55,7 @@ def _resolve_agent(name: str) -> "Agent":
56
55
 
57
56
 
58
57
  def _write_file(path: Path, content: str, label: str = "") -> None:
59
- """Write content to path, creating parent dirs. Wraps OSError as CliError."""
58
+ """write content to path, creating parent dirs. wraps OSError as CliError."""
60
59
  prefix = f"{label} " if label else ""
61
60
  try:
62
61
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -69,9 +68,9 @@ def _write_file(path: Path, content: str, label: str = "") -> None:
69
68
 
70
69
 
71
70
  def find_repo_root(start: Path) -> Path:
72
- """Find the repository root by looking for .git directory.
71
+ """find the repository root by looking for .git directory.
73
72
 
74
- Walks up from start directory. Falls back to start directory itself
73
+ walks up from start directory. falls back to start directory itself
75
74
  if no .git is found, allowing use outside of git repositories.
76
75
  """
77
76
  cur = start.resolve()
@@ -82,7 +81,7 @@ def find_repo_root(start: Path) -> Path:
82
81
 
83
82
 
84
83
  def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
85
- """Best-effort removal of a git worktree."""
84
+ """best-effort removal of a git worktree."""
86
85
  try:
87
86
  result = subprocess.run(
88
87
  [
@@ -95,6 +94,7 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
95
94
  str(worktree_path),
96
95
  ],
97
96
  capture_output=True,
97
+ check=False,
98
98
  )
99
99
  if result.returncode != 0:
100
100
  stderr = result.stderr.decode(errors="replace").strip()
@@ -107,8 +107,7 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
107
107
 
108
108
 
109
109
  def create_worktree(repo_root: Path) -> Path:
110
- """Create a temporary git worktree for the repository."""
111
- # Ensure it's a git repo
110
+ """create a temporary git worktree for the repository."""
112
111
  try:
113
112
  subprocess.run(
114
113
  ["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
@@ -121,8 +120,8 @@ def create_worktree(repo_root: Path) -> Path:
121
120
  )
122
121
 
123
122
  tmpdir = tempfile.mkdtemp(prefix="familiar-")
124
- # git worktree add requires the directory to NOT exist.
125
- # mkdtemp creates it. Let's remove it and let git create it.
123
+ # git worktree add requires the target to not exist, so remove the
124
+ # mkdtemp-created directory and let git create it
126
125
  os.rmdir(tmpdir)
127
126
 
128
127
  try:
@@ -155,7 +154,7 @@ def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
155
154
  def write_skill(
156
155
  repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
157
156
  ) -> Path:
158
- """Write a reusable skill file for supported agents."""
157
+ """write a reusable skill file for supported agents."""
159
158
  if not _VALID_NAME.match(skill_name):
160
159
  raise CliError(
161
160
  f"invalid skill name: {skill_name}",
@@ -188,7 +187,7 @@ def write_subagent(
188
187
  subagent_name: str,
189
188
  system: str,
190
189
  ) -> Path:
191
- """Write a reusable subagent file for supported agents."""
190
+ """write a reusable subagent file for supported agents."""
192
191
  if not _VALID_NAME.match(subagent_name):
193
192
  raise CliError(
194
193
  f"invalid subagent name: {subagent_name}",
@@ -291,7 +290,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
291
290
  target_dir = create_worktree(repo_root)
292
291
  print(f"created worktree at {target_dir}")
293
292
 
294
- # Best-effort copy of instruction file into the worktree
293
+ # best-effort copy of instruction file into the worktree
295
294
  try:
296
295
  agent = get_agent(args.agent)
297
296
  instr_file = repo_root / agent.output_file
@@ -332,7 +331,6 @@ def cmd_list(args: argparse.Namespace) -> int:
332
331
  repo_root = find_repo_root(Path(args.into or os.getcwd()))
333
332
 
334
333
  if args.kind is None:
335
- # list all
336
334
  conjurings = list_items(repo_root, "conjurings")
337
335
  invocations = list_items(repo_root, "invocations")
338
336
  snippets = list_snippets(repo_root)
@@ -363,7 +361,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
363
361
 
364
362
  messages = lint_all(repo_root)
365
363
 
366
- # Filter by level if requested
367
364
  if args.errors_only:
368
365
  messages = [m for m in messages if m.level == "error"]
369
366
 
@@ -371,7 +368,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
371
368
  print("all checks passed")
372
369
  return EXIT_SUCCESS
373
370
 
374
- # Group by level for output
375
371
  errors = [m for m in messages if m.level == "error"]
376
372
  warnings = [m for m in messages if m.level == "warning"]
377
373
 
@@ -514,7 +510,8 @@ def main() -> None:
514
510
  if e.hint:
515
511
  print(f"hint: {e.hint}", file=sys.stderr)
516
512
  raise SystemExit(e.exit_code)
517
- except Exception as e:
513
+ # top-level handler: any unexpected error becomes an exit code, not a traceback
514
+ except Exception as e: # noqa: BLE001
518
515
  if args.debug:
519
516
  traceback.print_exc()
520
517
  print(f"error: {e}", file=sys.stderr)