fix(cli): consolidate CLI dispatch surface and add nap project init

The dispatcher in `napseer_mcp_server.py::cli_main` had grown a duplicated
fragment of the project-command dispatcher (the attach/create/claim/status/
encryption block) that survived an earlier refactor. Because the fragment
was left at function-body indent after the `return` that closes the
canonical block, every `nap` subcommand that hands off to the MCP server
crashed at import time:

    File "/home/matias/.local/share/napseer/napseer_mcp_server.py",
    line 13561
        "frontend_url": cli_option(argv[3:], "--frontend-url",
                                   default=None),
    IndentationError: unexpected indent

That broke `nap project attach`, `nap project create`, `nap gateway
start`, and every other `nap *` command that dispatches to the MCP
server, surfacing as an "unknown command" / 401-style failure at the
caller. The pinned published revision `cbdc1840` still ships the broken
file; the next backend build needs to bump to this commit.

Rewrite `cli_main` so each `if command == "<cmd>":` block has a
single, complete dispatcher with no leftover fragments. Promote
`init` (first-time directory setup, no browser) to a canonical
`nap project init` subcommand, leave `attach` calling
`operator_loopback_login`, and `create` / `claim` unchanged.

Drop deprecated aliases (`ls`, `new`, `send`, `rm`, `set-status`,
`admin`, `global-list`, positional-state shortcut for
`nap project encryption set`, `process --all` shortcut) with clear
RuntimeError pointers to the canonical verbs.

Tests: `tests/test_cli_dispatch_consolidation.py` asserts canonical
verbs route correctly and dropped aliases raise with a clear pointer;
`tests/test_project_init_wizard.py` exercises the
`nap project init` wizard end-to-end;
`tests/test_nap_install_gateway_routes.py` updated for the
`process --all` removal. 19/19 pytest cases pass.

See /incidents/2026-06-15/napseer-mcp-server-py-13561-indentation-error-upstream
in Napseer memory for the prior incident writeup.
This commit is contained in:
HumbleTurtle
2026-06-15 14:10:39 -03:00
parent 9ea082c919
commit e1a4cf93f0
5 changed files with 695 additions and 137 deletions
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Smoke-test the consolidated CLI dispatch surface.
Verifies that:
- canonical verbs (status, list, create, update, delete, run, preregister, activate)
are accepted and route to the right function.
- dropped aliases (ls, new, send, rm, set-status, admin, global-list, etc.)
now raise RuntimeError with a clear pointer to the canonical verb.
- `nap project encryption set` requires --state; positional-state shortcut is gone.
- `nap project encryption plaintext` (action-as-state) now errors.
"""
import importlib.util
import pathlib
import sys
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server_dispatch_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def expect_unknown(mod, argv, expected_fragment):
"""Run cli_main(argv) and assert it raises RuntimeError with the fragment."""
try:
mod.cli_main(argv)
except RuntimeError as exc:
assert expected_fragment in str(exc), (argv, str(exc))
else:
raise AssertionError(f"expected RuntimeError for {argv}")
def test_chat_secret_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "chat", "secret", "init"],
["napseer_mcp_server.py", "chat", "secret", "configure"],
]:
expect_unknown(mod, args, "unknown chat secret action")
def test_lineage_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "lineage", "list"],
["napseer_mcp_server.py", "lineage", "ls"],
["napseer_mcp_server.py", "lineage", "check"],
]:
expect_unknown(mod, args, "unknown lineage subcommand")
def test_plan_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "plan", "ls"],
["napseer_mcp_server.py", "plan", "active"],
["napseer_mcp_server.py", "plan", "card-from"],
["napseer_mcp_server.py", "plan", "create-card"],
]:
expect_unknown(mod, args, "unknown plan subcommand")
def test_feedback_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "feedback", "ls"],
["napseer_mcp_server.py", "feedback", "admin"],
["napseer_mcp_server.py", "feedback", "admin-list"],
["napseer_mcp_server.py", "feedback", "global-list"],
["napseer_mcp_server.py", "feedback", "status", "abc", "open"],
["napseer_mcp_server.py", "feedback", "set-status", "abc", "open"],
["napseer_mcp_server.py", "feedback", "update", "abc", "open"],
["napseer_mcp_server.py", "feedback", "resolved", "abc"],
]:
expect_unknown(mod, args, "unknown feedback subcommand")
def test_agent_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "agent", "ls"],
["napseer_mcp_server.py", "agent", "registered"],
["napseer_mcp_server.py", "agent", "workspace-list"],
]:
expect_unknown(mod, args, "unknown agent subcommand")
def test_gateway_status_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "gateway", "ls"],
["napseer_mcp_server.py", "gateway", "list"],
]:
expect_unknown(mod, args, "unknown gateway subcommand")
def test_gateway_terminal_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "gateway", "terminals", "list"],
["napseer_mcp_server.py", "gateway", "terminal", "new"],
["napseer_mcp_server.py", "gateway", "terminal", "rm", "x"],
["napseer_mcp_server.py", "gateway", "terminal", "send", "x"],
]:
expect_unknown(mod, args, "unknown gateway")
def test_gateway_schedule_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "gateway", "schedules", "list"],
["napseer_mcp_server.py", "gateway", "cron", "list"],
["napseer_mcp_server.py", "gateway", "crons", "list"],
["napseer_mcp_server.py", "gateway", "schedule", "ls"],
["napseer_mcp_server.py", "gateway", "schedule", "new"],
["napseer_mcp_server.py", "gateway", "schedule", "edit", "x"],
["napseer_mcp_server.py", "gateway", "schedule", "rm", "x"],
["napseer_mcp_server.py", "gateway", "schedule", "run-now", "x"],
]:
expect_unknown(mod, args, "unknown gateway")
def test_gateway_service_run_rejected():
"""Lifecycle verbs (run, start, etc.) moved to `nap service`."""
mod = load_module()
for args in [
["napseer_mcp_server.py", "gateway", "service", "run"],
["napseer_mcp_server.py", "gateway", "service", "start"],
["napseer_mcp_server.py", "gateway", "service", "register"],
["napseer_mcp_server.py", "gateway", "service", "bootstrap"],
]:
expect_unknown(mod, args, "nap service start")
def test_gateway_vault_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "gateway", "vault", "list"],
["napseer_mcp_server.py", "gateway", "vault", "ls"],
]:
expect_unknown(mod, args, "gateway vault action: list")
def test_gateway_rotate_passphrase_aliases_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "gateway", "passwd"],
["napseer_mcp_server.py", "gateway", "change-passphrase"],
]:
expect_unknown(mod, args, "rotate-passphrase")
def test_project_encryption_state_flag_required():
"""nap project encryption set requires --state; positional state is rejected."""
mod = load_module()
# Without --state, the dispatch should raise
expect_unknown(
mod,
["napseer_mcp_server.py", "project", "encryption", "set", "plaintext"],
"--state plaintext|encrypted",
)
def test_project_encryption_action_as_state_rejected():
"""`nap project encryption plaintext` (the old shortcut) must error."""
mod = load_module()
expect_unknown(
mod,
["napseer_mcp_server.py", "project", "encryption", "plaintext"],
"project encryption",
)
def test_project_encryption_show_alias_rejected():
mod = load_module()
for args in [
["napseer_mcp_server.py", "project", "encryption", "state"],
["napseer_mcp_server.py", "project", "encryption", "show"],
["napseer_mcp_server.py", "project", "encryption", "encrypted"],
]:
expect_unknown(mod, args, "project encryption")
if __name__ == "__main__":
test_chat_secret_aliases_rejected()
test_lineage_aliases_rejected()
test_plan_aliases_rejected()
test_feedback_aliases_rejected()
test_agent_aliases_rejected()
test_gateway_status_aliases_rejected()
test_gateway_terminal_aliases_rejected()
test_gateway_schedule_aliases_rejected()
test_gateway_service_run_rejected()
test_gateway_vault_aliases_rejected()
test_gateway_rotate_passphrase_aliases_rejected()
test_project_encryption_state_flag_required()
test_project_encryption_action_as_state_rejected()
test_project_encryption_show_alias_rejected()
print("ok: cli dispatch consolidation tests passed")
+18 -11
View File
@@ -25,7 +25,7 @@ def run():
assert calls == []
assert mod.handle_gateway(["vault", "--help"]) == 0
assert calls == []
assert calls[-1] == ("gateway", ["vault", "--help"])
assert mod.handle_gateway(["vault", "list"]) == 0
assert calls[-1] == ("gateway", ["vault", "list"])
@@ -33,23 +33,24 @@ def run():
assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0
assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"])
assert mod.handle_gateway(["process", "--all"]) == 0
assert calls[-1] == ("gateway", ["vault", "process", "--all"])
try:
mod.handle_gateway(["process", "--all"])
except RuntimeError as exc:
assert "nap gateway vault process" in str(exc), str(exc)
else:
raise AssertionError("deprecated gateway process shortcut must not route")
assert mod.main(["nap", "chat", "secret", "setup"]) == 0
assert calls[-1] == ("chat", ["secret", "setup"])
try:
mod.handle_gateway(["config"])
except RuntimeError as exc:
assert "unknown gateway command" in str(exc)
assert "unknown gateway subcommand" in str(exc)
else:
raise AssertionError("gateway config alias must not route")
try:
mod.handle_gateway(["vault-setup", "list"])
except RuntimeError as exc:
assert "unknown gateway command" in str(exc)
assert "unknown gateway subcommand" in str(exc)
else:
raise AssertionError("deprecated gateway vault-setup alias must not route")
@@ -60,20 +61,26 @@ def run():
assert mod.main(["nap", "status"]) == 0
assert calls[-1] == ("configure", [])
# Install-level dispatch should reject: empty / config / configure / bootstrap / create / ui
for argv in (
["nap", "config"],
["nap", "configure"],
["nap", "bootstrap"],
["nap", "create"],
["nap", "ui"],
["nap", "project", "bootstrap"],
):
try:
mod.main(argv)
except RuntimeError as exc:
assert "unknown" in str(exc)
assert "unknown" in str(exc), (argv, str(exc))
else:
raise AssertionError(f"deprecated route must not be accepted: {argv}")
raise AssertionError(f"deprecated route must not be accepted at install level: {argv}")
# nap project bootstrap is now a deprecated alias handled by the MCP server
# (the install side forwards it; the MCP side raises with a clear error message).
forwarded = []
mod.run_wrapper = lambda command, args: forwarded.append((command, args)) or 0
mod.main(["nap", "project", "bootstrap"])
assert forwarded == [("project", ["bootstrap"])], forwarded
print("ok: nap installer gateway route smoke passed")
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Smoke-test the `nap project init` first-time setup wizard."""
import importlib.util
import pathlib
import sys
PROJECT_ID = "22222222-2222-2222-2222-222222222222"
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server_project_init_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_init_fresh_directory():
mod = load_module()
enrollment_calls = []
bootstrap_calls = []
saved_payloads = []
mod.TOKEN = None
mod.DEFAULT_PROJECT_ID = None
mod.AUTH = {}
mod.AUTH_PATH = pathlib.Path("/tmp/napseer-test/auth.json")
mod.BASE_URL = "https://api.example.test"
mod.load_public_auth_file = lambda: {}
def fake_ensure_enrolled(args):
enrollment_calls.append(args)
mod.TOKEN = "ssh-token"
return {"status": "enrolled", "token_expires_at": "2099-01-01T00:00:00Z"}
def fake_bootstrap_project(args):
bootstrap_calls.append(args)
mod.DEFAULT_PROJECT_ID = PROJECT_ID
return {
"status": "created",
"project": {"id": PROJECT_ID, "slug": args["slug"], "name": args["name"]},
"project_id": PROJECT_ID,
"encryption_state": "plaintext",
"project_unlocked": True,
"message": "created",
}
mod.ensure_enrolled = fake_ensure_enrolled
mod.bootstrap_project = fake_bootstrap_project
mod.save_auth = lambda updates: saved_payloads.append(dict(updates))
result = mod.cli_project_init([])
assert result["status"] == "initialized", result
assert result["project_id"] == PROJECT_ID
assert result["project"]["slug"], result
assert any(step.get("step") == "enroll" for step in result["steps"]), result["steps"]
assert any(step.get("step") == "create_project" for step in result["steps"]), result["steps"]
assert enrollment_calls, "ensure_enrolled should have been called"
assert bootstrap_calls, "bootstrap_project should have been called"
print("ok: project init fresh-directory wizard passed")
def test_init_already_initialized():
mod = load_module()
mod.TOKEN = "existing-token"
mod.DEFAULT_PROJECT_ID = PROJECT_ID
mod.AUTH = {"project_slug": "my-existing"}
mod.AUTH_PATH = pathlib.Path("/tmp/napseer-test/auth.json")
mod.load_public_auth_file = lambda: {"project_slug": "my-existing"}
enrollment_called = []
bootstrap_called = []
def fake_ensure_enrolled(args):
enrollment_called.append(args)
return {"status": "enrolled"}
def fake_bootstrap_project(args):
bootstrap_called.append(args)
return {}
mod.ensure_enrolled = fake_ensure_enrolled
mod.bootstrap_project = fake_bootstrap_project
result = mod.cli_project_init([])
assert result["status"] == "already_initialized", result
assert result["project_slug"] == "my-existing", result
assert not enrollment_called, "ensure_enrolled should NOT have been called"
assert not bootstrap_called, "bootstrap_project should NOT have been called"
print("ok: project init no-op on already-initialized folder passed")
def test_init_token_only_creates_project():
mod = load_module()
mod.TOKEN = "already-have-token"
mod.DEFAULT_PROJECT_ID = None
mod.AUTH = {}
mod.AUTH_PATH = pathlib.Path("/tmp/napseer-test/auth.json")
mod.load_public_auth_file = lambda: {}
enrollment_called = []
def fake_ensure_enrolled(args):
enrollment_called.append(args)
return {"status": "enrolled"}
def fake_bootstrap_project(args):
mod.DEFAULT_PROJECT_ID = PROJECT_ID
return {
"status": "created",
"project": {"id": PROJECT_ID, "slug": args["slug"], "name": args["name"]},
"project_id": PROJECT_ID,
"encryption_state": "plaintext",
"project_unlocked": True,
"message": "created",
}
mod.ensure_enrolled = fake_ensure_enrolled
mod.bootstrap_project = fake_bootstrap_project
result = mod.cli_project_init(["--slug", "my-slug", "--name", "My Name"])
assert result["status"] == "initialized", result
assert result["project_id"] == PROJECT_ID, result
assert not enrollment_called, "ensure_enrolled should NOT be called when TOKEN is already set"
enroll_step = next(s for s in result["steps"] if s["step"] == "enroll")
assert enroll_step["status"] == "skipped", enroll_step
create_step = next(s for s in result["steps"] if s["step"] == "create_project")
assert create_step["status"] == "created", create_step
assert fake_bootstrap_project.__call__ is not None
print("ok: project init creates project when token exists passed")
def test_cli_main_dispatches_init():
"""Verify the cli_main dispatch routes `project init` to cli_project_init."""
import io
import contextlib
mod = load_module()
mod.TOKEN = "t"
mod.DEFAULT_PROJECT_ID = PROJECT_ID
mod.AUTH = {"project_slug": "x"}
mod.AUTH_PATH = pathlib.Path("/tmp/napseer-test/auth.json")
mod.load_public_auth_file = lambda: {"project_slug": "x"}
captured = []
mod.cli_project_init = lambda args: (captured.append(args) or {"status": "already_initialized"})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = mod.cli_main(["napseer_mcp_server.py", "project", "init", "--no-browser"])
assert rc is None
assert captured == [["--no-browser"]]
assert '"already_initialized"' in buf.getvalue(), buf.getvalue()
print("ok: cli_main routes `project init` to cli_project_init passed")
def test_cli_main_rejects_old_aliases():
"""Verify `auth login` / `project bootstrap-existing` are no longer accepted."""
mod = load_module()
cases = [
(["napseer_mcp_server.py", "auth", "login"], "unknown auth subcommand: login"),
(["napseer_mcp_server.py", "auth", "operator-login"], "unknown auth subcommand: operator-login"),
(["napseer_mcp_server.py", "project", "login"], "unknown project subcommand: login"),
(["napseer_mcp_server.py", "project", "bootstrap-existing"], "unknown project subcommand: bootstrap-existing"),
]
for args, expected_substring in cases:
try:
mod.cli_main(args)
except RuntimeError as exc:
assert expected_substring in str(exc), (args, str(exc))
else:
raise AssertionError(f"expected RuntimeError for {args}")
print("ok: cli_main rejects old auth/project aliases with clear errors passed")
if __name__ == "__main__":
test_init_fresh_directory()
test_init_already_initialized()
test_init_token_only_creates_project()
test_cli_main_dispatches_init()
test_cli_main_rejects_old_aliases()
print("ok: project init wizard smoke tests passed")