diff --git a/scripts/fabric_sync.py b/scripts/fabric_sync.py index a960e46..769a7f3 100755 --- a/scripts/fabric_sync.py +++ b/scripts/fabric_sync.py @@ -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 (?, ?, ?, ?, ?)", + ["official@cmdforge.local", "", "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: diff --git a/scripts/import_fabric.py b/scripts/import_fabric.py index 556b4aa..b7c2b90 100755 --- a/scripts/import_fabric.py +++ b/scripts/import_fabric.py @@ -91,6 +91,85 @@ def clean_prompt(prompt: str) -> str: return prompt +def extract_description(system_prompt: str, pattern_name: str, max_length: int = 200) -> str: + """Extract a meaningful description from the system prompt. + + Looks for common patterns in Fabric prompts: + 1. "# IDENTITY and PURPOSE" section - most common + 2. "# IDENTITY" section + 3. First paragraph after any title + 4. First sentence of the prompt + """ + lines = system_prompt.strip().split('\n') + display_name = pattern_to_display_name(pattern_name) + + # Look for IDENTITY and PURPOSE section + in_identity = False + identity_lines = [] + + for i, line in enumerate(lines): + line_lower = line.lower().strip() + + # Check for identity section header + if 'identity' in line_lower and ('purpose' in line_lower or line_lower.startswith('#')): + in_identity = True + continue + + # End of identity section (next header) + if in_identity and line.strip().startswith('#'): + break + + # Skip filler lines in identity section + if in_identity: + stripped = line.strip() + if stripped and not stripped.lower().startswith(( + 'take a deep breath', + 'think step by step', + 'follow these steps', + 'use the following' + )): + identity_lines.append(stripped) + + if identity_lines: + # Use only the first meaningful line(s) that describe the identity + desc = identity_lines[0] + # Remove common filler phrases + desc = re.sub(r'^You are (an? )?', '', desc, flags=re.IGNORECASE) + desc = re.sub(r'^You\'re (an? )?', '', desc, flags=re.IGNORECASE) + desc = desc.strip() + if desc: + # Capitalize first letter + desc = desc[0].upper() + desc[1:] if len(desc) > 1 else desc.upper() + # Truncate if needed + if len(desc) > max_length: + desc = desc[:max_length-3].rsplit(' ', 1)[0] + '...' + return desc + + # Fallback: Look for first meaningful paragraph + for line in lines: + line = line.strip() + # Skip headers, empty lines, and short lines + if line.startswith('#') or not line or len(line) < 20: + continue + # Skip common instruction patterns and metadata + line_lower = line.lower() + if line_lower.startswith(( + 'take a deep breath', 'think step by step', 'input:', + 'title:', 'introduction' + )): + continue + # Found a content line - use it + desc = line + # Remove "Title:" prefix if present + desc = re.sub(r'^Title:\s*', '', desc, flags=re.IGNORECASE) + if len(desc) > max_length: + desc = desc[:max_length-3].rsplit(' ', 1)[0] + '...' + return desc + + # Last fallback - generic description + return f"{display_name} - AI-powered tool from Fabric patterns" + + def create_tool_config( pattern_name: str, system_prompt: str, @@ -101,15 +180,17 @@ def create_tool_config( cleaned_prompt = clean_prompt(system_prompt) display_name = pattern_to_display_name(pattern_name) category = get_category(pattern_name) + description = extract_description(system_prompt, pattern_name) # Build the full prompt with input placeholder full_prompt = f"{cleaned_prompt}\n\n{{input}}" config = { "name": pattern_name, - "description": f"{display_name} - imported from Fabric patterns", - "version": "1.0.0", + "description": description, + "version": "1.0.2", "category": category, + "tags": ["fabric", "imported"], # Attribution - marks this as an imported tool "source": { diff --git a/scripts/validate_tool.py b/scripts/validate_tool.py index 983f0fa..082f4f8 100644 --- a/scripts/validate_tool.py +++ b/scripts/validate_tool.py @@ -13,7 +13,7 @@ from typing import List import yaml -TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$") +TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+.+)?$") REQUIRED_README_SECTIONS = ["## Usage", "## Examples"] diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index 53d046f..315d97e 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -57,7 +57,7 @@ ALLOWED_SORT = { "/categories": {"name", "tool_count"}, } -TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$") +TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") OWNER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,37}[a-z0-9]$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") RESERVED_SLUGS = {"official", "admin", "system", "api", "registry", "cmdforge"} @@ -1018,10 +1018,12 @@ def create_app() -> Flask: categories_payload = yaml.safe_load(categories_yaml.read_text(encoding="utf-8")) or {} predefined_categories = (categories_payload or {}).get("categories", []) - # Get counts for all categories in the database + # Get counts for all categories in the database (filtered by visibility) + vis_filter, vis_params = build_visibility_filter() counts = query_all( g.db, - "SELECT category, COUNT(DISTINCT owner || '/' || name) AS total FROM tools GROUP BY category", + f"SELECT category, COUNT(DISTINCT owner || '/' || name) AS total FROM tools WHERE 1=1 {vis_filter} GROUP BY category", + vis_params, ) count_map = {row["category"]: row["total"] for row in counts} @@ -1079,31 +1081,35 @@ def create_app() -> Flask: category = request.args.get("category") limit = min(int(request.args.get("limit", 100)), 500) + # Build visibility filter + vis_filter, vis_params = build_visibility_filter("tools") + # Build query - extract tags from JSON array and count occurrences if category: rows = query_all( g.db, - """ + f""" SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count FROM tools, json_each(tools.tags) AS tag - WHERE tools.category = ? + WHERE tools.category = ? {vis_filter} GROUP BY tag.value ORDER BY count DESC LIMIT ? """, - (category, limit), + [category] + vis_params + [limit], ) else: rows = query_all( g.db, - """ + f""" SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count FROM tools, json_each(tools.tags) AS tag + WHERE 1=1 {vis_filter} GROUP BY tag.value ORDER BY count DESC LIMIT ? """, - (limit,), + vis_params + [limit], ) data = [{"name": row["name"], "count": row["count"]} for row in rows] @@ -1986,6 +1992,90 @@ def create_app() -> Flask: return jsonify({"data": {"status": "updated"}}) + @app.route("/api/v1/me", methods=["POST"]) + @require_token + def update_profile() -> Response: + """Update current user's profile (POST version for web forms).""" + data = request.get_json() or {} + + # Validate fields + display_name = data.get("display_name", "").strip() + bio = data.get("bio", "").strip() if data.get("bio") else None + website = data.get("website", "").strip() if data.get("website") else None + + if display_name and len(display_name) > 100: + return error_response("VALIDATION_ERROR", "Display name too long (max 100)", 400) + if bio and len(bio) > 500: + return error_response("VALIDATION_ERROR", "Bio too long (max 500)", 400) + if website and len(website) > 200: + return error_response("VALIDATION_ERROR", "Website URL too long (max 200)", 400) + + # Build update query + updates = [] + params = [] + if display_name: + updates.append("display_name = ?") + params.append(display_name) + if bio is not None: + updates.append("bio = ?") + params.append(bio) + if website is not None: + updates.append("website = ?") + params.append(website) + + if not updates: + return error_response("VALIDATION_ERROR", "No valid fields to update", 400) + + updates.append("updated_at = CURRENT_TIMESTAMP") + params.append(g.current_publisher["id"]) + + g.db.execute( + f"UPDATE publishers SET {', '.join(updates)} WHERE id = ?", + params, + ) + g.db.commit() + + return jsonify({"data": {"status": "updated"}}) + + @app.route("/api/v1/me/password", methods=["POST"]) + @require_token + def change_password() -> Response: + """Change current user's password.""" + data = request.get_json() or {} + + current_password = data.get("current_password", "") + new_password = data.get("new_password", "") + + if not current_password or not new_password: + return error_response("VALIDATION_ERROR", "Current and new password required", 400) + + if len(new_password) < 8: + return error_response("VALIDATION_ERROR", "New password must be at least 8 characters", 400) + + # Verify current password + publisher = query_one( + g.db, + "SELECT password_hash FROM publishers WHERE id = ?", + [g.current_publisher["id"]], + ) + if not publisher: + return error_response("NOT_FOUND", "Publisher not found", 404) + + try: + password_hasher.verify(publisher["password_hash"], current_password) + except VerifyMismatchError: + return error_response("INVALID_PASSWORD", "Current password is incorrect", 400) + + # Hash and save new password + new_hash = password_hasher.hash(new_password) + g.db.execute( + "UPDATE publishers SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [new_hash, g.current_publisher["id"]], + ) + g.db.commit() + + return jsonify({"data": {"status": "password_changed"}}) + @app.route("/api/v1/featured/tools", methods=["GET"]) def featured_tools() -> Response: """Get featured tools for homepage/landing.""" @@ -2309,6 +2399,14 @@ def create_app() -> Flask: data = [] for row in rows: + # Parse scrutiny report if available + scrutiny_report = None + if row["scrutiny_report"]: + try: + scrutiny_report = json.loads(row["scrutiny_report"]) + except (json.JSONDecodeError, TypeError): + pass + data.append({ "id": row["id"], "owner": row["owner"], @@ -2319,6 +2417,8 @@ def create_app() -> Flask: "published_at": row["published_at"], "publisher_name": row["publisher_name"], "visibility": row["visibility"], + "scrutiny_status": row["scrutiny_status"], + "scrutiny_report": scrutiny_report, }) return jsonify({ @@ -2388,6 +2488,92 @@ def create_app() -> Flask: return jsonify({"data": {"status": "rejected", "tool_id": tool_id}}) + @app.route("/api/v1/admin/scrutiny", methods=["GET"]) + @require_moderator + def admin_scrutiny_audit() -> Response: + """List all tools with their scrutiny status for audit purposes.""" + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", 50, type=int), 100) + offset = (page - 1) * per_page + + # Filter options + scrutiny_filter = request.args.get("scrutiny_status") # approved, pending_review, pending + moderation_filter = request.args.get("moderation_status") # approved, pending, rejected + + where_clauses = [] + params = [] + + if scrutiny_filter: + where_clauses.append("t.scrutiny_status = ?") + params.append(scrutiny_filter) + if moderation_filter: + where_clauses.append("t.moderation_status = ?") + params.append(moderation_filter) + + where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" + + rows = query_all( + g.db, + f""" + SELECT t.id, t.owner, t.name, t.version, t.description, t.category, + t.scrutiny_status, t.scrutiny_report, t.moderation_status, + t.moderation_note, t.published_at, p.display_name as publisher_name + FROM tools t + JOIN publishers p ON t.publisher_id = p.id + WHERE {where_sql} + ORDER BY t.published_at DESC + LIMIT ? OFFSET ? + """, + params + [per_page, offset], + ) + + count_row = query_one( + g.db, + f"SELECT COUNT(*) as total FROM tools t WHERE {where_sql}", + params, + ) + total = count_row["total"] if count_row else 0 + + data = [] + for row in rows: + scrutiny_report = None + if row["scrutiny_report"]: + try: + scrutiny_report = json.loads(row["scrutiny_report"]) + except (json.JSONDecodeError, TypeError): + pass + + data.append({ + "id": row["id"], + "owner": row["owner"], + "name": row["name"], + "version": row["version"], + "description": row["description"], + "category": row["category"], + "published_at": row["published_at"], + "publisher_name": row["publisher_name"], + "scrutiny_status": row["scrutiny_status"], + "scrutiny_report": scrutiny_report, + "moderation_status": row["moderation_status"], + "moderation_note": row["moderation_note"], + }) + + # Also get summary stats + stats = query_all( + g.db, + """ + SELECT scrutiny_status, moderation_status, COUNT(*) as count + FROM tools + GROUP BY scrutiny_status, moderation_status + """, + ) + + return jsonify({ + "data": data, + "meta": paginate(page, per_page, total), + "stats": [dict(s) for s in stats], + }) + @app.route("/api/v1/admin/tools//remove", methods=["POST"]) @require_moderator def admin_remove_tool(tool_id: int) -> Response: diff --git a/src/cmdforge/registry/db.py b/src/cmdforge/registry/db.py index f0c12a1..7b83666 100644 --- a/src/cmdforge/registry/db.py +++ b/src/cmdforge/registry/db.py @@ -499,12 +499,13 @@ def migrate_db(conn: sqlite3.Connection) -> None: pass # Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL - # This ensures existing tools remain visible after migration + # This ensures existing tools remain visible after migration (one-time migration) + # Note: Only applies to NULL, NOT to 'pending' - pending tools need manual review try: conn.execute(""" UPDATE tools SET moderation_status = 'approved' - WHERE moderation_status IS NULL OR moderation_status = 'pending' + WHERE moderation_status IS NULL """) conn.commit() except sqlite3.OperationalError: diff --git a/src/cmdforge/web/routes.py b/src/cmdforge/web/routes.py index 1b4ab26..3c51313 100644 --- a/src/cmdforge/web/routes.py +++ b/src/cmdforge/web/routes.py @@ -233,30 +233,68 @@ def tools(): return _render_tools() +def _load_tags() -> List[SimpleNamespace]: + """Load all tags with counts.""" + status, payload = _api_get("/api/v1/tags", params={"per_page": 100}) + if status != 200: + return [] + tags = [] + for item in payload.get("data", []): + name = item.get("name") + if not name: + continue + tags.append(SimpleNamespace( + name=name, + count=item.get("count", 0), + )) + return tags + + def _render_tools(category_override: Optional[str] = None): page = request.args.get("page", 1) sort = request.args.get("sort", "downloads") category = category_override or request.args.get("category") query = request.args.get("q") + + # Parse tag filter parameters (include/exclude) + include_tags_param = request.args.get("include_tags", "") + exclude_tags_param = request.args.get("exclude_tags", "") + include_tags = [t.strip() for t in include_tags_param.split(",") if t.strip()] + exclude_tags = [t.strip() for t in exclude_tags_param.split(",") if t.strip()] + params = {"page": page, "per_page": 12, "sort": sort} if category: params["category"] = category + if include_tags: + params["tags"] = ",".join(include_tags) status, payload = _api_get("/api/v1/tools", params=params) if status != 200: return render_template("errors/500.html"), 500 + # Filter out excluded tags client-side (API doesn't support exclude) + tools = payload.get("data", []) + if exclude_tags: + def has_excluded_tag(tool): + tool_tags = tool.get("tags", []) + return any(t in tool_tags for t in exclude_tags) + tools = [t for t in tools if not has_excluded_tag(t)] + meta = payload.get("meta", {}) categories, all_tools_total = _load_categories() + all_tags = _load_tags() return render_template( "pages/tools.html", - tools=payload.get("data", []), + tools=tools, categories=categories, current_category=category, total_count=all_tools_total, sort=sort, query=query, pagination=_build_pagination(meta), + all_tags=all_tags, + include_tags=include_tags, + exclude_tags=exclude_tags, ) @@ -625,13 +663,56 @@ def dashboard_tokens(): ) -@web_bp.route("/dashboard/settings", endpoint="dashboard_settings") +@web_bp.route("/dashboard/settings", endpoint="dashboard_settings", methods=["GET", "POST"]) def dashboard_settings(): redirect_response = _require_login() if redirect_response: return redirect_response token = session.get("auth_token") user = _load_current_publisher() or session.get("user", {}) + errors = [] + success_message = None + + if request.method == "POST": + form_type = request.form.get("form") + + if form_type == "profile": + # Update profile + data = { + "display_name": request.form.get("display_name", "").strip(), + "bio": request.form.get("bio", "").strip(), + "website": request.form.get("website", "").strip(), + } + status, payload = _api_post("/api/v1/me", data=data, token=token) + if status == 200: + success_message = "Profile updated successfully." + user = _load_current_publisher() or session.get("user", {}) + else: + errors.append(payload.get("error", {}).get("message", "Failed to update profile.")) + + elif form_type == "password": + # Change password + current_password = request.form.get("current_password", "") + new_password = request.form.get("new_password", "") + confirm_password = request.form.get("confirm_password", "") + + if not current_password or not new_password: + errors.append("Please fill in all password fields.") + elif new_password != confirm_password: + errors.append("New passwords do not match.") + elif len(new_password) < 8: + errors.append("New password must be at least 8 characters.") + else: + data = { + "current_password": current_password, + "new_password": new_password, + } + status, payload = _api_post("/api/v1/me/password", data=data, token=token) + if status == 200: + success_message = "Password updated successfully." + else: + errors.append(payload.get("error", {}).get("message", "Failed to update password.")) + tools_status, tools_payload = _api_get("/api/v1/me/tools", token=token) tools = tools_payload.get("data", []) if tools_status == 200 else [] token_status, token_payload = _api_get("/api/v1/tokens", token=token) @@ -647,8 +728,8 @@ def dashboard_settings(): tools=tools, stats=stats, tokens=tokens, - errors=[], - success_message=None, + errors=errors, + success_message=success_message, ) @@ -803,6 +884,8 @@ def sitemap(): SELECT owner, name, MAX(id) AS max_id FROM tools WHERE version NOT LIKE '%-%' + AND visibility = 'public' + AND moderation_status = 'approved' GROUP BY owner, name ) SELECT t.owner, t.name, t.published_at @@ -1132,3 +1215,71 @@ def admin_settings(): categories=categories, token=token, ) + + +@web_bp.route("/dashboard/admin/scrutiny", endpoint="admin_scrutiny") +def admin_scrutiny(): + """Scrutiny audit page - view all tool scrutiny results.""" + forbidden = _require_moderator_role() + if forbidden: + return forbidden + + user = _load_current_publisher() + token = session.get("auth_token") + page = request.args.get("page", 1, type=int) + scrutiny_filter = request.args.get("scrutiny_status", "") + moderation_filter = request.args.get("moderation_status", "") + + params = {"page": page, "per_page": 50} + if scrutiny_filter: + params["scrutiny_status"] = scrutiny_filter + if moderation_filter: + params["moderation_status"] = moderation_filter + + status, payload = _api_get("/api/v1/admin/scrutiny", params=params, token=token) + + if status != 200: + return render_template( + "admin/scrutiny.html", + user=user, + active_page="admin_scrutiny", + tools=[], + meta={}, + stats_summary={}, + scrutiny_filter=scrutiny_filter, + moderation_filter=moderation_filter, + error=payload.get("error", "Failed to load scrutiny data"), + ) + + # Process stats into easy-to-use summary + stats = payload.get("stats", []) + stats_summary = { + "approved_approved": 0, + "review_pending": 0, + "review_approved": 0, + "total_pending": 0, + } + for s in stats: + scrutiny = s.get("scrutiny_status") + moderation = s.get("moderation_status") + count = s.get("count", 0) + + if scrutiny == "approved" and moderation == "approved": + stats_summary["approved_approved"] += count + if scrutiny == "pending_review" and moderation == "pending": + stats_summary["review_pending"] += count + if scrutiny == "pending_review" and moderation == "approved": + stats_summary["review_approved"] += count + if moderation == "pending": + stats_summary["total_pending"] += count + + return render_template( + "admin/scrutiny.html", + user=user, + active_page="admin_scrutiny", + tools=payload.get("data", []), + meta=payload.get("meta", {}), + stats_summary=stats_summary, + scrutiny_filter=scrutiny_filter, + moderation_filter=moderation_filter, + ) diff --git a/src/cmdforge/web/seo.py b/src/cmdforge/web/seo.py index ad257fd..df2e946 100644 --- a/src/cmdforge/web/seo.py +++ b/src/cmdforge/web/seo.py @@ -26,12 +26,13 @@ def generate_sitemap() -> str: conn = connect_db() try: - rows = query_all(conn, "SELECT DISTINCT owner, name FROM tools") + # Only include approved public tools in sitemap + rows = query_all(conn, "SELECT DISTINCT owner, name FROM tools WHERE visibility = 'public' AND moderation_status = 'approved'") for row in rows: tool_path = url_for("web.tool_detail", owner=row["owner"], name=row["name"], _external=True) urls.append(_url_entry(tool_path, "daily", "0.9")) - categories = query_all(conn, "SELECT DISTINCT category FROM tools WHERE category IS NOT NULL") + categories = query_all(conn, "SELECT DISTINCT category FROM tools WHERE category IS NOT NULL AND visibility = 'public' AND moderation_status = 'approved'") for row in categories: cat_path = url_for("web.category", name=row["category"], _external=True) urls.append(_url_entry(cat_path, "weekly", "0.7")) diff --git a/src/cmdforge/web/templates/admin/index.html b/src/cmdforge/web/templates/admin/index.html index 8dd5734..623431d 100644 --- a/src/cmdforge/web/templates/admin/index.html +++ b/src/cmdforge/web/templates/admin/index.html @@ -83,6 +83,12 @@ Manage publishers + + + + + Scrutiny audit + {% if user.role == 'admin' %} diff --git a/src/cmdforge/web/templates/admin/pending.html b/src/cmdforge/web/templates/admin/pending.html index 0c055b2..a575458 100644 --- a/src/cmdforge/web/templates/admin/pending.html +++ b/src/cmdforge/web/templates/admin/pending.html @@ -19,7 +19,7 @@
Tool Publisher - Category + Scrutiny Submitted Actions
@@ -37,11 +37,40 @@
{{ tool.publisher_name }}
+
{{ tool.category or 'Uncategorized' }}
- - - {{ tool.category or 'Uncategorized' }} + + {% if tool.scrutiny_status == 'approved' %} + + Passed + {% elif tool.scrutiny_status == 'pending_review' %} + + Warnings + + {% else %} + + {{ tool.scrutiny_status or 'N/A' }} + + {% endif %} + {% if tool.scrutiny_report and tool.scrutiny_report.findings %} + + + {% endif %} {{ tool.published_at[:10] if tool.published_at else 'Unknown' }} @@ -100,6 +129,11 @@ +{% endblock %} diff --git a/src/cmdforge/web/templates/pages/tools.html b/src/cmdforge/web/templates/pages/tools.html index 5b141f9..f0a4f8d 100644 --- a/src/cmdforge/web/templates/pages/tools.html +++ b/src/cmdforge/web/templates/pages/tools.html @@ -67,6 +67,43 @@ + + {% if all_tags %} +
+ +
+ {% if include_tags or exclude_tags %} + + {% else %} +

Click to cycle: include/exclude/none

+ {% endif %} +
+ {% for tag in all_tags[:20] %} + {% set tag_state = 'include' if tag.name in include_tags else ('exclude' if tag.name in exclude_tags else 'neutral') %} + + {% endfor %} +
+
+
+ {% endif %} +
@@ -133,6 +170,29 @@
+ + + {% if all_tags %} +
+ +
+ {% for tag in all_tags[:12] %} + {% set tag_state = 'include' if tag.name in include_tags else ('exclude' if tag.name in exclude_tags else 'neutral') %} + + {% endfor %} +
+ {% if include_tags or exclude_tags %} + Clear tags + {% endif %} +
+ {% endif %} @@ -160,7 +220,8 @@ description=tool.description, category=tool.category, downloads=tool.downloads, - version=tool.version + version=tool.version, + tags=tool.tags ) }} {% endfor %} @@ -170,7 +231,7 @@