From f1c462146b287f28fdb8264f05c231c8d8457490 Mon Sep 17 00:00:00 2001
From: rob
Date: Fri, 16 Jan 2026 07:52:42 -0400
Subject: [PATCH] Fix publish state tracking and add sync status feature
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Track moderation status (pending/approved/rejected) in local tool config
- Add new "pending" state indicator (◐ yellow) distinct from "published" (✓ green)
- Add "Sync Status" button to check registry for updated moderation status
- Add /me/tools//status API endpoint for checking tool status
- Improve admin panel error handling with better error messages
Co-Authored-By: Claude Opus 4.5
---
src/cmdforge/gui/dialogs/publish_dialog.py | 4 +-
src/cmdforge/gui/pages/tools_page.py | 90 +++++++++++++++++--
src/cmdforge/registry/app.py | 32 +++++++
src/cmdforge/registry_client.py | 24 +++++
src/cmdforge/web/templates/admin/pending.html | 12 ++-
5 files changed, 151 insertions(+), 11 deletions(-)
diff --git a/src/cmdforge/gui/dialogs/publish_dialog.py b/src/cmdforge/gui/dialogs/publish_dialog.py
index c17c0dd..6fd1316 100644
--- a/src/cmdforge/gui/dialogs/publish_dialog.py
+++ b/src/cmdforge/gui/dialogs/publish_dialog.py
@@ -183,14 +183,16 @@ class PublishDialog(QDialog):
self.status_label.setText("Published successfully!")
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
- # Save registry_hash to local config for publish state tracking
+ # Save registry_hash and moderation status to local config for publish state tracking
config_hash = result.get("config_hash")
+ moderation_status = result.get("status", "pending")
if config_hash and hasattr(self._tool, 'path') and self._tool.path:
try:
config_path = self._tool.path
if config_path.exists():
config_data = yaml.safe_load(config_path.read_text()) or {}
config_data["registry_hash"] = config_hash
+ config_data["registry_status"] = moderation_status
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
except Exception:
pass # Non-critical - just won't show publish state
diff --git a/src/cmdforge/gui/pages/tools_page.py b/src/cmdforge/gui/pages/tools_page.py
index ff70392..4633980 100644
--- a/src/cmdforge/gui/pages/tools_page.py
+++ b/src/cmdforge/gui/pages/tools_page.py
@@ -29,7 +29,8 @@ def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
Returns:
Tuple of (state, registry_hash) where state is:
- - "published" - has registry_hash and current hash matches
+ - "published" - approved in registry and current hash matches
+ - "pending" - submitted but awaiting moderation
- "modified" - has registry_hash but current hash differs
- "local" - no registry_hash (never published/downloaded)
"""
@@ -40,6 +41,7 @@ def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
try:
config = yaml.safe_load(config_path.read_text())
registry_hash = config.get("registry_hash")
+ registry_status = config.get("registry_status", "pending")
if not registry_hash:
return ("local", None)
@@ -47,10 +49,15 @@ def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
# Compute current hash (excluding hash fields)
current_hash = compute_config_hash(config)
- if current_hash == registry_hash:
+ if current_hash != registry_hash:
+ return ("modified", registry_hash)
+
+ # Hash matches - check moderation status
+ if registry_status == "approved":
return ("published", registry_hash)
else:
- return ("modified", registry_hash)
+ # pending, rejected, or unknown
+ return ("pending", registry_hash)
except Exception:
return ("local", None)
@@ -156,6 +163,14 @@ class ToolsPage(QWidget):
btn_layout.addStretch()
+ # Sync status button (only when connected)
+ self.btn_sync = QPushButton("Sync Status")
+ self.btn_sync.setObjectName("secondary")
+ self.btn_sync.clicked.connect(self._sync_tool_status)
+ self.btn_sync.setEnabled(False)
+ self.btn_sync.setToolTip("Check registry for updated moderation status")
+ btn_layout.addWidget(self.btn_sync)
+
# Connect/Publish button
config = load_config()
if config.registry.token:
@@ -219,8 +234,12 @@ class ToolsPage(QWidget):
# Show state indicator in display name
if state == "published":
display_name = f"{name} ✓"
- tooltip = "Published to registry - up to date"
+ tooltip = "Published to registry - approved"
color = QColor(56, 161, 105) # Green
+ elif state == "pending":
+ display_name = f"{name} ◐"
+ tooltip = "Submitted to registry - pending review"
+ color = QColor(214, 158, 46) # Yellow/amber
elif state == "modified":
display_name = f"{name} ●"
tooltip = "Published to registry - local modifications"
@@ -298,7 +317,13 @@ class ToolsPage(QWidget):
lines.append(
""
- "✓ Published to registry - up to date
"
+ "✓ Published to registry - approved
"
+ )
+ elif state == "pending":
+ lines.append(
+ ""
+ "◐ Submitted to registry - pending review
"
)
elif state == "modified":
lines.append(
@@ -360,9 +385,16 @@ class ToolsPage(QWidget):
if config.registry.token:
# Connected - enable Publish when tool selected
self.btn_publish.setEnabled(has_selection)
+ # Enable Sync when tool selected and has registry_hash
+ if has_selection:
+ state, registry_hash = get_tool_publish_state(self._current_tool.name)
+ self.btn_sync.setEnabled(registry_hash is not None)
+ else:
+ self.btn_sync.setEnabled(False)
else:
# Not connected - Connect button is always enabled
self.btn_publish.setEnabled(True)
+ self.btn_sync.setEnabled(False)
def _create_tool(self):
"""Create a new tool."""
@@ -413,8 +445,54 @@ class ToolsPage(QWidget):
if not self._current_tool:
return
+ tool_name = self._current_tool.name # Save before refresh clears it
from ..dialogs.publish_dialog import PublishDialog
dialog = PublishDialog(self, self._current_tool)
if dialog.exec():
self.refresh() # Refresh to show updated publish state indicator
- self.main_window.show_status(f"Published '{self._current_tool.name}'")
+ self.main_window.show_status(f"Published '{tool_name}'")
+
+ def _sync_tool_status(self):
+ """Sync the moderation status of the selected tool from the registry."""
+ if not self._current_tool:
+ return
+
+ tool_name = self._current_tool.name
+ config_path = get_tools_dir() / tool_name / "config.yaml"
+
+ if not config_path.exists():
+ return
+
+ try:
+ from ...registry_client import RegistryClient, RegistryError
+
+ config = load_config()
+ client = RegistryClient()
+ client.token = config.registry.token
+
+ # Get status from registry
+ status_data = client.get_my_tool_status(tool_name)
+ new_status = status_data.get("status", "pending")
+
+ # Update local config
+ config_data = yaml.safe_load(config_path.read_text()) or {}
+ old_status = config_data.get("registry_status", "pending")
+
+ if old_status != new_status:
+ config_data["registry_status"] = new_status
+ config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
+
+ self.refresh()
+ if new_status == "approved":
+ self.main_window.show_status(f"Tool '{tool_name}' has been approved!")
+ elif new_status == "rejected":
+ self.main_window.show_status(f"Tool '{tool_name}' was rejected")
+ else:
+ self.main_window.show_status(f"Status updated: {new_status}")
+ else:
+ self.main_window.show_status(f"Status unchanged: {new_status}")
+
+ except RegistryError as e:
+ QMessageBox.warning(self, "Sync Error", f"Could not sync status:\n{e.message}")
+ except Exception as e:
+ QMessageBox.warning(self, "Sync Error", f"Could not sync status:\n{e}")
diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py
index 91bbdd8..daa13dd 100644
--- a/src/cmdforge/registry/app.py
+++ b/src/cmdforge/registry/app.py
@@ -1900,6 +1900,38 @@ def create_app() -> Flask:
})
return jsonify({"data": data})
+ @app.route("/api/v1/me/tools//status", methods=["GET"])
+ @require_token
+ def my_tool_status(name: str) -> Response:
+ """Get the moderation status of a specific tool owned by the current user."""
+ owner = g.current_publisher["slug"]
+
+ # Query for the most recent version of this tool
+ row = query_one(
+ g.db,
+ """
+ SELECT name, version, moderation_status, config_hash, published_at
+ FROM tools
+ WHERE owner = ? AND name = ?
+ ORDER BY published_at DESC
+ LIMIT 1
+ """,
+ [owner, name],
+ )
+
+ if not row:
+ return error_response("TOOL_NOT_FOUND", f"Tool '{name}' not found", 404)
+
+ return jsonify({
+ "data": {
+ "name": row["name"],
+ "version": row["version"],
+ "status": row["moderation_status"],
+ "config_hash": row["config_hash"],
+ "published_at": row["published_at"],
+ }
+ })
+
@app.route("/api/v1/tools///deprecate", methods=["POST"])
@require_token
def deprecate_tool(owner: str, name: str) -> Response:
diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py
index ee9f313..185c5af 100644
--- a/src/cmdforge/registry_client.py
+++ b/src/cmdforge/registry_client.py
@@ -620,6 +620,30 @@ class RegistryClient:
tools = response.json().get("data", [])
return [ToolInfo.from_dict(t) for t in tools]
+ def get_my_tool_status(self, name: str) -> Dict[str, Any]:
+ """
+ Get the moderation status of a specific tool owned by the authenticated user.
+
+ Args:
+ name: Tool name
+
+ Returns:
+ Dict with name, version, status (pending/approved/rejected), config_hash, published_at
+ """
+ response = self._request("GET", f"/me/tools/{name}/status", require_auth=True)
+
+ if response.status_code == 404:
+ raise RegistryError(
+ code="TOOL_NOT_FOUND",
+ message=f"Tool '{name}' not found",
+ http_status=404
+ )
+
+ if response.status_code != 200:
+ self._handle_error_response(response)
+
+ return response.json().get("data", {})
+
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
"""
Get most popular tools.
diff --git a/src/cmdforge/web/templates/admin/pending.html b/src/cmdforge/web/templates/admin/pending.html
index a575458..893f701 100644
--- a/src/cmdforge/web/templates/admin/pending.html
+++ b/src/cmdforge/web/templates/admin/pending.html
@@ -145,14 +145,16 @@ async function approveTool(toolId) {
}
});
if (response.ok) {
- document.getElementById(`tool-row-${toolId}`).remove();
+ const row = document.getElementById(`tool-row-${toolId}`);
+ if (row) row.remove();
location.reload();
} else {
const data = await response.json();
alert(data.error?.message || 'Failed to approve tool');
}
} catch (error) {
- alert('Network error');
+ console.error('Approve error:', error);
+ alert('Error: ' + (error.message || 'Unknown error occurred'));
}
}
@@ -184,14 +186,16 @@ async function confirmReject() {
});
if (response.ok) {
closeRejectModal();
- document.getElementById(`tool-row-${currentToolId}`).remove();
+ const row = document.getElementById(`tool-row-${currentToolId}`);
+ if (row) row.remove();
location.reload();
} else {
const data = await response.json();
alert(data.error?.message || 'Failed to reject tool');
}
} catch (error) {
- alert('Network error');
+ console.error('Reject error:', error);
+ alert('Error: ' + (error.message || 'Unknown error occurred'));
}
}