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,1723 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan sdk <list|install|current|switch>` -- which alp-sdk checkout is active.
3
+
4
+ Mirrors `crates/tan-cli/src/commands/sdk.rs`. A subcommand GROUP, but not a
5
+ Click/clap subcommand group: the Rust takes `subcommand` and `arg` as two plain
6
+ positionals (`cli.rs`'s `SdkArgs`), which is precisely why an unknown verb
7
+ reaches the command's own dispatch and gets a real envelope instead of a
8
+ parser-level usage error. Reproduced here for the same reason -- promoting
9
+ `sdk` to a `typer.Typer()` sub-app would hand `tan sdk bogus` to Click, which
10
+ exits 2 with usage text on stderr and NOTHING on stdout. The
11
+ `sdk-unknown-subcommand` golden pins exit **1** and a full envelope, so the
12
+ group has to stay flat.
13
+
14
+ Two committed goldens drive this file:
15
+
16
+ * `sdk-current-no-sdk` -- `sdk current` in a workspace with no SDK anywhere:
17
+ `sdkPath: null`, `readiness: null`, `sourceTier: "none"`, exit 0. It is a
18
+ SUCCESS: "nothing is selected" is an answer, not a failure.
19
+ * `sdk-unknown-subcommand` -- `sdk bogus`, exit 1. Note the asymmetry, which is
20
+ the contract and not a bug to tidy: the failure envelope carries the
21
+ **`list`-shaped** payload (`{"subcommand": "list", "releases": []}`), because
22
+ the Rust builds its unknown-verb failure out of `ListData`. `data.releases` is
23
+ what the extension reads with a `?? []` fallback, so the shape is deliberate.
24
+
25
+ **It cannot hang.** `current` is pure filesystem probing -- four tier lookups,
26
+ a readiness stat, and an optional `~/.alp/sdk-cache` listing. No subprocess, no
27
+ network, no prompt. That is a property of the no-checkout golden, not an
28
+ accident of it.
29
+
30
+ **Network is opt-in.** `sdk list` is the one verb that talks to GitHub, and it
31
+ only does so behind an explicit `--online`. tan-cli#351: without the flag it
32
+ answers OFFLINE, at exit 0 -- not a refusal. The oracle has no `--online` flag
33
+ at all and reaches the network unconditionally on every `sdk list` call
34
+ (measured: `--help` lists no such option; a live run with network reachable
35
+ succeeds at exit 0 with no flag given), so gating the call is this port's OWN
36
+ addition, for hermeticity (I-23) -- a command that silently opens a socket
37
+ cannot be driven from a hermetic test, an air-gapped host, or a fixture. That
38
+ gate is not a verdict on anything the caller did wrong, so it must not exit
39
+ non-zero: the bare answer says plainly that the releases it reports are
40
+ UPSTREAM and that `--online` is the switch that fetches them, the same way
41
+ `sdk current` answers "nothing configured" at exit 0 instead of failing (see
42
+ `_run_list`'s own docstring for the full reasoning). The fetch itself carries
43
+ an explicit timeout, because a `urlopen` with no timeout inherits the socket
44
+ default of "forever" and a CI job driving `--format json` would hang until the
45
+ runner killed it (I-23's failure mode, arrived at through a socket instead of
46
+ a prompt).
47
+
48
+ **No SDK is ever shelled.** Nothing here runs `python -m alp_cli` or
49
+ `alp_project.py`: readiness is a stat of `scripts/alp_project.py`,
50
+ `metadata/`, `VERSION` and `metadata/sdk_version.yaml`. I-32 and the
51
+ port-invariants anti-pattern list (#22, "Ship `tan init` shelling the SDK
52
+ instead of reading the vendored scaffold tree") forbid giving a command an
53
+ alp-sdk-checkout dependency it does not have; `sdk current`'s whole job is to
54
+ report on a checkout that may not exist at all, so shelling one would be
55
+ circular as well as forbidden.
56
+
57
+ **tan learns no hardware fact** (I-26). This file knows one path literal --
58
+ `scripts/alp_project.py`, the SDK-root marker (I-31) -- and no SKU, address,
59
+ pin name or vendor branch.
60
+
61
+ NOT PORTED, and loud about it: `install` and `switch`. Both refuse with
62
+ `sdk.not-ported` (exit 1, see `_run_not_ported`'s own docstring for why not 5)
63
+ rather than half-working. `switch` in particular must not land partially: the
64
+ Rust writes the active-SDK pointer AND reconciles `<topdir>/.west/config`'s own
65
+ `manifest.path` (tan-cli #62/#31), and a version that writes the pointer while
66
+ silently skipping the reconciliation reports success while `west` keeps
67
+ resolving the manifest from the stale pointer -- which is the bug #62
68
+ reported, re-introduced invisibly. A loud refusal is strictly safer than that.
69
+
70
+ **tan-cli#305: that refusal must not be a dead end.** `doctor_cmd`,
71
+ `bootstrap_cmd`, and this module's own `sdk current` used to independently
72
+ recommend `sdk install`/`sdk switch` as the fix for "no SDK resolved" -- three
73
+ copies of advice naming a subcommand this build refuses, which on a clean
74
+ host (no checkout, no cache, no pointer anywhere) left `tan bootstrap`
75
+ unreachable by any documented path. `NO_SDK_NEXT_STEPS` below is the one
76
+ surviving mechanism (`--sdk-root`, plus how to get a checkout at all) all
77
+ three now read instead.
78
+ """
79
+
80
+ from __future__ import annotations
81
+
82
+ import json
83
+ import os
84
+ from pathlib import Path
85
+ from typing import TYPE_CHECKING, Any, NamedTuple
86
+
87
+ if TYPE_CHECKING: # pragma: no cover
88
+ # Type-checker only. `urllib.request` is deferred into the two functions
89
+ # that use it (tan-cli#810), so this name exists for annotations and
90
+ # costs nothing at runtime -- `TYPE_CHECKING` is False in a real run.
91
+ from urllib.request import OpenerDirector
92
+
93
+ import typer
94
+
95
+ from tan.core.atomic_write import atomic_write_text
96
+ from tan.core.global_flags import accept_global_flags
97
+ from tan.core.proxy import (
98
+ HTTPS_PROXY_ENV_VARS,
99
+ host_of,
100
+ select_https_proxy,
101
+ unsupported_proxy_scheme,
102
+ )
103
+ # `_has_loader_script`/`_home_alp_dir`/`_read_file` moved to
104
+ # `tan.core.sdk_discovery` alongside `resolve_sdk_tiered` (tan-cli#408 review
105
+ # follow-up) -- `check_sdk_readiness`/`cached_sdk_versions`/
106
+ # `_default_cache_root` below still need them for readiness reporting, which
107
+ # stayed here because it is not part of the discovery/resolution cluster.
108
+ # `_abs_posix`/`_pointer_target` are the same kind of shared filesystem
109
+ # primitive, pulled in for `sdk remove` (tan-cli#790): `_abs_posix` is the
110
+ # cwd-anchored/lexical path form every removal-target comparison below uses,
111
+ # `_pointer_target` is the direct (not `resolve_sdk_tiered`-mediated) read of
112
+ # `~/.alp/sdk-default`'s own `sdkPath`, needed because that pointer can name
113
+ # a checkout THIS workspace does not resolve through at all while still being
114
+ # exactly what removing it would orphan for some OTHER project on the host.
115
+ from tan.core.sdk_discovery import (
116
+ ActiveSdk,
117
+ _abs_posix,
118
+ _has_loader_script,
119
+ _home_alp_dir,
120
+ _pointer_target,
121
+ _read_file,
122
+ global_default_foreign_project_issue,
123
+ project_pin_issue,
124
+ resolve_sdk_root_ladder,
125
+ resolve_sdk_tiered,
126
+ sdk_ladder_divergence_issue,
127
+ )
128
+ from tan.core.sdk_default_registry import (
129
+ load_raw,
130
+ normalized_sdk_path,
131
+ parse_registry,
132
+ prune_entries_by_sdk_path,
133
+ registry_path,
134
+ registry_text,
135
+ )
136
+ from tan.core.sdk_removal import (
137
+ RemovalOutcome,
138
+ is_cache_root_itself,
139
+ is_outside_cache_root,
140
+ removal_would_damage,
141
+ removal_would_take_out,
142
+ remove_sdk_tree,
143
+ resolve_removal_target,
144
+ )
145
+ from tan.core.text_layout import wrap_lines
146
+ from tan.env import wrap_width
147
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
148
+ from tan.exit_codes import ExitCode
149
+ from tan.net import default_ssl_context
150
+ from tan.output_format import FORMAT_HELP, OutputFormat
151
+
152
+ #: GitHub Releases API endpoint for `alplabai/alp-sdk`, from
153
+ #: `tan_core::sdk::GITHUB_RELEASES_URL`.
154
+ GITHUB_RELEASES_URL = "https://api.github.com/repos/alplabai/alp-sdk/releases"
155
+
156
+
157
+ #: Wall-clock ceiling on the ONE network call in this file. Every subprocess and
158
+ #: socket probe in the port carries a timeout; `urlopen` without one blocks on
159
+ #: the socket default (no timeout at all), which turns an unreachable proxy into
160
+ #: a hung `--format json` consumer rather than an error it can render.
161
+ NETWORK_TIMEOUT_SECONDS = 20.0
162
+
163
+ #: The verbs `run` dispatches, for the unknown-subcommand text line. Was
164
+ #: verbatim from `sdk.rs`'s failure text until tan-cli#305: the oracle's
165
+ #: `install`/`switch` really work, so its wording is honest there; this port's
166
+ #: do not (`_run_not_ported` below), so repeating it unqualified here was one
167
+ #: more site recommending a subcommand this build refuses.
168
+ AVAILABLE_SUBCOMMANDS = (
169
+ "Available subcommands: list, current, install <version> (refuses -- not "
170
+ "yet ported), switch <version> (refuses -- not yet ported), "
171
+ "remove <version|path>"
172
+ )
173
+
174
+ #: `install`/`switch` refuse outright in this build (`_run_not_ported` below)
175
+ #: -- the ONE fact `doctor_cmd.sdk_check` and `bootstrap_cmd`'s SDK-unresolved
176
+ #: refusal must read instead of each independently hardcoding "run `tan sdk
177
+ #: install <ver>`" (tan-cli#305). Three copies of that recommendation, none
178
+ #: checking whether the build it shipped in actually has the subcommand, is
179
+ #: how a clean host was left with no way to reach `tan bootstrap` at all.
180
+ NOT_PORTED_SDK_SUBCOMMANDS = frozenset({"install", "switch"})
181
+
182
+ #: The one accurate way to point tan at an SDK checkout in THIS build. Neither
183
+ #: `sdk install` nor `sdk switch` works (tan-cli#305), so `--sdk-root` on the
184
+ #: invocation itself is the only surviving mechanism, and a host with no
185
+ #: checkout anywhere needs telling how to get one at all -- an accurate manual
186
+ #: instruction beats naming a subcommand that exits 1. Shared by
187
+ #: `doctor_cmd.sdk_check`, `bootstrap_cmd`'s SDK-unresolved refusal, and
188
+ #: `_run_current` below so the three cannot drift into three different,
189
+ #: independently-worded dead ends again.
190
+ NO_SDK_NEXT_STEPS = (
191
+ "get an alp-sdk checkout (`git clone https://github.com/alplabai/alp-sdk`), "
192
+ "then point tan at it with `--sdk-root <path>`"
193
+ )
194
+
195
+ #: `sdk list`'s proxy/CA hint, verbatim from `tan_core::sdk::describe_network_error`.
196
+ #: Names only the variables that actually steer an `https://` request -- neither
197
+ #: tan nor git applies `HTTP_PROXY` to one, so naming it sent users to edit a
198
+ #: variable that changes nothing. That claim was FALSE for `ALL_PROXY` until
199
+ #: tan-cli#497: `urllib` mapped it to a key its `https` dispatch never consulted,
200
+ #: so this hint named a variable that really did change nothing. `tan/core/proxy.py`
201
+ #: is what makes all three names accurate.
202
+ _PROXY_HINT = (
203
+ "Check ALL_PROXY/HTTPS_PROXY/NO_PROXY — the configured proxy refused or "
204
+ "could not complete the connection."
205
+ )
206
+
207
+ #: #304: this used to open "This is USUALLY a TLS-intercepting proxy or a
208
+ #: corporate CA that this machine does not trust" -- a confident diagnosis with
209
+ #: no evidence behind it. The v0.5.0-rc2 asset hit this exact error with no
210
+ #: proxy and no corporate CA anywhere in sight; the real cause was the frozen
211
+ #: binary shipping with no CA bundle at all (fixed in `tan/net.py`, but a
212
+ #: future build that regresses that fix must not be misdiagnosed the same way
213
+ #: again). A message naming a probable cause needs evidence for that cause or
214
+ #: has to name the alternative -- this names both and gives the one comparison
215
+ #: (`curl`/a browser against the same host) that tells them apart, mirroring
216
+ #: how #304 itself was actually diagnosed.
217
+ _TLS_HINT = (
218
+ "Not necessarily a broken connection -- this can mean a TLS-intercepting "
219
+ "proxy or a corporate CA this machine does not trust, or that this tan "
220
+ "build's own CA trust failed to load. If curl or a browser reach this host "
221
+ "fine from here, the trust store is the likelier cause; if they fail too, "
222
+ "the proxy/CA is."
223
+ )
224
+
225
+
226
+ def global_default_pointer_fix_hint(native_path: str, native_registry_path: str) -> str:
227
+ """How to fix -- or safely clear -- an already-written `~/.alp/sdk-
228
+ default` pointer (`native_path`) AND its origin-keyed sibling
229
+ `~/.alp/sdk-defaults.json` (`native_registry_path`, tan-cli#466) by hand.
230
+ Both are the caller's to compute -- `_home_alp_dir() / "sdk-default"` and
231
+ `sdk_default_registry.registry_path(_home_alp_dir())`, each rendered
232
+ through whatever this-platform-separator helper it already has
233
+ (`bootstrap_cmd._native` for its callers).
234
+
235
+ Names BOTH files, unconditionally, because tan-cli#466's registry is
236
+ consulted FIRST and a caller cannot tell from the fix-hint's call site
237
+ alone whether the answer it just got came from the registry or fell
238
+ through to the legacy pointer -- so a hint naming only one of them could
239
+ send a reader to edit the file that was not actually the one that
240
+ answered.
241
+
242
+ `_pointer_target` above degrades every read failure on the legacy file
243
+ (missing, invalid JSON, list-shaped, no `sdkPath`) to `None`, and
244
+ `sdk_default_registry.parse_registry` degrades every read/parse failure
245
+ on the registry the identical way, to `{}` -- both resolvers
246
+ (`resolve_sdk_tiered`) then fall through to the next tier on that, so
247
+ DELETING either or both files is always a safe recovery, never a step
248
+ backwards; hand-editing a `"sdkPath"` field is the targeted fix when the
249
+ caller knows what it should say instead.
250
+
251
+ Shared so a caller describing this by hand cannot drift from what
252
+ `_pointer_target`/`parse_registry` actually read. `bootstrap_cmd`'s
253
+ workspace-relocation and rollback-failure messages are why this exists
254
+ (tan-cli#305 follow-up): they used to send a user -- sometimes one
255
+ already in a broken, checkout-moved-but-rollback-incomplete state -- to
256
+ `tan sdk switch --global`, which refuses outright in this build. Naming
257
+ the pointer files directly, not the command, is also honest about the
258
+ mechanism `switch` itself would use once ported, so this will not go
259
+ stale the moment that disposition changes.
260
+ """
261
+ return (
262
+ f"delete {native_path} and/or {native_registry_path} (tan falls "
263
+ f'through to the next SDK it can resolve), or edit the "sdkPath" '
264
+ f"field(s) by hand"
265
+ )
266
+
267
+
268
+ # ── readiness (tan_core::sdk::check_sdk_readiness) ──────────────────────────
269
+
270
+
271
+ def parse_sdk_version_yaml(text: str) -> str | None:
272
+ """`metadata/sdk_version.yaml`'s version: a `version:` line if present, else
273
+ the first bare (colon-free) scalar. A hand-rolled scan, deliberately, exactly
274
+ as the Rust is: the file is comment-heavy prose around a single scalar, and
275
+ tan ships no YAML dependency the offline paths can rely on.
276
+ """
277
+ bare: str | None = None
278
+ for raw in text.splitlines():
279
+ line = raw.strip()
280
+ if not line or line.startswith("#"):
281
+ continue
282
+ if line.startswith("version:"):
283
+ value = line[len("version:") :].strip().strip('"').strip("'")
284
+ if value:
285
+ return value
286
+ elif bare is None and ":" not in line:
287
+ bare = line.strip('"').strip("'")
288
+ return bare
289
+
290
+
291
+ def check_sdk_readiness(sdk_path: str) -> dict[str, Any]:
292
+ """Inspect a local SDK path. Never raises; a problem is an `issues[]` string.
293
+
294
+ `version` reads `VERSION` FIRST and falls back to
295
+ `metadata/sdk_version.yaml` (tan-cli #162): no released alp-sdk ships a
296
+ top-level `VERSION` file, so before the fallback every `sdk current`
297
+ reported `version (unknown)` while `tan doctor` resolved a real one from the
298
+ other file. `VERSION` stays first so an extracted release archive that DOES
299
+ carry one is never regressed.
300
+
301
+ `state` is `missing` on a absent loader script (not an SDK root at all),
302
+ `partial` when the marker is there but something else is not, else `ready`.
303
+ Note the asymmetry that follows: a missing `metadata/` adds an issue but
304
+ keeps `loaderScriptPresent` true, so the verdict is `partial`, never
305
+ `missing`.
306
+ """
307
+ root = Path(sdk_path)
308
+ issues: list[str] = []
309
+
310
+ loader_present = _has_loader_script(root)
311
+ if not loader_present:
312
+ issues.append(
313
+ f'scripts/alp_project.py not found — "{sdk_path}" is not a valid '
314
+ "Alp SDK root."
315
+ )
316
+
317
+ metadata_present = (root / "metadata").exists()
318
+ if not metadata_present:
319
+ issues.append("metadata/ directory is missing.")
320
+
321
+ version: str | None = None
322
+ version_file = root / "VERSION"
323
+ if version_file.exists():
324
+ contents = _read_file(version_file)
325
+ if contents is None:
326
+ issues.append("VERSION file could not be read.")
327
+ else:
328
+ # An empty/whitespace VERSION is "no version", not the empty string
329
+ # -- `format_readiness_block` renders `None` as `(unknown)`.
330
+ version = contents.strip() or None
331
+ if version is None:
332
+ sdk_version_yaml = root / "metadata" / "sdk_version.yaml"
333
+ if sdk_version_yaml.exists():
334
+ contents = _read_file(sdk_version_yaml)
335
+ if contents is not None:
336
+ version = parse_sdk_version_yaml(contents)
337
+
338
+ if not loader_present:
339
+ state = "missing"
340
+ elif issues:
341
+ state = "partial"
342
+ else:
343
+ state = "ready"
344
+
345
+ return {
346
+ "sdkPath": sdk_path,
347
+ "version": version,
348
+ "loaderScriptPresent": loader_present,
349
+ "metadataPresent": metadata_present,
350
+ "state": state,
351
+ "issues": issues,
352
+ }
353
+
354
+
355
+ def empty_readiness(sdk_path: str) -> dict[str, Any]:
356
+ """The placeholder `missing` report a failure envelope carries when no real
357
+ check ran (`sdk.rs`'s `empty_readiness`) -- so `data.readiness` keeps its
358
+ full key set on every path instead of appearing and disappearing."""
359
+ return {
360
+ "sdkPath": sdk_path,
361
+ "version": None,
362
+ "loaderScriptPresent": False,
363
+ "metadataPresent": False,
364
+ "state": "missing",
365
+ "issues": [],
366
+ }
367
+
368
+
369
+ # ── the install cache ────────────────────────────────────────────────────────
370
+ # `ActiveSdk`/`resolve_sdk_tiered`/the project-pin and global-default `Issue`
371
+ # builders moved to `tan.core.sdk_discovery` (tan-cli#408 review follow-up,
372
+ # see the module docstring and the top-of-file import) -- `_run_current`
373
+ # below imports them back from there.
374
+
375
+
376
+ def _default_cache_root() -> Path:
377
+ """`~/.alp/sdk-cache` -- where `tan sdk install` clones to."""
378
+ return _home_alp_dir() / "sdk-cache"
379
+
380
+
381
+ def cached_sdk_versions(cache_root: Path | None = None) -> list[str]:
382
+ """Directory names directly under the cache root that hold a real checkout,
383
+ sorted. An unreadable or absent cache root is an empty list, never an error.
384
+
385
+ This is what lets `current`'s "nothing selected" message name the
386
+ checkouts already sitting in the cache instead of claiming none exist --
387
+ tan-cli #162, a loop with no exit. Deliberately does not guess WHICH
388
+ version to point at; it lists what exists and lets the user choose (via
389
+ `--sdk-root`, since `sdk switch` itself refuses -- tan-cli#305).
390
+ """
391
+ root = _default_cache_root() if cache_root is None else cache_root
392
+ try:
393
+ return sorted(entry.name for entry in root.iterdir() if _has_loader_script(entry))
394
+ except OSError:
395
+ return []
396
+
397
+
398
+ # ── human text (stderr only; stdout is the envelope channel) ─────────────────
399
+
400
+
401
+ def no_active_sdk_text(cached: list[str]) -> list[str]:
402
+ """`sdk current`'s "nothing is configured" guidance. Used to send an empty
403
+ cache to `sdk install` and a populated one to `sdk switch` -- neither
404
+ works in this build (tan-cli#305), so both branches now point at
405
+ `NO_SDK_NEXT_STEPS`, the one mechanism that does."""
406
+ if not cached:
407
+ return [
408
+ "No active SDK configured for this workspace.",
409
+ f"To get started, {NO_SDK_NEXT_STEPS}.",
410
+ ]
411
+ return [
412
+ "No active SDK configured for this workspace.",
413
+ f"Already installed under {_default_cache_root()}: {', '.join(cached)}",
414
+ "Point tan at one with `--sdk-root <path>`.",
415
+ ]
416
+
417
+
418
+ def format_readiness_block(header: str, report: dict[str, Any]) -> list[str]:
419
+ lines = [
420
+ header,
421
+ f" path {report['sdkPath']}",
422
+ f" version {report['version'] or '(unknown)'}",
423
+ f" state {report['state']}",
424
+ ]
425
+ if report["issues"]:
426
+ lines.append(" issues:")
427
+ lines.extend(f" - {issue}" for issue in report["issues"])
428
+ return lines
429
+
430
+
431
+ def format_release_table(releases: list[dict[str, Any]]) -> list[str]:
432
+ """Tag, publish date, truncated notes, then the draft/prerelease flags.
433
+
434
+ Flags come AFTER the notes (tan-cli #122): placed between the fixed-width
435
+ date and the notes they would shift the notes column by their own width.
436
+ """
437
+ if not releases:
438
+ return ["No SDK releases found."]
439
+ lines = [f"Alp SDK releases ({len(releases)})"]
440
+ for release in releases:
441
+ date = release["publishedAt"][:10]
442
+ # Collapse ALL whitespace before truncating. `releaseNotesSummary` is
443
+ # the release body verbatim, and a body whose first line is short
444
+ # (`## Highlights`) used to put a literal newline INSIDE a table row:
445
+ # the row spilled onto a second, unindented line and the `{flags}`
446
+ # suffix landed after the spilled text rather than on the row it
447
+ # describes (#316). Collapsing also spends the 60-char budget on
448
+ # visible text rather than on layout this table then discards.
449
+ summary = " ".join((release["releaseNotesSummary"] or "").split())
450
+ # Char-aware truncation to 60, no ellipsis -- Python slicing is by
451
+ # character, matching the Rust's `chars().take(60)` rather than bytes.
452
+ # The Rust does NOT collapse first, so this is a deliberate divergence
453
+ # on the text surface: measured, no committed fixture and no parity
454
+ # case covers this table, and the oracle renders the same broken row.
455
+ notes = f" {summary[:60]}" if summary else ""
456
+ flags = {
457
+ (True, True): " [draft, prerelease]",
458
+ (True, False): " [draft]",
459
+ (False, True): " [prerelease]",
460
+ (False, False): "",
461
+ }[(bool(release["draft"]), bool(release["prerelease"]))]
462
+ lines.append(f" {release['tag']:<12} {date}{notes}{flags}")
463
+ return lines
464
+
465
+
466
+ # ── sdk list: the one network path, opt-in and timed out ─────────────────────
467
+
468
+
469
+ def _proxy_variable_name() -> str:
470
+ """The env var [`select_https_proxy`] actually read the winning proxy from,
471
+ for a message that sends the reader to the right line of their shell
472
+ profile. `ALL_PROXY` by default -- the head of the precedence list -- when
473
+ none is set, which only happens if a caller asks outside a proxied run."""
474
+ for name in HTTPS_PROXY_ENV_VARS:
475
+ value = os.environ.get(name)
476
+ if value and value.strip():
477
+ return name
478
+ return HTTPS_PROXY_ENV_VARS[0]
479
+
480
+
481
+ def describe_network_error(error: str, *, proxy_used: bool) -> str:
482
+ """Append the likely environmental cause to a raw transport error.
483
+
484
+ A middlebox-signed certificate and an unreachable proxy both read to a user
485
+ as "the network is down", so they go hunting in the wrong place. Ordering is
486
+ the whole design and matches `tan_core::sdk::describe_network_error`:
487
+ named-proxy first (a proxy failure that also mentions TLS is still a proxy
488
+ problem), then certificate/TLS even when a proxy IS set (the middlebox is
489
+ reachable; it is its CA that is untrusted, and the proxy vars are the wrong
490
+ knob there), and only then a bare connect failure -- and only when there is
491
+ a proxy to blame.
492
+
493
+ `proxy_used` is the caller's own POST-`NO_PROXY` answer -- whether the
494
+ request that just failed actually went through a proxy -- not a bare env
495
+ scan. The bare scan was a second wrong-blame case (tan-cli#497 defect 8):
496
+ `NO_PROXY=api.github.com` beside a `HTTPS_PROXY` correctly sends this
497
+ request DIRECT (measured: rc 0, 13 releases), yet a failure on that direct
498
+ connection was still hinted at the proxy, which had no part in it. The Rust
499
+ this ports (`http.rs:115`, `proxy_configured() -> env_proxy().is_some()`)
500
+ is post-bypass too.
501
+ """
502
+ lower = error.lower()
503
+ if "proxy" in lower:
504
+ hint = _PROXY_HINT
505
+ elif "certificate" in lower or "tls" in lower:
506
+ hint = _TLS_HINT
507
+ elif proxy_used and "connect" in lower:
508
+ hint = _PROXY_HINT
509
+ else:
510
+ return error
511
+ return f"{error} {hint}"
512
+
513
+
514
+ def _str_field(item: dict[str, Any], key: str) -> str:
515
+ value = item.get(key)
516
+ return value if isinstance(value, str) else ""
517
+
518
+
519
+ def _first_paragraph(body: str) -> str:
520
+ trimmed = body.strip()
521
+ if not trimmed:
522
+ return ""
523
+ head, separator, _ = trimmed.partition("\n\n")
524
+ return head.strip() if separator else trimmed
525
+
526
+
527
+ def parse_remote_sdk_releases(payload: Any) -> list[dict[str, Any]] | None:
528
+ """Map the GitHub Releases payload onto the wire shape, or `None` when the
529
+ response is not an array at all.
530
+
531
+ Entries without a string `tag_name` are dropped (there is nothing to name
532
+ them). `draft`/`prerelease` default to `False` when absent or non-boolean:
533
+ absent means "not flagged", never a reason to drop the release (tan-cli
534
+ #122).
535
+ """
536
+ if not isinstance(payload, list):
537
+ return None
538
+ releases: list[dict[str, Any]] = []
539
+ for item in payload:
540
+ if not isinstance(item, dict) or not isinstance(item.get("tag_name"), str):
541
+ continue
542
+ body = _str_field(item, "body")
543
+ releases.append(
544
+ {
545
+ "tag": _str_field(item, "tag_name"),
546
+ "publishedAt": _str_field(item, "published_at"),
547
+ "tarballUrl": _str_field(item, "tarball_url"),
548
+ "releaseNotesSummary": _first_paragraph(body),
549
+ "releaseNotes": body.strip(),
550
+ "draft": item.get("draft") is True,
551
+ "prerelease": item.get("prerelease") is True,
552
+ }
553
+ )
554
+ return releases
555
+
556
+
557
+ def _unroutable_proxy_refusal(proxy: str | None, url: str) -> str | None:
558
+ """Why this request must not be attempted at all, or `None`.
559
+
560
+ REFUSES rather than falling back to a direct connection when the selected
561
+ proxy uses a scheme urllib cannot carry (`unsupported_proxy_scheme`). A host
562
+ that exports a SOCKS proxy is telling tan it has no other egress, or that
563
+ direct egress is not permitted; quietly ignoring it is exactly the
564
+ silent-bypass defect this path was fixed for (tan-cli#497 defect 8). The
565
+ oracle CAN dial SOCKS -- `crates/tan-cli/src/http.rs` compiles ureq's
566
+ `socks-proxy` feature, measured -- so this names the limitation instead of
567
+ hiding it.
568
+
569
+ **The remediation names the variable that actually WON, and offers unsetting
570
+ it first.** The first cut said "Set `HTTPS_PROXY` to an http:// or https://
571
+ proxy", which cannot take effect: `HTTPS_PROXY_ENV_VARS` puts `ALL_PROXY`
572
+ ahead of `HTTPS_PROXY`, so the selection never reaches the variable the
573
+ reader was told to set. Measured --
574
+ `ALL_PROXY=socks5://127.0.0.1:45997 HTTPS_PROXY=http://127.0.0.1:45996` gave
575
+ the identical refusal at rc 1 with NEITHER socket touched. That is the same
576
+ self-defeating-remediation class as tan-cli#497 defect 7, which this branch
577
+ fixed two commands away: an instruction the user can follow exactly and be
578
+ no better off.
579
+ """
580
+ if proxy is None:
581
+ return None
582
+ scheme = unsupported_proxy_scheme(proxy)
583
+ if scheme is None:
584
+ return None
585
+ variable = _proxy_variable_name()
586
+ return (
587
+ f"Alp SDK: {variable} names a {scheme}:// proxy, which this "
588
+ f"build of tan cannot route through (its HTTP client has no {scheme} "
589
+ f"transport). Unset {variable}, or point it at an http:// or https:// "
590
+ f"proxy, or add {host_of(url)} to NO_PROXY to allow a direct connection."
591
+ )
592
+
593
+
594
+ def _releases_opener(proxy: str | None) -> OpenerDirector:
595
+ """An opener carrying OUR proxy decision, never urllib's own.
596
+
597
+ `urlopen` derives its handler from `getproxies_environment()`, which maps
598
+ `ALL_PROXY` to the key `all` -- and `OpenerDirector._open` dispatches on
599
+ `req.type` (`"https"`), so the `all_open` method that installs is never
600
+ called and the proxy is silently bypassed (tan-cli#497 defect 8). Both keys
601
+ carry the same value because a proxy chosen for an `https://` request is
602
+ what `ALL_PROXY` means, and an EMPTY mapping means DIRECT -- not "fall back
603
+ to the environment", which would re-open the `NO_PROXY` half of the same
604
+ defect.
605
+
606
+ tan-cli#810: `urllib.request` is imported HERE rather than at module scope.
607
+ `cli.py` static-imports every command module, so this file's module-scope
608
+ `import urllib.request` -- which drags `email`, `http` and `ssl` behind it
609
+ -- was paid by `tan --version`, for a stack only the two network functions
610
+ in this file ever touch.
611
+
612
+ The return annotation is spelled `OpenerDirector`, bound by the
613
+ `TYPE_CHECKING` block at the top rather than by a runtime import, and that
614
+ is deliberate: an annotation binds in the MODULE namespace, not the
615
+ function's, so `-> urllib.request.OpenerDirector` would have named
616
+ something no longer there. `from __future__ import annotations` keeps it a
617
+ string, so nothing breaks at def or call time -- but
618
+ `typing.get_type_hints()` on this function would raise `NameError`, and
619
+ that API is live in this very file (`accept_global_flags` calls it,
620
+ `core/global_flags.py`). It is aimed at the `sdk` COMMAND callback, never
621
+ at this private helper, so the two do not meet; do not add a wrapper that
622
+ resolves hints here without re-reading this paragraph.
623
+ """
624
+ import urllib.request # noqa: PLC0415
625
+
626
+ return urllib.request.build_opener(
627
+ urllib.request.ProxyHandler({} if proxy is None else {"http": proxy, "https": proxy}),
628
+ urllib.request.HTTPSHandler(context=default_ssl_context()),
629
+ )
630
+
631
+
632
+ def _fetch_releases(url: str = GITHUB_RELEASES_URL) -> tuple[list[dict[str, Any]], str | None]:
633
+ """GET the releases API and parse it. Returns `(releases, error_message)`;
634
+ exactly one side is meaningful.
635
+
636
+ Every failure -- DNS, TLS, a proxy, a 404, a truncated body, HTML where JSON
637
+ was promised -- comes back as a MESSAGE, so the caller always has an
638
+ envelope to emit. A bare `except Exception` is the point: this is the one
639
+ path in the file that touches a network stack, and an unanticipated
640
+ transport error escaping here is the port's recurring bug class (a traceback
641
+ on stderr, nothing on stdout, and an extension that renders nothing).
642
+ """
643
+ import urllib.request # noqa: PLC0415 -- deferred, see `_releases_opener`
644
+
645
+ request = urllib.request.Request( # noqa: S310 -- constant https:// endpoint
646
+ url,
647
+ headers={
648
+ "User-Agent": "tan-cli/0",
649
+ "Accept": "application/vnd.github+json",
650
+ "X-GitHub-Api-Version": "2022-11-28",
651
+ },
652
+ )
653
+ proxy = select_https_proxy(url)
654
+ refusal = _unroutable_proxy_refusal(proxy, url)
655
+ if refusal is not None:
656
+ return [], refusal
657
+ opener = _releases_opener(proxy)
658
+ try:
659
+ with opener.open(request, timeout=NETWORK_TIMEOUT_SECONDS) as response:
660
+ raw = response.read().decode("utf-8", errors="replace")
661
+ except Exception as err: # noqa: BLE001 -- see the docstring
662
+ detail = str(err) or type(err).__name__
663
+ return [], describe_network_error(detail, proxy_used=proxy is not None)
664
+
665
+ try:
666
+ payload = json.loads(raw)
667
+ except ValueError as err:
668
+ return [], f"Alp SDK: could not parse the GitHub Releases response: {err}"
669
+
670
+ releases = parse_remote_sdk_releases(payload)
671
+ if releases is None:
672
+ return [], "Alp SDK: unexpected response shape from GitHub Releases API."
673
+ return releases, None
674
+
675
+
676
+ # ── envelope plumbing ───────────────────────────────────────────────────────
677
+
678
+
679
+ def _list_data(releases: list[dict[str, Any]]) -> dict[str, Any]:
680
+ """`sdk list`'s payload -- and the payload the unknown-subcommand FAILURE
681
+ carries too, matching the Rust, which builds that refusal out of `ListData`.
682
+ `data.releases` is the key the extension reads with `?? []`."""
683
+ return {"subcommand": "list", "releases": releases}
684
+
685
+
686
+ def _emit(
687
+ *,
688
+ json_mode: bool,
689
+ data: dict[str, Any],
690
+ issues: list[Issue],
691
+ exit_code: ExitCode,
692
+ text_lines: list[str],
693
+ sdk: SdkInfo | None = None,
694
+ ) -> None:
695
+ """The single exit point: one envelope on stdout in JSON mode, human lines on
696
+ stderr otherwise, then `typer.Exit`.
697
+
698
+ `project` is always null (`sdk.rs`'s `null_project`) -- `sdk` verbs manage a
699
+ checkout, not a board. Text goes to stderr in BOTH modes so stdout can never
700
+ carry a stray byte beside the envelope; the conformance harness asserts
701
+ stderr is empty under `--format json`, which is why nothing is written there
702
+ in that mode at all.
703
+ """
704
+ if json_mode:
705
+ emit(
706
+ Envelope(
707
+ "sdk",
708
+ Project(root=None, board_yaml=None),
709
+ data,
710
+ issues,
711
+ exit_code,
712
+ sdk=sdk,
713
+ )
714
+ )
715
+ else:
716
+ stream = typer.get_text_stream("stderr")
717
+ for line in text_lines:
718
+ stream.write(f"{line}\n")
719
+ raise typer.Exit(int(exit_code))
720
+
721
+
722
+ def _fail(
723
+ *,
724
+ json_mode: bool,
725
+ data: dict[str, Any],
726
+ code: str,
727
+ message: str,
728
+ text_lines: list[str],
729
+ exit_code: ExitCode = ExitCode.RUNTIME_FAILURE,
730
+ issues: list[Issue] | None = None,
731
+ ) -> None:
732
+ """A refusal: the given payload plus the `sdk.<code>` error issue.
733
+ `exit_code` reaches the envelope as well as the process, so `ok` and
734
+ `exitCode` can never disagree.
735
+
736
+ `issues` is ADDITIVE, and always FOLLOWS the refusal (tan-cli#1051): the
737
+ error a caller is being refused for stays `issues[0]` on every branch, so
738
+ the `issues[0]["code"] == "sdk.<...>"` reading every existing test and
739
+ consumer does keeps meaning "the reason this failed" rather than
740
+ "whichever warning happened to be computed first". Its one caller so far
741
+ is `sdk remove`, carrying the `sdk.project-pin-unresolved` warning for a
742
+ `.alp/sdk-path` that no longer resolves -- a fact about the workspace that
743
+ is equally true whether the removal was refused, idempotent, or done, and
744
+ that must therefore reach the refusal branches too."""
745
+ _emit(
746
+ json_mode=json_mode,
747
+ data=data,
748
+ issues=[Issue(f"sdk.{code}", "error", message), *(issues or [])],
749
+ exit_code=exit_code,
750
+ text_lines=text_lines,
751
+ )
752
+
753
+
754
+ # ── subcommands ─────────────────────────────────────────────────────────────
755
+
756
+
757
+ def _run_current(*, json_mode: bool, sdk_root: str | None, workspace_root: Path) -> None:
758
+ """`tan sdk current` -- the active SDK, its readiness, and the winning tier.
759
+
760
+ Always exit 0. "Nothing is configured" is a successful answer to the
761
+ question asked, and the `sdk-current-no-sdk` golden pins it.
762
+ """
763
+ active = resolve_sdk_tiered(sdk_root, workspace_root)
764
+ if active.path is None:
765
+ # tan-cli#497 defect 1. `resolve_sdk_tiered` alone has NO candidate for
766
+ # a CHILD `<ws>/alp-sdk` -- the README Quickstart cwd, straight after
767
+ # `git clone https://github.com/alplabai/alp-sdk`. Measured there: this
768
+ # command answered `sdkPath: null`, `sourceTier: "none"`, `issues: []`
769
+ # and printed "get an alp-sdk checkout (`git clone ...`)", while
770
+ # `doctor`, `build`, `validate`, `inspect` and `trace` in that SAME cwd
771
+ # all reported `<ws>/alp-sdk` at `sourceTier: "discovery"` through
772
+ # `tan.core.sdk_discovery.resolve_sdk_root_ladder` -- whose own docstring names `sdk
773
+ # current` as one of its thirteen callers, and whose wide-walk TAIL is
774
+ # the tier missing here. `discover_workspace_sdk`'s docstring states the
775
+ # invariant this broke: "the tier it reports has to be what
776
+ # build/validate/doctor would actually resolve here."
777
+ #
778
+ # An intentional divergence from the oracle, which has the identical
779
+ # split (`sdk.rs::run_current` also calls the narrow `resolve_sdk_tiered`
780
+ # while `util::resolve_sdk_root` falls through to `discover_sdk_root`).
781
+ # `sdk current` is the one command whose entire job is answering "which
782
+ # SDK am I on?", so answering it differently from every command that
783
+ # ACTS on that SDK is the defect, not the parity.
784
+ #
785
+ # Consulted ONLY when the narrow ladder resolved nothing, and the ladder
786
+ # itself is reused rather than its tail re-implemented: this can add an
787
+ # answer where there was none, never change one that already existed
788
+ # (which would move the SDK root under a live workspace), and there is
789
+ # no third copy of the tier rule to keep true. `resolve_sdk_root_ladder`
790
+ # is imported at module level, from `tan.core.sdk_discovery`, alongside
791
+ # `resolve_sdk_tiered` and `ActiveSdk` (tan-cli#408 review follow-up) --
792
+ # no cycle between this module and that one exists any more, in either
793
+ # direction.
794
+ ladder = resolve_sdk_root_ladder(sdk_root, workspace_root)
795
+ if ladder.path is not None:
796
+ active = ActiveSdk(
797
+ str(ladder.path),
798
+ ladder.tier,
799
+ ladder.broken_project_pin,
800
+ ladder.foreign_global_default_for,
801
+ )
802
+ readiness = check_sdk_readiness(active.path) if active.path is not None else None
803
+ text = (
804
+ format_readiness_block("Active SDK", readiness)
805
+ if readiness is not None
806
+ else no_active_sdk_text(cached_sdk_versions())
807
+ )
808
+ issues: list[Issue] = []
809
+ pin_issue = project_pin_issue(active.broken_project_pin, active.tier)
810
+ if pin_issue is not None:
811
+ # tan-cli#263: without this, a workspace whose `.alp/sdk-path` names an
812
+ # unreachable checkout reports `ok: true`, `issues: []`, `sourceTier`
813
+ # silently one tier lower -- identical to a workspace that was never
814
+ # pinned at all. `warning`, not `error`: the command still answers the
815
+ # question asked (SOME SDK, or none, is active); it is the SILENCE
816
+ # about the rejected pin that was the defect, not the fallthrough
817
+ # itself.
818
+ text = [
819
+ f'Project pin .alp/sdk-path names "{active.broken_project_pin}", which '
820
+ f"does not resolve to an alp-sdk checkout from here.",
821
+ *text,
822
+ ]
823
+ issues.append(pin_issue)
824
+ foreign_issue = global_default_foreign_project_issue(active.foreign_global_default_for)
825
+ if foreign_issue is not None:
826
+ # tan-cli#464: the shared, last-writer-wins `~/.alp/sdk-default` used
827
+ # to answer here with no signal that a DIFFERENT project's bootstrap
828
+ # relocation was what actually decided it -- `ok: true`, `issues: []`,
829
+ # identical to the correct case. `warning`, matching `pin_issue`
830
+ # above: the tier still answers the question asked; it is the silence
831
+ # about WHOSE answer this is that was the defect.
832
+ text = [
833
+ f'The global default SDK was last set for '
834
+ f'"{active.foreign_global_default_for}", not this workspace.',
835
+ *text,
836
+ ]
837
+ issues.append(foreign_issue)
838
+ # tan-cli#407 names THIS command in particular: `sdk current` is what a
839
+ # user runs to ask "which SDK am I on?", and in a workspace holding both a
840
+ # child `<ws>/alp-sdk` and a lateral `../alp-sdk` it answers with the
841
+ # narrow one only -- reporting the readiness and VERSION of the checkout
842
+ # `tan generate` did not use. `sdk_ladder_divergence_issue` is imported at
843
+ # module level, from `tan.core.sdk_discovery` (tan-cli#408).
844
+ divergence = sdk_ladder_divergence_issue(sdk_root, workspace_root, wide=False)
845
+ if divergence is not None:
846
+ text = [*text, divergence.message]
847
+ issues.append(divergence)
848
+ if not json_mode:
849
+ # Only in text mode: `wrap_width()` probes stderr, which is the
850
+ # right thing to skip entirely for `--format json`, where `text` is
851
+ # built but never printed (`_emit`'s json branch reads `data`/
852
+ # `issues`, not `text_lines`).
853
+ text = wrap_lines(text, wrap_width())
854
+ _emit(
855
+ json_mode=json_mode,
856
+ data={
857
+ "subcommand": "current",
858
+ "sdkPath": active.path,
859
+ "readiness": readiness,
860
+ "sourceTier": active.tier,
861
+ },
862
+ issues=issues,
863
+ exit_code=ExitCode.SUCCESS,
864
+ text_lines=text,
865
+ # The envelope's optional `sdk` key mirrors the Rust's
866
+ # `sdk_report::record` at this exact call site: `run_current` is the one
867
+ # place whose resolution IS "the active SDK". Recorded even for an
868
+ # invalid `--sdk-root`, matching the `if let Some(root)` there -- and
869
+ # absent entirely (never `null`) when nothing resolved.
870
+ sdk=SdkInfo.from_resolution(active.path, active) if active.path is not None else None,
871
+ )
872
+
873
+
874
+ def _run_list(*, json_mode: bool, online: bool) -> None:
875
+ """`tan sdk list` -- the published alp-sdk releases.
876
+
877
+ tan-cli#351: bare `sdk list` (no `--online`) answers OFFLINE, at exit 0.
878
+ Measured first, since this file already carries the reasoning for why the
879
+ port diverges from the oracle here: the oracle (`target/debug/tan.exe`,
880
+ tan 0.4.1) has no `--online` flag at all -- `sdk list --help` lists none --
881
+ and reaches the network unconditionally on every `sdk list` call (a live
882
+ run against a reachable network succeeds at exit 0 with no flag given).
883
+ Gating the fetch behind `--online` is this PORT's own addition, for
884
+ hermeticity (I-23): a command that silently opens a socket cannot be
885
+ driven from a hermetic test, an air-gapped host, or a fixture. That gate
886
+ is not, itself, a verdict on anything the caller did wrong -- there is no
887
+ "failure" here to report, only a question (`sdk list` answers what alp-sdk
888
+ has published upstream) that needs an explicit flag to actually reach the
889
+ network for. Exiting non-zero for it (as this used to) treated a normal,
890
+ everyday invocation the same as a real error, which is exactly the
891
+ asymmetry `sdk current` never had: "nothing configured" is exit 0 there,
892
+ and "list needs `--online`" now is here too. The message says plainly
893
+ what `sdk list` reports (UPSTREAM releases) and that `--online` is the
894
+ switch that fetches them, rather than reporting the missing flag as a
895
+ network requirement failure.
896
+ """
897
+ if not online:
898
+ _emit(
899
+ json_mode=json_mode,
900
+ data=_list_data([]),
901
+ issues=[
902
+ Issue(
903
+ "sdk.network-required",
904
+ "warning",
905
+ "`sdk list` reports the Alp SDK releases published upstream "
906
+ "on GitHub -- there is no local/offline copy to answer from. "
907
+ "Add --online to fetch them.",
908
+ )
909
+ ],
910
+ exit_code=ExitCode.SUCCESS,
911
+ text_lines=[
912
+ "sdk list: reports Alp SDK releases published upstream on GitHub.",
913
+ "Add --online to fetch them: `tan sdk list --online`.",
914
+ ],
915
+ )
916
+ return
917
+
918
+ releases, error = _fetch_releases()
919
+ if error is not None:
920
+ _fail(
921
+ json_mode=json_mode,
922
+ data=_list_data([]),
923
+ code="fetch-failed",
924
+ message=error,
925
+ text_lines=["sdk list: failed to fetch releases.", error],
926
+ )
927
+ return
928
+
929
+ _emit(
930
+ json_mode=json_mode,
931
+ data=_list_data(releases),
932
+ issues=[],
933
+ exit_code=ExitCode.SUCCESS,
934
+ text_lines=format_release_table(releases),
935
+ )
936
+
937
+
938
+ def _run_not_ported(*, json_mode: bool, subcommand: str, data: dict[str, Any]) -> None:
939
+ """`install` / `switch` -- refused outright rather than half-implemented.
940
+
941
+ Exit 1 (`RuntimeFailure`) -- the same code every other refusal in this
942
+ module already uses (`sdk list` without `--online`, a bare `tan sdk`) and
943
+ the same one the seven deferred-verb stubs settled on before they shipped
944
+ (tan-cli#260; their module is gone as of tan-cli#427).
945
+
946
+ This was exit 5 (`InternalFailure`) until #262, on a docstring that
947
+ justified it as "following `validate_cmd`'s precedent for its own unported
948
+ spawn path". That citation was backwards: `validate_cmd` uses exit 1, and
949
+ its module docstring says exit 5 would wrongly tell CI/the extension this
950
+ is a tan crash. The comment argued for the opposite of what the code did.
951
+
952
+ Measured, not inferred -- the oracle's own `sdk switch` failure is exit 1:
953
+ `tan sdk switch 0.0.0-nonexistent --format json` -> rc=1,
954
+ `sdk.path-not-found`. Nothing under `contract/` pins a 5 here.
955
+
956
+ 5 stays reserved for a genuine tan crash; 2 would be rendered as a warning
957
+ by a consumer that should be seeing a hard stop. The payload keeps each
958
+ verb's real key set so a consumer reading `data.sdkPath`/`data.version`
959
+ still finds them.
960
+ """
961
+ _fail(
962
+ json_mode=json_mode,
963
+ data=data,
964
+ code="not-ported",
965
+ message=(
966
+ f"`sdk {subcommand}` is not available in this build of tan. It writes "
967
+ "the active-SDK pointer and reconciles the west workspace manifest, "
968
+ "and a partial implementation would report success while `west` kept "
969
+ "resolving the old SDK."
970
+ ),
971
+ text_lines=[
972
+ f"sdk {subcommand}: not available in this build of tan.",
973
+ f"To get started, {NO_SDK_NEXT_STEPS}.",
974
+ "`tan doctor` reports which checkout tan currently resolves.",
975
+ ],
976
+ exit_code=ExitCode.RUNTIME_FAILURE,
977
+ )
978
+
979
+
980
+ def _run_unknown(*, json_mode: bool, subcommand: str | None) -> None:
981
+ """Anything else. Exit 1 with the `list`-shaped payload -- both pinned by
982
+ the `sdk-unknown-subcommand` golden. `(none)` stands in for a bare `tan
983
+ sdk`, matching the Rust's `other.unwrap_or("(none)")`; an explicitly EMPTY
984
+ argument is reported as the empty string it was, same as there.
985
+ """
986
+ sub = "(none)" if subcommand is None else subcommand
987
+ _fail(
988
+ json_mode=json_mode,
989
+ data=_list_data([]),
990
+ code="unknown-subcommand",
991
+ message=f"Unknown sdk subcommand: {sub}",
992
+ text_lines=[f"sdk: unknown subcommand '{sub}'.", AVAILABLE_SUBCOMMANDS],
993
+ )
994
+
995
+
996
+ # ── sdk remove (tan-cli#790) ─────────────────────────────────────────────────
997
+ #
998
+ # The one destructive verb in this file, so it earns its own section rather
999
+ # than living beside `install`/`switch`'s refusal stubs. Designed for
1000
+ # long-term customer experience the way the maintainer's own standing
1001
+ # direction for this class of question asks, spelled out here once rather
1002
+ # than re-derived at every call site below:
1003
+ #
1004
+ # * an install that is currently load-bearing -- the ACTIVE resolution for
1005
+ # this workspace, the machine-global default, or a project's own
1006
+ # registered pin -- refuses to be removed without `--force`
1007
+ # ([`_load_bearing_reasons`]); silently orphaning any of the three is a
1008
+ # worse failure than a refusal that names exactly what would break;
1009
+ # * removal is IDEMPOTENT: a target that is already absent succeeds at
1010
+ # `data.removed: false`, so a rotation script never has to pre-check;
1011
+ # * a target outside the cache root refuses without `--force` too -- the
1012
+ # footgun guard `is_outside_cache_root` exists for -- but ONLY once it is
1013
+ # confirmed to exist, so an idempotent no-op never trips it;
1014
+ # * the cache ROOT ITSELF -- every install at once, not one of them --
1015
+ # refuses without `--force` too (`is_cache_root_itself`): the outside-root
1016
+ # guard above deliberately does NOT catch this (`target == destination` is
1017
+ # not "outside"), and no version is individually load-bearing for its own
1018
+ # root, so this was the one target `--force`-less `remove` could wipe the
1019
+ # whole cache with, found live during this review;
1020
+ # * every failure names WHAT blocked it (`sdk.remove-active`,
1021
+ # `sdk.remove-outside-root`, `sdk.remove-is-cache-root`,
1022
+ # `sdk.remove-in-use`, `sdk.remove-permission` -- flat, ONE dot, not the
1023
+ # nested `sdk.remove.active` shape the issue's own prose sketches:
1024
+ # `contract/issue-codes.json`'s `sdk.remove-missing-argument` entry
1025
+ # explains why nested is not available on this wire) and what to do
1026
+ # instead (`--force`, for the first three; close the holder, for the
1027
+ # fourth; fix the permissions/attributes by hand, for the fifth) -- the
1028
+ # issue's own bar: "the refusal messages must name what blocked it and
1029
+ # what to do instead".
1030
+ #
1031
+ # TWO follow-ups closed on top of that design, both about the same refusal.
1032
+ #
1033
+ # tan-cli#1053 -- the four comparisons behind it were a plain string `==`,
1034
+ # which answers the question the CALLER asked rather than the one the
1035
+ # FILESYSTEM would answer. On a case-INSENSITIVE volume (NTFS, and macOS's
1036
+ # DEFAULT APFS volume -- so this reproduced on a maintainer laptop, not only
1037
+ # on Windows) two spellings name ONE directory and compare unequal, so the
1038
+ # refusal never fired and the install was removed without `--force`. All four
1039
+ # now go through `sdk_removal.removal_would_take_out`, whose own section
1040
+ # banner carries the platform reasoning and the limits it still has.
1041
+ #
1042
+ # tan-cli#1051 -- `remove` deletes the install a workspace's `.alp/sdk-path`
1043
+ # names but never touches that FILE, so a forced removal leaves the pin
1044
+ # dangling, and `data.resolvesToAfter` then reports `sourceTier: "none"` --
1045
+ # which is also what a workspace that was never pinned reports. Measured: the
1046
+ # removal came back `issues: []` while `sdk current` in the same directory a
1047
+ # moment later carried `sdk.project-pin-unresolved` naming the dangling
1048
+ # pointer; two commands disagreeing about one workspace was the defect. Every
1049
+ # `_emit`/`_fail` call site below now carries that same warning (`_fail`
1050
+ # takes an ADDITIVE `issues` list for it, after the refusal, so `issues[0]`
1051
+ # still names what blocked the removal) on EVERY branch -- refusal,
1052
+ # idempotent no-op, removal failure, success -- because a dangling pin is a
1053
+ # fact about the workspace, not about whether this call deleted anything.
1054
+ #
1055
+ # The warning carries `resolvesToAfter`'s OWN narrow tier, which is not always
1056
+ # the tier `sdk current` names for the same workspace at the same instant.
1057
+ # Measured, in the tan-cli#497 workspace shape (a child `<ws>/alp-sdk`, which
1058
+ # only the WIDE `resolve_sdk_root_ladder` tail finds):
1059
+ #
1060
+ # sdk remove --force : "... falling through to the none tier instead."
1061
+ # sdk current : "... falling through to the discovery tier instead."
1062
+ #
1063
+ # That divergence is inherited, not introduced: `resolvesToAfter` has reported
1064
+ # the narrow ladder's answer since tan-cli#1028, on the deliberate ground that
1065
+ # `remove`'s reported outcome must come from the SAME ladder `remove`'s own
1066
+ # refusal consulted (see `_resolves_to_after`'s docstring). Feeding the warning
1067
+ # the WIDE tier instead would leave one envelope saying `sourceTier: "none"` in
1068
+ # `data` while its own `issues[]` said "falling through to the discovery tier"
1069
+ # -- a fresh contradiction inside a single response. Widening BOTH would change
1070
+ # a released contract field for exactly that workspace shape and undo
1071
+ # tan-cli#1028's stated design. So the warning stays narrow and internally
1072
+ # consistent, and the claim that the two commands emit the identical issue --
1073
+ # which an earlier draft of this change made in its changelog and pinned with a
1074
+ # test that had no discoverable checkout and so passed vacuously -- is simply
1075
+ # withdrawn. What IS true, and is what tan-cli#1051 asked for, is that `sdk
1076
+ # remove` no longer stays SILENT about a pin `sdk current` warns about.
1077
+ #
1078
+ # REPORTING, not repair, deliberately: clearing `.alp/sdk-path` was the other
1079
+ # live option and is rejected. `--force` on `remove <version>` is consent to
1080
+ # delete THAT INSTALL, not consent to rewrite a workspace config file the
1081
+ # caller never named, and every refusal above fires BEFORE any filesystem
1082
+ # write precisely so the target is the only thing this verb touches. Repair
1083
+ # would also be structurally partial -- `remove` can only reach the ONE
1084
+ # workspace it ran in, while every OTHER project on the host pinned at the
1085
+ # same install stays dangling either way, and only a report can reach those.
1086
+ #
1087
+ # `sdk list`'s proposed `managed`/`active` columns (tan-cli#790's own "related
1088
+ # gap" aside) are deliberately OUT of this change: `sdk list` today reports
1089
+ # UPSTREAM GitHub releases, not local installs, so a per-release
1090
+ # managed/active flag has no local install to describe until `sdk
1091
+ # install`/`sdk switch` are themselves ported (tan-cli#305) -- tracked
1092
+ # separately, not bundled into a single-verb change.
1093
+
1094
+
1095
+ def _sdk_default_pointer_target() -> str | None:
1096
+ """`~/.alp/sdk-default`'s own `sdkPath`, read DIRECTLY rather than through
1097
+ `resolve_sdk_tiered`. That ladder only ever reports the ONE tier that
1098
+ wins for the CALLER's workspace, and the plain machine-global pointer can
1099
+ name a checkout this workspace's own project pin or registry entry
1100
+ outranks -- so it would never surface as `active.path` here -- while
1101
+ still being exactly what removing it would orphan for every OTHER,
1102
+ unregistered project on the host that falls through to it. `None` on any
1103
+ read/parse failure, the same degrade `_pointer_target` already applies to
1104
+ a missing or malformed pointer.
1105
+ """
1106
+ return _pointer_target(_home_alp_dir() / "sdk-default")
1107
+
1108
+
1109
+ def _registered_entries_for(target_posix: str) -> list[tuple[str, str]]:
1110
+ """Every `~/.alp/sdk-defaults.json` `(origin, normalised sdkPath)` whose
1111
+ `sdkPath` names `target_posix` (posix-normalised, matching how the
1112
+ registry itself stores it -- `bootstrap_cmd._write_global_sdk_registry`'s
1113
+ own `_to_posix` write). Sorted for a deterministic message; `[]` on any
1114
+ read/parse failure, matching `parse_registry`'s own best-effort contract.
1115
+
1116
+ Returns the matched SPELLING alongside the origin, not just the origin,
1117
+ because the two callers need it at two different moments: the refusal
1118
+ names the origin BEFORE anything is deleted, while the prune runs AFTER
1119
+ `remove_sdk_tree` -- by which point the target directory is gone and the
1120
+ filesystem arm of `removal_would_take_out` can no longer recognise an
1121
+ alias spelling at all (see `_prune_registry_entries_for`).
1122
+ """
1123
+ raw = _read_file(registry_path(_home_alp_dir()))
1124
+ registry = parse_registry(raw)
1125
+ return sorted(
1126
+ (origin, normalized_sdk_path(sdk_path))
1127
+ for origin, sdk_path in registry.items()
1128
+ # Separator-folded AND platform-compared, never a raw `==` (which is
1129
+ # what this was through tan-cli#1053): a hand-edited registry on
1130
+ # Windows spells the same directory with backslashes,
1131
+ # `normalized_sdk_path` folds those; a case-INSENSITIVE volume (NTFS,
1132
+ # or macOS's default APFS) spells it in another case, and a symlinked
1133
+ # cache spells it through the link -- `removal_would_take_out` is
1134
+ # what answers those. Missing the match here does not merely skip a
1135
+ # tidy-up: this removal then does NOT refuse and silently orphans that
1136
+ # project.
1137
+ if removal_would_take_out(normalized_sdk_path(sdk_path), target_posix)
1138
+ )
1139
+
1140
+
1141
+ def _load_bearing_reasons(
1142
+ target_posix: str, active: ActiveSdk, registered: list[tuple[str, str]]
1143
+ ) -> list[str]:
1144
+ """Every reason removing `target_posix` right now would orphan something
1145
+ live -- the tan-cli#790 design bar itself: "silently orphaning either [the
1146
+ active install or a pinned one] is a worse failure than refusing". `[]`
1147
+ means safe to remove without `--force`. More than one reason can apply at
1148
+ once (the active install for THIS workspace can also be another
1149
+ project's registered default), and the caller reports all of them rather
1150
+ than only the first.
1151
+
1152
+ `registered` is passed IN rather than looked up here (tan-cli#1053
1153
+ review): the caller has to compute it before `remove_sdk_tree` runs
1154
+ anyway, so that the prune afterwards can still recognise the alias
1155
+ spellings whose directory no longer exists, and computing it twice would
1156
+ be two registry reads that can disagree.
1157
+ """
1158
+ reasons: list[str] = []
1159
+ # `removal_would_take_out`, not `==` (tan-cli#1053) -- on a
1160
+ # case-insensitive volume, or through a symlinked cache, two spellings
1161
+ # name ONE directory and a raw string compare answers this in the UNSAFE
1162
+ # direction: no reason is collected, nothing refuses, the workspace is
1163
+ # orphaned.
1164
+ if active.path is not None and removal_would_damage(_abs_posix(active.path), target_posix):
1165
+ reasons.append(f'the active alp-sdk for this workspace (sourceTier "{active.tier}")')
1166
+ default_target = _sdk_default_pointer_target()
1167
+ if default_target is not None and removal_would_damage(
1168
+ _abs_posix(default_target), target_posix
1169
+ ):
1170
+ reasons.append("the machine-global default SDK (~/.alp/sdk-default)")
1171
+ named = {origin for origin, _sdk_path in registered}
1172
+ # `registered` holds entries the removal destroys (equal to, or inside,
1173
+ # the target); an entry whose install CONTAINS the target is damaged too.
1174
+ named |= _registered_containing(target_posix, named)
1175
+ for origin in sorted(named):
1176
+ reasons.append(f'the registered global default for project "{origin}"')
1177
+ return reasons
1178
+
1179
+
1180
+ def _registered_containing(target_posix: str, already: set[str]) -> set[str]:
1181
+ """Origins of registry entries whose install CONTAINS `target_posix` (a
1182
+ subtree removal damages them without deleting them), minus `already`."""
1183
+ raw = _read_file(registry_path(_home_alp_dir()))
1184
+ return {
1185
+ origin
1186
+ for origin, sdk_path in parse_registry(raw).items()
1187
+ if origin not in already
1188
+ and removal_would_damage(normalized_sdk_path(sdk_path), target_posix)
1189
+ }
1190
+
1191
+
1192
+ def _prune_registry_entries_for(target_posix: str, registered: list[tuple[str, str]]) -> None:
1193
+ """Best-effort: drop every `~/.alp/sdk-defaults.json` entry naming the
1194
+ just-removed `target_posix` -- keeps tan-cli#905's registry honest about
1195
+ what still resolves (`sdk_default_registry.prune_entries_by_sdk_path`),
1196
+ the same read-modify-write shape `bootstrap_cmd._write_global_sdk_registry`
1197
+ already uses for the ORIGIN-pruning half of the identical file. Silent on
1198
+ any failure, matching every other best-effort registry write in this
1199
+ codebase: the removal itself already succeeded by the time this runs, and
1200
+ a registry entry this call could not prune degrades no worse than it
1201
+ already would have before this function existed (`deepest_covering_entry`
1202
+ skips a covering entry whose `sdkPath` fails `_has_loader_script`, so a
1203
+ dead entry left behind answers nobody incorrectly -- it is merely not yet
1204
+ tidied).
1205
+
1206
+ `registered` is the match set computed BEFORE `remove_sdk_tree` ran, and
1207
+ it is what makes this the FIFTH site of tan-cli#1053 rather than a sixth
1208
+ defect (review): this function is called after the target directory is
1209
+ gone, so `removal_would_take_out`'s filesystem arm has nothing left to
1210
+ compare `st_dev`/`st_ino` against and an alias-spelled entry -- the very
1211
+ entry the refusal above correctly named -- would be refused and then NOT
1212
+ pruned, leaving the registry claiming a checkout this call just deleted.
1213
+ Measured exactly that way on the first version of this change. The
1214
+ lexical arm still runs for anything `registered` did not capture (an
1215
+ entry written between the two moments), so this widens the prune and
1216
+ never narrows it.
1217
+ """
1218
+ try:
1219
+ path = registry_path(_home_alp_dir())
1220
+ raw = path.read_text(encoding="utf-8") if path.is_file() else None
1221
+ matched_before_removal = frozenset(sdk_path for _origin, sdk_path in registered)
1222
+
1223
+ def matches(stored: str, target: str) -> bool:
1224
+ return stored in matched_before_removal or removal_would_take_out(stored, target)
1225
+
1226
+ pruned = prune_entries_by_sdk_path(
1227
+ load_raw(raw), sdk_path=target_posix, matches=matches
1228
+ )
1229
+ path.parent.mkdir(parents=True, exist_ok=True)
1230
+ atomic_write_text(str(path), registry_text(pruned))
1231
+ except Exception: # noqa: BLE001 -- best-effort, matching every other registry write
1232
+ pass
1233
+
1234
+
1235
+ class _AfterRemoval(NamedTuple):
1236
+ """What `sdk remove` reports about the workspace once this call is done:
1237
+ the `data.resolvesToAfter` payload, and the issues that payload alone
1238
+ cannot express (tan-cli#1051). Paired in ONE return value so a call site
1239
+ physically cannot take the payload and drop the warning -- the shape the
1240
+ defect had before this."""
1241
+
1242
+ data: dict[str, Any]
1243
+ issues: list[Issue]
1244
+
1245
+
1246
+ def _resolves_to_after(workspace_root: Path) -> _AfterRemoval:
1247
+ """The narrow `resolve_sdk_tiered` answer for `workspace_root` right now
1248
+ -- tan-cli#1028's answer to "what resolves after this removal". This is
1249
+ NOT necessarily what `tan sdk current` would report for the same
1250
+ workspace at the same instant: it reuses `resolve_sdk_tiered`, the SAME
1251
+ narrow ladder `_load_bearing_reasons`'/`active` above already call
1252
+ (`resolve_sdk_tiered(None, workspace_root)`, `sdk_root` never threaded
1253
+ through from the `--sdk-root` flag either), so `remove`'s own load-bearing
1254
+ refusal logic and its `resolvesToAfter` answer are always asking the same
1255
+ question of the same ladder -- but `_run_current` falls through to the
1256
+ WIDE `resolve_sdk_root_ladder` tail when the narrow ladder finds nothing
1257
+ (tan-cli#497 defect 1) and honours an explicit `--sdk-root`. Neither
1258
+ fallback runs here, so this can report a tier -- or `"none"` -- one below
1259
+ what `sdk current` would say for the identical workspace right after this
1260
+ call returns. Consistency with `remove`'s own refusal ladder wins over
1261
+ parity with `sdk current`'s wider answer; a caller that needs the wider
1262
+ answer still makes the separate `sdk current` call.
1263
+
1264
+ Called at EVERY `_remove_data` call site below, not only on a load-bearing
1265
+ removal (tan-cli#1028's own design question, answered: an always-present
1266
+ field is easier for a caller to code against than one that appears only
1267
+ sometimes). Computed FRESH at each call site -- after `remove_sdk_tree` on
1268
+ the branches that delete something, unchanged on every
1269
+ refusal/idempotent/failed branch -- so it is truthful on both without a
1270
+ second "did this call mutate the tree" flag to keep in sync with the first.
1271
+
1272
+ Mirrors `sdk current`'s own `data` SHAPE (`sdkPath`, `readiness`,
1273
+ `sourceTier`) rather than inventing a fourth shape for the same question
1274
+ -- a caller that already knows how to read `sdk current`'s answer does
1275
+ not have to learn a second shape -- and the no-SDK-resolves case -- the
1276
+ one a caller most needs told about after a load-bearing removal -- comes
1277
+ back exactly as `sdk-current-no-sdk` pins it: `sdkPath: null`, `readiness:
1278
+ null`, `sourceTier: "none"`.
1279
+
1280
+ That `"none"` reading is also why this returns ISSUES beside the payload
1281
+ (tan-cli#1051): a force-removed project pin is left DANGLING, and
1282
+ `"none"` alone cannot be told apart from a never-pinned workspace. The
1283
+ `project_pin_issue` helper is called on the SAME `ActiveSdk` -- so the
1284
+ same NARROW tier -- this payload is built from, so warning and payload
1285
+ can never name different tiers for one workspace. It is NOT the
1286
+ byte-identical issue `sdk current` emits: see the section banner.
1287
+ """
1288
+ resolved = resolve_sdk_tiered(None, workspace_root)
1289
+ data = {
1290
+ "sdkPath": resolved.path,
1291
+ "readiness": check_sdk_readiness(resolved.path) if resolved.path is not None else None,
1292
+ "sourceTier": resolved.tier,
1293
+ }
1294
+ pin_issue = project_pin_issue(resolved.broken_project_pin, resolved.tier)
1295
+ return _AfterRemoval(data, [pin_issue] if pin_issue is not None else [])
1296
+
1297
+
1298
+ def _remove_data(
1299
+ *,
1300
+ removed: bool,
1301
+ path: str | None,
1302
+ version: str | None,
1303
+ was_active: bool,
1304
+ freed_bytes: int,
1305
+ resolves_to_after: dict[str, Any],
1306
+ ) -> dict[str, Any]:
1307
+ """`sdk remove`'s payload shape, built at every call site through this one
1308
+ function so a field can never drift between the idempotent, refused and
1309
+ successful arms below."""
1310
+ return {
1311
+ "subcommand": "remove",
1312
+ "removed": removed,
1313
+ "path": path,
1314
+ "version": version,
1315
+ "wasActive": was_active,
1316
+ "freedBytes": freed_bytes,
1317
+ "resolvesToAfter": resolves_to_after,
1318
+ }
1319
+
1320
+
1321
+ def _run_remove(
1322
+ *,
1323
+ json_mode: bool,
1324
+ arg: str | None,
1325
+ destination_arg: str | None,
1326
+ force: bool,
1327
+ workspace_root: Path,
1328
+ ) -> None:
1329
+ """`tan sdk remove <version|path>` (tan-cli#790) -- see the section banner
1330
+ above this function for the design this implements. Every refusal fires
1331
+ BEFORE any filesystem write, so the three possible outcomes -- refused,
1332
+ idempotently already-absent, or removed -- are the only ones a caller
1333
+ ever has to handle; nothing here can leave a target partially gone in a
1334
+ way a rerun cannot cleanly finish or recover from.
1335
+
1336
+ `active`/`was_active` are resolved ONCE, right after the target itself,
1337
+ and threaded through every branch below (including every refusal) rather
1338
+ than recomputed per-branch -- so `data.wasActive` is truthful on the
1339
+ outside-root refusal too, not just on the one branch that refuses BECAUSE
1340
+ of it.
1341
+ """
1342
+ raw_arg = (arg or "").strip()
1343
+ if not raw_arg:
1344
+ after = _resolves_to_after(workspace_root)
1345
+ _fail(
1346
+ json_mode=json_mode,
1347
+ data=_remove_data(
1348
+ removed=False,
1349
+ path=None,
1350
+ version=None,
1351
+ was_active=False,
1352
+ freed_bytes=0,
1353
+ resolves_to_after=after.data,
1354
+ ),
1355
+ issues=after.issues,
1356
+ code="remove-missing-argument",
1357
+ message=(
1358
+ "`sdk remove` needs a version name (looked up under --destination) "
1359
+ "or an explicit path naming the install to remove."
1360
+ ),
1361
+ text_lines=[
1362
+ "sdk remove: needs a version or path argument, e.g. `tan sdk remove v0.15.0`."
1363
+ ],
1364
+ )
1365
+ return
1366
+
1367
+ destination = Path(destination_arg).expanduser() if destination_arg else _default_cache_root()
1368
+ resolution = resolve_removal_target(raw_arg, destination)
1369
+ target = resolution.target
1370
+ target_posix = _abs_posix(str(target))
1371
+ version = raw_arg if resolution.is_named_version else None
1372
+
1373
+ active = resolve_sdk_tiered(None, workspace_root)
1374
+ # The SAME helper `_load_bearing_reasons` compares with (tan-cli#1053), not
1375
+ # a second rule: this one is reporting-only, but two comparisons of one
1376
+ # fact that can disagree are a defect waiting to be re-found. Argument
1377
+ # ORDER is load-bearing -- the active install is the CANDIDATE, the thing
1378
+ # being removed is the TARGET -- because the predicate is asymmetric about
1379
+ # symlinks (see its section banner).
1380
+ active_posix = _abs_posix(active.path) if active.path is not None else None
1381
+ was_active = active_posix is not None and removal_would_damage(
1382
+ active_posix, target_posix
1383
+ )
1384
+
1385
+ # `os.path.lexists`, NOT `target.exists()`: the latter FOLLOWS a link, so a
1386
+ # BROKEN symlink or a junction whose target is gone -- an ordinary leftover in
1387
+ # a cache that has had an install removed out from under a `current ->` style
1388
+ # pointer -- reports False while the link itself is still very much on disk.
1389
+ # Answering "already absent" there breaks the idempotence this branch exists to
1390
+ # provide (tan-cli#790's own point 3): the rotation script that trusted the
1391
+ # success then fails on the NEXT install with a path that already exists.
1392
+ # Everything downstream of this gate already handles the link case correctly --
1393
+ # `compute_tree_bytes` charges the link's OWN lstat size, and
1394
+ # `dir_removal.remove_dir` unlinks the link itself rather than following it --
1395
+ # so this predicate was the single place the link was invisible.
1396
+ if not os.path.lexists(target):
1397
+ after = _resolves_to_after(workspace_root)
1398
+ _emit(
1399
+ json_mode=json_mode,
1400
+ data=_remove_data(
1401
+ removed=False,
1402
+ path=target_posix,
1403
+ version=version,
1404
+ was_active=was_active,
1405
+ freed_bytes=0,
1406
+ resolves_to_after=after.data,
1407
+ ),
1408
+ issues=after.issues,
1409
+ exit_code=ExitCode.SUCCESS,
1410
+ text_lines=[f"sdk remove: nothing at {target_posix} -- already absent."],
1411
+ )
1412
+ return
1413
+
1414
+ # Checked BEFORE the outside-root guard, and separately from it:
1415
+ # `is_outside_cache_root` deliberately answers False for `target ==
1416
+ # destination` (a caller CAN name the root on purpose), which left the
1417
+ # single most destructive target -- the whole cache, every version at
1418
+ # once -- completely unguarded: `_load_bearing_reasons` only ever names a
1419
+ # specific version subdirectory, never the root that holds them, so
1420
+ # nothing else in this function would have refused it either. Found live
1421
+ # (`tan sdk remove .` from inside an otherwise-empty cache root, `ok:
1422
+ # true`, no `--force`) -- see `sdk_removal.is_cache_root_itself`.
1423
+ if is_cache_root_itself(target, destination) and not force:
1424
+ after = _resolves_to_after(workspace_root)
1425
+ _fail(
1426
+ json_mode=json_mode,
1427
+ data=_remove_data(
1428
+ removed=False,
1429
+ path=target_posix,
1430
+ version=version,
1431
+ was_active=was_active,
1432
+ freed_bytes=0,
1433
+ resolves_to_after=after.data,
1434
+ ),
1435
+ issues=after.issues,
1436
+ code="remove-is-cache-root",
1437
+ message=(
1438
+ f'"{target_posix}" IS the SDK cache root itself; removing it '
1439
+ "would delete every install under it at once, not a single "
1440
+ "one. Pass --force to remove the entire cache root, or name "
1441
+ "a specific version or path to remove one install."
1442
+ ),
1443
+ text_lines=[
1444
+ f"sdk remove: {target_posix} is the cache root; refusing without --force."
1445
+ ],
1446
+ )
1447
+ return
1448
+
1449
+ if is_outside_cache_root(target, destination) and not force:
1450
+ after = _resolves_to_after(workspace_root)
1451
+ _fail(
1452
+ json_mode=json_mode,
1453
+ data=_remove_data(
1454
+ removed=False,
1455
+ path=target_posix,
1456
+ version=version,
1457
+ was_active=was_active,
1458
+ freed_bytes=0,
1459
+ resolves_to_after=after.data,
1460
+ ),
1461
+ issues=after.issues,
1462
+ code="remove-outside-root",
1463
+ message=(
1464
+ f'"{target_posix}" is outside the SDK cache root '
1465
+ f'"{_abs_posix(str(destination))}". Pass --force to remove an '
1466
+ "explicit path outside the managed cache."
1467
+ ),
1468
+ text_lines=[
1469
+ f"sdk remove: {target_posix} is outside the cache root; refusing without --force."
1470
+ ],
1471
+ )
1472
+ return
1473
+
1474
+ if not resolution.is_named_version:
1475
+ version = check_sdk_readiness(str(target)).get("version")
1476
+
1477
+ # Computed BEFORE any removal and threaded through both users: the prune
1478
+ # at the end of this function runs once the target is gone, and cannot
1479
+ # re-derive an alias match from a filesystem that no longer has it.
1480
+ registered = _registered_entries_for(target_posix)
1481
+ reasons = _load_bearing_reasons(target_posix, active, registered)
1482
+ if reasons and not force:
1483
+ after = _resolves_to_after(workspace_root)
1484
+ _fail(
1485
+ json_mode=json_mode,
1486
+ data=_remove_data(
1487
+ removed=False,
1488
+ path=target_posix,
1489
+ version=version,
1490
+ was_active=was_active,
1491
+ freed_bytes=0,
1492
+ resolves_to_after=after.data,
1493
+ ),
1494
+ issues=after.issues,
1495
+ code="remove-active",
1496
+ message=(
1497
+ f'"{target_posix}" is currently load-bearing: it is '
1498
+ + "; and it is ".join(reasons)
1499
+ + ". Pass --force to remove it anyway."
1500
+ ),
1501
+ text_lines=[
1502
+ f"sdk remove: {target_posix} is still in use; refusing without --force."
1503
+ ],
1504
+ )
1505
+ return
1506
+
1507
+ outcome: RemovalOutcome = remove_sdk_tree(target)
1508
+ if not outcome.ok:
1509
+ failing = (outcome.failing_path or target_posix).replace("\\", "/")
1510
+ after = _resolves_to_after(workspace_root)
1511
+ failure_data = _remove_data(
1512
+ removed=False,
1513
+ path=target_posix,
1514
+ version=version,
1515
+ was_active=was_active,
1516
+ freed_bytes=outcome.freed_bytes,
1517
+ # Recomputed AFTER the attempt, not carried from the pre-attempt
1518
+ # `active` above: `remove_sdk_tree` can fail partway through a
1519
+ # multi-entry tree (`outcome.freed_bytes` above is already
1520
+ # partial-attempt-aware for the identical reason), so the
1521
+ # resolution has to be re-read from the filesystem it just
1522
+ # touched rather than assumed unchanged.
1523
+ resolves_to_after=after.data,
1524
+ )
1525
+ # TWO literal `code=` call sites, deliberately not one dynamic
1526
+ # `f"remove-{outcome.kind}"`: `test_every_issue_code_is_registered.py`
1527
+ # can only resolve a `code=` keyword argument to a registered wire
1528
+ # string when it is a literal at the call site, exactly the same
1529
+ # non-vacuity discipline that gate applies to every OTHER command in
1530
+ # this codebase.
1531
+ if outcome.kind == "in-use":
1532
+ _fail(
1533
+ json_mode=json_mode,
1534
+ data=failure_data,
1535
+ issues=after.issues,
1536
+ code="remove-in-use",
1537
+ message=(
1538
+ f"could not remove {failing}: {outcome.detail} -- another "
1539
+ "process still holds this open; close whatever has it open "
1540
+ "(a shell, a build, an editor, an indexer) and retry."
1541
+ ),
1542
+ text_lines=[f"sdk remove: failed removing {target_posix} (in-use)."],
1543
+ )
1544
+ else:
1545
+ _fail(
1546
+ json_mode=json_mode,
1547
+ data=failure_data,
1548
+ issues=after.issues,
1549
+ code="remove-permission",
1550
+ message=(
1551
+ f"could not remove {failing}: {outcome.detail} -- tan could "
1552
+ "not clear the permissions/attributes blocking this; check "
1553
+ "ownership/ACLs on the path above, or remove it by hand with "
1554
+ "elevated privileges."
1555
+ ),
1556
+ text_lines=[f"sdk remove: failed removing {target_posix} (permission)."],
1557
+ )
1558
+ return
1559
+
1560
+ _prune_registry_entries_for(target_posix, registered)
1561
+ # tan-cli#1498: a forced subtree removal also damages entries whose
1562
+ # install CONTAINS the target. Those are not pruned (the install they
1563
+ # name still exists), so say so rather than leaving them silent.
1564
+ containing = sorted(_registered_containing(target_posix, {o for o, _p in registered}))
1565
+ after = _resolves_to_after(workspace_root)
1566
+ if containing:
1567
+ after = _AfterRemoval(
1568
+ after.data,
1569
+ [
1570
+ *after.issues,
1571
+ *[
1572
+ Issue(
1573
+ "sdk.remove-registry-entry-damaged",
1574
+ "warning",
1575
+ f'the registered global default for project "{origin}" names an '
1576
+ f"install that contained {target_posix}; it was left in "
1577
+ f"~/.alp/sdk-defaults.json but is now incomplete -- restore "
1578
+ f"that SDK or remove that registry entry.",
1579
+ )
1580
+ for origin in containing
1581
+ ],
1582
+ ],
1583
+ )
1584
+ _emit(
1585
+ json_mode=json_mode,
1586
+ data=_remove_data(
1587
+ removed=True,
1588
+ path=target_posix,
1589
+ version=version,
1590
+ was_active=was_active,
1591
+ freed_bytes=outcome.freed_bytes,
1592
+ resolves_to_after=after.data,
1593
+ ),
1594
+ issues=after.issues,
1595
+ exit_code=ExitCode.SUCCESS,
1596
+ text_lines=[f"sdk remove: removed {target_posix} ({outcome.freed_bytes} bytes freed)."],
1597
+ )
1598
+
1599
+
1600
+ # ── the command ─────────────────────────────────────────────────────────────
1601
+
1602
+
1603
+ def sdk(
1604
+ subcommand: str = typer.Argument(
1605
+ None,
1606
+ metavar="SUBCOMMAND",
1607
+ help=(
1608
+ "list, current, install, switch, or remove. install/switch are not "
1609
+ "yet ported and refuse in this build -- use --sdk-root instead "
1610
+ "(tan-cli#305)."
1611
+ ),
1612
+ ),
1613
+ arg: str = typer.Argument(
1614
+ None,
1615
+ metavar="ARG",
1616
+ help="Version for install, version|path for switch/remove.",
1617
+ ),
1618
+ # `--destination` steers `install`/`switch` cache-root resolution (neither
1619
+ # ported, tan-cli#305) AND `remove`'s (tan-cli#790, live): a bare version
1620
+ # name is looked up under this root, and it is the root `remove`'s
1621
+ # outside-root footgun guard is measured against.
1622
+ destination: str = typer.Option(
1623
+ None, "--destination", metavar="PATH", help="SDK cache root (default: ~/.alp/sdk-cache)."
1624
+ ),
1625
+ force: bool = typer.Option(
1626
+ False,
1627
+ "--force",
1628
+ help=(
1629
+ "With remove: also remove the active/pinned install, or a path "
1630
+ "outside the cache root."
1631
+ ),
1632
+ ),
1633
+ global_: bool = typer.Option(
1634
+ False, "--global", help="With switch: pin the machine-global default."
1635
+ ),
1636
+ online: bool = typer.Option(
1637
+ False, "--online", help="Allow `list` to query the GitHub releases API."
1638
+ ),
1639
+ project: str = typer.Option(
1640
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
1641
+ ),
1642
+ sdk_root: str = typer.Option(
1643
+ None, "--sdk-root", metavar="PATH", help="Explicit alp-sdk checkout to report."
1644
+ ),
1645
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
1646
+ ) -> None:
1647
+ """Manage local Alp SDK installs."""
1648
+ json_mode = output_format == "json"
1649
+
1650
+ try:
1651
+ # `cwd.join(project)` unnormalised, matching `util.rs`'s
1652
+ # `cli_workspace_root`: every other reader of `.alp/sdk-path` resolves it
1653
+ # under this same root, so `tan --project ./firmware sdk current` must
1654
+ # report the pointer build/flash consult for THAT project, not the
1655
+ # repo-root one.
1656
+ workspace_root = Path.cwd() / project if project else Path.cwd()
1657
+
1658
+ if subcommand == "current":
1659
+ _run_current(
1660
+ json_mode=json_mode, sdk_root=sdk_root, workspace_root=workspace_root
1661
+ )
1662
+ elif subcommand == "list":
1663
+ _run_list(json_mode=json_mode, online=online)
1664
+ elif subcommand == "install":
1665
+ _run_not_ported(
1666
+ json_mode=json_mode,
1667
+ subcommand="install",
1668
+ data={
1669
+ "subcommand": "install",
1670
+ "version": arg or "",
1671
+ "sdkPath": "",
1672
+ "readiness": empty_readiness(""),
1673
+ "selected": False,
1674
+ },
1675
+ )
1676
+ elif subcommand == "switch":
1677
+ _run_not_ported(
1678
+ json_mode=json_mode,
1679
+ subcommand="switch",
1680
+ data={
1681
+ "subcommand": "switch",
1682
+ "sdkPath": "",
1683
+ "version": None,
1684
+ "scope": "global" if global_ else "project",
1685
+ },
1686
+ )
1687
+ elif subcommand == "remove":
1688
+ _run_remove(
1689
+ json_mode=json_mode,
1690
+ arg=arg,
1691
+ destination_arg=destination,
1692
+ force=force,
1693
+ workspace_root=workspace_root,
1694
+ )
1695
+ else:
1696
+ _run_unknown(json_mode=json_mode, subcommand=subcommand)
1697
+ except typer.Exit:
1698
+ # `_emit`'s own signal. `click.exceptions.Exit` subclasses RuntimeError,
1699
+ # so the catch-all below would otherwise swallow every successful run and
1700
+ # re-report it as an internal failure.
1701
+ raise
1702
+ except Exception as err: # noqa: BLE001 -- the envelope IS the error contract
1703
+ # The backstop for the failure nobody enumerated. Without it an unhandled
1704
+ # exception replaces the envelope with a traceback on stderr while stdout
1705
+ # stays empty, and the extension renders nothing at all -- no error, no
1706
+ # log, no warning. Five Criticals in this port were exactly that.
1707
+ _fail(
1708
+ json_mode=json_mode,
1709
+ data={"subcommand": subcommand or "(none)", "releases": []},
1710
+ code="internal-failure",
1711
+ message=f"sdk failed unexpectedly: {err}",
1712
+ text_lines=[f"sdk: unexpected failure: {err}"],
1713
+ exit_code=ExitCode.INTERNAL_FAILURE,
1714
+ )
1715
+
1716
+
1717
+ # tan-cli#261: adds the eight oracle `GlobalArgs` flags this command was
1718
+ # missing entirely (`--all`/`--board-yaml`/`--ci`/`--no-color`/
1719
+ # `--non-interactive`/`--quiet`/`--target`/`--verbose`); see
1720
+ # `tan.core.global_flags`. All inert here: every envelope `sdk` emits reports
1721
+ # `Project(root=None, board_yaml=None)`, an SDK-wide fact with no project of
1722
+ # its own to anchor a `--board-yaml`/`--target` on.
1723
+ sdk = accept_global_flags(sdk)