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/output/flatten.py ADDED
@@ -0,0 +1,25 @@
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
+ flat: dict[str, Any] = {}
11
+ _walk(record, "", flat)
12
+ if columns is None:
13
+ return flat
14
+ return {col: flat.get(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
nsc/output/headers.py ADDED
@@ -0,0 +1,9 @@
1
+ """Single source of truth for sensitive HTTP header names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ SENSITIVE_HEADERS: frozenset[str] = frozenset(
6
+ {"authorization", "cookie", "x-api-key", "proxy-authorization"}
7
+ )
8
+
9
+ __all__ = ["SENSITIVE_HEADERS"]
nsc/output/json_.py ADDED
@@ -0,0 +1,21 @@
1
+ """JSON output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any, TextIO
8
+
9
+
10
+ def render(
11
+ data: list[dict[str, Any]] | dict[str, Any],
12
+ *,
13
+ stream: TextIO = sys.stdout,
14
+ compact: bool = False,
15
+ ) -> None:
16
+ if compact:
17
+ stream.write(json.dumps(data, separators=(",", ":")))
18
+ stream.write("\n")
19
+ return
20
+ json.dump(data, stream, indent=2, sort_keys=False)
21
+ stream.write("\n")
nsc/output/jsonl.py ADDED
@@ -0,0 +1,21 @@
1
+ """JSON Lines output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any, TextIO
8
+
9
+
10
+ def render(
11
+ data: list[dict[str, Any]] | dict[str, Any],
12
+ *,
13
+ stream: TextIO = sys.stdout,
14
+ ) -> None:
15
+ if isinstance(data, dict):
16
+ stream.write(json.dumps(data))
17
+ stream.write("\n")
18
+ return
19
+ for record in data:
20
+ stream.write(json.dumps(record))
21
+ stream.write("\n")
nsc/output/render.py ADDED
@@ -0,0 +1,47 @@
1
+ """Single output entry point + format selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any, TextIO
7
+
8
+ from nsc.config.models import OutputFormat
9
+ from nsc.output import csv_, json_, jsonl, table, yaml_
10
+
11
+
12
+ def render(
13
+ data: list[dict[str, Any]] | dict[str, Any],
14
+ *,
15
+ format: OutputFormat,
16
+ columns: list[str] | None = None,
17
+ stream: TextIO = sys.stdout,
18
+ compact: bool = False,
19
+ ) -> None:
20
+ if format is OutputFormat.JSON:
21
+ json_.render(data, stream=stream, compact=compact)
22
+ elif format is OutputFormat.JSONL:
23
+ jsonl.render(data, stream=stream)
24
+ elif format is OutputFormat.YAML:
25
+ yaml_.render(data, stream=stream)
26
+ elif format is OutputFormat.CSV:
27
+ csv_.render(data, stream=stream, columns=columns)
28
+ elif format is OutputFormat.TABLE:
29
+ table.render(data, stream=stream, columns=columns)
30
+ else: # pragma: no cover (StrEnum exhaustively covered above)
31
+ raise ValueError(f"unknown output format: {format!r}")
32
+
33
+
34
+ def select_format(
35
+ *,
36
+ cli_value: str | None,
37
+ env_value: str | None,
38
+ is_tty: bool,
39
+ default: OutputFormat,
40
+ ) -> OutputFormat:
41
+ if cli_value is not None:
42
+ return OutputFormat(cli_value)
43
+ if env_value is not None:
44
+ return OutputFormat(env_value)
45
+ if not is_tty:
46
+ return OutputFormat.JSON
47
+ return default
nsc/output/table.py ADDED
@@ -0,0 +1,50 @@
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.console import Console
9
+ from rich.table import Table
10
+
11
+ from nsc.output.flatten import flatten
12
+
13
+
14
+ def render(
15
+ data: list[dict[str, Any]] | dict[str, Any],
16
+ *,
17
+ stream: TextIO = sys.stdout,
18
+ columns: list[str] | None = None,
19
+ ) -> None:
20
+ records = [data] if isinstance(data, dict) else list(data)
21
+ if not records:
22
+ Console(file=stream, force_terminal=False).print("(no records)")
23
+ return
24
+
25
+ flat_records = [flatten(r, columns=columns) for r in records]
26
+ fieldnames = columns if columns is not None else _gather_fieldnames(flat_records)
27
+
28
+ table = Table(show_header=True, header_style="bold")
29
+ for col in fieldnames:
30
+ table.add_column(col)
31
+ for r in flat_records:
32
+ table.add_row(*[_format_cell(r.get(col, "")) for col in fieldnames])
33
+
34
+ Console(file=stream, force_terminal=False).print(table)
35
+
36
+
37
+ def _gather_fieldnames(records: list[dict[str, Any]]) -> list[str]:
38
+ seen: dict[str, None] = {}
39
+ for r in records:
40
+ for k in r:
41
+ seen.setdefault(k, None)
42
+ return list(seen.keys())
43
+
44
+
45
+ def _format_cell(value: Any) -> str:
46
+ if value is None:
47
+ return ""
48
+ if isinstance(value, bool):
49
+ return "true" if value else "false"
50
+ return str(value)
nsc/output/yaml_.py ADDED
@@ -0,0 +1,28 @@
1
+ """YAML output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any, TextIO
7
+
8
+ from ruamel.yaml import YAML
9
+
10
+
11
+ def _emitter() -> YAML:
12
+ """A safe-mode emitter configured for stable, block-style output.
13
+
14
+ ruamel.yaml's safe dumper preserves regular `dict` insertion order natively
15
+ (Python 3.7+), so no explicit `sort_keys=False` toggle is needed — and
16
+ `YAML.sort_keys` is not part of the public API anyway.
17
+ """
18
+ yaml = YAML(typ="safe", pure=True)
19
+ yaml.default_flow_style = False
20
+ return yaml
21
+
22
+
23
+ def render(
24
+ data: list[dict[str, Any]] | dict[str, Any],
25
+ *,
26
+ stream: TextIO = sys.stdout,
27
+ ) -> None:
28
+ _emitter().dump(data, stream)
nsc/schema/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """OpenAPI parsing layer."""
nsc/schema/hashing.py ADDED
@@ -0,0 +1,24 @@
1
+ """Canonical hashing for schema bodies.
2
+
3
+ Two schema bodies that differ only in key ordering (or whitespace) hash to the
4
+ same value. This is the cache key for generated command-models.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+
12
+
13
+ def canonical_sha256(body: bytes) -> str:
14
+ """Return the SHA-256 of `body` after JSON canonicalization.
15
+
16
+ Raises:
17
+ ValueError: if `body` is not valid JSON.
18
+ """
19
+ try:
20
+ decoded = json.loads(body)
21
+ except json.JSONDecodeError as exc:
22
+ raise ValueError(f"schema body is not valid JSON: {exc}") from exc
23
+ canonical = json.dumps(decoded, sort_keys=True, separators=(",", ":")).encode("utf-8")
24
+ return hashlib.sha256(canonical).hexdigest()
nsc/schema/loader.py ADDED
@@ -0,0 +1,66 @@
1
+ """Load an OpenAPI schema from a URL or local file path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ from nsc.schema.hashing import canonical_sha256
12
+ from nsc.schema.models import OpenAPIDocument
13
+
14
+ _HTTP_ERROR_THRESHOLD = 400
15
+
16
+
17
+ class SchemaLoadError(Exception):
18
+ """Raised when a schema cannot be loaded or parsed."""
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class LoadedSchema:
23
+ """A parsed OpenAPI document plus the hash of its canonical body."""
24
+
25
+ source: str
26
+ body: bytes
27
+ hash: str
28
+ document: OpenAPIDocument
29
+
30
+
31
+ def load_schema(source: str, *, verify_ssl: bool = True, timeout: float = 30.0) -> LoadedSchema:
32
+ """Load and parse the schema at `source`.
33
+
34
+ `source` is either an `http(s)://` URL or a local filesystem path. The path
35
+ can be absolute or relative to the current working directory.
36
+ """
37
+ body = _fetch_body(source, verify_ssl=verify_ssl, timeout=timeout)
38
+ if source.endswith(".gz"):
39
+ try:
40
+ body = gzip.decompress(body)
41
+ except gzip.BadGzipFile as exc:
42
+ raise SchemaLoadError(f"{source}: not valid gzip ({exc})") from exc
43
+ try:
44
+ h = canonical_sha256(body)
45
+ except ValueError as exc:
46
+ raise SchemaLoadError(f"{source}: not valid JSON ({exc})") from exc
47
+ try:
48
+ doc = OpenAPIDocument.model_validate_json(body)
49
+ except Exception as exc: # pydantic.ValidationError
50
+ raise SchemaLoadError(f"{source}: schema does not match expected shape: {exc}") from exc
51
+ return LoadedSchema(source=source, body=body, hash=h, document=doc)
52
+
53
+
54
+ def _fetch_body(source: str, *, verify_ssl: bool, timeout: float) -> bytes:
55
+ if source.startswith(("http://", "https://")):
56
+ try:
57
+ response = httpx.get(source, verify=verify_ssl, timeout=timeout, follow_redirects=True)
58
+ except httpx.HTTPError as exc:
59
+ raise SchemaLoadError(f"{source}: request failed ({exc})") from exc
60
+ if response.status_code >= _HTTP_ERROR_THRESHOLD:
61
+ raise SchemaLoadError(f"{source}: HTTP {response.status_code}")
62
+ return response.content
63
+ p = Path(source)
64
+ if not p.exists():
65
+ raise SchemaLoadError(f"{source}: not found")
66
+ return p.read_bytes()
nsc/schema/models.py ADDED
@@ -0,0 +1,109 @@
1
+ """Pydantic models for the OpenAPI 3.x subset that NetBox emits and we consume.
2
+
3
+ Anything we don't explicitly model is tolerated via `extra="allow"` on root
4
+ container types, so vendor extensions and OpenAPI features we don't use can't
5
+ break parsing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+
16
+ class _Tolerant(BaseModel):
17
+ """Base for models that should accept unknown fields."""
18
+
19
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
20
+
21
+
22
+ class ParameterIn(StrEnum):
23
+ QUERY = "query"
24
+ PATH = "path"
25
+ HEADER = "header"
26
+ COOKIE = "cookie"
27
+
28
+
29
+ class SchemaObject(_Tolerant):
30
+ """A JSON Schema fragment as it appears inside an OpenAPI document.
31
+
32
+ We only model the fields we use; the rest is preserved via `extra="allow"`.
33
+ """
34
+
35
+ type: str | None = None
36
+ format: str | None = None
37
+ enum: list[Any] | None = None
38
+ items: SchemaObject | None = None
39
+ properties: dict[str, SchemaObject] | None = None
40
+ required: list[str] | None = None
41
+ description: str | None = None
42
+ ref: str | None = Field(default=None, alias="$ref")
43
+
44
+
45
+ class MediaType(_Tolerant):
46
+ schema_: SchemaObject | None = Field(default=None, alias="schema")
47
+
48
+
49
+ class RequestBody(_Tolerant):
50
+ description: str | None = None
51
+ required: bool = False
52
+ content: dict[str, MediaType] = Field(default_factory=dict)
53
+
54
+
55
+ class Response(_Tolerant):
56
+ description: str | None = None
57
+ content: dict[str, MediaType] = Field(default_factory=dict)
58
+
59
+
60
+ class Parameter(_Tolerant):
61
+ name: str
62
+ in_: ParameterIn = Field(alias="in")
63
+ description: str | None = None
64
+ required: bool = False
65
+ schema_: SchemaObject | None = Field(default=None, alias="schema")
66
+
67
+
68
+ class Operation(_Tolerant):
69
+ operation_id: str | None = Field(default=None, alias="operationId")
70
+ summary: str | None = None
71
+ description: str | None = None
72
+ tags: list[str] = Field(default_factory=list)
73
+ parameters: list[Parameter] = Field(default_factory=list)
74
+ request_body: RequestBody | None = Field(default=None, alias="requestBody")
75
+ responses: dict[str, Response] = Field(default_factory=dict)
76
+
77
+
78
+ class PathItem(_Tolerant):
79
+ get: Operation | None = None
80
+ put: Operation | None = None
81
+ post: Operation | None = None
82
+ delete: Operation | None = None
83
+ patch: Operation | None = None
84
+ options: Operation | None = None
85
+ head: Operation | None = None
86
+ parameters: list[Parameter] = Field(default_factory=list)
87
+
88
+
89
+ class Info(_Tolerant):
90
+ title: str
91
+ version: str
92
+ description: str | None = None
93
+
94
+
95
+ class Tag(_Tolerant):
96
+ name: str
97
+ description: str | None = None
98
+
99
+
100
+ class Components(_Tolerant):
101
+ schemas: dict[str, SchemaObject] = Field(default_factory=dict)
102
+
103
+
104
+ class OpenAPIDocument(_Tolerant):
105
+ openapi: str
106
+ info: Info
107
+ paths: dict[str, PathItem] = Field(default_factory=dict)
108
+ components: Components = Field(default_factory=Components)
109
+ tags: list[Tag] = Field(default_factory=list)
nsc/schema/source.py ADDED
@@ -0,0 +1,120 @@
1
+ """Schema-source resolution chain (Phase 2).
2
+
3
+ Order:
4
+ 1. --schema flag (path or URL)
5
+ 2. profile.schema_url
6
+ 3. {profile.url}/api/schema/?format=json
7
+ 4. cache hit (matched by profile name + schema hash)
8
+ 5. bundled fallback
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+
18
+ from nsc.builder.build import build_command_model
19
+ from nsc.cache.store import CacheStore
20
+ from nsc.cli.runtime import ResolvedProfile
21
+ from nsc.config.settings import Paths
22
+ from nsc.model.command_model import CommandModel
23
+ from nsc.schema.hashing import canonical_sha256
24
+ from nsc.schema.loader import LoadedSchema, load_schema
25
+ from nsc.schema.models import OpenAPIDocument
26
+ from nsc.schemas import bundled as _bundled_pkg
27
+
28
+
29
+ class SchemaSourceError(Exception):
30
+ """Raised when no schema source could be resolved."""
31
+
32
+
33
+ def resolve_command_model(
34
+ *,
35
+ paths: Paths,
36
+ profile: ResolvedProfile,
37
+ schema_override: str | None,
38
+ ) -> CommandModel:
39
+ if schema_override is not None:
40
+ loaded = load_schema(
41
+ schema_override, verify_ssl=profile.verify_ssl, timeout=profile.timeout
42
+ )
43
+ return _build_and_cache(loaded, paths, profile)
44
+
45
+ schema_url = (
46
+ str(profile.schema_url)
47
+ if profile.schema_url is not None
48
+ else f"{str(profile.url).rstrip('/')}/api/schema/?format=json"
49
+ )
50
+
51
+ try:
52
+ loaded = _fetch_schema(schema_url, profile)
53
+ except (httpx.RequestError, httpx.HTTPStatusError) as exc:
54
+ cached = _find_any_cached(paths, profile.name)
55
+ if cached is not None:
56
+ print(
57
+ f"Could not reach NetBox ({exc}); using cached schema.",
58
+ file=sys.stderr,
59
+ )
60
+ return cached
61
+ bundled = _load_bundled_command_model()
62
+ if bundled is not None:
63
+ print(
64
+ f"Could not reach NetBox ({exc}) and no cache; using bundled schema.",
65
+ file=sys.stderr,
66
+ )
67
+ return bundled
68
+ raise SchemaSourceError(
69
+ f"could not reach NetBox at {schema_url}, no cache, no bundled fallback: {exc}"
70
+ ) from exc
71
+
72
+ return _build_and_cache(loaded, paths, profile)
73
+
74
+
75
+ def _fetch_schema(url: str, profile: ResolvedProfile) -> LoadedSchema:
76
+ headers: dict[str, str] = {"Accept": "application/json"}
77
+ if profile.token:
78
+ headers["Authorization"] = f"Token {profile.token}"
79
+ with httpx.Client(verify=profile.verify_ssl, timeout=profile.timeout, headers=headers) as c:
80
+ response = c.get(url)
81
+ response.raise_for_status()
82
+ body = response.content
83
+ document = OpenAPIDocument.model_validate_json(body)
84
+ return LoadedSchema(source=url, body=body, hash=canonical_sha256(body), document=document)
85
+
86
+
87
+ def _build_and_cache(loaded: LoadedSchema, paths: Paths, profile: ResolvedProfile) -> CommandModel:
88
+ store = CacheStore(root=paths.cache_dir)
89
+ cached = store.load(profile.name, loaded.hash)
90
+ if cached is not None:
91
+ return cached
92
+ model = build_command_model(loaded)
93
+ store.save(profile.name, model)
94
+ return model
95
+
96
+
97
+ def _find_any_cached(paths: Paths, profile_name: str) -> CommandModel | None:
98
+ profile_dir = paths.cache_dir / profile_name
99
+ if not profile_dir.exists():
100
+ return None
101
+ candidates = sorted(
102
+ profile_dir.glob("*.json"),
103
+ key=lambda p: p.stat().st_mtime,
104
+ reverse=True,
105
+ )
106
+ for candidate in candidates:
107
+ try:
108
+ return CommandModel.model_validate_json(candidate.read_text())
109
+ except Exception:
110
+ continue
111
+ return None
112
+
113
+
114
+ def _load_bundled_command_model() -> CommandModel | None:
115
+ pkg_dir = Path(_bundled_pkg.__file__).resolve().parent
116
+ candidates = sorted(list(pkg_dir.glob("*.json")) + list(pkg_dir.glob("*.json.gz")))
117
+ if not candidates:
118
+ return None
119
+ loaded = load_schema(str(candidates[0]))
120
+ return build_command_model(loaded)
@@ -0,0 +1 @@
1
+ """Bundled NetBox OpenAPI snapshots and helpers."""
@@ -0,0 +1 @@
1
+ """Versioned NetBox OpenAPI snapshots, used as offline fallback."""
@@ -0,0 +1,5 @@
1
+ # Bundled NetBox OpenAPI snapshots.
2
+ # Maintained by `just fetch-schemas` (added in Phase 4).
3
+ schemas:
4
+ - version: "4.6.0-beta2"
5
+ file: "netbox-4.6.0-beta2.json.gz"
nsc/skill/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Access to the bundled portable Skill file.
2
+
3
+ The canonical source of truth lives at `skills/netbox-super-cli/SKILL.md` at
4
+ the repo root; `pyproject.toml`'s wheel `force-include` ships the same path
5
+ inside the installed wheel. The helper below uses `importlib.resources` so
6
+ both layouts work without special-casing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterator
12
+ from contextlib import contextmanager
13
+ from importlib.resources import as_file, files
14
+ from pathlib import Path
15
+
16
+ __all__ = ["BUNDLE_NAME", "bundle_path"]
17
+
18
+ BUNDLE_NAME = "netbox-super-cli"
19
+ """The bundled Skill's directory name; used by the install helper to derive
20
+ the per-target destination directory."""
21
+
22
+
23
+ @contextmanager
24
+ def bundle_path() -> Iterator[Path]:
25
+ """Yield an on-disk Path to the bundled `SKILL.md`.
26
+
27
+ Works in both the source-tree layout (file lives at
28
+ `<repo>/skills/netbox-super-cli/SKILL.md`) and the installed-wheel layout
29
+ (same relative path inside the wheel; `importlib.resources.as_file` will
30
+ materialize a real file when reading from a zipped wheel).
31
+ """
32
+ nsc_root = files("nsc")
33
+ skill_md = nsc_root.joinpath("..", "skills", BUNDLE_NAME, "SKILL.md")
34
+ with as_file(skill_md) as p:
35
+ yield p