Author SHA1 Message Date
robandClaude Opus 4.5 2c2707679f Add collections CLI commands and admin management
CLI Commands:
- cmdforge collections list - List available collections
- cmdforge collections info <name> - Show collection details
- cmdforge collections install <name> - Install all tools in a collection

Admin API:
- GET /api/v1/admin/collections - List all collections (admin)
- POST /api/v1/admin/collections - Create collection
- PUT /api/v1/admin/collections/<name> - Update collection
- DELETE /api/v1/admin/collections/<name> - Delete collection

Admin Web UI:
- /dashboard/admin/collections - List and manage collections
- /dashboard/admin/collections/new - Create new collection form
- /dashboard/admin/collections/<name>/edit - Edit collection form
- Added "Manage collections" link to admin dashboard

Registry Client:
- Added get_collections() method
- Added get_collection(name) method

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-17 03:43:58 -04:00
robandClaude Opus 4.5 0d91eb7fa0 Update olddocs to reflect current implementation state
- REGISTRY.md: Mark phases 1-4, 6-8 complete; phase 5 partial
- WEB_UI.md: Update Phase 7 checklist with completion status
- DESIGN.md: Note TUI replaced by PySide6 GUI, update structure
- PROJECT.md: Update source structure and database schema
- PROVIDERS.md, META_TOOLS.md, COLLECTIONS.md: Add status notes
- Add "Features Implemented Beyond Original Design" sections

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-17 03:35:42 -04:00
robandClaude Opus 4.5 a46add05b7 Add prefix matching to registry search
Search terms now automatically get a * suffix for prefix matching.
E.g., searching "summ" matches "summarize", "summary", etc.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-17 02:42:46 -04:00
robandClaude Opus 4.5 85d1212c0d Add startup connection validation to GUI
- Add validate_token() method to RegistryClient
  - Returns (is_valid, error_message) tuple
  - Catches 401/UNAUTHORIZED errors and connection issues
- Add startup validation in MainWindow
  - Runs 500ms after window shows (non-blocking)
  - Clears invalid tokens automatically
  - Shows status bar message explaining what happened
- Document cmdforge config disconnect command

This prevents confusing errors when trying to publish with stale or
revoked credentials - the GUI now auto-clears bad connections on restart.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-17 01:59:16 -04:00
robandClaude Opus 4.5 66fd121ee4 Add AI review logging and --skip-ai-review flag to fabric sync
- Add comprehensive logging to run_ai_scrutiny_review() function
  - Log tool path checks, warning counts, timing
  - Log timeout events, JSON parse errors, all exceptions
  - Previously failures were silently swallowed
- Add --skip-ai-review flag for large bulk imports
  - Prevents rate limiting during initial imports
  - Passes through daemon_loop, run_sync, sync_pattern, publish_to_registry

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-17 01:41:45 -04:00
robandClaude Opus 4.5 adefe909c2 Add fork display, version selector, and admin cleanup features
Registry Features:
- Fork tracking with forked_from/forked_version metadata
- Forked tools show "Forked from" notice on detail page
- Original tools display "Forks" section listing all forks
- Fork count in tool stats
- API: GET /api/v1/tools/<owner>/<name>/forks

GUI Improvements:
- Version selector dropdown for registry installs
- Fetch available versions via GET /api/v1/tools/<owner>/<name>/versions
- "Latest" option plus all available versions listed

Admin Features:
- POST /api/v1/admin/cleanup/rejected endpoint
- Maintenance section in admin dashboard
- "Dry Run" and "Run Cleanup" buttons for rejected version cleanup
- Configurable grace period (default 7 days)

Documentation:
- Updated CHANGELOG.md with all recent changes

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 23:42:32 -04:00
robandClaude Opus 4.5 74b5124360 Fix publish flow and consolidate My Tools view
- Make publish endpoint idempotent: return success if same content
  already exists instead of VERSION_EXISTS error (fixes retry issues)
- Always refresh tools page after publish dialog closes
- Consolidate My Tools page to group versions by tool name
- Show version count and list instead of separate rows per version
- Sum downloads across all versions for accurate totals

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 23:17:55 -04:00
robandClaude Opus 4.5 16de3e371c Add version display and bump buttons to publish dialog
- Show local and registry version in publish dialog
- Add +Patch/+Minor/+Major bump buttons
- Auto-fetch registry version on dialog open
- Suggest next version based on latest registry version
- Add fork detection and forked_from metadata support
- Update registry schema for forked_from/forked_version fields
- Display fork indicator when publishing forked tools

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 22:45:42 -04:00
robandClaude Opus 4.5 4756ea906c Replace Sync Status button with F5 keyboard shortcut
The visible button was confusing since auto-polling handles status
updates. Power users can press F5 to manually sync if needed.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 21:43:37 -04:00
robandClaude Opus 4.5 9f60e84075 Remove local hash comparison for publish state detection
Simplified approach: just show the moderation status from the server
without trying to detect local modifications. This eliminates the
fragile hash comparison that kept breaking due to representation
differences between to_dict(), raw YAML, and server-side normalization.

States are now simply: local, pending, published, changes_requested, rejected

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 20:53:26 -04:00
robandClaude Opus 4.5 66c0fc77ae Fix GUI publish to save normalized config for hash consistency
The GUI was sending to_dict() output to server but only updating the
existing config file with the hash. This caused mismatches because
to_dict() adds fields like 'arguments: []' that weren't in the original.

Now saves the exact config that was published, ensuring local file
matches what was sent to server.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 20:37:29 -04:00
robandClaude Opus 4.5 d7d0019472 Add registry_feedback to hash exclude list
When syncing feedback from server, the registry_feedback field was
being saved to config but not excluded from hash computation, causing
tools to appear as "modified" instead of showing their actual status.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 20:30:59 -04:00
robandClaude Opus 4.5 23c42faf75 Fix hash comparison in get_tool_publish_state
Use raw config for hash comparison instead of to_dict(), which was
adding fields like 'arguments: []' that weren't in the original config.
This caused tools to show as "modified" when they weren't.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 20:22:26 -04:00
robandClaude Opus 4.5 19c1cf086f Save registry_hash and status after CLI publish
The GUI was saving tracking fields but CLI wasn't. Now both save
registry_hash, registry_status, and clear feedback on republish.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 20:02:17 -04:00
robandClaude Opus 4.5 64b9e8e70d Add status polling and CLI status command for tool moderation
- Add QTimer polling every 30s when tools are pending/changes_requested
- Display changes_requested (⚠) and rejected (✗) states in tool tree
- Show moderator feedback in tool info panel
- Add `cmdforge registry status <tool>` CLI command with --sync flag
- Update _sync_tool to persist registry_feedback field

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 19:50:30 -04:00
robandClaude Opus 4.5 cd4c0682e8 Add Request Changes feature for admin tool moderation
- Add POST /api/v1/admin/tools/{id}/request-changes endpoint
- Sets moderation_status to 'changes_requested' with feedback
- Extend /me/tools/{name}/status to return feedback when status is
  changes_requested or rejected
- Add Request Changes button and modal in admin pending UI
- Make changes modal draggable like other modals

This allows admins to send feedback to publishers instead of just
approving or rejecting tools outright.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 17:59:15 -04:00
robandClaude Opus 4.5 9029286d4c Fix AI review variable name in publish endpoint
The AI review was failing because it referenced 'config' which doesn't
exist in the publish endpoint scope. Changed to 'data' which is the
actual variable containing the parsed tool configuration.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 17:00:50 -04:00
robandClaude Opus 4.5 12ae870b3c Fix tool argument passing for flag-style arguments
- Change nargs="*" to REMAINDER for tool_args to stop argparse from
  intercepting --flag style arguments meant for the tool
- Add -- separator handling to distinguish cmdforge args from tool args
- Map flag names to variable names using tool argument definitions
- Update AI review subprocess calls to use -- separator

Fixes scrutiny-ai-review tool arguments not being passed correctly.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 16:39:36 -04:00
robandClaude Opus 4.5 e1cae5ffda Fix cmdforge executable path for AI review
Find cmdforge in venv or PATH instead of assuming it's in PATH.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 16:05:51 -04:00
robandClaude Opus 4.5 8471480aa7 Add AI-powered secondary review for scrutiny warnings
- Create scrutiny-ai-review tool that uses AI to analyze warnings
- Integrate AI review into publish flow (app.py)
- Integrate AI review into Fabric sync script
- If AI review returns APPROVE with >=80% confidence, auto-approve
- Display AI review results in admin pending tools modal
- Shows verdict (APPROVE/REJECT/NEEDS_HUMAN_REVIEW) with confidence
- Shows per-finding analysis (FALSE_POSITIVE/LEGITIMATE_CONCERN)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 16:03:44 -04:00
robandClaude Opus 4.5 b0bd692be5 Add scrutiny warnings to tool detail modal
Shows warnings at the top of the detail view with:
- Yellow warning styling
- Check name, message, and suggestion
- Warning icon for visibility

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 15:25:40 -04:00
robandClaude Opus 4.5 807a0e28a5 Fix modal scrolling - content scrolls within fixed viewport
- Modal now has max-height of viewport minus padding
- Content area scrolls independently (overflow-y-auto)
- Header and footer buttons stay fixed/visible
- Background scroll locked when modal is open
- Fixes scroll context issues after dragging

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 15:22:10 -04:00
robandClaude Opus 4.5 957a65397a Fix modal header positioning and step content display
- Header now flush with top of modal (full draggable area)
- Content area properly padded below header
- Fix step rendering: use step.prompt not step.content
- Show provider, output_var, and step name in step display
- Add support for tool steps
- Add max-height with scroll for long prompts/code

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 15:13:27 -04:00
robandClaude Opus 4.5 9035a17f21 Improve modal styling and make dialogs draggable
- Darker overlay (bg-gray-900 bg-opacity-60)
- Stronger border (border-2 border-gray-300)
- Larger shadow (shadow-2xl)
- Gray header bar with cursor-move indicator
- Draggable by header (both detail and reject modals)
- Position resets on close for re-centering

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 15:04:05 -04:00
robandClaude Opus 4.5 3914ca74b3 Add tool detail view to admin pending page
- Click tool name to view full details in modal
- Shows description, arguments, steps (prompt/code), and README
- Approve/Reject buttons in detail modal
- New API endpoint GET /api/v1/admin/tools/<id> returns full tool config

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 14:55:50 -04:00
robandClaude Opus 4.5 706accc008 Remove redundant "Go to page" input from pagination
Page numbers are clickable, making the input unnecessary clutter.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 14:50:06 -04:00
robandClaude Opus 4.5 cb9625d613 Add improved pagination to admin pending tools page
- Page number links with ellipsis for large ranges
- First/Last page buttons (« and »)
- "Go to page" input for direct page jumping
- Current page highlighted in indigo

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 14:46:35 -04:00
robandClaude Opus 4.5 f22e95d119 Add automatic background sync for tool statuses
When the Tools page loads, automatically sync statuses for all published
tools in the background. This means users don't need to manually click
"Sync Status" - the indicators will update automatically.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 08:11:17 -04:00
robandClaude Opus 4.5 7f976d3f8c Fix hash computation to exclude version/tags metadata
The publish dialog adds version and tags fields that aren't stored in
the local config.yaml. Exclude these from hash computation so the
hash comparison works correctly.

Also use Tool.to_dict() for consistent hash computation.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 08:02:42 -04:00
robandClaude Opus 4.5 f1c462146b Fix publish state tracking and add sync status feature
- Track moderation status (pending/approved/rejected) in local tool config
- Add new "pending" state indicator (◐ yellow) distinct from "published" (✓ green)
- Add "Sync Status" button to check registry for updated moderation status
- Add /me/tools/<name>/status API endpoint for checking tool status
- Improve admin panel error handling with better error messages

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 07:52:42 -04:00
robandClaude Opus 4.5 3fccf854be Refresh tools list after successful publish
The tool list now refreshes after publishing to immediately show
the updated publish state indicator (✓).

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 07:20:40 -04:00
robandClaude Opus 4.5 62a4b4a2da Add path attribute to Tool class for config file tracking
The Tool dataclass now has a path field that points to the config.yaml
file it was loaded from. This is set by load_tool() and enables the
publish dialog to save the registry_hash back to the local config.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 07:16:54 -04:00
robandClaude Opus 4.5 4b1f3cee52 Save registry_hash after publish for state tracking
- Add config_hash to publish API response
- Save registry_hash to local tool config after successful publish
- Show appropriate message based on moderation status (approved vs pending)

This enables the GUI to show publish state indicators (✓ published, ● modified)
for tools that have been published to the registry.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 07:12:20 -04:00
robandClaude Opus 4.5 96f7c05744 Fix publish dialog sending dict instead of YAML string
The publish_tool() API expects a YAML config string, but the dialog
was passing a dict directly, causing a 500 error on the server.

- Update PublishWorker to accept config_yaml string and readme
- Build complete tool config from Tool.to_dict()
- Convert to YAML string before sending
- Also include README.md if it exists in tool directory

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 07:06:45 -04:00
robandClaude Opus 4.5 deddd8efbb Fix Publish button always greyed out when not connected
The _update_buttons method wasn't handling the no-token case properly.
When not connected, the Connect button should always be enabled.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 07:01:25 -04:00
robandClaude Opus 4.5 0216d1d77a Add disconnect command and fix GUI connection status refresh
- Add `cmdforge config disconnect` command to clear registry token
- Fix GUI connection status label not updating after connect
  - Store status as instance variable instead of local
  - Add _update_connection_status() method
  - Call update after connect dialog completes

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 06:55:30 -04:00
robandClaude Opus 4.5 5e01a335c0 Add registry improvements, tag filtering, and admin tools
Registry API improvements:
- Add /api/v1/me POST endpoint for profile updates
- Add /api/v1/me/password POST endpoint for password changes
- Fix visibility filtering on categories and tags endpoints to only
  show approved public tools
- Allow underscores in tool names for fabric pattern compatibility
- Fix sqlite3.Row access (use bracket notation instead of .get())

Web UI improvements:
- Add tag filter to /tools page with three-state buttons
  (include/exclude/neutral)
- Add mobile-friendly tag filter in responsive view
- Display tags on tool cards in the tools listing
- Add dashboard settings form handlers for profile and password

Admin improvements:
- Add scrutiny audit page for reviewing tool safety analysis
- Improve pending tools page with scrutiny report display
- Add scrutiny stats to admin dashboard

Fabric sync improvements:
- Add direct database publishing with scrutiny vetting
- Support auto-approve for tools passing scrutiny
- Improve error handling and logging
- Add source attribution for imported tools

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 06:20:16 -04:00
robandClaude Opus 4.5 78025aac8e Implement content hash system for integrity verification
- Add hash_utils.py module for SHA256 content hashing with normalized YAML
- Store config_hash in registry database on publish
- Include hash in download response for client verification
- Verify downloaded content matches registry hash on install
- Store registry_hash in local tool config for publish state tracking
- Show publish state indicators in Tools page UI:
  - Green checkmark: Published and up to date
  - Orange dot: Modified since last publish
  - No indicator: Local tool (never published)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 01:31:08 -04:00
robandClaude Opus 4.5 8e6b03cade Add step type icons and inline name editing
- Add icons module with programmatically drawn icons:
  - Speech bubble for prompt steps
  - Code brackets </> for code steps
  - Arrow icons for input/output nodes
- Display icons in list view next to step names
- Display icons in flow view node headers
- Add inline name editing in flow view via property_changed signal
- Sync name changes from flow view to tool model and list view

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-16 01:21:20 -04:00
39 changed files with 5250 additions and 396 deletions
+71 -1
View File
@@ -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.
+9
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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`
+445 -48
View File
@@ -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,85 +341,341 @@ 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
passed, reason = vet_pattern(pattern_dir, provider) system_md = pattern_dir / "system.md"
if not system_md.exists():
if not passed: logger.warning(f" ✗ {name}: No system.md found")
logger.warning(f" ✗ {name}: {reason}")
state.patterns[name] = {
"name": name,
"hash": pattern_hash,
"status": "failed",
"reason": reason,
"synced_at": datetime.now(timezone.utc).isoformat(),
}
return False return False
if dry_run: system_prompt = system_md.read_text()
logger.info(f" [DRY RUN] Would sync: {name} ({reason})")
return True
# Import the pattern # Import helper functions
try: script_dir = Path(__file__).parent
script_dir = Path(__file__).parent if str(script_dir) not in sys.path:
sys.path.insert(0, str(script_dir)) sys.path.insert(0, str(script_dir))
from import_fabric import import_pattern from import_fabric import create_tool_config, pattern_to_display_name
success = import_pattern( # Create tool config
name, config = create_tool_config(name, system_prompt, provider)
pattern_dir.parent, config_yaml = yaml.dump(config, default_flow_style=False, sort_keys=False)
output_dir,
provider, # Create README with usage and attribution
dry_run=False, display_name = pattern_to_display_name(name)
registry_format=False, 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: if success:
logger.info(f" ✓ {name}: {reason}") logger.info(f" ✓ {name} -> registry ({message})")
state.patterns[name] = { state.patterns[name] = {
"name": name, "name": name,
"hash": pattern_hash, "hash": pattern_hash,
"status": "synced", "status": "synced",
"message": message,
"synced_at": datetime.now(timezone.utc).isoformat(), "synced_at": datetime.now(timezone.utc).isoformat(),
} }
return True return True
else: else:
logger.error(f" ✗ {name}: Import failed") logger.warning(f" ✗ {name}: {message}")
state.patterns[name] = { state.patterns[name] = {
"name": name, "name": name,
"hash": pattern_hash, "hash": pattern_hash,
"status": "failed", "status": "failed",
"reason": "Import failed", "reason": message,
"synced_at": datetime.now(timezone.utc).isoformat(),
}
return False
else:
# Original behavior: create local files
passed, reason = vet_pattern(pattern_dir, provider)
if not passed:
logger.warning(f" ✗ {name}: {reason}")
state.patterns[name] = {
"name": name,
"hash": pattern_hash,
"status": "failed",
"reason": reason,
"synced_at": datetime.now(timezone.utc).isoformat(), "synced_at": datetime.now(timezone.utc).isoformat(),
} }
return False return False
except Exception as e: try:
logger.error(f" ✗ {name}: {e}") from import_fabric import import_pattern
state.patterns[name] = {
"name": name, success = import_pattern(
"hash": pattern_hash, name,
"status": "failed", pattern_dir.parent,
"reason": str(e), output_dir,
"synced_at": datetime.now(timezone.utc).isoformat(), provider,
} dry_run=False,
return False registry_format=False,
)
if success:
logger.info(f" ✓ {name} -> {output_dir}/{name}")
state.patterns[name] = {
"name": name,
"hash": pattern_hash,
"status": "synced",
"synced_at": datetime.now(timezone.utc).isoformat(),
}
return True
else:
logger.error(f" ✗ {name}: Import failed")
state.patterns[name] = {
"name": name,
"hash": pattern_hash,
"status": "failed",
"reason": "Import failed",
"synced_at": datetime.now(timezone.utc).isoformat(),
}
return False
except Exception as e:
logger.error(f" ✗ {name}: {e}")
state.patterns[name] = {
"name": name,
"hash": pattern_hash,
"status": "failed",
"reason": str(e),
"synced_at": datetime.now(timezone.utc).isoformat(),
}
return False
def run_sync( def run_sync(
@@ -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:
+83 -2
View File
@@ -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": {
+1 -1
View File
@@ -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"]
+38 -1
View File
@@ -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)))
+194
View File
@@ -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
+18
View File
@@ -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
+131 -1
View File
@@ -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
+32 -10
View File
@@ -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
+309 -26
View File
@@ -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):
+28 -1
View File
@@ -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 = [
+92 -9
View File
@@ -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()
+74 -3
View File
@@ -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
+403 -15
View File
@@ -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("<", "&lt;").replace(">", "&gt;").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("<", "&lt;").replace(">", "&gt;").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}")
+29 -50
View File
@@ -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,59 +464,23 @@ 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)
self.flow_changed.emit()
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()
def refresh(self): def refresh(self):
"""Refresh the graph from current tool data.""" """Refresh the graph from current tool data."""
+275
View File
@@ -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]
+127
View File
@@ -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
View File
@@ -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:
+10 -2
View File
@@ -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)")
+92 -1
View File
@@ -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.
+22 -3
View File
@@ -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
) )
+4 -1
View File
@@ -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
View File
@@ -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,
)
+3 -2
View File
@@ -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">&larr; 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">&larr; 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 %}
+142
View File
@@ -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>
<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> </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 %}
+472 -15
View File
@@ -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">&laquo;</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">&raquo;</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">&larr; 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>
+106 -3
View File
@@ -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 %}