klayout-tools 0.1.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.
@@ -0,0 +1,3 @@
1
+ """klayout-tools: tools for AI agents to work with IC layout."""
2
+
3
+ __version__ = "0.0.1"
klayout_tools/cells.py ADDED
@@ -0,0 +1,148 @@
1
+ """Enumerate the cell hierarchy of a GDSII/OASIS stream.
2
+
3
+ Pure library: :func:`cells_report` returns plain Python data (a ``dict`` of
4
+ JSON-serialisable primitives) and never prints. Serialisation and human-readable
5
+ formatting live in the CLI command module so this function stays reusable (e.g.
6
+ by a future MCP server).
7
+
8
+ Headless invariant: uses the pip ``klayout`` package's batch database API
9
+ (``klayout.db``) only — no GUI, no Qt. Runnable in CI.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any
16
+
17
+
18
+ class CellsError(Exception):
19
+ """Raised when a layout file cannot be read or enumerated.
20
+
21
+ The CLI turns this into a clean stderr message + exit code 1, never a
22
+ traceback.
23
+ """
24
+
25
+
26
+ def cells_report(path: str, top: bool = False) -> dict[str, Any]:
27
+ """Enumerate the cell hierarchy of a GDSII or OASIS stream.
28
+
29
+ KLayout auto-detects the stream format on read, so both ``.gds`` and
30
+ ``.oas`` inputs are handled by the same code path.
31
+
32
+ Returns a dict matching the documented JSON schema (see
33
+ ``docs/cli/cells.md``)::
34
+
35
+ {
36
+ "schema_version": 1,
37
+ "file": <path as provided>,
38
+ "dbu_um": <database unit in micrometres, float>,
39
+ "cell_count": <total cells in the whole layout, int>,
40
+ "top_cell_count": <total top cells in the whole layout, int>,
41
+ "cells": [
42
+ {
43
+ "name": str,
44
+ "index": int,
45
+ "is_top": bool,
46
+ "shapes": int,
47
+ "instances": int,
48
+ "children": [str, ...],
49
+ "parents": [str, ...],
50
+ "bbox_um": {"left": float, "bottom": float,
51
+ "right": float, "top": float} | None,
52
+ },
53
+ ...
54
+ ],
55
+ }
56
+
57
+ ``cells`` is sorted by ``index`` ascending for deterministic output.
58
+ ``cell_count``/``top_cell_count`` always describe the whole layout,
59
+ regardless of the ``top`` filter. When ``top`` is true, ``cells`` is
60
+ narrowed to entries with ``is_top: True`` only.
61
+
62
+ Field semantics:
63
+
64
+ - ``shapes`` is the count of shapes owned by this cell's own definition,
65
+ summed across all layers — not multiplied by instantiation (same
66
+ convention as ``klt layers``' ``shapes`` field).
67
+ - ``instances`` is the count of child-instance *placement records*
68
+ (``Cell.each_inst()``) — an arrayed instance counts as one placement
69
+ record, not its expanded row-by-column total.
70
+ - ``children``/``parents`` are deduplicated, sorted cell names one level
71
+ of instantiation away (direct only, not transitive).
72
+ - ``bbox_um`` is ``None`` when the cell (including its children) has no
73
+ geometry at all, rather than a degenerate/inverted box.
74
+
75
+ Raises :class:`CellsError` if the file is missing, unreadable, or not a
76
+ recognisable layout stream.
77
+ """
78
+ if not os.path.exists(path):
79
+ raise CellsError(f"file not found: {path}")
80
+ if os.path.isdir(path):
81
+ raise CellsError(f"not a file: {path}")
82
+
83
+ # Imported lazily so that `klt --version` and argument parsing do not pay
84
+ # the cost of loading the KLayout database module.
85
+ import klayout.db as kdb
86
+
87
+ layout = kdb.Layout()
88
+ try:
89
+ layout.read(path)
90
+ except Exception as exc: # klayout raises RuntimeError for bad/unknown streams
91
+ raise CellsError(f"could not read layout '{path}': {exc}") from exc
92
+
93
+ top_cell_indices = {cell.cell_index() for cell in layout.top_cells()}
94
+
95
+ cells: list[dict[str, Any]] = []
96
+ for cell in layout.each_cell():
97
+ index = cell.cell_index()
98
+ shape_count = sum(
99
+ cell.shapes(layer_index).size() for layer_index in layout.layer_indexes()
100
+ )
101
+ instance_count = sum(1 for _ in cell.each_inst())
102
+ children = sorted(
103
+ {layout.cell(child_index).name for child_index in cell.each_child_cell()}
104
+ )
105
+ parents = sorted(
106
+ {layout.cell(parent_index).name for parent_index in cell.each_parent_cell()}
107
+ )
108
+ bbox = cell.dbbox()
109
+ bbox_um = (
110
+ None
111
+ if bbox.empty()
112
+ else {
113
+ "left": bbox.left,
114
+ "bottom": bbox.bottom,
115
+ "right": bbox.right,
116
+ "top": bbox.top,
117
+ }
118
+ )
119
+
120
+ cells.append(
121
+ {
122
+ "name": cell.name,
123
+ "index": index,
124
+ "is_top": index in top_cell_indices,
125
+ "shapes": shape_count,
126
+ "instances": instance_count,
127
+ "children": children,
128
+ "parents": parents,
129
+ "bbox_um": bbox_um,
130
+ }
131
+ )
132
+
133
+ cells.sort(key=lambda entry: entry["index"])
134
+
135
+ cell_count = len(cells)
136
+ top_cell_count = len(top_cell_indices)
137
+
138
+ if top:
139
+ cells = [entry for entry in cells if entry["is_top"]]
140
+
141
+ return {
142
+ "schema_version": 1,
143
+ "file": path,
144
+ "dbu_um": layout.dbu,
145
+ "cell_count": cell_count,
146
+ "top_cell_count": top_cell_count,
147
+ "cells": cells,
148
+ }
@@ -0,0 +1,28 @@
1
+ """klt — CLI entry point.
2
+
3
+ Subcommands live in ``<verb>_cmd.py`` modules (mirroring kicad-tools' CLI
4
+ package). The argument parser is built in :mod:`parser`; this module dispatches
5
+ to the selected subcommand. Every subcommand supports ``--format json`` — JSON
6
+ is the API.
7
+ """
8
+
9
+ import sys
10
+
11
+ from .. import __version__
12
+ from .parser import create_parser
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = create_parser()
17
+ args = parser.parse_args(argv)
18
+
19
+ # No subcommand: preserve the scaffold status blurb (and exit 0).
20
+ if getattr(args, "func", None) is None:
21
+ print(f"klt {__version__} — scaffold; run `klt --help` for available commands")
22
+ return 0
23
+
24
+ return args.func(args)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m klayout_tools.cli``."""
2
+
3
+ import sys
4
+
5
+ from . import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,84 @@
1
+ """``klt cells`` command: serialise the cells report as text or JSON.
2
+
3
+ Output goes through the shared envelope helpers in :mod:`.output`, as with
4
+ every other ``klt`` subcommand — see ``docs/json-contract.md``.
5
+ """
6
+
7
+ import argparse
8
+
9
+ from ..cells import CellsError, cells_report
10
+ from .output import emit_error, emit_success
11
+
12
+
13
+ def run(args: argparse.Namespace) -> int:
14
+ try:
15
+ report = cells_report(args.file, top=args.top)
16
+ except CellsError as exc:
17
+ return emit_error("cells", str(exc), args.format)
18
+
19
+ emit_success(report, args.format, _print_text)
20
+ return 0
21
+
22
+
23
+ def _print_text(report: dict) -> None:
24
+ print(f"file: {report['file']}")
25
+ print(f"dbu_um: {report['dbu_um']}")
26
+ print(f"cells: {report['cell_count']}")
27
+ print(f"top_cells: {report['top_cell_count']}")
28
+
29
+ cells = report["cells"]
30
+ if not cells:
31
+ return
32
+
33
+ def fmt_bbox(bbox: dict | None) -> str:
34
+ if bbox is None:
35
+ return "-"
36
+ return (
37
+ f"({bbox['left']:g}, {bbox['bottom']:g}, "
38
+ f"{bbox['right']:g}, {bbox['top']:g})"
39
+ )
40
+
41
+ rows = [
42
+ (
43
+ str(entry["index"]),
44
+ entry["name"],
45
+ "yes" if entry["is_top"] else "no",
46
+ str(entry["shapes"]),
47
+ str(entry["instances"]),
48
+ ",".join(entry["children"]) if entry["children"] else "-",
49
+ ",".join(entry["parents"]) if entry["parents"] else "-",
50
+ fmt_bbox(entry["bbox_um"]),
51
+ )
52
+ for entry in cells
53
+ ]
54
+ headers = (
55
+ "index",
56
+ "name",
57
+ "is_top",
58
+ "shapes",
59
+ "instances",
60
+ "children",
61
+ "parents",
62
+ "bbox_um",
63
+ )
64
+ widths = [
65
+ max(len(headers[col]), max(len(row[col]) for row in rows))
66
+ for col in range(len(headers))
67
+ ]
68
+
69
+ def fmt(row: tuple[str, ...]) -> str:
70
+ # Numeric-ish columns (index, is_top, shapes, instances) right-aligned;
71
+ # name/children/parents/bbox_um left-aligned.
72
+ right_aligned = {0, 2, 3, 4}
73
+ return " ".join(
74
+ row[col].rjust(widths[col])
75
+ if col in right_aligned
76
+ else row[col].ljust(widths[col])
77
+ for col in range(len(headers))
78
+ )
79
+
80
+ print()
81
+ print(fmt(headers))
82
+ print(" ".join("-" * widths[col] for col in range(len(headers))))
83
+ for row in rows:
84
+ print(fmt(row))
@@ -0,0 +1,59 @@
1
+ """``klt drc`` command: serialise the DRC report as text or JSON.
2
+
3
+ Output goes through the shared envelope helpers in :mod:`.output`, as with
4
+ every other ``klt`` subcommand — see ``docs/json-contract.md``.
5
+
6
+ Exit codes (see ``docs/cli/drc.md`` for the full table):
7
+ 0 - ran clean, no violations
8
+ 1 - failed to run (bad file, unknown deck, engine error) — returned by
9
+ ``emit_error`` as ``output.ERROR_EXIT_CODE``
10
+ 3 - ran successfully, violations found
11
+ (2 is reserved for argparse usage errors, as with every other ``klt`` subcommand.)
12
+ """
13
+
14
+ import argparse
15
+
16
+ from ..drc import DrcError, run_drc
17
+ from .output import emit_error, emit_success
18
+
19
+ EXIT_CLEAN = 0
20
+ EXIT_VIOLATIONS = 3
21
+
22
+
23
+ def run(args: argparse.Namespace) -> int:
24
+ try:
25
+ report = run_drc(args.file, args.deck)
26
+ except DrcError as exc:
27
+ return emit_error("drc", str(exc), args.format)
28
+
29
+ emit_success(report, args.format, _print_text)
30
+
31
+ return EXIT_VIOLATIONS if report["status"] == "violations" else EXIT_CLEAN
32
+
33
+
34
+ def _print_text(report: dict) -> None:
35
+ print(f"file: {report['file']}")
36
+ print(f"deck: {report['deck']}")
37
+ print(f"dbu_um: {report['dbu_um']}")
38
+ print(f"status: {report['status']}")
39
+ print(f"violations: {report['violation_count']}")
40
+
41
+ rule_counts = report["rule_counts"]
42
+ if rule_counts:
43
+ print()
44
+ print("rule_counts:")
45
+ for rule_id in sorted(rule_counts):
46
+ print(f" {rule_id}: {rule_counts[rule_id]}")
47
+
48
+ violations = report["violations"]
49
+ if not violations:
50
+ return
51
+
52
+ print()
53
+ for entry in violations:
54
+ bbox = entry["bbox"]
55
+ print(
56
+ f"{entry['rule']} {entry['cell']} {entry['layer']} "
57
+ f"({bbox['left']},{bbox['bottom']})-({bbox['right']},{bbox['top']}) "
58
+ f"{entry['description']}"
59
+ )
@@ -0,0 +1,54 @@
1
+ """``klt layers`` command: serialise the layers report as text or JSON."""
2
+
3
+ import argparse
4
+
5
+ from ..layers import LayersError, layers_report
6
+ from .output import emit_error, emit_success
7
+
8
+
9
+ def run(args: argparse.Namespace) -> int:
10
+ try:
11
+ report = layers_report(args.file)
12
+ except LayersError as exc:
13
+ return emit_error("layers", str(exc), args.format)
14
+
15
+ emit_success(report, args.format, _print_text)
16
+ return 0
17
+
18
+
19
+ def _print_text(report: dict) -> None:
20
+ print(f"file: {report['file']}")
21
+ print(f"dbu_um: {report['dbu_um']}")
22
+ print(f"layers: {report['layer_count']}")
23
+
24
+ layers = report["layers"]
25
+ if not layers:
26
+ return
27
+
28
+ rows = [
29
+ (
30
+ str(entry["layer"]),
31
+ str(entry["datatype"]),
32
+ entry["name"] if entry["name"] is not None else "-",
33
+ str(entry["shapes"]),
34
+ )
35
+ for entry in layers
36
+ ]
37
+ headers = ("layer", "datatype", "name", "shapes")
38
+ widths = [
39
+ max(len(headers[col]), max(len(row[col]) for row in rows))
40
+ for col in range(len(headers))
41
+ ]
42
+
43
+ def fmt(row: tuple[str, ...]) -> str:
44
+ # layer/datatype/shapes right-aligned (numeric), name left-aligned.
45
+ return " ".join(
46
+ row[col].rjust(widths[col]) if col != 2 else row[col].ljust(widths[col])
47
+ for col in range(len(headers))
48
+ )
49
+
50
+ print()
51
+ print(fmt(headers))
52
+ print(" ".join("-" * widths[col] for col in range(len(headers))))
53
+ for row in rows:
54
+ print(fmt(row))
@@ -0,0 +1,79 @@
1
+ """Shared JSON/text output helper for ``klt`` subcommands.
2
+
3
+ Every ``*_cmd.py`` module emits through :func:`emit_success` /
4
+ :func:`emit_error` instead of hand-rolling ``json.dump``/``print`` — this is
5
+ the one place that knows the documented envelope shape (see
6
+ ``docs/json-contract.md``).
7
+
8
+ Design notes (additive envelope, not a wrapping one):
9
+
10
+ - Success payloads stay **flat** at the top level (no ``{"result": {...}}``
11
+ nesting) so existing, already-documented command shapes (e.g. ``klt
12
+ layers``) are unaffected. Commands add their own ``schema_version`` field to
13
+ the payload dict *before* calling :func:`emit_success` — this module does
14
+ not inject it, since the version is owned by the library function that
15
+ builds the payload (see ``layers.py``'s docstring on MCP reuse).
16
+ - ``--format json`` output on success goes to **stdout only**; on error, the
17
+ JSON error object goes to **stderr**, and stdout is left empty. This means
18
+ a caller never has to inspect stdout content to distinguish success from
19
+ failure under ``--format json`` — check the exit code.
20
+ - ``--format text`` is a courtesy rendering, not the contract: success calls
21
+ a command-supplied ``text_renderer`` callback, and errors print a plain
22
+ ``klt <command>: <message>`` line to stderr, matching pre-existing
23
+ behaviour.
24
+ - Exit code ``1`` is returned by :func:`emit_error` for application-level
25
+ errors. Argparse-level usage errors (exit code ``2``) are raised by
26
+ argparse itself before a command's ``run()`` executes, so they are out of
27
+ scope for this helper by construction.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import sys
34
+ from collections.abc import Callable
35
+
36
+ #: Application-level error exit code (as opposed to argparse's usage-error 2).
37
+ ERROR_EXIT_CODE = 1
38
+
39
+
40
+ def emit_success(
41
+ payload: dict,
42
+ format: str,
43
+ text_renderer: Callable[[dict], None],
44
+ ) -> None:
45
+ """Emit a successful command result in the requested ``format``.
46
+
47
+ ``format == "json"`` writes ``payload`` as indented JSON to stdout (plus a
48
+ trailing newline). ``format == "text"`` delegates to ``text_renderer``,
49
+ which is responsible for printing whatever human-readable rendering the
50
+ command defines; ``text_renderer`` is not part of the JSON contract.
51
+ """
52
+ if format == "json":
53
+ json.dump(payload, sys.stdout, indent=2)
54
+ print()
55
+ else:
56
+ text_renderer(payload)
57
+
58
+
59
+ def emit_error(command: str, message: str, format: str) -> int:
60
+ """Emit an application-level error and return the exit code to use.
61
+
62
+ ``format == "json"`` writes the documented error envelope to stderr:
63
+ ``{"schema_version": 1, "error": {"command": ..., "message": ...}}``.
64
+ ``format == "text"`` writes the pre-existing plain-text stderr line,
65
+ ``klt <command>: <message>``.
66
+
67
+ Always returns :data:`ERROR_EXIT_CODE` (``1``) so a command's ``run()``
68
+ can simply ``return emit_error(...)``.
69
+ """
70
+ if format == "json":
71
+ error_payload = {
72
+ "schema_version": 1,
73
+ "error": {"command": command, "message": message},
74
+ }
75
+ json.dump(error_payload, sys.stderr, indent=2)
76
+ print(file=sys.stderr)
77
+ else:
78
+ print(f"klt {command}: {message}", file=sys.stderr)
79
+ return ERROR_EXIT_CODE
@@ -0,0 +1,222 @@
1
+ """Argument parser for the ``klt`` CLI.
2
+
3
+ ``--format {text,json}`` is a *per-subcommand* option (kicad-tools convention),
4
+ defaulting to ``text``. New subcommands register themselves here and point their
5
+ ``func`` default at a ``run(args) -> int`` handler.
6
+ """
7
+
8
+ import argparse
9
+ import sys
10
+
11
+ from .. import __version__
12
+ from . import cells_cmd, drc_cmd, layers_cmd, pdk_cmd, stats_cmd
13
+
14
+
15
+ def create_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="klt",
18
+ description="Tools for AI agents to work with IC layout.",
19
+ )
20
+ parser.add_argument("--version", action="version", version=f"klt {__version__}")
21
+ # No default handler: absence of a subcommand is handled by main().
22
+ parser.set_defaults(func=None)
23
+
24
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
25
+
26
+ layers_parser = subparsers.add_parser(
27
+ "layers",
28
+ help="enumerate layers of a GDSII/OASIS stream",
29
+ description=(
30
+ "Report the layer/datatype pairs, names, and per-cell-definition "
31
+ "shape counts of a GDSII or OASIS layout file."
32
+ ),
33
+ )
34
+ layers_parser.add_argument("file", help="path to a GDSII or OASIS layout file")
35
+ layers_parser.add_argument(
36
+ "--format",
37
+ choices=["text", "json"],
38
+ default="text",
39
+ help="output format (default: text)",
40
+ )
41
+ layers_parser.set_defaults(func=layers_cmd.run)
42
+
43
+ stats_parser = subparsers.add_parser(
44
+ "stats",
45
+ help="report area, density, and polygon/vertex counts of a GDSII/OASIS stream",
46
+ description=(
47
+ "Report bounding box, drawn area, density, and polygon/vertex "
48
+ "counts of a GDSII or OASIS layout file, in total and optionally "
49
+ "per layer."
50
+ ),
51
+ )
52
+ stats_parser.add_argument("file", help="path to a GDSII or OASIS layout file")
53
+ stats_parser.add_argument(
54
+ "--per-layer",
55
+ action="store_true",
56
+ help="also report the same statistics broken down per layer",
57
+ )
58
+ stats_parser.add_argument(
59
+ "--format",
60
+ choices=["text", "json"],
61
+ default="text",
62
+ help="output format (default: text)",
63
+ )
64
+ stats_parser.set_defaults(func=stats_cmd.run)
65
+
66
+ cells_parser = subparsers.add_parser(
67
+ "cells",
68
+ help="report the cell hierarchy of a GDSII/OASIS stream",
69
+ description=(
70
+ "Report the cell hierarchy of a GDSII or OASIS layout file: "
71
+ "top-cell status, per-cell shape/instance counts, direct "
72
+ "children/parents, and bounding box."
73
+ ),
74
+ )
75
+ cells_parser.add_argument("file", help="path to a GDSII or OASIS layout file")
76
+ cells_parser.add_argument(
77
+ "--top",
78
+ action="store_true",
79
+ help="only report top cells (cells with no parent instances)",
80
+ )
81
+ cells_parser.add_argument(
82
+ "--format",
83
+ choices=["text", "json"],
84
+ default="text",
85
+ help="output format (default: text)",
86
+ )
87
+ cells_parser.set_defaults(func=cells_cmd.run)
88
+
89
+ drc_parser = subparsers.add_parser(
90
+ "drc",
91
+ help="run a headless DRC deck against a GDSII/OASIS stream",
92
+ description=(
93
+ "Run a DRC rule deck against a GDSII or OASIS layout file and "
94
+ "report violations as structured data. Runs fully headless via "
95
+ "KLayout's native Region check primitives — no GUI, no Qt, no "
96
+ "standalone klayout binary."
97
+ ),
98
+ )
99
+ drc_parser.add_argument("file", help="path to a GDSII or OASIS layout file")
100
+ drc_parser.add_argument(
101
+ "--deck",
102
+ required=True,
103
+ help=(
104
+ "DRC deck to run (currently: sky130, gf180mcu). Not validated by "
105
+ "argparse -- an unknown deck name exits 1 with a clean error, "
106
+ "per docs/cli/drc.md's exit-code contract, rather than "
107
+ "argparse's usage-error exit 2."
108
+ ),
109
+ )
110
+ drc_parser.add_argument(
111
+ "--format",
112
+ choices=["text", "json"],
113
+ default="text",
114
+ help="output format (default: text)",
115
+ )
116
+ drc_parser.set_defaults(func=drc_cmd.run)
117
+
118
+ _add_pdk_parser(subparsers)
119
+
120
+ return parser
121
+
122
+
123
+ def _add_pdk_parser(subparsers: argparse._SubParsersAction) -> None:
124
+ """Register the ``pdk`` verb with nested ``find``/``list``/``env`` subcommands.
125
+
126
+ The other verbs are flat; ``pdk`` groups discovery operations under one
127
+ verb (kicad-tools convention for multi-operation capabilities), so it uses
128
+ argparse sub-subparsers. Each ``--format`` stays a per-subcommand option,
129
+ matching the flat verbs.
130
+ """
131
+ pdk_parser = subparsers.add_parser(
132
+ "pdk",
133
+ help="discover and resolve an installed PDK",
134
+ description=(
135
+ "Locate an installed open_pdks-layout PDK (open_pdks, volare, or "
136
+ "ciel) and report its root, variant, version stamp, and per-tool "
137
+ "asset directories as structured data. This is the one shared "
138
+ "PDK_ROOT resolver every downstream tool imports instead of "
139
+ "re-implementing the lookup. Fully headless; safe in CI."
140
+ ),
141
+ )
142
+ pdk_sub = pdk_parser.add_subparsers(dest="pdk_command", metavar="<subcommand>")
143
+
144
+ def _no_subcommand(_args: argparse.Namespace) -> int:
145
+ pdk_parser.print_help(sys.stderr)
146
+ return 2
147
+
148
+ pdk_parser.set_defaults(func=_no_subcommand)
149
+
150
+ find_parser = pdk_sub.add_parser(
151
+ "find",
152
+ help="resolve one PDK install/variant and report its paths",
153
+ description=(
154
+ "Resolve a single PDK install and variant via the documented "
155
+ "resolution order and emit its root, variant, version, how it was "
156
+ "resolved, and its per-tool asset directories."
157
+ ),
158
+ )
159
+ find_parser.add_argument(
160
+ "--pdk",
161
+ help="variant to resolve (e.g. sky130A); overrides $PDK",
162
+ )
163
+ find_parser.add_argument(
164
+ "--pdk-root",
165
+ dest="pdk_root",
166
+ help="explicit install root; overrides $PDK_ROOT and the search order",
167
+ )
168
+ find_parser.add_argument(
169
+ "--format",
170
+ choices=["text", "json"],
171
+ default="text",
172
+ help="output format (default: text)",
173
+ )
174
+ find_parser.set_defaults(func=pdk_cmd.run_find)
175
+
176
+ list_parser = pdk_sub.add_parser(
177
+ "list",
178
+ help="enumerate every PDK install and variant discovered",
179
+ description=(
180
+ "Enumerate every install and variant found across the full search "
181
+ "order. An empty result is success (exit 0), not an error."
182
+ ),
183
+ )
184
+ list_parser.add_argument(
185
+ "--pdk-root",
186
+ dest="pdk_root",
187
+ help="restrict the scan to this install root",
188
+ )
189
+ list_parser.add_argument(
190
+ "--format",
191
+ choices=["text", "json"],
192
+ default="text",
193
+ help="output format (default: text)",
194
+ )
195
+ list_parser.set_defaults(func=pdk_cmd.run_list)
196
+
197
+ env_parser = pdk_sub.add_parser(
198
+ "env",
199
+ help="emit the resolved paths as eval-able shell exports",
200
+ description=(
201
+ "Emit the resolved install as shell `export` lines "
202
+ '(`eval "$(klt pdk env)"`) so an interactive simulator or '
203
+ "schematic-editor session uses the same install the automated "
204
+ "tooling picked. --format json emits the same payload as `find`."
205
+ ),
206
+ )
207
+ env_parser.add_argument(
208
+ "--pdk",
209
+ help="variant to resolve (e.g. sky130A); overrides $PDK",
210
+ )
211
+ env_parser.add_argument(
212
+ "--pdk-root",
213
+ dest="pdk_root",
214
+ help="explicit install root; overrides $PDK_ROOT and the search order",
215
+ )
216
+ env_parser.add_argument(
217
+ "--format",
218
+ choices=["text", "json"],
219
+ default="text",
220
+ help="output format (default: text; text emits shell exports)",
221
+ )
222
+ env_parser.set_defaults(func=pdk_cmd.run_env)