netbox-super-cli 1.0.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 (71) hide show
  1. netbox_super_cli-1.0.0.dist-info/METADATA +182 -0
  2. netbox_super_cli-1.0.0.dist-info/RECORD +71 -0
  3. netbox_super_cli-1.0.0.dist-info/WHEEL +4 -0
  4. netbox_super_cli-1.0.0.dist-info/entry_points.txt +3 -0
  5. netbox_super_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. nsc/__init__.py +5 -0
  7. nsc/__main__.py +6 -0
  8. nsc/_version.py +3 -0
  9. nsc/aliases/__init__.py +24 -0
  10. nsc/aliases/resolver.py +112 -0
  11. nsc/auth/__init__.py +5 -0
  12. nsc/auth/verify.py +143 -0
  13. nsc/builder/__init__.py +5 -0
  14. nsc/builder/build.py +514 -0
  15. nsc/cache/__init__.py +5 -0
  16. nsc/cache/store.py +295 -0
  17. nsc/cli/__init__.py +1 -0
  18. nsc/cli/aliases_commands.py +264 -0
  19. nsc/cli/app.py +291 -0
  20. nsc/cli/cache_commands.py +159 -0
  21. nsc/cli/commands_dump.py +57 -0
  22. nsc/cli/config_commands.py +156 -0
  23. nsc/cli/globals.py +65 -0
  24. nsc/cli/handlers.py +660 -0
  25. nsc/cli/init_commands.py +95 -0
  26. nsc/cli/login_commands.py +265 -0
  27. nsc/cli/profiles_commands.py +256 -0
  28. nsc/cli/registration.py +465 -0
  29. nsc/cli/runtime.py +290 -0
  30. nsc/cli/skill_commands.py +186 -0
  31. nsc/cli/writes/__init__.py +10 -0
  32. nsc/cli/writes/apply.py +177 -0
  33. nsc/cli/writes/bulk.py +231 -0
  34. nsc/cli/writes/coercion.py +9 -0
  35. nsc/cli/writes/confirmation.py +96 -0
  36. nsc/cli/writes/input.py +358 -0
  37. nsc/cli/writes/preflight.py +182 -0
  38. nsc/config/__init__.py +23 -0
  39. nsc/config/loader.py +69 -0
  40. nsc/config/models.py +54 -0
  41. nsc/config/settings.py +36 -0
  42. nsc/config/writer.py +207 -0
  43. nsc/http/__init__.py +6 -0
  44. nsc/http/audit.py +183 -0
  45. nsc/http/client.py +365 -0
  46. nsc/http/errors.py +35 -0
  47. nsc/http/retry.py +90 -0
  48. nsc/model/__init__.py +23 -0
  49. nsc/model/command_model.py +125 -0
  50. nsc/output/__init__.py +1 -0
  51. nsc/output/csv_.py +34 -0
  52. nsc/output/errors.py +346 -0
  53. nsc/output/explain.py +194 -0
  54. nsc/output/flatten.py +25 -0
  55. nsc/output/headers.py +9 -0
  56. nsc/output/json_.py +21 -0
  57. nsc/output/jsonl.py +21 -0
  58. nsc/output/render.py +47 -0
  59. nsc/output/table.py +50 -0
  60. nsc/output/yaml_.py +28 -0
  61. nsc/schema/__init__.py +1 -0
  62. nsc/schema/hashing.py +24 -0
  63. nsc/schema/loader.py +66 -0
  64. nsc/schema/models.py +109 -0
  65. nsc/schema/source.py +120 -0
  66. nsc/schemas/__init__.py +1 -0
  67. nsc/schemas/bundled/__init__.py +1 -0
  68. nsc/schemas/bundled/manifest.yaml +5 -0
  69. nsc/schemas/bundled/netbox-4.6.0-beta2.json.gz +0 -0
  70. nsc/skill/__init__.py +35 -0
  71. skills/netbox-super-cli/SKILL.md +127 -0
nsc/cli/app.py ADDED
@@ -0,0 +1,291 @@
1
+ """Root Typer application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Any
6
+
7
+ import click
8
+ import typer
9
+ from typer.core import TyperGroup
10
+ from typer.main import get_group, get_group_from_info
11
+
12
+ from nsc._version import __version__
13
+ from nsc.cli import (
14
+ aliases_commands,
15
+ cache_commands,
16
+ commands_dump,
17
+ config_commands,
18
+ init_commands,
19
+ login_commands,
20
+ profiles_commands,
21
+ skill_commands,
22
+ )
23
+ from nsc.cli.globals import GlobalState, build_runtime_context
24
+ from nsc.cli.registration import register_dynamic_commands
25
+ from nsc.cli.runtime import (
26
+ CLIOverrides,
27
+ NoProfileError,
28
+ RuntimeContext,
29
+ UnknownProfileError,
30
+ emit_envelope,
31
+ map_error,
32
+ )
33
+ from nsc.config import default_paths
34
+ from nsc.config.loader import ConfigParseError, load_config
35
+ from nsc.config.models import Config, OutputFormat
36
+ from nsc.http.errors import NetBoxAPIError, NetBoxClientError
37
+ from nsc.schema.source import SchemaSourceError
38
+
39
+ # Mutable dict used as a single-slot holder to avoid `global` statements.
40
+ # Keys: "runtime" -> RuntimeContext | None, "error" -> Exception | None.
41
+ _invocation: dict[str, object] = {"runtime": None, "error": None}
42
+
43
+ # Static subcommands that do not need a profile.
44
+ _META_COMMANDS: frozenset[str] = frozenset(
45
+ {"cache", "commands", "config", "init", "login", "profiles", "skill"}
46
+ )
47
+
48
+ # Populated at module-load time (after all static `register()` calls) so that
49
+ # make_context can tear down dynamically-added commands before each invocation.
50
+ # See the bottom of this module where these are assigned.
51
+ _static_groups_count: int = 0 # sentinel; set after `app` is created
52
+ _static_command_names: frozenset[str] = frozenset()
53
+
54
+
55
+ def _extract_global_overrides(args: list[str]) -> CLIOverrides:
56
+ """Scan raw argv for global flags without consuming the full Click context."""
57
+ kwargs: dict[str, object] = {}
58
+ two_arg = {
59
+ "--profile": "profile",
60
+ "--url": "url",
61
+ "--token": "token",
62
+ "--schema": "schema_override",
63
+ "--output": "output",
64
+ "-o": "output",
65
+ }
66
+ i = 0
67
+ while i < len(args):
68
+ a = args[i]
69
+ if a.startswith("--") and "=" in a:
70
+ flag, _, value = a.partition("=")
71
+ if flag in two_arg:
72
+ kwargs[two_arg[flag]] = value
73
+ i += 1
74
+ continue
75
+ if a in two_arg and i + 1 < len(args):
76
+ kwargs[two_arg[a]] = args[i + 1]
77
+ i += 2
78
+ continue
79
+ if a == "--insecure":
80
+ kwargs["insecure"] = True
81
+ i += 1
82
+ continue
83
+ if a == "--no-insecure":
84
+ kwargs["insecure"] = False
85
+ i += 1
86
+ continue
87
+ i += 1
88
+ return CLIOverrides(**kwargs) # type: ignore[arg-type]
89
+
90
+
91
+ def _first_non_option(args: list[str]) -> str | None:
92
+ """Return the first arg that looks like a subcommand name (not a flag/value)."""
93
+ skip_next = False
94
+ skip_flags = {
95
+ "--profile",
96
+ "--url",
97
+ "--token",
98
+ "--schema",
99
+ "--output",
100
+ "-o",
101
+ }
102
+ for a in args:
103
+ if skip_next:
104
+ skip_next = False
105
+ continue
106
+ if a in skip_flags:
107
+ skip_next = True
108
+ continue
109
+ if a.startswith("-"):
110
+ continue
111
+ return a
112
+ return None
113
+
114
+
115
+ class _BootstrappingGroup(TyperGroup):
116
+ """TyperGroup subclass that registers dynamic commands before dispatch.
117
+
118
+ Overrides `make_context` (called once per invocation, before `resolve_command`)
119
+ so dynamic Typer commands are visible to Click's command resolver.
120
+ """
121
+
122
+ def make_context(
123
+ self,
124
+ info_name: str | None,
125
+ args: list[str],
126
+ parent: click.Context | None = None,
127
+ **extra: Any,
128
+ ) -> click.Context:
129
+ _invocation["runtime"] = None
130
+ _invocation["error"] = None
131
+
132
+ # Tear down any dynamic commands registered by a previous invocation so
133
+ # that each call to `app(...)` (e.g. consecutive CliRunner.invoke calls
134
+ # in tests) starts from a clean static baseline. Without this, commands
135
+ # like `dcim` that were registered during an earlier invocation remain in
136
+ # `self.commands` and bypass the bootstrap-error path of `resolve_command`.
137
+ del app.registered_groups[_static_groups_count:]
138
+ for name in list(self.commands):
139
+ if name not in _static_command_names:
140
+ del self.commands[name]
141
+
142
+ subcommand = _first_non_option(args)
143
+
144
+ if subcommand not in _META_COMMANDS:
145
+ overrides = _extract_global_overrides(args)
146
+ debug = "--debug" in args
147
+ try:
148
+ config = load_config(default_paths().config_file)
149
+ except ConfigParseError as exc:
150
+ _invocation["error"] = exc
151
+ return super().make_context(info_name, args, parent, **extra)
152
+
153
+ state = GlobalState(overrides=overrides, config=config, debug=debug)
154
+
155
+ try:
156
+ runtime = build_runtime_context(state)
157
+ except (NoProfileError, UnknownProfileError, SchemaSourceError) as exc:
158
+ _invocation["error"] = exc
159
+ return super().make_context(info_name, args, parent, **extra)
160
+
161
+ _invocation["runtime"] = runtime
162
+ # `app` is defined after this class; by call time it is available.
163
+ register_dynamic_commands(app, runtime.command_model, lambda: runtime)
164
+ # Sync newly added Typer sub-apps into this Click group's commands
165
+ # dict. Typer builds its Click group once at invocation time from
166
+ # `app.registered_groups`; commands added inside `make_context`
167
+ # would otherwise be invisible to `resolve_command`.
168
+ for group_info in app.registered_groups:
169
+ sub = get_group_from_info(
170
+ group_info,
171
+ pretty_exceptions_short=app.pretty_exceptions_short,
172
+ rich_markup_mode=app.rich_markup_mode,
173
+ suggest_commands=app.suggest_commands,
174
+ )
175
+ if sub.name and sub.name not in self.commands:
176
+ self.commands[sub.name] = sub
177
+
178
+ return super().make_context(info_name, args, parent, **extra)
179
+
180
+ def resolve_command(
181
+ self, ctx: click.Context, args: list[str]
182
+ ) -> tuple[str | None, click.Command | None, list[str]]:
183
+ # Surface a bootstrap error when the requested command was not registered
184
+ # (because bootstrap failed). SchemaSourceError exits 3 per spec; all
185
+ # other bootstrap errors are usage errors (exit 2).
186
+ error = _invocation["error"]
187
+ if error is not None and args and args[0] not in self.commands:
188
+ if isinstance(error, SchemaSourceError):
189
+ # Exit code 3 matches EXIT_CODES[ErrorType.SCHEMA] — keep in sync.
190
+ typer.echo(f"Error: {error}", err=True)
191
+ raise typer.Exit(3)
192
+ ctx.fail(str(error))
193
+ return super().resolve_command(ctx, args)
194
+
195
+
196
+ app = typer.Typer(
197
+ name="nsc",
198
+ help="netbox-super-cli — dynamic NetBox CLI driven by the live OpenAPI schema.",
199
+ no_args_is_help=True,
200
+ add_completion=True,
201
+ cls=_BootstrappingGroup,
202
+ )
203
+
204
+
205
+ def _version_callback(value: bool) -> None:
206
+ if value:
207
+ typer.echo(f"nsc {__version__}")
208
+ raise typer.Exit()
209
+
210
+
211
+ @app.callback()
212
+ def _root(
213
+ ctx: typer.Context,
214
+ version: Annotated[
215
+ bool,
216
+ typer.Option(
217
+ "--version",
218
+ callback=_version_callback,
219
+ is_eager=True,
220
+ help="Show the nsc version and exit.",
221
+ ),
222
+ ] = False,
223
+ profile: Annotated[str | None, typer.Option("--profile")] = None,
224
+ url: Annotated[str | None, typer.Option("--url")] = None,
225
+ token: Annotated[str | None, typer.Option("--token")] = None,
226
+ insecure: Annotated[bool | None, typer.Option("--insecure/--no-insecure")] = None,
227
+ schema: Annotated[str | None, typer.Option("--schema")] = None,
228
+ output: Annotated[str | None, typer.Option("--output", "-o")] = None,
229
+ debug: Annotated[bool, typer.Option("--debug")] = False,
230
+ ) -> None:
231
+ """Root callback — global options live here."""
232
+ overrides = CLIOverrides(
233
+ profile=profile,
234
+ url=url,
235
+ token=token,
236
+ insecure=insecure,
237
+ schema_override=schema,
238
+ output=output,
239
+ )
240
+ try:
241
+ config = load_config(default_paths().config_file)
242
+ except ConfigParseError as exc:
243
+ if ctx.invoked_subcommand in ("cache", "init", "login", "profiles", "skill"):
244
+ config = Config()
245
+ else:
246
+ typer.echo(f"Error: {exc}", err=True)
247
+ raise typer.Exit(2) from exc
248
+
249
+ state = GlobalState(overrides=overrides, config=config, debug=debug)
250
+ ctx.obj = state
251
+
252
+ if ctx.invoked_subcommand in _META_COMMANDS or ctx.invoked_subcommand is None:
253
+ return
254
+
255
+ runtime = _invocation["runtime"]
256
+ if isinstance(runtime, RuntimeContext):
257
+ ctx.obj = (state, runtime)
258
+
259
+
260
+ cache_commands.register(app)
261
+ commands_dump.register(app)
262
+ config_commands.register(app)
263
+ init_commands.register(app)
264
+ login_commands.register(app)
265
+ profiles_commands.register(app)
266
+ aliases_commands.register(app)
267
+ skill_commands.register(app)
268
+
269
+ # Capture the static baseline AFTER all static sub-apps are registered so that
270
+ # make_context can restore this state at the start of every invocation.
271
+ _static_groups_count = len(app.registered_groups)
272
+ _static_command_names = frozenset(get_group(app).commands.keys())
273
+
274
+
275
+ def main() -> None:
276
+ try:
277
+ app()
278
+ except typer.Exit:
279
+ raise
280
+ except (NetBoxAPIError, NetBoxClientError) as exc:
281
+ env = map_error(exc)
282
+ code = emit_envelope(env, output_format=OutputFormat.TABLE)
283
+ raise typer.Exit(code) from exc
284
+ except Exception as exc: # catch-all to produce internal envelope
285
+ env = map_error(exc)
286
+ code = emit_envelope(env, output_format=OutputFormat.TABLE)
287
+ raise typer.Exit(code) from exc
288
+
289
+
290
+ if __name__ == "__main__":
291
+ main()
@@ -0,0 +1,159 @@
1
+ """`nsc cache` — local cache management. Phase 5a ships `prune`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from enum import StrEnum
8
+ from typing import Annotated
9
+
10
+ import httpx
11
+ import typer
12
+
13
+ from nsc.cache.store import (
14
+ CacheStore,
15
+ PrunePlan,
16
+ PruneResult,
17
+ compute_prune_plan,
18
+ prune_orphans,
19
+ )
20
+ from nsc.config.loader import ConfigParseError, load_config
21
+ from nsc.config.models import Config, Profile
22
+ from nsc.config.settings import default_paths
23
+ from nsc.schema.hashing import canonical_sha256
24
+
25
+ _PRUNE_FETCH_TIMEOUT_SECONDS = 5.0
26
+ """Per-profile timeout when fetching the live schema for stale-hash classification.
27
+ Deliberately tighter than `Config.defaults.timeout` (30s): cache prune is a fast
28
+ cleanup tool, and an unreachable profile must not stall the operation."""
29
+
30
+
31
+ class _OutputFormat(StrEnum):
32
+ TABLE = "table"
33
+ JSON = "json"
34
+
35
+
36
+ def _store() -> CacheStore:
37
+ return CacheStore(root=default_paths().cache_dir)
38
+
39
+
40
+ def _load_config_or_empty() -> Config:
41
+ try:
42
+ return load_config(default_paths().config_file)
43
+ except (FileNotFoundError, ConfigParseError):
44
+ return Config()
45
+
46
+
47
+ def _fetch_live_hash(profile: Profile, *, default_timeout: float) -> str:
48
+ url = str(profile.url).rstrip("/") + "/api/schema/?format=json"
49
+ headers = {"Accept": "application/json"}
50
+ if profile.token:
51
+ headers["Authorization"] = f"Token {profile.token}"
52
+ timeout = profile.timeout if profile.timeout is not None else default_timeout
53
+ with httpx.Client(verify=profile.verify_ssl, timeout=timeout, headers=headers) as c:
54
+ resp = c.get(url)
55
+ resp.raise_for_status()
56
+ return canonical_sha256(resp.content)
57
+
58
+
59
+ def _make_fetcher(default_timeout: float) -> Callable[[Profile], str]:
60
+ def fetcher(profile: Profile) -> str:
61
+ return _fetch_live_hash(profile, default_timeout=default_timeout)
62
+
63
+ return fetcher
64
+
65
+
66
+ def _render_table(plan: PrunePlan, mode: str, result: PruneResult | None) -> str:
67
+ lines: list[str] = []
68
+ if mode == "dry-run":
69
+ lines.append("nsc cache prune (dry-run) — pass --apply to delete")
70
+ else:
71
+ lines.append("nsc cache prune (applied)")
72
+ if plan.orphan_profile_dirs:
73
+ lines.append(" orphan profile directories:")
74
+ for d in plan.orphan_profile_dirs:
75
+ lines.append(f" {d}")
76
+ if plan.stale_hash_files:
77
+ lines.append(" stale-hash files:")
78
+ for f in plan.stale_hash_files:
79
+ lines.append(f" {f}")
80
+ if plan.aged_files:
81
+ lines.append(" aged-out files:")
82
+ for f in plan.aged_files:
83
+ lines.append(f" {f}")
84
+ if plan.total_count() == 0:
85
+ lines.append(" nothing to prune")
86
+ if mode == "dry-run":
87
+ lines.append(f" would free: {plan.total_bytes()} bytes")
88
+ elif result is not None:
89
+ lines.append(
90
+ f" freed: {result.freed_bytes} bytes "
91
+ f"({result.deleted_dirs} dir(s), {result.deleted_files} file(s))"
92
+ )
93
+ return "\n".join(lines)
94
+
95
+
96
+ def _render_json(plan: PrunePlan, mode: str, result: PruneResult | None) -> str:
97
+ payload: dict[str, object] = {
98
+ "mode": mode,
99
+ "plan": {
100
+ "orphan_profile_dirs": [str(p) for p in plan.orphan_profile_dirs],
101
+ "stale_hash_files": [str(p) for p in plan.stale_hash_files],
102
+ "aged_files": [str(p) for p in plan.aged_files],
103
+ "total_count": plan.total_count(),
104
+ },
105
+ }
106
+ if mode == "dry-run":
107
+ payload["would_free_bytes"] = plan.total_bytes()
108
+ elif result is not None:
109
+ payload["result"] = {
110
+ "deleted_dirs": result.deleted_dirs,
111
+ "deleted_files": result.deleted_files,
112
+ "freed_bytes": result.freed_bytes,
113
+ }
114
+ return json.dumps(payload)
115
+
116
+
117
+ def register(app: typer.Typer) -> None:
118
+ cache_app = typer.Typer(
119
+ name="cache",
120
+ help="Manage the on-disk command-model cache.",
121
+ no_args_is_help=True,
122
+ )
123
+
124
+ @cache_app.command("prune")
125
+ def prune_cmd(
126
+ apply_: Annotated[
127
+ bool, typer.Option("--apply", help="Actually delete (default: dry-run).")
128
+ ] = False,
129
+ max_age: Annotated[
130
+ int | None,
131
+ typer.Option(
132
+ "--max-age",
133
+ help="Also prune cache files older than N days.",
134
+ min=1,
135
+ ),
136
+ ] = None,
137
+ output: Annotated[
138
+ _OutputFormat,
139
+ typer.Option("--output", "-o", help="table|json"),
140
+ ] = _OutputFormat.TABLE,
141
+ ) -> None:
142
+ config = _load_config_or_empty()
143
+ store = _store()
144
+ fetcher = _make_fetcher(default_timeout=_PRUNE_FETCH_TIMEOUT_SECONDS)
145
+ plan = compute_prune_plan(
146
+ config=config,
147
+ store=store,
148
+ fetch_live_hash=fetcher,
149
+ max_age_days=max_age,
150
+ )
151
+ mode = "apply" if apply_ else "dry-run"
152
+ result = prune_orphans(plan) if apply_ else None
153
+
154
+ if output is _OutputFormat.JSON:
155
+ typer.echo(_render_json(plan, mode, result))
156
+ else:
157
+ typer.echo(_render_table(plan, mode, result))
158
+
159
+ app.add_typer(cache_app, name="cache")
@@ -0,0 +1,57 @@
1
+ """`nsc commands` — dump the generated CommandModel as JSON.
2
+
3
+ In Phase 1 this is the only useful subcommand. It exists so an agent or human
4
+ can see exactly what the schema-derived command tree would look like, without
5
+ needing the dynamic Typer registration that Phase 2 will add.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from enum import StrEnum
13
+
14
+ import typer
15
+
16
+ from nsc.builder.build import build_command_model
17
+ from nsc.schema.loader import SchemaLoadError, load_schema
18
+
19
+
20
+ class _Output(StrEnum):
21
+ JSON = "json"
22
+
23
+
24
+ def register(app: typer.Typer) -> None:
25
+ @app.command("commands")
26
+ def commands_dump(
27
+ schema: str = typer.Option(
28
+ ...,
29
+ "--schema",
30
+ help="Path or URL to an OpenAPI schema. Required in Phase 1.",
31
+ ),
32
+ output: _Output = typer.Option( # noqa: B008
33
+ _Output.JSON,
34
+ "--output",
35
+ "-o",
36
+ help="Output format. Phase 1 supports `json` only.",
37
+ ),
38
+ compact: bool = typer.Option(
39
+ False,
40
+ "--compact",
41
+ help="Emit a single-line JSON object instead of indented output.",
42
+ ),
43
+ ) -> None:
44
+ """Dump the schema-derived command tree."""
45
+ try:
46
+ loaded = load_schema(schema)
47
+ except SchemaLoadError as exc:
48
+ typer.echo(f"error: {exc}", err=True)
49
+ raise typer.Exit(code=2) from exc
50
+
51
+ model = build_command_model(loaded)
52
+
53
+ if output is _Output.JSON:
54
+ indent = None if compact else 2
55
+ payload = json.loads(model.model_dump_json())
56
+ json.dump(payload, sys.stdout, indent=indent, sort_keys=False)
57
+ sys.stdout.write("\n")
@@ -0,0 +1,156 @@
1
+ """`nsc config` — read/write the user's `~/.nsc/config.yaml`.
2
+
3
+ Phase 4a ships read commands (`get`, `list`, `path`) and write commands
4
+ (`set`, `unset`, `edit`). Storage is driven by `nsc/config/writer.py` and
5
+ preserves comments + `!env` tags through round trips.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ import typer
16
+ from ruamel.yaml.comments import CommentedMap
17
+
18
+ from nsc.config.settings import default_paths
19
+ from nsc.config.writer import (
20
+ ConfigWriteError,
21
+ acquire_lock,
22
+ atomic_write,
23
+ dump_round_trip,
24
+ load_round_trip,
25
+ set_path,
26
+ unset_path,
27
+ )
28
+
29
+
30
+ def _config_path() -> Path:
31
+ return default_paths().config_file
32
+
33
+
34
+ def _get_at(doc: CommentedMap, dotted: str) -> object:
35
+ cursor: object = doc
36
+ for key in [p for p in dotted.split(".") if p]:
37
+ if not isinstance(cursor, CommentedMap) or key not in cursor:
38
+ raise KeyError(dotted)
39
+ cursor = cursor[key]
40
+ return cursor
41
+
42
+
43
+ def _path_cmd() -> None:
44
+ """Print the resolved config-file path."""
45
+ typer.echo(str(_config_path()))
46
+
47
+
48
+ def _get_cmd(key: str) -> None:
49
+ """Print the value at the given dotted path."""
50
+ doc = load_round_trip(_config_path())
51
+ try:
52
+ value = _get_at(doc, key)
53
+ except KeyError:
54
+ typer.echo(f"error: no such key: {key}", err=True)
55
+ raise typer.Exit(code=1) from None
56
+ if isinstance(value, CommentedMap):
57
+ typer.echo(dump_round_trip(value), nl=False)
58
+ else:
59
+ typer.echo(str(value))
60
+
61
+
62
+ def _list_cmd() -> None:
63
+ """Print the entire config file."""
64
+ doc = load_round_trip(_config_path())
65
+ typer.echo(dump_round_trip(doc), nl=False)
66
+
67
+
68
+ def _set_cmd(key: str, value: str) -> None:
69
+ """Set the value at the given dotted path. Creates parents as needed."""
70
+ path = _config_path()
71
+ with acquire_lock(path):
72
+ doc = load_round_trip(path)
73
+ try:
74
+ set_path(doc, key, value)
75
+ except ConfigWriteError as exc:
76
+ typer.echo(f"error: {exc}", err=True)
77
+ raise typer.Exit(code=1) from exc
78
+ atomic_write(path, dump_round_trip(doc))
79
+
80
+
81
+ def _unset_cmd(key: str) -> None:
82
+ """Remove the leaf at the given dotted path. Prunes empty parents."""
83
+ path = _config_path()
84
+ with acquire_lock(path):
85
+ doc = load_round_trip(path)
86
+ try:
87
+ unset_path(doc, key)
88
+ except ConfigWriteError as exc:
89
+ typer.echo(f"error: {exc}", err=True)
90
+ raise typer.Exit(code=1) from exc
91
+ atomic_write(path, dump_round_trip(doc))
92
+
93
+
94
+ def _edit_cmd() -> None:
95
+ """Open the config file in $EDITOR."""
96
+ path = _config_path()
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ if not path.exists():
99
+ atomic_write(path, "")
100
+ editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")
101
+ if not editor:
102
+ editor = shutil.which("vi") or shutil.which("nano")
103
+ if not editor:
104
+ typer.echo(
105
+ "error: $EDITOR not set and no fallback editor (vi/nano) found",
106
+ err=True,
107
+ )
108
+ raise typer.Exit(code=1)
109
+ subprocess.run([editor, str(path)], check=True)
110
+
111
+
112
+ def register(app: typer.Typer) -> None:
113
+ config_app = typer.Typer(
114
+ name="config",
115
+ help="Read and edit ~/.nsc/config.yaml.",
116
+ no_args_is_help=True,
117
+ )
118
+
119
+ @config_app.command("path")
120
+ def path_cmd() -> None:
121
+ """Print the resolved config-file path."""
122
+ _path_cmd()
123
+
124
+ @config_app.command("get")
125
+ def get_cmd(
126
+ key: str = typer.Argument(..., help="Dotted path, e.g. profiles.prod.url"),
127
+ ) -> None:
128
+ """Print the value at the given dotted path."""
129
+ _get_cmd(key)
130
+
131
+ @config_app.command("list")
132
+ def list_cmd() -> None:
133
+ """Print the entire config file."""
134
+ _list_cmd()
135
+
136
+ @config_app.command("set")
137
+ def set_cmd(
138
+ key: str = typer.Argument(..., help="Dotted path"),
139
+ value: str = typer.Argument(..., help="Value (string)"),
140
+ ) -> None:
141
+ """Set the value at the given dotted path. Creates parents as needed."""
142
+ _set_cmd(key, value)
143
+
144
+ @config_app.command("unset")
145
+ def unset_cmd(
146
+ key: str = typer.Argument(..., help="Dotted path to remove"),
147
+ ) -> None:
148
+ """Remove the leaf at the given dotted path. Prunes empty parents."""
149
+ _unset_cmd(key)
150
+
151
+ @config_app.command("edit")
152
+ def edit_cmd() -> None:
153
+ """Open the config file in $EDITOR."""
154
+ _edit_cmd()
155
+
156
+ app.add_typer(config_app)