Add admin publish-as-official, hash-based status sync, and GUI fixes

Registry:
- Add admin owner override for publishing tools as "official"
- Add POST /api/v1/tools/status-by-hash batch endpoint for status lookup
  scoped to publisher_id (works for tools published under any owner)

GUI:
- Add "Publish as" dropdown in publish dialog for admin users
- Add "installed" tool state (teal with arrow indicator) for registry-installed tools
- Fix tool editing for official/* qualified tool names (_get_qualified_name helper)
- Fix cancel navigation returning to wrong page (Welcome instead of Tools)
- Fix collections tab not refreshing after publish
- Refactor StatusSyncWorker to use batch hash lookup (1 request instead of N)
  with chunking (100 max) and hash collision handling

CLI:
- Switch registry status sync to hash-based lookup
- Add collection dependency checking and unpublished dep detection

Publish dialog cleanup:
- Move yaml import to module level, remove duplicate _bump_patch_version
- Fix owner combo using currentText() for reliable selection

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-29 17:18:37 -04:00
co-authored by Claude Opus 4.5
parent 071ade0ffb
commit 0ee21f27f7
13 changed files with 1459 additions and 106 deletions
+149 -13
View File
@@ -440,6 +440,122 @@ def _cmd_registry_publish(args):
print("\nCancelled.")
return 1
# Check for unpublished dependencies
from ..collection import gather_local_unpublished_deps
from ..tool import load_tool, Tool, ToolStep
dep_result = None
try:
client = get_client()
# Get user slug for proper path resolution
try:
me = client.get_me()
my_owner = me.get("slug", "")
except Exception:
my_owner = None
tool = load_tool(name)
if tool:
dep_result = gather_local_unpublished_deps([name], client, my_owner)
else:
# Tool isn't installed locally; derive deps from config.yaml
temp_tool = Tool.from_dict(data)
dep_names = []
for dep in temp_tool.dependencies:
if '/' not in dep:
dep_names.append(dep)
for step in temp_tool.steps:
if isinstance(step, ToolStep) and '/' not in step.tool:
dep_names.append(step.tool)
dep_names = sorted(set(dep_names))
if dep_names:
dep_result = gather_local_unpublished_deps(dep_names, client, my_owner)
except Exception as e:
print(f"Warning: Could not check dependencies: {e}", file=sys.stderr)
dep_result = None
if dep_result and dep_result.unpublished:
print(f"Warning: This tool has unpublished dependencies:")
for dep in dep_result.unpublished:
print(f" - {dep}")
if dep_result.cycles:
print(f"\nWarning: Circular dependencies detected:")
for cycle in dep_result.cycles:
print(f" {' -> '.join(cycle)}")
if dep_result.skipped:
print(f"\nNote: Could not check {len(dep_result.skipped)} dep(s) due to errors")
print()
# Non-interactive mode: warn but continue
if not sys.stdin.isatty():
print("Non-interactive mode: proceeding without publishing dependencies.")
else:
try:
choice = input("Publish dependencies first? [Y/n/skip] ").lower().strip()
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
return 1
if choice == 'skip':
pass # Continue without deps
elif choice in ('n', 'no'):
print("Cancelled.")
return 1
elif choice in ('', 'y', 'yes'):
# Publish deps in topological order (only unpublished ones)
from .collections_commands import _publish_single_tool
for dep in dep_result.publish_order:
if dep in dep_result.unpublished and dep != name:
print(f"Publishing {dep}...")
result = _publish_single_tool(dep, client)
if not result.get("success"):
print(f" Failed: {result.get('error')}", file=sys.stderr)
return 1
elif result.get("pending"):
print(f" Submitted for review (pending approval)")
else:
print(f" Published successfully")
print()
else:
print("Cancelled.")
return 1
# Check if tool was previously rejected - if so, bump version
try:
client = get_client()
status_info = client.get_my_tool_status(name)
current_status = status_info.get("status", "")
if current_status == "rejected":
# Bump version to allow resubmission
def bump_patch(v):
import re as re_mod
match = re_mod.match(r'^(\d+)\.(\d+)\.(\d+)(.*)?$', v)
if match:
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
suffix = match.group(4) or ""
return f"{major}.{minor}.{patch + 1}{suffix}"
return f"{v}.1"
new_version = bump_patch(version)
print(f"Previous version was rejected. Bumping version {version} -> {new_version}")
# Update config_yaml with new version
data["version"] = new_version
config_yaml = yaml.dump(data, default_flow_style=False, sort_keys=False)
version = new_version
# Also update local file
config_path.write_text(config_yaml)
except RegistryError as e:
if e.code != "TOOL_NOT_FOUND":
pass # Other errors - continue with publish
except Exception:
pass # Continue with publish
print(f"Publishing {name}@{version}...")
try:
@@ -560,14 +676,34 @@ def _cmd_registry_my_tools(args):
def _cmd_registry_status(args):
"""Check moderation status of a tool."""
from ..registry_client import RegistryError, get_client
from ..tool import get_tools_dir
from ..tool import get_tools_dir, load_tool
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():
# Try to find the tool - prefer qualified (owned) path, then unqualified
config_path = None
tools_dir = get_tools_dir()
# Try to get user slug for owned path resolution
try:
client = get_client()
me = client.get_me()
my_owner = me.get("slug", "")
if my_owner:
owned_path = tools_dir / my_owner / tool_name / "config.yaml"
if owned_path.exists():
config_path = owned_path
except Exception:
pass # No auth or network error, try unqualified
# Fall back to unqualified path
if not config_path:
unqualified_path = tools_dir / tool_name / "config.yaml"
if unqualified_path.exists():
config_path = unqualified_path
if not config_path:
print(f"Error: Tool '{tool_name}' not found locally", file=sys.stderr)
return 1
@@ -587,14 +723,20 @@ def _cmd_registry_status(args):
print("Publish with: cmdforge registry publish")
return 0
# If syncing, fetch from server
# If syncing, fetch from server using hash-based lookup
if do_sync:
try:
client = get_client()
status_data = client.get_my_tool_status(tool_name)
# Use hash-based batch lookup (works for tools published under any owner)
results = client.get_tool_status_by_hashes([local_hash])
status_data = results.get(local_hash)
if not status_data:
print(f"Tool '{tool_name}' not found in registry.", file=sys.stderr)
return 1
new_status = status_data.get("status", "pending")
new_hash = status_data.get("config_hash")
new_feedback = status_data.get("feedback")
changed = False
@@ -602,10 +744,6 @@ def _cmd_registry_status(args):
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
@@ -622,8 +760,6 @@ def _cmd_registry_status(args):
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