CmdForge/src/cmdforge/cli/registry_commands.py

1393 lines
50 KiB
Python

"""Registry commands."""
import json
import re
import sys
from pathlib import Path
import yaml
from ..config import load_config, set_registry_token
from ..resolver import install_from_registry, uninstall_tool, ToolSpec
def cmd_registry(args):
"""Handle registry subcommands."""
from ..registry_client import RegistryClient, RegistryError, RateLimitError, get_client
if args.registry_cmd == "search":
return _cmd_registry_search(args)
elif args.registry_cmd == "tags":
return _cmd_registry_tags(args)
elif args.registry_cmd == "install":
return _cmd_registry_install(args)
elif args.registry_cmd == "uninstall":
return _cmd_registry_uninstall(args)
elif args.registry_cmd == "info":
return _cmd_registry_info(args)
elif args.registry_cmd == "update":
return _cmd_registry_update(args)
elif args.registry_cmd == "publish":
return _cmd_registry_publish(args)
elif args.registry_cmd == "signing-key":
return _cmd_registry_signing_key(args)
elif args.registry_cmd == "improve":
return _cmd_registry_improve(args)
elif args.registry_cmd == "review-improvement":
return _cmd_registry_review_improvement(args)
elif args.registry_cmd == "update-readme":
return _cmd_registry_update_readme(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 == "describe":
return _cmd_registry_describe(args)
elif args.registry_cmd == "config":
return _cmd_registry_config(args)
else:
# Default: show registry help
print("Registry commands:")
print(" search <query> Search for tools")
print(" tags List available tags")
print(" install <tool> Install a tool")
print(" uninstall <tool> Uninstall a tool")
print(" info <tool> Show tool information")
print(" update Update local index cache")
print(" publish [path] Publish a tool")
print(" signing-key Manage Ed25519 release signing")
print(" improve Submit a community improvement")
print(" update-readme Update README for published tool(s)")
print(" my-tools List your published tools")
print(" status <tool> Check moderation status of a tool")
print(" browse Browse tools (GUI)")
print(" describe <query> Find tools by describing what you need (AI)")
print(" config [action] Manage registry settings (admin)")
return 0
def _cmd_registry_search(args):
"""Search for tools in the registry."""
from ..registry_client import RegistryError, get_client
try:
client = get_client()
# Handle shortcut flags
min_downloads = getattr(args, 'min_downloads', None)
max_downloads = None
if getattr(args, 'popular', False):
min_downloads = 100
if getattr(args, 'new', False):
max_downloads = 10
results = client.search_tools(
query=args.query,
category=getattr(args, 'category', None),
tags=getattr(args, 'tags', None),
owner=getattr(args, 'owner', None),
min_downloads=min_downloads,
max_downloads=max_downloads,
published_after=getattr(args, 'since', None),
published_before=getattr(args, 'before', None),
include_deprecated=getattr(args, 'deprecated', False),
include_facets=getattr(args, 'show_facets', False),
per_page=args.limit or 20,
sort=getattr(args, 'sort', 'relevance')
)
# JSON output
if getattr(args, 'json', False):
output = {
"query": args.query,
"total": results.total,
"results": results.data
}
if results.facets:
output["facets"] = results.facets
print(json.dumps(output, indent=2))
return 0
if not results.data:
print(f"No tools found matching '{args.query}'")
return 0
print(f"Found {results.total} tools matching \"{args.query}\":")
# Show facets summary if requested
if results.facets:
cats = results.facets.get("categories", [])[:5]
tags = results.facets.get("tags", [])[:5]
if cats:
cat_str = ", ".join(f"{c['name']} ({c['count']})" for c in cats)
print(f"\nCategories: {cat_str}")
if tags:
tag_str = ", ".join(f"{t['name']} ({t['count']})" for t in tags)
print(f"Top Tags: {tag_str}")
print()
for tool in results.data:
owner = tool.get("owner", "")
name = tool.get("name", "")
version = tool.get("version", "")
desc = tool.get("description", "")
downloads = tool.get("downloads", 0)
tags = tool.get("tags", [])
print(f" {owner}/{name} v{version}")
print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}")
if tags:
print(f" Tags: {', '.join(tags[:5])}")
print(f" Downloads: {downloads:,}")
print()
if results.total_pages > 1:
print(f"Showing page {results.page}/{results.total_pages}")
except RegistryError as e:
if e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print(f"Rate limited. Please wait and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error searching registry: {e}", file=sys.stderr)
print("If the problem persists, check: cmdforge config show", file=sys.stderr)
return 1
return 0
def _cmd_registry_tags(args):
"""List available tags."""
from ..registry_client import RegistryError, get_client
try:
client = get_client()
tags = client.get_tags(
category=getattr(args, 'category', None),
limit=getattr(args, 'limit', 50)
)
# JSON output
if getattr(args, 'json', False):
print(json.dumps({"tags": tags}, indent=2))
return 0
if not tags:
print("No tags found")
return 0
print(f"Available tags ({len(tags)}):\n")
for tag in tags:
print(f" {tag['name']:20} ({tag['count']} tools)")
except RegistryError as e:
if e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_install(args):
"""Install a tool from the registry."""
from ..registry_client import RegistryError
from ..tool import BIN_DIR
from ..system_deps import prompt_install_missing
tool_spec = args.tool
version = args.version
auto_yes = getattr(args, 'yes', False)
skip_sys_deps = getattr(args, 'no_system_deps', False)
print(f"Installing {tool_spec}...")
try:
resolved = install_from_registry(tool_spec, version)
print(f"Installed: {resolved.full_name}@{resolved.version}")
print(f"Location: {resolved.path}")
# Show wrapper info
wrapper_name = resolved.tool.name
if resolved.owner:
# Check for collision
short_wrapper = BIN_DIR / resolved.tool.name
if short_wrapper.exists():
wrapper_name = f"{resolved.owner}-{resolved.tool.name}"
print(f"\nRun with: {wrapper_name}")
# Check system dependencies unless skipped
if not skip_sys_deps and resolved.tool.system_dependencies:
print()
tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name
prompt_install_missing(resolved.tool.system_dependencies, tool_ref, auto_yes=auto_yes)
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr)
print(f"Try: cmdforge registry search {tool_spec.split('/')[-1]}", file=sys.stderr)
elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE":
print(f"Error: {e.message}", file=sys.stderr)
if e.details and "available_versions" in e.details:
versions = e.details["available_versions"]
print(f"Available versions: {', '.join(versions[:5])}", file=sys.stderr)
if e.details.get("latest_stable"):
print(f"Latest stable: {e.details['latest_stable']}", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error installing tool: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_uninstall(args):
"""Uninstall a tool."""
tool_spec = args.tool
print(f"Uninstalling {tool_spec}...")
if uninstall_tool(tool_spec):
print(f"Uninstalled: {tool_spec}")
else:
print(f"Tool '{tool_spec}' not found", file=sys.stderr)
return 1
return 0
def _cmd_registry_info(args):
"""Show tool information."""
from ..registry_client import RegistryError, get_client
tool_spec = args.tool
try:
# Parse the tool spec
parsed = ToolSpec.parse(tool_spec)
owner = parsed.owner or "official"
client = get_client()
tool_info = client.get_tool(owner, parsed.name)
print(f"{tool_info.owner}/{tool_info.name} v{tool_info.version}")
print("=" * 50)
print(f"Description: {tool_info.description}")
print(f"Category: {tool_info.category}")
print(f"Tags: {', '.join(tool_info.tags)}")
print(f"Downloads: {tool_info.downloads}")
print(f"Published: {tool_info.published_at}")
if tool_info.deprecated:
print()
print(f"DEPRECATED: {tool_info.deprecated_message}")
if tool_info.replacement:
print(f"Use instead: {tool_info.replacement}")
# Show versions
versions = client.get_tool_versions(owner, parsed.name)
if versions:
print(f"\nVersions: {', '.join(versions[:5])}")
if len(versions) > 5:
print(f" ...and {len(versions) - 5} more")
print(f"\nInstall: cmdforge registry install {tool_info.owner}/{tool_info.name}")
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr)
print(f"Try: cmdforge registry search {parsed.name}", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error fetching tool info: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_update(args):
"""Update local index cache."""
from ..registry_client import RegistryError, get_client
print("Updating registry index...")
try:
client = get_client()
index = client.get_index(force_refresh=True)
tool_count = index.get("tool_count", len(index.get("tools", [])))
generated = index.get("generated_at", "unknown")
print(f"Index updated: {tool_count} tools")
print(f"Generated: {generated}")
except RegistryError as e:
if e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print("Rate limited. Please wait a moment and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error updating index: {e}", file=sys.stderr)
return 1
return 0
def _print_preflight_sections(report: dict, prefix: str = "") -> None:
"""Render the common portions of a structured preflight report."""
labels = (
("errors", "ERROR"),
("warnings", "WARN"),
("suggestions", "HINT"),
)
for key, marker in labels:
values = report.get(key) or []
if values:
print(f"{prefix}{key.title()} ({len(values)}):")
for value in values:
print(f" {marker}: {value}")
proposal = report.get("contract_proposal") or {}
proposed = {
key: proposal.get(key)
for key in ("input_schema", "output_schema")
if proposal.get(key) is not None
}
if proposed:
print(
f"{prefix}Contract proposal "
f"({proposal.get('confidence', 'low')} confidence; not saved):"
)
print(yaml.safe_dump(proposed, sort_keys=False).rstrip())
generated = report.get("generated_tests") or []
if generated:
print(f"{prefix}Contract conformance ({len(generated)} case(s)):")
for result in generated:
print(
f" {str(result.get('state', 'not_run')).upper()}: "
f"{result.get('step', 'unknown')}{result.get('detail', '')}"
)
regression = report.get("regression") or {}
if regression:
marker = (
"REGRESSION" if regression.get("has_regressions")
else "REVIEW" if regression.get("contract_changed")
else "STABLE"
)
print(
f"{prefix}Regression comparison: {marker}"
f"{regression.get('summary', 'no changes')}"
)
compatibility = report.get("compatibility") or []
if compatibility:
print(f"{prefix}ToolStep compatibility ({len(compatibility)}):")
for finding in compatibility:
print(
f" {str(finding.get('state', 'unknown')).upper()}: "
f"step {finding.get('step', '?')} -> "
f"{finding.get('tool', 'unknown')}{finding.get('detail', '')}"
)
def _print_quality_summary(quality: dict | None) -> None:
if not quality:
return
print(
f"Quality {quality.get('score', 0)}"
f"{quality.get('evidence_coverage', 0)}% evidence coverage"
)
for category in quality.get("categories", []):
state = category.get("state", "not_tested")
value = (
f"{category.get('earned', 0)}/{category.get('available', 0)}"
if state == "checked" else state.replace("_", " ")
)
print(f" {category.get('name', 'Unknown'):24s} {value:>12s}")
def _print_registry_suggestions(suggestions: dict) -> None:
"""Render registry-specific category, similarity, and scrutiny evidence."""
category = suggestions.get("category") or {}
if category.get("suggested"):
print(
f"Suggested category: {category['suggested']} "
f"({category.get('confidence', 0):.0%} confidence)"
)
similar = suggestions.get("similar_tools") or []
if similar:
print(f"Similar registry tools ({len(similar)}):")
for item in similar:
print(
f" - {item.get('name', 'unknown')} "
f"({item.get('similarity', 0):.0%} similar)"
)
scrutiny = suggestions.get("scrutiny") or {}
findings = scrutiny.get("findings") or []
if findings:
print(f"Scrutiny findings ({len(findings)}):")
for finding in findings:
marker = str(finding.get("result", "info")).upper()
print(f" {marker}: {finding.get('message', '')}")
def _cmd_registry_publish(args):
"""Publish a tool to the registry."""
from ..registry_client import RegistryError, get_client
# Read tool from current directory or specified path
tool_path = Path(args.path) if args.path else Path.cwd()
if tool_path.is_dir():
config_path = tool_path / "config.yaml"
else:
config_path = tool_path
tool_path = config_path.parent
if not config_path.exists():
print(f"Error: config.yaml not found in {tool_path}", file=sys.stderr)
return 1
# Read config
config_yaml = config_path.read_text()
# Read README if exists
readme_path = tool_path / "README.md"
readme = readme_path.read_text() if readme_path.exists() else ""
# Read defaults if exists
defaults_path = tool_path / "defaults.yaml"
defaults = ""
if defaults_path.exists():
defaults = defaults_path.read_text()
# Warn about potential secrets in defaults
defaults_lower = defaults.lower()
secret_patterns = ['api_key:', 'api_secret:', 'password:', 'token:', 'secret:']
for pattern in secret_patterns:
if pattern in defaults_lower:
# Check if it has a non-empty value
match = re.search(rf'{pattern}\s*["\']?([^"\'\n]+)', defaults_lower)
if match and match.group(1).strip() and match.group(1).strip() not in ('""', "''", ''):
print(f"Warning: defaults.yaml contains '{pattern[:-1]}' with a value.")
print(" Make sure you're not publishing actual credentials!")
if not getattr(args, 'force', False):
try:
confirm = input("Continue anyway? [y/N] ")
if confirm.lower() != 'y':
return 1
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
return 1
break
# Validate
try:
data = yaml.safe_load(config_yaml)
if not isinstance(data, dict):
print("Error: config.yaml must contain a YAML mapping", file=sys.stderr)
return 1
name = data.get("name", "")
version = data.get("version", "")
if not name or not version:
print("Error: config.yaml must have 'name' and 'version' fields", file=sys.stderr)
return 1
except yaml.YAMLError as e:
print(f"Error: Invalid YAML in config.yaml: {e}", file=sys.stderr)
return 1
if args.dry_run:
print("Dry run — validating locally and via registry preflight")
print()
# Local validation
print(f" Name: {name}")
print(f" Version: {version}")
print(f" Config: {len(config_yaml)} bytes")
print(f" README: {len(readme)} bytes")
print()
from ..preflight import analyze_tool
from ..tool import Tool
try:
local_tool = Tool.from_dict(data)
local_tool.path = config_path
local_report = analyze_tool(
local_tool, include_contract_tests=True
)
except (KeyError, TypeError, ValueError) as exc:
print(f"Local preflight error: {exc}", file=sys.stderr)
return 1
_print_preflight_sections(local_report.to_dict(), prefix="Local ")
if not local_report.ok:
return 1
# Attempt registry preflight if token is configured
config = load_config()
if not config.registry.token:
print("No registry token configured — local preflight only.")
return 0
try:
client = get_client()
result = client.publish_tool(
config_yaml, readme=readme, defaults=defaults,
dry_run=True,
)
except RegistryError as exc:
print(f"Registry preflight failed: {exc}", file=sys.stderr)
return 1
remote_report = result.get("preflight") or {}
_print_preflight_sections(remote_report, prefix="Registry ")
_print_quality_summary(result.get("quality"))
_print_registry_suggestions(result.get("suggestions") or {})
if not remote_report.get("errors"):
print("Registry preflight passed.")
return 0
return 1
# Check for token
config = load_config()
if not config.registry.token:
print("No registry token configured.")
print()
print("To publish tools, you need an account:")
print(" 1. Register at: https://cmdforge.brrd.tech/register")
print(" 2. Log in and go to Dashboard > Tokens")
print(" 3. Generate an API token")
print(" 4. Enter your token below (or run: cmdforge config set-token <token>)")
print()
try:
token = input("Registry token: ").strip()
if not token:
print("Cancelled.")
return 1
set_registry_token(token)
print("Token saved.")
except (EOFError, KeyboardInterrupt):
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
my_owner = ""
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 not tool:
# Tool isn't installed locally; derive from config.yaml
tool = Tool.from_dict(data)
dep_names = []
for dep in tool.dependencies:
if '/' not in dep:
dep_names.append(dep)
for step in tool.steps:
if isinstance(step, ToolStep) and '/' not in step.tool:
dep_names.append(step.tool)
dep_names = sorted(set(dep_names) - {name})
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
# Stage 1 runs after dependency publication and any automatic version bump,
# so the evidence describes the exact version Stage 2 will mutate.
from ..preflight import analyze_tool
try:
preflight_tool = Tool.from_dict(data)
preflight_tool.path = config_path
local_report = analyze_tool(preflight_tool, include_contract_tests=True)
except (KeyError, TypeError, ValueError) as exc:
print(f"Local preflight error: {exc}", file=sys.stderr)
return 1
print("Preflight stage:")
_print_preflight_sections(local_report.to_dict(), prefix="Local ")
if not local_report.ok:
return 1
try:
preflight_client = get_client()
preflight_result = preflight_client.publish_tool(
config_yaml, readme=readme, defaults=defaults,
owner=getattr(args, "owner", ""), dry_run=True,
)
except RegistryError as exc:
print(f"Registry preflight failed: {exc}", file=sys.stderr)
return 1
remote_report = preflight_result.get("preflight") or {}
_print_preflight_sections(remote_report, prefix="Registry ")
_print_quality_summary(preflight_result.get("quality"))
if remote_report.get("errors"):
return 1
release_content_hash = preflight_result.get("content_hash", "")
if sys.stdin.isatty() and not getattr(args, "force", False):
try:
if input("Publish this validated version? [y/N] ").strip().lower() != "y":
print("Cancelled.")
return 1
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
return 1
print(f"Publishing {name}@{version}...")
try:
client = get_client()
owner = getattr(args, "owner", "")
attestation = None
from ..signing import load_signing_key
signing_key = load_signing_key()
if signing_key:
if not release_content_hash:
print(
"Registry did not return a content identity; refusing to sign.",
file=sys.stderr,
)
return 1
from ..attestation import sign_tool
signer = owner or my_owner
attestation = sign_tool(
name, version, release_content_hash, signer, signing_key[0]
).to_dict()
result = client.publish_tool(
config_yaml, readme, defaults, owner=owner,
attestation=attestation,
improvement_id=getattr(args, "improvement_id", None),
)
pr_url = result.get("pr_url", "")
status = result.get("status", "")
if status == "published" or result.get("version"):
print(f"Published: {result.get('owner', '')}/{result.get('name', '')}@{result.get('version', version)}")
elif pr_url:
print(f"PR created: {pr_url}")
print("Your tool is pending review.")
else:
print("Published successfully!")
# Show suggestions if provided (from Phase 6 smart features)
suggestions = result.get("suggestions", {})
if suggestions:
print()
# Category suggestion
cat_suggestion = suggestions.get("category")
if cat_suggestion and cat_suggestion.get("suggested"):
confidence = cat_suggestion.get("confidence", 0)
print(f"Suggested category: {cat_suggestion['suggested']} ({confidence:.0%} confidence)")
# Similar tools warning
similar = suggestions.get("similar_tools", [])
if similar:
print("Similar existing tools:")
for tool in similar[:3]:
similarity = tool.get("similarity", 0)
print(f" - {tool.get('name', 'unknown')} ({similarity:.0%} similar)")
# Save registry_hash and status to local config for tracking
config_hash = result.get("config_hash")
moderation_status = result.get("status", "pending")
if config_hash:
try:
config_data = yaml.safe_load(config_path.read_text()) or {}
config_data["registry_hash"] = config_hash
config_data["registry_status"] = moderation_status
registry_owner = result.get("owner")
if registry_owner:
config_data["registry_owner"] = registry_owner
# Clear any old feedback when republishing
if "registry_feedback" in config_data:
del config_data["registry_feedback"]
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
except Exception:
pass # Non-critical
except RegistryError as e:
if e.code == "UNAUTHORIZED":
print("Authentication failed.", file=sys.stderr)
print("Your token may have expired. Generate a new one from the registry.", file=sys.stderr)
elif e.code == "INVALID_CONFIG":
print(f"Invalid tool config: {e.message}", file=sys.stderr)
print("Check your config.yaml for errors.", file=sys.stderr)
elif e.code == "VERSION_EXISTS":
print(f"Version already exists: {e.message}", file=sys.stderr)
print("Bump the version in config.yaml and try again.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print("Rate limited. Please wait a moment and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error publishing: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_signing_key(args):
"""Initialize a local key and register its public half with the registry."""
from ..signing import SIGNING_KEY_FILE, initialize_signing_key, load_signing_key
from ..registry_client import RegistryError, get_client
if args.action == "status":
key = load_signing_key()
print(
f"Signing key: {SIGNING_KEY_FILE}"
if key else "No release signing key configured."
)
return 0 if key else 1
try:
_, public_key = initialize_signing_key()
get_client().set_signing_public_key(public_key)
except (OSError, ValueError, RegistryError) as exc:
print(f"Could not initialize signing key: {exc}", file=sys.stderr)
return 1
print(f"Release signing key initialized: {SIGNING_KEY_FILE}")
print("The private key is stored locally with mode 0600; back it up securely.")
return 0
def _cmd_registry_improve(args):
from ..registry_client import RegistryError, get_client
if "/" not in args.tool:
print("Tool must be specified as owner/name.", file=sys.stderr)
return 2
owner, name = args.tool.split("/", 1)
try:
proposed = Path(args.file).read_text(encoding="utf-8")
result = get_client().submit_improvement(
owner, name, args.version, args.step_index, proposed,
args.rationale,
)
except (OSError, RegistryError) as exc:
print(f"Could not submit improvement: {exc}", file=sys.stderr)
return 1
print(f"Improvement {result.get('id')} tested and submitted for review.")
return 0
def _cmd_registry_review_improvement(args):
from ..registry_client import RegistryError, get_client
try:
result = get_client().review_improvement(args.id, args.decision, args.notes)
except RegistryError as exc:
print(f"Could not review improvement: {exc}", file=sys.stderr)
return 1
print(f"Improvement {args.id}: {result.get('status')}")
if result.get("ready_to_apply"):
print(
"Publish the updated version with "
f"--improvement-id {args.id} to apply credit and badges."
)
return 0
def _cmd_registry_update_readme(args):
"""Update README for published tool(s) on the registry."""
from ..registry_client import RegistryError, get_client
from ..tool import TOOLS_DIR, list_tools
try:
client = get_client()
except RegistryError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if getattr(args, "update_all", False):
# Update all published tools that have a local README
tools = list_tools()
updated = 0
skipped = 0
failed = 0
for tool_name in tools:
tool_dir = TOOLS_DIR / tool_name
config_path = tool_dir / "config.yaml"
readme_path = tool_dir / "README.md"
if not readme_path.exists():
continue
# Check if published
try:
config = yaml.safe_load(config_path.read_text()) or {}
except Exception:
continue
owner = config.get("registry_owner", "")
if not owner:
skipped += 1
continue
readme_content = readme_path.read_text()
try:
result = client.update_readme(owner, tool_name, readme_content)
count = result.get("versions_updated", 0)
print(f" {owner}/{tool_name}: updated ({count} version(s))")
updated += 1
except RegistryError as e:
print(f" {owner}/{tool_name}: FAILED - {e}", file=sys.stderr)
failed += 1
print(f"\nDone: {updated} updated, {failed} failed, {skipped} skipped (not published)")
return 0
# Single tool mode
tool_name = args.tool
if not tool_name:
print("Error: tool name required (or use --all)", file=sys.stderr)
return 1
tool_dir = TOOLS_DIR / tool_name
config_path = tool_dir / "config.yaml"
readme_path = tool_dir / "README.md"
if not config_path.exists():
print(f"Error: Tool '{tool_name}' not found", file=sys.stderr)
return 1
if not readme_path.exists():
print(f"Error: No README.md found for '{tool_name}'", file=sys.stderr)
return 1
config = yaml.safe_load(config_path.read_text()) or {}
owner = config.get("registry_owner", "")
if not owner:
print(f"Error: Tool '{tool_name}' has no registry_owner — not published?", file=sys.stderr)
return 1
readme_content = readme_path.read_text()
try:
result = client.update_readme(owner, tool_name, readme_content)
count = result.get("versions_updated", 0)
print(f"Updated README for {owner}/{tool_name} ({count} version(s))")
except RegistryError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_my_tools(args):
"""List your published tools."""
from ..registry_client import RegistryError, get_client
try:
client = get_client()
tools = client.get_my_tools()
if not tools:
print("You haven't published any tools yet.")
print("Publish your first tool with: cmdforge registry publish")
return 0
print(f"Your published tools ({len(tools)}):\n")
for tool in tools:
status = "[DEPRECATED]" if tool.deprecated else ""
print(f" {tool.owner}/{tool.name} v{tool.version} {status}")
print(f" Downloads: {tool.downloads}")
print()
except RegistryError as e:
if e.code == "UNAUTHORIZED":
print("Not logged in. Set your registry token first:", file=sys.stderr)
print(" cmdforge config set-token <token>", file=sys.stderr)
print()
print("Don't have a token? Register at the registry website.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print("Rate limited. Please wait a moment and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
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, load_tool
tool_name = args.tool
do_sync = getattr(args, 'sync', False)
# 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
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 using hash-based lookup
if do_sync:
try:
client = get_client()
# 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_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_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)
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_describe(args):
"""Find tools by describing what you need (semantic search)."""
from ..registry_client import RegistryError, get_client
query = args.query
limit = getattr(args, 'limit', 20)
try:
client = get_client()
result = client.semantic_search(query, limit=limit)
# JSON output
if getattr(args, 'json', False):
print(json.dumps(result, indent=2))
return 0
available = result.get("available", False)
tools = result.get("data", [])
error = result.get("error")
if error:
print(f"Error: {error}", file=sys.stderr)
return 1
if not available:
print("AI search is not available on this registry.")
print("The Ollama embedding service may be offline or the feature is disabled.")
return 1
if not tools:
print(f'No tools found matching: "{query}"')
print("Try different phrasing or use keyword search: cmdforge registry search <query>")
return 0
print(f'Found {len(tools)} tools matching: "{query}"\n')
for i, tool in enumerate(tools, 1):
similarity = tool.get("similarity", 0)
pct = f"{similarity * 100:.0f}%"
owner = tool.get("owner", "")
name = tool.get("name", "")
desc = tool.get("description", "")
print(f" {i}. {owner}/{name} ({pct} match)")
if desc:
print(f" {desc[:70]}{'...' if len(desc) > 70 else ''}")
print()
except RegistryError as e:
if e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_browse(args):
"""Browse tools (GUI)."""
from ..gui import run_gui
# Launch GUI - it will open to Registry page
return run_gui()
def _cmd_registry_config(args):
"""Manage registry settings (admin only)."""
from ..registry_client import RegistryError, get_client
action = getattr(args, 'action', 'list')
key = getattr(args, 'key', None)
value = getattr(args, 'value', None)
as_json = getattr(args, 'json', False)
category = getattr(args, 'category', None)
try:
client = get_client()
if action == "list":
return _config_list(client, as_json, category)
elif action == "get":
if not key:
print("Error: key is required for 'get' action", file=sys.stderr)
print("Usage: cmdforge registry config get <key>", file=sys.stderr)
return 1
return _config_get(client, key, as_json)
elif action == "set":
if not key or value is None:
print("Error: key and value are required for 'set' action", file=sys.stderr)
print("Usage: cmdforge registry config set <key> <value>", file=sys.stderr)
return 1
return _config_set(client, key, value)
except RegistryError as e:
if e.code == "UNAUTHORIZED":
print("Authentication failed.", file=sys.stderr)
print("This command requires admin privileges.", file=sys.stderr)
print("Set your admin token with: cmdforge config set-token <token>", file=sys.stderr)
elif e.code == "FORBIDDEN":
print("Access denied. Admin privileges required.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
return 0
def _config_list(client, as_json, category=None):
"""List all settings."""
# Use the admin settings endpoint
response = client._request("GET", "/admin/settings")
settings = response.get("settings", [])
# Filter by category if specified
if category:
settings = [s for s in settings if s.get("category") == category]
if as_json:
print(json.dumps({"settings": settings}, indent=2))
return 0
if not settings:
print("No settings found.")
return 0
# Group by category
by_category = {}
for s in settings:
cat = s.get("category", "general")
if cat not in by_category:
by_category[cat] = []
by_category[cat].append(s)
print("Registry Settings")
print("=" * 60)
for cat, cat_settings in sorted(by_category.items()):
print(f"\n[{cat.upper()}]")
for s in cat_settings:
key = s.get("key", "")
value = s.get("value")
value_type = s.get("value_type", "string")
desc = s.get("description", "")
is_default = s.get("is_default", True)
# Format value display
if value_type == "bool":
value_str = "true" if value else "false"
else:
value_str = str(value)
status = "" if is_default else " (modified)"
print(f" {key}")
print(f" Value: {value_str}{status}")
if desc:
print(f" {desc}")
print()
print("Use 'cmdforge registry config get <key>' to see a setting's value")
print("Use 'cmdforge registry config set <key> <value>' to change a setting")
return 0
def _config_get(client, key, as_json):
"""Get a specific setting."""
response = client._request("GET", f"/admin/settings/{key}")
if as_json:
print(json.dumps(response, indent=2))
return 0
setting = response.get("setting", {})
print(f"Key: {setting.get('key', key)}")
print(f"Value: {setting.get('value')}")
print(f"Type: {setting.get('value_type', 'string')}")
print(f"Category: {setting.get('category', 'general')}")
if setting.get('description'):
print(f"Description: {setting['description']}")
if setting.get('updated_at'):
print(f"Last updated: {setting['updated_at'][:19]} by {setting.get('updated_by', 'system')}")
return 0
def _config_set(client, key, value):
"""Set a setting value."""
# Try to parse value as appropriate type
parsed_value = value
# Try to parse as bool
if value.lower() in ("true", "false"):
parsed_value = value.lower() == "true"
# Try to parse as number
else:
try:
if "." in value:
parsed_value = float(value)
else:
parsed_value = int(value)
except ValueError:
# Keep as string
pass
response = client._request("PUT", f"/admin/settings/{key}", json={"value": parsed_value})
if response.get("success"):
print(f"Setting '{key}' updated successfully.")
print(f"New value: {response.get('setting', {}).get('value', parsed_value)}")
else:
print(f"Failed to update setting: {response.get('error', 'Unknown error')}", file=sys.stderr)
return 1
return 0