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