Skip to content

Commit 16ce4d1

Browse files
Thomas Christoryclaude
andcommitted
fix(app): single-source ConfigParseError recovery from _META_COMMANDS (#10)
The recovery branch in `nsc/cli/app.py:_root` listed only 5 of the 7 meta subcommands — `commands` and `config` were missing. Running them against a malformed `~/.nsc/config.yaml` aborted with the unhelpful root-level `Error: <ConfigParseError>` instead of recovering to an empty `Config()`. The recovery set is now sourced from `_META_COMMANDS` directly, eliminating the second source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6f51fff commit 16ce4d1

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

nsc/cli/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ def _root(
240240
try:
241241
config = load_config(default_paths().config_file)
242242
except ConfigParseError as exc:
243-
if ctx.invoked_subcommand in ("cache", "init", "login", "profiles", "skill"):
243+
if ctx.invoked_subcommand in _META_COMMANDS:
244244
config = Config()
245245
else:
246246
typer.echo(f"Error: {exc}", err=True)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Regression tests for issue #10.
2+
3+
A malformed `~/.nsc/config.yaml` must not block meta subcommands. Before this
4+
patch, the `ConfigParseError` recovery branch in `nsc/cli/app.py:_root` listed
5+
only 5 of the 7 meta subcommands — `commands` and `config` were missing, so
6+
running them against a broken YAML failed with the unhelpful root-level
7+
`ConfigParseError` instead of recovering and dispatching to the handler.
8+
9+
The fix sources the recovery set from `_META_COMMANDS` directly, eliminating
10+
the second source of truth.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
from pathlib import Path
17+
18+
import pytest
19+
from typer.testing import CliRunner
20+
21+
from nsc.cli.app import app
22+
23+
24+
def _bundled_schema() -> Path:
25+
bundled = Path(__file__).resolve().parents[2] / "nsc" / "schemas" / "bundled"
26+
candidates = sorted(bundled.glob("netbox-*.json.gz"))
27+
assert candidates
28+
return candidates[-1]
29+
30+
31+
@pytest.fixture
32+
def broken_config_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
33+
home = tmp_path / ".nsc"
34+
home.mkdir()
35+
(home / "config.yaml").write_text(": : : not valid yaml :\n", encoding="utf-8")
36+
monkeypatch.setenv("HOME", str(tmp_path))
37+
monkeypatch.setenv("NSC_HOME", str(home))
38+
monkeypatch.delenv("NSC_PROFILE", raising=False)
39+
monkeypatch.delenv("NSC_URL", raising=False)
40+
monkeypatch.delenv("NSC_TOKEN", raising=False)
41+
return home
42+
43+
44+
def test_commands_runs_with_broken_config(broken_config_home: Path) -> None:
45+
runner = CliRunner()
46+
result = runner.invoke(
47+
app, ["commands", "--output", "json", "--schema", str(_bundled_schema())]
48+
)
49+
assert result.exit_code == 0, result.stderr
50+
json.loads(result.stdout)
51+
52+
53+
def test_config_path_runs_with_broken_config(broken_config_home: Path) -> None:
54+
runner = CliRunner()
55+
result = runner.invoke(app, ["config", "path"])
56+
assert result.exit_code == 0, result.stderr
57+
assert str(broken_config_home / "config.yaml") in result.stdout
58+
59+
60+
def test_cache_prune_runs_with_broken_config(broken_config_home: Path) -> None:
61+
runner = CliRunner()
62+
result = runner.invoke(app, ["cache", "prune"])
63+
assert result.exit_code == 0, result.stderr
64+
65+
66+
def test_skill_install_dry_run_runs_with_broken_config(broken_config_home: Path) -> None:
67+
runner = CliRunner()
68+
result = runner.invoke(app, ["skill", "install", "--target", "codex"])
69+
assert result.exit_code == 0, result.stderr
70+
71+
72+
@pytest.mark.parametrize(
73+
"argv",
74+
[
75+
["init"],
76+
["login"],
77+
["profiles", "list"],
78+
],
79+
)
80+
def test_root_does_not_emit_config_parse_error_for_meta(
81+
argv: list[str], broken_config_home: Path
82+
) -> None:
83+
"""These meta subcommands re-load the config inside their own handlers, so
84+
they may exit non-zero against a broken config — but they must do so via
85+
their own structured envelope, not the root-level `Error: <ConfigParseError>`
86+
message #10 is fixing.
87+
"""
88+
runner = CliRunner()
89+
result = runner.invoke(app, argv, input="")
90+
assert "Error: " not in (result.stderr or ""), (
91+
f"`nsc {' '.join(argv)}` surfaced the root-level config-parse error "
92+
f"instead of recovering: {result.stderr!r}"
93+
)

0 commit comments

Comments
 (0)