diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 015c327..c1c9508 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -7344,6 +7344,85 @@ def cli_project_create(args): payload["passphrase"] = passphrase return bootstrap_project(payload) +def cli_project_init(args): + """First-time setup wizard for a fresh directory. + + Steps, in order, only running the ones that are needed: + 1. SSH-key enroll this machine (anonymous) if no bearer token exists. + 2. Create a project on the backend if none is configured for this folder. + 3. Persist the resulting project/slug/name in ./.napseer/auth.json. + + No browser is opened. Use `nap project attach` to OAuth-attach a pre-existing + project to a user account, or `nap project claim` to upgrade an anonymous + project to a claimed one. + """ + positionals = cli_positionals(args) + slug = cli_option(args, "--slug", default=positionals[0] if positionals else default_project_slug()) + name = cli_option(args, "--name", default=default_project_name(slug)) + description = cli_option(args, "--description", default="") + + already_initialized = bool(TOKEN) and bool(DEFAULT_PROJECT_ID) + if already_initialized: + existing_slug = load_public_auth_file().get("project_slug") or "" + return { + "status": "already_initialized", + "message": ( + "This folder is already initialized. " + f"Project '{existing_slug}' is configured at {AUTH_PATH}." + ), + "project_id": DEFAULT_PROJECT_ID, + "project_slug": existing_slug, + "auth_path": str(AUTH_PATH), + } + + steps = [] + if not TOKEN: + enrollment = ensure_enrolled({ + "slug": slug, + "worker_name": f"{slug}-agent", + }) + steps.append({ + "step": "enroll", + "status": enrollment.get("status", "enrolled"), + "token_expires_at": enrollment.get("token_expires_at"), + }) + else: + steps.append({"step": "enroll", "status": "skipped", "reason": "token already present"}) + + if not DEFAULT_PROJECT_ID: + bootstrap = bootstrap_project({ + "slug": slug, + "name": name, + "description": description, + "encryption": "plaintext", + }) + steps.append({ + "step": "create_project", + "status": bootstrap.get("status"), + "project_id": bootstrap.get("project_id"), + }) + return { + **bootstrap, + "status": "initialized", + "message": ( + f"Folder initialized. Project '{bootstrap['project']['slug']}' " + f"created and stored in {AUTH_PATH}. Next: `nap project attach` " + "to link it to your Napseer account, or `nap gateway configure` " + "to start a local gateway." + ), + "steps": steps, + "auth_path": str(AUTH_PATH), + } + + steps.append({"step": "create_project", "status": "skipped", "reason": "project already configured"}) + return { + "status": "initialized", + "message": "Folder initialized; existing project kept.", + "project_id": DEFAULT_PROJECT_ID, + "auth_path": str(AUTH_PATH), + "steps": steps, + } + def parse_tmux_target(value): value = str(value or "").strip() @@ -7421,35 +7500,24 @@ def cli_gateway_configure(args): gateway_setup({"passphrase": passphrase}) if not gateway_is_unlocked(): gateway_open_local(passphrase) - target = parse_tmux_target(cli_option(args, "--target", default="")) - command = default_command or "" - if not command and sys.stdin.isatty(): - value = input(f"Default terminal command [{normalize_gateway_command()}]: ").strip() - command = value or normalize_gateway_command() - if not command: - command = normalize_gateway_command() + command = default_command or normalize_gateway_command() payload = { "session": cli_option(args, "--session", default=target.get("session", "")), "window": cli_option(args, "--window", default=target.get("window", "")), "pane": cli_option(args, "--pane", default=target.get("pane", "")), "default_command": command, } - if "--target" not in args and "--session" not in args: - payload["session"] = "" - elif not payload["session"]: - payload.update(current_tmux_target()) if "--target" in args or "--session" in args: if not payload.get("session"): - payload["session"] = input("tmux session: ").strip() + raise RuntimeError( + "nap gateway configure requires --session NAME (or --target session:window.pane). " + "Pass --no-prompt is no longer needed; the command is non-interactive." + ) if not payload.get("window"): - value = input("tmux window, optional: ").strip() - if value: - payload["window"] = value - if not payload.get("pane"): - value = input("tmux pane, optional: ").strip() - if value: - payload["pane"] = value + raise RuntimeError( + "nap gateway configure requires --window NAME when --session is set." + ) return gateway_tmux_configure(payload) @@ -7500,14 +7568,17 @@ def mcp_gateway_configure(args): gateway_open_local(passphrase) target = parse_tmux_target(args.get("target", "")) + if not (args.get("target") or args.get("session")): + raise RuntimeError( + "nap_gateway_configure requires `session` (or `target` session:window.pane). " + "For interactive tmux discovery, use the CLI nap gateway configure with --target." + ) payload = { "session": args.get("session", target.get("session", "")), "window": args.get("window", target.get("window", "")), "pane": args.get("pane", target.get("pane", "")), "default_command": default_command or normalize_gateway_command(), } - if not args.get("target") and not args.get("session"): - payload["session"] = "" return gateway_tmux_configure(payload) @@ -12755,7 +12826,7 @@ def cli_main(argv): if command == "auth": subcommand = argv[2] if len(argv) > 2 else "status" args = argv[3:] - if subcommand in {"login-local", "operator-login", "login"}: + if subcommand == "login-local": print(json.dumps(operator_loopback_login({ "frontend_url": cli_option(args, "--frontend-url", default=None), "api_base_url": cli_option(args, "--api-base-url", "--base-url", default=None), @@ -12767,11 +12838,15 @@ def cli_main(argv): if subcommand in {"status", "show"}: print(json.dumps(cli_configure_status(), indent=2)) return + if subcommand in {"login", "operator-login"}: + raise RuntimeError( + f"unknown auth subcommand: {subcommand}. Use `nap auth login-local`." + ) print("Usage: napseer_mcp_server.py auth [login-local [--frontend-url URL] [--api-base-url URL] [--no-browser] [--timeout SECONDS]|status]") return if command == "project": subcommand = argv[2] if len(argv) > 2 else "help" - if subcommand in {"init", "attach", "bootstrap-existing"}: + if subcommand == "attach": print(json.dumps(operator_loopback_login({ "frontend_url": cli_option(argv[3:], "--frontend-url", default=None), "api_base_url": cli_option(argv[3:], "--api-base-url", "--base-url", default=None), @@ -12780,10 +12855,13 @@ def cli_main(argv): "port": cli_option(argv[3:], "--port", default=0), }), indent=2)) return + if subcommand == "init": + print(json.dumps(cli_project_init(argv[3:]), indent=2)) + return if subcommand == "create": print(json.dumps(cli_project_create(argv[3:]), indent=2)) return - if subcommand in {"claim", "login"}: + if subcommand == "claim": print(json.dumps(cli_project_claim(argv[3:]), indent=2)) return if subcommand in {"status", "current"}: @@ -12791,46 +12869,60 @@ def cli_main(argv): return if subcommand == "encryption": action = argv[3] if len(argv) > 3 else "status" - if action in {"status", "state", "show"}: + if action == "status": print(json.dumps(project_encryption_status({}), indent=2)) return if action == "set": - if len(argv) <= 4: - raise RuntimeError("nap project encryption set requires plaintext or encrypted") + desired = cli_option(argv[4:], "--state", default=None) + if desired not in {"plaintext", "encrypted"}: + raise RuntimeError( + "nap project encryption set requires --state plaintext|encrypted" + ) print(json.dumps(project_encryption_transition({ - "state": argv[4], - "passphrase": cli_option(argv[5:], "--passphrase", default=None), + "state": desired, + "passphrase": cli_option(argv[4:], "--passphrase", default=None), }), indent=2)) return - print(json.dumps(project_encryption_transition({ - "state": action, - "passphrase": cli_option(argv[4:], "--passphrase", default=None), - }), indent=2)) + if action in {"state", "show", "plaintext", "encrypted"}: + raise RuntimeError( + f"unknown project encryption action: {action}. " + "Use `nap project encryption status` or `nap project encryption set --state plaintext|encrypted`." + ) + print("Usage: napseer_mcp_server.py project encryption [status|set --state plaintext|encrypted [--passphrase TEXT]]") return - print("Usage: napseer_mcp_server.py project [init|attach [--no-browser] [--timeout SECONDS]|create [slug] [--name NAME] [--description TEXT] [--encryption plaintext]|claim [--no-browser] [--timeout SECONDS] [--return-url URL]|status|encryption [status|set plaintext|plaintext]]") + if subcommand in {"login", "bootstrap", "bootstrap-existing", "operator-login"}: + raise RuntimeError( + f"unknown project subcommand: {subcommand}. " + "Use one of: init, attach, create, claim, status, encryption." + ) + print("Usage: napseer_mcp_server.py project [init [--slug SLUG] [--name NAME] [--description TEXT]|attach [--frontend-url URL] [--api-base-url URL] [--no-browser] [--timeout SECONDS]|create [slug] [--name NAME] [--description TEXT] [--encryption plaintext]|claim [--frontend-url URL] [--api-base-url URL] [--no-browser] [--timeout SECONDS] [--return-url URL]|status|encryption [status|set --state plaintext|encrypted]]") return if command == "lineage": subcommand = argv[2] if len(argv) > 2 else "status" args = argv[3:] - if subcommand in {"status", "ls", "list", "check"}: + if subcommand == "status": print(json.dumps(lineage_status({ "limit": cli_option(args, "--limit", default=None), "include_archived": cli_flag(args, "--include-archived", "--archived"), "include_items": not cli_flag(args, "--summary-only", "--no-items"), }), indent=2)) return - print("Usage: napseer_mcp_server.py lineage [status|check] [--limit N] [--include-archived] [--summary-only]") + if subcommand in {"list", "ls", "check"}: + raise RuntimeError( + f"unknown lineage subcommand: {subcommand}. Use `nap lineage status`." + ) + print("Usage: napseer_mcp_server.py lineage status [--limit N] [--include-archived] [--summary-only]") return if command == "plan": subcommand = argv[2] if len(argv) > 2 else "list" args = argv[3:] - if subcommand in {"list", "ls", "active"}: + if subcommand == "list": print(json.dumps(list_active_plans({ "limit": cli_option(args, "--limit", default=None), "view": cli_option(args, "--view", default="summary"), }), indent=2)) return - if subcommand in {"to-kanban", "card-from", "create-card"}: + if subcommand == "to-kanban": plan_path = cli_option(args, "--plan-path", "--path", default=None) if not plan_path: plan_path = next((value for value in args if not value.startswith("-")), None) @@ -12848,12 +12940,16 @@ def cli_main(argv): } print(json.dumps(plan_to_kanban_card(payload), indent=2)) return + if subcommand in {"ls", "active", "card-from", "create-card"}: + raise RuntimeError( + f"unknown plan subcommand: {subcommand}. Use one of: list, to-kanban." + ) print("Usage: napseer_mcp_server.py plan [list|to-kanban [--column todo|doing|review|done|backlog] [--title TEXT] [--priority low|normal|high|urgent] [--upsert]]") return if command == "gateway": subcommand = argv[2] if len(argv) > 2 else "status" args = argv[3:] - if subcommand in {"status", "ls", "list"}: + if subcommand == "status": print(json.dumps(gateway_status(), indent=2)) return if subcommand == "configure": @@ -12867,24 +12963,25 @@ def cli_main(argv): cli_gateway_require_unlocked(args, "killing gateway terminal") print(json.dumps(gateway_terminal_kill({}), indent=2)) return - if subcommand in {"terminal", "terminals"}: + if subcommand == "terminal": action = args[0] if args else "list" + if action in {"ls", "status", "new", "rm", "remove", "send"}: + raise RuntimeError( + f"unknown gateway terminal action: {action}. Use one of: list, create, close, capture, key, input." + ) terminal_args = args[1:] if args else [] - if subcommand == "terminals" and action not in {"create", "new", "close", "rm", "remove", "capture", "input", "send"}: - action = "list" - terminal_args = args cli_gateway_require_unlocked(terminal_args, "managing gateway terminals") terminal_id = cli_option(terminal_args, "--terminal", "--terminal-id", "--id", default=None) - if action in {"list", "ls", "status"}: + if action == "list": print(json.dumps(gateway_terminal_sessions({"terminal_id": terminal_id}), indent=2)) return - if action in {"create", "new"}: + if action == "create": print(json.dumps(gateway_terminal_create({ "name": cli_option(terminal_args, "--name", default=None), "command": cli_option(terminal_args, "--command", "--default-command", default=None), }), indent=2)) return - if action in {"close", "rm", "remove"}: + if action == "close": target_id = terminal_id or (terminal_args[0] if terminal_args and not terminal_args[0].startswith("-") else None) print(json.dumps(gateway_terminal_close({"terminal_id": target_id}), indent=2)) return @@ -12898,7 +12995,7 @@ def cli_main(argv): "text": cli_option(terminal_args, "--text", default=None), }), indent=2)) return - if action in {"input", "send"}: + if action == "input": text = cli_option(terminal_args, "--text", "--input", default=None) if text is None: text_parts = [] @@ -12916,15 +13013,19 @@ def cli_main(argv): text = " ".join(text_parts) print(json.dumps(gateway_terminal_input({"terminal_id": terminal_id, "input": text}), indent=2)) return + if action in {"ls", "status", "new", "rm", "remove", "send"}: + raise RuntimeError( + f"unknown gateway terminal action: {action}. Use one of: list, create, close, capture, key, input." + ) raise RuntimeError("unknown gateway terminal action") - if subcommand in {"schedule", "schedules", "cron", "crons"}: + if subcommand == "schedule": action = args[0] if args else "list" + if action in {"ls", "status", "new", "edit", "rm", "remove", "run-now", "now", "schedules", "cron", "crons"}: + raise RuntimeError( + f"unknown gateway schedule action: {action}. Use one of: list, create, update, delete, run." + ) schedule_args = args[1:] if args else [] - if subcommand in {"schedules", "crons"} and action not in {"create", "new", "update", "edit", "delete", "rm", "remove", "run", "run-now", "now"}: - action = "list" - schedule_args = args cli_gateway_require_unlocked(schedule_args, "managing gateway schedules") - schedule_id = cli_option(schedule_args, "--schedule", "--schedule-id", "--id", default=None) payload = { "schedule_id": schedule_id, "name": cli_option(schedule_args, "--name", default=None), @@ -12936,31 +13037,35 @@ def cli_main(argv): payload["enabled"] = False if cli_flag(schedule_args, "--enabled", "--on"): payload["enabled"] = True - if action in {"list", "ls", "status"}: + if action == "list": print(json.dumps(gateway_schedule_list({}), indent=2)) return - if action in {"create", "new"}: + if action == "create": print(json.dumps(gateway_schedule_create(payload), indent=2)) return - if action in {"update", "edit"}: + if action == "update": if not payload.get("schedule_id"): payload["schedule_id"] = schedule_args[0] if schedule_args and not schedule_args[0].startswith("-") else None print(json.dumps(gateway_schedule_update(payload), indent=2)) return - if action in {"delete", "rm", "remove"}: + if action == "delete": if not payload.get("schedule_id"): payload["schedule_id"] = schedule_args[0] if schedule_args and not schedule_args[0].startswith("-") else None print(json.dumps(gateway_schedule_delete(payload), indent=2)) return - if action in {"run", "run-now", "now"}: + if action == "run": if not payload.get("schedule_id"): payload["schedule_id"] = schedule_args[0] if schedule_args and not schedule_args[0].startswith("-") else None print(json.dumps(gateway_schedule_run_now(payload), indent=2)) return + if action in {"ls", "status", "new", "edit", "rm", "remove", "run-now", "now", "schedules", "cron", "crons"}: + raise RuntimeError( + f"unknown gateway schedule action: {action}. Use one of: list, create, update, delete, run." + ) raise RuntimeError("unknown gateway schedule action") - if subcommand in {"service", "preregister"}: - service_action = args[0] if args and subcommand == "service" else "preregister" - service_args = args[1:] if args and subcommand == "service" else args + if subcommand == "service": + service_action = args[0] if args else "preregister" + service_args = args[1:] if args else [] payload = { "bootstrap_token": cli_option(service_args, "--bootstrap-token", default=None), "activation_token": cli_option(service_args, "--activation-token", default=None), @@ -12971,21 +13076,24 @@ def cli_main(argv): "image_ref": cli_option(service_args, "--image", "--image-ref", default=None), "token_expires_in_hours": cli_option(service_args, "--token-expires-in-hours", default=None), } - if service_action in {"preregister", "register", "bootstrap"}: + if service_action == "preregister": print(json.dumps(gateway_service_preregister(payload), indent=2)) return - if service_action in {"activate", "claim"}: + if service_action == "activate": print(json.dumps(gateway_service_activate(payload), indent=2)) return - if service_action in {"run", "start"}: - gateway_service_run(payload) - return - raise RuntimeError("unknown gateway service action; use preregister|activate|run") + if service_action in {"register", "bootstrap", "claim", "run", "start", "ls", "list", "status"}: + raise RuntimeError( + f"unknown gateway service action: {service_action}. " + "Use one of: preregister, activate. " + "Use `nap service start|stop|status|logs` for the local gateway service lifecycle." + ) + raise RuntimeError("unknown gateway service action; use preregister|activate") if subcommand == "vault": action = args[0] if args else "status" vault_args = args[1:] if args else [] if action in {"help", "-h", "--help"}: - print("Usage: napseer_mcp_server.py gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID]") + print("Usage: napseer_mcp_server.py gateway vault [status|process|rotate-secret] [--kind memory] [--all] [--project-id ID]") print("Chat secrets use: nap chat secret setup|status|rotate") return payload = { @@ -12994,30 +13102,42 @@ def cli_main(argv): "complete_all": cli_flag(vault_args, "--all", "--complete-all"), "vault_passphrase": cli_option(vault_args, "--vault-passphrase", "--passphrase", default=None), } - if action in {"list", "ls", "status"}: + if action == "status": print(json.dumps(gateway_vault_status(payload), indent=2)) return if action == "process": print(json.dumps(gateway_process_vault_setup_requests(payload), indent=2)) return if action == "rotate-secret": - if not payload.get("secret_kind"): - payload["secret_kind"] = vault_args[0] if vault_args and not vault_args[0].startswith("-") else "memory" + payload.setdefault("secret_kind", "memory") print(json.dumps(gateway_rotate_project_vault_secret(payload), indent=2)) return - raise RuntimeError("unknown gateway vault action; use status|list|process|rotate-secret") + if action in {"list", "ls"}: + raise RuntimeError( + "unknown gateway vault action: list. Use `nap gateway vault status`." + ) + raise RuntimeError("unknown gateway vault action; use status|process|rotate-secret") if subcommand == "setup": print(json.dumps(gateway_setup({ "passphrase": cli_option(args, "--passphrase", default=None), "default_command": cli_option(args, "--command", default=cli_option(args, "--default-command", default=None)), }), indent=2)) return - if subcommand in {"rotate-passphrase", "change-passphrase", "passwd"}: + if subcommand == "rotate-passphrase": print(json.dumps(gateway_rotate_passphrase({ "new_passphrase": cli_option(args, "--new-passphrase", "--passphrase", default=None), }), indent=2)) return - print("Usage: napseer_mcp_server.py gateway [status|configure [--command CMD] [--target session:window.pane]|vault [status|list|process|rotate-secret] [--kind memory] [--all]|terminal [list|create|close|capture|input]|schedule [list|create|update|delete|run]|restart|kill|setup|rotate-passphrase --new-passphrase TEXT]") + if subcommand in {"change-passphrase", "passwd"}: + raise RuntimeError( + f"unknown gateway subcommand: {subcommand}. Use `nap gateway rotate-passphrase`." + ) + if subcommand in {"ls", "list", "terminals", "schedules", "cron", "crons", "preregister", "register", "bootstrap"}: + raise RuntimeError( + f"unknown gateway subcommand: {subcommand}. " + "Use one of: status, configure, vault, terminal, schedule, service, restart, kill, setup, rotate-passphrase." + ) + print("Usage: napseer_mcp_server.py gateway [status|configure [--command CMD] [--target session:window.pane]|vault [status|process|rotate-secret] [--kind memory] [--all]|terminal [list|create|close|capture|key|input]|schedule [list|create|update|delete|run]|service [preregister|activate]|restart|kill|setup|rotate-passphrase --new-passphrase TEXT]") return if command == "chat": subcommand = argv[2] if len(argv) > 2 else "secret" @@ -13035,9 +13155,13 @@ def cli_main(argv): if action in {"status", "show", "ls", "list"}: print(json.dumps(chat_secret_status(payload), indent=2)) return - if action in {"setup", "configure", "init"}: + if action == "setup": print(json.dumps(chat_secret_setup(payload), indent=2)) return + if action in {"configure", "init"}: + raise RuntimeError( + f"unknown chat secret action: {action}. Use `nap chat secret setup`." + ) if action in {"rotate", "rotate-secret"}: print(json.dumps(chat_secret_rotate(payload), indent=2)) return @@ -13046,7 +13170,7 @@ def cli_main(argv): if command == "feedback": subcommand = argv[2] if len(argv) > 2 else "list" args = argv[3:] - if subcommand in {"list", "ls"}: + if subcommand == "list": query = { "status": cli_option(args, "--status", default=None), "kind": cli_option(args, "--kind", default=None), @@ -13058,7 +13182,7 @@ def cli_main(argv): query["status"] = "open" print(json.dumps(list_feedback_reports(query), indent=2)) return - if subcommand in {"admin", "admin-list", "global", "global-list"}: + if subcommand == "global": query = { "account_id": cli_option(args, "--account-id", default=None), "status": cli_option(args, "--status", default=None), @@ -13071,17 +13195,21 @@ def cli_main(argv): query["status"] = "open" print(json.dumps(list_admin_feedback_reports(query), indent=2)) return - if subcommand in {"status", "set-status", "update"}: - if len(args) < 2: - raise RuntimeError("nap feedback status requires ") + if subcommand == "set": + if len(args) < 1: + raise RuntimeError("nap feedback set requires --status STATUS") + feedback_id = args[0] + status = cli_option(args, "--status", default=None) + if not status: + raise RuntimeError("nap feedback set requires --status STATUS") print(json.dumps(update_feedback_status({ - "feedback_id": args[0], - "status": args[1], + "feedback_id": feedback_id, + "status": status, "resolution": cli_option(args, "--resolution", default=None), "notes": cli_option(args, "--notes", default=None), }), indent=2)) return - if subcommand in {"resolve", "resolved"}: + if subcommand == "resolve": if not args: raise RuntimeError("nap feedback resolve requires ") print(json.dumps(update_feedback_status({ @@ -13091,15 +13219,19 @@ def cli_main(argv): "notes": cli_option(args, "--notes", default=None), }), indent=2)) return - print("Usage: napseer_mcp_server.py feedback [list [--status STATUS|--all]|global [--account-id ID] [--status STATUS|--all]|status |resolve [--notes TEXT]]") + if subcommand in {"ls", "admin", "admin-list", "global-list", "status", "set-status", "update", "resolved"}: + raise RuntimeError( + f"unknown feedback subcommand: {subcommand}. Use one of: list, global, set, resolve." + ) + print("Usage: napseer_mcp_server.py feedback [list [--status STATUS|--all]|global [--account-id ID] [--status STATUS|--all]|set --status STATUS [--resolution TEXT] [--notes TEXT]|resolve [--notes TEXT]]") return if command == "agent": subcommand = argv[2] if len(argv) > 2 else "help" slug = argv[3] if len(argv) > 3 else "" - if subcommand in {"list", "ls", "registered"}: + if subcommand == "list": print(json.dumps(list_registered_agents({}), indent=2)) return - if subcommand in {"workspaces", "workspace-list"}: + if subcommand == "workspaces": print(json.dumps(list_agent_workspaces({}), indent=2)) return if subcommand == "create" and slug: @@ -13117,6 +13249,10 @@ def cli_main(argv): except KeyboardInterrupt: return return + if subcommand in {"ls", "registered", "workspace-list"}: + raise RuntimeError( + f"unknown agent subcommand: {subcommand}. Use one of: list, workspaces, create, show, edit." + ) print("Usage: napseer_mcp_server.py agent [list|workspaces|create |show |edit ]") return if command in {"help", "-h", "--help"}: diff --git a/scripts/nap_install.py b/scripts/nap_install.py index 57facc4..3dda5df 100644 --- a/scripts/nap_install.py +++ b/scripts/nap_install.py @@ -3,12 +3,13 @@ Usage: python3 nap_install.py + nap project init nap project create nap project status - nap gateway start - nap gateway stop + nap service start + nap service status + nap service logs nap gateway configure - nap gateway logs nap status nap agent list nap update @@ -231,7 +232,7 @@ def install_assets(update_project=False): }, "updated_project_paths": updated_project_paths, "path_warning": path_warning(), - "next": ["nap project create", "nap gateway configure", "nap gateway start", "nap gateway logs", "nap update"], + "next": ["nap project init", "nap service start", "nap gateway configure", "nap update"], } return result @@ -588,41 +589,57 @@ def service_logs(kind, args): def handle_gateway(args): - subcommand = args[0] if args else "ls" + subcommand = args[0] if args else "help" rest = args[1:] if args else [] - if subcommand in {"help", "-h", "--help"}: - print("Usage: nap gateway [start|stop|status|logs|configure|setup|restart|kill|vault|terminal|schedule|service]") - print(" nap gateway start [--port PORT] Start the local gateway service.") - print(" nap gateway status Show the managed gateway process.") + if subcommand in {"help", "-h", "--help", "ls", "list", "status"}: + print("Usage: nap gateway [configure|setup|restart|kill|vault|terminal|schedule|service]") print(" nap gateway configure [--command CMD] Configure the local gateway command.") print(" nap gateway setup Create or migrate local gateway storage.") - print(" nap gateway vault Show gateway vault status and pending setup requests.") - print(" nap gateway vault process Complete pending setup requests with the vault/content passphrase.") - print(" nap gateway vault rotate-secret --kind memory") - print(" nap chat secret setup Configure chat secret state with the vault/content passphrase.") - return 0 - if subcommand == "vault" and rest and rest[0] in {"help", "-h", "--help"}: - print("Usage: nap gateway vault [status|list|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]") - print(" status Show local gateway state and pending setup requests.") - print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.") - print(" rotate-secret Rotate the memory project secret from the gateway worker path.") - print(" Chat secrets use: nap chat secret setup|status|rotate; setup uses the same vault/content passphrase.") + print(" nap gateway vault ... Show gateway vault status and pending setup requests.") + print(" nap gateway terminal ... Manage gateway tmux terminals.") + print(" nap gateway schedule ... Manage gateway schedules (cron jobs).") + print(" nap gateway service preregister|activate Register or activate a gateway service token.") + print() + print("Service lifecycle (start/stop/status/logs) lives under `nap service`.") + print("Use `nap service start|stop|status|logs` for the local gateway process.") return 0 + if subcommand in {"configure", "setup", "restart", "kill", "vault", "terminal", "schedule", "service"}: + return run_wrapper("gateway", [subcommand, *rest]) + if subcommand in {"start", "stop", "logs", "log"}: + raise RuntimeError( + f"unknown gateway subcommand: {subcommand}. " + "Service lifecycle moved to `nap service `. " + f"Use `nap service {subcommand if subcommand != 'log' else 'logs'}`." + ) if subcommand == "process": - return run_wrapper("gateway", ["vault", "process", *rest]) + raise RuntimeError( + "unknown gateway subcommand: process. Use `nap gateway vault process`." + ) + raise RuntimeError("unknown gateway subcommand. Use `nap gateway help`.") + +def handle_service(args): + subcommand = args[0] if args else "status" + rest = args[1:] if args else [] + if subcommand in {"help", "-h", "--help"}: + print("Usage: nap service [start|stop|status|logs] [--port PORT] [--lines N]") + print(" start Start the local gateway service in this directory.") + print(" stop Stop the local gateway service.") + print(" status Show the managed gateway process state.") + print(" logs Tail the managed gateway log file.") + return 0 if subcommand == "start": return print_json(start_service("gateway", ["--no-browser", *rest])) or 0 if subcommand == "stop": return print_json(stop_service("gateway")) or 0 - if subcommand in {"ls", "list", "status"}: + if subcommand == "status": return print_json(list_service("gateway")) or 0 if subcommand in {"logs", "log"}: return print_json(service_logs("gateway", rest)) or 0 - if subcommand == "configure": - return run_wrapper("gateway", ["configure", *rest]) - if subcommand in {"setup", "restart", "kill", "vault", "terminal", "schedule", "service"}: - return run_wrapper("gateway", [subcommand, *rest]) - raise RuntimeError("unknown gateway command; use nap gateway start|stop|configure|setup|restart|kill|logs|status|vault|terminal|schedule|service") + if subcommand in {"ls", "list"}: + raise RuntimeError( + f"unknown service subcommand: {subcommand}. Use one of: start, stop, status, logs." + ) + raise RuntimeError("unknown service subcommand. Use `nap service help`.") def print_json(value): @@ -636,12 +653,10 @@ def main(argv): if command == "install": print_json(install_assets(update_project=False)) return 0 - if command == "update": - result = install_assets(update_project=True) - print_json({**result, "status": "updated", "message": "Napseer CLI and runtime scripts are updated."}) - return 0 if command == "gateway": return handle_gateway(args) + if command == "service": + return handle_service(args) if command == "chat": return run_wrapper("chat", args or ["secret", "status"]) if command == "status": @@ -649,8 +664,6 @@ def main(argv): if command == "project": if not args: return run_wrapper("project", ["status"]) - if args[0] == "bootstrap": - raise RuntimeError("unknown project command: bootstrap") return run_wrapper("project", args) if command == "lineage": return run_wrapper("lineage", args or ["status"]) @@ -662,6 +675,8 @@ def main(argv): return run_wrapper("auth", args) if command == "feedback": return run_wrapper("feedback", args) + if command == "agent": + return run_wrapper("agent", args) if command == "where": state = state_dir_status() print_json( @@ -680,11 +695,13 @@ def main(argv): ) return 0 if command in {"help", "-h", "--help"}: - print("Usage: nap [auth|project|chat|plan|lineage|gateway|agent|feedback|status|update|where|install]") + print("Usage: nap [auth|project|chat|plan|lineage|gateway|service|agent|feedback|status|update|where|install]") print(" auth Auth commands: login-local, status.") print(" Example: nap auth login-local") - print(" project Project commands: init/attach existing, create, status, encryption.") - print(" Examples: nap project init, nap project create, nap project status") + print(" project Project commands: init, attach, create, claim, status, encryption.") + print(" First-time setup: nap project init (enrolls and creates a project, no browser).") + print(" Attach an existing project to a Napseer account: nap project attach (opens browser).") + print(" Upgrade an anonymous project to a claimed one: nap project claim.") print(" Encrypted project setup is completed by the local gateway: nap gateway vault process") print(" chat Chat secret commands: secret status, secret setup, secret rotate.") print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status") @@ -692,13 +709,15 @@ def main(argv): print(" Example: nap plan to-kanban /plans/example --column todo") print(" lineage Generic source-record -> plan -> kanban lineage checks.") print(" Example: nap lineage status") - print(" gateway Gateway commands: start, stop, configure, setup, restart, kill, logs, status, vault.") - print(" Start auto-selects a local port; use --port to pin one.") - print(" Examples: nap gateway start, nap gateway vault, nap gateway vault process --all") + print(" gateway Gateway operations: configure, setup, restart, kill, vault, terminal, schedule, service.") + print(" Examples: nap gateway configure, nap gateway vault process --all") + print(" service Local gateway service lifecycle: start, stop, status, logs.") + print(" Start auto-selects a port; pass --port to pin one.") + print(" Examples: nap service start, nap service logs --lines 200") print(" agent Agent commands: list, workspaces, create, show, edit.") print(" list shows registered project agents; workspaces shows /agents folders.") - print(" feedback Feedback commands: list, global, status, resolve.") - print(" Examples: nap feedback list, nap feedback global --all, nap feedback resolve --notes TEXT") + print(" feedback Feedback commands: list, global, set, resolve.") + print(" Examples: nap feedback list, nap feedback set --status open, nap feedback resolve --notes TEXT") print(" status Show cwd Napseer configuration and next setup commands.") print(" update Refresh the globally installed nap command and wrapper.") print(" where Show installed nap paths and cwd auth location.") diff --git a/tests/test_cli_dispatch_consolidation.py b/tests/test_cli_dispatch_consolidation.py new file mode 100755 index 0000000..9e4e12e --- /dev/null +++ b/tests/test_cli_dispatch_consolidation.py @@ -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") diff --git a/tests/test_nap_install_gateway_routes.py b/tests/test_nap_install_gateway_routes.py index c0e8017..86e2aeb 100644 --- a/tests/test_nap_install_gateway_routes.py +++ b/tests/test_nap_install_gateway_routes.py @@ -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") diff --git a/tests/test_project_init_wizard.py b/tests/test_project_init_wizard.py new file mode 100755 index 0000000..c0de59d --- /dev/null +++ b/tests/test_project_init_wizard.py @@ -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")