tan-cli 0.7.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.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1740 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan init` -- scaffold a new project. A fresh customer's FIRST command.
3
+
4
+ Composition, not logic: resolve the template/name/destination, ask
5
+ `tan.core.scaffold` for the planned files, diff them against disk, then preview
6
+ or write -- and fold whatever comes back into exactly one envelope. Mirrors
7
+ `crates/tan-cli/src/commands/init/` (`mod.rs` + `from_example.rs`'s shared
8
+ `finish` step + `response.rs`'s envelope builders).
9
+
10
+ Four properties this file exists to hold:
11
+
12
+ **Every failure is a coded issue, never a traceback.** An unknown template, an
13
+ unwritable destination, a destination that is a file, files already in the way,
14
+ a missing SDK checkout for `--from-example`, a vendored template tree that will
15
+ not read, a `--som` whose SoM family has no vendored tree at all
16
+ (`init.som-unsupported`, tan-cli#579) -- each maps to an `issues[].code` and an
17
+ exit code. This is the port's recurring bug class: an uncaught exception
18
+ replaces the envelope with a Python traceback on stderr, and the extension
19
+ parses stdout, so it renders NOTHING with no error visible on either side.
20
+ The catch-all at the bottom of `init` is the
21
+ backstop for the case nobody enumerated.
22
+
23
+ **`--preview` writes nothing.** Not one directory, not one file, not even the
24
+ `.alp/sdk-path` pin. It is checked BEFORE the overwrite guard, because a
25
+ read-only operation has nothing to be guarded against -- the Rust learned that
26
+ the hard way: the guard used to run first and reject a preview with
27
+ `init.would-overwrite` on a project with local edits, failing the one operation
28
+ that could safely have answered.
29
+
30
+ **Nothing but the envelope on stdout.** Human text goes to stderr in both
31
+ formats; the JSON envelope is the only thing ever written to stdout.
32
+
33
+ **The OS is not an option (I-01/I-02).** No `--os`, no `--backend`. A core's
34
+ runtime follows its Cortex class, so a scaffolded `board.yaml` never carries a
35
+ top-level `os:` key and nothing here can be asked to put one there.
36
+
37
+ NOT PORTED, and honest about it: the interactive prompts (a missing `--template`
38
+ /`--name`/`--destination` takes the non-interactive default -- `zephyr-app`,
39
+ empty, `.` -- exactly as the Rust does with no terminal attached).
40
+
41
+ **`--board-yaml` and `--cores` are real, not parse-only.** `--board-yaml`
42
+ (Alp Studio's "render this resolved board.yaml verbatim") swaps the planned
43
+ board.yaml for the caller's file byte-for-byte; a template with no board.yaml
44
+ to override is a hard error (`init.board-yaml-unsupported`) rather than a
45
+ silent no-op that drops the caller's file. `--cores` (heterogeneous
46
+ scaffolding) validates + splices companion cores and a default RPMsg channel
47
+ into the SAME board.yaml `plan_template_files` already planned -- see
48
+ `tan.core.scaffold.splice_companion_cores`. `--from-example` ignores `--cores`
49
+ (by design: the example ships its own `cores:` topology) but DOES retarget
50
+ `--som` onto the copied board.yaml's `som:` block (`retarget_board_yaml_som`,
51
+ tan-cli#890/#1029) -- it is not ignored.
52
+
53
+ **`--template`'s id space is NOT the SDK's example catalog.** `TEMPLATE_IDS`
54
+ (`tan.core.scaffold`) is tan's own curated, vendored subset -- six starter
55
+ templates, five of which map onto five entries of the broader SDK catalog
56
+ (`metadata/templates/catalog-v1.json`, alp-sdk) under DIFFERENT ids (its
57
+ `minimal`/`sensor`/`iot`/`edge-ai`/`diagnostics` are this file's
58
+ `zephyr-app`/`sensor-starter`/`iot-starter`/`edge-ai-starter`/
59
+ `board-diagnostics`). `multicore-mailbox` (tan-cli#864) is the one id spelled
60
+ the SAME on both sides; `minimal-app` has no catalog counterpart at all, and the
61
+ catalog's `peripheral`/`multicore-rpmsg`/`gateway` entries have no `--template`
62
+ counterpart here -- reach them (and any other SDK example) with
63
+ `--from-example` instead, which copies the example's own tree verbatim
64
+ (I-32: no vendoring needed, but an SDK checkout is). Deliberately different
65
+ vocabularies, not a bug to "fix" by renaming one to match the other. The
66
+ `--template` help STRING is the one place this port knowingly diverges from
67
+ the oracle's own wording (tan-cli#1): the oracle's clap help is silent on the
68
+ catalog split, and a customer hitting it (e.g. trying `--template peripheral`)
69
+ gets no pointer to `--from-example` -- so the help text here says more than
70
+ `crates/tan-cli/src/cli.rs`'s does, on purpose, tracked, and pinned by
71
+ `test_help_distinguishes_template_ids_from_the_sdk_example_catalog`. This is
72
+ NOT the same claim as the paragraph below: that one is about which FLAGS are
73
+ listed at all, not about every flag's help wording matching byte-for-byte.
74
+
75
+ **`--topology` selects a template BY hardware topology instead of by id or
76
+ example path (alp-sdk#1652, tan-cli#996) -- and it is NOT `--cores`.**
77
+ `--cores` (above) splices a companion core onto an ALREADY-CHOSEN template's
78
+ board.yaml; `--topology core_id:os[,core_id:os...]` chooses WHICH template (or
79
+ SDK example) to use in the first place, by asking the SDK catalog which
80
+ record's own `cores:` declaration matches exactly -- a wizard that knows its
81
+ hardware ("M55-HP running Zephyr, A55 running Yocto") but not the catalog's
82
+ naming can ask this way instead of guessing a `--template`/`--from-example`
83
+ value. Mutually exclusive with both `--template` and `--from-example` (all
84
+ three answer the same question: which project to scaffold). Needs a resolved
85
+ SDK checkout, like `--from-example` -- the topology lives only in the SDK's
86
+ live catalog, never tan's own vendored set. Exactly one match resolves like
87
+ `--from-example <that record's example path>` would; zero matches or more
88
+ than one is a hard refusal (`init.topology-not-found` /
89
+ `init.topology-ambiguous`) naming the topologies or candidates that ARE on
90
+ offer -- silently picking the first of several candidates would hide the
91
+ other option from a customer who never knew it existed. See
92
+ `tan.core.example_catalog.find_example_by_cores`'s docstring for why this is
93
+ a small standalone re-implementation of alp-sdk's `find_template_by_cores`
94
+ rather than a call into `tan.planner.template`'s hand-ported copy.
95
+
96
+ **`--all`, `--target`, `--verbose`, `--quiet`, `--no-color` are accepted and
97
+ genuinely IGNORED here -- matching the oracle, not merely tolerated.** Every
98
+ one is a member of clap's `GlobalArgs` (`global = true`), so the real `tan
99
+ init --quiet`/`--verbose`/... parses, exits 0, AND lists every one of them in
100
+ `tan init --help` -- but `crates/tan-cli/src/commands/init/` never READS any
101
+ of the five (grep confirms: they appear only in `GlobalArgs` test-struct
102
+ literals, never in `init`'s own logic -- unlike `doctor`/`clean`, `init` calls
103
+ neither `style::render_report` nor `Theme::from_args`). Rejecting them here
104
+ would NOT be more honest, it would be wrong: it would make this port refuse
105
+ an argv the shipped binary accepts. Declared visible (not `hidden=True`,
106
+ unlike `clean_cmd.clean`'s equivalent block) so `tan init --help` LISTS all
107
+ five, matching the oracle -- docs/cli.md tabulates all five as `tan init`
108
+ flags, and a hidden option renders identically to a missing one there. (Their
109
+ own help STRINGS are still free to be tan's plain-English wording rather than
110
+ clap's doc-comment text; only their presence is the contract.)
111
+ """
112
+
113
+ from __future__ import annotations
114
+
115
+ import json
116
+ import os
117
+ import sys
118
+ from dataclasses import dataclass, field
119
+ from pathlib import Path
120
+
121
+ import typer
122
+
123
+ from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS
124
+ from tan.core.sdk_discovery import with_sdk_search
125
+ from tan.core.alp_conf_pregen import pregeneration_message
126
+ from tan.core.system_manifest import load_yaml_document
127
+ from tan.core.fs_confine import PathEscapeError, resolve_confined
128
+ from tan.core.global_flags import accept_global_flags
129
+ from tan.core.example_catalog import (
130
+ AmbiguousCoresTopologyError,
131
+ CoresTopologyNotFoundError,
132
+ MalformedCatalogError,
133
+ catalog_unreadable,
134
+ find_example_by_cores,
135
+ parse_topology_arg,
136
+ unsupported_som,
137
+ )
138
+ from tan.core.som_buildability import hw_rev_not_buildable
139
+ from tan.core.sdk_discovery import (
140
+ global_default_foreign_project_issue,
141
+ resolve_sdk_root_wide,
142
+ sdk_ladder_divergence_issue,
143
+ )
144
+ from tan.core.scaffold import (
145
+ DEFAULT_SOM_SKU,
146
+ DEFAULT_TEMPLATE_ID,
147
+ TEMPLATE_SUPPORTED_SKUS,
148
+ TEMPLATE_IDS,
149
+ CoresError,
150
+ ExampleReadError,
151
+ FileChange,
152
+ FlowStyleSomError,
153
+ PlannedFile,
154
+ ScaffoldWriteError,
155
+ SomBlockUnsupportedError,
156
+ TemplateDataError,
157
+ UnreadableSomBlockError,
158
+ UnsupportedSomError,
159
+ collect_file_changes,
160
+ is_plain_relative,
161
+ parse_cores,
162
+ plan_template_files,
163
+ posix,
164
+ read_example_tree,
165
+ retarget_board_yaml_som,
166
+ scaffold_tree_preview,
167
+ sdk_pointer_json,
168
+ splice_companion_cores,
169
+ vendored_app_core_key,
170
+ vendored_core_ids,
171
+ vendored_som,
172
+ write_files,
173
+ )
174
+ from tan.core.board_files import retarget_example_board_files
175
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
176
+ from tan.exit_codes import ExitCode
177
+ from tan.output_format import FORMAT_HELP, OutputFormat
178
+
179
+ #: `data.schemaVersion` for this command's payload.
180
+ DATA_SCHEMA_VERSION = "1"
181
+
182
+ #: `vendored_core_ids`'s `os` value is the raw `os:` child line text, quotes
183
+ #: and all (a vendored board.yaml spells an off companion `os: "off"`) --
184
+ #: stripped for the collision message so it reads `(os: off)`, not `(os:
185
+ #: "off")`. Mirrors Rust's `os.trim_matches('"')`.
186
+ DOUBLE_QUOTE = '"'
187
+
188
+
189
+ class InitError(Exception):
190
+ """A failure with its issue code and exit code already decided.
191
+
192
+ `partial` carries the files that DID land when a write failed part-way:
193
+ `written: []` for a project that is actually half on disk leaves a consumer
194
+ with no idea what to clean up or reopen.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ code: str,
200
+ message: str,
201
+ exit_code: ExitCode,
202
+ *,
203
+ partial: tuple[list[str], list[str]] = ([], []),
204
+ ) -> None:
205
+ super().__init__(message)
206
+ self.code = code
207
+ self.message = message
208
+ self.exit_code = exit_code
209
+ self.partial = partial
210
+
211
+
212
+ @dataclass
213
+ class _Outcome:
214
+ """A completed (non-error) run: preview, overwrite-guard refusal, or write.
215
+
216
+ `destination` and `project_root` are DELIBERATELY two different fields:
217
+ `destination` is where the caller pointed tan (`--destination`/`--project`,
218
+ unchanged by `--name`) and is what the JSON envelope's `project.root` /
219
+ `data.destination` pin -- `project_root` is `destination` + the resolved
220
+ `--name` subdirectory, i.e. where files actually landed. Text mode's
221
+ "created" line must report `project_root`: reporting `destination` there
222
+ (tan-cli#4) told an operator running `--name blink-e2e` that tan had
223
+ created `proj` when it had actually created `proj/blink-e2e`.
224
+ """
225
+
226
+ template_id: str
227
+ destination: str
228
+ project_root: str
229
+ preview: bool
230
+ file_changes: list[FileChange]
231
+ files: list[PlannedFile]
232
+ written: list[str] = field(default_factory=list)
233
+ unchanged: list[str] = field(default_factory=list)
234
+ sdk_pinned: str | None = None
235
+ issues: list[Issue] = field(default_factory=list)
236
+ exit_code: ExitCode = ExitCode.SUCCESS
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Envelope assembly
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ def _data(
245
+ *,
246
+ template_id: str,
247
+ destination: str,
248
+ preview: bool,
249
+ file_changes: list[FileChange],
250
+ written: list[str],
251
+ unchanged: list[str],
252
+ sdk_pinned: str | None,
253
+ ) -> dict:
254
+ return {
255
+ "schemaVersion": DATA_SCHEMA_VERSION,
256
+ "templateId": template_id,
257
+ "destination": destination,
258
+ "preview": preview,
259
+ "fileChanges": [
260
+ {"relativePath": c.relative_path, "kind": c.kind} for c in file_changes
261
+ ],
262
+ "written": written,
263
+ "unchanged": unchanged,
264
+ "sdkPinned": sdk_pinned,
265
+ }
266
+
267
+
268
+ def _stderr(line: str) -> None:
269
+ print(line, file=sys.stderr)
270
+
271
+
272
+ def _sdk_reportable(sdk: _Sdk | None) -> bool:
273
+ """Whether `sdk` is resolved AND a real checkout -- the one gate
274
+ `_sdk_block` used to apply internally before tan-cli#922 moved it to the
275
+ call site (see `_sdk_block`'s own docstring for why). Gated on the loader
276
+ marker, matching `build_output.resolve_project_context` ("the envelope's
277
+ `sdk` only records what core's own loader-marker check accepted"), and
278
+ NOT merely on `sdk is not None`. `_resolve_sdk_root` returns an explicit
279
+ `--sdk-root` unvalidated, and `_pin_sdk` then silently declines to pin a
280
+ path that is not a checkout -- so reporting `sdk` there would advertise a
281
+ checkout that is about to be pinned and is not."""
282
+ return sdk is not None and _is_sdk_checkout(sdk.path)
283
+
284
+
285
+ def _sdk_block(sdk: _Sdk) -> SdkInfo:
286
+ """The envelope's `sdk` block for a resolved, checked-out [`_Sdk`] --
287
+ ALWAYS one, never a bare `None` (tan-cli#922: the same shape tan-cli#900
288
+ fixed for `examples_cmd._resolve_sdk`/`generate_cmd._resolve_sdk_root`,
289
+ applied here). Callers gate the CALL itself on `_sdk_reportable(sdk)`
290
+ rather than this function collapsing to `None` -- moving the "should the
291
+ envelope carry an `sdk` key at all" decision to the caller, the same
292
+ place every other resolution wrapper in this repo makes it
293
+ (`.path is None`), rather than folding it into this function's return
294
+ type. `SdkInfo.root` has no representable "unresolved" state of its own
295
+ (it is a bare `str`, unlike `_ResolvedSdk`/`_SdkResolution`/
296
+ `_ResolvedSdkRoot`'s `path: X | None`), so this function could never have
297
+ represented "not reportable" WITHIN an `SdkInfo` the way those do --
298
+ `| None` on the return was the only way to spell that, and spelling it
299
+ there instead of at the call site is exactly the shape
300
+ `tests/gates/test_sdk_resolution_wrapper_is_not_optional.py` flags.
301
+
302
+ tan-cli#491 defect 5: `init` passed no `sdk=` to `Envelope(...)` on ANY
303
+ path, so the key was absent from all four outcomes and at both resolution
304
+ tiers, while the frozen v0.4.1 oracle emits `sdk:{root,sourceTier}` for the
305
+ identical argv (`test_oracle_parity.json`, the `init` capture:
306
+ `"sdk": {"root": "../rust-sdk", "sourceTier": "sdkRootFlag"}`). It was the
307
+ only field naming WHICH checkout a run is about to permanently pin --
308
+ `data.sdkPinned` is `null` on the preview, refusal and error paths.
309
+
310
+ `SdkInfo.from_resolution`, not a raw `SdkInfo(root, tier)`: `_Sdk` carries
311
+ `tier` and `foreign_global_default_for`, which is the shape that
312
+ constructor reads (`tests/gates/test_sdk_info_is_built_from_a_resolution.py`).
313
+ `init` still surfaces the foreign-global-default warning through its own
314
+ explicit `global_default_foreign_project_issue` call, computed BEFORE
315
+ `_pin_sdk` writes -- see that call site.
316
+
317
+ Note the ROOT reported is `_Sdk.display`, i.e. absolute, where the oracle
318
+ echoed `--sdk-root` verbatim. That divergence is `_Sdk`'s own, already
319
+ deliberate and already documented there (tan-cli#263: a relative pointer
320
+ persisted into `.alp/sdk-path` resolves against the wrong cwd later); this
321
+ field simply reports the same value `data.sdkPinned` does.
322
+ """
323
+ return SdkInfo.from_resolution(sdk.display, sdk)
324
+
325
+
326
+ def _emit_error(json_mode: bool, err: InitError, sdk: _Sdk | None = None) -> None:
327
+ """The error envelope. Note the asymmetry with `_emit_outcome`, which is
328
+ contract, not an oversight: an error reports `project.root: null` and an
329
+ EMPTY `templateId`/`destination` even when the CLI had already resolved
330
+ both, because nothing was created and there is no project to point a
331
+ consumer at. `contract/envelopes/init-invalid-template` pins it.
332
+
333
+ `sdk` defaults to `None` because the two `_emit_error` call sites sit in
334
+ handlers that can be reached BEFORE `_resolve_sdk_root` has run at all (a
335
+ bad `--template`, an unreadable `--board-yaml`); there is genuinely nothing
336
+ to report then, and the key stays absent rather than becoming `null`.
337
+ """
338
+ written, unchanged = err.partial
339
+ if json_mode:
340
+ emit(
341
+ Envelope(
342
+ "init",
343
+ Project(root=None, board_yaml=None),
344
+ _data(
345
+ template_id="",
346
+ destination="",
347
+ preview=False,
348
+ file_changes=[],
349
+ written=written,
350
+ unchanged=unchanged,
351
+ sdk_pinned=None,
352
+ ),
353
+ [Issue(err.code, "error", err.message)],
354
+ err.exit_code,
355
+ sdk=_sdk_block(sdk) if _sdk_reportable(sdk) else None,
356
+ )
357
+ )
358
+ else:
359
+ _stderr(f"init: {err.message}")
360
+ raise typer.Exit(int(err.exit_code))
361
+
362
+
363
+ def _emit_outcome(json_mode: bool, outcome: _Outcome, sdk: _Sdk | None = None) -> None:
364
+ if json_mode:
365
+ emit(
366
+ Envelope(
367
+ "init",
368
+ # The destination, not the nested project root: `project.root`
369
+ # is where the caller pointed tan, and the golden pins `"."`.
370
+ Project(root=outcome.destination, board_yaml=None),
371
+ _data(
372
+ template_id=outcome.template_id,
373
+ destination=outcome.destination,
374
+ preview=outcome.preview,
375
+ file_changes=outcome.file_changes,
376
+ written=outcome.written,
377
+ unchanged=outcome.unchanged,
378
+ sdk_pinned=outcome.sdk_pinned,
379
+ ),
380
+ outcome.issues,
381
+ outcome.exit_code,
382
+ sdk=_sdk_block(sdk) if _sdk_reportable(sdk) else None,
383
+ )
384
+ )
385
+ elif outcome.preview:
386
+ _stderr(f"init: preview for template '{outcome.template_id}'")
387
+ for issue in outcome.issues:
388
+ _stderr(f"init: {issue.message}")
389
+ _stderr(scaffold_tree_preview(outcome.files).rstrip("\n"))
390
+ elif outcome.exit_code != ExitCode.SUCCESS:
391
+ # Refused (the overwrite guard): nothing was written, so there is no
392
+ # "created" line to print, only the issue(s) that explain why.
393
+ for issue in outcome.issues:
394
+ _stderr(f"init: {issue.message}")
395
+ else:
396
+ # A successful write may still carry a warning (e.g.
397
+ # `init.example-missing-board-yaml`) -- print it, then the "created"
398
+ # line: files DID land, so unlike the refusal branch above this must
399
+ # never suppress it.
400
+ for issue in outcome.issues:
401
+ _stderr(f"init: {issue.message}")
402
+ # `project_root`, not `destination`: see `_Outcome`'s docstring.
403
+ _stderr(f"init: created '{outcome.project_root}' from template '{outcome.template_id}'")
404
+ _stderr(f" written: {len(outcome.written)}, unchanged: {len(outcome.unchanged)}")
405
+ raise typer.Exit(int(outcome.exit_code))
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Input resolution
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ def _resolve_template(template: str | None) -> str:
414
+ if template is None:
415
+ return DEFAULT_TEMPLATE_ID
416
+ if template not in TEMPLATE_IDS:
417
+ # Message pinned verbatim by `contract/envelopes/init-invalid-template`.
418
+ # Do not append the valid-template list here: the golden diffs
419
+ # `issues[].message` byte-for-byte, and a list that grows with the
420
+ # registry would break it on every new template. `tan explain` is where
421
+ # the catalogue is published.
422
+ raise InitError(
423
+ "init.invalid-template",
424
+ f"Unknown template '{template}'.",
425
+ ExitCode.VALIDATION_FAILURE,
426
+ )
427
+ return template
428
+
429
+
430
+ def _resolve_name(name: str | None) -> str:
431
+ """`--name` names a SUBDIRECTORY under the destination; empty means scaffold
432
+ straight into it. Guarded because this is the input that decides WHERE files
433
+ land -- an unchecked `..`/absolute value put the project root (and, with
434
+ `--force`, an overwrite target) anywhere the process can write."""
435
+ if name is None:
436
+ return ""
437
+ if name == "" or is_plain_relative(name):
438
+ return name
439
+ raise InitError(
440
+ "init.invalid-name",
441
+ f"Invalid --name '{name}': must be a plain relative path (no '..', absolute, "
442
+ f"or drive-rooted segments).",
443
+ ExitCode.VALIDATION_FAILURE,
444
+ )
445
+
446
+
447
+ def _cwd_or_dot() -> Path:
448
+ """`Path.cwd()`, falling back to `"."` when the cwd has been removed
449
+ (tan-cli#494 defect 10).
450
+
451
+ Unguarded, `Path.cwd()`'s `FileNotFoundError` escaped to `init`'s catch-all
452
+ as `init.internal-failure` / exit 5 with an empty preview, where the oracle
453
+ exits 0 and emits the full envelope (measured: same argv, same deleted cwd).
454
+ `clean_cmd._cli_workspace_root` and `presets_cmd.resolve_project_paths`
455
+ already wrap the identical call this way, quoting the Rust
456
+ `current_dir().unwrap_or_else(|_| PathBuf::from("."))`; `init` -- the one
457
+ command that also WRITES its answer into `.alp/sdk-path` -- was the one
458
+ that skipped it.
459
+ """
460
+ try:
461
+ return Path.cwd()
462
+ except OSError:
463
+ return Path(".")
464
+
465
+
466
+ def _join_display(dest: str, name: str) -> str:
467
+ """Mirror `PathBuf::from(dest).join(name)`'s `.display()` text
468
+ (`from_example.rs:70-74`): literal string concatenation with the native
469
+ separator, never pathlib's silent drop of a leading `.` component. This is
470
+ for the TEXT-mode "created" line only -- `project_root` (the `Path`) still
471
+ does the real filesystem work; `pathlib.Path(".") / "my-app"` normalises
472
+ away the `.`, so `--name my-app` in `.` would otherwise print `'my-app'`
473
+ where the oracle prints `'./my-app'` (`'.\\my-app'` on Windows)."""
474
+ if not name:
475
+ return dest
476
+ if dest.endswith(("/", "\\")):
477
+ return f"{dest}{name}"
478
+ return f"{dest}{os.sep}{name}"
479
+
480
+
481
+ @dataclass(frozen=True)
482
+ class _Sdk:
483
+ """A resolved alp-sdk checkout: the `path` to use, and the `display` string
484
+ to RECORD (`data.sdkPinned`, `.alp/sdk-path`) -- always the SAME absolute
485
+ directory, `path` as a `Path` for filesystem calls and `display` as its
486
+ posix-separator string for the wire/pointer file.
487
+
488
+ Both branches of [`_resolve_sdk_root`] build this from an already-absolute
489
+ path (an explicit `--sdk-root` is anchored against the real cwd; the
490
+ resolved-ladder branch was absolute already, being built from
491
+ `workspace_root`). tan-cli#263: an EARLIER revision recorded an explicit
492
+ `--sdk-root` verbatim -- `.\\alp-sdk` stayed `.\\alp-sdk` -- on the theory
493
+ that the oracle's `resolve_sdk_root` also returns the flag as-is and the two
494
+ binaries must write identical bytes into the same pointer file. That theory
495
+ only held for the value's IN-PROCESS use (resolved against THIS invocation's
496
+ cwd, in THIS run); persisted into `.alp/sdk-path`, the same relative string
497
+ is read back later by an unrelated invocation with a DIFFERENT cwd (a
498
+ `tan sdk current` run from inside the new project itself, typically), where
499
+ it silently resolves to the wrong directory or nowhere at all -- exactly the
500
+ layout the maintainer hit (`--sdk-root .\\alp-sdk` from the parent, read back
501
+ from the child). A persisted pointer has no cwd of its own to inherit, so it
502
+ must be self-contained: absolute. (A project-root-relative form was also
503
+ considered -- `.alp/sdk-path` lives inside the project it describes, and
504
+ that would survive the project being moved as a whole. Rejected here because
505
+ nothing on the READ side re-anchors a relative pointer against the project
506
+ root today -- `_pointer_target`/`_has_loader_script` hand it straight to
507
+ `Path()`, which is exactly the cwd-dependent behaviour this fix removes --
508
+ and because absolute matches what every OTHER tier already stores, with no
509
+ reader change required.)
510
+ """
511
+
512
+ path: Path
513
+ display: str
514
+ #: `SdkRootResolution.tier` carried through (tan-cli#491 defect 5) -- the
515
+ #: `sourceTier` half of the envelope's `sdk` block, which `init` was the
516
+ #: only wide-ladder command not to emit at all. `_resolve_sdk_root` had
517
+ #: the tier in hand on both branches and dropped it here, so neither
518
+ #: emitter could report which mechanism answered, on ANY of the four
519
+ #: outcome paths (preview, write, overwrite refusal, every `_emit_error`).
520
+ #: The explicit-flag branch is `"sdkRootFlag"` by construction; the ladder
521
+ #: branch carries `resolve_sdk_root_wide`'s own answer.
522
+ tier: str = "sdkRootFlag"
523
+ #: `SdkRootResolution.foreign_global_default_for` carried through
524
+ #: (tan-cli#464). `None` on the `--sdk-root` branch (an explicit flag is
525
+ #: never "foreign" -- there is nothing to fall through from) and on every
526
+ #: tier above `globalDefault`; set only when THIS run resolved through the
527
+ #: machine-global `~/.alp/sdk-default` pointer and it was last written for
528
+ #: a DIFFERENT project. `init` is the command this fact matters most for
529
+ #: (more than `tan build`'s own tan-cli#464 fix): a pin written here is
530
+ #: PERMANENT, so silently baking a foreign checkout's path into a brand
531
+ #: new project's `.alp/sdk-path` is worse than one build silently using
532
+ #: the wrong SDK once -- every later command in the new project inherits
533
+ #: it until someone notices and re-pins by hand.
534
+ foreign_global_default_for: str | None = None
535
+
536
+
537
+ def _resolve_sdk_root(sdk_root: str | None, workspace_root: Path) -> _Sdk | None:
538
+ """`--sdk-root` when given, else `.alp/sdk-path` project pin >
539
+ machine-global default (`~/.alp/sdk-default`) > the WIDE positional walk.
540
+ No `ALP_SDK_ROOT` tier (tried and reverted -- see
541
+ `resolve_sdk_root_ladder`'s own docstring).
542
+
543
+ `tan.core.sdk_discovery.resolve_sdk_root_wide`, not the narrow ladder its thirteen
544
+ sibling commands take (tan-cli#263, measured against the oracle): where a
545
+ workspace holds both a child `<ws>/alp-sdk` and a competing sibling
546
+ `../alp-sdk`, the oracle's `init` pins the CHILD. The narrow ladder pinned
547
+ the sibling -- and `init` does not merely resolve an SDK, it WRITES the
548
+ answer to `.alp/sdk-path`, which then outranks discovery for every later
549
+ command in that project. Getting it wrong here binds the wrong checkout
550
+ permanently, so this one call site is worth a second helper.
551
+
552
+ An explicit `--sdk-root` is expanded (`~`/`~user`) then anchored against
553
+ the process's real cwd with `os.path.abspath` -- lexical only (matching
554
+ `tan.core.sdk_discovery._abs_posix`'s own reasoning), never `Path.resolve()`, so a
555
+ project reached through a symlink keeps the name the user typed and a
556
+ not-yet-existing path still resolves. `expanduser` first: `abspath` alone
557
+ does not expand `~`, so `--sdk-root ~/alp-sdk` would otherwise anchor to
558
+ `<cwd>/~/alp-sdk` -- a pre-existing gap, but persisting the wrong path into
559
+ `.alp/sdk-path` (rather than just misusing it for this one invocation)
560
+ makes it worth closing here. Anchored against the true cwd, not
561
+ `workspace_root`: `--project` picks which workspace `init` operates ON, it
562
+ does not rebase every OTHER relative CLI argument onto it, and no sibling
563
+ option in this command does that either."""
564
+ if sdk_root:
565
+ resolved = Path(os.path.abspath(os.path.expanduser(sdk_root)))
566
+ return _Sdk(resolved, posix(resolved), "sdkRootFlag")
567
+ # `_broken_pin` unused: `init` is what WRITES `.alp/sdk-path`, so a broken
568
+ # pin already sitting in the parent workspace is superseded by this run's
569
+ # own resolution rather than something for `init` itself to disclose --
570
+ # `sdk current`/`build`/`doctor` (tan-cli#263 review) are what customers
571
+ # see it through after the fact. `foreign_global_default_for` is NOT
572
+ # dropped the same way (tan-cli#464 review): unlike a broken pin, which
573
+ # this run's own resolution supersedes, a foreign `globalDefault` is
574
+ # exactly what THIS run is about to resolve through and permanently pin
575
+ # -- `init()` surfaces it via `global_default_foreign_project_issue`
576
+ # before `_pin_sdk` ever writes.
577
+ resolution = resolve_sdk_root_wide(None, workspace_root)
578
+ found = resolution.path
579
+ if found is None:
580
+ return None
581
+ return _Sdk(
582
+ found,
583
+ posix(found),
584
+ resolution.tier,
585
+ foreign_global_default_for=resolution.foreign_global_default_for,
586
+ )
587
+
588
+
589
+ def _is_sdk_checkout(root: Path) -> bool:
590
+ """`scripts/alp_project.py` is THE marker for an alp-sdk checkout (I-31)."""
591
+ return (root / "scripts" / "alp_project.py").is_file()
592
+
593
+
594
+ def _sdk_root_flag_unresolved_issue(
595
+ sdk_root: str | None, resolved_sdk: _Sdk | None
596
+ ) -> Issue | None:
597
+ """tan-cli#642: an explicit `--sdk-root` that does not resolve to a real
598
+ alp-sdk checkout used to fail SILENTLY on the template path.
599
+
600
+ `_resolve_sdk_root`'s explicit-flag branch builds a `_Sdk` from
601
+ `--sdk-root` UNVALIDATED -- unlike the ladder branch, it never checks
602
+ `_is_sdk_checkout` before returning, so a typo'd path (or tan-cli#642's
603
+ more realistic route: a previously-valid `--sdk-root` that `tan
604
+ bootstrap` has since relocated) reaches `_finish` -> `_pin_sdk`, which
605
+ declines to write `.alp/sdk-path` for anything that fails
606
+ `_is_sdk_checkout` -- correctly, that write really would pin a checkout
607
+ that is not one -- but until this fix said NOTHING about it: `ok:true`,
608
+ `issues:[]`, `sdkPinned:null`, the whole project scaffolded normally,
609
+ though the README promises `init` "pins the SDK checkout in
610
+ .alp/sdk-path". A later command then silently fell through to
611
+ `~/.alp/sdk-default` -- some OTHER project's last `bootstrap`, in a
612
+ shared or multi-project setup.
613
+
614
+ EMISSION CONDITION: `--sdk-root` was given (non-empty) AND the path it
615
+ resolves to has no `scripts/alp_project.py` under it. `None` when
616
+ `--sdk-root` was not given at all -- that is the ordinary, no-SDK-
617
+ requested case every other ladder tier already covers with its own (or
618
+ no) diagnostic, not this one's to explain -- and `None` on the
619
+ `--from-example` path, which already hard-refuses the identical
620
+ condition as `init.sdk-root-unresolved` before this could ever run (an
621
+ SDK checkout is NOT optional there, since the thing being copied lives
622
+ inside one).
623
+ """
624
+ if not sdk_root or resolved_sdk is None or _is_sdk_checkout(resolved_sdk.path):
625
+ return None
626
+ return Issue(
627
+ "init.sdk-root-invalid",
628
+ "warning",
629
+ f"--sdk-root '{sdk_root}' does not resolve to an alp-sdk checkout "
630
+ f"(tried '{resolved_sdk.display}', no 'scripts/alp_project.py' "
631
+ f"found there); the project was scaffolded without pinning an SDK "
632
+ f"(.alp/sdk-path was not written). Re-run with a --sdk-root that "
633
+ f"resolves, or {NO_SDK_NEXT_STEPS}.",
634
+ )
635
+
636
+
637
+ def _example_som_catalog_issue(
638
+ resolved_sdk: _Sdk, example_src: str, som: str, subject_label: str
639
+ ) -> Issue | None:
640
+ """tan-cli#890/#1101: whether `--som som` against the SDK scaffold
641
+ catalog's record for `example_src` is (a) declared unsupported, (b)
642
+ could not be checked at all, or (c) neither -- extracted out of
643
+ `init()` itself (tan-cli#1101 review minor: this was inline and pushed
644
+ `init()` to the 6th-largest function in the tree) since the whole block
645
+ is exactly this seam: four already-resolved values in, one optional
646
+ `Issue` out, no other state.
647
+
648
+ `unsupported_som` returning `None` is ambiguous by its own documented
649
+ contract -- EITHER "checked, --som is fine (or there was no record to
650
+ check against)" OR "could not check at all". This second, narrower read
651
+ (`catalog_unreadable`) distinguishes the two, and returns its reason
652
+ ALREADY led correctly for which of its two shapes actually happened (a
653
+ genuinely unreadable catalog document vs. one that read fine but whose
654
+ matching record did not) -- see that function's own docstring, and the
655
+ module docstring section it points at, rather than re-deriving either
656
+ lead here. `ok`/`exitCode` and the scaffold itself stay exactly as they
657
+ are either way (refusing here would be the tightening tan-cli#1084
658
+ promised not to do) -- this only stops the envelope from claiming a
659
+ check that never ran.
660
+ """
661
+ supported = unsupported_som(resolved_sdk.path, example_src, som)
662
+ if supported is not None:
663
+ return Issue(
664
+ "init.example-som-unsupported",
665
+ "warning",
666
+ f"{subject_label} declares supported.som_skus "
667
+ f"{list(supported)} in the SDK scaffold catalog; --som "
668
+ f"'{som}' is outside that set, and `alp_project.py --emit "
669
+ f"scaffold` refuses the same pair. The files were still "
670
+ f"written -- check the scaffolded board.yaml against your "
671
+ f"SoM's topology before building, or widen som_skus in "
672
+ f"the catalog if the example really does support it.",
673
+ )
674
+ check_skipped_reason = catalog_unreadable(resolved_sdk.path, example_src)
675
+ if check_skipped_reason is not None:
676
+ return Issue(
677
+ "init.example-som-unchecked",
678
+ "warning",
679
+ f"{subject_label}: {check_skipped_reason}, so whether --som "
680
+ f"'{som}' is supported could not be checked. The files were "
681
+ f"still written -- verify '{som}' against {subject_label}'s "
682
+ f"supported SoMs before building.",
683
+ )
684
+ return None
685
+
686
+
687
+ # ---------------------------------------------------------------------------
688
+ # The two planning paths
689
+ # ---------------------------------------------------------------------------
690
+
691
+
692
+ def _plan_from_template(
693
+ template: str | None, som: str | None, cores_raw: str | None
694
+ ) -> tuple[str, list[PlannedFile]]:
695
+ template_id = _resolve_template(template)
696
+ sku = som or DEFAULT_SOM_SKU
697
+ # Checked BEFORE anything is planned. A template whose SDK catalog entry
698
+ # restricts `supported.som_skus` must refuse every other `--som` here --
699
+ # never render it against the wrong family tree (silent, `exitCode 0`) and
700
+ # never let it fall through to `init.template-unreadable` (which blames
701
+ # the installation for a wrong argument). See TEMPLATE_SUPPORTED_SKUS.
702
+ supported = TEMPLATE_SUPPORTED_SKUS.get(template_id)
703
+ if supported is not None and sku not in supported:
704
+ plural = "s" if len(supported) > 1 else ""
705
+ allowed = ", ".join(f"'{s}'" for s in supported)
706
+ raise InitError(
707
+ "init.invalid-som",
708
+ f"Template '{template_id}' supports only SoM SKU{plural} "
709
+ f"{allowed}; got '{sku}'.",
710
+ ExitCode.VALIDATION_FAILURE,
711
+ )
712
+ try:
713
+ files = plan_template_files(template_id, sku)
714
+ except UnsupportedSomError as err:
715
+ # tan-cli#579. A VALIDATION failure, not an internal one: the tree is
716
+ # not missing from the install, it was never captured -- alp-sdk's
717
+ # scaffold catalog declares no entry for this SoM family -- so the
718
+ # actionable thing is the `--som`/`--template` pair the customer typed.
719
+ # Distinct from `init.invalid-som` above, which says "this TEMPLATE
720
+ # supports one SKU"; this one says "tan has no scaffold for this SoM
721
+ # family at all, in any family-split template". `UnsupportedSomError`
722
+ # itself records why refusing beats vendoring or warning.
723
+ raise InitError(
724
+ "init.som-unsupported", str(err), ExitCode.VALIDATION_FAILURE
725
+ ) from err
726
+ except TemplateDataError as err:
727
+ # tan's own template data is unreadable -- a broken installation (or a
728
+ # frozen binary built without the template `--add-data`), not a project
729
+ # problem, so INTERNAL_FAILURE rather than a validation code that would
730
+ # send the customer looking at their own board.yaml.
731
+ raise InitError(
732
+ "init.template-unreadable", str(err), ExitCode.INTERNAL_FAILURE
733
+ ) from err
734
+ except SomBlockUnsupportedError as err:
735
+ # tan-cli#1029/#1041, defensive: every vendored tree's board.yaml is
736
+ # tan's OWN template data (`tan/templates/vendored/`, captured
737
+ # block-style, pinned by `test_scaffold_content_oracle_parity.py`),
738
+ # never reachable in practice -- ANY of `FlowStyleSomError`'s or
739
+ # `UnreadableSomBlockError`'s shapes here means the shipped template
740
+ # data itself is broken, not a customer argument, so this maps to
741
+ # the same code/exit as `TemplateDataError` above rather than the
742
+ # validation-failure `_plan_from_example` raises for the
743
+ # customer-reachable copy of these same shapes.
744
+ raise InitError(
745
+ "init.template-unreadable", str(err), ExitCode.INTERNAL_FAILURE
746
+ ) from err
747
+
748
+ try:
749
+ cores = parse_cores(cores_raw)
750
+ except CoresError as err:
751
+ raise InitError(
752
+ "init.invalid-cores", err.message, ExitCode.VALIDATION_FAILURE
753
+ ) from err
754
+ if cores:
755
+ files = _apply_cores(template_id, files, cores)
756
+ return template_id, files
757
+
758
+
759
+ def _apply_cores(
760
+ template_id: str, files: list[PlannedFile], cores: list[tuple[str, str]]
761
+ ) -> list[PlannedFile]:
762
+ """Validate `--cores` against the plan's OWN board.yaml, then splice
763
+ companion cores (+ a default RPMsg channel) into it. Reads the app core
764
+ and any pre-declared companions straight off the PLANNED board.yaml
765
+ (`vendored_app_core_key`/`vendored_core_ids`) rather than a second,
766
+ independently-derived per-template lookup, so this check can never
767
+ disagree with what actually gets spliced -- the exact drift the Rust
768
+ port guards against with its `vendored_*_app_core_for_sku` family.
769
+ A plan with no board.yaml (or no `cores:` block) has nothing to validate
770
+ or splice against; every registered template plans one today."""
771
+ board = next((f for f in files if f.relative_path == "board.yaml"), None)
772
+ if board is None:
773
+ return files
774
+ app_core = vendored_app_core_key(board.content)
775
+ if app_core is None:
776
+ return files
777
+
778
+ # The app core's runtime is fixed (the scaffolded src/ + prj.conf are
779
+ # Zephyr); reject a contradictory --cores request instead of silently
780
+ # overriding it.
781
+ for core_id, os_value in cores:
782
+ if core_id == app_core and os_value != "zephyr":
783
+ raise InitError(
784
+ "init.invalid-cores",
785
+ f"Core '{app_core}' is this SoM's app core and runs zephyr; "
786
+ f"--cores requested '{os_value}'. Omit the entry or use "
787
+ f"{app_core}:zephyr.",
788
+ ExitCode.VALIDATION_FAILURE,
789
+ )
790
+
791
+ # A vendored scaffold may already pre-declare a companion core (e.g.
792
+ # edge-ai ships `a55_cluster`, os: "off") -- a --cores id colliding with
793
+ # one would append a SECOND, duplicate `cores:` mapping key. Reject
794
+ # rather than silently drop the caller's os or silently override the
795
+ # scaffold's. Checked BEFORE the zephyr-companion refusal below: a
796
+ # collision with an already-declared companion is the more specific,
797
+ # more actionable conflict (name the existing declaration), whatever OS
798
+ # the caller's entry asked for.
799
+ existing = dict(vendored_core_ids(board.content))
800
+ for core_id, _os_value in cores:
801
+ if core_id != app_core and core_id in existing:
802
+ raise InitError(
803
+ "init.invalid-cores",
804
+ f"Core '{core_id}' is already declared by the {template_id} "
805
+ f"scaffold (os: {existing[core_id].strip(DOUBLE_QUOTE)}); edit "
806
+ f"board.yaml to change its os instead of passing --cores.",
807
+ ExitCode.VALIDATION_FAILURE,
808
+ )
809
+
810
+ # tan-cli#643: a --cores entry naming a DIFFERENT core than the plan's
811
+ # app core, requesting `zephyr` or `baremetal`, is the app-core-swap
812
+ # request this mechanism cannot honor -- `splice_companion_cores` only
813
+ # ever adds an `os:` child to a companion, never an `app:` (that source
814
+ # directory does not exist for anything but the template's own app
815
+ # core), so the result would be a second core silently declared with no
816
+ # source to build there, next to a REQUESTED core-id/os pair that
817
+ # `board.yaml` never actually honors. Before this fix that shape reached
818
+ # `splice_companion_cores` unrejected: `--cores m55_he:zephyr` on a
819
+ # single-core `--som E1M-AEN301` request left the app pinned to the
820
+ # template's guessed `m55_hp`, added the requested `m55_he` app-less, and
821
+ # spliced an unrequested default RPMsg carve-out between the two -- all
822
+ # at `ok:true`/`issues:[]` (the AEN bench e2e that filed the issue: an
823
+ # operator asking for one core got a two-core project on the wrong one).
824
+ # `baremetal` reaches the identical dead end via a different door: the
825
+ # planner's own `_enforce_loader_rules` (tan/planner/validate.py) refuses
826
+ # an app-less `os: baremetal` slice just as hard as an app-less `os:
827
+ # zephyr` one -- `--cores <id>:baremetal` would still plan to `ok:true`
828
+ # here and only fail two commands later, at `tan build`, against a
829
+ # board.yaml the customer never edited by hand.
830
+ #
831
+ # `yocto` is NOT unconditionally safe either -- it reaches the identical
832
+ # dead end on a Cortex-M companion through a THIRD door: the planner's
833
+ # `_enforce_os_matches_core_class` (tan/planner/validate.py) rejects a
834
+ # Cortex-M core running Yocto exactly as hard as it rejects a Cortex-A
835
+ # core running Zephyr. Measured against `E1M-AEN801` (a Cortex-M
836
+ # `m55_he` companion, app core `m55_hp`): `--cores m55_he:yocto` planned
837
+ # to `ok:true`/`issues:[]` here, and `tan validate` on the result then
838
+ # raised `validate.schema-violation` ("its runtime is determined by the
839
+ # core class ... got os: 'yocto'") -- the exact #643 shape, just reached
840
+ # through the one os: value the original fix left unchecked. `tan init`
841
+ # is deliberately SDK-free (module docstring above) and cannot resolve a
842
+ # companion's real `topology.<core>.type`, so this reads the same `a`- vs
843
+ # `m`-prefix convention every SoM topology already follows
844
+ # (`a32_cluster`/`a55_cluster` are Cortex-A; `m33`/`m33_sm`/`m55_hp`/
845
+ # `m55_he` are Cortex-M -- true across all 11 `metadata/e1m_modules/
846
+ # *.yaml` topologies, measured, and the same heuristic
847
+ # `tan/planner/project_emit/hw_info.py` already relies on) to accept
848
+ # `yocto` only for an `a`-prefixed companion id. `off` is the one os:
849
+ # value `_enforce_loader_rules` accepts app-less on ANY core class, so it
850
+ # is unconditionally allowed regardless of prefix (and
851
+ # `splice_companion_cores` now quotes it `"off"` so it round-trips as the
852
+ # schema's string, not YAML 1.1's bareword boolean). Refuse instead of
853
+ # guessing which core the caller actually meant.
854
+ for core_id, os_value in cores:
855
+ if core_id == app_core or os_value == "off":
856
+ continue
857
+ if os_value == "yocto" and core_id.startswith("a"):
858
+ continue
859
+ raise InitError(
860
+ "init.invalid-cores",
861
+ f"Core '{core_id}' requests {os_value}, but this plan's app "
862
+ f"core is '{app_core}' -- --cores can only splice '{core_id}' "
863
+ f"in as an app-less companion, and the only os: values a "
864
+ f"companion can honor app-less are 'off' (always) or 'yocto' "
865
+ f"(only when '{core_id}' is a Cortex-A core, i.e. its id starts "
866
+ f"with 'a') -- {os_value} is not one of those. If '{core_id}' "
867
+ f"is meant to host the app instead of '{app_core}', pick a "
868
+ f"--template/--som combination whose app core is '{core_id}', "
869
+ f"or scaffold with --board-yaml to declare it yourself.",
870
+ ExitCode.VALIDATION_FAILURE,
871
+ )
872
+
873
+ spliced = splice_companion_cores(board.content, cores)
874
+ return [
875
+ PlannedFile("board.yaml", spliced) if f.relative_path == "board.yaml" else f
876
+ for f in files
877
+ ]
878
+
879
+
880
+ def _apply_board_yaml_override(
881
+ files: list[PlannedFile],
882
+ subject_label: str,
883
+ board_yaml_path: str | None,
884
+ *,
885
+ allow_add: bool = False,
886
+ ) -> list[PlannedFile]:
887
+ """Honor `--board-yaml`: swap the planned board.yaml for the caller's file
888
+ verbatim -- this lets Alp Studio adopt `tan init` as its project render.
889
+ Shared by the template and `--from-example` paths (mirrors `init/mod.rs`
890
+ step 5b and `init/from_example.rs`'s identical block).
891
+
892
+ `allow_add` (set only on the `--from-example` path) lets this ADD the
893
+ caller's file as the project's board.yaml when the example plans none --
894
+ the real escape hatch the `init.example-missing-board-yaml` warning names.
895
+ Every registered TEMPLATE plans its own board.yaml, so a plan with none on
896
+ the template path is still a hard error rather than a silent no-op that
897
+ would drop the caller's file."""
898
+ if board_yaml_path is None:
899
+ return files
900
+ try:
901
+ with open(board_yaml_path, encoding="utf-8", newline="") as handle:
902
+ content = handle.read()
903
+ except (OSError, ValueError) as err:
904
+ raise InitError(
905
+ "init.board-yaml-unreadable",
906
+ f"--board-yaml '{board_yaml_path}' could not be read: {err}",
907
+ ExitCode.VALIDATION_FAILURE,
908
+ ) from err
909
+ if not any(f.relative_path == "board.yaml" for f in files):
910
+ if allow_add:
911
+ return [*files, PlannedFile("board.yaml", content)]
912
+ raise InitError(
913
+ "init.board-yaml-unsupported",
914
+ f"--board-yaml was given but {subject_label} has no board.yaml to override.",
915
+ ExitCode.VALIDATION_FAILURE,
916
+ )
917
+ return [
918
+ PlannedFile("board.yaml", content) if f.relative_path == "board.yaml" else f
919
+ for f in files
920
+ ]
921
+
922
+
923
+ def _copied_board_cores(files: list[PlannedFile]) -> object:
924
+ """The copied board.yaml's `cores:` mapping, or `None` when there is no
925
+ board.yaml or it does not parse -- `pregeneration_message` then names a
926
+ `<core-id>` placeholder instead of guessing."""
927
+ board = next((f.content for f in files if f.relative_path == "board.yaml"), None)
928
+ if board is None:
929
+ return None
930
+ try:
931
+ doc = load_yaml_document(board)
932
+ except Exception: # noqa: BLE001 -- unparseable degrades to the placeholder
933
+ return None
934
+ return doc.get("cores") if isinstance(doc, dict) else None
935
+
936
+
937
+ def _plan_from_example(
938
+ src: str, som: str | None, sdk: _Sdk | None, search_note: str = ""
939
+ ) -> tuple[str, list[PlannedFile]]:
940
+ """Copy an SDK example directory verbatim. The ONE init path that needs a
941
+ checkout, because the thing it copies lives in one."""
942
+ src = src.strip()
943
+ if not src:
944
+ raise InitError(
945
+ "init.invalid-example",
946
+ "--from-example requires a non-empty example source directory.",
947
+ ExitCode.VALIDATION_FAILURE,
948
+ )
949
+ if not is_plain_relative(src):
950
+ raise InitError(
951
+ "init.invalid-example",
952
+ f"Invalid example '{src}': must be a relative path under the SDK "
953
+ f"examples/ directory.",
954
+ ExitCode.VALIDATION_FAILURE,
955
+ )
956
+ if sdk is None or not _is_sdk_checkout(sdk.path):
957
+ raise InitError(
958
+ "init.sdk-root-unresolved",
959
+ "alp-sdk root is unresolved. Use --sdk-root or run near an alp-sdk "
960
+ "checkout to copy an example."
961
+ + (f" (tried '{sdk.display}')" if sdk is not None else "")
962
+ + search_note,
963
+ ExitCode.VALIDATION_FAILURE,
964
+ )
965
+
966
+ examples_root = sdk.path / "examples"
967
+ example_dir = examples_root / src
968
+ # Containment guard on the RESOLVED paths, on top of the lexical check
969
+ # above: it is what defeats a directory-symlink escape, which no amount of
970
+ # string inspection can see.
971
+ try:
972
+ contained = example_dir.resolve(strict=True).is_relative_to(
973
+ examples_root.resolve(strict=True)
974
+ )
975
+ except OSError:
976
+ contained = False
977
+ if not contained:
978
+ raise InitError(
979
+ "init.example-not-found",
980
+ f"Example '{src}' was not found under the SDK examples/ directory.",
981
+ ExitCode.VALIDATION_FAILURE,
982
+ )
983
+
984
+ try:
985
+ files = read_example_tree(example_dir)
986
+ except ExampleReadError as err:
987
+ if err.not_found:
988
+ raise InitError(
989
+ "init.example-not-found",
990
+ f"Example '{src}' was not found under the SDK examples/ directory.",
991
+ ExitCode.VALIDATION_FAILURE,
992
+ ) from err
993
+ raise InitError(
994
+ "init.example-unreadable",
995
+ f"Example '{src}' could not be read: {err}",
996
+ ExitCode.RUNTIME_FAILURE,
997
+ ) from err
998
+ if not files:
999
+ raise InitError(
1000
+ "init.example-not-found",
1001
+ f"Example '{src}' contains no files to copy.",
1002
+ ExitCode.VALIDATION_FAILURE,
1003
+ )
1004
+
1005
+ if som:
1006
+ # tan-cli#1351: read BEFORE board.yaml is retargeted below, which
1007
+ # would erase the example's own SKU. `boards/<old board>.{conf,
1008
+ # overlay}` never applies on another SKU's board target.
1009
+ files = retarget_example_board_files(files, som, sdk.path / "metadata")
1010
+ # Retarget the copied board.yaml onto the chosen SoM, so an example can
1011
+ # be scaffolded onto the customer's own module rather than the example's.
1012
+ try:
1013
+ files = [
1014
+ PlannedFile(f.relative_path, retarget_board_yaml_som(f.content, som))
1015
+ if f.relative_path == "board.yaml"
1016
+ else f
1017
+ for f in files
1018
+ ]
1019
+ except FlowStyleSomError as err:
1020
+ # tan-cli#1029. Before this, a flow-style `som:` block
1021
+ # (`som: {sku: ..., hw_rev: ...}`) made `retarget_board_yaml_som`
1022
+ # silently return the example's board.yaml byte-for-byte
1023
+ # unchanged -- `--som` discarded with `issues: []` and exit 0,
1024
+ # indistinguishable from success. Refusing here means the files
1025
+ # are never written rather than written wrong.
1026
+ raise InitError(
1027
+ "init.som-flow-style-unsupported",
1028
+ f"Example '{src}' board.yaml: {err}",
1029
+ ExitCode.VALIDATION_FAILURE,
1030
+ ) from err
1031
+ except UnreadableSomBlockError as err:
1032
+ # tan-cli#1041 (the amendment). Same symptom as the flow-style
1033
+ # refusal immediately above -- `--som` silently discarded,
1034
+ # `issues: []`, exit 0 -- for the three shapes that are not
1035
+ # flow-style at all: a `*alias`, an un-overridden `<<:` merge
1036
+ # key (inside the `som:` block or at the document root), and a
1037
+ # flow mapping split across more than one physical line. A
1038
+ # DIFFERENT code from `init.som-flow-style-unsupported`
1039
+ # (rather than folding these into it) because the customer
1040
+ # remediation reads the same but the diagnosis does not -- a
1041
+ # tool consuming this code some day should not have to string-
1042
+ # match the message to tell "your `som:` is flow style" apart
1043
+ # from "your `som:` uses a YAML feature (alias/merge) this
1044
+ # scaffolder does not follow".
1045
+ raise InitError(
1046
+ "init.som-block-unsupported",
1047
+ f"Example '{src}' board.yaml: {err}",
1048
+ ExitCode.VALIDATION_FAILURE,
1049
+ ) from err
1050
+ except SomBlockUnsupportedError as err:
1051
+ # tan-cli#1060 review: `SomBlockUnsupportedError`'s own docstring
1052
+ # promises every call site catches THIS base, not either leaf
1053
+ # above, so a THIRD leaf (the next `som:` spelling this file
1054
+ # learns to refuse) needs no call site touched outside
1055
+ # `scaffold.py`. Before this trailing clause that promise was
1056
+ # false here specifically -- the two `except` blocks above catch
1057
+ # only the two leaves that exist today, so an unenumerated third
1058
+ # leaf would fall through uncaught and surface as
1059
+ # `init.internal-failure` (measured with a temporary third leaf:
1060
+ # exit code 5, message "init failed unexpectedly: ..."), loud but
1061
+ # not the customer-actionable `ExitCode.VALIDATION_FAILURE` its
1062
+ # two siblings give today. Same code as the generic
1063
+ # `UnreadableSomBlockError` case just above (the customer
1064
+ # remediation is the same "this `som:` block isn't one tan's
1065
+ # scaffolder can retarget"); a leaf specific enough to need its
1066
+ # own code/message earns its own `except` clause ahead of this
1067
+ # one, same as `UnreadableSomBlockError` did.
1068
+ raise InitError(
1069
+ "init.som-block-unsupported",
1070
+ f"Example '{src}' board.yaml: {err}",
1071
+ ExitCode.VALIDATION_FAILURE,
1072
+ ) from err
1073
+ return f"example:{src}", files
1074
+
1075
+
1076
+ def _plan_from_topology(
1077
+ topology_raw: str, som: str | None, sdk: _Sdk | None, search_note: str = ""
1078
+ ) -> tuple[str, list[PlannedFile]]:
1079
+ """Resolve `--topology` to a catalog record, then delegate to
1080
+ `_plan_from_example` exactly as if `--from-example <record's example>`
1081
+ had been given -- one resolution step ahead of the existing path, not a
1082
+ second file-copying implementation."""
1083
+ try:
1084
+ cores = parse_topology_arg(topology_raw)
1085
+ except ValueError as err:
1086
+ raise InitError(
1087
+ "init.invalid-topology", str(err), ExitCode.VALIDATION_FAILURE
1088
+ ) from err
1089
+ if sdk is None or not _is_sdk_checkout(sdk.path):
1090
+ raise InitError(
1091
+ "init.sdk-root-unresolved",
1092
+ "alp-sdk root is unresolved. Use --sdk-root or run near an "
1093
+ "alp-sdk checkout to select a template by --topology."
1094
+ + (f" (tried '{sdk.display}')" if sdk is not None else "")
1095
+ + search_note,
1096
+ ExitCode.VALIDATION_FAILURE,
1097
+ )
1098
+ try:
1099
+ src = find_example_by_cores(sdk.path, cores)
1100
+ except MalformedCatalogError as err:
1101
+ # tan-cli#1084: a malformed catalog escaped BOTH handlers below as a
1102
+ # raw traceback. Handled on its own class, not on the shared
1103
+ # `CoresTopologyError` base, so the envelope names the catalog
1104
+ # rather than implying the requested topology was the problem.
1105
+ raise InitError(
1106
+ "init.catalog-malformed", str(err), ExitCode.VALIDATION_FAILURE
1107
+ ) from err
1108
+ except CoresTopologyNotFoundError as err:
1109
+ raise InitError(
1110
+ "init.topology-not-found", str(err), ExitCode.VALIDATION_FAILURE
1111
+ ) from err
1112
+ except AmbiguousCoresTopologyError as err:
1113
+ raise InitError(
1114
+ "init.topology-ambiguous", str(err), ExitCode.VALIDATION_FAILURE
1115
+ ) from err
1116
+ return _plan_from_example(src, som, sdk, search_note)
1117
+
1118
+
1119
+ # ---------------------------------------------------------------------------
1120
+ # Diff, then preview / guard / write
1121
+ # ---------------------------------------------------------------------------
1122
+
1123
+
1124
+ def overwrite_refusal_message(changes: list[FileChange]) -> str:
1125
+ """Name the files that actually collide, and offer the non-destructive
1126
+ option first. `--force` is the only escape the old wording named, which
1127
+ made overwriting look like the intended next step rather than the last
1128
+ resort."""
1129
+ colliding = [c.relative_path for c in changes if c.kind == "update"]
1130
+ listed = ", ".join(sorted(colliding))
1131
+ return (
1132
+ f"{len(colliding)} file(s) would be overwritten: {listed}. "
1133
+ "Run with --preview to see the full plan, --destination <DIR> or "
1134
+ "--name <NAME> to write somewhere else, or --force to overwrite."
1135
+ )
1136
+
1137
+
1138
+ def _finish(
1139
+ template_id: str,
1140
+ destination: str,
1141
+ project_root: Path,
1142
+ project_root_display: str,
1143
+ files: list[PlannedFile],
1144
+ *,
1145
+ preview: bool,
1146
+ force: bool,
1147
+ sdk: _Sdk | None,
1148
+ ) -> _Outcome:
1149
+ changes = collect_file_changes(project_root, files)
1150
+ # tan-cli#1484: an existing `.alp/sdk-path` that names a DIFFERENT checkout
1151
+ # is a planned change like any other file, so the would-overwrite guard
1152
+ # below covers it. Re-pinning the project to another SDK silently is the
1153
+ # failure this prevents.
1154
+ pin_change = _sdk_pin_change(project_root, sdk)
1155
+ if pin_change is not None:
1156
+ changes.append(pin_change)
1157
+
1158
+ if preview:
1159
+ # Before the overwrite guard, deliberately: a preview touches no disk,
1160
+ # so there is nothing to guard, and guarding it turned a read-only
1161
+ # question into a failure on any project with local edits.
1162
+ return _Outcome(
1163
+ template_id=template_id,
1164
+ destination=destination,
1165
+ project_root=project_root_display,
1166
+ preview=True,
1167
+ file_changes=changes,
1168
+ files=files,
1169
+ )
1170
+
1171
+ if any(c.kind == "update" for c in changes) and not force:
1172
+ return _Outcome(
1173
+ template_id=template_id,
1174
+ destination=destination,
1175
+ project_root=project_root_display,
1176
+ preview=False,
1177
+ file_changes=changes,
1178
+ files=files,
1179
+ issues=[
1180
+ Issue(
1181
+ "init.would-overwrite",
1182
+ "error",
1183
+ overwrite_refusal_message(changes),
1184
+ )
1185
+ ],
1186
+ exit_code=ExitCode.WRITE_FAILURE,
1187
+ )
1188
+
1189
+ # A destination that exists as a FILE would otherwise surface as an
1190
+ # errno-flavoured mkdir/write failure a few frames later, naming a path
1191
+ # inside a file. Say it plainly, and before anything is written.
1192
+ if project_root.exists() and not project_root.is_dir():
1193
+ raise InitError(
1194
+ "init.write-failed",
1195
+ f"Destination '{posix(project_root)}' exists and is not a directory.",
1196
+ ExitCode.WRITE_FAILURE,
1197
+ )
1198
+
1199
+ try:
1200
+ result = write_files(project_root, files)
1201
+ except ScaffoldWriteError as err:
1202
+ raise InitError(
1203
+ "init.write-failed",
1204
+ f"Failed to write files: {err}",
1205
+ ExitCode.WRITE_FAILURE,
1206
+ partial=(err.partial.written, err.partial.unchanged),
1207
+ ) from err
1208
+
1209
+ try:
1210
+ sdk_pinned = _pin_sdk(project_root, sdk)
1211
+ except (PathEscapeError, OSError, ValueError) as err:
1212
+ # The project's own files already landed (`result` above); `partial`
1213
+ # carries them so a consumer knows what to clean up rather than
1214
+ # reading `written: []` for a project that is really half on disk --
1215
+ # same reasoning as the `ScaffoldWriteError` branch just above.
1216
+ raise InitError(
1217
+ "init.write-failed",
1218
+ f"refusing to pin the SDK: {err}",
1219
+ ExitCode.WRITE_FAILURE,
1220
+ partial=(result.written, result.unchanged),
1221
+ ) from err
1222
+
1223
+ return _Outcome(
1224
+ template_id=template_id,
1225
+ destination=destination,
1226
+ project_root=project_root_display,
1227
+ preview=False,
1228
+ file_changes=changes,
1229
+ files=files,
1230
+ written=result.written,
1231
+ unchanged=result.unchanged,
1232
+ sdk_pinned=sdk_pinned,
1233
+ )
1234
+
1235
+
1236
+ def _sdk_pin_change(project_root: Path, sdk: _Sdk | None) -> FileChange | None:
1237
+ """`update` for `.alp/sdk-path` when it already pins a different checkout
1238
+ than the one this run resolved (or is unreadable); `None` otherwise."""
1239
+ if sdk is None or not _is_sdk_checkout(sdk.path):
1240
+ return None
1241
+ pointer = project_root / ".alp" / "sdk-path"
1242
+ if not pointer.is_file():
1243
+ return None
1244
+ try:
1245
+ existing = json.loads(pointer.read_text(encoding="utf-8")).get("sdkPath")
1246
+ except (OSError, ValueError, AttributeError):
1247
+ existing = None
1248
+ if existing == sdk.display:
1249
+ return None
1250
+ return FileChange(".alp/sdk-path", "update")
1251
+
1252
+
1253
+ def _pin_sdk(project_root: Path, sdk: _Sdk | None) -> str | None:
1254
+ """Record the resolved SDK in `<project>/.alp/sdk-path` so the new project is
1255
+ reproducible without a separate `tan sdk switch`.
1256
+
1257
+ `None` and "not a real checkout" are a silent skip HERE -- an optional
1258
+ convenience pointer is never a reason to fail a `tan init` whose real
1259
+ files already landed. Reached only after the write, so `--preview` can
1260
+ never trip it. Not silent on the WIRE any more when the caller passed an
1261
+ explicit `--sdk-root` that failed the checkout check: `init()`'s own
1262
+ `_sdk_root_flag_unresolved_issue` (tan-cli#642) reports that specific
1263
+ case as a coded warning before this function ever runs, since this
1264
+ function's job is only the write, not the diagnosis, and an unresolved
1265
+ default-ladder SDK (no `--sdk-root` given at all) is not a misconfig this
1266
+ layer has anything to say about.
1267
+
1268
+ A confinement ESCAPE is different: a pre-existing symlinked (or
1269
+ junctioned) `.alp`, or a symlinked parent of it, used to carry this write
1270
+ to wherever that link pointed while still reporting the logical
1271
+ `.alp/sdk-path` as pinned (tan-cli#325, the same defect `scaffold.py`'s
1272
+ `write_files` and `generate_cmd`'s default outputs already close). Checked
1273
+ with the SAME shared guard those two use (`tan.core.fs_confine`) before
1274
+ anything is written, and raised rather than swallowed -- `_finish` turns
1275
+ it into a coded `init.write-failed` refusal instead of a silent
1276
+ `sdkPinned: null` that would hide bytes landing outside the project. Any
1277
+ OTHER write failure (permission denied, a full disk) still returns `None`:
1278
+ it is a real filesystem problem, not an escape, and the existing
1279
+ silent-skip contract for those is unchanged.
1280
+ """
1281
+ if sdk is None or not _is_sdk_checkout(sdk.path):
1282
+ return None
1283
+ sdk_path = sdk.display
1284
+ pointer = resolve_confined(project_root, project_root / ".alp" / "sdk-path")
1285
+ try:
1286
+ pointer.parent.mkdir(parents=True, exist_ok=True)
1287
+ with pointer.open("w", encoding="utf-8", newline="") as handle:
1288
+ handle.write(sdk_pointer_json(sdk_path))
1289
+ except OSError:
1290
+ return None
1291
+ return sdk_path
1292
+
1293
+
1294
+ # ---------------------------------------------------------------------------
1295
+ # Entry point
1296
+ # ---------------------------------------------------------------------------
1297
+
1298
+
1299
+ def init(
1300
+ template: str = typer.Option(
1301
+ None,
1302
+ "--template",
1303
+ metavar="ID",
1304
+ help=(
1305
+ f"Project template: {', '.join(TEMPLATE_IDS)}. tan's own curated "
1306
+ f"starter set -- a different, smaller id space than the SDK's "
1307
+ f"example catalog; use --from-example for any other SDK example "
1308
+ f"(e.g. peripheral-io/gpio-button-led for a button+LED starter)."
1309
+ ),
1310
+ ),
1311
+ from_example: str = typer.Option(
1312
+ None,
1313
+ "--from-example",
1314
+ metavar="DIR",
1315
+ help="Copy an SDK example (e.g. peripheral-io/hello-world) instead of a template.",
1316
+ ),
1317
+ name: str = typer.Option(
1318
+ None, "--name", metavar="NAME", help="Subdirectory to create under the destination."
1319
+ ),
1320
+ destination: str = typer.Option(
1321
+ None, "--destination", metavar="PATH", help="Destination directory (defaults to '.')."
1322
+ ),
1323
+ som: str = typer.Option(
1324
+ None,
1325
+ "--som",
1326
+ # tan-cli#720: `--sku` accepted as an alias. The value IS a SKU string
1327
+ # -- `tan presets` prints it under `skus=`, `board.yaml` nests it as
1328
+ # `som.sku`, and `tan pinmux`/`tan new-som` spell the same value
1329
+ # `--sku`. A customer who scaffolds with `--som` and then runs
1330
+ # `tan pinmux --som ...` was rejected for the flag `init` had just
1331
+ # taught them. Each command keeps its existing name FIRST, so help
1332
+ # text and every existing script are unchanged; only an alias is added.
1333
+ "--sku",
1334
+ metavar="SKU",
1335
+ help="SoM SKU to target in the generated board.yaml (alias: --sku).",
1336
+ ),
1337
+ board_yaml: str = typer.Option(
1338
+ None,
1339
+ "--board-yaml",
1340
+ metavar="PATH",
1341
+ help="Render this file verbatim as the scaffolded board.yaml instead of the planned one.",
1342
+ ),
1343
+ cores: str = typer.Option(
1344
+ None,
1345
+ "--cores",
1346
+ metavar="CORES",
1347
+ help=(
1348
+ "Comma-separated cores for a heterogeneous project, `id[:os]` "
1349
+ "(e.g. `m33_sm:zephyr,a55_cluster:yocto`). OS is inferred from "
1350
+ "the id when omitted, but that inference is only honored for "
1351
+ "the plan's app core -- any other id can only be spliced in "
1352
+ "app-less, as `:off` or (on a Cortex-A id) `:yocto`, so a bare "
1353
+ "companion id like `m55_he` infers `:zephyr` and is refused "
1354
+ "unless `m55_he` is the app core. SPLICES onto an already-"
1355
+ "chosen template/example -- to instead CHOOSE which template to "
1356
+ "use by its hardware topology, see --topology. Mutually "
1357
+ "exclusive with --topology, which has no already-chosen "
1358
+ "template left for this to splice onto."
1359
+ ),
1360
+ ),
1361
+ topology: str = typer.Option(
1362
+ None,
1363
+ "--topology",
1364
+ metavar="TOPOLOGY",
1365
+ help=(
1366
+ "core_id:os[,core_id:os...] hardware topology (e.g. "
1367
+ "'m55_hp:zephyr,m55_he:zephyr'); an alternative to --template/"
1368
+ "--from-example that SELECTS whichever SDK catalog template's "
1369
+ "own cores: topology matches exactly, instead of naming a "
1370
+ "template id or example path directly. Not --cores, which "
1371
+ "splices a companion core onto an ALREADY-CHOSEN template "
1372
+ "instead of choosing one. Mutually exclusive with --template, "
1373
+ "--from-example, AND --cores (there is no already-chosen "
1374
+ "template left for --cores to splice onto -- add the extra "
1375
+ "core to --topology itself instead); needs a resolved SDK "
1376
+ "checkout."
1377
+ ),
1378
+ ),
1379
+ preview: bool = typer.Option(
1380
+ False, "--preview", help="Show the planned files and write nothing."
1381
+ ),
1382
+ force: bool = typer.Option(
1383
+ False, "--force", help="Allow overwriting files that already exist."
1384
+ ),
1385
+ project: str = typer.Option(
1386
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
1387
+ ),
1388
+ sdk_root: str = typer.Option(
1389
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
1390
+ ),
1391
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
1392
+ verbose: bool = typer.Option(
1393
+ False, "--verbose", help="Emit additional diagnostic detail."
1394
+ ),
1395
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress non-essential output."),
1396
+ no_color: bool = typer.Option(
1397
+ False, "--no-color", help="Disable ANSI color in text output."
1398
+ ),
1399
+ target: str = typer.Option(
1400
+ None,
1401
+ "--target",
1402
+ metavar="EMIT",
1403
+ help="Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf).",
1404
+ ),
1405
+ all_targets: bool = typer.Option(
1406
+ False, "--all", help="Run command against all relevant targets."
1407
+ ),
1408
+ ) -> None:
1409
+ """Scaffold a new project from a template or an SDK example."""
1410
+ # The five options above are `clap`'s `GlobalArgs` members that
1411
+ # `crates/tan-cli/src/commands/init/` accepts and never reads (see the
1412
+ # module docstring) -- declared here (NOT `hidden=True`, unlike
1413
+ # `clean_cmd.clean`'s equivalent block) because the real `tan init --help`
1414
+ # DOES list all five: they are `global = true` in clap, so every
1415
+ # subcommand's help renders them whether or not that subcommand's own
1416
+ # code reads them. Hiding them here would parse identically but make
1417
+ # `tan init --help`'s TEXT diverge from the oracle's.
1418
+ del verbose, quiet, no_color, target, all_targets
1419
+ json_mode = output_format == "json"
1420
+
1421
+ # Bound BEFORE the `try` (tan-cli#491 defect 5): both `except` arms emit an
1422
+ # envelope, and an `InitError` raised before `_resolve_sdk_root` runs (a bad
1423
+ # `--template`, an unreadable `--board-yaml`) must not turn the `sdk` block
1424
+ # into an `UnboundLocalError` inside the very handler whose job is to keep a
1425
+ # traceback off stdout.
1426
+ resolved_sdk: _Sdk | None = None
1427
+ try:
1428
+ # `--destination`, then the global `--project`, then `.`. `--name` does
1429
+ # NOT answer this: it names a subdirectory INSIDE the destination, so
1430
+ # "which directory does my-app go in" is still a real question with
1431
+ # `--name` set.
1432
+ dest = destination if destination else (project if project else ".")
1433
+ resolved_name = _resolve_name(name)
1434
+ # `project_root` (the `Path`) does the real filesystem work, so it goes
1435
+ # through pathlib; `project_root_display` is the TEXT-mode "created"
1436
+ # line's own literal string join (`_join_display`), because pathlib's
1437
+ # `Path(dest) / name` silently drops a leading `.` from `dest` that the
1438
+ # oracle's `PathBuf::join` keeps (`tan-cli` review, scaffold-cx#5). The
1439
+ # destination itself is echoed back verbatim (unjoined) in the envelope.
1440
+ project_root = Path(dest) / resolved_name if resolved_name else Path(dest)
1441
+ project_root_display = _join_display(dest, resolved_name)
1442
+
1443
+ workspace_root = Path(os.path.abspath(project)) if project else _cwd_or_dot()
1444
+ resolved_sdk = _resolve_sdk_root(sdk_root, workspace_root)
1445
+ # tan-cli#407, and this is the command where it matters most: `init`
1446
+ # does not merely resolve an SDK, it WRITES the answer to
1447
+ # `.alp/sdk-path` (`_record_sdk_pin`, below), and that pointer then
1448
+ # outranks BOTH discovery tiers for every later command in the new
1449
+ # project. So the collision is settled here, permanently, in the user's
1450
+ # favour or against it -- and the wide ladder settles it on the CHILD
1451
+ # `<ws>/alp-sdk` while thirteen commands would have taken the lateral
1452
+ # `../alp-sdk`. Disclosing which one got bound is the whole point:
1453
+ # after this run the divergence is genuinely GONE (both ladders read
1454
+ # the pin), so this is the last moment the fact is observable at all.
1455
+ # Computed BEFORE `_finish` for exactly that reason.
1456
+ divergence_issue = sdk_ladder_divergence_issue(sdk_root, workspace_root, wide=True)
1457
+ # tan-cli#464 review: `init` is the command a foreign `globalDefault`
1458
+ # hurts most of all -- `_pin_sdk` below is about to bake whatever
1459
+ # `resolved_sdk` names into `<project>/.alp/sdk-path` PERMANENTLY, so
1460
+ # a caller resolving a checkout a DIFFERENT project's bootstrap
1461
+ # relocation actually wrote must be told before that pin lands, not
1462
+ # after. Computed here, alongside `divergence_issue`, for the same
1463
+ # reason: this is the last moment the fact is cheap to disclose --
1464
+ # once pinned, every later command in the new project reads the pin
1465
+ # tier instead and this warning never fires again.
1466
+ foreign_issue = global_default_foreign_project_issue(
1467
+ resolved_sdk.foreign_global_default_for if resolved_sdk is not None else None
1468
+ )
1469
+ # tan-cli#642: computed here, alongside the other two SDK-resolution
1470
+ # warnings above, for the same reason -- this is the last moment the
1471
+ # fact is cheap to disclose, before `_finish` -> `_pin_sdk` silently
1472
+ # declines to write `.alp/sdk-path` for it.
1473
+ sdk_root_invalid_issue = _sdk_root_flag_unresolved_issue(sdk_root, resolved_sdk)
1474
+
1475
+ # `--topology`, `--template`, `--from-example` all answer the same
1476
+ # question (which project to scaffold) -- refuse rather than
1477
+ # silently prefer one, the same posture `find_example_by_cores`
1478
+ # itself takes for an ambiguous topology match (tan-cli#996).
1479
+ _chosen = [n for n, v in (
1480
+ ("--topology", topology), ("--template", template),
1481
+ ("--from-example", from_example),
1482
+ ) if v is not None]
1483
+ if len(_chosen) > 1:
1484
+ raise InitError(
1485
+ "init.scaffold-input-conflict",
1486
+ f"tan init takes at most one of --template, --from-example, "
1487
+ f"--topology; got {', '.join(_chosen)}.",
1488
+ ExitCode.VALIDATION_FAILURE,
1489
+ )
1490
+
1491
+ # tan-cli#1001 review: `--topology` ALREADY selects the template by
1492
+ # its full hardware topology -- `--cores` combined with it has
1493
+ # nothing left to splice onto, since there is no "already-chosen
1494
+ # template" yet for `--cores` to attach a companion core to (unlike
1495
+ # the `--template`/default path below, where `_plan_from_template`
1496
+ # genuinely reads `cores`). Before this check, `--cores` on the
1497
+ # `--topology` path was silently discarded: `ok: true`, exit 0,
1498
+ # `issues: []`, and no trace of the requested core in the written
1499
+ # `board.yaml` -- measured. Refuse instead, the same posture the
1500
+ # `_chosen` conflict above takes for two scaffold-source flags.
1501
+ # `--from-example` + `--cores` is a DIFFERENT, pre-existing case
1502
+ # (the example ships its own board.yaml, so `--cores` is silently
1503
+ # ignored there too, documented at `_plan_from_example`'s call site
1504
+ # below) -- not touched here; only the `--topology` combination is
1505
+ # new in this PR and had no such note.
1506
+ # tan-cli#1484: `--board-yaml` replaces the planned board.yaml
1507
+ # verbatim AFTER `--cores` was spliced into it, so the core vanished
1508
+ # with `ok: true`. Same silent-discard shape, same refusal.
1509
+ if isinstance(board_yaml, str) and isinstance(cores, str):
1510
+ raise InitError(
1511
+ "init.scaffold-input-conflict",
1512
+ "tan init --board-yaml supplies the whole board.yaml, so --cores "
1513
+ "would be silently discarded. Put the core in your board.yaml, "
1514
+ "or drop --board-yaml.",
1515
+ ExitCode.VALIDATION_FAILURE,
1516
+ )
1517
+
1518
+ if topology is not None and cores is not None:
1519
+ raise InitError(
1520
+ "init.scaffold-input-conflict",
1521
+ "tan init --topology already selects the template by its "
1522
+ "full hardware topology; --cores has nothing left to splice "
1523
+ "onto and would be silently ignored. Add the extra core to "
1524
+ "--topology instead (e.g. --topology "
1525
+ "m55_hp:zephyr,a32_cluster:yocto), or drop --topology and "
1526
+ "use --template/--from-example with --cores.",
1527
+ ExitCode.VALIDATION_FAILURE,
1528
+ )
1529
+
1530
+ # tan-cli#1463: where the ladder looked, appended to the unresolved
1531
+ # refusal. An explicit `--sdk-root` is terminal (I-31), so it adds none.
1532
+ # Computed only when a refusal needs it: `Path.absolute()` raises if the
1533
+ # cwd has been removed, which a preview must still survive.
1534
+ search_note = ""
1535
+ if (topology is not None or from_example is not None) and (
1536
+ resolved_sdk is None or not _is_sdk_checkout(resolved_sdk.path)
1537
+ ):
1538
+ search_note = with_sdk_search("", workspace_root, sdk_root)
1539
+ if topology is not None:
1540
+ template_id, files = _plan_from_topology(topology, som, resolved_sdk, search_note)
1541
+ # `find_example_by_cores` resolved to an example, so this is the
1542
+ # same shape `_plan_from_example` returns directly below.
1543
+ subject_label = f"example '{template_id[len('example:') :]}'"
1544
+ elif from_example is not None:
1545
+ template_id, files = _plan_from_example(from_example, som, resolved_sdk, search_note)
1546
+ # `--cores` is ignored on this path (the example ships its own
1547
+ # board.yaml); `--board-yaml`'s subject names the example, not a
1548
+ # template id -- `template_id` here is `"example:<src>"`.
1549
+ subject_label = f"example '{template_id[len('example:') :]}'"
1550
+ else:
1551
+ template_id, files = _plan_from_template(template, som, cores)
1552
+ subject_label = f"template '{template_id}'"
1553
+
1554
+ # `--topology` resolves to an example the same way `--from-example`
1555
+ # does (`template_id` starts "example:" either way), so both share
1556
+ # the same `allow_add`/SoM-support/missing-board.yaml treatment below
1557
+ # -- a topology-resolved example is not a template's own plan and can
1558
+ # equally have no board.yaml or a supported.som_skus mismatch.
1559
+ is_example_shaped = template_id.startswith("example:")
1560
+
1561
+ files = _apply_board_yaml_override(
1562
+ files, subject_label, board_yaml, allow_add=is_example_shaped
1563
+ )
1564
+
1565
+ # `tan build` discovers a project's board.yaml at its root; an example
1566
+ # tree with none would otherwise succeed here and only fail two
1567
+ # commands later at `tan build` ("no board.yaml found"). WARN at
1568
+ # scaffold time instead of refusing the copy outright: 57 of 66
1569
+ # examples/aen/* (raw west/twister examples, not meant for the `tan
1570
+ # build` flow) have no board.yaml, and refusing `tan init
1571
+ # --from-example` for nearly the whole AEN family is worse than
1572
+ # scaffolding something that still needs one. `--board-yaml` (above,
1573
+ # `allow_add=True` on this path) is the real fix, so it never fires
1574
+ # when one was given. Every registered TEMPLATE plans its own
1575
+ # board.yaml (tan.core.scaffold), so this can only trip on
1576
+ # --from-example.
1577
+ # tan-cli#890: this path retargets `--som` onto the copied board.yaml
1578
+ # without asking the catalog whether the example supports that SKU.
1579
+ # WARNS rather than refuses, for the same reason as the board.yaml
1580
+ # case just below; `tan/core/example_catalog.py` carries the full
1581
+ # reasoning and the "cannot tell means silent" rule.
1582
+ example_som_issue = None
1583
+ if is_example_shaped and som is not None and resolved_sdk is not None:
1584
+ example_src = template_id[len("example:") :]
1585
+ example_som_issue = _example_som_catalog_issue(
1586
+ resolved_sdk, example_src, som, subject_label
1587
+ )
1588
+
1589
+ missing_board_yaml_issue = None
1590
+ if is_example_shaped and not any(f.relative_path == "board.yaml" for f in files):
1591
+ missing_board_yaml_issue = Issue(
1592
+ "init.example-missing-board-yaml",
1593
+ "warning",
1594
+ f"{subject_label} has no board.yaml, so `tan build` will not "
1595
+ f"find a board to build here; pass --board-yaml to add one.",
1596
+ )
1597
+
1598
+ # alp-sdk#866: the copied example may read a pre-generated
1599
+ # `generated/alp.conf` its own prose cannot produce outside alp-sdk.
1600
+ # `--from-example` copies verbatim, so say which command does -- see
1601
+ # `tan.core.alp_conf_pregen`.
1602
+ pregen_issue = None
1603
+ if is_example_shaped and resolved_sdk is not None:
1604
+ pregen_text = pregeneration_message(
1605
+ files, _copied_board_cores(files), resolved_sdk.display, subject_label
1606
+ )
1607
+ if pregen_text is not None:
1608
+ pregen_issue = Issue("init.alp-conf-pregeneration", "info", pregen_text)
1609
+
1610
+ # tan-cli#743: a scaffolded board.yaml with no explicit `hw_rev:`
1611
+ # resolves, at validate/build time, to its SoM preset's own
1612
+ # `default_hw_rev:` -- so a SoM whose default revision the SDK
1613
+ # itself marks not-buildable (`status: reserved`/`tbd`/absent)
1614
+ # produces a project whose FIRST `tan validate` hard-errors, with
1615
+ # `init` never having said a word. `--board-yaml` renders customer
1616
+ # content verbatim, whose effective SKU/hw_rev this command does not
1617
+ # parse, so that path is left alone.
1618
+ #
1619
+ # tan-cli#1008 review majors 1+2: the SKU/hw_rev this check judges
1620
+ # are read off the PLANNED board.yaml itself (`vendored_som`), not
1621
+ # `--som`/`DEFAULT_SOM_SKU` -- `--som` alone missed a not-buildable
1622
+ # SKU a bare `--from-example`/`--topology` already writes to disk
1623
+ # (`--som` is `None` there), and `retarget_board_yaml_som` only drops
1624
+ # an example's explicit `hw_rev:` on a CROSS-family retarget (review
1625
+ # round 4) -- an unretargeted example's own value, or one surviving
1626
+ # a same-family retarget, must be read rather than assumed absent.
1627
+ # This also obsoletes the old `is_example_shaped` gate on
1628
+ # `--som`-required-to-know-the-SKU: the file always carries a `sku:`
1629
+ # once one has been planned, on every path (template, example,
1630
+ # topology) alike.
1631
+ hw_rev_issue = None
1632
+ if board_yaml is None and resolved_sdk is not None:
1633
+ board_file = next((f for f in files if f.relative_path == "board.yaml"), None)
1634
+ if board_file is not None:
1635
+ try:
1636
+ file_sku, file_hw_rev = vendored_som(board_file.content)
1637
+ except SomBlockUnsupportedError:
1638
+ # tan-cli#1029/#1041. Only reachable here with no
1639
+ # `--som` at all (a `--som` retarget onto any of these
1640
+ # shapes already refused earlier, in
1641
+ # `_plan_from_example`) -- a `som:` block this advisory
1642
+ # check cannot read (flow style, an alias, an
1643
+ # un-overridden merge key -- see `SomBlockUnsupportedError`'s
1644
+ # two leaves), so it degrades the same way an unparsed
1645
+ # `--board-yaml` already does above: this specific pair
1646
+ # is simply not judged, not a hard failure.
1647
+ file_sku, file_hw_rev = None, None
1648
+ else:
1649
+ file_sku, file_hw_rev = None, None
1650
+ effective_sku = file_sku if file_sku is not None else (
1651
+ som if is_example_shaped else (som or DEFAULT_SOM_SKU)
1652
+ )
1653
+ if effective_sku is not None:
1654
+ not_buildable = hw_rev_not_buildable(
1655
+ resolved_sdk.path, effective_sku, file_hw_rev
1656
+ )
1657
+ if not_buildable is not None:
1658
+ status_repr = (
1659
+ f"status: {not_buildable.status!r}"
1660
+ if not_buildable.status is not None
1661
+ else "carries no `status:` key"
1662
+ )
1663
+ if file_hw_rev is not None:
1664
+ resolution_clause = (
1665
+ f"the scaffolded board.yaml explicitly sets `hw_rev: "
1666
+ f"{file_hw_rev}`"
1667
+ )
1668
+ promotion_clause = "this hardware revision is promoted"
1669
+ else:
1670
+ resolution_clause = (
1671
+ "the scaffolded board.yaml sets no explicit `hw_rev:` "
1672
+ "and so resolves to this SoM's default"
1673
+ )
1674
+ promotion_clause = "its default hardware revision is promoted"
1675
+ alternative_clause = (
1676
+ ", or until board.yaml names a buildable `hw_rev:` explicitly"
1677
+ if not_buildable.has_buildable_alternative
1678
+ else ""
1679
+ )
1680
+ hw_rev_issue = Issue(
1681
+ "init.hw-rev-not-buildable",
1682
+ "warning",
1683
+ f"SoM {not_buildable.sku} hw_rev {not_buildable.hw_rev!r} "
1684
+ f"exists but is not buildable ({status_repr}) -- the check "
1685
+ f"`tan validate` will refuse next, since {resolution_clause}. "
1686
+ f"The files were still written -- `tan build` will not work "
1687
+ f"against this SoM until {promotion_clause}{alternative_clause}.",
1688
+ )
1689
+
1690
+ outcome = _finish(
1691
+ template_id,
1692
+ dest,
1693
+ project_root,
1694
+ project_root_display,
1695
+ files,
1696
+ preview=preview,
1697
+ force=force,
1698
+ sdk=resolved_sdk,
1699
+ )
1700
+ if example_som_issue is not None:
1701
+ outcome.issues.append(example_som_issue)
1702
+ if missing_board_yaml_issue is not None:
1703
+ outcome.issues.append(missing_board_yaml_issue)
1704
+ if hw_rev_issue is not None:
1705
+ outcome.issues.append(hw_rev_issue)
1706
+ if divergence_issue is not None:
1707
+ outcome.issues.append(divergence_issue)
1708
+ if foreign_issue is not None:
1709
+ outcome.issues.append(foreign_issue)
1710
+ if sdk_root_invalid_issue is not None:
1711
+ outcome.issues.append(sdk_root_invalid_issue)
1712
+ if pregen_issue is not None:
1713
+ outcome.issues.append(pregen_issue)
1714
+ except InitError as err:
1715
+ _emit_error(json_mode, err, resolved_sdk)
1716
+ return
1717
+ except Exception as err: # noqa: BLE001 -- the backstop; see the module docstring
1718
+ # Nothing gets to replace the envelope with a traceback. `typer.Exit`
1719
+ # cannot reach here: it is only ever raised from the emit helpers, which
1720
+ # run outside this try (or from within the handler above, which
1721
+ # propagates).
1722
+ _emit_error(
1723
+ json_mode,
1724
+ InitError(
1725
+ "init.internal-failure",
1726
+ f"init failed unexpectedly: {err.__class__.__name__}: {err}",
1727
+ ExitCode.INTERNAL_FAILURE,
1728
+ ),
1729
+ resolved_sdk,
1730
+ )
1731
+ return
1732
+
1733
+ _emit_outcome(json_mode, outcome, resolved_sdk)
1734
+
1735
+
1736
+ # tan-cli#261: adds the two oracle `GlobalArgs` flags this command was still
1737
+ # missing (`--ci`/`--non-interactive`) on top of the five already declared
1738
+ # above (`--verbose`/`--quiet`/`--no-color`/`--target`/`--all`, all read for
1739
+ # real); see `tan.core.global_flags`.
1740
+ init = accept_global_flags(init)