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
|
# 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.
|
Collections are curated groups of tools that can be installed together with a single command.
|
||||||
|
|
||||||
## Use Cases
|
## Use Cases
|
||||||
|
|||||||
+41
-16
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
> A lightweight personal tool builder for AI-powered CLI commands
|
> 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
|
## 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.
|
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
|
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.
|
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
|
### Directory Structure
|
||||||
|
|
||||||
|
> **Note**: The structure below is simplified. See `src/cmdforge/` for the full implementation.
|
||||||
|
|
||||||
```
|
```
|
||||||
cmdforge/
|
src/cmdforge/
|
||||||
__init__.py
|
__init__.py
|
||||||
cli.py # Entry point, argument parsing
|
cli/ # CLI commands (list, create, edit, run, registry, etc.)
|
||||||
ui.py # UI dispatcher (chooses urwid/snack/dialog)
|
gui/ # PySide6 desktop GUI
|
||||||
ui_urwid.py # Urwid-based BIOS-style UI
|
main_window.py
|
||||||
ui_snack.py # Snack/newt fallback UI
|
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
|
tool.py # Tool loading/saving/wrapper scripts
|
||||||
runner.py # Execute tools
|
runner.py # Execute tools
|
||||||
providers.py # Provider abstraction
|
providers.py # Provider abstraction
|
||||||
|
profiles.py # AI persona profiles
|
||||||
```
|
```
|
||||||
|
|
||||||
### Provider System
|
### Provider System
|
||||||
@@ -380,29 +400,34 @@ output: "{result}"
|
|||||||
|
|
||||||
## What This Design Doesn't Include
|
## 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
|
- Container isolation / sandboxing
|
||||||
- Certification testing
|
|
||||||
- Distribution packaging
|
|
||||||
- PII redaction
|
- PII redaction
|
||||||
- Audit logging
|
|
||||||
- Provider capability negotiation
|
- 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.
|
**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
|
## Dependencies
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- PyYAML
|
- PyYAML
|
||||||
- urwid (for TUI)
|
- PySide6 (for GUI)
|
||||||
|
|
||||||
Optional fallbacks:
|
Optional (for web/registry):
|
||||||
- python3-newt/snack (simpler TUI)
|
- Flask
|
||||||
- dialog/whiptail (basic TUI)
|
- SQLite with FTS5
|
||||||
|
|
||||||
|
Legacy TUI (no longer primary):
|
||||||
|
- urwid
|
||||||
|
- python3-newt/snack
|
||||||
|
|
||||||
## Example Workflow
|
## Example Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Meta-Tools: Tools That Call Other Tools
|
# 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.
|
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`
|
## Step Type: `tool`
|
||||||
|
|||||||
+43
-23
@@ -1,5 +1,9 @@
|
|||||||
# CmdForge Project Overview
|
# 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.
|
This document provides a comprehensive overview of the CmdForge project structure, components, and how everything fits together.
|
||||||
|
|
||||||
## What is CmdForge?
|
## What is CmdForge?
|
||||||
@@ -32,52 +36,68 @@ See `docs/diagrams/` for visual representations:
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/cmdforge/
|
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
|
├── tool.py # Tool dataclasses, YAML loading
|
||||||
├── runner.py # Step execution engine
|
├── runner.py # Step execution engine
|
||||||
├── providers.py # AI provider abstraction
|
├── providers.py # AI provider abstraction
|
||||||
|
├── profiles.py # AI persona profiles
|
||||||
├── config.py # Global configuration
|
├── 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
|
├── registry/ # Registry API Server
|
||||||
│ ├── app.py # Flask API application
|
│ ├── app.py # Flask API with 100+ endpoints
|
||||||
│ ├── db.py # Database operations, FTS5 search
|
│ ├── db.py # Database operations, FTS5 search (25+ tables)
|
||||||
│ ├── rate_limit.py # API rate limiting
|
│ ├── rate_limit.py # API rate limiting
|
||||||
│ ├── sync.py # Git sync for tool submissions
|
│ ├── scrutiny.py # Automated code analysis
|
||||||
│ ├── categorize.py # Auto-categorization
|
│ ├── similarity.py # Duplicate detection
|
||||||
│ └── similarity.py # Similar tool recommendations
|
│ └── sync.py # Git sync for tool submissions
|
||||||
│
|
│
|
||||||
├── web/ # Web UI Application
|
├── web/ # Web UI Application
|
||||||
│ ├── app.py # Flask web app, Sentry integration
|
│ ├── app.py # Flask web app
|
||||||
│ ├── routes.py # Page routes and handlers
|
│ ├── routes.py # Page routes and handlers
|
||||||
│ ├── auth.py # Password hashing, login/register
|
│ ├── auth.py # Password hashing, login/register
|
||||||
│ ├── sessions.py # Session management
|
│ ├── sessions.py # Session management
|
||||||
│ ├── docs_content.py # Documentation text content
|
│ ├── forum/ # Forum blueprint
|
||||||
│ ├── seo.py # Sitemap, robots.txt
|
|
||||||
│ ├── filters.py # Jinja template filters
|
|
||||||
│ ├── templates/ # Jinja HTML templates
|
│ ├── templates/ # Jinja HTML templates
|
||||||
│ └── static/ # CSS, JS, images
|
│ └── static/ # CSS (Tailwind), JS, images
|
||||||
│
|
│
|
||||||
├── registry_client.py # CLI client for registry API
|
└── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
SQLite database at `data/cmdforge.db`:
|
SQLite database at `~/.cmdforge/registry.db` (or via `CMDFORGE_REGISTRY_DB` env var):
|
||||||
|
|
||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `publishers` | User accounts (username, email, password_hash) |
|
| `publishers` | User accounts with roles, banning, verification |
|
||||||
| `tools` | Published tools (name, owner, description, config) |
|
| `tools` | Published tools with moderation status, fork tracking |
|
||||||
| `api_tokens` | Authentication tokens for API access |
|
| `api_tokens` | Authentication tokens for API access |
|
||||||
| `download_stats` | Tool download tracking |
|
| `download_stats` | Tool download tracking by date |
|
||||||
| `pageviews` | Analytics for tool detail pages |
|
| `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) |
|
| `tools_fts` | Full-text search index (FTS5) |
|
||||||
|
| `tool_stats` | Cached tool statistics |
|
||||||
|
| `publisher_stats` | Cached publisher statistics |
|
||||||
|
| `registry_settings` | Runtime-configurable settings |
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Provider Setup Guide
|
# 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.
|
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
|
## Provider Comparison
|
||||||
|
|||||||
+112
-45
@@ -1,5 +1,11 @@
|
|||||||
# CmdForge Registry Design
|
# 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
|
## Purpose
|
||||||
Build a centralized registry for CmdForge to enable discovery, publishing, dependency management, and future curation at scale.
|
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
|
## Implementation Phases
|
||||||
|
|
||||||
### Phase 1: Foundation
|
> **Status as of January 2026**: Phases 1-4 and 6-8 are complete. Phase 5 is partially complete.
|
||||||
- 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
|
|
||||||
|
|
||||||
### Phase 2: Core Backend
|
### Phase 1: Foundation ✅ Complete
|
||||||
- Set up Flask/FastAPI project structure
|
- [x] Define `cmdforge.yaml` manifest format
|
||||||
- Implement SQLite database schema
|
- [x] Implement tool resolution order (local → global → registry)
|
||||||
- Build core API endpoints (list, search, get, download)
|
- [x] Create CmdForge-Registry repo on Gitea (bootstrap)
|
||||||
- Implement webhook receiver for Gitea sync
|
- [x] Add 3-5 example tools to seed the registry (233+ Fabric patterns imported)
|
||||||
- Set up HMAC verification
|
|
||||||
|
|
||||||
### Phase 3: CLI Commands
|
### Phase 2: Core Backend ✅ Complete
|
||||||
- `cmdforge registry search`
|
- [x] Set up Flask/FastAPI project structure
|
||||||
- `cmdforge registry install`
|
- [x] Implement SQLite database schema (25+ tables including FTS5, reviews, issues, audit)
|
||||||
- `cmdforge registry info`
|
- [x] Build core API endpoints (list, search, get, download)
|
||||||
- `cmdforge registry browse` (TUI)
|
- [x] Implement webhook receiver for Gitea sync
|
||||||
- Local index caching
|
- [x] Set up HMAC verification
|
||||||
|
|
||||||
### Phase 4: Publishing
|
### Phase 3: CLI Commands ✅ Complete
|
||||||
- Publisher registration (web UI)
|
- [x] `cmdforge registry search` (with faceted search, tags, categories, owner, date range)
|
||||||
- Token management
|
- [x] `cmdforge registry install`
|
||||||
- `cmdforge registry publish` command
|
- [x] `cmdforge registry info`
|
||||||
- PR creation via Gitea API
|
- [x] `cmdforge registry browse` (opens PySide6 GUI)
|
||||||
- CI validation workflows
|
- [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
|
### Phase 4: Publishing ✅ Complete
|
||||||
- `cmdforge install` (from manifest)
|
- [x] Publisher registration (web UI)
|
||||||
- `cmdforge add` command
|
- [x] Token management
|
||||||
- Runtime override application
|
- [x] `cmdforge registry publish` command (with dry-run, version bump, similarity warnings)
|
||||||
- Dependency resolution
|
- [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
|
### Phase 5: Project Dependencies ⚠️ Partial
|
||||||
- SQLite FTS5 search index
|
- [ ] `cmdforge install` (from manifest) - Not implemented
|
||||||
- AI-powered auto-categorization
|
- [ ] `cmdforge add` command - Not implemented
|
||||||
- Duplicate/similarity detection
|
- [ ] Runtime override application - Not implemented
|
||||||
- Security scanning
|
- [ ] Dependency resolution - Not implemented
|
||||||
|
- [x] Individual tool install via `cmdforge registry install`
|
||||||
|
|
||||||
### Phase 7: Full Web UI
|
### Phase 6: Smart Features ✅ Complete
|
||||||
- Landing page
|
- [x] SQLite FTS5 search index (with prefix matching)
|
||||||
- Tool browsing/search pages
|
- [x] AI-powered auto-categorization
|
||||||
- Tool detail pages with README rendering
|
- [x] Duplicate/similarity detection
|
||||||
- Publisher dashboard
|
- [x] Security scanning (scrutiny system)
|
||||||
- Documentation/tutorials section
|
- [x] AI-powered secondary review (scrutiny-ai-review integration)
|
||||||
|
- [x] Auto-approve/review/reject decision logic
|
||||||
|
- [x] Confidence-based moderation
|
||||||
|
|
||||||
### Phase 8: Polish & Scale
|
### Phase 7: Full Web UI ✅ Complete
|
||||||
- Rate limiting
|
- [x] Landing page (with featured tools, tutorials, contributor spotlight)
|
||||||
- Abuse reporting
|
- [x] Tool browsing/search pages (with faceted filters)
|
||||||
- Analytics integration
|
- [x] Tool detail pages with README rendering (plus reviews, issues, versions, forks)
|
||||||
- Performance optimization
|
- [x] Publisher dashboard (tools, tokens, settings)
|
||||||
- Monitoring/alerting
|
- [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
|
# 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
|
## 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.
|
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
|
## Phase 7 Implementation Checklist
|
||||||
|
|
||||||
### 7.1 Foundation & Setup
|
> **Status as of January 2026**: Phase 7 is complete with most items implemented. See notes for partial items.
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
### 7.2 Core Templates & Components
|
### 7.1 Foundation & Setup ✅ Complete
|
||||||
- [ ] Create base template with header/footer
|
- [x] Set up Flask project structure with blueprints
|
||||||
- [ ] Implement navigation component (desktop + mobile)
|
- [x] Configure Jinja2 templates with base layout
|
||||||
- [ ] Build reusable card components (tool, tutorial, contributor)
|
- [x] Integrate Tailwind CSS (build pipeline)
|
||||||
- [ ] Create form components (inputs, buttons, dropdowns)
|
- [x] Set up static asset handling (CSS, JS, images)
|
||||||
- [ ] Implement callout/alert components
|
- [x] Configure development/production environments
|
||||||
- [ ] Build code block component with copy functionality
|
- [x] Set up database models for web-specific tables (25+ tables)
|
||||||
- [ ] Create loading states (skeleton, spinner)
|
|
||||||
- [ ] Implement responsive grid system
|
|
||||||
|
|
||||||
### 7.3 Landing Page
|
### 7.2 Core Templates & Components ✅ Complete
|
||||||
- [ ] Hero section with install snippet
|
- [x] Create base template with header/footer
|
||||||
- [ ] Three pillars section (Easy, Powerful, Community)
|
- [x] Implement navigation component (desktop + mobile)
|
||||||
- [ ] Featured tools grid (API integration)
|
- [x] Build reusable card components (tool, tutorial, contributor)
|
||||||
- [ ] Getting started tutorial cards
|
- [x] Create form components (inputs, buttons, dropdowns)
|
||||||
- [ ] Featured contributor spotlight
|
- [x] Implement callout/alert components
|
||||||
- [ ] Footer with links and optional ad zone
|
- [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)
|
### 7.3 Landing Page ✅ Complete
|
||||||
- [ ] Tool browse page with search bar
|
- [x] Hero section with install snippet
|
||||||
- [ ] Category dropdown filter
|
- [x] Three pillars section (Easy, Powerful, Community)
|
||||||
- [ ] Sort options (popular, recent, name)
|
- [x] Featured tools grid (API integration)
|
||||||
- [ ] Pagination component
|
- [x] Getting started tutorial cards
|
||||||
- [ ] Tool card grid layout
|
- [x] Featured contributor spotlight
|
||||||
- [ ] Tool detail page with README rendering
|
- [x] Footer with links and optional ad zone
|
||||||
- [ ] Version selector in sidebar
|
|
||||||
- [ ] Install command with copy
|
|
||||||
- [ ] Report abuse button/modal
|
|
||||||
- [ ] Category pages
|
|
||||||
|
|
||||||
### 7.5 Documentation & Tutorials
|
### 7.4 Registry Pages (Ad-Free) ✅ Complete
|
||||||
- [ ] Docs landing page with section links
|
- [x] Tool browse page with search bar
|
||||||
- [ ] Tutorial listing page
|
- [x] Category dropdown filter
|
||||||
- [ ] Content page template with TOC
|
- [x] Sort options (popular, recent, name)
|
||||||
- [ ] Scroll-spy for TOC highlighting
|
- [x] Pagination component
|
||||||
- [ ] Code syntax highlighting (Prism/Highlight.js)
|
- [x] Tool card grid layout
|
||||||
- [ ] Video embed component (YouTube)
|
- [x] Tool detail page with README rendering
|
||||||
- [ ] Related articles section
|
- [x] Version selector in sidebar
|
||||||
- [ ] Sidebar ad placement (desktop only)
|
- [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
|
### 7.5 Documentation & Tutorials ⚠️ Mostly Complete
|
||||||
- [ ] Registration page and flow
|
- [x] Docs landing page with section links
|
||||||
- [ ] Login page with error handling
|
- [x] Tutorial listing page
|
||||||
- [ ] Password reset flow (if implementing)
|
- [x] Content page template with TOC
|
||||||
- [ ] Session management (cookies, CSRF)
|
- [ ] Scroll-spy for TOC highlighting (not implemented)
|
||||||
- [ ] Dashboard layout with sidebar
|
- [x] Code syntax highlighting (Prism/Highlight.js)
|
||||||
- [ ] My Tools tab with tool list
|
- [ ] Video embed component (YouTube) (not implemented)
|
||||||
- [ ] API Tokens tab with create/revoke
|
- [ ] Related articles section (limited implementation)
|
||||||
- [ ] Settings tab with profile edit
|
- [x] Sidebar ad placement (desktop only)
|
||||||
- [ ] Logout functionality
|
|
||||||
|
|
||||||
### 7.7 Privacy & Consent
|
### 7.6 Authentication & Dashboard ✅ Complete
|
||||||
- [ ] Cookie consent banner
|
- [x] Registration page and flow
|
||||||
- [ ] Consent preferences modal
|
- [x] Login page with error handling
|
||||||
- [ ] Consent state storage
|
- [ ] Password reset flow (placeholder only - not functional)
|
||||||
- [ ] Privacy policy page
|
- [x] Session management (cookies, CSRF)
|
||||||
- [ ] Terms of service page
|
- [x] Dashboard layout with sidebar
|
||||||
- [ ] Honor consent in analytics/ad loading
|
- [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
|
### 7.7 Privacy & Consent ⚠️ Mostly Complete
|
||||||
- [ ] AdSense integration (account setup)
|
- [x] Cookie consent banner (API exists)
|
||||||
- [ ] Ad container components
|
- [x] Consent preferences modal (API exists)
|
||||||
- [ ] Lazy loading for ad scripts
|
- [x] Consent state storage
|
||||||
- [ ] Ad placement rules enforcement
|
- [x] Privacy policy page
|
||||||
- [ ] Sponsored content styling (if applicable)
|
- [x] Terms of service page
|
||||||
- [ ] Donate page with donation options
|
- [x] Honor consent in analytics/ad loading
|
||||||
|
|
||||||
### 7.9 SEO & Performance
|
### 7.8 Ads & Monetization ⚠️ Partial
|
||||||
- [ ] Meta tags for all page types
|
- [ ] AdSense integration (account setup) (placeholders only)
|
||||||
- [ ] Open Graph tags
|
- [x] Ad container components
|
||||||
- [ ] Schema.org structured data
|
- [x] Lazy loading for ad scripts
|
||||||
- [ ] Sitemap generation
|
- [x] Ad placement rules enforcement
|
||||||
- [ ] robots.txt configuration
|
- [ ] Sponsored content styling (if applicable) (not implemented)
|
||||||
- [ ] Canonical URL implementation
|
- [x] Donate page with donation options
|
||||||
- [ ] Image optimization pipeline
|
|
||||||
- [ ] CSS/JS minification
|
|
||||||
- [ ] Critical CSS inlining
|
|
||||||
- [ ] Lazy loading for images
|
|
||||||
|
|
||||||
### 7.10 Testing & QA
|
### 7.9 SEO & Performance ✅ Mostly Complete
|
||||||
- [ ] Responsive design testing (all breakpoints)
|
- [x] Meta tags for all page types
|
||||||
- [ ] Accessibility testing (WCAG 2.1 AA)
|
- [x] Open Graph tags
|
||||||
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)
|
- [ ] Schema.org structured data (not verified)
|
||||||
- [ ] Performance testing (Lighthouse scores)
|
- [x] Sitemap generation
|
||||||
- [ ] Form validation testing
|
- [x] robots.txt configuration
|
||||||
- [ ] Error state testing
|
- [x] Canonical URL implementation (partial)
|
||||||
- [ ] Mobile usability testing
|
- [ ] 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
|
### 7.10 Testing & QA ⚠️ Ongoing
|
||||||
- [ ] Content creation (initial docs, tutorials)
|
- [x] Responsive design testing (all breakpoints)
|
||||||
- [ ] Seed featured tools selection
|
- [ ] Accessibility testing (WCAG 2.1 AA) (not formally verified)
|
||||||
- [ ] Initial contributor spotlight
|
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge) (not formally verified)
|
||||||
- [ ] Analytics setup (privacy-respecting)
|
- [ ] Performance testing (Lighthouse scores) (not formally verified)
|
||||||
- [ ] Error monitoring (Sentry or similar)
|
- [x] Form validation testing
|
||||||
- [ ] SSL certificate configuration
|
- [x] Error state testing
|
||||||
- [ ] CDN setup (optional)
|
- [x] Mobile usability testing
|
||||||
- [ ] Backup and recovery procedures
|
|
||||||
|
### 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
|
## 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):**
|
**Public (read-only):**
|
||||||
- `GET /api/v1/tools` - List tools with pagination/filters
|
- `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}` - Tool details
|
||||||
- `GET /api/v1/tools/{owner}/{name}/versions` - Version list
|
- `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/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/stats/popular` - Popular tools
|
||||||
|
- `GET /api/v1/publishers/{slug}/stats` - Publisher stats
|
||||||
|
- `GET /api/v1/index.json` - Full index for offline use
|
||||||
|
|
||||||
**Authenticated (dashboard):**
|
**Authenticated (dashboard):**
|
||||||
- `POST /api/v1/login` - User login (returns session)
|
- `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
|
- `POST /api/v1/tokens` - Create new token
|
||||||
- `DELETE /api/v1/tokens/{id}` - Revoke token
|
- `DELETE /api/v1/tokens/{id}` - Revoke token
|
||||||
- `PUT /api/v1/me/settings` - Update profile
|
- `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/tools` - Curated featured tools
|
||||||
- `GET /api/v1/featured/contributors` - Featured contributor
|
- `GET /api/v1/featured/contributors` - Featured contributor
|
||||||
- `GET /api/v1/content/announcements` - Site announcements
|
- `GET /api/v1/content/announcements` - Site announcements
|
||||||
- `POST /api/v1/reports` - Abuse report submission
|
- `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
|
## Diagram References
|
||||||
|
|
||||||
- Landing page mockup: `discussions/diagrams/cmdforge-registry_rob_6.svg`
|
- 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"
|
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
|
@dataclass
|
||||||
class PatternState:
|
class PatternState:
|
||||||
"""State of a single pattern."""
|
"""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}"
|
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(
|
def sync_pattern(
|
||||||
pattern_dir: Path,
|
pattern_dir: Path,
|
||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
provider: str,
|
provider: str,
|
||||||
state: SyncState,
|
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:
|
) -> bool:
|
||||||
"""Sync a single pattern.
|
"""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:
|
Returns:
|
||||||
True if successful
|
True if successful
|
||||||
"""
|
"""
|
||||||
name = pattern_dir.name
|
name = pattern_dir.name
|
||||||
pattern_hash = hash_pattern(pattern_dir)
|
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)
|
passed, reason = vet_pattern(pattern_dir, provider)
|
||||||
|
|
||||||
if not passed:
|
if not passed:
|
||||||
@@ -270,15 +634,7 @@ def sync_pattern(
|
|||||||
}
|
}
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
logger.info(f" [DRY RUN] Would sync: {name} ({reason})")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Import the pattern
|
|
||||||
try:
|
try:
|
||||||
script_dir = Path(__file__).parent
|
|
||||||
sys.path.insert(0, str(script_dir))
|
|
||||||
|
|
||||||
from import_fabric import import_pattern
|
from import_fabric import import_pattern
|
||||||
|
|
||||||
success = import_pattern(
|
success = import_pattern(
|
||||||
@@ -291,7 +647,7 @@ def sync_pattern(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info(f" ✓ {name}: {reason}")
|
logger.info(f" ✓ {name} -> {output_dir}/{name}")
|
||||||
state.patterns[name] = {
|
state.patterns[name] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"hash": pattern_hash,
|
"hash": pattern_hash,
|
||||||
@@ -328,10 +684,24 @@ def run_sync(
|
|||||||
state_file: Path,
|
state_file: Path,
|
||||||
provider: str,
|
provider: str,
|
||||||
dry_run: bool = False,
|
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:
|
) -> dict:
|
||||||
"""Run the sync process.
|
"""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:
|
Returns:
|
||||||
Summary dict with counts
|
Summary dict with counts
|
||||||
"""
|
"""
|
||||||
@@ -365,10 +735,11 @@ def run_sync(
|
|||||||
failed = 0
|
failed = 0
|
||||||
|
|
||||||
if to_sync:
|
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:
|
for name in to_sync:
|
||||||
pattern_dir = patterns_dir / name
|
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
|
synced += 1
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
@@ -438,14 +809,19 @@ def daemon_loop(
|
|||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
state_file: Path,
|
state_file: Path,
|
||||||
provider: str,
|
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."""
|
"""Run sync in a loop."""
|
||||||
logger.info(f"Starting daemon mode with {interval}s interval")
|
logger.info(f"Starting daemon mode with {interval}s interval")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Sync failed: {e}")
|
logger.error(f"Sync failed: {e}")
|
||||||
|
|
||||||
@@ -514,6 +890,21 @@ def main():
|
|||||||
default=DEFAULT_PROVIDER,
|
default=DEFAULT_PROVIDER,
|
||||||
help=f"Default provider for tools (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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -530,7 +921,10 @@ def main():
|
|||||||
args.output,
|
args.output,
|
||||||
state_file,
|
state_file,
|
||||||
args.provider,
|
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
|
return 0
|
||||||
|
|
||||||
@@ -541,7 +935,10 @@ def main():
|
|||||||
state_file,
|
state_file,
|
||||||
args.provider,
|
args.provider,
|
||||||
dry_run=args.dry_run,
|
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:
|
if summary["failed"] > 0:
|
||||||
|
|||||||
@@ -91,6 +91,85 @@ def clean_prompt(prompt: str) -> str:
|
|||||||
return prompt
|
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(
|
def create_tool_config(
|
||||||
pattern_name: str,
|
pattern_name: str,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
@@ -101,15 +180,17 @@ def create_tool_config(
|
|||||||
cleaned_prompt = clean_prompt(system_prompt)
|
cleaned_prompt = clean_prompt(system_prompt)
|
||||||
display_name = pattern_to_display_name(pattern_name)
|
display_name = pattern_to_display_name(pattern_name)
|
||||||
category = get_category(pattern_name)
|
category = get_category(pattern_name)
|
||||||
|
description = extract_description(system_prompt, pattern_name)
|
||||||
|
|
||||||
# Build the full prompt with input placeholder
|
# Build the full prompt with input placeholder
|
||||||
full_prompt = f"{cleaned_prompt}\n\n{{input}}"
|
full_prompt = f"{cleaned_prompt}\n\n{{input}}"
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"name": pattern_name,
|
"name": pattern_name,
|
||||||
"description": f"{display_name} - imported from Fabric patterns",
|
"description": description,
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"category": category,
|
"category": category,
|
||||||
|
"tags": ["fabric", "imported"],
|
||||||
|
|
||||||
# Attribution - marks this as an imported tool
|
# Attribution - marks this as an imported tool
|
||||||
"source": {
|
"source": {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from typing import List
|
|||||||
|
|
||||||
import yaml
|
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.-]+)?(?:\+.+)?$")
|
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+.+)?$")
|
||||||
|
|
||||||
REQUIRED_README_SECTIONS = ["## Usage", "## Examples"]
|
REQUIRED_README_SECTIONS = ["## Usage", "## Examples"]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from .tool_commands import (
|
|||||||
)
|
)
|
||||||
from .provider_commands import cmd_providers
|
from .provider_commands import cmd_providers
|
||||||
from .registry_commands import cmd_registry
|
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 .project_commands import cmd_deps, cmd_install_deps, cmd_add, cmd_init
|
||||||
from .config_commands import cmd_config
|
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("--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("--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("-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)
|
p_run.set_defaults(func=cmd_run)
|
||||||
|
|
||||||
# 'ui' command (explicit)
|
# '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 = registry_sub.add_parser("my-tools", help="List your published tools")
|
||||||
p_reg_mytools.set_defaults(func=cmd_registry)
|
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
|
# registry browse
|
||||||
p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)")
|
p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)")
|
||||||
p_reg_browse.set_defaults(func=cmd_registry)
|
p_reg_browse.set_defaults(func=cmd_registry)
|
||||||
@@ -203,6 +210,32 @@ def main():
|
|||||||
# Default for registry with no subcommand
|
# 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)))
|
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
|
# Project Commands
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -255,6 +288,10 @@ def main():
|
|||||||
p_cfg_connect.add_argument("username", help="Your CmdForge username")
|
p_cfg_connect.add_argument("username", help="Your CmdForge username")
|
||||||
p_cfg_connect.set_defaults(func=cmd_config)
|
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
|
# 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)))
|
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)
|
return _cmd_config_set(args)
|
||||||
elif args.config_cmd == "connect":
|
elif args.config_cmd == "connect":
|
||||||
return _cmd_config_connect(args)
|
return _cmd_config_connect(args)
|
||||||
|
elif args.config_cmd == "disconnect":
|
||||||
|
return _cmd_config_disconnect(args)
|
||||||
else:
|
else:
|
||||||
print("Config commands:")
|
print("Config commands:")
|
||||||
print(" show Show current configuration")
|
print(" show Show current configuration")
|
||||||
print(" connect <username> Connect this app to your CmdForge account")
|
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-token <token> Set registry authentication token")
|
||||||
print(" set <key> <value> Set a configuration value")
|
print(" set <key> <value> Set a configuration value")
|
||||||
return 0
|
return 0
|
||||||
@@ -165,3 +168,18 @@ def _cmd_config_connect(args):
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n\nConnection cancelled.")
|
print("\n\nConnection cancelled.")
|
||||||
return 1
|
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)
|
return _cmd_registry_publish(args)
|
||||||
elif args.registry_cmd == "my-tools":
|
elif args.registry_cmd == "my-tools":
|
||||||
return _cmd_registry_my_tools(args)
|
return _cmd_registry_my_tools(args)
|
||||||
|
elif args.registry_cmd == "status":
|
||||||
|
return _cmd_registry_status(args)
|
||||||
elif args.registry_cmd == "browse":
|
elif args.registry_cmd == "browse":
|
||||||
return _cmd_registry_browse(args)
|
return _cmd_registry_browse(args)
|
||||||
elif args.registry_cmd == "config":
|
elif args.registry_cmd == "config":
|
||||||
@@ -45,7 +47,8 @@ def cmd_registry(args):
|
|||||||
print(" update Update local index cache")
|
print(" update Update local index cache")
|
||||||
print(" publish [path] Publish a tool")
|
print(" publish [path] Publish a tool")
|
||||||
print(" my-tools List your published tools")
|
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)")
|
print(" config [action] Manage registry settings (admin)")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -437,6 +440,21 @@ def _cmd_registry_publish(args):
|
|||||||
similarity = tool.get("similarity", 0)
|
similarity = tool.get("similarity", 0)
|
||||||
print(f" - {tool.get('name', 'unknown')} ({similarity:.0%} similar)")
|
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:
|
except RegistryError as e:
|
||||||
if e.code == "UNAUTHORIZED":
|
if e.code == "UNAUTHORIZED":
|
||||||
print("Authentication failed.", file=sys.stderr)
|
print("Authentication failed.", file=sys.stderr)
|
||||||
@@ -503,6 +521,118 @@ def _cmd_registry_my_tools(args):
|
|||||||
return 0
|
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):
|
def _cmd_registry_browse(args):
|
||||||
"""Browse tools (GUI)."""
|
"""Browse tools (GUI)."""
|
||||||
from ..gui import run_gui
|
from ..gui import run_gui
|
||||||
|
|||||||
@@ -202,26 +202,48 @@ def cmd_run(args):
|
|||||||
|
|
||||||
# Collect custom args from remaining arguments
|
# Collect custom args from remaining arguments
|
||||||
custom_args = {}
|
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:
|
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
|
# Parse tool-specific arguments
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(args.tool_args):
|
while i < len(tool_args):
|
||||||
arg = args.tool_args[i]
|
arg = tool_args[i]
|
||||||
if arg.startswith('--'):
|
if arg.startswith('--'):
|
||||||
key = arg[2:].replace('-', '_')
|
flag_key = arg[2:].replace('-', '_')
|
||||||
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('--'):
|
# Map flag name to variable name (use flag_key as fallback for unknown flags)
|
||||||
custom_args[key] = args.tool_args[i + 1]
|
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
|
i += 2
|
||||||
else:
|
else:
|
||||||
custom_args[key] = True
|
custom_args[var_name] = True
|
||||||
i += 1
|
i += 1
|
||||||
elif arg.startswith('-'):
|
elif arg.startswith('-'):
|
||||||
key = arg[1:].replace('-', '_')
|
flag_key = arg[1:].replace('-', '_')
|
||||||
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('-'):
|
var_name = flag_to_var.get(flag_key, flag_key)
|
||||||
custom_args[key] = args.tool_args[i + 1]
|
if i + 1 < len(tool_args) and not tool_args[i + 1].startswith('-'):
|
||||||
|
custom_args[var_name] = tool_args[i + 1]
|
||||||
i += 2
|
i += 2
|
||||||
else:
|
else:
|
||||||
custom_args[key] = True
|
custom_args[var_name] = True
|
||||||
i += 1
|
i += 1
|
||||||
else:
|
else:
|
||||||
i += 1
|
i += 1
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Publish tool dialog."""
|
"""Publish tool dialog."""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QLabel, QPushButton,
|
QDialog, QVBoxLayout, QLabel, QPushButton,
|
||||||
QHBoxLayout, QLineEdit, QTextEdit, QFormLayout,
|
QHBoxLayout, QLineEdit, QTextEdit, QFormLayout,
|
||||||
QProgressBar, QMessageBox, QComboBox
|
QProgressBar, QMessageBox, QComboBox, QFrame, QWidget
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import QThread, Signal
|
from PySide6.QtCore import QThread, Signal
|
||||||
|
|
||||||
@@ -12,14 +13,35 @@ from ...registry_client import RegistryClient, RegistryError
|
|||||||
from ...config import load_config
|
from ...config import load_config
|
||||||
|
|
||||||
|
|
||||||
class PublishWorker(QThread):
|
def parse_version(version: str) -> tuple:
|
||||||
"""Background worker for publishing."""
|
"""Parse version string into tuple of ints."""
|
||||||
success = Signal(dict)
|
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)
|
error = Signal(str)
|
||||||
|
|
||||||
def __init__(self, tool_data: dict):
|
def __init__(self, name: str, original_owner: Optional[str] = None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.tool_data = tool_data
|
self.name = name
|
||||||
|
self.original_owner = original_owner
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
@@ -27,7 +49,53 @@ class PublishWorker(QThread):
|
|||||||
client = RegistryClient()
|
client = RegistryClient()
|
||||||
client.token = config.registry.token
|
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)
|
self.success.emit(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.error.emit(str(e))
|
self.error.emit(str(e))
|
||||||
@@ -39,10 +107,114 @@ class PublishDialog(QDialog):
|
|||||||
def __init__(self, parent, tool: Tool):
|
def __init__(self, parent, tool: Tool):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Publish Tool")
|
self.setWindowTitle("Publish Tool")
|
||||||
self.setMinimumSize(500, 400)
|
self.setMinimumSize(500, 450)
|
||||||
self._tool = tool
|
self._tool = tool
|
||||||
self._worker = None
|
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._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):
|
def _setup_ui(self):
|
||||||
"""Set up the UI."""
|
"""Set up the UI."""
|
||||||
@@ -54,15 +226,76 @@ class PublishDialog(QDialog):
|
|||||||
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
||||||
layout.addWidget(title)
|
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
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setSpacing(12)
|
form.setSpacing(12)
|
||||||
|
|
||||||
# Version
|
# Version input with bump buttons
|
||||||
|
version_row = QHBoxLayout()
|
||||||
self.version_input = QLineEdit()
|
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")
|
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
|
# Category
|
||||||
self.category_combo = QComboBox()
|
self.category_combo = QComboBox()
|
||||||
@@ -127,6 +360,8 @@ class PublishDialog(QDialog):
|
|||||||
|
|
||||||
def _publish(self):
|
def _publish(self):
|
||||||
"""Publish the tool."""
|
"""Publish the tool."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
version = self.version_input.text().strip()
|
version = self.version_input.text().strip()
|
||||||
if not version:
|
if not version:
|
||||||
QMessageBox.warning(self, "Validation", "Version is required")
|
QMessageBox.warning(self, "Validation", "Version is required")
|
||||||
@@ -140,37 +375,85 @@ class PublishDialog(QDialog):
|
|||||||
category = self.category_combo.currentText()
|
category = self.category_combo.currentText()
|
||||||
tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()]
|
tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()]
|
||||||
|
|
||||||
# Build tool data for publishing
|
# Build complete tool config from the Tool object
|
||||||
tool_data = {
|
tool_config = self._tool.to_dict() if hasattr(self._tool, 'to_dict') else {}
|
||||||
"name": self._tool.name,
|
|
||||||
"version": version,
|
# Update with publish dialog values
|
||||||
"description": description,
|
tool_config["name"] = self._tool.name
|
||||||
"category": category,
|
tool_config["version"] = version
|
||||||
"tags": tags,
|
tool_config["description"] = description
|
||||||
"definition": self._tool.to_dict() if hasattr(self._tool, 'to_dict') else {}
|
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_publish.setEnabled(False)
|
||||||
self.btn_cancel.setEnabled(False)
|
self.btn_cancel.setEnabled(False)
|
||||||
self.progress.show()
|
self.progress.show()
|
||||||
self.status_label.setText("Publishing...")
|
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.success.connect(self._on_success)
|
||||||
self._worker.error.connect(self._on_error)
|
self._worker.error.connect(self._on_error)
|
||||||
self._worker.start()
|
self._worker.start()
|
||||||
|
|
||||||
def _on_success(self, result: dict):
|
def _on_success(self, result: dict):
|
||||||
"""Handle publish success."""
|
"""Handle publish success."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
self.progress.hide()
|
self.progress.hide()
|
||||||
self.status_label.setText("Published successfully!")
|
self.status_label.setText("Published successfully!")
|
||||||
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
|
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
|
||||||
|
|
||||||
QMessageBox.information(
|
# Save the published config with registry tracking fields
|
||||||
self, "Success",
|
# This ensures local file matches exactly what was sent to server (for hash consistency)
|
||||||
f"Tool '{self._tool.name}' has been published.\n\n"
|
config_hash = result.get("config_hash")
|
||||||
f"It will be available after review."
|
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()
|
self.accept()
|
||||||
|
|
||||||
def _on_error(self, error: str):
|
def _on_error(self, error: str):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from PySide6.QtWidgets import (
|
|||||||
QListWidget, QListWidgetItem, QStackedWidget,
|
QListWidget, QListWidgetItem, QStackedWidget,
|
||||||
QStatusBar, QLabel, QSplitter
|
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 PySide6.QtGui import QIcon, QFont, QShortcut, QKeySequence
|
||||||
|
|
||||||
from .styles import STYLESHEET
|
from .styles import STYLESHEET
|
||||||
@@ -68,6 +68,33 @@ class MainWindow(QMainWindow):
|
|||||||
# Restore window geometry
|
# Restore window geometry
|
||||||
self._restore_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):
|
def _setup_sidebar(self):
|
||||||
"""Set up sidebar navigation items."""
|
"""Set up sidebar navigation items."""
|
||||||
items = [
|
items = [
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ class SearchWorker(QThread):
|
|||||||
self.error.emit(str(e))
|
self.error.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
class InstallWorker(QThread):
|
class VersionsWorker(QThread):
|
||||||
"""Background worker for tool installation."""
|
"""Background worker to fetch available versions."""
|
||||||
finished = Signal(str)
|
finished = Signal(list) # List of version strings
|
||||||
error = Signal(str)
|
error = Signal(str)
|
||||||
|
|
||||||
def __init__(self, owner: str, name: str):
|
def __init__(self, owner: str, name: str):
|
||||||
@@ -70,8 +70,29 @@ class InstallWorker(QThread):
|
|||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
client = RegistryClient()
|
client = RegistryClient()
|
||||||
client.install_tool(self.owner, self.name)
|
versions = client.get_tool_versions(self.owner, self.name)
|
||||||
self.finished.emit(f"{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:
|
except Exception as e:
|
||||||
self.error.emit(str(e))
|
self.error.emit(str(e))
|
||||||
|
|
||||||
@@ -84,7 +105,9 @@ class RegistryPage(QWidget):
|
|||||||
self.main_window = main_window
|
self.main_window = main_window
|
||||||
self._search_worker = None
|
self._search_worker = None
|
||||||
self._install_worker = None
|
self._install_worker = None
|
||||||
|
self._versions_worker = None
|
||||||
self._selected_tool = None
|
self._selected_tool = None
|
||||||
|
self._available_versions = []
|
||||||
self._current_page = 1
|
self._current_page = 1
|
||||||
self._total_pages = 1
|
self._total_pages = 1
|
||||||
self._current_tags = []
|
self._current_tags = []
|
||||||
@@ -228,6 +251,23 @@ class RegistryPage(QWidget):
|
|||||||
|
|
||||||
right_layout.addWidget(details_box, 1)
|
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
|
# Action buttons
|
||||||
actions = QHBoxLayout()
|
actions = QHBoxLayout()
|
||||||
|
|
||||||
@@ -405,6 +445,9 @@ class RegistryPage(QWidget):
|
|||||||
self.details_text.clear()
|
self.details_text.clear()
|
||||||
self.btn_install.setEnabled(False)
|
self.btn_install.setEnabled(False)
|
||||||
self.btn_update.hide()
|
self.btn_update.hide()
|
||||||
|
self.version_combo.clear()
|
||||||
|
self.version_combo.addItem("Latest", None)
|
||||||
|
self.version_combo.setEnabled(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
row = items[0].row()
|
row = items[0].row()
|
||||||
@@ -413,11 +456,47 @@ class RegistryPage(QWidget):
|
|||||||
self._selected_tool = tool
|
self._selected_tool = tool
|
||||||
self._show_tool_details(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
|
# 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:
|
if tool_name in self._installed_tools:
|
||||||
installed_version = self._installed_tools[tool_name]
|
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:
|
if installed_version != registry_version:
|
||||||
self.btn_install.hide()
|
self.btn_install.hide()
|
||||||
self.btn_update.show()
|
self.btn_update.show()
|
||||||
@@ -546,12 +625,16 @@ class RegistryPage(QWidget):
|
|||||||
|
|
||||||
owner = self._selected_tool.get("owner", "")
|
owner = self._selected_tool.get("owner", "")
|
||||||
name = self._selected_tool.get("name", "")
|
name = self._selected_tool.get("name", "")
|
||||||
|
version = self.version_combo.currentData() # None for "Latest"
|
||||||
|
|
||||||
self.btn_install.setEnabled(False)
|
self.btn_install.setEnabled(False)
|
||||||
self.btn_update.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.finished.connect(self._on_install_complete)
|
||||||
self._install_worker.error.connect(self._on_install_error)
|
self._install_worker.error.connect(self._on_install_error)
|
||||||
self._install_worker.start()
|
self._install_worker.start()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from ...tool import (
|
|||||||
Tool, ToolArgument, PromptStep, CodeStep,
|
Tool, ToolArgument, PromptStep, CodeStep,
|
||||||
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
||||||
)
|
)
|
||||||
|
from ..widgets.icons import get_prompt_icon, get_code_icon
|
||||||
|
|
||||||
|
|
||||||
class ToolBuilderPage(QWidget):
|
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.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_deleted.connect(self._on_flow_steps_deleted)
|
||||||
self._flow_widget.steps_reordered.connect(self._on_flow_steps_reordered)
|
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
|
# Replace placeholder
|
||||||
old_widget = self.steps_stack.widget(1)
|
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)
|
# Refresh the list view (flow view will be refreshed by set_tool)
|
||||||
self._refresh_steps()
|
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):
|
def _on_flow_steps_reordered(self, new_order: list):
|
||||||
"""Handle step reordering from flow view.
|
"""Handle step reordering from flow view.
|
||||||
|
|
||||||
@@ -371,15 +384,20 @@ class ToolBuilderPage(QWidget):
|
|||||||
prompt_count += 1
|
prompt_count += 1
|
||||||
# Use custom name if set, otherwise default per-type naming
|
# Use custom name if set, otherwise default per-type naming
|
||||||
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
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):
|
elif isinstance(step, CodeStep):
|
||||||
code_count += 1
|
code_count += 1
|
||||||
step_name = step.name if step.name else f"Code {code_count}"
|
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:
|
else:
|
||||||
text = f"{i}. Unknown step"
|
text = f"Unknown step"
|
||||||
|
icon = None
|
||||||
|
|
||||||
item = QListWidgetItem(text)
|
item = QListWidgetItem(text)
|
||||||
|
if icon:
|
||||||
|
item.setIcon(icon)
|
||||||
item.setData(Qt.UserRole, step)
|
item.setData(Qt.UserRole, step)
|
||||||
|
|
||||||
# Check for broken dependencies
|
# Check for broken dependencies
|
||||||
@@ -401,6 +419,59 @@ class ToolBuilderPage(QWidget):
|
|||||||
if self._flow_widget:
|
if self._flow_widget:
|
||||||
self._flow_widget.set_tool(self._tool)
|
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):
|
def _add_argument(self):
|
||||||
"""Add a new argument."""
|
"""Add a new argument."""
|
||||||
from ..dialogs.argument_dialog import ArgumentDialog
|
from ..dialogs.argument_dialog import ArgumentDialog
|
||||||
|
|||||||
@@ -1,21 +1,134 @@
|
|||||||
"""Tools page - main view for managing tools."""
|
"""Tools page - main view for managing tools."""
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
||||||
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
|
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
|
||||||
QPushButton, QGroupBox, QMessageBox, QFrame
|
QPushButton, QGroupBox, QMessageBox, QFrame
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||||
from PySide6.QtGui import QFont
|
from PySide6.QtGui import QFont, QColor, QBrush, QShortcut, QKeySequence
|
||||||
|
|
||||||
from ...tool import (
|
from ...tool import (
|
||||||
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
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
|
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):
|
class ToolsPage(QWidget):
|
||||||
"""Main tools management page."""
|
"""Main tools management page."""
|
||||||
|
|
||||||
@@ -23,6 +136,10 @@ class ToolsPage(QWidget):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.main_window = main_window
|
self.main_window = main_window
|
||||||
self._current_tool = None
|
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._setup_ui()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@@ -44,14 +161,9 @@ class ToolsPage(QWidget):
|
|||||||
header_layout.addStretch()
|
header_layout.addStretch()
|
||||||
|
|
||||||
# Connection status
|
# Connection status
|
||||||
config = load_config()
|
self.connection_status = QLabel()
|
||||||
if config.registry.token:
|
self._update_connection_status()
|
||||||
status = QLabel("Connected to Registry")
|
header_layout.addWidget(self.connection_status)
|
||||||
status.setStyleSheet("color: #38a169; font-weight: 500;")
|
|
||||||
else:
|
|
||||||
status = QLabel("Not connected")
|
|
||||||
status.setStyleSheet("color: #718096;")
|
|
||||||
header_layout.addWidget(status)
|
|
||||||
|
|
||||||
layout.addWidget(header)
|
layout.addWidget(header)
|
||||||
|
|
||||||
@@ -135,11 +247,26 @@ class ToolsPage(QWidget):
|
|||||||
|
|
||||||
layout.addWidget(buttons)
|
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):
|
def refresh(self):
|
||||||
"""Refresh the tool list."""
|
"""Refresh the tool list."""
|
||||||
self.tool_tree.clear()
|
self.tool_tree.clear()
|
||||||
self._current_tool = None
|
self._current_tool = None
|
||||||
self.info_text.clear()
|
self.info_text.clear()
|
||||||
|
self._has_pending_tools = False # Reset, will be set during tree building
|
||||||
|
|
||||||
tools = list_tools()
|
tools = list_tools()
|
||||||
|
|
||||||
@@ -169,10 +296,46 @@ class ToolsPage(QWidget):
|
|||||||
|
|
||||||
# Tools in category
|
# Tools in category
|
||||||
for name, tool in sorted(tools_by_category[category], key=lambda x: x[0]):
|
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)
|
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":
|
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)
|
cat_item.addChild(tool_item)
|
||||||
|
|
||||||
self.tool_tree.addTopLevelItem(cat_item)
|
self.tool_tree.addTopLevelItem(cat_item)
|
||||||
@@ -186,6 +349,102 @@ class ToolsPage(QWidget):
|
|||||||
"or browse the Registry to install community tools."
|
"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):
|
def _on_selection_changed(self):
|
||||||
"""Handle tool selection change."""
|
"""Handle tool selection change."""
|
||||||
items = self.tool_tree.selectedItems()
|
items = self.tool_tree.selectedItems()
|
||||||
@@ -215,6 +474,17 @@ class ToolsPage(QWidget):
|
|||||||
if tool_name:
|
if tool_name:
|
||||||
self._edit_tool()
|
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):
|
def _show_tool_info(self, tool: Tool):
|
||||||
"""Display tool information."""
|
"""Display tool information."""
|
||||||
lines = []
|
lines = []
|
||||||
@@ -224,6 +494,51 @@ class ToolsPage(QWidget):
|
|||||||
if tool.description:
|
if tool.description:
|
||||||
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
|
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
|
# Source info
|
||||||
if tool.source:
|
if tool.source:
|
||||||
source_type = tool.source.type
|
source_type = tool.source.type
|
||||||
@@ -275,7 +590,11 @@ class ToolsPage(QWidget):
|
|||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if config.registry.token:
|
if config.registry.token:
|
||||||
|
# Connected - enable Publish when tool selected
|
||||||
self.btn_publish.setEnabled(has_selection)
|
self.btn_publish.setEnabled(has_selection)
|
||||||
|
else:
|
||||||
|
# Not connected - Connect button is always enabled
|
||||||
|
self.btn_publish.setEnabled(True)
|
||||||
|
|
||||||
def _create_tool(self):
|
def _create_tool(self):
|
||||||
"""Create a new tool."""
|
"""Create a new tool."""
|
||||||
@@ -314,6 +633,7 @@ class ToolsPage(QWidget):
|
|||||||
dialog = ConnectDialog(self)
|
dialog = ConnectDialog(self)
|
||||||
if dialog.exec():
|
if dialog.exec():
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
self._update_connection_status()
|
||||||
self.main_window.show_status("Connected to registry")
|
self.main_window.show_status("Connected to registry")
|
||||||
# Recreate publish button
|
# Recreate publish button
|
||||||
self.btn_publish.setText("Publish")
|
self.btn_publish.setText("Publish")
|
||||||
@@ -325,7 +645,75 @@ class ToolsPage(QWidget):
|
|||||||
if not self._current_tool:
|
if not self._current_tool:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
tool_name = self._current_tool.name # Save before refresh clears it
|
||||||
from ..dialogs.publish_dialog import PublishDialog
|
from ..dialogs.publish_dialog import PublishDialog
|
||||||
dialog = PublishDialog(self, self._current_tool)
|
dialog = PublishDialog(self, self._current_tool)
|
||||||
if dialog.exec():
|
result = dialog.exec()
|
||||||
self.main_window.show_status(f"Published '{self._current_tool.name}'")
|
# 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 NodeGraphQt import NodeGraph, BaseNode
|
||||||
|
|
||||||
from ...tool import Tool, PromptStep, CodeStep
|
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):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.set_color(90, 90, 160) # Indigo
|
self.set_color(90, 90, 160) # Indigo
|
||||||
|
self.set_icon(get_input_icon_path(16))
|
||||||
# Default output for stdin
|
# Default output for stdin
|
||||||
self.add_output('input', color=(180, 180, 250))
|
self.add_output('input', color=(180, 180, 250))
|
||||||
|
|
||||||
@@ -56,7 +61,8 @@ class PromptNode(CmdForgeBaseNode):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.set_color(102, 126, 234) # Indigo (matching CmdForge theme)
|
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))
|
self.add_output('out', color=(180, 250, 180))
|
||||||
|
|
||||||
# Add properties for display
|
# Add properties for display
|
||||||
@@ -82,7 +88,8 @@ class CodeNode(CmdForgeBaseNode):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.set_color(72, 187, 120) # Green
|
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))
|
self.add_output('out', color=(250, 220, 180))
|
||||||
|
|
||||||
# Add properties
|
# Add properties
|
||||||
@@ -106,7 +113,8 @@ class OutputNode(CmdForgeBaseNode):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.set_color(237, 137, 54) # Orange
|
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):
|
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)
|
# Emitted when a node is double-clicked (step_index, step_type)
|
||||||
node_double_clicked = Signal(int, str)
|
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)
|
# Emitted when steps are reordered (new order as list of step indices)
|
||||||
steps_reordered = Signal(list)
|
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):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._tool: Optional[Tool] = None
|
self._tool: Optional[Tool] = None
|
||||||
@@ -157,7 +173,7 @@ class FlowGraphWidget(QWidget):
|
|||||||
# Connect signals
|
# Connect signals
|
||||||
self._graph.node_double_clicked.connect(self._on_node_double_clicked)
|
self._graph.node_double_clicked.connect(self._on_node_double_clicked)
|
||||||
self._graph.nodes_deleted.connect(self._on_nodes_deleted)
|
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
|
# Add graph widget
|
||||||
layout.addWidget(self._graph.widget, 1)
|
layout.addWidget(self._graph.widget, 1)
|
||||||
@@ -172,7 +188,6 @@ class FlowGraphWidget(QWidget):
|
|||||||
"Zoom: Scroll | "
|
"Zoom: Scroll | "
|
||||||
"Select: Click/drag | "
|
"Select: Click/drag | "
|
||||||
"Edit: Double-click | "
|
"Edit: Double-click | "
|
||||||
"Cut links: Alt+Shift+drag | "
|
|
||||||
"Select All: A | "
|
"Select All: A | "
|
||||||
"Fit: F"
|
"Fit: F"
|
||||||
)
|
)
|
||||||
@@ -449,58 +464,22 @@ class FlowGraphWidget(QWidget):
|
|||||||
self.steps_deleted.emit(deleted_indices)
|
self.steps_deleted.emit(deleted_indices)
|
||||||
self.flow_changed.emit()
|
self.flow_changed.emit()
|
||||||
|
|
||||||
def _on_port_disconnected(self, input_port, output_port):
|
def _on_property_changed(self, node, prop_name, value):
|
||||||
"""Handle port disconnection - reorder steps.
|
"""Handle node property change.
|
||||||
|
|
||||||
When a connection is broken:
|
Filters for 'name' property to detect step name changes.
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
# Ignore during rebuild
|
# Ignore during rebuild
|
||||||
if self._rebuilding:
|
if self._rebuilding:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._tool or not self._tool.steps:
|
# Only interested in name changes
|
||||||
|
if prop_name != 'name':
|
||||||
return
|
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)
|
# Only handle step nodes (not Input/Output)
|
||||||
if not hasattr(disconnected_node, '_step_index') or disconnected_node._step_index < 0:
|
if hasattr(node, '_step_index') and node._step_index >= 0:
|
||||||
return
|
self.step_name_changed.emit(node._step_index, value)
|
||||||
|
|
||||||
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)
|
|
||||||
self.flow_changed.emit()
|
self.flow_changed.emit()
|
||||||
|
|
||||||
def refresh(self):
|
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 .db import connect_db, init_db, query_all, query_one
|
||||||
from .rate_limit import RateLimiter
|
from .rate_limit import RateLimiter
|
||||||
from .sync import process_webhook, get_categories_cache_path, get_repo_dir
|
from .sync import process_webhook, get_categories_cache_path, get_repo_dir
|
||||||
|
from ..hash_utils import compute_yaml_hash
|
||||||
from .stats import (
|
from .stats import (
|
||||||
refresh_tool_stats, get_tool_stats, refresh_publisher_stats,
|
refresh_tool_stats, get_tool_stats, refresh_publisher_stats,
|
||||||
get_publisher_stats, track_tool_usage, calculate_badges, BADGES,
|
get_publisher_stats, track_tool_usage, calculate_badges, BADGES,
|
||||||
@@ -56,7 +57,7 @@ ALLOWED_SORT = {
|
|||||||
"/categories": {"name", "tool_count"},
|
"/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]$")
|
OWNER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,37}[a-z0-9]$")
|
||||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
RESERVED_SLUGS = {"official", "admin", "system", "api", "registry", "cmdforge"}
|
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]
|
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:
|
def create_app() -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY_BYTES
|
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
|
# Sanitize query for FTS5 - escape special characters that cause syntax errors
|
||||||
# FTS5 special chars: * " ( ) : ^ - NOT AND OR NEAR
|
# FTS5 special chars: * " ( ) : ^ - NOT AND OR NEAR
|
||||||
# For safety, we'll quote the entire query if it contains special chars
|
# 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'):
|
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
|
# Escape double quotes and wrap in quotes for literal search
|
||||||
query_text = '"' + query_text.replace('"', '""') + '"'
|
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")
|
page, per_page, sort, order, error = parse_pagination("/tools/search", "downloads")
|
||||||
if error:
|
if error:
|
||||||
@@ -894,6 +977,17 @@ def create_app() -> Flask:
|
|||||||
"url": row["source_url"],
|
"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 = {
|
payload = {
|
||||||
"owner": row["owner"],
|
"owner": row["owner"],
|
||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
@@ -909,6 +1003,9 @@ def create_app() -> Flask:
|
|||||||
"config": row["config_yaml"],
|
"config": row["config_yaml"],
|
||||||
"readme": row["readme"],
|
"readme": row["readme"],
|
||||||
"source": source_obj,
|
"source": source_obj,
|
||||||
|
"forked_from": row.get("forked_from"),
|
||||||
|
"forked_version": row.get("forked_version"),
|
||||||
|
"fork_count": fork_count,
|
||||||
}
|
}
|
||||||
response = jsonify({"data": payload})
|
response = jsonify({"data": payload})
|
||||||
response.headers["Cache-Control"] = "max-age=60"
|
response.headers["Cache-Control"] = "max-age=60"
|
||||||
@@ -937,6 +1034,55 @@ def create_app() -> Flask:
|
|||||||
versions = [row["version"] for row in rows]
|
versions = [row["version"] for row in rows]
|
||||||
return jsonify({"data": {"versions": versions}})
|
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"])
|
@app.route("/api/v1/tools/<owner>/<name>/download", methods=["GET"])
|
||||||
def download_tool(owner: str, name: str) -> Response:
|
def download_tool(owner: str, name: str) -> Response:
|
||||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
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"],
|
"resolved_version": row["version"],
|
||||||
"config": row["config_yaml"],
|
"config": row["config_yaml"],
|
||||||
"readme": row["readme"] or "",
|
"readme": row["readme"] or "",
|
||||||
|
"config_hash": row.get("config_hash") or "",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
response.headers["Cache-Control"] = "max-age=3600, immutable"
|
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 {}
|
categories_payload = yaml.safe_load(categories_yaml.read_text(encoding="utf-8")) or {}
|
||||||
predefined_categories = (categories_payload or {}).get("categories", [])
|
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(
|
counts = query_all(
|
||||||
g.db,
|
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}
|
count_map = {row["category"]: row["total"] for row in counts}
|
||||||
|
|
||||||
@@ -1077,31 +1226,35 @@ def create_app() -> Flask:
|
|||||||
category = request.args.get("category")
|
category = request.args.get("category")
|
||||||
limit = min(int(request.args.get("limit", 100)), 500)
|
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
|
# Build query - extract tags from JSON array and count occurrences
|
||||||
if category:
|
if category:
|
||||||
rows = query_all(
|
rows = query_all(
|
||||||
g.db,
|
g.db,
|
||||||
"""
|
f"""
|
||||||
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
|
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
|
||||||
FROM tools, json_each(tools.tags) AS tag
|
FROM tools, json_each(tools.tags) AS tag
|
||||||
WHERE tools.category = ?
|
WHERE tools.category = ? {vis_filter}
|
||||||
GROUP BY tag.value
|
GROUP BY tag.value
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(category, limit),
|
[category] + vis_params + [limit],
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rows = query_all(
|
rows = query_all(
|
||||||
g.db,
|
g.db,
|
||||||
"""
|
f"""
|
||||||
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
|
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
|
||||||
FROM tools, json_each(tools.tags) AS tag
|
FROM tools, json_each(tools.tags) AS tag
|
||||||
|
WHERE 1=1 {vis_filter}
|
||||||
GROUP BY tag.value
|
GROUP BY tag.value
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(limit,),
|
vis_params + [limit],
|
||||||
)
|
)
|
||||||
|
|
||||||
data = [{"name": row["name"], "count": row["count"]} for row in rows]
|
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"
|
response.headers["Cache-Control"] = "max-age=3600"
|
||||||
return response
|
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"])
|
@app.route("/api/v1/stats/popular", methods=["GET"])
|
||||||
def popular_tools() -> Response:
|
def popular_tools() -> Response:
|
||||||
limit = min(int(request.args.get("limit", 10)), 50)
|
limit = min(int(request.args.get("limit", 10)), 50)
|
||||||
@@ -1692,6 +2042,10 @@ def create_app() -> Flask:
|
|||||||
# Create a minimal source_json for consistency
|
# Create a minimal source_json for consistency
|
||||||
source_json = json.dumps({"type": "imported", "original_tool": source, "url": source_url})
|
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:
|
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")
|
return error_response("VALIDATION_ERROR", "Invalid tool name")
|
||||||
if not version or Semver.parse(version) is None:
|
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")
|
return error_response("VALIDATION_ERROR", "Tag exceeds 32 characters")
|
||||||
|
|
||||||
owner = g.current_publisher["slug"]
|
owner = g.current_publisher["slug"]
|
||||||
|
|
||||||
|
# Compute config hash early for idempotency check
|
||||||
|
config_hash = compute_yaml_hash(config_text)
|
||||||
|
|
||||||
existing = query_one(
|
existing = query_one(
|
||||||
g.db,
|
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],
|
[owner, name, version],
|
||||||
)
|
)
|
||||||
if existing:
|
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(
|
return error_response(
|
||||||
"VERSION_EXISTS",
|
"VERSION_EXISTS",
|
||||||
f"Version {version} already exists",
|
f"Version {version} already exists with different content. Use a new version number.",
|
||||||
409,
|
409,
|
||||||
details={"published_at": existing["published_at"]},
|
details={"published_at": existing["published_at"]},
|
||||||
)
|
)
|
||||||
@@ -1779,6 +2152,20 @@ def create_app() -> Flask:
|
|||||||
details={"scrutiny": scrutiny_report},
|
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:
|
if dry_run:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1792,6 +2179,8 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
tags_json = json.dumps(tags)
|
tags_json = json.dumps(tags)
|
||||||
|
|
||||||
|
# config_hash already computed earlier for idempotency check
|
||||||
|
|
||||||
# Determine status based on scrutiny
|
# Determine status based on scrutiny
|
||||||
if scrutiny_report and scrutiny_report.get("decision") == "approve":
|
if scrutiny_report and scrutiny_report.get("decision") == "approve":
|
||||||
scrutiny_status = "approved"
|
scrutiny_status = "approved"
|
||||||
@@ -1816,8 +2205,9 @@ def create_app() -> Flask:
|
|||||||
owner, name, version, description, category, tags, config_yaml, readme,
|
owner, name, version, description, category, tags, config_yaml, readme,
|
||||||
publisher_id, deprecated, deprecated_message, replacement, downloads,
|
publisher_id, deprecated, deprecated_message, replacement, downloads,
|
||||||
scrutiny_status, scrutiny_report, source, source_url, source_json,
|
scrutiny_status, scrutiny_report, source, source_url, source_json,
|
||||||
visibility, moderation_status, published_at
|
config_hash, visibility, moderation_status, forked_from, forked_version,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
published_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
[
|
[
|
||||||
owner,
|
owner,
|
||||||
@@ -1838,8 +2228,11 @@ def create_app() -> Flask:
|
|||||||
source,
|
source,
|
||||||
source_url,
|
source_url,
|
||||||
source_json,
|
source_json,
|
||||||
|
config_hash,
|
||||||
visibility,
|
visibility,
|
||||||
moderation_status,
|
moderation_status,
|
||||||
|
forked_from,
|
||||||
|
forked_version,
|
||||||
datetime.utcnow().isoformat(),
|
datetime.utcnow().isoformat(),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1850,9 +2243,12 @@ def create_app() -> Flask:
|
|||||||
"owner": owner,
|
"owner": owner,
|
||||||
"name": name,
|
"name": name,
|
||||||
"version": version,
|
"version": version,
|
||||||
|
"config_hash": config_hash,
|
||||||
"pr_url": "",
|
"pr_url": "",
|
||||||
"status": moderation_status,
|
"status": moderation_status,
|
||||||
"visibility": visibility,
|
"visibility": visibility,
|
||||||
|
"forked_from": forked_from,
|
||||||
|
"forked_version": forked_version,
|
||||||
"suggestions": suggestions,
|
"suggestions": suggestions,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1887,6 +2283,42 @@ def create_app() -> Flask:
|
|||||||
})
|
})
|
||||||
return jsonify({"data": data})
|
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"])
|
@app.route("/api/v1/tools/<owner>/<name>/deprecate", methods=["POST"])
|
||||||
@require_token
|
@require_token
|
||||||
def deprecate_tool(owner: str, name: str) -> Response:
|
def deprecate_tool(owner: str, name: str) -> Response:
|
||||||
@@ -1980,6 +2412,90 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
return jsonify({"data": {"status": "updated"}})
|
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"])
|
@app.route("/api/v1/featured/tools", methods=["GET"])
|
||||||
def featured_tools() -> Response:
|
def featured_tools() -> Response:
|
||||||
"""Get featured tools for homepage/landing."""
|
"""Get featured tools for homepage/landing."""
|
||||||
@@ -2303,6 +2819,14 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
data = []
|
data = []
|
||||||
for row in rows:
|
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({
|
data.append({
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"owner": row["owner"],
|
"owner": row["owner"],
|
||||||
@@ -2313,6 +2837,8 @@ def create_app() -> Flask:
|
|||||||
"published_at": row["published_at"],
|
"published_at": row["published_at"],
|
||||||
"publisher_name": row["publisher_name"],
|
"publisher_name": row["publisher_name"],
|
||||||
"visibility": row["visibility"],
|
"visibility": row["visibility"],
|
||||||
|
"scrutiny_status": row["scrutiny_status"],
|
||||||
|
"scrutiny_report": scrutiny_report,
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -2320,6 +2846,59 @@ def create_app() -> Flask:
|
|||||||
"meta": paginate(page, per_page, total),
|
"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"])
|
@app.route("/api/v1/admin/tools/<int:tool_id>/approve", methods=["POST"])
|
||||||
@require_moderator
|
@require_moderator
|
||||||
def admin_approve_tool(tool_id: int) -> Response:
|
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}})
|
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"])
|
@app.route("/api/v1/admin/tools/<int:tool_id>/remove", methods=["POST"])
|
||||||
@require_moderator
|
@require_moderator
|
||||||
def admin_remove_tool(tool_id: int) -> Response:
|
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}})
|
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"])
|
@app.route("/api/v1/admin/tools/<int:tool_id>", methods=["DELETE"])
|
||||||
@require_admin
|
@require_admin
|
||||||
def admin_delete_tool(tool_id: int) -> Response:
|
def admin_delete_tool(tool_id: int) -> Response:
|
||||||
|
|||||||
@@ -59,11 +59,14 @@ CREATE TABLE IF NOT EXISTS tools (
|
|||||||
source TEXT,
|
source TEXT,
|
||||||
source_url TEXT,
|
source_url TEXT,
|
||||||
source_json TEXT,
|
source_json TEXT,
|
||||||
|
config_hash TEXT,
|
||||||
visibility TEXT DEFAULT 'public',
|
visibility TEXT DEFAULT 'public',
|
||||||
moderation_status TEXT DEFAULT 'pending',
|
moderation_status TEXT DEFAULT 'pending',
|
||||||
moderation_note TEXT,
|
moderation_note TEXT,
|
||||||
moderated_by TEXT,
|
moderated_by TEXT,
|
||||||
moderated_at TIMESTAMP,
|
moderated_at TIMESTAMP,
|
||||||
|
forked_from TEXT,
|
||||||
|
forked_version TEXT,
|
||||||
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(owner, name, version)
|
UNIQUE(owner, name, version)
|
||||||
);
|
);
|
||||||
@@ -448,11 +451,14 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
|||||||
("source", "TEXT", "NULL"),
|
("source", "TEXT", "NULL"),
|
||||||
("source_url", "TEXT", "NULL"),
|
("source_url", "TEXT", "NULL"),
|
||||||
("source_json", "TEXT", "NULL"),
|
("source_json", "TEXT", "NULL"),
|
||||||
|
("config_hash", "TEXT", "NULL"),
|
||||||
("visibility", "TEXT", "'public'"),
|
("visibility", "TEXT", "'public'"),
|
||||||
("moderation_status", "TEXT", "'pending'"),
|
("moderation_status", "TEXT", "'pending'"),
|
||||||
("moderation_note", "TEXT", "NULL"),
|
("moderation_note", "TEXT", "NULL"),
|
||||||
("moderated_by", "TEXT", "NULL"),
|
("moderated_by", "TEXT", "NULL"),
|
||||||
("moderated_at", "TIMESTAMP", "NULL"),
|
("moderated_at", "TIMESTAMP", "NULL"),
|
||||||
|
("forked_from", "TEXT", "NULL"),
|
||||||
|
("forked_version", "TEXT", "NULL"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for col_name, col_type, default in tools_migrations:
|
for col_name, col_type, default in tools_migrations:
|
||||||
@@ -497,12 +503,13 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL
|
# 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:
|
try:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
UPDATE tools
|
UPDATE tools
|
||||||
SET moderation_status = 'approved'
|
SET moderation_status = 'approved'
|
||||||
WHERE moderation_status IS NULL OR moderation_status = 'pending'
|
WHERE moderation_status IS NULL
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
@@ -512,6 +519,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
|||||||
try:
|
try:
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner)")
|
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_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_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_publishers_banned ON publishers(banned)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id)")
|
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
|
resolved_version: str
|
||||||
config_yaml: str
|
config_yaml: str
|
||||||
readme: str = ""
|
readme: str = ""
|
||||||
|
config_hash: str = "" # Registry hash for integrity verification
|
||||||
|
|
||||||
|
|
||||||
class RegistryClient:
|
class RegistryClient:
|
||||||
@@ -544,7 +545,8 @@ class RegistryClient:
|
|||||||
name=data.get("name", name),
|
name=data.get("name", name),
|
||||||
resolved_version=data.get("resolved_version", ""),
|
resolved_version=data.get("resolved_version", ""),
|
||||||
config_yaml=data.get("config", ""),
|
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]]:
|
def get_categories(self) -> List[Dict[str, Any]]:
|
||||||
@@ -561,6 +563,44 @@ class RegistryClient:
|
|||||||
|
|
||||||
return response.json().get("data", [])
|
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(
|
def publish_tool(
|
||||||
self,
|
self,
|
||||||
config_yaml: str,
|
config_yaml: str,
|
||||||
@@ -603,6 +643,33 @@ class RegistryClient:
|
|||||||
|
|
||||||
return response.json().get("data", {})
|
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]:
|
def get_my_tools(self) -> List[ToolInfo]:
|
||||||
"""
|
"""
|
||||||
Get tools published by the authenticated user.
|
Get tools published by the authenticated user.
|
||||||
@@ -618,6 +685,30 @@ class RegistryClient:
|
|||||||
tools = response.json().get("data", [])
|
tools = response.json().get("data", [])
|
||||||
return [ToolInfo.from_dict(t) for t in tools]
|
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]:
|
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
|
||||||
"""
|
"""
|
||||||
Get most popular tools.
|
Get most popular tools.
|
||||||
|
|||||||
@@ -352,7 +352,8 @@ class ToolResolver:
|
|||||||
name=result.name,
|
name=result.name,
|
||||||
version=result.resolved_version,
|
version=result.resolved_version,
|
||||||
config_yaml=result.config_yaml,
|
config_yaml=result.config_yaml,
|
||||||
readme=result.readme
|
readme=result.readme,
|
||||||
|
config_hash=result.config_hash
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
@@ -379,13 +380,30 @@ class ToolResolver:
|
|||||||
name: str,
|
name: str,
|
||||||
version: str,
|
version: str,
|
||||||
config_yaml: str,
|
config_yaml: str,
|
||||||
readme: str = ""
|
readme: str = "",
|
||||||
|
config_hash: str = ""
|
||||||
) -> ResolvedTool:
|
) -> ResolvedTool:
|
||||||
"""Install a tool fetched from registry to global directory."""
|
"""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
|
# Create directory structure
|
||||||
tool_dir = TOOLS_DIR / owner / name
|
tool_dir = TOOLS_DIR / owner / name
|
||||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
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
|
# Write config
|
||||||
config_path = tool_dir / "config.yaml"
|
config_path = tool_dir / "config.yaml"
|
||||||
config_path.write_text(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,
|
name=result.name,
|
||||||
version=result.resolved_version,
|
version=result.resolved_version,
|
||||||
config_yaml=result.config_yaml,
|
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
|
dependencies: List[str] = field(default_factory=list) # Required tools for meta-tools
|
||||||
source: Optional[ToolSource] = None # Attribution for imported/external tools
|
source: Optional[ToolSource] = None # Attribution for imported/external tools
|
||||||
version: str = "" # Tool version
|
version: str = "" # Tool version
|
||||||
|
path: Optional[Path] = None # Path to config.yaml (set by load_tool)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "Tool":
|
def from_dict(cls, data: dict) -> "Tool":
|
||||||
@@ -331,7 +332,9 @@ def load_tool(name: str) -> Optional[Tool]:
|
|||||||
"output": "{response}" if steps else "{input}"
|
"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:
|
except yaml.YAMLError as e:
|
||||||
import sys
|
import sys
|
||||||
print(f"Error loading tool '{name}': YAML syntax error", file=sys.stderr)
|
print(f"Error loading tool '{name}': YAML syntax error", file=sys.stderr)
|
||||||
|
|||||||
+260
-5
@@ -233,30 +233,68 @@ def tools():
|
|||||||
return _render_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):
|
def _render_tools(category_override: Optional[str] = None):
|
||||||
page = request.args.get("page", 1)
|
page = request.args.get("page", 1)
|
||||||
sort = request.args.get("sort", "downloads")
|
sort = request.args.get("sort", "downloads")
|
||||||
category = category_override or request.args.get("category")
|
category = category_override or request.args.get("category")
|
||||||
query = request.args.get("q")
|
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}
|
params = {"page": page, "per_page": 12, "sort": sort}
|
||||||
if category:
|
if category:
|
||||||
params["category"] = category
|
params["category"] = category
|
||||||
|
if include_tags:
|
||||||
|
params["tags"] = ",".join(include_tags)
|
||||||
|
|
||||||
status, payload = _api_get("/api/v1/tools", params=params)
|
status, payload = _api_get("/api/v1/tools", params=params)
|
||||||
if status != 200:
|
if status != 200:
|
||||||
return render_template("errors/500.html"), 500
|
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", {})
|
meta = payload.get("meta", {})
|
||||||
categories, all_tools_total = _load_categories()
|
categories, all_tools_total = _load_categories()
|
||||||
|
all_tags = _load_tags()
|
||||||
return render_template(
|
return render_template(
|
||||||
"pages/tools.html",
|
"pages/tools.html",
|
||||||
tools=payload.get("data", []),
|
tools=tools,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
current_category=category,
|
current_category=category,
|
||||||
total_count=all_tools_total,
|
total_count=all_tools_total,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
query=query,
|
query=query,
|
||||||
pagination=_build_pagination(meta),
|
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 = issues_payload.get("data", [])
|
||||||
issues_meta = issues_payload.get("meta", {})
|
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(
|
return render_template(
|
||||||
"pages/tool_detail.html",
|
"pages/tool_detail.html",
|
||||||
tool=tool,
|
tool=tool,
|
||||||
@@ -390,6 +432,7 @@ def tool_detail(owner: str, name: str):
|
|||||||
reviews_total=reviews_meta.get("total", 0),
|
reviews_total=reviews_meta.get("total", 0),
|
||||||
issues=issues,
|
issues=issues,
|
||||||
issues_total=issues_meta.get("total", 0),
|
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 = issues_payload.get("data", [])
|
||||||
issues_meta = issues_payload.get("meta", {})
|
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(
|
return render_template(
|
||||||
"pages/tool_detail.html",
|
"pages/tool_detail.html",
|
||||||
tool=tool,
|
tool=tool,
|
||||||
@@ -432,6 +479,7 @@ def tool_version(owner: str, name: str, version: str):
|
|||||||
reviews_total=reviews_meta.get("total", 0),
|
reviews_total=reviews_meta.get("total", 0),
|
||||||
issues=issues,
|
issues=issues,
|
||||||
issues_total=issues_meta.get("total", 0),
|
issues_total=issues_meta.get("total", 0),
|
||||||
|
forks=forks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -564,7 +612,32 @@ def dashboard_tools():
|
|||||||
token = session.get("auth_token")
|
token = session.get("auth_token")
|
||||||
user = _load_current_publisher() or session.get("user", {})
|
user = _load_current_publisher() or session.get("user", {})
|
||||||
status, payload = _api_get("/api/v1/me/tools", token=token)
|
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
|
# Load rating/issue data for each tool
|
||||||
for tool in tools:
|
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():
|
def dashboard_settings():
|
||||||
redirect_response = _require_login()
|
redirect_response = _require_login()
|
||||||
if redirect_response:
|
if redirect_response:
|
||||||
return redirect_response
|
return redirect_response
|
||||||
token = session.get("auth_token")
|
token = session.get("auth_token")
|
||||||
user = _load_current_publisher() or session.get("user", {})
|
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_status, tools_payload = _api_get("/api/v1/me/tools", token=token)
|
||||||
tools = tools_payload.get("data", []) if tools_status == 200 else []
|
tools = tools_payload.get("data", []) if tools_status == 200 else []
|
||||||
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
|
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
|
||||||
@@ -647,8 +763,8 @@ def dashboard_settings():
|
|||||||
tools=tools,
|
tools=tools,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
tokens=tokens,
|
tokens=tokens,
|
||||||
errors=[],
|
errors=errors,
|
||||||
success_message=None,
|
success_message=success_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -803,6 +919,8 @@ def sitemap():
|
|||||||
SELECT owner, name, MAX(id) AS max_id
|
SELECT owner, name, MAX(id) AS max_id
|
||||||
FROM tools
|
FROM tools
|
||||||
WHERE version NOT LIKE '%-%'
|
WHERE version NOT LIKE '%-%'
|
||||||
|
AND visibility = 'public'
|
||||||
|
AND moderation_status = 'approved'
|
||||||
GROUP BY owner, name
|
GROUP BY owner, name
|
||||||
)
|
)
|
||||||
SELECT t.owner, t.name, t.published_at
|
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)
|
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
|
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(
|
return render_template(
|
||||||
"admin/index.html",
|
"admin/index.html",
|
||||||
user=user,
|
user=user,
|
||||||
@@ -1013,6 +1135,7 @@ def admin_dashboard():
|
|||||||
pending_count=pending_count,
|
pending_count=pending_count,
|
||||||
reports_count=reports_count,
|
reports_count=reports_count,
|
||||||
publishers_count=publishers_count,
|
publishers_count=publishers_count,
|
||||||
|
rejected_count=rejected_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1132,3 +1255,135 @@ def admin_settings():
|
|||||||
categories=categories,
|
categories=categories,
|
||||||
token=token,
|
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()
|
conn = connect_db()
|
||||||
try:
|
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:
|
for row in rows:
|
||||||
tool_path = url_for("web.tool_detail", owner=row["owner"], name=row["name"], _external=True)
|
tool_path = url_for("web.tool_detail", owner=row["owner"], name=row["name"], _external=True)
|
||||||
urls.append(_url_entry(tool_path, "daily", "0.9"))
|
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:
|
for row in categories:
|
||||||
cat_path = url_for("web.category", name=row["category"], _external=True)
|
cat_path = url_for("web.category", name=row["category"], _external=True)
|
||||||
urls.append(_url_entry(cat_path, "weekly", "0.7"))
|
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>
|
</svg>
|
||||||
<span class="text-sm font-medium text-gray-700">Manage publishers</span>
|
<span class="text-sm font-medium text-gray-700">Manage publishers</span>
|
||||||
</a>
|
</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' %}
|
{% 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">
|
<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">
|
<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>
|
</svg>
|
||||||
<span class="text-sm font-medium text-gray-700">Registry settings</span>
|
<span class="text-sm font-medium text-gray-700">Registry settings</span>
|
||||||
</a>
|
</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 %}
|
{% endif %}
|
||||||
<a href="{{ url_for('web.dashboard') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
<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">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<tr>
|
<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">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">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-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>
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -30,24 +30,56 @@
|
|||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<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 class="text-sm text-gray-500">v{{ tool.version }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm text-gray-900">{{ tool.publisher_name }}</div>
|
<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>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4">
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
{% if tool.scrutiny_status == 'approved' %}
|
||||||
{{ tool.category or 'Uncategorized' }}
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
Passed
|
||||||
</span>
|
</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>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{{ tool.published_at[:10] if tool.published_at else 'Unknown' }}
|
{{ tool.published_at[:10] if tool.published_at else 'Unknown' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<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>
|
<button onclick="rejectTool({{ tool.id }})" class="text-red-600 hover:text-red-900">Reject</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -62,12 +94,37 @@
|
|||||||
<div class="text-sm text-gray-700">
|
<div class="text-sm text-gray-700">
|
||||||
Page {{ meta.page }} of {{ meta.total_pages }} ({{ meta.total }} total)
|
Page {{ meta.page }} of {{ meta.total_pages }} ({{ meta.total }} total)
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
|
<!-- First/Previous -->
|
||||||
{% if meta.page > 1 %}
|
{% 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>
|
<a href="?page={{ meta.page - 1 }}" class="px-3 py-1 border rounded text-sm hover:bg-gray-100">Previous</a>
|
||||||
{% endif %}
|
{% 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 %}
|
{% 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.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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,11 +140,94 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 -->
|
<!-- 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 id="reject-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 p-5 border w-96 shadow-lg rounded-md bg-white">
|
<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 class="mt-3">
|
<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 mb-4">Reject Tool</h3>
|
<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>
|
<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">
|
<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>
|
<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>
|
<script>
|
||||||
let currentToolId = null;
|
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) {
|
async function approveTool(toolId) {
|
||||||
if (!confirm('Approve this tool?')) return;
|
if (!confirm('Approve this tool?')) return;
|
||||||
@@ -111,25 +452,36 @@ async function approveTool(toolId) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
document.getElementById(`tool-row-${toolId}`).remove();
|
const row = document.getElementById(`tool-row-${toolId}`);
|
||||||
|
if (row) row.remove();
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
alert(data.error?.message || 'Failed to approve tool');
|
alert(data.error?.message || 'Failed to approve tool');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Network error');
|
console.error('Approve error:', error);
|
||||||
|
alert('Error: ' + (error.message || 'Unknown error occurred'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectTool(toolId) {
|
function rejectTool(toolId) {
|
||||||
currentToolId = toolId;
|
currentToolId = toolId;
|
||||||
document.getElementById('reject-modal').classList.remove('hidden');
|
document.getElementById('reject-modal').classList.remove('hidden');
|
||||||
|
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
||||||
document.getElementById('reject-reason').value = '';
|
document.getElementById('reject-reason').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRejectModal() {
|
function closeRejectModal() {
|
||||||
document.getElementById('reject-modal').classList.add('hidden');
|
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;
|
currentToolId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,15 +502,120 @@ async function confirmReject() {
|
|||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
closeRejectModal();
|
closeRejectModal();
|
||||||
document.getElementById(`tool-row-${currentToolId}`).remove();
|
const row = document.getElementById(`tool-row-${currentToolId}`);
|
||||||
|
if (row) row.remove();
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
alert(data.error?.message || 'Failed to reject tool');
|
alert(data.error?.message || 'Failed to reject tool');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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>
|
</script>
|
||||||
{% endblock %}
|
{% 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>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span class="text-sm text-gray-900">v{{ tool.version }}</span>
|
<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>
|
<p class="text-xs text-gray-500">{{ tool.published_at|timeago }}</p>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{{ tool.downloads|format_number }}
|
{{ tool.downloads|format_number }}
|
||||||
|
|||||||
@@ -66,7 +66,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
<div class="flex items-start">
|
<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">
|
<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>
|
</ul>
|
||||||
</div>
|
</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 -->
|
<!-- Stats -->
|
||||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
<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>
|
<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>
|
<dd class="text-sm font-medium text-gray-900">{{ rating.unique_users|format_number }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,43 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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 -->
|
<!-- Sort -->
|
||||||
<div>
|
<div>
|
||||||
<label for="sort-select" class="block text-sm font-medium text-gray-700 mb-3">Sort By</label>
|
<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>
|
<option value="name" {{ 'selected' if sort == 'name' }}>Name (A-Z)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,7 +220,8 @@
|
|||||||
description=tool.description,
|
description=tool.description,
|
||||||
category=tool.category,
|
category=tool.category,
|
||||||
downloads=tool.downloads,
|
downloads=tool.downloads,
|
||||||
version=tool.version
|
version=tool.version,
|
||||||
|
tags=tool.tags
|
||||||
) }}
|
) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +231,7 @@
|
|||||||
<nav class="mt-12 flex items-center justify-center" aria-label="Pagination">
|
<nav class="mt-12 flex items-center justify-center" aria-label="Pagination">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if pagination.has_prev %}
|
{% 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">
|
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
|
Previous
|
||||||
</a>
|
</a>
|
||||||
@@ -181,7 +242,7 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{% if pagination.has_next %}
|
{% 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">
|
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
|
Next
|
||||||
</a>
|
</a>
|
||||||
@@ -228,5 +289,47 @@ function updateSort(value) {
|
|||||||
url.searchParams.set('sort', value);
|
url.searchParams.set('sort', value);
|
||||||
window.location.href = url.toString();
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user