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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user