diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index 2c9fc40..6177984 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -240,6 +240,11 @@ def main(): p_cfg_set.add_argument("value", help="Config value") p_cfg_set.set_defaults(func=cmd_config) + # config connect + p_cfg_connect = config_sub.add_parser("connect", help="Connect this app to your CmdForge account") + p_cfg_connect.add_argument("username", help="Your CmdForge username") + p_cfg_connect.set_defaults(func=cmd_config) + # Default for config with no subcommand p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(args))) diff --git a/src/cmdforge/cli/config_commands.py b/src/cmdforge/cli/config_commands.py index 8af0c31..ec0300e 100644 --- a/src/cmdforge/cli/config_commands.py +++ b/src/cmdforge/cli/config_commands.py @@ -1,6 +1,10 @@ """Configuration commands.""" -from ..config import load_config, save_config, set_registry_token +import socket +import sys +import time + +from ..config import load_config, save_config, set_registry_token, get_registry_url def cmd_config(args): @@ -11,9 +15,12 @@ def cmd_config(args): return _cmd_config_set_token(args) elif args.config_cmd == "set": return _cmd_config_set(args) + elif args.config_cmd == "connect": + return _cmd_config_connect(args) else: print("Config commands:") print(" show Show current configuration") + print(" connect Connect this app to your CmdForge account") print(" set-token Set registry authentication token") print(" set Set a configuration value") return 0 @@ -60,3 +67,101 @@ def _cmd_config_set(args): save_config(config) print(f"Set {key} = {value}") return 0 + + +def _cmd_config_connect(args): + """Connect this app to a CmdForge account via web pairing.""" + try: + import requests + except ImportError: + print("Error: requests library required. Install with: pip install requests") + return 1 + + username = args.username + hostname = socket.gethostname() + registry_url = get_registry_url() + + # Remove trailing /api/v1 if present to get base URL + base_url = registry_url.rstrip("/") + if base_url.endswith("/api/v1"): + base_url = base_url[:-7] + + pairing_url = f"{base_url}/api/v1/pairing/check/{username}" + + print(f"Connecting to CmdForge as @{username}...") + print(f"Device: {hostname}") + print() + print("Waiting for approval from the web interface...") + print("Go to https://cmdforge.brrd.tech/dashboard/connected-apps") + print("and click 'Connect New App', then 'I've Run the Command'") + print() + print("Press Ctrl+C to cancel") + print() + + # Poll for pairing status + max_attempts = 150 # 5 minutes at 2-second intervals + attempt = 0 + + try: + while attempt < max_attempts: + attempt += 1 + try: + response = requests.get( + pairing_url, + params={"hostname": hostname}, + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + status = data.get("data", {}).get("status") + + if status == "connected": + token = data.get("data", {}).get("token") + if token: + set_registry_token(token) + print("\nConnected successfully!") + print(f"Your device '{hostname}' is now linked to @{username}") + print("\nYou can now publish tools with: cmdforge registry publish") + return 0 + else: + print("\nError: Pairing completed but no token received") + return 1 + elif status == "pending": + # Still waiting, continue polling + pass + elif status == "expired": + print("\nPairing request expired. Please try again.") + return 1 + elif status == "not_found": + # No pending pairing yet, continue waiting + pass + else: + # Unknown status, keep waiting + pass + + elif response.status_code == 404: + # No pairing found yet, keep waiting + pass + else: + # Server error, but keep trying + pass + + except requests.exceptions.RequestException: + # Network error, keep trying + pass + + # Show progress indicator + dots = "." * ((attempt % 3) + 1) + sys.stdout.write(f"\rWaiting{dots} ") + sys.stdout.flush() + + time.sleep(2) + + print("\n\nTimed out waiting for approval.") + print("Please initiate the connection from the web interface first.") + return 1 + + except KeyboardInterrupt: + print("\n\nConnection cancelled.") + return 1 diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index 9626e57..df14380 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -1466,6 +1466,169 @@ def create_app() -> Flask: g.db.commit() return jsonify({"data": {"revoked": True}}) + # ─── App Pairing (Connect Flow) ────────────────────────────────────────────── + + @app.route("/api/v1/pairing/initiate", methods=["POST"]) + @require_token + def initiate_pairing() -> Response: + """Initiate a pairing request from the website. Creates a pending pairing code.""" + import secrets + + # Clean up old expired pairing requests + g.db.execute( + "DELETE FROM pairing_requests WHERE expires_at < ? OR status = 'claimed'", + [datetime.utcnow().isoformat()], + ) + g.db.commit() + + # Cancel any existing pending requests for this user + g.db.execute( + "UPDATE pairing_requests SET status = 'cancelled' WHERE publisher_id = ? AND status = 'pending'", + [g.current_publisher["id"]], + ) + + # Generate pairing code and token + pairing_code = secrets.token_urlsafe(16) + token, token_hash = generate_token() + expires_at = (datetime.utcnow() + timedelta(minutes=5)).isoformat() + + g.db.execute( + """ + INSERT INTO pairing_requests (publisher_id, pairing_code, token_hash, token_plain, expires_at) + VALUES (?, ?, ?, ?, ?) + """, + [g.current_publisher["id"], pairing_code, token_hash, token, expires_at], + ) + g.db.commit() + + return jsonify({ + "data": { + "status": "pending", + "username": g.current_publisher["slug"], + "expires_in": 300, + } + }) + + @app.route("/api/v1/pairing/check/", methods=["GET"]) + def check_pairing(username: str) -> Response: + """CLI polls this to check if pairing is ready. Returns token if ready.""" + hostname = request.args.get("hostname", "Unknown Device") + + # Find pending pairing for this username + row = query_one( + g.db, + """ + SELECT pr.*, p.slug + FROM pairing_requests pr + JOIN publishers p ON pr.publisher_id = p.id + WHERE p.slug = ? AND pr.status = 'pending' AND pr.expires_at > ? + ORDER BY pr.created_at DESC + LIMIT 1 + """, + [username, datetime.utcnow().isoformat()], + ) + + if not row: + return jsonify({"data": {"status": "not_found"}}) + + # Claim the pairing + g.db.execute( + """ + UPDATE pairing_requests + SET status = 'claimed', hostname = ?, claimed_at = ? + WHERE id = ? + """, + [hostname, datetime.utcnow().isoformat(), row["id"]], + ) + + # Create the actual API token with hostname + g.db.execute( + """ + INSERT INTO api_tokens (publisher_id, token_hash, name, hostname, created_at) + VALUES (?, ?, ?, ?, ?) + """, + [row["publisher_id"], row["token_hash"], f"App: {hostname}", hostname, datetime.utcnow().isoformat()], + ) + g.db.commit() + + return jsonify({ + "data": { + "status": "connected", + "token": row["token_plain"], + "username": username, + } + }) + + @app.route("/api/v1/pairing/status", methods=["GET"]) + @require_token + def pairing_status() -> Response: + """Check if there's an active pairing request for the current user.""" + row = query_one( + g.db, + """ + SELECT status, expires_at, created_at + FROM pairing_requests + WHERE publisher_id = ? AND status = 'pending' AND expires_at > ? + ORDER BY created_at DESC + LIMIT 1 + """, + [g.current_publisher["id"], datetime.utcnow().isoformat()], + ) + + if not row: + return jsonify({"data": {"status": "none"}}) + + return jsonify({ + "data": { + "status": "pending", + "expires_at": row["expires_at"], + } + }) + + @app.route("/api/v1/connected-apps", methods=["GET"]) + @require_token + def list_connected_apps() -> Response: + """List connected apps (tokens with hostnames) for the current user.""" + rows = query_all( + g.db, + """ + SELECT id, name, hostname, created_at, last_used_at + FROM api_tokens + WHERE publisher_id = ? AND revoked_at IS NULL + ORDER BY last_used_at DESC NULLS LAST, created_at DESC + """, + [g.current_publisher["id"]], + ) + data = [] + for row in rows: + data.append({ + "id": row["id"], + "name": row["name"], + "hostname": row["hostname"] or "Unknown", + "created_at": row["created_at"], + "last_used_at": row["last_used_at"], + }) + return jsonify({"data": data}) + + @app.route("/api/v1/connected-apps/", methods=["DELETE"]) + @require_token + def disconnect_app(app_id: int) -> Response: + """Disconnect (revoke) an app.""" + row = query_one( + g.db, + "SELECT id FROM api_tokens WHERE id = ? AND publisher_id = ?", + [app_id, g.current_publisher["id"]], + ) + if not row: + return error_response("NOT_FOUND", "App not found", 404) + + g.db.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE id = ?", + [datetime.utcnow().isoformat(), app_id], + ) + g.db.commit() + return jsonify({"data": {"disconnected": True}}) + @app.route("/api/v1/tools", methods=["POST"]) @require_token def publish_tool() -> Response: diff --git a/src/cmdforge/registry/db.py b/src/cmdforge/registry/db.py index 08d8375..7c94074 100644 --- a/src/cmdforge/registry/db.py +++ b/src/cmdforge/registry/db.py @@ -208,6 +208,23 @@ CREATE TABLE IF NOT EXISTS audit_log ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- App pairing for simplified connection flow +CREATE TABLE IF NOT EXISTS pairing_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + publisher_id INTEGER NOT NULL REFERENCES publishers(id), + pairing_code TEXT UNIQUE NOT NULL, + token_hash TEXT, + token_plain TEXT, + hostname TEXT, + status TEXT DEFAULT 'pending', + expires_at TIMESTAMP NOT NULL, + claimed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_pairing_code ON pairing_requests(pairing_code, status); +CREATE INDEX IF NOT EXISTS idx_pairing_publisher ON pairing_requests(publisher_id, status); + CREATE TABLE IF NOT EXISTS consents ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_id TEXT, @@ -359,6 +376,22 @@ def migrate_db(conn: sqlite3.Connection) -> None: except sqlite3.OperationalError: pass + # API tokens table migrations (for Connected Apps feature) + cursor = conn.execute("PRAGMA table_info(api_tokens)") + tokens_cols = {row[1] for row in cursor.fetchall()} + + tokens_migrations = [ + ("hostname", "TEXT", "NULL"), + ] + + for col_name, col_type, default in tokens_migrations: + if col_name not in tokens_cols: + try: + conn.execute(f"ALTER TABLE api_tokens ADD COLUMN {col_name} {col_type} DEFAULT {default}") + conn.commit() + except sqlite3.OperationalError: + pass + # Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL # This ensures existing tools remain visible after migration try: diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py index 80aa9e1..3751802 100644 --- a/src/cmdforge/registry_client.py +++ b/src/cmdforge/registry_client.py @@ -565,7 +565,8 @@ class RegistryClient: self, config_yaml: str, readme: str = "", - dry_run: bool = False + dry_run: bool = False, + visibility: str = "public" ) -> Dict[str, Any]: """ Publish a tool to the registry. @@ -574,6 +575,7 @@ class RegistryClient: config_yaml: Tool configuration YAML content readme: README.md content dry_run: If True, validate without publishing + visibility: Tool visibility - "public", "private", or "unlisted" Returns: Dict with PR URL or validation results @@ -581,7 +583,8 @@ class RegistryClient: payload = { "config": config_yaml, "readme": readme, - "dry_run": dry_run + "dry_run": dry_run, + "visibility": visibility } response = self._request( diff --git a/src/cmdforge/ui_urwid/__init__.py b/src/cmdforge/ui_urwid/__init__.py index 9e7d782..ac8d193 100644 --- a/src/cmdforge/ui_urwid/__init__.py +++ b/src/cmdforge/ui_urwid/__init__.py @@ -172,12 +172,23 @@ class CmdForgeUI: tool_listbox = ToolListBox(self._tool_walker, on_focus_change=self._on_tool_focus) tool_box = urwid.LineBox(tool_listbox, title='Tools') + # Check if connected to registry + from ..config import load_config + config = load_config() + is_connected = bool(config.registry.token) + # Action buttons - Tab navigates here from tool list (3D style) create_btn = Button3DCompact("Create", lambda _: self._create_tool_before_selected()) edit_btn = Button3DCompact("Edit", lambda _: self._edit_selected_tool()) delete_btn = Button3DCompact("Delete", lambda _: self._delete_selected_tool()) test_btn = Button3DCompact("Test", lambda _: self._test_selected_tool()) - publish_btn = Button3DCompact("Publish", lambda _: self._publish_selected_tool()) + + # Show Connect or Publish based on connection status + if is_connected: + connect_publish_btn = Button3DCompact("Publish", lambda _: self._publish_selected_tool()) + else: + connect_publish_btn = Button3DCompact("Connect", lambda _: self._start_connect_flow()) + registry_btn = Button3DCompact("Registry", lambda _: self.browse_registry()) providers_btn = Button3DCompact("Providers", lambda _: self.manage_providers()) @@ -190,7 +201,7 @@ class CmdForgeUI: ('pack', urwid.Text(" ")), ('pack', test_btn), ('pack', urwid.Text(" ")), - ('pack', publish_btn), + ('pack', connect_publish_btn), ('pack', urwid.Text(" ")), ('pack', registry_btn), ('pack', urwid.Text(" ")), @@ -403,10 +414,15 @@ class CmdForgeUI: ) def _prompt_for_token(self, tool, version): - """Prompt user to enter their registry token.""" + """Prompt user to connect their account or enter a token manually.""" from ..config import set_registry_token - def on_info_ok(): + def on_connect(_): + self.close_overlay() + self._start_connect_flow(tool, version) + + def on_manual(_): + self.close_overlay() def on_token(token): if token and token.strip(): token = token.strip() @@ -427,14 +443,158 @@ class CmdForgeUI: on_token ) - self.message_box( + def on_cancel(_): + self.close_overlay() + + body = urwid.Text( + "To publish tools, you need to connect your account.\n\n" + "Option 1: Connect (Recommended)\n" + " - Creates a secure link to your account\n" + " - No manual token handling\n\n" + "Option 2: Enter Token Manually\n" + " - For CI/CD or advanced setups\n" + " - Get token from cmdforge.brrd.tech/dashboard" + ) + + dialog = Dialog( "Authentication Required", - "To publish tools, you need an API token.\n\n" - "1. Go to cmdforge.brrd.tech/register\n" - "2. Log in and go to Dashboard > Tokens\n" - "3. Create a token and copy it\n" - "4. Paste it in the next dialog", - callback=on_info_ok + body, + [("Connect", on_connect), ("Manual Token", on_manual), ("Cancel", on_cancel)] + ) + self.show_overlay(dialog, width=55, height=16) + + def _start_connect_flow(self, tool=None, version=None): + """Start the account connection flow.""" + from ..config import set_registry_token, get_registry_url + import socket + import time + import threading + + def on_username(username): + if not username or not username.strip(): + self.message_box("Error", "Username is required.") + return + + username = username.strip() + hostname = socket.gethostname() + registry_url = get_registry_url() + + # Remove trailing /api/v1 if present + base_url = registry_url.rstrip("/") + if base_url.endswith("/api/v1"): + base_url = base_url[:-7] + + pairing_url = f"{base_url}/api/v1/pairing/check/{username}" + + # Show waiting dialog + status_text = urwid.Text(('label', f"Connecting as @{username}...\nDevice: {hostname}\n\nWaiting for approval...")) + instructions = urwid.Text( + "\nGo to cmdforge.brrd.tech/dashboard/connected-apps\n" + "and click 'Connect New App' to approve." + ) + countdown_text = urwid.Text(('label', "Expires in 5:00")) + + body = urwid.Pile([ + status_text, + urwid.Divider(), + instructions, + urwid.Divider(), + countdown_text, + ]) + + # Track polling state + polling_state = {"active": True, "start_time": time.time()} + + def on_cancel(_): + polling_state["active"] = False + self.close_overlay() + + dialog = Dialog("Connecting...", body, [("Cancel", on_cancel)]) + self.show_overlay(dialog, width=55, height=16) + + def poll_for_connection(): + """Background thread to poll for connection.""" + import requests + + max_time = 300 # 5 minutes + while polling_state["active"]: + elapsed = time.time() - polling_state["start_time"] + if elapsed > max_time: + if self.loop: + self.loop.set_alarm_in(0, lambda l, d: self._on_connect_timeout()) + return + + # Update countdown + remaining = int(max_time - elapsed) + mins = remaining // 60 + secs = remaining % 60 + + def update_countdown(l, d, m=mins, s=secs): + countdown_text.set_text(('label', f"Expires in {m}:{s:02d}")) + self.refresh() + if self.loop: + self.loop.set_alarm_in(0, update_countdown) + + try: + response = requests.get( + pairing_url, + params={"hostname": hostname}, + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + status = data.get("data", {}).get("status") + + if status == "connected": + token = data.get("data", {}).get("token") + if token: + set_registry_token(token) + if self.loop: + self.loop.set_alarm_in(0, lambda l, d: self._on_connect_success(tool, version, username, hostname)) + return + + except Exception: + pass # Network errors, keep trying + + time.sleep(2) + + # Start polling in background thread + thread = threading.Thread(target=poll_for_connection, daemon=True) + thread.start() + + self.input_dialog( + "Connect Account", + "Enter your CmdForge username", + "", + on_username + ) + + def _on_connect_timeout(self): + """Called when connection times out.""" + self.close_overlay() + self.message_box( + "Timeout", + "Connection timed out.\n\n" + "Please initiate the connection from the web interface first,\n" + "then try again." + ) + + def _on_connect_success(self, tool, version, username, hostname): + """Called when connection succeeds.""" + self.close_overlay() + + def on_ok(): + if tool and version: + self._do_publish(tool, version) + + self.message_box( + "Connected!", + f"Successfully connected!\n\n" + f"Device: {hostname}\n" + f"Account: @{username}\n\n" + "You can now publish tools.", + callback=on_ok ) def _do_publish(self, tool, version): @@ -1523,14 +1683,136 @@ No explanations, no markdown fencing, just the code.""" self.message_box("Error", error_msg) return - if not self._is_edit and tool_exists(tool.name): - def on_yes(): - save_tool(tool) - self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) - self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", on_yes=on_yes) - else: + def do_save(): save_tool(tool) + self._offer_sync_after_save(tool) + + if not self._is_edit and tool_exists(tool.name): + self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", on_yes=do_save) + else: + do_save() + + def _offer_sync_after_save(self, tool): + """After saving, offer to sync to registry if connected.""" + from ..config import load_config + import yaml + + config = load_config() + + # Not connected - just show success and go to main menu + if not config.registry.token: self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) + return + + # Connected - offer to sync privately + tool_dir = get_tools_dir() / tool.name + config_path = tool_dir / "config.yaml" + + if not config_path.exists(): + self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) + return + + try: + config_data = yaml.safe_load(config_path.read_text()) + version = config_data.get("version", "") + except Exception: + version = "" + + if not version: + # No version - need to add one before syncing + def on_yes(): + self._prompt_for_version_and_sync(tool, config_path) + + def on_no(): + self.show_main_menu() + + self.yes_no( + "Sync to Registry?", + f"Tool '{tool.name}' saved locally.\n\n" + "Sync as a private tool to your account?\n" + "(Accessible only to you across devices)", + on_yes=on_yes, + on_no=on_no + ) + else: + # Already has version - offer sync + def on_yes(): + self._sync_tool_privately(tool, version) + + def on_no(): + self.show_main_menu() + + self.yes_no( + "Sync to Registry?", + f"Tool '{tool.name}' v{version} saved locally.\n\n" + "Sync as a private tool to your account?\n" + "(Accessible only to you across devices)", + on_yes=on_yes, + on_no=on_no + ) + + def _prompt_for_version_and_sync(self, tool, config_path): + """Prompt for version then sync privately.""" + import yaml + + def on_version(version): + version = version.strip() + if not version: + self.message_box("Cancelled", "Version is required for sync.", self.show_main_menu) + return + + # Update config with version + try: + config_data = yaml.safe_load(config_path.read_text()) or {} + config_data["version"] = version + config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False)) + except Exception as e: + self.message_box("Error", f"Failed to save version: {e}", self.show_main_menu) + return + + self._sync_tool_privately(tool, version) + + self.input_dialog( + "Version Required", + "Enter version for sync (e.g., 1.0.0)", + "1.0.0", + on_version + ) + + def _sync_tool_privately(self, tool, version): + """Sync tool to registry as private.""" + tool_dir = get_tools_dir() / tool.name + config_path = tool_dir / "config.yaml" + readme_path = tool_dir / "README.md" + + try: + config_yaml = config_path.read_text() + readme = readme_path.read_text() if readme_path.exists() else "" + + client = RegistryClient() + result = client.publish_tool(config_yaml, readme, visibility="private") + + owner = result.get("owner", "you") + self.message_box( + "Synced", + f"Tool synced privately!\n\n" + f"{owner}/{tool.name}@{version}\n\n" + "Only visible to you. Use 'Publish' to make public.", + self.show_main_menu + ) + + except RegistryError as e: + if e.code == "VERSION_EXISTS": + self.message_box( + "Already Synced", + f"Version {version} is already synced.\n" + "Bump the version to sync changes.", + self.show_main_menu + ) + else: + self.message_box("Sync Failed", f"Error: {e.message}", self.show_main_menu) + except Exception as e: + self.message_box("Sync Failed", f"Error: {e}", self.show_main_menu) def _on_cancel_tool(self, _): """Cancel tool editing.""" diff --git a/src/cmdforge/web/templates/dashboard/tokens.html b/src/cmdforge/web/templates/dashboard/tokens.html index 6cb3d97..1647ed7 100644 --- a/src/cmdforge/web/templates/dashboard/tokens.html +++ b/src/cmdforge/web/templates/dashboard/tokens.html @@ -1,21 +1,21 @@ {% extends "dashboard/base.html" %} {% set active_page = 'tokens' %} -{% block title %}API Tokens - CmdForge Dashboard{% endblock %} +{% block title %}Connected Apps - CmdForge Dashboard{% endblock %} {% block dashboard_header %}
-

API Tokens

-

Manage tokens for CLI and API access

+

Connected Apps

+

Manage devices connected to your CmdForge account

{% endblock %} @@ -28,11 +28,11 @@
-

About API Tokens

+

About Connected Apps

- API tokens are used to authenticate with the CmdForge registry from the CLI. - Use cmdforge auth login to authenticate, - or set the CMDFORGE_TOKEN environment variable. + Connected apps can publish tools and sync your work across devices. + Run cmdforge config connect <username> in your terminal, + then click "Connect New App" here to complete the connection.

@@ -44,16 +44,13 @@ - Name + Device - Created + Connected - Last Used - - - Status + Last Active Actions @@ -65,14 +62,16 @@
-
- - +
+ +
-

{{ token.name }}

-

st_...{{ token.token_suffix }}

+

{{ token.hostname or token.name }}

+ {% if token.hostname and token.name != 'App: ' + token.hostname %} +

{{ token.name }}

+ {% endif %}
@@ -82,27 +81,12 @@ {{ token.last_used_at|timeago if token.last_used_at else 'Never' }} - - {% if token.revoked_at %} - - Revoked - - {% else %} - - Active - - {% endif %} - - {% if not token.revoked_at %} - {% else %} - Revoked {{ token.revoked_at|timeago }} - {% endif %} {% endfor %} @@ -113,21 +97,101 @@
- + -

No API tokens

+

No connected apps

- Create an API token to authenticate with the registry from the command line. + Connect your CmdForge CLI to sync tools and publish across devices.

{% endif %} - + + + + - + - -

- To use this token, run: -

-
export CMDFORGE_TOKEN=""
@@ -230,6 +289,154 @@
{% endblock %}