Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c2707679f | ||
|
|
0d91eb7fa0 | ||
|
|
a46add05b7 | ||
|
|
85d1212c0d | ||
|
|
66fd121ee4 | ||
|
|
adefe909c2 | ||
|
|
74b5124360 | ||
|
|
16de3e371c | ||
|
|
4756ea906c | ||
|
|
9f60e84075 | ||
|
|
66c0fc77ae | ||
|
|
d7d0019472 | ||
|
|
23c42faf75 | ||
|
|
19c1cf086f | ||
|
|
64b9e8e70d | ||
|
|
cd4c0682e8 | ||
|
|
9029286d4c | ||
|
|
12ae870b3c | ||
|
|
e1cae5ffda | ||
|
|
8471480aa7 | ||
|
|
b0bd692be5 | ||
|
|
807a0e28a5 | ||
|
|
957a65397a | ||
|
|
9035a17f21 | ||
|
|
3914ca74b3 | ||
|
|
706accc008 | ||
|
|
cb9625d613 | ||
|
|
f22e95d119 | ||
|
|
7f976d3f8c | ||
|
|
f1c462146b | ||
|
|
3fccf854be | ||
|
|
62a4b4a2da | ||
|
|
4b1f3cee52 | ||
|
|
96f7c05744 | ||
|
|
deddd8efbb | ||
|
|
0216d1d77a | ||
|
|
5e01a335c0 | ||
|
|
78025aac8e | ||
|
|
8e6b03cade |
+71
-1
@@ -1 +1,71 @@
|
||||
# Auto-deploy test Tue 13 Jan 2026 03:09:44 AM AST
|
||||
# Changelog
|
||||
|
||||
All notable changes to CmdForge will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
#### Registry Features
|
||||
- **Fork tracking and display**: Tools now track their fork origin with `forked_from` and `forked_version` metadata
|
||||
- Forked tools show a "Forked from" notice on the tool detail page
|
||||
- Original tools display a "Forks" section listing all forks
|
||||
- Fork count displayed in tool stats
|
||||
- API endpoint: `GET /api/v1/tools/<owner>/<name>/forks`
|
||||
|
||||
- **Version selector for installs**: Users can select specific versions when installing tools from the registry
|
||||
- Version dropdown in registry page populated via `GET /api/v1/tools/<owner>/<name>/versions`
|
||||
- "Latest" option plus all available versions listed
|
||||
- Selected version passed to install worker
|
||||
|
||||
- **Auto-cleanup rejected versions**: Admin maintenance feature to purge rejected tool submissions
|
||||
- API endpoint: `POST /api/v1/admin/cleanup/rejected`
|
||||
- Parameters:
|
||||
- `days` (default: 7) - grace period before deletion
|
||||
- `dry_run` (default: false) - preview mode without actual deletion
|
||||
- Admin dashboard UI with "Dry Run" and "Run Cleanup" buttons
|
||||
- Shows count of rejected versions pending cleanup
|
||||
|
||||
#### GUI Improvements
|
||||
- Version display and bump buttons in publish dialog
|
||||
- Auto-fetch registry version when opening publish dialog
|
||||
- Fork detection during publish workflow
|
||||
- Always refresh tools page after publish dialog closes
|
||||
- **Startup connection validation**: GUI validates registry token on startup
|
||||
- Automatically clears invalid/revoked tokens
|
||||
- Shows status bar message when connection is cleared
|
||||
- Prevents confusing errors when trying to publish with stale credentials
|
||||
|
||||
#### CLI Features
|
||||
- `cmdforge config disconnect` - Clear registry token from local configuration
|
||||
|
||||
#### Admin Features
|
||||
- Maintenance section in admin dashboard
|
||||
- Rejected version count display
|
||||
- Cleanup result modal with detailed output
|
||||
|
||||
#### Fabric Sync Improvements
|
||||
- **AI review logging**: Added comprehensive logging to `run_ai_scrutiny_review()` function
|
||||
- Logs tool path checks, warning counts, timing, success/failure states
|
||||
- Logs timeout events, JSON parse errors, and all exception types
|
||||
- Previously failures were silently swallowed with `return None`
|
||||
- **`--skip-ai-review` flag**: Skip AI secondary review for large bulk imports
|
||||
- Prevents rate limiting and timeouts during initial large imports
|
||||
- Usage: `python fabric_sync.py --sync --skip-ai-review`
|
||||
- Logs when AI review is skipped with reason
|
||||
|
||||
### Fixed
|
||||
- **Registry search partial matching**: Search now supports prefix matching (e.g., "summ" matches "summarize")
|
||||
- VERSION_EXISTS error showing after successful publish (made endpoint idempotent by checking config_hash)
|
||||
- My Tools page listing every version separately (now consolidated by tool name)
|
||||
- GUI not refreshing after publish dialog closes
|
||||
|
||||
### Changed
|
||||
- Publish endpoint now returns success if same config_hash already exists (idempotent)
|
||||
- My Tools page groups versions by tool name, showing version count and list
|
||||
|
||||
---
|
||||
|
||||
## Previous Changes
|
||||
|
||||
See git history for changes prior to this changelog.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# CmdForge Collections
|
||||
|
||||
> **Document Status (January 2026)**: Collections are partially implemented:
|
||||
> - ✅ API endpoints (`/api/v1/collections`, etc.)
|
||||
> - ✅ Web UI pages (`/collections`, `/collections/:name`)
|
||||
> - ✅ Database schema
|
||||
> - ❌ CLI commands (`cmdforge collections list/install`) - Not implemented
|
||||
> - ❌ Registry repo sync - Not implemented
|
||||
>
|
||||
> Collections can be browsed on the web and via API, but must be installed tool-by-tool via CLI.
|
||||
|
||||
Collections are curated groups of tools that can be installed together with a single command.
|
||||
|
||||
## Use Cases
|
||||
|
||||
+41
-16
@@ -2,6 +2,11 @@
|
||||
|
||||
> A lightweight personal tool builder for AI-powered CLI commands
|
||||
|
||||
> **Document Status (January 2026)**: This is the original design document. The core concepts
|
||||
> (tool format, steps, variables, providers) remain accurate. The Terminal UI section describes
|
||||
> the original urwid TUI which has been replaced by a PySide6 desktop GUI. For the latest
|
||||
> documentation, see the project-docs system.
|
||||
|
||||
## Overview
|
||||
|
||||
CmdForge lets you create custom AI-powered terminal commands. You define a tool once (name, steps, provider), then use it like any Linux command.
|
||||
@@ -188,7 +193,11 @@ cmdforge check sum # Check dependencies for meta-tools
|
||||
cmdforge ui # Launch interactive UI
|
||||
```
|
||||
|
||||
## Terminal UI
|
||||
## Terminal UI (Historical)
|
||||
|
||||
> **Note**: The urwid TUI described below has been replaced by a modern PySide6 desktop GUI.
|
||||
> The GUI provides the same functionality with a more polished interface. Run `cmdforge` to
|
||||
> launch the GUI, or `cmdforge ui` for legacy TUI mode if available.
|
||||
|
||||
A BIOS-style terminal UI using `urwid` with full mouse support.
|
||||
|
||||
@@ -281,16 +290,27 @@ The current section's title is highlighted with brackets: `[ Tool Info ]`
|
||||
|
||||
### Directory Structure
|
||||
|
||||
> **Note**: The structure below is simplified. See `src/cmdforge/` for the full implementation.
|
||||
|
||||
```
|
||||
cmdforge/
|
||||
src/cmdforge/
|
||||
__init__.py
|
||||
cli.py # Entry point, argument parsing
|
||||
ui.py # UI dispatcher (chooses urwid/snack/dialog)
|
||||
ui_urwid.py # Urwid-based BIOS-style UI
|
||||
ui_snack.py # Snack/newt fallback UI
|
||||
cli/ # CLI commands (list, create, edit, run, registry, etc.)
|
||||
gui/ # PySide6 desktop GUI
|
||||
main_window.py
|
||||
pages/ # Tools, Registry, Providers, Profiles pages
|
||||
dialogs/ # Step editors, Publish dialog, Connect dialog
|
||||
web/ # Flask web application
|
||||
app.py
|
||||
routes.py
|
||||
templates/
|
||||
registry/ # Registry API
|
||||
app.py
|
||||
db.py
|
||||
tool.py # Tool loading/saving/wrapper scripts
|
||||
runner.py # Execute tools
|
||||
providers.py # Provider abstraction
|
||||
profiles.py # AI persona profiles
|
||||
```
|
||||
|
||||
### Provider System
|
||||
@@ -380,29 +400,34 @@ output: "{result}"
|
||||
|
||||
## What This Design Doesn't Include
|
||||
|
||||
Intentionally omitted (not needed for personal use):
|
||||
Intentionally omitted for local tool execution (not needed for personal use):
|
||||
|
||||
- Trust tiers / security levels
|
||||
- Cryptographic signing
|
||||
- Container isolation / sandboxing
|
||||
- Certification testing
|
||||
- Distribution packaging
|
||||
- PII redaction
|
||||
- Audit logging
|
||||
- Provider capability negotiation
|
||||
|
||||
**Why?** This is a personal tool builder. You write the tools, you run the tools, you accept the responsibility. Just like any bash script you write.
|
||||
|
||||
> **Note**: Some features were later added for the **registry** (community tool sharing):
|
||||
> - Trust tiers → Publisher trust scores and badges
|
||||
> - Security scanning → Scrutiny system with AI review
|
||||
> - Audit logging → Admin audit trail for moderation actions
|
||||
> - Distribution → Registry publishing with version management
|
||||
|
||||
## Dependencies
|
||||
|
||||
Required:
|
||||
- Python 3.10+
|
||||
- PyYAML
|
||||
- urwid (for TUI)
|
||||
- PySide6 (for GUI)
|
||||
|
||||
Optional fallbacks:
|
||||
- python3-newt/snack (simpler TUI)
|
||||
- dialog/whiptail (basic TUI)
|
||||
Optional (for web/registry):
|
||||
- Flask
|
||||
- SQLite with FTS5
|
||||
|
||||
Legacy TUI (no longer primary):
|
||||
- urwid
|
||||
- python3-newt/snack
|
||||
|
||||
## Example Workflow
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Meta-Tools: Tools That Call Other Tools
|
||||
|
||||
> **Document Status (January 2026)**: The `tool` step type is implemented and works as described.
|
||||
> The CLI dependency commands (`cmdforge install --deps`, `cmdforge check`) are not yet implemented.
|
||||
> Tools can still use `tool` steps, but dependency resolution is manual.
|
||||
|
||||
Meta-tools are CmdForge tools that can invoke other tools as steps in their workflow. This enables powerful composition and reuse of existing tools.
|
||||
|
||||
## Step Type: `tool`
|
||||
|
||||
+43
-23
@@ -1,5 +1,9 @@
|
||||
# CmdForge Project Overview
|
||||
|
||||
> **Document Status (January 2026)**: This overview is mostly accurate but the source structure
|
||||
> section is outdated. The TUI has been replaced by a PySide6 GUI. Database now has 25+ tables.
|
||||
> For current architecture, see `src/cmdforge/` directly.
|
||||
|
||||
This document provides a comprehensive overview of the CmdForge project structure, components, and how everything fits together.
|
||||
|
||||
## What is CmdForge?
|
||||
@@ -32,52 +36,68 @@ See `docs/diagrams/` for visual representations:
|
||||
|
||||
```
|
||||
src/cmdforge/
|
||||
├── cli.py # Entry point, subcommand routing
|
||||
├── cli/ # CLI commands
|
||||
│ ├── __init__.py # Entry point, subcommand routing
|
||||
│ └── registry_commands.py # Registry CLI commands
|
||||
├── tool.py # Tool dataclasses, YAML loading
|
||||
├── runner.py # Step execution engine
|
||||
├── providers.py # AI provider abstraction
|
||||
├── profiles.py # AI persona profiles
|
||||
├── config.py # Global configuration
|
||||
├── manifest.py # Tool manifest handling
|
||||
│
|
||||
├── gui/ # PySide6 Desktop GUI (primary UI)
|
||||
│ ├── main_window.py # Main application window
|
||||
│ ├── pages/ # Tools, Registry, Providers, Profiles pages
|
||||
│ ├── dialogs/ # Step editors, Publish, Connect dialogs
|
||||
│ └── workers/ # Background threads for API calls
|
||||
│
|
||||
├── registry/ # Registry API Server
|
||||
│ ├── app.py # Flask API application
|
||||
│ ├── db.py # Database operations, FTS5 search
|
||||
│ ├── app.py # Flask API with 100+ endpoints
|
||||
│ ├── db.py # Database operations, FTS5 search (25+ tables)
|
||||
│ ├── rate_limit.py # API rate limiting
|
||||
│ ├── sync.py # Git sync for tool submissions
|
||||
│ ├── categorize.py # Auto-categorization
|
||||
│ └── similarity.py # Similar tool recommendations
|
||||
│ ├── scrutiny.py # Automated code analysis
|
||||
│ ├── similarity.py # Duplicate detection
|
||||
│ └── sync.py # Git sync for tool submissions
|
||||
│
|
||||
├── web/ # Web UI Application
|
||||
│ ├── app.py # Flask web app, Sentry integration
|
||||
│ ├── app.py # Flask web app
|
||||
│ ├── routes.py # Page routes and handlers
|
||||
│ ├── auth.py # Password hashing, login/register
|
||||
│ ├── sessions.py # Session management
|
||||
│ ├── docs_content.py # Documentation text content
|
||||
│ ├── seo.py # Sitemap, robots.txt
|
||||
│ ├── filters.py # Jinja template filters
|
||||
│ ├── forum/ # Forum blueprint
|
||||
│ ├── templates/ # Jinja HTML templates
|
||||
│ └── static/ # CSS, JS, images
|
||||
│ └── static/ # CSS (Tailwind), JS, images
|
||||
│
|
||||
├── registry_client.py # CLI client for registry API
|
||||
├── resolver.py # Dependency resolution
|
||||
│
|
||||
├── ui.py # TUI dispatcher
|
||||
├── ui_urwid.py # urwid TUI implementation
|
||||
└── ui_snack.py # newt/snack TUI fallback
|
||||
└── registry_client.py # CLI client for registry API
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
SQLite database at `data/cmdforge.db`:
|
||||
SQLite database at `~/.cmdforge/registry.db` (or via `CMDFORGE_REGISTRY_DB` env var):
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `publishers` | User accounts (username, email, password_hash) |
|
||||
| `tools` | Published tools (name, owner, description, config) |
|
||||
| `publishers` | User accounts with roles, banning, verification |
|
||||
| `tools` | Published tools with moderation status, fork tracking |
|
||||
| `api_tokens` | Authentication tokens for API access |
|
||||
| `download_stats` | Tool download tracking |
|
||||
| `pageviews` | Analytics for tool detail pages |
|
||||
| `download_stats` | Tool download tracking by date |
|
||||
| `reviews` | Tool reviews with 1-5 star ratings |
|
||||
| `review_votes` | Helpful/unhelpful votes on reviews |
|
||||
| `tool_issues` | Bug, security, compatibility issue reports |
|
||||
| `collections` | Curated tool groups |
|
||||
| `announcements` | Admin announcements |
|
||||
| `featured_tools` | Featured tool placement |
|
||||
| `featured_contributors` | Featured publisher placement |
|
||||
| `reports` | Abuse reports for moderation |
|
||||
| `audit_log` | Admin action audit trail |
|
||||
| `pairing_requests` | App pairing flow |
|
||||
| `web_sessions` | Session storage |
|
||||
| `consents` | User consent for analytics/ads |
|
||||
| `pageviews` | Analytics tracking |
|
||||
| `tools_fts` | Full-text search index (FTS5) |
|
||||
| `tool_stats` | Cached tool statistics |
|
||||
| `publisher_stats` | Cached publisher statistics |
|
||||
| `registry_settings` | Runtime-configurable settings |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Provider Setup Guide
|
||||
|
||||
> **Document Status (January 2026)**: This guide is current and accurate. Provider benchmarks
|
||||
> may vary based on model updates. Use `cmdforge providers install` for the latest setup.
|
||||
|
||||
CmdForge works with any AI CLI tool that accepts input via stdin or arguments. This guide covers setup for the most popular providers.
|
||||
|
||||
## Provider Comparison
|
||||
|
||||
+112
-45
@@ -1,5 +1,11 @@
|
||||
# CmdForge Registry Design
|
||||
|
||||
> **Document Status (January 2026)**: This is the original design document used to build the registry.
|
||||
> Phases 1-4 and 6-8 are complete. Phase 5 (Project Dependencies) is partially implemented.
|
||||
> Many features beyond this spec have been added (reviews, issues, fork tracking, scrutiny, admin
|
||||
> moderation). See the Implementation Phases section for current status. For the latest
|
||||
> documentation, see the project-docs system.
|
||||
|
||||
## Purpose
|
||||
Build a centralized registry for CmdForge to enable discovery, publishing, dependency management, and future curation at scale.
|
||||
|
||||
@@ -1969,55 +1975,116 @@ def render_readme_safe(readme_raw: str) -> str:
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
- Define `cmdforge.yaml` manifest format
|
||||
- Implement tool resolution order (local → global → registry)
|
||||
- Create CmdForge-Registry repo on Gitea (bootstrap)
|
||||
- Add 3-5 example tools to seed the registry
|
||||
> **Status as of January 2026**: Phases 1-4 and 6-8 are complete. Phase 5 is partially complete.
|
||||
|
||||
### Phase 2: Core Backend
|
||||
- Set up Flask/FastAPI project structure
|
||||
- Implement SQLite database schema
|
||||
- Build core API endpoints (list, search, get, download)
|
||||
- Implement webhook receiver for Gitea sync
|
||||
- Set up HMAC verification
|
||||
### Phase 1: Foundation ✅ Complete
|
||||
- [x] Define `cmdforge.yaml` manifest format
|
||||
- [x] Implement tool resolution order (local → global → registry)
|
||||
- [x] Create CmdForge-Registry repo on Gitea (bootstrap)
|
||||
- [x] Add 3-5 example tools to seed the registry (233+ Fabric patterns imported)
|
||||
|
||||
### Phase 3: CLI Commands
|
||||
- `cmdforge registry search`
|
||||
- `cmdforge registry install`
|
||||
- `cmdforge registry info`
|
||||
- `cmdforge registry browse` (TUI)
|
||||
- Local index caching
|
||||
### Phase 2: Core Backend ✅ Complete
|
||||
- [x] Set up Flask/FastAPI project structure
|
||||
- [x] Implement SQLite database schema (25+ tables including FTS5, reviews, issues, audit)
|
||||
- [x] Build core API endpoints (list, search, get, download)
|
||||
- [x] Implement webhook receiver for Gitea sync
|
||||
- [x] Set up HMAC verification
|
||||
|
||||
### Phase 4: Publishing
|
||||
- Publisher registration (web UI)
|
||||
- Token management
|
||||
- `cmdforge registry publish` command
|
||||
- PR creation via Gitea API
|
||||
- CI validation workflows
|
||||
### Phase 3: CLI Commands ✅ Complete
|
||||
- [x] `cmdforge registry search` (with faceted search, tags, categories, owner, date range)
|
||||
- [x] `cmdforge registry install`
|
||||
- [x] `cmdforge registry info`
|
||||
- [x] `cmdforge registry browse` (opens PySide6 GUI)
|
||||
- [x] Local index caching
|
||||
- [x] `cmdforge registry tags` (list available tags)
|
||||
- [x] `cmdforge registry uninstall`
|
||||
- [x] `cmdforge registry my-tools`
|
||||
- [x] `cmdforge registry status`
|
||||
- [x] `cmdforge registry config` (admin settings)
|
||||
|
||||
### Phase 5: Project Dependencies
|
||||
- `cmdforge install` (from manifest)
|
||||
- `cmdforge add` command
|
||||
- Runtime override application
|
||||
- Dependency resolution
|
||||
### Phase 4: Publishing ✅ Complete
|
||||
- [x] Publisher registration (web UI)
|
||||
- [x] Token management
|
||||
- [x] `cmdforge registry publish` command (with dry-run, version bump, similarity warnings)
|
||||
- [x] PR creation via Gitea API
|
||||
- [x] CI validation workflows
|
||||
- [x] App pairing/connection flow (polling-based, replaces manual token entry)
|
||||
- [x] Connected apps management
|
||||
|
||||
### Phase 6: Smart Features
|
||||
- SQLite FTS5 search index
|
||||
- AI-powered auto-categorization
|
||||
- Duplicate/similarity detection
|
||||
- Security scanning
|
||||
### Phase 5: Project Dependencies ⚠️ Partial
|
||||
- [ ] `cmdforge install` (from manifest) - Not implemented
|
||||
- [ ] `cmdforge add` command - Not implemented
|
||||
- [ ] Runtime override application - Not implemented
|
||||
- [ ] Dependency resolution - Not implemented
|
||||
- [x] Individual tool install via `cmdforge registry install`
|
||||
|
||||
### Phase 7: Full Web UI
|
||||
- Landing page
|
||||
- Tool browsing/search pages
|
||||
- Tool detail pages with README rendering
|
||||
- Publisher dashboard
|
||||
- Documentation/tutorials section
|
||||
### Phase 6: Smart Features ✅ Complete
|
||||
- [x] SQLite FTS5 search index (with prefix matching)
|
||||
- [x] AI-powered auto-categorization
|
||||
- [x] Duplicate/similarity detection
|
||||
- [x] Security scanning (scrutiny system)
|
||||
- [x] AI-powered secondary review (scrutiny-ai-review integration)
|
||||
- [x] Auto-approve/review/reject decision logic
|
||||
- [x] Confidence-based moderation
|
||||
|
||||
### Phase 8: Polish & Scale
|
||||
- Rate limiting
|
||||
- Abuse reporting
|
||||
- Analytics integration
|
||||
- Performance optimization
|
||||
- Monitoring/alerting
|
||||
### Phase 7: Full Web UI ✅ Complete
|
||||
- [x] Landing page (with featured tools, tutorials, contributor spotlight)
|
||||
- [x] Tool browsing/search pages (with faceted filters)
|
||||
- [x] Tool detail pages with README rendering (plus reviews, issues, versions, forks)
|
||||
- [x] Publisher dashboard (tools, tokens, settings)
|
||||
- [x] Documentation/tutorials section
|
||||
- [x] Admin dashboard (pending queue, publishers, reports, scrutiny, settings)
|
||||
- [x] Forum (categories, topics, replies)
|
||||
- [x] Collections pages
|
||||
- [x] Publisher profiles with trust scores and badges
|
||||
|
||||
### Phase 8: Polish & Scale ✅ Complete
|
||||
- [x] Rate limiting
|
||||
- [x] Abuse reporting
|
||||
- [x] Analytics integration (pageviews, consent management)
|
||||
- [x] Performance optimization
|
||||
- [x] Monitoring/alerting (Sentry integration)
|
||||
- [x] Audit logging
|
||||
- [x] Role-based access control (user, moderator, admin)
|
||||
- [x] Publisher banning/unbanning
|
||||
- [x] Review voting and flagging
|
||||
- [x] Issue tracking (bug, security, compatibility)
|
||||
|
||||
## Features Implemented Beyond Original Design
|
||||
|
||||
The following features were added during development but not in the original design:
|
||||
|
||||
### Reviews & Ratings System
|
||||
- 1-5 star ratings with text reviews
|
||||
- Helpful/unhelpful voting on reviews
|
||||
- Review flagging for moderation
|
||||
- Rating aggregates and statistics
|
||||
- Publisher reputation scores
|
||||
|
||||
### Issue Tracking
|
||||
- Bug, security, and compatibility issue types
|
||||
- Severity levels (low, medium, high, critical)
|
||||
- Issue status workflow (open, confirmed, fixed, wontfix, duplicate)
|
||||
- Issue resolution with notes
|
||||
|
||||
### Fork Tracking
|
||||
- Track original tool when forking (`forked_from` field)
|
||||
- Store `forked_version` for version tracking
|
||||
- Display fork information in GUI and web
|
||||
|
||||
### Scrutiny System (Automated Vetting)
|
||||
- Automated security/compatibility analysis on publish
|
||||
- AI-powered secondary review
|
||||
- Confidence-based approval decisions
|
||||
- Admin scrutiny dashboard with statistics
|
||||
|
||||
### Trust & Badges
|
||||
- Publisher trust scores
|
||||
- Achievement badges
|
||||
- Download milestones
|
||||
|
||||
### PySide6 Desktop GUI
|
||||
- Modern desktop GUI replacing urwid TUI
|
||||
- Tool Builder with visual form editor
|
||||
- AI-assisted code generation for code steps
|
||||
- AI persona profiles system
|
||||
|
||||
+184
-98
@@ -1,5 +1,10 @@
|
||||
# CmdForge Web UI Design
|
||||
|
||||
> **Document Status (January 2026)**: This is the original design document used to build the web UI.
|
||||
> The implementation is complete with additional features beyond this spec. See the Phase 7
|
||||
> Implementation Checklist below for current status. For the latest documentation, see the
|
||||
> project-docs system.
|
||||
|
||||
## Purpose
|
||||
Deliver a professional web front-end that explains CmdForge, helps users discover tools, and supports a collaborative ecosystem. The site should drive sustainable revenue without undermining trust or usability.
|
||||
|
||||
@@ -1162,123 +1167,174 @@ Sitemap: https://cmdforge.dev/sitemap.xml
|
||||
|
||||
## Phase 7 Implementation Checklist
|
||||
|
||||
### 7.1 Foundation & Setup
|
||||
- [ ] Set up Flask project structure with blueprints
|
||||
- [ ] Configure Jinja2 templates with base layout
|
||||
- [ ] Integrate Tailwind CSS (build pipeline)
|
||||
- [ ] Set up static asset handling (CSS, JS, images)
|
||||
- [ ] Configure development/production environments
|
||||
- [ ] Set up database models for web-specific tables
|
||||
> **Status as of January 2026**: Phase 7 is complete with most items implemented. See notes for partial items.
|
||||
|
||||
### 7.2 Core Templates & Components
|
||||
- [ ] Create base template with header/footer
|
||||
- [ ] Implement navigation component (desktop + mobile)
|
||||
- [ ] Build reusable card components (tool, tutorial, contributor)
|
||||
- [ ] Create form components (inputs, buttons, dropdowns)
|
||||
- [ ] Implement callout/alert components
|
||||
- [ ] Build code block component with copy functionality
|
||||
- [ ] Create loading states (skeleton, spinner)
|
||||
- [ ] Implement responsive grid system
|
||||
### 7.1 Foundation & Setup ✅ Complete
|
||||
- [x] Set up Flask project structure with blueprints
|
||||
- [x] Configure Jinja2 templates with base layout
|
||||
- [x] Integrate Tailwind CSS (build pipeline)
|
||||
- [x] Set up static asset handling (CSS, JS, images)
|
||||
- [x] Configure development/production environments
|
||||
- [x] Set up database models for web-specific tables (25+ tables)
|
||||
|
||||
### 7.3 Landing Page
|
||||
- [ ] Hero section with install snippet
|
||||
- [ ] Three pillars section (Easy, Powerful, Community)
|
||||
- [ ] Featured tools grid (API integration)
|
||||
- [ ] Getting started tutorial cards
|
||||
- [ ] Featured contributor spotlight
|
||||
- [ ] Footer with links and optional ad zone
|
||||
### 7.2 Core Templates & Components ✅ Complete
|
||||
- [x] Create base template with header/footer
|
||||
- [x] Implement navigation component (desktop + mobile)
|
||||
- [x] Build reusable card components (tool, tutorial, contributor)
|
||||
- [x] Create form components (inputs, buttons, dropdowns)
|
||||
- [x] Implement callout/alert components
|
||||
- [x] Build code block component with copy functionality
|
||||
- [x] Create loading states (skeleton, spinner)
|
||||
- [x] Implement responsive grid system
|
||||
|
||||
### 7.4 Registry Pages (Ad-Free)
|
||||
- [ ] Tool browse page with search bar
|
||||
- [ ] Category dropdown filter
|
||||
- [ ] Sort options (popular, recent, name)
|
||||
- [ ] Pagination component
|
||||
- [ ] Tool card grid layout
|
||||
- [ ] Tool detail page with README rendering
|
||||
- [ ] Version selector in sidebar
|
||||
- [ ] Install command with copy
|
||||
- [ ] Report abuse button/modal
|
||||
- [ ] Category pages
|
||||
### 7.3 Landing Page ✅ Complete
|
||||
- [x] Hero section with install snippet
|
||||
- [x] Three pillars section (Easy, Powerful, Community)
|
||||
- [x] Featured tools grid (API integration)
|
||||
- [x] Getting started tutorial cards
|
||||
- [x] Featured contributor spotlight
|
||||
- [x] Footer with links and optional ad zone
|
||||
|
||||
### 7.5 Documentation & Tutorials
|
||||
- [ ] Docs landing page with section links
|
||||
- [ ] Tutorial listing page
|
||||
- [ ] Content page template with TOC
|
||||
- [ ] Scroll-spy for TOC highlighting
|
||||
- [ ] Code syntax highlighting (Prism/Highlight.js)
|
||||
- [ ] Video embed component (YouTube)
|
||||
- [ ] Related articles section
|
||||
- [ ] Sidebar ad placement (desktop only)
|
||||
### 7.4 Registry Pages (Ad-Free) ✅ Complete
|
||||
- [x] Tool browse page with search bar
|
||||
- [x] Category dropdown filter
|
||||
- [x] Sort options (popular, recent, name)
|
||||
- [x] Pagination component
|
||||
- [x] Tool card grid layout
|
||||
- [x] Tool detail page with README rendering
|
||||
- [x] Version selector in sidebar
|
||||
- [x] Install command with copy
|
||||
- [x] Report abuse button/modal
|
||||
- [x] Category pages
|
||||
- [x] **Added**: Reviews and ratings display
|
||||
- [x] **Added**: Issue tracking display
|
||||
- [x] **Added**: Fork information display
|
||||
- [x] **Added**: Publisher profile pages with trust scores
|
||||
- [x] **Added**: Collections pages
|
||||
|
||||
### 7.6 Authentication & Dashboard
|
||||
- [ ] Registration page and flow
|
||||
- [ ] Login page with error handling
|
||||
- [ ] Password reset flow (if implementing)
|
||||
- [ ] Session management (cookies, CSRF)
|
||||
- [ ] Dashboard layout with sidebar
|
||||
- [ ] My Tools tab with tool list
|
||||
- [ ] API Tokens tab with create/revoke
|
||||
- [ ] Settings tab with profile edit
|
||||
- [ ] Logout functionality
|
||||
### 7.5 Documentation & Tutorials ⚠️ Mostly Complete
|
||||
- [x] Docs landing page with section links
|
||||
- [x] Tutorial listing page
|
||||
- [x] Content page template with TOC
|
||||
- [ ] Scroll-spy for TOC highlighting (not implemented)
|
||||
- [x] Code syntax highlighting (Prism/Highlight.js)
|
||||
- [ ] Video embed component (YouTube) (not implemented)
|
||||
- [ ] Related articles section (limited implementation)
|
||||
- [x] Sidebar ad placement (desktop only)
|
||||
|
||||
### 7.7 Privacy & Consent
|
||||
- [ ] Cookie consent banner
|
||||
- [ ] Consent preferences modal
|
||||
- [ ] Consent state storage
|
||||
- [ ] Privacy policy page
|
||||
- [ ] Terms of service page
|
||||
- [ ] Honor consent in analytics/ad loading
|
||||
### 7.6 Authentication & Dashboard ✅ Complete
|
||||
- [x] Registration page and flow
|
||||
- [x] Login page with error handling
|
||||
- [ ] Password reset flow (placeholder only - not functional)
|
||||
- [x] Session management (cookies, CSRF)
|
||||
- [x] Dashboard layout with sidebar
|
||||
- [x] My Tools tab with tool list (version consolidation, ratings, issues)
|
||||
- [x] API Tokens tab with create/revoke (now "Connections" with app pairing)
|
||||
- [x] Settings tab with profile edit
|
||||
- [x] Logout functionality
|
||||
- [x] **Added**: Admin dashboard (pending queue, publishers, reports, scrutiny, settings)
|
||||
- [x] **Added**: Role-based access (user, moderator, admin)
|
||||
|
||||
### 7.8 Ads & Monetization
|
||||
- [ ] AdSense integration (account setup)
|
||||
- [ ] Ad container components
|
||||
- [ ] Lazy loading for ad scripts
|
||||
- [ ] Ad placement rules enforcement
|
||||
- [ ] Sponsored content styling (if applicable)
|
||||
- [ ] Donate page with donation options
|
||||
### 7.7 Privacy & Consent ⚠️ Mostly Complete
|
||||
- [x] Cookie consent banner (API exists)
|
||||
- [x] Consent preferences modal (API exists)
|
||||
- [x] Consent state storage
|
||||
- [x] Privacy policy page
|
||||
- [x] Terms of service page
|
||||
- [x] Honor consent in analytics/ad loading
|
||||
|
||||
### 7.9 SEO & Performance
|
||||
- [ ] Meta tags for all page types
|
||||
- [ ] Open Graph tags
|
||||
- [ ] Schema.org structured data
|
||||
- [ ] Sitemap generation
|
||||
- [ ] robots.txt configuration
|
||||
- [ ] Canonical URL implementation
|
||||
- [ ] Image optimization pipeline
|
||||
- [ ] CSS/JS minification
|
||||
- [ ] Critical CSS inlining
|
||||
- [ ] Lazy loading for images
|
||||
### 7.8 Ads & Monetization ⚠️ Partial
|
||||
- [ ] AdSense integration (account setup) (placeholders only)
|
||||
- [x] Ad container components
|
||||
- [x] Lazy loading for ad scripts
|
||||
- [x] Ad placement rules enforcement
|
||||
- [ ] Sponsored content styling (if applicable) (not implemented)
|
||||
- [x] Donate page with donation options
|
||||
|
||||
### 7.10 Testing & QA
|
||||
- [ ] Responsive design testing (all breakpoints)
|
||||
- [ ] Accessibility testing (WCAG 2.1 AA)
|
||||
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)
|
||||
- [ ] Performance testing (Lighthouse scores)
|
||||
- [ ] Form validation testing
|
||||
- [ ] Error state testing
|
||||
- [ ] Mobile usability testing
|
||||
### 7.9 SEO & Performance ✅ Mostly Complete
|
||||
- [x] Meta tags for all page types
|
||||
- [x] Open Graph tags
|
||||
- [ ] Schema.org structured data (not verified)
|
||||
- [x] Sitemap generation
|
||||
- [x] robots.txt configuration
|
||||
- [x] Canonical URL implementation (partial)
|
||||
- [ ] Image optimization pipeline (not implemented)
|
||||
- [x] CSS/JS minification (via Tailwind build)
|
||||
- [ ] Critical CSS inlining (not implemented)
|
||||
- [x] Lazy loading for images
|
||||
|
||||
### 7.11 Launch Preparation
|
||||
- [ ] Content creation (initial docs, tutorials)
|
||||
- [ ] Seed featured tools selection
|
||||
- [ ] Initial contributor spotlight
|
||||
- [ ] Analytics setup (privacy-respecting)
|
||||
- [ ] Error monitoring (Sentry or similar)
|
||||
- [ ] SSL certificate configuration
|
||||
- [ ] CDN setup (optional)
|
||||
- [ ] Backup and recovery procedures
|
||||
### 7.10 Testing & QA ⚠️ Ongoing
|
||||
- [x] Responsive design testing (all breakpoints)
|
||||
- [ ] Accessibility testing (WCAG 2.1 AA) (not formally verified)
|
||||
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge) (not formally verified)
|
||||
- [ ] Performance testing (Lighthouse scores) (not formally verified)
|
||||
- [x] Form validation testing
|
||||
- [x] Error state testing
|
||||
- [x] Mobile usability testing
|
||||
|
||||
### 7.11 Launch Preparation ✅ Complete
|
||||
- [x] Content creation (initial docs, tutorials)
|
||||
- [x] Seed featured tools selection (233+ Fabric patterns)
|
||||
- [x] Initial contributor spotlight
|
||||
- [x] Analytics setup (privacy-respecting)
|
||||
- [x] Error monitoring (Sentry or similar)
|
||||
- [x] SSL certificate configuration (via Cloudflare)
|
||||
- [ ] CDN setup (optional) (not implemented)
|
||||
- [x] Backup and recovery procedures
|
||||
|
||||
## Features Implemented Beyond Original Checklist
|
||||
|
||||
### Admin Moderation System
|
||||
- Pending tools queue with approve/reject/request-changes workflow
|
||||
- Publisher management (ban/unban, role changes)
|
||||
- Report resolution workflow
|
||||
- Audit logging for all admin actions
|
||||
- Scrutiny dashboard with auto-vetting statistics
|
||||
- Registry settings management
|
||||
|
||||
### Forum/Community
|
||||
- Categories (General, Tutorials, Showcase, Help)
|
||||
- Topics within categories
|
||||
- Replies with threading
|
||||
- View counts
|
||||
- Honeypot spam protection
|
||||
|
||||
### Reviews & Ratings
|
||||
- 5-star rating system
|
||||
- Review text with title
|
||||
- Helpful/unhelpful voting
|
||||
- Review flagging
|
||||
- Average rating aggregates
|
||||
|
||||
### Issue Tracking
|
||||
- Bug, security, compatibility issue types
|
||||
- Severity levels
|
||||
- Status workflow (open, confirmed, fixed, wontfix, duplicate)
|
||||
- Resolution notes
|
||||
|
||||
## API Endpoints for Web UI
|
||||
|
||||
The web UI consumes these existing API endpoints:
|
||||
> **Note**: This section lists the originally planned endpoints. The actual implementation includes
|
||||
> 100+ endpoints. See `src/cmdforge/registry/app.py` for the complete API.
|
||||
|
||||
The web UI consumes these API endpoints:
|
||||
|
||||
**Public (read-only):**
|
||||
- `GET /api/v1/tools` - List tools with pagination/filters
|
||||
- `GET /api/v1/tools/search?q=...` - Search tools
|
||||
- `GET /api/v1/tools/search?q=...` - Search tools with facets
|
||||
- `GET /api/v1/tools/{owner}/{name}` - Tool details
|
||||
- `GET /api/v1/tools/{owner}/{name}/versions` - Version list
|
||||
- `GET /api/v1/tools/{owner}/{name}/forks` - Fork list
|
||||
- `GET /api/v1/tools/{owner}/{name}/reviews` - Review list
|
||||
- `GET /api/v1/tools/{owner}/{name}/issues` - Issue list
|
||||
- `GET /api/v1/tools/{owner}/{name}/rating` - Rating summary
|
||||
- `GET /api/v1/categories` - Category list
|
||||
- `GET /api/v1/tags` - Tag list with counts
|
||||
- `GET /api/v1/collections` - Collection list
|
||||
- `GET /api/v1/collections/{name}` - Collection details
|
||||
- `GET /api/v1/stats/popular` - Popular tools
|
||||
- `GET /api/v1/publishers/{slug}/stats` - Publisher stats
|
||||
- `GET /api/v1/index.json` - Full index for offline use
|
||||
|
||||
**Authenticated (dashboard):**
|
||||
- `POST /api/v1/login` - User login (returns session)
|
||||
@@ -1288,13 +1344,43 @@ The web UI consumes these existing API endpoints:
|
||||
- `POST /api/v1/tokens` - Create new token
|
||||
- `DELETE /api/v1/tokens/{id}` - Revoke token
|
||||
- `PUT /api/v1/me/settings` - Update profile
|
||||
- `POST /api/v1/me/password` - Change password
|
||||
|
||||
**New endpoints for web UI:**
|
||||
**App Pairing (replaces manual token entry):**
|
||||
- `POST /api/v1/pairing/initiate` - Start pairing flow
|
||||
- `GET /api/v1/pairing/check/{username}` - Check pairing status
|
||||
- `GET /api/v1/connected-apps` - List connected apps
|
||||
- `DELETE /api/v1/connected-apps/{app_id}` - Remove app
|
||||
|
||||
**Reviews & Issues:**
|
||||
- `POST /api/v1/tools/{owner}/{name}/reviews` - Submit review
|
||||
- `PUT /api/v1/reviews/{id}` - Edit review
|
||||
- `DELETE /api/v1/reviews/{id}` - Delete review
|
||||
- `POST /api/v1/reviews/{id}/vote` - Vote on review
|
||||
- `POST /api/v1/reviews/{id}/flag` - Flag review
|
||||
- `POST /api/v1/tools/{owner}/{name}/issues` - Report issue
|
||||
- `PUT /api/v1/issues/{id}` - Update issue
|
||||
- `POST /api/v1/issues/{id}/resolve` - Resolve issue
|
||||
|
||||
**Content & Featured:**
|
||||
- `GET /api/v1/featured/tools` - Curated featured tools
|
||||
- `GET /api/v1/featured/contributors` - Featured contributor
|
||||
- `GET /api/v1/content/announcements` - Site announcements
|
||||
- `POST /api/v1/reports` - Abuse report submission
|
||||
|
||||
**Admin Endpoints (moderator/admin role required):**
|
||||
- `GET /api/v1/admin/tools/pending` - Pending moderation queue
|
||||
- `POST /api/v1/admin/tools/{id}/approve` - Approve tool
|
||||
- `POST /api/v1/admin/tools/{id}/reject` - Reject tool
|
||||
- `POST /api/v1/admin/tools/{id}/request-changes` - Request changes
|
||||
- `GET /api/v1/admin/publishers` - Publisher management
|
||||
- `POST /api/v1/admin/publishers/{id}/ban` - Ban publisher
|
||||
- `GET /api/v1/admin/reports` - Report queue
|
||||
- `GET /api/v1/admin/scrutiny` - Scrutiny dashboard
|
||||
- `GET /api/v1/admin/audit-log` - Audit log
|
||||
- `GET /api/v1/admin/settings` - Registry settings
|
||||
- `PUT /api/v1/admin/settings/{key}` - Update setting
|
||||
|
||||
## Diagram References
|
||||
|
||||
- Landing page mockup: `discussions/diagrams/cmdforge-registry_rob_6.svg`
|
||||
|
||||
+415
-18
@@ -58,6 +58,106 @@ DEFAULT_STATE_FILE = DEFAULT_SYNC_DIR / "sync_state.json"
|
||||
DEFAULT_PROVIDER = "opencode-pickle"
|
||||
|
||||
|
||||
def run_ai_scrutiny_review(scrutiny_report: dict, config: dict, tool_name: str, description: str):
|
||||
"""Run AI-powered secondary review of scrutiny warnings.
|
||||
|
||||
Uses the scrutiny-ai-review tool to analyze warnings and identify false positives.
|
||||
|
||||
Args:
|
||||
scrutiny_report: The scrutiny report with findings
|
||||
config: The tool configuration dict
|
||||
tool_name: Name of the tool being reviewed
|
||||
description: Tool description
|
||||
|
||||
Returns:
|
||||
AI review result dict, or None if review fails
|
||||
"""
|
||||
# Check if the AI review tool exists
|
||||
tool_path = Path.home() / ".cmdforge" / "scrutiny-ai-review" / "config.yaml"
|
||||
if not tool_path.exists():
|
||||
logger.debug(f" AI review skipped for {tool_name}: scrutiny-ai-review tool not found at {tool_path}")
|
||||
return None
|
||||
|
||||
# Extract warnings from scrutiny report
|
||||
warnings = [
|
||||
f for f in scrutiny_report.get("findings", [])
|
||||
if f.get("result") == "warning"
|
||||
]
|
||||
|
||||
if not warnings:
|
||||
logger.debug(f" AI review skipped for {tool_name}: no warnings to review")
|
||||
return None
|
||||
|
||||
# Prepare tool config for review
|
||||
tool_config = {
|
||||
"name": tool_name,
|
||||
"description": description or "",
|
||||
"steps": config.get("steps", []),
|
||||
"arguments": config.get("arguments", []),
|
||||
}
|
||||
|
||||
# Find cmdforge executable - try venv first, then PATH
|
||||
cmdforge_paths = [
|
||||
Path(sys.executable).parent / "cmdforge", # Same venv as current Python
|
||||
Path("/srv/mergerfs/data_pool/home/rob/cmdforge-registry/venv/bin/cmdforge"), # Server venv
|
||||
"cmdforge", # PATH
|
||||
]
|
||||
|
||||
cmdforge_exe = None
|
||||
for p in cmdforge_paths:
|
||||
p = Path(p) if not isinstance(p, Path) else p
|
||||
if p.exists() if isinstance(p, Path) and p.is_absolute() else True:
|
||||
cmdforge_exe = str(p)
|
||||
break
|
||||
|
||||
if not cmdforge_exe:
|
||||
logger.warning(f" AI review skipped for {tool_name}: cmdforge executable not found")
|
||||
return None
|
||||
|
||||
# Run the tool (use -- to separate cmdforge args from tool args)
|
||||
logger.debug(f" Running AI review for {tool_name} ({len(warnings)} warnings)...")
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
cmdforge_exe, "run", "scrutiny-ai-review", "--",
|
||||
"--warnings", json.dumps(warnings),
|
||||
"--tool-config", json.dumps(tool_config),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60, # 60 second timeout
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
logger.debug(f" AI review completed for {tool_name} in {elapsed:.1f}s")
|
||||
return json.loads(result.stdout.strip())
|
||||
else:
|
||||
logger.warning(f" AI review failed for {tool_name}: returncode={result.returncode}, elapsed={elapsed:.1f}s")
|
||||
if result.stderr:
|
||||
logger.warning(f" stderr: {result.stderr[:200]}")
|
||||
return None
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
elapsed = time.time() - start_time
|
||||
logger.warning(f" AI review TIMEOUT for {tool_name} after {elapsed:.1f}s")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
elapsed = time.time() - start_time
|
||||
logger.warning(f" AI review JSON parse error for {tool_name}: {e}")
|
||||
return None
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f" AI review failed for {tool_name}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
elapsed = time.time() - start_time
|
||||
logger.warning(f" AI review unexpected error for {tool_name} after {elapsed:.1f}s: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatternState:
|
||||
"""State of a single pattern."""
|
||||
@@ -241,22 +341,286 @@ 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,
|
||||
skip_ai_review: bool = False
|
||||
) -> tuple[bool, str, dict]:
|
||||
"""Publish a tool directly to the registry database with vetting.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
config_yaml: Tool configuration as YAML string
|
||||
readme: README content
|
||||
provider: AI provider name
|
||||
auto_approve: If True, auto-approve tools that pass scrutiny
|
||||
skip_ai_review: If True, skip AI secondary review (for large bulk imports)
|
||||
|
||||
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}")
|
||||
|
||||
# Run AI secondary review if there are warnings (unless skipped)
|
||||
scrutiny_decision = scrutiny_report.get("decision", "review")
|
||||
if scrutiny_decision == "review" and skip_ai_review:
|
||||
logger.info(f" AI review skipped for {name} (--skip-ai-review flag)")
|
||||
scrutiny_report["ai_review_skipped"] = True
|
||||
elif scrutiny_decision == "review":
|
||||
try:
|
||||
ai_review = run_ai_scrutiny_review(scrutiny_report, config, name, description)
|
||||
if ai_review:
|
||||
scrutiny_report["ai_review"] = ai_review
|
||||
# Update decision based on AI review
|
||||
if ai_review.get("overall_verdict") == "APPROVE" and ai_review.get("confidence", 0) >= 0.8:
|
||||
scrutiny_report["decision"] = "approve"
|
||||
scrutiny_report["ai_approved"] = True
|
||||
scrutiny_decision = "approve"
|
||||
logger.info(f" AI review approved {name} (confidence: {ai_review.get('confidence', 0):.2f})")
|
||||
except Exception as e:
|
||||
scrutiny_report["ai_review_error"] = str(e)
|
||||
logger.warning(f"AI review failed for {name}: {e}")
|
||||
|
||||
# Check scrutiny decision
|
||||
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,
|
||||
skip_ai_review: 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
|
||||
skip_ai_review: If True, skip AI secondary review (for large bulk imports)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
name = pattern_dir.name
|
||||
pattern_hash = hash_pattern(pattern_dir)
|
||||
|
||||
# Vet the pattern
|
||||
# Read pattern content
|
||||
system_md = pattern_dir / "system.md"
|
||||
if not system_md.exists():
|
||||
logger.warning(f" ✗ {name}: No system.md found")
|
||||
return False
|
||||
|
||||
system_prompt = system_md.read_text()
|
||||
|
||||
# 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 create_tool_config, pattern_to_display_name
|
||||
|
||||
# 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, skip_ai_review)
|
||||
|
||||
if success:
|
||||
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.warning(f" ✗ {name}: {message}")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "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:
|
||||
@@ -270,15 +634,7 @@ def sync_pattern(
|
||||
}
|
||||
return False
|
||||
|
||||
if dry_run:
|
||||
logger.info(f" [DRY RUN] Would sync: {name} ({reason})")
|
||||
return True
|
||||
|
||||
# Import the pattern
|
||||
try:
|
||||
script_dir = Path(__file__).parent
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
from import_fabric import import_pattern
|
||||
|
||||
success = import_pattern(
|
||||
@@ -291,7 +647,7 @@ def sync_pattern(
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f" ✓ {name}: {reason}")
|
||||
logger.info(f" ✓ {name} -> {output_dir}/{name}")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
@@ -328,10 +684,24 @@ 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,
|
||||
skip_ai_review: 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
|
||||
skip_ai_review: If True, skip AI secondary review (for large bulk imports)
|
||||
|
||||
Returns:
|
||||
Summary dict with counts
|
||||
"""
|
||||
@@ -365,10 +735,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, skip_ai_review):
|
||||
synced += 1
|
||||
else:
|
||||
failed += 1
|
||||
@@ -438,14 +809,19 @@ def daemon_loop(
|
||||
output_dir: Path,
|
||||
state_file: Path,
|
||||
provider: str,
|
||||
interval: int
|
||||
interval: int,
|
||||
publish_to_db: bool = True,
|
||||
auto_approve: bool = False,
|
||||
skip_ai_review: 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,
|
||||
skip_ai_review=skip_ai_review)
|
||||
except Exception as e:
|
||||
logger.error(f"Sync failed: {e}")
|
||||
|
||||
@@ -514,6 +890,21 @@ 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)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-ai-review",
|
||||
action="store_true",
|
||||
help="Skip AI secondary review (for large bulk imports to avoid rate limiting)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -530,7 +921,10 @@ def main():
|
||||
args.output,
|
||||
state_file,
|
||||
args.provider,
|
||||
args.interval
|
||||
args.interval,
|
||||
publish_to_db=not args.local_only,
|
||||
auto_approve=args.auto_approve,
|
||||
skip_ai_review=args.skip_ai_review
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -541,7 +935,10 @@ 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,
|
||||
skip_ai_review=args.skip_ai_review
|
||||
)
|
||||
|
||||
if summary["failed"] > 0:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -11,6 +11,7 @@ from .tool_commands import (
|
||||
)
|
||||
from .provider_commands import cmd_providers
|
||||
from .registry_commands import cmd_registry
|
||||
from .collections_commands import cmd_collections
|
||||
from .project_commands import cmd_deps, cmd_install_deps, cmd_add, cmd_init
|
||||
from .config_commands import cmd_config
|
||||
|
||||
@@ -67,7 +68,7 @@ def main():
|
||||
p_run.add_argument("--dry-run", action="store_true", help="Show what would happen without executing")
|
||||
p_run.add_argument("--show-prompt", action="store_true", help="Show prompts in addition to output")
|
||||
p_run.add_argument("-v", "--verbose", action="store_true", help="Show debug information")
|
||||
p_run.add_argument("tool_args", nargs="*", help="Additional tool-specific arguments")
|
||||
p_run.add_argument("tool_args", nargs=argparse.REMAINDER, help="Additional tool-specific arguments (use -- to separate)")
|
||||
p_run.set_defaults(func=cmd_run)
|
||||
|
||||
# 'ui' command (explicit)
|
||||
@@ -186,6 +187,12 @@ def main():
|
||||
p_reg_mytools = registry_sub.add_parser("my-tools", help="List your published tools")
|
||||
p_reg_mytools.set_defaults(func=cmd_registry)
|
||||
|
||||
# registry status
|
||||
p_reg_status = registry_sub.add_parser("status", help="Check moderation status of a tool")
|
||||
p_reg_status.add_argument("tool", help="Tool name to check status for")
|
||||
p_reg_status.add_argument("--sync", action="store_true", help="Sync status from server and update local config")
|
||||
p_reg_status.set_defaults(func=cmd_registry)
|
||||
|
||||
# registry browse
|
||||
p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)")
|
||||
p_reg_browse.set_defaults(func=cmd_registry)
|
||||
@@ -203,6 +210,32 @@ def main():
|
||||
# Default for registry with no subcommand
|
||||
p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args)))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Collections Commands
|
||||
# -------------------------------------------------------------------------
|
||||
p_collections = subparsers.add_parser("collections", help="Manage tool collections")
|
||||
collections_sub = p_collections.add_subparsers(dest="collections_cmd", help="Collections commands")
|
||||
|
||||
# collections list
|
||||
p_coll_list = collections_sub.add_parser("list", help="List available collections")
|
||||
p_coll_list.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
p_coll_list.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections info
|
||||
p_coll_info = collections_sub.add_parser("info", help="Show collection details")
|
||||
p_coll_info.add_argument("name", help="Collection name")
|
||||
p_coll_info.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
p_coll_info.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections install
|
||||
p_coll_install = collections_sub.add_parser("install", help="Install all tools in a collection")
|
||||
p_coll_install.add_argument("name", help="Collection name")
|
||||
p_coll_install.add_argument("--pinned", action="store_true", help="Use pinned versions from collection")
|
||||
p_coll_install.set_defaults(func=cmd_collections)
|
||||
|
||||
# Default for collections with no subcommand
|
||||
p_collections.set_defaults(func=lambda args: cmd_collections(args) if args.collections_cmd else (setattr(args, 'collections_cmd', None) or cmd_collections(args)))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Project Commands
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -255,6 +288,10 @@ def main():
|
||||
p_cfg_connect.add_argument("username", help="Your CmdForge username")
|
||||
p_cfg_connect.set_defaults(func=cmd_config)
|
||||
|
||||
# config disconnect
|
||||
p_cfg_disconnect = config_sub.add_parser("disconnect", help="Disconnect from registry (clear token)")
|
||||
p_cfg_disconnect.set_defaults(func=cmd_config)
|
||||
|
||||
# Default for config with no subcommand
|
||||
p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(args)))
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Collections commands for CmdForge CLI."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from ..resolver import install_from_registry
|
||||
|
||||
|
||||
def cmd_collections(args):
|
||||
"""Handle collections subcommands."""
|
||||
if args.collections_cmd == "list":
|
||||
return _cmd_collections_list(args)
|
||||
elif args.collections_cmd == "info":
|
||||
return _cmd_collections_info(args)
|
||||
elif args.collections_cmd == "install":
|
||||
return _cmd_collections_install(args)
|
||||
else:
|
||||
# Default: show help
|
||||
print("Collections commands:")
|
||||
print(" list List available collections")
|
||||
print(" info <name> Show collection details")
|
||||
print(" install <name> Install all tools in a collection")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_collections_list(args):
|
||||
"""List available collections."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
collections = client.get_collections()
|
||||
|
||||
# JSON output
|
||||
if getattr(args, 'json', False):
|
||||
print(json.dumps({"collections": collections}, indent=2))
|
||||
return 0
|
||||
|
||||
if not collections:
|
||||
print("No collections available.")
|
||||
return 0
|
||||
|
||||
print(f"Available collections ({len(collections)}):\n")
|
||||
for coll in collections:
|
||||
name = coll.get("name", "")
|
||||
display_name = coll.get("display_name", name)
|
||||
description = coll.get("description", "")
|
||||
tools = coll.get("tools", [])
|
||||
tool_count = len(tools) if isinstance(tools, list) else 0
|
||||
|
||||
print(f" {name}")
|
||||
print(f" {display_name}")
|
||||
if description:
|
||||
desc_short = description[:60] + ('...' if len(description) > 60 else '')
|
||||
print(f" {desc_short}")
|
||||
print(f" Tools: {tool_count}")
|
||||
print()
|
||||
|
||||
print("Install a collection with: cmdforge collections install <name>")
|
||||
|
||||
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_collections_info(args):
|
||||
"""Show collection details."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
name = args.name
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
coll = client.get_collection(name)
|
||||
|
||||
# JSON output
|
||||
if getattr(args, 'json', False):
|
||||
print(json.dumps(coll, indent=2))
|
||||
return 0
|
||||
|
||||
display_name = coll.get("display_name", name)
|
||||
description = coll.get("description", "")
|
||||
maintainer = coll.get("maintainer", "")
|
||||
tools = coll.get("tools", [])
|
||||
pinned = coll.get("pinned", {})
|
||||
tags = coll.get("tags", [])
|
||||
|
||||
print(f"{display_name}")
|
||||
print("=" * 50)
|
||||
if description:
|
||||
print(f"\n{description}\n")
|
||||
if maintainer:
|
||||
print(f"Maintainer: {maintainer}")
|
||||
if tags:
|
||||
print(f"Tags: {', '.join(tags)}")
|
||||
|
||||
print(f"\nTools ({len(tools)}):")
|
||||
for tool_ref in tools:
|
||||
version_constraint = pinned.get(tool_ref, "")
|
||||
if version_constraint:
|
||||
print(f" - {tool_ref} @ {version_constraint}")
|
||||
else:
|
||||
print(f" - {tool_ref}")
|
||||
|
||||
print(f"\nInstall all: cmdforge collections install {name}")
|
||||
|
||||
except RegistryError as e:
|
||||
if e.code == "COLLECTION_NOT_FOUND":
|
||||
print(f"Collection '{name}' not found.", file=sys.stderr)
|
||||
print("List available collections: cmdforge collections list", 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 _cmd_collections_install(args):
|
||||
"""Install all tools in a collection."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
name = args.name
|
||||
use_pinned = getattr(args, 'pinned', False)
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
coll = client.get_collection(name)
|
||||
|
||||
tools = coll.get("tools", [])
|
||||
pinned = coll.get("pinned", {}) if use_pinned else {}
|
||||
display_name = coll.get("display_name", name)
|
||||
|
||||
if not tools:
|
||||
print(f"Collection '{name}' has no tools.")
|
||||
return 0
|
||||
|
||||
print(f"Installing collection: {display_name}")
|
||||
print(f"Tools to install: {len(tools)}")
|
||||
if use_pinned and pinned:
|
||||
print(f"Using pinned versions: {len(pinned)} tools")
|
||||
print()
|
||||
|
||||
installed = 0
|
||||
failed = 0
|
||||
|
||||
for tool_ref in tools:
|
||||
version = pinned.get(tool_ref)
|
||||
try:
|
||||
print(f" Installing {tool_ref}...", end=" ", flush=True)
|
||||
resolved = install_from_registry(tool_ref, version)
|
||||
print(f"v{resolved.version}")
|
||||
installed += 1
|
||||
except RegistryError as e:
|
||||
print(f"FAILED: {e.message}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
failed += 1
|
||||
|
||||
print()
|
||||
print(f"Installed: {installed}/{len(tools)}")
|
||||
if failed:
|
||||
print(f"Failed: {failed}")
|
||||
return 1
|
||||
|
||||
print(f"\nCollection '{name}' installed successfully!")
|
||||
|
||||
except RegistryError as e:
|
||||
if e.code == "COLLECTION_NOT_FOUND":
|
||||
print(f"Collection '{name}' not found.", file=sys.stderr)
|
||||
print("List available collections: cmdforge collections list", 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
|
||||
@@ -17,10 +17,13 @@ def cmd_config(args):
|
||||
return _cmd_config_set(args)
|
||||
elif args.config_cmd == "connect":
|
||||
return _cmd_config_connect(args)
|
||||
elif args.config_cmd == "disconnect":
|
||||
return _cmd_config_disconnect(args)
|
||||
else:
|
||||
print("Config commands:")
|
||||
print(" show Show current configuration")
|
||||
print(" connect <username> Connect this app to your CmdForge account")
|
||||
print(" disconnect Disconnect from registry (clear token)")
|
||||
print(" set-token <token> Set registry authentication token")
|
||||
print(" set <key> <value> Set a configuration value")
|
||||
return 0
|
||||
@@ -165,3 +168,18 @@ def _cmd_config_connect(args):
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nConnection cancelled.")
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_config_disconnect(args):
|
||||
"""Disconnect from registry by clearing the token."""
|
||||
config = load_config()
|
||||
|
||||
if not config.registry.token:
|
||||
print("Not connected to registry (no token set).")
|
||||
return 0
|
||||
|
||||
# Clear the token
|
||||
set_registry_token(None)
|
||||
print("Disconnected from registry.")
|
||||
print("Token has been cleared from local configuration.")
|
||||
return 0
|
||||
|
||||
@@ -30,6 +30,8 @@ def cmd_registry(args):
|
||||
return _cmd_registry_publish(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 == "config":
|
||||
@@ -45,7 +47,8 @@ def cmd_registry(args):
|
||||
print(" update Update local index cache")
|
||||
print(" publish [path] Publish a tool")
|
||||
print(" my-tools List your published tools")
|
||||
print(" browse Browse tools (TUI)")
|
||||
print(" status <tool> Check moderation status of a tool")
|
||||
print(" browse Browse tools (GUI)")
|
||||
print(" config [action] Manage registry settings (admin)")
|
||||
return 0
|
||||
|
||||
@@ -437,6 +440,21 @@ def _cmd_registry_publish(args):
|
||||
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
|
||||
# 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)
|
||||
@@ -503,6 +521,118 @@ def _cmd_registry_my_tools(args):
|
||||
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
|
||||
|
||||
tool_name = args.tool
|
||||
do_sync = getattr(args, 'sync', False)
|
||||
|
||||
# Check if tool exists locally
|
||||
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||
if not config_path.exists():
|
||||
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
|
||||
if do_sync:
|
||||
try:
|
||||
client = get_client()
|
||||
status_data = client.get_my_tool_status(tool_name)
|
||||
|
||||
new_status = status_data.get("status", "pending")
|
||||
new_hash = status_data.get("config_hash")
|
||||
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_hash and local_hash != new_hash:
|
||||
config_data["registry_hash"] = new_hash
|
||||
local_hash = new_hash
|
||||
changed = True
|
||||
if new_feedback != local_feedback:
|
||||
if new_feedback:
|
||||
config_data["registry_feedback"] = new_feedback
|
||||
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)
|
||||
elif e.code == "TOOL_NOT_FOUND":
|
||||
print(f"Tool '{tool_name}' not found in registry.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error syncing: {e.message}", file=sys.stderr)
|
||||
return 1
|
||||
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_browse(args):
|
||||
"""Browse tools (GUI)."""
|
||||
from ..gui import run_gui
|
||||
|
||||
@@ -202,26 +202,48 @@ def cmd_run(args):
|
||||
|
||||
# Collect custom args from remaining arguments
|
||||
custom_args = {}
|
||||
|
||||
# Build a map from flag names to variable names for proper argument mapping
|
||||
flag_to_var = {}
|
||||
for tool_arg in tool.arguments:
|
||||
# Handle both --flag and -f style flags
|
||||
flag = tool_arg.flag
|
||||
if flag.startswith('--'):
|
||||
flag_key = flag[2:].replace('-', '_')
|
||||
elif flag.startswith('-'):
|
||||
flag_key = flag[1:].replace('-', '_')
|
||||
else:
|
||||
flag_key = flag.replace('-', '_')
|
||||
flag_to_var[flag_key] = tool_arg.variable
|
||||
|
||||
if args.tool_args:
|
||||
# Remove leading '--' separator if present (used to separate cmdforge args from tool args)
|
||||
tool_args = list(args.tool_args)
|
||||
if tool_args and tool_args[0] == '--':
|
||||
tool_args = tool_args[1:]
|
||||
|
||||
# Parse tool-specific arguments
|
||||
i = 0
|
||||
while i < len(args.tool_args):
|
||||
arg = args.tool_args[i]
|
||||
while i < len(tool_args):
|
||||
arg = tool_args[i]
|
||||
if arg.startswith('--'):
|
||||
key = arg[2:].replace('-', '_')
|
||||
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('--'):
|
||||
custom_args[key] = args.tool_args[i + 1]
|
||||
flag_key = arg[2:].replace('-', '_')
|
||||
# Map flag name to variable name (use flag_key as fallback for unknown flags)
|
||||
var_name = flag_to_var.get(flag_key, flag_key)
|
||||
if i + 1 < len(tool_args) and not tool_args[i + 1].startswith('--'):
|
||||
custom_args[var_name] = tool_args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
custom_args[key] = True
|
||||
custom_args[var_name] = True
|
||||
i += 1
|
||||
elif arg.startswith('-'):
|
||||
key = arg[1:].replace('-', '_')
|
||||
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('-'):
|
||||
custom_args[key] = args.tool_args[i + 1]
|
||||
flag_key = arg[1:].replace('-', '_')
|
||||
var_name = flag_to_var.get(flag_key, flag_key)
|
||||
if i + 1 < len(tool_args) and not tool_args[i + 1].startswith('-'):
|
||||
custom_args[var_name] = tool_args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
custom_args[key] = True
|
||||
custom_args[var_name] = True
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Publish tool dialog."""
|
||||
|
||||
from typing import Optional, List
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLabel, QPushButton,
|
||||
QHBoxLayout, QLineEdit, QTextEdit, QFormLayout,
|
||||
QProgressBar, QMessageBox, QComboBox
|
||||
QProgressBar, QMessageBox, QComboBox, QFrame, QWidget
|
||||
)
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
@@ -12,14 +13,35 @@ from ...registry_client import RegistryClient, RegistryError
|
||||
from ...config import load_config
|
||||
|
||||
|
||||
class PublishWorker(QThread):
|
||||
"""Background worker for publishing."""
|
||||
success = Signal(dict)
|
||||
def parse_version(version: str) -> tuple:
|
||||
"""Parse version string into tuple of ints."""
|
||||
try:
|
||||
parts = version.split(".")
|
||||
return tuple(int(p) for p in parts[:3])
|
||||
except (ValueError, AttributeError):
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def bump_version(version: str, bump_type: str) -> str:
|
||||
"""Bump version by type (patch, minor, major)."""
|
||||
major, minor, patch = parse_version(version)
|
||||
if bump_type == "major":
|
||||
return f"{major + 1}.0.0"
|
||||
elif bump_type == "minor":
|
||||
return f"{major}.{minor + 1}.0"
|
||||
else: # patch
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
|
||||
|
||||
class VersionFetchWorker(QThread):
|
||||
"""Background worker to fetch registry version info."""
|
||||
finished = Signal(dict) # Dict with 'my_version' and 'original_versions'
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, tool_data: dict):
|
||||
def __init__(self, name: str, original_owner: Optional[str] = None):
|
||||
super().__init__()
|
||||
self.tool_data = tool_data
|
||||
self.name = name
|
||||
self.original_owner = original_owner
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
@@ -27,7 +49,53 @@ class PublishWorker(QThread):
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
result = client.publish_tool(self.tool_data)
|
||||
result = {
|
||||
"my_version": None,
|
||||
"my_status": None,
|
||||
"original_versions": [],
|
||||
"original_owner": self.original_owner,
|
||||
}
|
||||
|
||||
# Get my published version of this tool
|
||||
if config.registry.token:
|
||||
try:
|
||||
status = client.get_my_tool_status(self.name)
|
||||
result["my_version"] = status.get("version")
|
||||
result["my_status"] = status.get("status")
|
||||
except RegistryError:
|
||||
pass # Tool not published by me yet
|
||||
|
||||
# If this was forked from another owner, get their versions too
|
||||
if self.original_owner:
|
||||
try:
|
||||
result["original_versions"] = client.get_tool_versions(
|
||||
self.original_owner, self.name
|
||||
)
|
||||
except RegistryError:
|
||||
pass
|
||||
|
||||
self.finished.emit(result)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class PublishWorker(QThread):
|
||||
"""Background worker for publishing."""
|
||||
success = Signal(dict)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, config_yaml: str, readme: str = ""):
|
||||
super().__init__()
|
||||
self.config_yaml = config_yaml
|
||||
self.readme = readme
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
result = client.publish_tool(self.config_yaml, self.readme)
|
||||
self.success.emit(result)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
@@ -39,10 +107,114 @@ class PublishDialog(QDialog):
|
||||
def __init__(self, parent, tool: Tool):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Publish Tool")
|
||||
self.setMinimumSize(500, 400)
|
||||
self.setMinimumSize(500, 450)
|
||||
self._tool = tool
|
||||
self._worker = None
|
||||
self._version_worker = None
|
||||
self._my_registry_version: Optional[str] = None
|
||||
self._my_registry_status: Optional[str] = None
|
||||
self._original_versions: List[str] = []
|
||||
self._local_version = self._get_local_version()
|
||||
self._original_owner = self._get_original_owner()
|
||||
self._setup_ui()
|
||||
self._fetch_registry_versions()
|
||||
|
||||
def _get_local_version(self) -> str:
|
||||
"""Get the version from local tool config."""
|
||||
if hasattr(self._tool, 'path') and self._tool.path:
|
||||
try:
|
||||
import yaml
|
||||
config = yaml.safe_load(self._tool.path.read_text())
|
||||
return config.get("version", "1.0.0")
|
||||
except Exception:
|
||||
pass
|
||||
return "1.0.0"
|
||||
|
||||
def _get_original_owner(self) -> Optional[str]:
|
||||
"""Get the original owner if this tool was installed from registry."""
|
||||
if hasattr(self._tool, 'path') and self._tool.path:
|
||||
try:
|
||||
import yaml
|
||||
config = yaml.safe_load(self._tool.path.read_text())
|
||||
# Check if it has forked_from or was installed from registry
|
||||
if config.get("forked_from"):
|
||||
return config["forked_from"].split("/")[0]
|
||||
if config.get("installed_from"):
|
||||
return config["installed_from"].split("/")[0]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _fetch_registry_versions(self):
|
||||
"""Fetch versions from registry in background."""
|
||||
self._version_worker = VersionFetchWorker(self._tool.name, self._original_owner)
|
||||
self._version_worker.finished.connect(self._on_versions_fetched)
|
||||
self._version_worker.error.connect(self._on_versions_error)
|
||||
self._version_worker.start()
|
||||
|
||||
def _on_versions_fetched(self, result: dict):
|
||||
"""Handle registry versions response."""
|
||||
self._my_registry_version = result.get("my_version")
|
||||
self._my_registry_status = result.get("my_status")
|
||||
self._original_versions = result.get("original_versions", [])
|
||||
self._update_version_info()
|
||||
|
||||
def _on_versions_error(self, error: str):
|
||||
"""Handle version fetch error (non-critical)."""
|
||||
self._my_registry_version = None
|
||||
self._original_versions = []
|
||||
self._update_version_info()
|
||||
|
||||
def _update_version_info(self):
|
||||
"""Update version info display."""
|
||||
if self._my_registry_version:
|
||||
# Already published by me - show my latest version
|
||||
self.registry_version_label.setText(
|
||||
f"Registry: v{self._my_registry_version} ({self._my_registry_status or 'unknown'})"
|
||||
)
|
||||
if self._my_registry_status == "approved":
|
||||
self.registry_version_label.setStyleSheet("color: #38a169;") # Green
|
||||
elif self._my_registry_status == "pending":
|
||||
self.registry_version_label.setStyleSheet("color: #d69e2e;") # Yellow
|
||||
else:
|
||||
self.registry_version_label.setStyleSheet("color: #4299e1;") # Blue
|
||||
# Suggest next version
|
||||
suggested = bump_version(self._my_registry_version, "patch")
|
||||
self.version_input.setText(suggested)
|
||||
self._show_bump_buttons(True)
|
||||
elif self._original_versions:
|
||||
# This is a fork - show original tool info
|
||||
latest_original = self._original_versions[0]
|
||||
self.registry_version_label.setText(
|
||||
f"Fork of {self._original_owner}/{self._tool.name} v{latest_original}"
|
||||
)
|
||||
self.registry_version_label.setStyleSheet("color: #805ad5;") # Purple for fork
|
||||
self.version_input.setText("1.0.0") # Start fresh for forks
|
||||
self._show_bump_buttons(False)
|
||||
else:
|
||||
self.registry_version_label.setText("Registry: not published")
|
||||
self.registry_version_label.setStyleSheet("color: #718096;")
|
||||
self.version_input.setText(self._local_version)
|
||||
self._show_bump_buttons(False)
|
||||
|
||||
def _show_bump_buttons(self, show: bool):
|
||||
"""Show or hide version bump buttons."""
|
||||
self.bump_widget.setVisible(show)
|
||||
|
||||
def _bump_patch(self):
|
||||
"""Bump patch version."""
|
||||
if self._my_registry_version:
|
||||
self.version_input.setText(bump_version(self._my_registry_version, "patch"))
|
||||
|
||||
def _bump_minor(self):
|
||||
"""Bump minor version."""
|
||||
if self._my_registry_version:
|
||||
self.version_input.setText(bump_version(self._my_registry_version, "minor"))
|
||||
|
||||
def _bump_major(self):
|
||||
"""Bump major version."""
|
||||
if self._my_registry_version:
|
||||
self.version_input.setText(bump_version(self._my_registry_version, "major"))
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
@@ -54,15 +226,76 @@ class PublishDialog(QDialog):
|
||||
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Version info section
|
||||
version_frame = QFrame()
|
||||
version_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #f7fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
version_info_layout = QHBoxLayout(version_frame)
|
||||
version_info_layout.setContentsMargins(12, 8, 12, 8)
|
||||
|
||||
self.local_version_label = QLabel(f"Local: v{self._local_version}")
|
||||
self.local_version_label.setStyleSheet("color: #2d3748; font-weight: 500;")
|
||||
version_info_layout.addWidget(self.local_version_label)
|
||||
|
||||
version_info_layout.addSpacing(20)
|
||||
|
||||
self.registry_version_label = QLabel("Registry: checking...")
|
||||
self.registry_version_label.setStyleSheet("color: #718096;")
|
||||
version_info_layout.addWidget(self.registry_version_label)
|
||||
|
||||
version_info_layout.addStretch()
|
||||
layout.addWidget(version_frame)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
# Version
|
||||
# Version input with bump buttons
|
||||
version_row = QHBoxLayout()
|
||||
self.version_input = QLineEdit()
|
||||
self.version_input.setText("1.0.0")
|
||||
self.version_input.setText(self._local_version)
|
||||
self.version_input.setPlaceholderText("1.0.0")
|
||||
form.addRow("Version:", self.version_input)
|
||||
self.version_input.setMaximumWidth(100)
|
||||
version_row.addWidget(self.version_input)
|
||||
|
||||
# Bump buttons (hidden until we know registry version)
|
||||
self.bump_widget = QWidget()
|
||||
bump_layout = QHBoxLayout(self.bump_widget)
|
||||
bump_layout.setContentsMargins(8, 0, 0, 0)
|
||||
bump_layout.setSpacing(4)
|
||||
|
||||
btn_patch = QPushButton("+Patch")
|
||||
btn_patch.setObjectName("secondary")
|
||||
btn_patch.setFixedWidth(60)
|
||||
btn_patch.setToolTip("Bump patch version (bug fixes)")
|
||||
btn_patch.clicked.connect(self._bump_patch)
|
||||
bump_layout.addWidget(btn_patch)
|
||||
|
||||
btn_minor = QPushButton("+Minor")
|
||||
btn_minor.setObjectName("secondary")
|
||||
btn_minor.setFixedWidth(60)
|
||||
btn_minor.setToolTip("Bump minor version (new features)")
|
||||
btn_minor.clicked.connect(self._bump_minor)
|
||||
bump_layout.addWidget(btn_minor)
|
||||
|
||||
btn_major = QPushButton("+Major")
|
||||
btn_major.setObjectName("secondary")
|
||||
btn_major.setFixedWidth(60)
|
||||
btn_major.setToolTip("Bump major version (breaking changes)")
|
||||
btn_major.clicked.connect(self._bump_major)
|
||||
bump_layout.addWidget(btn_major)
|
||||
|
||||
self.bump_widget.setVisible(False)
|
||||
version_row.addWidget(self.bump_widget)
|
||||
|
||||
version_row.addStretch()
|
||||
form.addRow("Version:", version_row)
|
||||
|
||||
# Category
|
||||
self.category_combo = QComboBox()
|
||||
@@ -127,6 +360,8 @@ class PublishDialog(QDialog):
|
||||
|
||||
def _publish(self):
|
||||
"""Publish the tool."""
|
||||
import yaml
|
||||
|
||||
version = self.version_input.text().strip()
|
||||
if not version:
|
||||
QMessageBox.warning(self, "Validation", "Version is required")
|
||||
@@ -140,37 +375,85 @@ class PublishDialog(QDialog):
|
||||
category = self.category_combo.currentText()
|
||||
tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()]
|
||||
|
||||
# Build tool data for publishing
|
||||
tool_data = {
|
||||
"name": self._tool.name,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"definition": self._tool.to_dict() if hasattr(self._tool, 'to_dict') else {}
|
||||
}
|
||||
# Build complete tool config from the Tool object
|
||||
tool_config = self._tool.to_dict() if hasattr(self._tool, 'to_dict') else {}
|
||||
|
||||
# Update with publish dialog values
|
||||
tool_config["name"] = self._tool.name
|
||||
tool_config["version"] = version
|
||||
tool_config["description"] = description
|
||||
tool_config["category"] = category
|
||||
if tags:
|
||||
tool_config["tags"] = tags
|
||||
|
||||
# Add fork metadata if this tool originated from another owner
|
||||
if self._original_owner and self._original_versions:
|
||||
tool_config["forked_from"] = f"{self._original_owner}/{self._tool.name}"
|
||||
# Record which version we forked from (use local version or latest original)
|
||||
forked_version = self._local_version
|
||||
if forked_version in self._original_versions:
|
||||
tool_config["forked_version"] = forked_version
|
||||
else:
|
||||
# Use the latest original version as reference
|
||||
tool_config["forked_version"] = self._original_versions[0]
|
||||
|
||||
# Convert to YAML string
|
||||
config_yaml = yaml.dump(tool_config, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Get README if it exists
|
||||
readme = ""
|
||||
tool_dir = self._tool.path.parent if hasattr(self._tool, 'path') and self._tool.path else None
|
||||
if tool_dir:
|
||||
readme_path = tool_dir / "README.md"
|
||||
if readme_path.exists():
|
||||
readme = readme_path.read_text()
|
||||
|
||||
self.btn_publish.setEnabled(False)
|
||||
self.btn_cancel.setEnabled(False)
|
||||
self.progress.show()
|
||||
self.status_label.setText("Publishing...")
|
||||
|
||||
self._worker = PublishWorker(tool_data)
|
||||
# Store the config we're publishing so we can save it on success
|
||||
self._published_config = tool_config
|
||||
|
||||
self._worker = PublishWorker(config_yaml, readme)
|
||||
self._worker.success.connect(self._on_success)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_success(self, result: dict):
|
||||
"""Handle publish success."""
|
||||
import yaml
|
||||
|
||||
self.progress.hide()
|
||||
self.status_label.setText("Published successfully!")
|
||||
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
|
||||
|
||||
QMessageBox.information(
|
||||
self, "Success",
|
||||
f"Tool '{self._tool.name}' has been published.\n\n"
|
||||
f"It will be available after review."
|
||||
)
|
||||
# Save the published config with registry tracking fields
|
||||
# This ensures local file matches exactly what was sent to server (for hash consistency)
|
||||
config_hash = result.get("config_hash")
|
||||
moderation_status = result.get("status", "pending")
|
||||
if config_hash and hasattr(self._tool, 'path') and self._tool.path:
|
||||
try:
|
||||
config_path = self._tool.path
|
||||
if config_path.exists() and hasattr(self, '_published_config'):
|
||||
# Use the exact config we published, add registry tracking fields
|
||||
config_data = self._published_config.copy()
|
||||
config_data["registry_hash"] = config_hash
|
||||
config_data["registry_status"] = moderation_status
|
||||
# Clear any old feedback
|
||||
config_data.pop("registry_feedback", None)
|
||||
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
|
||||
except Exception:
|
||||
pass # Non-critical - just won't show publish state
|
||||
|
||||
status = result.get("status", "pending")
|
||||
if status == "approved":
|
||||
msg = f"Tool '{self._tool.name}' has been published and is now live!"
|
||||
else:
|
||||
msg = f"Tool '{self._tool.name}' has been published.\n\nIt will be available after review."
|
||||
|
||||
QMessageBox.information(self, "Success", msg)
|
||||
self.accept()
|
||||
|
||||
def _on_error(self, error: str):
|
||||
|
||||
@@ -5,7 +5,7 @@ from PySide6.QtWidgets import (
|
||||
QListWidget, QListWidgetItem, QStackedWidget,
|
||||
QStatusBar, QLabel, QSplitter
|
||||
)
|
||||
from PySide6.QtCore import Qt, QSize, QSettings
|
||||
from PySide6.QtCore import Qt, QSize, QSettings, QTimer
|
||||
from PySide6.QtGui import QIcon, QFont, QShortcut, QKeySequence
|
||||
|
||||
from .styles import STYLESHEET
|
||||
@@ -68,6 +68,33 @@ class MainWindow(QMainWindow):
|
||||
# Restore window geometry
|
||||
self._restore_geometry()
|
||||
|
||||
# Validate registry connection after window shows
|
||||
QTimer.singleShot(500, self._validate_registry_connection)
|
||||
|
||||
def _validate_registry_connection(self):
|
||||
"""Check if registry connection is valid on startup, clear if invalid."""
|
||||
try:
|
||||
from ..registry_client import RegistryClient
|
||||
from ..config import set_registry_token, load_config
|
||||
|
||||
config = load_config()
|
||||
if not config.registry.token:
|
||||
return # No connection configured, nothing to validate
|
||||
|
||||
client = RegistryClient()
|
||||
is_valid, error = client.validate_token()
|
||||
|
||||
if not is_valid:
|
||||
# Token is invalid, clear it
|
||||
set_registry_token(None)
|
||||
self.status_bar.showMessage(
|
||||
f"Registry connection cleared: {error}. Please reconnect.",
|
||||
10000 # Show for 10 seconds
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't crash on validation errors, just log to status
|
||||
self.status_bar.showMessage(f"Could not validate registry connection: {e}", 5000)
|
||||
|
||||
def _setup_sidebar(self):
|
||||
"""Set up sidebar navigation items."""
|
||||
items = [
|
||||
|
||||
@@ -57,9 +57,9 @@ class SearchWorker(QThread):
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class InstallWorker(QThread):
|
||||
"""Background worker for tool installation."""
|
||||
finished = Signal(str)
|
||||
class VersionsWorker(QThread):
|
||||
"""Background worker to fetch available versions."""
|
||||
finished = Signal(list) # List of version strings
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, owner: str, name: str):
|
||||
@@ -70,8 +70,29 @@ class InstallWorker(QThread):
|
||||
def run(self):
|
||||
try:
|
||||
client = RegistryClient()
|
||||
client.install_tool(self.owner, self.name)
|
||||
self.finished.emit(f"{self.owner}/{self.name}")
|
||||
versions = client.get_tool_versions(self.owner, self.name)
|
||||
self.finished.emit(versions)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class InstallWorker(QThread):
|
||||
"""Background worker for tool installation."""
|
||||
finished = Signal(str)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, owner: str, name: str, version: str = None):
|
||||
super().__init__()
|
||||
self.owner = owner
|
||||
self.name = name
|
||||
self.version = version
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
client = RegistryClient()
|
||||
client.install_tool(self.owner, self.name, version=self.version)
|
||||
version_str = f"@{self.version}" if self.version else ""
|
||||
self.finished.emit(f"{self.owner}/{self.name}{version_str}")
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
@@ -84,7 +105,9 @@ class RegistryPage(QWidget):
|
||||
self.main_window = main_window
|
||||
self._search_worker = None
|
||||
self._install_worker = None
|
||||
self._versions_worker = None
|
||||
self._selected_tool = None
|
||||
self._available_versions = []
|
||||
self._current_page = 1
|
||||
self._total_pages = 1
|
||||
self._current_tags = []
|
||||
@@ -228,6 +251,23 @@ class RegistryPage(QWidget):
|
||||
|
||||
right_layout.addWidget(details_box, 1)
|
||||
|
||||
# Version selector row
|
||||
version_row = QHBoxLayout()
|
||||
version_row.setSpacing(8)
|
||||
|
||||
version_label = QLabel("Version:")
|
||||
version_label.setStyleSheet("color: #4a5568;")
|
||||
version_row.addWidget(version_label)
|
||||
|
||||
self.version_combo = QComboBox()
|
||||
self.version_combo.setMinimumWidth(120)
|
||||
self.version_combo.addItem("Latest", None)
|
||||
self.version_combo.setEnabled(False)
|
||||
version_row.addWidget(self.version_combo)
|
||||
|
||||
version_row.addStretch()
|
||||
right_layout.addLayout(version_row)
|
||||
|
||||
# Action buttons
|
||||
actions = QHBoxLayout()
|
||||
|
||||
@@ -405,6 +445,9 @@ class RegistryPage(QWidget):
|
||||
self.details_text.clear()
|
||||
self.btn_install.setEnabled(False)
|
||||
self.btn_update.hide()
|
||||
self.version_combo.clear()
|
||||
self.version_combo.addItem("Latest", None)
|
||||
self.version_combo.setEnabled(False)
|
||||
return
|
||||
|
||||
row = items[0].row()
|
||||
@@ -413,11 +456,47 @@ class RegistryPage(QWidget):
|
||||
self._selected_tool = tool
|
||||
self._show_tool_details(tool)
|
||||
|
||||
# Reset and fetch versions
|
||||
self.version_combo.clear()
|
||||
self.version_combo.addItem("Loading...", None)
|
||||
self.version_combo.setEnabled(False)
|
||||
self._fetch_versions(tool.get("owner", ""), tool.get("name", ""))
|
||||
|
||||
# Check if installed / update available
|
||||
tool_name = tool.get("name", "")
|
||||
self._update_install_buttons()
|
||||
|
||||
def _fetch_versions(self, owner: str, name: str):
|
||||
"""Fetch available versions for a tool."""
|
||||
self._versions_worker = VersionsWorker(owner, name)
|
||||
self._versions_worker.finished.connect(self._on_versions_fetched)
|
||||
self._versions_worker.error.connect(self._on_versions_error)
|
||||
self._versions_worker.start()
|
||||
|
||||
def _on_versions_fetched(self, versions: list):
|
||||
"""Handle versions fetch completion."""
|
||||
self._available_versions = versions
|
||||
self.version_combo.clear()
|
||||
self.version_combo.addItem("Latest", None)
|
||||
for version in versions:
|
||||
self.version_combo.addItem(f"v{version}", version)
|
||||
self.version_combo.setEnabled(len(versions) > 1)
|
||||
|
||||
def _on_versions_error(self, error: str):
|
||||
"""Handle versions fetch error."""
|
||||
self._available_versions = []
|
||||
self.version_combo.clear()
|
||||
self.version_combo.addItem("Latest", None)
|
||||
self.version_combo.setEnabled(False)
|
||||
|
||||
def _update_install_buttons(self):
|
||||
"""Update install/update buttons based on selection."""
|
||||
if not self._selected_tool:
|
||||
return
|
||||
|
||||
tool_name = self._selected_tool.get("name", "")
|
||||
if tool_name in self._installed_tools:
|
||||
installed_version = self._installed_tools[tool_name]
|
||||
registry_version = tool.get("version", "1.0.0")
|
||||
registry_version = self._selected_tool.get("version", "1.0.0")
|
||||
if installed_version != registry_version:
|
||||
self.btn_install.hide()
|
||||
self.btn_update.show()
|
||||
@@ -546,12 +625,16 @@ class RegistryPage(QWidget):
|
||||
|
||||
owner = self._selected_tool.get("owner", "")
|
||||
name = self._selected_tool.get("name", "")
|
||||
version = self.version_combo.currentData() # None for "Latest"
|
||||
|
||||
self.btn_install.setEnabled(False)
|
||||
self.btn_update.setEnabled(False)
|
||||
self.status_label.setText(f"Installing {owner}/{name}...")
|
||||
self.version_combo.setEnabled(False)
|
||||
|
||||
self._install_worker = InstallWorker(owner, name)
|
||||
version_str = f"@{version}" if version else ""
|
||||
self.status_label.setText(f"Installing {owner}/{name}{version_str}...")
|
||||
|
||||
self._install_worker = InstallWorker(owner, name, version)
|
||||
self._install_worker.finished.connect(self._on_install_complete)
|
||||
self._install_worker.error.connect(self._on_install_error)
|
||||
self._install_worker.start()
|
||||
|
||||
@@ -13,6 +13,7 @@ from ...tool import (
|
||||
Tool, ToolArgument, PromptStep, CodeStep,
|
||||
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
||||
)
|
||||
from ..widgets.icons import get_prompt_icon, get_code_icon
|
||||
|
||||
|
||||
class ToolBuilderPage(QWidget):
|
||||
@@ -242,6 +243,7 @@ class ToolBuilderPage(QWidget):
|
||||
self._flow_widget.node_double_clicked.connect(self._on_flow_node_double_clicked)
|
||||
self._flow_widget.steps_deleted.connect(self._on_flow_steps_deleted)
|
||||
self._flow_widget.steps_reordered.connect(self._on_flow_steps_reordered)
|
||||
self._flow_widget.step_name_changed.connect(self._on_flow_step_name_changed)
|
||||
|
||||
# Replace placeholder
|
||||
old_widget = self.steps_stack.widget(1)
|
||||
@@ -283,6 +285,17 @@ class ToolBuilderPage(QWidget):
|
||||
# Refresh the list view (flow view will be refreshed by set_tool)
|
||||
self._refresh_steps()
|
||||
|
||||
def _on_flow_step_name_changed(self, step_index: int, new_name: str):
|
||||
"""Handle step name change from flow view inline editing."""
|
||||
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
|
||||
return
|
||||
|
||||
step = self._tool.steps[step_index]
|
||||
step.name = new_name
|
||||
|
||||
# Refresh list view to show updated name
|
||||
self._refresh_steps_list_only()
|
||||
|
||||
def _on_flow_steps_reordered(self, new_order: list):
|
||||
"""Handle step reordering from flow view.
|
||||
|
||||
@@ -371,15 +384,20 @@ class ToolBuilderPage(QWidget):
|
||||
prompt_count += 1
|
||||
# Use custom name if set, otherwise default per-type naming
|
||||
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
||||
text = f"{i}. {step_name} [{step.provider}] → ${step.output_var}"
|
||||
text = f"{step_name} [{step.provider}] → ${step.output_var}"
|
||||
icon = get_prompt_icon(20)
|
||||
elif isinstance(step, CodeStep):
|
||||
code_count += 1
|
||||
step_name = step.name if step.name else f"Code {code_count}"
|
||||
text = f"{i}. {step_name} [python] → ${step.output_var}"
|
||||
text = f"{step_name} [python] → ${step.output_var}"
|
||||
icon = get_code_icon(20)
|
||||
else:
|
||||
text = f"{i}. Unknown step"
|
||||
text = f"Unknown step"
|
||||
icon = None
|
||||
|
||||
item = QListWidgetItem(text)
|
||||
if icon:
|
||||
item.setIcon(icon)
|
||||
item.setData(Qt.UserRole, step)
|
||||
|
||||
# Check for broken dependencies
|
||||
@@ -401,6 +419,59 @@ class ToolBuilderPage(QWidget):
|
||||
if self._flow_widget:
|
||||
self._flow_widget.set_tool(self._tool)
|
||||
|
||||
def _refresh_steps_list_only(self):
|
||||
"""Refresh only the steps list (not flow view).
|
||||
|
||||
Used when a change originated from the flow view to avoid
|
||||
rebuilding the graph unnecessarily.
|
||||
"""
|
||||
self.steps_list.clear()
|
||||
if self._tool and self._tool.steps:
|
||||
# Count steps by type for default naming
|
||||
prompt_count = 0
|
||||
code_count = 0
|
||||
|
||||
# Track available variables for dependency checking
|
||||
available_vars = {"input"}
|
||||
if self._tool.arguments:
|
||||
for arg in self._tool.arguments:
|
||||
if arg.variable:
|
||||
available_vars.add(arg.variable)
|
||||
|
||||
for i, step in enumerate(self._tool.steps, 1):
|
||||
if isinstance(step, PromptStep):
|
||||
prompt_count += 1
|
||||
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
||||
text = f"{step_name} [{step.provider}] → ${step.output_var}"
|
||||
icon = get_prompt_icon(20)
|
||||
elif isinstance(step, CodeStep):
|
||||
code_count += 1
|
||||
step_name = step.name if step.name else f"Code {code_count}"
|
||||
text = f"{step_name} [python] → ${step.output_var}"
|
||||
icon = get_code_icon(20)
|
||||
else:
|
||||
text = f"Unknown step"
|
||||
icon = None
|
||||
|
||||
item = QListWidgetItem(text)
|
||||
if icon:
|
||||
item.setIcon(icon)
|
||||
item.setData(Qt.UserRole, step)
|
||||
|
||||
# Check for broken dependencies
|
||||
refs = self._get_step_variable_refs(step)
|
||||
broken_refs = [r for r in refs if r not in available_vars]
|
||||
if broken_refs:
|
||||
item.setForeground(Qt.red)
|
||||
item.setToolTip(f"Missing variables: {', '.join(broken_refs)}")
|
||||
|
||||
self.steps_list.addItem(item)
|
||||
|
||||
# Add this step's output to available vars for next iteration
|
||||
if hasattr(step, 'output_var') and step.output_var:
|
||||
for var in step.output_var.split(','):
|
||||
available_vars.add(var.strip())
|
||||
|
||||
def _add_argument(self):
|
||||
"""Add a new argument."""
|
||||
from ..dialogs.argument_dialog import ArgumentDialog
|
||||
|
||||
@@ -1,21 +1,134 @@
|
||||
"""Tools page - main view for managing tools."""
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
||||
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
|
||||
QPushButton, QGroupBox, QMessageBox, QFrame
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||
from PySide6.QtGui import QFont, QColor, QBrush, QShortcut, QKeySequence
|
||||
|
||||
from ...tool import (
|
||||
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
||||
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES
|
||||
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES,
|
||||
get_tools_dir
|
||||
)
|
||||
from ...config import load_config
|
||||
|
||||
|
||||
class StatusSyncWorker(QThread):
|
||||
"""Background worker to sync tool statuses from registry."""
|
||||
finished = Signal()
|
||||
tool_updated = Signal(str) # Emits tool name when status changes
|
||||
|
||||
def __init__(self, tool_names: list):
|
||||
super().__init__()
|
||||
self.tool_names = tool_names
|
||||
|
||||
def run(self):
|
||||
"""Sync status for all tools with registry_hash."""
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
if not config.registry.token:
|
||||
return
|
||||
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
for tool_name in self.tool_names:
|
||||
try:
|
||||
self._sync_tool(client, tool_name)
|
||||
except Exception:
|
||||
pass # Skip tools that fail to sync
|
||||
except Exception:
|
||||
pass # Silently fail - this is background sync
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
def _sync_tool(self, client, tool_name: str):
|
||||
"""Sync a single tool's status."""
|
||||
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
if not config_data.get("registry_hash"):
|
||||
return # Not published
|
||||
|
||||
# Get status from registry
|
||||
status_data = client.get_my_tool_status(tool_name)
|
||||
new_status = status_data.get("status", "pending")
|
||||
new_hash = status_data.get("config_hash")
|
||||
new_feedback = status_data.get("feedback")
|
||||
|
||||
old_status = config_data.get("registry_status", "pending")
|
||||
old_hash = config_data.get("registry_hash")
|
||||
old_feedback = config_data.get("registry_feedback")
|
||||
|
||||
changed = False
|
||||
if old_status != new_status:
|
||||
config_data["registry_status"] = new_status
|
||||
changed = True
|
||||
if new_hash and old_hash != new_hash:
|
||||
config_data["registry_hash"] = new_hash
|
||||
changed = True
|
||||
if new_feedback != old_feedback:
|
||||
if new_feedback:
|
||||
config_data["registry_feedback"] = new_feedback
|
||||
elif "registry_feedback" in config_data:
|
||||
del config_data["registry_feedback"]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
|
||||
self.tool_updated.emit(tool_name)
|
||||
|
||||
|
||||
def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Get the publish state of a tool.
|
||||
|
||||
Returns:
|
||||
Tuple of (state, registry_hash) where state is:
|
||||
- "published" - approved in registry
|
||||
- "pending" - submitted but awaiting moderation
|
||||
- "changes_requested" - admin requested changes before approval
|
||||
- "rejected" - rejected by admin
|
||||
- "local" - no registry_hash (never published)
|
||||
"""
|
||||
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return ("local", None)
|
||||
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text())
|
||||
registry_hash = config.get("registry_hash")
|
||||
registry_status = config.get("registry_status", "pending")
|
||||
|
||||
if not registry_hash:
|
||||
return ("local", None)
|
||||
|
||||
# Return the moderation status directly - no local hash comparison
|
||||
if registry_status == "approved":
|
||||
return ("published", registry_hash)
|
||||
elif registry_status == "changes_requested":
|
||||
return ("changes_requested", registry_hash)
|
||||
elif registry_status == "rejected":
|
||||
return ("rejected", registry_hash)
|
||||
else:
|
||||
return ("pending", registry_hash)
|
||||
except Exception:
|
||||
return ("local", None)
|
||||
|
||||
|
||||
class ToolsPage(QWidget):
|
||||
"""Main tools management page."""
|
||||
|
||||
@@ -23,6 +136,10 @@ class ToolsPage(QWidget):
|
||||
super().__init__()
|
||||
self.main_window = main_window
|
||||
self._current_tool = None
|
||||
self._sync_worker = None
|
||||
self._syncing = False # Prevent re-sync during update
|
||||
self._poll_timer = None # Timer for automatic status polling
|
||||
self._has_pending_tools = False # Track if we need to poll
|
||||
self._setup_ui()
|
||||
self.refresh()
|
||||
|
||||
@@ -44,14 +161,9 @@ class ToolsPage(QWidget):
|
||||
header_layout.addStretch()
|
||||
|
||||
# Connection status
|
||||
config = load_config()
|
||||
if config.registry.token:
|
||||
status = QLabel("Connected to Registry")
|
||||
status.setStyleSheet("color: #38a169; font-weight: 500;")
|
||||
else:
|
||||
status = QLabel("Not connected")
|
||||
status.setStyleSheet("color: #718096;")
|
||||
header_layout.addWidget(status)
|
||||
self.connection_status = QLabel()
|
||||
self._update_connection_status()
|
||||
header_layout.addWidget(self.connection_status)
|
||||
|
||||
layout.addWidget(header)
|
||||
|
||||
@@ -135,11 +247,26 @@ class ToolsPage(QWidget):
|
||||
|
||||
layout.addWidget(buttons)
|
||||
|
||||
# Keyboard shortcut: F5 to sync status
|
||||
self._sync_shortcut = QShortcut(QKeySequence("F5"), self)
|
||||
self._sync_shortcut.activated.connect(self._sync_tool_status)
|
||||
|
||||
def _update_connection_status(self):
|
||||
"""Update the connection status label."""
|
||||
config = load_config()
|
||||
if config.registry.token:
|
||||
self.connection_status.setText("Connected to Registry")
|
||||
self.connection_status.setStyleSheet("color: #38a169; font-weight: 500;")
|
||||
else:
|
||||
self.connection_status.setText("Not connected")
|
||||
self.connection_status.setStyleSheet("color: #718096;")
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the tool list."""
|
||||
self.tool_tree.clear()
|
||||
self._current_tool = None
|
||||
self.info_text.clear()
|
||||
self._has_pending_tools = False # Reset, will be set during tree building
|
||||
|
||||
tools = list_tools()
|
||||
|
||||
@@ -169,10 +296,46 @@ class ToolsPage(QWidget):
|
||||
|
||||
# Tools in category
|
||||
for name, tool in sorted(tools_by_category[category], key=lambda x: x[0]):
|
||||
tool_item = QTreeWidgetItem([name])
|
||||
# Get publish state
|
||||
state, registry_hash = get_tool_publish_state(name)
|
||||
|
||||
# Track if we have pending tools for polling
|
||||
if state in ("pending", "changes_requested"):
|
||||
self._has_pending_tools = True
|
||||
|
||||
# Show state indicator in display name
|
||||
if state == "published":
|
||||
display_name = f"{name} ✓"
|
||||
tooltip = "Published to registry - approved"
|
||||
color = QColor(56, 161, 105) # Green
|
||||
elif state == "pending":
|
||||
display_name = f"{name} ◐"
|
||||
tooltip = "Submitted to registry - pending review"
|
||||
color = QColor(214, 158, 46) # Yellow/amber
|
||||
elif state == "changes_requested":
|
||||
display_name = f"{name} ⚠"
|
||||
tooltip = "Changes requested - see feedback"
|
||||
color = QColor(245, 158, 11) # Orange/amber
|
||||
elif state == "rejected":
|
||||
display_name = f"{name} ✗"
|
||||
tooltip = "Rejected by moderator"
|
||||
color = QColor(220, 38, 38) # Red
|
||||
else:
|
||||
display_name = name
|
||||
tooltip = "Local tool - not published"
|
||||
color = None
|
||||
|
||||
tool_item = QTreeWidgetItem([display_name])
|
||||
tool_item.setData(0, Qt.UserRole, name)
|
||||
|
||||
if color:
|
||||
tool_item.setForeground(0, QBrush(color))
|
||||
|
||||
# Build tooltip
|
||||
if tool.source and tool.source.type == "imported":
|
||||
tool_item.setToolTip(0, f"Imported from {tool.source.url or 'registry'}")
|
||||
tooltip = f"Imported from {tool.source.url or 'registry'}"
|
||||
tool_item.setToolTip(0, tooltip)
|
||||
|
||||
cat_item.addChild(tool_item)
|
||||
|
||||
self.tool_tree.addTopLevelItem(cat_item)
|
||||
@@ -186,6 +349,102 @@ class ToolsPage(QWidget):
|
||||
"or browse the Registry to install community tools."
|
||||
)
|
||||
|
||||
# Start background sync for published tools
|
||||
self._start_background_sync(tools)
|
||||
|
||||
# Manage polling timer based on pending state
|
||||
self._manage_poll_timer()
|
||||
|
||||
def _start_background_sync(self, tools: list):
|
||||
"""Start background sync for tools that have been published."""
|
||||
if self._syncing:
|
||||
return # Avoid re-syncing during an update
|
||||
|
||||
config = load_config()
|
||||
if not config.registry.token:
|
||||
return # Not connected
|
||||
|
||||
# Collect tools with registry_hash
|
||||
published_tools = []
|
||||
for name in tools:
|
||||
config_path = get_tools_dir() / name / "config.yaml"
|
||||
if config_path.exists():
|
||||
try:
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
if config_data.get("registry_hash"):
|
||||
published_tools.append(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not published_tools:
|
||||
return
|
||||
|
||||
# Stop any existing sync
|
||||
if self._sync_worker and self._sync_worker.isRunning():
|
||||
self._sync_worker.wait(1000)
|
||||
|
||||
# Start new sync
|
||||
self._sync_worker = StatusSyncWorker(published_tools)
|
||||
self._sync_worker.tool_updated.connect(self._on_tool_status_updated)
|
||||
self._sync_worker.finished.connect(self._on_sync_finished)
|
||||
self._syncing = True
|
||||
self._sync_worker.start()
|
||||
|
||||
def _on_sync_finished(self):
|
||||
"""Handle background sync completion."""
|
||||
self._syncing = False
|
||||
|
||||
def _manage_poll_timer(self):
|
||||
"""Start or stop the polling timer based on pending tools."""
|
||||
config = load_config()
|
||||
should_poll = self._has_pending_tools and config.registry.token
|
||||
|
||||
if should_poll:
|
||||
if not self._poll_timer:
|
||||
# Create timer that polls every 30 seconds
|
||||
self._poll_timer = QTimer(self)
|
||||
self._poll_timer.timeout.connect(self._poll_status)
|
||||
if not self._poll_timer.isActive():
|
||||
self._poll_timer.start(30000) # 30 seconds
|
||||
else:
|
||||
# No pending tools, stop polling
|
||||
if self._poll_timer and self._poll_timer.isActive():
|
||||
self._poll_timer.stop()
|
||||
|
||||
def _poll_status(self):
|
||||
"""Timer callback to poll status for pending tools."""
|
||||
if self._syncing:
|
||||
return # Already syncing
|
||||
|
||||
# Get list of tools with pending status
|
||||
tools = list_tools()
|
||||
pending_tools = []
|
||||
|
||||
for name in tools:
|
||||
config_path = get_tools_dir() / name / "config.yaml"
|
||||
if config_path.exists():
|
||||
try:
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
status = config_data.get("registry_status")
|
||||
if status in ("pending", "changes_requested") and config_data.get("registry_hash"):
|
||||
pending_tools.append(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if pending_tools:
|
||||
self._start_background_sync(pending_tools)
|
||||
else:
|
||||
# No more pending tools, stop timer
|
||||
if self._poll_timer and self._poll_timer.isActive():
|
||||
self._poll_timer.stop()
|
||||
self._has_pending_tools = False
|
||||
|
||||
def _on_tool_status_updated(self, tool_name: str):
|
||||
"""Handle background sync updating a tool's status."""
|
||||
# Refresh the display - _syncing flag prevents re-triggering sync
|
||||
self.refresh()
|
||||
self.main_window.show_status(f"Status updated for '{tool_name}'")
|
||||
|
||||
def _on_selection_changed(self):
|
||||
"""Handle tool selection change."""
|
||||
items = self.tool_tree.selectedItems()
|
||||
@@ -215,6 +474,17 @@ class ToolsPage(QWidget):
|
||||
if tool_name:
|
||||
self._edit_tool()
|
||||
|
||||
def _get_tool_feedback(self, tool_name: str) -> Optional[str]:
|
||||
"""Get the feedback for a tool from its config."""
|
||||
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||
if config_path.exists():
|
||||
try:
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
return config_data.get("registry_feedback")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _show_tool_info(self, tool: Tool):
|
||||
"""Display tool information."""
|
||||
lines = []
|
||||
@@ -224,6 +494,51 @@ class ToolsPage(QWidget):
|
||||
if tool.description:
|
||||
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
|
||||
|
||||
# Publish state
|
||||
state, registry_hash = get_tool_publish_state(tool.name)
|
||||
if state == "published":
|
||||
lines.append(
|
||||
"<p style='background: #c6f6d5; color: #276749; padding: 6px 10px; "
|
||||
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
||||
"✓ Published to registry - approved</p>"
|
||||
)
|
||||
elif state == "pending":
|
||||
lines.append(
|
||||
"<p style='background: #fef3c7; color: #92400e; padding: 6px 10px; "
|
||||
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
||||
"◐ Submitted to registry - pending review</p>"
|
||||
)
|
||||
elif state == "changes_requested":
|
||||
lines.append(
|
||||
"<p style='background: #fef3c7; color: #92400e; padding: 6px 10px; "
|
||||
"border-radius: 4px; margin-bottom: 8px; font-size: 12px;'>"
|
||||
"⚠ Changes requested - please address feedback and republish</p>"
|
||||
)
|
||||
# Show feedback if available
|
||||
feedback = self._get_tool_feedback(tool.name)
|
||||
if feedback:
|
||||
feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "<br>")
|
||||
lines.append(
|
||||
f"<div style='background: #fff7ed; border-left: 3px solid #f59e0b; "
|
||||
f"padding: 8px 12px; margin-bottom: 12px; font-size: 12px;'>"
|
||||
f"<strong>Feedback:</strong><br>{feedback_escaped}</div>"
|
||||
)
|
||||
elif state == "rejected":
|
||||
lines.append(
|
||||
"<p style='background: #fee2e2; color: #991b1b; padding: 6px 10px; "
|
||||
"border-radius: 4px; margin-bottom: 8px; font-size: 12px;'>"
|
||||
"✗ Rejected by moderator</p>"
|
||||
)
|
||||
# Show feedback if available
|
||||
feedback = self._get_tool_feedback(tool.name)
|
||||
if feedback:
|
||||
feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "<br>")
|
||||
lines.append(
|
||||
f"<div style='background: #fef2f2; border-left: 3px solid #dc2626; "
|
||||
f"padding: 8px 12px; margin-bottom: 12px; font-size: 12px;'>"
|
||||
f"<strong>Reason:</strong><br>{feedback_escaped}</div>"
|
||||
)
|
||||
|
||||
# Source info
|
||||
if tool.source:
|
||||
source_type = tool.source.type
|
||||
@@ -275,7 +590,11 @@ class ToolsPage(QWidget):
|
||||
|
||||
config = load_config()
|
||||
if config.registry.token:
|
||||
# Connected - enable Publish when tool selected
|
||||
self.btn_publish.setEnabled(has_selection)
|
||||
else:
|
||||
# Not connected - Connect button is always enabled
|
||||
self.btn_publish.setEnabled(True)
|
||||
|
||||
def _create_tool(self):
|
||||
"""Create a new tool."""
|
||||
@@ -314,6 +633,7 @@ class ToolsPage(QWidget):
|
||||
dialog = ConnectDialog(self)
|
||||
if dialog.exec():
|
||||
self.refresh()
|
||||
self._update_connection_status()
|
||||
self.main_window.show_status("Connected to registry")
|
||||
# Recreate publish button
|
||||
self.btn_publish.setText("Publish")
|
||||
@@ -325,7 +645,75 @@ class ToolsPage(QWidget):
|
||||
if not self._current_tool:
|
||||
return
|
||||
|
||||
tool_name = self._current_tool.name # Save before refresh clears it
|
||||
from ..dialogs.publish_dialog import PublishDialog
|
||||
dialog = PublishDialog(self, self._current_tool)
|
||||
if dialog.exec():
|
||||
self.main_window.show_status(f"Published '{self._current_tool.name}'")
|
||||
result = dialog.exec()
|
||||
# Always refresh after dialog closes - tool config may have been updated
|
||||
# even if user canceled (e.g., server processed request before client timeout)
|
||||
self.refresh()
|
||||
if result:
|
||||
self.main_window.show_status(f"Published '{tool_name}'")
|
||||
|
||||
def _sync_tool_status(self):
|
||||
"""Sync the moderation status of the selected tool from the registry."""
|
||||
if not self._current_tool:
|
||||
return
|
||||
|
||||
tool_name = self._current_tool.name
|
||||
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
# Get status from registry
|
||||
status_data = client.get_my_tool_status(tool_name)
|
||||
new_status = status_data.get("status", "pending")
|
||||
new_hash = status_data.get("config_hash")
|
||||
new_feedback = status_data.get("feedback")
|
||||
|
||||
# Update local config
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
old_status = config_data.get("registry_status", "pending")
|
||||
old_hash = config_data.get("registry_hash")
|
||||
old_feedback = config_data.get("registry_feedback")
|
||||
|
||||
changed = False
|
||||
if old_status != new_status:
|
||||
config_data["registry_status"] = new_status
|
||||
changed = True
|
||||
if new_hash and old_hash != new_hash:
|
||||
config_data["registry_hash"] = new_hash
|
||||
changed = True
|
||||
if new_feedback != old_feedback:
|
||||
if new_feedback:
|
||||
config_data["registry_feedback"] = new_feedback
|
||||
elif "registry_feedback" in config_data:
|
||||
del config_data["registry_feedback"]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
|
||||
self.refresh()
|
||||
if new_status == "approved":
|
||||
self.main_window.show_status(f"Tool '{tool_name}' has been approved!")
|
||||
elif new_status == "rejected":
|
||||
self.main_window.show_status(f"Tool '{tool_name}' was rejected")
|
||||
elif new_status == "changes_requested":
|
||||
self.main_window.show_status(f"Changes requested for '{tool_name}' - see feedback")
|
||||
else:
|
||||
self.main_window.show_status(f"Status updated: {new_status}")
|
||||
else:
|
||||
self.main_window.show_status(f"Status unchanged: {new_status}")
|
||||
|
||||
except RegistryError as e:
|
||||
QMessageBox.warning(self, "Sync Error", f"Could not sync status:\n{e.message}")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Sync Error", f"Could not sync status:\n{e}")
|
||||
|
||||
@@ -9,6 +9,10 @@ from PySide6.QtGui import QKeyEvent, QAction
|
||||
from NodeGraphQt import NodeGraph, BaseNode
|
||||
|
||||
from ...tool import Tool, PromptStep, CodeStep
|
||||
from .icons import (
|
||||
get_prompt_icon_path, get_code_icon_path,
|
||||
get_input_icon_path, get_output_icon_path
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -35,6 +39,7 @@ class InputNode(CmdForgeBaseNode):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_color(90, 90, 160) # Indigo
|
||||
self.set_icon(get_input_icon_path(16))
|
||||
# Default output for stdin
|
||||
self.add_output('input', color=(180, 180, 250))
|
||||
|
||||
@@ -56,7 +61,8 @@ class PromptNode(CmdForgeBaseNode):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_color(102, 126, 234) # Indigo (matching CmdForge theme)
|
||||
self.add_input('in', color=(180, 180, 250), multi_input=True)
|
||||
self.set_icon(get_prompt_icon_path(16))
|
||||
self.add_input('in', color=(180, 180, 250))
|
||||
self.add_output('out', color=(180, 250, 180))
|
||||
|
||||
# Add properties for display
|
||||
@@ -82,7 +88,8 @@ class CodeNode(CmdForgeBaseNode):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_color(72, 187, 120) # Green
|
||||
self.add_input('in', color=(180, 250, 180), multi_input=True)
|
||||
self.set_icon(get_code_icon_path(16))
|
||||
self.add_input('in', color=(180, 250, 180))
|
||||
self.add_output('out', color=(250, 220, 180))
|
||||
|
||||
# Add properties
|
||||
@@ -106,7 +113,8 @@ class OutputNode(CmdForgeBaseNode):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_color(237, 137, 54) # Orange
|
||||
self.add_input('in', color=(250, 220, 180), multi_input=True)
|
||||
self.set_icon(get_output_icon_path(16))
|
||||
self.add_input('in', color=(250, 220, 180))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -114,7 +122,12 @@ class OutputNode(CmdForgeBaseNode):
|
||||
# =============================================================================
|
||||
|
||||
class FlowGraphWidget(QWidget):
|
||||
"""Widget for visualizing tool flow as a node graph."""
|
||||
"""Widget for visualizing tool flow as a node graph.
|
||||
|
||||
Note: Currently the flow view is read-only for connections. Reordering
|
||||
steps by manipulating connections is a future enhancement (TODO).
|
||||
Use the list view for reordering steps via drag-drop.
|
||||
"""
|
||||
|
||||
# Emitted when a node is double-clicked (step_index, step_type)
|
||||
node_double_clicked = Signal(int, str)
|
||||
@@ -128,6 +141,9 @@ class FlowGraphWidget(QWidget):
|
||||
# Emitted when steps are reordered (new order as list of step indices)
|
||||
steps_reordered = Signal(list)
|
||||
|
||||
# Emitted when a step name is changed (step_index, new_name)
|
||||
step_name_changed = Signal(int, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._tool: Optional[Tool] = None
|
||||
@@ -157,7 +173,7 @@ class FlowGraphWidget(QWidget):
|
||||
# Connect signals
|
||||
self._graph.node_double_clicked.connect(self._on_node_double_clicked)
|
||||
self._graph.nodes_deleted.connect(self._on_nodes_deleted)
|
||||
self._graph.port_disconnected.connect(self._on_port_disconnected)
|
||||
self._graph.property_changed.connect(self._on_property_changed)
|
||||
|
||||
# Add graph widget
|
||||
layout.addWidget(self._graph.widget, 1)
|
||||
@@ -172,7 +188,6 @@ class FlowGraphWidget(QWidget):
|
||||
"Zoom: Scroll | "
|
||||
"Select: Click/drag | "
|
||||
"Edit: Double-click | "
|
||||
"Cut links: Alt+Shift+drag | "
|
||||
"Select All: A | "
|
||||
"Fit: F"
|
||||
)
|
||||
@@ -449,58 +464,22 @@ class FlowGraphWidget(QWidget):
|
||||
self.steps_deleted.emit(deleted_indices)
|
||||
self.flow_changed.emit()
|
||||
|
||||
def _on_port_disconnected(self, input_port, output_port):
|
||||
"""Handle port disconnection - reorder steps.
|
||||
def _on_property_changed(self, node, prop_name, value):
|
||||
"""Handle node property change.
|
||||
|
||||
When a connection is broken:
|
||||
1. The node that lost its input moves to the end
|
||||
2. The chain reconnects (previous node → next node)
|
||||
3. Disconnected node attaches at the end before Output
|
||||
Filters for 'name' property to detect step name changes.
|
||||
"""
|
||||
# Ignore during rebuild
|
||||
if self._rebuilding:
|
||||
return
|
||||
|
||||
if not self._tool or not self._tool.steps:
|
||||
# Only interested in name changes
|
||||
if prop_name != 'name':
|
||||
return
|
||||
|
||||
# Get the nodes involved
|
||||
# input_port belongs to the node that lost its input
|
||||
# output_port belongs to the node that was providing the input
|
||||
disconnected_node = input_port.node()
|
||||
source_node = output_port.node()
|
||||
|
||||
# Only handle step nodes (not Input/Output)
|
||||
if not hasattr(disconnected_node, '_step_index') or disconnected_node._step_index < 0:
|
||||
return
|
||||
|
||||
disconnected_idx = disconnected_node._step_index
|
||||
|
||||
# Find the node that the disconnected node was connected to (its output target)
|
||||
next_node = None
|
||||
if disconnected_node.output_ports():
|
||||
out_port = disconnected_node.output_ports()[0]
|
||||
connected_ports = out_port.connected_ports()
|
||||
if connected_ports:
|
||||
next_node = connected_ports[0].node()
|
||||
|
||||
# Build new order: move disconnected step to end
|
||||
old_order = list(range(len(self._tool.steps)))
|
||||
old_order.remove(disconnected_idx)
|
||||
old_order.append(disconnected_idx)
|
||||
|
||||
# Reconnect the graph visually
|
||||
# Connect source to next (if next is a step node)
|
||||
if next_node and hasattr(next_node, '_step_index') and next_node._step_index >= 0:
|
||||
if source_node.output_ports() and next_node.input_ports():
|
||||
source_node.output_ports()[0].connect_to(next_node.input_ports()[0])
|
||||
|
||||
# Connect the last step (before disconnected) to disconnected node
|
||||
# and disconnected node to output
|
||||
# This will be handled by the rebuild after reorder
|
||||
|
||||
# Emit the new order
|
||||
self.steps_reordered.emit(old_order)
|
||||
if hasattr(node, '_step_index') and node._step_index >= 0:
|
||||
self.step_name_changed.emit(node._step_index, value)
|
||||
self.flow_changed.emit()
|
||||
|
||||
def refresh(self):
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Step type icons for CmdForge GUI."""
|
||||
|
||||
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QPen, QBrush, QPainterPath, QFont
|
||||
from PySide6.QtCore import Qt, QRect, QRectF
|
||||
|
||||
|
||||
def create_prompt_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||
"""Create a speech bubble icon for prompt steps.
|
||||
|
||||
Args:
|
||||
size: Icon size in pixels
|
||||
color: Icon color (defaults to indigo #667eea)
|
||||
"""
|
||||
if color is None:
|
||||
color = QColor(102, 126, 234) # Indigo
|
||||
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
# Draw speech bubble
|
||||
pen = QPen(color, 1.5)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(QBrush(color))
|
||||
|
||||
# Bubble body
|
||||
margin = size * 0.1
|
||||
bubble_rect = QRectF(margin, margin, size - 2 * margin, size * 0.65)
|
||||
painter.drawRoundedRect(bubble_rect, 4, 4)
|
||||
|
||||
# Bubble tail (triangle pointing down-left)
|
||||
path = QPainterPath()
|
||||
tail_x = margin + size * 0.2
|
||||
tail_y = bubble_rect.bottom()
|
||||
path.moveTo(tail_x, tail_y - 2)
|
||||
path.lineTo(tail_x - size * 0.1, tail_y + size * 0.2)
|
||||
path.lineTo(tail_x + size * 0.15, tail_y - 2)
|
||||
path.closeSubpath()
|
||||
painter.drawPath(path)
|
||||
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def create_code_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||
"""Create a code brackets icon for code steps.
|
||||
|
||||
Args:
|
||||
size: Icon size in pixels
|
||||
color: Icon color (defaults to green #48bb78)
|
||||
"""
|
||||
if color is None:
|
||||
color = QColor(72, 187, 120) # Green
|
||||
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
pen = QPen(color, 2.0)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
painter.setPen(pen)
|
||||
|
||||
# Draw < > brackets
|
||||
margin = size * 0.15
|
||||
mid_y = size / 2
|
||||
bracket_height = size * 0.5
|
||||
|
||||
# Left bracket <
|
||||
left_x = margin + size * 0.1
|
||||
painter.drawLine(
|
||||
int(left_x + size * 0.2), int(mid_y - bracket_height / 2),
|
||||
int(left_x), int(mid_y)
|
||||
)
|
||||
painter.drawLine(
|
||||
int(left_x), int(mid_y),
|
||||
int(left_x + size * 0.2), int(mid_y + bracket_height / 2)
|
||||
)
|
||||
|
||||
# Right bracket >
|
||||
right_x = size - margin - size * 0.1
|
||||
painter.drawLine(
|
||||
int(right_x - size * 0.2), int(mid_y - bracket_height / 2),
|
||||
int(right_x), int(mid_y)
|
||||
)
|
||||
painter.drawLine(
|
||||
int(right_x), int(mid_y),
|
||||
int(right_x - size * 0.2), int(mid_y + bracket_height / 2)
|
||||
)
|
||||
|
||||
# Slash in middle /
|
||||
slash_width = size * 0.12
|
||||
painter.drawLine(
|
||||
int(size / 2 + slash_width), int(mid_y - bracket_height / 2.5),
|
||||
int(size / 2 - slash_width), int(mid_y + bracket_height / 2.5)
|
||||
)
|
||||
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def create_input_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||
"""Create an input icon (arrow pointing right into box).
|
||||
|
||||
Args:
|
||||
size: Icon size in pixels
|
||||
color: Icon color (defaults to indigo #5a5a9f)
|
||||
"""
|
||||
if color is None:
|
||||
color = QColor(90, 90, 160) # Indigo
|
||||
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
pen = QPen(color, 2.0)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
painter.setPen(pen)
|
||||
|
||||
margin = size * 0.15
|
||||
mid_y = size / 2
|
||||
|
||||
# Arrow pointing right
|
||||
arrow_start = margin
|
||||
arrow_end = size - margin - size * 0.15
|
||||
|
||||
# Arrow shaft
|
||||
painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y))
|
||||
|
||||
# Arrow head
|
||||
head_size = size * 0.2
|
||||
painter.drawLine(
|
||||
int(arrow_end), int(mid_y),
|
||||
int(arrow_end - head_size), int(mid_y - head_size)
|
||||
)
|
||||
painter.drawLine(
|
||||
int(arrow_end), int(mid_y),
|
||||
int(arrow_end - head_size), int(mid_y + head_size)
|
||||
)
|
||||
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def create_output_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||
"""Create an output icon (arrow pointing right out of box).
|
||||
|
||||
Args:
|
||||
size: Icon size in pixels
|
||||
color: Icon color (defaults to orange #ed8936)
|
||||
"""
|
||||
if color is None:
|
||||
color = QColor(237, 137, 54) # Orange
|
||||
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
pen = QPen(color, 2.0)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
painter.setPen(pen)
|
||||
|
||||
margin = size * 0.15
|
||||
mid_y = size / 2
|
||||
|
||||
# Arrow pointing right (export style)
|
||||
arrow_start = margin + size * 0.15
|
||||
arrow_end = size - margin
|
||||
|
||||
# Arrow shaft
|
||||
painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y))
|
||||
|
||||
# Arrow head
|
||||
head_size = size * 0.2
|
||||
painter.drawLine(
|
||||
int(arrow_end), int(mid_y),
|
||||
int(arrow_end - head_size), int(mid_y - head_size)
|
||||
)
|
||||
painter.drawLine(
|
||||
int(arrow_end), int(mid_y),
|
||||
int(arrow_end - head_size), int(mid_y + head_size)
|
||||
)
|
||||
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
# Cached icons for reuse
|
||||
_icon_cache = {}
|
||||
|
||||
# Cached icon file paths for NodeGraphQt (which requires file paths)
|
||||
_icon_path_cache = {}
|
||||
|
||||
|
||||
def _get_icon_path(icon_type: str, size: int, create_func) -> str:
|
||||
"""Get or create a cached icon file path.
|
||||
|
||||
NodeGraphQt requires file paths for icons, so we save to temp files.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
key = (icon_type, size)
|
||||
if key not in _icon_path_cache:
|
||||
icon = create_func(size)
|
||||
pixmap = icon.pixmap(size, size)
|
||||
|
||||
# Save to temp directory with a stable name
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), 'cmdforge_icons')
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
path = os.path.join(temp_dir, f'{icon_type}_{size}.png')
|
||||
pixmap.save(path, 'PNG')
|
||||
_icon_path_cache[key] = path
|
||||
|
||||
return _icon_path_cache[key]
|
||||
|
||||
|
||||
def get_prompt_icon_path(size: int = 16) -> str:
|
||||
"""Get file path to prompt icon (for NodeGraphQt)."""
|
||||
return _get_icon_path('prompt', size, create_prompt_icon)
|
||||
|
||||
|
||||
def get_code_icon_path(size: int = 16) -> str:
|
||||
"""Get file path to code icon (for NodeGraphQt)."""
|
||||
return _get_icon_path('code', size, create_code_icon)
|
||||
|
||||
|
||||
def get_input_icon_path(size: int = 16) -> str:
|
||||
"""Get file path to input icon (for NodeGraphQt)."""
|
||||
return _get_icon_path('input', size, create_input_icon)
|
||||
|
||||
|
||||
def get_output_icon_path(size: int = 16) -> str:
|
||||
"""Get file path to output icon (for NodeGraphQt)."""
|
||||
return _get_icon_path('output', size, create_output_icon)
|
||||
|
||||
|
||||
def get_prompt_icon(size: int = 24) -> QIcon:
|
||||
"""Get cached prompt icon."""
|
||||
key = ('prompt', size)
|
||||
if key not in _icon_cache:
|
||||
_icon_cache[key] = create_prompt_icon(size)
|
||||
return _icon_cache[key]
|
||||
|
||||
|
||||
def get_code_icon(size: int = 24) -> QIcon:
|
||||
"""Get cached code icon."""
|
||||
key = ('code', size)
|
||||
if key not in _icon_cache:
|
||||
_icon_cache[key] = create_code_icon(size)
|
||||
return _icon_cache[key]
|
||||
|
||||
|
||||
def get_input_icon(size: int = 24) -> QIcon:
|
||||
"""Get cached input icon."""
|
||||
key = ('input', size)
|
||||
if key not in _icon_cache:
|
||||
_icon_cache[key] = create_input_icon(size)
|
||||
return _icon_cache[key]
|
||||
|
||||
|
||||
def get_output_icon(size: int = 24) -> QIcon:
|
||||
"""Get cached output icon."""
|
||||
key = ('output', size)
|
||||
if key not in _icon_cache:
|
||||
_icon_cache[key] = create_output_icon(size)
|
||||
return _icon_cache[key]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Content hash utilities for tool integrity verification.
|
||||
|
||||
This module provides consistent SHA256 hashing for tool content,
|
||||
used for:
|
||||
- Publish state tracking (detect local modifications)
|
||||
- Download integrity verification
|
||||
- Registry content verification
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Optional, Dict, Any
|
||||
import yaml
|
||||
|
||||
|
||||
def compute_config_hash(config: Dict[str, Any], exclude_fields: Optional[list] = None) -> str:
|
||||
"""Compute SHA256 hash of tool configuration.
|
||||
|
||||
The hash is computed from a normalized YAML representation to ensure
|
||||
consistent hashing regardless of field order or formatting.
|
||||
|
||||
Args:
|
||||
config: Tool configuration dictionary
|
||||
exclude_fields: Fields to exclude from hashing (e.g., 'published_hash', 'registry_hash')
|
||||
|
||||
Returns:
|
||||
Hash string in format "sha256:<64-char-hex>"
|
||||
"""
|
||||
if exclude_fields is None:
|
||||
# Exclude registry metadata and publication metadata from hash
|
||||
# These fields are added during publish but aren't part of tool content
|
||||
exclude_fields = [
|
||||
'published_hash', 'registry_hash', 'registry_status', 'registry_feedback', # registry metadata
|
||||
'version', 'tags', # publication metadata (added by publish dialog)
|
||||
]
|
||||
|
||||
# Create a copy without excluded fields
|
||||
config_copy = {k: v for k, v in config.items() if k not in exclude_fields}
|
||||
|
||||
# Normalize to YAML with sorted keys for consistent ordering
|
||||
normalized = yaml.dump(config_copy, sort_keys=True, default_flow_style=False)
|
||||
|
||||
# Compute SHA256
|
||||
hash_bytes = hashlib.sha256(normalized.encode('utf-8')).hexdigest()
|
||||
|
||||
return f"sha256:{hash_bytes}"
|
||||
|
||||
|
||||
def compute_yaml_hash(yaml_content: str, exclude_fields: Optional[list] = None) -> str:
|
||||
"""Compute SHA256 hash of YAML content string.
|
||||
|
||||
Parses the YAML, normalizes it, and computes hash.
|
||||
Useful for hashing raw config.yaml content.
|
||||
|
||||
Args:
|
||||
yaml_content: Raw YAML string
|
||||
exclude_fields: Fields to exclude from hashing
|
||||
|
||||
Returns:
|
||||
Hash string in format "sha256:<64-char-hex>"
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
if config is None:
|
||||
config = {}
|
||||
return compute_config_hash(config, exclude_fields)
|
||||
|
||||
|
||||
def compute_file_hash(file_path: str) -> str:
|
||||
"""Compute SHA256 hash of a file's contents.
|
||||
|
||||
Used for hashing pattern files (e.g., Fabric's system.md).
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
|
||||
Returns:
|
||||
Hash string in format "sha256:<64-char-hex>"
|
||||
"""
|
||||
with open(file_path, 'rb') as f:
|
||||
hash_bytes = hashlib.sha256(f.read()).hexdigest()
|
||||
return f"sha256:{hash_bytes}"
|
||||
|
||||
|
||||
def verify_hash(content: str, expected_hash: str) -> bool:
|
||||
"""Verify content matches expected hash.
|
||||
|
||||
Args:
|
||||
content: Content to verify (YAML string)
|
||||
expected_hash: Expected hash in format "sha256:<hex>"
|
||||
|
||||
Returns:
|
||||
True if hash matches, False otherwise
|
||||
"""
|
||||
if not expected_hash or not expected_hash.startswith("sha256:"):
|
||||
return False
|
||||
|
||||
computed = compute_yaml_hash(content)
|
||||
return computed == expected_hash
|
||||
|
||||
|
||||
def extract_hash_value(hash_string: str) -> Optional[str]:
|
||||
"""Extract the hex value from a hash string.
|
||||
|
||||
Args:
|
||||
hash_string: Hash in format "sha256:<hex>"
|
||||
|
||||
Returns:
|
||||
The hex value without prefix, or None if invalid
|
||||
"""
|
||||
if not hash_string or not hash_string.startswith("sha256:"):
|
||||
return None
|
||||
return hash_string[7:] # Remove "sha256:" prefix
|
||||
|
||||
|
||||
def short_hash(hash_string: str, length: int = 8) -> str:
|
||||
"""Get a shortened version of a hash for display.
|
||||
|
||||
Args:
|
||||
hash_string: Full hash string
|
||||
length: Number of hex chars to include
|
||||
|
||||
Returns:
|
||||
Shortened hash (e.g., "sha256:abc123...")
|
||||
"""
|
||||
hex_value = extract_hash_value(hash_string)
|
||||
if hex_value:
|
||||
return f"sha256:{hex_value[:length]}..."
|
||||
return hash_string[:length + 10] + "..."
|
||||
+792
-13
@@ -21,6 +21,7 @@ from argon2.exceptions import VerifyMismatchError
|
||||
from .db import connect_db, init_db, query_all, query_one
|
||||
from .rate_limit import RateLimiter
|
||||
from .sync import process_webhook, get_categories_cache_path, get_repo_dir
|
||||
from ..hash_utils import compute_yaml_hash
|
||||
from .stats import (
|
||||
refresh_tool_stats, get_tool_stats, refresh_publisher_stats,
|
||||
get_publisher_stats, track_tool_usage, calculate_badges, BADGES,
|
||||
@@ -56,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"}
|
||||
@@ -193,6 +194,83 @@ def select_version(versions: List[str], constraint_raw: Optional[str]) -> Option
|
||||
return max(filtered, key=lambda item: item[0])[1]
|
||||
|
||||
|
||||
def run_ai_scrutiny_review(scrutiny_report: dict, config: dict, tool_name: str, description: str) -> Optional[dict]:
|
||||
"""Run AI-powered secondary review of scrutiny warnings.
|
||||
|
||||
Uses the scrutiny-ai-review tool to analyze warnings and identify false positives.
|
||||
|
||||
Args:
|
||||
scrutiny_report: The scrutiny report with findings
|
||||
config: The tool configuration dict
|
||||
tool_name: Name of the tool being reviewed
|
||||
description: Tool description
|
||||
|
||||
Returns:
|
||||
AI review result dict, or None if review fails
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Check if the AI review tool exists
|
||||
tool_path = Path.home() / ".cmdforge" / "scrutiny-ai-review" / "config.yaml"
|
||||
if not tool_path.exists():
|
||||
return None
|
||||
|
||||
# Extract warnings from scrutiny report
|
||||
warnings = [
|
||||
f for f in scrutiny_report.get("findings", [])
|
||||
if f.get("result") == "warning"
|
||||
]
|
||||
|
||||
if not warnings:
|
||||
return None
|
||||
|
||||
# Prepare tool config for review
|
||||
tool_config = {
|
||||
"name": tool_name,
|
||||
"description": description or "",
|
||||
"steps": config.get("steps", []),
|
||||
"arguments": config.get("arguments", []),
|
||||
}
|
||||
|
||||
# Find cmdforge executable - try venv first, then PATH
|
||||
cmdforge_paths = [
|
||||
Path(sys.executable).parent / "cmdforge", # Same venv as current Python
|
||||
Path("/srv/mergerfs/data_pool/home/rob/cmdforge-registry/venv/bin/cmdforge"), # Server venv
|
||||
"cmdforge", # PATH
|
||||
]
|
||||
|
||||
cmdforge_exe = None
|
||||
for p in cmdforge_paths:
|
||||
p = Path(p) if not isinstance(p, Path) else p
|
||||
if p.exists() if isinstance(p, Path) and p.is_absolute() else True:
|
||||
cmdforge_exe = str(p)
|
||||
break
|
||||
|
||||
if not cmdforge_exe:
|
||||
return None
|
||||
|
||||
# Run the tool (use -- to separate cmdforge args from tool args)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
cmdforge_exe, "run", "scrutiny-ai-review", "--",
|
||||
"--warnings", json.dumps(warnings),
|
||||
"--tool-config", json.dumps(tool_config),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60, # 60 second timeout
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return json.loads(result.stdout.strip())
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY_BYTES
|
||||
@@ -599,10 +677,15 @@ def create_app() -> Flask:
|
||||
# Sanitize query for FTS5 - escape special characters that cause syntax errors
|
||||
# FTS5 special chars: * " ( ) : ^ - NOT AND OR NEAR
|
||||
# For safety, we'll quote the entire query if it contains special chars
|
||||
fts5_special = set('*"():^-')
|
||||
fts5_special = set('"():^-')
|
||||
if any(c in fts5_special for c in query_text) or query_text.upper() in ('NOT', 'AND', 'OR', 'NEAR'):
|
||||
# Escape double quotes and wrap in quotes for literal search
|
||||
query_text = '"' + query_text.replace('"', '""') + '"'
|
||||
else:
|
||||
# Add prefix matching - append * to each word for partial matches
|
||||
# e.g., "summ text" becomes "summ* text*" to match "summarize", "text", "texting"
|
||||
words = query_text.split()
|
||||
query_text = " ".join(word + "*" if not word.endswith("*") else word for word in words)
|
||||
|
||||
page, per_page, sort, order, error = parse_pagination("/tools/search", "downloads")
|
||||
if error:
|
||||
@@ -894,6 +977,17 @@ def create_app() -> Flask:
|
||||
"url": row["source_url"],
|
||||
}
|
||||
|
||||
# Count forks of this tool
|
||||
fork_count = 0
|
||||
fork_pattern = f"{row['owner']}/{row['name']}"
|
||||
fork_row = query_one(
|
||||
g.db,
|
||||
"SELECT COUNT(DISTINCT owner || '/' || name) as cnt FROM tools WHERE forked_from = ? AND moderation_status = 'approved'",
|
||||
[fork_pattern]
|
||||
)
|
||||
if fork_row:
|
||||
fork_count = fork_row["cnt"]
|
||||
|
||||
payload = {
|
||||
"owner": row["owner"],
|
||||
"name": row["name"],
|
||||
@@ -909,6 +1003,9 @@ def create_app() -> Flask:
|
||||
"config": row["config_yaml"],
|
||||
"readme": row["readme"],
|
||||
"source": source_obj,
|
||||
"forked_from": row.get("forked_from"),
|
||||
"forked_version": row.get("forked_version"),
|
||||
"fork_count": fork_count,
|
||||
}
|
||||
response = jsonify({"data": payload})
|
||||
response.headers["Cache-Control"] = "max-age=60"
|
||||
@@ -937,6 +1034,55 @@ def create_app() -> Flask:
|
||||
versions = [row["version"] for row in rows]
|
||||
return jsonify({"data": {"versions": versions}})
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/forks", methods=["GET"])
|
||||
def list_tool_forks(owner: str, name: str) -> Response:
|
||||
"""List all forks of a tool."""
|
||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
|
||||
|
||||
# Check if the original tool exists
|
||||
original = query_one(
|
||||
g.db,
|
||||
"SELECT id FROM tools WHERE owner = ? AND name = ? LIMIT 1",
|
||||
[owner, name]
|
||||
)
|
||||
if not original:
|
||||
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404)
|
||||
|
||||
# Find all tools that were forked from this one
|
||||
fork_pattern = f"{owner}/{name}"
|
||||
rows = query_all(
|
||||
g.db,
|
||||
"""
|
||||
SELECT DISTINCT owner, name, description, forked_version,
|
||||
(SELECT MAX(version) FROM tools t2 WHERE t2.owner = t.owner AND t2.name = t.name) as latest_version,
|
||||
(SELECT SUM(downloads) FROM tools t2 WHERE t2.owner = t.owner AND t2.name = t.name) as total_downloads
|
||||
FROM tools t
|
||||
WHERE forked_from = ? AND moderation_status = 'approved'
|
||||
ORDER BY total_downloads DESC
|
||||
""",
|
||||
[fork_pattern]
|
||||
)
|
||||
|
||||
forks = [
|
||||
{
|
||||
"owner": row["owner"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"forked_version": row["forked_version"],
|
||||
"latest_version": row["latest_version"],
|
||||
"downloads": row["total_downloads"] or 0,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"forks": forks,
|
||||
"fork_count": len(forks),
|
||||
}
|
||||
})
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/download", methods=["GET"])
|
||||
def download_tool(owner: str, name: str) -> Response:
|
||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||
@@ -996,6 +1142,7 @@ def create_app() -> Flask:
|
||||
"resolved_version": row["version"],
|
||||
"config": row["config_yaml"],
|
||||
"readme": row["readme"] or "",
|
||||
"config_hash": row.get("config_hash") or "",
|
||||
}
|
||||
})
|
||||
response.headers["Cache-Control"] = "max-age=3600, immutable"
|
||||
@@ -1016,10 +1163,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}
|
||||
|
||||
@@ -1077,31 +1226,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]
|
||||
@@ -1199,6 +1352,203 @@ def create_app() -> Flask:
|
||||
response.headers["Cache-Control"] = "max-age=3600"
|
||||
return response
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Admin Collections Management
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@app.route("/api/v1/admin/collections", methods=["GET"])
|
||||
@require_auth
|
||||
def admin_list_collections() -> Response:
|
||||
"""List all collections with full details (admin)."""
|
||||
user = get_current_user()
|
||||
if not user or user.get("role") not in ("admin", "moderator"):
|
||||
return error_response("FORBIDDEN", "Admin access required", 403)
|
||||
|
||||
rows = query_all(
|
||||
g.db,
|
||||
"SELECT * FROM collections ORDER BY name",
|
||||
)
|
||||
data = []
|
||||
for row in rows:
|
||||
tools = json.loads(row["tools"]) if row["tools"] else []
|
||||
pinned = json.loads(row["pinned"]) if row["pinned"] else {}
|
||||
tags = json.loads(row["tags"]) if row["tags"] else []
|
||||
data.append({
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"display_name": row["display_name"],
|
||||
"description": row["description"],
|
||||
"icon": row["icon"],
|
||||
"maintainer": row["maintainer"],
|
||||
"tools": tools,
|
||||
"pinned": pinned,
|
||||
"tags": tags,
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
})
|
||||
return jsonify({"data": data})
|
||||
|
||||
@app.route("/api/v1/admin/collections", methods=["POST"])
|
||||
@require_auth
|
||||
def admin_create_collection() -> Response:
|
||||
"""Create a new collection (admin)."""
|
||||
user = get_current_user()
|
||||
if not user or user.get("role") != "admin":
|
||||
return error_response("FORBIDDEN", "Admin access required", 403)
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = data.get("name", "").strip().lower()
|
||||
display_name = data.get("display_name", "").strip()
|
||||
description = data.get("description", "").strip()
|
||||
icon = data.get("icon", "").strip()
|
||||
maintainer = data.get("maintainer", user.get("username", "")).strip()
|
||||
tools = data.get("tools", [])
|
||||
pinned = data.get("pinned", {})
|
||||
tags = data.get("tags", [])
|
||||
|
||||
# Validation
|
||||
if not name:
|
||||
return error_response("INVALID_INPUT", "Collection name is required", 400)
|
||||
if not display_name:
|
||||
display_name = name.replace("-", " ").title()
|
||||
if not isinstance(tools, list):
|
||||
return error_response("INVALID_INPUT", "Tools must be a list", 400)
|
||||
|
||||
# Check for duplicate
|
||||
existing = query_one(g.db, "SELECT id FROM collections WHERE name = ?", [name])
|
||||
if existing:
|
||||
return error_response("DUPLICATE", f"Collection '{name}' already exists", 409)
|
||||
|
||||
# Validate tool references
|
||||
validated_tools = []
|
||||
for tool_ref in tools:
|
||||
if isinstance(tool_ref, str) and "/" in tool_ref:
|
||||
validated_tools.append(tool_ref)
|
||||
|
||||
try:
|
||||
g.db.execute(
|
||||
"""
|
||||
INSERT INTO collections (name, display_name, description, icon, maintainer, tools, pinned, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
name, display_name, description, icon, maintainer,
|
||||
json.dumps(validated_tools),
|
||||
json.dumps(pinned) if pinned else None,
|
||||
json.dumps(tags) if tags else None,
|
||||
],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
# Audit log
|
||||
log_admin_action(g.db, user["id"], "create_collection", {"collection": name})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Collection '{name}' created",
|
||||
"name": name,
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
return error_response("SERVER_ERROR", str(e), 500)
|
||||
|
||||
@app.route("/api/v1/admin/collections/<name>", methods=["PUT"])
|
||||
@require_auth
|
||||
def admin_update_collection(name: str) -> Response:
|
||||
"""Update a collection (admin)."""
|
||||
user = get_current_user()
|
||||
if not user or user.get("role") != "admin":
|
||||
return error_response("FORBIDDEN", "Admin access required", 403)
|
||||
|
||||
existing = query_one(g.db, "SELECT * FROM collections WHERE name = ?", [name])
|
||||
if not existing:
|
||||
return error_response("COLLECTION_NOT_FOUND", f"Collection '{name}' not found", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Get updated values (keep existing if not provided)
|
||||
display_name = data.get("display_name", existing["display_name"])
|
||||
description = data.get("description", existing["description"])
|
||||
icon = data.get("icon", existing["icon"])
|
||||
maintainer = data.get("maintainer", existing["maintainer"])
|
||||
|
||||
# Handle tools - parse existing if not provided in request
|
||||
if "tools" in data:
|
||||
tools = data["tools"]
|
||||
if not isinstance(tools, list):
|
||||
return error_response("INVALID_INPUT", "Tools must be a list", 400)
|
||||
validated_tools = [t for t in tools if isinstance(t, str) and "/" in t]
|
||||
else:
|
||||
validated_tools = json.loads(existing["tools"]) if existing["tools"] else []
|
||||
|
||||
# Handle pinned versions
|
||||
if "pinned" in data:
|
||||
pinned = data["pinned"] if isinstance(data["pinned"], dict) else {}
|
||||
else:
|
||||
pinned = json.loads(existing["pinned"]) if existing["pinned"] else {}
|
||||
|
||||
# Handle tags
|
||||
if "tags" in data:
|
||||
tags = data["tags"] if isinstance(data["tags"], list) else []
|
||||
else:
|
||||
tags = json.loads(existing["tags"]) if existing["tags"] else []
|
||||
|
||||
try:
|
||||
g.db.execute(
|
||||
"""
|
||||
UPDATE collections
|
||||
SET display_name = ?, description = ?, icon = ?, maintainer = ?,
|
||||
tools = ?, pinned = ?, tags = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE name = ?
|
||||
""",
|
||||
[
|
||||
display_name, description, icon, maintainer,
|
||||
json.dumps(validated_tools),
|
||||
json.dumps(pinned) if pinned else None,
|
||||
json.dumps(tags) if tags else None,
|
||||
name,
|
||||
],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
# Audit log
|
||||
log_admin_action(g.db, user["id"], "update_collection", {"collection": name})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Collection '{name}' updated",
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return error_response("SERVER_ERROR", str(e), 500)
|
||||
|
||||
@app.route("/api/v1/admin/collections/<name>", methods=["DELETE"])
|
||||
@require_auth
|
||||
def admin_delete_collection(name: str) -> Response:
|
||||
"""Delete a collection (admin)."""
|
||||
user = get_current_user()
|
||||
if not user or user.get("role") != "admin":
|
||||
return error_response("FORBIDDEN", "Admin access required", 403)
|
||||
|
||||
existing = query_one(g.db, "SELECT id FROM collections WHERE name = ?", [name])
|
||||
if not existing:
|
||||
return error_response("COLLECTION_NOT_FOUND", f"Collection '{name}' not found", 404)
|
||||
|
||||
try:
|
||||
g.db.execute("DELETE FROM collections WHERE name = ?", [name])
|
||||
g.db.commit()
|
||||
|
||||
# Audit log
|
||||
log_admin_action(g.db, user["id"], "delete_collection", {"collection": name})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Collection '{name}' deleted",
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return error_response("SERVER_ERROR", str(e), 500)
|
||||
|
||||
@app.route("/api/v1/stats/popular", methods=["GET"])
|
||||
def popular_tools() -> Response:
|
||||
limit = min(int(request.args.get("limit", 10)), 50)
|
||||
@@ -1692,6 +2042,10 @@ def create_app() -> Flask:
|
||||
# Create a minimal source_json for consistency
|
||||
source_json = json.dumps({"type": "imported", "original_tool": source, "url": source_url})
|
||||
|
||||
# Extract fork metadata
|
||||
forked_from = (data.get("forked_from") or "").strip() or None
|
||||
forked_version = (data.get("forked_version") or "").strip() or None
|
||||
|
||||
if not name or not TOOL_NAME_RE.match(name) or len(name) > MAX_TOOL_NAME_LEN:
|
||||
return error_response("VALIDATION_ERROR", "Invalid tool name")
|
||||
if not version or Semver.parse(version) is None:
|
||||
@@ -1708,15 +2062,34 @@ def create_app() -> Flask:
|
||||
return error_response("VALIDATION_ERROR", "Tag exceeds 32 characters")
|
||||
|
||||
owner = g.current_publisher["slug"]
|
||||
|
||||
# Compute config hash early for idempotency check
|
||||
config_hash = compute_yaml_hash(config_text)
|
||||
|
||||
existing = query_one(
|
||||
g.db,
|
||||
"SELECT published_at FROM tools WHERE owner = ? AND name = ? AND version = ?",
|
||||
"SELECT published_at, config_hash, moderation_status, visibility FROM tools WHERE owner = ? AND name = ? AND version = ?",
|
||||
[owner, name, version],
|
||||
)
|
||||
if existing:
|
||||
# If same content (config_hash matches), treat as idempotent retry - return success
|
||||
if existing["config_hash"] == config_hash:
|
||||
return jsonify({
|
||||
"data": {
|
||||
"owner": owner,
|
||||
"name": name,
|
||||
"version": version,
|
||||
"config_hash": config_hash,
|
||||
"pr_url": "",
|
||||
"status": existing["moderation_status"],
|
||||
"visibility": existing["visibility"],
|
||||
"already_published": True,
|
||||
}
|
||||
})
|
||||
# Different content - actual version conflict
|
||||
return error_response(
|
||||
"VERSION_EXISTS",
|
||||
f"Version {version} already exists",
|
||||
f"Version {version} already exists with different content. Use a new version number.",
|
||||
409,
|
||||
details={"published_at": existing["published_at"]},
|
||||
)
|
||||
@@ -1779,6 +2152,20 @@ def create_app() -> Flask:
|
||||
details={"scrutiny": scrutiny_report},
|
||||
)
|
||||
|
||||
# Run AI secondary review if there are warnings
|
||||
if scrutiny_report.get("decision") == "review":
|
||||
try:
|
||||
ai_review = run_ai_scrutiny_review(scrutiny_report, data, name, description)
|
||||
if ai_review:
|
||||
scrutiny_report["ai_review"] = ai_review
|
||||
# Update decision based on AI review
|
||||
if ai_review.get("overall_verdict") == "APPROVE" and ai_review.get("confidence", 0) >= 0.8:
|
||||
scrutiny_report["decision"] = "approve"
|
||||
scrutiny_report["ai_approved"] = True
|
||||
except Exception:
|
||||
# Don't fail publish if AI review fails
|
||||
pass
|
||||
|
||||
if dry_run:
|
||||
return jsonify({
|
||||
"data": {
|
||||
@@ -1792,6 +2179,8 @@ def create_app() -> Flask:
|
||||
|
||||
tags_json = json.dumps(tags)
|
||||
|
||||
# config_hash already computed earlier for idempotency check
|
||||
|
||||
# Determine status based on scrutiny
|
||||
if scrutiny_report and scrutiny_report.get("decision") == "approve":
|
||||
scrutiny_status = "approved"
|
||||
@@ -1816,8 +2205,9 @@ def create_app() -> Flask:
|
||||
owner, name, version, description, category, tags, config_yaml, readme,
|
||||
publisher_id, deprecated, deprecated_message, replacement, downloads,
|
||||
scrutiny_status, scrutiny_report, source, source_url, source_json,
|
||||
visibility, moderation_status, published_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
config_hash, visibility, moderation_status, forked_from, forked_version,
|
||||
published_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
owner,
|
||||
@@ -1838,8 +2228,11 @@ def create_app() -> Flask:
|
||||
source,
|
||||
source_url,
|
||||
source_json,
|
||||
config_hash,
|
||||
visibility,
|
||||
moderation_status,
|
||||
forked_from,
|
||||
forked_version,
|
||||
datetime.utcnow().isoformat(),
|
||||
],
|
||||
)
|
||||
@@ -1850,9 +2243,12 @@ def create_app() -> Flask:
|
||||
"owner": owner,
|
||||
"name": name,
|
||||
"version": version,
|
||||
"config_hash": config_hash,
|
||||
"pr_url": "",
|
||||
"status": moderation_status,
|
||||
"visibility": visibility,
|
||||
"forked_from": forked_from,
|
||||
"forked_version": forked_version,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
})
|
||||
@@ -1887,6 +2283,42 @@ def create_app() -> Flask:
|
||||
})
|
||||
return jsonify({"data": data})
|
||||
|
||||
@app.route("/api/v1/me/tools/<name>/status", methods=["GET"])
|
||||
@require_token
|
||||
def my_tool_status(name: str) -> Response:
|
||||
"""Get the moderation status of a specific tool owned by the current user."""
|
||||
owner = g.current_publisher["slug"]
|
||||
|
||||
# Query for the most recent version of this tool
|
||||
row = query_one(
|
||||
g.db,
|
||||
"""
|
||||
SELECT name, version, moderation_status, moderation_note, config_hash, published_at
|
||||
FROM tools
|
||||
WHERE owner = ? AND name = ?
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
[owner, name],
|
||||
)
|
||||
|
||||
if not row:
|
||||
return error_response("TOOL_NOT_FOUND", f"Tool '{name}' not found", 404)
|
||||
|
||||
result = {
|
||||
"name": row["name"],
|
||||
"version": row["version"],
|
||||
"status": row["moderation_status"],
|
||||
"config_hash": row["config_hash"],
|
||||
"published_at": row["published_at"],
|
||||
}
|
||||
|
||||
# Include feedback if status is changes_requested or rejected
|
||||
if row["moderation_status"] in ("changes_requested", "rejected") and row["moderation_note"]:
|
||||
result["feedback"] = row["moderation_note"]
|
||||
|
||||
return jsonify({"data": result})
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/deprecate", methods=["POST"])
|
||||
@require_token
|
||||
def deprecate_tool(owner: str, name: str) -> Response:
|
||||
@@ -1980,6 +2412,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."""
|
||||
@@ -2303,6 +2819,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"],
|
||||
@@ -2313,6 +2837,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({
|
||||
@@ -2320,6 +2846,59 @@ def create_app() -> Flask:
|
||||
"meta": paginate(page, per_page, total),
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/tools/<int:tool_id>", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_get_tool(tool_id: int) -> Response:
|
||||
"""Get full tool details for admin review."""
|
||||
tool = query_one(
|
||||
g.db,
|
||||
"""
|
||||
SELECT t.*, p.display_name as publisher_name
|
||||
FROM tools t
|
||||
JOIN publishers p ON t.publisher_id = p.id
|
||||
WHERE t.id = ?
|
||||
""",
|
||||
[tool_id],
|
||||
)
|
||||
if not tool:
|
||||
return error_response("TOOL_NOT_FOUND", "Tool not found", 404)
|
||||
|
||||
# Parse config YAML to extract steps
|
||||
config = {}
|
||||
if tool["config_yaml"]:
|
||||
try:
|
||||
config = yaml.safe_load(tool["config_yaml"]) or {}
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
|
||||
# Parse scrutiny report
|
||||
scrutiny_report = None
|
||||
if tool["scrutiny_report"]:
|
||||
try:
|
||||
scrutiny_report = json.loads(tool["scrutiny_report"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"id": tool["id"],
|
||||
"owner": tool["owner"],
|
||||
"name": tool["name"],
|
||||
"version": tool["version"],
|
||||
"description": tool["description"],
|
||||
"category": tool["category"],
|
||||
"tags": tool["tags"],
|
||||
"published_at": tool["published_at"],
|
||||
"publisher_name": tool["publisher_name"],
|
||||
"visibility": tool["visibility"],
|
||||
"moderation_status": tool["moderation_status"],
|
||||
"scrutiny_status": tool["scrutiny_status"],
|
||||
"scrutiny_report": scrutiny_report,
|
||||
"config": config,
|
||||
"readme": tool["readme"] or "",
|
||||
}
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/tools/<int:tool_id>/approve", methods=["POST"])
|
||||
@require_moderator
|
||||
def admin_approve_tool(tool_id: int) -> Response:
|
||||
@@ -2382,6 +2961,127 @@ def create_app() -> Flask:
|
||||
|
||||
return jsonify({"data": {"status": "rejected", "tool_id": tool_id}})
|
||||
|
||||
@app.route("/api/v1/admin/tools/<int:tool_id>/request-changes", methods=["POST"])
|
||||
@require_moderator
|
||||
def admin_request_changes(tool_id: int) -> Response:
|
||||
"""Request changes from tool publisher before approval."""
|
||||
data = request.get_json() or {}
|
||||
feedback = (data.get("feedback") or data.get("reason") or "").strip()
|
||||
|
||||
if not feedback:
|
||||
return error_response("VALIDATION_ERROR", "Feedback is required", 400)
|
||||
|
||||
tool = query_one(g.db, "SELECT * FROM tools WHERE id = ?", [tool_id])
|
||||
if not tool:
|
||||
return error_response("TOOL_NOT_FOUND", "Tool not found", 404)
|
||||
|
||||
g.db.execute(
|
||||
"""
|
||||
UPDATE tools
|
||||
SET moderation_status = 'changes_requested',
|
||||
moderation_note = ?,
|
||||
moderated_by = ?,
|
||||
moderated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
[feedback, g.current_publisher["slug"], datetime.utcnow().isoformat(), tool_id],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
log_audit("request_changes", "tool", str(tool_id), {
|
||||
"tool": f"{tool['owner']}/{tool['name']}",
|
||||
"version": tool["version"],
|
||||
"feedback": feedback[:200], # Truncate for audit log
|
||||
})
|
||||
|
||||
return jsonify({"data": {"status": "changes_requested", "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/<int:tool_id>/remove", methods=["POST"])
|
||||
@require_moderator
|
||||
def admin_remove_tool(tool_id: int) -> Response:
|
||||
@@ -2414,6 +3114,85 @@ def create_app() -> Flask:
|
||||
|
||||
return jsonify({"data": {"status": "removed", "tool_id": tool_id}})
|
||||
|
||||
@app.route("/api/v1/admin/cleanup/rejected", methods=["POST"])
|
||||
@require_admin
|
||||
def admin_cleanup_rejected() -> Response:
|
||||
"""
|
||||
Delete rejected tool versions older than N days.
|
||||
|
||||
This endpoint permanently deletes tools that were rejected during
|
||||
moderation after a grace period, allowing users time to see the
|
||||
rejection reason and fix issues.
|
||||
|
||||
Query params:
|
||||
days: Number of days to retain rejected versions (default: 7)
|
||||
dry_run: If "true", only report what would be deleted without deleting
|
||||
"""
|
||||
days = request.args.get("days", 7, type=int)
|
||||
dry_run = request.args.get("dry_run", "false").lower() == "true"
|
||||
|
||||
if days < 0:
|
||||
return error_response("VALIDATION_ERROR", "Days must be non-negative", 400)
|
||||
|
||||
# Calculate cutoff date
|
||||
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
|
||||
|
||||
# Find rejected tools older than cutoff
|
||||
rejected_tools = query_all(
|
||||
g.db,
|
||||
"""
|
||||
SELECT id, owner, name, version, moderated_at
|
||||
FROM tools
|
||||
WHERE moderation_status = 'rejected'
|
||||
AND moderated_at < ?
|
||||
""",
|
||||
[cutoff],
|
||||
)
|
||||
|
||||
if not rejected_tools:
|
||||
return jsonify({
|
||||
"data": {
|
||||
"deleted_count": 0,
|
||||
"dry_run": dry_run,
|
||||
"cutoff_date": cutoff,
|
||||
"deleted_tools": [],
|
||||
}
|
||||
})
|
||||
|
||||
deleted_tools = []
|
||||
for tool in rejected_tools:
|
||||
deleted_tools.append({
|
||||
"id": tool["id"],
|
||||
"name": f"{tool['owner']}/{tool['name']}",
|
||||
"version": tool["version"],
|
||||
"moderated_at": tool["moderated_at"],
|
||||
})
|
||||
|
||||
if not dry_run:
|
||||
tool_id = tool["id"]
|
||||
# Delete associated records
|
||||
g.db.execute("DELETE FROM download_stats WHERE tool_id = ?", [tool_id])
|
||||
g.db.execute("DELETE FROM reports WHERE tool_id = ?", [tool_id])
|
||||
g.db.execute("DELETE FROM featured_tools WHERE tool_id = ?", [tool_id])
|
||||
g.db.execute("DELETE FROM tools WHERE id = ?", [tool_id])
|
||||
|
||||
if not dry_run:
|
||||
g.db.commit()
|
||||
log_audit("cleanup_rejected", "system", "cleanup", {
|
||||
"days": days,
|
||||
"deleted_count": len(deleted_tools),
|
||||
"tool_ids": [t["id"] for t in deleted_tools],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"deleted_count": len(deleted_tools),
|
||||
"dry_run": dry_run,
|
||||
"cutoff_date": cutoff,
|
||||
"deleted_tools": deleted_tools,
|
||||
}
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/tools/<int:tool_id>", methods=["DELETE"])
|
||||
@require_admin
|
||||
def admin_delete_tool(tool_id: int) -> Response:
|
||||
|
||||
@@ -59,11 +59,14 @@ CREATE TABLE IF NOT EXISTS tools (
|
||||
source TEXT,
|
||||
source_url TEXT,
|
||||
source_json TEXT,
|
||||
config_hash TEXT,
|
||||
visibility TEXT DEFAULT 'public',
|
||||
moderation_status TEXT DEFAULT 'pending',
|
||||
moderation_note TEXT,
|
||||
moderated_by TEXT,
|
||||
moderated_at TIMESTAMP,
|
||||
forked_from TEXT,
|
||||
forked_version TEXT,
|
||||
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(owner, name, version)
|
||||
);
|
||||
@@ -448,11 +451,14 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
("source", "TEXT", "NULL"),
|
||||
("source_url", "TEXT", "NULL"),
|
||||
("source_json", "TEXT", "NULL"),
|
||||
("config_hash", "TEXT", "NULL"),
|
||||
("visibility", "TEXT", "'public'"),
|
||||
("moderation_status", "TEXT", "'pending'"),
|
||||
("moderation_note", "TEXT", "NULL"),
|
||||
("moderated_by", "TEXT", "NULL"),
|
||||
("moderated_at", "TIMESTAMP", "NULL"),
|
||||
("forked_from", "TEXT", "NULL"),
|
||||
("forked_version", "TEXT", "NULL"),
|
||||
]
|
||||
|
||||
for col_name, col_type, default in tools_migrations:
|
||||
@@ -497,12 +503,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:
|
||||
@@ -512,6 +519,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_moderation ON tools(moderation_status, visibility)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_hash ON tools(config_hash)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_role ON publishers(role)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_banned ON publishers(banned)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id)")
|
||||
|
||||
@@ -112,6 +112,7 @@ class DownloadResult:
|
||||
resolved_version: str
|
||||
config_yaml: str
|
||||
readme: str = ""
|
||||
config_hash: str = "" # Registry hash for integrity verification
|
||||
|
||||
|
||||
class RegistryClient:
|
||||
@@ -544,7 +545,8 @@ class RegistryClient:
|
||||
name=data.get("name", name),
|
||||
resolved_version=data.get("resolved_version", ""),
|
||||
config_yaml=data.get("config", ""),
|
||||
readme=data.get("readme", "")
|
||||
readme=data.get("readme", ""),
|
||||
config_hash=data.get("config_hash", "")
|
||||
)
|
||||
|
||||
def get_categories(self) -> List[Dict[str, Any]]:
|
||||
@@ -561,6 +563,44 @@ class RegistryClient:
|
||||
|
||||
return response.json().get("data", [])
|
||||
|
||||
def get_collections(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of tool collections.
|
||||
|
||||
Returns:
|
||||
List of collection dicts with name, display_name, description, tools
|
||||
"""
|
||||
response = self._request("GET", "/collections")
|
||||
|
||||
if response.status_code != 200:
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", [])
|
||||
|
||||
def get_collection(self, name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed information about a collection.
|
||||
|
||||
Args:
|
||||
name: Collection name
|
||||
|
||||
Returns:
|
||||
Collection dict with tools and pinned versions
|
||||
"""
|
||||
response = self._request("GET", f"/collections/{name}")
|
||||
|
||||
if response.status_code == 404:
|
||||
raise RegistryError(
|
||||
code="COLLECTION_NOT_FOUND",
|
||||
message=f"Collection '{name}' not found",
|
||||
http_status=404
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", {})
|
||||
|
||||
def publish_tool(
|
||||
self,
|
||||
config_yaml: str,
|
||||
@@ -603,6 +643,33 @@ class RegistryClient:
|
||||
|
||||
return response.json().get("data", {})
|
||||
|
||||
def validate_token(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate that the current token is valid.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
- (True, "") if token is valid
|
||||
- (False, "reason") if token is invalid or missing
|
||||
"""
|
||||
if not self.token:
|
||||
return False, "No token configured"
|
||||
|
||||
try:
|
||||
response = self._request("GET", "/me/tools", require_auth=True)
|
||||
if response.status_code == 200:
|
||||
return True, ""
|
||||
elif response.status_code == 401:
|
||||
return False, "Token is invalid or revoked"
|
||||
else:
|
||||
return False, f"Unexpected response: {response.status_code}"
|
||||
except RegistryError as e:
|
||||
if e.http_status == 401 or e.code == "UNAUTHORIZED":
|
||||
return False, "Token is invalid or revoked"
|
||||
return False, str(e.message)
|
||||
except Exception as e:
|
||||
return False, f"Connection error: {e}"
|
||||
|
||||
def get_my_tools(self) -> List[ToolInfo]:
|
||||
"""
|
||||
Get tools published by the authenticated user.
|
||||
@@ -618,6 +685,30 @@ class RegistryClient:
|
||||
tools = response.json().get("data", [])
|
||||
return [ToolInfo.from_dict(t) for t in tools]
|
||||
|
||||
def get_my_tool_status(self, name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the moderation status of a specific tool owned by the authenticated user.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
Dict with name, version, status (pending/approved/rejected), config_hash, published_at
|
||||
"""
|
||||
response = self._request("GET", f"/me/tools/{name}/status", require_auth=True)
|
||||
|
||||
if response.status_code == 404:
|
||||
raise RegistryError(
|
||||
code="TOOL_NOT_FOUND",
|
||||
message=f"Tool '{name}' not found",
|
||||
http_status=404
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", {})
|
||||
|
||||
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
|
||||
"""
|
||||
Get most popular tools.
|
||||
|
||||
@@ -352,7 +352,8 @@ class ToolResolver:
|
||||
name=result.name,
|
||||
version=result.resolved_version,
|
||||
config_yaml=result.config_yaml,
|
||||
readme=result.readme
|
||||
readme=result.readme,
|
||||
config_hash=result.config_hash
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
@@ -379,13 +380,30 @@ class ToolResolver:
|
||||
name: str,
|
||||
version: str,
|
||||
config_yaml: str,
|
||||
readme: str = ""
|
||||
readme: str = "",
|
||||
config_hash: str = ""
|
||||
) -> ResolvedTool:
|
||||
"""Install a tool fetched from registry to global directory."""
|
||||
# Verify hash if provided
|
||||
if config_hash:
|
||||
from .hash_utils import compute_yaml_hash
|
||||
computed_hash = compute_yaml_hash(config_yaml)
|
||||
if computed_hash != config_hash:
|
||||
raise RuntimeError(
|
||||
f"Hash mismatch for {owner}/{name}: expected {config_hash[:20]}..., "
|
||||
f"got {computed_hash[:20]}... - content may have been tampered with"
|
||||
)
|
||||
|
||||
# Create directory structure
|
||||
tool_dir = TOOLS_DIR / owner / name
|
||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Add registry_hash to config so we can track publish state
|
||||
if config_hash:
|
||||
parsed_config = yaml.safe_load(config_yaml)
|
||||
parsed_config["registry_hash"] = config_hash
|
||||
config_yaml = yaml.dump(parsed_config, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Write config
|
||||
config_path = tool_dir / "config.yaml"
|
||||
config_path.write_text(config_yaml)
|
||||
@@ -593,7 +611,8 @@ def install_from_registry(spec: str, version: Optional[str] = None) -> ResolvedT
|
||||
name=result.name,
|
||||
version=result.resolved_version,
|
||||
config_yaml=result.config_yaml,
|
||||
readme=result.readme
|
||||
readme=result.readme,
|
||||
config_hash=result.config_hash
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ class Tool:
|
||||
dependencies: List[str] = field(default_factory=list) # Required tools for meta-tools
|
||||
source: Optional[ToolSource] = None # Attribution for imported/external tools
|
||||
version: str = "" # Tool version
|
||||
path: Optional[Path] = None # Path to config.yaml (set by load_tool)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Tool":
|
||||
@@ -331,7 +332,9 @@ def load_tool(name: str) -> Optional[Tool]:
|
||||
"output": "{response}" if steps else "{input}"
|
||||
}
|
||||
|
||||
return Tool.from_dict(data)
|
||||
tool = Tool.from_dict(data)
|
||||
tool.path = config_path
|
||||
return tool
|
||||
except yaml.YAMLError as e:
|
||||
import sys
|
||||
print(f"Error loading tool '{name}': YAML syntax error", file=sys.stderr)
|
||||
|
||||
+260
-5
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -380,6 +418,10 @@ def tool_detail(owner: str, name: str):
|
||||
issues = issues_payload.get("data", [])
|
||||
issues_meta = issues_payload.get("meta", {})
|
||||
|
||||
# Load forks list
|
||||
_, forks_payload = _api_get(f"/api/v1/tools/{owner}/{name}/forks")
|
||||
forks = forks_payload.get("data", {}).get("forks", [])
|
||||
|
||||
return render_template(
|
||||
"pages/tool_detail.html",
|
||||
tool=tool,
|
||||
@@ -390,6 +432,7 @@ def tool_detail(owner: str, name: str):
|
||||
reviews_total=reviews_meta.get("total", 0),
|
||||
issues=issues,
|
||||
issues_total=issues_meta.get("total", 0),
|
||||
forks=forks,
|
||||
)
|
||||
|
||||
|
||||
@@ -422,6 +465,10 @@ def tool_version(owner: str, name: str, version: str):
|
||||
issues = issues_payload.get("data", [])
|
||||
issues_meta = issues_payload.get("meta", {})
|
||||
|
||||
# Load forks list
|
||||
_, forks_payload = _api_get(f"/api/v1/tools/{owner}/{name}/forks")
|
||||
forks = forks_payload.get("data", {}).get("forks", [])
|
||||
|
||||
return render_template(
|
||||
"pages/tool_detail.html",
|
||||
tool=tool,
|
||||
@@ -432,6 +479,7 @@ def tool_version(owner: str, name: str, version: str):
|
||||
reviews_total=reviews_meta.get("total", 0),
|
||||
issues=issues,
|
||||
issues_total=issues_meta.get("total", 0),
|
||||
forks=forks,
|
||||
)
|
||||
|
||||
|
||||
@@ -564,7 +612,32 @@ def dashboard_tools():
|
||||
token = session.get("auth_token")
|
||||
user = _load_current_publisher() or session.get("user", {})
|
||||
status, payload = _api_get("/api/v1/me/tools", token=token)
|
||||
tools = payload.get("data", []) if status == 200 else []
|
||||
all_tools = payload.get("data", []) if status == 200 else []
|
||||
|
||||
# Consolidate multiple versions into single tool entries
|
||||
# Group by tool name, keep latest version as main, collect all versions
|
||||
tools_by_name = {}
|
||||
for tool in all_tools:
|
||||
name = tool.get("name")
|
||||
if name not in tools_by_name:
|
||||
tools_by_name[name] = {
|
||||
**tool,
|
||||
"versions": [tool.get("version")],
|
||||
"all_downloads": tool.get("downloads", 0),
|
||||
}
|
||||
else:
|
||||
# Add this version to the list
|
||||
tools_by_name[name]["versions"].append(tool.get("version"))
|
||||
tools_by_name[name]["all_downloads"] += tool.get("downloads", 0)
|
||||
# Keep the latest version's data (assuming sorted by published_at desc)
|
||||
# The first one we saw is the latest, so no need to update
|
||||
|
||||
# Convert back to list and sort versions for display
|
||||
tools = list(tools_by_name.values())
|
||||
for tool in tools:
|
||||
tool["versions"].sort(key=lambda v: [int(x) for x in v.split(".")], reverse=True)
|
||||
tool["version_count"] = len(tool["versions"])
|
||||
tool["downloads"] = tool["all_downloads"] # Use total across all versions
|
||||
|
||||
# Load rating/issue data for each tool
|
||||
for tool in tools:
|
||||
@@ -625,13 +698,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 +763,8 @@ def dashboard_settings():
|
||||
tools=tools,
|
||||
stats=stats,
|
||||
tokens=tokens,
|
||||
errors=[],
|
||||
success_message=None,
|
||||
errors=errors,
|
||||
success_message=success_message,
|
||||
)
|
||||
|
||||
|
||||
@@ -803,6 +919,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
|
||||
@@ -1006,6 +1124,10 @@ def admin_dashboard():
|
||||
status, payload = _api_get("/api/v1/admin/publishers?per_page=1", token=token)
|
||||
publishers_count = payload.get("meta", {}).get("total", 0) if status == 200 else 0
|
||||
|
||||
# Get rejected tools count for cleanup
|
||||
status, payload = _api_get("/api/v1/admin/scrutiny?moderation_status=rejected&per_page=1", token=token)
|
||||
rejected_count = payload.get("meta", {}).get("total", 0) if status == 200 else 0
|
||||
|
||||
return render_template(
|
||||
"admin/index.html",
|
||||
user=user,
|
||||
@@ -1013,6 +1135,7 @@ def admin_dashboard():
|
||||
pending_count=pending_count,
|
||||
reports_count=reports_count,
|
||||
publishers_count=publishers_count,
|
||||
rejected_count=rejected_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -1132,3 +1255,135 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/admin/collections", endpoint="admin_collections")
|
||||
def admin_collections():
|
||||
"""Manage tool collections."""
|
||||
forbidden = _require_admin_role()
|
||||
if forbidden:
|
||||
return forbidden
|
||||
|
||||
user = _load_current_publisher()
|
||||
token = session.get("auth_token")
|
||||
|
||||
status, payload = _api_get("/api/v1/admin/collections", token=token)
|
||||
collections = payload.get("data", []) if status == 200 else []
|
||||
|
||||
return render_template(
|
||||
"admin/collections.html",
|
||||
user=user,
|
||||
active_page="admin_collections",
|
||||
collections=collections,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/admin/collections/new", endpoint="admin_collection_new")
|
||||
def admin_collection_new():
|
||||
"""Create a new collection form."""
|
||||
forbidden = _require_admin_role()
|
||||
if forbidden:
|
||||
return forbidden
|
||||
|
||||
user = _load_current_publisher()
|
||||
return render_template(
|
||||
"admin/collection_form.html",
|
||||
user=user,
|
||||
active_page="admin_collections",
|
||||
collection=None,
|
||||
is_new=True,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/admin/collections/<name>/edit", endpoint="admin_collection_edit")
|
||||
def admin_collection_edit(name):
|
||||
"""Edit a collection form."""
|
||||
forbidden = _require_admin_role()
|
||||
if forbidden:
|
||||
return forbidden
|
||||
|
||||
user = _load_current_publisher()
|
||||
token = session.get("auth_token")
|
||||
|
||||
status, payload = _api_get(f"/api/v1/collections/{name}", token=token)
|
||||
if status != 200:
|
||||
flash(f"Collection not found: {name}", "error")
|
||||
return redirect(url_for("web.admin_collections"))
|
||||
|
||||
collection = payload.get("data", {})
|
||||
|
||||
return render_template(
|
||||
"admin/collection_form.html",
|
||||
user=user,
|
||||
active_page="admin_collections",
|
||||
collection=collection,
|
||||
is_new=False,
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block dashboard_header %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ 'New Collection' if is_new else 'Edit Collection' }}</h1>
|
||||
<p class="mt-1 text-gray-600">{{ 'Create a new tool collection' if is_new else 'Update collection details' }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('web.admin_collections') }}" class="text-sm text-indigo-600 hover:text-indigo-700">← Back to Collections</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<form id="collection-form" class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Name (slug)</label>
|
||||
<input type="text" id="name" name="name"
|
||||
value="{{ collection.name if collection else '' }}"
|
||||
{% if not is_new %}readonly{% endif %}
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {% if not is_new %}bg-gray-100{% endif %}"
|
||||
placeholder="e.g., writing-toolkit"
|
||||
pattern="[a-z0-9-]+"
|
||||
required>
|
||||
<p class="mt-1 text-xs text-gray-500">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="display_name" class="block text-sm font-medium text-gray-700">Display Name</label>
|
||||
<input type="text" id="display_name" name="display_name"
|
||||
value="{{ collection.display_name if collection else '' }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g., Writing Toolkit"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea id="description" name="description" rows="3"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Describe what this collection is for...">{{ collection.description if collection else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="maintainer" class="block text-sm font-medium text-gray-700">Maintainer</label>
|
||||
<input type="text" id="maintainer" name="maintainer"
|
||||
value="{{ collection.maintainer if collection else user.username }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g., official">
|
||||
</div>
|
||||
<div>
|
||||
<label for="icon" class="block text-sm font-medium text-gray-700">Icon (optional)</label>
|
||||
<input type="text" id="icon" name="icon"
|
||||
value="{{ collection.icon if collection else '' }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g., pencil, code, data">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tags" class="block text-sm font-medium text-gray-700">Tags</label>
|
||||
<input type="text" id="tags" name="tags"
|
||||
value="{{ collection.tags|join(', ') if collection and collection.tags else '' }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g., writing, text, productivity">
|
||||
<p class="mt-1 text-xs text-gray-500">Comma-separated list of tags</p>
|
||||
</div>
|
||||
|
||||
<!-- Tools Section -->
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Tools</label>
|
||||
<button type="button" onclick="addToolRow()" class="text-sm text-indigo-600 hover:text-indigo-700">+ Add Tool</button>
|
||||
</div>
|
||||
<div id="tools-container" class="space-y-2">
|
||||
{% if collection and collection.tools %}
|
||||
{% for tool in collection.tools %}
|
||||
<div class="tool-row flex items-center space-x-2">
|
||||
<input type="text" name="tool" value="{{ tool.owner }}/{{ tool.name }}"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="owner/tool-name">
|
||||
<input type="text" name="pinned" value="{{ tool.pinned_version or '' }}"
|
||||
class="w-32 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="version">
|
||||
<button type="button" onclick="removeToolRow(this)" class="text-red-500 hover:text-red-700 p-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="tool-row flex items-center space-x-2">
|
||||
<input type="text" name="tool"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="owner/tool-name">
|
||||
<input type="text" name="pinned"
|
||||
class="w-32 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="version">
|
||||
<button type="button" onclick="removeToolRow(this)" class="text-red-500 hover:text-red-700 p-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">Enter tool references as owner/name. Version is optional (leave blank for latest).</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex justify-end space-x-4 pt-4 border-t border-gray-200">
|
||||
<a href="{{ url_for('web.admin_collections') }}" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</a>
|
||||
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
|
||||
{{ 'Create Collection' if is_new else 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function addToolRow() {
|
||||
const container = document.getElementById('tools-container');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tool-row flex items-center space-x-2';
|
||||
row.innerHTML = `
|
||||
<input type="text" name="tool"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="owner/tool-name">
|
||||
<input type="text" name="pinned"
|
||||
class="w-32 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="version">
|
||||
<button type="button" onclick="removeToolRow(this)" class="text-red-500 hover:text-red-700 p-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function removeToolRow(button) {
|
||||
const row = button.closest('.tool-row');
|
||||
const container = document.getElementById('tools-container');
|
||||
if (container.children.length > 1) {
|
||||
row.remove();
|
||||
} else {
|
||||
// Clear the inputs instead of removing the last row
|
||||
row.querySelectorAll('input').forEach(input => input.value = '');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('collection-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const isNew = {{ 'true' if is_new else 'false' }};
|
||||
const collectionName = document.getElementById('name').value;
|
||||
|
||||
// Collect tools and pinned versions
|
||||
const toolInputs = document.querySelectorAll('input[name="tool"]');
|
||||
const pinnedInputs = document.querySelectorAll('input[name="pinned"]');
|
||||
const tools = [];
|
||||
const pinned = {};
|
||||
|
||||
toolInputs.forEach((input, index) => {
|
||||
const toolRef = input.value.trim();
|
||||
if (toolRef && toolRef.includes('/')) {
|
||||
tools.push(toolRef);
|
||||
const pinnedVersion = pinnedInputs[index].value.trim();
|
||||
if (pinnedVersion) {
|
||||
pinned[toolRef] = pinnedVersion;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Parse tags
|
||||
const tagsStr = document.getElementById('tags').value;
|
||||
const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t);
|
||||
|
||||
const payload = {
|
||||
name: collectionName,
|
||||
display_name: document.getElementById('display_name').value,
|
||||
description: document.getElementById('description').value,
|
||||
maintainer: document.getElementById('maintainer').value,
|
||||
icon: document.getElementById('icon').value,
|
||||
tools: tools,
|
||||
pinned: pinned,
|
||||
tags: tags
|
||||
};
|
||||
|
||||
try {
|
||||
const url = isNew
|
||||
? '/api/v1/admin/collections'
|
||||
: `/api/v1/admin/collections/${collectionName}`;
|
||||
const method = isNew ? 'POST' : 'PUT';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Authorization': 'Bearer {{ session.get("auth_token") }}',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = '{{ url_for("web.admin_collections") }}';
|
||||
} else {
|
||||
alert(data.error?.message || 'Failed to save collection');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error: ' + error.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block dashboard_header %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Collections</h1>
|
||||
<p class="mt-1 text-gray-600">Manage tool collections</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="{{ url_for('web.admin_collection_new') }}" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
|
||||
New Collection
|
||||
</a>
|
||||
<a href="{{ url_for('web.admin_dashboard') }}" class="text-sm text-indigo-600 hover:text-indigo-700">← Back to Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<div class="bg-white rounded-lg border border-gray-200">
|
||||
{% if collections %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Collection</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Maintainer</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tools</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tags</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for coll in collections %}
|
||||
<tr id="coll-row-{{ coll.name }}" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ coll.display_name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ coll.name }}</div>
|
||||
{% if coll.description %}
|
||||
<div class="text-xs text-gray-400 mt-1">{{ coll.description[:80] }}{% if coll.description|length > 80 %}...{% endif %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ coll.maintainer }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ coll.tools|length }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% for tag in coll.tags[:3] %}
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 mr-1">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% if coll.tags|length > 3 %}
|
||||
<span class="text-xs text-gray-400">+{{ coll.tags|length - 3 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ url_for('web.admin_collection_edit', name=coll.name) }}" class="text-indigo-600 hover:text-indigo-900 mr-3">Edit</a>
|
||||
<button onclick="deleteCollection('{{ coll.name }}', '{{ coll.display_name }}')" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<h3 class="text-sm font-medium text-gray-900">No collections</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a new collection.</p>
|
||||
<div class="mt-6">
|
||||
<a href="{{ url_for('web.admin_collection_new') }}" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
|
||||
New Collection
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div id="delete-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-red-600 mb-2">Delete Collection</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Are you sure you want to delete <strong id="delete-collection-name"></strong>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="mt-4 flex justify-end space-x-3">
|
||||
<button onclick="closeDeleteModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</button>
|
||||
<button onclick="confirmDelete()" class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let deleteCollectionName = null;
|
||||
|
||||
function deleteCollection(name, displayName) {
|
||||
deleteCollectionName = name;
|
||||
document.getElementById('delete-collection-name').textContent = displayName;
|
||||
document.getElementById('delete-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('delete-modal').classList.add('hidden');
|
||||
deleteCollectionName = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteCollectionName) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/collections/${deleteCollectionName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': 'Bearer {{ session.get("auth_token") }}'
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(data.error?.message || 'Failed to delete collection');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -83,6 +83,12 @@
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Manage publishers</span>
|
||||
</a>
|
||||
<a href="{{ url_for('web.admin_scrutiny') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Scrutiny audit</span>
|
||||
</a>
|
||||
{% if user.role == 'admin' %}
|
||||
<a href="{{ url_for('web.admin_settings') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -91,6 +97,12 @@
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Registry settings</span>
|
||||
</a>
|
||||
<a href="{{ url_for('web.admin_collections') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Manage collections</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('web.dashboard') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -100,5 +112,135 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Section (Admin only) -->
|
||||
{% if user.role == 'admin' %}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Maintenance</h3>
|
||||
<div class="space-y-4">
|
||||
<!-- Cleanup Rejected Versions -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-red-100 rounded-lg p-2 mr-4">
|
||||
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700">Cleanup Rejected Versions</p>
|
||||
<p class="text-xs text-gray-500">Delete rejected tool versions older than 7 days</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<span id="rejected-count">{{ rejected_count }}</span> rejected version(s) in registry
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="runCleanup(true)" class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Dry Run
|
||||
</button>
|
||||
<button onclick="runCleanup(false)" class="px-3 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
|
||||
Run Cleanup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Cleanup Result Modal -->
|
||||
<div id="cleanup-modal" class="fixed inset-0 bg-gray-900 bg-opacity-60 hidden overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto border-2 border-gray-300 w-[32rem] shadow-2xl rounded-lg bg-white">
|
||||
<div class="bg-gray-50 px-5 py-4 rounded-t-lg border-b border-gray-200">
|
||||
<h3 id="cleanup-modal-title" class="text-lg font-medium text-gray-900">Cleanup Results</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div id="cleanup-loading" class="text-center py-4">
|
||||
<svg class="animate-spin h-8 w-8 text-indigo-600 mx-auto" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-600">Running cleanup...</p>
|
||||
</div>
|
||||
<div id="cleanup-result" class="hidden">
|
||||
<div id="cleanup-summary" class="mb-4 p-3 rounded-lg bg-gray-50"></div>
|
||||
<div id="cleanup-list" class="max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-4 rounded-b-lg flex justify-end">
|
||||
<button onclick="closeCleanupModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function runCleanup(dryRun) {
|
||||
document.getElementById('cleanup-modal').classList.remove('hidden');
|
||||
document.getElementById('cleanup-loading').classList.remove('hidden');
|
||||
document.getElementById('cleanup-result').classList.add('hidden');
|
||||
document.getElementById('cleanup-modal-title').textContent = dryRun ? 'Cleanup Preview (Dry Run)' : 'Cleanup Results';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/cleanup/rejected?days=7&dry_run=${dryRun}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer {{ session.get("auth_token") }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error?.message || 'Cleanup failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const data = result.data;
|
||||
|
||||
document.getElementById('cleanup-loading').classList.add('hidden');
|
||||
document.getElementById('cleanup-result').classList.remove('hidden');
|
||||
|
||||
// Summary
|
||||
const summaryHtml = `
|
||||
<p class="text-sm">
|
||||
<span class="font-medium">${data.deleted_count}</span> rejected version(s)
|
||||
${data.dry_run ? 'would be' : 'were'} deleted
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Cutoff: ${data.cutoff_date.split('T')[0]}</p>
|
||||
${data.dry_run ? '<p class="text-xs text-yellow-600 mt-2">This was a dry run. No tools were actually deleted.</p>' : ''}
|
||||
`;
|
||||
document.getElementById('cleanup-summary').innerHTML = summaryHtml;
|
||||
|
||||
// List of deleted tools
|
||||
if (data.deleted_tools && data.deleted_tools.length > 0) {
|
||||
const listHtml = '<ul class="space-y-2">' + data.deleted_tools.map(tool => `
|
||||
<li class="text-sm p-2 bg-white rounded border">
|
||||
<span class="font-medium">${tool.name}</span>
|
||||
<span class="text-gray-500">v${tool.version}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">rejected ${tool.moderated_at?.split('T')[0] || 'unknown'}</span>
|
||||
</li>
|
||||
`).join('') + '</ul>';
|
||||
document.getElementById('cleanup-list').innerHTML = listHtml;
|
||||
} else {
|
||||
document.getElementById('cleanup-list').innerHTML = '<p class="text-sm text-gray-500">No rejected versions found older than 7 days.</p>';
|
||||
}
|
||||
|
||||
// Update the count display if not dry run
|
||||
if (!data.dry_run) {
|
||||
const newCount = Math.max(0, parseInt(document.getElementById('rejected-count').textContent) - data.deleted_count);
|
||||
document.getElementById('rejected-count').textContent = newCount;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('cleanup-loading').classList.add('hidden');
|
||||
document.getElementById('cleanup-result').classList.remove('hidden');
|
||||
document.getElementById('cleanup-summary').innerHTML = `<p class="text-sm text-red-600">Error: ${error.message}</p>`;
|
||||
document.getElementById('cleanup-list').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function closeCleanupModal() {
|
||||
document.getElementById('cleanup-modal').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tool</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Publisher</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scrutiny</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Submitted</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
@@ -30,24 +30,56 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ tool.owner }}/{{ tool.name }}</div>
|
||||
<button onclick="viewTool({{ tool.id }})" class="text-sm font-medium text-indigo-600 hover:text-indigo-800 hover:underline">
|
||||
{{ tool.owner }}/{{ tool.name }}
|
||||
</button>
|
||||
<div class="text-sm text-gray-500">v{{ tool.version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900">{{ tool.publisher_name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ tool.category or 'Uncategorized' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
{{ tool.category or 'Uncategorized' }}
|
||||
<td class="px-6 py-4">
|
||||
{% if tool.scrutiny_status == 'approved' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Passed
|
||||
</span>
|
||||
{% elif tool.scrutiny_status == 'pending_review' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Warnings
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
{{ tool.scrutiny_status or 'N/A' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if tool.scrutiny_report and tool.scrutiny_report.findings %}
|
||||
<button onclick="toggleFindings({{ tool.id }})" class="ml-2 text-xs text-indigo-600 hover:text-indigo-800">
|
||||
({{ tool.scrutiny_report.findings | selectattr('result', 'equalto', 'warning') | list | length }} warnings)
|
||||
</button>
|
||||
<div id="findings-{{ tool.id }}" class="hidden mt-2 text-xs space-y-1 max-w-md">
|
||||
{% for finding in tool.scrutiny_report.findings %}
|
||||
{% if finding.result == 'warning' %}
|
||||
<div class="p-2 rounded bg-yellow-50">
|
||||
<span class="font-medium">{{ finding.check }}:</span>
|
||||
<span class="text-yellow-700">{{ finding.message }}</span>
|
||||
{% if finding.suggestion %}
|
||||
<div class="text-gray-500 italic text-xs">{{ finding.suggestion }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ tool.published_at[:10] if tool.published_at else 'Unknown' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick="approveTool({{ tool.id }})" class="text-green-600 hover:text-green-900 mr-3">Approve</button>
|
||||
<button onclick="approveTool({{ tool.id }})" class="text-green-600 hover:text-green-900 mr-2">Approve</button>
|
||||
<button onclick="requestChanges({{ tool.id }})" class="mr-2" style="color: #d97706;">Changes</button>
|
||||
<button onclick="rejectTool({{ tool.id }})" class="text-red-600 hover:text-red-900">Reject</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -62,12 +94,37 @@
|
||||
<div class="text-sm text-gray-700">
|
||||
Page {{ meta.page }} of {{ meta.total_pages }} ({{ meta.total }} total)
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- First/Previous -->
|
||||
{% if meta.page > 1 %}
|
||||
<a href="?page=1" class="px-2 py-1 border rounded text-sm hover:bg-gray-100" title="First page">«</a>
|
||||
<a href="?page={{ meta.page - 1 }}" class="px-3 py-1 border rounded text-sm hover:bg-gray-100">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page numbers -->
|
||||
{% set start_page = [1, meta.page - 2] | max %}
|
||||
{% set end_page = [meta.total_pages, meta.page + 2] | min %}
|
||||
|
||||
{% if start_page > 1 %}
|
||||
<span class="text-gray-400">...</span>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(start_page, end_page + 1) %}
|
||||
{% if p == meta.page %}
|
||||
<span class="px-3 py-1 bg-indigo-600 text-white rounded text-sm">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="?page={{ p }}" class="px-3 py-1 border rounded text-sm hover:bg-gray-100">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if end_page < meta.total_pages %}
|
||||
<span class="text-gray-400">...</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Next/Last -->
|
||||
{% if meta.page < meta.total_pages %}
|
||||
<a href="?page={{ meta.page + 1 }}" class="px-3 py-1 border rounded text-sm hover:bg-gray-100">Next</a>
|
||||
<a href="?page={{ meta.total_pages }}" class="px-2 py-1 border rounded text-sm hover:bg-gray-100" title="Last page">»</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,11 +140,94 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tool Detail Modal -->
|
||||
<div id="detail-modal" class="fixed inset-0 bg-gray-900 bg-opacity-60 hidden z-50 flex items-start justify-center pt-10 pb-10">
|
||||
<div id="detail-modal-content" class="border-2 border-gray-300 max-w-4xl w-full shadow-2xl rounded-lg bg-white flex flex-col" style="max-height: calc(100vh - 80px);">
|
||||
<div id="detail-modal-header" class="flex items-center justify-between cursor-move select-none bg-gray-50 px-6 py-4 rounded-t-lg border-b border-gray-200 flex-shrink-0">
|
||||
<h3 id="detail-title" class="text-lg font-medium text-gray-900">Tool Details</h3>
|
||||
<button onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-grow">
|
||||
<div id="detail-loading" class="py-8 text-center text-gray-500">Loading...</div>
|
||||
<div id="detail-content" class="hidden">
|
||||
<!-- AI Review (if available) -->
|
||||
<div id="detail-ai-review-section" class="mb-6 hidden">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">AI Review</h4>
|
||||
<div id="detail-ai-review" class="rounded-md p-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scrutiny Warnings -->
|
||||
<div id="detail-warnings-section" class="mb-6 hidden">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Scrutiny Warnings</h4>
|
||||
<div id="detail-warnings" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Description</h4>
|
||||
<p id="detail-description" class="text-sm text-gray-600"></p>
|
||||
</div>
|
||||
|
||||
<!-- Arguments -->
|
||||
<div id="detail-args-section" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Arguments</h4>
|
||||
<div id="detail-args" class="bg-gray-50 rounded-md p-3 text-sm font-mono overflow-x-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div id="detail-steps-section" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Steps</h4>
|
||||
<div id="detail-steps" class="space-y-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- README -->
|
||||
<div id="detail-readme-section" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">README</h4>
|
||||
<div id="detail-readme" class="bg-gray-50 rounded-md p-4 text-sm prose prose-sm max-w-none overflow-auto max-h-64"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-end space-x-3 border-t bg-gray-50 rounded-b-lg flex-shrink-0">
|
||||
<button onclick="closeDetailModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Close</button>
|
||||
<button id="detail-approve-btn" class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700">Approve</button>
|
||||
<button id="detail-changes-btn" class="px-4 py-2 border-2 border-yellow-500 text-yellow-700 text-sm font-medium rounded-md hover:bg-yellow-50" style="background-color: #fef3c7; border-color: #f59e0b; color: #92400e;">Request Changes</button>
|
||||
<button id="detail-reject-btn" class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request Changes Modal -->
|
||||
<div id="changes-modal" class="fixed inset-0 bg-gray-900 bg-opacity-60 hidden overflow-y-auto h-full w-full z-50">
|
||||
<div id="changes-modal-content" class="relative top-20 mx-auto border-2 border-gray-300 w-[32rem] shadow-2xl rounded-lg bg-white">
|
||||
<div id="changes-modal-header" class="cursor-move select-none bg-gray-50 px-5 py-4 rounded-t-lg border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Request Changes</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">This feedback will be visible to the publisher</p>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Feedback for publisher:</label>
|
||||
<textarea id="changes-feedback" rows="5" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500" placeholder="Describe what changes are needed..."></textarea>
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
Tip: Include specific issues and suggestions for improvement
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end space-x-3">
|
||||
<button onclick="closeChangesModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</button>
|
||||
<button onclick="confirmRequestChanges()" class="px-4 py-2 text-sm font-medium rounded-md" style="background-color: #f59e0b; color: white;">Send Feedback</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
<div id="reject-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Reject Tool</h3>
|
||||
<div id="reject-modal" class="fixed inset-0 bg-gray-900 bg-opacity-60 hidden overflow-y-auto h-full w-full z-50">
|
||||
<div id="reject-modal-content" class="relative top-20 mx-auto border-2 border-gray-300 w-96 shadow-2xl rounded-lg bg-white">
|
||||
<div id="reject-modal-header" class="cursor-move select-none bg-gray-50 px-5 py-4 rounded-t-lg border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Reject Tool</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<textarea id="reject-reason" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="Reason for rejection (required)"></textarea>
|
||||
<div class="mt-4 flex justify-end space-x-3">
|
||||
<button onclick="closeRejectModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</button>
|
||||
@@ -99,6 +239,207 @@
|
||||
|
||||
<script>
|
||||
let currentToolId = null;
|
||||
let currentDetailToolId = null;
|
||||
|
||||
function toggleFindings(toolId) {
|
||||
const el = document.getElementById(`findings-${toolId}`);
|
||||
el.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
async function viewTool(toolId) {
|
||||
currentDetailToolId = toolId;
|
||||
document.getElementById('detail-modal').classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
||||
document.getElementById('detail-loading').classList.remove('hidden');
|
||||
document.getElementById('detail-content').classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/tools/${toolId}`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer {{ session.get("auth_token") }}'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load tool details');
|
||||
}
|
||||
const result = await response.json();
|
||||
const tool = result.data;
|
||||
|
||||
// Update title
|
||||
document.getElementById('detail-title').textContent = `${tool.owner}/${tool.name} v${tool.version}`;
|
||||
|
||||
// AI Review (if available)
|
||||
const aiReviewSection = document.getElementById('detail-ai-review-section');
|
||||
const aiReviewDiv = document.getElementById('detail-ai-review');
|
||||
const aiReview = tool.scrutiny_report?.ai_review;
|
||||
if (aiReview) {
|
||||
aiReviewSection.classList.remove('hidden');
|
||||
const verdict = aiReview.overall_verdict || 'UNKNOWN';
|
||||
const confidence = (aiReview.confidence * 100).toFixed(0) || '?';
|
||||
let verdictColor, verdictBg;
|
||||
if (verdict === 'APPROVE') {
|
||||
verdictColor = 'text-green-800';
|
||||
verdictBg = 'bg-green-50 border-green-200';
|
||||
} else if (verdict === 'REJECT') {
|
||||
verdictColor = 'text-red-800';
|
||||
verdictBg = 'bg-red-50 border-red-200';
|
||||
} else {
|
||||
verdictColor = 'text-yellow-800';
|
||||
verdictBg = 'bg-yellow-50 border-yellow-200';
|
||||
}
|
||||
|
||||
let findingsHtml = '';
|
||||
if (aiReview.findings && aiReview.findings.length > 0) {
|
||||
findingsHtml = '<div class="mt-3 space-y-2">' + aiReview.findings.map(f => {
|
||||
let fColor = f.verdict === 'FALSE_POSITIVE' ? 'text-green-700' :
|
||||
f.verdict === 'LEGITIMATE_CONCERN' ? 'text-red-700' : 'text-yellow-700';
|
||||
return `<div class="text-sm border-l-2 pl-3 ${f.verdict === 'FALSE_POSITIVE' ? 'border-green-400' : f.verdict === 'LEGITIMATE_CONCERN' ? 'border-red-400' : 'border-yellow-400'}">
|
||||
<div class="font-medium ${fColor}">${escapeHtml(f.verdict || 'UNKNOWN')}</div>
|
||||
<div class="text-gray-600">${escapeHtml(f.explanation || '')}</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
aiReviewDiv.className = `rounded-md p-4 border ${verdictBg}`;
|
||||
aiReviewDiv.innerHTML = `
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium ${verdictColor}">${verdict}</span>
|
||||
<span class="text-sm text-gray-500">${confidence}% confidence</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700">${escapeHtml(aiReview.summary || '')}</div>
|
||||
${findingsHtml}
|
||||
`;
|
||||
} else if (tool.scrutiny_report?.ai_review_error) {
|
||||
aiReviewSection.classList.remove('hidden');
|
||||
aiReviewDiv.className = 'rounded-md p-4 border bg-gray-50 border-gray-200';
|
||||
aiReviewDiv.innerHTML = `<div class="text-sm text-gray-500">AI review failed: ${escapeHtml(tool.scrutiny_report.ai_review_error)}</div>`;
|
||||
} else {
|
||||
aiReviewSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Scrutiny Warnings
|
||||
const warningsSection = document.getElementById('detail-warnings-section');
|
||||
const warningsDiv = document.getElementById('detail-warnings');
|
||||
const warnings = tool.scrutiny_report?.findings?.filter(f => f.result === 'warning') || [];
|
||||
if (warnings.length > 0) {
|
||||
warningsSection.classList.remove('hidden');
|
||||
warningsDiv.innerHTML = warnings.map(w => `
|
||||
<div class="p-3 rounded-md bg-yellow-50 border border-yellow-200">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-yellow-600 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-yellow-800">${escapeHtml(w.check || 'Warning')}</div>
|
||||
<div class="text-sm text-yellow-700">${escapeHtml(w.message || '')}</div>
|
||||
${w.suggestion ? `<div class="text-xs text-yellow-600 mt-1 italic">${escapeHtml(w.suggestion)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
warningsSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Description
|
||||
document.getElementById('detail-description').textContent = tool.description || 'No description';
|
||||
|
||||
// Arguments
|
||||
const argsSection = document.getElementById('detail-args-section');
|
||||
const argsDiv = document.getElementById('detail-args');
|
||||
if (tool.config.arguments && tool.config.arguments.length > 0) {
|
||||
argsSection.classList.remove('hidden');
|
||||
argsDiv.innerHTML = tool.config.arguments.map(arg => {
|
||||
const required = arg.required ? '<span class="text-red-600">*</span>' : '';
|
||||
return `<div class="mb-2"><span class="text-indigo-600">${arg.flag || arg.variable}</span>${required} - ${arg.description || 'No description'}</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
argsSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Steps
|
||||
const stepsSection = document.getElementById('detail-steps-section');
|
||||
const stepsDiv = document.getElementById('detail-steps');
|
||||
if (tool.config.steps && tool.config.steps.length > 0) {
|
||||
stepsSection.classList.remove('hidden');
|
||||
stepsDiv.innerHTML = tool.config.steps.map((step, idx) => {
|
||||
let content = '';
|
||||
const stepName = step.name ? ` - ${step.name}` : '';
|
||||
if (step.type === 'prompt') {
|
||||
const provider = step.provider ? `<span class="text-indigo-600">[${step.provider}]</span> ` : '';
|
||||
const outputVar = step.output_var ? ` → <span class="text-green-600">${step.output_var}</span>` : '';
|
||||
content = `<div class="text-xs text-gray-500 mb-2">${provider}Prompt Step${outputVar}</div>
|
||||
<pre class="bg-white p-3 rounded border text-xs overflow-x-auto whitespace-pre-wrap max-h-48 overflow-y-auto">${escapeHtml(step.prompt || step.prompt_file || 'No prompt content')}</pre>`;
|
||||
} else if (step.type === 'code') {
|
||||
const outputVar = step.output_var ? ` → <span class="text-green-600">${step.output_var}</span>` : '';
|
||||
content = `<div class="text-xs text-gray-500 mb-2">Code Step${outputVar}</div>
|
||||
<pre class="bg-gray-900 text-green-400 p-3 rounded text-xs overflow-x-auto max-h-64 overflow-y-auto"><code>${escapeHtml(step.code || step.code_file || 'No code content')}</code></pre>`;
|
||||
} else if (step.type === 'tool') {
|
||||
const toolArgs = step.arguments ? Object.entries(step.arguments).map(([k, v]) => `${k}="${v}"`).join(' ') : '';
|
||||
content = `<div class="text-xs text-gray-500 mb-2">Tool Step: <span class="text-indigo-600">${step.tool || 'unknown'}</span></div>
|
||||
<pre class="bg-gray-100 p-3 rounded text-xs">${toolArgs || 'No arguments'}</pre>`;
|
||||
} else {
|
||||
content = `<div class="text-xs text-gray-500">${step.type || 'Unknown'} step</div>`;
|
||||
}
|
||||
return `<div class="bg-gray-50 p-3 rounded-md border border-gray-200">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">Step ${idx + 1}${stepName}</div>
|
||||
${content}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
stepsSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// README
|
||||
const readmeSection = document.getElementById('detail-readme-section');
|
||||
const readmeDiv = document.getElementById('detail-readme');
|
||||
if (tool.readme) {
|
||||
readmeSection.classList.remove('hidden');
|
||||
readmeDiv.innerHTML = `<pre class="whitespace-pre-wrap text-xs">${escapeHtml(tool.readme)}</pre>`;
|
||||
} else {
|
||||
readmeSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Wire up approve/reject buttons
|
||||
document.getElementById('detail-approve-btn').onclick = () => {
|
||||
closeDetailModal();
|
||||
approveTool(toolId);
|
||||
};
|
||||
document.getElementById('detail-reject-btn').onclick = () => {
|
||||
closeDetailModal();
|
||||
rejectTool(toolId);
|
||||
};
|
||||
document.getElementById('detail-changes-btn').onclick = () => {
|
||||
closeDetailModal();
|
||||
requestChanges(toolId);
|
||||
};
|
||||
|
||||
document.getElementById('detail-loading').classList.add('hidden');
|
||||
document.getElementById('detail-content').classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error('Error loading tool:', error);
|
||||
document.getElementById('detail-loading').textContent = 'Error loading tool details';
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detail-modal').classList.add('hidden');
|
||||
document.body.style.overflow = ''; // Restore background scroll
|
||||
// Reset position for next open
|
||||
const modal = document.getElementById('detail-modal-content');
|
||||
modal.style.position = '';
|
||||
modal.style.left = '';
|
||||
modal.style.top = '';
|
||||
modal.style.margin = '';
|
||||
modal.style.transform = '';
|
||||
currentDetailToolId = null;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function approveTool(toolId) {
|
||||
if (!confirm('Approve this tool?')) return;
|
||||
@@ -111,25 +452,36 @@ async function approveTool(toolId) {
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
document.getElementById(`tool-row-${toolId}`).remove();
|
||||
const row = document.getElementById(`tool-row-${toolId}`);
|
||||
if (row) row.remove();
|
||||
location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(data.error?.message || 'Failed to approve tool');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error');
|
||||
console.error('Approve error:', error);
|
||||
alert('Error: ' + (error.message || 'Unknown error occurred'));
|
||||
}
|
||||
}
|
||||
|
||||
function rejectTool(toolId) {
|
||||
currentToolId = toolId;
|
||||
document.getElementById('reject-modal').classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
||||
document.getElementById('reject-reason').value = '';
|
||||
}
|
||||
|
||||
function closeRejectModal() {
|
||||
document.getElementById('reject-modal').classList.add('hidden');
|
||||
document.body.style.overflow = ''; // Restore background scroll
|
||||
// Reset position for next open
|
||||
const modal = document.getElementById('reject-modal-content');
|
||||
modal.style.position = '';
|
||||
modal.style.left = '';
|
||||
modal.style.top = '';
|
||||
modal.style.margin = '';
|
||||
modal.style.transform = '';
|
||||
currentToolId = null;
|
||||
}
|
||||
|
||||
@@ -150,15 +502,120 @@ async function confirmReject() {
|
||||
});
|
||||
if (response.ok) {
|
||||
closeRejectModal();
|
||||
document.getElementById(`tool-row-${currentToolId}`).remove();
|
||||
const row = document.getElementById(`tool-row-${currentToolId}`);
|
||||
if (row) row.remove();
|
||||
location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(data.error?.message || 'Failed to reject tool');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error');
|
||||
console.error('Reject error:', error);
|
||||
alert('Error: ' + (error.message || 'Unknown error occurred'));
|
||||
}
|
||||
}
|
||||
|
||||
function requestChanges(toolId) {
|
||||
currentToolId = toolId;
|
||||
document.getElementById('changes-modal').classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.getElementById('changes-feedback').value = '';
|
||||
}
|
||||
|
||||
function closeChangesModal() {
|
||||
document.getElementById('changes-modal').classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
const modal = document.getElementById('changes-modal-content');
|
||||
modal.style.position = '';
|
||||
modal.style.left = '';
|
||||
modal.style.top = '';
|
||||
modal.style.margin = '';
|
||||
modal.style.transform = '';
|
||||
currentToolId = null;
|
||||
}
|
||||
|
||||
async function confirmRequestChanges() {
|
||||
const feedback = document.getElementById('changes-feedback').value.trim();
|
||||
if (!feedback) {
|
||||
alert('Feedback is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/tools/${currentToolId}/request-changes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer {{ session.get("auth_token") }}',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ feedback })
|
||||
});
|
||||
if (response.ok) {
|
||||
closeChangesModal();
|
||||
const row = document.getElementById(`tool-row-${currentToolId}`);
|
||||
if (row) row.remove();
|
||||
location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(data.error?.message || 'Failed to request changes');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Request changes error:', error);
|
||||
alert('Error: ' + (error.message || 'Unknown error occurred'));
|
||||
}
|
||||
}
|
||||
|
||||
// Draggable modal functionality
|
||||
function makeDraggable(modalContentId, headerHandleId) {
|
||||
const modal = document.getElementById(modalContentId);
|
||||
const header = document.getElementById(headerHandleId);
|
||||
if (!modal || !header) return;
|
||||
|
||||
let isDragging = false;
|
||||
let startX, startY, initialX, initialY;
|
||||
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return; // Don't drag when clicking buttons
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
|
||||
const rect = modal.getBoundingClientRect();
|
||||
initialX = rect.left;
|
||||
initialY = rect.top;
|
||||
|
||||
// Switch to fixed positioning for dragging
|
||||
modal.style.position = 'fixed';
|
||||
modal.style.left = initialX + 'px';
|
||||
modal.style.top = initialY + 'px';
|
||||
modal.style.margin = '0';
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
|
||||
modal.style.left = (initialX + deltaX) + 'px';
|
||||
modal.style.top = (initialY + deltaY) + 'px';
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize draggable modals
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
makeDraggable('detail-modal-content', 'detail-modal-header');
|
||||
makeDraggable('reject-modal-content', 'reject-modal-header');
|
||||
makeDraggable('changes-modal-content', 'changes-modal-header');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block dashboard_header %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Scrutiny Audit</h1>
|
||||
<p class="mt-1 text-gray-600">Review automated scrutiny results for all tools</p>
|
||||
</div>
|
||||
<a href="{{ url_for('web.admin_dashboard') }}" class="text-sm text-indigo-600 hover:text-indigo-700">← Back to Admin</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<!-- Stats Summary -->
|
||||
<div class="mb-6 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="text-sm text-gray-500">Auto-Approved</div>
|
||||
<div class="text-2xl font-semibold text-green-600">{{ stats_summary.approved_approved or 0 }}</div>
|
||||
<div class="text-xs text-gray-400">Scrutiny passed</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="text-sm text-gray-500">Needs Review</div>
|
||||
<div class="text-2xl font-semibold text-yellow-600">{{ stats_summary.review_pending or 0 }}</div>
|
||||
<div class="text-xs text-gray-400">Has warnings</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="text-sm text-gray-500">Manually Approved</div>
|
||||
<div class="text-2xl font-semibold text-blue-600">{{ stats_summary.review_approved or 0 }}</div>
|
||||
<div class="text-xs text-gray-400">With warnings</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="text-sm text-gray-500">Total Pending</div>
|
||||
<div class="text-2xl font-semibold text-gray-600">{{ stats_summary.total_pending or 0 }}</div>
|
||||
<div class="text-xs text-gray-400">Awaiting review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex flex-wrap gap-4">
|
||||
<form method="get" class="flex items-center gap-4">
|
||||
<select name="scrutiny_status" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">All Scrutiny Status</option>
|
||||
<option value="approved" {% if scrutiny_filter == 'approved' %}selected{% endif %}>Approved</option>
|
||||
<option value="pending_review" {% if scrutiny_filter == 'pending_review' %}selected{% endif %}>Pending Review (Warnings)</option>
|
||||
<option value="pending" {% if scrutiny_filter == 'pending' %}selected{% endif %}>Pending</option>
|
||||
</select>
|
||||
<select name="moderation_status" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">All Moderation Status</option>
|
||||
<option value="approved" {% if moderation_filter == 'approved' %}selected{% endif %}>Approved</option>
|
||||
<option value="pending" {% if moderation_filter == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="rejected" {% if moderation_filter == 'rejected' %}selected{% endif %}>Rejected</option>
|
||||
</select>
|
||||
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">Filter</button>
|
||||
{% if scrutiny_filter or moderation_filter %}
|
||||
<a href="{{ url_for('web.admin_scrutiny') }}" class="text-sm text-gray-500 hover:text-gray-700">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg border border-gray-200">
|
||||
{% if tools %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tool</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scrutiny</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Moderation</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Findings</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Published</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for tool in tools %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}" class="hover:text-indigo-600">
|
||||
{{ tool.owner }}/{{ tool.name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">v{{ tool.version }} - {{ tool.category or 'Uncategorized' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap">
|
||||
{% if tool.scrutiny_status == 'approved' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Passed
|
||||
</span>
|
||||
{% elif tool.scrutiny_status == 'pending_review' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Warnings
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
{{ tool.scrutiny_status or 'N/A' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap">
|
||||
{% if tool.moderation_status == 'approved' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Approved
|
||||
</span>
|
||||
{% elif tool.moderation_status == 'pending' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Pending
|
||||
</span>
|
||||
{% elif tool.moderation_status == 'rejected' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Rejected
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
{% if tool.scrutiny_report and tool.scrutiny_report.findings %}
|
||||
<button onclick="showFindings({{ tool.id }})" class="text-sm text-indigo-600 hover:text-indigo-800">
|
||||
View {{ tool.scrutiny_report.findings | length }} finding(s)
|
||||
</button>
|
||||
<div id="findings-{{ tool.id }}" class="hidden mt-2 text-xs space-y-1">
|
||||
{% for finding in tool.scrutiny_report.findings %}
|
||||
<div class="p-2 rounded {% if finding.result == 'pass' %}bg-green-50{% elif finding.result == 'warning' %}bg-yellow-50{% else %}bg-red-50{% endif %}">
|
||||
<span class="font-medium">{{ finding.check }}:</span>
|
||||
<span class="{% if finding.result == 'pass' %}text-green-700{% elif finding.result == 'warning' %}text-yellow-700{% else %}text-red-700{% endif %}">
|
||||
{{ finding.result }}
|
||||
</span>
|
||||
<div class="text-gray-600">{{ finding.message }}</div>
|
||||
{% if finding.suggestion %}
|
||||
<div class="text-gray-500 italic">Suggestion: {{ finding.suggestion }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-400">No findings</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ tool.published_at[:10] if tool.published_at else 'Unknown' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if meta.total_pages > 1 %}
|
||||
<div class="bg-gray-50 px-6 py-3 flex items-center justify-between border-t border-gray-200">
|
||||
<div class="text-sm text-gray-700">
|
||||
Page {{ meta.page }} of {{ meta.total_pages }} ({{ meta.total }} total)
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
{% if meta.page > 1 %}
|
||||
<a href="?page={{ meta.page - 1 }}{% if scrutiny_filter %}&scrutiny_status={{ scrutiny_filter }}{% endif %}{% if moderation_filter %}&moderation_status={{ moderation_filter }}{% endif %}" class="px-3 py-1 border rounded text-sm hover:bg-gray-100">Previous</a>
|
||||
{% endif %}
|
||||
{% if meta.page < meta.total_pages %}
|
||||
<a href="?page={{ meta.page + 1 }}{% if scrutiny_filter %}&scrutiny_status={{ scrutiny_filter }}{% endif %}{% if moderation_filter %}&moderation_status={{ moderation_filter }}{% endif %}" class="px-3 py-1 border rounded text-sm hover:bg-gray-100">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No tools found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Try adjusting your filters.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showFindings(toolId) {
|
||||
const el = document.getElementById(`findings-${toolId}`);
|
||||
el.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -69,7 +69,14 @@
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-gray-900">v{{ tool.version }}</span>
|
||||
{% if tool.version_count and tool.version_count > 1 %}
|
||||
<span class="ml-1 text-xs text-gray-500">(+{{ tool.version_count - 1 }} more)</span>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
{{ tool.versions[:3]|join(', ') }}{% if tool.version_count > 3 %}...{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-500">{{ tool.published_at|timeago }}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ tool.downloads|format_number }}
|
||||
|
||||
@@ -66,7 +66,24 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tool.source %}
|
||||
{% if tool.forked_from %}
|
||||
<div class="mt-4 p-3 bg-purple-50 border border-purple-200 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-purple-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<div class="text-sm text-purple-800">
|
||||
<span class="font-medium">Forked from</span>
|
||||
{% set fork_parts = tool.forked_from.split('/') %}
|
||||
<a href="{{ url_for('web.tool_detail', owner=fork_parts[0], name=fork_parts[1]) }}"
|
||||
class="font-medium underline hover:text-purple-900">{{ tool.forked_from }}</a>
|
||||
{% if tool.forked_version %}
|
||||
<span class="text-purple-600">v{{ tool.forked_version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif tool.source %}
|
||||
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-amber-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -330,6 +347,44 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Forks -->
|
||||
{% if forks %}
|
||||
<div class="bg-white rounded-lg border border-purple-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-5 h-5 text-purple-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
Forks
|
||||
<span class="ml-2 px-2 py-0.5 text-xs font-medium text-purple-700 bg-purple-100 rounded-full">{{ forks|length }}</span>
|
||||
</span>
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
{% for fork in forks[:5] %}
|
||||
<li>
|
||||
<a href="{{ url_for('web.tool_detail', owner=fork.owner, name=fork.name) }}"
|
||||
class="block hover:bg-purple-50 -mx-2 px-2 py-1 rounded">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900 hover:text-purple-600">
|
||||
{{ fork.owner }}/{{ fork.name }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">v{{ fork.latest_version }}</span>
|
||||
</div>
|
||||
{% if fork.description %}
|
||||
<p class="text-xs text-gray-500 truncate mt-0.5">{{ fork.description|truncate(60) }}</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if forks|length > 5 %}
|
||||
<p class="mt-3 text-sm text-center text-purple-600">
|
||||
+ {{ forks|length - 5 }} more fork{{ 's' if forks|length - 5 != 1 else '' }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Stats</h3>
|
||||
@@ -359,6 +414,12 @@
|
||||
<dd class="text-sm font-medium text-gray-900">{{ rating.unique_users|format_number }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tool.fork_count %}
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-sm text-gray-500">Forks</dt>
|
||||
<dd class="text-sm font-medium text-purple-600">{{ tool.fork_count }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,6 +67,43 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Tags Filter -->
|
||||
{% if all_tags %}
|
||||
<div>
|
||||
<button type="button"
|
||||
onclick="toggleTagsFilter()"
|
||||
class="flex items-center justify-between w-full text-sm font-medium text-gray-700 mb-3">
|
||||
<span>Tags</span>
|
||||
<svg id="tags-chevron" class="w-4 h-4 text-gray-500 transition-transform {{ '' if include_tags or exclude_tags else 'rotate-180' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="tags-filter" class="{{ '' if include_tags or exclude_tags else 'hidden' }}">
|
||||
{% if include_tags or exclude_tags %}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-500">Click to cycle: include/exclude/none</span>
|
||||
<a href="{{ url_for('web.tools', sort=sort, category=current_category) }}" class="text-xs text-indigo-600 hover:underline">Clear</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-500 mb-2">Click to cycle: include/exclude/none</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% 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') %}
|
||||
<button type="button"
|
||||
onclick="cycleTagState('{{ tag.name }}', '{{ tag_state }}')"
|
||||
class="tag-btn px-2 py-1 text-xs rounded-full border transition-colors
|
||||
{% if tag_state == 'include' %}bg-green-100 border-green-300 text-green-700
|
||||
{% elif tag_state == 'exclude' %}bg-red-100 border-red-300 text-red-700 line-through
|
||||
{% else %}bg-white border-gray-300 text-gray-600 hover:border-gray-400{% endif %}">
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sort -->
|
||||
<div>
|
||||
<label for="sort-select" class="block text-sm font-medium text-gray-700 mb-3">Sort By</label>
|
||||
@@ -133,6 +170,29 @@
|
||||
<option value="name" {{ 'selected' if sort == 'name' }}>Name (A-Z)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Tags Filter -->
|
||||
{% if all_tags %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% 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') %}
|
||||
<button type="button"
|
||||
onclick="cycleTagState('{{ tag.name }}', '{{ tag_state }}')"
|
||||
class="px-2 py-1 text-xs rounded-full border transition-colors
|
||||
{% if tag_state == 'include' %}bg-green-100 border-green-300 text-green-700
|
||||
{% elif tag_state == 'exclude' %}bg-red-100 border-red-300 text-red-700 line-through
|
||||
{% else %}bg-white border-gray-300 text-gray-600{% endif %}">
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if include_tags or exclude_tags %}
|
||||
<a href="{{ url_for('web.tools', sort=sort, category=current_category) }}" class="text-xs text-indigo-600 hover:underline mt-2 inline-block">Clear tags</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +220,8 @@
|
||||
description=tool.description,
|
||||
category=tool.category,
|
||||
downloads=tool.downloads,
|
||||
version=tool.version
|
||||
version=tool.version,
|
||||
tags=tool.tags
|
||||
) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -170,7 +231,7 @@
|
||||
<nav class="mt-12 flex items-center justify-center" aria-label="Pagination">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('web.tools', page=pagination.prev_num, sort=sort, category=current_category) }}"
|
||||
<a href="{{ url_for('web.tools', page=pagination.prev_num, sort=sort, category=current_category, include_tags=include_tags|join(',') if include_tags else none, exclude_tags=exclude_tags|join(',') if exclude_tags else none) }}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Previous
|
||||
</a>
|
||||
@@ -181,7 +242,7 @@
|
||||
</span>
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('web.tools', page=pagination.next_num, sort=sort, category=current_category) }}"
|
||||
<a href="{{ url_for('web.tools', page=pagination.next_num, sort=sort, category=current_category, include_tags=include_tags|join(',') if include_tags else none, exclude_tags=exclude_tags|join(',') if exclude_tags else none) }}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Next
|
||||
</a>
|
||||
@@ -228,5 +289,47 @@ function updateSort(value) {
|
||||
url.searchParams.set('sort', value);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
function toggleTagsFilter() {
|
||||
const panel = document.getElementById('tags-filter');
|
||||
const chevron = document.getElementById('tags-chevron');
|
||||
panel.classList.toggle('hidden');
|
||||
chevron.classList.toggle('rotate-180');
|
||||
}
|
||||
|
||||
function cycleTagState(tagName, currentState) {
|
||||
const url = new URL(window.location.href);
|
||||
let includeTags = (url.searchParams.get('include_tags') || '').split(',').filter(Boolean);
|
||||
let excludeTags = (url.searchParams.get('exclude_tags') || '').split(',').filter(Boolean);
|
||||
|
||||
// Remove from both lists first
|
||||
includeTags = includeTags.filter(t => t !== tagName);
|
||||
excludeTags = excludeTags.filter(t => t !== tagName);
|
||||
|
||||
// Cycle: neutral -> include -> exclude -> neutral
|
||||
if (currentState === 'neutral') {
|
||||
includeTags.push(tagName);
|
||||
} else if (currentState === 'include') {
|
||||
excludeTags.push(tagName);
|
||||
}
|
||||
// else: exclude -> neutral (already removed)
|
||||
|
||||
// Update URL params
|
||||
if (includeTags.length > 0) {
|
||||
url.searchParams.set('include_tags', includeTags.join(','));
|
||||
} else {
|
||||
url.searchParams.delete('include_tags');
|
||||
}
|
||||
if (excludeTags.length > 0) {
|
||||
url.searchParams.set('exclude_tags', excludeTags.join(','));
|
||||
} else {
|
||||
url.searchParams.delete('exclude_tags');
|
||||
}
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
url.searchParams.delete('page');
|
||||
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user