chipfoundry-cli 2.6.0__tar.gz → 2.7.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,3 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: chipfoundry-cli
3
+ Version: 2.7.0
4
+ Summary: CLI tool to automate ChipFoundry project submission to SFTP server
5
+ License: Apache-2.0
6
+ License-File: LICENSE
7
+ Author: ChipFoundry
8
+ Author-email: marwan.abbas@chipfoundry.io
9
+ Requires-Python: >=3.8.1,<4.0
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Requires-Dist: cf-ipm
20
+ Requires-Dist: click (>=8.0.0,<9)
21
+ Requires-Dist: httpx (>=0.24.0,<1.0)
22
+ Requires-Dist: paramiko (>=3.0.0,<4)
23
+ Requires-Dist: rich (>=13,<14)
24
+ Requires-Dist: textual (>=0.40.0,<1)
25
+ Requires-Dist: toml (>=0.10,<1.0)
26
+ Project-URL: Homepage, https://chipfoundry.io
27
+ Project-URL: Repository, https://github.com/chipfoundry/cf-cli
28
+ Description-Content-Type: text/markdown
29
+
1
30
  # ChipFoundry CLI (`cf-cli`)
2
31
 
3
32
  [![PyPI version](https://img.shields.io/pypi/v/chipfoundry-cli?color=blue)](https://badge.fury.io/py/chipfoundry-cli)
@@ -112,6 +141,24 @@ If you already have a project directory with GDS files:
112
141
 
113
142
  ---
114
143
 
144
+ ## Double-wide openframe projects
145
+
146
+ Double-wide projects (`type: double_wide`, detected from
147
+ `gds/double_wide_openframe_project_wrapper.gds`) use the same commands as
148
+ openframe projects, with these differences:
149
+
150
+ - `cf setup` clones the double-wide harness
151
+ ([chipfoundry/double_wide_openframe](https://github.com/chipfoundry/double_wide_openframe))
152
+ into `caravel/` instead of caravel-lite, and skips the management core wrapper.
153
+ The harness ref comes from `harness_refs.double_wide` in `versions.json` (default `main`).
154
+ - `cf precheck` passes `caravel/` to cf-precheck as the golden root, since the
155
+ precheck image only ships the standard caravel.
156
+ - `cf verify` runs caravel-cocotb in OpenFrame mode. The harness supplies the
157
+ 63-pad testbench (`verilog/rtl/toplevel_cocotb.v`) and include lists.
158
+ - `cf gpio-config` is not used: the user wrapper drives every pad control signal.
159
+
160
+ ---
161
+
115
162
  ## Project Structure Requirements
116
163
 
117
164
  Your project directory **must** contain:
@@ -120,6 +167,7 @@ Your project directory **must** contain:
120
167
  - `user_project_wrapper.gds` (for digital projects)
121
168
  - `user_analog_project_wrapper.gds` (for analog projects)
122
169
  - `openframe_project_wrapper.gds` (for openframe projects)
170
+ - `double_wide_openframe_project_wrapper.gds` (for double-wide openframe projects)
123
171
  - **Note**: Both compressed (`.gz`) and uncompressed (`.gds`) files are supported
124
172
  - `verilog/rtl/user_defines.v` (required for digital/analog)
125
173
  - `.cf/project.json` (optional; will be created/updated automatically)
@@ -617,7 +665,7 @@ Preconditions:
617
665
  - Project has a GitHub repo URL (set via `cf init`, shown in the portal).
618
666
  - The ChipFoundry GitHub App is installed on that repo (prompted in the portal).
619
667
  - Your local `HEAD` has been pushed to `origin` on some branch (`git push`).
620
- - Push-critical files at `HEAD` are clean: wrapper GDS, `verilog/rtl/user_defines.v` (when not an openframe project), and `.cf/project.json` (when tracked).
668
+ - Push-critical files at `HEAD` are clean: wrapper GDS, `verilog/rtl/user_defines.v` (digital and analog projects only), and `.cf/project.json` (when tracked).
621
669
 
622
670
  What happens:
623
671
  1. `cf push --remote` resolves your local HEAD SHA and checks it is reachable from a remote ref.
@@ -641,8 +689,8 @@ both port 22 and GitHub.
641
689
  Preconditions:
642
690
  - Project is linked to the platform (`cf init` or `cf link`) and you are logged in.
643
691
  - Wrapper GDS exists locally under `gds/` (one of `user_project_wrapper.gds[.gz]`,
644
- `user_analog_project_wrapper.gds[.gz]`, `openframe_project_wrapper.gds[.gz]`).
645
- - For non-openframe projects, `verilog/rtl/user_defines.v` is also uploaded when present.
692
+ `user_analog_project_wrapper.gds[.gz]`, `openframe_project_wrapper.gds[.gz]`, `double_wide_openframe_project_wrapper.gds[.gz]`).
693
+ - For digital and analog projects, `verilog/rtl/user_defines.v` is also uploaded when present.
646
694
  - Outbound HTTPS to `*.s3.us-east-2.amazonaws.com` is allowed.
647
695
 
648
696
  What happens:
@@ -863,7 +911,7 @@ The CLI tracks your project submission state through the `submission_state` fiel
863
911
 
864
912
  1. **File Collection:**
865
913
  - Checks for required GDS and Verilog files
866
- - Auto-detects project type (digital, analog, openframe) based on GDS file name
914
+ - Auto-detects project type (digital, analog, openframe, double_wide) based on GDS file name
867
915
  - **Supports both compressed and uncompressed GDS files**
868
916
 
869
917
  2. **Configuration:**
@@ -932,7 +980,7 @@ cf config
932
980
  # Initialize project (in your project directory)
933
981
  cf init
934
982
  # Project name (detected: my_awesome_project):
935
- # Project type (digital/analog/openframe) (detected: digital):
983
+ # Project type (digital/analog/openframe/double_wide) (detected: digital):
936
984
 
937
985
  # Upload your project
938
986
  cf push
@@ -1050,3 +1098,4 @@ gds/user_project_wrapper.gds.gz # ← Remove this one
1050
1098
 
1051
1099
  - For help, contact info@chipfoundry.io or visit [chipfoundry.io](https://chipfoundry.io)
1052
1100
  - For bug reports or feature requests, open an issue on [GitHub](https://github.com/chipfoundry/cf-cli)
1101
+
@@ -1,29 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: chipfoundry-cli
3
- Version: 2.6.0
4
- Summary: CLI tool to automate ChipFoundry project submission to SFTP server
5
- Home-page: https://chipfoundry.io
6
- License: Apache-2.0
7
- Author: ChipFoundry
8
- Author-email: marwan.abbas@chipfoundry.io
9
- Requires-Python: >=3.8.1,<4.0
10
- Classifier: License :: OSI Approved :: Apache Software License
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.9
13
- Classifier: Programming Language :: Python :: 3.10
14
- Classifier: Programming Language :: Python :: 3.11
15
- Classifier: Programming Language :: Python :: 3.12
16
- Classifier: Programming Language :: Python :: 3.13
17
- Requires-Dist: cf-ipm
18
- Requires-Dist: click (>=8.0.0,<9)
19
- Requires-Dist: httpx (>=0.24.0,<1.0)
20
- Requires-Dist: paramiko (>=3.0.0,<4)
21
- Requires-Dist: rich (>=13,<14)
22
- Requires-Dist: textual (>=0.40.0,<1)
23
- Requires-Dist: toml (>=0.10,<1.0)
24
- Project-URL: Repository, https://github.com/chipfoundry/cf-cli
25
- Description-Content-Type: text/markdown
26
-
27
1
  # ChipFoundry CLI (`cf-cli`)
28
2
 
29
3
  [![PyPI version](https://img.shields.io/pypi/v/chipfoundry-cli?color=blue)](https://badge.fury.io/py/chipfoundry-cli)
@@ -138,6 +112,24 @@ If you already have a project directory with GDS files:
138
112
 
139
113
  ---
140
114
 
115
+ ## Double-wide openframe projects
116
+
117
+ Double-wide projects (`type: double_wide`, detected from
118
+ `gds/double_wide_openframe_project_wrapper.gds`) use the same commands as
119
+ openframe projects, with these differences:
120
+
121
+ - `cf setup` clones the double-wide harness
122
+ ([chipfoundry/double_wide_openframe](https://github.com/chipfoundry/double_wide_openframe))
123
+ into `caravel/` instead of caravel-lite, and skips the management core wrapper.
124
+ The harness ref comes from `harness_refs.double_wide` in `versions.json` (default `main`).
125
+ - `cf precheck` passes `caravel/` to cf-precheck as the golden root, since the
126
+ precheck image only ships the standard caravel.
127
+ - `cf verify` runs caravel-cocotb in OpenFrame mode. The harness supplies the
128
+ 63-pad testbench (`verilog/rtl/toplevel_cocotb.v`) and include lists.
129
+ - `cf gpio-config` is not used: the user wrapper drives every pad control signal.
130
+
131
+ ---
132
+
141
133
  ## Project Structure Requirements
142
134
 
143
135
  Your project directory **must** contain:
@@ -146,6 +138,7 @@ Your project directory **must** contain:
146
138
  - `user_project_wrapper.gds` (for digital projects)
147
139
  - `user_analog_project_wrapper.gds` (for analog projects)
148
140
  - `openframe_project_wrapper.gds` (for openframe projects)
141
+ - `double_wide_openframe_project_wrapper.gds` (for double-wide openframe projects)
149
142
  - **Note**: Both compressed (`.gz`) and uncompressed (`.gds`) files are supported
150
143
  - `verilog/rtl/user_defines.v` (required for digital/analog)
151
144
  - `.cf/project.json` (optional; will be created/updated automatically)
@@ -643,7 +636,7 @@ Preconditions:
643
636
  - Project has a GitHub repo URL (set via `cf init`, shown in the portal).
644
637
  - The ChipFoundry GitHub App is installed on that repo (prompted in the portal).
645
638
  - Your local `HEAD` has been pushed to `origin` on some branch (`git push`).
646
- - Push-critical files at `HEAD` are clean: wrapper GDS, `verilog/rtl/user_defines.v` (when not an openframe project), and `.cf/project.json` (when tracked).
639
+ - Push-critical files at `HEAD` are clean: wrapper GDS, `verilog/rtl/user_defines.v` (digital and analog projects only), and `.cf/project.json` (when tracked).
647
640
 
648
641
  What happens:
649
642
  1. `cf push --remote` resolves your local HEAD SHA and checks it is reachable from a remote ref.
@@ -667,8 +660,8 @@ both port 22 and GitHub.
667
660
  Preconditions:
668
661
  - Project is linked to the platform (`cf init` or `cf link`) and you are logged in.
669
662
  - Wrapper GDS exists locally under `gds/` (one of `user_project_wrapper.gds[.gz]`,
670
- `user_analog_project_wrapper.gds[.gz]`, `openframe_project_wrapper.gds[.gz]`).
671
- - For non-openframe projects, `verilog/rtl/user_defines.v` is also uploaded when present.
663
+ `user_analog_project_wrapper.gds[.gz]`, `openframe_project_wrapper.gds[.gz]`, `double_wide_openframe_project_wrapper.gds[.gz]`).
664
+ - For digital and analog projects, `verilog/rtl/user_defines.v` is also uploaded when present.
672
665
  - Outbound HTTPS to `*.s3.us-east-2.amazonaws.com` is allowed.
673
666
 
674
667
  What happens:
@@ -889,7 +882,7 @@ The CLI tracks your project submission state through the `submission_state` fiel
889
882
 
890
883
  1. **File Collection:**
891
884
  - Checks for required GDS and Verilog files
892
- - Auto-detects project type (digital, analog, openframe) based on GDS file name
885
+ - Auto-detects project type (digital, analog, openframe, double_wide) based on GDS file name
893
886
  - **Supports both compressed and uncompressed GDS files**
894
887
 
895
888
  2. **Configuration:**
@@ -958,7 +951,7 @@ cf config
958
951
  # Initialize project (in your project directory)
959
952
  cf init
960
953
  # Project name (detected: my_awesome_project):
961
- # Project type (digital/analog/openframe) (detected: digital):
954
+ # Project type (digital/analog/openframe/double_wide) (detected: digital):
962
955
 
963
956
  # Upload your project
964
957
  cf push
@@ -1076,4 +1069,3 @@ gds/user_project_wrapper.gds.gz # ← Remove this one
1076
1069
 
1077
1070
  - For help, contact info@chipfoundry.io or visit [chipfoundry.io](https://chipfoundry.io)
1078
1071
  - For bug reports or feature requests, open an issue on [GitHub](https://github.com/chipfoundry/cf-cli)
1079
-
@@ -1,2 +1,2 @@
1
1
  """ChipFoundry CLI package: Automate project submission to SFTP."""
2
- __version__ = "2.6.0"
2
+ __version__ = "2.7.0"
@@ -228,6 +228,146 @@ def new_run_tag() -> str:
228
228
  return datetime.now().strftime("%y_%m_%d_%H_%M")
229
229
 
230
230
 
231
+ def double_wide_flake_dir(project_root: Path) -> Optional[Path]:
232
+ """The double_wide project's own flake.nix dir, pinning LibreLane 3.
233
+
234
+ double_wide ships its own flake (not cf-cli's `chipfoundry/openlane-2`
235
+ flake used by every other project type) because the flat wrapper needs
236
+ LibreLane 3's native PDN-to-pad routing. Returns None if the project
237
+ doesn't have one (e.g. it predates this, or a user deleted it).
238
+ """
239
+ if (project_root / "flake.nix").is_file():
240
+ return project_root
241
+ return None
242
+
243
+
244
+ def double_wide_nix_available(flake_dir: Path, timeout: float = 20) -> bool:
245
+ """Check that `nix develop` can resolve the project's pinned LibreLane 3 flake."""
246
+ if shutil.which("nix") is None:
247
+ return False
248
+ try:
249
+ result = subprocess.run(
250
+ ["nix", "flake", "metadata", str(flake_dir), "--json"],
251
+ capture_output=True,
252
+ timeout=timeout,
253
+ )
254
+ return result.returncode == 0
255
+ except (OSError, subprocess.TimeoutExpired):
256
+ return False
257
+
258
+
259
+ def build_double_wide_librelane_command(
260
+ *,
261
+ project_root: Path,
262
+ flake_dir: Path,
263
+ config_file: Path,
264
+ pdk_root: Path,
265
+ pdk: str,
266
+ tag: str,
267
+ from_step: Optional[str] = None,
268
+ open_in_openroad: bool = False,
269
+ open_in_klayout: bool = False,
270
+ ) -> Tuple[List[str], Dict[str, str]]:
271
+ """Build the `nix develop` command that runs LibreLane 3 on a flat double_wide wrapper.
272
+
273
+ No `--manual-pdk`/`--pdk-root`/`--pdk` flags: LibreLane 3's default
274
+ (Ciel-managed) PDK resolution auto-fetches the pinned PDK commit into
275
+ `PDK_ROOT` on its own, the same project-local `dependencies/pdks` root
276
+ every other project type's PDK already lives under (Ciel keys PDK
277
+ versions by commit, so the two coexist without conflict).
278
+ """
279
+ env = os.environ.copy()
280
+ env.update(
281
+ {
282
+ "PROJECT_ROOT": str(project_root),
283
+ "PDK_ROOT": str(pdk_root),
284
+ "PDK": pdk,
285
+ "LIBRELANE_RUN_TAG": tag,
286
+ }
287
+ )
288
+
289
+ librelane_args = []
290
+ if open_in_openroad:
291
+ librelane_args.extend(["--flow", "OpenInOpenROAD"])
292
+ elif open_in_klayout:
293
+ librelane_args.extend(["--flow", "OpenInKLayout"])
294
+ librelane_args.extend(["--ef-save-views-to", str(project_root), "--run-tag", tag])
295
+ if not from_step and not (open_in_openroad or open_in_klayout):
296
+ librelane_args.append("--overwrite")
297
+ if from_step:
298
+ librelane_args.extend(["--from", from_step])
299
+ librelane_args.append(str(config_file))
300
+
301
+ cmd = ["nix", "develop", str(flake_dir), "--command", "librelane", *librelane_args]
302
+ return cmd, env
303
+
304
+
305
+ def get_double_wide_valid_from_steps(
306
+ *, flake_dir: Path, config_file: Path, working_dir: Path
307
+ ) -> Tuple[Optional[List[str]], Optional[str]]:
308
+ """Resolve valid `--from` step names for a double_wide config via LibreLane 3."""
309
+ script = (
310
+ "import json, sys\n"
311
+ "from librelane.config import Config\n"
312
+ "from librelane.flows import Flow, SequentialFlow\n"
313
+ "cfg = sys.argv[1]\n"
314
+ "target = Flow.factory.get('Classic')\n"
315
+ "meta = Config.get_meta(cfg)\n"
316
+ "if meta:\n"
317
+ " if isinstance(meta.flow, str):\n"
318
+ " found = Flow.factory.get(meta.flow)\n"
319
+ " if found is None:\n"
320
+ " raise RuntimeError(f\"Unknown flow '{meta.flow}' in config metadata\")\n"
321
+ " target = found\n"
322
+ " elif isinstance(meta.flow, list):\n"
323
+ " target = SequentialFlow.make(meta.flow)\n"
324
+ " if meta.substituting_steps is not None:\n"
325
+ " if meta.flow is None:\n"
326
+ " raise RuntimeError('substituting_steps is set but flow is not defined')\n"
327
+ " if not issubclass(target, SequentialFlow):\n"
328
+ " raise RuntimeError('substituting_steps requires a sequential flow')\n"
329
+ " target = target.Substitute(meta.substituting_steps)\n"
330
+ "steps = []\n"
331
+ "seen = set()\n"
332
+ "for step in getattr(target, 'Steps', []) or []:\n"
333
+ " step_id = getattr(step, 'id', None)\n"
334
+ " if not step_id or step_id in seen:\n"
335
+ " continue\n"
336
+ " seen.add(step_id)\n"
337
+ " steps.append(step_id)\n"
338
+ "print('__CF_STEPS__' + json.dumps({'steps': steps}))\n"
339
+ )
340
+ try:
341
+ result = subprocess.run(
342
+ [
343
+ "nix",
344
+ "develop",
345
+ str(flake_dir),
346
+ "--command",
347
+ "python3",
348
+ "-c",
349
+ script,
350
+ str(config_file),
351
+ ],
352
+ cwd=str(working_dir),
353
+ capture_output=True,
354
+ text=True,
355
+ timeout=120,
356
+ )
357
+ except (OSError, subprocess.TimeoutExpired) as exc:
358
+ return None, str(exc)
359
+ if result.returncode != 0:
360
+ return None, (result.stderr or result.stdout or "unknown error").strip()
361
+ marker = "__CF_STEPS__"
362
+ for line in result.stdout.splitlines():
363
+ if line.startswith(marker):
364
+ try:
365
+ return json.loads(line[len(marker):])["steps"], None
366
+ except Exception as exc:
367
+ return None, str(exc)
368
+ return None, "LibreLane did not report a step list"
369
+
370
+
231
371
  def find_synth_stat_json(flow_root: Path, macro: str, run_tag: str) -> Optional[Path]:
232
372
  """Locate Yosys stat.json written by a synth-only or full LibreLane run."""
233
373
  run_dir = flow_root / macro / "runs" / run_tag
@@ -18,7 +18,10 @@ from chipfoundry_cli.utils import (
18
18
  get_gpio_config_from_project_json, save_gpio_config_to_project_json,
19
19
  GPIO_MODES, GPIO_MODE_DESCRIPTIONS, GPIO_HEX_TO_MODE,
20
20
  detect_github_repo_url, get_head_commit_sha,
21
+ PROJECT_TYPES, HARNESS_REPOS, is_padframe_only, harness_ref,
22
+ read_project_type, DOUBLE_WIDE_LIBRELANE_REV,
21
23
  )
24
+ from chipfoundry_cli import librelane_run
22
25
  import os
23
26
  from pathlib import Path
24
27
  from rich.console import Console
@@ -33,6 +36,7 @@ import sys
33
36
  import shutil
34
37
  import signal
35
38
  import difflib
39
+ import shlex
36
40
 
37
41
  # Textual imports for GPIO grid UI
38
42
  from textual.app import App, ComposeResult
@@ -568,11 +572,16 @@ def init(project_root, shuttle, description):
568
572
 
569
573
  name = _prompt_with_default("Project name", current_name, default_name) or default_name
570
574
  project_type = _prompt_with_default(
571
- "Project type (digital/analog/openframe)", current_type, detected_type
575
+ f"Project type ({'/'.join(PROJECT_TYPES)})", current_type, detected_type
572
576
  )
573
577
  if not project_type:
574
578
  console.print("[red]Project type is required.[/red]")
575
579
  raise click.Abort()
580
+ if project_type not in PROJECT_TYPES:
581
+ console.print(
582
+ f"[red]Unknown project type '{project_type}'. Expected one of: {', '.join(PROJECT_TYPES)}.[/red]"
583
+ )
584
+ raise click.Abort()
576
585
 
577
586
  if description is not None:
578
587
  description_val: Optional[str] = description or None
@@ -780,10 +789,11 @@ def gpio_config(project_root, view):
780
789
  project_data = json.load(f)
781
790
  project_type = project_data.get('project', {}).get('type', 'digital')
782
791
 
783
- # For openframe, GPIO config is not needed
784
- if project_type == 'openframe':
785
- console.print("[red]GPIO configuration is not available for openframe projects.[/red]")
786
- console.print("[yellow]Openframe projects do not use user_defines.v.[/yellow]")
792
+ if is_padframe_only(project_type):
793
+ console.print(f"[red]GPIO configuration is not available for {project_type} projects.[/red]")
794
+ console.print(
795
+ f"[yellow]{project_type} projects do not use user_defines.v; the user wrapper drives the pad controls.[/yellow]"
796
+ )
787
797
  raise click.Abort()
788
798
 
789
799
  user_defines_path = project_root / 'verilog' / 'rtl' / 'user_defines.v'
@@ -1772,8 +1782,8 @@ def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, s
1772
1782
  """Return [(rel_path, abs_path, size, kind)] for files the platform
1773
1783
  accepts via --https.
1774
1784
 
1775
- Picks exactly one wrapper GDS (analog/digital/openframe) and, for
1776
- non-openframe projects, ``verilog/rtl/user_defines.v`` if it exists.
1785
+ Picks exactly one wrapper GDS (analog/digital/openframe/double_wide) and,
1786
+ for caravel-based projects, ``verilog/rtl/user_defines.v`` if it exists.
1777
1787
  Raises FileNotFoundError if no wrapper is present or
1778
1788
  ValueError if multiple are.
1779
1789
  """
@@ -1790,7 +1800,8 @@ def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, s
1790
1800
  raise FileNotFoundError(
1791
1801
  "No wrapper GDS found (expected one of gds/user_project_wrapper.gds[.gz], "
1792
1802
  "gds/user_analog_project_wrapper.gds[.gz], "
1793
- "gds/openframe_project_wrapper.gds[.gz])."
1803
+ "gds/openframe_project_wrapper.gds[.gz], "
1804
+ "gds/double_wide_openframe_project_wrapper.gds[.gz])."
1794
1805
  )
1795
1806
  if len(hits) > 1:
1796
1807
  paths = ", ".join(h[1] for h in hits)
@@ -1804,7 +1815,7 @@ def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, s
1804
1815
  (wrapper_rel, abs_wrapper, abs_wrapper.stat().st_size, "wrapper")
1805
1816
  ]
1806
1817
 
1807
- if kind != "openframe":
1818
+ if not is_padframe_only(kind):
1808
1819
  ud = project_root / USER_DEFINES_REL
1809
1820
  if ud.is_file():
1810
1821
  results.append((USER_DEFINES_REL, ud, ud.stat().st_size, "aux"))
@@ -2169,7 +2180,7 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
2169
2180
  upload_map = {
2170
2181
  ".cf/project.json": project_json_path,
2171
2182
  }
2172
- if detected_type != "openframe":
2183
+ if not is_padframe_only(detected_type):
2173
2184
  upload_map["verilog/rtl/user_defines.v"] = collected.get("verilog/rtl/user_defines.v")
2174
2185
 
2175
2186
  # Add the appropriate GDS file based on what was collected
@@ -3156,8 +3167,9 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3156
3167
  except (json.JSONDecodeError, IOError):
3157
3168
  pass # Use default if we can't read it
3158
3169
 
3159
- is_openframe = project_type == 'openframe'
3160
-
3170
+ padframe_only = is_padframe_only(project_type)
3171
+ harness_repo = HARNESS_REPOS.get(project_type)
3172
+
3161
3173
  had_errors = False
3162
3174
 
3163
3175
  def _error_text(err):
@@ -3186,8 +3198,8 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3186
3198
  # If in "only" mode, only install what's specified
3187
3199
  # If not in "only" mode, install everything
3188
3200
  install_caravel = only_caravel or not only_mode
3189
- # MCW is not used for openframe projects
3190
- install_mcw = (only_mcw or not only_mode) and not is_openframe
3201
+ # MCW is not used by bare-padframe projects
3202
+ install_mcw = (only_mcw or not only_mode) and not padframe_only
3191
3203
  install_openlane = only_openlane or not only_mode
3192
3204
  install_pdk = only_pdk or not only_mode
3193
3205
  install_timing = only_timing or not only_mode
@@ -3201,11 +3213,14 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3201
3213
  f"Repository: [yellow]{repo_owner}/{repo_name}@{branch}[/yellow]",
3202
3214
  f"PDK: [yellow]{pdk}[/yellow]",
3203
3215
  f"Project type: [yellow]{project_type}[/yellow]",
3204
- f"Caravel variant: [yellow]{'caravel-lite' if caravel_lite else 'caravel'}[/yellow]",
3205
3216
  ]
3206
-
3207
- if is_openframe:
3208
- config_lines.append("[dim]MCW not needed for openframe projects[/dim]")
3217
+ if harness_repo:
3218
+ config_lines.append(f"Harness: [yellow]{harness_repo}[/yellow]")
3219
+ else:
3220
+ config_lines.append(f"Caravel variant: [yellow]{'caravel-lite' if caravel_lite else 'caravel'}[/yellow]")
3221
+
3222
+ if padframe_only:
3223
+ config_lines.append(f"[dim]MCW not needed for {project_type} projects[/dim]")
3209
3224
 
3210
3225
  if only_mode:
3211
3226
  installing = []
@@ -3254,21 +3269,26 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3254
3269
  deps_dir.mkdir(exist_ok=True)
3255
3270
  console.print(f"[green]✓[/green] Dependencies directory ready at {deps_dir}")
3256
3271
 
3257
- # Step 2: Install Caravel/Caravel-Lite
3272
+ # Step 2: Install Caravel/Caravel-Lite, or the project type's own harness
3258
3273
  if install_caravel:
3259
- console.print("\n[bold]Step 2:[/bold] Installing Caravel...")
3260
3274
  caravel_dir = project_root_path / 'caravel'
3261
- caravel_name = 'caravel-lite' if caravel_lite else 'caravel'
3262
-
3263
- # Determine MPW tag based on PDK
3264
- if pdk not in mpw_tags:
3265
- console.print(f"[red]✗[/red] PDK '{pdk}' not found in version configuration")
3266
- console.print(f"[yellow]Available PDKs: {', '.join(mpw_tags.keys())}[/yellow]")
3267
- raise click.Abort()
3268
- mpw_tag = mpw_tags[pdk]
3269
-
3270
- # Caravel repository URL
3271
- caravel_repo = f'https://github.com/chipfoundry/{caravel_name}'
3275
+ if harness_repo:
3276
+ console.print("\n[bold]Step 2:[/bold] Installing harness...")
3277
+ caravel_name = harness_repo.rstrip('/').rsplit('/', 1)[-1]
3278
+ caravel_repo = harness_repo
3279
+ mpw_tag = harness_ref(project_type, versions)
3280
+ else:
3281
+ console.print("\n[bold]Step 2:[/bold] Installing Caravel...")
3282
+ caravel_name = 'caravel-lite' if caravel_lite else 'caravel'
3283
+
3284
+ # Determine MPW tag based on PDK
3285
+ if pdk not in mpw_tags:
3286
+ console.print(f"[red]✗[/red] PDK '{pdk}' not found in version configuration")
3287
+ console.print(f"[yellow]Available PDKs: {', '.join(mpw_tags.keys())}[/yellow]")
3288
+ raise click.Abort()
3289
+ mpw_tag = mpw_tags[pdk]
3290
+
3291
+ caravel_repo = f'https://github.com/chipfoundry/{caravel_name}'
3272
3292
 
3273
3293
  # Check if already installed with correct version
3274
3294
  is_correct_version, current_version = check_version_installed(caravel_dir, mpw_tag)
@@ -3305,10 +3325,9 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3305
3325
  console.print(f"[dim]{e.stderr}[/dim]")
3306
3326
 
3307
3327
  # Step 3: Install Management Core Wrapper
3308
- # Show message if user explicitly requested MCW but project is openframe
3309
- if only_mcw and is_openframe:
3328
+ if only_mcw and padframe_only:
3310
3329
  console.print("\n[bold]Step 3:[/bold] Installing Management Core Wrapper...")
3311
- console.print("[yellow]⚠[/yellow] MCW is not used for openframe projects, skipping...")
3330
+ console.print(f"[yellow]⚠[/yellow] MCW is not used for {project_type} projects, skipping...")
3312
3331
  elif install_mcw:
3313
3332
  console.print("\n[bold]Step 3:[/bold] Installing Management Core Wrapper...")
3314
3333
  mcw_dir = project_root_path / 'mgmt_core_wrapper'
@@ -3641,13 +3660,17 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3641
3660
  capture_output=True
3642
3661
  )
3643
3662
  # Install from GitHub source (workaround: PyPI package is under efabless, we use chipfoundry repo)
3663
+ env = os.environ.copy()
3664
+ if sys.platform == 'darwin':
3665
+ env['LDSHARED'] = 'clang++ -dynamiclib -undefined dynamic_lookup'
3644
3666
  subprocess.run(
3645
3667
  [
3646
3668
  venv_python, '-m', 'pip', 'install', '--upgrade', '--no-cache-dir',
3647
3669
  'git+https://github.com/chipfoundry/caravel-sim-infrastructure.git@main#subdirectory=cocotb'
3648
3670
  ],
3649
3671
  check=True,
3650
- capture_output=True
3672
+ capture_output=True,
3673
+ env=env
3651
3674
  )
3652
3675
  console.print("[green]✓[/green] Cocotb environment set up successfully")
3653
3676
 
@@ -3738,6 +3761,8 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3738
3761
  console.print("[bold green]Installation complete![/bold green]")
3739
3762
  else:
3740
3763
  console.print("[bold green]Setup complete![/bold green]")
3764
+ if had_errors:
3765
+ sys.exit(1)
3741
3766
 
3742
3767
 
3743
3768
  def _is_nix_toolchain_noise_line(line: str) -> bool:
@@ -3945,6 +3970,181 @@ def _queue_and_maybe_poll_remote_job(
3945
3970
  client.close()
3946
3971
 
3947
3972
 
3973
+ def _harden_double_wide(
3974
+ *,
3975
+ macro: str,
3976
+ macro_dir: Path,
3977
+ config_file: Path,
3978
+ project_root_path: Path,
3979
+ tag: Optional[str],
3980
+ from_step: Optional[str],
3981
+ open_in_openroad: bool,
3982
+ open_in_klayout: bool,
3983
+ pdk: Optional[str],
3984
+ use_docker: bool,
3985
+ dry_run: bool,
3986
+ list_from_steps: bool,
3987
+ ):
3988
+ """Harden a `double_wide` macro with LibreLane 3 (native PDN-to-pad routing).
3989
+
3990
+ double_wide bypasses the OpenLane-2/Docker-venv path used by every other
3991
+ project type: its upstream template ships its own `flake.nix` pinning
3992
+ LibreLane 3, whose native PDN generator (`PDN_ENABLE_PINS`) reaches the
3993
+ padframe-aligned power pins directly, so no bridging "connection" macro
3994
+ or cf-cli-managed OpenLane install is needed.
3995
+ """
3996
+ gui_mode_count = int(open_in_openroad) + int(open_in_klayout)
3997
+
3998
+ if use_docker:
3999
+ console.print("[red]✗[/red] double_wide hardening does not support --use-docker")
4000
+ console.print(
4001
+ "[yellow]double_wide uses the project's own Nix flake (LibreLane 3). "
4002
+ "Install Nix: https://librelane.readthedocs.io[/yellow]"
4003
+ )
4004
+ raise click.Abort()
4005
+
4006
+ flake_dir = librelane_run.double_wide_flake_dir(project_root_path)
4007
+ if flake_dir is None:
4008
+ console.print(f"[red]✗[/red] No flake.nix found in {project_root_path}")
4009
+ console.print("[yellow]double_wide projects must ship a flake.nix pinning LibreLane 3[/yellow]")
4010
+ raise click.Abort()
4011
+
4012
+ if not librelane_run.double_wide_nix_available(flake_dir):
4013
+ console.print("[red]✗[/red] Nix not available or cannot resolve the project's LibreLane 3 flake")
4014
+ console.print("[yellow]Install Nix from: https://librelane.readthedocs.io[/yellow]")
4015
+ raise click.Abort()
4016
+
4017
+ pdk_root = librelane_run.pdk_root_dir(project_root_path)
4018
+ pdk = librelane_run.detect_pdk(project_root_path, pdk)
4019
+
4020
+ valid_from_steps, valid_from_steps_error = librelane_run.get_double_wide_valid_from_steps(
4021
+ flake_dir=flake_dir, config_file=config_file, working_dir=project_root_path,
4022
+ )
4023
+ if valid_from_steps_error:
4024
+ console.print("[red]✗[/red] Failed to load LibreLane 3 step list for this macro")
4025
+ console.print(f"[yellow]Error:[/yellow] {valid_from_steps_error}")
4026
+ sys.exit(1)
4027
+
4028
+ if list_from_steps:
4029
+ console.print(f"[bold cyan]Valid --from steps for {macro}:[/bold cyan]")
4030
+ if valid_from_steps:
4031
+ for step_name in valid_from_steps:
4032
+ console.print(f" • {step_name}")
4033
+ else:
4034
+ console.print("[yellow]No steps found for this flow[/yellow]")
4035
+ return
4036
+
4037
+ if from_step and from_step not in (valid_from_steps or []):
4038
+ console.print(f"[red]✗[/red] Invalid --from step: {from_step}")
4039
+ matches = difflib.get_close_matches(from_step, valid_from_steps or [], n=5, cutoff=0.4)
4040
+ if matches:
4041
+ console.print("[yellow]Did you mean:[/yellow]")
4042
+ for m in matches:
4043
+ console.print(f" • {m}")
4044
+ else:
4045
+ console.print(f"[yellow]Use 'cf harden {macro} --list-from-steps' to see valid step names[/yellow]")
4046
+ sys.exit(1)
4047
+
4048
+ auto_selected_latest_tag = False
4049
+ if (from_step or gui_mode_count) and not tag:
4050
+ runs_dir = macro_dir / 'runs'
4051
+ candidate_runs = [p for p in runs_dir.iterdir() if p.is_dir()] if runs_dir.exists() else []
4052
+ if not candidate_runs:
4053
+ console.print("[red]✗[/red] No existing runs found for this macro")
4054
+ if gui_mode_count:
4055
+ console.print("[yellow]Create a hardening run first, or specify --tag <existing_tag>[/yellow]")
4056
+ else:
4057
+ console.print("[yellow]Run without --from first, or specify --tag <existing_tag>[/yellow]")
4058
+ sys.exit(1)
4059
+ latest_run = max(candidate_runs, key=lambda p: p.stat().st_mtime)
4060
+ tag = latest_run.name
4061
+ auto_selected_latest_tag = True
4062
+ elif not tag:
4063
+ tag = librelane_run.new_run_tag()
4064
+
4065
+ console.print("\n" + "="*60)
4066
+ console.print(f"[bold cyan]Hardening: {macro}[/bold cyan] [dim](LibreLane 3, native PDN-to-pad routing)[/dim]")
4067
+ console.print(f"Config: [yellow]{config_file.name}[/yellow]")
4068
+ console.print(f"Run tag: [yellow]{tag}[/yellow]")
4069
+ if auto_selected_latest_tag:
4070
+ if gui_mode_count:
4071
+ console.print("[yellow]Using latest existing run tag (auto-selected because GUI mode was requested without --tag)[/yellow]")
4072
+ else:
4073
+ console.print("[yellow]Using latest existing run tag (auto-selected because --from was provided without --tag)[/yellow]")
4074
+ if from_step:
4075
+ console.print(f"Start from: [yellow]{from_step}[/yellow]")
4076
+ console.print("[yellow]Mode:[/yellow] resume from existing state under this tag (no overwrite)")
4077
+ if open_in_openroad:
4078
+ console.print("[yellow]Mode:[/yellow] open existing run in OpenROAD GUI")
4079
+ elif open_in_klayout:
4080
+ console.print("[yellow]Mode:[/yellow] open existing run in KLayout GUI")
4081
+ console.print(f"PDK: [yellow]{pdk}[/yellow]")
4082
+ console.print(f"PDK Root: [yellow]{pdk_root}[/yellow]")
4083
+ console.print("Execution: [yellow]Nix (project flake, LibreLane 3)[/yellow]")
4084
+ console.print("="*60 + "\n")
4085
+
4086
+ cmd, env = librelane_run.build_double_wide_librelane_command(
4087
+ project_root=project_root_path,
4088
+ flake_dir=flake_dir,
4089
+ config_file=config_file,
4090
+ pdk_root=pdk_root,
4091
+ pdk=pdk,
4092
+ tag=tag,
4093
+ from_step=from_step,
4094
+ open_in_openroad=open_in_openroad,
4095
+ open_in_klayout=open_in_klayout,
4096
+ )
4097
+
4098
+ if dry_run:
4099
+ console.print("[yellow]Dry run - command that would be executed:[/yellow]")
4100
+ console.print(" ".join(cmd))
4101
+ return
4102
+
4103
+ console.print(f"[cyan]Running LibreLane 3 via Nix on {macro}...[/cyan]")
4104
+
4105
+ process = None
4106
+ try:
4107
+ process = subprocess.Popen(
4108
+ cmd,
4109
+ cwd=str(project_root_path),
4110
+ env=env,
4111
+ preexec_fn=os.setsid if os.name != 'nt' else None,
4112
+ )
4113
+ returncode = process.wait()
4114
+
4115
+ if returncode == 0:
4116
+ console.print(f"\n[green]✓[/green] [bold green]Successfully hardened {macro}![/bold green]")
4117
+ console.print(f"[dim]Run directory: {macro_dir}/runs/{tag}/[/dim]")
4118
+ elif returncode == -2 or returncode == 130: # SIGINT
4119
+ console.print("\n[yellow]⚠[/yellow] Hardening interrupted by user")
4120
+ sys.exit(130)
4121
+ else:
4122
+ console.print(f"\n[red]✗[/red] [bold red]Hardening failed with exit code {returncode}[/bold red]")
4123
+ console.print(f"[yellow]Check logs in: {macro_dir}/runs/{tag}/[/yellow]")
4124
+ sys.exit(returncode)
4125
+
4126
+ except KeyboardInterrupt:
4127
+ console.print("\n[yellow]⚠[/yellow] Hardening interrupted by user")
4128
+ try:
4129
+ if process is not None:
4130
+ if os.name != 'nt':
4131
+ os.killpg(os.getpgid(process.pid), signal.SIGTERM)
4132
+ process.wait(timeout=5)
4133
+ else:
4134
+ process.terminate()
4135
+ process.wait(timeout=5)
4136
+ except Exception:
4137
+ if process is not None:
4138
+ if os.name != 'nt':
4139
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
4140
+ else:
4141
+ process.kill()
4142
+ sys.exit(130)
4143
+ except Exception as e:
4144
+ console.print(f"\n[red]✗[/red] Error: {e}")
4145
+ sys.exit(1)
4146
+
4147
+
3948
4148
  @main.command(
3949
4149
  'harden',
3950
4150
  cls=CategorizedCommand,
@@ -4002,6 +4202,11 @@ def harden(
4002
4202
  ):
4003
4203
  """Harden a macro using LibreLane (OpenLane 2).
4004
4204
 
4205
+ For `double_wide` projects, hardens the single flat wrapper design with
4206
+ LibreLane 3 instead (using the project's own pinned flake.nix), since its
4207
+ native PDN generator routes directly to the padframe-aligned power pins.
4208
+ Every other project type is unaffected and keeps using OpenLane 2.
4209
+
4005
4210
  Examples:
4006
4211
  cf harden user_proj_example
4007
4212
  cf harden --list
@@ -4059,7 +4264,7 @@ def harden(
4059
4264
  wait_timeout=wait_timeout,
4060
4265
  label="Remote PNR",
4061
4266
  )
4062
- return
4267
+ sys.exit(1)
4063
4268
 
4064
4269
  # Check if project is initialized (skip check for --list or when no macro specified, allow graceful return)
4065
4270
  if not list_designs and macro:
@@ -4074,24 +4279,26 @@ def harden(
4074
4279
  if not openlane_dir.exists():
4075
4280
  console.print(f"[red]✗[/red] OpenLane directory not found: {openlane_dir}")
4076
4281
  console.print("[yellow]Run 'cf setup' first to install OpenLane[/yellow]")
4077
- return
4282
+ if list_designs:
4283
+ return
4284
+ sys.exit(1)
4078
4285
 
4079
4286
  if list_from_steps and not macro:
4080
4287
  console.print("[red]✗[/red] --list-from-steps requires a macro name")
4081
4288
  console.print("[yellow]Example:[/yellow] cf harden user_proj_example --list-from-steps")
4082
- return
4289
+ sys.exit(1)
4083
4290
 
4084
4291
  gui_mode_count = int(open_in_openroad) + int(open_in_klayout)
4085
4292
  if gui_mode_count > 1:
4086
4293
  console.print("[red]✗[/red] Use only one GUI flag: --open-in-openroad or --open-in-klayout")
4087
- return
4294
+ sys.exit(1)
4088
4295
  if gui_mode_count and from_step:
4089
4296
  console.print("[red]✗[/red] --from cannot be combined with GUI modes")
4090
4297
  console.print("[yellow]Use --tag to select which run to open, or omit --tag to use latest run[/yellow]")
4091
- return
4298
+ sys.exit(1)
4092
4299
  if gui_mode_count and list_from_steps:
4093
4300
  console.print("[red]✗[/red] --list-from-steps cannot be combined with GUI modes")
4094
- return
4301
+ sys.exit(1)
4095
4302
 
4096
4303
  # If no macro specified, show prompt with available macros
4097
4304
  no_macro_specified = not macro and not list_designs and not list_from_steps
@@ -4123,7 +4330,7 @@ def harden(
4123
4330
  if not macro_dir.exists():
4124
4331
  console.print(f"[red]✗[/red] Macro not found: {macro}")
4125
4332
  console.print(f"[yellow]Run 'cf harden --list' to see available macros[/yellow]")
4126
- return
4333
+ sys.exit(1)
4127
4334
 
4128
4335
  # Find config file
4129
4336
  config_file = None
@@ -4136,8 +4343,29 @@ def harden(
4136
4343
  if not config_file:
4137
4344
  console.print(f"[red]✗[/red] No config file found for {macro}")
4138
4345
  console.print(f"[yellow]Expected one of: config.json, config.yaml, config.tcl[/yellow]")
4346
+ sys.exit(1)
4347
+
4348
+ # `double_wide` is hardened as one flat design with LibreLane 3's native
4349
+ # PDN-to-pad routing, using the project's own pinned flake.nix instead of
4350
+ # cf-cli's OpenLane-2 Nix/Docker install. Every other project type falls
4351
+ # through to the existing OpenLane 2 (CI2511) flow below, unaffected.
4352
+ if read_project_type(project_root_path) == "double_wide":
4353
+ _harden_double_wide(
4354
+ macro=macro,
4355
+ macro_dir=macro_dir,
4356
+ config_file=Path(config_file),
4357
+ project_root_path=project_root_path,
4358
+ tag=tag,
4359
+ from_step=from_step,
4360
+ open_in_openroad=open_in_openroad,
4361
+ open_in_klayout=open_in_klayout,
4362
+ pdk=pdk,
4363
+ use_docker=use_docker,
4364
+ dry_run=dry_run,
4365
+ list_from_steps=list_from_steps,
4366
+ )
4139
4367
  return
4140
-
4368
+
4141
4369
  # Check for LibreLane venv
4142
4370
  librelane_venv = openlane_dir / '.venv'
4143
4371
  if not librelane_venv.exists():
@@ -4178,7 +4406,7 @@ def harden(
4178
4406
  " continue\n"
4179
4407
  " seen.add(step_id)\n"
4180
4408
  " steps.append(step_id)\n"
4181
- "print(json.dumps({'steps': steps}))\n"
4409
+ f"print({_STEPS_MARKER!r} + json.dumps({{'steps': steps}}))\n"
4182
4410
  )
4183
4411
  result = subprocess.run(
4184
4412
  [str(librelane_python), '-c', script, macro_config],
@@ -4190,8 +4418,7 @@ def harden(
4190
4418
  err = (result.stderr or result.stdout or 'unknown error').strip()
4191
4419
  return None, err
4192
4420
  try:
4193
- payload = json.loads(result.stdout.strip())
4194
- return payload.get('steps', []), None
4421
+ return _parse_librelane_steps(result.stdout), None
4195
4422
  except Exception as exc:
4196
4423
  return None, str(exc)
4197
4424
 
@@ -4206,7 +4433,7 @@ def harden(
4206
4433
  console.print("[red]✗[/red] Failed to load LibreLane step list for this macro")
4207
4434
  console.print(f"[yellow]Error:[/yellow] {valid_from_steps_error}")
4208
4435
  console.print("[yellow]Run 'cf setup --only-openlane' to ensure LibreLane is installed correctly[/yellow]")
4209
- return
4436
+ sys.exit(1)
4210
4437
 
4211
4438
  if list_from_steps:
4212
4439
  console.print(f"[bold cyan]Valid --from steps for {macro}:[/bold cyan]")
@@ -4226,7 +4453,7 @@ def harden(
4226
4453
  console.print(f" • {m}")
4227
4454
  else:
4228
4455
  console.print(f"[yellow]Use 'cf harden {macro} --list-from-steps' to see valid step names[/yellow]")
4229
- return
4456
+ sys.exit(1)
4230
4457
 
4231
4458
  # Fetch versions from upstream
4232
4459
  console.print("[dim]Fetching version information from cf-cli repository...[/dim]")
@@ -4250,7 +4477,7 @@ def harden(
4250
4477
  # Check for conflicting flags
4251
4478
  if force_nix_flag and force_docker_flag:
4252
4479
  console.print("[red]✗[/red] Cannot use both --use-nix and --use-docker")
4253
- return
4480
+ sys.exit(1)
4254
4481
 
4255
4482
  # Check if Nix is available
4256
4483
  if force_nix_flag or not force_docker_flag:
@@ -4321,7 +4548,7 @@ def harden(
4321
4548
  if not pdk_dir.exists():
4322
4549
  console.print(f"[red]✗[/red] PDK not found: {pdk_dir}")
4323
4550
  console.print("[yellow]Run 'cf setup --only-pdk' to install the PDK[/yellow]")
4324
- return
4551
+ sys.exit(1)
4325
4552
 
4326
4553
  auto_selected_latest_tag = False
4327
4554
  if (from_step or gui_mode_count) and not tag:
@@ -4332,7 +4559,7 @@ def harden(
4332
4559
  console.print("[yellow]Create a hardening run first, or specify --tag <existing_tag>[/yellow]")
4333
4560
  else:
4334
4561
  console.print("[yellow]Run without --from first, or specify --tag <existing_tag>[/yellow]")
4335
- return
4562
+ sys.exit(1)
4336
4563
  candidate_runs = [p for p in runs_dir.iterdir() if p.is_dir()]
4337
4564
  if not candidate_runs:
4338
4565
  console.print("[red]✗[/red] No existing runs found for this macro")
@@ -4340,7 +4567,7 @@ def harden(
4340
4567
  console.print("[yellow]Create a hardening run first, or specify --tag <existing_tag>[/yellow]")
4341
4568
  else:
4342
4569
  console.print("[yellow]Run without --from first, or specify --tag <existing_tag>[/yellow]")
4343
- return
4570
+ sys.exit(1)
4344
4571
  latest_run = max(candidate_runs, key=lambda p: p.stat().st_mtime)
4345
4572
  tag = latest_run.name
4346
4573
  auto_selected_latest_tag = True
@@ -4471,13 +4698,13 @@ def harden(
4471
4698
 
4472
4699
  if returncode == 0:
4473
4700
  console.print(f"\n[green]✓[/green] [bold green]Successfully hardened {macro}![/bold green]")
4474
- console.print(f"[dim]Results saved to: {project_root_path}/runs/{macro}/{tag}/[/dim]")
4701
+ console.print(f"[dim]Run directory: {macro_dir}/runs/{tag}/[/dim]")
4475
4702
  elif returncode == -2 or returncode == 130: # SIGINT
4476
4703
  console.print("\n[yellow]⚠[/yellow] Hardening interrupted by user")
4477
4704
  sys.exit(130)
4478
4705
  else:
4479
4706
  console.print(f"\n[red]✗[/red] [bold red]Hardening failed with exit code {returncode}[/bold red]")
4480
- console.print(f"[yellow]Check logs in: {project_root_path}/runs/{macro}/{tag}/[/yellow]")
4707
+ console.print(f"[yellow]Check logs in: {macro_dir}/runs/{tag}/[/yellow]")
4481
4708
  sys.exit(returncode)
4482
4709
 
4483
4710
  except KeyboardInterrupt:
@@ -4495,8 +4722,10 @@ def harden(
4495
4722
  os.killpg(os.getpgid(process.pid), signal.SIGKILL)
4496
4723
  else:
4497
4724
  process.kill()
4725
+ sys.exit(130)
4498
4726
  except Exception as e:
4499
4727
  console.print(f"\n[red]✗[/red] Error: {e}")
4728
+ sys.exit(1)
4500
4729
 
4501
4730
  @main.group('repo')
4502
4731
  def repo_group():
@@ -4860,7 +5089,7 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run,
4860
5089
  project_data = json.load(f)
4861
5090
  project_type = project_data.get('project', {}).get('type', 'digital')
4862
5091
 
4863
- if project_type != 'openframe':
5092
+ if not is_padframe_only(project_type):
4864
5093
  gpio_config = get_gpio_config_from_project_json(str(project_json_path))
4865
5094
  if not gpio_config or len(gpio_config) == 0:
4866
5095
  console.print("[red]✗[/red] GPIO configuration not found in project.json")
@@ -4882,20 +5111,31 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run,
4882
5111
  if not (pdk_root / pdk).exists():
4883
5112
  console.print(f"[red]✗[/red] PDK not found at {pdk_root / pdk}")
4884
5113
  console.print("[yellow]Run 'cf setup --only-pdk' to install[/yellow]")
4885
- return
5114
+ sys.exit(1)
4886
5115
 
4887
5116
  if shutil.which('docker') is None:
4888
5117
  console.print("[red]✗[/red] Docker not found. Docker is required to run precheck.")
4889
- return
5118
+ sys.exit(1)
4890
5119
 
4891
5120
  pdk_path = pdk_root / pdk
4892
-
5121
+
5122
+ # The precheck image ships caravel at /opt/caravel. Other harnesses are
5123
+ # installed by `cf setup` into <project>/caravel, which is mounted below.
5124
+ golden_root = '/opt/caravel'
5125
+ if project_type in HARNESS_REPOS:
5126
+ harness_dir = project_root_path / 'caravel'
5127
+ if not harness_dir.is_dir():
5128
+ console.print(f"[red]✗[/red] {project_type} harness not found at {harness_dir}")
5129
+ console.print("[yellow]Run 'cf setup --only-caravel' to install it[/yellow]")
5130
+ sys.exit(1)
5131
+ golden_root = str(harness_dir.resolve())
5132
+
4893
5133
  docker_image = 'chipfoundry/mpw_precheck:latest'
4894
5134
 
4895
5135
  precheck_args = [
4896
5136
  '-i', str(project_root_path),
4897
5137
  '-p', str(pdk_path),
4898
- '-c', '/opt/caravel',
5138
+ '-c', golden_root,
4899
5139
  ]
4900
5140
 
4901
5141
  if magic_drc:
@@ -4909,13 +5149,33 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run,
4909
5149
  if skip_checks:
4910
5150
  precheck_args.extend(['--skip-checks'] + list(skip_checks))
4911
5151
 
4912
- inner_cmd = 'pip3 install --upgrade -q --root-user-action=ignore cf-precheck 2>/dev/null && exec cf-precheck ' + ' '.join(precheck_args)
5152
+ # CF_PRECHECK_SOURCE points at a local cf-precheck checkout to test
5153
+ # unreleased changes; otherwise the latest release is installed from PyPI.
5154
+ precheck_source = os.environ.get('CF_PRECHECK_SOURCE')
5155
+ if precheck_source:
5156
+ precheck_source = str(Path(precheck_source).expanduser().resolve())
5157
+ if not (Path(precheck_source) / 'pyproject.toml').is_file():
5158
+ console.print(f"[red]✗[/red] CF_PRECHECK_SOURCE is not a cf-precheck checkout: {precheck_source}")
5159
+ sys.exit(1)
5160
+ install_cmd = (
5161
+ f'cp -r {shlex.quote(precheck_source)} /tmp/cf-precheck-src && '
5162
+ 'pip3 install -q --root-user-action=ignore /tmp/cf-precheck-src'
5163
+ )
5164
+ else:
5165
+ install_cmd = 'pip3 install --upgrade -q --root-user-action=ignore cf-precheck 2>/dev/null'
5166
+ inner_cmd = install_cmd + ' && exec cf-precheck ' + ' '.join(precheck_args)
4913
5167
 
4914
5168
  docker_cmd = [
4915
5169
  'docker', 'run', '--rm', '--init',
4916
5170
  '--platform', 'linux/amd64',
4917
5171
  '-v', f'{project_root_path}:{project_root_path}',
4918
5172
  '-v', f'{pdk_root}:{pdk_root}',
5173
+ ]
5174
+ if golden_root != '/opt/caravel' and not golden_root.startswith(f'{project_root_path}/'):
5175
+ docker_cmd += ['-v', f'{golden_root}:{golden_root}']
5176
+ if precheck_source:
5177
+ docker_cmd += ['-v', f'{precheck_source}:{precheck_source}:ro']
5178
+ docker_cmd += [
4919
5179
  '-e', f'PDK_ROOT={pdk_root}',
4920
5180
  '-e', f'PDK_PATH={pdk_path}',
4921
5181
  '-e', f'PDKPATH={pdk_path}',
@@ -4928,6 +5188,8 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run,
4928
5188
  console.print("[bold cyan]CF Precheck[/bold cyan]")
4929
5189
  console.print(f"Project: [yellow]{project_root_path}[/yellow]")
4930
5190
  console.print(f"PDK: [yellow]{pdk}[/yellow]")
5191
+ if precheck_source:
5192
+ console.print(f"cf-precheck: [yellow]{precheck_source}[/yellow] (CF_PRECHECK_SOURCE)")
4931
5193
  if skip_checks:
4932
5194
  console.print(f"Skipping: [yellow]{', '.join(skip_checks)}[/yellow]")
4933
5195
  if magic_drc:
@@ -4957,7 +5219,7 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run,
4957
5219
  )
4958
5220
  if result.returncode != 0:
4959
5221
  console.print(f"[red]✗[/red] Docker image '{docker_image}' not found. Run 'cf setup --only-precheck' or check your connection.")
4960
- return
5222
+ sys.exit(1)
4961
5223
  console.print("[yellow]⚠[/yellow] Could not check for image updates (using cached image)")
4962
5224
 
4963
5225
  console.print("[cyan]Running cf-precheck...[/cyan]\n")
@@ -4990,6 +5252,35 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run,
4990
5252
  sys.exit(130)
4991
5253
  except Exception as e:
4992
5254
  console.print(f"\n[red]✗[/red] Error running precheck: {e}")
5255
+ sys.exit(1)
5256
+
5257
+ _STEPS_MARKER = 'CF_STEPS_JSON='
5258
+
5259
+
5260
+ def _parse_librelane_steps(stdout: str):
5261
+ """Extract the step list printed by the `cf harden` step-list probe."""
5262
+ # LibreLane's console may emit terminal control sequences around the
5263
+ # payload (e.g. a show-cursor escape at exit), so locate it by marker.
5264
+ for line in stdout.splitlines():
5265
+ idx = line.find(_STEPS_MARKER)
5266
+ if idx != -1:
5267
+ payload, _ = json.JSONDecoder().raw_decode(line[idx + len(_STEPS_MARKER):])
5268
+ return payload.get('steps', [])
5269
+ raise ValueError('step list not found in LibreLane output')
5270
+
5271
+
5272
+ def _cocotb_run_results(run_dir: Path):
5273
+ """Return (passed, failed) test directory names for a caravel_cocotb run."""
5274
+ passed, failed = [], []
5275
+ if not run_dir.is_dir():
5276
+ return passed, failed
5277
+ for test_dir in sorted(p for p in run_dir.iterdir() if p.is_dir()):
5278
+ if (test_dir / 'failed').exists():
5279
+ failed.append(test_dir.name)
5280
+ elif (test_dir / 'passed').exists():
5281
+ passed.append(test_dir.name)
5282
+ return passed, failed
5283
+
4993
5284
 
4994
5285
  @main.command('verify')
4995
5286
  @click.argument('test', required=False)
@@ -5090,14 +5381,16 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p
5090
5381
 
5091
5382
  project_json_path = project_root_path / '.cf' / 'project.json'
5092
5383
 
5093
- # Get project type (needed for openframe flag)
5094
5384
  with open(project_json_path, 'r') as f:
5095
5385
  project_data = json.load(f)
5096
5386
  project_type = project_data.get('project', {}).get('type', 'digital')
5097
-
5098
- # Check if GPIO configuration exists (skip check if just listing tests or openframe)
5387
+ # Bare-padframe projects run caravel_cocotb in OpenFrame mode against the
5388
+ # harness in <project>/caravel (its toplevel_cocotb.v sets the pad count).
5389
+ padframe_only = is_padframe_only(project_type)
5390
+
5391
+ # Check if GPIO configuration exists (skip check if just listing tests or padframe-only)
5099
5392
  if not list_tests:
5100
- if project_type != 'openframe':
5393
+ if not padframe_only:
5101
5394
  gpio_config = get_gpio_config_from_project_json(str(project_json_path))
5102
5395
  if not gpio_config or len(gpio_config) == 0:
5103
5396
  console.print("[red]✗[/red] GPIO configuration not found in project.json")
@@ -5197,7 +5490,7 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p
5197
5490
 
5198
5491
  if dry_run:
5199
5492
  console.print("[bold yellow]Dry run - configuration ready[/bold yellow]\n")
5200
- openframe_flag = " --openframe" if project_type == 'openframe' else ""
5493
+ openframe_flag = " --openframe" if padframe_only else ""
5201
5494
  if test:
5202
5495
  console.print(f"Would run: {caravel_cocotb_bin} -t {test} -sim {sim_arg}{openframe_flag}")
5203
5496
  elif run_all:
@@ -5257,12 +5550,15 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p
5257
5550
  if sim.lower() == 'gl':
5258
5551
  cmd.extend(['-sim', 'GL'])
5259
5552
 
5260
- # Add openframe flag for openframe projects
5261
- if project_type == 'openframe':
5553
+ if padframe_only:
5262
5554
  cmd.append('--openframe')
5263
5555
 
5264
5556
  # Add CI flag to disable Docker interactive mode (required when not running in a terminal)
5265
5557
  cmd.append('--CI')
5558
+
5559
+ from datetime import datetime
5560
+ run_tag = f"cf_verify_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
5561
+ cmd.extend(['-tag', run_tag])
5266
5562
 
5267
5563
  # Run cocotb tests
5268
5564
  console.print(f"[cyan]Running cocotb verification...[/cyan]")
@@ -5278,9 +5574,20 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p
5278
5574
 
5279
5575
  # Wait for process to complete
5280
5576
  returncode = process.wait()
5281
-
5577
+
5282
5578
  if returncode == 0:
5283
- console.print(f"\n[green]✓[/green] Verification passed!")
5579
+ # caravel_cocotb exits 0 even when tests fail; it records the
5580
+ # outcome as a 'passed' / 'failed' marker file in each test dir.
5581
+ passed, failed = _cocotb_run_results(cocotb_dir / 'sim' / run_tag)
5582
+ if failed or not passed:
5583
+ console.print(
5584
+ f"\n[red]✗[/red] Verification failed: {len(failed)} failed, {len(passed)} passed"
5585
+ )
5586
+ for name in failed:
5587
+ console.print(f" [red]•[/red] {name}")
5588
+ console.print(f"[yellow]Check logs in: {cocotb_dir / 'sim' / run_tag}[/yellow]")
5589
+ sys.exit(1)
5590
+ console.print(f"\n[green]✓[/green] Verification passed! ({len(passed)} test(s))")
5284
5591
  elif returncode == -2 or returncode == 130: # SIGINT
5285
5592
  console.print("\n[yellow]⚠[/yellow] Verification interrupted by user")
5286
5593
  sys.exit(130)
@@ -5304,8 +5611,10 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p
5304
5611
  os.killpg(os.getpgid(process.pid), signal.SIGKILL)
5305
5612
  else:
5306
5613
  process.kill()
5614
+ sys.exit(130)
5307
5615
  except Exception as e:
5308
5616
  console.print(f"\n[red]✗[/red] Error: {e}")
5617
+ sys.exit(1)
5309
5618
 
5310
5619
 
5311
5620
  DEFAULT_API_URL = 'https://api.chipfoundry.io'
@@ -22,8 +22,10 @@ _GDS_BASES: Tuple[Tuple[str, str], ...] = (
22
22
  ("analog", "gds/user_analog_project_wrapper"),
23
23
  ("digital", "gds/user_project_wrapper"),
24
24
  ("openframe", "gds/openframe_project_wrapper"),
25
+ ("double_wide", "gds/double_wide_openframe_project_wrapper"),
25
26
  ("mini", "gds/user_project_wrapper_mini4"),
26
27
  )
28
+ _PADFRAME_ONLY_KINDS = frozenset({"openframe", "double_wide"})
27
29
  _GDS_SUFFIXES: Tuple[str, ...] = (".gds", ".gds.gz")
28
30
 
29
31
  USER_DEFINES_REL = "verilog/rtl/user_defines.v"
@@ -301,7 +303,8 @@ def _push_critical_paths(repo: Path, project_json: Path) -> Set[str]:
301
303
  f"{e} "
302
304
  "Check that the wrapper GDS is committed and located under the "
303
305
  "expected path (e.g. gds/user_project_wrapper.gds, "
304
- "gds/openframe_project_wrapper.gds, etc.). If you use Git LFS, "
306
+ "gds/openframe_project_wrapper.gds, "
307
+ "gds/double_wide_openframe_project_wrapper.gds, etc.). If you use Git LFS, "
305
308
  "run `git lfs pull` so the actual file (not the pointer) is present."
306
309
  ) from e
307
310
  out: Set[str] = {gds_rel}
@@ -313,8 +316,8 @@ def _push_critical_paths(repo: Path, project_json: Path) -> Set[str]:
313
316
  "Fix project type or GDS layout before remote push."
314
317
  )
315
318
 
316
- # user_defines.v is required except for openframe, matching collect_project_files().
317
- if kind_gds != "openframe":
319
+ # user_defines.v is required except for bare-padframe projects, matching collect_project_files().
320
+ if kind_gds not in _PADFRAME_ONLY_KINDS:
318
321
  ud = repo / USER_DEFINES_REL
319
322
  if ud.is_file() or _path_tracked_in_git(repo, USER_DEFINES_REL):
320
323
  out.add(USER_DEFINES_REL)
@@ -24,8 +24,34 @@ GDS_TYPE_MAP = {
24
24
  'user_analog_project_wrapper.gds.gz': 'analog',
25
25
  'openframe_project_wrapper.gds': 'openframe',
26
26
  'openframe_project_wrapper.gds.gz': 'openframe',
27
+ 'double_wide_openframe_project_wrapper.gds': 'double_wide',
28
+ 'double_wide_openframe_project_wrapper.gds.gz': 'double_wide',
27
29
  }
28
30
 
31
+ PROJECT_TYPES: Tuple[str, ...] = ("digital", "analog", "openframe", "double_wide")
32
+
33
+ # Bare-padframe project types: no management SoC, no user_defines.v and no
34
+ # GPIO configuration. The user wrapper drives every pad control signal.
35
+ PADFRAME_ONLY_TYPES = frozenset({"openframe", "double_wide"})
36
+
37
+ # Project types whose harness is not caravel / caravel-lite. `cf setup` clones
38
+ # the harness into <project>/caravel so CARAVEL_ROOT keeps working for cocotb
39
+ # and precheck.
40
+ HARNESS_REPOS: Dict[str, str] = {
41
+ "double_wide": "https://github.com/chipfoundry/double_wide_openframe",
42
+ }
43
+ DEFAULT_HARNESS_REF = "main"
44
+
45
+ # `double_wide` is hardened as one flat design (no separate macros), and its
46
+ # upstream template ships its own flake.nix pinning LibreLane 3: the native
47
+ # PDN generator reaches the padframe-aligned power pins directly, so no
48
+ # bridging "connection" macro is needed. Every other project type keeps using
49
+ # `openlane_version` (LibreLane/OpenLane 2, fetched from cf-cli's versions.json).
50
+ # Pin the exact commit (not the `3.1.0.dev2` tag) so `nix` resolves it from the
51
+ # local store/cache without an unauthenticated GitHub API call that can 403.
52
+ DOUBLE_WIDE_LIBRELANE_REPO = "github:librelane/librelane"
53
+ DOUBLE_WIDE_LIBRELANE_REV = "f18a07aabb2dd3d6a7e3acc8c3b3621baa784a08" # 3.1.0.dev2
54
+
29
55
  # Canonical GDS wrapper layouts used by remote precheck and remote push.
30
56
  # Each entry is (project_kind, base path without suffix). Suffixes below.
31
57
  # Keep in sync with chipignite-backend-services/src/precheck_service and
@@ -34,9 +60,35 @@ GDS_WRAPPER_BASES: Tuple[Tuple[str, str], ...] = (
34
60
  ("analog", "gds/user_analog_project_wrapper"),
35
61
  ("digital", "gds/user_project_wrapper"),
36
62
  ("openframe", "gds/openframe_project_wrapper"),
63
+ ("double_wide", "gds/double_wide_openframe_project_wrapper"),
37
64
  )
38
65
  GDS_WRAPPER_SUFFIXES: Tuple[str, ...] = (".gds", ".gds.gz")
39
66
 
67
+
68
+ def is_padframe_only(project_type: Optional[str]) -> bool:
69
+ """True for project types that have no management SoC or user_defines.v."""
70
+ return project_type in PADFRAME_ONLY_TYPES
71
+
72
+
73
+ def read_project_type(project_root: Path, default: str = "digital") -> str:
74
+ """Read `project.type` from `.cf/project.json`, defaulting if missing/unreadable."""
75
+ project_json_path = Path(project_root) / ".cf" / "project.json"
76
+ if project_json_path.is_file():
77
+ try:
78
+ data = json.loads(project_json_path.read_text())
79
+ project_type = data.get("project", {}).get("type")
80
+ if isinstance(project_type, str) and project_type.strip():
81
+ return project_type.strip()
82
+ except (json.JSONDecodeError, IOError):
83
+ pass
84
+ return default
85
+
86
+
87
+ def harness_ref(project_type: str, versions: Optional[dict] = None) -> str:
88
+ """Git ref of the harness that `cf setup` installs for ``project_type``."""
89
+ refs = (versions or {}).get("harness_refs") or {}
90
+ return refs.get(project_type) or DEFAULT_HARNESS_REF
91
+
40
92
  USER_DEFINES_REL = "verilog/rtl/user_defines.v"
41
93
  CF_PROJECT_JSON_REL = ".cf/project.json"
42
94
 
@@ -89,7 +141,7 @@ def collect_project_files(project_root: str) -> Dict[str, Optional[str]]:
89
141
  Collect required project files from the given project_root.
90
142
  Returns a dict mapping logical names to absolute file paths (or None if not found and optional).
91
143
  Raises FileNotFoundError if any required file is missing.
92
- Openframe projects do not require user_defines.v.
144
+ Openframe and double-wide projects do not require user_defines.v.
93
145
  """
94
146
  project_root = Path(project_root)
95
147
  collected = {}
@@ -158,13 +210,12 @@ def collect_project_files(project_root: str) -> Dict[str, Optional[str]]:
158
210
  gds_name, gds_path = gds_file_to_use
159
211
  collected[f"gds/{gds_name}"] = gds_path
160
212
 
161
- # Collect standard required files (user_defines.v not required for openframe)
162
213
  cf_json_path = project_root / ".cf" / "project.json"
163
214
  collected[".cf/project.json"] = str(cf_json_path) if cf_json_path.exists() else None
164
215
 
165
216
  user_defines_path = project_root / "verilog" / "rtl" / "user_defines.v"
166
- if project_type == "openframe":
167
- collected["verilog/rtl/user_defines.v"] = None # openframe does not use user_defines.v
217
+ if is_padframe_only(project_type):
218
+ collected["verilog/rtl/user_defines.v"] = None
168
219
  else:
169
220
  if user_defines_path.exists():
170
221
  collected["verilog/rtl/user_defines.v"] = str(user_defines_path)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "2.6.0"
3
+ version = "2.7.0"
4
4
  description = "CLI tool to automate ChipFoundry project submission to SFTP server"
5
5
  authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
6
6
  readme = "README.md"
File without changes