Add registry improvements, tag filtering, and admin tools
Registry API improvements: - Add /api/v1/me POST endpoint for profile updates - Add /api/v1/me/password POST endpoint for password changes - Fix visibility filtering on categories and tags endpoints to only show approved public tools - Allow underscores in tool names for fabric pattern compatibility - Fix sqlite3.Row access (use bracket notation instead of .get()) Web UI improvements: - Add tag filter to /tools page with three-state buttons (include/exclude/neutral) - Add mobile-friendly tag filter in responsive view - Display tags on tool cards in the tools listing - Add dashboard settings form handlers for profile and password Admin improvements: - Add scrutiny audit page for reviewing tool safety analysis - Improve pending tools page with scrutiny report display - Add scrutiny stats to admin dashboard Fabric sync improvements: - Add direct database publishing with scrutiny vetting - Support auto-approve for tools passing scrutiny - Improve error handling and logging - Add source attribution for imported tools Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
+304
-48
@@ -241,85 +241,311 @@ def vet_pattern(pattern_dir: Path, provider: str = DEFAULT_PROVIDER) -> tuple[bo
|
||||
return False, f"Vetting error: {e}"
|
||||
|
||||
|
||||
def publish_to_registry(
|
||||
name: str,
|
||||
config_yaml: str,
|
||||
readme: str,
|
||||
provider: str,
|
||||
auto_approve: bool = False
|
||||
) -> tuple[bool, str, dict]:
|
||||
"""Publish a tool directly to the registry database with vetting.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message, scrutiny_report)
|
||||
"""
|
||||
try:
|
||||
# Add src to path for registry imports
|
||||
src_dir = Path(__file__).parent.parent / "src"
|
||||
if str(src_dir) not in sys.path:
|
||||
sys.path.insert(0, str(src_dir))
|
||||
|
||||
from cmdforge.registry.db import connect_db, query_one
|
||||
from cmdforge.registry.scrutiny import scrutinize_tool
|
||||
from cmdforge.hash_utils import compute_yaml_hash
|
||||
|
||||
# Parse config
|
||||
config = yaml.safe_load(config_yaml)
|
||||
version = config.get("version", "1.0.0")
|
||||
description = config.get("description", "")
|
||||
category = config.get("category")
|
||||
tags = config.get("tags", [])
|
||||
|
||||
conn = connect_db()
|
||||
|
||||
# Check if already exists
|
||||
existing = query_one(
|
||||
conn,
|
||||
"SELECT id, version FROM tools WHERE owner = ? AND name = ?",
|
||||
["official", name],
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Check if same version
|
||||
if existing["version"] == version:
|
||||
conn.close()
|
||||
return True, "Already exists (same version)", {}
|
||||
|
||||
# Run scrutiny
|
||||
scrutiny_report = {}
|
||||
try:
|
||||
scrutiny_report = scrutinize_tool(config_yaml, description, readme)
|
||||
except Exception as e:
|
||||
logger.warning(f"Scrutiny failed for {name}: {e}")
|
||||
|
||||
# Check scrutiny decision
|
||||
scrutiny_decision = scrutiny_report.get("decision", "review")
|
||||
if scrutiny_decision == "reject":
|
||||
fail_findings = [f for f in scrutiny_report.get("findings", []) if f.get("result") == "fail"]
|
||||
fail_msg = fail_findings[0]["message"] if fail_findings else "quality too low"
|
||||
conn.close()
|
||||
return False, f"Rejected by scrutiny: {fail_msg}", scrutiny_report
|
||||
|
||||
# Determine statuses
|
||||
if scrutiny_decision == "approve":
|
||||
scrutiny_status = "approved"
|
||||
elif scrutiny_decision == "review":
|
||||
scrutiny_status = "pending_review"
|
||||
else:
|
||||
scrutiny_status = "pending"
|
||||
|
||||
# Moderation status based on auto_approve setting
|
||||
if auto_approve and scrutiny_status == "approved":
|
||||
moderation_status = "approved"
|
||||
else:
|
||||
moderation_status = "pending"
|
||||
|
||||
# Compute hash
|
||||
config_hash = compute_yaml_hash(config_yaml)
|
||||
|
||||
# Source attribution
|
||||
source_json = json.dumps({
|
||||
"type": "imported",
|
||||
"original_tool": f"fabric/patterns/{name}",
|
||||
"url": "https://github.com/danielmiessler/fabric",
|
||||
"license": "MIT",
|
||||
"author": "Daniel Miessler"
|
||||
})
|
||||
|
||||
tags_json = json.dumps(tags) if tags else "[]"
|
||||
scrutiny_json = json.dumps(scrutiny_report) if scrutiny_report else None
|
||||
|
||||
# Ensure official publisher exists
|
||||
publisher = query_one(conn, "SELECT id FROM publishers WHERE slug = ?", ["official"])
|
||||
if not publisher:
|
||||
conn.execute(
|
||||
"INSERT INTO publishers (email, password_hash, slug, display_name, verified) VALUES (?, ?, ?, ?, ?)",
|
||||
["[email protected]", "", "official", "Official", True]
|
||||
)
|
||||
publisher_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
else:
|
||||
publisher_id = publisher["id"]
|
||||
|
||||
if existing:
|
||||
# Update existing tool
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE tools SET
|
||||
version = ?, description = ?, category = ?, tags = ?,
|
||||
config_yaml = ?, readme = ?, scrutiny_status = ?, scrutiny_report = ?,
|
||||
source_json = ?, config_hash = ?, moderation_status = ?,
|
||||
published_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
[
|
||||
version, description, category, tags_json,
|
||||
config_yaml, readme, scrutiny_status, scrutiny_json,
|
||||
source_json, config_hash, moderation_status,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
existing["id"]
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Insert new tool
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tools (
|
||||
owner, name, version, description, category, tags, config_yaml, readme,
|
||||
publisher_id, scrutiny_status, scrutiny_report, source_json,
|
||||
config_hash, visibility, moderation_status, published_at, downloads
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
"official", name, version, description, category, tags_json,
|
||||
config_yaml, readme, publisher_id, scrutiny_status, scrutiny_json,
|
||||
source_json, config_hash, "public", moderation_status,
|
||||
datetime.now(timezone.utc).isoformat(), 0
|
||||
]
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
decision = scrutiny_report.get("decision", "unknown")
|
||||
return True, f"Published (scrutiny: {decision}, moderation: {moderation_status})", scrutiny_report
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Registry publish failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False, str(e), {}
|
||||
|
||||
|
||||
def sync_pattern(
|
||||
pattern_dir: Path,
|
||||
output_dir: Path,
|
||||
provider: str,
|
||||
state: SyncState,
|
||||
dry_run: bool = False
|
||||
dry_run: bool = False,
|
||||
publish_to_db: bool = True,
|
||||
auto_approve: bool = False
|
||||
) -> bool:
|
||||
"""Sync a single pattern.
|
||||
|
||||
Args:
|
||||
pattern_dir: Path to the pattern directory
|
||||
output_dir: Output directory for local tools (if not publishing to DB)
|
||||
provider: AI provider name
|
||||
state: Sync state object
|
||||
dry_run: If True, don't make changes
|
||||
publish_to_db: If True, publish to registry database; if False, create local files
|
||||
auto_approve: If True, auto-approve tools that pass scrutiny
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
name = pattern_dir.name
|
||||
pattern_hash = hash_pattern(pattern_dir)
|
||||
|
||||
# Vet the pattern
|
||||
passed, reason = vet_pattern(pattern_dir, provider)
|
||||
|
||||
if not passed:
|
||||
logger.warning(f" ✗ {name}: {reason}")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "failed",
|
||||
"reason": reason,
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
# Read pattern content
|
||||
system_md = pattern_dir / "system.md"
|
||||
if not system_md.exists():
|
||||
logger.warning(f" ✗ {name}: No system.md found")
|
||||
return False
|
||||
|
||||
if dry_run:
|
||||
logger.info(f" [DRY RUN] Would sync: {name} ({reason})")
|
||||
return True
|
||||
system_prompt = system_md.read_text()
|
||||
|
||||
# Import the pattern
|
||||
try:
|
||||
script_dir = Path(__file__).parent
|
||||
# Import helper functions
|
||||
script_dir = Path(__file__).parent
|
||||
if str(script_dir) not in sys.path:
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
from import_fabric import import_pattern
|
||||
from import_fabric import create_tool_config, pattern_to_display_name
|
||||
|
||||
success = import_pattern(
|
||||
name,
|
||||
pattern_dir.parent,
|
||||
output_dir,
|
||||
provider,
|
||||
dry_run=False,
|
||||
registry_format=False,
|
||||
)
|
||||
# Create tool config
|
||||
config = create_tool_config(name, system_prompt, provider)
|
||||
config_yaml = yaml.dump(config, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Create README with usage and attribution
|
||||
display_name = pattern_to_display_name(name)
|
||||
readme = f"""# {display_name}
|
||||
|
||||
{config.get('description', '')}
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cat input.txt | {name}
|
||||
```
|
||||
|
||||
## Source
|
||||
|
||||
This tool was imported from [Fabric](https://github.com/danielmiessler/fabric) patterns.
|
||||
|
||||
- **Original pattern**: `{name}`
|
||||
- **Author**: Daniel Miessler
|
||||
- **License**: MIT
|
||||
"""
|
||||
|
||||
if dry_run:
|
||||
# Quick vet check for dry run
|
||||
passed, reason = vet_pattern(pattern_dir, provider)
|
||||
if passed:
|
||||
logger.info(f" [DRY RUN] Would sync: {name} ({reason})")
|
||||
else:
|
||||
logger.info(f" [DRY RUN] Would skip: {name} ({reason})")
|
||||
return passed
|
||||
|
||||
if publish_to_db:
|
||||
# Publish directly to registry database
|
||||
success, message, report = publish_to_registry(name, config_yaml, readme, provider, auto_approve)
|
||||
|
||||
if success:
|
||||
logger.info(f" ✓ {name}: {reason}")
|
||||
logger.info(f" ✓ {name} -> registry ({message})")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "synced",
|
||||
"message": message,
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return True
|
||||
else:
|
||||
logger.error(f" ✗ {name}: Import failed")
|
||||
logger.warning(f" ✗ {name}: {message}")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "failed",
|
||||
"reason": "Import failed",
|
||||
"reason": message,
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return False
|
||||
else:
|
||||
# Original behavior: create local files
|
||||
passed, reason = vet_pattern(pattern_dir, provider)
|
||||
|
||||
if not passed:
|
||||
logger.warning(f" ✗ {name}: {reason}")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "failed",
|
||||
"reason": reason,
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ {name}: {e}")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "failed",
|
||||
"reason": str(e),
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return False
|
||||
try:
|
||||
from import_fabric import import_pattern
|
||||
|
||||
success = import_pattern(
|
||||
name,
|
||||
pattern_dir.parent,
|
||||
output_dir,
|
||||
provider,
|
||||
dry_run=False,
|
||||
registry_format=False,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f" ✓ {name} -> {output_dir}/{name}")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "synced",
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return True
|
||||
else:
|
||||
logger.error(f" ✗ {name}: Import failed")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "failed",
|
||||
"reason": "Import failed",
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ {name}: {e}")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "failed",
|
||||
"reason": str(e),
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return False
|
||||
|
||||
|
||||
def run_sync(
|
||||
@@ -328,10 +554,22 @@ def run_sync(
|
||||
state_file: Path,
|
||||
provider: str,
|
||||
dry_run: bool = False,
|
||||
force_patterns: list[str] = None
|
||||
force_patterns: list[str] = None,
|
||||
publish_to_db: bool = True,
|
||||
auto_approve: bool = False
|
||||
) -> dict:
|
||||
"""Run the sync process.
|
||||
|
||||
Args:
|
||||
sync_dir: Directory for sync data (Fabric clone)
|
||||
output_dir: Output directory for local tools (if not publishing to DB)
|
||||
state_file: Path to state file
|
||||
provider: AI provider name
|
||||
dry_run: If True, don't make changes
|
||||
force_patterns: List of pattern names to force resync
|
||||
publish_to_db: If True, publish to registry database
|
||||
auto_approve: If True, auto-approve tools that pass scrutiny
|
||||
|
||||
Returns:
|
||||
Summary dict with counts
|
||||
"""
|
||||
@@ -365,10 +603,11 @@ def run_sync(
|
||||
failed = 0
|
||||
|
||||
if to_sync:
|
||||
logger.info(f"\nSyncing {len(to_sync)} patterns...")
|
||||
dest = "registry database" if publish_to_db else output_dir
|
||||
logger.info(f"\nSyncing {len(to_sync)} patterns to {dest}...")
|
||||
for name in to_sync:
|
||||
pattern_dir = patterns_dir / name
|
||||
if sync_pattern(pattern_dir, output_dir, provider, state, dry_run):
|
||||
if sync_pattern(pattern_dir, output_dir, provider, state, dry_run, publish_to_db, auto_approve):
|
||||
synced += 1
|
||||
else:
|
||||
failed += 1
|
||||
@@ -438,14 +677,17 @@ def daemon_loop(
|
||||
output_dir: Path,
|
||||
state_file: Path,
|
||||
provider: str,
|
||||
interval: int
|
||||
interval: int,
|
||||
publish_to_db: bool = True,
|
||||
auto_approve: bool = False
|
||||
):
|
||||
"""Run sync in a loop."""
|
||||
logger.info(f"Starting daemon mode with {interval}s interval")
|
||||
|
||||
while True:
|
||||
try:
|
||||
run_sync(sync_dir, output_dir, state_file, provider)
|
||||
run_sync(sync_dir, output_dir, state_file, provider,
|
||||
publish_to_db=publish_to_db, auto_approve=auto_approve)
|
||||
except Exception as e:
|
||||
logger.error(f"Sync failed: {e}")
|
||||
|
||||
@@ -514,6 +756,16 @@ def main():
|
||||
default=DEFAULT_PROVIDER,
|
||||
help=f"Default provider for tools (default: {DEFAULT_PROVIDER})"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--local-only",
|
||||
action="store_true",
|
||||
help="Create local tools only (don't publish to registry database)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto-approve",
|
||||
action="store_true",
|
||||
help="Auto-approve tools that pass scrutiny (skip moderation queue)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -530,7 +782,9 @@ def main():
|
||||
args.output,
|
||||
state_file,
|
||||
args.provider,
|
||||
args.interval
|
||||
args.interval,
|
||||
publish_to_db=not args.local_only,
|
||||
auto_approve=args.auto_approve
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -541,7 +795,9 @@ def main():
|
||||
state_file,
|
||||
args.provider,
|
||||
dry_run=args.dry_run,
|
||||
force_patterns=args.force
|
||||
force_patterns=args.force,
|
||||
publish_to_db=not args.local_only,
|
||||
auto_approve=args.auto_approve
|
||||
)
|
||||
|
||||
if summary["failed"] > 0:
|
||||
|
||||
Reference in New Issue
Block a user