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
@@ -0,0 +1,95 @@
1
+ """`nsc init` — first-run wizard.
2
+
3
+ Prompts for a profile name, URL, and token storage mode, then writes a minimal
4
+ `~/.nsc/config.yaml`. Refuses to clobber an existing non-empty config (spec §4.2);
5
+ the user should `nsc login --new --profile <name>` to add a profile to an
6
+ existing config instead.
7
+
8
+ `init` is offline-safe: it does not call `verify()` — that is `login`'s job.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import typer
16
+ from ruamel.yaml.comments import CommentedMap, TaggedScalar
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
+ )
26
+
27
+
28
+ def _config_path() -> Path:
29
+ return default_paths().config_file
30
+
31
+
32
+ def _existing_is_empty(path: Path) -> bool:
33
+ """True only when the file parses as an empty mapping; malformed → False.
34
+
35
+ A malformed pre-existing config is more worth refusing to clobber, not less —
36
+ so any parse failure is treated as 'present and non-empty.'
37
+ """
38
+ try:
39
+ existing = load_round_trip(path)
40
+ except ConfigWriteError:
41
+ return False
42
+ return len(existing) == 0
43
+
44
+
45
+ def _build_doc(
46
+ profile_name: str,
47
+ url: str,
48
+ token_value: object,
49
+ ) -> CommentedMap:
50
+ """Produce the minimal config doc for a fresh init run."""
51
+ profiles = CommentedMap()
52
+ profile = CommentedMap()
53
+ profile["url"] = url
54
+ profile["token"] = token_value
55
+ profiles[profile_name] = profile
56
+
57
+ doc = CommentedMap()
58
+ doc["default_profile"] = profile_name
59
+ doc["profiles"] = profiles
60
+ return doc
61
+
62
+
63
+ def _env_tagged(var_name: str) -> object:
64
+ """Build a `!env <VARNAME>` scalar that ruamel will emit with its tag intact."""
65
+ return TaggedScalar(value=var_name, style=None, tag="!env")
66
+
67
+
68
+ def register(app: typer.Typer) -> None:
69
+ @app.command("init", help="First-run wizard — create ~/.nsc/config.yaml.")
70
+ def init_cmd() -> None:
71
+ path = _config_path()
72
+ if path.exists() and not _existing_is_empty(path):
73
+ typer.echo(
74
+ f"error: config already exists at {path}; use `nsc login --new` to add a profile.",
75
+ err=True,
76
+ )
77
+ raise typer.Exit(code=12)
78
+
79
+ profile_name = typer.prompt("Profile name", default="default")
80
+ url = typer.prompt("NetBox URL (e.g. https://netbox.example.com/)")
81
+ storage = typer.prompt("Token storage [plaintext|env]", default="plaintext").strip().lower()
82
+ token_value: object
83
+ if storage == "env":
84
+ var_name = typer.prompt("Environment variable name (e.g. NSC_PROD_TOKEN)")
85
+ token_value = _env_tagged(var_name.strip())
86
+ else:
87
+ token_value = typer.prompt("Token", hide_input=True)
88
+
89
+ doc = _build_doc(profile_name.strip(), url.strip(), token_value)
90
+
91
+ with acquire_lock(path):
92
+ atomic_write(path, dump_round_trip(doc))
93
+
94
+ typer.echo(f"wrote {path}")
95
+ typer.echo(f"next: nsc login --profile {profile_name.strip()}")
@@ -0,0 +1,265 @@
1
+ """`nsc login` — verify / create / rotate a profile's token.
2
+
3
+ Three modes (mutually exclusive):
4
+
5
+ * bare or `--profile <name>` — verify an existing profile against the live NetBox.
6
+ * `--new --profile <name>` — create a new profile entry, then verify.
7
+ * `--rotate --profile <name>` — prompt for a new token, verify, replace in storage.
8
+
9
+ All three end with `verify()`; only success persists changes (for `--rotate`)
10
+ or returns 0 (for bare). Cache is never touched.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Annotated
17
+
18
+ import typer
19
+ from ruamel.yaml.comments import CommentedMap, TaggedScalar
20
+
21
+ from nsc.auth.verify import VerifyError, verify
22
+ from nsc.cli.runtime import emit_envelope
23
+ from nsc.config.loader import ConfigParseError, load_config
24
+ from nsc.config.models import OutputFormat, Profile
25
+ from nsc.config.settings import default_paths
26
+ from nsc.config.writer import (
27
+ acquire_lock,
28
+ atomic_write,
29
+ dump_round_trip,
30
+ load_round_trip,
31
+ )
32
+ from nsc.output.errors import ErrorEnvelope, ErrorType
33
+
34
+
35
+ def _config_path() -> Path:
36
+ return default_paths().config_file
37
+
38
+
39
+ def _env_tagged(var_name: str) -> object:
40
+ """Build a `!env <VARNAME>` scalar that ruamel will emit with its tag intact."""
41
+ return TaggedScalar(value=var_name, style=None, tag="!env")
42
+
43
+
44
+ def _emit_auth_envelope(
45
+ message: str,
46
+ *,
47
+ status_code: int | None,
48
+ user_check_status: int | None,
49
+ ) -> int:
50
+ details: dict[str, object] = {"reason": "rejected"}
51
+ if user_check_status is not None:
52
+ details["user_check_status"] = user_check_status
53
+ env = ErrorEnvelope(
54
+ error=message,
55
+ type=ErrorType.AUTH,
56
+ status_code=status_code,
57
+ details=details,
58
+ )
59
+ return emit_envelope(env, output_format=OutputFormat.TABLE)
60
+
61
+
62
+ def _emit_config_envelope(message: str) -> int:
63
+ env = ErrorEnvelope(error=message, type=ErrorType.CONFIG)
64
+ return emit_envelope(env, output_format=OutputFormat.TABLE)
65
+
66
+
67
+ def _resolved_profile(name: str) -> Profile:
68
+ """Return the validated `Profile` for `name`, raising on missing/invalid config."""
69
+ try:
70
+ config = load_config(_config_path())
71
+ except ConfigParseError as exc:
72
+ raise typer.BadParameter(str(exc)) from exc
73
+ if name not in config.profiles:
74
+ raise typer.BadParameter(f"profile {name!r} not in config")
75
+ return config.profiles[name]
76
+
77
+
78
+ def _ensure_profile_exists_in_doc(doc: CommentedMap, name: str) -> CommentedMap:
79
+ profiles = doc.get("profiles")
80
+ if not isinstance(profiles, CommentedMap):
81
+ profiles = CommentedMap()
82
+ doc["profiles"] = profiles
83
+ if name not in profiles:
84
+ profiles[name] = CommentedMap()
85
+ entry = profiles[name]
86
+ assert isinstance(entry, CommentedMap)
87
+ return entry
88
+
89
+
90
+ def _write_profile_entry(*, profile: str, url: str, token_value: object, set_default: bool) -> None:
91
+ path = _config_path()
92
+ with acquire_lock(path):
93
+ doc = load_round_trip(path)
94
+ if set_default and "default_profile" not in doc:
95
+ doc["default_profile"] = profile
96
+ entry = _ensure_profile_exists_in_doc(doc, profile)
97
+ entry["url"] = url
98
+ entry["token"] = token_value
99
+ atomic_write(path, dump_round_trip(doc))
100
+
101
+
102
+ def _replace_token(profile: str, token_value: object) -> None:
103
+ path = _config_path()
104
+ with acquire_lock(path):
105
+ doc = load_round_trip(path)
106
+ profiles = doc.get("profiles")
107
+ if not isinstance(profiles, CommentedMap) or profile not in profiles:
108
+ raise typer.BadParameter(f"profile {profile!r} not in config")
109
+ entry = profiles[profile]
110
+ if not isinstance(entry, CommentedMap):
111
+ raise typer.BadParameter(f"profile {profile!r} is not a mapping")
112
+ entry["token"] = token_value
113
+ atomic_write(path, dump_round_trip(doc))
114
+
115
+
116
+ def _print_success(username: str, version: str) -> None:
117
+ typer.echo(f"✓ authenticated as {username}, NetBox {version}")
118
+
119
+
120
+ def register(app: typer.Typer) -> None:
121
+ @app.command("login", help="Verify / create / rotate a profile's token.")
122
+ def login_cmd(
123
+ profile: Annotated[str | None, typer.Option("--profile")] = None,
124
+ new: Annotated[bool, typer.Option("--new", help="Create a new profile.")] = False,
125
+ rotate: Annotated[
126
+ bool, typer.Option("--rotate", help="Replace an existing profile's token.")
127
+ ] = False,
128
+ url: Annotated[str | None, typer.Option("--url")] = None,
129
+ store: Annotated[
130
+ str, typer.Option("--store", help="Token storage: plaintext|env.")
131
+ ] = "plaintext",
132
+ env_var: Annotated[
133
+ str | None,
134
+ typer.Option(
135
+ "--env-var",
136
+ help="Environment variable name (required when --store=env).",
137
+ ),
138
+ ] = None,
139
+ ) -> None:
140
+ if new and rotate:
141
+ raise typer.BadParameter("--new and --rotate are mutually exclusive")
142
+
143
+ if new:
144
+ _do_login_new(profile, url, store, env_var)
145
+ return
146
+ if rotate:
147
+ _do_login_rotate(profile, store, env_var)
148
+ return
149
+ _do_login_verify(profile)
150
+
151
+
152
+ def _do_login_verify(profile_name: str | None) -> None:
153
+ try:
154
+ config = load_config(_config_path())
155
+ except ConfigParseError as exc:
156
+ code = _emit_config_envelope(str(exc))
157
+ raise typer.Exit(code=code) from exc
158
+ name = profile_name or config.default_profile
159
+ if name is None:
160
+ code = _emit_config_envelope("no profile selected and no default_profile set in config")
161
+ raise typer.Exit(code=code)
162
+ if name not in config.profiles:
163
+ code = _emit_config_envelope(f"profile {name!r} not in config")
164
+ raise typer.Exit(code=code)
165
+ profile = config.profiles[name]
166
+ try:
167
+ result = verify(profile)
168
+ except VerifyError as exc:
169
+ code = _emit_auth_envelope(
170
+ str(exc),
171
+ status_code=exc.status_code,
172
+ user_check_status=exc.user_check_status,
173
+ )
174
+ raise typer.Exit(code=code) from exc
175
+ _print_success(result.username, result.netbox_version)
176
+
177
+
178
+ def _do_login_new(
179
+ profile_name: str | None,
180
+ url: str | None,
181
+ store: str,
182
+ env_var: str | None,
183
+ ) -> None:
184
+ if profile_name is None:
185
+ raise typer.BadParameter("--new requires --profile <name>")
186
+ if url is None:
187
+ raise typer.BadParameter("--new requires --url <url>")
188
+ try:
189
+ existing_config = load_config(_config_path())
190
+ except ConfigParseError:
191
+ existing_config = None
192
+ if existing_config and profile_name in existing_config.profiles:
193
+ typer.echo(
194
+ f"error: profile {profile_name!r} already exists; "
195
+ f"use `nsc login --rotate --profile {profile_name}` to replace its token.",
196
+ err=True,
197
+ )
198
+ raise typer.Exit(code=12)
199
+ token_input = typer.prompt("Token", hide_input=True)
200
+ token_for_verify = token_input.strip()
201
+ token_value: object
202
+ if store.lower() == "env":
203
+ if not env_var:
204
+ raise typer.BadParameter("--store=env requires --env-var <NAME>")
205
+ token_value = _env_tagged(env_var)
206
+ else:
207
+ token_value = token_for_verify
208
+
209
+ candidate = Profile(name=profile_name, url=url, token=token_for_verify) # type: ignore[arg-type]
210
+ try:
211
+ result = verify(candidate)
212
+ except VerifyError as exc:
213
+ code = _emit_auth_envelope(
214
+ str(exc),
215
+ status_code=exc.status_code,
216
+ user_check_status=exc.user_check_status,
217
+ )
218
+ raise typer.Exit(code=code) from exc
219
+ set_default = (existing_config is None) or (existing_config.default_profile is None)
220
+ _write_profile_entry(
221
+ profile=profile_name,
222
+ url=url,
223
+ token_value=token_value,
224
+ set_default=set_default,
225
+ )
226
+ _print_success(result.username, result.netbox_version)
227
+
228
+
229
+ def _do_login_rotate(
230
+ profile_name: str | None,
231
+ store: str,
232
+ env_var: str | None,
233
+ ) -> None:
234
+ if profile_name is None:
235
+ raise typer.BadParameter("--rotate requires --profile <name>")
236
+ profile = _resolved_profile(profile_name)
237
+ new_token = typer.prompt("New token", hide_input=True).strip()
238
+
239
+ candidate = Profile(
240
+ name=profile.name,
241
+ url=profile.url,
242
+ token=new_token,
243
+ verify_ssl=profile.verify_ssl,
244
+ schema_url=profile.schema_url,
245
+ timeout=profile.timeout,
246
+ )
247
+ try:
248
+ result = verify(candidate)
249
+ except VerifyError as exc:
250
+ code = _emit_auth_envelope(
251
+ str(exc),
252
+ status_code=exc.status_code,
253
+ user_check_status=exc.user_check_status,
254
+ )
255
+ raise typer.Exit(code=code) from exc
256
+
257
+ token_value: object
258
+ if store.lower() == "env":
259
+ if not env_var:
260
+ raise typer.BadParameter("--store=env requires --env-var <NAME>")
261
+ token_value = _env_tagged(env_var)
262
+ else:
263
+ token_value = new_token
264
+ _replace_token(profile_name, token_value)
265
+ _print_success(result.username, result.netbox_version)
@@ -0,0 +1,256 @@
1
+ """`nsc profiles` — list / add / remove / rename / set-default.
2
+
3
+ Operates on the same `~/.nsc/config.yaml` as `nsc config` and the same on-disk
4
+ cache as the dynamic-tree handlers. `add` runs `verify()` before persisting;
5
+ `remove` purges the cache; `rename` moves the cache directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from enum import StrEnum
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import typer
16
+ from ruamel.yaml.comments import CommentedMap
17
+
18
+ from nsc.auth.verify import VerifyError, verify
19
+ from nsc.cache.store import CacheStore
20
+ from nsc.cli.runtime import emit_envelope
21
+ from nsc.config.loader import ConfigParseError, load_config
22
+ from nsc.config.models import OutputFormat, Profile
23
+ from nsc.config.settings import default_paths
24
+ from nsc.config.writer import (
25
+ acquire_lock,
26
+ atomic_write,
27
+ dump_round_trip,
28
+ load_round_trip,
29
+ )
30
+ from nsc.output.errors import ErrorEnvelope, ErrorType
31
+
32
+
33
+ class _ListFormat(StrEnum):
34
+ TABLE = "table"
35
+ JSON = "json"
36
+
37
+
38
+ def _config_path() -> Path:
39
+ return default_paths().config_file
40
+
41
+
42
+ def _cache() -> CacheStore:
43
+ return CacheStore(root=default_paths().cache_dir)
44
+
45
+
46
+ def _emit_auth_envelope(
47
+ message: str, *, status_code: int | None, user_check_status: int | None
48
+ ) -> int:
49
+ details: dict[str, object] = {"reason": "rejected"}
50
+ if user_check_status is not None:
51
+ details["user_check_status"] = user_check_status
52
+ env = ErrorEnvelope(
53
+ error=message,
54
+ type=ErrorType.AUTH,
55
+ status_code=status_code,
56
+ details=details,
57
+ )
58
+ return emit_envelope(env, output_format=OutputFormat.TABLE)
59
+
60
+
61
+ def _emit_config_envelope(message: str) -> int:
62
+ env = ErrorEnvelope(error=message, type=ErrorType.CONFIG)
63
+ return emit_envelope(env, output_format=OutputFormat.TABLE)
64
+
65
+
66
+ def register(app: typer.Typer) -> None:
67
+ profiles_app = typer.Typer(
68
+ name="profiles",
69
+ help="Manage profiles in ~/.nsc/config.yaml.",
70
+ no_args_is_help=True,
71
+ )
72
+
73
+ @profiles_app.command("list")
74
+ def list_cmd(
75
+ output: Annotated[
76
+ _ListFormat,
77
+ typer.Option("--output", "-o", help="table|json"),
78
+ ] = _ListFormat.TABLE,
79
+ ) -> None:
80
+ _do_list(output)
81
+
82
+ @profiles_app.command("add")
83
+ def add_cmd(
84
+ name: str = typer.Argument(...),
85
+ url: str = typer.Option(..., "--url"),
86
+ token: str = typer.Option(..., "--token"),
87
+ ) -> None:
88
+ _do_add(name, url, token)
89
+
90
+ @profiles_app.command("remove")
91
+ def remove_cmd(
92
+ name: str = typer.Argument(...),
93
+ force: bool = typer.Option(False, "--force"),
94
+ ) -> None:
95
+ _do_remove(name, force=force)
96
+
97
+ @profiles_app.command("rename")
98
+ def rename_cmd(
99
+ old: str = typer.Argument(...),
100
+ new: str = typer.Argument(...),
101
+ ) -> None:
102
+ _do_rename(old, new)
103
+
104
+ @profiles_app.command("set-default")
105
+ def set_default_cmd(name: str = typer.Argument(...)) -> None:
106
+ _do_set_default(name)
107
+
108
+ app.add_typer(profiles_app)
109
+
110
+
111
+ def _load_doc() -> CommentedMap:
112
+ path = _config_path()
113
+ doc = load_round_trip(path)
114
+ if "profiles" not in doc:
115
+ doc["profiles"] = CommentedMap()
116
+ return doc
117
+
118
+
119
+ def _do_list(output: _ListFormat) -> None:
120
+ try:
121
+ config = load_config(_config_path())
122
+ except ConfigParseError as exc:
123
+ code = _emit_config_envelope(str(exc))
124
+ raise typer.Exit(code=code) from exc
125
+
126
+ if output is _ListFormat.JSON:
127
+ payload = {
128
+ "default": config.default_profile,
129
+ "profiles": [{"name": p.name, "url": str(p.url)} for p in config.profiles.values()],
130
+ }
131
+ typer.echo(json.dumps(payload, indent=2))
132
+ return
133
+
134
+ if not config.profiles:
135
+ typer.echo("(no profiles configured)")
136
+ return
137
+ for name, profile in config.profiles.items():
138
+ marker = "*" if name == config.default_profile else " "
139
+ typer.echo(f"{marker} {name}\t{profile.url}")
140
+
141
+
142
+ def _do_add(name: str, url: str, token: str) -> None:
143
+ path = _config_path()
144
+ try:
145
+ existing = load_config(path)
146
+ except ConfigParseError:
147
+ existing = None
148
+ if existing and name in existing.profiles:
149
+ code = _emit_config_envelope(
150
+ f"profile {name!r} already exists; use `nsc login --rotate --profile {name}` "
151
+ f"to replace its token."
152
+ )
153
+ raise typer.Exit(code=code)
154
+
155
+ candidate = Profile(name=name, url=url, token=token) # type: ignore[arg-type]
156
+ try:
157
+ result = verify(candidate)
158
+ except VerifyError as exc:
159
+ code = _emit_auth_envelope(
160
+ str(exc),
161
+ status_code=exc.status_code,
162
+ user_check_status=exc.user_check_status,
163
+ )
164
+ raise typer.Exit(code=code) from exc
165
+
166
+ with acquire_lock(path):
167
+ doc = _load_doc()
168
+ if existing is None or existing.default_profile is None:
169
+ doc.setdefault("default_profile", name)
170
+ profiles = doc["profiles"]
171
+ entry = CommentedMap()
172
+ entry["url"] = url
173
+ entry["token"] = token
174
+ profiles[name] = entry
175
+ atomic_write(path, dump_round_trip(doc))
176
+ typer.echo(f"✓ added profile {name!r}; authenticated as {result.username}")
177
+
178
+
179
+ def _do_remove(name: str, *, force: bool) -> None:
180
+ path = _config_path()
181
+ try:
182
+ config = load_config(path)
183
+ except ConfigParseError as exc:
184
+ code = _emit_config_envelope(str(exc))
185
+ raise typer.Exit(code=code) from exc
186
+ if name not in config.profiles:
187
+ code = _emit_config_envelope(f"profile {name!r} not in config")
188
+ raise typer.Exit(code=code)
189
+ if config.default_profile == name and not force:
190
+ code = _emit_config_envelope(
191
+ f"refusing to remove default profile {name!r}; "
192
+ f"`nsc profiles set-default <other>` first, or pass --force."
193
+ )
194
+ raise typer.Exit(code=code)
195
+
196
+ with acquire_lock(path):
197
+ doc = _load_doc()
198
+ profiles = doc.get("profiles") or CommentedMap()
199
+ if name in profiles:
200
+ del profiles[name]
201
+ if doc.get("default_profile") == name:
202
+ del doc["default_profile"]
203
+ atomic_write(path, dump_round_trip(doc))
204
+ _cache().purge(name)
205
+ typer.echo(f"✓ removed profile {name!r}")
206
+
207
+
208
+ def _do_rename(old: str, new: str) -> None:
209
+ path = _config_path()
210
+ try:
211
+ config = load_config(path)
212
+ except ConfigParseError as exc:
213
+ code = _emit_config_envelope(str(exc))
214
+ raise typer.Exit(code=code) from exc
215
+ if old not in config.profiles:
216
+ code = _emit_config_envelope(f"profile {old!r} not in config")
217
+ raise typer.Exit(code=code)
218
+ if new in config.profiles:
219
+ code = _emit_config_envelope(f"profile {new!r} already exists")
220
+ raise typer.Exit(code=code)
221
+
222
+ with acquire_lock(path):
223
+ doc = _load_doc()
224
+ profiles = doc["profiles"]
225
+ new_profiles = CommentedMap()
226
+ for k, v in profiles.items():
227
+ new_profiles[new if k == old else k] = v
228
+ doc["profiles"] = new_profiles
229
+ if doc.get("default_profile") == old:
230
+ doc["default_profile"] = new
231
+ atomic_write(path, dump_round_trip(doc))
232
+ try:
233
+ _cache().move(old, new)
234
+ except FileExistsError as exc:
235
+ typer.echo(
236
+ f"warning: cache for {new!r} already exists ({exc}); skipping cache move",
237
+ err=True,
238
+ )
239
+ typer.echo(f"✓ renamed profile {old!r} → {new!r}")
240
+
241
+
242
+ def _do_set_default(name: str) -> None:
243
+ path = _config_path()
244
+ try:
245
+ config = load_config(path)
246
+ except ConfigParseError as exc:
247
+ code = _emit_config_envelope(str(exc))
248
+ raise typer.Exit(code=code) from exc
249
+ if name not in config.profiles:
250
+ code = _emit_config_envelope(f"profile {name!r} not in config")
251
+ raise typer.Exit(code=code)
252
+ with acquire_lock(path):
253
+ doc = _load_doc()
254
+ doc["default_profile"] = name
255
+ atomic_write(path, dump_round_trip(doc))
256
+ typer.echo(f"✓ default_profile = {name}")