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,1073 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan support-bundle` -- export a diagnostic bundle (inspect + trace +
3
+ doctor) to a JSON file for attaching to a bug report.
4
+
5
+ Port of `crates/tan-cli/src/commands/support_bundle.rs`. Composes three
6
+ sections into one written file: the resolved debug context + its resolved
7
+ values (`inspect_cmd`'s own model), the generation-trace decisions
8
+ (`trace_cmd`'s own model), and a doctor report -- then returns a stdout
9
+ envelope naming the written path + a decision count. Exit follows the
10
+ oracle's rule verbatim: `DOCTOR_FAILURE` (4) when the bundled doctor
11
+ section's `summary.fail > 0` (`support_bundle.rs`: `let exit = if
12
+ doctor.summary.fail > 0 { ExitCode::DoctorFailure }`), else `SUCCESS` (0).
13
+ The bundle file is still written either way. An unsupported target/server
14
+ pairing -> `DOCTOR_FAILURE` (4) too; a bad `--target-kind`/`--server` value
15
+ -> `INTERNAL_FAILURE` (5).
16
+
17
+ **The doctor section is the DEBUG-focused report, not this port's `tan
18
+ doctor` checklist** (tan-cli#357). Until that issue this module substituted
19
+ `doctor_cmd._collect`'s build/flash-readiness list and pinned the exit at
20
+ `SUCCESS`, so a bundle attached to a debug failure carried no debugger state
21
+ at all while automation read `ok: true` next to error-severity issues.
22
+ Measured against the oracle (empty PATH, no SDK): rust rc=4 ok=false
23
+ issues=[`support-bundle.sdkRoot`, `support-bundle.hostPrerequisites`]; the
24
+ port answered rc=0 ok=true with six error issues.
25
+
26
+ [`_debug_doctor_report`] is therefore the port of `tan_core::debug::doctor::
27
+ build_doctor_report` + `tan-cli`'s two appends (`append_host_prerequisites`,
28
+ `append_host_environment`). The base/target-branch checks are written here
29
+ because nothing else in this port produces them -- `support-bundle` is this
30
+ port's ONLY debug-report consumer, the debug half of `tan doctor` still
31
+ being unported (`doctor_cmd.py`'s own module docstring). The five host
32
+ checks are NOT rewritten: they are harvested by name from
33
+ `doctor_cmd.host_environment_checks`, the seam that owns each one, so a
34
+ change there reaches the bundle with no second copy to drift (see
35
+ [`_host_checks_from_doctor`]). `--target-kind`/`--server` now genuinely
36
+ change the report, exactly as they do in the oracle.
37
+
38
+ **tan-cli#441: `host_environment_checks` replaced a call into
39
+ `doctor_cmd._collect`.** Until this fix, harvesting these five checks by
40
+ name meant running `_collect`'s WHOLE build/flash-readiness checklist first
41
+ -- `sdk`/`boardYaml`/`workspace`/`westResolved`/`venvProvenance`/
42
+ `zephyrVersion`/`zephyrWorkspace`/`hostPython`/`pythonFloor`/`west`/
43
+ `zephyrSdk`/`sevenZip`/`setools`/`jlink`/`sdkProvenance`, none of which this
44
+ command ever reported -- including every spawned `west --version`, the JLink
45
+ version-banner probe, and a git provenance shell-out, purely to throw the
46
+ results away. `host_environment_checks` builds ONLY the five kept checks,
47
+ from the same underlying functions `_collect` itself now calls through the
48
+ shared `doctor_cmd._resolve_prerequisites_environment` -- see that seam's own
49
+ docstring for how `_collect` and this command stay byte-identical on the
50
+ checks both still report.
51
+
52
+ **One harvested check is capped, not passed through raw** (tan-cli#374
53
+ finding 1): `doctor_cmd`'s `longPaths` carries a Windows-only `fail` arm
54
+ (tan-cli#306) the oracle cannot reach, and feeding it straight into this
55
+ command's verdict made a first-run customer host (registry `LongPathsEnabled`
56
+ on, no global `.gitconfig`) exit 4 where the oracle exits 0 with no issues,
57
+ on every target/server shape. See [`_demote_long_paths_fail`].
58
+
59
+ REDACTION POLICY (this port's own decision -- the oracle does not redact at
60
+ all; verified: a fresh bundle from a freshly-built `target/debug/tan.exe`
61
+ writes the literal `Home directory has no spaces: C:\\Users\\<realname>`
62
+ straight into the file). Every string value in the WRITTEN FILE -- never the
63
+ stdout envelope, whose `data.outputPath` must stay a real, followable path for
64
+ the caller that just asked for it -- has every literal occurrence of the
65
+ resolved home directory (`%USERPROFILE%` on Windows, `$HOME` elsewhere, in
66
+ both its native and its posix-slash spelling) replaced with the placeholder
67
+ `<home>`. This is deliberately narrow, not a blanket path-scrubber: it targets
68
+ the one concrete PII class this bundle contains today -- the OS account name,
69
+ which rides on almost every absolute path the bundle reports (`workspaceRoot`,
70
+ `sdkRoot`, every trace `outputPath`/command line, and the doctor section's own
71
+ `homePath`-flavoured detail strings) -- while leaving a workspace/SDK root
72
+ OUTSIDE the home directory legible on purpose: a maintainer reading an
73
+ attached bundle needs the real project layout to diagnose a path problem, and
74
+ none of this command's own inputs (tool presence/versions, filesystem facts)
75
+ carry a token or credential to redact beyond the account name. See
76
+ [`_redact`]/[`_home_variants`].
77
+
78
+ Two limits on that replacement, both tan-cli#499 defect 5. It is anchored to a
79
+ PATH BOUNDARY, because a bare `str.replace` rewrote any path the home merely
80
+ prefixed into a silently wrong one (with `HOME=/home/<user>`,
81
+ `/home/<user>-two/alp-sdk` -> `<home>-two/alp-sdk`). And it is REFUSED outright for a home that is a
82
+ filesystem root ([`home_redaction_refusal`]) -- `HOME=/` is what Docker hands a
83
+ uid with no `/etc/passwd` entry, and replacing it shredded every separator in
84
+ the file (measured: exit 0, no warning, 88 substitutions in one bundle). A refusal emits `support-bundle.redaction-skipped` in the envelope
85
+ AND in text mode: the user is about to attach this file believing it was
86
+ scrubbed.
87
+ """
88
+
89
+ from __future__ import annotations
90
+
91
+ import json
92
+ import ntpath
93
+ import os
94
+ import re
95
+ from dataclasses import dataclass, replace
96
+ from typing import Any
97
+
98
+ import typer
99
+
100
+ from tan.commands import doctor_cmd
101
+ from tan.commands.inspect_cmd import (
102
+ ResolvedDebugContext,
103
+ collect_resolved_values,
104
+ resolve_debug_project_context,
105
+ )
106
+ from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS
107
+ from tan.commands.trace_cmd import (
108
+ TraceTargetError,
109
+ build_trace_decisions,
110
+ resolve_trace_targets,
111
+ )
112
+ from tan.core.sdk_discovery import sdk_resolution_issues
113
+ from tan.core.debug_launch import (
114
+ BAREMETAL_MCU,
115
+ GDBSERVER,
116
+ JLINK,
117
+ NATIVE_HOST,
118
+ OPENOCD,
119
+ PYOCD,
120
+ SERVER_NONE,
121
+ YOCTO_USERSPACE,
122
+ ZEPHYR_MCU,
123
+ DebugConfigError,
124
+ is_server_supported_for_target,
125
+ parse_server_kind,
126
+ parse_target_kind,
127
+ )
128
+ from tan.core.timestamp import generated_at_iso
129
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
130
+ from tan.exit_codes import ExitCode
131
+ from tan.output_format import FORMAT_HELP, OutputFormat, resolve_format
132
+
133
+ #: `data.schemaVersion` for this command's stdout payload, and the bundle
134
+ #: file's own top-level + per-section schema versions (all "1", like the
135
+ #: oracle).
136
+ DATA_SCHEMA_VERSION = "1"
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Redaction -- see the module docstring's "REDACTION POLICY".
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ #: The issue code that says the bundle went out UNREDACTED. Redaction is a
145
+ #: best-effort privacy measure, so refusing it is not an error -- but it must
146
+ #: not be silent either: the user is about to attach this file to a public
147
+ #: issue believing the account name was scrubbed.
148
+ REDACTION_SKIPPED_CODE = "support-bundle.redaction-skipped"
149
+
150
+
151
+ def _resolved_home() -> str:
152
+ """`%USERPROFILE%` on Windows, `$HOME` elsewhere; `""` when unset."""
153
+ return os.environ.get("USERPROFILE" if os.name == "nt" else "HOME") or ""
154
+
155
+
156
+ def home_redaction_refusal(home: str) -> str | None:
157
+ """Why `<home>`-redaction must NOT run for this home directory, or `None`
158
+ when it is safe to run.
159
+
160
+ A home that is a filesystem/drive/UNC ROOT is refused. Docker and OpenShift
161
+ hand a uid with no `/etc/passwd` entry `HOME=/`, and an unanchored
162
+ `str.replace("/", "<home>")` then rewrites EVERY separator in EVERY string
163
+ of the bundle. MEASURED: `HOME=/ tan support-bundle` exited 0, emitted no
164
+ warning, and wrote 88 `<home>` substitutions into one bundle -- every
165
+ separator of every path in it. The shape is
166
+ `workspaceRoot: "<home>srv<home>ci<home>work<home>..."`, ILLUSTRATIVE and
167
+ not a transcript: the run's real paths are elided. The maintainer
168
+ receiving that attachment cannot read a single path -- the bundle exists to
169
+ carry exactly those paths verbatim -- and there is nothing to protect
170
+ anyway, because a root home contains no account name.
171
+
172
+ Pure and taking the home as an argument so the rule is testable without
173
+ mutating the process environment.
174
+
175
+ `ntpath.splitdrive` unconditionally, not `os.path`'s: the same cross-OS
176
+ rule `core.image_bundle.is_plain_filename` settled on. `os.path.splitdrive`
177
+ is a no-op on POSIX, so a Windows drive or UNC root would be judged an
178
+ ordinary directory when this rule is exercised anywhere but Windows, and
179
+ the rule would then be untestable on the host CI runs on.
180
+ """
181
+ if not home:
182
+ return None
183
+ stripped = ntpath.splitdrive(home)[1].strip("\\/")
184
+ if stripped == "":
185
+ return (
186
+ f'$HOME is the filesystem root ("{home}"), so redacting it would '
187
+ "replace every path separator in the bundle. Written UNREDACTED "
188
+ "instead -- a root home carries no account name to protect, but "
189
+ "check the file before attaching it anywhere public."
190
+ )
191
+ return None
192
+
193
+
194
+ def _home_variants() -> tuple[str, ...]:
195
+ """The resolved home directory, in both its native and posix-slash
196
+ spelling -- the bundle mixes both (native in doctor detail strings and
197
+ `--destination`-normalised paths, posix in `workspaceRoot`/`sdkRoot`/
198
+ `boardYamlPath`), so redaction has to look for both. Empty when the host
199
+ has neither `USERPROFILE` nor `HOME` set -- redacts nothing rather than
200
+ guessing -- and empty for a home [`home_redaction_refusal`] refuses."""
201
+ home = _resolved_home()
202
+ if not home or home_redaction_refusal(home):
203
+ return ()
204
+ return tuple({home, home.replace("\\", "/")})
205
+
206
+
207
+ #: Characters that end a path token in the strings this bundle carries. A home
208
+ #: occurrence must START at one of these (or at the beginning of the string) and
209
+ #: END at one of these, at a path separator, or at the end of the string.
210
+ #:
211
+ #: `/` and `\\` are deliberately ABSENT from the leading set for a home with no
212
+ #: drive. The shape that rule DECIDES is a home directly preceded by a
213
+ #: separator -- `/srv//home/dev/proj`, or `file:///home/dev/proj` -- which names
214
+ #: a different directory and must survive; with `HOME=/home/dev` both are left
215
+ #: alone, and treating a separator as a token start would rewrite the first to
216
+ #: `/srv/<home>/proj`.
217
+ #:
218
+ #: Note what this rule does NOT decide, since an earlier version of this comment
219
+ #: claimed it did: `/srv/home/dev/proj` (single separator) is already safe
220
+ #: because the character before the match is `v`, an ordinary name character
221
+ #: that is in no boundary set. Measured both ways -- it survives even with
222
+ #: separators admitted to the leading set.
223
+ #:
224
+ #: Separators ARE in the trailing set, because `<home>/proj` is exactly the
225
+ #: substitution wanted, and they are added back to the LEADING set for a
226
+ #: drive-anchored home -- see [`_home_pattern`].
227
+ _TOKEN_BREAK = " \t\r\n\"'=,;:()[]{}<>|`"
228
+
229
+ #: Path separators. A leading boundary for a DRIVE-ANCHORED home only.
230
+ _SEPARATORS = "/\\"
231
+
232
+
233
+ def _home_pattern(variant: str) -> re.Pattern[str]:
234
+ """A path-boundary-anchored matcher for one home spelling.
235
+
236
+ A bare `str.replace` rewrote any path the home merely PREFIXES into a
237
+ silently wrong one: measured with `HOME=/home/<user>`,
238
+ `/home/<user>-two/alp-sdk` was written as `<home>-two/alp-sdk` and
239
+ `/srv/home/<user>-ops/proj` as `/srv<home>-ops/proj` -- register-grade path
240
+ data corrupted in the one artifact that exists to carry it, at `ok: true`.
241
+
242
+ A DRIVE-ANCHORED variant also accepts a separator as its leading boundary.
243
+ `%USERPROFILE%` starts with a drive letter, so Windows' extended-length
244
+ prefix put one in front of it and the account name survived: measured,
245
+ `\\\\?\\C:\\Users\\<name>\\proj` came back UNREDACTED, on the one platform
246
+ whose home directory is the account name. The POSIX exclusion above cannot
247
+ be relaxed, but it does not apply here -- a drive letter re-anchors the path
248
+ absolutely, so no preceding component can make `C:\\Users\\alice` name a
249
+ different directory the way `/srv` + `/home/dev` does.
250
+ """
251
+ break_class = re.escape(_TOKEN_BREAK)
252
+ drive_anchored = ntpath.splitdrive(variant)[0] != ""
253
+ lead_class = break_class + (re.escape(_SEPARATORS) if drive_anchored else "")
254
+ return re.compile(
255
+ rf"(?<![^{lead_class}])" # string start, or a leading boundary before it
256
+ + re.escape(variant)
257
+ + rf"(?=[{re.escape(_SEPARATORS)}]|[{break_class}]|$)", # sep, break, or end
258
+ # A drive-anchored (Windows) home names a case-INSENSITIVE volume: VS Code
259
+ # hands tan `c:\\Users\\Alice` for a `C:\\Users\\Alice` profile, and a
260
+ # case-sensitive match would leak the account name (tan-cli#1483). A POSIX
261
+ # home stays case-sensitive -- `/home/Alice` and `/home/alice` differ there.
262
+ re.IGNORECASE if drive_anchored else 0,
263
+ )
264
+
265
+
266
+ def _redact(value: Any, home_variants: tuple[str, ...]) -> Any:
267
+ """Recursively replace every path-boundary-anchored `home_variants`
268
+ occurrence in every string this bundle payload carries with `<home>`. Walks
269
+ dicts/lists; every other JSON-safe type (bool/int/float/None) passes through
270
+ unchanged."""
271
+ if isinstance(value, str):
272
+ for variant in home_variants:
273
+ value = _home_pattern(variant).sub("<home>", value)
274
+ return value
275
+ if isinstance(value, dict):
276
+ return {k: _redact(v, home_variants) for k, v in value.items()}
277
+ if isinstance(value, list):
278
+ return [_redact(v, home_variants) for v in value]
279
+ return value
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Bundle assembly
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ def _timestamp_for_file(generated_at: str) -> str:
288
+ """Makes an ISO timestamp filename-safe: `:`/`.` -> `-`. Mirrors the
289
+ oracle's `timestamp_for_file`."""
290
+ return "".join("-" if c in ":." else c for c in generated_at)
291
+
292
+
293
+ def _create_bundle_trace_decisions(
294
+ context: ResolvedDebugContext, target: str | None, focus: str | None
295
+ ) -> list[dict[str, Any]]:
296
+ """The bundle's own trace section: `Planned` decisions when an SDK root
297
+ resolved, else one `Failed` placeholder -- port of `support_bundle.rs`'s
298
+ `create_bundle_trace_decisions`.
299
+
300
+ Deliberately checks ONLY `context.sdk_root is not None`, never
301
+ `board_yaml_exists` -- measured against the oracle: a project with a
302
+ resolved SDK but a MISSING board.yaml still gets four `Planned` decisions
303
+ here (`decisionCount: 4`), unlike bare `tan trace`, which refuses outright
304
+ on a missing board.yaml. `workspace_root`/`board_yaml_path` need no
305
+ presence check of their own in this port: both are unconditionally
306
+ resolved by [`resolve_debug_project_context`] (see that module's
307
+ docstring), exactly mirroring why the oracle's own three-way `Option`
308
+ match only ever turns on `sdk_root`.
309
+ """
310
+ if context.sdk_root is None:
311
+ decisions: list[dict[str, Any]] = [
312
+ {
313
+ "key": "generation.targets",
314
+ "outcome": "failed",
315
+ "detail": (
316
+ "Generation targets were not traced because project context "
317
+ "is unresolved."
318
+ ),
319
+ }
320
+ ]
321
+ else:
322
+ targets = resolve_trace_targets(target) # may raise TraceTargetError
323
+ decisions = build_trace_decisions(
324
+ context.workspace_root,
325
+ context.sdk_root,
326
+ context.board_yaml_path,
327
+ context.python_binary,
328
+ targets,
329
+ None, # the focus-path decision is appended once, below
330
+ )
331
+
332
+ if focus is not None:
333
+ decisions.append(
334
+ {
335
+ "key": f"config.path.{focus}",
336
+ "outcome": "planned",
337
+ "detail": (
338
+ "Path-level trace was requested and captured as part of "
339
+ "bundle metadata."
340
+ ),
341
+ }
342
+ )
343
+ return decisions
344
+
345
+
346
+ #: The `DebuggerExtensionsState` the standalone binary reports, verbatim from
347
+ #: `crates/tan-cli/src/commands/doctor.rs::standalone_debugger_extensions`.
348
+ #: The three flags are an inherited assumption from the VS Code extension --
349
+ #: only an extension host can enumerate its own marketplace extensions -- and
350
+ #: `observable: false` is what stops anything reading them as facts: every
351
+ #: derived check renders `unknown` instead of claiming "vadimcn.vscode-lldb is
352
+ #: installed." on a headless container (#102).
353
+ STANDALONE_DEBUGGER_EXTENSIONS = {
354
+ "cortexDebug": True,
355
+ "cppTools": True,
356
+ "codeLLDB": True,
357
+ "observable": False,
358
+ }
359
+
360
+ #: Per-server executable candidates, first hit on PATH wins -- port of
361
+ #: `tan_core::debug::context::collect_runtime_capabilities_from_commands`. The
362
+ #: reported value is the COMMAND NAME, not the resolved path: that is what the
363
+ #: oracle puts in the check detail (`.map(|c| (*c).to_string())`).
364
+ _RUNTIME_EXECUTABLES: dict[str, tuple[str, ...]] = {
365
+ JLINK: ("JLinkGDBServerCL", "JLinkGDBServer"),
366
+ OPENOCD: ("openocd",),
367
+ PYOCD: ("pyocd",),
368
+ GDBSERVER: ("gdb", "arm-none-eabi-gdb"),
369
+ SERVER_NONE: ("lldb-dap", "lldb"),
370
+ }
371
+
372
+ #: The host checks the oracle appends to the pure report AFTER building it
373
+ #: (`append_host_prerequisites` then `append_host_environment`), in that
374
+ #: order. `bootstrapManifest` has no oracle counterpart as a CHECK -- Rust
375
+ #: folds a rejected `metadata/bootstrap.json` into `hostPrerequisites`'
376
+ #: own detail via `manifest_error`, this port reports it as a sibling warn --
377
+ #: so it rides here rather than being dropped: a bundle whose prerequisite
378
+ #: list came from tan's fallback instead of the SDK's manifest must say so.
379
+ #: `longPaths` is Windows-only and simply absent elsewhere. Its `fail` status
380
+ #: is demoted before it ever reaches this command's verdict -- see
381
+ #: [`_demote_long_paths_fail`].
382
+ _HOST_CHECK_ORDER = (
383
+ "bootstrapManifest",
384
+ "hostPrerequisites",
385
+ "zephyrSdkAvailableForHost",
386
+ "longPaths",
387
+ "homePath",
388
+ )
389
+
390
+
391
+ def _demote_long_paths_fail(check: doctor_cmd.Check) -> doctor_cmd.Check:
392
+ """tan-cli#374 finding 1. tan-cli#306 widened `doctor_cmd.long_paths_check`
393
+ with a Windows-only `fail` arm (registry `LongPathsEnabled` on, git's own
394
+ `core.longpaths` not) that the oracle cannot reach at all: Rust's
395
+ `long_paths_check` (`crates/tan-core/src/host_env.rs:300-330`) takes only
396
+ the registry axis and never returns worse than `Warn` -- confirmed by
397
+ RUNNING the oracle, not by reading it. Every OTHER `longPaths` state
398
+ (`warn`/`pass`) already matches the oracle's own verdict for that state
399
+ and is left alone; only this one, oracle-unreachable combination is
400
+ downgraded.
401
+
402
+ Feeding the undemoted check into this command's verdict was exactly the
403
+ bug: on a first-run customer host (registry on, no global `.gitconfig`,
404
+ so git's own flag is unset) it made `support-bundle` exit 4 -- doctor
405
+ failure -- where the oracle exits 0 with no issues at all, on every
406
+ target/server shape. `tan doctor` KEEPS the fail arm unchanged (that is
407
+ #306's real fix, for the command that actually gates a build and where
408
+ "fail fast on a certain west-update break" is the right call); this
409
+ command only ever attaches a debug snapshot, and its own acceptance bar
410
+ is matching the oracle's axes, which do not include this one.
411
+
412
+ Demoting here (not filtering the check out of the report) keeps the
413
+ written bundle, `doctor.summary`, `nextSteps` and the wire verdict all
414
+ reading the SAME check list -- a maintainer opening the file still sees
415
+ the real git/registry mismatch (the `detail`/`fix` text is untouched),
416
+ just at the severity this command can actually promise the oracle
417
+ agrees with.
418
+ """
419
+ if check.name == "longPaths" and check.status == "fail":
420
+ return replace(check, status="warn")
421
+ return check
422
+
423
+
424
+ def _first_on_path(names: tuple[str, ...]) -> str | None:
425
+ return next((name for name in names if doctor_cmd.on_path(name) is not None), None)
426
+
427
+
428
+ def _extension_check(name: str, extension_id: str) -> doctor_cmd.Check:
429
+ """One VS Code extension-presence check, always `unknown` here -- port of
430
+ `doctor.rs::extension_check`'s `None` arm, which is the only arm the
431
+ standalone binary can reach (`observable: false`). Not `warn`: a warning
432
+ says something is wrong, and nothing is -- the question was not askable.
433
+ `unknown` is counted in no summary bucket and raises no issue, so it can
434
+ never move this command's exit code.
435
+
436
+ The dash in `detail` is a real U+2014 EM DASH, not `--` (tan-cli#374
437
+ finding 6): the oracle's own literal
438
+ (`crates/tan-core/src/debug/doctor.rs:132`) uses one, and this string is a
439
+ verbatim port of it -- unlike this file's own prose/comments, which use
440
+ `--` throughout, an emitted user-facing detail string copies its source of
441
+ truth byte for byte."""
442
+ return doctor_cmd.Check(
443
+ name,
444
+ "unknown",
445
+ f"{extension_id}: unknown — the standalone tan binary cannot see "
446
+ "VS Code's installed extensions.",
447
+ scope="host",
448
+ )
449
+
450
+
451
+ def _host_checks_from_doctor(context: ResolvedDebugContext) -> list[doctor_cmd.Check]:
452
+ """The host half of the bundled report, harvested BY NAME from
453
+ `doctor_cmd.host_environment_checks` rather than re-probed here.
454
+
455
+ The oracle appends these with `append_host_prerequisites` /
456
+ `append_host_environment`, both `pub(crate)` in `tan-cli` precisely so
457
+ `support_bundle.rs` can share `doctor.rs`'s copy. This port has the same
458
+ five checks -- `prerequisites_check`, `zephyr_sdk_host_check`,
459
+ `long_paths_check`, `home_path_check`, plus `bootstrapManifest` (see
460
+ `_HOST_CHECK_ORDER`) -- and `doctor_cmd.host_environment_checks` is the
461
+ ONE place that builds them; `doctor_cmd._collect` (`tan doctor`'s own,
462
+ much longer checklist) calls the same underlying
463
+ `_resolve_prerequisites_environment` seam for `bootstrapManifest`/
464
+ `hostPrerequisites`, so this command and `tan doctor` can never build a
465
+ diverging verdict for a check both report.
466
+
467
+ tan-cli#441: this used to call `doctor_cmd._collect` and pick the five
468
+ checks out by name, which meant running `_collect`'s WHOLE
469
+ build/flash-readiness checklist first -- `sdk`/`boardYaml`/`workspace`/
470
+ `westResolved`/`venvProvenance`/`zephyrVersion`/`zephyrWorkspace`/
471
+ `hostPython`/`pythonFloor`/`west`/`zephyrSdk`/`sevenZip`/`setools`/
472
+ `jlink`/`sdkProvenance`, none of which this command ever reported --
473
+ including every spawned `west --version`, the JLink version-banner probe,
474
+ and a git provenance shell-out, purely to throw the results away.
475
+ `host_environment_checks` never builds any of those: no board.yaml or
476
+ project scope is even accepted any more, because none of the five kept
477
+ checks reads either (`sdk_check`/`board_yaml_preflight_check`/
478
+ `libraries_check` are the only `_collect` checks that did, and none of
479
+ the three is one of the five).
480
+ """
481
+ collected = doctor_cmd.host_environment_checks(
482
+ context.sdk_root,
483
+ workspace_root=context.workspace_root,
484
+ )
485
+ by_name = {check.name: check for check in collected}
486
+ return [
487
+ _demote_long_paths_fail(by_name[name])
488
+ for name in _HOST_CHECK_ORDER
489
+ if name in by_name
490
+ ]
491
+
492
+
493
+ def _debug_doctor_report(
494
+ context: ResolvedDebugContext,
495
+ project_selected: bool,
496
+ target: str,
497
+ server: str,
498
+ ) -> tuple[dict[str, Any], list[doctor_cmd.Check]]:
499
+ """The bundle's DEBUG-focused doctor section (tan-cli#357) -- port of
500
+ `tan_core::debug::doctor::build_doctor_report` plus `tan-cli`'s two host
501
+ appends. See the module docstring for what it replaced and why.
502
+
503
+ No `serverCompatibility` check: the oracle's builder emits one and then
504
+ short-circuits, but `support_bundle.rs` refuses an unsupported pairing
505
+ with its own `support-bundle.server-compatibility` issue BEFORE building
506
+ any report (`_server_incompatible` here does the same), so that arm is
507
+ unreachable from this command. Verified against the oracle:
508
+ `--target-kind yocto-userspace --server jlink` writes no bundle at all.
509
+
510
+ `workspaceRoot` has no fail arm for the same reason `inspect_cmd`'s
511
+ resolved-values rows have none: `resolve_debug_project_context` always
512
+ resolves a workspace root (cwd, or `--project` joined onto it), so Rust's
513
+ `is_present(&context.workspace_root)` is always true in this port's
514
+ construction. Left as a reported check because the oracle reports it and
515
+ a bundle reader looks for it.
516
+ """
517
+ checks = [
518
+ doctor_cmd.Check("workspaceRoot", "pass", context.workspace_root, scope="project"),
519
+ doctor_cmd.Check(
520
+ "sdkRoot",
521
+ "pass" if context.sdk_root else "fail",
522
+ context.sdk_root or "No alp-sdk checkout resolved.",
523
+ None
524
+ if context.sdk_root
525
+ # tan-cli#381: the `fix` said "Run `tan sdk switch <path>`", which
526
+ # refuses in this build (`sdk_cmd._run_not_ported`) -- and a support
527
+ # bundle is read by whoever is already stuck, so a fix line naming a
528
+ # command that exits 1 is the worst place for the #305 dead end to
529
+ # come back. `doctor_cmd.sdk_check`'s own wording, shared verbatim.
530
+ else f"Resolve an alp-sdk checkout: {NO_SDK_NEXT_STEPS}.",
531
+ scope="project",
532
+ ),
533
+ _board_yaml_check(context, project_selected),
534
+ *_target_checks(target, server),
535
+ *_host_checks_from_doctor(context),
536
+ ]
537
+ missing_prerequisites = next(
538
+ (c.missing for c in checks if c.name == "hostPrerequisites"), None
539
+ )
540
+ report = {
541
+ "generatedAt": None, # filled by the caller, which already has the timestamp
542
+ "targetKind": target,
543
+ "server": server,
544
+ "summary": doctor_cmd.summarise(checks),
545
+ "checks": [c.as_dict() for c in checks],
546
+ "nextSteps": doctor_cmd.next_steps(checks),
547
+ "missingPrerequisites": missing_prerequisites,
548
+ }
549
+ return report, checks
550
+
551
+
552
+ def _board_yaml_check(
553
+ context: ResolvedDebugContext, project_selected: bool
554
+ ) -> doctor_cmd.Check:
555
+ """Port of `doctor.rs::board_yaml_check` -- the DEBUG report's `boardYaml`,
556
+ which reports the resolved PATH as its detail (this port's build-checklist
557
+ `board_yaml_preflight_check` reports "board.yaml found" instead, and stays
558
+ where it is).
559
+
560
+ A missing `board.yaml` is a hard failure only once the user NAMED a
561
+ project: with neither `--project` nor `--board-yaml` the path is a guess
562
+ at the working directory, and `tan bootstrap` sends every new customer to
563
+ run `tan doctor` from the SDK checkout root, which has no `board.yaml` and
564
+ needs none (#100).
565
+ """
566
+ if context.board_yaml_exists:
567
+ return doctor_cmd.Check("boardYaml", "pass", context.board_yaml_path, scope="project")
568
+ if project_selected:
569
+ return doctor_cmd.Check(
570
+ "boardYaml",
571
+ "fail",
572
+ context.board_yaml_path,
573
+ "Create board.yaml or pass `--board-yaml <path>`.",
574
+ scope="project",
575
+ )
576
+ return doctor_cmd.Check(
577
+ "boardYaml",
578
+ "warn",
579
+ f"no project selected -- no board.yaml at {context.board_yaml_path}",
580
+ "Select a project with `--project <dir>` (or `--board-yaml <path>`) to check one.",
581
+ scope="project",
582
+ )
583
+
584
+
585
+ def _target_checks(target: str, server: str) -> list[doctor_cmd.Check]:
586
+ """The per-target-kind arm of `build_doctor_report`: one extension check
587
+ plus one tool check, both driven by `--target-kind`/`--server`.
588
+
589
+ `lldb` always passes, with no `fix` (#131): `vadimcn.vscode-lldb` ships
590
+ its own complete LLDB inside the extension directory and never consults
591
+ PATH, so warning that none was found and telling the user to install one
592
+ is a remedy for a tool the product does not need. The resolved executable
593
+ is still REPORTED when present -- that is real information; only the
594
+ verdict and the advice were wrong.
595
+ """
596
+ if target in (ZEPHYR_MCU, BAREMETAL_MCU):
597
+ found = _first_on_path(_RUNTIME_EXECUTABLES[server])
598
+ return [
599
+ _extension_check("cortexDebugExtension", "marus25.cortex-debug"),
600
+ doctor_cmd.Check(
601
+ f"{server}Backend",
602
+ "pass" if found else "warn",
603
+ found or f"No {server} executable was found on PATH.",
604
+ None if found else f"Install {server} and make sure it is on PATH.",
605
+ scope="host",
606
+ ),
607
+ ]
608
+ if target == YOCTO_USERSPACE:
609
+ gdb = _first_on_path(_RUNTIME_EXECUTABLES[GDBSERVER])
610
+ return [
611
+ _extension_check("cppToolsExtension", "ms-vscode.cpptools"),
612
+ doctor_cmd.Check(
613
+ "gdb",
614
+ "pass" if gdb else "warn",
615
+ gdb or "No local gdb executable was found on PATH.",
616
+ None if gdb else "Install gdb locally for symbolized remote debugging.",
617
+ scope="host",
618
+ ),
619
+ ]
620
+ lldb = _first_on_path(_RUNTIME_EXECUTABLES[SERVER_NONE])
621
+ return [
622
+ _extension_check("codeLLDBExtension", "vadimcn.vscode-lldb"),
623
+ doctor_cmd.Check(
624
+ "lldb",
625
+ "pass",
626
+ lldb or "vadimcn.vscode-lldb ships its own LLDB, so none is needed on PATH.",
627
+ scope="host",
628
+ ),
629
+ ]
630
+
631
+
632
+ def _doctor_issues(checks: list[doctor_cmd.Check]) -> list[Issue]:
633
+ """Warn/fail checks become `support-bundle.<kebab-name>` issues -- port of
634
+ `support_bundle.rs::doctor_checks_to_issues`, with THIS port's own
635
+ `doctor_cmd.Check` shape (`name`/`status`/`detail`) instead of Rust's
636
+ `DoctorCheck`. `unknown` raises nothing: the question was not askable,
637
+ not a problem."""
638
+ return [
639
+ Issue(
640
+ f"support-bundle.{doctor_cmd.kebab_check_name(c.name)}",
641
+ "error" if c.status == "fail" else "warning",
642
+ c.detail,
643
+ )
644
+ for c in checks
645
+ if c.status in ("warn", "fail")
646
+ ]
647
+
648
+
649
+ def _write_bundle(
650
+ destination: str | None,
651
+ workspace_root: str,
652
+ generated_at: str,
653
+ payload: dict[str, Any],
654
+ ) -> str:
655
+ """Write the redacted `payload` to a timestamped
656
+ `debug-support-bundle-*.json` file under `destination` (resolved against
657
+ cwd, like the oracle's `normalize_path(cwd.join(dest))`) or
658
+ `<workspace_root>/.alp-support`. Returns the written path."""
659
+ file_name = f"debug-support-bundle-{_timestamp_for_file(generated_at)}.json"
660
+ base_dir = (
661
+ os.path.abspath(destination)
662
+ if destination is not None
663
+ else os.path.join(workspace_root, ".alp-support")
664
+ )
665
+ output_path = os.path.join(base_dir, file_name)
666
+ os.makedirs(base_dir, exist_ok=True)
667
+ redacted = _redact(payload, _home_variants())
668
+ with open(output_path, "w", encoding="utf-8", newline="") as handle:
669
+ json.dump(redacted, handle, indent=2)
670
+ handle.write("\n")
671
+ return output_path
672
+
673
+
674
+ # ---------------------------------------------------------------------------
675
+ # Envelope assembly
676
+ # ---------------------------------------------------------------------------
677
+
678
+
679
+ @dataclass
680
+ class _Outcome:
681
+ exit_code: ExitCode
682
+ data: dict[str, Any]
683
+ project: Project
684
+ sdk: SdkInfo | None
685
+ issues: list[Issue]
686
+ text: list[str]
687
+ #: Whether `--verbose` may append its "use --format json" hint under text
688
+ #: mode -- only the bundle-written path does (`support_bundle_text` in the
689
+ #: oracle); the three failure shapes (`internal_failure`/
690
+ #: `server_incompatible`, plus this port's outer exception guard) have
691
+ #: their own fixed text and never grow a verbose-only line. Verified: `tan
692
+ #: support-bundle --target-kind yocto-userspace --server jlink --verbose`
693
+ #: prints only the one incompatibility line, no hint.
694
+ verbose_hint_eligible: bool = False
695
+
696
+
697
+ def _empty_data(generated_at: str, target: str, server: str) -> dict[str, Any]:
698
+ return {
699
+ "schemaVersion": DATA_SCHEMA_VERSION,
700
+ "generatedAt": generated_at,
701
+ "outputPath": "",
702
+ "targetKind": target,
703
+ "server": server,
704
+ "decisionCount": 0,
705
+ }
706
+
707
+
708
+ def _internal_failure(
709
+ generated_at: str,
710
+ message: str,
711
+ target: str,
712
+ server: str,
713
+ sdk: SdkInfo | None,
714
+ *,
715
+ broken_project_pin: str | None = None,
716
+ sdk_tier: str = "none",
717
+ foreign_global_default_for: str | None = None,
718
+ ) -> _Outcome:
719
+ # tan-cli#478 MAJOR 1: this early-return path used to drop the
720
+ # SDK-resolution pair entirely -- exactly the customer who most needs it,
721
+ # since it fires on a machine where something has already gone wrong.
722
+ # `sdk_resolution_issues` is `[]` when neither fires, so callers that
723
+ # have not resolved a project context yet (the outer exception guard)
724
+ # can pass the defaults and get the prior behaviour unchanged.
725
+ issues = sdk_resolution_issues(broken_project_pin, sdk_tier, foreign_global_default_for)
726
+ issues.append(Issue("support-bundle.internal-failure", "error", message))
727
+ return _Outcome(
728
+ exit_code=ExitCode.INTERNAL_FAILURE,
729
+ data=_empty_data(generated_at, target, server),
730
+ project=Project(root=None, board_yaml=None),
731
+ sdk=sdk,
732
+ issues=issues,
733
+ text=["support-bundle: internal failure", message],
734
+ )
735
+
736
+
737
+ def _server_incompatible(
738
+ generated_at: str,
739
+ target: str,
740
+ server: str,
741
+ sdk: SdkInfo | None,
742
+ *,
743
+ broken_project_pin: str | None = None,
744
+ sdk_tier: str = "none",
745
+ foreign_global_default_for: str | None = None,
746
+ ) -> _Outcome:
747
+ # tan-cli#478 MAJOR 1: same fix as `_internal_failure` above.
748
+ message = f"Server '{server}' is not supported for target '{target}'."
749
+ issues = sdk_resolution_issues(broken_project_pin, sdk_tier, foreign_global_default_for)
750
+ issues.append(Issue("support-bundle.server-compatibility", "error", message))
751
+ return _Outcome(
752
+ exit_code=ExitCode.DOCTOR_FAILURE,
753
+ data=_empty_data(generated_at, target, server),
754
+ project=Project(root=None, board_yaml=None),
755
+ sdk=sdk,
756
+ issues=issues,
757
+ text=[f"support-bundle: server '{server}' is not supported for target '{target}'."],
758
+ )
759
+
760
+
761
+ def _run(
762
+ *,
763
+ project_arg: str | None,
764
+ board_yaml_arg: str | None,
765
+ sdk_root_arg: str | None,
766
+ target_kind_arg: str | None,
767
+ server_arg: str | None,
768
+ path_arg: str | None,
769
+ target_arg: str | None,
770
+ destination_arg: str | None,
771
+ ) -> _Outcome:
772
+ """The whole command as a pure-ish computation (project/SDK resolution and
773
+ the bundle-file WRITE are the only IO) returning one outcome. Mirrors
774
+ `debug_config_cmd._run`'s split: nothing here emits or exits, so the
775
+ caller's exception guard can wrap this call without swallowing
776
+ `typer.Exit`."""
777
+ generated_at = generated_at_iso(millis=True)
778
+ context = resolve_debug_project_context(project_arg, board_yaml_arg, sdk_root_arg)
779
+
780
+ try:
781
+ target = parse_target_kind(target_kind_arg)
782
+ server = parse_server_kind(server_arg)
783
+ except DebugConfigError as err:
784
+ return _internal_failure(
785
+ generated_at,
786
+ str(err),
787
+ NATIVE_HOST,
788
+ SERVER_NONE,
789
+ context.sdk,
790
+ broken_project_pin=context.broken_project_pin,
791
+ sdk_tier=context.sdk_tier,
792
+ foreign_global_default_for=context.foreign_global_default_for,
793
+ )
794
+
795
+ if not is_server_supported_for_target(target, server):
796
+ return _server_incompatible(
797
+ generated_at,
798
+ target,
799
+ server,
800
+ context.sdk,
801
+ broken_project_pin=context.broken_project_pin,
802
+ sdk_tier=context.sdk_tier,
803
+ foreign_global_default_for=context.foreign_global_default_for,
804
+ )
805
+
806
+ try:
807
+ decisions = _create_bundle_trace_decisions(context, target_arg, path_arg)
808
+ except TraceTargetError as err:
809
+ return _internal_failure(
810
+ generated_at,
811
+ str(err),
812
+ target,
813
+ server,
814
+ context.sdk,
815
+ broken_project_pin=context.broken_project_pin,
816
+ sdk_tier=context.sdk_tier,
817
+ foreign_global_default_for=context.foreign_global_default_for,
818
+ )
819
+
820
+ # Port of `doctor.rs::project_selected`: `--project`/`--board-yaml` are the
821
+ # whole selection surface, so with neither given the resolved board.yaml
822
+ # path is a guess at the cwd, not a request.
823
+ project_selected = any(
824
+ (arg or "").strip() for arg in (project_arg, board_yaml_arg)
825
+ )
826
+ doctor_report, checks = _debug_doctor_report(context, project_selected, target, server)
827
+ doctor_report["generatedAt"] = generated_at
828
+
829
+ notes = [
830
+ f"targetKind={target}",
831
+ f"server={server}",
832
+ f"workspaceRoot={context.workspace_root}",
833
+ ]
834
+ payload = {
835
+ "schemaVersion": DATA_SCHEMA_VERSION,
836
+ "generatedAt": generated_at,
837
+ "inspect": {
838
+ "schemaVersion": DATA_SCHEMA_VERSION,
839
+ "generatedAt": generated_at,
840
+ # The oracle's `DebugWorkspaceContext`, key for key (tan-cli#357).
841
+ # `projectSelected`/`debuggerExtensions` are NOT fabricated: the
842
+ # first is derived from this invocation's own flags (see
843
+ # `project_selected` above, and `doctor.rs::project_selected`), the
844
+ # second is the standalone binary's own declared, self-describing
845
+ # state -- `observable: false` says in the file itself that the
846
+ # three flags were never probed. Omitting them cost the bundle the
847
+ # two facts that explain WHY its `boardYaml` and extension checks
848
+ # read the way they do, in the artifact a debug failure gets
849
+ # attached to.
850
+ "context": {
851
+ "generatedAt": generated_at,
852
+ "workspaceRoot": context.workspace_root,
853
+ "sdkRoot": context.sdk_root,
854
+ "boardYamlPath": context.board_yaml_path,
855
+ "westCwd": context.west_cwd,
856
+ "pythonBinary": context.python_binary,
857
+ "boardYamlExists": context.board_yaml_exists,
858
+ "projectSelected": project_selected,
859
+ "debuggerExtensions": STANDALONE_DEBUGGER_EXTENSIONS,
860
+ },
861
+ "resolvedValues": collect_resolved_values(context),
862
+ },
863
+ "trace": {
864
+ "schemaVersion": DATA_SCHEMA_VERSION,
865
+ "generatedAt": generated_at,
866
+ "workflow": "cli.support-bundle",
867
+ "decisions": decisions,
868
+ },
869
+ "doctor": doctor_report,
870
+ "notes": notes,
871
+ # tan-cli#478's headline requirement, and the item review called worth
872
+ # fixing FIRST: the FILE has to carry it, not just the stdout envelope.
873
+ # An earlier revision computed these AFTER `_write_bundle` below, so
874
+ # the bundle a user attaches to a bug report -- the whole point of the
875
+ # command -- still answered False to the issue's own repro:
876
+ # 'global-default-foreign-project' in open(BUNDLE).read()
877
+ # Its own `doctor` section could not have supplied it either: that set
878
+ # keeps host checks only (tan-cli#441), 8 where a standalone doctor
879
+ # emits ~17.
880
+ "sdkResolution": [
881
+ {"code": issue.code, "severity": issue.severity, "message": issue.message}
882
+ for issue in sdk_resolution_issues(
883
+ context.broken_project_pin,
884
+ context.sdk_tier,
885
+ context.foreign_global_default_for,
886
+ )
887
+ ],
888
+ }
889
+
890
+ try:
891
+ output_path = _write_bundle(destination_arg, context.workspace_root, generated_at, payload)
892
+ except OSError as err:
893
+ return _internal_failure(
894
+ generated_at,
895
+ str(err),
896
+ target,
897
+ server,
898
+ context.sdk,
899
+ broken_project_pin=context.broken_project_pin,
900
+ sdk_tier=context.sdk_tier,
901
+ foreign_global_default_for=context.foreign_global_default_for,
902
+ )
903
+
904
+ # tan-cli#357: the doctor summary IS this command's verdict, exactly as in
905
+ # the oracle (`if doctor.summary.fail > 0 { ExitCode::DoctorFailure }`).
906
+ # `exit_code_for` is `any(status == "fail")` over the same list, so it is
907
+ # that rule spelled in this port's vocabulary -- and it keeps the CLI-wide
908
+ # invariant `process exit code == envelope.exitCode == (not ok)` intact,
909
+ # which the previous hardcoded SUCCESS broke: automation saw `ok: true`
910
+ # and exit 0 beside error-severity issues in the same envelope. The bundle
911
+ # file is still written on the failing path -- it is what the user
912
+ # attaches, and a doctor failure is the reason they are attaching it.
913
+ # tan-cli#478: the SDK-resolution pair FIRST, then the doctor rollup.
914
+ # This bundle is what a user sends to explain a broken machine, and it
915
+ # was the only place the foreign-default warning never appeared. The
916
+ # embedded doctor set would not have covered it either -- it keeps host
917
+ # checks only (tan-cli#441): 8, where a standalone doctor emitted ~17.
918
+ issues = sdk_resolution_issues(
919
+ context.broken_project_pin, context.sdk_tier, context.foreign_global_default_for
920
+ )
921
+ # tan-cli#499: a bundle that went out UNREDACTED must say so. `_redact` is
922
+ # silent by construction (it returns a payload either way), so the one
923
+ # condition under which it is skipped wholesale is reported here, beside
924
+ # the path the user is about to attach.
925
+ refusal = home_redaction_refusal(_resolved_home())
926
+ if refusal is not None:
927
+ issues.append(Issue(REDACTION_SKIPPED_CODE, "warning", refusal))
928
+ issues.extend(_doctor_issues(checks))
929
+ exit_code = doctor_cmd.exit_code_for(checks)
930
+
931
+ data = {
932
+ "schemaVersion": DATA_SCHEMA_VERSION,
933
+ "generatedAt": generated_at,
934
+ "outputPath": output_path,
935
+ "targetKind": target,
936
+ "server": server,
937
+ "decisionCount": len(decisions),
938
+ }
939
+ text = [
940
+ f"support-bundle: exported {output_path}",
941
+ f"support-bundle: trace decisions={len(decisions)}",
942
+ ]
943
+ return _Outcome(
944
+ exit_code=exit_code,
945
+ data=data,
946
+ project=context.project,
947
+ sdk=context.sdk,
948
+ issues=issues,
949
+ text=text,
950
+ verbose_hint_eligible=True,
951
+ )
952
+
953
+
954
+ def support_bundle(
955
+ ctx: typer.Context,
956
+ project: str = typer.Option(
957
+ None, "--project", metavar="PATH", help="Project root (defaults to current directory)."
958
+ ),
959
+ target_kind: str = typer.Option(
960
+ None,
961
+ "--target-kind",
962
+ metavar="KIND",
963
+ help="Debug target class (zephyr-mcu, baremetal-mcu, yocto-userspace, native-host).",
964
+ ),
965
+ board_yaml: str = typer.Option(
966
+ None,
967
+ "--board-yaml",
968
+ metavar="PATH",
969
+ help="Explicit board.yaml path (overrides project resolution).",
970
+ ),
971
+ server: str = typer.Option(
972
+ None,
973
+ "--server",
974
+ metavar="SERVER",
975
+ help="Debug server backend (jlink, openocd, pyocd, gdbserver, none).",
976
+ ),
977
+ path: str = typer.Option(
978
+ None, "--path", metavar="PATH", help="Limit generation tracing to this config key path."
979
+ ),
980
+ sdk_root: str = typer.Option(
981
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
982
+ ),
983
+ destination: str = typer.Option(
984
+ None,
985
+ "--destination",
986
+ metavar="DESTINATION",
987
+ help="Output directory for the bundle (default: <workspace>/.alp-support).",
988
+ ),
989
+ target: str = typer.Option(
990
+ None,
991
+ "--target",
992
+ metavar="EMIT",
993
+ help="Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf).",
994
+ ),
995
+ output_format: OutputFormat = typer.Option(None, "--format", help=FORMAT_HELP),
996
+ verbose: bool = typer.Option(
997
+ False, "--verbose", help="Emit additional diagnostic detail."
998
+ ),
999
+ quiet: bool = typer.Option(False, "--quiet", hidden=True),
1000
+ no_color: bool = typer.Option(False, "--no-color", hidden=True),
1001
+ non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True),
1002
+ ci: bool = typer.Option(False, "--ci", hidden=True),
1003
+ all_targets: bool = typer.Option(False, "--all", hidden=True),
1004
+ ) -> None:
1005
+ """Export a diagnostic support bundle (inspect + trace + doctor).
1006
+
1007
+ `--all` is accepted and ignored, same as `tan trace`. `--quiet`/
1008
+ `--no-color`/`--non-interactive`/`--ci` are `global = true` clap options
1009
+ `support_bundle.rs` never reads.
1010
+ """
1011
+ del quiet, no_color, non_interactive, ci, all_targets
1012
+ resolved_format = resolve_format(output_format, ctx.obj, choices=OutputFormat)
1013
+ json_mode = resolved_format == "json"
1014
+
1015
+ try:
1016
+ outcome = _run(
1017
+ project_arg=project,
1018
+ board_yaml_arg=board_yaml,
1019
+ sdk_root_arg=sdk_root,
1020
+ target_kind_arg=target_kind,
1021
+ server_arg=server,
1022
+ path_arg=path,
1023
+ target_arg=target,
1024
+ destination_arg=destination,
1025
+ )
1026
+ except Exception as err: # noqa: BLE001 -- see debug_config_cmd's identical guard
1027
+ outcome = _internal_failure(
1028
+ generated_at_iso(millis=True),
1029
+ f"support-bundle failed unexpectedly: {err.__class__.__name__}: {err}",
1030
+ NATIVE_HOST,
1031
+ SERVER_NONE,
1032
+ None,
1033
+ )
1034
+
1035
+ if not json_mode:
1036
+ # tan-cli#478 review finding 6: the bundle FILE and the JSON envelope
1037
+ # both carry `sdkResolution`/the foreign-default pair now, but
1038
+ # `outcome.text` (every `_Outcome` constructor above) never did --
1039
+ # the default `tan support-bundle` with no `--format` stayed silent.
1040
+ # Read off `outcome.issues`, which every `_Outcome` constructor above
1041
+ # now builds from `context`'s own carried
1042
+ # `broken_project_pin`/`sdk_tier`/`foreign_global_default_for` --
1043
+ # including the three early-return failure paths. Re-deriving here
1044
+ # from `outcome.sdk` instead would be a second copy of the same
1045
+ # decision in the one command whose whole purpose is explaining a
1046
+ # broken machine; this only ever re-prints what is already there.
1047
+ #
1048
+ # tan-cli#499 adds `support-bundle.redaction-skipped` to the same
1049
+ # loop: the warning that the attached file was NOT scrubbed is
1050
+ # useless in the one mode the user reads, and text is the default.
1051
+ for issue in outcome.issues:
1052
+ if issue.code.startswith("sdk.") or issue.code == REDACTION_SKIPPED_CODE:
1053
+ typer.echo(issue.message, err=True)
1054
+ for line in outcome.text:
1055
+ typer.echo(line, err=True)
1056
+ if verbose and outcome.verbose_hint_eligible:
1057
+ typer.echo(
1058
+ "support-bundle: include --format json for machine-readable envelopes.",
1059
+ err=True,
1060
+ )
1061
+
1062
+ if json_mode:
1063
+ emit(
1064
+ Envelope(
1065
+ "support-bundle",
1066
+ outcome.project,
1067
+ outcome.data,
1068
+ outcome.issues,
1069
+ outcome.exit_code,
1070
+ sdk=outcome.sdk,
1071
+ )
1072
+ )
1073
+ raise typer.Exit(int(outcome.exit_code))