1393 lines
50 KiB
Python
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
|