netbox-super-cli 1.0.4__tar.gz → 1.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/CHANGELOG.md +36 -0
  2. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/PKG-INFO +1 -1
  3. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/guides/output-formats.md +14 -0
  4. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/reference/config.md +2 -1
  5. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/_version.py +1 -1
  6. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/globals.py +3 -0
  7. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/handlers.py +18 -10
  8. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/runtime.py +13 -3
  9. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/config/models.py +7 -0
  10. netbox_super_cli-1.0.6/nsc/output/_console.py +16 -0
  11. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/output/errors.py +11 -8
  12. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/output/explain.py +17 -12
  13. netbox_super_cli-1.0.6/nsc/output/flatten.py +49 -0
  14. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/output/render.py +2 -1
  15. netbox_super_cli-1.0.6/nsc/output/table.py +82 -0
  16. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/pyproject.toml +1 -1
  17. netbox_super_cli-1.0.6/tests/cli/test_globals_color.py +63 -0
  18. netbox_super_cli-1.0.6/tests/cli/test_runtime_color.py +55 -0
  19. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/writes/test_handler_helpers.py +52 -0
  20. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/config/test_models.py +10 -1
  21. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/test_login.py +4 -1
  22. netbox_super_cli-1.0.6/tests/output/test_console.py +40 -0
  23. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_csv.py +23 -0
  24. netbox_super_cli-1.0.6/tests/output/test_errors_color.py +47 -0
  25. netbox_super_cli-1.0.6/tests/output/test_explain_color.py +50 -0
  26. netbox_super_cli-1.0.6/tests/output/test_flatten.py +88 -0
  27. netbox_super_cli-1.0.6/tests/output/test_table_color.py +138 -0
  28. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/uv.lock +1 -1
  29. netbox_super_cli-1.0.4/nsc/output/flatten.py +0 -25
  30. netbox_super_cli-1.0.4/nsc/output/table.py +0 -50
  31. netbox_super_cli-1.0.4/tests/output/test_flatten.py +0 -34
  32. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.github/workflows/agents-md-sync.yml +0 -0
  33. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.github/workflows/bench.yml +0 -0
  34. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.github/workflows/claude.yml +0 -0
  35. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.github/workflows/docs.yml +0 -0
  36. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.github/workflows/e2e.yml +0 -0
  37. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.github/workflows/lint.yml +0 -0
  38. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.github/workflows/release.yml +0 -0
  39. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.github/workflows/test.yml +0 -0
  40. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.gitignore +0 -0
  41. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.pre-commit-config.yaml +0 -0
  42. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/.python-version +0 -0
  43. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/AGENTS.md +0 -0
  44. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/CLAUDE.md +0 -0
  45. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/LICENSE +0 -0
  46. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/README.md +0 -0
  47. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/architecture/caching.md +0 -0
  48. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/architecture/command-generation.md +0 -0
  49. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/architecture/http-client.md +0 -0
  50. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/architecture/overview.md +0 -0
  51. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/architecture/schema-loading.md +0 -0
  52. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/contributing/adding-bundled-schemas.md +0 -0
  53. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/contributing/branching.md +0 -0
  54. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/contributing/development.md +0 -0
  55. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/contributing/release-process.md +0 -0
  56. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/getting-started/concepts.md +0 -0
  57. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/getting-started/first-run.md +0 -0
  58. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/getting-started/install.md +0 -0
  59. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/guides/ci-and-automation.md +0 -0
  60. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/guides/managing-profiles.md +0 -0
  61. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/guides/using-with-ai-agents.md +0 -0
  62. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/guides/working-with-plugins.md +0 -0
  63. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/guides/writes-and-safety.md +0 -0
  64. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/index.md +0 -0
  65. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/reference/cli.md +0 -0
  66. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/reference/exit-codes.md +0 -0
  67. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/docs/reference/schemas.md +0 -0
  68. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/justfile +0 -0
  69. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/mkdocs.yml +0 -0
  70. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/__init__.py +0 -0
  71. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/__main__.py +0 -0
  72. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/aliases/__init__.py +0 -0
  73. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/aliases/resolver.py +0 -0
  74. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/auth/__init__.py +0 -0
  75. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/auth/verify.py +0 -0
  76. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/builder/__init__.py +0 -0
  77. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/builder/build.py +0 -0
  78. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cache/__init__.py +0 -0
  79. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cache/store.py +0 -0
  80. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/__init__.py +0 -0
  81. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/aliases_commands.py +0 -0
  82. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/app.py +0 -0
  83. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/cache_commands.py +0 -0
  84. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/commands_dump.py +0 -0
  85. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/config_commands.py +0 -0
  86. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/init_commands.py +0 -0
  87. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/login_commands.py +0 -0
  88. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/profiles_commands.py +0 -0
  89. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/registration.py +0 -0
  90. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/skill_commands.py +0 -0
  91. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/writes/__init__.py +0 -0
  92. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/writes/apply.py +0 -0
  93. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/writes/bulk.py +0 -0
  94. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/writes/coercion.py +0 -0
  95. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/writes/confirmation.py +0 -0
  96. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/writes/input.py +0 -0
  97. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/cli/writes/preflight.py +0 -0
  98. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/config/__init__.py +0 -0
  99. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/config/loader.py +0 -0
  100. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/config/settings.py +0 -0
  101. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/config/writer.py +0 -0
  102. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/http/__init__.py +0 -0
  103. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/http/audit.py +0 -0
  104. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/http/client.py +0 -0
  105. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/http/errors.py +0 -0
  106. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/http/retry.py +0 -0
  107. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/model/__init__.py +0 -0
  108. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/model/command_model.py +0 -0
  109. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/output/__init__.py +0 -0
  110. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/output/csv_.py +0 -0
  111. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/output/headers.py +0 -0
  112. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/output/json_.py +0 -0
  113. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/output/jsonl.py +0 -0
  114. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/output/yaml_.py +0 -0
  115. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schema/__init__.py +0 -0
  116. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schema/hashing.py +0 -0
  117. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schema/loader.py +0 -0
  118. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schema/models.py +0 -0
  119. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schema/source.py +0 -0
  120. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schemas/__init__.py +0 -0
  121. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schemas/bundled/__init__.py +0 -0
  122. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schemas/bundled/manifest.yaml +0 -0
  123. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schemas/bundled/netbox-4.5.10.json.gz +0 -0
  124. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/schemas/bundled/netbox-4.6.0.json.gz +0 -0
  125. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/nsc/skill/__init__.py +0 -0
  126. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/scripts/gen_docs.py +0 -0
  127. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/scripts/sync_agents_md.py +0 -0
  128. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/skills/netbox-super-cli/SKILL.md +0 -0
  129. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/__init__.py +0 -0
  130. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/aliases/__init__.py +0 -0
  131. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/aliases/test_resolver.py +0 -0
  132. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/auth/__init__.py +0 -0
  133. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/auth/test_verify.py +0 -0
  134. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/benchmarks/__init__.py +0 -0
  135. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/benchmarks/test_startup.py +0 -0
  136. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/builder/__init__.py +0 -0
  137. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/builder/test_build.py +0 -0
  138. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/builder/test_default_columns.py +0 -0
  139. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/builder/test_redaction_marking.py +0 -0
  140. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/builder/test_request_body_shape.py +0 -0
  141. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cache/__init__.py +0 -0
  142. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cache/test_prune.py +0 -0
  143. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cache/test_store.py +0 -0
  144. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/__init__.py +0 -0
  145. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_aliases_commands.py +0 -0
  146. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_app_smoke.py +0 -0
  147. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_cache_commands.py +0 -0
  148. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_commands_dump.py +0 -0
  149. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_completion_smoke.py +0 -0
  150. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_config_commands.py +0 -0
  151. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_explain.py +0 -0
  152. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_handlers.py +0 -0
  153. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_init_commands.py +0 -0
  154. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_login_commands.py +0 -0
  155. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_meta_subcommands_under_broken_config.py +0 -0
  156. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_ndjson_input.py +0 -0
  157. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_profiles_commands.py +0 -0
  158. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_registration.py +0 -0
  159. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_respx_integration.py +0 -0
  160. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_runtime.py +0 -0
  161. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_skill_commands.py +0 -0
  162. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_stdin_sniffer.py +0 -0
  163. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/test_writes_respx.py +0 -0
  164. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/writes/__init__.py +0 -0
  165. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/writes/test_apply.py +0 -0
  166. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/writes/test_bulk.py +0 -0
  167. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/writes/test_confirmation.py +0 -0
  168. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/writes/test_handlers_audit.py +0 -0
  169. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/writes/test_input.py +0 -0
  170. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/cli/writes/test_preflight.py +0 -0
  171. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/config/__init__.py +0 -0
  172. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/config/test_loader.py +0 -0
  173. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/config/test_writer_atomicity.py +0 -0
  174. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/config/test_writer_dotted_paths.py +0 -0
  175. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/config/test_writer_roundtrip.py +0 -0
  176. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/conftest.py +0 -0
  177. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/README.md +0 -0
  178. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/__init__.py +0 -0
  179. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/conftest.py +0 -0
  180. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/docker-compose.yml +0 -0
  181. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/test_audit_redaction.py +0 -0
  182. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/test_auth_error_envelope.py +0 -0
  183. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/test_bulk_create_with_loop_fallback.py +0 -0
  184. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/test_full_cycle.py +0 -0
  185. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/test_ndjson.py +0 -0
  186. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/test_preflight_blocks_apply.py +0 -0
  187. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/test_validation_error_envelope_from_netbox.py +0 -0
  188. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/e2e/wait_for_netbox.sh +0 -0
  189. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/fixtures/profiles/single_profile.yaml +0 -0
  190. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/fixtures/responses/auth_401.json +0 -0
  191. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/fixtures/responses/circuits_providers_list.json +0 -0
  192. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/fixtures/responses/dcim_devices_get.json +0 -0
  193. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/fixtures/responses/dcim_devices_list_p1.json +0 -0
  194. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/fixtures/responses/dcim_devices_list_p2.json +0 -0
  195. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/http/__init__.py +0 -0
  196. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/http/test_audit.py +0 -0
  197. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/http/test_audit_redaction.py +0 -0
  198. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/http/test_client.py +0 -0
  199. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/http/test_client_redaction_threading.py +0 -0
  200. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/http/test_errors.py +0 -0
  201. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/http/test_retry.py +0 -0
  202. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/model/__init__.py +0 -0
  203. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/model/test_command_model.py +0 -0
  204. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/model/test_request_body_shape.py +0 -0
  205. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/__init__.py +0 -0
  206. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_errors.py +0 -0
  207. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_errors_aliases.py +0 -0
  208. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_explain.py +0 -0
  209. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_input_error_envelope.py +0 -0
  210. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_json.py +0 -0
  211. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_jsonl.py +0 -0
  212. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_render_dispatch.py +0 -0
  213. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_table.py +0 -0
  214. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/output/test_yaml.py +0 -0
  215. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/schema/__init__.py +0 -0
  216. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/schema/test_hashing.py +0 -0
  217. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/schema/test_loader.py +0 -0
  218. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/schema/test_models.py +0 -0
  219. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/schema/test_source.py +0 -0
  220. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/schema/test_source_ttl.py +0 -0
  221. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/scripts/__init__.py +0 -0
  222. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/scripts/test_gen_docs.py +0 -0
  223. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/skill/__init__.py +0 -0
  224. {netbox_super_cli-1.0.4 → netbox_super_cli-1.0.6}/tests/skill/test_bundle.py +0 -0
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to netbox-super-cli are tracked here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely. From v1.0.0 onward, releases follow [Semantic Versioning](https://semver.org/) and the version in `pyproject.toml` matches the git tag. Pre-1.0 milestones (Phase 1-5) were pinned by tag while `pyproject.toml` stayed at `0.0.1`.
4
4
 
5
+ ## v1.0.6 — 2026-06-09
6
+
7
+ Patch release. Fixes table/CSV column rendering for fields that are nested
8
+ objects or lists of objects (issue #78).
9
+
10
+ ### Fixed
11
+
12
+ - **Nested-object and list columns now render in `table`/`csv`** (issue #78).
13
+ A `--columns` (or `columns:` config) entry pointing to a nested object — a
14
+ NetBox brief FK such as `role`, `site`, `tenant`, `device_type` — rendered as
15
+ an empty cell; an entry pointing to a list of objects (e.g. `tags`) rendered
16
+ as a raw JSON blob. Such columns now collapse to the object's `display` label
17
+ (compact-JSON fallback for objects without one), and list columns render their
18
+ members' labels joined with `, ` (empty list → blank). Scalars, nulls, and
19
+ dotted leaf paths (`status.value`, `role.name`) are unchanged, as is the full
20
+ output of the `json`, `jsonl`, and `yaml` formats.
21
+
22
+ ## v1.0.5 — 2026-05-21
23
+
24
+ Adds opt-in semantic terminal coloring (issue #24). No config migration is
25
+ needed — `color_mode` defaults to `auto`, which colors a stream only when it
26
+ is a TTY, so piped and redirected output is byte-for-byte unchanged.
27
+
28
+ ### Added
29
+
30
+ - **Semantic terminal coloring** (issue #24, PR #72). A new `defaults.color_mode`
31
+ config field — `auto` (default), `on`, or `off` — controls colored output.
32
+ Table cells are colored by meaning: status values (`active`/`enabled`/`online`
33
+ green, `planned`/`staged` yellow, `failed`/`disabled`/`offline` red), booleans,
34
+ and empty cells. `nsc explain` traces and Rich error panels are colored too.
35
+ stdout and stderr are gated independently by each stream's own TTY, so
36
+ `nsc … | jq` and `nsc … 2>err.log` no longer mis-color one stream or leak
37
+ ANSI into the other. Structured formats (`json`, `jsonl`, `yaml`, `csv`) are
38
+ never colored. All server- and user-sourced text is escaped before Rich
39
+ markup parsing, so stray `[`/`]` in API responses cannot garble output.
40
+
5
41
  ## v1.0.4 — 2026-05-16
6
42
 
7
43
  Maintenance release: a codebase-wide internal simplification pass (no behavioural change), a full documentation parity audit, and a dependency bump. There are no CLI, config, or output-contract changes — upgrading from v1.0.3 is transparent.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-super-cli
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Dynamic NetBox CLI generated from the live OpenAPI schema.
5
5
  Project-URL: Homepage, https://github.com/thomaschristory/netbox-super-cli
6
6
  Project-URL: Issues, https://github.com/thomaschristory/netbox-super-cli/issues
@@ -43,6 +43,20 @@ columns:
43
43
  Unknown column names are silently ignored, so the same config remains valid
44
44
  across NetBox versions and plugin changes.
45
45
 
46
+ ### Nested objects and lists in columns
47
+
48
+ A column may name a nested field directly or drill in with a dotted path:
49
+
50
+ - A dotted path selects the leaf scalar: `status.value`, `site.name`.
51
+ - A bare nested object (`site`, `role`, `tenant`, `status`) collapses to its
52
+ `display` label — `role` renders `Router`, not an empty cell. An object with
53
+ no `display` field falls back to compact JSON.
54
+ - A list of objects renders as its members' labels joined with `, ` (e.g.
55
+ `tags` → `prod, edge`); an empty list renders blank.
56
+
57
+ This applies to both `table` and `csv`. The `json`, `jsonl`, and `yaml` formats
58
+ always keep the full nested structure untouched.
59
+
46
60
  ## Compact JSON
47
61
 
48
62
  ```sh
@@ -11,7 +11,7 @@ All fields below describe `~/.nsc/config.yaml`.
11
11
  |---|---|---|
12
12
  | `default_profile` | `str | None` | `None` |
13
13
  | `profiles` | `dict[str, Profile]` | `{}` |
14
- | `defaults` | `<class 'Defaults'>` | `Defaults(output=<OutputFormat.TABLE: 'table'>, page_size=50, timeout=30.0, schema_refresh=<SchemaRefresh.DAILY: 'daily'>)` |
14
+ | `defaults` | `<class 'Defaults'>` | `Defaults(output=<OutputFormat.TABLE: 'table'>, page_size=50, timeout=30.0, schema_refresh=<SchemaRefresh.DAILY: 'daily'>, color_mode=<ColorMode.AUTO: 'auto'>)` |
15
15
  | `columns` | `dict[str, dict[str, list[str]]]` | `{}` |
16
16
 
17
17
  ## `Profile`
@@ -33,3 +33,4 @@ All fields below describe `~/.nsc/config.yaml`.
33
33
  | `page_size` | `<class 'int'>` | `50` |
34
34
  | `timeout` | `<class 'float'>` | `30.0` |
35
35
  | `schema_refresh` | `<enum 'SchemaRefresh'>` | `<SchemaRefresh.DAILY: 'daily'>` |
36
+ | `color_mode` | `<enum 'ColorMode'>` | `<ColorMode.AUTO: 'auto'>` |
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "1.0.4"
3
+ __version__ = "1.0.6"
@@ -9,6 +9,7 @@ from dataclasses import dataclass
9
9
  from nsc.cli.runtime import (
10
10
  CLIOverrides,
11
11
  RuntimeContext,
12
+ resolve_color,
12
13
  resolve_profile,
13
14
  )
14
15
  from nsc.config import default_paths
@@ -50,6 +51,8 @@ def build_runtime_context(state: GlobalState) -> RuntimeContext:
50
51
  output_format=output,
51
52
  debug=state.debug,
52
53
  page_size=state.config.defaults.page_size,
54
+ color=resolve_color(state.config.defaults.color_mode, is_tty=sys.stdout.isatty()),
55
+ color_stderr=resolve_color(state.config.defaults.color_mode, is_tty=sys.stderr.isatty()),
53
56
  )
54
57
 
55
58
 
@@ -38,6 +38,7 @@ from nsc.config.settings import default_paths
38
38
  from nsc.http.audit import AuditEntry, append_audit_jsonl
39
39
  from nsc.http.errors import NetBoxAPIError, NetBoxClientError
40
40
  from nsc.model.command_model import HttpMethod, Operation, ParameterLocation
41
+ from nsc.output._console import make_console
41
42
  from nsc.output.errors import (
42
43
  ClientError,
43
44
  ErrorEnvelope,
@@ -101,10 +102,11 @@ def handle_list(
101
102
  columns=ctx.resolve_columns(op_tag, op_resource, operation),
102
103
  stream=_out(stream),
103
104
  compact=ctx.compact,
105
+ color=ctx.color,
104
106
  )
105
107
  except (NetBoxAPIError, NetBoxClientError) as exc:
106
108
  env = map_error(exc, operation_id=operation.operation_id)
107
- code = emit_envelope(env, output_format=ctx.output_format)
109
+ code = emit_envelope(env, output_format=ctx.output_format, color=ctx.color_stderr)
108
110
  raise typer.Exit(code) from exc
109
111
 
110
112
 
@@ -126,10 +128,11 @@ def handle_get(
126
128
  columns=ctx.resolve_columns(op_tag, op_resource, operation),
127
129
  stream=_out(stream),
128
130
  compact=ctx.compact,
131
+ color=ctx.color,
129
132
  )
130
133
  except (NetBoxAPIError, NetBoxClientError) as exc:
131
134
  env = map_error(exc, operation_id=operation.operation_id)
132
- code = emit_envelope(env, output_format=ctx.output_format)
135
+ code = emit_envelope(env, output_format=ctx.output_format, color=ctx.color_stderr)
133
136
  raise typer.Exit(code) from exc
134
137
 
135
138
 
@@ -316,7 +319,7 @@ def _handle_write(
316
319
  is_delete=is_delete,
317
320
  )
318
321
  except ClientError as exc:
319
- code = emit_envelope(exc.envelope, output_format=ctx.output_format)
322
+ code = emit_envelope(exc.envelope, output_format=ctx.output_format, color=ctx.color_stderr)
320
323
  raise typer.Exit(code) from exc
321
324
  except NDJSONParseError as exc:
322
325
  env = input_error_envelope(
@@ -324,11 +327,11 @@ def _handle_write(
324
327
  bad_lines=exc.bad_lines,
325
328
  operation_id=operation.operation_id,
326
329
  )
327
- code = emit_envelope(env, output_format=ctx.output_format)
330
+ code = emit_envelope(env, output_format=ctx.output_format, color=ctx.color_stderr)
328
331
  raise typer.Exit(code) from exc
329
332
  except InputError as exc:
330
333
  env = client_envelope(str(exc), operation_id=operation.operation_id)
331
- code = emit_envelope(env, output_format=ctx.output_format)
334
+ code = emit_envelope(env, output_format=ctx.output_format, color=ctx.color_stderr)
332
335
  raise typer.Exit(code) from exc
333
336
  except (NetBoxAPIError, NetBoxClientError) as exc:
334
337
  if (
@@ -340,7 +343,7 @@ def _handle_write(
340
343
  _render_delete_already_absent(ctx, stream=out)
341
344
  return
342
345
  env = map_error(exc, operation_id=operation.operation_id)
343
- code = emit_envelope(env, output_format=ctx.output_format)
346
+ code = emit_envelope(env, output_format=ctx.output_format, color=ctx.color_stderr)
344
347
  raise typer.Exit(code) from exc
345
348
 
346
349
 
@@ -364,13 +367,13 @@ def _handle_dry_run_or_preflight(
364
367
  _render_explain_or_dry_run(trace, ctx, stream=out)
365
368
  if not preflight.ok:
366
369
  env = _preflight_envelope(operation, preflight, applied=False)
367
- code = emit_envelope(env, output_format=ctx.output_format)
370
+ code = emit_envelope(env, output_format=ctx.output_format, color=ctx.color_stderr)
368
371
  raise typer.Exit(code)
369
372
  return True
370
373
  if not preflight.ok:
371
374
  _emit_dry_run_audit(operation, resolved, preflight, ctx, preflight_blocked=True)
372
375
  env = _preflight_envelope(operation, preflight, applied=False)
373
- code = emit_envelope(env, output_format=ctx.output_format)
376
+ code = emit_envelope(env, output_format=ctx.output_format, color=ctx.color_stderr)
374
377
  raise typer.Exit(code)
375
378
  return False
376
379
 
@@ -480,7 +483,7 @@ def _execute_loop(
480
483
  operation_id=operation.operation_id,
481
484
  total_records=total_records,
482
485
  )
483
- code = emit_envelope(env, output_format=ctx.output_format)
486
+ code = emit_envelope(env, output_format=ctx.output_format, color=ctx.color_stderr)
484
487
  raise typer.Exit(code)
485
488
 
486
489
 
@@ -539,7 +542,7 @@ def _render_explain_or_dry_run(trace: ExplainTrace, ctx: RuntimeContext, *, stre
539
542
  if ctx.output_format is OutputFormat.JSON:
540
543
  print(render_explain_json(trace), file=stream)
541
544
  elif ctx.output_format is OutputFormat.TABLE:
542
- render_explain_rich(trace, stream=stream)
545
+ render_explain_rich(trace, stream=stream, color=ctx.color)
543
546
  else:
544
547
  # CSV/YAML/JSONL on dry-run → JSON to stdout (the formatters expect rows,
545
548
  # not a structured trace). Consistent with spec §4.2.3 fallback rules.
@@ -607,6 +610,7 @@ def _render_response(
607
610
  columns=ctx.resolve_columns(op_tag, op_resource, operation),
608
611
  stream=stream,
609
612
  compact=ctx.compact,
613
+ color=ctx.color,
610
614
  )
611
615
 
612
616
 
@@ -614,6 +618,8 @@ def _render_delete_ok(ctx: RuntimeContext, *, stream: TextIO) -> None:
614
618
  payload = {"deleted": True}
615
619
  if ctx.output_format is OutputFormat.JSON:
616
620
  print(_json.dumps(payload), file=stream)
621
+ elif ctx.output_format is OutputFormat.TABLE and ctx.color:
622
+ make_console(stream, color=True).print("[yellow]deleted[/]")
617
623
  else:
618
624
  print("deleted", file=stream)
619
625
 
@@ -622,6 +628,8 @@ def _render_delete_already_absent(ctx: RuntimeContext, *, stream: TextIO) -> Non
622
628
  payload = {"deleted": False, "reason": "already_absent"}
623
629
  if ctx.output_format is OutputFormat.JSON:
624
630
  print(_json.dumps(payload), file=stream)
631
+ elif ctx.output_format is OutputFormat.TABLE and ctx.color:
632
+ make_console(stream, color=True).print("[dim]already absent (no change)[/]")
625
633
  else:
626
634
  print("already absent (no change)", file=stream)
627
635
 
@@ -15,7 +15,7 @@ from typing import Any, Literal
15
15
  from pydantic import BaseModel, ConfigDict, HttpUrl, SkipValidation
16
16
 
17
17
  from nsc.cache.store import ADHOC_PROFILE
18
- from nsc.config.models import Config, OutputFormat, Profile
18
+ from nsc.config.models import ColorMode, Config, OutputFormat, Profile
19
19
  from nsc.config.settings import default_paths
20
20
  from nsc.http.client import NetBoxClient
21
21
  from nsc.http.errors import NetBoxAPIError, NetBoxClientError
@@ -178,6 +178,14 @@ def _url_only(value: str | None) -> str | None:
178
178
  return None
179
179
 
180
180
 
181
+ def resolve_color(mode: ColorMode, *, is_tty: bool) -> bool:
182
+ if mode is ColorMode.ON:
183
+ return True
184
+ if mode is ColorMode.OFF:
185
+ return False
186
+ return is_tty
187
+
188
+
181
189
  class RuntimeContext(BaseModel):
182
190
  """Per-invocation runtime state.
183
191
 
@@ -198,6 +206,8 @@ class RuntimeContext(BaseModel):
198
206
  limit: int | None = None
199
207
  fetch_all: bool = False
200
208
  compact: bool = False
209
+ color: bool = False
210
+ color_stderr: bool = False
201
211
  apply: bool = False
202
212
  explain: bool = False
203
213
  strict: bool = False
@@ -302,7 +312,7 @@ def map_error(
302
312
  )
303
313
 
304
314
 
305
- def emit_envelope(env: ErrorEnvelope, *, output_format: OutputFormat) -> int:
315
+ def emit_envelope(env: ErrorEnvelope, *, output_format: OutputFormat, color: bool = False) -> int:
306
316
  """Write the envelope to the right target and return the exit code."""
307
317
  target = select_render_target(output_format=output_format, stdout_is_tty=sys.stdout.isatty())
308
318
  if target is RenderTarget.JSON_STDOUT:
@@ -310,5 +320,5 @@ def emit_envelope(env: ErrorEnvelope, *, output_format: OutputFormat) -> int:
310
320
  elif target is RenderTarget.JSON_STDERR:
311
321
  print(render_to_json(env), file=sys.stderr)
312
322
  else:
313
- render_to_rich_stderr(env, stream=sys.stderr)
323
+ render_to_rich_stderr(env, stream=sys.stderr, color=color)
314
324
  return EXIT_CODES.get(env.type, 1)
@@ -31,11 +31,18 @@ class SchemaRefresh(StrEnum):
31
31
  WEEKLY = "weekly"
32
32
 
33
33
 
34
+ class ColorMode(StrEnum):
35
+ AUTO = "auto"
36
+ ON = "on"
37
+ OFF = "off"
38
+
39
+
34
40
  class Defaults(_Frozen):
35
41
  output: OutputFormat = OutputFormat.TABLE
36
42
  page_size: int = 50
37
43
  timeout: float = 30.0
38
44
  schema_refresh: SchemaRefresh = SchemaRefresh.DAILY
45
+ color_mode: ColorMode = ColorMode.AUTO
39
46
 
40
47
 
41
48
  class Profile(_Frozen):
@@ -0,0 +1,16 @@
1
+ """Shared Rich Console factory used across all output formatters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TextIO
6
+
7
+ from rich.console import Console
8
+
9
+
10
+ def make_console(stream: TextIO, *, color: bool, soft_wrap: bool = False) -> Console:
11
+ # highlight=False on both paths: Rich's auto-highlighter bolds parens and
12
+ # colorizes numbers/IPs in plain strings, which would corrupt our output and
13
+ # fight the explicit semantic markup applied by the formatters.
14
+ if color:
15
+ return Console(file=stream, force_terminal=True, highlight=False, soft_wrap=soft_wrap)
16
+ return Console(file=stream, no_color=True, highlight=False, soft_wrap=soft_wrap)
@@ -10,11 +10,12 @@ from enum import Enum, StrEnum
10
10
  from typing import Any, Literal, TextIO
11
11
 
12
12
  from pydantic import BaseModel, ConfigDict, Field
13
- from rich.console import Console
13
+ from rich.markup import escape
14
14
  from rich.panel import Panel
15
15
 
16
16
  from nsc.config.models import OutputFormat
17
17
  from nsc.model.command_model import HttpMethod
18
+ from nsc.output._console import make_console
18
19
 
19
20
 
20
21
  class ErrorType(StrEnum):
@@ -97,25 +98,27 @@ def select_render_target(*, output_format: OutputFormat, stdout_is_tty: bool) ->
97
98
  return RenderTarget.JSON_STDERR
98
99
 
99
100
 
100
- def render_to_rich_stderr(env: ErrorEnvelope, *, stream: TextIO) -> None:
101
- console = Console(file=stream, soft_wrap=True, force_terminal=False)
101
+ def render_to_rich_stderr(env: ErrorEnvelope, *, stream: TextIO, color: bool = False) -> None:
102
+ console = make_console(stream, color=color, soft_wrap=True)
103
+ # env.error/endpoint/operation_id/audit_log_path/details carry server- and
104
+ # user-sourced text; escape it so stray [..] is not parsed as Rich markup.
102
105
  body_lines = [
103
- f"[bold red]{env.type.value}[/]: {env.error}",
106
+ f"[bold red]{env.type.value}[/]: {escape(env.error)}",
104
107
  ]
105
108
  if env.endpoint:
106
- body_lines.append(f"endpoint: {env.endpoint}")
109
+ body_lines.append(f"endpoint: {escape(env.endpoint)}")
107
110
  if env.method is not None:
108
111
  body_lines.append(f"method: {env.method.value}")
109
112
  if env.status_code is not None:
110
113
  body_lines.append(f"status: {env.status_code}")
111
114
  if env.operation_id:
112
- body_lines.append(f"op: {env.operation_id}")
115
+ body_lines.append(f"op: {escape(env.operation_id)}")
113
116
  if env.attempt_n is not None:
114
117
  body_lines.append(f"attempt: {env.attempt_n}")
115
118
  if env.audit_log_path:
116
- body_lines.append(f"audit: {env.audit_log_path}")
119
+ body_lines.append(f"audit: {escape(env.audit_log_path)}")
117
120
  if env.details:
118
- body_lines.append(f"details: {env.details}")
121
+ body_lines.append(f"details: {escape(str(env.details))}")
119
122
  console.print(Panel("\n".join(body_lines), title="nsc error", border_style="red"))
120
123
 
121
124
 
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  from typing import Any, Literal, TextIO
12
12
 
13
13
  from pydantic import BaseModel, ConfigDict, Field
14
- from rich.console import Console
14
+ from rich.markup import escape
15
15
  from rich.panel import Panel
16
16
 
17
17
  from nsc.cli.writes.apply import ResolvedRequest
@@ -19,6 +19,7 @@ from nsc.cli.writes.bulk import RoutingDecision
19
19
  from nsc.cli.writes.input import RawWriteInput
20
20
  from nsc.cli.writes.preflight import PreflightResult
21
21
  from nsc.model.command_model import Operation
22
+ from nsc.output._console import make_console
22
23
 
23
24
  DECISION_CAP = 200
24
25
 
@@ -151,26 +152,28 @@ def render_to_json(trace: ExplainTrace) -> str:
151
152
  return trace.model_dump_json()
152
153
 
153
154
 
154
- def render_to_rich_stdout(trace: ExplainTrace, *, stream: TextIO) -> None:
155
- console = Console(file=stream, soft_wrap=True, force_terminal=False)
155
+ def render_to_rich_stdout(trace: ExplainTrace, *, stream: TextIO, color: bool = False) -> None:
156
+ console = make_console(stream, color=color, soft_wrap=True)
157
+ # Reasoning strings and decision values carry user/schema/server text that
158
+ # may contain [..]; escape it so it is not parsed as Rich markup.
156
159
  body = [
157
- f"[bold cyan]operation:[/] {trace.operation_id}",
160
+ f"[bold cyan]operation:[/] {escape(trace.operation_id)}",
158
161
  ]
159
162
  if trace.operation_summary:
160
- body.append(f"summary: {trace.operation_summary}")
161
- body.append(f"method: {trace.method_reasoning}")
162
- body.append(f"url: {trace.url_reasoning}")
163
+ body.append(f"summary: {escape(trace.operation_summary)}")
164
+ body.append(f"method: {escape(trace.method_reasoning)}")
165
+ body.append(f"url: {escape(trace.url_reasoning)}")
163
166
  if trace.bulk_reasoning:
164
- body.append(f"bulk: {trace.bulk_reasoning}")
167
+ body.append(f"bulk: {escape(trace.bulk_reasoning)}")
165
168
  for r in trace.requests:
166
- body.append(f" → {r.method.value} {r.url}")
169
+ body.append(f" → {r.method.value} {escape(r.url)}")
167
170
  if r.body is not None:
168
- body.append(f" body: {r.body}")
171
+ body.append(f" body: {escape(str(r.body))}")
169
172
  if trace.preflight is not None and not trace.preflight.ok:
170
173
  body.append("preflight: [red]FAIL[/]")
171
174
  for issue in trace.preflight.issues:
172
175
  loc = f"records[{issue.record_index}].{issue.field_path}"
173
- body.append(f" [{issue.kind}] {loc}: {issue.message}")
176
+ body.append(escape(f" [{issue.kind}] {loc}: {issue.message}"))
174
177
  elif trace.preflight is not None:
175
178
  body.append("preflight: [green]OK[/]")
176
179
  if trace.decisions:
@@ -178,7 +181,9 @@ def render_to_rich_stdout(trace: ExplainTrace, *, stream: TextIO) -> None:
178
181
  for d in trace.decisions:
179
182
  extra = f" — {d.note}" if d.note else ""
180
183
  body.append(
181
- f" {d.field_path} [{d.source}] {d.raw_value!r} → {d.resolved_value!r}{extra}"
184
+ escape(
185
+ f" {d.field_path} [{d.source}] {d.raw_value!r} → {d.resolved_value!r}{extra}"
186
+ )
182
187
  )
183
188
  if trace.decisions_truncated:
184
189
  body.append(f" … truncated at {DECISION_CAP} entries")
@@ -0,0 +1,49 @@
1
+ """Shared dotted-path flattener used by table and csv formatters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def flatten(record: dict[str, Any], *, columns: list[str] | None = None) -> dict[str, Any]:
10
+ if columns is None:
11
+ flat: dict[str, Any] = {}
12
+ _walk(record, "", flat)
13
+ return flat
14
+ return {col: _select(record, col) for col in columns}
15
+
16
+
17
+ def _walk(value: Any, prefix: str, out: dict[str, Any]) -> None:
18
+ if isinstance(value, dict):
19
+ for k, v in value.items():
20
+ child = f"{prefix}.{k}" if prefix else k
21
+ _walk(v, child, out)
22
+ elif isinstance(value, list):
23
+ out[prefix] = json.dumps(value)
24
+ else:
25
+ out[prefix] = value
26
+
27
+
28
+ def _select(record: dict[str, Any], path: str) -> Any:
29
+ cur: Any = record
30
+ for part in path.split("."):
31
+ if isinstance(cur, dict) and part in cur:
32
+ cur = cur[part]
33
+ else:
34
+ return ""
35
+ return _displayify(cur)
36
+
37
+
38
+ def _displayify(value: Any) -> Any:
39
+ if isinstance(value, dict):
40
+ display = value.get("display")
41
+ return display if isinstance(display, str) else json.dumps(value, separators=(",", ":"))
42
+ if isinstance(value, list):
43
+ return ", ".join(_as_cell(v) for v in value)
44
+ return value
45
+
46
+
47
+ def _as_cell(value: Any) -> str:
48
+ rendered = _displayify(value)
49
+ return "" if rendered is None else str(rendered)
@@ -16,6 +16,7 @@ def render(
16
16
  columns: list[str] | None = None,
17
17
  stream: TextIO = sys.stdout,
18
18
  compact: bool = False,
19
+ color: bool = False,
19
20
  ) -> None:
20
21
  if format is OutputFormat.JSON:
21
22
  json_.render(data, stream=stream, compact=compact)
@@ -26,7 +27,7 @@ def render(
26
27
  elif format is OutputFormat.CSV:
27
28
  csv_.render(data, stream=stream, columns=columns)
28
29
  elif format is OutputFormat.TABLE:
29
- table.render(data, stream=stream, columns=columns)
30
+ table.render(data, stream=stream, columns=columns, color=color)
30
31
  else: # pragma: no cover (StrEnum exhaustively covered above)
31
32
  raise ValueError(f"unknown output format: {format!r}")
32
33
 
@@ -0,0 +1,82 @@
1
+ """Rich table output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any, TextIO
7
+
8
+ from rich.markup import escape
9
+ from rich.table import Table
10
+
11
+ from nsc.output._console import make_console
12
+ from nsc.output.flatten import flatten
13
+
14
+ _STATUS_COLORS: dict[str, str] = {
15
+ "active": "green",
16
+ "enabled": "green",
17
+ "online": "green",
18
+ "connected": "green",
19
+ "planned": "yellow",
20
+ "staged": "yellow",
21
+ "decommissioning": "yellow",
22
+ "failed": "red",
23
+ "disabled": "red",
24
+ "offline": "red",
25
+ "error": "red",
26
+ "true": "green",
27
+ "false": "dim",
28
+ }
29
+
30
+
31
+ def render(
32
+ data: list[dict[str, Any]] | dict[str, Any],
33
+ *,
34
+ stream: TextIO = sys.stdout,
35
+ columns: list[str] | None = None,
36
+ color: bool = False,
37
+ ) -> None:
38
+ records = [data] if isinstance(data, dict) else list(data)
39
+ if not records:
40
+ make_console(stream, color=color).print("(no records)")
41
+ return
42
+
43
+ flat_records = [flatten(r, columns=columns) for r in records]
44
+ fieldnames = columns if columns is not None else _gather_fieldnames(flat_records)
45
+
46
+ table = Table(show_header=True, header_style="bold")
47
+ for col in fieldnames:
48
+ table.add_column(col)
49
+ for r in flat_records:
50
+ table.add_row(*[_format_cell(r.get(col, ""), color=color) for col in fieldnames])
51
+
52
+ make_console(stream, color=color).print(table)
53
+
54
+
55
+ def _gather_fieldnames(records: list[dict[str, Any]]) -> list[str]:
56
+ seen: dict[str, None] = {}
57
+ for r in records:
58
+ for k in r:
59
+ seen.setdefault(k, None)
60
+ return list(seen.keys())
61
+
62
+
63
+ def _format_cell(value: Any, *, color: bool = False) -> str:
64
+ if value is None:
65
+ text = ""
66
+ elif isinstance(value, bool):
67
+ text = "true" if value else "false"
68
+ else:
69
+ text = str(value)
70
+
71
+ # Rich parses markup in table cells regardless of the no-color setting, so
72
+ # arbitrary cell values must be escaped on every return path.
73
+ if not color:
74
+ return escape(text)
75
+
76
+ if not text:
77
+ return "[dim]-[/]"
78
+
79
+ style = _STATUS_COLORS.get(text.lower())
80
+ if style:
81
+ return f"[{style}]{escape(text)}[/]"
82
+ return escape(text)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "netbox-super-cli"
3
- version = "1.0.4"
3
+ version = "1.0.6"
4
4
  description = "Dynamic NetBox CLI generated from the live OpenAPI schema."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import pytest
6
+
7
+ from nsc.cli import globals as globals_mod
8
+ from nsc.cli.globals import GlobalState, build_runtime_context
9
+ from nsc.cli.runtime import CLIOverrides
10
+ from nsc.config.models import Config
11
+ from nsc.model.command_model import CommandModel
12
+
13
+
14
+ def _state() -> GlobalState:
15
+ return GlobalState(overrides=CLIOverrides(), config=Config(), debug=False)
16
+
17
+
18
+ def _stub_command_model(monkeypatch: pytest.MonkeyPatch) -> None:
19
+ monkeypatch.setattr(
20
+ globals_mod,
21
+ "resolve_command_model",
22
+ lambda **kwargs: CommandModel(info_title="t", info_version="v", schema_hash="x"),
23
+ )
24
+
25
+
26
+ def test_build_runtime_context_gates_stderr_color_independently(
27
+ monkeypatch: pytest.MonkeyPatch,
28
+ ) -> None:
29
+ """color (stdout) and color_stderr (stderr) must each follow their own TTY.
30
+
31
+ Regression: build_runtime_context derived both flags from sys.stdout.isatty(),
32
+ so `nsc ... 2>err.log` (stdout a TTY, stderr a file) leaked ANSI into the file.
33
+ """
34
+ monkeypatch.setenv("NSC_URL", "https://nb.example/")
35
+ monkeypatch.setenv("NSC_TOKEN", "tok")
36
+ for var in ("NSC_PROFILE", "NSC_SCHEMA", "NSC_OUTPUT"):
37
+ monkeypatch.delenv(var, raising=False)
38
+ _stub_command_model(monkeypatch)
39
+ monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
40
+ monkeypatch.setattr(sys.stderr, "isatty", lambda: False)
41
+
42
+ ctx = build_runtime_context(_state())
43
+
44
+ assert ctx.color is True
45
+ assert ctx.color_stderr is False
46
+
47
+
48
+ def test_build_runtime_context_gates_stdout_color_independently(
49
+ monkeypatch: pytest.MonkeyPatch,
50
+ ) -> None:
51
+ """The reverse: stdout piped, stderr a TTY — error panels stay colored."""
52
+ monkeypatch.setenv("NSC_URL", "https://nb.example/")
53
+ monkeypatch.setenv("NSC_TOKEN", "tok")
54
+ for var in ("NSC_PROFILE", "NSC_SCHEMA", "NSC_OUTPUT"):
55
+ monkeypatch.delenv(var, raising=False)
56
+ _stub_command_model(monkeypatch)
57
+ monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
58
+ monkeypatch.setattr(sys.stderr, "isatty", lambda: True)
59
+
60
+ ctx = build_runtime_context(_state())
61
+
62
+ assert ctx.color is False
63
+ assert ctx.color_stderr is True