Files
nap-cli/scripts/nap_install.py
T
HumbleTurtle e1a4cf93f0 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.
2026-06-15 14:10:39 -03:00

735 lines
27 KiB
Python

#!/usr/bin/env python3
"""Install the Napseer local command.
Usage:
python3 nap_install.py
nap project init
nap project create
nap project status
nap service start
nap service status
nap service logs
nap gateway configure
nap status
nap agent list
nap update
"""
import json
import hashlib
import os
import pathlib
import shutil
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE_URL = os.environ.get("NAPSEER_BASE_URL", "https://api.napseer.com").rstrip("/")
INSTALL_DIR = pathlib.Path(os.environ.get("NAPSEER_HOME", pathlib.Path.home() / ".local" / "share" / "napseer"))
BIN_DIR = pathlib.Path(os.environ.get("NAPSEER_BIN_DIR", pathlib.Path.home() / ".local" / "bin"))
SCRIPT_NAMES = (
"napseer_mcp_server.py",
"napseer_spake2.py",
"terminal_init.py",
"terminal_protocol.py",
"terminal_pty_manager.py",
"nap_install.py",
)
HTTP_TIMEOUT_SECONDS = int(os.environ.get("NAPSEER_HTTP_TIMEOUT_SECONDS", "30"))
LOCAL_HOST = "127.0.0.1"
DEFAULT_GATEWAY_PORT = os.environ.get("NAPSEER_GATEWAY_PORT")
RUNTIME_SCRIPT_NAMES = (
"napseer_spake2.py",
"terminal_init.py",
"terminal_protocol.py",
"terminal_pty_manager.py",
"napseer_mcp_server.py",
)
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-05-19"
CLI_GENERATED_SOURCE_REPO = os.environ.get("NAPSEER_CLI_SOURCE_REPO", "https://github.com/napseer/nap-cli")
CLI_GENERATED_SOURCE_REVISION = os.environ.get("NAPSEER_CLI_SOURCE_REVISION", "unresolved")
CLI_GENERATED_SOURCE_REVISION_STATUS = os.environ.get("NAPSEER_CLI_SOURCE_REVISION_STATUS", "unresolved")
def chmod_best_effort(path, mode):
try:
path.chmod(mode)
except OSError:
pass
def normalize_proxy_url(value):
if not value:
return None
proxy_url = str(value).strip()
if not proxy_url:
return None
if "://" not in proxy_url:
proxy_url = f"http://{proxy_url}"
return urllib.parse.urlparse(proxy_url)
def api_proxy_url(parsed):
host = parsed.hostname or ""
if host and urllib.request.proxy_bypass(host):
return None
explicit = (
os.environ.get("NAPSEER_API_PROXY_URL")
or os.environ.get("NAPSEER_HTTP_PROXY_URL")
or os.environ.get("NAPSEER_PROXY_URL")
or os.environ.get("NAPSEER_RELAY_PROXY_URL")
or os.environ.get("NAPSEER_GATEWAY_PROXY_URL")
)
if explicit:
return explicit
proxies = urllib.request.getproxies_environment()
return proxies.get(parsed.scheme) or proxies.get("all")
def api_urlopen(request, timeout):
parsed = urllib.parse.urlparse(request.full_url)
proxy = normalize_proxy_url(api_proxy_url(parsed))
if not proxy:
return urllib.request.urlopen(request, timeout=timeout)
proxy_url = urllib.parse.urlunparse(proxy)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({parsed.scheme: proxy_url}))
return opener.open(request, timeout=timeout)
def read_url_bytes(url, user_agent, label):
request = urllib.request.Request(url, headers={"User-Agent": user_agent}, method="GET")
try:
with api_urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
return response.read()
except (TimeoutError, socket.timeout) as exc:
raise RuntimeError(f"{label} timed out after {HTTP_TIMEOUT_SECONDS}s") from exc
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{label} failed: HTTP {exc.code}: {body}") from exc
def fetch_script(name):
try:
body = read_url_bytes(
f"{BASE_URL}/v1/scripts/{name}",
"nap-install-python/0.1",
f"GET /v1/scripts/{name}",
)
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeError(f"{name} response was not valid JSON") from exc
content = payload.get("content")
if not isinstance(content, str) or not content.strip():
raise RuntimeError(f"{name} response did not include script content")
expected_sha256 = payload.get("sha256")
if isinstance(expected_sha256, str) and expected_sha256.strip():
actual_sha256 = hashlib.sha256(content.encode("utf-8")).hexdigest()
if actual_sha256 != expected_sha256.lower():
raise RuntimeError(
f"{name} backend script sha256 mismatch: expected {expected_sha256.lower()}, got {actual_sha256}"
)
return payload, content
def write_file(path, content, executable=False):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
chmod_best_effort(path, 0o755 if executable else 0o644)
def path_warning():
path_entries = os.environ.get("PATH", "").split(os.pathsep)
if str(BIN_DIR) not in path_entries:
return f"Add {BIN_DIR} to PATH to run nap from any directory."
return None
def source_revision_status_for(value):
if not isinstance(value, str):
return "unresolved"
revision = value.strip()
if not revision or revision in ("unresolved", "unknown", "null"):
return "unresolved"
return "resolved"
def install_assets(update_project=False):
scripts = {}
fetched = {}
for name in SCRIPT_NAMES:
payload, content = fetch_script(name)
fetched[name] = (payload, content)
install_order = []
for name in (*RUNTIME_SCRIPT_NAMES, "nap_install.py", *SCRIPT_NAMES):
if name not in install_order:
install_order.append(name)
for name in install_order:
payload, content = fetched[name]
target = INSTALL_DIR / str(payload.get("filename") or name)
write_file(target, content, executable=name.endswith(".py"))
scripts[name] = {
"path": str(target),
"version": payload.get("version"),
"build_commit": payload.get("build_commit"),
"sha256": payload.get("sha256"),
"source_repo": payload.get("source_repo"),
"source_revision": payload.get("source_revision"),
"source_revision_status": payload.get("source_revision_status")
or source_revision_status_for(payload.get("source_revision")),
"generated_at": payload.get("generated_at"),
"generator_version": payload.get("generator_version"),
"contract_version": payload.get("contract_version"),
"supported_backend_api_versions": payload.get("supported_backend_api_versions"),
"supported_gateway_contract_versions": payload.get("supported_gateway_contract_versions"),
}
installer_content = (INSTALL_DIR / "nap_install.py").read_text(encoding="utf-8")
nap_path = BIN_DIR / "nap"
write_file(nap_path, installer_content, executable=True)
if os.name == "nt":
write_file(BIN_DIR / "nap.cmd", f'@echo off\r\n"{sys.executable}" "{nap_path}" %*\r\n', executable=False)
updated_project_paths = []
if update_project:
for script_name in ("napseer_mcp_server.py", "napseer_spake2.py"):
content = (INSTALL_DIR / script_name).read_text(encoding="utf-8")
for candidate in (
pathlib.Path.cwd() / script_name,
pathlib.Path.cwd() / "resources" / "scripts" / script_name,
):
if candidate.exists():
write_file(candidate, content, executable=script_name.endswith(".py"))
updated_project_paths.append(str(candidate))
installer_payload = fetched["nap_install.py"][0]
cli_source_revision = installer_payload.get("source_revision") or CLI_GENERATED_SOURCE_REVISION
cli_source_revision_status = (
installer_payload.get("source_revision_status")
or CLI_GENERATED_SOURCE_REVISION_STATUS
or source_revision_status_for(cli_source_revision)
)
result = {
"status": "installed",
"message": "Napseer CLI and runtime scripts are installed.",
"bin": str(nap_path),
"install_dir": str(INSTALL_DIR),
"scripts": scripts,
"cli_distribution": {
"contract_version": CLI_DISTRIBUTION_CONTRACT_VERSION,
"source_repo": installer_payload.get("source_repo") or CLI_GENERATED_SOURCE_REPO,
"source_revision": None
if source_revision_status_for(cli_source_revision) == "unresolved"
else cli_source_revision,
"source_revision_status": cli_source_revision_status,
},
"updated_project_paths": updated_project_paths,
"path_warning": path_warning(),
"next": ["nap project init", "nap service start", "nap gateway configure", "nap update"],
}
return result
def runtime_assets_missing():
missing = []
for name in RUNTIME_SCRIPT_NAMES:
path = INSTALL_DIR / {
"terminal_init.py": "terminal/__init__.py",
"terminal_protocol.py": "terminal/protocol.py",
"terminal_pty_manager.py": "terminal/pty_manager.py",
}.get(name, name)
if not path.exists() or path.stat().st_size == 0:
missing.append(name)
return missing
def ensure_runtime_assets():
missing = runtime_assets_missing()
if missing:
install_assets(update_project=False)
missing = runtime_assets_missing()
if missing:
raise RuntimeError(f"missing installed Napseer runtime scripts after repair: {', '.join(missing)}")
def run_wrapper(command_name, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py"
ensure_runtime_assets()
return subprocess.call([sys.executable, str(wrapper), command_name, *args])
def preferred_state_dir():
return pathlib.Path.cwd() / ".napseer"
def cwd_state_dir():
preferred = preferred_state_dir()
preferred.mkdir(exist_ok=True)
chmod_best_effort(preferred, 0o700)
return preferred
def state_dir_status():
preferred = preferred_state_dir()
active = cwd_state_dir()
return {
"active_state_dir": str(active),
"active_auth_path": str(active / "auth.json"),
"state_dir": str(preferred),
"auth_path": str(preferred / "auth.json"),
"state_dir_exists": preferred.exists(),
"state_dir_message": ".napseer directory is active.",
}
def service_state_path(kind):
return cwd_state_dir() / f"{kind}.json"
def service_log_path(kind):
return cwd_state_dir() / f"{kind}.log"
def read_service_state(kind):
path = service_state_path(kind)
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def write_service_state(kind, state):
path = service_state_path(kind)
write_file(path, json.dumps(state, indent=2), executable=False)
chmod_best_effort(path, 0o600)
def pid_running(pid):
if not pid:
return False
proc_stat = pathlib.Path("/proc") / str(pid) / "stat"
try:
fields = proc_stat.read_text(encoding="utf-8").split()
if len(fields) > 2 and fields[2] == "Z":
return False
except Exception:
pass
try:
os.kill(int(pid), 0)
return True
except OSError:
return False
def find_free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((LOCAL_HOST, 0))
return int(sock.getsockname()[1])
def port_accepts_connections(port):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.2)
return sock.connect_ex((LOCAL_HOST, int(port))) == 0
def port_can_bind(port):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((LOCAL_HOST, int(port)))
return True
except OSError:
return False
def local_gateway_status(port):
request = urllib.request.Request(
f"http://{LOCAL_HOST}:{int(port)}/gateway/status",
headers={"User-Agent": "nap-cli-python/0.1"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=1) as response:
return json.loads(response.read().decode("utf-8"))
except Exception:
return None
def log_tail(path, max_chars=2000):
try:
text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
except Exception:
return ""
return text[-max_chars:]
def log_tail_lines(path, max_lines=120):
try:
lines = pathlib.Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
except FileNotFoundError:
return []
except Exception as exc:
return [f"failed to read log: {exc}"]
return lines[-max(1, max_lines):]
def compact_log_file(path, max_bytes=MAX_SERVICE_LOG_BYTES):
if max_bytes <= 0:
return
file_path = pathlib.Path(path)
try:
size = file_path.stat().st_size
except FileNotFoundError:
return
except OSError:
return
if size <= max_bytes:
return
try:
with file_path.open("rb") as handle:
handle.seek(-max_bytes, os.SEEK_END)
chunk = handle.read(max_bytes)
except OSError:
return
# Try to start on a newline boundary so the log stays line-oriented.
newline_index = chunk.find(b"\n")
if newline_index >= 0 and newline_index + 1 < len(chunk):
chunk = chunk[newline_index + 1 :]
tmp_path = file_path.with_suffix(file_path.suffix + ".tmp")
try:
tmp_path.write_bytes(chunk)
tmp_path.replace(file_path)
except OSError:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
def ensure_port_available(kind, port):
if not port_accepts_connections(port) and port_can_bind(port):
return
status = local_gateway_status(port)
if status:
cwd = status.get("cwd") or "unknown cwd"
project_id = status.get("project_id") or "no project"
raise RuntimeError(
f"{LOCAL_HOST}:{port} is already serving a Napseer gateway for {cwd} "
f"(project {project_id}). Stop that gateway before starting this one."
)
raise RuntimeError(f"{LOCAL_HOST}:{port} is already in use or still being released")
def choose_service_port(kind, args, existing=None):
requested = cli_option(args, "--port", default=None)
if requested is not None:
return int(requested)
if kind == "gateway":
if DEFAULT_GATEWAY_PORT:
return int(DEFAULT_GATEWAY_PORT)
existing_port = (existing or {}).get("port")
if existing_port and port_can_bind(existing_port):
return int(existing_port)
return find_free_port()
return find_free_port()
def wait_for_service(kind, process, port, log_path):
deadline = time.time() + 3
while time.time() < deadline:
if process.poll() is not None:
tail = log_tail(log_path)
detail = f": {tail.strip()}" if tail.strip() else ""
raise RuntimeError(f"{kind} service exited during startup{detail}")
if kind == "gateway" and local_gateway_status(port):
return
if kind != "gateway" and port_accepts_connections(port):
return
time.sleep(0.1)
raise RuntimeError(f"{kind} service did not start listening on {LOCAL_HOST}:{port}")
def cli_option(args, *names, default=None):
for name in names:
if name in args:
index = args.index(name)
if index + 1 >= len(args):
raise RuntimeError(f"{name} requires a value")
return args[index + 1]
return default
def start_service(kind, args):
wrapper = INSTALL_DIR / "napseer_mcp_server.py"
ensure_runtime_assets()
existing = read_service_state(kind)
if existing and pid_running(existing.get("pid")):
return {
**existing,
"status": "running",
"message": f"{kind} service is already running.",
}
port = choose_service_port(kind, args, existing)
ensure_port_available(kind, port)
log_path = service_log_path(kind)
compact_log_file(log_path)
command = [sys.executable, str(wrapper), "open-ui", "--port", str(port), "--no-browser"]
if kind == "ui" and "--no-browser" not in args:
command.remove("--no-browser")
with log_path.open("ab") as log:
kwargs = {"stdout": log, "stderr": subprocess.STDOUT, "cwd": str(pathlib.Path.cwd())}
if os.name != "nt":
kwargs["start_new_session"] = True
else:
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
process = subprocess.Popen(command, **kwargs)
wait_for_service(kind, process, port, log_path)
state = {
"status": "running",
"kind": kind,
"pid": process.pid,
"host": LOCAL_HOST,
"port": port,
"url": f"http://{LOCAL_HOST}:{port}/",
"cwd": str(pathlib.Path.cwd()),
"log_path": str(log_path),
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
write_service_state(kind, state)
return {**state, "message": f"{kind} service started."}
def stop_service(kind):
state = read_service_state(kind)
if not state:
return {"status": "stopped", "kind": kind}
pid = state.get("pid")
if pid_running(pid):
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
try:
os.killpg(int(pid), signal.SIGTERM)
except OSError:
try:
os.kill(int(pid), signal.SIGTERM)
except OSError:
pass
deadline = time.time() + 5
while time.time() < deadline and pid_running(pid):
time.sleep(0.1)
if pid_running(pid):
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
try:
os.killpg(int(pid), signal.SIGKILL)
except OSError:
try:
os.kill(int(pid), signal.SIGKILL)
except OSError:
pass
deadline = time.time() + 2
while time.time() < deadline and pid_running(pid):
time.sleep(0.1)
port = state.get("port")
if port:
deadline = time.time() + 2
while time.time() < deadline and not port_can_bind(port):
time.sleep(0.1)
stopped = not pid_running(pid) and (not port or port_can_bind(port))
state = {**state, "status": "stopped" if stopped else "stopping"}
write_service_state(kind, state)
if not stopped:
state["warning"] = f"{kind} process or port {port} is still being released"
state["message"] = state["warning"]
else:
state["message"] = f"{kind} service stopped."
return state
def list_service(kind):
state = read_service_state(kind) or {
"kind": kind,
"host": LOCAL_HOST,
"port": None,
"url": None,
"cwd": str(pathlib.Path.cwd()),
}
state["status"] = "running" if pid_running(state.get("pid")) else "stopped"
state["message"] = f"{kind} service is {state['status']}."
return state
def service_logs(kind, args):
max_lines = int(cli_option(args, "--lines", "--tail", default="120"))
path = service_log_path(kind)
compact_log_file(path)
lines = log_tail_lines(path, max_lines=max_lines)
status = "ok" if path.exists() else "missing"
return {
"status": status,
"message": f"{kind} log {'read' if path.exists() else 'does not exist yet'}.",
"kind": kind,
"log_path": str(path),
"line_count": len(lines),
"lines": lines,
"text": "\n".join(lines),
}
def handle_gateway(args):
subcommand = args[0] if args else "help"
rest = args[1:] if args else []
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 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 <verb>`. "
f"Use `nap service {subcommand if subcommand != 'log' else 'logs'}`."
)
if subcommand == "process":
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 == "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 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):
print(json.dumps(value, indent=2))
def main(argv):
invoked_as_nap = pathlib.Path(argv[0]).name in {"nap", "nap.cmd"}
command = argv[1] if len(argv) > 1 else ("help" if invoked_as_nap else "install")
args = argv[2:]
if command == "install":
print_json(install_assets(update_project=False))
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":
return run_wrapper("configure", args)
if command == "project":
if not args:
return run_wrapper("project", ["status"])
return run_wrapper("project", args)
if command == "lineage":
return run_wrapper("lineage", args or ["status"])
if command == "plan":
return run_wrapper("plan", args or ["list"])
if command == "agent":
return run_wrapper("agent", args)
if command == "auth":
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(
{
"bin": shutil.which("nap") or str(BIN_DIR / "nap"),
"install_dir": str(INSTALL_DIR),
"wrapper": str(INSTALL_DIR / "napseer_mcp_server.py"),
"cwd": str(pathlib.Path.cwd()),
"cwd_auth_path": state["active_auth_path"],
"cwd_auth_configured": pathlib.Path(state["active_auth_path"]).exists(),
**state,
"path_warning": path_warning(),
"status": "ok",
"message": f"Napseer install and cwd paths resolved. {state['state_dir_message']}",
}
)
return 0
if command in {"help", "-h", "--help"}:
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, 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")
print(" plan Plan commands: list, to-kanban.")
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 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, set, resolve.")
print(" Examples: nap feedback list, nap feedback set <id> --status open, nap feedback resolve <id> --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.")
print(" install Reinstall/repair nap from the hosted script directory.")
return 0
raise RuntimeError(f"unknown command: {command}")
if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv))
except Exception as exc:
print(json.dumps({"error": str(exc)}, indent=2), file=sys.stderr)
raise SystemExit(1)