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.
- netbox_super_cli-1.0.0.dist-info/METADATA +182 -0
- netbox_super_cli-1.0.0.dist-info/RECORD +71 -0
- netbox_super_cli-1.0.0.dist-info/WHEEL +4 -0
- netbox_super_cli-1.0.0.dist-info/entry_points.txt +3 -0
- netbox_super_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- nsc/__init__.py +5 -0
- nsc/__main__.py +6 -0
- nsc/_version.py +3 -0
- nsc/aliases/__init__.py +24 -0
- nsc/aliases/resolver.py +112 -0
- nsc/auth/__init__.py +5 -0
- nsc/auth/verify.py +143 -0
- nsc/builder/__init__.py +5 -0
- nsc/builder/build.py +514 -0
- nsc/cache/__init__.py +5 -0
- nsc/cache/store.py +295 -0
- nsc/cli/__init__.py +1 -0
- nsc/cli/aliases_commands.py +264 -0
- nsc/cli/app.py +291 -0
- nsc/cli/cache_commands.py +159 -0
- nsc/cli/commands_dump.py +57 -0
- nsc/cli/config_commands.py +156 -0
- nsc/cli/globals.py +65 -0
- nsc/cli/handlers.py +660 -0
- nsc/cli/init_commands.py +95 -0
- nsc/cli/login_commands.py +265 -0
- nsc/cli/profiles_commands.py +256 -0
- nsc/cli/registration.py +465 -0
- nsc/cli/runtime.py +290 -0
- nsc/cli/skill_commands.py +186 -0
- nsc/cli/writes/__init__.py +10 -0
- nsc/cli/writes/apply.py +177 -0
- nsc/cli/writes/bulk.py +231 -0
- nsc/cli/writes/coercion.py +9 -0
- nsc/cli/writes/confirmation.py +96 -0
- nsc/cli/writes/input.py +358 -0
- nsc/cli/writes/preflight.py +182 -0
- nsc/config/__init__.py +23 -0
- nsc/config/loader.py +69 -0
- nsc/config/models.py +54 -0
- nsc/config/settings.py +36 -0
- nsc/config/writer.py +207 -0
- nsc/http/__init__.py +6 -0
- nsc/http/audit.py +183 -0
- nsc/http/client.py +365 -0
- nsc/http/errors.py +35 -0
- nsc/http/retry.py +90 -0
- nsc/model/__init__.py +23 -0
- nsc/model/command_model.py +125 -0
- nsc/output/__init__.py +1 -0
- nsc/output/csv_.py +34 -0
- nsc/output/errors.py +346 -0
- nsc/output/explain.py +194 -0
- nsc/output/flatten.py +25 -0
- nsc/output/headers.py +9 -0
- nsc/output/json_.py +21 -0
- nsc/output/jsonl.py +21 -0
- nsc/output/render.py +47 -0
- nsc/output/table.py +50 -0
- nsc/output/yaml_.py +28 -0
- nsc/schema/__init__.py +1 -0
- nsc/schema/hashing.py +24 -0
- nsc/schema/loader.py +66 -0
- nsc/schema/models.py +109 -0
- nsc/schema/source.py +120 -0
- nsc/schemas/__init__.py +1 -0
- nsc/schemas/bundled/__init__.py +1 -0
- nsc/schemas/bundled/manifest.yaml +5 -0
- nsc/schemas/bundled/netbox-4.6.0-beta2.json.gz +0 -0
- nsc/skill/__init__.py +35 -0
- skills/netbox-super-cli/SKILL.md +127 -0
nsc/cli/runtime.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Runtime context, profile resolution, exit-code mapping.
|
|
2
|
+
|
|
3
|
+
`RuntimeContext` carries the live `NetBoxClient`, command model, config, and
|
|
4
|
+
output preferences for a single invocation. It is populated by the bootstrap
|
|
5
|
+
pipeline in the root Typer callback (Task 12) and consumed by the dynamic
|
|
6
|
+
read handlers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, HttpUrl, SkipValidation
|
|
17
|
+
|
|
18
|
+
from nsc.cache.store import ADHOC_PROFILE
|
|
19
|
+
from nsc.config.models import Config, OutputFormat, Profile
|
|
20
|
+
from nsc.config.settings import default_paths
|
|
21
|
+
from nsc.http.client import NetBoxClient
|
|
22
|
+
from nsc.http.errors import NetBoxAPIError, NetBoxClientError
|
|
23
|
+
from nsc.model.command_model import CommandModel, Operation
|
|
24
|
+
from nsc.output.errors import (
|
|
25
|
+
EXIT_CODES,
|
|
26
|
+
ErrorEnvelope,
|
|
27
|
+
ErrorType,
|
|
28
|
+
RenderTarget,
|
|
29
|
+
render_to_json,
|
|
30
|
+
render_to_rich_stderr,
|
|
31
|
+
select_render_target,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Named constants to satisfy PLR2004 (no magic numbers in comparisons).
|
|
35
|
+
_STATUS_UNAUTHORIZED = 401
|
|
36
|
+
_STATUS_FORBIDDEN = 403
|
|
37
|
+
_STATUS_NOT_FOUND = 404
|
|
38
|
+
_STATUS_CONFLICT = 409
|
|
39
|
+
_STATUS_TOO_MANY = 429
|
|
40
|
+
_STATUS_4XX_MIN = 400
|
|
41
|
+
_STATUS_5XX_MIN = 500
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Frozen(BaseModel):
|
|
45
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CLIOverrides(_Frozen):
|
|
49
|
+
profile: str | None = None
|
|
50
|
+
url: str | None = None
|
|
51
|
+
token: str | None = None
|
|
52
|
+
insecure: bool | None = None
|
|
53
|
+
schema_override: str | None = None
|
|
54
|
+
output: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ResolvedProfile(_Frozen):
|
|
58
|
+
name: str
|
|
59
|
+
url: HttpUrl
|
|
60
|
+
token: str
|
|
61
|
+
verify_ssl: bool
|
|
62
|
+
timeout: float
|
|
63
|
+
schema_url: HttpUrl | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NoProfileError(Exception):
|
|
67
|
+
"""No URL/token available from any source."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UnknownProfileError(Exception):
|
|
71
|
+
"""A profile was requested by name but is not in the config."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_profile(
|
|
75
|
+
config: Config,
|
|
76
|
+
overrides: CLIOverrides,
|
|
77
|
+
env: Mapping[str, str],
|
|
78
|
+
) -> ResolvedProfile:
|
|
79
|
+
base, base_name = _select_base_profile(config, overrides, env)
|
|
80
|
+
|
|
81
|
+
url = _first_set(overrides.url, env.get("NSC_URL"), str(base.url) if base else None)
|
|
82
|
+
token = _first_set(overrides.token, env.get("NSC_TOKEN"), base.token if base else None)
|
|
83
|
+
if url is None or token is None:
|
|
84
|
+
raise NoProfileError(
|
|
85
|
+
"no NetBox URL/token configured (set NSC_URL+NSC_TOKEN, "
|
|
86
|
+
"pass --url and --token, or configure a profile in ~/.nsc/config.yaml)"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
insecure_env = _bool_env(env.get("NSC_INSECURE"))
|
|
90
|
+
if overrides.insecure is not None:
|
|
91
|
+
verify_ssl = not overrides.insecure
|
|
92
|
+
elif insecure_env is not None:
|
|
93
|
+
verify_ssl = not insecure_env
|
|
94
|
+
elif base is not None:
|
|
95
|
+
verify_ssl = base.verify_ssl
|
|
96
|
+
else:
|
|
97
|
+
verify_ssl = True
|
|
98
|
+
|
|
99
|
+
timeout = base.timeout if (base and base.timeout is not None) else config.defaults.timeout
|
|
100
|
+
|
|
101
|
+
schema_url_raw = _first_set(
|
|
102
|
+
_url_only(overrides.schema_override),
|
|
103
|
+
_url_only(env.get("NSC_SCHEMA")),
|
|
104
|
+
str(base.schema_url) if base and base.schema_url else None,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return ResolvedProfile(
|
|
108
|
+
name=base_name,
|
|
109
|
+
url=HttpUrl(url),
|
|
110
|
+
token=token,
|
|
111
|
+
verify_ssl=verify_ssl,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
schema_url=HttpUrl(schema_url_raw) if schema_url_raw else None,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _select_base_profile(
|
|
118
|
+
config: Config, overrides: CLIOverrides, env: Mapping[str, str]
|
|
119
|
+
) -> tuple[Profile | None, str]:
|
|
120
|
+
name = overrides.profile or env.get("NSC_PROFILE") or config.default_profile
|
|
121
|
+
if name is None:
|
|
122
|
+
return None, ADHOC_PROFILE
|
|
123
|
+
if name not in config.profiles:
|
|
124
|
+
raise UnknownProfileError(f"profile {name!r} is not defined in ~/.nsc/config.yaml")
|
|
125
|
+
return config.profiles[name], name
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _first_set(*values: str | None) -> str | None:
|
|
129
|
+
for v in values:
|
|
130
|
+
if v is not None and v != "":
|
|
131
|
+
return v
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _bool_env(raw: str | None) -> bool | None:
|
|
136
|
+
if raw is None:
|
|
137
|
+
return None
|
|
138
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _url_only(value: str | None) -> str | None:
|
|
142
|
+
"""Return the value only if it looks like an HTTP(S) URL; else None.
|
|
143
|
+
|
|
144
|
+
`--schema`/`NSC_SCHEMA` may be a local path, in which case it should not
|
|
145
|
+
populate the profile's `schema_url` (which is HttpUrl-validated). The
|
|
146
|
+
schema_override flow consumes the raw value directly from `CLIOverrides`.
|
|
147
|
+
"""
|
|
148
|
+
if value is None or not value:
|
|
149
|
+
return None
|
|
150
|
+
if value.startswith(("http://", "https://")):
|
|
151
|
+
return value
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class RuntimeContext(BaseModel):
|
|
156
|
+
"""Per-invocation runtime state.
|
|
157
|
+
|
|
158
|
+
Not frozen because `client` (a NetBoxClient wrapping httpx.Client) is mutable.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
|
|
162
|
+
|
|
163
|
+
resolved_profile: ResolvedProfile
|
|
164
|
+
config: Config
|
|
165
|
+
command_model: SkipValidation[CommandModel]
|
|
166
|
+
client: SkipValidation[NetBoxClient]
|
|
167
|
+
output_format: OutputFormat
|
|
168
|
+
debug: bool = False
|
|
169
|
+
page_size: int = 50
|
|
170
|
+
columns_override: list[str] | None = None
|
|
171
|
+
filters: list[tuple[str, str]] = []
|
|
172
|
+
limit: int | None = None
|
|
173
|
+
fetch_all: bool = False
|
|
174
|
+
compact: bool = False
|
|
175
|
+
apply: bool = False
|
|
176
|
+
explain: bool = False
|
|
177
|
+
strict: bool = False
|
|
178
|
+
file: str | None = None
|
|
179
|
+
fields: list[str] = []
|
|
180
|
+
file_format: str | None = None
|
|
181
|
+
bulk: bool | None = None
|
|
182
|
+
no_bulk: bool | None = None
|
|
183
|
+
on_error: Literal["stop", "continue"] = "stop"
|
|
184
|
+
|
|
185
|
+
def resolve_columns(self, tag: str, resource: str, operation: Operation) -> list[str] | None:
|
|
186
|
+
if self.columns_override is not None:
|
|
187
|
+
return self.columns_override
|
|
188
|
+
per_tag = self.config.columns.get(tag, {})
|
|
189
|
+
configured = per_tag.get(resource)
|
|
190
|
+
if configured is not None:
|
|
191
|
+
return configured
|
|
192
|
+
return operation.default_columns
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def apply_limit(
|
|
196
|
+
iterator: Iterable[dict[str, Any]],
|
|
197
|
+
*,
|
|
198
|
+
limit: int | None,
|
|
199
|
+
fetch_all: bool,
|
|
200
|
+
page_size: int,
|
|
201
|
+
) -> Iterator[dict[str, Any]]:
|
|
202
|
+
cap: int | None
|
|
203
|
+
if limit is not None:
|
|
204
|
+
cap = limit
|
|
205
|
+
elif fetch_all:
|
|
206
|
+
cap = None
|
|
207
|
+
else:
|
|
208
|
+
cap = page_size
|
|
209
|
+
for n, record in enumerate(iterator):
|
|
210
|
+
if cap is not None and n >= cap:
|
|
211
|
+
return
|
|
212
|
+
yield record
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _audit_log_path() -> str | None:
|
|
216
|
+
p = default_paths().logs_dir / "audit.jsonl"
|
|
217
|
+
return str(p) if p.exists() else None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _api_error_type(status_code: int) -> ErrorType:
|
|
221
|
+
if status_code in (_STATUS_UNAUTHORIZED, _STATUS_FORBIDDEN):
|
|
222
|
+
return ErrorType.AUTH
|
|
223
|
+
if status_code == _STATUS_NOT_FOUND:
|
|
224
|
+
return ErrorType.NOT_FOUND
|
|
225
|
+
if status_code == _STATUS_CONFLICT:
|
|
226
|
+
return ErrorType.CONFLICT
|
|
227
|
+
if status_code == _STATUS_TOO_MANY:
|
|
228
|
+
return ErrorType.RATE_LIMITED
|
|
229
|
+
if _STATUS_4XX_MIN <= status_code < _STATUS_5XX_MIN:
|
|
230
|
+
return ErrorType.VALIDATION
|
|
231
|
+
return ErrorType.SERVER
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def map_error(
|
|
235
|
+
exc: BaseException,
|
|
236
|
+
*,
|
|
237
|
+
operation_id: str | None = None,
|
|
238
|
+
attempt_n: int | None = None,
|
|
239
|
+
) -> ErrorEnvelope:
|
|
240
|
+
"""Translate a known nsc exception into an ErrorEnvelope. Unknown → internal."""
|
|
241
|
+
if isinstance(exc, NetBoxAPIError):
|
|
242
|
+
et = _api_error_type(exc.status_code)
|
|
243
|
+
details: dict[str, Any] = {}
|
|
244
|
+
if et is ErrorType.SERVER:
|
|
245
|
+
details = {"body_excerpt": exc.body_snippet, "retry_safe": False}
|
|
246
|
+
elif et is ErrorType.AUTH:
|
|
247
|
+
details = {"reason": "rejected"}
|
|
248
|
+
elif et is ErrorType.VALIDATION:
|
|
249
|
+
details = {"source": "server", "body_excerpt": exc.body_snippet}
|
|
250
|
+
return ErrorEnvelope(
|
|
251
|
+
error=str(exc),
|
|
252
|
+
type=et,
|
|
253
|
+
endpoint=exc.url,
|
|
254
|
+
status_code=exc.status_code,
|
|
255
|
+
attempt_n=attempt_n,
|
|
256
|
+
audit_log_path=_audit_log_path(),
|
|
257
|
+
operation_id=operation_id,
|
|
258
|
+
details=details,
|
|
259
|
+
)
|
|
260
|
+
if isinstance(exc, NetBoxClientError):
|
|
261
|
+
return ErrorEnvelope(
|
|
262
|
+
error=str(exc),
|
|
263
|
+
type=ErrorType.TRANSPORT,
|
|
264
|
+
endpoint=exc.url,
|
|
265
|
+
attempt_n=attempt_n,
|
|
266
|
+
audit_log_path=_audit_log_path(),
|
|
267
|
+
operation_id=operation_id,
|
|
268
|
+
details={"cause": "connect", "retry_safe": True},
|
|
269
|
+
)
|
|
270
|
+
if isinstance(exc, NoProfileError):
|
|
271
|
+
return ErrorEnvelope(error=str(exc), type=ErrorType.CONFIG)
|
|
272
|
+
if isinstance(exc, UnknownProfileError):
|
|
273
|
+
return ErrorEnvelope(error=str(exc), type=ErrorType.CONFIG)
|
|
274
|
+
return ErrorEnvelope(
|
|
275
|
+
error=f"internal error: {exc}",
|
|
276
|
+
type=ErrorType.INTERNAL,
|
|
277
|
+
details={"traceback_id": str(uuid.uuid4())},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def emit_envelope(env: ErrorEnvelope, *, output_format: OutputFormat) -> int:
|
|
282
|
+
"""Write the envelope to the right target and return the exit code."""
|
|
283
|
+
target = select_render_target(output_format=output_format, stdout_is_tty=sys.stdout.isatty())
|
|
284
|
+
if target is RenderTarget.JSON_STDOUT:
|
|
285
|
+
print(render_to_json(env), file=sys.stdout)
|
|
286
|
+
elif target is RenderTarget.JSON_STDERR:
|
|
287
|
+
print(render_to_json(env), file=sys.stderr)
|
|
288
|
+
else:
|
|
289
|
+
render_to_rich_stderr(env, stream=sys.stderr)
|
|
290
|
+
return EXIT_CODES.get(env.type, 1)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""`nsc skill` — install the bundled portable Skill into agent harnesses.
|
|
2
|
+
|
|
3
|
+
Phase 5c ships `install`. Default behavior is dry-run; `--apply` copies the
|
|
4
|
+
bundled `SKILL.md` to each target's documented Skills directory. Targets
|
|
5
|
+
whose convention is unknown print actionable manual instructions instead of
|
|
6
|
+
guessing a path silently.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from nsc.skill import BUNDLE_NAME, bundle_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Target(StrEnum):
|
|
25
|
+
CLAUDE_CODE = "claude-code"
|
|
26
|
+
CODEX = "codex"
|
|
27
|
+
GEMINI = "gemini"
|
|
28
|
+
COPILOT = "copilot"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _OutputFormat(StrEnum):
|
|
32
|
+
TABLE = "table"
|
|
33
|
+
JSON = "json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class _Resolution:
|
|
38
|
+
target: _Target
|
|
39
|
+
path: Path | None
|
|
40
|
+
manual_instructions: str | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _home() -> Path:
|
|
44
|
+
return Path(os.environ.get("HOME") or os.path.expanduser("~"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_claude_code() -> _Resolution:
|
|
48
|
+
return _Resolution(
|
|
49
|
+
target=_Target.CLAUDE_CODE,
|
|
50
|
+
path=_home() / ".claude" / "skills" / BUNDLE_NAME / "SKILL.md",
|
|
51
|
+
manual_instructions=None,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_codex() -> _Resolution:
|
|
56
|
+
# Per T1 research (developers.openai.com/codex/skills): Codex CLI loads
|
|
57
|
+
# user-scoped skills from $HOME/.agents/skills/<name>/SKILL.md. The
|
|
58
|
+
# `.agents/` prefix is agent-tool-neutral, not codex-specific.
|
|
59
|
+
return _Resolution(
|
|
60
|
+
target=_Target.CODEX,
|
|
61
|
+
path=_home() / ".agents" / "skills" / BUNDLE_NAME / "SKILL.md",
|
|
62
|
+
manual_instructions=None,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_gemini() -> _Resolution:
|
|
67
|
+
return _Resolution(
|
|
68
|
+
target=_Target.GEMINI,
|
|
69
|
+
path=None,
|
|
70
|
+
manual_instructions=(
|
|
71
|
+
"Gemini CLI does not document a programmatic Skill install path "
|
|
72
|
+
"as of this nsc release. To use the bundled Skill: paste its "
|
|
73
|
+
"content into a project-scoped GEMINI.md or your Gemini system "
|
|
74
|
+
"prompt."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_copilot() -> _Resolution:
|
|
80
|
+
return _Resolution(
|
|
81
|
+
target=_Target.COPILOT,
|
|
82
|
+
path=None,
|
|
83
|
+
manual_instructions=(
|
|
84
|
+
"GitHub Copilot CLI does not document a stable user-scoped Skill "
|
|
85
|
+
"install path as of this nsc release. To use the bundled Skill: "
|
|
86
|
+
"paste its content into .github/copilot-instructions.md or your "
|
|
87
|
+
"team's Copilot configuration."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_RESOLVERS = {
|
|
93
|
+
_Target.CLAUDE_CODE: _resolve_claude_code,
|
|
94
|
+
_Target.CODEX: _resolve_codex,
|
|
95
|
+
_Target.GEMINI: _resolve_gemini,
|
|
96
|
+
_Target.COPILOT: _resolve_copilot,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve(target: _Target) -> _Resolution:
|
|
101
|
+
return _RESOLVERS[target]()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _render_table(resolution: _Resolution, mode: str, written: bool, source: Path) -> str:
|
|
105
|
+
lines: list[str] = []
|
|
106
|
+
if resolution.path is None:
|
|
107
|
+
lines.append(f"nsc skill install --target {resolution.target.value}")
|
|
108
|
+
lines.append("")
|
|
109
|
+
assert resolution.manual_instructions is not None
|
|
110
|
+
lines.append(resolution.manual_instructions)
|
|
111
|
+
lines.append("")
|
|
112
|
+
lines.append(f" source SKILL.md: {source}")
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
|
|
115
|
+
if mode == "dry-run":
|
|
116
|
+
lines.append(
|
|
117
|
+
f"nsc skill install --target {resolution.target.value} (dry-run) "
|
|
118
|
+
"— pass --apply to install"
|
|
119
|
+
)
|
|
120
|
+
lines.append(f" would write to {resolution.path}")
|
|
121
|
+
lines.append(f" source: {source}")
|
|
122
|
+
elif written:
|
|
123
|
+
lines.append(f"✓ installed netbox-super-cli skill at {resolution.path}")
|
|
124
|
+
else:
|
|
125
|
+
lines.append(f"nsc skill install --target {resolution.target.value} (no-op)")
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _render_json(resolution: _Resolution, mode: str, written: bool, source: Path) -> str:
|
|
130
|
+
payload: dict[str, object] = {
|
|
131
|
+
"mode": mode,
|
|
132
|
+
"target": resolution.target.value,
|
|
133
|
+
"source": str(source),
|
|
134
|
+
"destination": str(resolution.path) if resolution.path else None,
|
|
135
|
+
"manual": resolution.path is None,
|
|
136
|
+
}
|
|
137
|
+
if resolution.manual_instructions is not None:
|
|
138
|
+
payload["instructions"] = resolution.manual_instructions
|
|
139
|
+
if mode == "apply":
|
|
140
|
+
payload["written"] = written
|
|
141
|
+
return json.dumps(payload)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def register(app: typer.Typer) -> None:
|
|
145
|
+
skill_app = typer.Typer(
|
|
146
|
+
name="skill",
|
|
147
|
+
help="Manage the bundled portable Skill for AI agent harnesses.",
|
|
148
|
+
no_args_is_help=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@skill_app.command("install")
|
|
152
|
+
def install_cmd(
|
|
153
|
+
target: Annotated[
|
|
154
|
+
_Target,
|
|
155
|
+
typer.Option(
|
|
156
|
+
"--target",
|
|
157
|
+
"-t",
|
|
158
|
+
help="Which agent harness to install the Skill into.",
|
|
159
|
+
case_sensitive=False,
|
|
160
|
+
),
|
|
161
|
+
],
|
|
162
|
+
apply_: Annotated[
|
|
163
|
+
bool,
|
|
164
|
+
typer.Option("--apply", help="Actually copy the file (default: dry-run)."),
|
|
165
|
+
] = False,
|
|
166
|
+
output: Annotated[
|
|
167
|
+
_OutputFormat,
|
|
168
|
+
typer.Option("--output", "-o", help="table|json"),
|
|
169
|
+
] = _OutputFormat.TABLE,
|
|
170
|
+
) -> None:
|
|
171
|
+
resolution = _resolve(target)
|
|
172
|
+
mode = "apply" if apply_ else "dry-run"
|
|
173
|
+
written = False
|
|
174
|
+
|
|
175
|
+
with bundle_path() as source:
|
|
176
|
+
if apply_ and resolution.path is not None:
|
|
177
|
+
resolution.path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
shutil.copy2(source, resolution.path)
|
|
179
|
+
written = True
|
|
180
|
+
|
|
181
|
+
if output is _OutputFormat.JSON:
|
|
182
|
+
typer.echo(_render_json(resolution, mode, written, source))
|
|
183
|
+
else:
|
|
184
|
+
typer.echo(_render_table(resolution, mode, written, source))
|
|
185
|
+
|
|
186
|
+
app.add_typer(skill_app, name="skill")
|
nsc/cli/writes/apply.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Stage 3 of the write pipeline: build the wire-shape ResolvedRequest.
|
|
2
|
+
|
|
3
|
+
3b emits one ResolvedRequest per input record (single-record loop shape).
|
|
4
|
+
3c adds a bulk variant gated by ``mode=RoutingMode.BULK`` that produces a
|
|
5
|
+
single request with a list-shaped body and ``record_indices=[0..N)``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
from nsc.cli.writes.bulk import RoutingMode
|
|
15
|
+
from nsc.cli.writes.coercion import FALSY as _FALSY
|
|
16
|
+
from nsc.cli.writes.coercion import TRUTHY as _TRUTHY
|
|
17
|
+
from nsc.cli.writes.input import RawWriteInput
|
|
18
|
+
from nsc.model.command_model import FieldShape, HttpMethod, Operation, PrimitiveType
|
|
19
|
+
from nsc.output.headers import SENSITIVE_HEADERS
|
|
20
|
+
|
|
21
|
+
_REDACTED_AUTH = "Token <redacted>"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Frozen(BaseModel):
|
|
25
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResolvedRequest(_Frozen):
|
|
29
|
+
method: HttpMethod
|
|
30
|
+
url: str
|
|
31
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
32
|
+
query: dict[str, Any] = Field(default_factory=dict)
|
|
33
|
+
body: dict[str, Any] | list[dict[str, Any]] | None = None
|
|
34
|
+
path_vars: dict[str, str] = Field(default_factory=dict)
|
|
35
|
+
operation_id: str
|
|
36
|
+
record_indices: list[int] = Field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve(
|
|
40
|
+
raw: RawWriteInput,
|
|
41
|
+
operation: Operation,
|
|
42
|
+
*,
|
|
43
|
+
path_vars: dict[str, str],
|
|
44
|
+
base_url: str,
|
|
45
|
+
headers: dict[str, str] | None = None,
|
|
46
|
+
mode: RoutingMode = RoutingMode.SINGLE,
|
|
47
|
+
) -> list[ResolvedRequest]:
|
|
48
|
+
"""Build wire-shape requests.
|
|
49
|
+
|
|
50
|
+
Mode drives the body shape:
|
|
51
|
+
SINGLE / LOOP -> one ResolvedRequest per record, body=dict, record_indices=[i].
|
|
52
|
+
BULK -> one ResolvedRequest, body=list[dict], record_indices=[0..N).
|
|
53
|
+
|
|
54
|
+
SINGLE and LOOP differ only in caller intent (single record vs. multiple
|
|
55
|
+
records that the user opted out of bulk for). The wire shape is identical
|
|
56
|
+
per request.
|
|
57
|
+
"""
|
|
58
|
+
base = base_url.rstrip("/")
|
|
59
|
+
url = base + operation.path.format(**path_vars)
|
|
60
|
+
visible_headers = _redact(headers or {})
|
|
61
|
+
records = raw.records or [{}]
|
|
62
|
+
|
|
63
|
+
if mode is RoutingMode.BULK:
|
|
64
|
+
body_list: list[dict[str, Any]] = []
|
|
65
|
+
for record in records:
|
|
66
|
+
shaped = _shape_body(record, operation)
|
|
67
|
+
assert shaped is not None, "bulk path requires non-None body shape"
|
|
68
|
+
body_list.append(shaped)
|
|
69
|
+
return [
|
|
70
|
+
ResolvedRequest(
|
|
71
|
+
method=operation.http_method,
|
|
72
|
+
url=url,
|
|
73
|
+
headers=visible_headers,
|
|
74
|
+
query={},
|
|
75
|
+
body=body_list,
|
|
76
|
+
path_vars=dict(path_vars),
|
|
77
|
+
operation_id=operation.operation_id,
|
|
78
|
+
record_indices=list(range(len(records))),
|
|
79
|
+
)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
out: list[ResolvedRequest] = []
|
|
83
|
+
for index, record in enumerate(records):
|
|
84
|
+
body = _shape_body(record, operation)
|
|
85
|
+
out.append(
|
|
86
|
+
ResolvedRequest(
|
|
87
|
+
method=operation.http_method,
|
|
88
|
+
url=url,
|
|
89
|
+
headers=visible_headers,
|
|
90
|
+
query={},
|
|
91
|
+
body=body,
|
|
92
|
+
path_vars=dict(path_vars),
|
|
93
|
+
operation_id=operation.operation_id,
|
|
94
|
+
record_indices=[index],
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _shape_body(record: dict[str, Any], operation: Operation) -> dict[str, Any] | None:
|
|
101
|
+
if operation.http_method is HttpMethod.DELETE:
|
|
102
|
+
return None
|
|
103
|
+
if operation.request_body is None:
|
|
104
|
+
return record or None
|
|
105
|
+
cast_record: dict[str, Any] = {}
|
|
106
|
+
for key, value in record.items():
|
|
107
|
+
shape = operation.request_body.fields.get(key)
|
|
108
|
+
cast_record[key] = _cast(value, shape)
|
|
109
|
+
return cast_record
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cast(value: Any, shape: FieldShape | None) -> Any:
|
|
113
|
+
if shape is None or value is None:
|
|
114
|
+
return value
|
|
115
|
+
match shape.primitive:
|
|
116
|
+
case PrimitiveType.INTEGER:
|
|
117
|
+
return _cast_int(value)
|
|
118
|
+
case PrimitiveType.NUMBER:
|
|
119
|
+
return _cast_number(value)
|
|
120
|
+
case PrimitiveType.BOOLEAN:
|
|
121
|
+
return _cast_bool(value)
|
|
122
|
+
case _:
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cast_int(value: Any) -> Any:
|
|
127
|
+
if isinstance(value, bool):
|
|
128
|
+
return value
|
|
129
|
+
if isinstance(value, str):
|
|
130
|
+
try:
|
|
131
|
+
return int(value)
|
|
132
|
+
except ValueError:
|
|
133
|
+
return value
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _cast_number(value: Any) -> Any:
|
|
138
|
+
if isinstance(value, str):
|
|
139
|
+
try:
|
|
140
|
+
return float(value)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return value
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _cast_bool(value: Any) -> Any:
|
|
147
|
+
if isinstance(value, bool):
|
|
148
|
+
return value
|
|
149
|
+
if isinstance(value, str):
|
|
150
|
+
lowered = value.strip().lower()
|
|
151
|
+
if lowered in _TRUTHY:
|
|
152
|
+
return True
|
|
153
|
+
if lowered in _FALSY:
|
|
154
|
+
return False
|
|
155
|
+
return value
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_OTHER_SENSITIVE_HEADERS = SENSITIVE_HEADERS - {"authorization"}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _redact(headers: dict[str, str]) -> dict[str, str]:
|
|
162
|
+
out: dict[str, str] = {}
|
|
163
|
+
for k, v in headers.items():
|
|
164
|
+
if k.lower() == "authorization":
|
|
165
|
+
out[k] = _REDACTED_AUTH
|
|
166
|
+
elif k.lower() in _OTHER_SENSITIVE_HEADERS:
|
|
167
|
+
out[k] = "<redacted>"
|
|
168
|
+
else:
|
|
169
|
+
out[k] = v
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
CastSource = Literal["file", "field_flag", "default", "schema_cast"]
|
|
174
|
+
"""Reserved for ExplainTrace consumers. Defined here so apply.py owns the type."""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = ["CastSource", "ResolvedRequest", "resolve"]
|