Files

818 lines
44 KiB
Python

"""English command-line interface for FrameBridge Expanded."""
from __future__ import annotations
import argparse
import copy
import importlib
import json
import os
from pathlib import Path
import re
import socket
import sys
import shlex
from .config import default_config, load_config, save_config
from . import tunnel, __version__
_EMAIL = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+\Z")
def resolve_state(value=None):
return Path(value or os.environ.get("FRAMEBRIDGE_EXPANDED_STATE")
or os.environ.get("FRAMEBRIDGE_STATE_DIR")
or Path.home() / ".local/share/framebridge-expanded").expanduser().absolute()
def _module(name):
return importlib.import_module("." + name, __package__)
def _show(value):
print(json.dumps(_structured_result(value) if _OUTPUT and _OUTPUT.get("json") else value, ensure_ascii=False, indent=2, default=str))
def _ask(label, default="", *, ask=None):
ask = ask or input
value = ask(label + (f" [{default}]" if default != "" else "") + ": ").strip()
return value if value else default
def _yes(label, default=False, *, ask=None):
answer = _ask(label + (" [Y/n]" if default else " [y/N]"), ask=ask).lower()
return default if not answer else answer in {"y", "yes"}
def _show_autostart_enabled(result):
"""Summarize observed state without treating configuration as readiness."""
enabled = result.get("configured") is True and result.get("enabled") is True
if not enabled:
print("Automatic startup is not enabled. Check: fb status")
return 1
print("Automatic startup enabled.")
service = result.get("service_state", "unavailable")
if service not in {"active", "activating", "deactivating", "inactive", "failed", "reloading", "maintenance", "refreshing"}:
service = "unavailable"
print("Service: " + service)
current = result.get("runtime")
current = current if isinstance(current, dict) else {}
capabilities = current.get("capabilities")
capabilities = capabilities if isinstance(capabilities, dict) else {}
ready = current.get("ready") is True
if ready and all(capabilities.get(name) is True for name in ("fbexec", "fbterm", "cua_tty_module")):
print("Tools: FBEXEC, FBTERM, CUA-TTY ready.")
elif current.get("status_unavailable"):
print("Tools: status unavailable. Check: fb status")
else:
print("Tools: starting.")
context = current.get("display_context")
context = context if isinstance(context, dict) else {}
kind = context.get("kind")
if ready and capabilities.get("graphical_cua") is True and kind in {"physical-x11", "virtual-x11"}:
print("Desktop: " + ("detected; CUA ready." if kind == "physical-x11" else "private; CUA ready."))
elif kind == "text":
print("Desktop: text mode.")
else:
print("Desktop: checking.")
if current.get("role") == "node":
print("Connection: managed by the central computer.")
else:
connection = result.get("connection")
connection = connection if isinstance(connection, dict) else {}
phase = connection.get("state")
if connection.get("verified") is True:
print("Connection: ready.")
elif phase == "retrying":
print("Connection: retrying in the background.")
elif phase in {"starting", "checking", "access", "unverified"}:
print("Connection: checking in the background.")
elif phase in {"error", "rejected"}:
print("Connection: needs attention. Check: fb status connection")
elif phase in {"stopped", "closed"}:
print("Connection: stopped. Check: fb status connection")
else:
print("Connection: checking. Check: fb status connection")
return 0 if result.get("ok") is not False and service not in {"failed", "maintenance"} else 1
def setup(state, *, ask=None):
"""Review and save public settings; never start services or a tunnel."""
state = resolve_state(state)
if (state / "config.json").exists():
current = _module("runtime").status(state)
if current.get("running") or any(p.get("alive") for p in current.get("processes", [])):
raise RuntimeError("Stop this profile before changing setup. Run fb service stop.")
config = copy.deepcopy(load_config(state) if (state / "config.json").exists() else default_config())
print("Set up this computer")
print("standalone: local tools; central: local tools and a node broker; node: joins a central broker.")
config["role"] = _ask("Role (standalone/central/node)", config["role"], ask=ask).lower()
config["name"] = _ask("Computer name", config["name"], ask=ask)
desktop_mode = config.get("headless", "auto")
if desktop_mode == "auto":
print("Desktop: automatic detection.")
elif desktop_mode in ("yes", True):
print("Desktop: existing private desktop setting preserved.")
else:
print("Desktop: existing physical desktop setting preserved.")
ports = {}
for service in ("mcp", "hub", "broker"):
value = _ask(service.upper() + " port", str(config["ports"][service]), ask=ask)
try:
ports[service] = int(value)
except ValueError:
raise ValueError("Service ports must be integers.") from None
config["ports"] = ports
label = _ask("ChatGPT email label (optional; browser login is separate)", config.get("account_label", ""), ask=ask)
if label and not _EMAIL.fullmatch(label):
raise ValueError("Enter an email label or leave it empty.")
config["account_label"] = label
certificate_plan = None
if config["role"] == "central":
print("Loopback is local only. Choose 0.0.0.0 explicitly to accept IPv4 LAN nodes with HTTPS.")
config["broker_listen_host"] = _ask("Broker listen host", config.get("broker_listen_host", "127.0.0.1"), ask=ask)
mode = _ask("TLS certificate setup (generate/existing)", "existing" if config.get("tls_cert") else "generate", ask=ask).lower()
if mode == "generate":
if (state / "tls").exists() or any(config.get(k) for k in ("tls_cert", "tls_key", "tls_ca")):
raise ValueError("This profile already has TLS material. Choose existing certificates or an explicit new profile.")
tls = _module("tls")
host = tls._dns_name(_ask("Certificate DNS name", config["name"].replace("_", "-"), ask=ask))
address = str(tls._address(_ask("Advertised broker IP address", ask=ask)))
certificate_plan = {"host": host, "address": address, "listen_host": config["broker_listen_host"]}
print("A private local CA and server certificate will be created only after confirmation.")
elif mode == "existing":
config["tls_cert"] = _ask("Broker HTTPS certificate file (path only; optional on loopback)", config.get("tls_cert", ""), ask=ask)
config["tls_key"] = _ask("Broker HTTPS private key file (path only)", config.get("tls_key", ""), ask=ask)
config["tls_ca"] = _ask("Trusted CA file (path only; optional)", config.get("tls_ca", ""), ask=ask)
config["broker_url"] = _ask("Advertised broker URL (optional on loopback)", config.get("broker_url", ""), ask=ask)
scheme = "https" if config["tls_cert"] and config["tls_key"] else "http"
config["broker_control_url"] = scheme + "://127.0.0.1:" + str(ports["broker"])
if scheme == "https":
print("Existing certificates must cover 127.0.0.1 or localhost for private local control.")
if config["broker_listen_host"] not in {"localhost", "127.0.0.1", "::1"} and not (config["tls_cert"] and config["tls_key"]):
raise ValueError("A broker listening outside loopback requires HTTPS certificate and key files.")
else:
raise ValueError("Choose generate or existing for TLS certificate setup.")
elif config["role"] == "node":
config["broker_url"] = _ask("Central broker URL (verified HTTPS for remote hosts; may be set later)", config.get("broker_url", ""), ask=ask)
config["tls_ca"] = _ask("Central broker CA file (path only; blank uses system trust)", config.get("tls_ca", ""), ask=ask)
print("Review this profile. TLS paths refer to node transport files; they are not ChatGPT API keys.")
_show(config)
if certificate_plan is not None:
_show({"new_lan_certificate": certificate_plan})
if not _yes("Save this configuration?", True, ask=ask):
print("Configuration was not saved.")
return 0
save_config(state, config)
if certificate_plan is not None:
_show(_module("tls").create_certificates(state, **certificate_plan))
print("Configuration saved. No service or ChatGPT connection was started.")
if config["role"] == "node":
print("Next: framebridge doctor, framebridge nodes enroll, ask the central operator to approve, then framebridge nodes poll.")
print("After approval is confirmed, run fb service start.")
else:
print("Next: fb connect. This starts the managed tools and connection.")
print("Use the same --state-dir option with every command when using a custom profile.")
return 0
def connect_chatgpt(state, *, foreground=False, replace=False):
config_path = Path(state) / "config.json"
config = load_config(state) if config_path.exists() or config_path.is_symlink() else default_config()
if config["role"] == "node":
raise RuntimeError("A node profile does not publish an MCP server. Connect ChatGPT from the central profile.")
runtime = _module("runtime")
port = config["ports"]["mcp"]
expected_url = "http://127.0.0.1:" + str(port) + "/mcp"
binary = Path(runtime.PREFIX) / "original/geranas/FrameBridge-MCP-Private/bin/tunnel-client"
if not binary.is_file():
raise RuntimeError("The packaged tunnel client is missing.")
manifest = Path(state) / "autostart/installation.json"
if not foreground and not replace and (manifest.exists() or manifest.is_symlink()):
return tunnel.resume_managed_connection(state, _module("autostart").resume_connection)
if foreground:
current = runtime.status(state)
if current.get("running") is not True or current.get("ready") is not True or current.get("mcp_url") != expected_url:
raise RuntimeError("This profile's MCP service is not ready. Run fb service start for this profile first.")
def save_public(settings):
config.update(settings)
save_config(state, config)
return tunnel.connect_chatgpt(binary, state, expected_url, config, save_public, foreground=True)
def configure_connection(profile, settings, runtime_key):
current = runtime.status(state) if config_path.is_file() else {"running": False}
if current.get("running") and current.get("mcp_url") != expected_url:
raise RuntimeError("This profile's MCP endpoint does not match its configuration. Run fb doctor.")
if not current.get("running"):
for name in ("mcp", "hub") + (("broker",) if config["role"] == "central" else ()):
try:
with socket.socket() as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
probe.bind(("127.0.0.1", config["ports"][name]))
except OSError:
raise RuntimeError("The local " + name.upper() + " port " + str(config["ports"][name]) + " is already in use. Choose an available port with fb setup.") from None
return _module("autostart").configure_connection(profile, settings, runtime_key)
return tunnel.connect_chatgpt(binary, state, expected_url, config, configure_connection=configure_connection)
def nodes_command(args, state):
config = load_config(state)
if args.node_command == "list":
broker = _module("broker")
broker.configure(None, state_dir=state)
previous = os.environ.get("FRAMEBRIDGE_EXPANDED_STATE")
try:
os.environ["FRAMEBRIDGE_EXPANDED_STATE"] = str(state)
_show(_module("fleet").discover_nodes())
finally:
if previous is None:
os.environ.pop("FRAMEBRIDGE_EXPANDED_STATE", None)
else:
os.environ["FRAMEBRIDGE_EXPANDED_STATE"] = previous
return 0
if args.node_command in {"pending", "approve"}:
broker = _module("broker")
broker.configure(None, state_dir=state)
result = broker.list_pending() if args.node_command == "pending" else broker.approve(args.request_id, args.node_id, state_dir=state)
else:
node = _module("node")
if args.node_command == "enroll":
result = node.enroll(args.central or config.get("broker_url", ""), args.name or config["name"], state_dir=state)
elif args.node_command == "status":
result = node.status(state_dir=state)
elif args.node_command == "poll":
result = node.poll_enrollment(state_dir=state)
else:
result = node.run(state_dir=state, once=args.once)
if result is not None:
_show(result)
return 0
def _target_tty(state, target, payload=None, service=None):
"""Use the existing authenticated node transport; never guess SSH identities."""
if target == "LOCAL":
tty = _module("tty")
return getattr(tty, service)(state) if service else tty.request(state, payload)
previous = os.environ.get("FRAMEBRIDGE_EXPANDED_STATE")
os.environ["FRAMEBRIDGE_EXPANDED_STATE"] = str(state)
try:
fleet = _module("fleet")
wire = _module("tty_mcp")
def execute(**kwargs):
alias = kwargs.pop("target")
return fleet.execute_node(alias, **kwargs)
if service:
return wire._exec_json(execute, target, [service])
return wire._remote_request(execute, target, payload)
finally:
if previous is None:
os.environ.pop("FRAMEBRIDGE_EXPANDED_STATE", None)
else:
os.environ["FRAMEBRIDGE_EXPANDED_STATE"] = previous
def tty_command(args, state):
action = getattr(args, "tty_service_action", None) if args.tty_command == "service" else args.tty_command
target = getattr(args, "node", "LOCAL")
if action in {"start", "stop", "status"}:
result = _target_tty(state, target, service=action)
else:
payload = {"authorized": True}
if action == "list":
payload["op"] = "status"
elif action == "open":
payload.update(op="open", program=args.program, rows=args.rows, cols=args.cols)
elif action == "sudo":
payload.update(op="sudo", session_id=args.session_id, mode=args.sudo_mode)
elif action == "observe":
payload.update(op="observe", session_id=args.session_id)
elif action == "key":
payload.update(op="key.send", session_id=args.session_id, key=args.key)
elif action == "type":
text = args.text if args.text is not None else input("Text to send: ")
payload.update(op="text.type", session_id=args.session_id, text=text)
elif action == "resize":
payload.update(op="resize", session_id=args.session_id, rows=args.rows, cols=args.cols)
else:
payload.update(op="close", session_id=args.session_id)
result = _target_tty(state, target, payload=payload)
_show(result)
return 1 if isinstance(result, dict) and result.get("ok") is False else 0
def tutorial():
runtime = _module("runtime")
candidates = [Path(runtime.PREFIX) / "docs/DEBIAN-TUTORIAL.md",
Path(__file__).resolve().parents[2] / "docs/DEBIAN-TUTORIAL.md"]
for path in candidates:
if path.is_file():
print(path.read_text(encoding="utf-8"))
return 0
raise RuntimeError("The tutorial is not installed. Run framebridge --help.")
_OUTPUT = None
def _structured_result(value):
"""Versioned envelope used only when --json was explicitly requested."""
return {"schema_version": 1, "product": "FrameBridge", "version": __version__,
"command": _OUTPUT.get("command") if _OUTPUT else None,
"ok": not isinstance(value, dict) or value.get("ok") is not False,
"result": value}
def _result_code(value):
return 1 if isinstance(value, dict) and value.get("ok") is False else 0
def _scoped_action(action):
if _OUTPUT and _OUTPUT.get("explicit_state") and action.startswith("fb "):
return "fb --state-dir " + shlex.quote(_OUTPUT["explicit_state"]) + " " + action[3:]
return action
def _interaction_required(action):
action = _scoped_action(action)
result = {"ok": False, "error": "INTERACTION_REQUIRED",
"message": "Complete this action in a normal interactive terminal. Do not send credentials through chat.",
"next_action": action}
if _OUTPUT and _OUTPUT.get("json"):
_show(result)
else:
print("An interactive terminal is required. Next: " + action, file=sys.stderr)
return 2
def _saved_noninteractive_ready(state):
value = _module("lifecycle").status(state)
boot = value.get("boot", {})
return all(boot.get(name) is True for name in
("configured", "encrypted_credential_present", "linger"))
def _runtime_label(value):
if value.get("status_unavailable") or "running" not in value:
return "unavailable"
return "ready" if value.get("ready") is True else ("running" if value.get("running") is True else "stopped")
def _boot_label(value):
if value.get("configured") is False:
return "not configured"
if value.get("configured") is not True or type(value.get("enabled")) is not bool:
return "unavailable"
return "enabled" if value["enabled"] else "disabled"
def _human_status(value, scope=None):
if scope == "connection":
conn = value
print("ChatGPT connection: " + ("connected (verified)" if conn.get("verified") is True else str(conn.get("state", "unavailable"))))
if conn.get("desired_connection"):
print("Connection intent: " + str(conn["desired_connection"]))
return
if scope == "boot":
boot = value
policy = _boot_label(boot)
print("Automatic startup: " + policy)
print("Current service: " + str(boot.get("service_state", "unavailable")))
print("Saved connection material: " + ("present" if boot.get("encrypted_credential_present") is True else ("absent" if boot.get("encrypted_credential_present") is False else "unavailable")))
return
if scope == "runtime":
runtime = value
print("Local tools: " + _runtime_label(runtime))
print("Role: " + str(runtime.get("role", "unavailable")))
return
if scope == "console":
print("Console: " + str(value.get("status", value.get("state", "available" if value.get("panes") else "not open"))))
return
runtime = value.get("runtime", {})
runtime = runtime if isinstance(runtime, dict) else {}
connection = value.get("connection", {})
connection = connection if isinstance(connection, dict) else {}
boot = value.get("boot", {})
boot = boot if isinstance(boot, dict) else {}
print("FrameBridge " + __version__)
print("Role: " + str(runtime.get("role", "unavailable")))
print("Local tools: " + _runtime_label(runtime))
if runtime.get("role") == "node":
print("ChatGPT connection: provided by the central computer")
else:
print("ChatGPT connection: " + ("connected (verified)" if connection.get("verified") is True else str(connection.get("state", "unavailable"))))
print("Connection intent: " + str(value.get("desired_connection", "unavailable")))
print("Automatic startup: " + _boot_label(boot))
print("Current service: " + str(boot.get("service_state", "unavailable")))
if value.get("ok") is False:
print("Some status information is unavailable. Run fb doctor or inspect fb status --json.")
def _status_command(args, state):
scope = args.scope
if scope == "console":
value = _module("console").status(state, "LOCAL")
else:
aggregate = _module("lifecycle").status(state)
value = aggregate.get(scope, {}) if scope else aggregate
if scope == "connection":
value = dict(value, desired_connection=aggregate.get("desired_connection", "unavailable"))
if args.json:
_show(value)
else:
_human_status(value, scope)
return _result_code(value)
def _doctor_command(args, state):
value = _module("runtime").doctor(state)
if args.json:
_show(value)
else:
print("Diagnostics: " + ("passed" if value.get("ok") else "needs attention"))
compatibility = value.get("platform_compatibility") or {}
print("Platform: " + ("compatible" if compatibility.get("ok") else "not verified compatible"))
if compatibility.get("profile_id"):
print("Build profile: " + str(compatibility["profile_id"]))
for issue in compatibility.get("issues", []):
print("Platform issue: " + str(issue))
print("Desktop: " + str(value.get("display", "unavailable")))
for item in value.get("missing_commands", []):
print("Missing command: " + str(item))
for item in value.get("issues", []):
print("Issue: " + str(item))
virtual_missing = value.get("optional_missing_commands", {}).get("private_desktop", [])
if virtual_missing:
print("Optional private desktop tools missing: " + ", ".join(virtual_missing))
hint = value.get("optional_install_commands", {}).get("private_desktop")
if hint:
print("Install only for an explicit virtual desktop: " + hint)
print("This checks prerequisites; use fb status to inspect the current connection.")
return _result_code(value)
def _service_command(args, state):
action = args.service_action
lifecycle = _module("lifecycle")
if action == "credentials":
if not args.confirm:
result = {"ok": False, "error": "CONFIRMATION_REQUIRED",
"message": "This stops and removes the managed connection setup and saved credential. The profile is retained.",
"next_action": _scoped_action("fb service credentials forget --confirm")}
_show(result)
return 2
result = lifecycle.forget_credentials(state)
elif action == "boot":
if args.boot_action == "enable" and (args.json or args.non_interactive):
boot = lifecycle.status(state).get("boot", {})
if not boot.get("configured") or not boot.get("linger"):
return _interaction_required("fb service boot enable")
result = getattr(lifecycle, "boot_" + args.boot_action)(state)
else:
if action == "start" and (args.json or args.non_interactive):
config_path = state / "config.json"
if config_path.is_file() and load_config(state)["role"] == "node":
if not lifecycle.status(state).get("boot", {}).get("configured"):
return _interaction_required("fb service start")
result = getattr(lifecycle, "service_" + action)(state)
if args.json:
_show(result)
elif result.get("ok") is False:
print("Managed operation failed: " + str(result.get("error", result.get("message", "inspect fb status --json"))), file=sys.stderr)
else:
print("Managed " + ("startup policy" if action == "boot" else "service") + ": " + str(result.get("state", result.get("operation", "request completed"))))
if result.get("initial_configuration") is True:
print("Initial node service setup: automatic startup enabled.")
if action == "boot":
print("Current execution and saved connection material are retained.")
elif action == "credentials":
print("The profile and user service persistence are retained.")
if isinstance(result.get("runtime"), dict) and isinstance(result.get("boot"), dict):
_human_status(result)
elif result.get("message"):
print(str(result["message"]))
return _result_code(result)
def _help_command(args):
guide = _module("guide")
topic = "all" if args.command == "tutorial" else args.topic
if args.json:
value = guide.get_guide(topic)
_show(value)
return _result_code(value)
text = guide.render_text(topic)
if sys.stdout.isatty() and sys.stdin.isatty() and not args.non_interactive:
import pydoc
pydoc.pager(text)
else:
print(text)
return 0
def _add_common_options(parser):
# SUPPRESS preserves an option supplied before a nested subcommand.
if not any("--state-dir" in item.option_strings for item in parser._actions):
parser.add_argument("--state-dir", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
parser.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
help="Emit one versioned JSON result; never request interactive input.")
parser.add_argument("--non-interactive", action="store_true", default=argparse.SUPPRESS,
help="Never request interactive input; report when a terminal action is needed.")
for item in parser._actions:
if isinstance(item, argparse._SubParsersAction):
for child in item.choices.values():
_add_common_options(child)
def build_parser():
parser = argparse.ArgumentParser(prog="framebridge",
description="One command-line interface for human operators and authorized agents. fb is the short name for framebridge.",
epilog="Normal first use: fb connect. Advanced setup is optional. Read fb help for the guide and fb help compatibility for older commands.")
parser.add_argument("--state-dir", help="Profile directory; defaults to ~/.local/share/framebridge-expanded.")
parser.set_defaults(json=False, non_interactive=False)
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
connect = sub.add_parser("connect", help="Connect or resume ChatGPT; normal first use needs no separate setup/start.")
connect.add_argument("connection", nargs="?", choices=("chatgpt",), default=None, help=argparse.SUPPRESS)
modes = connect.add_mutually_exclusive_group()
modes.add_argument("--foreground", action="store_true", help="Advanced temporary connection; this terminal remains open.")
modes.add_argument("--replace", action="store_true", help="Replace saved connection settings through hidden local prompts.")
disconnect = sub.add_parser("disconnect", help="Pause ChatGPT until the next connect, including after restart; keep local tools.")
disconnect.add_argument("disconnection", nargs="?", choices=("chatgpt",), default=None, help=argparse.SUPPRESS)
status = sub.add_parser("status", help="Observe local tools, ChatGPT connection and startup policy without changing them.")
status.add_argument("scope", nargs="?", choices=("connection", "runtime", "boot", "console"))
sub.add_parser("doctor", help="Diagnose prerequisites and problems; does not repair or connect.")
sub.add_parser("setup", help="Optional advanced role/profile setup; does not start services or connect.")
help_parser = sub.add_parser("help", help="Read the installed, versioned English guide.")
help_parser.add_argument("topic", nargs="?", default="start", help="Guide topic; use all for the complete reference.")
service = sub.add_parser("service", help="Manage current services separately from future startup policy.")
services = service.add_subparsers(dest="service_action", required=True)
services.add_parser("start", help="Start services; the first node start also prepares automatic startup.")
services.add_parser("stop", help="Stop this profile's managed stack now without changing boot policy.")
boot = services.add_parser("boot", help="Change future startup policy; retain current execution and saved material.")
boot_actions = boot.add_subparsers(dest="boot_action", required=True)
for action in ("enable", "disable"):
boot_actions.add_parser(action)
credentials = services.add_parser("credentials", help="Explicit destructive management of saved connection setup.")
forget = credentials.add_subparsers(dest="credential_action", required=True).add_parser("forget",
help="Stop/remove managed connection setup and saved credential; retain profile and user persistence.")
forget.add_argument("--confirm", action="store_true", help="Explicitly confirm this destructive operation.")
screen = sub.add_parser("screen-access", help="Explicit persistent read-only access to the real local framebuffer.")
ss = screen.add_subparsers(dest="screen_access_command", required=True)
ss.add_parser("status", help="Read the access policy without changing permissions.")
enable = ss.add_parser("enable", help="Administrator opt-in: sudo fb screen-access enable --user NAME.")
enable.add_argument("--user", required=True)
ss.add_parser("disable", help="Revoke only the exact owned unchanged framebuffer ACL.")
certificates = sub.add_parser("certificates", help="Manage profile-scoped private LAN TLS certificates.")
create = certificates.add_subparsers(dest="certificate_command", required=True).add_parser("create",
help="Create a private CA/server certificate without overwriting existing material.")
create.add_argument("--host", required=True, help="Certificate DNS name; localhost is included automatically.")
create.add_argument("--address", required=True, help="Broker IP included in the certificate and advertised by default.")
create.add_argument("--listen-host", help="Explicit bind IP; preserve the saved setting when omitted.")
create.add_argument("--advertise-host", help="Advertise the certificate DNS name instead of its IP.")
nodes = sub.add_parser("nodes", help="Manage node enrollment, approval and agent state.")
ns = nodes.add_subparsers(dest="node_command", required=True)
for name in ("list", "pending", "status", "poll"):
ns.add_parser(name)
approve = ns.add_parser("approve", help="Approve after checking the node identity.")
approve.add_argument("request_id")
approve.add_argument("--node-id", required=True, help="Expected UUID from the pending request.")
enroll = ns.add_parser("enroll", help="Submit enrollment; this does not start the agent.")
enroll.add_argument("--central")
enroll.add_argument("--name")
run = ns.add_parser("run", help="Advanced foreground node agent.")
run.add_argument("--once", action="store_true", help="Process at most one polling cycle.")
console = sub.add_parser("console", help="Open or reattach the shared text Bash/FBTERM work view.")
console.add_argument("--node", default="LOCAL", help="Local alias; remote interactive attachment requires explicit SSH.")
console.add_argument("--mode", choices=("auto", "gui", "tty"), default="auto")
console.add_argument("--inside", action="store_true", help=argparse.SUPPRESS)
console.add_argument("--status", action="store_true", help=argparse.SUPPRESS)
login_flags = console.add_mutually_exclusive_group()
login_flags.add_argument("--enable-login", action="store_const", const="enable", dest="login_action", help=argparse.SUPPRESS)
login_flags.add_argument("--disable-login", action="store_const", const="disable", dest="login_action", help=argparse.SUPPRESS)
login_flags.add_argument("--login-status", action="store_const", const="status", dest="login_action", help=argparse.SUPPRESS)
con_sub = console.add_subparsers(dest="console_command")
login = con_sub.add_parser("login", help="Optional work-view opening after authenticated tty1 login; not service startup.")
login.add_argument("console_login_action", choices=("enable", "status", "disable"))
desktop = con_sub.add_parser("desktop", help="Migrate a recognized legacy desktop console into this package.")
desktop.add_argument("console_desktop_action", choices=("migrate-legacy",))
tty = sub.add_parser("tty", help="Manage persistent Bash sessions; persistence is between calls, not reboots.")
ts = tty.add_subparsers(dest="tty_command", required=True, metavar="ACTION")
tty_service = ts.add_parser("service", help="Manage the terminal daemon.")
tss = tty_service.add_subparsers(dest="tty_service_action", required=True)
for name in ("start", "stop", "status"):
item = tss.add_parser(name)
item.add_argument("--node", default="LOCAL")
for name in ("start", "stop", "status", "list"):
item = ts.add_parser(name, help="List session state." if name == "list" else argparse.SUPPRESS)
item.add_argument("--node", default="LOCAL")
sudo = ts.add_parser("sudo", help="Per-session sudo eligibility; changing mode recreates Bash and loses shell variables.")
modes = sudo.add_subparsers(dest="sudo_mode", required=True)
for mode in ("on", "status", "off"):
item = modes.add_parser(mode)
item.add_argument("session_id")
item.add_argument("--node", default="LOCAL")
op = ts.add_parser("open", help="Create a real Bash session and return its ID.")
op.add_argument("--node", default="LOCAL")
op.add_argument("--program", choices=("bash",), default="bash")
op.add_argument("--rows", type=int, default=24)
op.add_argument("--cols", type=int, default=80)
for name in ("observe", "key", "type", "resize", "close"):
item = ts.add_parser(name, help={"observe":"Read terminal text, not a real screen capture.",
"key":"Send a named key such as ENTER or CTRL_C.", "type":"Type text without adding Enter.",
"resize":"Change terminal dimensions.", "close":"Close this session and its processes; not detachment."}[name])
item.add_argument("session_id")
item.add_argument("--node", default="LOCAL")
if name == "key":
item.add_argument("key")
elif name == "type":
item.add_argument("text", nargs="?")
elif name == "resize":
item.add_argument("--rows", type=int, required=True)
item.add_argument("--cols", type=int, required=True)
# Compatibility parsers retain their actual historical scope.
sub.add_parser("start", help=argparse.SUPPRESS, description="Compatibility: starts only local runtime; use fb service start for the managed stack.")
sub.add_parser("stop", help=argparse.SUPPRESS, description="Compatibility: pauses this boot's managed worker and stops local runtime; startup policy is retained.")
sub.add_parser("tutorial", help=argparse.SUPPRESS, description="Compatibility: read the complete versioned guide; prefer fb help.")
connection = sub.add_parser("connection", help=argparse.SUPPRESS)
connection.add_subparsers(dest="connection_action", required=True).add_parser("status")
autostart = sub.add_parser("autostart", help=argparse.SUPPRESS,
description="Compatibility controls. WARNING: disable removes managed setup and saved connection material. Prefer fb service boot disable for policy only.")
auto = autostart.add_subparsers(dest="autostart_action", required=True)
for action in ("enable", "status", "disable"):
auto.add_parser(action)
# Do not advertise duplicate compatibility spellings in ordinary help.
for group in (sub, ts):
group._choices_actions[:] = [a for a in group._choices_actions if a.help is not argparse.SUPPRESS]
_add_common_options(parser)
return parser
def main(argv=None):
global _OUTPUT
parser = build_parser()
args = parser.parse_args(argv)
previous = _OUTPUT
_OUTPUT = {"json": args.json, "command": args.command,
"explicit_state": str(resolve_state(args.state_dir)) if args.command != "screen-access" and (args.state_dir or os.environ.get("FRAMEBRIDGE_EXPANDED_STATE") or os.environ.get("FRAMEBRIDGE_STATE_DIR")) else None}
try:
return _dispatch(parser, args)
finally:
_OUTPUT = previous
def _dispatch(parser, args):
state = None if args.command == "screen-access" else resolve_state(args.state_dir)
if args.command is None:
if args.json or args.non_interactive or not sys.stdin.isatty():
if args.json:
return _help_command(argparse.Namespace(command="help", topic="start", json=True, non_interactive=True))
parser.print_help()
return 0
print("FrameBridge\n1. Connect\n2. Status\n3. Doctor\n4. Open console\n5. Help\n6. Advanced setup\n0. Exit")
try:
choice = input("Choose an action: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nCancelled.", file=sys.stderr)
return 130
action = {"1":["connect"], "2":["status"], "3":["doctor"], "4":["console"],
"5":["help"], "6":["setup"]}.get(choice)
if choice == "0":
return 0
if action is None:
print("Choose one of the listed actions.", file=sys.stderr)
return 2
return main(["--state-dir", str(state)] + action)
try:
if args.command == "screen-access":
value = _module("screen_access").command(args)
_show(value)
return _result_code(value)
if args.command in {"help", "tutorial"}:
return _help_command(args)
if args.command == "setup":
if args.json or args.non_interactive or not sys.stdin.isatty():
return _interaction_required("fb setup")
return setup(state)
if args.command == "connect":
if args.json or args.non_interactive:
config_path = state / "config.json"
if config_path.is_file() and load_config(state)["role"] == "node":
raise RuntimeError("A node profile does not publish an MCP server. Connect ChatGPT from the central profile.")
manifest = state / "autostart/installation.json"
if args.foreground or args.replace or not manifest.is_file() or not _saved_noninteractive_ready(state):
return _interaction_required("fb connect" + (" --foreground" if args.foreground else (" --replace" if args.replace else "")))
if args.json:
import contextlib
with contextlib.redirect_stdout(sys.stderr):
code = connect_chatgpt(state, foreground=False, replace=False)
value = _module("lifecycle").status(state)
if code:
value = dict(value, ok=False, error="CONNECTION_NOT_READY")
_show(value)
return code
return connect_chatgpt(state, foreground=args.foreground, replace=args.replace)
if args.command == "status":
return _status_command(args, state)
if args.command == "doctor":
return _doctor_command(args, state)
if args.command == "service":
return _service_command(args, state)
if args.command == "connection":
value = _module("connection_background").status(state)
_show(value)
return _result_code(value)
if args.command == "disconnect":
module = "connection_background" if args.disconnection == "chatgpt" else "lifecycle"
result = _module(module).disconnect(state)
if args.json or args.disconnection == "chatgpt":
_show(result)
elif result.get("ok") is False:
print("Connection pause failed: " + str(result.get("error", "inspect fb status connection --json")), file=sys.stderr)
else:
print("ChatGPT connection paused until fb connect. Local tools and saved connection material are retained.")
return _result_code(result)
if args.command == "autostart":
autostart = _module("autostart")
if args.autostart_action == "enable" and (args.json or args.non_interactive):
value = autostart.status(state)
if not value.get("configured") or not value.get("linger"):
return _interaction_required("fb service boot enable")
result = getattr(autostart, args.autostart_action)(state)
if args.json or args.autostart_action == "status":
_show(result)
return _result_code(result)
if args.autostart_action == "enable":
if result.get("state") == "cancelled":
print("Automatic startup was not enabled.")
return 130
return _show_autostart_enabled(result)
print("Automatic startup disabled." if result.get("state") == "disabled" else "No managed automatic startup configuration was found.")
try:
current = autostart.status(state)
except (OSError, ValueError, RuntimeError):
current = {}
service = current.get("service_state", "unavailable")
if service not in {"active", "activating", "deactivating", "inactive", "failed", "reloading", "maintenance", "refreshing"}:
service = "unavailable"
print("Service: " + service)
if result.get("desktop_setting_restored") is False:
print("Current profile settings were preserved; no previous desktop setting was restored.")
if current.get("linger") is True:
print("User service persistence remains enabled.")
return _result_code(result)
if args.command == "certificates":
value = _module("tls").create_certificates(state, args.host, args.address, listen_host=args.listen_host, advertise_host=args.advertise_host)
_show(value)
return _result_code(value)
if args.command == "nodes":
if args.json and args.node_command == "run" and not args.once:
return _interaction_required("fb nodes run")
return nodes_command(args, state)
if args.command == "console":
if getattr(args, "console_desktop_action", None):
if args.node != "LOCAL" or args.status or args.login_action:
raise ValueError("Desktop console migration must target the local profile alone.")
result = _module("console_desktop").migrate_legacy(state)
_show(result)
return _result_code(result)
action = getattr(args, "console_login_action", None) or args.login_action
if getattr(args, "console_login_action", None) and args.login_action:
raise ValueError("Choose one console login command form.")
if args.status and action:
raise ValueError("Choose console status or a login action, not both.")
if action:
if args.node != "LOCAL":
raise ValueError("Console login setup is local; run it on the requested node.")
result = getattr(_module("console_startup"), action)(state)
_show(result)
return _result_code(result)
console = _module("console")
if args.status:
_show(console.status(state, args.node))
return 0
if args.json or args.non_interactive:
return _interaction_required("fb console")
return console.run(state, args.node, inside=args.inside, mode=args.mode)
if args.command == "tty":
if args.tty_command == "type" and args.text is None and (args.json or args.non_interactive):
return _interaction_required("fb tty type SESSION_ID TEXT")
return tty_command(args, state)
if args.command == "stop":
_module("autostart").stop_for_current_boot(state)
value = getattr(_module("runtime"), args.command)(state)
_show(value)
return _result_code(value)
except (EOFError, KeyboardInterrupt):
if args.json:
_show({"ok":False, "error":"CANCELLED"})
else:
print("\nCancelled.", file=sys.stderr)
return 130
except (OSError, ValueError, RuntimeError) as exc:
if args.json:
_show({"ok":False, "error":"COMMAND_FAILED", "message":str(exc)})
else:
print("Error: " + str(exc), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())