fenolite 0.0.1.dev0__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.
fenolite/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Fenolite contributors
3
+ """Fenolite: headless, agent-first PCB design automation (KiCad first, Altium second)."""
4
+
5
+ __version__ = "0.0.1.dev0"
6
+
7
+ __all__ = ["__version__"]
fenolite/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Fenolite contributors
3
+ """Allow ``python -m fenolite``; behaves exactly like the ``fenolite`` console script."""
4
+
5
+ from fenolite.cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Fenolite contributors
3
+ """Command-line interface of Fenolite."""
fenolite/cli/api.py ADDED
@@ -0,0 +1,101 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Fenolite contributors
3
+ """What a command module provides, what it receives and what it returns.
4
+
5
+ A command lives in ``fenolite/cli/cmd_<name>.py`` (dashes become underscores) and exposes a
6
+ module-level ``COMMAND`` (:class:`Command`). Commands never write files themselves: they return
7
+ :class:`PlannedWrite` objects and the dispatcher applies the mutation protocol
8
+ (``--dry-run`` / ``--confirm``, atomic writes, backups, receipts).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import importlib
15
+ import pkgutil
16
+ import random
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from fenolite.cli.output import Evidence, InputRef, Issue, OutputMode
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Context:
28
+ """Everything a command may depend on besides its own arguments."""
29
+
30
+ mode: OutputMode
31
+ seed: int | None
32
+ timestamp: datetime
33
+ rng: random.Random
34
+ no_backup: bool
35
+ cwd: Path
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class PlannedWrite:
40
+ """A file a mutating command wants to write. ``path`` is relative to the working directory."""
41
+
42
+ path: str
43
+ data: bytes
44
+ kind: str
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class Result:
49
+ """What a command returns to the dispatcher."""
50
+
51
+ result: dict[str, Any] = field(default_factory=lambda: {})
52
+ issues: tuple[Issue, ...] = ()
53
+ evidence: Evidence = Evidence()
54
+ input: InputRef | None = None
55
+ writes: tuple[PlannedWrite, ...] = ()
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Command:
60
+ """A registered sub-command. ``help=None`` hides it from ``--help``."""
61
+
62
+ name: str
63
+ help: str | None
64
+ mutates: bool
65
+ register: Callable[[argparse.ArgumentParser], None]
66
+ run: Callable[[argparse.Namespace, Context], Result]
67
+ example_args: tuple[str, ...] = ()
68
+ mutation_example_args: tuple[str, ...] | None = None
69
+
70
+ @property
71
+ def hidden(self) -> bool:
72
+ return self.help is None
73
+
74
+ @property
75
+ def schema(self) -> str:
76
+ return f"fenolite.{self.name}.v0"
77
+
78
+
79
+ def module_name_for(command_name: str) -> str:
80
+ return "cmd_" + command_name.replace("-", "_")
81
+
82
+
83
+ def discover() -> dict[str, Command]:
84
+ """Import every ``fenolite.cli.cmd_*`` module and return its ``COMMAND`` keyed by name."""
85
+ import fenolite.cli as package
86
+
87
+ found: dict[str, Command] = {}
88
+ for info in sorted(pkgutil.iter_modules(package.__path__), key=lambda m: m.name):
89
+ if not info.name.startswith("cmd_"):
90
+ continue
91
+ module = importlib.import_module(f"{package.__name__}.{info.name}")
92
+ command = getattr(module, "COMMAND", None)
93
+ if not isinstance(command, Command):
94
+ raise TypeError(f"{module.__name__} does not define COMMAND as fenolite.cli.api.Command")
95
+ if module_name_for(command.name) != info.name:
96
+ raise ValueError(f"{module.__name__} declares command {command.name!r}; module name must match")
97
+ found[command.name] = command
98
+ return found
99
+
100
+
101
+ __all__ = ["Command", "Context", "PlannedWrite", "Result", "discover", "module_name_for"]
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Fenolite contributors
3
+ """Hidden ``_echo`` command: exercises every branch of the CLI contract for the consistency tests."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import uuid
9
+ from typing import Any
10
+
11
+ from fenolite.cli.api import Command, Context, PlannedWrite, Result
12
+ from fenolite.cli.output import Issue
13
+
14
+
15
+ def _register(parser: argparse.ArgumentParser) -> None:
16
+ parser.add_argument("--issue", action="append", choices=["error", "warning", "info"], default=[],
17
+ help="emit an issue of this severity (repeatable)") # fmt: skip
18
+ parser.add_argument("--raise", dest="raise_", action="store_true", help="raise an internal exception")
19
+ parser.add_argument("--write", metavar="PATH", help="plan a write of --content to PATH")
20
+ parser.add_argument("--content", default="echo\n", help="content for --write")
21
+ parser.add_argument("--gen-id", action="store_true", help="generate an id and a timestamp")
22
+
23
+
24
+ def _run(args: argparse.Namespace, ctx: Context) -> Result:
25
+ if args.raise_:
26
+ raise RuntimeError("requested failure (--raise)")
27
+ severities: list[str] = args.issue
28
+ issues = tuple(
29
+ Issue(code=f"echo.{sev}", severity=sev, message=f"requested {sev} issue", where="_echo") # type: ignore[arg-type]
30
+ for sev in severities
31
+ )
32
+ result: dict[str, Any] = {"echo": {"issues": severities, "write": args.write}}
33
+ if args.gen_id:
34
+ result["id"] = str(uuid.UUID(int=ctx.rng.getrandbits(128), version=4))
35
+ result["timestamp"] = ctx.timestamp.isoformat()
36
+ writes: tuple[PlannedWrite, ...] = ()
37
+ if args.write:
38
+ writes = (PlannedWrite(path=args.write, data=str(args.content).encode("utf-8"), kind="text"),)
39
+ return Result(result=result, issues=issues, writes=writes)
40
+
41
+
42
+ COMMAND = Command(
43
+ name="_echo",
44
+ help=None,
45
+ mutates=True,
46
+ register=_register,
47
+ run=_run,
48
+ example_args=(),
49
+ mutation_example_args=("--write", "echo.txt"),
50
+ )
@@ -0,0 +1,112 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Fenolite contributors
3
+ """``fenolite capabilities``: what this installation can do. Agents should call it first."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import importlib.metadata
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ from functools import cache
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from fenolite import __version__
18
+ from fenolite.cli.api import Command, Context, Result, discover
19
+
20
+ _MACOS_KICAD_CLI = "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli"
21
+ _EXTRA_MARKER = re.compile(r"extra\s*==\s*['\"]([^'\"]+)['\"]")
22
+ _DIST_NAME = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
23
+
24
+
25
+ def _extras() -> dict[str, dict[str, Any]]:
26
+ """Each optional extra of the installed distribution and whether its packages are installed."""
27
+ try:
28
+ requires = importlib.metadata.metadata("fenolite").get_all("Requires-Dist") or []
29
+ except importlib.metadata.PackageNotFoundError:
30
+ return {}
31
+ wanted: dict[str, list[str]] = {}
32
+ for requirement in requires:
33
+ extra = _EXTRA_MARKER.search(requirement)
34
+ name = _DIST_NAME.match(requirement)
35
+ if extra and name:
36
+ wanted.setdefault(extra.group(1), []).append(name.group(1))
37
+ report: dict[str, dict[str, Any]] = {}
38
+ for extra, dists in sorted(wanted.items()):
39
+ missing: list[str] = []
40
+ for dist in sorted(dists):
41
+ try:
42
+ importlib.metadata.version(dist)
43
+ except importlib.metadata.PackageNotFoundError:
44
+ missing.append(dist)
45
+ report[extra] = {"installed": not missing, "missing": missing}
46
+ return report
47
+
48
+
49
+ def _run_version(argv: list[str]) -> str | None:
50
+ try:
51
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=15, check=False)
52
+ except (OSError, subprocess.TimeoutExpired):
53
+ return None
54
+ if proc.returncode != 0:
55
+ return None
56
+ text = (proc.stdout or proc.stderr).strip()
57
+ return text.splitlines()[0].strip() if text else None
58
+
59
+
60
+ def _kicad_cli_path() -> str | None:
61
+ override = os.environ.get("FENOLITE_KICAD_CLI")
62
+ if override:
63
+ return override if Path(override).is_file() else None
64
+ found = shutil.which("kicad-cli")
65
+ if found:
66
+ return found
67
+ return _MACOS_KICAD_CLI if Path(_MACOS_KICAD_CLI).is_file() else None
68
+
69
+
70
+ @cache
71
+ def detect_tools() -> dict[str, dict[str, str] | None]:
72
+ """External tools Fenolite can drive, with their version, or ``None`` when absent or unusable."""
73
+ tools: dict[str, dict[str, str] | None] = {}
74
+ probes: dict[str, tuple[str | None, list[str]]] = {
75
+ "kicad-cli": (_kicad_cli_path(), ["version"]),
76
+ "java": (shutil.which("java"), ["-version"]),
77
+ "docker": (shutil.which("docker"), ["--version"]),
78
+ }
79
+ for name, (path, version_args) in probes.items():
80
+ version = _run_version([path, *version_args]) if path else None
81
+ tools[name] = {"path": path, "version": version} if path and version else None
82
+ return tools
83
+
84
+
85
+ def _register(parser: argparse.ArgumentParser) -> None:
86
+ parser.add_argument("--no-tools", action="store_true", help="skip external tool detection")
87
+
88
+
89
+ def _run(args: argparse.Namespace, ctx: Context) -> Result:
90
+ commands = [
91
+ {"name": c.name, "mutates": c.mutates, "schema": c.schema, "hidden": c.hidden}
92
+ for c in sorted(discover().values(), key=lambda c: c.name)
93
+ ]
94
+ result: dict[str, Any] = {
95
+ "fenolite_version": __version__,
96
+ "commands": commands,
97
+ "backends": [],
98
+ "extras": _extras(),
99
+ "tools": {} if args.no_tools else detect_tools(),
100
+ "sends_data_offsite": False,
101
+ }
102
+ return Result(result=result)
103
+
104
+
105
+ COMMAND = Command(
106
+ name="capabilities",
107
+ help="list commands, backends, extras and external tools available here",
108
+ mutates=False,
109
+ register=_register,
110
+ run=_run,
111
+ example_args=("--no-tools",),
112
+ )
fenolite/cli/errors.py ADDED
@@ -0,0 +1,120 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Fenolite contributors
3
+ """Typed CLI errors: the ``FEN-NNNN`` registry, the stderr error object and :class:`CliError`."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass, field
9
+
10
+ from fenolite.cli.exitcodes import ExitCode
11
+
12
+ ERROR_CODE = re.compile(r"^FEN-[1-7][0-9]{3}$")
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class ErrorSpec:
17
+ """Registry entry for one error code."""
18
+
19
+ code: str
20
+ exit_code: ExitCode
21
+ message: str
22
+ hint: str
23
+ retryable: bool = False
24
+
25
+
26
+ _SPECS = (
27
+ ErrorSpec(
28
+ "FEN-1001", ExitCode.INTERNAL, "internal error", "this is a bug; report it with the command line"
29
+ ),
30
+ ErrorSpec("FEN-2001", ExitCode.USAGE, "invalid command line", "run 'fenolite --help'"),
31
+ ErrorSpec(
32
+ "FEN-2002", ExitCode.USAGE, "unknown field in --fields", "list the result keys with --json first"
33
+ ),
34
+ ErrorSpec(
35
+ "FEN-2003", ExitCode.USAGE, "--dry-run and --confirm are mutually exclusive", "use one of them"
36
+ ),
37
+ ErrorSpec("FEN-2004", ExitCode.USAGE, "invalid --timestamp", "use ISO 8601, e.g. 2026-01-01T00:00:00Z"),
38
+ ErrorSpec(
39
+ "FEN-3001", ExitCode.INPUT, "input file missing or unreadable", "check the path and permissions"
40
+ ),
41
+ ErrorSpec(
42
+ "FEN-3002", ExitCode.INPUT, "input uses a newer format version than supported", "upgrade fenolite"
43
+ ),
44
+ ErrorSpec(
45
+ "FEN-4001",
46
+ ExitCode.CONFIRM_REQUIRED,
47
+ "confirmation required; nothing was written",
48
+ "review result.plan, then re-run with --confirm (or --dry-run to only preview)",
49
+ ),
50
+ ErrorSpec(
51
+ "FEN-5001",
52
+ ExitCode.FINDINGS,
53
+ "verification produced findings of severity error",
54
+ "read 'issues' in the envelope",
55
+ ),
56
+ ErrorSpec(
57
+ "FEN-6001",
58
+ ExitCode.TOOL,
59
+ "external tool not found",
60
+ "run 'fenolite capabilities' to see what is missing",
61
+ retryable=True,
62
+ ),
63
+ ErrorSpec(
64
+ "FEN-6002", ExitCode.TOOL, "external tool version not supported", "install a supported version"
65
+ ),
66
+ ErrorSpec(
67
+ "FEN-7001",
68
+ ExitCode.LOSSY,
69
+ "operation would lose information",
70
+ "re-run with --allow-lossy to accept the loss",
71
+ ),
72
+ )
73
+
74
+ REGISTRY: dict[str, ErrorSpec] = {spec.code: spec for spec in _SPECS}
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class ErrorInfo:
79
+ """The single error object written to stderr when the exit code is not 0."""
80
+
81
+ code: str = field(metadata={"pattern": ERROR_CODE.pattern})
82
+ message: str
83
+ hint: str
84
+ retryable: bool
85
+ where: str
86
+
87
+
88
+ class CliError(Exception):
89
+ """An error with a registered ``FEN-NNNN`` code; the dispatcher maps it to an exit code."""
90
+
91
+ def __init__(
92
+ self,
93
+ code: str,
94
+ message: str | None = None,
95
+ *,
96
+ hint: str | None = None,
97
+ where: str = "",
98
+ retryable: bool | None = None,
99
+ ) -> None:
100
+ if code not in REGISTRY:
101
+ raise ValueError(f"unregistered error code {code!r}")
102
+ spec = REGISTRY[code]
103
+ self.code = code
104
+ self.message = message or spec.message
105
+ self.hint = spec.hint if hint is None else hint
106
+ self.where = where
107
+ self.retryable = spec.retryable if retryable is None else retryable
108
+ super().__init__(f"{code}: {self.message}")
109
+
110
+ @property
111
+ def exit_code(self) -> ExitCode:
112
+ return REGISTRY[self.code].exit_code
113
+
114
+ def info(self) -> ErrorInfo:
115
+ return ErrorInfo(
116
+ code=self.code, message=self.message, hint=self.hint, retryable=self.retryable, where=self.where
117
+ )
118
+
119
+
120
+ __all__ = ["ERROR_CODE", "REGISTRY", "CliError", "ErrorInfo", "ErrorSpec"]
@@ -0,0 +1,23 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Fenolite contributors
3
+ """The fixed exit-code vocabulary of the ``fenolite`` command (frozen at 1.0)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from enum import IntEnum
8
+
9
+
10
+ class ExitCode(IntEnum):
11
+ """Process exit codes. The first digit of every ``FEN-NNNN`` error code equals one of these."""
12
+
13
+ OK = 0
14
+ INTERNAL = 1
15
+ USAGE = 2
16
+ INPUT = 3
17
+ CONFIRM_REQUIRED = 4
18
+ FINDINGS = 5
19
+ TOOL = 6
20
+ LOSSY = 7
21
+
22
+
23
+ __all__ = ["ExitCode"]
fenolite/cli/main.py ADDED
@@ -0,0 +1,215 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Fenolite contributors
3
+ """The ``fenolite`` dispatcher: global flags, command discovery, errors, exit codes and the
4
+ mutation protocol. See ``docs/cli-contract.md``."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import random
10
+ import sys
11
+ import time
12
+ from collections.abc import Sequence
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import NoReturn, TextIO
16
+
17
+ from fenolite import __version__
18
+ from fenolite.cli.api import Command, Context, Result, discover
19
+ from fenolite.cli.errors import CliError, ErrorInfo
20
+ from fenolite.cli.exitcodes import ExitCode
21
+ from fenolite.cli.output import (
22
+ Envelope,
23
+ FieldNotFoundError,
24
+ OutputMode,
25
+ Receipt,
26
+ WrittenFile,
27
+ parse_fields,
28
+ project_fields,
29
+ render_json,
30
+ render_text,
31
+ resolve_mode,
32
+ write_error,
33
+ )
34
+ from fenolite.core.io import atomic_write, sha256_bytes
35
+
36
+
37
+ class _Parser(argparse.ArgumentParser):
38
+ """argparse that raises typed usage errors instead of printing and exiting."""
39
+
40
+ def error(self, message: str) -> NoReturn:
41
+ raise CliError("FEN-2001", message, hint=f"run '{self.prog} --help'")
42
+
43
+
44
+ def _add_global_options(parser: argparse.ArgumentParser, *, top_level: bool) -> None:
45
+ default: object = argparse.SUPPRESS
46
+ group = parser.add_argument_group("output and determinism")
47
+ group.add_argument("--json", action="store_true", default=False if top_level else default,
48
+ help="force JSON output") # fmt: skip
49
+ group.add_argument("--text", action="store_true", default=False if top_level else default,
50
+ help="force human-readable output") # fmt: skip
51
+ group.add_argument("--fields", default=None if top_level else default, metavar="A,B.C",
52
+ help="keep only these dotted paths of 'result'") # fmt: skip
53
+ group.add_argument("--seed", type=int, default=None if top_level else default,
54
+ help="seed for generated ids (reproducible output)") # fmt: skip
55
+ group.add_argument("--timestamp", default=None if top_level else default, metavar="ISO8601",
56
+ help="fixed timestamp for generated dates") # fmt: skip
57
+ group.add_argument("--no-backup", action="store_true", default=False if top_level else default,
58
+ help="do not keep .bak copies of overwritten files") # fmt: skip
59
+
60
+
61
+ def build_parser(commands: dict[str, Command]) -> argparse.ArgumentParser:
62
+ parser = _Parser(prog="fenolite", description="Headless, agent-first PCB design automation.")
63
+ parser.add_argument("--version", action="version", version=f"fenolite {__version__}")
64
+ _add_global_options(parser, top_level=True)
65
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND", parser_class=_Parser)
66
+ for command in sorted(commands.values(), key=lambda c: c.name):
67
+ if command.help is None: # hidden: not listed in --help
68
+ child = sub.add_parser(command.name)
69
+ else:
70
+ child = sub.add_parser(command.name, help=command.help, description=command.help)
71
+ _add_global_options(child, top_level=False)
72
+ if command.mutates:
73
+ protocol = child.add_argument_group("writing files")
74
+ protocol.add_argument("--dry-run", action="store_true", help="show the plan; write nothing")
75
+ protocol.add_argument("--confirm", action="store_true", help="perform the writes")
76
+ command.register(child)
77
+ return parser
78
+
79
+
80
+ def _timestamp(value: str | None) -> datetime:
81
+ if value is None:
82
+ return datetime.now(UTC)
83
+ try:
84
+ parsed = datetime.fromisoformat(value)
85
+ except ValueError as exc:
86
+ raise CliError("FEN-2004", f"invalid --timestamp {value!r}") from exc
87
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
88
+
89
+
90
+ def _context(args: argparse.Namespace, mode: OutputMode) -> Context:
91
+ seed: int | None = args.seed
92
+ rng = random.Random(seed) if seed is not None else random.Random()
93
+ return Context(
94
+ mode=mode,
95
+ seed=seed,
96
+ timestamp=_timestamp(args.timestamp),
97
+ rng=rng,
98
+ no_backup=bool(args.no_backup),
99
+ cwd=Path.cwd(),
100
+ )
101
+
102
+
103
+ def _emit(envelope: Envelope, mode: OutputMode, out: TextIO) -> None:
104
+ out.write((render_json(envelope) if mode == "json" else render_text(envelope)) + "\n")
105
+
106
+
107
+ def _envelope(command: Command, outcome: Result, result: dict[str, object], *, ok: bool,
108
+ receipt: Receipt | None, started: float) -> Envelope: # fmt: skip
109
+ return Envelope(
110
+ ok=ok,
111
+ command=command.name,
112
+ schema=command.schema,
113
+ input=outcome.input,
114
+ result=dict(result),
115
+ issues=outcome.issues,
116
+ evidence=outcome.evidence,
117
+ receipt=receipt,
118
+ elapsed_ms=max(0, round((time.perf_counter() - started) * 1000)),
119
+ )
120
+
121
+
122
+ def _dispatch(command: Command, args: argparse.Namespace, ctx: Context, started: float,
123
+ out: TextIO, err: TextIO) -> int: # fmt: skip
124
+ dry_run = bool(getattr(args, "dry_run", False))
125
+ confirm = bool(getattr(args, "confirm", False))
126
+ if dry_run and confirm:
127
+ raise CliError("FEN-2003")
128
+ fields = parse_fields(args.fields) if args.fields else []
129
+
130
+ try:
131
+ outcome = command.run(args, ctx)
132
+ if outcome.writes and not command.mutates:
133
+ raise RuntimeError(f"command {command.name!r} returned writes but is not declared mutating")
134
+ except CliError as exc:
135
+ _emit(_envelope(command, Result(), {}, ok=False, receipt=None, started=started), ctx.mode, out)
136
+ write_error(exc.info(), ctx.mode, err)
137
+ return int(exc.exit_code)
138
+ except Exception as exc: # any bug inside a command becomes exit 1 with FEN-1001
139
+ _emit(_envelope(command, Result(), {}, ok=False, receipt=None, started=started), ctx.mode, out)
140
+ write_error(
141
+ CliError("FEN-1001", f"{type(exc).__name__}: {exc}", where=command.name).info(), ctx.mode, err
142
+ )
143
+ return int(ExitCode.INTERNAL)
144
+
145
+ try:
146
+ result = project_fields(outcome.result, fields) if fields else dict(outcome.result)
147
+ except FieldNotFoundError as exc:
148
+ raise CliError("FEN-2002", f"unknown field {exc.args[0]!r} in --fields") from exc
149
+
150
+ code = ExitCode.OK
151
+ error: ErrorInfo | None = None
152
+ receipt: Receipt | None = None
153
+ if outcome.writes:
154
+ plan = [
155
+ {"path": w.path, "kind": w.kind, "bytes": len(w.data), "sha256": sha256_bytes(w.data),
156
+ "overwrite": (ctx.cwd / w.path).exists()}
157
+ for w in outcome.writes
158
+ ] # fmt: skip
159
+ if dry_run:
160
+ result["plan"] = plan
161
+ elif not confirm:
162
+ result["plan"] = plan
163
+ code = ExitCode.CONFIRM_REQUIRED
164
+ error = CliError("FEN-4001", where=command.name).info()
165
+ else:
166
+ written: list[WrittenFile] = []
167
+ backups: list[str] = []
168
+ for w in outcome.writes:
169
+ receipt_io = atomic_write(ctx.cwd / w.path, w.data, backup=not ctx.no_backup)
170
+ written.append(WrittenFile(path=w.path, sha256=receipt_io.sha256))
171
+ if receipt_io.backup_path is not None:
172
+ backups.append(w.path + ".bak")
173
+ receipt = Receipt(written=tuple(written), backup=tuple(backups))
174
+
175
+ n_errors = sum(1 for issue in outcome.issues if issue.severity == "error")
176
+ if code is ExitCode.OK and n_errors:
177
+ code = ExitCode.FINDINGS
178
+ error = CliError("FEN-5001", f"{n_errors} finding(s) of severity error", where=command.name).info()
179
+
180
+ _emit(
181
+ _envelope(command, outcome, result, ok=code is ExitCode.OK, receipt=receipt, started=started),
182
+ ctx.mode,
183
+ out,
184
+ )
185
+ if error is not None:
186
+ write_error(error, ctx.mode, err)
187
+ return int(code)
188
+
189
+
190
+ def main(argv: Sequence[str] | None = None) -> int:
191
+ """Run ``fenolite`` with ``argv`` (defaults to ``sys.argv[1:]``) and return the exit code."""
192
+ started = time.perf_counter()
193
+ raw = list(sys.argv[1:] if argv is None else argv)
194
+ out, err = sys.stdout, sys.stderr
195
+ mode = resolve_mode(force_json="--json" in raw, force_text="--text" in raw, stream=out)
196
+ try:
197
+ commands = discover()
198
+ parser = build_parser(commands)
199
+ try:
200
+ args = parser.parse_args(raw)
201
+ except SystemExit as exc: # --help / --version print and exit 0
202
+ return exc.code if isinstance(exc.code, int) else 0
203
+ if args.json and args.text:
204
+ raise CliError("FEN-2001", "--json and --text are mutually exclusive")
205
+ if args.command is None:
206
+ parser.print_help(out)
207
+ return int(ExitCode.OK)
208
+ ctx = _context(args, mode)
209
+ return _dispatch(commands[args.command], args, ctx, started, out, err)
210
+ except CliError as exc:
211
+ write_error(exc.info(), mode, err)
212
+ return int(exc.exit_code)
213
+
214
+
215
+ __all__ = ["build_parser", "main"]