From 64b9e8e70da46830b2763d6c36fe5a9897684627 Mon Sep 17 00:00:00 2001
From: rob
Date: Fri, 16 Jan 2026 19:50:30 -0400
Subject: [PATCH] Add status polling and CLI status command for tool moderation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add QTimer polling every 30s when tools are pending/changes_requested
- Display changes_requested (⚠) and rejected (✗) states in tool tree
- Show moderator feedback in tool info panel
- Add `cmdforge registry status ` CLI command with --sync flag
- Update _sync_tool to persist registry_feedback field
Co-Authored-By: Claude Opus 4.5
---
src/cmdforge/cli/__init__.py | 6 +
src/cmdforge/cli/registry_commands.py | 117 +++++++++++++++-
src/cmdforge/gui/pages/tools_page.py | 132 +++++++++++++++++-
src/cmdforge/web/templates/admin/pending.html | 6 +-
4 files changed, 255 insertions(+), 6 deletions(-)
diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py
index 7e0721d..7ef3570 100644
--- a/src/cmdforge/cli/__init__.py
+++ b/src/cmdforge/cli/__init__.py
@@ -186,6 +186,12 @@ def main():
p_reg_mytools = registry_sub.add_parser("my-tools", help="List your published tools")
p_reg_mytools.set_defaults(func=cmd_registry)
+ # registry status
+ p_reg_status = registry_sub.add_parser("status", help="Check moderation status of a tool")
+ p_reg_status.add_argument("tool", help="Tool name to check status for")
+ p_reg_status.add_argument("--sync", action="store_true", help="Sync status from server and update local config")
+ p_reg_status.set_defaults(func=cmd_registry)
+
# registry browse
p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)")
p_reg_browse.set_defaults(func=cmd_registry)
diff --git a/src/cmdforge/cli/registry_commands.py b/src/cmdforge/cli/registry_commands.py
index 365af39..bf9e9d1 100644
--- a/src/cmdforge/cli/registry_commands.py
+++ b/src/cmdforge/cli/registry_commands.py
@@ -30,6 +30,8 @@ def cmd_registry(args):
return _cmd_registry_publish(args)
elif args.registry_cmd == "my-tools":
return _cmd_registry_my_tools(args)
+ elif args.registry_cmd == "status":
+ return _cmd_registry_status(args)
elif args.registry_cmd == "browse":
return _cmd_registry_browse(args)
elif args.registry_cmd == "config":
@@ -45,7 +47,8 @@ def cmd_registry(args):
print(" update Update local index cache")
print(" publish [path] Publish a tool")
print(" my-tools List your published tools")
- print(" browse Browse tools (TUI)")
+ print(" status Check moderation status of a tool")
+ print(" browse Browse tools (GUI)")
print(" config [action] Manage registry settings (admin)")
return 0
@@ -503,6 +506,118 @@ def _cmd_registry_my_tools(args):
return 0
+def _cmd_registry_status(args):
+ """Check moderation status of a tool."""
+ from ..registry_client import RegistryError, get_client
+ from ..tool import get_tools_dir
+
+ tool_name = args.tool
+ do_sync = getattr(args, 'sync', False)
+
+ # Check if tool exists locally
+ config_path = get_tools_dir() / tool_name / "config.yaml"
+ if not config_path.exists():
+ print(f"Error: Tool '{tool_name}' not found locally", file=sys.stderr)
+ return 1
+
+ try:
+ config_data = yaml.safe_load(config_path.read_text()) or {}
+ except yaml.YAMLError as e:
+ print(f"Error reading tool config: {e}", file=sys.stderr)
+ return 1
+
+ local_status = config_data.get("registry_status", "not_published")
+ local_hash = config_data.get("registry_hash")
+ local_feedback = config_data.get("registry_feedback")
+
+ if not local_hash:
+ print(f"Tool '{tool_name}' has not been published to the registry.")
+ print()
+ print("Publish with: cmdforge registry publish")
+ return 0
+
+ # If syncing, fetch from server
+ if do_sync:
+ try:
+ client = get_client()
+ status_data = client.get_my_tool_status(tool_name)
+
+ new_status = status_data.get("status", "pending")
+ new_hash = status_data.get("config_hash")
+ new_feedback = status_data.get("feedback")
+
+ changed = False
+ if local_status != new_status:
+ config_data["registry_status"] = new_status
+ local_status = new_status
+ changed = True
+ if new_hash and local_hash != new_hash:
+ config_data["registry_hash"] = new_hash
+ local_hash = new_hash
+ changed = True
+ if new_feedback != local_feedback:
+ if new_feedback:
+ config_data["registry_feedback"] = new_feedback
+ local_feedback = new_feedback
+ elif "registry_feedback" in config_data:
+ del config_data["registry_feedback"]
+ local_feedback = None
+ changed = True
+
+ if changed:
+ config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
+ print("Status synced from server.\n")
+
+ except RegistryError as e:
+ if e.code == "UNAUTHORIZED":
+ print("Not logged in. Set your registry token to sync.", file=sys.stderr)
+ elif e.code == "TOOL_NOT_FOUND":
+ print(f"Tool '{tool_name}' not found in registry.", file=sys.stderr)
+ else:
+ print(f"Error syncing: {e.message}", file=sys.stderr)
+ return 1
+ except Exception as e:
+ print(f"Error syncing: {e}", file=sys.stderr)
+ return 1
+
+ # Display status
+ print(f"Tool: {tool_name}")
+ print(f"Registry Hash: {local_hash[:16]}...")
+
+ status_colors = {
+ "approved": "\033[32mApproved\033[0m", # Green
+ "pending": "\033[33mPending Review\033[0m", # Yellow
+ "changes_requested": "\033[93mChanges Requested\033[0m", # Light yellow/orange
+ "rejected": "\033[31mRejected\033[0m", # Red
+ }
+ status_display = status_colors.get(local_status, local_status)
+ print(f"Status: {status_display}")
+
+ if local_feedback:
+ print()
+ print("Feedback from moderator:")
+ print("-" * 40)
+ print(local_feedback)
+ print("-" * 40)
+
+ if local_status == "changes_requested":
+ print()
+ print("Action required: Address the feedback above and republish.")
+ print(" cmdforge registry publish")
+ elif local_status == "rejected":
+ print()
+ print("Your tool was rejected. Review the feedback above.")
+ elif local_status == "pending":
+ print()
+ print("Your tool is waiting for moderator review.")
+ print("Use --sync to check for updates.")
+ elif local_status == "approved":
+ print()
+ print("Your tool is live in the registry!")
+
+ return 0
+
+
def _cmd_registry_browse(args):
"""Browse tools (GUI)."""
from ..gui import run_gui
diff --git a/src/cmdforge/gui/pages/tools_page.py b/src/cmdforge/gui/pages/tools_page.py
index 6d6a3ad..f4fb928 100644
--- a/src/cmdforge/gui/pages/tools_page.py
+++ b/src/cmdforge/gui/pages/tools_page.py
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import (
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
QPushButton, QGroupBox, QMessageBox, QFrame
)
-from PySide6.QtCore import Qt, QThread, Signal
+from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont, QColor, QBrush
from ...tool import (
@@ -68,9 +68,11 @@ class StatusSyncWorker(QThread):
status_data = client.get_my_tool_status(tool_name)
new_status = status_data.get("status", "pending")
new_hash = status_data.get("config_hash")
+ new_feedback = status_data.get("feedback")
old_status = config_data.get("registry_status", "pending")
old_hash = config_data.get("registry_hash")
+ old_feedback = config_data.get("registry_feedback")
changed = False
if old_status != new_status:
@@ -79,6 +81,12 @@ class StatusSyncWorker(QThread):
if new_hash and old_hash != new_hash:
config_data["registry_hash"] = new_hash
changed = True
+ if new_feedback != old_feedback:
+ if new_feedback:
+ config_data["registry_feedback"] = new_feedback
+ elif "registry_feedback" in config_data:
+ del config_data["registry_feedback"]
+ changed = True
if changed:
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
@@ -93,6 +101,8 @@ def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
Tuple of (state, registry_hash) where state is:
- "published" - approved in registry and current hash matches
- "pending" - submitted but awaiting moderation
+ - "changes_requested" - admin requested changes before approval
+ - "rejected" - rejected by admin
- "modified" - has registry_hash but current hash differs
- "local" - no registry_hash (never published/downloaded)
"""
@@ -123,8 +133,12 @@ def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
# Hash matches - check moderation status
if registry_status == "approved":
return ("published", registry_hash)
+ elif registry_status == "changes_requested":
+ return ("changes_requested", registry_hash)
+ elif registry_status == "rejected":
+ return ("rejected", registry_hash)
else:
- # pending, rejected, or unknown
+ # pending or unknown
return ("pending", registry_hash)
except Exception:
return ("local", None)
@@ -139,6 +153,8 @@ class ToolsPage(QWidget):
self._current_tool = None
self._sync_worker = None
self._syncing = False # Prevent re-sync during update
+ self._poll_timer = None # Timer for automatic status polling
+ self._has_pending_tools = False # Track if we need to poll
self._setup_ui()
self.refresh()
@@ -269,6 +285,7 @@ class ToolsPage(QWidget):
self.tool_tree.clear()
self._current_tool = None
self.info_text.clear()
+ self._has_pending_tools = False # Reset, will be set during tree building
tools = list_tools()
@@ -301,6 +318,10 @@ class ToolsPage(QWidget):
# Get publish state
state, registry_hash = get_tool_publish_state(name)
+ # Track if we have pending tools for polling
+ if state in ("pending", "changes_requested"):
+ self._has_pending_tools = True
+
# Show state indicator in display name
if state == "published":
display_name = f"{name} ✓"
@@ -310,6 +331,14 @@ class ToolsPage(QWidget):
display_name = f"{name} ◐"
tooltip = "Submitted to registry - pending review"
color = QColor(214, 158, 46) # Yellow/amber
+ elif state == "changes_requested":
+ display_name = f"{name} ⚠"
+ tooltip = "Changes requested - see feedback"
+ color = QColor(245, 158, 11) # Orange/amber
+ elif state == "rejected":
+ display_name = f"{name} ✗"
+ tooltip = "Rejected by moderator"
+ color = QColor(220, 38, 38) # Red
elif state == "modified":
display_name = f"{name} ●"
tooltip = "Published to registry - local modifications"
@@ -346,6 +375,9 @@ class ToolsPage(QWidget):
# Start background sync for published tools
self._start_background_sync(tools)
+ # Manage polling timer based on pending state
+ self._manage_poll_timer()
+
def _start_background_sync(self, tools: list):
"""Start background sync for tools that have been published."""
if self._syncing:
@@ -385,6 +417,51 @@ class ToolsPage(QWidget):
"""Handle background sync completion."""
self._syncing = False
+ def _manage_poll_timer(self):
+ """Start or stop the polling timer based on pending tools."""
+ config = load_config()
+ should_poll = self._has_pending_tools and config.registry.token
+
+ if should_poll:
+ if not self._poll_timer:
+ # Create timer that polls every 30 seconds
+ self._poll_timer = QTimer(self)
+ self._poll_timer.timeout.connect(self._poll_status)
+ if not self._poll_timer.isActive():
+ self._poll_timer.start(30000) # 30 seconds
+ else:
+ # No pending tools, stop polling
+ if self._poll_timer and self._poll_timer.isActive():
+ self._poll_timer.stop()
+
+ def _poll_status(self):
+ """Timer callback to poll status for pending tools."""
+ if self._syncing:
+ return # Already syncing
+
+ # Get list of tools with pending status
+ tools = list_tools()
+ pending_tools = []
+
+ for name in tools:
+ config_path = get_tools_dir() / name / "config.yaml"
+ if config_path.exists():
+ try:
+ config_data = yaml.safe_load(config_path.read_text()) or {}
+ status = config_data.get("registry_status")
+ if status in ("pending", "changes_requested") and config_data.get("registry_hash"):
+ pending_tools.append(name)
+ except Exception:
+ pass
+
+ if pending_tools:
+ self._start_background_sync(pending_tools)
+ else:
+ # No more pending tools, stop timer
+ if self._poll_timer and self._poll_timer.isActive():
+ self._poll_timer.stop()
+ self._has_pending_tools = False
+
def _on_tool_status_updated(self, tool_name: str):
"""Handle background sync updating a tool's status."""
# Refresh the display - _syncing flag prevents re-triggering sync
@@ -420,6 +497,17 @@ class ToolsPage(QWidget):
if tool_name:
self._edit_tool()
+ def _get_tool_feedback(self, tool_name: str) -> Optional[str]:
+ """Get the feedback for a tool from its config."""
+ config_path = get_tools_dir() / tool_name / "config.yaml"
+ if config_path.exists():
+ try:
+ config_data = yaml.safe_load(config_path.read_text()) or {}
+ return config_data.get("registry_feedback")
+ except Exception:
+ pass
+ return None
+
def _show_tool_info(self, tool: Tool):
"""Display tool information."""
lines = []
@@ -443,6 +531,36 @@ class ToolsPage(QWidget):
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
"◐ Submitted to registry - pending review
"
)
+ elif state == "changes_requested":
+ lines.append(
+ ""
+ "⚠ Changes requested - please address feedback and republish
"
+ )
+ # Show feedback if available
+ feedback = self._get_tool_feedback(tool.name)
+ if feedback:
+ feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "
")
+ lines.append(
+ f""
+ f"Feedback:
{feedback_escaped}
"
+ )
+ elif state == "rejected":
+ lines.append(
+ ""
+ "✗ Rejected by moderator
"
+ )
+ # Show feedback if available
+ feedback = self._get_tool_feedback(tool.name)
+ if feedback:
+ feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "
")
+ lines.append(
+ f""
+ f"Reason:
{feedback_escaped}
"
+ )
elif state == "modified":
lines.append(
"
-
+
|
@@ -194,7 +194,7 @@
-
+
@@ -215,7 +215,7 @@
-
+