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/cache/store.py ADDED
@@ -0,0 +1,295 @@
1
+ """Persist generated CommandModels under `~/.nsc/cache/<profile>/<hash>.json`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ import re
8
+ import shutil
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from nsc.config.models import Config, Profile
15
+ from nsc.model.command_model import CommandModel
16
+
17
+ _LOG = logging.getLogger(__name__)
18
+ _PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
19
+ _HASH_RE = re.compile(r"^[0-9a-f]{64}$")
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class CacheEntry:
24
+ profile: str
25
+ schema_hash: str
26
+ path: Path
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class PrunePlan:
31
+ orphan_profile_dirs: list[Path]
32
+ stale_hash_files: list[Path]
33
+ aged_files: list[Path]
34
+
35
+ def total_count(self) -> int:
36
+ return len(self.orphan_profile_dirs) + len(self.stale_hash_files) + len(self.aged_files)
37
+
38
+ def total_bytes(self) -> int:
39
+ total = 0
40
+ for d in self.orphan_profile_dirs:
41
+ for f in d.rglob("*"):
42
+ if f.is_file():
43
+ total += f.stat().st_size
44
+ for f in (*self.stale_hash_files, *self.aged_files):
45
+ if f.exists():
46
+ total += f.stat().st_size
47
+ return total
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class PruneResult:
52
+ deleted_dirs: int
53
+ deleted_files: int
54
+ freed_bytes: int
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class CacheStore:
59
+ root: Path
60
+
61
+ def load(self, profile: str, schema_hash: str) -> CommandModel | None:
62
+ self._validate_profile(profile)
63
+ if not _HASH_RE.match(schema_hash):
64
+ return None
65
+ target = self._path_for(profile, schema_hash)
66
+ if not target.exists():
67
+ return None
68
+ try:
69
+ text = target.read_text()
70
+ model = CommandModel.model_validate_json(text)
71
+ except Exception as exc: # broad: corrupt JSON, schema mismatch, etc.
72
+ _LOG.warning("cache: ignoring corrupt entry %s (%s)", target, exc)
73
+ return None
74
+ if model.schema_hash != schema_hash:
75
+ _LOG.warning("cache: hash mismatch for %s (file says %s)", target, model.schema_hash)
76
+ return None
77
+ return model
78
+
79
+ def save(self, profile: str, model: CommandModel) -> Path:
80
+ self._validate_profile(profile)
81
+ target = self._path_for(profile, model.schema_hash)
82
+ target.parent.mkdir(parents=True, exist_ok=True)
83
+ target.write_text(model.model_dump_json(indent=2))
84
+ return target
85
+
86
+ def clear(self, *, profile: str | None = None) -> None:
87
+ if profile is None:
88
+ if self.root.exists():
89
+ shutil.rmtree(self.root)
90
+ return
91
+ self._validate_profile(profile)
92
+ target = self.root / profile
93
+ if target.exists():
94
+ shutil.rmtree(target)
95
+
96
+ def move(self, old: str, new: str) -> None:
97
+ """Rename a profile's cache directory from `old` to `new`.
98
+
99
+ No-op when `old` does not exist (the profile was never warmed). Raises
100
+ `FileExistsError` when `new` already exists — the caller must purge
101
+ the target first if that's the intent. Both names are validated to
102
+ prevent path-component injection (matches `_PROFILE_RE`).
103
+ """
104
+ self._validate_profile(old)
105
+ self._validate_profile(new)
106
+ src = self.root / old
107
+ dst = self.root / new
108
+ if not src.exists():
109
+ return
110
+ if dst.exists():
111
+ raise FileExistsError(f"cache directory for profile {new!r} already exists at {dst}")
112
+ dst.parent.mkdir(parents=True, exist_ok=True)
113
+ src.rename(dst)
114
+
115
+ def purge(self, profile: str) -> None:
116
+ """Remove a profile's cache directory entirely. No-op if missing."""
117
+ self._validate_profile(profile)
118
+ target = self.root / profile
119
+ if target.exists():
120
+ shutil.rmtree(target)
121
+
122
+ def enumerate_caches(self) -> list[CacheEntry]:
123
+ """Walk the cache root and return one CacheEntry per valid <profile>/<hash>.json file.
124
+
125
+ Silently skips:
126
+ - Entries in `self.root` that aren't directories.
127
+ - Directories whose names don't match `_PROFILE_RE`.
128
+ - Files whose stems don't match `_HASH_RE` or whose suffix isn't `.json`.
129
+ """
130
+ if not self.root.exists():
131
+ return []
132
+ entries: list[CacheEntry] = []
133
+ for profile_dir in self.root.iterdir():
134
+ if not profile_dir.is_dir():
135
+ continue
136
+ if not _PROFILE_RE.match(profile_dir.name):
137
+ continue
138
+ for cache_file in profile_dir.iterdir():
139
+ if cache_file.suffix != ".json":
140
+ continue
141
+ stem = cache_file.stem
142
+ if not _HASH_RE.match(stem):
143
+ continue
144
+ entries.append(
145
+ CacheEntry(profile=profile_dir.name, schema_hash=stem, path=cache_file)
146
+ )
147
+ return entries
148
+
149
+ def _path_for(self, profile: str, schema_hash: str) -> Path:
150
+ return self.root / profile / f"{schema_hash}.json"
151
+
152
+ @staticmethod
153
+ def _validate_profile(profile: str) -> None:
154
+ if not _PROFILE_RE.match(profile):
155
+ raise ValueError(f"invalid profile name {profile!r}: must match {_PROFILE_RE.pattern}")
156
+
157
+
158
+ ADHOC_PROFILE = "adhoc"
159
+ """Cache subdirectory name used by `nsc/cli/runtime.py` for env-var-only
160
+ invocations (no profile in config). The prune logic must never delete it."""
161
+
162
+
163
+ def _find_orphan_dirs(entries: list[CacheEntry], profile_names: set[str]) -> list[Path]:
164
+ """Type A: return one Path per profile directory not in config (adhoc excluded)."""
165
+ orphan_dirs: list[Path] = []
166
+ seen_dirs: set[Path] = set()
167
+ for entry in entries:
168
+ if entry.profile == ADHOC_PROFILE or entry.profile in profile_names:
169
+ continue
170
+ d = entry.path.parent
171
+ if d not in seen_dirs:
172
+ seen_dirs.add(d)
173
+ orphan_dirs.append(d)
174
+ return orphan_dirs
175
+
176
+
177
+ def _find_stale_files(
178
+ entries: list[CacheEntry],
179
+ config: Config,
180
+ fetch_live_hash: Callable[[Profile], str],
181
+ ) -> list[Path]:
182
+ """Type B: return files whose hash doesn't match the live hash; skip on fetcher error."""
183
+ stale_files: list[Path] = []
184
+ for name in config.profiles:
185
+ try:
186
+ live_hash = fetch_live_hash(config.profiles[name])
187
+ except Exception: # tolerated per-profile; caller skips unreachable instances
188
+ continue
189
+ for entry in entries:
190
+ if entry.profile == name and entry.schema_hash != live_hash:
191
+ stale_files.append(entry.path)
192
+ return stale_files
193
+
194
+
195
+ def _find_aged_files(
196
+ entries: list[CacheEntry],
197
+ orphan_dirs: list[Path],
198
+ cutoff: float,
199
+ ) -> list[Path]:
200
+ """Type C: return files older than `cutoff`, excluding those inside orphan dirs."""
201
+ orphan_paths = set(orphan_dirs)
202
+ aged: list[Path] = []
203
+ for entry in entries:
204
+ if entry.path.parent in orphan_paths:
205
+ continue
206
+ try:
207
+ mtime = entry.path.stat().st_mtime
208
+ except FileNotFoundError:
209
+ continue
210
+ if mtime < cutoff:
211
+ aged.append(entry.path)
212
+ return aged
213
+
214
+
215
+ def compute_prune_plan(
216
+ *,
217
+ config: Config,
218
+ store: CacheStore,
219
+ fetch_live_hash: Callable[[Profile], str] | None = None,
220
+ max_age_days: int | None = None,
221
+ now: float | None = None,
222
+ ) -> PrunePlan:
223
+ """Classify cache entries into the three prune categories.
224
+
225
+ Type A (orphan_profile_dirs): a profile directory whose name is not in
226
+ `config.profiles` (and is not the `adhoc` sentinel).
227
+
228
+ Type B (stale_hash_files): for an *active* profile, files whose
229
+ `<schema_hash>` does not match the live schema's current hash.
230
+ Requires `fetch_live_hash`. If the fetcher raises for a given profile,
231
+ type B is silently skipped for that profile only.
232
+
233
+ Type C (aged_files): files whose mtime is older than `max_age_days`.
234
+ Independent of A and B but deduplicated against A: a file inside an
235
+ orphan profile dir is reported under A only (the rmtree handles it).
236
+ """
237
+ entries = store.enumerate_caches()
238
+
239
+ orphan_dirs = _find_orphan_dirs(entries, set(config.profiles.keys()))
240
+
241
+ stale_files = (
242
+ _find_stale_files(entries, config, fetch_live_hash) if fetch_live_hash is not None else []
243
+ )
244
+
245
+ aged = (
246
+ _find_aged_files(
247
+ entries,
248
+ orphan_dirs,
249
+ (now if now is not None else time.time()) - max_age_days * 86400,
250
+ )
251
+ if max_age_days is not None
252
+ else []
253
+ )
254
+
255
+ return PrunePlan(
256
+ orphan_profile_dirs=orphan_dirs,
257
+ stale_hash_files=stale_files,
258
+ aged_files=aged,
259
+ )
260
+
261
+
262
+ def prune_orphans(plan: PrunePlan) -> PruneResult:
263
+ """Delete every path in the plan; tolerant of paths deleted out-of-band.
264
+
265
+ Returns counts + freed bytes for output rendering. Bytes are measured
266
+ BEFORE deletion using `Path.stat()`; if a file vanished between
267
+ classification and apply, it contributes 0 bytes and 0 deletions.
268
+ """
269
+ deleted_dirs = 0
270
+ deleted_files = 0
271
+ freed = 0
272
+
273
+ for d in plan.orphan_profile_dirs:
274
+ if not d.exists():
275
+ continue
276
+ for f in d.rglob("*"):
277
+ if f.is_file():
278
+ with contextlib.suppress(FileNotFoundError):
279
+ freed += f.stat().st_size
280
+ shutil.rmtree(d)
281
+ deleted_dirs += 1
282
+
283
+ for f in (*plan.stale_hash_files, *plan.aged_files):
284
+ if not f.exists():
285
+ continue
286
+ with contextlib.suppress(FileNotFoundError):
287
+ freed += f.stat().st_size
288
+ f.unlink()
289
+ deleted_files += 1
290
+
291
+ return PruneResult(
292
+ deleted_dirs=deleted_dirs,
293
+ deleted_files=deleted_files,
294
+ freed_bytes=freed,
295
+ )
nsc/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Typer-based command-line interface."""
@@ -0,0 +1,264 @@
1
+ """`nsc ls / get / rm / search` — curated aliases over the dynamic command tree.
2
+
3
+ Each verb resolves its term to a `(tag, resource, operation)` triple via
4
+ `nsc.aliases.resolve` and delegates to the same handler the dynamic tree
5
+ uses. The audit log is written by the handler (or by `NetBoxClient` for
6
+ write methods), so alias and full-path invocations produce byte-identical
7
+ `audit.jsonl` lines (modulo timestamp and duration_ms).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from nsc.aliases import (
17
+ AliasVerb,
18
+ AmbiguousAlias,
19
+ ResolvedAlias,
20
+ UnknownAlias,
21
+ resolve,
22
+ )
23
+ from nsc.cli.handlers import handle_delete, handle_get, handle_list
24
+ from nsc.cli.runtime import RuntimeContext, emit_envelope
25
+ from nsc.config.models import OutputFormat
26
+ from nsc.http.errors import NetBoxAPIError, NetBoxClientError
27
+ from nsc.model.command_model import Operation
28
+ from nsc.output.errors import (
29
+ ErrorEnvelope,
30
+ ErrorType,
31
+ ambiguous_alias_envelope,
32
+ client_envelope,
33
+ unknown_alias_envelope,
34
+ )
35
+
36
+
37
+ def _runtime_from_ctx(ctx: typer.Context) -> RuntimeContext:
38
+ """Extract the bootstrapped RuntimeContext from `ctx.obj`."""
39
+ obj = ctx.obj
40
+ if isinstance(obj, tuple) and len(obj) == 2: # noqa: PLR2004
41
+ runtime = obj[1]
42
+ if isinstance(runtime, RuntimeContext):
43
+ return runtime
44
+ raise typer.Exit(2)
45
+
46
+
47
+ def _emit_alias_envelope(env: ErrorEnvelope, ctx: RuntimeContext) -> int:
48
+ return emit_envelope(env, output_format=ctx.output_format)
49
+
50
+
51
+ def _dereference_by_name(
52
+ runtime: RuntimeContext,
53
+ *,
54
+ list_op: Operation,
55
+ name: str,
56
+ ) -> int | ErrorEnvelope:
57
+ """List the resource filtered by `name=<name>` and return the single id.
58
+
59
+ Returns an `ErrorEnvelope` (already-shaped ambiguous_alias / unknown_alias)
60
+ if zero or >=2 records match. Caller emits and exits.
61
+ """
62
+ try:
63
+ rows = list(runtime.client.paginate(list_op.path, {"name": name}))
64
+ except (NetBoxAPIError, NetBoxClientError) as exc:
65
+ return client_envelope(
66
+ f"failed to dereference name {name!r}: {exc}",
67
+ operation_id=list_op.operation_id,
68
+ )
69
+ if len(rows) == 0:
70
+ return ErrorEnvelope(
71
+ error=f"no record matched name={name!r}",
72
+ type=ErrorType.UNKNOWN_ALIAS,
73
+ details={"verb": "rm", "term": name, "reason": "name_not_found"},
74
+ )
75
+ if len(rows) >= 2: # noqa: PLR2004
76
+ return ErrorEnvelope(
77
+ error=f"{len(rows)} records matched name={name!r}; refuse to delete",
78
+ type=ErrorType.AMBIGUOUS_ALIAS,
79
+ details={
80
+ "verb": "rm",
81
+ "term": name,
82
+ "reason": "name_matched_multiple",
83
+ "matched_ids": [row["id"] for row in rows],
84
+ },
85
+ )
86
+ return int(rows[0]["id"])
87
+
88
+
89
+ def register(app: typer.Typer) -> None: # noqa: PLR0915
90
+ @app.command("ls", help="List records on a resource (alias for `<tag> <resource> list`).")
91
+ def ls_cmd(
92
+ ctx: typer.Context,
93
+ term: Annotated[str, typer.Argument(help="Resource name (plural, e.g. `devices`).")],
94
+ output: Annotated[str | None, typer.Option("--output", "-o")] = None,
95
+ compact: Annotated[bool, typer.Option("--compact")] = False,
96
+ columns: Annotated[str | None, typer.Option("--columns")] = None,
97
+ limit: Annotated[int | None, typer.Option("--limit")] = None,
98
+ all_: Annotated[bool, typer.Option("--all")] = False,
99
+ filter_: Annotated[list[str] | None, typer.Option("--filter")] = None,
100
+ ) -> None:
101
+ runtime = _runtime_from_ctx(ctx)
102
+ update: dict[str, object] = {
103
+ "compact": compact,
104
+ "columns_override": columns.split(",") if columns else None,
105
+ "limit": limit,
106
+ "fetch_all": all_,
107
+ "filters": [
108
+ (item.split("=", 1)[0], item.split("=", 1)[1])
109
+ for item in (filter_ or [])
110
+ if "=" in item
111
+ ],
112
+ }
113
+ if output:
114
+ update["output_format"] = OutputFormat(output)
115
+ runtime = runtime.model_copy(update=update)
116
+
117
+ result = resolve(AliasVerb.LS, term, runtime.command_model)
118
+ if isinstance(result, AmbiguousAlias):
119
+ env = ambiguous_alias_envelope(verb="ls", term=term, candidates=result.candidates)
120
+ raise typer.Exit(_emit_alias_envelope(env, runtime))
121
+ if isinstance(result, UnknownAlias):
122
+ env = unknown_alias_envelope(verb="ls", term=term, reason=result.reason)
123
+ raise typer.Exit(_emit_alias_envelope(env, runtime))
124
+ assert isinstance(result, ResolvedAlias)
125
+ handle_list(
126
+ result.operation,
127
+ op_tag=result.tag,
128
+ op_resource=result.resource_name,
129
+ ctx=runtime,
130
+ )
131
+
132
+ @app.command("rm", help="Delete one record (alias for `<tag> <resource> delete`).")
133
+ def rm_cmd(
134
+ ctx: typer.Context,
135
+ term: Annotated[str, typer.Argument(help="Resource name (plural).")],
136
+ id_or_name: Annotated[str, typer.Argument(help="Numeric id or unique name.")],
137
+ apply: Annotated[bool, typer.Option("--apply", "-a")] = False,
138
+ explain: Annotated[bool, typer.Option("--explain")] = False,
139
+ strict: Annotated[bool, typer.Option("--strict")] = False,
140
+ output: Annotated[str | None, typer.Option("--output", "-o")] = None,
141
+ ) -> None:
142
+ runtime = _runtime_from_ctx(ctx)
143
+ update: dict[str, object] = {
144
+ "apply": apply,
145
+ "explain": explain,
146
+ "strict": strict,
147
+ }
148
+ if output:
149
+ update["output_format"] = OutputFormat(output)
150
+ runtime = runtime.model_copy(update=update)
151
+
152
+ result = resolve(AliasVerb.RM, term, runtime.command_model)
153
+ if isinstance(result, AmbiguousAlias):
154
+ env = ambiguous_alias_envelope(verb="rm", term=term, candidates=result.candidates)
155
+ raise typer.Exit(_emit_alias_envelope(env, runtime))
156
+ if isinstance(result, UnknownAlias):
157
+ env = unknown_alias_envelope(verb="rm", term=term, reason=result.reason)
158
+ raise typer.Exit(_emit_alias_envelope(env, runtime))
159
+ assert isinstance(result, ResolvedAlias)
160
+
161
+ if id_or_name.isdigit():
162
+ resolved_id = int(id_or_name)
163
+ else:
164
+ resource = runtime.command_model.tags[result.tag].resources[result.resource_name]
165
+ list_op = resource.list_op
166
+ if list_op is None:
167
+ env = unknown_alias_envelope(
168
+ verb="rm", term=term, reason="no_list_op_for_dereference"
169
+ )
170
+ raise typer.Exit(_emit_alias_envelope(env, runtime))
171
+ outcome = _dereference_by_name(runtime, list_op=list_op, name=id_or_name)
172
+ if isinstance(outcome, ErrorEnvelope):
173
+ raise typer.Exit(_emit_alias_envelope(outcome, runtime))
174
+ resolved_id = outcome
175
+
176
+ handle_delete(
177
+ result.operation,
178
+ op_tag=result.tag,
179
+ op_resource=result.resource_name,
180
+ ctx=runtime,
181
+ id=str(resolved_id),
182
+ )
183
+
184
+ @app.command("get", help="Get one record (alias for `<tag> <resource> get`).")
185
+ def get_cmd(
186
+ ctx: typer.Context,
187
+ term: Annotated[str, typer.Argument(help="Resource name (plural).")],
188
+ id_or_name: Annotated[str, typer.Argument(help="Numeric id or unique name.")],
189
+ output: Annotated[str | None, typer.Option("--output", "-o")] = None,
190
+ compact: Annotated[bool, typer.Option("--compact")] = False,
191
+ columns: Annotated[str | None, typer.Option("--columns")] = None,
192
+ ) -> None:
193
+ runtime = _runtime_from_ctx(ctx)
194
+ update: dict[str, object] = {
195
+ "compact": compact,
196
+ "columns_override": columns.split(",") if columns else None,
197
+ }
198
+ if output:
199
+ update["output_format"] = OutputFormat(output)
200
+ runtime = runtime.model_copy(update=update)
201
+
202
+ result = resolve(AliasVerb.GET, term, runtime.command_model)
203
+ if isinstance(result, AmbiguousAlias):
204
+ env = ambiguous_alias_envelope(verb="get", term=term, candidates=result.candidates)
205
+ raise typer.Exit(_emit_alias_envelope(env, runtime))
206
+ if isinstance(result, UnknownAlias):
207
+ env = unknown_alias_envelope(verb="get", term=term, reason=result.reason)
208
+ raise typer.Exit(_emit_alias_envelope(env, runtime))
209
+ assert isinstance(result, ResolvedAlias)
210
+
211
+ if id_or_name.isdigit():
212
+ resolved_id = int(id_or_name)
213
+ else:
214
+ list_op = runtime.command_model.tags[result.tag].resources[result.resource_name].list_op
215
+ if list_op is None:
216
+ env = unknown_alias_envelope(
217
+ verb="get", term=term, reason="no_list_op_for_dereference"
218
+ )
219
+ raise typer.Exit(_emit_alias_envelope(env, runtime))
220
+ outcome = _dereference_by_name(runtime, list_op=list_op, name=id_or_name)
221
+ if isinstance(outcome, ErrorEnvelope):
222
+ outcome = outcome.model_copy(update={"details": {**outcome.details, "verb": "get"}})
223
+ raise typer.Exit(_emit_alias_envelope(outcome, runtime))
224
+ resolved_id = outcome
225
+
226
+ handle_get(
227
+ result.operation,
228
+ op_tag=result.tag,
229
+ op_resource=result.resource_name,
230
+ ctx=runtime,
231
+ id=str(resolved_id),
232
+ )
233
+
234
+ @app.command("search", help="Search NetBox via /api/search/?q=<query>.")
235
+ def search_cmd(
236
+ ctx: typer.Context,
237
+ query: Annotated[str, typer.Argument(help="Search query string.")],
238
+ output: Annotated[str | None, typer.Option("--output", "-o")] = None,
239
+ compact: Annotated[bool, typer.Option("--compact")] = False,
240
+ limit: Annotated[int | None, typer.Option("--limit")] = None,
241
+ all_: Annotated[bool, typer.Option("--all")] = False,
242
+ ) -> None:
243
+ runtime = _runtime_from_ctx(ctx)
244
+ update: dict[str, object] = {
245
+ "compact": compact,
246
+ "limit": limit,
247
+ "fetch_all": all_,
248
+ "filters": [("q", query)],
249
+ }
250
+ if output:
251
+ update["output_format"] = OutputFormat(output)
252
+ runtime = runtime.model_copy(update=update)
253
+
254
+ result = resolve(AliasVerb.SEARCH, query, runtime.command_model)
255
+ if isinstance(result, UnknownAlias):
256
+ env = unknown_alias_envelope(verb="search", term=query, reason=result.reason)
257
+ raise typer.Exit(_emit_alias_envelope(env, runtime))
258
+ assert isinstance(result, ResolvedAlias)
259
+ handle_list(
260
+ result.operation,
261
+ op_tag=result.tag,
262
+ op_resource=result.resource_name,
263
+ ctx=runtime,
264
+ )