Author SHA1 Message Date
robandClaude Opus 4.5 bc970fb9f7 Update help banner with link cutting shortcut
Added "Cut links: Alt+Shift+drag" to help banner.
Shortened other labels to fit.

In NodeGraphQt, connections are cut by:
- Alt+Shift+Left-drag to slice through connections
- Dragging a connection endpoint to empty space

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 23:59:35 -04:00
robandClaude Opus 4.5 683a8a6d8f Fix signal handling during graph rebuild
- Add _rebuilding flag to ignore signals during rebuild
- Prevents cascade of port_disconnected signals when clearing graph
- Fixes KeyError in NodeGraphQt undo stack
- Fixes false dependency warnings on view switch

The port_disconnected and nodes_deleted signals were firing during
clear_session(), causing recursive rebuilds and errors.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 23:54:48 -04:00
robandClaude Opus 4.5 d1f0c2f893 Add flow view reordering via connection breaking
When a connection is broken in the flow view:
1. The node that lost its input moves to the end of the chain
2. The remaining nodes reconnect automatically
3. Tool model updates to reflect new order
4. Dependency validation warns about broken references

Flow: Input → A → B → C → Output
Break B's input: Input → A → C → B → Output

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 23:50:18 -04:00
robandClaude Opus 4.5 260ebf1b2f Fix false positives in variable dependency validation
Only parse {variable} patterns in PromptSteps, not CodeSteps.

In CodeSteps, variables are available directly as Python variables
(e.g., use `input` not `{input}`). The {var} syntax in code is
typically Python f-string or .format() syntax, not CmdForge
template substitution.

This fixes false positives like artifact-ai where Python f-strings
like f"{format_guide}" were incorrectly flagged as missing CmdForge
variable references.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 23:29:13 -04:00
robandClaude Opus 4.5 b8760eb208 Add drag-reorder, validation, and flow-to-tool sync
List View:
- Enable drag-drop reordering of steps
- Show broken dependency warnings when reordering
- Steps with missing variable refs shown in red with tooltip

Validation:
- Parse {variable} references from prompts and code
- Track available variables at each step position
- Warn when steps reference undefined variables

Flow View Sync:
- Deleting nodes in flow view updates the tool model
- steps_deleted signal propagates changes to builder
- Both views stay in sync

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 23:13:44 -04:00
robandClaude Opus 4.5 8ab0fba67c Change Fit All to Fit Selection with Select All shortcut
- F key now fits to selected nodes (or all if none selected)
- A key selects all nodes
- Workflow: drag-select nodes then F to zoom, or A then F for all
- Updated help banner and context menu
- Context menu now has Select All (A) and Fit Selection (F)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 22:53:47 -04:00
robandClaude Opus 4.5 827f0f9eef Fix help banner visibility
- Show banner on showEvent (when flow view becomes visible)
- Show banner on mouse enter event
- Raise banner to front to ensure visibility
- Recalculate size before positioning

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 22:41:03 -04:00
robandClaude Opus 4.5 fb879d09ea Improve flow view UX and fix step naming
- Remove Fit All button, F key shortcut is sufficient
- Add right-click context menu with "Fit All (F)" option
- Add floating help banner that appears on focus and auto-hides
- Banner stays visible when mouse hovers over it
- Fix default step naming to increment per type (Code 1, Prompt 1, Code 2)
  instead of globally (Code 1, Prompt 2, Code 3)
- List view still shows overall step number (1, 2, 3) with per-type names

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 22:36:13 -04:00
robandClaude Opus 4.5 4d9a0e5943 Add CmdForge welcome page to sidebar
- Create welcome_page.py with branded landing page
- Add quick action cards for common tasks (Create Tool, Registry, etc.)
- Add "CmdForge" entry at top of sidebar navigation
- Update page indices for navigation

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 22:18:00 -04:00
robandClaude Opus 4.5 88c9a8a1e7 Add custom step names and fix F key shortcut
- Add name field to PromptStep, CodeStep, and ToolStep dataclasses
- Update step dialogs to allow editing step names
- Display custom step names in both list and flow views
- Fix F key shortcut by installing event filter on graph widget
- Steps without custom names show default "Prompt N" / "Code N"

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 22:16:15 -04:00
robandClaude Opus 4.5 3c39cc9cda Improve flow visualization UX
- Add navigation help bar at top of flow view with instructions:
  Pan, Zoom, Select, Edit step shortcuts
- Fix zoom to fit all nodes on initial load (was only centering last node)
- Use QTimer to ensure fit happens after widget is fully rendered
- Clear selection after fitting so nodes don't appear selected

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 21:52:36 -04:00
robandClaude Opus 4.5 ad9a59283c Add patch for NodeGraphQt PySide6 6.7+ compatibility
The setSelectionArea() API changed in PySide6 6.7 - it now requires
an ItemSelectionOperation parameter before ItemSelectionMode.

Files added:
- patches/nodegraphqt_pyside6_compat.patch: Git-style patch file
- patches/UPSTREAM_ISSUE.md: Issue report for upstream project
- scripts/patch_nodegraphqt.py: Script to auto-apply the patch

The patch fixes rubber band selection errors when dragging in the
node graph viewer. Run after pip install:

    python scripts/patch_nodegraphqt.py

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 19:10:10 -04:00
robandClaude Opus 4.5 042be8b49b Add flow visualization for tool builder
Implements node-based flow visualization using NodeGraphQt-QuiltiX-fork:

New files:
- src/cmdforge/gui/widgets/flow_graph.py: FlowGraphWidget with custom node types
  - InputNode: Shows tool inputs ($input + argument variables)
  - PromptNode: AI prompt steps with provider display
  - CodeNode: Python code steps
  - OutputNode: Final output
- scripts/test_nodegraph.py: Standalone prototype for testing

Modified files:
- tool_builder_page.py: Added List/Flow view toggle with QStackedWidget
  - Lazy-loads flow widget on first use
  - Syncs between list and flow views
  - Double-click nodes to edit steps
- styles.py: Added viewToggle button styling
- widgets/__init__.py: Export FlowGraphWidget
- pyproject.toml: Added 'flow' optional dependency group

Features:
- Toggle between List and Flow views in Steps panel
- Nodes auto-connected based on step order
- Auto-layout and fit-to-view on load
- Double-click nodes opens step edit dialog
- Graceful fallback if NodeGraphQt not installed

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 17:07:35 -04:00
robandClaude Opus 4.5 a5e0948881 Add admin configuration system and M3 content automation
Admin Configuration System:
- Add registry_settings table to database schema
- Create settings.py module with 17 configurable settings across 5 categories
  (vetting, similarity, sync, moderation, rate_limits)
- Add admin API endpoints for settings management (list/get/set/reset)
- Create admin settings web UI page with tabbed categories
- Add CLI commands: cmdforge registry config list/get/set
- Wire settings page link into admin index

M3 Content Automation Scripts:
- scrutiny.py: Quality scoring with 5 weighted checks
- similarity.py: TF-IDF based duplicate detection
- vet_pipeline.py: Combined vetting with auto-decision logic
- fabric_sync.py: Scheduled Fabric pattern synchronization
- fabric-sync.service/timer: systemd units for scheduled sync

Settings available:
- Vetting thresholds (approve/reject) and check weights
- Similarity detection thresholds (duplicate/similar/related)
- Fabric sync configuration (enabled, interval, auto-approve)
- Moderation settings (require review, auto-approve private)
- Rate limiting configuration

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-15 15:20:21 -04:00
robandClaude Opus 4.5 d1eb4231a7 Fix Output Template visibility and default value
- Set default value "{response}" for new tools
- Add minimum height (80px) so it's always visible
- Add stretch factors to layout so Steps expands, Output stays fixed
- Update placeholder text to be more helpful

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 14:33:07 -04:00
robandClaude Opus 4.5 436d6292ff Add Python syntax checking to Code Step dialog
Validates code with ast.parse() before accepting. Shows line number
and error message if syntax is invalid.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 14:27:55 -04:00
robandClaude Opus 4.5 d0ff6c0c35 Document AI-assisted code generation in README
Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 14:21:31 -04:00
robandClaude Opus 4.5 2438aec831 Restore AI-assisted code generation in Code Step dialog
Feature was present in TUI but missing after GUI conversion. Now includes:
- Split layout: Code editor (left) | AI Assist panel (right)
- Provider selector dropdown for AI calls
- Prompt editor with smart default template showing available variables
- "Generate Code" button that calls AI in background thread
- Response feedback area showing success/error status
- Automatic markdown fence stripping from AI responses
- Available variables computed from tool arguments + previous step outputs

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 14:20:58 -04:00
robandClaude Opus 4.5 bee74061b1 Add Profiles documentation to README
- Add Profiles (AI Personas) section under GUI Features
- Add Ctrl+4 shortcut for Profiles page

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 05:23:57 -04:00
robandClaude Opus 4.5 9588c3f8b0 Add AI persona profiles for prompt injection
Profiles allow users to inject system instructions into prompts,
customizing the AI's behavior and persona for tool execution.

Features:
- Profile dataclass with name, description, system_prompt
- 8 built-in profiles: None, Comedian, Technical Writer, Teacher,
  Concise, Creative, Code Reviewer, Analyst
- Custom profile creation and storage in ~/.cmdforge/profiles/
- Profile selector in Prompt Step dialog
- Profile injection during tool execution
- Profiles page in GUI (Ctrl+4) for viewing and managing profiles

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 05:22:31 -04:00
robandClaude Opus 4.5 9d56f703cd Fix sort field values to match API
- 'newest' -> 'published_at' (valid API field)
- Remove 'rating' sort (not supported by API)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 05:03:55 -04:00
robandClaude Opus 4.5 ebb70f5865 Fix Browse All to use list_tools instead of search_tools
search_tools requires a query, but list_tools allows browsing
without a search term. Use list_tools when no query or tags are set.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 05:01:01 -04:00
robandClaude Opus 4.5 305c09a929 Fix QTextEdit -> QTextBrowser for clickable links
setOpenExternalLinks is only available on QTextBrowser, not QTextEdit.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 04:58:39 -04:00
robandClaude Opus 4.5 518a04a8b0 Add tool marketplace UI enhancements
- Browse all tools on page load without search
- Category filter dropdown (Text, Developer, Data, etc.)
- Sort options (downloads, rating, newest, name)
- Star ratings display in results table
- Clickable tags for filtering
- Installed indicator (✓) for local tools
- Update available indicator (↑) for newer versions
- Pagination controls for large result sets
- Publisher reputation info in details

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 04:51:10 -04:00
robandClaude Opus 4.5 4fe2d26244 Convert TUI to PySide6 desktop GUI
Major UI overhaul replacing the urwid-based Terminal UI with a modern
PySide6 desktop application.

New GUI features:
- Sidebar navigation (My Tools, Registry, Providers)
- Tool Builder with visual form for creating/editing tools
- Registry browser with search and one-click install
- Provider management page
- Connect dialog for account pairing
- Publish dialog for sharing tools

- Keyboard shortcuts (Ctrl+N, Ctrl+S, Ctrl+R, Ctrl+1/2/3, Escape, Ctrl+Q)
- Window geometry persistence (remembers size/position)
- Modern clean stylesheet

Removed:
- ui.py, ui_snack.py, ui_registry.py, ui_urwid.py
- ui_urwid/ directory (urwid TUI implementation)

Updated:
- pyproject.toml: PySide6 now required, removed urwid
- CLI entry points to launch GUI
- All documentation (README, CLAUDE.md, AGENTS.md, wiki)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 04:38:35 -04:00
robandClaude Opus 4.5 604b473806 Add ratings, reviews, and publisher reputation system
- Add database schema for reviews, issues, and stats caching
- Add API endpoints for reviews (CRUD, voting, flagging)
- Add API endpoints for issues (report, list, resolve)
- Add publisher stats and badge system
- Add trust score calculation (0-100 scale)
- Update tool detail page with ratings, reviews, issues sections
- Update publisher profile with stats, badges, trust score
- Add dedicated reviews and issues pages with filtering
- Update dashboard tools table with rating/issues columns
- Update tool cards with inline rating display

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 03:12:31 -04:00
robandClaude Opus 4.5 d2c668dc99 Improve connection UX based on feedback
Web UI:
- Change sidebar "API Tokens" to "Connections"
- Change empty state button to "Make Your First Connection"
- Add TUI option to "About Connected Apps" info banner
- Add both TUI and CLI options to pairing modal

TUI:
- Change dialog title to "Connect to your CmdForge Account"
- Add account creation hint in username prompt
- Improve instructions with full URL and steps
- Fix timer refresh by calling loop.draw_screen()

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 01:45:01 -04:00
robandClaude Opus 4.5 367fac204b Add app pairing feature for simplified registry connection
- Add pairing_requests table and hostname column to api_tokens
- Add pairing API endpoints: initiate, check, status, connected-apps
- Add cmdforge config connect <username> CLI command
- Rewrite tokens.html as Connected Apps with pairing flow
- Update TUI: Connect button when not authenticated, Publish when connected
- Add private sync option after save in TUI when connected
- Add visibility parameter to publish_tool in registry_client

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 01:10:12 -04:00
robandClaude Opus 4.5 3fe7b07c70 Fix /api/v1/me/tools to include published_at
The dashboard template expected published_at but it was missing from the query.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 00:11:08 -04:00
robandClaude Opus 4.5 155905b61e Fix password reset to use argon2 hasher
Was using werkzeug's generate_password_hash which creates incompatible
hashes. Now uses the same argon2 password_hasher as registration/login.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 00:07:21 -04:00
robandClaude Opus 4.5 eacfd0d74a Add admin tools: delete publisher and reset password
- DELETE /api/v1/admin/publishers/<id> - delete publisher with optional tool deletion
- POST /api/v1/admin/publishers/<id>/reset-password - generate temporary password

Safety features:
- Cannot delete yourself or other admins
- Delete requires typing username to confirm
- Reset password shows temp password in modal with copy button

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 23:56:06 -04:00
robandClaude Opus 4.5 a4ed9ed730 Fix admin dashboard and update repository URLs
- Fix _api_get to handle query strings in URL path
- Replace placeholder github.com/your-org URLs with gitea.brrd.tech/rob

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 23:47:59 -04:00
robandClaude Opus 4.5 50326a11c6 Fix migration: move new column indexes to migrate_db()
The index creation for moderation_status, visibility, role, and banned
columns was in SCHEMA_SQL which runs before migrate_db(). This caused
failures on existing databases that didn't have these columns yet.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 23:24:48 -04:00
robandClaude Opus 4.5 58f187ccad Update deployment docs with user systemd service option
Add documentation for running as a user service (systemctl --user)
which is how the current OMV deployment is configured. The previous
docs only showed system service setup which caused confusion.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 23:16:34 -04:00
robandClaude Opus 4.5 946a5933b4 Add registry curation system with role-based moderation
Implement comprehensive moderation system for the CmdForge registry:

- Role-based access control (user, moderator, admin)
- Tool moderation workflow: pending → approved/rejected/removed
- Tool visibility: public, private, unlisted
- Publisher management: ban/unban with token revocation
- Report resolution with configurable actions
- Audit logging for all moderation actions

Database changes:
- Add role, banned columns to publishers table
- Add visibility, moderation_status columns to tools table
- Create audit_log table for accountability

API additions:
- Admin endpoints for tool moderation (approve/reject/remove)
- Publisher management endpoints (ban/unban/role)
- Report resolution endpoint
- Audit log query endpoint

Web UI:
- Admin dashboard with stats overview
- Pending tools queue with approve/reject
- Publisher management with ban/role controls
- Report queue with resolve actions
- Role badge in dashboard sidebar

Existing tools grandfathered as approved, all publishers default to user role.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 22:58:37 -04:00
robandClaude Opus 4.5 b1d1a3692d Update README Docker section with new containers
- Document Dockerfile.ready (pre-installed container)
- Document Dockerfile.test-install (fresh install testing)
- Document install.sh interactive installer
- Keep legacy docker-compose as fallback option

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 22:19:31 -04:00
robandClaude Opus 4.5 63149aec07 Add database migration for schema updates
When the registry server starts, it now automatically adds any missing
columns to existing tables. This prevents 500 errors when publishing
to a database created before newer columns were added.

Columns added by migration:
- scrutiny_status, scrutiny_report (tool scrutiny)
- source, source_url, source_json (tool attribution)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 21:48:38 -04:00
robandClaude Opus 4.5 0462750b88 Allow pasting registry token directly in TUI
When publishing without a configured token, the TUI now:
1. Shows instructions for getting a token
2. Opens an input dialog to paste the token
3. Saves the token and continues with publishing

This eliminates the need to exit the TUI or use another terminal.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 21:40:09 -04:00
robandClaude Opus 4.5 4d09c3f912 Add pre-installed Docker container
Dockerfile.ready provides CmdForge pre-installed and ready to use,
for regular usage after testing the installer with Dockerfile.test-install.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 21:32:09 -04:00
robandClaude Opus 4.5 a70267cf53 Fix token modal and session token issues
- Fix create token modal z-index so it's not covered by backdrop
- Rename session tokens from "login" to "Web Session" for clarity
- Delete old session tokens when logging in (prevents accumulation)
- Filter out session tokens from dashboard display (users only see
  their manually created API tokens)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 21:27:01 -04:00
robandClaude Opus 4.5 e18a575f76 Add TUI publish feature and fix web UI dropdown
- Add Publish button to TUI for publishing tools directly from the
  graphical interface without using the command line
- Fix user dropdown menu in web UI header that stayed open after
  clicking (replaced Alpine.js directives with vanilla JS)
- Update test to use correct registry URL (cmdforge.brrd.tech)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 21:12:42 -04:00
robandClaude Opus 4.5 79739369f0 Fix registry registration URL in CLI
Update publish command to show correct registration URL
(cmdforge.brrd.tech instead of gitea.brrd.tech)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 20:42:37 -04:00
robandClaude Opus 4.5 86d82fcd72 Fix TUI registry browser and harden search API
- TUI: Use list_tools for browsing (no query), search_tools only when
  user enters a search term. Fixes 500 error on initial registry load.

- API: Sanitize FTS5 queries by escaping special characters (* " ( ) etc)
  Prevents SQL errors from malformed search queries like "*"

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 20:36:21 -04:00
robandClaude Opus 4.5 19e5be7e5a Fix category display and tool counts in web UI
- Categories API now includes dynamic categories from database
  (categories used by tools but not in predefined list)
- Add total_tools to categories API meta for accurate All Tools count
- Fix web routes to use total_tools instead of summing category counts
- Dynamic categories get auto-generated descriptions

This fixes:
- "All Tools" showing 2 instead of 4
- Categories like "code-analysis" and "education" not appearing
- Incorrect category counts in sidebar

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 19:53:40 -04:00
robandClaude Opus 4.5 c89bd44be8 Fix registry URL and add registry browser to TUI
- Fix default registry URL: gitea.brrd.tech -> cmdforge.brrd.tech
  (gitea.brrd.tech was Gitea's API, not CmdForge registry)

- Add "Registry" button to TUI main menu
- Add registry browser overlay with:
  - Search input with live search
  - Tool list with owner/name display
  - Info panel showing name, publisher, version, description,
    downloads, and tags
  - Install button with confirmation dialog
  - Proper error handling for network issues

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 19:43:20 -04:00
robandClaude Opus 4.5 b40f26622f Add installer script and test installation Docker image
- install.sh: Interactive installer that:
  - Checks Python version and prerequisites
  - Creates virtual environment at ~/.cmdforge-venv
  - Installs CmdForge with all dependencies
  - Sets up PATH in .bashrc/.zshrc
  - Creates tool wrappers
  - Optionally installs 27 example tools
  - Supports --yes for non-interactive mode

- Dockerfile.test-install: Fresh Ubuntu image for testing
  - Simulates a new computer (CmdForge source present but not installed)
  - Non-root user (testuser) for realistic permissions
  - Run interactively to test the full installation experience

Usage:
  docker build -f Dockerfile.test-install -t cmdforge-test-install .
  docker run -it --rm cmdforge-test-install
  # Inside: ./install.sh

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 07:17:54 -04:00
robandClaude Opus 4.5 14448408af Add enhanced search and filtering (M2 feature)
- Add /api/v1/tags endpoint for listing available tags
- Enhance search API with tag filtering (AND logic), multi-category
  filtering (OR logic), owner filter, download range, and date range
- Add faceted response support (category/tag/owner counts)
- Update registry client with new search parameters and get_tags method
- Add CLI search options: -t/--tag, -o/--owner, --min-downloads,
  --popular, --new, --since, --before, --json, --show-facets
- Add new 'registry tags' CLI subcommand
- Add web UI filter sidebar with checkboxes, dropdowns, and active
  filter chips with URL-based state management
- Display clickable tags on tool cards

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 06:13:37 -04:00
robandClaude Opus 4.5 614b145f5f Improve error messages for debugging
Code step errors:
- Show line numbers with context (line before/after)
- Display available variables for debugging
- Include step number in error message

YAML loading errors:
- Show line and column number for syntax errors
- Display the problematic line with arrow pointer
- Show the specific YAML problem description

Nested tool errors:
- Track call stack through tool chain
- Display full call path when nested tool fails
- Show step numbers at each level

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 04:50:19 -04:00
rob f86db9e0d9 Test auto-deploy webhook 2026-01-13 03:09:50 -04:00
robandClaude Opus 4.5 9141a3eedf Add deploy webhook for CI/CD
- New /api/v1/webhook/deploy endpoint
- Verifies Gitea HMAC-SHA256 signature
- Only deploys on push to main/master branch
- Runs git pull and service restart in background

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 03:03:11 -04:00
robandClaude Opus 4.5 62f06813a4 Add full ToolSource support to registry
- Add source_json column to store complete source attribution
- Update publish API to accept source object or legacy string
- Update get_tool API to return parsed source object
- Update web UI to display type, author, license, url, original_tool
- Add gunicorn config and dependency for production server

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 02:43:09 -04:00
rob b324d67a31 Update before docs deploy 2026-01-12 10:38:08 -04:00
robandClaude Opus 4.5 9fbe45244d Move docs to project-docs symlink structure
- Docs now point to project-docs/docs/projects/cmdforge
- Old docs preserved in olddocs/
- Updated .gitignore to ignore docs symlink

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-06 22:26:39 -04:00
robandClaude Opus 4.5 1caa3454f0 Add source field support and Fabric import script
- Add ToolSource dataclass for attribution metadata (type, license, url, author, original_tool)
- Add source and version fields to Tool dataclass
- Update Tool.from_dict() and to_dict() to handle source field
- Display source attribution in TUI info panel
- Show [imported]/[forked] markers in cmdforge list
- Add import_fabric.py script to import Fabric patterns as CmdForge tools

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-03 23:52:29 -04:00
robandClaude Opus 4.5 2c139b3982 Add meta-tools, collections, and attribution features
Meta-tools:
- Add ToolStep dataclass for tools calling other tools
- Implement execute_tool_step() with recursion depth limiting
- Add check_dependencies() for validating tool dependencies
- Add 'cmdforge check' command for dependency verification

Collections:
- Add collections API endpoints and database schema
- Create collections web UI (list and detail pages)
- Add collections to navigation header and homepage
- Document collections in REGISTRY.md

Attribution:
- Add source attribution fields to tool detail page
- Document source types (original, adapted, imported)
- Add license field documentation

Documentation updates across README, DESIGN.md, REGISTRY.md,
and new META_TOOLS.md design document.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-03 03:05:23 -04:00
robandClaude Opus 4.5 497fe87fc5 Rename project from SmartTools to CmdForge
Major rename operation:
- Rename Python package: smarttools -> cmdforge
- Update CLI entry point: smarttools -> cmdforge
- Update all imports and module references
- Update pyproject.toml with new name, URLs, entry point
- Update all HTML templates with new branding
- Update documentation (CLAUDE.md, README.md, docs/*, wiki/*)
- Update environment variables:
  - SMARTTOOLS_ENV -> CMDFORGE_ENV
  - SMARTTOOLS_REGISTRY_DB -> CMDFORGE_REGISTRY_DB
  - SMARTTOOLS_TOKEN -> CMDFORGE_TOKEN
  - SMARTTOOLS_REGISTRY_* -> CMDFORGE_REGISTRY_*
- Update Dockerfile and docker-compose.yml
- Update tests to use new package name
- Update scripts and examples
- Update package.json and tailwind.config.js

All 158 unit tests pass. The CLI is working correctly with the
new cmdforge command.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-02 22:15:55 -04:00
robandClaude Opus 4.5 2939dd806b Document forum feature across site
- Add prominent forum CTA to community page
- Add Get Help section to getting-started docs
- Add forum link to footer Resources section
- Update CLAUDE.md with web UI and forum architecture

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-02 01:17:25 -04:00
robandClaude Opus 4.5 3efed0bf33 Add both Forum and Community links to navigation
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-02 00:52:00 -04:00
robandClaude Opus 4.5 a80adcedfd Add community forum feature
- New forum blueprint with categories, topics, and replies
- Markdown rendering for posts with safe HTML escaping
- Honeypot spam protection for forms
- Categories: General, Help, Showcase, Ideas, Tutorials
- View counts and reply tracking
- Updated navigation to link to forum

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-02 00:45:15 -04:00
robandClaude Opus 4.5 e2d1002d2e Fix 'All Tools' count showing filtered category count
total_count was using the API response meta.total which reflects
the current filter. Now sums all category counts for consistent display.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 07:04:12 -04:00
robandClaude Opus 4.5 9699717443 Fix SQL parameter count in category filter query
The query uses base_where twice (for latest_any and latest_stable CTEs)
but params were only passed once, causing sqlite3.ProgrammingError when
filtering by category.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 06:58:30 -04:00
robandClaude Opus 4.5 0a1c03ce09 Replace placeholder content on Community and Support pages
Community page:
- Replaced "Coming soon" sections with working links
- Links to Gitea repository and issues
- Added "Ways to Contribute" section with actionable items
- Added quick links to Installation, Tools, and Tutorials

Support page (formerly Donate):
- Removed placeholder donation links
- Focus on non-monetary contributions (tools, feedback, word-of-mouth)
- Links to repository and publishing guide
- More appropriate for a personal/open-source project

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 06:49:13 -04:00
robandClaude Opus 4.5 01995392da Add Visual Builder tutorial for the TUI
- New comprehensive tutorial covering smarttools ui
- ASCII art mockups of menu interface
- Documents all keyboard shortcuts
- Explains UI backends (urwid, snack, dialog)
- Comparison table: CLI vs Visual Builder use cases
- Updated getting-started to mention both UI and CLI options
- Added visual-builder to documentation TOC

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 06:36:34 -04:00
robandClaude Opus 4.5 62e0ddeabd Add prose styling for documentation content
The prose classes from @tailwindcss/typography weren't working because
the plugin wasn't installed. Added custom prose styling for:
- Proper paragraph spacing (mb-4)
- Header margins (mt-10 for h2, mt-8 for h3)
- Lead paragraphs styling
- List styling with proper indentation
- Code block styling (dark theme)
- Inline code styling
- Table styling
- Blockquote and details styling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 06:13:02 -04:00
robandClaude Opus 4.5 d85eb36a72 Fix SQLite WAL mode error on FUSE filesystems
WAL mode can fail on filesystems like mergerfs that don't fully support
memory-mapped files. Add graceful fallback to DELETE journal mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 05:50:03 -04:00
robandClaude Opus 4.5 55d46d1f61 Rewrite tutorials in engaging O'Reilly book style
- Transform multi-step into "Chaining AI Like a Pro" with visual
  waterfall diagram, three patterns grid, and exercises
- Transform code-steps into "Python Superpowers" with cookbook
  recipes, error handling patterns, and CSV analyzer exercise
- Transform advanced-workflows into "The Advanced Playbook" with
  model selection guide, self-improving loops, and research
  assistant exercise

All tutorials now feature:
- "What You'll Learn" callout boxes
- Visual cards and grids for concepts
- Pro tips and warning callouts
- Hands-on exercises with collapsible solutions
- Conversational, engaging tone

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 05:46:09 -04:00
rob 6acbafc061 Fix tutorial page template format to match docs pattern 2026-01-01 05:25:37 -04:00
rob 427dfcb669 Fix tutorials route to pass correct template variables 2026-01-01 05:23:24 -04:00
robandClaude Opus 4.5 d072d7ccad Add comprehensive tutorial content
- yaml-config: Complete YAML structure reference
- arguments: Custom CLI flags and patterns
- multi-step: Chaining prompts and code steps
- code-steps: Python processing between AI calls
- advanced-workflows: Multi-provider, conditionals, pipelines

Update tutorials route to use docs content system
Expand table of contents with new sections

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 05:20:33 -04:00
robandClaude Opus 4.5 c1eb425457 Replace pip install command with link to Gitea install guide
The package isn't on PyPI yet, so link to the Gitea README which
has multiple installation options (pip from git, Docker, 60-second setup).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 04:41:49 -04:00
robandClaude Opus 4.5 02842e4287 Include all dependencies and tests in Docker image
- Add tests/ to Docker image for in-container testing
- Install [all,dev] dependencies (includes Flask, registry, TUI)
- Make integration tests configurable via REGISTRY_URL env var
- Add error handling to publish test fixture for rate limiting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 03:51:46 -04:00
robandClaude Opus 4.5 940f91d445 Add parallel orchestration documentation to web UI
New docs page at /docs/parallel-orchestration showing:
- Why parallel execution (3x speedup example)
- Basic ThreadPoolExecutor pattern
- Complete multi-perspective analysis example
- Progress feedback implementation
- Error handling best practices
- Reference to orchestrated-discussions project

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 03:21:33 -04:00
robandClaude Opus 4.5 cdb17db43a Add comprehensive test suite for core modules
New test files:
- test_tool.py: 35 tests for Tool, ToolArgument, steps, persistence
- test_runner.py: 31 tests for variable substitution, step execution
- test_providers.py: 35 tests for provider management, mocked calls
- test_cli.py: 20 tests for CLI commands
- tests/README.md: Test suite documentation

Removed placeholder test.py (triangle calculator).

Total: 158 unit tests passing, 12 integration tests (require server)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 03:07:16 -04:00
robandClaude Opus 4.5 97f9de52e6 Refactor CLI and UI into modular packages, add registry scrutiny
Major refactoring:
- Split cli.py (1465 lines) into cli/ package with 6 modules
- Split ui_urwid.py (2313 lines) into ui_urwid/ package with 4 modules
- Maintain backwards compatibility via thin wrapper modules

New features:
- Add tool scrutiny system for registry publishing (honesty, transparency, scope, efficiency checks)
- Add optimization suggestions for AI calls that could be pure code

Bug fixes:
- Fix variable substitution escaping ({{literal}} now works)
- Fix provider command parsing with shlex for quoted paths
- Add error logging even without --verbose flag

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-01 02:44:39 -04:00
robandClaude Opus 4.5 4fb229858f Fix select element accessibility labels on tools page
Add proper label associations with for/id attributes to all select
elements on the tools page for screen reader compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 23:25:16 -04:00
robandClaude Opus 4.5 e7937361f3 Fix additional accessibility issues on tools and search pages
- Add submit button to search forms for keyboard accessibility
- Fix category count text contrast (text-gray-400 → text-gray-600)
- Add aria-label to search inputs
- Add visible Search button on mobile filters

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 23:23:04 -04:00
robandClaude Opus 4.5 26ae319815 Add Sentry error monitoring and fix accessibility issues
Sentry integration:
- Add sentry-sdk[flask] to registry dependencies
- Initialize Sentry with Flask integration (if SENTRY_DSN is set)
- Configure trace/profile sampling rates via environment variables
- Privacy-first: send_default_pii=False

Accessibility fixes (WCAG 2.0 AA compliance):
- Add aria-label to search input
- Add label and aria-label to essential cookies checkbox
- Fix color contrast issues:
  - $ prompt: text-gray-400 → text-gray-600
  - Version numbers: text-gray-400 → text-gray-600
  - Tutorial button: text-cyan-600 → text-cyan-700
  - Step labels: text-indigo-500 → text-indigo-700
  - Footer text: text-gray-500 → text-gray-300 (on dark bg)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 23:20:11 -04:00
robandClaude Opus 4.5 357997dc7d Fix docs heading format for template compatibility
Convert (id, title) tuples to SimpleNamespace objects with id, text,
and level attributes to match template expectations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 23:10:34 -04:00
robandClaude Opus 4.5 8c7c22b958 Fix duplicate SEO route registration
Remove sitemap.xml and robots.txt route registrations from app.py
since they're already defined in routes.py. This was causing an
AssertionError on startup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 23:09:01 -04:00
robandClaude Opus 4.5 4318b6cec5 Add SEO files and real documentation content
- Add permissive robots.txt welcoming all crawlers including AI
- Add dynamic sitemap.xml with all pages and tools
- Add real documentation content (Getting Started, Installation, First Tool, Publishing, Providers)
- Update docs route to use content from docs_content.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 23:05:57 -04:00
robandClaude Opus 4.5 1ac3054aaa Add Phase 8 features: analytics, reporting, health check
- Add pageviews table and /api/v1/analytics/pageview endpoint
- Abuse reporting API already existed at /api/v1/reports
- Add health_check.py script for monitoring
- ChatGPT additions: report modal UI, analytics tracking JS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 22:49:58 -04:00
rob 096c0739d8 Merge pull request 'Add SmartTools Registry with Web UI (Phase 1-7)' (#18) from feature/registry into main 2026-01-01 01:21:37 +00:00
robandClaude Opus 4.5 9a33d57de1 Add JavaScript interactivity and consent API
- Complete main.js with all interactive functions:
  - Mobile menu toggle with icon switching
  - Search modal with live search
  - Dashboard modals (tokens, deprecate, settings)
  - Tool detail page (copy install, report modal)
  - Toast notifications
- Add /api/v1/consent endpoint for cookie preferences
- Session-based consent storage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 21:06:40 -04:00
robandClaude Opus 4.5 e029166ba6 Change web blueprint static URL path to avoid conflict
The main Flask app and web blueprint both used /static, causing
conflicts. Changed web blueprint to use /web-static instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 20:08:20 -04:00
robandClaude Opus 4.5 9070029d31 Fix static folder path resolution for blueprints
Use absolute paths for templates and static folders to ensure
they resolve correctly regardless of the working directory.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 20:06:34 -04:00
robandClaude Opus 4.5 24c91b8de7 Add deployment guide to WEB_UI.md
Includes:
- Quick start instructions
- Environment variables
- Database configuration (with mergerfs note)
- systemd service example
- nginx reverse proxy config
- SSL setup with certbot
- Tailwind CSS build commands
- Health check and troubleshooting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 20:00:59 -04:00
robandClaude Opus 4.5 b26ae5092b Add error handlers for 404 and 500 pages
Register Flask error handlers to render the error templates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:59:01 -04:00
robandClaude Opus 4.5 c41d2eace2 Update .gitignore and remove untracked files
- Added node_modules/, package-lock.json, discussions/, diagrams/
  and temporary files to .gitignore
- Removed accidentally committed files from git tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:50:13 -04:00
robandClaude Opus 4.5 16554c0694 Fix csrf_token collision in auth.py
The auth.py was passing csrf_token as a string variable which
collided with the global csrf_token function in Jinja2. Removed
the redundant passing since csrf_token() is already available
as a Jinja global.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:47:29 -04:00
robandClaude Opus 4.5 0200ad4f6e Add 'now' global function to Jinja2 templates
The footer template uses now().year for the copyright date.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:43:43 -04:00
robandClaude Opus 4.5 b90e632ec7 Fix session interface method indentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:40:42 -04:00
robandClaude Opus 4.5 d6519bde66 Fix blueprint route registration order
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:36:05 -04:00
robandClaude Opus 4.5 c8a937ec15 Fix Semver dataclass order conflict with Python 3.11
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:34:36 -04:00
robandClaude Opus 4.5 cfb9858967 Add built Tailwind CSS and lockfile
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:14:54 -04:00
robandClaude Opus 4.5 34428f0e72 Add SmartTools Registry and Web UI (Phases 1-7)
Registry Features (Phases 1-6):
- Tool manifest format and validation
- Registry client for API communication
- Resolver integration for tool installation
- CLI commands: install, search, browse, publish, auth
- TUI browse interface with urwid
- Smart features: auto-categorization, similarity suggestions

Web UI (Phase 7):
- Flask blueprint with server-side sessions
- Authentication routes (login, register, logout)
- Public pages: landing, tools browse, tool detail, search
- Dashboard: overview, tools management, API tokens, settings
- Template components: cards, forms, callouts, code blocks
- Tailwind CSS build pipeline
- SEO infrastructure (sitemap, robots.txt)
- Cookie consent and privacy compliance

Infrastructure:
- SQLite database with FTS5 search
- Rate limiting for API endpoints
- Git-based tool submission workflow
- Session management with auto-cleanup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 19:03:41 -04:00
robandClaude Opus 4.5 284e9981bc Add issue templates for bug reports, feature requests, and questions
Adds Gitea issue templates in .gitea/ISSUE_TEMPLATE/:
- bug_report.md: Environment, steps to reproduce, error output
- feature_request.md: Use case, proposed solution, alternatives
- question.md: Context, what's been tried

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 03:04:23 -04:00
robandClaude Opus 4.5 b67d44ac8b Clean up OpenCode ProviderModelNotFoundError messages
- Detect ProviderModelNotFoundError and show clean message
- Extract provider and model IDs from error
- Suggest: connect provider in opencode, use --provider flag, or edit in UI
- Truncate other stderr to 200 chars to avoid code dumps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 01:25:15 -04:00
robandClaude Opus 4.5 9d783af2f9 Improve provider error messages with install suggestion
- Add "smarttools providers install" suggestion when command not found
- Add suggestion when provider exits with error containing "not found"
- Detect empty output and warn about unavailable model
- Show stderr hint when provider returns nothing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 01:11:00 -04:00
robandClaude Opus 4.5 c1499e76d2 Add 'Try It Now' quick demo section to README
60-second demo using Docker container:
- Pull container, install OpenCode, run eli5 on README
- Uses free Big Pickle model by default, no sign-up required
- Demonstrates the tool while explaining itself

Keep native install as 'Quick Start (Native Install)' for regular use.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 00:55:43 -04:00
robandClaude Opus 4.5 c8a0433ecc Update OpenCode provider info with free models and 75+ providers
- Mention 4 free models: Big Pickle, GLM-4.7, Grok Code Fast 1, MiniMax M2.1
- Note that 75+ providers are available through OpenCode
- Change setup to "opens browser to connect more providers"
- Put opencode-pickle first in variants so test uses a free provider

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 00:09:14 -04:00
robandClaude Opus 4.5 e2816bf6f6 Make source ~/.bashrc instruction more prominent after provider install
Always show the IMPORTANT message about refreshing PATH, with clear
step numbering that makes source ~/.bashrc step 1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 23:47:11 -04:00
robandClaude Opus 4.5 d830a483d9 Update Docker provider install instructions for pre-built container
Add separate instructions for:
- Pre-built container: docker run with display/volume flags
- Build from source: docker-compose run setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 23:40:44 -04:00
robandClaude Opus 4.5 38a446640f Add pre-built container pull instructions to README
Users can now pull the image directly from the Gitea registry:
  docker pull gitea.brrd.tech/rob/smarttools:latest

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 23:21:12 -04:00
robandClaude Opus 4.5 543ded0a5f Add interactive provider installer and fix documentation
- Add `smarttools providers install` interactive guide for installing AI CLIs
- Fix OpenCode install URL (remove .sh suffix)
- Auto-refresh PATH after provider installation
- Fix incorrect npm package names in docs:
  - @anthropic-ai/claude-code (not claude-cli)
  - @openai/codex (not codex-cli)
  - @google/gemini-cli (not @anthropic-ai/gemini-cli)
- Update all docs to reflect clone-first install workflow (not PyPI)
- Add Docker provider installation section to README

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 17:33:23 -04:00
rob b09008c215 fix: correct Gemini install command (npm @google/gemini-cli) 2025-12-29 15:50:42 -04:00
rob 2cbaaeb9d5 fix: correct Codex install command (npm, not pip) 2025-12-29 15:40:34 -04:00
rob 452cebadda fix: add xdg-utils for browser opening in Docker 2025-12-29 15:19:44 -04:00
robandClaude Opus 4.5 6106b7837a feat: add browser support for provider OAuth in Docker
Docker changes:
- Install Node.js and npm (for Claude CLI)
- Install Firefox ESR for browser-based OAuth
- Install X11 libraries for display forwarding
- Add 'setup' service with display access

Usage:
  xhost +local:docker
  docker-compose run --rm setup
  smarttools providers install

The 'setup' service has DISPLAY and X11 socket access, so browser-based
OAuth flows (Claude, Codex, Gemini, OpenCode) can work inside Docker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 13:15:13 -04:00
robandClaude Opus 4.5 9d3b65118d fix: correct provider setup descriptions for browser-based auth
All major providers (Claude, Codex, Gemini, OpenCode) use browser-based
OAuth authentication, not manual API keys:
- Run the CLI command
- Browser opens for sign-in
- Auth tokens saved automatically

Also added post_install_note for Ollama to show how to add the provider
after installing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 13:09:19 -04:00
robandClaude Opus 4.5 b4c7491784 feat: add interactive provider installation guide
New command: smarttools providers install

Features:
- Lists all provider groups (Claude, Codex, Gemini, OpenCode, Ollama)
- Shows cost, requirements, and included models for each
- Marks already-installed providers
- Offers to run installation command automatically
- Shows post-install setup instructions

Provider groups:
- Anthropic Claude (paid): claude, claude-haiku, claude-opus, claude-sonnet
- OpenAI Codex (paid): codex
- Google Gemini (free tier): gemini, gemini-flash
- OpenCode (free tier): opencode-deepseek, opencode-pickle, etc.
- Ollama (free, local): custom models

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 13:04:17 -04:00
robandClaude Opus 4.5 6abca22019 feat: add providers CLI command for managing AI providers
New commands:
- smarttools providers list - List all providers with status
- smarttools providers check - Check which providers are available
- smarttools providers add <name> <command> - Add/update provider
- smarttools providers remove <name> - Remove provider
- smarttools providers test <name> - Test a provider

Features:
- Shows [+] for available, [-] for missing providers
- Mock provider always shows as available (built-in)
- Helpful installation hints when no providers found
- Can add custom providers (Ollama, local scripts, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-29 12:53:43 -04:00
robandClaude Opus 4.5 f65d250973 docs: Add Docker section to README
Friends can now clone and test without installing anything locally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-24 01:21:56 -04:00
robandClaude Opus 4.5 42be82df6b Add standalone docker-compose.yml
Self-contained Docker setup for SmartTools with no external dependencies.
Includes cli, test, and shell services.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-24 01:01:35 -04:00
robandClaude Opus 4.5 65279bd8c8 Add Docker support and documentation updates
- Add Dockerfile for containerized builds
- Add .dockerignore for cleaner builds
- Add CLAUDE.md for development guidance
- Update README and docs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-23 23:40:13 -04:00
rob 7e9bd0d533 made an adjustment to the adjust code instructions 2025-12-07 05:34:24 -04:00
robandClaude 35835f8254 fix: Add clearer example showing variable assignment pattern
The AI prompt now explicitly shows:
1. Assign Available Variables to standard Python variables first
2. Then use those variables in the code
3. Multi-line example with file writing pattern

This prevents the AI from trying to use {variable} directly in
expressions without proper assignment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-07 05:15:12 -04:00
robandClaude d09a0088ef fix: Show available variables in triple-quote format in AI prompt
The AI follows the pattern better when the available variables are
shown in the exact format they should be used:
  """{input}""", """{response}""", etc.

This helps the AI generate code that properly uses triple quotes
for variable substitution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-07 04:58:37 -04:00
robandClaude bcda4150a7 docs: Improve AI auto-adjust prompt with variable substitution guidance
Updated the default prompt template to explain how to use variables:
- Variables must be wrapped in curly braces: {variable}
- Use triple quotes since content may contain quotes/newlines
- Added example: my_var = """{response}"""

This helps the AI generate code that works correctly with the
variable substitution system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-07 03:57:41 -04:00
robandClaude 0c966a8adb fix: Add variable substitution to code steps
Code steps were executing with literal {variable} placeholders instead
of substituted values. Now substitute_variables() is called on the code
before execution, matching the behavior of prompt steps.

This fixes issues like {outputfile} being treated as a Python set literal
or written as a literal filename.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-07 02:59:35 -04:00
robandClaude 9c6b682c73 fix: Improve AI auto-adjust prompt to prevent function wrapping
The AI was generating function definitions instead of inline code.
Updated the default prompt template to explicitly:
- Request inline executable code, NOT function definitions
- Clarify that variables are already available
- Emphasize no wrapping in functions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 21:39:48 -04:00
robandClaude 541d49b7f9 feat: Add tool categories for organization
- Add category field to Tool dataclass (Text, Developer, Data, Other)
- Display tools grouped by category in main UI with section headers
- Add category dropdown selector in tool builder dialog
- Update example tools with appropriate categories
- Document category feature in README

Categories help organize tools in the UI for easier navigation.
Tools without a category default to "Other".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 04:33:58 -04:00
robandClaude cff3b674ac docs: Add undo/redo and $EDITOR to UI navigation docs
- Added Alt+U/Alt+R for undo/redo in key bindings table
- Added tips about undo/redo (50 states) and $EDITOR button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 03:35:55 -04:00
robandClaude f84e06aa05 fix: Use Alt+U/Alt+R for undo/redo, fix state tracking
- Changed undo/redo keys to Alt+U and Alt+R (meta keys work in urwid)
- Fixed undo logic: now saves state BEFORE edit happens, not after
- Added duplicate state prevention
- Simplified the tracking logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 03:33:13 -04:00
robandClaude 67a83e5379 fix: Change undo/redo keys to Ctrl+U/Ctrl+R
Ctrl+Z is the Unix suspend signal (SIGTSTP) which suspends the process
before urwid can intercept it. Changed to:
- Ctrl+U for undo
- Ctrl+R for redo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 02:48:51 -04:00
robandClaude 6a4e153a62 feat: Add undo/redo for code editor
- Add UndoableEdit class with undo/redo support:
  - Ctrl+Z to undo (up to 50 states)
  - Ctrl+Y or Ctrl+Shift+Z to redo
  - Automatically tracks edit history and cursor position
  - Tab passes through for focus cycling

- Remove syntax highlighting attempt (incompatible with urwid Edit widget's
  text layout engine which requires raw text strings, not markup)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 02:44:03 -04:00
robandClaude f227831feb Add $EDITOR button to open code in external editor
When editing a code step, users can now click the "$EDITOR" button
to open the current code in their preferred external editor:

- Uses $EDITOR environment variable (falls back to $VISUAL, then nano)
- Creates a temp .py file with current code
- Suspends urwid UI while editor runs
- Imports edited code back when editor closes
- Shows success/error status in the dialog

This allows using vim, nano, VS Code, or any editor for complex
code editing while still using the SmartTools UI for workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 02:04:42 -04:00
robandClaude 765a53df1c Add DOS scrollbar to AI prompt editor
Wrap the AI prompt ListBox in DOSScrollBar for consistent
scrolling experience in the auto-adjust panel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:54:38 -04:00
robandClaude aef5c0f99f Add 3D-style buttons with box-drawing characters
Create DOS/BIOS-style 3D button effects using Unicode characters:

Button3D (multi-line):
  ┌──────────┐
  │  Label   │▄
  └──────────┘█

Button3DCompact (single-line):
  ▐ Label ▌▄

Changes:
- Add Button3D class for large standalone buttons
- Add Button3DCompact class for inline/dialog buttons
- Update palette with shadow colors (shadow_edge, button_highlight, button_shadow)
- Replace all ClickableButton/urwid.Button with Button3DCompact throughout:
  - Main menu action buttons (Create, Edit, Delete, Test, Providers, EXIT)
  - Dialog OK/Cancel buttons
  - Load buttons in code/prompt dialogs
  - Provider dropdown buttons
  - Auto-adjust button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:46:20 -04:00
robandClaude b8c85df398 docs: Add AI-Assisted Code Generation section to README
Document the new AI assist panel in the code step dialog:
- Provider selection dropdown
- Editable prompt template with {code} placeholder
- Output/feedback area showing status and errors
- Auto-adjust button to generate/modify code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:40:52 -04:00
robandClaude bdaf9e48de Redesign AI auto-adjust UI with boxed section
- Move auto-adjust feature to a dedicated LineBox panel
- Add full editable prompt template with {code} placeholder
- Add scrollable output/feedback area showing AI responses
- Display success/error messages with provider info
- Side-by-side layout: Code editor on left, AI assist on right
- Increase dialog size to accommodate new layout (90x30)
- Default prompt shows available variables and clear instructions

The AI assist panel now shows:
- Provider selector with dropdown
- Editable prompt (user can customize the full AI instruction)
- Output area with status messages and error details
- Centered Auto-adjust button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:27:22 -04:00
robandClaude c4b5492dc7 Cleanup: remove dead code and improve documentation
- Remove 6 unused methods from ui_urwid.py:
  - select_edit_tool, select_delete_tool, select_test_tool (replaced by main menu)
  - show_tools_list (replaced by info panel)
  - _edit_step_dialog, _edit_argument_dialog (replaced by direct dialog calls)
- Remove unused variable assignment in _edit_argument_at
- Remove ToolInput legacy alias from tool.py (never used)
- Enhance docstrings for DOSScrollBar and TabCyclePile classes
- Net reduction of ~120 lines of dead code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 01:01:01 -04:00
robandClaude 039df4a6a5 Add DOS-style scrollbars and improve dialog navigation
- Add DOSScrollBar class with ▲/▼ arrow buttons for code/prompt editors
- Expand scrollbar click sensitivity (last 2 columns, zone-based scrolling)
- Fix urwid 3.x compatibility: use explicit ('pack', widget) tuples in Piles
- Add TabPassEdit class for proper Tab cycling in multiline editors
- Add TabCyclePile for focus cycling with Tab key in dialogs
- Add auto-adjust AI feature to automatically set output_var from code
- Update README with new UI navigation documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-05 00:47:25 -04:00
robandClaude 4c1d3aa4fd Add Bitcoin donation address
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-04 13:13:06 -04:00
robandClaude 02b5a496c4 Update all URLs to point to Gitea
- Changed all github.com references to gitea.brrd.tech/rob/smarttools
- Updated pyproject.toml URLs
- Updated documentation install commands
- Updated curl examples for raw file access

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-04 12:47:14 -04:00
robandClaude 4c71dbded2 Add comprehensive documentation and 28 example tools
- Updated README with full feature overview and examples
- Added docs/INSTALL.md with detailed setup instructions
- Added docs/PROVIDERS.md with 12 profiled providers
- Added docs/EXAMPLES.md with all 28 tool configurations
- Added examples/install.py for one-command tool installation
- Updated pyproject.toml with better metadata
- Added urwid TUI with provider descriptions in dropdown
- Profiled all providers for speed/accuracy/cost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-12-04 12:42:04 -04:00
robandClaude 28dac465e6 Implement core SmartTools functionality
- providers.py: Provider abstraction with mock support
- tool.py: Tool loading, saving, and wrapper script generation
- runner.py: Tool execution engine with prompt building
- ui.py: Dialog-based UI for tool management
- cli.py: CLI entry point with list/create/edit/delete/test commands

~430 lines of Python as designed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-11-28 04:45:39 -04:00
robandClaude ba18c0feaa Initial commit: SmartTools design and README
A lightweight personal tool builder for AI-powered CLI commands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
2025-11-28 04:30:55 -04:00
191 changed files with 44949 additions and 422 deletions
+35
View File
@@ -0,0 +1,35 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info
.eggs
*.egg
dist
build
.venv
venv
ENV
# IDE
.idea
.vscode
*.swp
# Docker
Dockerfile*
docker-compose*
.docker
# Misc
*.md
!README.md
.coverage
.pytest_cache
htmlcov
+32
View File
@@ -0,0 +1,32 @@
---
name: Bug Report
about: Report something that isn't working
labels: bug
---
## Description
What happened?
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
What should have happened?
## Environment
- OS:
- CmdForge version:
- Provider:
- Docker or native install?
## Error Output
```
Paste any error messages here
```
+21
View File
@@ -0,0 +1,21 @@
---
name: Feature Request
about: Suggest a new feature or improvement
labels: enhancement
---
## Feature Description
A clear description of the feature you'd like to see.
## Use Case
Why do you need this? What problem does it solve?
## Proposed Solution
How do you think it should work? (optional)
## Alternatives Considered
Any other approaches you've thought about? (optional)
+17
View File
@@ -0,0 +1,17 @@
---
name: Question
about: Ask a question about CmdForge
labels: question
---
## Question
What would you like to know?
## What I've Tried
Any steps you've already taken to find the answer?
## Context
Any additional context that might help?
+50
View File
@@ -0,0 +1,50 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Node.js
node_modules/
package-lock.json
# Project specific
*.log
discussions/
diagrams/
*.puml
.tmp_*
# Documentation symlink (points to project-docs)
docs
-16
View File
File diff suppressed because one or more lines are too long
+42
View File
@@ -0,0 +1,42 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/cmdforge/` contains the core Python package (CLI entry point, tool model, runner, providers, UI backends).
- `tests/` holds pytest suites.
- `docs/` includes installation, provider setup, examples, and design notes.
- `examples/` provides sample tools and an installer script.
- `wiki/` contains additional reference material.
## Architecture Overview
- `cli/` directory routes subcommands like `list`, `create`, `run`, `test`, and `refresh`.
- `tool.py` defines tool/step models and handles YAML config loading and wrapper generation.
- `runner.py` executes steps and performs `{input}`/argument variable substitution.
- `providers.py` shells out to configured AI provider CLIs (or the `mock` provider).
- `gui/` provides the PySide6 desktop GUI with pages for tools, registry, and providers.
## Build, Test, and Development Commands
- `pip install -e ".[dev]"` installs CmdForge in editable mode with dev dependencies.
- `pytest` runs the full test suite.
- `pytest tests/test.py::test_name` runs a focused test.
- `python -m cmdforge.cli` runs the CLI module directly.
- `cmdforge` launches the desktop GUI (requires PySide6).
- `docker-compose build` builds the dev container image.
- `docker-compose run --rm test` runs tests inside Docker.
## Coding Style & Naming Conventions
- Python code uses 4-space indentation and module/variable names in `snake_case`.
- Keep CLI command names and tool IDs lowercase with hyphens (e.g., `fix-grammar`, `json-extract`).
- Prefer small, composable functions that mirror the Unix pipe flow described in `README.md`.
## Testing Guidelines
- Framework: `pytest` (configured in `pyproject.toml`).
- Place new tests under `tests/` and name files `test_*.py`.
- For provider-dependent logic, prefer the built-in `mock` provider to avoid network calls.
## Commit & Pull Request Guidelines
- Commit messages follow short, imperative, sentence-case subjects (e.g., "Improve provider error messages").
- PRs should include a clear summary, test results (or rationale if tests are skipped), and screenshots when UI behavior changes.
## Security & Configuration Tips
- Provider credentials live in `~/.cmdforge/providers.yaml`; avoid committing secrets.
- User tool configs live in `~/.cmdforge/<toolname>/config.yaml` and should not be added to the repo.
+1
View File
@@ -0,0 +1 @@
# Auto-deploy test Tue 13 Jan 2026 03:09:44 AM AST
+137
View File
@@ -0,0 +1,137 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
CmdForge is a lightweight personal tool builder for AI-powered CLI commands. It lets users create custom terminal commands that call AI providers, chain prompts with Python code steps, and use them like any Unix pipe command.
## Development Commands
```bash
# Install for development
pip install -e ".[dev]"
# Run tests
pytest
# Run a single test
pytest tests/test.py::test_name
# Run the CLI
python -m cmdforge.cli
# Launch the GUI
cmdforge
```
## Architecture
### Core Modules (`src/cmdforge/`)
- **cli/**: CLI commands entry points (`cmdforge` command). Routes subcommands: list, create, edit, delete, test, run, refresh
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation
- **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()`
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.cmdforge/providers.yaml`
- **gui/**: PySide6 desktop GUI
- **main_window.py**: Main application window with sidebar navigation
- **pages/**: Tools page, Tool Builder, Registry browser, Providers management
- **dialogs/**: Step editors, Argument editor, Provider dialog, Connect/Publish dialogs
### Key Paths
- **Tools storage**: `~/.cmdforge/<toolname>/config.yaml`
- **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts)
- **Provider config**: `~/.cmdforge/providers.yaml`
### Tool Structure
Tools are YAML configs with:
- `name`, `description`, `category`
- `arguments`: Custom flags with defaults (e.g., `--max` → `{max}`)
- `steps`: Ordered list of `prompt` or `code` steps
- `output`: Template for final output (e.g., `"{response}"`)
### Step Types
1. **Prompt Step**: Calls AI provider with template, stores result in `output_var`
2. **Code Step**: Executes Python code via `exec()`, captures specified variables
### Variable Flow
Variables are passed between steps:
- `{input}` - always available (stdin/file content)
- `{argname}` - from tool arguments
- `{step_output_var}` - from previous step's `output_var`
Variable substitution is simple string replacement in `runner.py:substitute_variables()`.
## Provider System
Providers are CLI tools that accept prompts via stdin and output to stdout. Defined in `~/.cmdforge/providers.yaml`:
```yaml
providers:
- name: claude
command: "claude -p"
- name: mock
command: "echo '[MOCK]'"
```
The `mock` provider is built-in for testing without API calls.
## Web UI & Registry
CmdForge includes a web interface and tool registry:
### Web Modules (`src/cmdforge/web/`)
- **app.py**: Flask app factory, registers blueprints
- **routes.py**: Main web routes (docs, tutorials, tools, etc.)
- **forum/**: Community forum blueprint
- **models.py**: Forum database schema (categories, topics, replies)
- **routes.py**: Forum routes (/forum, /forum/c/<slug>, /forum/t/<id>)
- **filters.py**: Jinja2 filters (timeago, markdown, etc.)
- **docs_content.py**: Documentation and tutorial content
### Registry Modules (`src/cmdforge/registry/`)
- **app.py**: Registry API (tool publishing, search, downloads)
- **db.py**: SQLite schema and queries
### Key URLs
- `/forum` - Community forum
- `/docs` - Documentation
- `/tutorials` - Tutorial guides
- `/tools` - Tool registry browser
### Running the Web UI
```bash
# Development
python -m cmdforge.web.app
# Production (example)
CMDFORGE_REGISTRY_DB=/path/to/db PORT=5050 python -m cmdforge.web.app
```
## Infrastructure Documentation
For deployment, server details, and operations, see the `docs/` folder:
- **docs/servers.md** - Server IPs (192.168.0.162), SSH access, paths, service commands
- **docs/deployment.md** - Architecture diagram, deploy process, systemd service config
- **docs/maintenance.md** - Backups, updates, troubleshooting
- **docs/architecture.md** - Module structure, data flow diagrams
### Production Server Quick Reference
| Property | Value |
|----------|-------|
| Server | 192.168.0.162 (OpenMediaVault) |
| SSH | `ssh [email protected]` |
| App Path | `/srv/mergerfs/data_pool/home/rob/cmdforge-registry/` |
| Service | `systemctl --user status cmdforge-web` |
| Public URL | https://cmdforge.brrd.tech |
| Port | 5050 (via Cloudflare tunnel) |
+56
View File
@@ -0,0 +1,56 @@
# CmdForge - AI-powered CLI command builder
# Build: docker build -t cmdforge .
# Run: docker run -it --rm -v ~/.cmdforge:/root/.cmdforge cmdforge
FROM python:3.12-slim
LABEL maintainer="rob"
LABEL description="CmdForge - Personal AI-powered CLI command builder"
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
# Node.js for Claude CLI
nodejs \
npm \
# Browser for OAuth authentication
firefox-esr \
# xdg-open for opening URLs (used by Claude CLI)
xdg-utils \
# X11 libraries for display
libx11-6 \
libxext6 \
libxrender1 \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy project files
COPY pyproject.toml README.md ./
COPY src/ ./src/
COPY examples/ ./examples/
COPY tests/ ./tests/
# Install CmdForge and all dependencies (CLI, TUI, registry)
RUN pip install --no-cache-dir -e ".[all,dev]"
# Create cmdforge directory
RUN mkdir -p /root/.cmdforge /root/.local/bin
# Install example tools
RUN python examples/install.py
# Generate CLI wrappers
RUN cmdforge refresh
# Add local bin to PATH
ENV PATH="/root/.local/bin:${PATH}"
# Default command - show help
CMD ["cmdforge", "--help"]
# For interactive use:
# docker run -it --rm cmdforge cmdforge ui
# docker run -it --rm cmdforge bash
+98
View File
@@ -0,0 +1,98 @@
# CmdForge - Ready-to-Use Container
#
# CmdForge is pre-installed and ready to use immediately.
# Use this for regular usage after testing the installer.
#
# Build:
# docker build -f Dockerfile.ready -t cmdforge-ready .
#
# Run:
# docker run -it --rm cmdforge-ready
#
# With persistent storage (keeps your tools and settings):
# docker run -it --rm -v cmdforge-data:/home/user/.cmdforge cmdforge-ready
#
# Inside the container:
# cmdforge ui # Launch the TUI
# cmdforge list # List installed tools
# cmdforge run <tool> # Run a tool
FROM ubuntu:22.04
LABEL maintainer="rob"
LABEL description="CmdForge pre-installed and ready to use"
# Prevent interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
# Python
python3 \
python3-pip \
python3-venv \
# Common tools
git \
curl \
vim \
less \
# For providers that need browser auth
firefox \
xdg-utils \
# X11 for display forwarding
libx11-6 \
libxext6 \
libxrender1 \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN useradd -m -s /bin/bash user
USER user
WORKDIR /home/user
# Copy CmdForge source
COPY --chown=user:user . /home/user/CmdForge
# Set up PATH
RUN mkdir -p /home/user/.local/bin
ENV PATH="/home/user/.cmdforge-venv/bin:/home/user/.local/bin:${PATH}"
# Install CmdForge in a virtual environment
RUN python3 -m venv /home/user/.cmdforge-venv && \
/home/user/.cmdforge-venv/bin/pip install --upgrade pip && \
/home/user/.cmdforge-venv/bin/pip install -e "/home/user/CmdForge[all]"
# Install example tools
RUN /home/user/.cmdforge-venv/bin/python /home/user/CmdForge/examples/install.py
# Generate CLI wrappers
RUN /home/user/.cmdforge-venv/bin/cmdforge refresh
# Welcome message
RUN echo '\n\
echo ""\n\
echo "================================================================="\n\
echo " CmdForge - Ready to Use"\n\
echo "================================================================="\n\
echo ""\n\
echo " Commands:"\n\
echo " cmdforge ui # Launch the graphical UI"\n\
echo " cmdforge list # List installed tools"\n\
echo " cmdforge run <tool> # Run a tool"\n\
echo " cmdforge create <name> # Create a new tool"\n\
echo ""\n\
echo " Registry:"\n\
echo " cmdforge registry search <query> # Search for tools"\n\
echo " cmdforge registry install <tool> # Install from registry"\n\
echo ""\n\
echo " Provider setup:"\n\
echo " cmdforge providers install # Set up AI provider"\n\
echo ""\n\
echo "================================================================="\n\
echo ""\n\
' >> /home/user/.bashrc
WORKDIR /home/user
# Start in interactive bash
CMD ["/bin/bash"]
+85
View File
@@ -0,0 +1,85 @@
# CmdForge - Test Installation Container
#
# This container simulates a fresh computer for testing the installation process.
# CmdForge source is available but NOT installed - you run the installer yourself.
#
# Build:
# docker build -f Dockerfile.test-install -t cmdforge-test-install .
#
# Run:
# docker run -it --rm cmdforge-test-install
#
# Inside the container:
# ./install.sh # Run the installer
# cmdforge ui # Launch the UI
# cmdforge providers install # Set up an AI provider
FROM ubuntu:22.04
LABEL maintainer="rob"
LABEL description="CmdForge installation test environment"
# Prevent interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies (what a typical dev machine would have)
RUN apt-get update && apt-get install -y --no-install-recommends \
# Python
python3 \
python3-pip \
python3-venv \
# Common tools a developer would have
git \
curl \
vim \
less \
# For providers that need browser auth
firefox \
xdg-utils \
# X11 for display forwarding
libx11-6 \
libxext6 \
libxrender1 \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user (simulates typical user setup)
RUN useradd -m -s /bin/bash testuser
USER testuser
WORKDIR /home/testuser
# Copy CmdForge source (owned by testuser)
COPY --chown=testuser:testuser . /home/testuser/CmdForge
# Set up PATH for local bin (where cmdforge wrappers go)
RUN mkdir -p /home/testuser/.local/bin
ENV PATH="/home/testuser/.local/bin:${PATH}"
# Make install script executable
RUN chmod +x /home/testuser/CmdForge/install.sh
# Welcome message
RUN echo '\n\
echo ""\n\
echo "================================================================="\n\
echo " CmdForge Installation Test Environment"\n\
echo "================================================================="\n\
echo ""\n\
echo " CmdForge source is in: ~/CmdForge"\n\
echo " To install, run:"\n\
echo ""\n\
echo " cd ~/CmdForge"\n\
echo " ./install.sh"\n\
echo ""\n\
echo " After installation:"\n\
echo " cmdforge ui # Launch the UI"\n\
echo " cmdforge list # List tools"\n\
echo " cmdforge providers install # Set up AI provider"\n\
echo ""\n\
echo "================================================================="\n\
echo ""\n\
' >> /home/testuser/.bashrc
WORKDIR /home/testuser/CmdForge
# Start in interactive bash
CMD ["/bin/bash"]
+683
View File
@@ -0,0 +1,683 @@
# CmdForge
**A lightweight personal tool builder for AI-powered CLI commands.**
Turn any AI model into a Unix-style pipe command. Build once, use forever.
```bash
# Fix grammar in any file
echo "teh cat sat on teh mat" | fix-grammar
# Output: The cat sat on the mat
# Explain errors instantly
cat error.log | explain-error
# Generate commit messages
git diff --staged | commit-msg
# Extract data as validated JSON
echo "Price $49.99, SKU ABC-123" | json-extract --fields "price, sku"
# Output: {"price": 49.99, "sku": "ABC-123"}
```
## Why CmdForge?
- **Unix Philosophy** - Tools that do one thing well, composable with pipes
- **Provider Agnostic** - Works with Claude, GPT, Gemini, DeepSeek, local models
- **No Lock-in** - Your tools are YAML files, your prompts are yours
- **Multi-step Pipelines** - Chain AI prompts with Python code for validation
- **Meta-tools** - Tools can call other tools for powerful composition
- **12+ Providers Profiled** - We tested them so you don't have to
## Try It Now (60 seconds)
See CmdForge in action without installing anything on your system:
```bash
# Pull the container
docker pull gitea.brrd.tech/rob/cmdforge:latest
# Run it
docker run -it --rm gitea.brrd.tech/rob/cmdforge bash
# Inside the container - install OpenCode (includes free AI models)
cmdforge providers install # Select option 4, then 'y'
source ~/.bashrc
# Try it! This explains the README using the free Big Pickle model
cat README.md | eli5
```
That's it - you just used AI to explain itself. The `eli5` tool uses a free model by default, no sign-up required.
## Quick Start (Native Install)
For regular use, install natively:
```bash
# Clone and install
git clone https://gitea.brrd.tech/rob/CmdForge.git
cd CmdForge
pip install -e .
# Ensure ~/.local/bin is in PATH
export PATH="$HOME/.local/bin:$PATH"
# Install an AI provider (interactive guide)
cmdforge providers install
# Launch the GUI
cmdforge
# Or create your first tool via CLI
cmdforge create summarize
```
## Installation
### Native Install
```bash
git clone https://gitea.brrd.tech/rob/CmdForge.git
cd CmdForge
pip install -e .
```
### With Development Dependencies
```bash
git clone https://gitea.brrd.tech/rob/CmdForge.git
cd CmdForge
pip install -e ".[dev]"
```
### Requirements
- Python 3.10+
- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md))
- PySide6 (included automatically - requires display server on Linux)
### Post-Install
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
Then refresh wrapper scripts:
```bash
cmdforge refresh
```
## Usage
### GUI Mode (Recommended for Beginners)
```bash
cmdforge
```
Opens the graphical interface where you can create and manage tools visually. Features include:
- **My Tools** - Browse, create, edit, and delete tools organized by category
- **Registry** - Search and install community tools from the CmdForge registry
- **Providers** - Manage AI provider configurations
### CLI Mode
```bash
cmdforge list # List all tools
cmdforge create mytool # Create new tool
cmdforge edit mytool # Edit in $EDITOR
cmdforge delete mytool # Delete tool
cmdforge run mytool # Run a tool
cmdforge test mytool # Test with mock provider
cmdforge check mytool # Check dependencies (meta-tools)
cmdforge refresh # Update executable wrappers
```
### Running Tools
Once created, tools work like any Unix command:
```bash
# Direct invocation
mytool -i input.txt -o output.txt
# Pipe input (most common)
cat file.txt | mytool
# With arguments
echo "hello" | translate --lang French
# Preview without calling AI
cat file.txt | mytool --dry-run
# Test with mock provider
cat file.txt | mytool --provider mock
```
### Composing Tools
CmdForge follows Unix philosophy: each tool does one thing well, and tools chain together for complex workflows.
```bash
# Chain CmdForge together
cat error.log | log-errors | summarize | translate --lang Spanish
# Code review pipeline: focus on changes, review, then summarize
git diff | diff-focus | review-code | tldr
# Extract data, convert format, analyze
cat report.pdf | json-extract --fields "revenue, costs, date" | json2csv | csv-insights
# Process text through multiple transformations
cat technical_doc.txt | simplify | fix-grammar | tone-shift --tone friendly
# Generate code, validate it, then explain what it does
echo "parse CSV and calculate averages" | code-validate | explain-code
```
**Mix with standard Unix tools:**
```bash
# Extract emails, deduplicate, count
cat inbox.txt | extract-emails | sort -u | wc -l
# Find errors in multiple logs, explain them
cat *.log | grep -i error | explain-error
# Generate changelog for specific author
git log --author="rob" --oneline | changelog > CHANGELOG.md
# Review only large files in a commit
git diff --stat | awk '$3 > 100 {print $1}' | xargs cat | review-code
```
**Build shell functions for common pipelines:**
```bash
# ~/.bashrc
quick-review() {
git diff "$@" | diff-focus | review-code --focus "bugs and security" | tldr
}
translate-doc() {
cat "$1" | summarize | translate --lang "$2"
}
# Usage: quick-review HEAD~3
# Usage: translate-doc manual.txt French
```
## Example Tools
CmdForge comes with 28 pre-built examples you can install:
### Text Processing
| Tool | Description | Example |
|------|-------------|---------|
| `summarize` | Condense documents | `cat article.txt \| summarize` |
| `translate` | Translate text | `echo "Hello" \| translate --lang Spanish` |
| `fix-grammar` | Fix spelling/grammar | `cat draft.txt \| fix-grammar` |
| `simplify` | Rewrite for clarity | `cat legal.txt \| simplify --level "5th grade"` |
| `tone-shift` | Change tone | `cat email.txt \| tone-shift --tone professional` |
| `eli5` | Explain like I'm 5 | `cat quantum.txt \| eli5` |
| `tldr` | One-line summary | `cat readme.txt \| tldr` |
| `expand` | Expand bullet points | `cat notes.txt \| expand` |
### Developer Tools
| Tool | Description | Example |
|------|-------------|---------|
| `explain-error` | Explain stack traces | `cat error.log \| explain-error` |
| `explain-code` | Explain what code does | `cat script.py \| explain-code` |
| `review-code` | Quick code review | `cat pr.diff \| review-code --focus security` |
| `gen-tests` | Generate unit tests | `cat module.py \| gen-tests --framework pytest` |
| `docstring` | Add docstrings | `cat functions.py \| docstring` |
| `commit-msg` | Generate commit message | `git diff --staged \| commit-msg` |
### Data Tools
| Tool | Description | Example |
|------|-------------|---------|
| `json-extract` | Extract as validated JSON | `cat text.txt \| json-extract --fields "name, email"` |
| `json2csv` | Convert JSON to CSV | `cat data.json \| json2csv` |
| `extract-emails` | Extract email addresses | `cat page.html \| extract-emails` |
| `extract-contacts` | Extract contacts as CSV | `cat notes.txt \| extract-contacts` |
| `sql-from-text` | Natural language to SQL | `echo "get active users" \| sql-from-text` |
| `safe-sql` | SQL with safety checks | `echo "delete old records" \| safe-sql` |
| `parse-log` | Analyze log files | `cat app.log \| parse-log --focus errors` |
| `csv-insights` | Analyze CSV data | `cat sales.csv \| csv-insights --question "trends?"` |
### Advanced Multi-Step Tools
| Tool | Description | Pattern |
|------|-------------|---------|
| `log-errors` | Extract & explain errors from huge logs | Code→AI |
| `diff-focus` | Review only added lines | Code→AI |
| `changelog` | Git log to formatted changelog | Code→AI→Code |
| `code-validate` | Generate syntax-checked Python | AI→Code |
### Install Example Tools
```bash
# Run the example installer (from the CmdForge directory)
python examples/install.py
cmdforge refresh
```
## Providers
CmdForge works with any AI CLI tool. We've profiled 12 providers:
| Provider | Speed | Accuracy | Cost | Best For |
|----------|-------|----------|------|----------|
| `opencode-deepseek` | 13s | 4/4 | Cheap | **Best value** - daily use |
| `opencode-pickle` | 13s | 4/4 | FREE | **Best free** - accurate |
| `claude-haiku` | 14s | 4/4 | Paid | Fast + accurate |
| `codex` | 14s | 4/4 | Paid | Reliable |
| `claude-opus` | 18s | 4/4 | $$$ | Highest quality |
| `gemini` | 91s | 3/4 | Paid | Large docs (1M tokens) |
See [docs/PROVIDERS.md](docs/PROVIDERS.md) for setup instructions.
## Tool Anatomy
A tool is a YAML config file in `~/.cmdforge/<name>/config.yaml`:
```yaml
name: summarize
description: Condense documents to key points
category: Text # Optional: Text, Developer, Data, or Other
arguments:
- flag: --length
variable: length
default: "3-5 bullet points"
steps:
- type: prompt
prompt: |
Summarize this text into {length}:
{input}
provider: opencode-pickle
output_var: response
output: "{response}"
```
### Categories
Tools can be organized into categories for easier navigation in the UI:
| Category | Description |
|----------|-------------|
| `Text` | Text processing (summarize, translate, grammar) |
| `Developer` | Dev tools (explain-error, gen-tests, commit-msg) |
| `Data` | Data extraction and conversion (json-extract, csv) |
| `Other` | Default for uncategorized tools |
### Multi-Step Tools
Chain AI prompts with Python code for powerful workflows:
```yaml
name: json-extract
description: Extract data as validated JSON
steps:
# Step 1: AI extracts data
- type: prompt
prompt: "Extract {fields} as JSON from: {input}"
provider: opencode-deepseek
output_var: raw_json
# Step 2: Code validates JSON
- type: code
code: |
import json
try:
parsed = json.loads(raw_json)
validated = json.dumps(parsed, indent=2)
except:
validated = "ERROR: Invalid JSON"
output_var: validated
output: "{validated}"
```
### Meta-Tools (Tools Calling Tools)
Tools can call other tools as steps, enabling powerful composition:
```yaml
name: summarize-and-translate
description: Summarize text then translate the summary
dependencies:
- official/summarize
- official/translate
steps:
- type: tool
tool: official/summarize
input: "{input}"
args:
max_words: "100"
output_var: summary
- type: tool
tool: official/translate
input: "{summary}"
args:
target_language: "{language}"
output_var: translated
output: "{translated}"
```
Check dependencies before running: `cmdforge check my-meta-tool`
See [docs/META_TOOLS.md](docs/META_TOOLS.md) for full documentation.
### Variables
| Variable | Description |
|----------|-------------|
| `{input}` | The piped/file input |
| `{argname}` | Custom argument value |
| `{output_var}` | Output from previous step (prompt, code, or tool) |
## Shell Integration
### Git Hooks
```bash
# .git/hooks/prepare-commit-msg
#!/bin/bash
git diff --cached | commit-msg > "$1"
```
### Aliases
```bash
# ~/.bashrc
alias gc='git diff --staged | commit-msg'
alias wtf='explain-error'
alias fixme='fix-grammar'
```
### Vim Integration
```vim
" Fix grammar in selection
vnoremap <leader>fg :!fix-grammar<CR>
" Explain selected code
vnoremap <leader>ec :!explain-code<CR>
```
## GUI Features
The graphical interface provides a modern desktop experience:
### My Tools Page
- View all your tools organized by category (Text, Developer, Data, Other)
- Double-click a tool to edit it
- Create new tools with the built-in Tool Builder
- Connect to the registry to publish your tools
### Tool Builder
- Visual form for creating and editing tools
- Add arguments with flags and default values
- Add prompt steps (AI calls) with profile selection
- Add code steps with **AI-assisted code generation**:
- Split view: code editor + AI assist panel
- Describe what you want and generate Python code
- Shows available variables from arguments and previous steps
- Automatic markdown fence stripping from AI responses
### Registry Browser
- **Browse all** tools on page load (no search required)
- **Search** by name, description, or keyword
- **Filter** by category (Text, Developer, Data, Other)
- **Sort** by popularity, rating, newest, or name
- **Star ratings** displayed in results and details
- **Clickable tags** to filter by tag
- **Installed indicator** (✓) shows which tools you have
- **Update available** (↑) when newer version exists
- **Pagination** for browsing large result sets
- **Publisher info** showing reputation and total downloads
### Provider Management
- Add and configure AI providers
- Test provider connectivity
- Set default providers for new tools
### Profiles (AI Personas)
- **Built-in profiles**: Comedian, Technical Writer, Teacher, Concise, Creative, Code Reviewer, Analyst
- **Custom profiles**: Create your own personas with custom system prompts
- **Profile selector**: Choose a profile when adding prompt steps to tools
- **Automatic injection**: Profile system prompts are prepended to your prompts during execution
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+N` | Create new tool |
| `Ctrl+S` | Save tool (in builder) |
| `Ctrl+R` | Refresh current page |
| `Ctrl+1` | Go to My Tools |
| `Ctrl+2` | Go to Registry |
| `Ctrl+3` | Go to Providers |
| `Ctrl+4` | Go to Profiles |
| `Escape` | Close tool builder |
| `Ctrl+Q` | Quit application |
## Multi-Step Tool Patterns
CmdForge supports powerful multi-step workflows combining AI and code:
### Code → AI
Extract or transform data with Python, then pass to AI:
```yaml
steps:
- type: code
code: |
# Extract just the error lines
errors = [l for l in input_text.split('\n') if 'error' in l.lower()]
result = '\n'.join(errors[-10:]) # Last 10 errors
output_var: errors
- type: prompt
prompt: "Explain these errors:\n{errors}"
output_var: explanation
```
### AI → Code
Generate content with AI, then validate/process with Python:
```yaml
steps:
- type: prompt
prompt: "Generate Python code to: {input}"
output_var: generated
- type: code
code: |
import ast
try:
ast.parse(generated) # Syntax check
result = generated
except SyntaxError as e:
result = f"# Syntax Error: {e}\n{generated}"
output_var: validated
```
## Philosophy
CmdForge is built on **Unix philosophy**:
1. **Do one thing well** - Each tool solves one problem. `summarize` summarizes. `translate` translates. No bloated mega-tools.
2. **Compose freely** - Tools read stdin, write stdout. Chain them: `cat doc.txt | summarize | translate --lang French`
3. **Text is the interface** - No proprietary formats. Pipe text in, get text out. Works with `grep`, `awk`, `sed`, and every other Unix tool.
4. **You own everything** - Tools are YAML files. Prompts are plain text. No vendor lock-in, no cloud dependency for your tool definitions.
5. **Swap parts freely** - Change AI providers per-tool or per-run. Today's best model might not be tomorrow's.
## Contributing
```bash
git clone https://gitea.brrd.tech/rob/cmdforge.git
cd cmdforge
pip install -e ".[dev]"
pytest
```
## Support
If CmdForge saves you time, consider supporting the project:
**Bitcoin:** `3KeLqGv2Xo8jGYkqA1i68a6nXwgQnuJoza`
**Questions?** robdickson444@hotmail.com
## License
MIT - Use it, modify it, share it.
## Docker
### Pre-installed Container (Recommended)
Ready-to-use container with CmdForge and 27 example tools pre-installed:
```bash
# Build from source
docker build -f Dockerfile.ready -t cmdforge-ready .
# Run interactively
docker run -it --rm cmdforge-ready
# With persistent storage (keeps your tools between runs)
docker run -it --rm -v cmdforge-data:/home/user/.cmdforge cmdforge-ready
```
Inside the container, CmdForge is ready to use:
```bash
cmdforge list # See 27 pre-installed tools
cmdforge # Launch the GUI (requires display)
cmdforge run summarize # Run a tool
```
### Test Installation Container
Simulates a fresh computer for testing the installation process:
```bash
# Build the test container
docker build -f Dockerfile.test-install -t cmdforge-test-install .
# Run interactively
docker run -it --rm cmdforge-test-install
# Inside the container - run the installer
cd ~/CmdForge
./install.sh
```
### Interactive Installer
The `install.sh` script provides guided installation:
```bash
./install.sh # Full interactive install
./install.sh --yes # Non-interactive (accept defaults)
./install.sh --no-venv # Install without virtual environment
./install.sh --no-examples # Skip installing example tools
```
Features:
- Creates virtual environment at `~/.cmdforge-venv`
- Configures PATH in your shell config (bash/zsh/fish)
- Optionally installs 27 example tools
- Works on macOS and Linux
### Legacy Container
The original docker-compose setup still works:
```bash
# Build the container
docker-compose build
# Run tests
docker-compose run --rm test
# Use the CLI
docker-compose run --rm cli cmdforge list
# Interactive shell
docker-compose run --rm shell
```
### Installing AI Providers in Docker
AI providers require browser-based authentication. You need display access for the browser to open.
**Using the pre-built container:**
```bash
# Allow Docker to access your display (Linux)
xhost +local:docker
# Run with display access and persistent storage
docker run -it --rm \
-e DISPLAY=$DISPLAY \
-v /tmp/.X11-unix:/tmp/.X11-unix \
-v cmdforge-data:/root/.cmdforge \
--network host \
gitea.brrd.tech/rob/cmdforge bash
# Inside the container:
cmdforge providers install
```
**Using docker-compose (build from source):**
```bash
xhost +local:docker
docker-compose run --rm setup
# Inside the container:
cmdforge providers install
```
**After installing a provider:**
```bash
# Test the provider works
cmdforge providers test claude
```
**Available Providers:**
| # | Provider | Cost | Notes |
|---|----------|------|-------|
| 1 | Claude | Pay-per-use | Anthropic account |
| 2 | Codex | Pay-per-use | OpenAI account |
| 3 | Gemini | Free tier | Google account |
| 4 | OpenCode | Free tier | Multiple models |
| 5 | Ollama | FREE | Local, no internet |
## Links
### User Documentation
- [Installation Guide](docs/INSTALL.md)
- [Provider Setup](docs/PROVIDERS.md)
- [Example Tools](docs/EXAMPLES.md)
- [User Wiki](wiki/Home.md)
### Developer Documentation
- [Project Overview](docs/PROJECT.md) - Start here to understand the codebase
- [Design Document](docs/DESIGN.md)
- [Meta-Tools Design](docs/META_TOOLS.md) - Tools that call other tools
- [Registry API Design](docs/REGISTRY.md)
- [Web UI Design](docs/WEB_UI.md)
- [Deployment Guide](docs/DEPLOYMENT.md)
- [Architecture Diagrams](docs/diagrams/)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[237],{3363(e,t,i){i.d(t,{A:()=>a});i(6540);var o=i(4164),n=i(1312),s=i(1107),r=i(4848);function a({className:e}){return(0,r.jsx)("main",{className:(0,o.A)("container margin-vert--xl",e),children:(0,r.jsx)("div",{className:"row",children:(0,r.jsxs)("div",{className:"col col--6 col--offset-3",children:[(0,r.jsx)(s.A,{as:"h1",className:"hero__title",children:(0,r.jsx)(n.A,{id:"theme.NotFound.title",description:"The title of the 404 page",children:"Page Not Found"})}),(0,r.jsx)("p",{children:(0,r.jsx)(n.A,{id:"theme.NotFound.p1",description:"The first paragraph of the 404 page",children:"We could not find what you were looking for."})}),(0,r.jsx)("p",{children:(0,r.jsx)(n.A,{id:"theme.NotFound.p2",description:"The 2nd paragraph of the 404 page",children:"Please contact the owner of the site that linked you to the original URL and let them know their link is broken."})})]})})})}},2237(e,t,i){i.r(t),i.d(t,{default:()=>l});i(6540);var o=i(1312),n=i(5500),s=i(1656),r=i(3363),a=i(4848);function l(){const e=(0,o.T)({id:"theme.NotFound.title",message:"Page Not Found"});return(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)(n.be,{title:e}),(0,a.jsx)(s.A,{children:(0,a.jsx)(r.A,{})})]})}}}]);
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[854],{5253(e){e.exports=JSON.parse('{"version":{"pluginId":"default","version":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","isLast":true,"docsSidebars":{"docs":[{"type":"link","href":"/rob/CmdForge/","label":"CmdForge Overview","docId":"overview","unlisted":false},{"type":"link","href":"/rob/CmdForge/architecture","label":"CmdForge Architecture","docId":"architecture","unlisted":false},{"type":"link","href":"/rob/CmdForge/goals","label":"Goals","docId":"goals","unlisted":false},{"type":"link","href":"/rob/CmdForge/ideas-and-exploration","label":"Ideas & Exploration","docId":"ideas-and-exploration","unlisted":false},{"type":"link","href":"/rob/CmdForge/milestones","label":"Milestones","docId":"milestones","unlisted":false}]},"docs":{"architecture":{"id":"architecture","title":"CmdForge Architecture","description":"Module Structure","sidebar":"docs"},"goals":{"id":"goals","title":"Goals","description":"Vision","sidebar":"docs"},"ideas-and-exploration":{"id":"ideas-and-exploration","title":"Ideas & Exploration","description":"Completed","sidebar":"docs"},"milestones":{"id":"milestones","title":"Milestones","description":"Active","sidebar":"docs"},"overview":{"id":"overview","title":"CmdForge Overview","description":"A lightweight personal tool builder for AI-powered CLI commands.","sidebar":"docs"}}}}')}}]);
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[647],{7121(e,c,s){s.r(c),s.d(c,{default:()=>t});s(6540);var r=s(4164),u=s(7559),a=s(5500),l=s(2831),o=s(1656),p=s(4848);function t(e){return(0,p.jsx)(a.e3,{className:(0,r.A)(u.G.wrapper.docsPages),children:(0,p.jsx)(o.A,{children:(0,l.v)(e.route.routes)})})}}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[98],{1723(n,e,s){s.r(e),s.d(e,{default:()=>d});s(6540);var r=s(5500);function o(n,e){return`docs-${n}-${e}`}var c=s(3025),t=s(2831),i=s(1463),u=s(4848);function l(n){const{version:e}=n;return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(i.A,{version:e.version,tag:o(e.pluginId,e.version)}),(0,u.jsx)(r.be,{children:e.noIndex&&(0,u.jsx)("meta",{name:"robots",content:"noindex, nofollow"})})]})}function a(n){const{version:e,route:s}=n;return(0,u.jsx)(r.e3,{className:e.className,children:(0,u.jsx)(c.n,{version:e,children:(0,t.v)(s.routes)})})}function d(n){return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(l,{...n}),(0,u.jsx)(a,{...n})]})}}}]);
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
"use strict";(globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[]).push([[742],{7093(c){c.exports=JSON.parse('{"name":"docusaurus-plugin-content-docs","id":"default"}')}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-61
View File
@@ -1,61 +0,0 @@
/* NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress
* @license MIT */
/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
-1
View File
@@ -1 +0,0 @@
(()=>{"use strict";var e,r,t,a,o,n={},c={};function i(e){var r=c[e];if(void 0!==r)return r.exports;var t=c[e]={id:e,loaded:!1,exports:{}};return n[e].call(t.exports,t,t.exports,i),t.loaded=!0,t.exports}i.m=n,i.c=c,e=[],i.O=(r,t,a,o)=>{if(!t){var n=1/0;for(l=0;l<e.length;l++){for(var[t,a,o]=e[l],c=!0,f=0;f<t.length;f++)(!1&o||n>=o)&&Object.keys(i.O).every(e=>i.O[e](t[f]))?t.splice(f--,1):(c=!1,o<n&&(n=o));if(c){e.splice(l--,1);var d=a();void 0!==d&&(r=d)}}return r}o=o||0;for(var l=e.length;l>0&&e[l-1][2]>o;l--)e[l]=e[l-1];e[l]=[t,a,o]},i.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return i.d(r,{a:r}),r},t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__,i.t=function(e,a){if(1&a&&(e=this(e)),8&a)return e;if("object"==typeof e&&e){if(4&a&&e.__esModule)return e;if(16&a&&"function"==typeof e.then)return e}var o=Object.create(null);i.r(o);var n={};r=r||[null,t({}),t([]),t(t)];for(var c=2&a&&e;("object"==typeof c||"function"==typeof c)&&!~r.indexOf(c);c=t(c))Object.getOwnPropertyNames(c).forEach(r=>n[r]=()=>e[r]);return n.default=()=>e,i.d(o,n),o},i.d=(e,r)=>{for(var t in r)i.o(r,t)&&!i.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},i.f={},i.e=e=>Promise.all(Object.keys(i.f).reduce((r,t)=>(i.f[t](e,r),r),[])),i.u=e=>"assets/js/"+({48:"a94703ab",98:"a7bd4aaa",207:"e719f3dc",401:"17896441",413:"1db64337",443:"5281b7a2",574:"817f7194",647:"5e95c892",742:"aba21aa0",854:"3fbf7384",894:"5eebbccf"}[e]||e)+"."+{48:"c0ce3492",98:"3ba34601",207:"5bfd6599",237:"46c4f719",401:"a2525508",413:"ab5ebde9",443:"1c1a5adb",574:"070f961b",647:"a3b66919",742:"4a552a5c",854:"1561f6b5",894:"7d0f69ae"}[e]+".js",i.miniCssF=e=>{},i.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),a={},o="project-public-docs:",i.l=(e,r,t,n)=>{if(a[e])a[e].push(r);else{var c,f;if(void 0!==t)for(var d=document.getElementsByTagName("script"),l=0;l<d.length;l++){var u=d[l];if(u.getAttribute("src")==e||u.getAttribute("data-webpack")==o+t){c=u;break}}c||(f=!0,(c=document.createElement("script")).charset="utf-8",i.nc&&c.setAttribute("nonce",i.nc),c.setAttribute("data-webpack",o+t),c.src=e),a[e]=[r];var b=(r,t)=>{c.onerror=c.onload=null,clearTimeout(s);var o=a[e];if(delete a[e],c.parentNode&&c.parentNode.removeChild(c),o&&o.forEach(e=>e(t)),r)return r(t)},s=setTimeout(b.bind(null,void 0,{type:"timeout",target:c}),12e4);c.onerror=b.bind(null,c.onerror),c.onload=b.bind(null,c.onload),f&&document.head.appendChild(c)}},i.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.p="/rob/CmdForge/",i.gca=function(e){return e={17896441:"401",a94703ab:"48",a7bd4aaa:"98",e719f3dc:"207","1db64337":"413","5281b7a2":"443","817f7194":"574","5e95c892":"647",aba21aa0:"742","3fbf7384":"854","5eebbccf":"894"}[e]||e,i.p+i.u(e)},(()=>{var e={354:0,869:0};i.f.j=(r,t)=>{var a=i.o(e,r)?e[r]:void 0;if(0!==a)if(a)t.push(a[2]);else if(/^(354|869)$/.test(r))e[r]=0;else{var o=new Promise((t,o)=>a=e[r]=[t,o]);t.push(a[2]=o);var n=i.p+i.u(r),c=new Error;i.l(n,t=>{if(i.o(e,r)&&(0!==(a=e[r])&&(e[r]=void 0),a)){var o=t&&("load"===t.type?"missing":t.type),n=t&&t.target&&t.target.src;c.message="Loading chunk "+r+" failed.\n("+o+": "+n+")",c.name="ChunkLoadError",c.type=o,c.request=n,a[1](c)}},"chunk-"+r,r)}},i.O.j=r=>0===e[r];var r=(r,t)=>{var a,o,[n,c,f]=t,d=0;if(n.some(r=>0!==e[r])){for(a in c)i.o(c,a)&&(i.m[a]=c[a]);if(f)var l=f(i)}for(r&&r(t);d<n.length;d++)o=n[d],i.o(e,o)&&e[o]&&e[o][0](),e[o]=0;return i.O(l)},t=globalThis.webpackChunkproject_public_docs=globalThis.webpackChunkproject_public_docs||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})()})();
+97
View File
@@ -0,0 +1,97 @@
# CmdForge - AI-powered CLI command builder
#
# Quick Start:
# docker-compose build # Build the image
# docker-compose run --rm test # Run tests
# docker-compose run --rm cli cmdforge list
#
# This is a standalone project with no external dependencies.
version: '3.8'
services:
# ============================================================================
# Main CLI
# ============================================================================
cli:
build:
context: .
dockerfile: Dockerfile
image: cmdforge:latest
volumes:
- cmdforge-data:/root/.cmdforge
command: ["cmdforge", "--help"]
# ============================================================================
# Tests
# ============================================================================
test:
build:
context: .
dockerfile: Dockerfile
image: cmdforge:latest
volumes:
- cmdforge-data:/root/.cmdforge
command: ["pytest", "-v"]
# ============================================================================
# Interactive Shell
# ============================================================================
shell:
build:
context: .
dockerfile: Dockerfile
image: cmdforge:latest
volumes:
- cmdforge-data:/root/.cmdforge
command: ["/bin/bash"]
stdin_open: true
tty: true
# ============================================================================
# Setup (with display for browser-based auth)
# ============================================================================
# Use this for installing providers that need browser authentication
# First run: xhost +local:docker
setup:
build:
context: .
dockerfile: Dockerfile
image: cmdforge:latest
environment:
- DISPLAY=${DISPLAY:-:0}
volumes:
- cmdforge-data:/root/.cmdforge
- /tmp/.X11-unix:/tmp/.X11-unix:ro
command: ["/bin/bash"]
stdin_open: true
tty: true
network_mode: host
volumes:
cmdforge-data:
# Persists ~/.cmdforge between container runs
# ==============================================================================
# Usage Examples
# ==============================================================================
#
# Build:
# docker-compose build
#
# Run tests:
# docker-compose run --rm test
#
# Use CLI:
# docker-compose run --rm cli cmdforge list
# docker-compose run --rm cli cmdforge run hello-world
#
# Interactive shell:
# docker-compose run --rm shell
#
# Install providers (with browser auth):
# xhost +local:docker
# docker-compose run --rm setup
# # Inside container:
# cmdforge providers install # Interactive guide
# # Select a provider, it will install and open browser for auth
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Install all CmdForge example tools.
Usage:
python3 install.py # Install all tools
python3 install.py --list # List available tools
python3 install.py summarize # Install specific tool
python3 install.py --category dev # Install category
Run from anywhere:
curl -sSL https://gitea.brrd.tech/rob/cmdforge/raw/branch/main/examples/install.py | python3
"""
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("PyYAML required. Install with: pip install pyyaml")
sys.exit(1)
TOOLS_DIR = Path.home() / ".cmdforge"
# All example tool configurations
TOOLS = {
# TEXT PROCESSING
"summarize": {
"category": "text",
"description": "Condense long documents to key points",
"arguments": [{"flag": "--length", "variable": "length", "default": "3-5 bullet points"}],
"steps": [{"type": "prompt", "prompt": "Summarize the following text into {length}. Be concise and capture the key points:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"translate": {
"category": "text",
"description": "Translate text to any language",
"arguments": [{"flag": "--lang", "variable": "lang", "default": "Spanish"}],
"steps": [{"type": "prompt", "prompt": "Translate the following text to {lang}. Only output the translation, nothing else:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"fix-grammar": {
"category": "text",
"description": "Fix grammar and spelling errors",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Fix all grammar, spelling, and punctuation errors in the following text. Only output the corrected text, no explanations:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"simplify": {
"category": "text",
"description": "Rewrite text for easier understanding",
"arguments": [{"flag": "--level", "variable": "level", "default": "5th grade reading level"}],
"steps": [{"type": "prompt", "prompt": "Rewrite the following text for a {level}. Keep the meaning but use simpler words and shorter sentences:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"tone-shift": {
"category": "text",
"description": "Change the tone of text",
"arguments": [{"flag": "--tone", "variable": "tone", "default": "professional"}],
"steps": [{"type": "prompt", "prompt": "Rewrite the following text in a {tone} tone. Keep the core message but adjust the style:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"eli5": {
"category": "text",
"description": "Explain like I'm 5",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Explain this like I'm 5 years old. Use simple words and fun analogies:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"tldr": {
"category": "text",
"description": "One-line summary",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Give a one-line TL;DR summary of this text:\n\n{input}", "provider": "opencode-grok", "output_var": "response"}],
"output": "{response}"
},
"expand": {
"category": "text",
"description": "Expand bullet points to paragraphs",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Expand these bullet points into well-written paragraphs:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
# DEVELOPER TOOLS
"explain-error": {
"category": "dev",
"description": "Explain error messages and stack traces",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Explain this error/stack trace in plain English. What went wrong and how to fix it:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"explain-code": {
"category": "dev",
"description": "Explain what code does",
"arguments": [{"flag": "--detail", "variable": "detail", "default": "moderate"}],
"steps": [{"type": "prompt", "prompt": "Explain what this code does at a {detail} level of detail:\n\n```\n{input}\n```", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"review-code": {
"category": "dev",
"description": "Quick code review with suggestions",
"arguments": [{"flag": "--focus", "variable": "focus", "default": "bugs, security, and improvements"}],
"steps": [{"type": "prompt", "prompt": "Review this code focusing on {focus}. Be concise and actionable:\n\n```\n{input}\n```", "provider": "claude-sonnet", "output_var": "response"}],
"output": "{response}"
},
"gen-tests": {
"category": "dev",
"description": "Generate unit tests for code",
"arguments": [{"flag": "--framework", "variable": "framework", "default": "pytest"}],
"steps": [{"type": "prompt", "prompt": "Generate comprehensive unit tests for this code using {framework}. Include edge cases:\n\n```\n{input}\n```", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"docstring": {
"category": "dev",
"description": "Add docstrings to functions/classes",
"arguments": [{"flag": "--style", "variable": "style", "default": "Google style"}],
"steps": [{"type": "prompt", "prompt": "Add {style} docstrings to all functions and classes in this code. Output the complete code with docstrings:\n\n```\n{input}\n```", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"commit-msg": {
"category": "dev",
"description": "Generate commit message from diff",
"arguments": [{"flag": "--style", "variable": "style", "default": "conventional commits"}],
"steps": [{"type": "prompt", "prompt": "Generate a concise {style} commit message for this diff. Just the message, no explanation:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
# DATA TOOLS
"json-extract": {
"category": "data",
"description": "Extract structured data as validated JSON",
"arguments": [{"flag": "--fields", "variable": "fields", "default": "any relevant fields"}],
"steps": [
{"type": "prompt", "prompt": "Extract {fields} from this text as a JSON object. Output ONLY valid JSON, no markdown, no explanation:\n\n{input}", "provider": "opencode-deepseek", "output_var": "raw_json"},
{"type": "code", "code": "import json\nimport re\ntext = raw_json.strip()\ntext = re.sub(r'^```json?\\s*', '', text)\ntext = re.sub(r'\\s*```$', '', text)\ntry:\n parsed = json.loads(text)\n validated = json.dumps(parsed, indent=2)\nexcept json.JSONDecodeError as e:\n validated = f\"ERROR: Invalid JSON - {e}\\nRaw output: {text[:500]}\"", "output_var": "validated"}
],
"output": "{validated}"
},
"json2csv": {
"category": "data",
"description": "Convert JSON to CSV format",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Convert this JSON to CSV format. Output only the CSV, no explanation:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
"extract-emails": {
"category": "data",
"description": "Extract email addresses from text",
"arguments": [],
"steps": [{"type": "prompt", "prompt": "Extract all email addresses from this text. Output one email per line, nothing else:\n\n{input}", "provider": "opencode-pickle", "output_var": "response"}],
"output": "{response}"
},
"extract-contacts": {
"category": "data",
"description": "Extract contact info as structured CSV",
"arguments": [],
"steps": [
{"type": "prompt", "prompt": "Extract all contact information (name, email, phone, company) from this text. Output as JSON array with objects having keys: name, email, phone, company. Use null for missing fields. Output ONLY the JSON array:\n\n{input}", "provider": "opencode-pickle", "output_var": "contacts_json"},
{"type": "code", "code": "import json\nimport re\ntext = contacts_json.strip()\ntext = re.sub(r'^```json?\\s*', '', text)\ntext = re.sub(r'\\s*```$', '', text)\ntry:\n contacts = json.loads(text)\n lines = ['name,email,phone,company']\n for c in contacts:\n row = [str(c.get('name') or ''), str(c.get('email') or ''), str(c.get('phone') or ''), str(c.get('company') or '')]\n lines.append(','.join(f'\"{f}\"' for f in row))\n csv_output = '\\n'.join(lines)\nexcept:\n csv_output = f\"Error parsing contacts: {text[:200]}\"", "output_var": "csv_output"}
],
"output": "{csv_output}"
},
"sql-from-text": {
"category": "data",
"description": "Generate SQL from natural language",
"arguments": [{"flag": "--dialect", "variable": "dialect", "default": "PostgreSQL"}],
"steps": [{"type": "prompt", "prompt": "Generate a {dialect} SQL query for this request. Output only the SQL, no explanation:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"safe-sql": {
"category": "data",
"description": "Generate SQL with safety checks",
"arguments": [{"flag": "--dialect", "variable": "dialect", "default": "PostgreSQL"}],
"steps": [
{"type": "prompt", "prompt": "Generate a {dialect} SELECT query for: {input}\n\nOutput ONLY the SQL query, no explanation.", "provider": "claude-haiku", "output_var": "raw_sql"},
{"type": "code", "code": "import re\nsql = raw_sql.strip()\nsql = re.sub(r'^```sql?\\s*', '', sql)\nsql = re.sub(r'\\s*```$', '', sql)\ndangerous = ['DROP', 'DELETE', 'TRUNCATE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT']\nwarnings = []\nfor keyword in dangerous:\n if re.search(r'\\b' + keyword + r'\\b', sql, re.I):\n warnings.append(f\"WARNING: Contains {keyword}\")\nif warnings:\n validated = '\\n'.join(warnings) + '\\n\\n' + sql\nelse:\n validated = sql", "output_var": "validated"}
],
"output": "{validated}"
},
"parse-log": {
"category": "data",
"description": "Summarize and analyze log files",
"arguments": [{"flag": "--focus", "variable": "focus", "default": "errors and warnings"}],
"steps": [{"type": "prompt", "prompt": "Analyze these logs focusing on {focus}. Summarize key issues and patterns:\n\n{input}", "provider": "claude-haiku", "output_var": "response"}],
"output": "{response}"
},
"csv-insights": {
"category": "data",
"description": "Analyze CSV with sampled data for large files",
"arguments": [{"flag": "--question", "variable": "question", "default": "What patterns and insights can you find?"}],
"steps": [
{"type": "code", "code": "import random\nlines = input.strip().split('\\n')\nheader = lines[0] if lines else ''\ndata_lines = lines[1:] if len(lines) > 1 else []\nif len(data_lines) > 50:\n sampled = random.sample(data_lines, 50)\n sample_note = f\"(Sampled 50 of {len(data_lines)} rows)\"\nelse:\n sampled = data_lines\n sample_note = f\"({len(data_lines)} rows)\"\nsampled_csv = header + '\\n' + '\\n'.join(sampled)\nstats = f\"Columns: {len(header.split(','))}, Rows: {len(data_lines)} {sample_note}\"", "output_var": "sampled_csv,stats"},
{"type": "prompt", "prompt": "Analyze this CSV data. {stats}\n\nQuestion: {question}\n\nData:\n{sampled_csv}", "provider": "claude-haiku", "output_var": "response"}
],
"output": "{response}"
},
# ADVANCED TOOLS
"log-errors": {
"category": "advanced",
"description": "Extract and explain errors from large log files",
"arguments": [],
"steps": [
{"type": "code", "code": "import re\nlines = input.split('\\n')\nerrors = [l for l in lines if re.search(r'\\b(ERROR|CRITICAL|FATAL|Exception|Traceback)\\b', l, re.I)]\nresult = []\nfor i, line in enumerate(lines):\n if re.search(r'\\b(ERROR|CRITICAL|FATAL|Exception|Traceback)\\b', line, re.I):\n result.extend(lines[i:i+5])\nextracted = '\\n'.join(result[:200])", "output_var": "extracted"},
{"type": "prompt", "prompt": "Analyze these error log entries. Group by error type, explain likely causes, and suggest fixes:\n\n{extracted}", "provider": "claude-haiku", "output_var": "response"}
],
"output": "{response}"
},
"diff-focus": {
"category": "advanced",
"description": "Review only the added/changed code in a diff",
"arguments": [],
"steps": [
{"type": "code", "code": "lines = input.split('\\n')\nresult = []\nfor i, line in enumerate(lines):\n if line.startswith('@@') or line.startswith('+++') or line.startswith('---'):\n result.append(line)\n elif line.startswith('+') and not line.startswith('+++'):\n result.append(line)\nextracted = '\\n'.join(result)", "output_var": "extracted"},
{"type": "prompt", "prompt": "Review these added lines of code. Focus on bugs, security issues, and improvements:\n\n{extracted}", "provider": "claude-haiku", "output_var": "response"}
],
"output": "{response}"
},
"changelog": {
"category": "advanced",
"description": "Generate changelog from git commits",
"arguments": [{"flag": "--since", "variable": "since", "default": "last week"}],
"steps": [
{"type": "code", "code": "lines = input.strip().split('\\n')\ncommits = []\nfor line in lines:\n if line.strip() and not line.startswith('commit '):\n commits.append(line.strip())\ngit_log = '\\n'.join(commits[:100])", "output_var": "git_log"},
{"type": "prompt", "prompt": "Generate a user-friendly changelog from these git commits. Group by category (Features, Fixes, Improvements, etc). Use markdown with bullet points:\n\n{git_log}", "provider": "opencode-pickle", "output_var": "changelog_raw"},
{"type": "code", "code": "from datetime import datetime\nheader = f\"\"\"# Changelog\\n\\nGenerated: {datetime.now().strftime('%Y-%m-%d')}\\n\\n\"\"\"\nformatted = header + changelog_raw", "output_var": "formatted"}
],
"output": "{formatted}"
},
"code-validate": {
"category": "advanced",
"description": "Generate and validate Python code",
"arguments": [{"flag": "--task", "variable": "task", "default": ""}],
"steps": [
{"type": "prompt", "prompt": "Write Python code to: {task}\n\nContext/input data:\n{input}\n\nOutput ONLY the Python code, no markdown, no explanation.", "provider": "claude-haiku", "output_var": "raw_code"},
{"type": "code", "code": "import ast\nimport re\ncode = raw_code.strip()\ncode = re.sub(r'^```python?\\s*', '', code)\ncode = re.sub(r'\\s*```$', '', code)\ntry:\n ast.parse(code)\n validated = code\nexcept SyntaxError as e:\n validated = f\"# SYNTAX ERROR at line {e.lineno}: {e.msg}\\n# Fix needed:\\n\\n{code}\"", "output_var": "validated"}
],
"output": "{validated}"
},
"reply-email": {
"category": "text",
"description": "Draft email replies",
"arguments": [{"flag": "--tone", "variable": "tone", "default": "professional and friendly"}],
"steps": [{"type": "prompt", "prompt": "Draft a {tone} reply to this email:\n\n{input}", "provider": "opencode-deepseek", "output_var": "response"}],
"output": "{response}"
},
}
CATEGORIES = {
"text": "Text Processing",
"dev": "Developer Tools",
"data": "Data Tools",
"advanced": "Advanced Multi-Step Tools",
}
def install_tool(name: str, config: dict, force: bool = False) -> bool:
"""Install a single tool."""
tool_dir = TOOLS_DIR / name
config_file = tool_dir / "config.yaml"
if config_file.exists() and not force:
print(f" {name}: already exists (use --force to overwrite)")
return False
tool_dir.mkdir(parents=True, exist_ok=True)
# Build config without category
tool_config = {
"name": name,
"description": config["description"],
"arguments": config.get("arguments", []),
"steps": config["steps"],
"output": config["output"]
}
with open(config_file, 'w') as f:
yaml.dump(tool_config, f, default_flow_style=False, sort_keys=False)
print(f" {name}: installed")
return True
def list_tools():
"""List all available tools."""
print("Available example tools:\n")
for cat_id, cat_name in CATEGORIES.items():
tools_in_cat = [(n, c) for n, c in TOOLS.items() if c.get("category") == cat_id]
if tools_in_cat:
print(f"{cat_name}:")
for name, config in tools_in_cat:
print(f" {name:<20} {config['description']}")
print()
def main():
parser = argparse.ArgumentParser(description="Install CmdForge example tools")
parser.add_argument("tools", nargs="*", help="Specific tools to install (default: all)")
parser.add_argument("--list", action="store_true", help="List available tools")
parser.add_argument("--category", "-c", choices=list(CATEGORIES.keys()), help="Install tools from category")
parser.add_argument("--force", "-f", action="store_true", help="Overwrite existing tools")
args = parser.parse_args()
if args.list:
list_tools()
return
# Determine which tools to install
if args.tools:
to_install = {n: c for n, c in TOOLS.items() if n in args.tools}
unknown = set(args.tools) - set(TOOLS.keys())
if unknown:
print(f"Unknown tools: {', '.join(unknown)}")
print("Use --list to see available tools")
return
elif args.category:
to_install = {n: c for n, c in TOOLS.items() if c.get("category") == args.category}
else:
to_install = TOOLS
if not to_install:
print("No tools to install")
return
print(f"Installing {len(to_install)} tools to {TOOLS_DIR}...\n")
installed = 0
for name, config in to_install.items():
if install_tool(name, config, args.force):
installed += 1
print(f"\nInstalled {installed} tools.")
print("\nRun 'cmdforge refresh' to create executable wrappers.")
if __name__ == "__main__":
main()
-55
View File
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
"""Gunicorn configuration for CmdForge web service."""
import os
# Server socket
bind = "0.0.0.0:" + os.environ.get("PORT", "5050")
backlog = 2048
# Worker processes
workers = 2 # Conservative for shared server
worker_class = "sync"
worker_connections = 1000
timeout = 120
keepalive = 2
# Process naming
proc_name = "cmdforge-web"
# Logging
accesslog = "-" # stdout
errorlog = "-" # stderr
loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
# Server mechanics
daemon = False
pidfile = None
umask = 0
user = None
group = None
tmp_upload_dir = None
# SSL (handled by Cloudflare, not needed here)
keyfile = None
certfile = None
File diff suppressed because one or more lines are too long
View File
-55
View File
File diff suppressed because one or more lines are too long
Executable
+413
View File
@@ -0,0 +1,413 @@
#!/bin/bash
#
# CmdForge Installer
#
# Usage:
# ./install.sh # Interactive installation
# ./install.sh --yes # Accept all defaults (non-interactive)
# ./install.sh --no-venv # Install to system Python (not recommended)
# ./install.sh --no-examples # Skip example tools
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$HOME/.cmdforge-venv"
LOCAL_BIN="$HOME/.local/bin"
MIN_PYTHON_VERSION="3.10"
# Parse arguments
USE_VENV=true
INSTALL_EXAMPLES=true
AUTO_YES=false
while [[ $# -gt 0 ]]; do
case $1 in
--yes|-y)
AUTO_YES=true
shift
;;
--no-venv)
USE_VENV=false
shift
;;
--no-examples)
INSTALL_EXAMPLES=false
shift
;;
--help|-h)
echo "CmdForge Installer"
echo ""
echo "Usage: ./install.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --yes, -y Accept all defaults (non-interactive)"
echo " --no-venv Install to system Python (not recommended)"
echo " --no-examples Skip installing example tools"
echo " --help, -h Show this help message"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Run './install.sh --help' for usage"
exit 1
;;
esac
done
# Helper functions
print_header() {
echo ""
echo -e "${CYAN}${BOLD}$1${NC}"
echo -e "${CYAN}$(printf '=%.0s' {1..60})${NC}"
}
print_step() {
echo -e "${BLUE}[*]${NC} $1"
}
print_success() {
echo -e "${GREEN}[+]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[!]${NC} $1"
}
print_error() {
echo -e "${RED}[X]${NC} $1"
}
ask_yes_no() {
if $AUTO_YES; then
return 0
fi
local prompt="$1"
local default="${2:-y}"
if [[ "$default" == "y" ]]; then
prompt="$prompt [Y/n] "
else
prompt="$prompt [y/N] "
fi
read -r -p "$prompt" response
response=${response:-$default}
[[ "$response" =~ ^[Yy]$ ]]
}
version_gte() {
# Check if version $1 >= version $2
printf '%s\n%s\n' "$2" "$1" | sort -V -C
}
# Start installation
clear 2>/dev/null || true
echo ""
echo -e "${CYAN}${BOLD}"
echo " ____ _ _____ "
echo " / ___|_ __ ___ __| | ___|__ _ __ __ _ ___ "
echo " | | | '_ \` _ \\ / _\` | |_ / _ \\| '__/ _\` |/ _ \\"
echo " | |___| | | | | | (_| | _| (_) | | | (_| | __/"
echo " \\____|_| |_| |_|\\__,_|_| \\___/|_| \\__, |\\___|"
echo " |___/ "
echo -e "${NC}"
echo " AI-powered CLI command builder"
echo ""
# ==============================================================================
# Step 1: Check prerequisites
# ==============================================================================
print_header "Step 1: Checking Prerequisites"
# Check Python
print_step "Checking Python version..."
PYTHON_CMD=""
for cmd in python3 python; do
if command -v "$cmd" &> /dev/null; then
version=$("$cmd" --version 2>&1 | awk '{print $2}')
if version_gte "$version" "$MIN_PYTHON_VERSION"; then
PYTHON_CMD="$cmd"
break
fi
fi
done
if [[ -z "$PYTHON_CMD" ]]; then
print_error "Python $MIN_PYTHON_VERSION or higher is required"
echo ""
echo "Please install Python first:"
echo " Ubuntu/Debian: sudo apt install python3 python3-pip python3-venv"
echo " macOS: brew install python3"
echo " Windows: https://www.python.org/downloads/"
exit 1
fi
PYTHON_VERSION=$("$PYTHON_CMD" --version 2>&1 | awk '{print $2}')
print_success "Python $PYTHON_VERSION found ($PYTHON_CMD)"
# Check pip
print_step "Checking pip..."
if ! "$PYTHON_CMD" -m pip --version &> /dev/null; then
print_error "pip is not installed"
echo ""
echo "Please install pip:"
echo " Ubuntu/Debian: sudo apt install python3-pip"
echo " macOS: python3 -m ensurepip"
exit 1
fi
print_success "pip is available"
# Check venv module (if using venv)
if $USE_VENV; then
print_step "Checking venv module..."
if ! "$PYTHON_CMD" -m venv --help &> /dev/null; then
print_warning "venv module not available"
echo ""
if ask_yes_no "Install python3-venv? (recommended)" "y"; then
if command -v apt-get &> /dev/null; then
sudo apt-get update && sudo apt-get install -y python3-venv
else
print_error "Cannot auto-install venv. Please install manually."
echo " Ubuntu/Debian: sudo apt install python3-venv"
exit 1
fi
else
print_warning "Continuing without venv (installing to system Python)"
USE_VENV=false
fi
else
print_success "venv module is available"
fi
fi
# Check we're in the CmdForge directory
print_step "Checking CmdForge source..."
if [[ ! -f "$SCRIPT_DIR/pyproject.toml" ]]; then
print_error "Cannot find CmdForge source files"
echo "Please run this script from the CmdForge directory"
exit 1
fi
print_success "CmdForge source found at $SCRIPT_DIR"
# ==============================================================================
# Step 2: Installation options
# ==============================================================================
print_header "Step 2: Installation Options"
if ! $AUTO_YES; then
echo ""
echo "Installation options:"
echo ""
if $USE_VENV; then
echo -e " ${GREEN}1.${NC} Use virtual environment at $VENV_DIR ${GREEN}(recommended)${NC}"
else
echo -e " ${YELLOW}1.${NC} Install to system Python ${YELLOW}(--no-venv specified)${NC}"
fi
if $INSTALL_EXAMPLES; then
echo -e " ${GREEN}2.${NC} Install 28 example tools"
else
echo -e " ${YELLOW}2.${NC} Skip example tools ${YELLOW}(--no-examples specified)${NC}"
fi
echo ""
if ! ask_yes_no "Proceed with installation?" "y"; then
echo "Installation cancelled."
exit 0
fi
fi
# ==============================================================================
# Step 3: Create virtual environment (if using)
# ==============================================================================
if $USE_VENV; then
print_header "Step 3: Setting Up Virtual Environment"
if [[ -d "$VENV_DIR" ]]; then
print_warning "Virtual environment already exists at $VENV_DIR"
if ask_yes_no "Remove and recreate?" "n"; then
rm -rf "$VENV_DIR"
fi
fi
if [[ ! -d "$VENV_DIR" ]]; then
print_step "Creating virtual environment..."
"$PYTHON_CMD" -m venv "$VENV_DIR"
print_success "Virtual environment created at $VENV_DIR"
fi
print_step "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
print_success "Virtual environment activated"
# Upgrade pip in venv
print_step "Upgrading pip..."
pip install --upgrade pip --quiet
print_success "pip upgraded"
else
print_header "Step 3: Using System Python"
print_warning "Installing to system Python (use --no-venv was specified)"
fi
# ==============================================================================
# Step 4: Install CmdForge
# ==============================================================================
print_header "Step 4: Installing CmdForge"
print_step "Installing CmdForge and dependencies..."
cd "$SCRIPT_DIR"
# Install with all optional dependencies
pip install -e ".[all]" --quiet
print_success "CmdForge installed successfully"
# Verify installation
print_step "Verifying installation..."
if command -v cmdforge &> /dev/null; then
VERSION=$(cmdforge --version 2>&1 | head -1)
print_success "cmdforge command available: $VERSION"
else
print_error "cmdforge command not found in PATH"
exit 1
fi
# ==============================================================================
# Step 5: Set up PATH
# ==============================================================================
print_header "Step 5: Setting Up PATH"
# Create local bin directory
mkdir -p "$LOCAL_BIN"
# Determine shell config file
SHELL_CONFIG=""
if [[ -f "$HOME/.bashrc" ]]; then
SHELL_CONFIG="$HOME/.bashrc"
elif [[ -f "$HOME/.zshrc" ]]; then
SHELL_CONFIG="$HOME/.zshrc"
elif [[ -f "$HOME/.bash_profile" ]]; then
SHELL_CONFIG="$HOME/.bash_profile"
fi
# Check if PATH already includes local bin
PATH_LINE='export PATH="$HOME/.local/bin:$PATH"'
PATH_CONFIGURED=false
if [[ -n "$SHELL_CONFIG" ]]; then
if grep -q '.local/bin' "$SHELL_CONFIG" 2>/dev/null; then
print_success "PATH already configured in $SHELL_CONFIG"
PATH_CONFIGURED=true
fi
fi
# Add venv activation if using venv
if $USE_VENV; then
VENV_LINE="source $VENV_DIR/bin/activate"
if [[ -n "$SHELL_CONFIG" ]] && ! grep -q "cmdforge-venv" "$SHELL_CONFIG" 2>/dev/null; then
print_step "Adding virtual environment activation to $SHELL_CONFIG..."
echo "" >> "$SHELL_CONFIG"
echo "# CmdForge virtual environment" >> "$SHELL_CONFIG"
echo "$VENV_LINE" >> "$SHELL_CONFIG"
print_success "Added venv activation to $SHELL_CONFIG"
fi
fi
# Add PATH if not already there
if ! $PATH_CONFIGURED && [[ -n "$SHELL_CONFIG" ]]; then
print_step "Adding ~/.local/bin to PATH in $SHELL_CONFIG..."
echo "" >> "$SHELL_CONFIG"
echo "# CmdForge tool wrappers" >> "$SHELL_CONFIG"
echo "$PATH_LINE" >> "$SHELL_CONFIG"
print_success "Added PATH configuration to $SHELL_CONFIG"
fi
# Export for current session
export PATH="$LOCAL_BIN:$PATH"
# ==============================================================================
# Step 6: Create wrapper scripts
# ==============================================================================
print_header "Step 6: Creating Tool Wrappers"
print_step "Running cmdforge refresh..."
cmdforge refresh
print_success "Wrapper scripts created in $LOCAL_BIN"
# ==============================================================================
# Step 7: Install example tools (optional)
# ==============================================================================
if $INSTALL_EXAMPLES; then
print_header "Step 7: Installing Example Tools"
print_step "Installing 28 example tools..."
"$PYTHON_CMD" "$SCRIPT_DIR/examples/install.py"
print_step "Refreshing wrappers for new tools..."
cmdforge refresh
print_success "Example tools installed"
else
print_header "Step 7: Skipping Example Tools"
print_warning "Example tools not installed (use 'python examples/install.py' later)"
fi
# ==============================================================================
# Complete!
# ==============================================================================
print_header "Installation Complete!"
echo ""
echo -e "${GREEN}${BOLD}CmdForge has been installed successfully!${NC}"
echo ""
# Show installed tools count
TOOL_COUNT=$(cmdforge list 2>/dev/null | grep -c "^" || echo "0")
echo -e " ${CYAN}Tools installed:${NC} $TOOL_COUNT"
echo -e " ${CYAN}Config location:${NC} ~/.cmdforge/"
echo -e " ${CYAN}Tool wrappers:${NC} ~/.local/bin/"
if $USE_VENV; then
echo -e " ${CYAN}Virtual env:${NC} $VENV_DIR"
fi
echo ""
echo -e "${BOLD}Next Steps:${NC}"
echo ""
echo " 1. Reload your shell (or run: source $SHELL_CONFIG)"
echo ""
echo " 2. Set up an AI provider:"
echo -e " ${CYAN}cmdforge providers install${NC}"
echo ""
echo " 3. Launch the UI:"
echo -e " ${CYAN}cmdforge ui${NC}"
echo ""
echo " 4. Or try a tool directly:"
echo -e " ${CYAN}echo 'Hello world' | eli5${NC}"
echo ""
if $USE_VENV; then
echo -e "${YELLOW}Note:${NC} The virtual environment will be activated automatically"
echo " in new shell sessions. For this session, run:"
echo -e " ${CYAN}source $VENV_DIR/bin/activate${NC}"
echo ""
fi
echo -e "${CYAN}Documentation:${NC} https://pages.brrd.tech/rob/cmdforge/"
echo -e "${CYAN}Source code:${NC} https://gitea.brrd.tech/rob/CmdForge"
echo ""
File diff suppressed because one or more lines are too long
+116
View File
@@ -0,0 +1,116 @@
# CmdForge Collections
Collections are curated groups of tools that can be installed together with a single command.
## Use Cases
1. **Thematic bundles**: "writing-toolkit" with grammar, tone, simplify tools
2. **Application stacks**: "data-science" with json-extract, csv-insights, etc.
3. **Source bundles**: "fabric-text" with all Fabric text processing patterns
4. **Workflow packages**: Tools that work well together for a specific task
## Collection Manifest Format
Collections are defined in the registry repo under `collections/`:
```yaml
# collections/writing-toolkit.yaml
name: writing-toolkit
display_name: Writing Toolkit
description: Essential tools for writers and editors
icon: pencil # Optional icon identifier
maintainer: official # Publisher who maintains this collection
tools:
- official/fix-grammar
- official/simplify
- official/tone-shift
- official/expand
- official/proofread
# Optional: version constraints
pinned:
official/fix-grammar: "^1.0.0"
# Optional: suggested order for documentation
order:
- official/fix-grammar
- official/proofread
- official/simplify
- official/tone-shift
- official/expand
tags:
- writing
- editing
- grammar
```
## CLI Usage
```bash
# List available collections
cmdforge collections list
# View collection details
cmdforge collections info writing-toolkit
# Install all tools in a collection
cmdforge collections install writing-toolkit
# Install with version constraints from collection
cmdforge collections install writing-toolkit --pinned
```
## API Endpoints
```
GET /api/v1/collections # List all collections
GET /api/v1/collections/:name # Get collection details with tool info
GET /api/v1/collections/:name/tools # Get just the tools list
```
## Database Schema
```sql
CREATE TABLE collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
icon TEXT,
maintainer TEXT NOT NULL,
tools TEXT NOT NULL, -- JSON array of tool refs
pinned TEXT, -- JSON object of version constraints
tags TEXT, -- JSON array
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_collections_name ON collections(name);
CREATE INDEX idx_collections_maintainer ON collections(maintainer);
```
## Web UI
- `/collections` - Browse all collections
- `/collections/:name` - Collection detail page with tool grid
- Install button that shows CLI command
- Filter by tag, maintainer
## Sync from Registry Repo
Collections sync from the registry repo just like tools:
```
CmdForge-Registry/
├── tools/
│ └── ...
└── collections/
├── writing-toolkit.yaml
├── data-science.yaml
├── fabric-text.yaml
└── fabric-code.yaml
```
The webhook sync process reads `collections/*.yaml` and upserts to database.
+319
View File
@@ -0,0 +1,319 @@
# CmdForge Deployment Guide
This guide covers deploying the CmdForge Registry web application.
## Quick Start (Development)
```bash
# Clone and install
git clone https://gitea.brrd.tech/rob/CmdForge.git
cd CmdForge
pip install -e ".[dev]"
# Initialize database
cd src/cmdforge/web
python -c "from app import create_app; create_app()"
# Run development server
flask run --port 5050 --host 0.0.0.0
```
Access at `http://localhost:5050`
## Production Deployment
### Prerequisites
- Python 3.8+
- pip/pipx
- nginx (recommended for reverse proxy)
- systemd (for process management)
### 1. Install Application
```bash
# Create application directory
sudo mkdir -p /opt/cmdforge
sudo chown $USER:$USER /opt/cmdforge
cd /opt/cmdforge
# Clone repository
git clone https://gitea.brrd.tech/rob/CmdForge.git .
# Create virtual environment
python3 -m venv venv
source venv/bin/activate
# Install dependencies
pip install -e ".[prod]"
pip install gunicorn
```
### 2. Configure Environment
Create `/opt/cmdforge/.env`:
```bash
# Required
FLASK_ENV=production
SECRET_KEY=your-secret-key-here # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
# Optional
SENTRY_DSN=https://[email protected]/xxx # Error monitoring
SITE_URL=https://cmdforge.brrd.tech # For sitemap/SEO
```
### 3. Initialize Database
```bash
cd /opt/cmdforge/src/cmdforge/web
python -c "from app import create_app; create_app()"
```
Database will be created at `data/cmdforge.db`.
### 4. systemd Service
You can run as either a **system service** (requires root) or a **user service** (no root needed).
#### Option A: User Service (Recommended for personal servers)
Create `~/.config/systemd/user/cmdforge-web.service`:
```ini
[Unit]
Description=CmdForge Registry Web Application
After=network.target
[Service]
Type=simple
WorkingDirectory=/path/to/cmdforge
Environment="PATH=/path/to/cmdforge/venv/bin"
Environment="CMDFORGE_REGISTRY_DB=/tmp/cmdforge-data/registry.db"
ExecStartPre=/path/to/cmdforge/scripts/db-restore.sh
ExecStart=/path/to/cmdforge/venv/bin/gunicorn \
-c gunicorn.conf.py \
cmdforge.web.app:create_web_app()
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
```
Enable and start:
```bash
# Enable lingering (so service runs without login)
loginctl enable-linger $USER
# Reload and enable
systemctl --user daemon-reload
systemctl --user enable cmdforge-web
systemctl --user start cmdforge-web
# Check status
systemctl --user status cmdforge-web
# View logs
journalctl --user -u cmdforge-web -f
```
**Note:** The current OMV deployment uses this approach.
#### Option B: System Service (Traditional deployment)
Create `/etc/systemd/system/cmdforge-web.service`:
```ini
[Unit]
Description=CmdForge Registry Web Application
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/cmdforge
Environment="PATH=/opt/cmdforge/venv/bin"
EnvironmentFile=/opt/cmdforge/.env
ExecStart=/opt/cmdforge/venv/bin/gunicorn \
--workers 4 \
--bind 127.0.0.1:5050 \
--access-logfile /var/log/cmdforge/access.log \
--error-logfile /var/log/cmdforge/error.log \
'cmdforge.web.app:create_app()'
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
# Create log directory
sudo mkdir -p /var/log/cmdforge
sudo chown www-data:www-data /var/log/cmdforge
# Enable service
sudo systemctl daemon-reload
sudo systemctl enable cmdforge-web
sudo systemctl start cmdforge-web
# Check status
sudo systemctl status cmdforge-web
```
### 5. nginx Configuration
Create `/etc/nginx/sites-available/cmdforge`:
```nginx
server {
listen 80;
server_name cmdforge.brrd.tech;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name cmdforge.brrd.tech;
# SSL certificates (use certbot for Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/cmdforge.brrd.tech/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cmdforge.brrd.tech/privkey.pem;
# SSL settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
# Static files (optional - Flask can serve these)
location /static/ {
alias /opt/cmdforge/src/cmdforge/web/static/;
expires 1d;
add_header Cache-Control "public, immutable";
}
# Proxy to gunicorn
location / {
proxy_pass http://127.0.0.1:5050;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable site:
```bash
sudo ln -s /etc/nginx/sites-available/cmdforge /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 6. SSL with Let's Encrypt
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d cmdforge.brrd.tech
```
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `FLASK_ENV` | Yes | `production` or `development` |
| `SECRET_KEY` | Yes | Secret key for sessions (32+ chars) |
| `SENTRY_DSN` | No | Sentry error monitoring DSN |
| `SITE_URL` | No | Public URL for sitemap generation |
| `DATABASE_PATH` | No | Override database path (default: `data/cmdforge.db`) |
## Database Backup
```bash
# Backup
cp /opt/cmdforge/data/cmdforge.db /backup/cmdforge-$(date +%Y%m%d).db
# Or use SQLite backup command for consistency
sqlite3 /opt/cmdforge/data/cmdforge.db ".backup '/backup/cmdforge.db'"
```
## Monitoring
### Logs
```bash
# For user service (OMV deployment)
journalctl --user -u cmdforge-web -f
# For system service
tail -f /var/log/cmdforge/error.log
tail -f /var/log/cmdforge/access.log
journalctl -u cmdforge-web -f
```
### Sentry Integration
The application includes Sentry integration for error monitoring. Set `SENTRY_DSN` environment variable to enable.
### Health Check
```bash
curl http://localhost:5050/api/v1/tools
```
## Troubleshooting
### Service won't start
```bash
# For user service
journalctl --user -u cmdforge-web -n 50
systemctl --user status cmdforge-web
# For system service
journalctl -u cmdforge-web -n 50
sudo systemctl status cmdforge-web
# Verify Python path
/path/to/cmdforge/venv/bin/python -c "import cmdforge"
# Test gunicorn manually
cd /path/to/cmdforge
source venv/bin/activate
gunicorn -c gunicorn.conf.py 'cmdforge.web.app:create_web_app()'
```
### Database errors
```bash
# Check database exists and is readable
ls -la /opt/cmdforge/data/cmdforge.db
# Verify permissions
chown www-data:www-data /opt/cmdforge/data/
chown www-data:www-data /opt/cmdforge/data/cmdforge.db
```
### Static files not loading
```bash
# Rebuild Tailwind CSS
cd /opt/cmdforge
npm install
npm run css:build
# Or use development CSS
# (static/css/output.css should exist)
```
## Architecture Diagram
See `docs/diagrams/deployment-architecture.svg` for visual representation.
+425
View File
@@ -0,0 +1,425 @@
# CmdForge - Design Document
> A lightweight personal tool builder for AI-powered CLI commands
## 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.
**Example:**
```bash
# Create a summarizer tool, then use it:
sum -i text.txt -o summary.txt --max 512
```
## Core Concepts
### Tool = Directory + Config
```
~/.cmdforge/
sum/
config.yaml
processed.py # Optional external code file
reviewer/
config.yaml
translator/
config.yaml
```
### config.yaml Format
```yaml
name: sum
description: "Summarize documents"
arguments:
- flag: --max
variable: max
default: "500"
description: "Maximum words in summary"
steps:
- type: prompt
prompt: |
Summarize the following text in {max} words or less:
{input}
provider: claude
output_var: response
output: "{response}"
# Optional: declare dependencies for meta-tools
dependencies:
- official/summarize
- official/translate@^1.0.0 # With version constraint
```
### Step Types
**Prompt Step** - Calls an AI provider:
```yaml
- type: prompt
prompt: "Your prompt template with {variables}"
provider: claude
output_var: response
```
**Code Step** - Runs Python code:
```yaml
- type: code
code: |
processed = input.upper()
count = len(processed.split())
output_var: processed, count
code_file: processed.py # Optional: external file storage
```
**Tool Step** - Calls another CmdForge tool (meta-tools):
```yaml
- type: tool
tool: official/summarize # Tool reference (owner/name or just name for local)
input: "{input}" # Input to pass (supports variable substitution)
args: # Optional arguments
max_words: "100"
output_var: summary # Variable to store the tool's output
provider: claude # Optional: override the called tool's provider
```
Steps execute in order. Each step's `output_var` becomes available to subsequent steps.
### Variables
- `{input}` - Always available, contains stdin or input file content (empty string if no input)
- `{variable_name}` - From arguments (e.g., `{max}`)
- `{output_var}` - From previous steps (e.g., `{response}`, `{processed}`)
### Output Variables
The `output_var` field specifies which Python variable(s) to capture from your code:
**Single variable:**
```yaml
output_var: processed
```
```python
processed = input.upper() # This gets captured
```
**Multiple variables (comma-separated):**
```yaml
output_var: processed, count, summary
```
```python
processed = input.upper()
count = len(processed.split())
summary = f"Processed {count} words"
# All three are captured and available as {processed}, {count}, {summary}
```
## CLI Interface
### Running Tools
```bash
# Basic usage (wrapper script in ~/.local/bin)
sum -i document.txt -o summary.txt
# With custom args
sum -i document.txt --max 200
# Preview prompt without calling AI
sum -i document.txt --dry-run
# See what prompt gets sent
sum -i document.txt --show-prompt
# Test with mock (no API call)
sum -i document.txt --provider mock
# Read from stdin, write to stdout
cat doc.txt | sum | less
# Interactive stdin input
sum --stdin
# No input (empty string) - useful for tools using only arguments
sum --max 100
# Or via cmdforge run
cmdforge run sum -i document.txt
```
### Input Handling
| Scenario | Behavior |
|----------|----------|
| Piped stdin | Automatically read (`cat file.txt \| mytool`) |
| `-i file.txt` | Read from file |
| `--stdin` | Interactive input (type then Ctrl+D) |
| No input | Empty string (useful for argument-only tools) |
### Universal Flags (all tools)
| Flag | Short | Description |
|------|-------|-------------|
| `--input` | `-i` | Input file |
| `--output` | `-o` | Output file (or stdout if omitted) |
| `--stdin` | | Read input interactively (type then Ctrl+D) |
| `--dry-run` | | Show prompt, don't call AI |
| `--show-prompt` | | Call AI but also print prompt to stderr |
| `--provider` | `-p` | Override provider (e.g., `--provider mock`) |
| `--verbose` | `-v` | Show debug info |
| `--help` | `-h` | Show help |
### Managing Tools
```bash
# Launch the UI to manage tools
cmdforge
# Or use CLI directly:
cmdforge list # List all tools
cmdforge create sum # Create new tool (basic)
cmdforge edit sum # Edit tool config in $EDITOR
cmdforge delete sum # Delete tool
cmdforge test sum # Test with mock provider
cmdforge run sum # Run tool for real
cmdforge refresh # Refresh all wrapper scripts
cmdforge check sum # Check dependencies for meta-tools
cmdforge ui # Launch interactive UI
```
## Terminal UI
A BIOS-style terminal UI using `urwid` with full mouse support.
### Main Menu
```
┌──────────────────────────────────────┐
│ CmdForge Manager │
├──────────────────────────────────────┤
│ Tools: │
│ ┌────────────────────────────────┐ │
│ │ sum │ │
│ │ reviewer │ │
│ │ translator │ │
│ └────────────────────────────────┘ │
│ │
│ [Create] [Edit] [Delete] [Test] │
│ │
│ Tool Info: │
│ Name: sum │
│ Description: Summarize documents │
│ Arguments: --max │
│ Steps: PROMPT[claude] -> {response} │
│ │
│ [EXIT] │
└──────────────────────────────────────┘
```
### Tool Builder
```
┌──────────────────────────────────────────────────────────────┐
│ New Tool │
├──────────────────────────────────────────────────────────────┤
│ ┌─[ Tool Info ]──────────┐ ┌─ Arguments ─────────────────┐ │
│ │ Name: [sum__________] │ │ --max -> {max} │ │
│ │ │ │ │ │
│ │ Desc: [Summarize____] │ └─────────────────────────────┘ │
│ │ │ [Add] [Edit] [Delete] │
│ │ Output: [{response}_] │ │
│ └────────────────────────┘ ┌─ Execution Steps ───────────┐ │
│ │ P:claude -> {response} │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ [Add] [Edit] [Delete] │
│ │
│ [Save] [Cancel] │
└──────────────────────────────────────────────────────────────┘
```
**Navigation:**
- **Tab** - Cycle between sections (Tool Info -> Arguments -> Steps -> Buttons)
- **Arrow keys** - Navigate within lists
- **Enter/Click** - Select/activate
- **Escape** - Cancel/back
- **Shift + mouse** - Terminal-native text selection (bypasses UI mouse handling)
- **Ctrl+Shift+V** - Paste
The current section's title is highlighted with brackets: `[ Tool Info ]`
**Copy/Paste tip:** Hold `Shift` while dragging with mouse to select text, then use your terminal's copy function (usually right-click or Ctrl+Shift+C).
### Code Step Dialog
```
┌──────────────── Add Code Step ─────────────────┐
│ Variables: input, max, response │
│ │
│ File: [processed.py______________] < Load > │
│ │
│ ┌─ Code ─────────────────────────────────────┐ │
│ │processed = input.upper() │ │
│ │count = len(processed.split()) │ │
│ │ │ │
│ └────────────────────────────────────────────┘ │
│ │
│ Output var: [processed, count____] │
├────────────────────────────────────────────────┤
│ < OK > < Cancel > │
└────────────────────────────────────────────────┘
```
**Code Step Features:**
- **Multiline editor** - Write multi-line Python code
- **External file storage** - Code is auto-saved to `~/.cmdforge/<toolname>/<filename>` on OK
- **Load button** - Load code from external file (with confirmation)
- **Multiple output vars** - Capture multiple variables (comma-separated)
## Implementation
### Directory Structure
```
cmdforge/
__init__.py
cli.py # Entry point, argument parsing
ui.py # UI dispatcher (chooses urwid/snack/dialog)
ui_urwid.py # Urwid-based BIOS-style UI
ui_snack.py # Snack/newt fallback UI
tool.py # Tool loading/saving/wrapper scripts
runner.py # Execute tools
providers.py # Provider abstraction
```
### Provider System
Providers are defined in `~/.cmdforge/providers.yaml`:
```yaml
providers:
- name: claude
command: "claude -p"
description: "Anthropic Claude"
- name: codex
command: "codex -p"
description: "OpenAI Codex"
- name: gemini
command: "gemini"
description: "Google Gemini"
```
The provider command receives the prompt via stdin and outputs to stdout.
### Wrapper Scripts
When you save a tool, CmdForge creates a wrapper script in `~/.local/bin/`:
```bash
#!/bin/bash
# CmdForge wrapper for 'sum'
# Auto-generated - do not edit
exec /path/to/python -m cmdforge.runner sum "$@"
```
The wrapper uses the full Python path to ensure the correct environment is used.
Use `cmdforge refresh` to regenerate all wrapper scripts (e.g., after changing Python environments).
### Tool Name Validation
Tool names must:
- Start with a letter or underscore
- Contain only letters, numbers, underscores, and dashes
- Not contain spaces or shell-problematic characters (`/\|&;$`"'<>(){}[]!?*#~`)
## Tool Composition
CmdForge are designed to chain together like any Unix command.
### External Pipelines (Tool-to-Tool)
```bash
# Chain multiple CmdForge
cat logs.txt | log-errors | summarize | translate --lang Japanese
# Mix with standard Unix tools
git log --oneline | head -20 | changelog | tee CHANGELOG.md
# Build complex workflows
cat *.py | review-code --focus security | json-extract --fields "issue, severity, file" | json2csv
```
Each tool reads stdin and writes stdout. No special integration needed.
### Internal Pipelines (Multi-Step)
Within a single tool, chain steps for preprocessing/validation:
```yaml
steps:
- type: code # Preprocess
code: |
filtered = '\n'.join(l for l in input.split('\n') if 'ERROR' in l)
output_var: filtered
- type: prompt # AI processes filtered input
prompt: "Explain these errors: {filtered}"
provider: claude-haiku
output_var: explanation
- type: code # Post-process
code: |
result = f"Found {len(filtered.split(chr(10)))} errors:\n\n{explanation}"
output_var: result
output: "{result}"
```
**When to use which:**
- **External pipelines**: Reusable tools, different providers per stage, standard Unix interop
- **Internal pipelines**: Tightly coupled steps, shared context, validation of AI output
## What This Design Doesn't Include
Intentionally omitted (not needed for personal use):
- Trust tiers / security levels
- Cryptographic signing
- Container isolation / sandboxing
- Certification testing
- Distribution packaging
- PII redaction
- Audit logging
- Provider capability negotiation
**Why?** This is a personal tool builder. You write the tools, you run the tools, you accept the responsibility. Just like any bash script you write.
## Dependencies
Required:
- Python 3.10+
- PyYAML
- urwid (for TUI)
Optional fallbacks:
- python3-newt/snack (simpler TUI)
- dialog/whiptail (basic TUI)
## Example Workflow
1. Run `cmdforge` to open UI
2. Select "Create" to create a new tool
3. Fill in: name, description, output template
4. Add arguments (e.g., `--max` with default `500`)
5. Add a prompt step with your prompt template and provider
6. Click "Save"
7. Exit UI
8. Run `sum -i myfile.txt -o summary.txt`
Done. No containers, no signing, no certification. Just a tool that works.
## Default Values
| Step Type | Default Output Var | Default File |
|-----------|-------------------|--------------|
| Prompt | `response` | n/a |
| Code | `processed` | `processed.py` |
+1058
View File
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
# Installation Guide
Complete installation instructions for CmdForge.
## Quick Install
```bash
git clone https://gitea.brrd.tech/rob/CmdForge.git
cd CmdForge
pip install -e .
export PATH="$HOME/.local/bin:$PATH"
cmdforge providers install
```
## Detailed Installation
### Step 1: Install Python 3.10+
**Ubuntu/Debian:**
```bash
sudo apt update
sudo apt install python3 python3-pip
```
**macOS:**
```bash
brew install python@3.11
```
**Verify:**
```bash
python3 --version # Should be 3.10 or higher
```
### Step 2: Install CmdForge
```bash
git clone https://gitea.brrd.tech/rob/CmdForge.git
cd CmdForge
pip install -e .
```
**With TUI and development dependencies:**
```bash
pip install -e ".[dev]"
```
### Step 3: Configure PATH
CmdForge creates executable wrappers in `~/.local/bin/`. Add this to your shell config:
**Bash (~/.bashrc):**
```bash
export PATH="$HOME/.local/bin:$PATH"
```
**Zsh (~/.zshrc):**
```bash
export PATH="$HOME/.local/bin:$PATH"
```
**Fish (~/.config/fish/config.fish):**
```fish
set -gx PATH $HOME/.local/bin $PATH
```
Then reload:
```bash
source ~/.bashrc # or restart terminal
```
### Step 4: Install an AI Provider
You need at least one AI CLI tool. The easiest way is the interactive installer:
```bash
cmdforge providers install
```
This guides you through installing and authenticating providers.
#### Manual Installation
**OpenCode (free tier available):**
```bash
curl -fsSL https://opencode.ai/install | bash
source ~/.bashrc # or start new shell
opencode # Opens browser for sign-in
```
**Claude CLI (pay-per-use):**
```bash
npm install -g @anthropic-ai/claude-code
claude # Opens browser for sign-in
```
**Codex (pay-per-use):**
```bash
npm install -g @openai/codex
codex # Opens browser for sign-in
```
**Gemini (free tier available):**
```bash
npm install -g @google/gemini-cli
gemini # Opens browser for Google sign-in
```
**Ollama (free, runs locally):**
```bash
curl -fsSL https://ollama.ai/install.sh | bash
ollama pull llama3
cmdforge providers add ollama "ollama run llama3" -d "Local Llama 3"
```
See [PROVIDERS.md](PROVIDERS.md) for full provider setup instructions.
### Step 5: Verify Installation
```bash
# Check CmdForge is installed
cmdforge --version
# List configured providers
cmdforge providers list
# Check which providers are available
cmdforge providers check
# Test with mock provider (no AI needed)
echo "hello world" | cmdforge run --provider mock
```
### Step 6: Create Wrapper Scripts
After installation or any Python environment change:
```bash
cmdforge refresh
```
This creates executable scripts in `~/.local/bin/` for all your tools.
## Installing Example Tools
### Quick Install (All 28 Tools)
```bash
python examples/install.py
cmdforge refresh
```
### Manual Install
Copy individual tool configs to `~/.cmdforge/<toolname>/config.yaml`.
See [EXAMPLES.md](EXAMPLES.md) for all tool configurations.
## Troubleshooting
### "cmdforge: command not found"
PATH is not configured. Add to your shell config:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
### "Provider 'X' not found"
1. Check provider is installed: `which claude` or `which opencode`
2. Check provider is configured: `cmdforge providers`
3. Add missing provider: `cmdforge providers add`
### "Permission denied" on tool execution
Refresh wrapper scripts:
```bash
cmdforge refresh
```
### TUI doesn't start / looks broken
Install urwid:
```bash
pip install urwid
```
Ensure your terminal supports mouse (most modern terminals do).
### Tools work with `cmdforge run` but not directly
The wrapper scripts may be outdated. Refresh them:
```bash
cmdforge refresh
```
### Python version mismatch
If you have multiple Python versions, ensure CmdForge uses the right one:
```bash
python3.11 -m pip install cmdforge
```
## Uninstalling
```bash
# Remove package
pip uninstall cmdforge
# Remove config and tools (optional)
rm -rf ~/.cmdforge
# Remove wrapper scripts (optional)
rm ~/.local/bin/{summarize,translate,fix-grammar,...}
```
## Upgrading
```bash
pip install --upgrade cmdforge
cmdforge refresh
```
## Directory Structure
After installation:
```
~/.cmdforge/
├── providers.yaml # Provider configurations
├── summarize/
│ └── config.yaml # Tool config
├── translate/
│ ├── config.yaml
│ └── prompt.txt # External prompt file (optional)
└── ...
~/.local/bin/
├── cmdforge # Main command
├── summarize # Tool wrapper script
├── translate # Tool wrapper script
└── ...
```
## Next Steps
1. [Set up providers](PROVIDERS.md)
2. [Browse example tools](EXAMPLES.md)
3. Run `cmdforge ui` to create your first tool
+249
View File
@@ -0,0 +1,249 @@
# Meta-Tools: Tools That Call Other 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`
A new step type `tool` allows calling another CmdForge tool from within a tool's workflow.
### Manifest Format
```yaml
name: summarize-and-translate
description: Summarize text then translate the summary
version: 1.0.0
category: Text
steps:
- type: tool
tool: official/summarize # Tool to call (owner/name or just name for local)
input: "{input}" # What to pass as input (supports variable substitution)
args: # Optional arguments to pass to the tool
max_words: "100"
output_var: summary # Variable to store the output
- type: tool
tool: official/translate
input: "{summary}" # Use output from previous step
args:
target_language: "{language}" # Can use tool's own arguments
output_var: translated
output: "{translated}"
arguments:
- flag: "--language"
variable: language
default: "Spanish"
description: "Target language for translation"
```
### Resolution Order
When resolving a tool reference in a `tool` step:
1. **Fully qualified**: `owner/name` - looks up in registry or installed tools
2. **Local tool**: Just `name` - checks local `~/.cmdforge/<name>/config.yaml` first
3. **Registry fallback**: If not found locally, checks installed registry tools
### Tool Step Properties
| Property | Required | Description |
|----------|----------|-------------|
| `type` | Yes | Must be `"tool"` |
| `tool` | Yes | Tool reference (owner/name or name) |
| `input` | No | Input to pass to the tool (default: current `{input}`) |
| `args` | No | Dictionary of arguments to pass |
| `output_var` | Yes | Variable name to store the tool's output |
| `provider` | No | Override provider for the called tool |
### Execution
When a `tool` step executes:
1. Resolve the tool reference to find the tool definition
2. Substitute variables in `input` and `args` values
3. Execute the tool with the resolved input and arguments
4. Capture the output in the specified `output_var`
### Error Handling
- If a called tool fails, the parent tool fails with an appropriate error
- Tool not found errors include helpful installation suggestions
- Circular dependencies are detected and prevented
## Dependency Resolution
### Declaring Dependencies
Tools can declare their dependencies in the config:
```yaml
name: my-meta-tool
dependencies:
- official/summarize
- official/translate@^1.0.0 # With version constraint
```
### CLI Integration
```bash
# Install a tool and its dependencies
cmdforge install official/summarize-and-translate
# Check if dependencies are satisfied
cmdforge check my-meta-tool
# Install missing dependencies
cmdforge install --deps my-meta-tool
```
### Dependency Checking
Before running a meta-tool, the runner verifies all dependencies are installed:
```python
def check_dependencies(tool: Tool) -> List[str]:
"""Returns list of missing dependencies."""
missing = []
for dep in tool.dependencies:
if not is_tool_installed(dep):
missing.append(dep)
return missing
```
## Security Considerations
1. **Sandboxing**: Called tools run in the same process context as the parent
2. **Input validation**: All inputs are treated as untrusted strings
3. **No shell execution**: Tool references cannot contain shell commands
4. **Depth limit**: Maximum nesting depth (default: 10) prevents runaway recursion
## Example: Multi-Step Analysis Pipeline
```yaml
name: code-review-pipeline
description: Comprehensive code review using multiple specialized tools
version: 1.0.0
category: Developer
dependencies:
- official/analyze-complexity
- official/find-bugs
- official/suggest-improvements
steps:
# Step 1: Analyze code complexity
- type: tool
tool: official/analyze-complexity
input: "{input}"
output_var: complexity_report
# Step 2: Find potential bugs
- type: tool
tool: official/find-bugs
input: "{input}"
output_var: bug_report
# Step 3: Generate improvement suggestions based on findings
- type: tool
tool: official/suggest-improvements
input: |
## Code:
{input}
## Complexity Analysis:
{complexity_report}
## Bug Report:
{bug_report}
output_var: suggestions
output: |
# Code Review Results
## Complexity Analysis
{complexity_report}
## Potential Issues
{bug_report}
## Improvement Suggestions
{suggestions}
```
## Implementation Notes
### ToolStep Dataclass
```python
@dataclass
class ToolStep:
"""A step that calls another tool."""
tool: str # Tool reference (owner/name or name)
output_var: str # Variable to store output
input_template: str = "{input}" # Input template
args: Dict[str, str] = field(default_factory=dict)
provider: Optional[str] = None # Provider override
def to_dict(self) -> dict:
d = {
"type": "tool",
"tool": self.tool,
"output_var": self.output_var,
}
if self.input_template != "{input}":
d["input"] = self.input_template
if self.args:
d["args"] = self.args
if self.provider:
d["provider"] = self.provider
return d
```
### Execution Function
```python
def execute_tool_step(
step: ToolStep,
variables: dict,
depth: int = 0,
max_depth: int = 10
) -> tuple[str, bool]:
"""Execute a tool step by calling another tool."""
if depth >= max_depth:
return "", False # Prevent infinite recursion
# Resolve the tool
try:
resolved = resolve_tool(step.tool)
except ToolNotFoundError:
print(f"Error: Tool '{step.tool}' not found", file=sys.stderr)
return "", False
# Prepare input
input_text = substitute_variables(step.input_template, variables)
# Prepare arguments
args = {}
for key, value in step.args.items():
args[key] = substitute_variables(value, variables)
# Run the tool
output, exit_code = run_tool(
tool=resolved.tool,
input_text=input_text,
custom_args=args,
provider_override=step.provider,
_depth=depth + 1 # Track nesting depth
)
return output, exit_code == 0
```
## Future Enhancements
- **Parallel tool execution**: Run independent tool steps concurrently
- **Conditional execution**: Skip steps based on conditions
- **Tool aliases**: Define shorthand names for frequently used tools
- **Caching**: Cache tool outputs for identical inputs
+180
View File
@@ -0,0 +1,180 @@
# CmdForge Project Overview
This document provides a comprehensive overview of the CmdForge project structure, components, and how everything fits together.
## What is CmdForge?
CmdForge is a personal tool builder for AI-powered CLI commands. It consists of three main components:
1. **CLI Tool** (`cmdforge`) - Create, manage, and run AI-powered command-line tools
2. **Registry API** - REST API for publishing and discovering tools
3. **Web UI** - Browser interface for the registry with docs, search, and user dashboard
## Quick Reference
| Component | Location | Purpose |
|-----------|----------|---------|
| CLI | `src/cmdforge/cli.py` | Main entry point for `cmdforge` command |
| Registry API | `src/cmdforge/registry/` | REST API for tool registry |
| Web UI | `src/cmdforge/web/` | Flask web application |
| Tool Storage | `~/.cmdforge/` | User's installed tools |
| Database | `data/cmdforge.db` | SQLite with FTS5 search |
## Architecture Diagrams
See `docs/diagrams/` for visual representations:
- **system-architecture.svg** - How components interact
- **deployment-architecture.svg** - Production deployment setup
- **source-organization.svg** - Code structure
## Source Code Structure
```
src/cmdforge/
├── cli.py # Entry point, subcommand routing
├── tool.py # Tool dataclasses, YAML loading
├── runner.py # Step execution engine
├── providers.py # AI provider abstraction
├── config.py # Global configuration
├── manifest.py # Tool manifest handling
│
├── registry/ # Registry API Server
│ ├── app.py # Flask API application
│ ├── db.py # Database operations, FTS5 search
│ ├── rate_limit.py # API rate limiting
│ ├── sync.py # Git sync for tool submissions
│ ├── categorize.py # Auto-categorization
│ └── similarity.py # Similar tool recommendations
│
├── web/ # Web UI Application
│ ├── app.py # Flask web app, Sentry integration
│ ├── routes.py # Page routes and handlers
│ ├── auth.py # Password hashing, login/register
│ ├── sessions.py # Session management
│ ├── docs_content.py # Documentation text content
│ ├── seo.py # Sitemap, robots.txt
│ ├── filters.py # Jinja template filters
│ ├── templates/ # Jinja HTML templates
│ └── static/ # CSS, JS, images
│
├── registry_client.py # CLI client for registry API
├── resolver.py # Dependency resolution
│
├── ui.py # TUI dispatcher
├── ui_urwid.py # urwid TUI implementation
└── ui_snack.py # newt/snack TUI fallback
```
## Database Schema
SQLite database at `data/cmdforge.db`:
| Table | Purpose |
|-------|---------|
| `publishers` | User accounts (username, email, password_hash) |
| `tools` | Published tools (name, owner, description, config) |
| `api_tokens` | Authentication tokens for API access |
| `download_stats` | Tool download tracking |
| `pageviews` | Analytics for tool detail pages |
| `tools_fts` | Full-text search index (FTS5) |
## API Endpoints
Base URL: `/api/v1/`
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/tools` | GET | List/search tools |
| `/tools/<owner>/<name>` | GET | Get tool details |
| `/tools/<owner>/<name>` | POST | Publish tool (auth required) |
| `/tools/<owner>/<name>/download` | POST | Record download |
| `/me` | GET | Current user info |
| `/tokens` | GET/POST | Manage API tokens |
## Web Routes
| Route | Purpose |
|-------|---------|
| `/` | Homepage with featured tools |
| `/tools` | Tool browser with search |
| `/tools/<owner>/<name>` | Tool detail page |
| `/docs/*` | Documentation pages |
| `/login`, `/register` | Authentication |
| `/dashboard/*` | User dashboard, token management |
## Key Files to Know
### Configuration
- `~/.cmdforge/providers.yaml` - AI provider configurations
- `~/.cmdforge/config.yaml` - Global settings
- `~/.cmdforge/<tool>/config.yaml` - Individual tool configs
### Development
- `pyproject.toml` - Package configuration
- `pytest.ini` - Test configuration
- `tailwind.config.js` - Tailwind CSS configuration
### Documentation
- `docs/REGISTRY.md` - Registry design document (comprehensive)
- `docs/WEB_UI.md` - Web UI design document (comprehensive)
- `wiki/Home.md` - User-facing wiki documentation
- `CLAUDE.md` - Development guidance for AI assistants
## Running the Project
### Development
```bash
# Install with dev dependencies
pip install -e ".[dev]"
# Run CLI
python -m cmdforge.cli
# Run web server (development)
cd src/cmdforge/web
flask run --port 5050
```
### Production
```bash
# Using gunicorn
gunicorn -w 4 -b 0.0.0.0:5050 'cmdforge.web.app:create_app()'
# Environment variables needed:
# FLASK_ENV=production
# SECRET_KEY=<your-secret-key>
# SENTRY_DSN=<optional-sentry-dsn>
# SITE_URL=https://your-domain.com
```
### systemd Service
See `docs/DEPLOYMENT.md` for systemd service configuration.
## Testing
```bash
# Run all tests
pytest
# Run specific test
pytest tests/test_name.py::test_function
# Run with coverage
pytest --cov=cmdforge
```
## Design Documents
For detailed design decisions and specifications:
- **docs/REGISTRY.md** - Complete registry API design (Phases 1-8)
- **docs/WEB_UI.md** - Complete web UI design (Phase 7)
- **wiki/Home.md** - User documentation for CLI usage
## Archive
Old development discussions and diagrams are preserved in `archive/discussions/` for historical reference.
+328
View File
@@ -0,0 +1,328 @@
# Provider Setup Guide
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
We profiled 12 providers with a 4-task benchmark (Math, Code, Reasoning, Data Extraction):
| Provider | Speed | Score | Cost | Best For |
|----------|-------|-------|------|----------|
| **opencode-deepseek** | 13s | 4/4 | ~$0.28/M tokens | **Best value** - daily driver |
| **opencode-pickle** | 13s | 4/4 | FREE | **Best free** - accurate |
| **claude-haiku** | 14s | 4/4 | ~$0.25/M tokens | Fast + high quality |
| **codex** | 14s | 4/4 | ~$1.25/M tokens | Reliable, auto-routes |
| **claude** | 18s | 4/4 | Varies | Auto-routes to best |
| **claude-opus** | 18s | 4/4 | ~$15/M tokens | Highest quality |
| **claude-sonnet** | 21s | 4/4 | ~$3/M tokens | Balanced |
| **opencode-nano** | 24s | 4/4 | Paid | GPT-5 Nano |
| **gemini-flash** | 28s | 4/4 | ~$0.075/M tokens | Google, faster |
| **opencode-reasoner** | 33s | 4/4 | ~$0.28/M tokens | Complex reasoning |
| **gemini** | 91s | 3/4 | ~$1.25/M tokens | 1M token context |
| **opencode-grok** | 11s | 2/4 | FREE | Fastest but unreliable |
### Recommendations
- **Daily use:** `opencode-deepseek` or `opencode-pickle` (free)
- **Quality work:** `claude-haiku` or `claude-opus`
- **Complex reasoning:** `opencode-reasoner`
- **Large documents:** `gemini` (1M token context window)
- **Budget:** `opencode-pickle` (free) or `opencode-deepseek` (cheap)
## Provider Setup
### OpenCode (Recommended)
OpenCode provides access to multiple models including free options.
**Install:**
```bash
curl -fsSL https://opencode.ai/install | bash
```
**Authenticate:**
```bash
~/.opencode/bin/opencode auth
```
**Available Models:**
| Provider Name | Model | Cost |
|---------------|-------|------|
| `opencode-deepseek` | deepseek-chat | Cheap |
| `opencode-pickle` | big-pickle | FREE |
| `opencode-grok` | grok-code | FREE |
| `opencode-nano` | gpt-5-nano | Paid |
| `opencode-reasoner` | deepseek-reasoner | Cheap |
**Test:**
```bash
echo "Hello" | ~/.opencode/bin/opencode run --model opencode/big-pickle
```
### Claude CLI
Anthropic's official CLI for Claude models.
**Install:**
```bash
npm install -g @anthropic-ai/claude-code
```
**Authenticate:**
```bash
claude # Opens browser for sign-in (auto-saves auth tokens)
```
**Available Models:**
| Provider Name | Model | Cost |
|---------------|-------|------|
| `claude` | Auto-routes | Varies |
| `claude-haiku` | Haiku 4.5 | Cheap |
| `claude-sonnet` | Sonnet 4.5 | Medium |
| `claude-opus` | Opus 4.5 | Expensive |
**Test:**
```bash
echo "Hello" | claude -p
```
### Codex (OpenAI)
OpenAI's Codex CLI with auto-routing.
**Install:**
```bash
npm install -g @openai/codex
```
**Authenticate:**
```bash
codex # Opens browser for sign-in (auto-saves auth tokens)
```
**Test:**
```bash
echo "Hello" | codex exec -
```
### Gemini
Google's Gemini models. Best for large context (1M tokens).
**Install:**
```bash
npm install -g @google/gemini-cli
```
**Authenticate:**
```bash
gemini # Opens browser for Google sign-in
```
**Available Models:**
| Provider Name | Model | Notes |
|---------------|-------|-------|
| `gemini` | gemini-2.5-pro | Quality, slow CLI |
| `gemini-flash` | gemini-2.5-flash | Faster |
**Note:** Gemini CLI has known performance issues. Use `gemini-flash` for interactive tasks, `gemini` for large documents.
**Test:**
```bash
echo "Hello" | gemini --model gemini-2.5-flash
```
## Managing Providers
### Interactive Installation (Recommended)
The easiest way to install providers:
```bash
cmdforge providers install
```
This interactive guide:
- Shows available AI providers with costs
- Runs the installation command
- Updates PATH automatically
- Shows next steps for authentication
### List Providers
```bash
cmdforge providers list
```
### Check Availability
```bash
cmdforge providers check
```
### Add Custom Provider
```bash
cmdforge providers add myname "my-command --args" -d "Description"
```
Or edit `~/.cmdforge/providers.yaml`:
```yaml
providers:
- name: my-custom
command: my-ai-tool --prompt
description: My custom AI tool
```
### Provider Command Format
The command should:
1. Accept input via stdin
2. Output response to stdout
3. Exit 0 on success
Example commands:
```bash
# Claude
claude -p
# OpenCode
$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat
# Gemini
gemini --model gemini-2.5-flash
# Codex
codex exec -
# Custom (any tool that reads stdin)
my-tool --input -
```
### Environment Variables
Provider commands can use environment variables:
```yaml
providers:
- name: opencode
command: $HOME/.opencode/bin/opencode run
```
`$HOME` and `~` are expanded automatically.
## Using Providers in Tools
### In Tool Config
```yaml
steps:
- type: prompt
prompt: "Summarize: {input}"
provider: opencode-pickle # Use this provider
output_var: response
```
### Override at Runtime
```bash
# Use a different provider for this run
cat file.txt | summarize --provider claude-opus
```
### Provider Selection Strategy
1. **Tool default** - Set in tool's config.yaml
2. **Runtime override** - `--provider` flag
3. **Cost optimization** - Use cheap providers for simple tasks
4. **Quality needs** - Use opus/sonnet for important work
## Troubleshooting
### "Provider 'X' not found"
1. Check it's in your providers list: `cmdforge providers`
2. Verify the command works: `echo "test" | <command>`
3. Add it: `cmdforge providers add`
### "Command 'X' not found"
The AI CLI tool isn't installed or not in PATH:
```bash
which claude # Should show path
which opencode # Might need full path
```
For OpenCode, use full path in provider:
```yaml
command: $HOME/.opencode/bin/opencode run
```
### Slow Provider
- Use `gemini-flash` instead of `gemini`
- Use `claude-haiku` instead of `claude-opus`
- Use `opencode-deepseek` for best speed/quality ratio
### Provider Errors
Check the provider works directly:
```bash
echo "Say hello" | claude -p
echo "Say hello" | ~/.opencode/bin/opencode run
```
If it works directly but not in CmdForge, check:
1. Provider command in `~/.cmdforge/providers.yaml`
2. Environment variables are expanded correctly
## Cost Optimization
### Free Providers
- `opencode-pickle` - Big Pickle model (FREE, accurate)
- `opencode-grok` - Grok Code (FREE, fast but less reliable)
### Cheap Providers
- `opencode-deepseek` - ~$0.28/M tokens
- `opencode-reasoner` - ~$0.28/M tokens
- `claude-haiku` - ~$0.25/M tokens
### Tips
1. Use `opencode-pickle` for simple tasks (free + accurate)
2. Use `claude-haiku` when you need reliability
3. Reserve `claude-opus` for important work
4. Use `gemini` only for large document analysis
## Adding New Providers
Any CLI tool that:
- Reads from stdin
- Writes to stdout
- Exits 0 on success
Can be a provider. Examples:
**Local LLM (Ollama):**
```yaml
- name: ollama-llama
command: ollama run llama3
description: Local Llama 3
```
**Custom API wrapper:**
```yaml
- name: my-api
command: curl -s -X POST https://my-api.com/chat -d @-
description: My custom API
```
**Python script:**
```yaml
- name: my-python
command: python3 ~/scripts/my_ai.py
description: Custom Python AI
```
+2023
View File
File diff suppressed because it is too large Load Diff
+1462
View File
File diff suppressed because it is too large Load Diff
+1015
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{
"name": "cmdforge-web",
"private": true,
"scripts": {
"css:build": "tailwindcss -i src/cmdforge/web/static/src/input.css -o src/cmdforge/web/static/css/main.css --minify",
"css:watch": "tailwindcss -i src/cmdforge/web/static/src/input.css -o src/cmdforge/web/static/css/main.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.19"
}
}
+80
View File
@@ -0,0 +1,80 @@
# PySide6 6.7+ Compatibility Issue: setSelectionArea API Change
## Issue
When using NodeGraphQt-QuiltiX-fork with PySide6 6.7+, dragging a rubber band selection in the node graph causes repeated TypeError exceptions:
```
TypeError: 'PySide6.QtWidgets.QGraphicsScene.setSelectionArea' called with wrong argument types:
PySide6.QtWidgets.QGraphicsScene.setSelectionArea(QPainterPath, ItemSelectionMode)
Supported signatures:
PySide6.QtWidgets.QGraphicsScene.setSelectionArea(PySide6.QtGui.QPainterPath, PySide6.QtGui.QTransform)
PySide6.QtWidgets.QGraphicsScene.setSelectionArea(PySide6.QtGui.QPainterPath, PySide6.QtCore.Qt.ItemSelectionOperation = Instance(Qt.ReplaceSelection), PySide6.QtCore.Qt.ItemSelectionMode = Instance(Qt.IntersectsItemShape), PySide6.QtGui.QTransform = Default(QTransform))
```
## Root Cause
The `QGraphicsScene.setSelectionArea()` method signature changed in PySide6 6.7. The old signature allowed:
```python
setSelectionArea(path, mode) # mode = Qt.ItemSelectionMode
```
The new signature requires:
```python
setSelectionArea(path, operation, mode) # operation = Qt.ItemSelectionOperation
```
## Affected File
`NodeGraphQt/widgets/viewer.py` line 524-526
## Current Code
```python
self.scene().setSelectionArea(
path, QtCore.Qt.IntersectsItemShape
)
```
## Fix
```python
self.scene().setSelectionArea(
path, QtCore.Qt.ReplaceSelection,
QtCore.Qt.IntersectsItemShape
)
```
## Environment
- Python: 3.12
- PySide6: 6.6.3.1 (installed by NodeGraphQt-QuiltiX-fork, but issue affects 6.7+)
- NodeGraphQt-QuiltiX-fork: 0.7.0
## Workaround
Apply this patch after installation:
```python
# In viewer.py, change line 524-526 from:
self.scene().setSelectionArea(
path, QtCore.Qt.IntersectsItemShape
)
# To:
self.scene().setSelectionArea(
path, QtCore.Qt.ReplaceSelection,
QtCore.Qt.IntersectsItemShape
)
```
## Related
This issue may also exist in:
- Original NodeGraphQt
- OdenGraphQt
- Other forks
The fix is backward compatible with older PySide6 versions that support the 3-argument signature.
+13
View File
@@ -0,0 +1,13 @@
--- a/NodeGraphQt/widgets/viewer.py
+++ b/NodeGraphQt/widgets/viewer.py
@@ -521,8 +521,10 @@ class NodeViewer(QtWidgets.QGraphicsView):
path = QtGui.QPainterPath()
path.addRect(map_rect)
self._rubber_band.setGeometry(rect)
+ # PySide6 6.7+ requires ItemSelectionOperation parameter
self.scene().setSelectionArea(
- path, QtCore.Qt.IntersectsItemShape
+ path, QtCore.Qt.ReplaceSelection,
+ QtCore.Qt.IntersectsItemShape
)
self.scene().update(map_rect)
+79
View File
@@ -0,0 +1,79 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "cmdforge"
version = "0.1.0"
description = "Build custom AI-powered CLI commands in YAML"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "Rob"}
]
keywords = ["ai", "cli", "tools", "automation", "llm", "terminal", "unix", "pipe"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Utilities",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Text Processing",
]
dependencies = [
"PyYAML>=6.0",
"requests>=2.28",
"PySide6>=6.5",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
]
registry = [
"Flask>=2.3",
"argon2-cffi>=21.0",
"sentry-sdk[flask]>=1.0",
"gunicorn>=21.0",
]
flow = [
"NodeGraphQt-QuiltiX-fork[pyside6]>=0.7.0",
"setuptools", # Required for distutils compatibility
]
all = [
"Flask>=2.3",
"argon2-cffi>=21.0",
"sentry-sdk[flask]>=1.0",
"gunicorn>=21.0",
"NodeGraphQt-QuiltiX-fork[pyside6]>=0.7.0",
"setuptools",
]
[project.scripts]
cmdforge = "cmdforge.cli:main"
[project.urls]
Homepage = "https://cmdforge.brrd.tech"
Documentation = "https://cmdforge.brrd.tech/docs"
Repository = "https://gitea.brrd.tech/rob/CmdForge.git"
Issues = "https://gitea.brrd.tech/rob/CmdForge/issues"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
markers = [
"integration: marks tests as integration tests (require running server)",
]
@@ -0,0 +1,21 @@
# code-review
Review code for issues, risks, and improvements.
## Usage
```bash
code-review --language python --focus bugs,security --severity medium < app.py
```
## Arguments
- `--language`: Programming language or `auto`.
- `--focus`: Comma-separated focus areas (e.g., `bugs,security,performance`).
- `--severity`: Minimum severity level (`low`, `medium`, `high`).
## Examples
```bash
cat main.go | code-review --language go --focus performance --severity high
```
@@ -0,0 +1,37 @@
name: code-review
version: "1.0.0"
description: "Review code for issues and improvements"
category: code-analysis
tags:
- code
- review
- quality
arguments:
- name: language
flag: "--language"
default: "auto"
description: "Programming language (e.g., python, javascript, go)"
- name: focus
flag: "--focus"
default: "bugs,security,performance"
description: "Comma-separated focus areas"
- name: severity
flag: "--severity"
default: "medium"
description: "Minimum severity: low, medium, high"
steps:
- type: prompt
provider: "claude"
template: |
You are a senior code reviewer.
Language: {language}
Focus areas: {focus}
Minimum severity: {severity}
Review the following code. Identify issues, risks, and improvements.
Provide a concise, prioritized list with suggestions.
Code:
{input}
output_var: review
output: "{review}"
+20
View File
@@ -0,0 +1,20 @@
# explain
Explain code or concepts clearly with adjustable audience and format.
## Usage
```bash
explain --audience beginner --format step-by-step < snippet.txt
```
## Arguments
- `--audience`: Audience level (`beginner`, `general`, `expert`).
- `--format`: Output format (`paragraph`, `bullet`, `step-by-step`).
## Examples
```bash
echo "Explain recursion" | explain --audience general --format bullet
```
@@ -0,0 +1,29 @@
name: explain
version: "1.0.0"
description: "Explain code or concepts clearly"
category: education
tags:
- explanation
- learning
- code
arguments:
- name: audience
flag: "--audience"
default: "general"
description: "Audience level: beginner, general, expert"
- name: format
flag: "--format"
default: "paragraph"
description: "Output format: paragraph, bullet, or step-by-step"
steps:
- type: prompt
provider: "claude"
template: |
You are a clear and concise explainer.
Audience: {audience}
Format: {format}
Explain the following content:
{input}
output_var: explanation
output: "{explanation}"
@@ -0,0 +1,20 @@
# summarize
Summarize text using AI. Designed for quick overviews, briefs, or executive summaries.
## Usage
```bash
summarize --max-length 200 --style neutral < input.txt
```
## Arguments
- `--max-length`: Maximum length of the summary in words. Default: `200`.
- `--style`: Summary style. Options: `neutral`, `bullet`, `executive`.
## Examples
```bash
cat report.txt | summarize --max-length 120 --style executive
```
@@ -0,0 +1,29 @@
name: summarize
version: "1.0.0"
description: "Summarize text using AI"
category: text-processing
tags:
- summarization
- text
- ai
arguments:
- name: max_length
flag: "--max-length"
default: "200"
description: "Maximum length of the summary in words"
- name: style
flag: "--style"
default: "neutral"
description: "Summary style: neutral, bullet, or executive"
steps:
- type: prompt
provider: "claude"
template: |
You are a helpful assistant that summarizes text.
Style: {style}
Max length: {max_length} words.
Summarize the following text:
{input}
output_var: summary
output: "{summary}"
@@ -0,0 +1,21 @@
# translate
Translate text between languages using AI.
## Usage
```bash
translate --from auto --to en --tone neutral < input.txt
```
## Arguments
- `--from`: Source language (e.g., `en`, `es`, `fr`) or `auto`.
- `--to`: Target language (e.g., `en`, `es`, `fr`).
- `--tone`: Translation tone: `neutral`, `formal`, `casual`.
## Examples
```bash
echo "Hola mundo" | translate --from es --to en --tone formal
```
@@ -0,0 +1,34 @@
name: translate
version: "1.0.0"
description: "Translate text between languages using AI"
category: text-processing
tags:
- translation
- localization
- text
arguments:
- name: from_language
flag: "--from"
default: "auto"
description: "Source language (e.g., en, es, fr) or auto"
- name: to_language
flag: "--to"
default: "en"
description: "Target language (e.g., en, es, fr)"
- name: tone
flag: "--tone"
default: "neutral"
description: "Tone: neutral, formal, or casual"
steps:
- type: prompt
provider: "claude"
template: |
You are a translation assistant.
Translate from: {from_language}
Translate to: {to_language}
Tone: {tone}
Translate the following text:
{input}
output_var: translation
output: "{translation}"
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=CmdForge Fabric Pattern Sync
Documentation=https://pages.brrd.tech/rob/cmdforge/
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=rob
Group=rob
WorkingDirectory=/home/rob/PycharmProjects/CmdForge
# Use the project's virtual environment
ExecStart=/home/rob/PycharmProjects/CmdForge/.venv/bin/python scripts/fabric_sync.py --sync
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=fabric-sync
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/lib/cmdforge /home/rob/.cmdforge
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Daily Fabric Pattern Sync for CmdForge
Documentation=https://pages.brrd.tech/rob/cmdforge/
[Timer]
# Run daily at 3 AM
OnCalendar=*-*-* 03:00:00
# Randomize start time by up to 1 hour to avoid thundering herd
RandomizedDelaySec=3600
# Run immediately if we missed a scheduled run
Persistent=true
[Install]
WantedBy=timers.target
+556
View File
@@ -0,0 +1,556 @@
#!/usr/bin/env python3
"""
Scheduled Fabric pattern sync for CmdForge.
Monitors the Fabric repository for new/updated patterns and syncs them
to the CmdForge registry through the vetting pipeline.
Usage:
# Check for new patterns (dry run)
python scripts/fabric_sync.py --dry-run
# Sync new patterns to registry
python scripts/fabric_sync.py --sync
# Check status of tracked patterns
python scripts/fabric_sync.py --status
# Force resync of specific patterns
python scripts/fabric_sync.py --force summarize extract_wisdom
# Run as daemon with interval
python scripts/fabric_sync.py --daemon --interval 3600
Setup for cron (daily sync):
0 3 * * * /path/to/venv/bin/python /path/to/scripts/fabric_sync.py --sync >> /var/log/fabric_sync.log 2>&1
Setup for systemd timer:
See scripts/fabric-sync.service and scripts/fabric-sync.timer
"""
import argparse
import hashlib
import json
import logging
import os
import subprocess
import sys
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import yaml
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# Constants
FABRIC_REPO = "https://github.com/danielmiessler/fabric.git"
DEFAULT_SYNC_DIR = Path("/var/lib/cmdforge/fabric-sync")
DEFAULT_STATE_FILE = DEFAULT_SYNC_DIR / "sync_state.json"
DEFAULT_PROVIDER = "opencode-pickle"
@dataclass
class PatternState:
"""State of a single pattern."""
name: str
hash: str # SHA256 of system.md content
synced_at: Optional[str] = None
version: str = "1.0.0"
status: str = "pending" # pending, synced, failed, skipped
@dataclass
class SyncState:
"""Overall sync state."""
last_check: Optional[str] = None
last_sync: Optional[str] = None
repo_commit: Optional[str] = None
patterns: dict = field(default_factory=dict) # name -> PatternState as dict
def to_dict(self) -> dict:
return {
"last_check": self.last_check,
"last_sync": self.last_sync,
"repo_commit": self.repo_commit,
"patterns": self.patterns,
}
@classmethod
def from_dict(cls, data: dict) -> "SyncState":
return cls(
last_check=data.get("last_check"),
last_sync=data.get("last_sync"),
repo_commit=data.get("repo_commit"),
patterns=data.get("patterns", {}),
)
def load_state(state_file: Path) -> SyncState:
"""Load sync state from file."""
if state_file.exists():
with open(state_file) as f:
data = json.load(f)
return SyncState.from_dict(data)
return SyncState()
def save_state(state: SyncState, state_file: Path):
"""Save sync state to file."""
state_file.parent.mkdir(parents=True, exist_ok=True)
with open(state_file, "w") as f:
json.dump(state.to_dict(), f, indent=2)
def clone_or_update_repo(sync_dir: Path) -> tuple[Path, str]:
"""Clone or update the Fabric repository.
Returns:
Tuple of (patterns_dir, commit_hash)
"""
fabric_dir = sync_dir / "fabric"
patterns_dir = fabric_dir / "data" / "patterns"
if fabric_dir.exists():
logger.info("Updating existing Fabric clone...")
subprocess.run(
["git", "-C", str(fabric_dir), "fetch", "--quiet"],
check=True,
capture_output=True
)
subprocess.run(
["git", "-C", str(fabric_dir), "reset", "--hard", "origin/main", "--quiet"],
check=True,
capture_output=True
)
else:
logger.info("Cloning Fabric repository...")
sync_dir.mkdir(parents=True, exist_ok=True)
subprocess.run(
["git", "clone", "--depth", "1", FABRIC_REPO, str(fabric_dir)],
check=True,
capture_output=True
)
# Get current commit hash
result = subprocess.run(
["git", "-C", str(fabric_dir), "rev-parse", "HEAD"],
capture_output=True,
text=True
)
commit_hash = result.stdout.strip()[:12]
return patterns_dir, commit_hash
def hash_pattern(pattern_dir: Path) -> Optional[str]:
"""Calculate hash of pattern content."""
system_md = pattern_dir / "system.md"
if not system_md.exists():
return None
content = system_md.read_bytes()
return hashlib.sha256(content).hexdigest()[:16]
def scan_patterns(patterns_dir: Path) -> dict[str, str]:
"""Scan all patterns and return name -> hash mapping."""
patterns = {}
for entry in sorted(patterns_dir.iterdir()):
if entry.is_dir():
pattern_hash = hash_pattern(entry)
if pattern_hash:
patterns[entry.name] = pattern_hash
return patterns
def find_changes(
current_patterns: dict[str, str],
state: SyncState
) -> tuple[list[str], list[str], list[str]]:
"""Find new, updated, and removed patterns.
Returns:
Tuple of (new_patterns, updated_patterns, removed_patterns)
"""
new_patterns = []
updated_patterns = []
removed_patterns = []
# Check for new and updated
for name, current_hash in current_patterns.items():
if name not in state.patterns:
new_patterns.append(name)
elif state.patterns[name].get("hash") != current_hash:
updated_patterns.append(name)
# Check for removed
for name in state.patterns:
if name not in current_patterns:
removed_patterns.append(name)
return new_patterns, updated_patterns, removed_patterns
def vet_pattern(pattern_dir: Path, provider: str = DEFAULT_PROVIDER) -> tuple[bool, str]:
"""Run vetting pipeline on a pattern.
Returns:
Tuple of (passed, reason)
"""
try:
# Try to import the vetting pipeline
script_dir = Path(__file__).parent
sys.path.insert(0, str(script_dir))
from import_fabric import create_tool_config, clean_prompt, get_category, pattern_to_display_name
# Read pattern
system_md = pattern_dir / "system.md"
system_prompt = system_md.read_text()
# Create config
config = create_tool_config(pattern_dir.name, system_prompt, provider)
# Run scrutiny
try:
from scrutiny import vet_tool, VetResult
report = vet_tool(config, str(pattern_dir))
if report.result == VetResult.REJECT:
return False, f"Rejected: {report.suggestions[0] if report.suggestions else 'quality too low'}"
elif report.result == VetResult.REVIEW:
return True, f"Approved (needs review): score {report.overall_score:.2f}"
else:
return True, f"Approved: score {report.overall_score:.2f}"
except ImportError:
# Scrutiny not available - basic validation
if len(system_prompt.strip()) < 50:
return False, "Pattern too short"
return True, "Basic validation passed"
except Exception as e:
return False, f"Vetting error: {e}"
def sync_pattern(
pattern_dir: Path,
output_dir: Path,
provider: str,
state: SyncState,
dry_run: bool = False
) -> bool:
"""Sync a single pattern.
Returns:
True if successful
"""
name = pattern_dir.name
pattern_hash = hash_pattern(pattern_dir)
# Vet the pattern
passed, reason = vet_pattern(pattern_dir, provider)
if not passed:
logger.warning(f" ✗ {name}: {reason}")
state.patterns[name] = {
"name": name,
"hash": pattern_hash,
"status": "failed",
"reason": reason,
"synced_at": datetime.now(timezone.utc).isoformat(),
}
return False
if dry_run:
logger.info(f" [DRY RUN] Would sync: {name} ({reason})")
return True
# Import the pattern
try:
script_dir = Path(__file__).parent
sys.path.insert(0, str(script_dir))
from import_fabric import import_pattern
success = import_pattern(
name,
pattern_dir.parent,
output_dir,
provider,
dry_run=False,
registry_format=False,
)
if success:
logger.info(f" ✓ {name}: {reason}")
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(
sync_dir: Path,
output_dir: Path,
state_file: Path,
provider: str,
dry_run: bool = False,
force_patterns: list[str] = None
) -> dict:
"""Run the sync process.
Returns:
Summary dict with counts
"""
# Load state
state = load_state(state_file)
# Clone/update repo
patterns_dir, commit_hash = clone_or_update_repo(sync_dir)
# Scan patterns
current_patterns = scan_patterns(patterns_dir)
logger.info(f"Found {len(current_patterns)} patterns in Fabric repo (commit {commit_hash})")
# Find changes
if force_patterns:
new_patterns = [p for p in force_patterns if p in current_patterns]
updated_patterns = []
removed_patterns = []
else:
new_patterns, updated_patterns, removed_patterns = find_changes(current_patterns, state)
logger.info(f"Changes: {len(new_patterns)} new, {len(updated_patterns)} updated, {len(removed_patterns)} removed")
# Update state timestamp
state.last_check = datetime.now(timezone.utc).isoformat()
state.repo_commit = commit_hash
# Process new and updated patterns
to_sync = new_patterns + updated_patterns
synced = 0
failed = 0
if to_sync:
logger.info(f"\nSyncing {len(to_sync)} patterns...")
for name in to_sync:
pattern_dir = patterns_dir / name
if sync_pattern(pattern_dir, output_dir, provider, state, dry_run):
synced += 1
else:
failed += 1
# Mark removed patterns
for name in removed_patterns:
if name in state.patterns:
state.patterns[name]["status"] = "removed"
# Save state
if not dry_run:
state.last_sync = datetime.now(timezone.utc).isoformat()
save_state(state, state_file)
logger.info(f"\nState saved to {state_file}")
# Summary
summary = {
"total_patterns": len(current_patterns),
"new": len(new_patterns),
"updated": len(updated_patterns),
"removed": len(removed_patterns),
"synced": synced,
"failed": failed,
"commit": commit_hash,
}
logger.info(f"\nSync complete: {synced} synced, {failed} failed")
return summary
def print_status(state_file: Path):
"""Print current sync status."""
state = load_state(state_file)
print(f"\nFabric Sync Status")
print(f"{'=' * 50}")
print(f"Last check: {state.last_check or 'Never'}")
print(f"Last sync: {state.last_sync or 'Never'}")
print(f"Repo commit: {state.repo_commit or 'Unknown'}")
if state.patterns:
# Count by status
by_status = {}
for p in state.patterns.values():
status = p.get("status", "unknown")
by_status[status] = by_status.get(status, 0) + 1
print(f"\nPatterns: {len(state.patterns)} total")
for status, count in sorted(by_status.items()):
print(f" {status}: {count}")
# Show failed patterns
failed = [p for p in state.patterns.values() if p.get("status") == "failed"]
if failed:
print(f"\nFailed patterns:")
for p in failed[:10]:
print(f" - {p['name']}: {p.get('reason', 'Unknown error')}")
if len(failed) > 10:
print(f" ... and {len(failed) - 10} more")
else:
print("\nNo patterns tracked yet. Run --sync to start.")
def daemon_loop(
sync_dir: Path,
output_dir: Path,
state_file: Path,
provider: str,
interval: int
):
"""Run sync in a loop."""
logger.info(f"Starting daemon mode with {interval}s interval")
while True:
try:
run_sync(sync_dir, output_dir, state_file, provider)
except Exception as e:
logger.error(f"Sync failed: {e}")
logger.info(f"Sleeping for {interval}s...")
time.sleep(interval)
def main():
parser = argparse.ArgumentParser(
description="Scheduled Fabric pattern sync for CmdForge",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
"--sync",
action="store_true",
help="Run sync process"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be synced without making changes"
)
parser.add_argument(
"--status",
action="store_true",
help="Show current sync status"
)
parser.add_argument(
"--force",
nargs="+",
metavar="PATTERN",
help="Force resync of specific patterns"
)
parser.add_argument(
"--daemon",
action="store_true",
help="Run in daemon mode"
)
parser.add_argument(
"--interval",
type=int,
default=3600,
help="Sync interval in seconds for daemon mode (default: 3600)"
)
parser.add_argument(
"--sync-dir",
type=Path,
default=DEFAULT_SYNC_DIR,
help=f"Directory for sync data (default: {DEFAULT_SYNC_DIR})"
)
parser.add_argument(
"--output",
type=Path,
default=Path.home() / ".cmdforge",
help="Output directory for synced tools (default: ~/.cmdforge)"
)
parser.add_argument(
"--state-file",
type=Path,
help="State file path (default: <sync-dir>/sync_state.json)"
)
parser.add_argument(
"--provider",
default=DEFAULT_PROVIDER,
help=f"Default provider for tools (default: {DEFAULT_PROVIDER})"
)
args = parser.parse_args()
# Set state file default
state_file = args.state_file or (args.sync_dir / "sync_state.json")
if args.status:
print_status(state_file)
return 0
if args.daemon:
daemon_loop(
args.sync_dir,
args.output,
state_file,
args.provider,
args.interval
)
return 0
if args.sync or args.dry_run or args.force:
summary = run_sync(
args.sync_dir,
args.output,
state_file,
args.provider,
dry_run=args.dry_run,
force_patterns=args.force
)
if summary["failed"] > 0:
return 1
return 0
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())
+116
View File
@@ -0,0 +1,116 @@
"""Basic health check for the CmdForge Registry API and DB."""
from __future__ import annotations
import json
import sys
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
def _add_src_to_path() -> None:
repo_root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(repo_root / "src"))
def _get_registry_url() -> str:
from cmdforge.config import get_registry_url
return get_registry_url().rstrip("/")
def _get_db_path() -> Path:
from cmdforge.registry.db import get_db_path
return get_db_path()
def check_api(url: str) -> str | None:
endpoint = f"{url}/tools?limit=1"
try:
req = Request(endpoint, headers={"Accept": "application/json"})
with urlopen(req, timeout=5) as resp:
if resp.status != 200:
return f"API unhealthy: status {resp.status}"
payload = json.loads(resp.read().decode("utf-8"))
if "data" not in payload:
return "API unhealthy: missing data field"
except HTTPError as exc:
return f"API unhealthy: HTTP {exc.code}"
except URLError as exc:
return f"API unhealthy: {exc.reason}"
except Exception as exc:
return f"API unhealthy: {exc}"
return None
def check_db(db_path: Path) -> str | None:
if not db_path.exists():
return f"DB missing: {db_path}"
if not db_path.is_file():
return f"DB path is not a file: {db_path}"
try:
with db_path.open("rb"):
pass
except OSError as exc:
return f"DB not readable: {exc}"
return None
def check_last_sync(db_path: Path) -> str | None:
try:
conn = sqlite3.connect(db_path)
try:
row = conn.execute("SELECT MAX(processed_at) FROM webhook_log").fetchone()
finally:
conn.close()
except sqlite3.Error as exc:
return f"DB error: {exc}"
if not row or not row[0]:
return "No webhook sync recorded in webhook_log"
raw = str(row[0]).replace("Z", "+00:00")
try:
last_sync = datetime.fromisoformat(raw)
except ValueError:
return f"Invalid sync timestamp: {row[0]}"
if datetime.utcnow() - last_sync > timedelta(hours=1):
return f"Last sync too old: {last_sync.isoformat()}"
return None
def main() -> int:
_add_src_to_path()
errors = []
registry_url = _get_registry_url()
db_path = _get_db_path()
api_error = check_api(registry_url)
if api_error:
errors.append(api_error)
db_error = check_db(db_path)
if db_error:
errors.append(db_error)
else:
sync_error = check_last_sync(db_path)
if sync_error:
errors.append(sync_error)
if errors:
for err in errors:
print(err, file=sys.stderr)
return 1
print("Health check OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""
Import Fabric patterns as CmdForge tools.
Usage:
# Import all patterns
python scripts/import_fabric.py --all
# Import specific pattern(s)
python scripts/import_fabric.py summarize extract_wisdom
# List available patterns
python scripts/import_fabric.py --list
# Dry run (show what would be created)
python scripts/import_fabric.py --all --dry-run
# Specify output directory
python scripts/import_fabric.py --all --output /tmp/fabric-tools
# Specify default provider
python scripts/import_fabric.py --all --provider opencode-deepseek
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Optional
import yaml
# Fabric repo URL
FABRIC_REPO = "https://github.com/danielmiessler/fabric.git"
# Default provider for imported tools
DEFAULT_PROVIDER = "opencode-pickle"
# Category mapping based on pattern name prefixes/keywords
CATEGORY_RULES = [
(r"^analyze_", "Data"),
(r"^extract_", "Data"),
(r"^create_", "Developer"),
(r"^write_", "Text"),
(r"^improve_", "Text"),
(r"^summarize", "Text"),
(r"^explain_", "Text"),
(r"^review_", "Developer"),
(r"^code", "Developer"),
(r"^debug", "Developer"),
(r"^test", "Developer"),
(r"rate_", "Data"),
(r"json", "Data"),
(r"csv", "Data"),
(r"sql", "Data"),
(r"translate", "Text"),
(r"transcri", "Text"),
]
def get_category(pattern_name: str) -> str:
"""Determine category based on pattern name."""
name_lower = pattern_name.lower()
for regex, category in CATEGORY_RULES:
if re.search(regex, name_lower):
return category
return "Other"
def pattern_to_display_name(pattern_name: str) -> str:
"""Convert pattern name to display name."""
# Replace underscores with spaces and title case
return pattern_name.replace("_", " ").title()
def clean_prompt(prompt: str) -> str:
"""Clean up the Fabric prompt for use in CmdForge."""
# Remove trailing INPUT: placeholder (we'll add {input} ourselves)
prompt = prompt.strip()
# Remove various forms of input placeholder at the end
for suffix in ["INPUT:", "# INPUT", "# INPUT:", "INPUT"]:
if prompt.endswith(suffix):
prompt = prompt[:-len(suffix)].strip()
return prompt
def create_tool_config(
pattern_name: str,
system_prompt: str,
provider: str = DEFAULT_PROVIDER,
) -> dict:
"""Create a CmdForge tool config from a Fabric pattern."""
cleaned_prompt = clean_prompt(system_prompt)
display_name = pattern_to_display_name(pattern_name)
category = get_category(pattern_name)
# Build the full prompt with input placeholder
full_prompt = f"{cleaned_prompt}\n\n{{input}}"
config = {
"name": pattern_name,
"description": f"{display_name} - imported from Fabric patterns",
"version": "1.0.0",
"category": category,
# Attribution - marks this as an imported tool
"source": {
"type": "imported",
"license": "MIT",
"url": "https://github.com/danielmiessler/fabric",
"author": "Daniel Miessler",
"original_tool": f"fabric/patterns/{pattern_name}",
},
"arguments": [],
"steps": [
{
"type": "prompt",
"prompt": full_prompt,
"provider": provider,
"output_var": "response",
}
],
"output": "{response}",
}
return config
def clone_fabric(target_dir: Path) -> Path:
"""Clone or update Fabric repo, return patterns directory."""
fabric_dir = target_dir / "fabric"
patterns_dir = fabric_dir / "data" / "patterns"
if fabric_dir.exists():
print(f"Using existing Fabric clone at {fabric_dir}")
# Pull latest
subprocess.run(
["git", "-C", str(fabric_dir), "pull", "--quiet"],
check=False
)
else:
print(f"Cloning Fabric to {fabric_dir}...")
subprocess.run(
["git", "clone", "--depth", "1", FABRIC_REPO, str(fabric_dir)],
check=True,
capture_output=True
)
if not patterns_dir.exists():
raise RuntimeError(f"Patterns directory not found at {patterns_dir}")
return patterns_dir
def list_patterns(patterns_dir: Path) -> list[str]:
"""List all available pattern names."""
patterns = []
for entry in sorted(patterns_dir.iterdir()):
if entry.is_dir() and (entry / "system.md").exists():
patterns.append(entry.name)
return patterns
def import_pattern(
pattern_name: str,
patterns_dir: Path,
output_dir: Path,
provider: str,
dry_run: bool = False,
registry_format: bool = False,
namespace: str = "official",
) -> bool:
"""Import a single pattern. Returns True on success."""
pattern_path = patterns_dir / pattern_name
system_md = pattern_path / "system.md"
if not system_md.exists():
print(f" ✗ Pattern '{pattern_name}' not found", file=sys.stderr)
return False
# Read the system prompt
system_prompt = system_md.read_text()
# Create tool config
config = create_tool_config(pattern_name, system_prompt, provider)
# Output directory for this tool
if registry_format:
# Registry format: tools/<namespace>/<name>/config.yaml
tool_dir = output_dir / "tools" / namespace / pattern_name
else:
# Local format: <output>/<name>/config.yaml
tool_dir = output_dir / pattern_name
config_file = tool_dir / "config.yaml"
if dry_run:
print(f" [DRY RUN] Would create: {config_file}")
print(f" Category: {config['category']}")
print(f" Provider: {provider}")
return True
# Create tool directory
tool_dir.mkdir(parents=True, exist_ok=True)
# Write config.yaml with proper multi-line string handling
class LiteralStr(str):
pass
def literal_representer(dumper, data):
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
yaml.add_representer(LiteralStr, literal_representer)
# Convert the prompt to literal style
config["steps"][0]["prompt"] = LiteralStr(config["steps"][0]["prompt"])
with open(config_file, "w") as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
# Create a basic README.md
readme_content = f"""# {pattern_to_display_name(pattern_name)}
{config['description']}
## Usage
```bash
cat input.txt | {pattern_name}
```
## Source
This tool was imported from [Fabric](https://github.com/danielmiessler/fabric) patterns.
- **Original pattern**: `{pattern_name}`
- **Author**: Daniel Miessler
- **License**: MIT
"""
(tool_dir / "README.md").write_text(readme_content)
print(f" ✓ {pattern_name} -> {tool_dir}")
return True
def main():
parser = argparse.ArgumentParser(
description="Import Fabric patterns as CmdForge tools",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
"patterns",
nargs="*",
help="Pattern name(s) to import"
)
parser.add_argument(
"--all",
action="store_true",
help="Import all patterns"
)
parser.add_argument(
"--list",
action="store_true",
help="List available patterns"
)
parser.add_argument(
"--output", "-o",
type=Path,
default=Path.home() / ".cmdforge",
help="Output directory (default: ~/.cmdforge)"
)
parser.add_argument(
"--provider", "-p",
default=DEFAULT_PROVIDER,
help=f"Default provider for tools (default: {DEFAULT_PROVIDER})"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be created without writing files"
)
parser.add_argument(
"--fabric-dir",
type=Path,
default=Path("/tmp/fabric-import"),
help="Directory to clone Fabric repo (default: /tmp/fabric-import)"
)
parser.add_argument(
"--registry",
action="store_true",
help="Output in registry format (tools/official/<name>/config.yaml)"
)
parser.add_argument(
"--namespace",
default="official",
help="Namespace for registry format (default: official)"
)
args = parser.parse_args()
# Validate arguments
if not args.list and not args.all and not args.patterns:
parser.error("Specify pattern names, --all, or --list")
# Clone/update Fabric
args.fabric_dir.mkdir(parents=True, exist_ok=True)
patterns_dir = clone_fabric(args.fabric_dir)
# List mode
if args.list:
patterns = list_patterns(patterns_dir)
print(f"\nAvailable Fabric patterns ({len(patterns)}):\n")
# Group by category
by_category = {}
for p in patterns:
cat = get_category(p)
by_category.setdefault(cat, []).append(p)
for cat in sorted(by_category.keys()):
print(f" {cat}:")
for p in sorted(by_category[cat]):
print(f" - {p}")
print()
return 0
# Determine which patterns to import
if args.all:
to_import = list_patterns(patterns_dir)
else:
to_import = args.patterns
if not to_import:
print("No patterns to import.", file=sys.stderr)
return 1
print(f"\nImporting {len(to_import)} pattern(s) to {args.output}")
print(f"Provider: {args.provider}")
if args.dry_run:
print("(DRY RUN - no files will be written)\n")
else:
print()
# Import each pattern
success = 0
failed = 0
for pattern in to_import:
if import_pattern(
pattern,
patterns_dir,
args.output,
args.provider,
args.dry_run,
args.registry,
args.namespace
):
success += 1
else:
failed += 1
print(f"\nDone: {success} imported, {failed} failed")
if not args.dry_run and success > 0:
print(f"\nNext steps:")
print(f" 1. Review generated tools in {args.output}")
print(f" 2. Run 'cmdforge refresh' to create wrapper scripts")
print(f" 3. Test with: cmdforge test <pattern_name>")
print(f" 4. Publish with: cmdforge registry publish {args.output}/<pattern_name>")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Patch NodeGraphQt for PySide6 6.7+ compatibility.
The setSelectionArea() API changed in PySide6 6.7 to require an
ItemSelectionOperation parameter. This script patches the installed
NodeGraphQt package to work with newer PySide6 versions.
Issue: setSelectionArea(path, mode) -> setSelectionArea(path, operation, mode)
Usage:
python scripts/patch_nodegraphqt.py
This can be run after `pip install NodeGraphQt-QuiltiX-fork[pyside6]`
"""
import sys
import site
from pathlib import Path
def find_viewer_file():
"""Find the NodeGraphQt viewer.py file in site-packages."""
# Check common locations
for site_dir in site.getsitepackages() + [site.getusersitepackages()]:
viewer_path = Path(site_dir) / "NodeGraphQt" / "widgets" / "viewer.py"
if viewer_path.exists():
return viewer_path
# Check virtual environment
venv_path = Path(sys.prefix) / "lib"
for python_dir in venv_path.glob("python*"):
viewer_path = python_dir / "site-packages" / "NodeGraphQt" / "widgets" / "viewer.py"
if viewer_path.exists():
return viewer_path
return None
def patch_file(viewer_path: Path) -> bool:
"""Apply the PySide6 compatibility patch."""
content = viewer_path.read_text()
# Check if already patched
if "Qt.ReplaceSelection" in content:
print(f"Already patched: {viewer_path}")
return True
# The old code pattern
old_code = """self.scene().setSelectionArea(
path, QtCore.Qt.IntersectsItemShape
)"""
# The new code with ItemSelectionOperation
new_code = """self.scene().setSelectionArea(
path, QtCore.Qt.ReplaceSelection,
QtCore.Qt.IntersectsItemShape
)"""
if old_code not in content:
print(f"Warning: Could not find code to patch in {viewer_path}")
print("The file may have a different format or already be modified.")
return False
# Apply patch
patched_content = content.replace(old_code, new_code)
# Backup original
backup_path = viewer_path.with_suffix(".py.bak")
if not backup_path.exists():
viewer_path.rename(backup_path)
print(f"Backup created: {backup_path}")
# Write patched file
viewer_path.write_text(patched_content)
print(f"Patched successfully: {viewer_path}")
return True
def main():
print("NodeGraphQt PySide6 Compatibility Patch")
print("=" * 40)
viewer_path = find_viewer_file()
if not viewer_path:
print("Error: Could not find NodeGraphQt installation.")
print("Make sure NodeGraphQt-QuiltiX-fork is installed:")
print(" pip install 'NodeGraphQt-QuiltiX-fork[pyside6]'")
return 1
print(f"Found: {viewer_path}")
if patch_file(viewer_path):
print("\nPatch applied successfully!")
print("The rubber band selection should now work in PySide6 6.7+")
return 0
else:
print("\nPatch failed.")
return 1
if __name__ == "__main__":
sys.exit(main())
+507
View File
@@ -0,0 +1,507 @@
#!/usr/bin/env python3
"""
Tool vetting/scrutiny module for CmdForge.
Performs AI-powered analysis of tools to assess quality and safety:
- Honesty: Does the tool do what it claims?
- Transparency: Is the prompt clear and understandable?
- Scope: Is the tool appropriately scoped?
- Efficiency: Is the prompt well-structured?
- Safety: Are there any concerning patterns?
Usage:
# Vet a single tool
python scripts/scrutiny.py path/to/tool/config.yaml
# Vet all tools in directory
python scripts/scrutiny.py --all ~/.cmdforge/
# Output as JSON
python scripts/scrutiny.py --json path/to/tool/config.yaml
# Use specific provider for analysis
python scripts/scrutiny.py --provider claude path/to/tool/config.yaml
"""
import argparse
import json
import sys
from dataclasses import dataclass, field, asdict
from enum import Enum
from pathlib import Path
from typing import Optional
import yaml
class VetResult(Enum):
"""Vetting decision."""
APPROVE = "approve" # Auto-approve - meets all criteria
REVIEW = "review" # Needs human review - some concerns
REJECT = "reject" # Auto-reject - fails criteria
ERROR = "error" # Could not vet
@dataclass
class VetScore:
"""Individual score for a vetting criterion."""
criterion: str
score: float # 0.0 to 1.0
max_score: float = 1.0
notes: str = ""
concerns: list[str] = field(default_factory=list)
@dataclass
class VetReport:
"""Complete vetting report for a tool."""
tool_name: str
tool_path: str
result: VetResult
overall_score: float # 0.0 to 1.0
scores: list[VetScore] = field(default_factory=list)
suggestions: list[str] = field(default_factory=list)
error: Optional[str] = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
d = asdict(self)
d['result'] = self.result.value
return d
# Thresholds for auto-approve/reject
APPROVE_THRESHOLD = 0.8 # Score >= 0.8 -> auto-approve
REJECT_THRESHOLD = 0.3 # Score < 0.3 -> auto-reject
def load_tool_config(path: Path) -> Optional[dict]:
"""Load tool configuration from YAML file."""
if path.is_dir():
config_file = path / "config.yaml"
else:
config_file = path
if not config_file.exists():
return None
with open(config_file) as f:
return yaml.safe_load(f)
def vet_honesty(config: dict) -> VetScore:
"""Check if tool description matches what it actually does."""
score = VetScore(criterion="honesty", score=0.0, notes="")
concerns = []
name = config.get("name", "")
description = config.get("description", "")
steps = config.get("steps", [])
# Check that description exists
if not description:
concerns.append("Missing description")
score.score = 0.3
else:
score.score = 0.6
# Check that steps exist
if not steps:
concerns.append("No execution steps defined")
score.score = min(score.score, 0.2)
else:
# Check if description keywords appear in prompts
desc_words = set(description.lower().split())
prompt_text = ""
for step in steps:
if step.get("type") == "prompt":
prompt_text += step.get("prompt", "").lower() + " "
# Simple keyword overlap check
prompt_words = set(prompt_text.split())
overlap = desc_words & prompt_words
meaningful_overlap = overlap - {"the", "a", "an", "and", "or", "is", "to", "for", "of", "in"}
if len(meaningful_overlap) >= 2:
score.score = min(1.0, score.score + 0.3)
score.notes = f"Description matches prompt content ({len(meaningful_overlap)} keywords)"
else:
concerns.append("Description may not match actual behavior")
score.concerns = concerns
return score
def vet_transparency(config: dict) -> VetScore:
"""Check if the tool's behavior is clear and understandable."""
score = VetScore(criterion="transparency", score=0.0, notes="")
concerns = []
steps = config.get("steps", [])
if not steps:
concerns.append("No steps to analyze")
score.concerns = concerns
return score
# Analyze each step
total_prompt_length = 0
has_clear_instructions = False
for step in steps:
if step.get("type") == "prompt":
prompt = step.get("prompt", "")
total_prompt_length += len(prompt)
# Check for clear instruction patterns
instruction_patterns = [
"you are", "your task", "please", "analyze", "extract",
"summarize", "create", "write", "explain", "review"
]
prompt_lower = prompt.lower()
if any(p in prompt_lower for p in instruction_patterns):
has_clear_instructions = True
# Score based on findings
if has_clear_instructions:
score.score += 0.5
score.notes = "Contains clear instructions"
if total_prompt_length > 50:
score.score += 0.3
score.notes += "; Substantial prompt content"
elif total_prompt_length > 0:
score.score += 0.1
concerns.append("Very short prompt - may lack clarity")
# Check for output variable naming
for step in steps:
output_var = step.get("output_var", "")
if output_var and output_var != "response":
score.score += 0.2
score.notes += "; Descriptive output variable"
break
score.score = min(1.0, score.score)
score.concerns = concerns
return score
def vet_scope(config: dict) -> VetScore:
"""Check if tool is appropriately scoped (not too broad/narrow)."""
score = VetScore(criterion="scope", score=0.0, notes="")
concerns = []
description = config.get("description", "")
steps = config.get("steps", [])
arguments = config.get("arguments", [])
# Start with base score
score.score = 0.5
# Single-step tools are well-scoped
if len(steps) == 1:
score.score += 0.2
score.notes = "Single-step tool - focused scope"
elif len(steps) <= 3:
score.score += 0.1
score.notes = "Multi-step tool with reasonable complexity"
else:
concerns.append(f"Complex tool with {len(steps)} steps - may be over-scoped")
score.score -= 0.1
# Check for overly generic descriptions
generic_terms = ["everything", "anything", "all", "any task", "general purpose"]
desc_lower = description.lower()
if any(term in desc_lower for term in generic_terms):
concerns.append("Description suggests overly broad scope")
score.score -= 0.2
# Arguments indicate configurable scope (good)
if arguments:
score.score += 0.1
score.notes += "; Configurable via arguments"
score.score = max(0.0, min(1.0, score.score))
score.concerns = concerns
return score
def vet_efficiency(config: dict) -> VetScore:
"""Check if prompt is well-structured and efficient."""
score = VetScore(criterion="efficiency", score=0.0, notes="")
concerns = []
steps = config.get("steps", [])
# Analyze prompts
for step in steps:
if step.get("type") == "prompt":
prompt = step.get("prompt", "")
# Check for excessive repetition
words = prompt.lower().split()
word_counts = {}
for word in words:
if len(word) > 4: # Only check meaningful words
word_counts[word] = word_counts.get(word, 0) + 1
max_repetition = max(word_counts.values()) if word_counts else 0
if max_repetition > 5:
concerns.append(f"Repetitive language detected ({max_repetition}x)")
score.score = max(0.0, score.score - 0.2)
# Check for structured output hints
structure_patterns = [
"markdown", "json", "format", "structure", "sections",
"bullet", "numbered", "list", "table"
]
if any(p in prompt.lower() for p in structure_patterns):
score.score += 0.3
score.notes = "Specifies output structure"
# Reasonable length (not too short, not excessive)
if 100 <= len(prompt) <= 5000:
score.score += 0.4
elif len(prompt) < 100:
concerns.append("Very short prompt - may lack guidance")
score.score += 0.2
else:
concerns.append("Very long prompt - may be inefficient")
score.score += 0.2
# Base score if steps exist
if steps:
score.score += 0.3
score.score = min(1.0, score.score)
score.concerns = concerns
return score
def vet_safety(config: dict) -> VetScore:
"""Check for concerning patterns in the tool."""
score = VetScore(criterion="safety", score=1.0, notes="No safety concerns")
concerns = []
steps = config.get("steps", [])
# Check for code steps
code_step_count = 0
for step in steps:
if step.get("type") == "code":
code_step_count += 1
code = step.get("code", "")
# Check for potentially dangerous patterns
dangerous_patterns = [
("subprocess", "Executes shell commands"),
("os.system", "Executes shell commands"),
("eval(", "Dynamic code execution"),
("exec(", "Dynamic code execution"),
("open(", "File operations"),
("requests.", "Network requests"),
("urllib", "Network requests"),
("shutil.rmtree", "Recursive deletion"),
]
for pattern, concern in dangerous_patterns:
if pattern in code:
concerns.append(f"Code contains {concern.lower()}")
score.score -= 0.15
if code_step_count > 0:
score.notes = f"Contains {code_step_count} code step(s)"
if not concerns:
score.notes += " - no dangerous patterns detected"
score.score = max(0.0, score.score)
score.concerns = concerns
return score
def vet_tool(config: dict, tool_path: str) -> VetReport:
"""Perform complete vetting of a tool."""
name = config.get("name", "unknown")
# Run all checks
scores = [
vet_honesty(config),
vet_transparency(config),
vet_scope(config),
vet_efficiency(config),
vet_safety(config),
]
# Calculate overall score (weighted average)
weights = {
"honesty": 0.25,
"transparency": 0.20,
"scope": 0.15,
"efficiency": 0.15,
"safety": 0.25,
}
total_weight = sum(weights.values())
weighted_sum = sum(s.score * weights.get(s.criterion, 0.1) for s in scores)
overall_score = weighted_sum / total_weight
# Determine result
if overall_score >= APPROVE_THRESHOLD:
result = VetResult.APPROVE
elif overall_score < REJECT_THRESHOLD:
result = VetResult.REJECT
else:
result = VetResult.REVIEW
# Collect all concerns for suggestions
suggestions = []
for s in scores:
for concern in s.concerns:
suggestions.append(f"[{s.criterion}] {concern}")
return VetReport(
tool_name=name,
tool_path=tool_path,
result=result,
overall_score=overall_score,
scores=scores,
suggestions=suggestions,
)
def vet_directory(directory: Path, provider: Optional[str] = None) -> list[VetReport]:
"""Vet all tools in a directory."""
reports = []
for entry in directory.iterdir():
config_file = None
if entry.is_dir():
config_file = entry / "config.yaml"
elif entry.suffix in [".yaml", ".yml"]:
config_file = entry
if config_file and config_file.exists():
config = load_tool_config(config_file)
if config:
report = vet_tool(config, str(entry))
reports.append(report)
return reports
def print_report(report: VetReport, verbose: bool = False):
"""Print a vetting report to console."""
# Result emoji
result_emoji = {
VetResult.APPROVE: "✅",
VetResult.REVIEW: "⚠️",
VetResult.REJECT: "❌",
VetResult.ERROR: "💥",
}
emoji = result_emoji.get(report.result, "❓")
print(f"\n{emoji} {report.tool_name}: {report.result.value.upper()} (score: {report.overall_score:.2f})")
if verbose or report.result != VetResult.APPROVE:
print(f" Path: {report.tool_path}")
# Print individual scores
for score in report.scores:
bar = "█" * int(score.score * 10) + "░" * (10 - int(score.score * 10))
print(f" {score.criterion:12} [{bar}] {score.score:.2f}")
if score.concerns:
for concern in score.concerns:
print(f" ⚠ {concern}")
# Print suggestions
if report.suggestions and verbose:
print(" Suggestions:")
for suggestion in report.suggestions:
print(f" • {suggestion}")
def main():
parser = argparse.ArgumentParser(
description="Vet CmdForge tools for quality and safety",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
"path",
type=Path,
nargs="?",
help="Tool config file or directory to vet"
)
parser.add_argument(
"--all",
action="store_true",
help="Vet all tools in directory"
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show detailed output"
)
parser.add_argument(
"--provider",
default=None,
help="AI provider for enhanced analysis (future feature)"
)
args = parser.parse_args()
if not args.path:
parser.error("Please specify a path to vet")
# Collect reports
reports = []
if args.all or args.path.is_dir():
reports = vet_directory(args.path, args.provider)
else:
config = load_tool_config(args.path)
if not config:
print(f"Error: Could not load tool config from {args.path}", file=sys.stderr)
return 1
report = vet_tool(config, str(args.path))
reports.append(report)
if not reports:
print("No tools found to vet", file=sys.stderr)
return 1
# Output
if args.json:
output = [r.to_dict() for r in reports]
print(json.dumps(output, indent=2))
else:
# Summary
approved = sum(1 for r in reports if r.result == VetResult.APPROVE)
review = sum(1 for r in reports if r.result == VetResult.REVIEW)
rejected = sum(1 for r in reports if r.result == VetResult.REJECT)
print(f"Vetting {len(reports)} tool(s)...")
for report in reports:
print_report(report, args.verbose)
print(f"\n{'─' * 40}")
print(f"Summary: {approved} approved, {review} need review, {rejected} rejected")
# Return code based on results
if rejected > 0:
return 2
elif review > 0:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+441
View File
@@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""
Duplicate detection for CmdForge tools using text similarity.
Finds tools that may be duplicates or very similar to existing tools.
Uses TF-IDF vectorization and cosine similarity for comparison.
Usage:
# Check a tool against all existing tools
python scripts/similarity.py path/to/tool/config.yaml
# Check against tools in a specific directory
python scripts/similarity.py path/to/tool/config.yaml --against ~/.cmdforge/
# Find all similar pairs in a directory
python scripts/similarity.py --scan ~/.cmdforge/
# Set similarity threshold (default: 0.7)
python scripts/similarity.py --threshold 0.8 path/to/tool/config.yaml
# Output as JSON
python scripts/similarity.py --json path/to/tool/config.yaml
"""
import argparse
import json
import math
import re
import sys
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import yaml
@dataclass
class SimilarityMatch:
"""A similarity match between two tools."""
tool1_name: str
tool1_path: str
tool2_name: str
tool2_path: str
similarity: float # 0.0 to 1.0
match_type: str # "duplicate", "similar", "related"
def to_dict(self) -> dict:
return {
"tool1": {"name": self.tool1_name, "path": self.tool1_path},
"tool2": {"name": self.tool2_name, "path": self.tool2_path},
"similarity": self.similarity,
"match_type": self.match_type,
}
@dataclass
class ToolText:
"""Extracted text from a tool for similarity comparison."""
name: str
path: str
text: str
tokens: list[str] = field(default_factory=list)
tfidf: dict = field(default_factory=dict)
# Similarity thresholds
DUPLICATE_THRESHOLD = 0.9 # >= 0.9 is likely a duplicate
SIMILAR_THRESHOLD = 0.7 # >= 0.7 is very similar
RELATED_THRESHOLD = 0.5 # >= 0.5 is related
def load_tool_config(path: Path) -> Optional[dict]:
"""Load tool configuration from YAML file."""
if path.is_dir():
config_file = path / "config.yaml"
else:
config_file = path
if not config_file.exists():
return None
with open(config_file) as f:
return yaml.safe_load(f)
def extract_tool_text(config: dict, path: str) -> ToolText:
"""Extract all meaningful text from a tool config."""
texts = []
# Name and description
name = config.get("name", "")
texts.append(name)
texts.append(config.get("description", ""))
# Category and tags
texts.append(config.get("category", ""))
tags = config.get("tags", [])
if isinstance(tags, list):
texts.extend(tags)
# Steps - extract prompts and code
for step in config.get("steps", []):
if step.get("type") == "prompt":
texts.append(step.get("prompt", ""))
elif step.get("type") == "code":
# Extract meaningful parts from code (comments, strings)
code = step.get("code", "")
# Add variable names
texts.append(code)
# Arguments
for arg in config.get("arguments", []):
texts.append(arg.get("flag", ""))
texts.append(arg.get("description", ""))
texts.append(arg.get("variable", ""))
# Combine and clean
combined = " ".join(texts)
return ToolText(name=name, path=path, text=combined)
def tokenize(text: str) -> list[str]:
"""Tokenize text into words, removing stopwords."""
# Convert to lowercase and extract words
words = re.findall(r'\b[a-z]{2,}\b', text.lower())
# Remove common stopwords
stopwords = {
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for",
"of", "with", "by", "from", "as", "is", "was", "are", "were", "been",
"be", "have", "has", "had", "do", "does", "did", "will", "would",
"could", "should", "may", "might", "must", "shall", "can", "this",
"that", "these", "those", "it", "its", "you", "your", "we", "our",
"they", "their", "he", "his", "she", "her", "if", "then", "else",
"when", "where", "which", "who", "what", "how", "all", "each",
"every", "both", "few", "more", "most", "other", "some", "such",
"no", "not", "only", "same", "so", "than", "too", "very", "just",
"also", "now", "here", "there", "any", "into", "out", "up", "down",
}
return [w for w in words if w not in stopwords]
def compute_tfidf(documents: list[ToolText]) -> None:
"""Compute TF-IDF vectors for all documents (modifies in place)."""
# Tokenize all documents
for doc in documents:
doc.tokens = tokenize(doc.text)
# Compute document frequencies
doc_freq = Counter()
for doc in documents:
unique_tokens = set(doc.tokens)
doc_freq.update(unique_tokens)
num_docs = len(documents)
# Compute TF-IDF for each document
for doc in documents:
term_freq = Counter(doc.tokens)
total_terms = len(doc.tokens) or 1
tfidf = {}
for term, count in term_freq.items():
tf = count / total_terms
# Add 1 to avoid division by zero
idf = math.log((num_docs + 1) / (doc_freq[term] + 1)) + 1
tfidf[term] = tf * idf
doc.tfidf = tfidf
def cosine_similarity(vec1: dict, vec2: dict) -> float:
"""Compute cosine similarity between two TF-IDF vectors."""
# Get all terms
all_terms = set(vec1.keys()) | set(vec2.keys())
if not all_terms:
return 0.0
# Compute dot product and magnitudes
dot_product = 0.0
mag1 = 0.0
mag2 = 0.0
for term in all_terms:
v1 = vec1.get(term, 0.0)
v2 = vec2.get(term, 0.0)
dot_product += v1 * v2
mag1 += v1 * v1
mag2 += v2 * v2
mag1 = math.sqrt(mag1)
mag2 = math.sqrt(mag2)
if mag1 == 0 or mag2 == 0:
return 0.0
return dot_product / (mag1 * mag2)
def classify_similarity(score: float) -> str:
"""Classify similarity score into a match type."""
if score >= DUPLICATE_THRESHOLD:
return "duplicate"
elif score >= SIMILAR_THRESHOLD:
return "similar"
elif score >= RELATED_THRESHOLD:
return "related"
return "different"
def find_similar(tool: ToolText, corpus: list[ToolText], threshold: float = RELATED_THRESHOLD) -> list[SimilarityMatch]:
"""Find tools in corpus similar to the given tool."""
matches = []
for other in corpus:
if other.path == tool.path:
continue
similarity = cosine_similarity(tool.tfidf, other.tfidf)
if similarity >= threshold:
match_type = classify_similarity(similarity)
matches.append(SimilarityMatch(
tool1_name=tool.name,
tool1_path=tool.path,
tool2_name=other.name,
tool2_path=other.path,
similarity=similarity,
match_type=match_type,
))
# Sort by similarity descending
matches.sort(key=lambda m: m.similarity, reverse=True)
return matches
def scan_directory(directory: Path, threshold: float = RELATED_THRESHOLD) -> list[SimilarityMatch]:
"""Scan a directory for similar tool pairs."""
# Load all tools
tools = []
for entry in directory.iterdir():
config_file = None
if entry.is_dir():
config_file = entry / "config.yaml"
elif entry.suffix in [".yaml", ".yml"]:
config_file = entry
if config_file and config_file.exists():
config = load_tool_config(config_file)
if config:
tool_text = extract_tool_text(config, str(entry))
tools.append(tool_text)
if len(tools) < 2:
return []
# Compute TF-IDF
compute_tfidf(tools)
# Find all similar pairs
matches = []
seen_pairs = set()
for tool in tools:
for other in tools:
if tool.path >= other.path: # Avoid duplicates
continue
pair_key = (tool.path, other.path)
if pair_key in seen_pairs:
continue
seen_pairs.add(pair_key)
similarity = cosine_similarity(tool.tfidf, other.tfidf)
if similarity >= threshold:
match_type = classify_similarity(similarity)
matches.append(SimilarityMatch(
tool1_name=tool.name,
tool1_path=tool.path,
tool2_name=other.name,
tool2_path=other.path,
similarity=similarity,
match_type=match_type,
))
# Sort by similarity descending
matches.sort(key=lambda m: m.similarity, reverse=True)
return matches
def load_corpus(directory: Path) -> list[ToolText]:
"""Load all tools from a directory as ToolText objects."""
tools = []
for entry in directory.iterdir():
config_file = None
if entry.is_dir():
config_file = entry / "config.yaml"
elif entry.suffix in [".yaml", ".yml"]:
config_file = entry
if config_file and config_file.exists():
config = load_tool_config(config_file)
if config:
tool_text = extract_tool_text(config, str(entry))
tools.append(tool_text)
return tools
def print_match(match: SimilarityMatch, verbose: bool = False):
"""Print a similarity match to console."""
# Match type emoji
type_emoji = {
"duplicate": "🔴",
"similar": "🟠",
"related": "🟡",
}
emoji = type_emoji.get(match.match_type, "⚪")
bar = "█" * int(match.similarity * 20) + "░" * (20 - int(match.similarity * 20))
print(f" {emoji} {match.match_type.upper()} [{bar}] {match.similarity:.2%}")
print(f" {match.tool1_name} <-> {match.tool2_name}")
if verbose:
print(f" Paths: {match.tool1_path}")
print(f" {match.tool2_path}")
def main():
parser = argparse.ArgumentParser(
description="Find similar/duplicate CmdForge tools",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
"path",
type=Path,
nargs="?",
help="Tool config file to check"
)
parser.add_argument(
"--against",
type=Path,
default=Path.home() / ".cmdforge",
help="Directory to compare against (default: ~/.cmdforge)"
)
parser.add_argument(
"--scan",
type=Path,
metavar="DIR",
help="Scan directory for all similar pairs"
)
parser.add_argument(
"--threshold", "-t",
type=float,
default=RELATED_THRESHOLD,
help=f"Similarity threshold (default: {RELATED_THRESHOLD})"
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show detailed output"
)
args = parser.parse_args()
matches = []
if args.scan:
# Scan mode - find all similar pairs
print(f"Scanning {args.scan} for similar tools...")
matches = scan_directory(args.scan, args.threshold)
elif args.path:
# Check single tool against corpus
config = load_tool_config(args.path)
if not config:
print(f"Error: Could not load tool config from {args.path}", file=sys.stderr)
return 1
tool_text = extract_tool_text(config, str(args.path))
# Load corpus
corpus = load_corpus(args.against)
if not corpus:
print(f"No tools found in {args.against}", file=sys.stderr)
return 1
# Add the new tool to corpus for TF-IDF computation
all_tools = corpus + [tool_text]
compute_tfidf(all_tools)
print(f"Checking {tool_text.name} against {len(corpus)} existing tools...")
matches = find_similar(tool_text, corpus, args.threshold)
else:
parser.error("Specify a tool path or use --scan")
# Output
if args.json:
output = [m.to_dict() for m in matches]
print(json.dumps(output, indent=2))
else:
if not matches:
print("\n✅ No similar tools found above threshold")
else:
print(f"\nFound {len(matches)} match(es):\n")
for match in matches:
print_match(match, args.verbose)
# Summary by type
duplicates = sum(1 for m in matches if m.match_type == "duplicate")
similar = sum(1 for m in matches if m.match_type == "similar")
related = sum(1 for m in matches if m.match_type == "related")
print(f"\n{'─' * 40}")
print(f"Summary: {duplicates} duplicates, {similar} similar, {related} related")
# Return code
has_duplicates = any(m.match_type == "duplicate" for m in matches)
has_similar = any(m.match_type == "similar" for m in matches)
if has_duplicates:
return 2
elif has_similar:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Sync registry tools into the database.
Usage: python scripts/sync_to_db.py /path/to/registry/repo
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Dict, Any
import yaml
# Allow running from repo root
sys.path.append(str(Path(__file__).resolve().parents[1] / "src"))
from cmdforge.registry.db import connect_db, query_one
from cmdforge.registry.sync import ensure_publisher, normalize_tags
def load_yaml(path: Path) -> Dict[str, Any]:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def sync_repo(repo_root: Path) -> int:
tools_root = repo_root / "tools"
if not tools_root.exists():
print(f"Missing tools directory at {tools_root}")
return 1
conn = connect_db()
synced = 0
skipped = 0
try:
for config_path in tools_root.glob("*/*/config.yaml"):
owner = config_path.parent.parent.name
name = config_path.parent.name
config_text = config_path.read_text(encoding="utf-8")
data = load_yaml(config_path)
version = (data.get("version") or "").strip()
if not version:
skipped += 1
continue
existing = query_one(
conn,
"SELECT id FROM tools WHERE owner = ? AND name = ? AND version = ?",
[owner, name, version],
)
if existing:
skipped += 1
continue
readme_path = config_path.parent / "README.md"
readme_text = readme_path.read_text(encoding="utf-8") if readme_path.exists() else ""
publisher_id = ensure_publisher(conn, owner)
tags = normalize_tags(data.get("tags"))
conn.execute(
"""
INSERT INTO tools (
owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
owner,
name,
version,
data.get("description"),
data.get("category"),
tags,
config_text,
readme_text,
publisher_id,
int(bool(data.get("deprecated"))),
data.get("deprecated_message"),
data.get("replacement"),
int((data.get("registry") or {}).get("downloads", 0) or 0),
(data.get("registry") or {}).get("published_at"),
],
)
synced += 1
conn.commit()
finally:
conn.close()
print(f"Synced: {synced}")
print(f"Skipped (existing/invalid): {skipped}")
return 0
def main() -> int:
if len(sys.argv) < 2:
print("Usage: python scripts/sync_to_db.py /path/to/registry/repo")
return 1
repo_root = Path(sys.argv[1])
return sync_repo(repo_root)
if __name__ == "__main__":
raise SystemExit(main())
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Test script for NodeGraphQt integration."""
import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
from NodeGraphQt import NodeGraph, BaseNode
class InputNode(BaseNode):
"""Node representing tool input."""
__identifier__ = 'cmdforge.nodes'
NODE_NAME = 'Input'
def __init__(self):
super().__init__()
self.set_color(100, 100, 180) # Blue-ish
self.add_output('input', color=(180, 180, 250))
self.add_output('file', color=(180, 180, 250))
class PromptNode(BaseNode):
"""Node representing a prompt step."""
__identifier__ = 'cmdforge.nodes'
NODE_NAME = 'Prompt'
def __init__(self):
super().__init__()
self.set_color(80, 120, 180) # Indigo-ish
self.add_input('in', color=(180, 180, 250))
self.add_output('response', color=(180, 250, 180))
# Add properties
self.add_text_input('provider', 'Provider', text='claude')
self.add_text_input('output_var', 'Output Var', text='response')
class CodeNode(BaseNode):
"""Node representing a code step."""
__identifier__ = 'cmdforge.nodes'
NODE_NAME = 'Code'
def __init__(self):
super().__init__()
self.set_color(80, 160, 100) # Green-ish
self.add_input('in', color=(180, 250, 180))
self.add_output('result', color=(250, 220, 180))
# Add properties
self.add_text_input('output_var', 'Output Var', text='result')
class OutputNode(BaseNode):
"""Node representing tool output."""
__identifier__ = 'cmdforge.nodes'
NODE_NAME = 'Output'
def __init__(self):
super().__init__()
self.set_color(180, 120, 80) # Orange-ish
self.add_input('in', color=(250, 220, 180))
def main():
app = QApplication(sys.argv)
# Create main window
window = QMainWindow()
window.setWindowTitle('CmdForge Flow Visualization - Prototype')
window.setGeometry(100, 100, 1000, 700)
# Create node graph
graph = NodeGraph()
# Register our custom nodes
graph.register_node(InputNode)
graph.register_node(PromptNode)
graph.register_node(CodeNode)
graph.register_node(OutputNode)
# Get the graph widget
graph_widget = graph.widget
# Set as central widget
window.setCentralWidget(graph_widget)
# Create a sample tool visualization
input_node = graph.create_node('cmdforge.nodes.InputNode', name='Input')
input_node.set_pos(-300, 0)
prompt_node = graph.create_node('cmdforge.nodes.PromptNode', name='Summarize')
prompt_node.set_pos(-50, 0)
prompt_node.set_property('provider', 'claude')
prompt_node.set_property('output_var', 'response')
code_node = graph.create_node('cmdforge.nodes.CodeNode', name='Format')
code_node.set_pos(200, 0)
code_node.set_property('output_var', 'formatted')
output_node = graph.create_node('cmdforge.nodes.OutputNode', name='Output')
output_node.set_pos(450, 0)
# Connect nodes
input_node.output(0).connect_to(prompt_node.input(0))
prompt_node.output(0).connect_to(code_node.input(0))
code_node.output(0).connect_to(output_node.input(0))
# Auto-layout
graph.auto_layout_nodes()
# Fit view
graph.fit_to_selection()
window.show()
print("Flow visualization prototype running...")
print("Nodes created: Input -> Prompt -> Code -> Output")
print("Close the window to exit.")
sys.exit(app.exec())
if __name__ == '__main__':
main()
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Validate a registry tool submission.
Usage: python scripts/validate_tool.py path/to/tool
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import List
import yaml
TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+.+)?$")
REQUIRED_README_SECTIONS = ["## Usage", "## Examples"]
def find_repo_root(start: Path) -> Path | None:
current = start.resolve()
while current != current.parent:
if (current / "categories" / "categories.yaml").exists():
return current
current = current.parent
if (current / "categories" / "categories.yaml").exists():
return current
return None
def load_categories(repo_root: Path) -> List[str]:
categories_path = repo_root / "categories" / "categories.yaml"
data = yaml.safe_load(categories_path.read_text(encoding="utf-8")) or {}
categories = data.get("categories", [])
return [c.get("name") for c in categories if c.get("name")]
def validate_tool(tool_path: Path) -> List[str]:
errors: List[str] = []
if tool_path.is_dir():
config_path = tool_path / "config.yaml"
readme_path = tool_path / "README.md"
else:
config_path = tool_path
readme_path = tool_path.parent / "README.md"
if not config_path.exists():
return [f"Missing config.yaml at {config_path}"]
try:
config_text = config_path.read_text(encoding="utf-8")
data = yaml.safe_load(config_text) or {}
except Exception as exc:
return [f"Invalid YAML in config.yaml: {exc}"]
name = (data.get("name") or "").strip()
version = (data.get("version") or "").strip()
description = (data.get("description") or "").strip()
category = (data.get("category") or "").strip()
if not name:
errors.append("Missing required field: name")
elif not TOOL_NAME_RE.match(name):
errors.append("Tool name must match ^[A-Za-z0-9-]{1,64}$")
if not version:
errors.append("Missing required field: version")
elif not SEMVER_RE.match(version):
errors.append("Version must be valid semver (MAJOR.MINOR.PATCH)")
if not description:
errors.append("Missing required field: description")
repo_root = find_repo_root(tool_path)
if repo_root:
categories = load_categories(repo_root)
if category and category not in categories:
errors.append(f"Unknown category '{category}' (not in categories.yaml)")
else:
if category:
errors.append("Cannot validate category (categories.yaml not found)")
if not readme_path.exists():
errors.append(f"Missing README.md at {readme_path}")
else:
readme_text = readme_path.read_text(encoding="utf-8")
for section in REQUIRED_README_SECTIONS:
if section not in readme_text:
errors.append(f"README.md missing section: {section}")
return errors
def main() -> int:
if len(sys.argv) < 2:
print("Usage: python scripts/validate_tool.py path/to/tool")
return 1
tool_path = Path(sys.argv[1])
errors = validate_tool(tool_path)
if errors:
print("Validation failed:")
for err in errors:
print(f"- {err}")
return 1
print("Validation passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""
Auto-vetting pipeline for CmdForge tool submissions.
Combines scrutiny (quality checks) and similarity (duplicate detection)
to automatically triage incoming tools:
- Auto-approve: High quality, no duplicates
- Auto-reject: Low quality or exact duplicates
- Review queue: Needs human review
Usage:
# Vet a single tool
python scripts/vet_pipeline.py path/to/tool/config.yaml
# Vet with custom thresholds
python scripts/vet_pipeline.py --approve-threshold 0.85 path/to/tool/config.yaml
# Process all tools in import directory
python scripts/vet_pipeline.py --batch /tmp/fabric-import/
# Output detailed JSON report
python scripts/vet_pipeline.py --json path/to/tool/config.yaml
"""
import argparse
import json
import sys
from dataclasses import dataclass, asdict
from enum import Enum
from pathlib import Path
from typing import Optional
# Import our vetting modules
from scrutiny import (
VetResult as ScrutinyResult,
VetReport,
load_tool_config,
vet_tool,
)
from similarity import (
ToolText,
SimilarityMatch,
extract_tool_text,
load_corpus,
compute_tfidf,
find_similar,
DUPLICATE_THRESHOLD,
SIMILAR_THRESHOLD,
)
class PipelineDecision(Enum):
"""Final pipeline decision."""
AUTO_APPROVE = "auto_approve"
AUTO_REJECT = "auto_reject"
NEEDS_REVIEW = "needs_review"
ERROR = "error"
@dataclass
class PipelineResult:
"""Complete pipeline result for a tool."""
tool_name: str
tool_path: str
decision: PipelineDecision
reason: str
scrutiny_report: Optional[VetReport] = None
similarity_matches: list = None
suggestions: list = None
def __post_init__(self):
if self.similarity_matches is None:
self.similarity_matches = []
if self.suggestions is None:
self.suggestions = []
def to_dict(self) -> dict:
d = {
"tool_name": self.tool_name,
"tool_path": self.tool_path,
"decision": self.decision.value,
"reason": self.reason,
"suggestions": self.suggestions,
}
if self.scrutiny_report:
d["scrutiny"] = self.scrutiny_report.to_dict()
if self.similarity_matches:
d["similar_tools"] = [m.to_dict() for m in self.similarity_matches]
return d
# Pipeline thresholds
DEFAULT_APPROVE_THRESHOLD = 0.8
DEFAULT_REJECT_THRESHOLD = 0.3
def run_pipeline(
tool_path: Path,
corpus_dir: Path,
approve_threshold: float = DEFAULT_APPROVE_THRESHOLD,
reject_threshold: float = DEFAULT_REJECT_THRESHOLD,
) -> PipelineResult:
"""Run the complete vetting pipeline on a tool."""
# Load tool config
config = load_tool_config(tool_path)
if not config:
return PipelineResult(
tool_name="unknown",
tool_path=str(tool_path),
decision=PipelineDecision.ERROR,
reason=f"Could not load tool config from {tool_path}",
)
tool_name = config.get("name", "unknown")
# Phase 1: Scrutiny (quality checks)
scrutiny_report = vet_tool(config, str(tool_path))
# Phase 2: Similarity check
tool_text = extract_tool_text(config, str(tool_path))
corpus = load_corpus(corpus_dir) if corpus_dir.exists() else []
similarity_matches = []
if corpus:
all_tools = corpus + [tool_text]
compute_tfidf(all_tools)
similarity_matches = find_similar(tool_text, corpus, threshold=0.5)
# Phase 3: Decision logic
decision, reason, suggestions = make_decision(
scrutiny_report,
similarity_matches,
approve_threshold,
reject_threshold,
)
return PipelineResult(
tool_name=tool_name,
tool_path=str(tool_path),
decision=decision,
reason=reason,
scrutiny_report=scrutiny_report,
similarity_matches=similarity_matches,
suggestions=suggestions,
)
def make_decision(
scrutiny: VetReport,
similarity_matches: list[SimilarityMatch],
approve_threshold: float,
reject_threshold: float,
) -> tuple[PipelineDecision, str, list[str]]:
"""Make final decision based on scrutiny and similarity results."""
suggestions = list(scrutiny.suggestions) if scrutiny.suggestions else []
score = scrutiny.overall_score
# Check for exact duplicates first
duplicates = [m for m in similarity_matches if m.match_type == "duplicate"]
if duplicates:
dupe = duplicates[0]
return (
PipelineDecision.AUTO_REJECT,
f"Duplicate of existing tool '{dupe.tool2_name}' ({dupe.similarity:.0%} match)",
suggestions + [f"Consider updating {dupe.tool2_name} instead of creating a new tool"],
)
# Check for very similar tools
similar = [m for m in similarity_matches if m.match_type == "similar"]
if similar:
# If high quality + similar, might be an improved version - needs review
if score >= approve_threshold:
sim = similar[0]
return (
PipelineDecision.NEEDS_REVIEW,
f"High quality but similar to '{sim.tool2_name}' ({sim.similarity:.0%})",
suggestions + [f"Review whether this improves on {sim.tool2_name}"],
)
else:
sim = similar[0]
return (
PipelineDecision.NEEDS_REVIEW,
f"Similar to existing tool '{sim.tool2_name}' ({sim.similarity:.0%})",
suggestions + [f"Consider if this duplicates {sim.tool2_name}"],
)
# No duplicates or very similar tools - decide based on quality
if scrutiny.result == ScrutinyResult.REJECT or score < reject_threshold:
return (
PipelineDecision.AUTO_REJECT,
f"Quality score too low ({score:.2f} < {reject_threshold})",
suggestions,
)
if scrutiny.result == ScrutinyResult.APPROVE and score >= approve_threshold:
return (
PipelineDecision.AUTO_APPROVE,
f"High quality score ({score:.2f}) with no similar tools",
suggestions,
)
# Middle ground - needs review
return (
PipelineDecision.NEEDS_REVIEW,
f"Quality score {score:.2f} - needs human review",
suggestions,
)
def print_result(result: PipelineResult, verbose: bool = False):
"""Print pipeline result to console."""
decision_emoji = {
PipelineDecision.AUTO_APPROVE: "✅",
PipelineDecision.AUTO_REJECT: "❌",
PipelineDecision.NEEDS_REVIEW: "⚠️",
PipelineDecision.ERROR: "💥",
}
decision_color = {
PipelineDecision.AUTO_APPROVE: "\033[92m", # Green
PipelineDecision.AUTO_REJECT: "\033[91m", # Red
PipelineDecision.NEEDS_REVIEW: "\033[93m", # Yellow
PipelineDecision.ERROR: "\033[91m", # Red
}
reset = "\033[0m"
emoji = decision_emoji.get(result.decision, "❓")
color = decision_color.get(result.decision, "")
print(f"\n{emoji} {color}{result.tool_name}: {result.decision.value.upper()}{reset}")
print(f" {result.reason}")
if verbose:
print(f" Path: {result.tool_path}")
if result.scrutiny_report:
print(f"\n Quality Scores:")
for score in result.scrutiny_report.scores:
bar = "█" * int(score.score * 10) + "░" * (10 - int(score.score * 10))
print(f" {score.criterion:12} [{bar}] {score.score:.2f}")
if result.similarity_matches:
print(f"\n Similar Tools:")
for match in result.similarity_matches[:3]:
print(f" • {match.tool2_name} ({match.similarity:.0%} {match.match_type})")
if result.suggestions:
print(f"\n Suggestions:")
for suggestion in result.suggestions[:5]:
print(f" • {suggestion}")
def main():
parser = argparse.ArgumentParser(
description="Run auto-vetting pipeline on CmdForge tools",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
"path",
type=Path,
nargs="?",
help="Tool config file or directory to vet"
)
parser.add_argument(
"--batch",
type=Path,
metavar="DIR",
help="Process all tools in directory"
)
parser.add_argument(
"--corpus",
type=Path,
default=Path.home() / ".cmdforge",
help="Directory of existing tools for comparison (default: ~/.cmdforge)"
)
parser.add_argument(
"--approve-threshold",
type=float,
default=DEFAULT_APPROVE_THRESHOLD,
help=f"Score threshold for auto-approve (default: {DEFAULT_APPROVE_THRESHOLD})"
)
parser.add_argument(
"--reject-threshold",
type=float,
default=DEFAULT_REJECT_THRESHOLD,
help=f"Score threshold for auto-reject (default: {DEFAULT_REJECT_THRESHOLD})"
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show detailed output"
)
args = parser.parse_args()
if not args.path and not args.batch:
parser.error("Specify a tool path or use --batch")
results = []
if args.batch:
# Batch mode
batch_dir = args.batch
if not batch_dir.exists():
print(f"Error: Directory {batch_dir} does not exist", file=sys.stderr)
return 1
print(f"Processing tools in {batch_dir}...")
for entry in sorted(batch_dir.iterdir()):
config_file = None
if entry.is_dir():
config_file = entry / "config.yaml"
elif entry.suffix in [".yaml", ".yml"]:
config_file = entry
if config_file and config_file.exists():
result = run_pipeline(
config_file,
args.corpus,
args.approve_threshold,
args.reject_threshold,
)
results.append(result)
else:
# Single tool mode
result = run_pipeline(
args.path,
args.corpus,
args.approve_threshold,
args.reject_threshold,
)
results.append(result)
# Output
if args.json:
output = [r.to_dict() for r in results]
print(json.dumps(output, indent=2))
else:
for result in results:
print_result(result, args.verbose)
# Summary
if len(results) > 1:
approved = sum(1 for r in results if r.decision == PipelineDecision.AUTO_APPROVE)
rejected = sum(1 for r in results if r.decision == PipelineDecision.AUTO_REJECT)
review = sum(1 for r in results if r.decision == PipelineDecision.NEEDS_REVIEW)
errors = sum(1 for r in results if r.decision == PipelineDecision.ERROR)
print(f"\n{'═' * 50}")
print(f"Pipeline Summary: {len(results)} tools processed")
print(f" ✅ Auto-approved: {approved}")
print(f" ⚠️ Needs review: {review}")
print(f" ❌ Auto-rejected: {rejected}")
if errors:
print(f" 💥 Errors: {errors}")
# Return codes
has_rejected = any(r.decision == PipelineDecision.AUTO_REJECT for r in results)
has_review = any(r.decision == PipelineDecision.NEEDS_REVIEW for r in results)
if has_rejected:
return 2
elif has_review:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://pages.brrd.tech/rob/CmdForge/architecture/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/goals/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/ideas-and-exploration/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/milestones/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url><url><loc>https://pages.brrd.tech/rob/CmdForge/</loc><changefreq>weekly</changefreq><priority>0.5</priority></url></urlset>
+3
View File
@@ -0,0 +1,3 @@
"""CmdForge - A lightweight personal tool builder for AI-powered CLI commands."""
__version__ = "0.1.0"
+11
View File
@@ -0,0 +1,11 @@
"""CLI entry point for CmdForge.
This module is a thin wrapper for backwards compatibility.
The actual implementation is in the cli/ package.
"""
from .cli import main
if __name__ == "__main__":
import sys
sys.exit(main())
+271
View File
@@ -0,0 +1,271 @@
"""CLI entry point for CmdForge."""
import argparse
import sys
from .. import __version__
from .tool_commands import (
cmd_list, cmd_create, cmd_edit, cmd_delete, cmd_test, cmd_run,
cmd_ui, cmd_refresh, cmd_docs, cmd_check
)
from .provider_commands import cmd_providers
from .registry_commands import cmd_registry
from .project_commands import cmd_deps, cmd_install_deps, cmd_add, cmd_init
from .config_commands import cmd_config
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
prog="cmdforge",
description="A lightweight personal tool builder for AI-powered CLI commands"
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# No command = launch UI
# 'list' command
p_list = subparsers.add_parser("list", help="List all tools")
p_list.set_defaults(func=cmd_list)
# 'create' command
p_create = subparsers.add_parser("create", help="Create a new tool")
p_create.add_argument("name", help="Tool name")
p_create.add_argument("-d", "--description", help="Tool description")
p_create.add_argument("-p", "--prompt", help="Prompt template")
p_create.add_argument("--provider", help="AI provider (default: mock)")
p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
p_create.set_defaults(func=cmd_create)
# 'edit' command
p_edit = subparsers.add_parser("edit", help="Edit a tool config")
p_edit.add_argument("name", help="Tool name")
p_edit.set_defaults(func=cmd_edit)
# 'delete' command
p_delete = subparsers.add_parser("delete", help="Delete a tool")
p_delete.add_argument("name", help="Tool name")
p_delete.add_argument("-f", "--force", action="store_true", help="Skip confirmation")
p_delete.set_defaults(func=cmd_delete)
# 'test' command
p_test = subparsers.add_parser("test", help="Test a tool with mock provider")
p_test.add_argument("name", help="Tool name")
p_test.add_argument("-i", "--input", help="Input file for testing")
p_test.add_argument("--dry-run", action="store_true", help="Show prompt only")
p_test.set_defaults(func=cmd_test)
# 'run' command
p_run = subparsers.add_parser("run", help="Run a tool")
p_run.add_argument("name", help="Tool name")
p_run.add_argument("-i", "--input", help="Input file (reads from stdin if piped)")
p_run.add_argument("-o", "--output", help="Output file (writes to stdout if omitted)")
p_run.add_argument("--stdin", action="store_true", help="Read input interactively (type then Ctrl+D)")
p_run.add_argument("-p", "--provider", help="Override provider")
p_run.add_argument("--dry-run", action="store_true", help="Show what would happen without executing")
p_run.add_argument("--show-prompt", action="store_true", help="Show prompts in addition to output")
p_run.add_argument("-v", "--verbose", action="store_true", help="Show debug information")
p_run.add_argument("tool_args", nargs="*", help="Additional tool-specific arguments")
p_run.set_defaults(func=cmd_run)
# 'ui' command (explicit)
p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
p_ui.set_defaults(func=cmd_ui)
# 'refresh' command
p_refresh = subparsers.add_parser("refresh", help="Refresh all wrapper scripts")
p_refresh.set_defaults(func=cmd_refresh)
# 'docs' command
p_docs = subparsers.add_parser("docs", help="View or edit tool documentation")
p_docs.add_argument("name", help="Tool name")
p_docs.add_argument("-e", "--edit", action="store_true", help="Edit/create README in $EDITOR")
p_docs.set_defaults(func=cmd_docs)
# 'check' command
p_check = subparsers.add_parser("check", help="Check dependencies for a tool (meta-tools)")
p_check.add_argument("name", help="Tool name")
p_check.set_defaults(func=cmd_check)
# 'providers' command
p_providers = subparsers.add_parser("providers", help="Manage AI providers")
providers_sub = p_providers.add_subparsers(dest="providers_cmd", help="Provider commands")
# providers list
p_prov_list = providers_sub.add_parser("list", help="List all providers and their status")
p_prov_list.set_defaults(func=cmd_providers)
# providers check
p_prov_check = providers_sub.add_parser("check", help="Check which providers are available")
p_prov_check.set_defaults(func=cmd_providers)
# providers install
p_prov_install = providers_sub.add_parser("install", help="Interactive guide to install AI providers")
p_prov_install.set_defaults(func=cmd_providers)
# providers add
p_prov_add = providers_sub.add_parser("add", help="Add or update a provider")
p_prov_add.add_argument("name", help="Provider name")
p_prov_add.add_argument("command", help="Command to run (e.g., 'claude -p')")
p_prov_add.add_argument("-d", "--description", help="Provider description")
p_prov_add.set_defaults(func=cmd_providers)
# providers remove
p_prov_remove = providers_sub.add_parser("remove", help="Remove a provider")
p_prov_remove.add_argument("name", help="Provider name")
p_prov_remove.set_defaults(func=cmd_providers)
# providers test
p_prov_test = providers_sub.add_parser("test", help="Test a provider")
p_prov_test.add_argument("name", help="Provider name")
p_prov_test.set_defaults(func=cmd_providers)
# Default for providers with no subcommand
p_providers.set_defaults(func=lambda args: cmd_providers(args) if args.providers_cmd else (setattr(args, 'providers_cmd', 'list') or cmd_providers(args)))
# -------------------------------------------------------------------------
# Registry Commands
# -------------------------------------------------------------------------
p_registry = subparsers.add_parser("registry", help="Registry commands (search, install, publish)")
registry_sub = p_registry.add_subparsers(dest="registry_cmd", help="Registry commands")
# registry search
p_reg_search = registry_sub.add_parser("search", help="Search for tools")
p_reg_search.add_argument("query", help="Search query")
p_reg_search.add_argument("-c", "--category", help="Filter by category")
p_reg_search.add_argument("-t", "--tag", action="append", dest="tags", help="Filter by tag (repeatable, AND logic)")
p_reg_search.add_argument("-o", "--owner", help="Filter by publisher/owner")
p_reg_search.add_argument("--min-downloads", type=int, help="Minimum downloads")
p_reg_search.add_argument("--popular", action="store_true", help="Shortcut for --min-downloads 100")
p_reg_search.add_argument("--new", action="store_true", help="Shortcut for --max-downloads 10")
p_reg_search.add_argument("--since", help="Published after date (YYYY-MM-DD)")
p_reg_search.add_argument("--before", help="Published before date (YYYY-MM-DD)")
p_reg_search.add_argument("-s", "--sort", choices=["relevance", "downloads", "published_at", "name"], default="relevance", help="Sort by field")
p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)")
p_reg_search.add_argument("--json", action="store_true", help="Output as JSON")
p_reg_search.add_argument("--show-facets", action="store_true", help="Show category/tag counts")
p_reg_search.add_argument("--deprecated", action="store_true", help="Include deprecated tools")
p_reg_search.set_defaults(func=cmd_registry)
# registry tags
p_reg_tags = registry_sub.add_parser("tags", help="List available tags")
p_reg_tags.add_argument("-c", "--category", help="Filter tags by category")
p_reg_tags.add_argument("-l", "--limit", type=int, default=50, help="Max tags to show (default: 50)")
p_reg_tags.add_argument("--json", action="store_true", help="Output as JSON")
p_reg_tags.set_defaults(func=cmd_registry)
# registry install
p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry")
p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)")
p_reg_install.add_argument("-v", "--version", help="Version constraint")
p_reg_install.set_defaults(func=cmd_registry)
# registry uninstall
p_reg_uninstall = registry_sub.add_parser("uninstall", help="Uninstall a tool")
p_reg_uninstall.add_argument("tool", help="Tool to uninstall (owner/name)")
p_reg_uninstall.set_defaults(func=cmd_registry)
# registry info
p_reg_info = registry_sub.add_parser("info", help="Show tool information")
p_reg_info.add_argument("tool", help="Tool name (owner/name)")
p_reg_info.set_defaults(func=cmd_registry)
# registry update
p_reg_update = registry_sub.add_parser("update", help="Update local index cache")
p_reg_update.set_defaults(func=cmd_registry)
# registry publish
p_reg_publish = registry_sub.add_parser("publish", help="Publish a tool to registry")
p_reg_publish.add_argument("path", nargs="?", help="Path to tool directory (default: current dir)")
p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing")
p_reg_publish.set_defaults(func=cmd_registry)
# registry my-tools
p_reg_mytools = registry_sub.add_parser("my-tools", help="List your published tools")
p_reg_mytools.set_defaults(func=cmd_registry)
# registry browse
p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)")
p_reg_browse.set_defaults(func=cmd_registry)
# registry config (admin settings management)
p_reg_config = registry_sub.add_parser("config", help="Manage registry settings (admin)")
p_reg_config.add_argument("action", nargs="?", choices=["list", "get", "set"], default="list",
help="Action to perform (default: list)")
p_reg_config.add_argument("key", nargs="?", help="Setting key (for get/set)")
p_reg_config.add_argument("value", nargs="?", help="Setting value (for set)")
p_reg_config.add_argument("--json", action="store_true", help="Output as JSON")
p_reg_config.add_argument("--category", "-c", help="Filter by category (for list)")
p_reg_config.set_defaults(func=cmd_registry)
# 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)))
# -------------------------------------------------------------------------
# Project Commands
# -------------------------------------------------------------------------
# 'deps' command
p_deps = subparsers.add_parser("deps", help="Show project dependencies")
p_deps.set_defaults(func=cmd_deps)
# 'install' command (for dependencies)
p_install = subparsers.add_parser("install", help="Install dependencies from cmdforge.yaml")
p_install.set_defaults(func=cmd_install_deps)
# 'add' command
p_add = subparsers.add_parser("add", help="Add a tool to project dependencies")
p_add.add_argument("tool", help="Tool to add (owner/name)")
p_add.add_argument("-v", "--version", help="Version constraint (default: *)")
p_add.add_argument("--no-install", action="store_true", help="Don't install after adding")
p_add.set_defaults(func=cmd_add)
# 'init' command
p_init = subparsers.add_parser("init", help="Initialize cmdforge.yaml")
p_init.add_argument("-n", "--name", help="Project name")
p_init.add_argument("-v", "--version", help="Project version")
p_init.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
p_init.set_defaults(func=cmd_init)
# -------------------------------------------------------------------------
# Config Commands
# -------------------------------------------------------------------------
p_config = subparsers.add_parser("config", help="Manage configuration")
config_sub = p_config.add_subparsers(dest="config_cmd", help="Config commands")
# config show
p_cfg_show = config_sub.add_parser("show", help="Show current configuration")
p_cfg_show.set_defaults(func=cmd_config)
# config set-token
p_cfg_token = config_sub.add_parser("set-token", help="Set registry authentication token")
p_cfg_token.add_argument("token", help="Registry token")
p_cfg_token.set_defaults(func=cmd_config)
# config set
p_cfg_set = config_sub.add_parser("set", help="Set a configuration value")
p_cfg_set.add_argument("key", help="Config key")
p_cfg_set.add_argument("value", help="Config value")
p_cfg_set.set_defaults(func=cmd_config)
# config connect
p_cfg_connect = config_sub.add_parser("connect", help="Connect this app to your CmdForge account")
p_cfg_connect.add_argument("username", help="Your CmdForge username")
p_cfg_connect.set_defaults(func=cmd_config)
# Default for config with no subcommand
p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(args)))
args = parser.parse_args()
# If no command, launch UI
if args.command is None:
return cmd_ui(args)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -0,0 +1,7 @@
"""Allow running the CLI as a module."""
import sys
from . import main
if __name__ == "__main__":
sys.exit(main())
+167
View File
@@ -0,0 +1,167 @@
"""Configuration commands."""
import socket
import sys
import time
from ..config import load_config, save_config, set_registry_token, get_registry_url
def cmd_config(args):
"""Manage CmdForge configuration."""
if args.config_cmd == "show":
return _cmd_config_show(args)
elif args.config_cmd == "set-token":
return _cmd_config_set_token(args)
elif args.config_cmd == "set":
return _cmd_config_set(args)
elif args.config_cmd == "connect":
return _cmd_config_connect(args)
else:
print("Config commands:")
print(" show Show current configuration")
print(" connect <username> Connect this app to your CmdForge account")
print(" set-token <token> Set registry authentication token")
print(" set <key> <value> Set a configuration value")
return 0
def _cmd_config_show(args):
"""Show current configuration."""
config = load_config()
print("CmdForge Configuration:")
print(f" Registry URL: {config.registry.url}")
print(f" Token: {'***' if config.registry.token else '(not set)'}")
print(f" Client ID: {config.client_id}")
print(f" Auto-fetch: {config.auto_fetch_from_registry}")
if config.default_provider:
print(f" Default provider: {config.default_provider}")
return 0
def _cmd_config_set_token(args):
"""Set registry authentication token."""
token = args.token
set_registry_token(token)
print("Registry token saved.")
return 0
def _cmd_config_set(args):
"""Set a configuration value."""
config = load_config()
key = args.key
value = args.value
if key == "auto_fetch":
config.auto_fetch_from_registry = value.lower() in ("true", "1", "yes")
elif key == "default_provider":
config.default_provider = value if value else None
elif key == "registry_url":
config.registry.url = value
else:
print(f"Unknown config key: {key}")
print("Available keys: auto_fetch, default_provider, registry_url")
return 1
save_config(config)
print(f"Set {key} = {value}")
return 0
def _cmd_config_connect(args):
"""Connect this app to a CmdForge account via web pairing."""
try:
import requests
except ImportError:
print("Error: requests library required. Install with: pip install requests")
return 1
username = args.username
hostname = socket.gethostname()
registry_url = get_registry_url()
# Remove trailing /api/v1 if present to get base URL
base_url = registry_url.rstrip("/")
if base_url.endswith("/api/v1"):
base_url = base_url[:-7]
pairing_url = f"{base_url}/api/v1/pairing/check/{username}"
print(f"Connecting to CmdForge as @{username}...")
print(f"Device: {hostname}")
print()
print("Waiting for approval from the web interface...")
print("Go to https://cmdforge.brrd.tech/dashboard/connected-apps")
print("and click 'Connect New App', then 'I've Run the Command'")
print()
print("Press Ctrl+C to cancel")
print()
# Poll for pairing status
max_attempts = 150 # 5 minutes at 2-second intervals
attempt = 0
try:
while attempt < max_attempts:
attempt += 1
try:
response = requests.get(
pairing_url,
params={"hostname": hostname},
timeout=10
)
if response.status_code == 200:
data = response.json()
status = data.get("data", {}).get("status")
if status == "connected":
token = data.get("data", {}).get("token")
if token:
set_registry_token(token)
print("\nConnected successfully!")
print(f"Your device '{hostname}' is now linked to @{username}")
print("\nYou can now publish tools with: cmdforge registry publish")
return 0
else:
print("\nError: Pairing completed but no token received")
return 1
elif status == "pending":
# Still waiting, continue polling
pass
elif status == "expired":
print("\nPairing request expired. Please try again.")
return 1
elif status == "not_found":
# No pending pairing yet, continue waiting
pass
else:
# Unknown status, keep waiting
pass
elif response.status_code == 404:
# No pairing found yet, keep waiting
pass
else:
# Server error, but keep trying
pass
except requests.exceptions.RequestException:
# Network error, keep trying
pass
# Show progress indicator
dots = "." * ((attempt % 3) + 1)
sys.stdout.write(f"\rWaiting{dots} ")
sys.stdout.flush()
time.sleep(2)
print("\n\nTimed out waiting for approval.")
print("Please initiate the connection from the web interface first.")
return 1
except KeyboardInterrupt:
print("\n\nConnection cancelled.")
return 1
+202
View File
@@ -0,0 +1,202 @@
"""Project management commands."""
import sys
from pathlib import Path
from ..manifest import (
load_manifest, save_manifest, create_manifest, find_manifest,
MANIFEST_FILENAME
)
from ..resolver import (
find_tool, install_from_registry, ToolSpec
)
def cmd_deps(args):
"""Show project dependencies from cmdforge.yaml."""
manifest = load_manifest()
if manifest is None:
print("No cmdforge.yaml found in current project.")
print("Create one with: cmdforge init")
return 1
print(f"Project: {manifest.name} v{manifest.version}")
print()
if not manifest.dependencies:
print("No dependencies defined.")
print("Add one with: cmdforge add <owner/name>")
return 0
print(f"Dependencies ({len(manifest.dependencies)}):")
print()
for dep in manifest.dependencies:
# Check if installed
installed = find_tool(dep.name)
status = "[installed]" if installed else "[not installed]"
print(f" {dep.name}")
print(f" Version: {dep.version}")
print(f" Status: {status}")
print()
if manifest.overrides:
print("Overrides:")
for name, override in manifest.overrides.items():
if override.provider:
print(f" {name}: provider={override.provider}")
return 0
def cmd_install_deps(args):
"""Install dependencies from cmdforge.yaml."""
from ..registry_client import RegistryError
manifest = load_manifest()
if manifest is None:
print("No cmdforge.yaml found in current project.")
print("Create one with: cmdforge init")
return 1
if not manifest.dependencies:
print("No dependencies to install.")
return 0
print(f"Installing dependencies for {manifest.name}...")
print()
failed = []
installed = []
for i, dep in enumerate(manifest.dependencies, 1):
print(f"[{i}/{len(manifest.dependencies)}] {dep.name}@{dep.version}")
# Check if already installed
existing = find_tool(dep.name)
if existing:
print(f" Already installed: {existing.full_name}")
installed.append(dep.name)
continue
try:
print(f" Downloading...")
resolved = install_from_registry(dep.name, dep.version)
print(f" Installed: {resolved.full_name}@{resolved.version}")
installed.append(dep.name)
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f" Not found in registry")
elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE":
print(f" Version {dep.version} not available")
elif e.code == "CONNECTION_ERROR":
print(f" Connection failed (check network)")
else:
print(f" Failed: {e.message}")
failed.append(dep.name)
except Exception as e:
print(f" Failed: {e}")
failed.append(dep.name)
print()
print(f"Installed {len(installed)} tools")
if failed:
print(f"Failed: {', '.join(failed)}")
return 1
return 0
def cmd_add(args):
"""Add a tool to project dependencies."""
from ..registry_client import RegistryError
tool_spec = args.tool
version = args.version or "*"
# Find or create manifest
manifest_path = find_manifest()
if manifest_path:
manifest = load_manifest(manifest_path)
else:
# Create in current directory
manifest = create_manifest(name=Path.cwd().name)
manifest_path = Path.cwd() / MANIFEST_FILENAME
# Parse tool spec
parsed = ToolSpec.parse(tool_spec)
full_name = parsed.full_name
# Add dependency
manifest.add_dependency(full_name, version)
# Save
save_manifest(manifest, manifest_path)
print(f"Added {full_name}@{version} to {manifest_path.name}")
# Install if requested
if not args.no_install:
print(f"Installing {full_name}...")
try:
resolved = install_from_registry(tool_spec, version if version != "*" else None)
print(f"Installed: {resolved.full_name}@{resolved.version}")
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f"Tool not found in registry.", file=sys.stderr)
print(f"It's been added to your dependencies - you can install it manually later.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print(f"Could not connect to registry.", file=sys.stderr)
print("Run 'cmdforge install' to try again later.", file=sys.stderr)
else:
print(f"Install failed: {e.message}", file=sys.stderr)
print("Run 'cmdforge install' to try again.", file=sys.stderr)
except Exception as e:
print(f"Install failed: {e}", file=sys.stderr)
print("Run 'cmdforge install' to try again.", file=sys.stderr)
return 0
def cmd_init(args):
"""Initialize a new cmdforge.yaml."""
manifest_path = Path.cwd() / MANIFEST_FILENAME
if manifest_path.exists() and not args.force:
print(f"{MANIFEST_FILENAME} already exists. Use --force to overwrite.")
return 1
# Get project name
default_name = Path.cwd().name
if args.name:
name = args.name
else:
try:
name = input(f"Project name [{default_name}]: ").strip() or default_name
except (EOFError, KeyboardInterrupt):
print()
name = default_name
# Get version
if args.version:
version = args.version
else:
try:
version = input("Version [1.0.0]: ").strip() or "1.0.0"
except (EOFError, KeyboardInterrupt):
print()
version = "1.0.0"
# Create manifest
manifest = create_manifest(name=name, version=version)
save_manifest(manifest, manifest_path)
print(f"Created {MANIFEST_FILENAME}")
print()
print("Add dependencies with: cmdforge add <owner/name>")
print("Install them with: cmdforge install")
return 0
+307
View File
@@ -0,0 +1,307 @@
"""Provider management commands."""
import shutil
import subprocess
from pathlib import Path
from ..providers import load_providers, add_provider, delete_provider, Provider, call_provider
PROVIDER_INSTALL_INFO = {
"claude": {
"group": "Anthropic Claude",
"install_cmd": "npm install -g @anthropic-ai/claude-code",
"requires": "Node.js 18+ and npm",
"setup": "Run 'claude' - opens browser for sign-in (auto-saves auth tokens)",
"cost": "Pay-per-use (billed to your Anthropic account)",
"variants": ["claude", "claude-haiku", "claude-opus", "claude-sonnet"],
},
"codex": {
"group": "OpenAI Codex",
"install_cmd": "npm install -g @openai/codex",
"requires": "Node.js 18+ and npm",
"setup": "Run 'codex' - opens browser for sign-in (auto-saves auth tokens)",
"cost": "Pay-per-use (billed to your OpenAI account)",
"variants": ["codex"],
},
"gemini": {
"group": "Google Gemini",
"install_cmd": "npm install -g @google/gemini-cli",
"requires": "Node.js 18+ and npm",
"setup": "Run 'gemini' - opens browser for Google sign-in",
"cost": "Free tier available, pay-per-use for more",
"variants": ["gemini", "gemini-flash"],
},
"opencode": {
"group": "OpenCode (75+ providers)",
"install_cmd": "curl -fsSL https://opencode.ai/install | bash",
"requires": "curl, bash",
"setup": "Run 'opencode' - opens browser to connect more providers",
"cost": "4 FREE models included (Big Pickle, GLM-4.7, Grok Code Fast 1, MiniMax M2.1), 75+ more available",
"variants": ["opencode-pickle", "opencode-deepseek", "opencode-nano", "opencode-reasoner", "opencode-grok"],
},
"ollama": {
"group": "Ollama (Local LLMs)",
"install_cmd": "curl -fsSL https://ollama.ai/install.sh | bash",
"requires": "curl, bash, 8GB+ RAM (GPU recommended)",
"setup": "Run 'ollama pull llama3' to download a model, then add provider",
"cost": "FREE (runs entirely on your machine)",
"variants": [],
"custom": True,
"post_install_note": "After installing, add the provider:\n cmdforge providers add ollama 'ollama run llama3' -d 'Local Llama 3'",
},
}
def cmd_providers(args):
"""Manage AI providers."""
if args.providers_cmd == "install":
return _cmd_providers_install(args)
elif args.providers_cmd == "list":
return _cmd_providers_list(args)
elif args.providers_cmd == "add":
return _cmd_providers_add(args)
elif args.providers_cmd == "remove":
return _cmd_providers_remove(args)
elif args.providers_cmd == "test":
return _cmd_providers_test(args)
elif args.providers_cmd == "check":
return _cmd_providers_check(args)
return 0
def _cmd_providers_install(args):
"""Interactive guide to install AI providers."""
print("=" * 60)
print("CmdForge Provider Installation Guide")
print("=" * 60)
print()
# Check what's already installed
providers = load_providers()
installed_groups = set()
for p in providers:
if p.name.lower() == "mock":
continue
cmd_parts = p.command.split()[0]
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
if shutil.which(cmd_expanded) or Path(cmd_expanded).exists():
# Find which group this belongs to
for group, info in PROVIDER_INSTALL_INFO.items():
if p.name in info.get("variants", []):
installed_groups.add(group)
# Show available provider groups
print("Available AI Provider Groups:\n")
options = []
for i, (key, info) in enumerate(PROVIDER_INSTALL_INFO.items(), 1):
status = "[INSTALLED]" if key in installed_groups else ""
print(f" {i}. {info['group']} {status}")
print(f" Cost: {info['cost']}")
print(f" Models: {', '.join(info['variants']) if info['variants'] else 'Custom'}")
print()
options.append(key)
print(" 0. Cancel")
print()
try:
choice = input("Select a provider to install (1-{}, or 0 to cancel): ".format(len(options)))
choice = int(choice)
except (ValueError, EOFError):
print("Cancelled.")
return 0
if choice == 0 or choice > len(options):
print("Cancelled.")
return 0
selected = options[choice - 1]
info = PROVIDER_INSTALL_INFO[selected]
print()
print("=" * 60)
print(f"Installing: {info['group']}")
print("=" * 60)
print()
print(f"Requirements: {info['requires']}")
print(f"Install command: {info['install_cmd']}")
print(f"Post-install: {info['setup']}")
print()
try:
confirm = input("Run installation command? (y/N): ").strip().lower()
except EOFError:
confirm = "n"
if confirm == "y":
print()
print(f"Running: {info['install_cmd']}")
print("-" * 40)
result = subprocess.run(info['install_cmd'], shell=True)
print("-" * 40)
if result.returncode == 0:
# Refresh PATH to pick up newly installed tools
import os
new_paths = []
# Common install locations that might have been added
potential_paths = [
Path.home() / ".opencode" / "bin", # OpenCode
Path.home() / ".local" / "bin", # pip/pipx installs
Path("/usr/local/bin"), # Ollama, system installs
]
# Also try to get npm global bin path
try:
npm_result = subprocess.run(
["npm", "bin", "-g"],
capture_output=True, text=True, timeout=5
)
if npm_result.returncode == 0:
npm_bin = npm_result.stdout.strip()
if npm_bin:
potential_paths.append(Path(npm_bin))
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
current_path = os.environ.get("PATH", "")
for p in potential_paths:
if p.exists() and str(p) not in current_path:
new_paths.append(str(p))
if new_paths:
os.environ["PATH"] = ":".join(new_paths) + ":" + current_path
print()
print("Installation completed!")
print()
print("IMPORTANT: Refresh your shell PATH before continuing:")
print(" source ~/.bashrc")
print()
print(f"Next steps:")
print(f" 1. source ~/.bashrc (required!)")
print(f" 2. {info['setup']}")
if info.get('post_install_note'):
print(f" 3. {info['post_install_note']}")
print(f" 4. Test with: cmdforge providers test {selected}")
else:
print(f" 3. Test with: cmdforge providers test {info['variants'][0] if info['variants'] else selected}")
else:
print()
print(f"Installation failed (exit code {result.returncode})")
print("Try running the command manually:")
print(f" {info['install_cmd']}")
else:
print()
print("To install manually, run:")
print(f" {info['install_cmd']}")
print()
print(f"Then: {info['setup']}")
return 0
def _cmd_providers_list(args):
"""List all providers and their status."""
providers = load_providers()
print(f"Configured providers ({len(providers)}):\n")
for p in providers:
# Mock provider is always available
if p.name.lower() == "mock":
print(f" [+] {p.name}")
print(f" Command: (built-in)")
print(f" Status: OK (always available)")
if p.description:
print(f" Info: {p.description}")
print()
continue
# Check if command exists
cmd_parts = p.command.split()[0]
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
exists = shutil.which(cmd_expanded) is not None or Path(cmd_expanded).exists()
status = "OK" if exists else "NOT FOUND"
status_icon = "+" if exists else "-"
print(f" [{status_icon}] {p.name}")
print(f" Command: {p.command}")
print(f" Status: {status}")
if p.description:
print(f" Info: {p.description}")
print()
return 0
def _cmd_providers_add(args):
"""Add or update a provider."""
name = args.name
command = args.command
description = args.description or ""
provider = Provider(name, command, description)
add_provider(provider)
print(f"Provider '{name}' added/updated.")
return 0
def _cmd_providers_remove(args):
"""Remove a provider."""
name = args.name
if delete_provider(name):
print(f"Provider '{name}' removed.")
return 0
else:
print(f"Provider '{name}' not found.")
return 1
def _cmd_providers_test(args):
"""Test a provider."""
name = args.name
print(f"Testing provider '{name}'...")
result = call_provider(name, "Say 'hello' and nothing else.", timeout=30)
if result.success:
print(f"SUCCESS: {result.text[:200]}...")
else:
print(f"FAILED: {result.error}")
return 0 if result.success else 1
def _cmd_providers_check(args):
"""Check which providers are available."""
providers = load_providers()
print("Checking all providers...\n")
available = []
missing = []
for p in providers:
# Mock provider is always available (handled specially)
if p.name.lower() == "mock":
available.append(p.name)
print(f" [+] {p.name}: OK (built-in)")
continue
cmd_parts = p.command.split()[0]
cmd_expanded = cmd_parts.replace("$HOME", str(Path.home())).replace("~", str(Path.home()))
exists = shutil.which(cmd_expanded) is not None or Path(cmd_expanded).exists()
if exists:
available.append(p.name)
print(f" [+] {p.name}: OK")
else:
missing.append(p.name)
print(f" [-] {p.name}: NOT FOUND ({cmd_parts})")
print(f"\nSummary: {len(available)} available, {len(missing)} missing")
if len(available) == 1 and available[0] == "mock":
print(f"\nNo real AI providers found. Install one of these:")
print(f" - claude: npm install -g @anthropic-ai/claude-cli")
print(f" - codex: pip install openai-codex")
print(f" - gemini: pip install google-generative-ai")
print(f"\nMeanwhile, use mock provider for testing:")
print(f" echo 'test' | summarize --provider mock")
elif missing:
print(f"\nAvailable providers: {', '.join(available)}")
return 0
+665
View File
@@ -0,0 +1,665 @@
"""Registry commands."""
import json
import sys
from pathlib import Path
import yaml
from ..config import load_config, set_registry_token
from ..resolver import install_from_registry, uninstall_tool, ToolSpec
def cmd_registry(args):
"""Handle registry subcommands."""
from ..registry_client import RegistryClient, RegistryError, RateLimitError, get_client
if args.registry_cmd == "search":
return _cmd_registry_search(args)
elif args.registry_cmd == "tags":
return _cmd_registry_tags(args)
elif args.registry_cmd == "install":
return _cmd_registry_install(args)
elif args.registry_cmd == "uninstall":
return _cmd_registry_uninstall(args)
elif args.registry_cmd == "info":
return _cmd_registry_info(args)
elif args.registry_cmd == "update":
return _cmd_registry_update(args)
elif args.registry_cmd == "publish":
return _cmd_registry_publish(args)
elif args.registry_cmd == "my-tools":
return _cmd_registry_my_tools(args)
elif args.registry_cmd == "browse":
return _cmd_registry_browse(args)
elif args.registry_cmd == "config":
return _cmd_registry_config(args)
else:
# Default: show registry help
print("Registry commands:")
print(" search <query> Search for tools")
print(" tags List available tags")
print(" install <tool> Install a tool")
print(" uninstall <tool> Uninstall a tool")
print(" info <tool> Show tool information")
print(" update Update local index cache")
print(" publish [path] Publish a tool")
print(" my-tools List your published tools")
print(" browse Browse tools (TUI)")
print(" config [action] Manage registry settings (admin)")
return 0
def _cmd_registry_search(args):
"""Search for tools in the registry."""
from ..registry_client import RegistryError, get_client
try:
client = get_client()
# Handle shortcut flags
min_downloads = getattr(args, 'min_downloads', None)
max_downloads = None
if getattr(args, 'popular', False):
min_downloads = 100
if getattr(args, 'new', False):
max_downloads = 10
results = client.search_tools(
query=args.query,
category=getattr(args, 'category', None),
tags=getattr(args, 'tags', None),
owner=getattr(args, 'owner', None),
min_downloads=min_downloads,
max_downloads=max_downloads,
published_after=getattr(args, 'since', None),
published_before=getattr(args, 'before', None),
include_deprecated=getattr(args, 'deprecated', False),
include_facets=getattr(args, 'show_facets', False),
per_page=args.limit or 20,
sort=getattr(args, 'sort', 'relevance')
)
# JSON output
if getattr(args, 'json', False):
output = {
"query": args.query,
"total": results.total,
"results": results.data
}
if results.facets:
output["facets"] = results.facets
print(json.dumps(output, indent=2))
return 0
if not results.data:
print(f"No tools found matching '{args.query}'")
return 0
print(f"Found {results.total} tools matching \"{args.query}\":")
# Show facets summary if requested
if results.facets:
cats = results.facets.get("categories", [])[:5]
tags = results.facets.get("tags", [])[:5]
if cats:
cat_str = ", ".join(f"{c['name']} ({c['count']})" for c in cats)
print(f"\nCategories: {cat_str}")
if tags:
tag_str = ", ".join(f"{t['name']} ({t['count']})" for t in tags)
print(f"Top Tags: {tag_str}")
print()
for tool in results.data:
owner = tool.get("owner", "")
name = tool.get("name", "")
version = tool.get("version", "")
desc = tool.get("description", "")
downloads = tool.get("downloads", 0)
tags = tool.get("tags", [])
print(f" {owner}/{name} v{version}")
print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}")
if tags:
print(f" Tags: {', '.join(tags[:5])}")
print(f" Downloads: {downloads:,}")
print()
if results.total_pages > 1:
print(f"Showing page {results.page}/{results.total_pages}")
except RegistryError as e:
if e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print(f"Rate limited. Please wait and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error searching registry: {e}", file=sys.stderr)
print("If the problem persists, check: cmdforge config show", file=sys.stderr)
return 1
return 0
def _cmd_registry_tags(args):
"""List available tags."""
from ..registry_client import RegistryError, get_client
try:
client = get_client()
tags = client.get_tags(
category=getattr(args, 'category', None),
limit=getattr(args, 'limit', 50)
)
# JSON output
if getattr(args, 'json', False):
print(json.dumps({"tags": tags}, indent=2))
return 0
if not tags:
print("No tags found")
return 0
print(f"Available tags ({len(tags)}):\n")
for tag in tags:
print(f" {tag['name']:20} ({tag['count']} tools)")
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_registry_install(args):
"""Install a tool from the registry."""
from ..registry_client import RegistryError
from ..tool import BIN_DIR
tool_spec = args.tool
version = args.version
print(f"Installing {tool_spec}...")
try:
resolved = install_from_registry(tool_spec, version)
print(f"Installed: {resolved.full_name}@{resolved.version}")
print(f"Location: {resolved.path}")
# Show wrapper info
wrapper_name = resolved.tool.name
if resolved.owner:
# Check for collision
short_wrapper = BIN_DIR / resolved.tool.name
if short_wrapper.exists():
wrapper_name = f"{resolved.owner}-{resolved.tool.name}"
print(f"\nRun with: {wrapper_name}")
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr)
print(f"Try: cmdforge registry search {tool_spec.split('/')[-1]}", file=sys.stderr)
elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE":
print(f"Error: {e.message}", file=sys.stderr)
if e.details and "available_versions" in e.details:
versions = e.details["available_versions"]
print(f"Available versions: {', '.join(versions[:5])}", file=sys.stderr)
if e.details.get("latest_stable"):
print(f"Latest stable: {e.details['latest_stable']}", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error installing tool: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_uninstall(args):
"""Uninstall a tool."""
tool_spec = args.tool
print(f"Uninstalling {tool_spec}...")
if uninstall_tool(tool_spec):
print(f"Uninstalled: {tool_spec}")
else:
print(f"Tool '{tool_spec}' not found", file=sys.stderr)
return 1
return 0
def _cmd_registry_info(args):
"""Show tool information."""
from ..registry_client import RegistryError, get_client
tool_spec = args.tool
try:
# Parse the tool spec
parsed = ToolSpec.parse(tool_spec)
owner = parsed.owner or "official"
client = get_client()
tool_info = client.get_tool(owner, parsed.name)
print(f"{tool_info.owner}/{tool_info.name} v{tool_info.version}")
print("=" * 50)
print(f"Description: {tool_info.description}")
print(f"Category: {tool_info.category}")
print(f"Tags: {', '.join(tool_info.tags)}")
print(f"Downloads: {tool_info.downloads}")
print(f"Published: {tool_info.published_at}")
if tool_info.deprecated:
print()
print(f"DEPRECATED: {tool_info.deprecated_message}")
if tool_info.replacement:
print(f"Use instead: {tool_info.replacement}")
# Show versions
versions = client.get_tool_versions(owner, parsed.name)
if versions:
print(f"\nVersions: {', '.join(versions[:5])}")
if len(versions) > 5:
print(f" ...and {len(versions) - 5} more")
print(f"\nInstall: cmdforge registry install {tool_info.owner}/{tool_info.name}")
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f"Tool '{tool_spec}' not found in the registry.", file=sys.stderr)
print(f"Try: cmdforge registry search {parsed.name}", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error fetching tool info: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_update(args):
"""Update local index cache."""
from ..registry_client import RegistryError, get_client
print("Updating registry index...")
try:
client = get_client()
index = client.get_index(force_refresh=True)
tool_count = index.get("tool_count", len(index.get("tools", [])))
generated = index.get("generated_at", "unknown")
print(f"Index updated: {tool_count} tools")
print(f"Generated: {generated}")
except RegistryError as e:
if e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print("Rate limited. Please wait a moment and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error updating index: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_publish(args):
"""Publish a tool to the registry."""
from ..registry_client import RegistryError, get_client
# Read tool from current directory or specified path
tool_path = Path(args.path) if args.path else Path.cwd()
if tool_path.is_dir():
config_path = tool_path / "config.yaml"
else:
config_path = tool_path
tool_path = config_path.parent
if not config_path.exists():
print(f"Error: config.yaml not found in {tool_path}", file=sys.stderr)
return 1
# Read config
config_yaml = config_path.read_text()
# Read README if exists
readme_path = tool_path / "README.md"
readme = readme_path.read_text() if readme_path.exists() else ""
# Validate
try:
data = yaml.safe_load(config_yaml)
name = data.get("name", "")
version = data.get("version", "")
if not name or not version:
print("Error: config.yaml must have 'name' and 'version' fields", file=sys.stderr)
return 1
except yaml.YAMLError as e:
print(f"Error: Invalid YAML in config.yaml: {e}", file=sys.stderr)
return 1
if args.dry_run:
print("Dry run - validating only")
print()
print(f"Would publish:")
print(f" Name: {name}")
print(f" Version: {version}")
print(f" Config: {len(config_yaml)} bytes")
print(f" README: {len(readme)} bytes")
return 0
# Check for token
config = load_config()
if not config.registry.token:
print("No registry token configured.")
print()
print("To publish tools, you need an account:")
print(" 1. Register at: https://cmdforge.brrd.tech/register")
print(" 2. Log in and go to Dashboard > Tokens")
print(" 3. Generate an API token")
print(" 4. Enter your token below (or run: cmdforge config set-token <token>)")
print()
try:
token = input("Registry token: ").strip()
if not token:
print("Cancelled.")
return 1
set_registry_token(token)
print("Token saved.")
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
return 1
print(f"Publishing {name}@{version}...")
try:
client = get_client()
result = client.publish_tool(config_yaml, readme)
pr_url = result.get("pr_url", "")
status = result.get("status", "")
if status == "published" or result.get("version"):
print(f"Published: {result.get('owner', '')}/{result.get('name', '')}@{result.get('version', version)}")
elif pr_url:
print(f"PR created: {pr_url}")
print("Your tool is pending review.")
else:
print("Published successfully!")
# Show suggestions if provided (from Phase 6 smart features)
suggestions = result.get("suggestions", {})
if suggestions:
print()
# Category suggestion
cat_suggestion = suggestions.get("category")
if cat_suggestion and cat_suggestion.get("suggested"):
confidence = cat_suggestion.get("confidence", 0)
print(f"Suggested category: {cat_suggestion['suggested']} ({confidence:.0%} confidence)")
# Similar tools warning
similar = suggestions.get("similar_tools", [])
if similar:
print("Similar existing tools:")
for tool in similar[:3]:
similarity = tool.get("similarity", 0)
print(f" - {tool.get('name', 'unknown')} ({similarity:.0%} similar)")
except RegistryError as e:
if e.code == "UNAUTHORIZED":
print("Authentication failed.", file=sys.stderr)
print("Your token may have expired. Generate a new one from the registry.", file=sys.stderr)
elif e.code == "INVALID_CONFIG":
print(f"Invalid tool config: {e.message}", file=sys.stderr)
print("Check your config.yaml for errors.", file=sys.stderr)
elif e.code == "VERSION_EXISTS":
print(f"Version already exists: {e.message}", file=sys.stderr)
print("Bump the version in config.yaml and try again.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print("Rate limited. Please wait a moment and try again.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error publishing: {e}", file=sys.stderr)
return 1
return 0
def _cmd_registry_my_tools(args):
"""List your published tools."""
from ..registry_client import RegistryError, get_client
try:
client = get_client()
tools = client.get_my_tools()
if not tools:
print("You haven't published any tools yet.")
print("Publish your first tool with: cmdforge registry publish")
return 0
print(f"Your published tools ({len(tools)}):\n")
for tool in tools:
status = "[DEPRECATED]" if tool.deprecated else ""
print(f" {tool.owner}/{tool.name} v{tool.version} {status}")
print(f" Downloads: {tool.downloads}")
print()
except RegistryError as e:
if e.code == "UNAUTHORIZED":
print("Not logged in. Set your registry token first:", file=sys.stderr)
print(" cmdforge config set-token <token>", file=sys.stderr)
print()
print("Don't have a token? Register at the registry website.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
print("Check your internet connection or try again later.", file=sys.stderr)
elif e.code == "RATE_LIMITED":
print("Rate limited. Please wait a moment and try again.", 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_registry_browse(args):
"""Browse tools (GUI)."""
from ..gui import run_gui
# Launch GUI - it will open to Registry page
return run_gui()
def _cmd_registry_config(args):
"""Manage registry settings (admin only)."""
from ..registry_client import RegistryError, get_client
action = getattr(args, 'action', 'list')
key = getattr(args, 'key', None)
value = getattr(args, 'value', None)
as_json = getattr(args, 'json', False)
category = getattr(args, 'category', None)
try:
client = get_client()
if action == "list":
return _config_list(client, as_json, category)
elif action == "get":
if not key:
print("Error: key is required for 'get' action", file=sys.stderr)
print("Usage: cmdforge registry config get <key>", file=sys.stderr)
return 1
return _config_get(client, key, as_json)
elif action == "set":
if not key or value is None:
print("Error: key and value are required for 'set' action", file=sys.stderr)
print("Usage: cmdforge registry config set <key> <value>", file=sys.stderr)
return 1
return _config_set(client, key, value)
except RegistryError as e:
if e.code == "UNAUTHORIZED":
print("Authentication failed.", file=sys.stderr)
print("This command requires admin privileges.", file=sys.stderr)
print("Set your admin token with: cmdforge config set-token <token>", file=sys.stderr)
elif e.code == "FORBIDDEN":
print("Access denied. Admin privileges required.", 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 _config_list(client, as_json, category=None):
"""List all settings."""
# Use the admin settings endpoint
response = client._request("GET", "/admin/settings")
settings = response.get("settings", [])
# Filter by category if specified
if category:
settings = [s for s in settings if s.get("category") == category]
if as_json:
print(json.dumps({"settings": settings}, indent=2))
return 0
if not settings:
print("No settings found.")
return 0
# Group by category
by_category = {}
for s in settings:
cat = s.get("category", "general")
if cat not in by_category:
by_category[cat] = []
by_category[cat].append(s)
print("Registry Settings")
print("=" * 60)
for cat, cat_settings in sorted(by_category.items()):
print(f"\n[{cat.upper()}]")
for s in cat_settings:
key = s.get("key", "")
value = s.get("value")
value_type = s.get("value_type", "string")
desc = s.get("description", "")
is_default = s.get("is_default", True)
# Format value display
if value_type == "bool":
value_str = "true" if value else "false"
else:
value_str = str(value)
status = "" if is_default else " (modified)"
print(f" {key}")
print(f" Value: {value_str}{status}")
if desc:
print(f" {desc}")
print()
print("Use 'cmdforge registry config get <key>' to see a setting's value")
print("Use 'cmdforge registry config set <key> <value>' to change a setting")
return 0
def _config_get(client, key, as_json):
"""Get a specific setting."""
response = client._request("GET", f"/admin/settings/{key}")
if as_json:
print(json.dumps(response, indent=2))
return 0
setting = response.get("setting", {})
print(f"Key: {setting.get('key', key)}")
print(f"Value: {setting.get('value')}")
print(f"Type: {setting.get('value_type', 'string')}")
print(f"Category: {setting.get('category', 'general')}")
if setting.get('description'):
print(f"Description: {setting['description']}")
if setting.get('updated_at'):
print(f"Last updated: {setting['updated_at'][:19]} by {setting.get('updated_by', 'system')}")
return 0
def _config_set(client, key, value):
"""Set a setting value."""
# Try to parse value as appropriate type
parsed_value = value
# Try to parse as bool
if value.lower() in ("true", "false"):
parsed_value = value.lower() == "true"
# Try to parse as number
else:
try:
if "." in value:
parsed_value = float(value)
else:
parsed_value = int(value)
except ValueError:
# Keep as string
pass
response = client._request("PUT", f"/admin/settings/{key}", json={"value": parsed_value})
if response.get("success"):
print(f"Setting '{key}' updated successfully.")
print(f"New value: {response.get('setting', {}).get('value', parsed_value)}")
else:
print(f"Failed to update setting: {response.get('error', 'Unknown error')}", file=sys.stderr)
return 1
return 0
+393
View File
@@ -0,0 +1,393 @@
"""Tool management commands."""
import sys
from pathlib import Path
from ..tool import (
list_tools, load_tool, save_tool, delete_tool, get_tools_dir,
Tool, ToolArgument, PromptStep, CodeStep, ToolStep
)
from ..gui import run_gui
def cmd_list(args):
"""List all tools."""
tools = list_tools()
if not tools:
print("No tools found.")
print("Create your first tool with: cmdforge ui")
return 0
print(f"Available tools ({len(tools)}):\n")
for name in tools:
tool = load_tool(name)
if tool:
# Show source indicator for imported tools
source_marker = ""
if tool.source and tool.source.type == "imported":
source_marker = " [imported]"
elif tool.source and tool.source.type == "forked":
source_marker = " [forked]"
print(f" {name}{source_marker}")
print(f" {tool.description or 'No description'}")
# Show arguments
if tool.arguments:
args_str = ", ".join(arg.flag for arg in tool.arguments)
print(f" Arguments: {args_str}")
# Show steps
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
elif isinstance(step, CodeStep):
step_info.append("CODE")
elif isinstance(step, ToolStep):
step_info.append(f"TOOL[{step.tool}]")
print(f" Steps: {' -> '.join(step_info)}")
print()
return 0
def cmd_create(args):
"""Create a new tool (basic CLI creation - use 'ui' for full builder)."""
name = args.name
# Check if already exists
existing = load_tool(name)
if existing and not args.force:
print(f"Error: Tool '{name}' already exists. Use --force to overwrite.")
return 1
# Create a tool with a single prompt step
steps = []
if args.prompt:
steps.append(PromptStep(
prompt=args.prompt,
provider=args.provider or "mock",
output_var="response"
))
tool = Tool(
name=name,
description=args.description or "",
arguments=[],
steps=steps,
output="{response}" if steps else "{input}"
)
path = save_tool(tool)
print(f"Created tool '{name}'")
print(f"Config: {path}")
print(f"\nUse 'cmdforge ui' to add arguments, steps, and customize.")
print(f"Or run: {name} < input.txt")
return 0
def cmd_edit(args):
"""Edit a tool (opens in $EDITOR or nano)."""
import os
import subprocess
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.")
return 1
config_path = get_tools_dir() / args.name / "config.yaml"
editor = os.environ.get("EDITOR", "nano")
try:
subprocess.run([editor, str(config_path)], check=True)
print(f"Tool '{args.name}' updated.")
return 0
except subprocess.CalledProcessError:
print(f"Error: Editor failed.")
return 1
except FileNotFoundError:
print(f"Error: Editor '{editor}' not found. Set $EDITOR environment variable.")
return 1
def cmd_delete(args):
"""Delete a tool."""
if not load_tool(args.name):
print(f"Error: Tool '{args.name}' not found.")
return 1
if not args.force:
confirm = input(f"Delete tool '{args.name}'? [y/N] ")
if confirm.lower() != 'y':
print("Cancelled.")
return 0
if delete_tool(args.name):
print(f"Deleted tool '{args.name}'.")
return 0
else:
print(f"Error: Failed to delete '{args.name}'.")
return 1
def cmd_test(args):
"""Test a tool with mock provider."""
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.")
return 1
from ..runner import run_tool
# Read test input
if args.input:
input_text = Path(args.input).read_text()
else:
print("Enter test input (Ctrl+D to end):")
input_text = sys.stdin.read()
print("\n--- Running with mock provider ---\n")
output, code = run_tool(
tool=tool,
input_text=input_text,
custom_args={},
provider_override="mock",
dry_run=args.dry_run,
show_prompt=True,
verbose=True
)
if output:
print("\n--- Output ---\n")
print(output)
return code
def cmd_run(args):
"""Run a tool."""
from ..runner import run_tool
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.", file=sys.stderr)
return 1
# Read input
if args.input:
# Read from file
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
return 1
input_text = input_path.read_text()
elif args.stdin:
# Explicit interactive input requested
print("Reading from stdin (Ctrl+D to end):", file=sys.stderr)
input_text = sys.stdin.read()
elif not sys.stdin.isatty():
# Stdin is piped - read it
input_text = sys.stdin.read()
else:
# No input provided - use empty string
input_text = ""
# Collect custom args from remaining arguments
custom_args = {}
if args.tool_args:
# Parse tool-specific arguments
i = 0
while i < len(args.tool_args):
arg = args.tool_args[i]
if arg.startswith('--'):
key = arg[2:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('--'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
elif arg.startswith('-'):
key = arg[1:].replace('-', '_')
if i + 1 < len(args.tool_args) and not args.tool_args[i + 1].startswith('-'):
custom_args[key] = args.tool_args[i + 1]
i += 2
else:
custom_args[key] = True
i += 1
else:
i += 1
# Run tool
output, code = run_tool(
tool=tool,
input_text=input_text,
custom_args=custom_args,
provider_override=args.provider,
dry_run=args.dry_run,
show_prompt=args.show_prompt,
verbose=args.verbose
)
# Write output
if code == 0 and output:
if args.output:
Path(args.output).write_text(output)
else:
print(output)
return code
def cmd_ui(args):
"""Launch the interactive GUI."""
return run_gui()
def cmd_refresh(args):
"""Refresh all wrapper scripts with the current Python path."""
from ..tool import create_wrapper_script
tools = list_tools()
if not tools:
print("No tools found.")
return 0
print(f"Refreshing wrapper scripts for {len(tools)} tools...")
for name in tools:
path = create_wrapper_script(name)
print(f" {name} -> {path}")
print("\nDone. Wrapper scripts updated to use current Python interpreter.")
return 0
def cmd_docs(args):
"""View or edit tool documentation."""
import os
import subprocess
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.")
return 1
readme_path = get_tools_dir() / args.name / "README.md"
if args.edit:
# Edit/create README
editor = os.environ.get("EDITOR", "nano")
# Create a template if README doesn't exist
if not readme_path.exists():
template = f"""# {args.name}
{tool.description or 'No description provided.'}
## Usage
```bash
echo "input" | {args.name}
```
## Arguments
| Flag | Default | Description |
|------|---------|-------------|
"""
for arg in tool.arguments:
template += f"| `{arg.flag}` | {arg.default or ''} | {arg.description or ''} |\n"
template += """
## Examples
```bash
# Example 1
```
## Requirements
- List any dependencies here
"""
readme_path.write_text(template)
print(f"Created template: {readme_path}")
try:
subprocess.run([editor, str(readme_path)], check=True)
print(f"Documentation updated: {readme_path}")
return 0
except subprocess.CalledProcessError:
print("Error: Editor failed.")
return 1
except FileNotFoundError:
print(f"Error: Editor '{editor}' not found. Set $EDITOR environment variable.")
return 1
else:
# View README
if not readme_path.exists():
print(f"No documentation found for '{args.name}'.")
print(f"Create it with: cmdforge docs {args.name} --edit")
return 1
print(readme_path.read_text())
return 0
def cmd_check(args):
"""Check dependencies for a tool (meta-tools)."""
from ..runner import check_dependencies
from ..tool import ToolStep
tool = load_tool(args.name)
if not tool:
print(f"Error: Tool '{args.name}' not found.")
return 1
# Check if this is a meta-tool
has_tool_steps = any(isinstance(s, ToolStep) for s in tool.steps)
has_deps = bool(tool.dependencies)
if not has_tool_steps and not has_deps:
print(f"Tool '{args.name}' has no dependencies (not a meta-tool).")
return 0
print(f"Checking dependencies for '{args.name}'...")
print()
# Show declared dependencies
if tool.dependencies:
print("Declared dependencies:")
for dep in tool.dependencies:
print(f" - {dep}")
print()
# Show tool steps
if has_tool_steps:
print("Tool steps (implicit dependencies):")
for i, step in enumerate(tool.steps):
if isinstance(step, ToolStep):
print(f" Step {i+1}: {step.tool}")
print()
# Check which are missing
missing = check_dependencies(tool)
if missing:
print(f"Missing dependencies ({len(missing)}):")
for dep in missing:
print(f" - {dep}")
print()
print("Install with:")
print(f" cmdforge install {' '.join(missing)}")
return 1
else:
print("All dependencies are satisfied.")
return 0
+135
View File
@@ -0,0 +1,135 @@
"""Global configuration handling for CmdForge.
Manages ~/.cmdforge/config.yaml with registry settings, tokens, and preferences.
"""
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import yaml
# Default configuration directory
CONFIG_DIR = Path.home() / ".cmdforge"
CONFIG_FILE = CONFIG_DIR / "config.yaml"
# Default registry URL (canonical base path)
DEFAULT_REGISTRY_URL = "https://cmdforge.brrd.tech/api/v1"
@dataclass
class RegistryConfig:
"""Registry-related configuration."""
url: str = DEFAULT_REGISTRY_URL
token: Optional[str] = None
def to_dict(self) -> dict:
d = {"url": self.url}
if self.token:
d["token"] = self.token
return d
@classmethod
def from_dict(cls, data: dict) -> "RegistryConfig":
return cls(
url=data.get("url", DEFAULT_REGISTRY_URL),
token=data.get("token")
)
@dataclass
class Config:
"""Global CmdForge configuration."""
registry: RegistryConfig = field(default_factory=RegistryConfig)
client_id: str = ""
auto_fetch_from_registry: bool = True
default_provider: Optional[str] = None
def __post_init__(self):
# Generate client_id if not set
if not self.client_id:
self.client_id = f"anon_{uuid.uuid4().hex[:16]}"
def to_dict(self) -> dict:
d = {
"registry": self.registry.to_dict(),
"client_id": self.client_id,
"auto_fetch_from_registry": self.auto_fetch_from_registry,
}
if self.default_provider:
d["default_provider"] = self.default_provider
return d
@classmethod
def from_dict(cls, data: dict) -> "Config":
registry_data = data.get("registry", {})
return cls(
registry=RegistryConfig.from_dict(registry_data),
client_id=data.get("client_id", ""),
auto_fetch_from_registry=data.get("auto_fetch_from_registry", True),
default_provider=data.get("default_provider")
)
def get_config_dir() -> Path:
"""Get the config directory, creating it if needed."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
return CONFIG_DIR
def load_config() -> Config:
"""Load configuration from disk, creating defaults if needed."""
config_path = get_config_dir() / "config.yaml"
if not config_path.exists():
# Create default config
config = Config()
save_config(config)
return config
try:
data = yaml.safe_load(config_path.read_text()) or {}
return Config.from_dict(data)
except Exception as e:
print(f"Warning: Error loading config, using defaults: {e}")
return Config()
def save_config(config: Config) -> Path:
"""Save configuration to disk."""
config_path = get_config_dir() / "config.yaml"
config_path.write_text(yaml.dump(config.to_dict(), default_flow_style=False, sort_keys=False))
return config_path
def get_registry_url() -> str:
"""Get the configured registry URL."""
config = load_config()
return config.registry.url
def get_registry_token() -> Optional[str]:
"""Get the configured registry token."""
config = load_config()
return config.registry.token
def set_registry_token(token: str) -> None:
"""Set and save the registry token."""
config = load_config()
config.registry.token = token
save_config(config)
def get_client_id() -> str:
"""Get the client ID for anonymous usage tracking."""
config = load_config()
return config.client_id
def is_auto_fetch_enabled() -> bool:
"""Check if auto-fetch from registry is enabled."""
config = load_config()
return config.auto_fetch_from_registry
+18
View File
@@ -0,0 +1,18 @@
"""PySide6 GUI for CmdForge."""
import sys
def run_gui():
"""Launch the CmdForge GUI application."""
from PySide6.QtWidgets import QApplication
from .main_window import MainWindow
app = QApplication(sys.argv)
app.setApplicationName("CmdForge")
app.setOrganizationName("CmdForge")
window = MainWindow()
window.show()
return app.exec()
+1
View File
@@ -0,0 +1 @@
"""GUI dialogs."""
+104
View File
@@ -0,0 +1,104 @@
"""Argument editor dialog."""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
QCheckBox, QPushButton, QHBoxLayout, QLabel
)
from ...tool import ToolArgument
class ArgumentDialog(QDialog):
"""Dialog for editing tool arguments."""
def __init__(self, parent, argument: ToolArgument = None):
super().__init__(parent)
self.setWindowTitle("Edit Argument" if argument else "Add Argument")
self.setMinimumWidth(400)
self._argument = argument
self._setup_ui()
if argument:
self._load_argument(argument)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Form
form = QFormLayout()
form.setSpacing(12)
self.flag_input = QLineEdit()
self.flag_input.setPlaceholderText("--input, -i")
form.addRow("Flag:", self.flag_input)
self.var_input = QLineEdit()
self.var_input.setPlaceholderText("input_text")
form.addRow("Variable:", self.var_input)
self.default_input = QLineEdit()
self.default_input.setPlaceholderText("(optional)")
form.addRow("Default:", self.default_input)
self.desc_input = QLineEdit()
self.desc_input.setPlaceholderText("(optional)")
form.addRow("Description:", self.desc_input)
layout.addLayout(form)
# Help text
help_text = QLabel(
"The flag is how users specify this argument (e.g. --input).\n"
"The variable name is used in prompts and templates as ${variable}."
)
help_text.setStyleSheet("color: #718096; font-size: 11px;")
help_text.setWordWrap(True)
layout.addWidget(help_text)
# Buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("OK")
self.btn_ok.clicked.connect(self._validate_and_accept)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
def _load_argument(self, arg: ToolArgument):
"""Load argument data into form."""
self.flag_input.setText(arg.flag)
self.var_input.setText(arg.variable)
self.default_input.setText(arg.default or "")
self.desc_input.setText(arg.description or "")
def _validate_and_accept(self):
"""Validate input and accept."""
flag = self.flag_input.text().strip()
variable = self.var_input.text().strip()
if not flag:
self.flag_input.setFocus()
return
if not variable:
self.var_input.setFocus()
return
self.accept()
def get_argument(self) -> ToolArgument:
"""Get the argument from form data."""
return ToolArgument(
flag=self.flag_input.text().strip(),
variable=self.var_input.text().strip(),
default=self.default_input.text().strip() or None,
description=self.desc_input.text().strip() or ""
)
+234
View File
@@ -0,0 +1,234 @@
"""Connect to registry dialog."""
import socket
import time
import requests
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QPushButton,
QHBoxLayout, QLineEdit, QProgressBar, QTextEdit
)
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from ...config import load_config, save_config, get_registry_url, set_registry_token
class PairingWorker(QThread):
"""Background worker for pairing flow."""
success = Signal(str) # token
error = Signal(str)
status = Signal(str)
def __init__(self, username: str):
super().__init__()
self.username = username
self.hostname = socket.gethostname()
self._stop = False
def stop(self):
self._stop = True
def run(self):
try:
registry_url = get_registry_url()
# Remove trailing /api/v1 if present to get base URL
base_url = registry_url.rstrip("/")
if base_url.endswith("/api/v1"):
base_url = base_url[:-7]
pairing_url = f"{base_url}/api/v1/pairing/check/{self.username}"
self.status.emit(f"Connecting to {base_url}...")
# Poll for pairing status
max_attempts = 150 # 5 minutes at 2-second intervals
attempt = 0
while attempt < max_attempts:
if self._stop:
return
attempt += 1
try:
response = requests.get(
pairing_url,
params={"hostname": self.hostname},
timeout=10
)
if response.status_code == 200:
data = response.json()
status = data.get("data", {}).get("status")
if status == "connected":
token = data.get("data", {}).get("token")
if token:
self.success.emit(token)
return
else:
self.error.emit("Pairing completed but no token received")
return
elif status == "expired":
self.error.emit("Pairing request expired. Please try again.")
return
elif status == "pending":
self.status.emit(f"Pairing pending, approve in web UI... ({attempt})")
elif status == "not_found":
self.status.emit(f"Waiting for web UI pairing... ({attempt})")
else:
self.status.emit(f"Status: {status} ({attempt})")
elif response.status_code == 404:
self.status.emit(f"Waiting for web approval... ({attempt})")
else:
self.status.emit(f"Server returned {response.status_code}")
except requests.exceptions.RequestException:
self.status.emit(f"Network error, retrying... ({attempt})")
time.sleep(2)
self.error.emit("Timed out waiting for approval")
except Exception as e:
self.error.emit(str(e))
class ConnectDialog(QDialog):
"""Dialog for connecting to registry."""
def __init__(self, parent):
super().__init__(parent)
self.setWindowTitle("Connect to Registry")
self.setMinimumWidth(450)
self._worker = None
self._setup_ui()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Title
title = QLabel("Connect Your Account")
title.setStyleSheet("font-size: 16px; font-weight: 600;")
layout.addWidget(title)
# Instructions
self.instructions = QLabel(
"To publish tools, connect your CmdForge account.\n\n"
"1. Enter your CmdForge username below\n"
"2. Click Connect\n"
"3. Go to cmdforge.brrd.tech/dashboard/connected-apps\n"
"4. Click 'Connect New App' and approve this device"
)
self.instructions.setWordWrap(True)
layout.addWidget(self.instructions)
# Username input
username_layout = QHBoxLayout()
username_label = QLabel("Username:")
self.username_input = QLineEdit()
self.username_input.setPlaceholderText("your-username")
self.username_input.returnPressed.connect(self._start_pairing)
username_layout.addWidget(username_label)
username_layout.addWidget(self.username_input, 1)
layout.addLayout(username_layout)
# Device info
hostname = socket.gethostname()
device_label = QLabel(f"Device: {hostname}")
device_label.setStyleSheet("color: #718096; font-size: 12px;")
layout.addWidget(device_label)
# Status
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #718096;")
layout.addWidget(self.status_label)
# Log area for debugging
self.log_area = QTextEdit()
self.log_area.setReadOnly(True)
self.log_area.setMaximumHeight(120)
self.log_area.setStyleSheet("font-family: monospace; font-size: 11px; background: #f7fafc;")
self.log_area.hide()
layout.addWidget(self.log_area)
# Progress
self.progress = QProgressBar()
self.progress.setRange(0, 0) # Indeterminate
self.progress.hide()
layout.addWidget(self.progress)
# Buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self._cancel)
buttons.addWidget(self.btn_cancel)
self.btn_connect = QPushButton("Connect")
self.btn_connect.clicked.connect(self._start_pairing)
buttons.addWidget(self.btn_connect)
layout.addLayout(buttons)
def _start_pairing(self):
"""Start the pairing flow."""
username = self.username_input.text().strip()
if not username:
self.status_label.setText("Please enter your username")
self.status_label.setStyleSheet("color: #e53e3e;")
return
self.btn_connect.setEnabled(False)
self.username_input.setEnabled(False)
self.progress.show()
self.log_area.clear()
self.log_area.show()
self.status_label.setStyleSheet("color: #718096;")
self._worker = PairingWorker(username)
self._worker.success.connect(self._on_success)
self._worker.error.connect(self._on_error)
self._worker.status.connect(self._on_status)
self._worker.start()
def _on_success(self, token: str):
"""Handle successful pairing."""
set_registry_token(token)
self.progress.hide()
self.status_label.setText("Connected successfully!")
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
QTimer.singleShot(1000, self.accept)
def _on_error(self, error: str):
"""Handle error."""
self.progress.hide()
self.btn_connect.setEnabled(True)
self.username_input.setEnabled(True)
self.status_label.setText(f"Error: {error}")
self.status_label.setStyleSheet("color: #e53e3e;")
def _on_status(self, status: str):
"""Handle status update."""
self.status_label.setText(status)
# Also append to log for debugging
self.log_area.append(status)
def _cancel(self):
"""Cancel pairing."""
if self._worker:
self._worker.stop()
self._worker.wait(1000)
self.reject()
def closeEvent(self, event):
"""Handle close."""
if self._worker:
self._worker.stop()
self._worker.wait(1000)
super().closeEvent(event)
+101
View File
@@ -0,0 +1,101 @@
"""Provider editor dialog."""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
QPushButton, QHBoxLayout, QLabel
)
from ...providers import Provider, add_provider
class ProviderDialog(QDialog):
"""Dialog for adding/editing providers."""
def __init__(self, parent, name: str = None, provider: Provider = None):
super().__init__(parent)
self.setWindowTitle("Edit Provider" if provider else "Add Provider")
self.setMinimumWidth(450)
self._editing = provider is not None
self._original_name = name
self._setup_ui()
if provider:
self._load_provider(name, provider)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Form
form = QFormLayout()
form.setSpacing(12)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("claude")
form.addRow("Name:", self.name_input)
self.cmd_input = QLineEdit()
self.cmd_input.setPlaceholderText("claude-cli")
form.addRow("Command:", self.cmd_input)
self.desc_input = QLineEdit()
self.desc_input.setPlaceholderText("Claude AI via claude-cli")
form.addRow("Description:", self.desc_input)
layout.addLayout(form)
# Help text
help_text = QLabel(
"The command should accept input on stdin and output to stdout.\n"
"Example commands: claude-cli, sgpt, llm, mods"
)
help_text.setStyleSheet("color: #718096; font-size: 11px;")
help_text.setWordWrap(True)
layout.addWidget(help_text)
layout.addStretch()
# Buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("Save")
self.btn_ok.clicked.connect(self._save)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
def _load_provider(self, name: str, provider: Provider):
"""Load provider data into form."""
self.name_input.setText(name)
self.name_input.setEnabled(False) # Can't rename
self.cmd_input.setText(provider.command)
self.desc_input.setText(provider.description or "")
def _save(self):
"""Save the provider."""
name = self.name_input.text().strip()
command = self.cmd_input.text().strip()
if not name:
self.name_input.setFocus()
return
if not command:
self.cmd_input.setFocus()
return
description = self.desc_input.text().strip() or None
try:
add_provider(name, command, description)
self.accept()
except Exception as e:
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error", f"Failed to save provider:\n{e}")
+182
View File
@@ -0,0 +1,182 @@
"""Publish tool dialog."""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QPushButton,
QHBoxLayout, QLineEdit, QTextEdit, QFormLayout,
QProgressBar, QMessageBox, QComboBox
)
from PySide6.QtCore import QThread, Signal
from ...tool import Tool
from ...registry_client import RegistryClient, RegistryError
from ...config import load_config
class PublishWorker(QThread):
"""Background worker for publishing."""
success = Signal(dict)
error = Signal(str)
def __init__(self, tool_data: dict):
super().__init__()
self.tool_data = tool_data
def run(self):
try:
config = load_config()
client = RegistryClient()
client.token = config.registry.token
result = client.publish_tool(self.tool_data)
self.success.emit(result)
except Exception as e:
self.error.emit(str(e))
class PublishDialog(QDialog):
"""Dialog for publishing a tool."""
def __init__(self, parent, tool: Tool):
super().__init__(parent)
self.setWindowTitle("Publish Tool")
self.setMinimumSize(500, 400)
self._tool = tool
self._worker = None
self._setup_ui()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Title
title = QLabel(f"Publish '{self._tool.name}'")
title.setStyleSheet("font-size: 16px; font-weight: 600;")
layout.addWidget(title)
# Form
form = QFormLayout()
form.setSpacing(12)
# Version
self.version_input = QLineEdit()
self.version_input.setText("1.0.0")
self.version_input.setPlaceholderText("1.0.0")
form.addRow("Version:", self.version_input)
# Category
self.category_combo = QComboBox()
self.category_combo.setEditable(True)
categories = [
"Text Processing", "Code", "Data", "System",
"Writing", "Analysis", "Productivity", "Other"
]
self.category_combo.addItems(categories)
if self._tool.category:
idx = self.category_combo.findText(self._tool.category)
if idx >= 0:
self.category_combo.setCurrentIndex(idx)
else:
self.category_combo.setCurrentText(self._tool.category)
form.addRow("Category:", self.category_combo)
# Tags
self.tags_input = QLineEdit()
self.tags_input.setPlaceholderText("ai, text, productivity (comma separated)")
form.addRow("Tags:", self.tags_input)
layout.addLayout(form)
# Description
desc_label = QLabel("Description:")
layout.addWidget(desc_label)
self.desc_input = QTextEdit()
self.desc_input.setPlaceholderText("Describe what your tool does...")
self.desc_input.setPlainText(self._tool.description or "")
self.desc_input.setMaximumHeight(100)
layout.addWidget(self.desc_input)
# Status
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #718096;")
layout.addWidget(self.status_label)
# Progress
self.progress = QProgressBar()
self.progress.setRange(0, 0)
self.progress.hide()
layout.addWidget(self.progress)
layout.addStretch()
# Buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_publish = QPushButton("Publish")
self.btn_publish.clicked.connect(self._publish)
buttons.addWidget(self.btn_publish)
layout.addLayout(buttons)
def _publish(self):
"""Publish the tool."""
version = self.version_input.text().strip()
if not version:
QMessageBox.warning(self, "Validation", "Version is required")
return
description = self.desc_input.toPlainText().strip()
if not description:
QMessageBox.warning(self, "Validation", "Description is required")
return
category = self.category_combo.currentText()
tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()]
# Build tool data for publishing
tool_data = {
"name": self._tool.name,
"version": version,
"description": description,
"category": category,
"tags": tags,
"definition": self._tool.to_dict() if hasattr(self._tool, 'to_dict') else {}
}
self.btn_publish.setEnabled(False)
self.btn_cancel.setEnabled(False)
self.progress.show()
self.status_label.setText("Publishing...")
self._worker = PublishWorker(tool_data)
self._worker.success.connect(self._on_success)
self._worker.error.connect(self._on_error)
self._worker.start()
def _on_success(self, result: dict):
"""Handle publish success."""
self.progress.hide()
self.status_label.setText("Published successfully!")
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
QMessageBox.information(
self, "Success",
f"Tool '{self._tool.name}' has been published.\n\n"
f"It will be available after review."
)
self.accept()
def _on_error(self, error: str):
"""Handle publish error."""
self.progress.hide()
self.btn_publish.setEnabled(True)
self.btn_cancel.setEnabled(True)
self.status_label.setText(f"Error: {error}")
self.status_label.setStyleSheet("color: #e53e3e;")
+426
View File
@@ -0,0 +1,426 @@
"""Step editor dialogs."""
import ast
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
QComboBox, QPushButton, QHBoxLayout, QLabel,
QPlainTextEdit, QSplitter, QGroupBox, QTextEdit, QMessageBox
)
from PySide6.QtCore import Qt, QThread, Signal
from ...tool import PromptStep, CodeStep
from ...providers import load_providers, call_provider
from ...profiles import list_profiles
class PromptStepDialog(QDialog):
"""Dialog for editing prompt steps."""
def __init__(self, parent, step: PromptStep = None):
super().__init__(parent)
self.setWindowTitle("Edit Prompt Step" if step else "Add Prompt Step")
self.setMinimumSize(500, 400)
self._step = step
self._setup_ui()
if step:
self._load_step(step)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Form
form = QFormLayout()
form.setSpacing(12)
# Step name (optional)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Optional display name")
form.addRow("Step name:", self.name_input)
# Provider selection
self.provider_combo = QComboBox()
providers = load_providers()
for provider in sorted(providers, key=lambda p: p.name):
self.provider_combo.addItem(provider.name)
# Add common defaults if not present
for default in ["claude", "gpt", "mock"]:
if self.provider_combo.findText(default) < 0:
self.provider_combo.addItem(default)
form.addRow("Provider:", self.provider_combo)
# Profile selection
self.profile_combo = QComboBox()
profiles = list_profiles()
for profile in profiles:
self.profile_combo.addItem(profile.name)
# Set tooltip with description
idx = self.profile_combo.count() - 1
self.profile_combo.setItemData(idx, profile.description, Qt.ToolTipRole)
form.addRow("Profile:", self.profile_combo)
# Output variable
self.output_input = QLineEdit()
self.output_input.setPlaceholderText("response")
self.output_input.setText("response")
form.addRow("Output variable:", self.output_input)
layout.addLayout(form)
# Prompt text
prompt_label = QLabel("Prompt template:")
layout.addWidget(prompt_label)
self.prompt_input = QPlainTextEdit()
self.prompt_input.setPlaceholderText(
"Enter your prompt here...\n\n"
"Use {input} for stdin input.\n"
"Use {variable} for argument values.\n"
"Use {prev_output} for previous step output."
)
layout.addWidget(self.prompt_input, 1)
# Buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("OK")
self.btn_ok.clicked.connect(self._validate_and_accept)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
def _load_step(self, step: PromptStep):
"""Load step data into form."""
# Load name
if step.name:
self.name_input.setText(step.name)
idx = self.provider_combo.findText(step.provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
else:
self.provider_combo.setCurrentText(step.provider)
# Load profile
if step.profile:
idx = self.profile_combo.findText(step.profile)
if idx >= 0:
self.profile_combo.setCurrentIndex(idx)
self.output_input.setText(step.output_var)
self.prompt_input.setPlainText(step.prompt)
def _validate_and_accept(self):
"""Validate and accept."""
prompt = self.prompt_input.toPlainText().strip()
if not prompt:
self.prompt_input.setFocus()
return
output = self.output_input.text().strip()
if not output:
self.output_input.setFocus()
return
self.accept()
def get_step(self) -> PromptStep:
"""Get the step from form data."""
profile = self.profile_combo.currentText()
# Don't store "None" profile
if profile == "None":
profile = None
# Get name, use None if empty
name = self.name_input.text().strip() or None
return PromptStep(
prompt=self.prompt_input.toPlainText(),
provider=self.provider_combo.currentText(),
output_var=self.output_input.text().strip(),
profile=profile,
name=name
)
class AIGenerateWorker(QThread):
"""Background worker for AI code generation."""
finished = Signal(str)
error = Signal(str)
def __init__(self, provider: str, prompt: str):
super().__init__()
self.provider = provider
self.prompt = prompt
def run(self):
try:
result = call_provider(self.provider, self.prompt)
if result.success:
self.finished.emit(result.text)
else:
self.error.emit(result.error or "Unknown error")
except Exception as e:
self.error.emit(str(e))
class CodeStepDialog(QDialog):
"""Dialog for editing code steps with AI assist."""
def __init__(self, parent, step: CodeStep = None, available_vars: list = None):
super().__init__(parent)
self.setWindowTitle("Edit Code Step" if step else "Add Code Step")
self.setMinimumSize(900, 600)
self._step = step
self._available_vars = available_vars or ["input"]
self._worker = None
self._setup_ui()
if step:
self._load_step(step)
def _setup_ui(self):
"""Set up the UI with code editor and AI assist panel."""
layout = QVBoxLayout(self)
layout.setSpacing(12)
# Top: Step name and Output variable
form = QFormLayout()
form.setSpacing(8)
# Step name (optional)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Optional display name")
form.addRow("Step name:", self.name_input)
self.output_input = QLineEdit()
self.output_input.setPlaceholderText("result")
self.output_input.setText("result")
form.addRow("Output variable:", self.output_input)
layout.addLayout(form)
# Available variables display
vars_text = ", ".join(self._available_vars)
vars_label = QLabel(f"Available variables: {vars_text}")
vars_label.setStyleSheet("color: #718096; font-size: 11px;")
layout.addWidget(vars_label)
# Main splitter: Code editor | AI Assist
splitter = QSplitter(Qt.Horizontal)
# Left: Code editor
code_group = QGroupBox("Code")
code_layout = QVBoxLayout(code_group)
self.code_input = QPlainTextEdit()
self.code_input.setPlaceholderText(
"# Python code here\n"
"# Access input with: input\n"
"# Access args with their variable names\n\n"
"result = input.upper()"
)
font = self.code_input.font()
font.setFamily("Consolas, Monaco, monospace")
self.code_input.setFont(font)
code_layout.addWidget(self.code_input)
splitter.addWidget(code_group)
# Right: AI Assist panel
ai_group = QGroupBox("AI Assisted Code Generation")
ai_layout = QVBoxLayout(ai_group)
ai_layout.setSpacing(8)
# Provider selector
provider_layout = QHBoxLayout()
provider_layout.addWidget(QLabel("Provider:"))
self.ai_provider_combo = QComboBox()
providers = load_providers()
for provider in sorted(providers, key=lambda p: p.name):
self.ai_provider_combo.addItem(provider.name)
# Add common defaults if not present
for default in ["claude", "gpt", "mock"]:
if self.ai_provider_combo.findText(default) < 0:
self.ai_provider_combo.addItem(default)
self.ai_provider_combo.setMinimumWidth(150)
provider_layout.addWidget(self.ai_provider_combo)
provider_layout.addStretch()
ai_layout.addLayout(provider_layout)
# Prompt editor
prompt_label = QLabel("Describe what you want the code to do:")
ai_layout.addWidget(prompt_label)
self.ai_prompt_input = QPlainTextEdit()
self.ai_prompt_input.setPlaceholderText(
"Example: Parse the input as JSON and extract the 'name' field"
)
# Set default prompt template
self._set_default_prompt()
ai_layout.addWidget(self.ai_prompt_input, 2)
# Generate button
btn_layout = QHBoxLayout()
self.btn_generate = QPushButton("Generate Code")
self.btn_generate.clicked.connect(self._generate_code)
self.btn_generate.setMinimumHeight(32)
btn_layout.addStretch()
btn_layout.addWidget(self.btn_generate)
btn_layout.addStretch()
ai_layout.addLayout(btn_layout)
# Output/feedback area
feedback_label = QLabel("AI Response:")
ai_layout.addWidget(feedback_label)
self.ai_output = QTextEdit()
self.ai_output.setReadOnly(True)
self.ai_output.setPlaceholderText("AI response will appear here...")
self.ai_output.setMaximumHeight(100)
ai_layout.addWidget(self.ai_output, 1)
splitter.addWidget(ai_group)
splitter.setSizes([450, 450])
layout.addWidget(splitter, 1)
# Bottom: OK/Cancel buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("OK")
self.btn_ok.clicked.connect(self._validate_and_accept)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
def _set_default_prompt(self):
"""Set the default AI prompt template."""
vars_formatted = ', '.join(f'"{{{v}}}"' for v in self._available_vars)
default_prompt = f"""Write inline Python code (NOT a function definition) according to my instruction.
The code runs directly with variable substitution. Assign any available variables to a local variable first using triple quotes for multi-line content safety.
Example:
my_var = \"\"\"{{input}}\"\"\"
result = my_var.upper()
INSTRUCTION: [Describe what you want the code to do]
CURRENT CODE:
{{code}}
AVAILABLE VARIABLES: {vars_formatted}
IMPORTANT: Return ONLY executable inline Python code. No function definitions, no markdown fencing, no explanations - just the code."""
self.ai_prompt_input.setPlainText(default_prompt)
def _generate_code(self):
"""Generate code using AI."""
prompt_template = self.ai_prompt_input.toPlainText().strip()
if not prompt_template:
self.ai_output.setHtml("<span style='color: #e53e3e;'>Please enter a prompt</span>")
return
# Replace {code} placeholder with current code
current_code = self.code_input.toPlainText().strip() or "# No code yet"
prompt = prompt_template.replace("{code}", current_code)
provider = self.ai_provider_combo.currentText()
# Disable button and show loading state
self.btn_generate.setEnabled(False)
self.btn_generate.setText("Generating...")
self.ai_output.setHtml(f"<span style='color: #718096;'>Calling {provider}...</span>")
# Start worker thread
self._worker = AIGenerateWorker(provider, prompt)
self._worker.finished.connect(self._on_generate_finished)
self._worker.error.connect(self._on_generate_error)
self._worker.start()
def _on_generate_finished(self, result: str):
"""Handle successful AI generation."""
self.btn_generate.setEnabled(True)
self.btn_generate.setText("Generate Code")
# Clean up the result - strip markdown code fences if present
code = result.strip()
if code.startswith("```python"):
code = code[9:]
elif code.startswith("```"):
code = code[3:]
if code.endswith("```"):
code = code[:-3]
code = code.strip()
# Update code editor
self.code_input.setPlainText(code)
# Show success message
self.ai_output.setHtml(
f"<span style='color: #38a169;'>Code generated successfully!</span><br>"
f"<span style='color: #718096;'>Response length: {len(result)} chars</span>"
)
def _on_generate_error(self, error: str):
"""Handle AI generation error."""
self.btn_generate.setEnabled(True)
self.btn_generate.setText("Generate Code")
self.ai_output.setHtml(f"<span style='color: #e53e3e;'>Error: {error}</span>")
def _load_step(self, step: CodeStep):
"""Load step data into form."""
# Load name
if step.name:
self.name_input.setText(step.name)
self.output_input.setText(step.output_var)
self.code_input.setPlainText(step.code)
def _validate_and_accept(self):
"""Validate and accept."""
code = self.code_input.toPlainText().strip()
if not code:
self.code_input.setFocus()
return
output = self.output_input.text().strip()
if not output:
self.output_input.setFocus()
return
# Syntax check the Python code
try:
ast.parse(code)
except SyntaxError as e:
line_info = f" (line {e.lineno})" if e.lineno else ""
QMessageBox.warning(
self, "Syntax Error",
f"Python syntax error{line_info}:\n\n{e.msg}"
)
self.code_input.setFocus()
return
self.accept()
def get_step(self) -> CodeStep:
"""Get the step from form data."""
# Get name, use None if empty
name = self.name_input.text().strip() or None
return CodeStep(
code=self.code_input.toPlainText(),
output_var=self.output_input.text().strip(),
name=name
)
+249
View File
@@ -0,0 +1,249 @@
"""Main window for CmdForge GUI."""
from PySide6.QtWidgets import (
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
QListWidget, QListWidgetItem, QStackedWidget,
QStatusBar, QLabel, QSplitter
)
from PySide6.QtCore import Qt, QSize, QSettings
from PySide6.QtGui import QIcon, QFont, QShortcut, QKeySequence
from .styles import STYLESHEET
class MainWindow(QMainWindow):
"""Main application window with sidebar navigation."""
def __init__(self):
super().__init__()
self.setWindowTitle("CmdForge")
self.setMinimumSize(1000, 700)
self.resize(1200, 800)
# Apply stylesheet
self.setStyleSheet(STYLESHEET)
# Settings for persistence
self._settings = QSettings("CmdForge", "CmdForge")
# Central widget
central = QWidget()
self.setCentralWidget(central)
# Main layout
layout = QHBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Sidebar
self.sidebar = QListWidget()
self.sidebar.setObjectName("sidebar")
self.sidebar.setFixedWidth(180)
self.sidebar.setFocusPolicy(Qt.NoFocus)
self._setup_sidebar()
# Stacked pages
self.pages = QStackedWidget()
self.pages.setObjectName("content_area")
# Add pages (lazy load to avoid import issues)
self._setup_pages()
# Add to layout
layout.addWidget(self.sidebar)
layout.addWidget(self.pages, 1)
# Status bar
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Ready")
# Connect sidebar
self.sidebar.currentRowChanged.connect(self._on_page_changed)
self.sidebar.setCurrentRow(0)
# Setup keyboard shortcuts
self._setup_shortcuts()
# Restore window geometry
self._restore_geometry()
def _setup_sidebar(self):
"""Set up sidebar navigation items."""
items = [
("CmdForge", "Welcome to CmdForge"),
("Tools", "Manage your tools"),
("Registry", "Browse and install tools"),
("Providers", "Configure AI providers"),
("Profiles", "AI persona profiles"),
]
font = QFont()
font.setPointSize(11)
for i, (name, tooltip) in enumerate(items):
item = QListWidgetItem(name)
item.setFont(font)
item.setToolTip(tooltip)
item.setSizeHint(QSize(180, 48))
# Style the CmdForge item differently
if i == 0:
bold_font = QFont(font)
bold_font.setBold(True)
item.setFont(bold_font)
self.sidebar.addItem(item)
def _setup_pages(self):
"""Set up content pages."""
# Import pages here to avoid circular imports
from .pages.welcome_page import WelcomePage
from .pages.tools_page import ToolsPage
from .pages.registry_page import RegistryPage
from .pages.providers_page import ProvidersPage
from .pages.profiles_page import ProfilesPage
self.welcome_page = WelcomePage(self)
self.tools_page = ToolsPage(self)
self.registry_page = RegistryPage(self)
self.providers_page = ProvidersPage(self)
self.profiles_page = ProfilesPage(self)
self.pages.addWidget(self.welcome_page)
self.pages.addWidget(self.tools_page)
self.pages.addWidget(self.registry_page)
self.pages.addWidget(self.providers_page)
self.pages.addWidget(self.profiles_page)
def _on_page_changed(self, index: int):
"""Handle page changes."""
self.pages.setCurrentIndex(index)
# Refresh page if it has a refresh method
current_page = self.pages.currentWidget()
if hasattr(current_page, 'refresh'):
current_page.refresh()
def show_status(self, message: str, timeout: int = 5000):
"""Show a status bar message."""
self.status_bar.showMessage(message, timeout)
def navigate_to(self, page_name: str):
"""Navigate to a specific page by name."""
page_map = {
"welcome": 0,
"cmdforge": 0,
"tools": 1,
"registry": 2,
"providers": 3,
"profiles": 4,
}
if page_name.lower() in page_map:
self.sidebar.setCurrentRow(page_map[page_name.lower()])
def open_tool_builder(self, tool_name: str = None):
"""Open the tool builder page for creating or editing a tool."""
from .pages.tool_builder_page import ToolBuilderPage
# Create builder page
builder = ToolBuilderPage(self, tool_name)
# Add to stack and switch to it
index = self.pages.addWidget(builder)
self.pages.setCurrentIndex(index)
# Hide sidebar selection
self.sidebar.clearSelection()
def close_tool_builder(self):
"""Close the tool builder and return to tools page."""
# Remove builder page from stack
current = self.pages.currentWidget()
if hasattr(current, '__class__') and current.__class__.__name__ == 'ToolBuilderPage':
self.pages.removeWidget(current)
current.deleteLater()
# Return to tools page
self.sidebar.setCurrentRow(0)
self.tools_page.refresh()
def _setup_shortcuts(self):
"""Set up keyboard shortcuts."""
# Ctrl+N: New tool
shortcut_new = QShortcut(QKeySequence("Ctrl+N"), self)
shortcut_new.activated.connect(self._shortcut_new_tool)
# Ctrl+S: Save (when in tool builder)
shortcut_save = QShortcut(QKeySequence("Ctrl+S"), self)
shortcut_save.activated.connect(self._shortcut_save)
# Escape: Go back
shortcut_escape = QShortcut(QKeySequence("Escape"), self)
shortcut_escape.activated.connect(self._shortcut_escape)
# Ctrl+1/2/3/4: Navigate pages
shortcut_page1 = QShortcut(QKeySequence("Ctrl+1"), self)
shortcut_page1.activated.connect(lambda: self.sidebar.setCurrentRow(0))
shortcut_page2 = QShortcut(QKeySequence("Ctrl+2"), self)
shortcut_page2.activated.connect(lambda: self.sidebar.setCurrentRow(1))
shortcut_page3 = QShortcut(QKeySequence("Ctrl+3"), self)
shortcut_page3.activated.connect(lambda: self.sidebar.setCurrentRow(2))
shortcut_page4 = QShortcut(QKeySequence("Ctrl+4"), self)
shortcut_page4.activated.connect(lambda: self.sidebar.setCurrentRow(3))
# Ctrl+R: Refresh current page
shortcut_refresh = QShortcut(QKeySequence("Ctrl+R"), self)
shortcut_refresh.activated.connect(self._shortcut_refresh)
# Ctrl+Q: Quit
shortcut_quit = QShortcut(QKeySequence("Ctrl+Q"), self)
shortcut_quit.activated.connect(self.close)
def _shortcut_new_tool(self):
"""Handle Ctrl+N: Create new tool."""
self.open_tool_builder()
self.show_status("Creating new tool... (Ctrl+S to save)")
def _shortcut_save(self):
"""Handle Ctrl+S: Save current tool."""
current = self.pages.currentWidget()
if hasattr(current, 'save_tool'):
current.save_tool()
def _shortcut_escape(self):
"""Handle Escape: Go back or close builder."""
current = self.pages.currentWidget()
if hasattr(current, '__class__') and current.__class__.__name__ == 'ToolBuilderPage':
self.close_tool_builder()
else:
# Could close dialogs if any are open
pass
def _shortcut_refresh(self):
"""Handle Ctrl+R: Refresh current page."""
current = self.pages.currentWidget()
if hasattr(current, 'refresh'):
current.refresh()
self.show_status("Refreshed")
def _restore_geometry(self):
"""Restore window geometry from settings."""
geometry = self._settings.value("geometry")
if geometry:
self.restoreGeometry(geometry)
state = self._settings.value("windowState")
if state:
self.restoreState(state)
def _save_geometry(self):
"""Save window geometry to settings."""
self._settings.setValue("geometry", self.saveGeometry())
self._settings.setValue("windowState", self.saveState())
def closeEvent(self, event):
"""Handle window close - save geometry."""
self._save_geometry()
super().closeEvent(event)
+9
View File
@@ -0,0 +1,9 @@
"""GUI pages."""
from .welcome_page import WelcomePage
from .tools_page import ToolsPage
from .tool_builder_page import ToolBuilderPage
from .registry_page import RegistryPage
from .providers_page import ProvidersPage
__all__ = ["WelcomePage", "ToolsPage", "ToolBuilderPage", "RegistryPage", "ProvidersPage"]
+250
View File
@@ -0,0 +1,250 @@
"""Profiles page - manage AI persona profiles."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QLabel, QGroupBox, QTextEdit, QLineEdit,
QFormLayout, QMessageBox, QSplitter
)
from PySide6.QtCore import Qt
from ...profiles import list_profiles, save_profile, delete_profile, Profile
class ProfilesPage(QWidget):
"""Profiles management page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._setup_ui()
self.refresh()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
header = QHBoxLayout()
title = QLabel("Profiles")
title.setObjectName("heading")
header.addWidget(title)
header.addStretch()
self.btn_new = QPushButton("New Profile")
self.btn_new.clicked.connect(self._new_profile)
header.addWidget(self.btn_new)
layout.addLayout(header)
# Description
desc = QLabel(
"AI persona profiles inject system instructions into prompts. "
"Select a profile when creating a prompt step to customize the AI's behavior."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #718096;")
layout.addWidget(desc)
# Main content splitter
splitter = QSplitter(Qt.Horizontal)
# Left: Profile list
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
self.profile_list = QListWidget()
self.profile_list.currentItemChanged.connect(self._on_profile_selected)
left_layout.addWidget(self.profile_list)
splitter.addWidget(left)
# Right: Profile details/editor
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
# Details group
details_box = QGroupBox("Profile Details")
details_layout = QFormLayout(details_box)
details_layout.setSpacing(12)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Profile name")
details_layout.addRow("Name:", self.name_input)
self.desc_input = QLineEdit()
self.desc_input.setPlaceholderText("Brief description")
details_layout.addRow("Description:", self.desc_input)
right_layout.addWidget(details_box)
# System prompt group
prompt_box = QGroupBox("System Prompt")
prompt_layout = QVBoxLayout(prompt_box)
self.prompt_input = QTextEdit()
self.prompt_input.setPlaceholderText(
"Enter the system instructions that will be injected into prompts.\n\n"
"Example: You are a helpful assistant who explains things clearly..."
)
prompt_layout.addWidget(self.prompt_input)
right_layout.addWidget(prompt_box, 1)
# Action buttons
actions = QHBoxLayout()
self.btn_save = QPushButton("Save")
self.btn_save.clicked.connect(self._save_profile)
self.btn_save.setEnabled(False)
actions.addWidget(self.btn_save)
self.btn_delete = QPushButton("Delete")
self.btn_delete.setObjectName("danger")
self.btn_delete.clicked.connect(self._delete_profile)
self.btn_delete.setEnabled(False)
actions.addWidget(self.btn_delete)
actions.addStretch()
right_layout.addLayout(actions)
splitter.addWidget(right)
splitter.setSizes([300, 500])
layout.addWidget(splitter, 1)
# Status label
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #718096; font-style: italic;")
layout.addWidget(self.status_label)
def refresh(self):
"""Refresh the profile list."""
self.profile_list.clear()
profiles = list_profiles()
for profile in profiles:
item = QListWidgetItem()
if profile.builtin:
item.setText(f"{profile.name} (built-in)")
else:
item.setText(profile.name)
item.setData(Qt.UserRole, profile)
self.profile_list.addItem(item)
self._clear_form()
self.status_label.setText(f"{len(profiles)} profiles available")
def _on_profile_selected(self, current, previous):
"""Handle profile selection."""
if not current:
self._clear_form()
return
profile = current.data(Qt.UserRole)
self._load_profile(profile)
def _load_profile(self, profile: Profile):
"""Load profile into form."""
self.name_input.setText(profile.name)
self.desc_input.setText(profile.description)
self.prompt_input.setPlainText(profile.system_prompt)
# Built-in profiles can't be edited
is_builtin = profile.builtin
self.name_input.setEnabled(not is_builtin)
self.desc_input.setEnabled(not is_builtin)
self.prompt_input.setEnabled(not is_builtin)
self.btn_save.setEnabled(not is_builtin)
self.btn_delete.setEnabled(not is_builtin and profile.name != "None")
if is_builtin:
self.status_label.setText("Built-in profile (read-only)")
else:
self.status_label.setText("Custom profile")
def _clear_form(self):
"""Clear the form."""
self.name_input.clear()
self.name_input.setEnabled(True)
self.desc_input.clear()
self.desc_input.setEnabled(True)
self.prompt_input.clear()
self.prompt_input.setEnabled(True)
self.btn_save.setEnabled(False)
self.btn_delete.setEnabled(False)
def _new_profile(self):
"""Start creating a new profile."""
self.profile_list.clearSelection()
self._clear_form()
self.name_input.setFocus()
self.btn_save.setEnabled(True)
self.status_label.setText("Creating new profile...")
def _save_profile(self):
"""Save the current profile."""
name = self.name_input.text().strip()
if not name:
QMessageBox.warning(self, "Validation", "Profile name is required")
return
if name == "None":
QMessageBox.warning(self, "Validation", "'None' is a reserved profile name")
return
description = self.desc_input.text().strip()
system_prompt = self.prompt_input.toPlainText().strip()
if not system_prompt:
QMessageBox.warning(self, "Validation", "System prompt is required")
return
profile = Profile(
name=name,
description=description,
system_prompt=system_prompt
)
try:
save_profile(profile)
self.main_window.show_status(f"Saved profile '{name}'")
self.refresh()
# Select the saved profile
for i in range(self.profile_list.count()):
item = self.profile_list.item(i)
if item.data(Qt.UserRole).name == name:
self.profile_list.setCurrentItem(item)
break
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save profile:\n{e}")
def _delete_profile(self):
"""Delete the selected profile."""
current = self.profile_list.currentItem()
if not current:
return
profile = current.data(Qt.UserRole)
if profile.builtin:
QMessageBox.warning(self, "Error", "Cannot delete built-in profiles")
return
reply = QMessageBox.question(
self, "Confirm Delete",
f"Delete profile '{profile.name}'?\n\nThis cannot be undone.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
if delete_profile(profile.name):
self.main_window.show_status(f"Deleted profile '{profile.name}'")
self.refresh()
else:
QMessageBox.warning(self, "Error", "Failed to delete profile")
+214
View File
@@ -0,0 +1,214 @@
"""Providers page - manage AI providers."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
QTableWidgetItem, QHeaderView, QPushButton, QLabel,
QMessageBox
)
from PySide6.QtCore import Qt
from ...providers import Provider, load_providers, delete_provider
class ProvidersPage(QWidget):
"""Provider management page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._setup_ui()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
header = QWidget()
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("AI Providers")
title.setObjectName("heading")
header_layout.addWidget(title)
header_layout.addStretch()
self.btn_add = QPushButton("Add Provider")
self.btn_add.clicked.connect(self._add_provider)
header_layout.addWidget(self.btn_add)
layout.addWidget(header)
# Description
desc = QLabel(
"Providers are external AI commands that CmdForge tools can use. "
"Each provider wraps a CLI tool that accepts input on stdin and outputs to stdout."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #718096;")
layout.addWidget(desc)
# Providers table
self.table = QTableWidget()
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(["Name", "Command", "Description"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setSelectionMode(QTableWidget.SingleSelection)
self.table.verticalHeader().setVisible(False)
self.table.itemSelectionChanged.connect(self._on_selection_changed)
layout.addWidget(self.table, 1)
# Action buttons
buttons = QWidget()
btn_layout = QHBoxLayout(buttons)
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.setSpacing(12)
self.btn_edit = QPushButton("Edit")
self.btn_edit.setObjectName("secondary")
self.btn_edit.clicked.connect(self._edit_provider)
self.btn_edit.setEnabled(False)
btn_layout.addWidget(self.btn_edit)
self.btn_delete = QPushButton("Delete")
self.btn_delete.setObjectName("danger")
self.btn_delete.clicked.connect(self._delete_provider)
self.btn_delete.setEnabled(False)
btn_layout.addWidget(self.btn_delete)
btn_layout.addStretch()
self.btn_test = QPushButton("Test")
self.btn_test.clicked.connect(self._test_provider)
self.btn_test.setEnabled(False)
btn_layout.addWidget(self.btn_test)
layout.addWidget(buttons)
def refresh(self):
"""Refresh the provider list."""
providers = load_providers()
self.table.setRowCount(len(providers))
for row, provider in enumerate(sorted(providers, key=lambda p: p.name)):
name_item = QTableWidgetItem(provider.name)
name_item.setData(Qt.UserRole, provider)
self.table.setItem(row, 0, name_item)
self.table.setItem(row, 1, QTableWidgetItem(provider.command))
self.table.setItem(row, 2, QTableWidgetItem(provider.description or ""))
self._on_selection_changed()
def _on_selection_changed(self):
"""Handle selection change."""
has_selection = len(self.table.selectedItems()) > 0
self.btn_edit.setEnabled(has_selection)
self.btn_delete.setEnabled(has_selection)
self.btn_test.setEnabled(has_selection)
def _get_selected_provider(self) -> tuple:
"""Get selected provider name and object."""
items = self.table.selectedItems()
if not items:
return None, None
row = items[0].row()
name_item = self.table.item(row, 0)
return name_item.text(), name_item.data(Qt.UserRole)
def _add_provider(self):
"""Add a new provider."""
from ..dialogs.provider_dialog import ProviderDialog
dialog = ProviderDialog(self)
if dialog.exec():
self.refresh()
self.main_window.show_status("Provider added")
def _edit_provider(self):
"""Edit selected provider."""
name, provider = self._get_selected_provider()
if not name:
return
from ..dialogs.provider_dialog import ProviderDialog
dialog = ProviderDialog(self, name, provider)
if dialog.exec():
self.refresh()
self.main_window.show_status("Provider updated")
def _delete_provider(self):
"""Delete selected provider."""
name, _ = self._get_selected_provider()
if not name:
return
reply = QMessageBox.question(
self,
"Delete Provider",
f"Are you sure you want to delete provider '{name}'?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
try:
delete_provider(name)
self.refresh()
self.main_window.show_status(f"Deleted provider '{name}'")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to delete provider:\n{e}")
def _test_provider(self):
"""Test selected provider."""
name, provider = self._get_selected_provider()
if not name or not provider:
return
import subprocess
import shutil
# Check if command exists
cmd_parts = provider.command.split()
if not cmd_parts:
QMessageBox.warning(self, "Invalid", "Provider has no command configured")
return
exe = cmd_parts[0]
if not shutil.which(exe):
QMessageBox.warning(
self, "Not Found",
f"Command '{exe}' not found in PATH.\n\n"
"Make sure the provider is installed and accessible."
)
return
# Try running with --help or similar
try:
result = subprocess.run(
cmd_parts + ["--help"],
capture_output=True,
text=True,
timeout=5
)
QMessageBox.information(
self, "Provider Test",
f"Provider '{name}' is available.\n\n"
f"Command: {provider.command}\n"
f"Exit code: {result.returncode}"
)
except subprocess.TimeoutExpired:
QMessageBox.information(
self, "Provider Test",
f"Provider '{name}' command exists but timed out.\n"
"This may be normal for interactive commands."
)
except Exception as e:
QMessageBox.warning(
self, "Provider Test",
f"Error testing provider:\n{e}"
)
+583
View File
@@ -0,0 +1,583 @@
"""Registry page - browse and install tools from registry."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit,
QPushButton, QTableWidget, QTableWidgetItem, QLabel,
QHeaderView, QGroupBox, QTextBrowser, QSplitter, QMessageBox,
QComboBox, QFrame
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor
from ...registry_client import RegistryClient, RegistryError
from ...config import load_config
from ...tool import list_tools, load_tool
class SearchWorker(QThread):
"""Background worker for registry search."""
finished = Signal(object) # PaginatedResponse
error = Signal(str)
def __init__(self, query: str = "", category: str = None, sort: str = "downloads",
tags: list = None, page: int = 1, per_page: int = 20):
super().__init__()
self.query = query
self.category = category
self.sort = sort
self.tags = tags
self.page = page
self.per_page = per_page
def run(self):
try:
client = RegistryClient()
category = self.category if self.category and self.category != "All" else None
# Use list_tools for browsing (no query/tags), search_tools for searching
if not self.query and not self.tags:
result = client.list_tools(
category=category,
page=self.page,
per_page=self.per_page,
sort=self.sort
)
else:
result = client.search_tools(
self.query,
category=category,
tags=self.tags,
page=self.page,
per_page=self.per_page,
sort=self.sort,
include_facets=True
)
self.finished.emit(result)
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):
super().__init__()
self.owner = owner
self.name = name
def run(self):
try:
client = RegistryClient()
client.install_tool(self.owner, self.name)
self.finished.emit(f"{self.owner}/{self.name}")
except Exception as e:
self.error.emit(str(e))
class RegistryPage(QWidget):
"""Registry browser page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._search_worker = None
self._install_worker = None
self._selected_tool = None
self._current_page = 1
self._total_pages = 1
self._current_tags = []
self._installed_tools = {} # name -> version
self._setup_ui()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
header = QHBoxLayout()
title = QLabel("Tool Registry")
title.setObjectName("heading")
header.addWidget(title)
header.addStretch()
# Browse all button
self.btn_browse = QPushButton("Browse All")
self.btn_browse.clicked.connect(self._browse_all)
header.addWidget(self.btn_browse)
layout.addLayout(header)
# Search and filters row
filters_box = QWidget()
filters_layout = QHBoxLayout(filters_box)
filters_layout.setContentsMargins(0, 0, 0, 0)
filters_layout.setSpacing(12)
# Search input
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search tools...")
self.search_input.returnPressed.connect(self._do_search)
filters_layout.addWidget(self.search_input, 2)
# Category filter
cat_label = QLabel("Category:")
filters_layout.addWidget(cat_label)
self.category_combo = QComboBox()
self.category_combo.addItems(["All", "Text", "Developer", "Data", "Other"])
self.category_combo.setMinimumWidth(100)
self.category_combo.currentTextChanged.connect(self._on_filter_changed)
filters_layout.addWidget(self.category_combo)
# Sort dropdown
sort_label = QLabel("Sort:")
filters_layout.addWidget(sort_label)
self.sort_combo = QComboBox()
self.sort_combo.addItem("Most Popular", "downloads")
self.sort_combo.addItem("Newest", "published_at")
self.sort_combo.addItem("Name (A-Z)", "name")
self.sort_combo.setMinimumWidth(120)
self.sort_combo.currentIndexChanged.connect(self._on_filter_changed)
filters_layout.addWidget(self.sort_combo)
# Search button
self.btn_search = QPushButton("Search")
self.btn_search.clicked.connect(self._do_search)
filters_layout.addWidget(self.btn_search)
layout.addWidget(filters_box)
# Active tags display
self.tags_widget = QWidget()
self.tags_layout = QHBoxLayout(self.tags_widget)
self.tags_layout.setContentsMargins(0, 0, 0, 0)
self.tags_layout.setSpacing(8)
self.tags_label = QLabel("Filtering by tags:")
self.tags_label.setStyleSheet("color: #718096;")
self.tags_layout.addWidget(self.tags_label)
self.tags_layout.addStretch()
self.tags_widget.hide()
layout.addWidget(self.tags_widget)
# Content splitter
splitter = QSplitter(Qt.Horizontal)
# Left: Results table
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
self.results_table = QTableWidget()
self.results_table.setColumnCount(6)
self.results_table.setHorizontalHeaderLabels(["", "Name", "Owner", "Rating", "Downloads", "Version"])
self.results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed)
self.results_table.setColumnWidth(0, 30) # Installed indicator
self.results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.results_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
self.results_table.setSelectionBehavior(QTableWidget.SelectRows)
self.results_table.setSelectionMode(QTableWidget.SingleSelection)
self.results_table.verticalHeader().setVisible(False)
self.results_table.itemSelectionChanged.connect(self._on_selection_changed)
left_layout.addWidget(self.results_table)
# Pagination controls
pagination = QWidget()
pag_layout = QHBoxLayout(pagination)
pag_layout.setContentsMargins(0, 8, 0, 0)
self.btn_prev = QPushButton("← Previous")
self.btn_prev.clicked.connect(self._prev_page)
self.btn_prev.setEnabled(False)
pag_layout.addWidget(self.btn_prev)
pag_layout.addStretch()
self.page_label = QLabel("Page 1 of 1")
self.page_label.setStyleSheet("color: #718096;")
pag_layout.addWidget(self.page_label)
pag_layout.addStretch()
self.btn_next = QPushButton("Next →")
self.btn_next.clicked.connect(self._next_page)
self.btn_next.setEnabled(False)
pag_layout.addWidget(self.btn_next)
left_layout.addWidget(pagination)
splitter.addWidget(left)
# Right: Tool details
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
details_box = QGroupBox("Tool Details")
details_layout = QVBoxLayout(details_box)
self.details_text = QTextBrowser()
self.details_text.setOpenExternalLinks(False)
self.details_text.anchorClicked.connect(self._on_tag_clicked)
details_layout.addWidget(self.details_text)
right_layout.addWidget(details_box, 1)
# Action buttons
actions = QHBoxLayout()
self.btn_install = QPushButton("Install")
self.btn_install.clicked.connect(self._install_tool)
self.btn_install.setEnabled(False)
actions.addWidget(self.btn_install)
self.btn_update = QPushButton("Update Available")
self.btn_update.clicked.connect(self._install_tool)
self.btn_update.setEnabled(False)
self.btn_update.setStyleSheet("background-color: #48bb78; color: white;")
self.btn_update.hide()
actions.addWidget(self.btn_update)
right_layout.addLayout(actions)
splitter.addWidget(right)
splitter.setSizes([550, 450])
layout.addWidget(splitter, 1)
# Status label
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #718096;")
layout.addWidget(self.status_label)
def refresh(self):
"""Refresh on page enter - load popular tools."""
self._load_installed_tools()
self._browse_all()
def _load_installed_tools(self):
"""Load list of installed tools and their versions."""
self._installed_tools = {}
for name in list_tools():
tool = load_tool(name)
if tool:
self._installed_tools[name] = tool.version or "1.0.0"
def _browse_all(self):
"""Browse all tools (no search query)."""
self.search_input.clear()
self._current_page = 1
self._do_search()
def _on_filter_changed(self):
"""Handle filter/sort change."""
self._current_page = 1
self._do_search()
def _do_search(self):
"""Perform search with current filters."""
query = self.search_input.text().strip()
category = self.category_combo.currentText()
sort = self.sort_combo.currentData()
self.btn_search.setEnabled(False)
self.btn_browse.setEnabled(False)
self.status_label.setText("Searching...")
self.results_table.setRowCount(0)
self._search_worker = SearchWorker(
query=query,
category=category,
sort=sort,
tags=self._current_tags if self._current_tags else None,
page=self._current_page,
per_page=20
)
self._search_worker.finished.connect(self._on_search_complete)
self._search_worker.error.connect(self._on_search_error)
self._search_worker.start()
def _on_search_complete(self, result):
"""Handle search results."""
self.btn_search.setEnabled(True)
self.btn_browse.setEnabled(True)
tools = result.data
self._total_pages = result.total_pages or 1
total = result.total or len(tools)
self.status_label.setText(f"Found {total} tools")
self.page_label.setText(f"Page {self._current_page} of {self._total_pages}")
# Update pagination buttons
self.btn_prev.setEnabled(self._current_page > 1)
self.btn_next.setEnabled(self._current_page < self._total_pages)
self.results_table.setRowCount(len(tools))
for row, tool in enumerate(tools):
tool_name = tool.get("name", "")
# Installed indicator
installed_item = QTableWidgetItem()
if tool_name in self._installed_tools:
installed_version = self._installed_tools[tool_name]
registry_version = tool.get("version", "1.0.0")
if installed_version != registry_version:
installed_item.setText("↑") # Update available
installed_item.setToolTip(f"Update available: {installed_version} → {registry_version}")
installed_item.setForeground(QColor("#48bb78"))
else:
installed_item.setText("✓")
installed_item.setToolTip("Installed")
installed_item.setForeground(QColor("#4299e1"))
installed_item.setTextAlignment(Qt.AlignCenter)
self.results_table.setItem(row, 0, installed_item)
# Name
name_item = QTableWidgetItem(tool_name)
name_item.setData(Qt.UserRole, tool)
self.results_table.setItem(row, 1, name_item)
# Owner
self.results_table.setItem(row, 2, QTableWidgetItem(tool.get("owner", "")))
# Rating (stars)
rating = tool.get("average_rating", 0) or 0
rating_count = tool.get("rating_count", 0) or 0
stars = self._rating_to_stars(rating)
rating_item = QTableWidgetItem(f"{stars} ({rating_count})")
rating_item.setToolTip(f"{rating:.1f}/5 from {rating_count} ratings")
self.results_table.setItem(row, 3, rating_item)
# Downloads
downloads = tool.get("downloads", 0)
downloads_str = self._format_downloads(downloads)
self.results_table.setItem(row, 4, QTableWidgetItem(downloads_str))
# Version
self.results_table.setItem(row, 5, QTableWidgetItem(tool.get("version", "1.0.0")))
def _rating_to_stars(self, rating: float) -> str:
"""Convert rating to star display."""
if rating <= 0:
return "☆☆☆☆☆"
full_stars = int(rating)
half_star = rating - full_stars >= 0.5
empty_stars = 5 - full_stars - (1 if half_star else 0)
return "★" * full_stars + ("½" if half_star else "") + "☆" * empty_stars
def _format_downloads(self, downloads: int) -> str:
"""Format download count."""
if downloads >= 1000000:
return f"{downloads/1000000:.1f}M"
elif downloads >= 1000:
return f"{downloads/1000:.1f}K"
return str(downloads)
def _on_search_error(self, error: str):
"""Handle search error."""
self.btn_search.setEnabled(True)
self.btn_browse.setEnabled(True)
self.status_label.setText(f"Error: {error}")
def _prev_page(self):
"""Go to previous page."""
if self._current_page > 1:
self._current_page -= 1
self._do_search()
def _next_page(self):
"""Go to next page."""
if self._current_page < self._total_pages:
self._current_page += 1
self._do_search()
def _on_selection_changed(self):
"""Handle selection change."""
items = self.results_table.selectedItems()
if not items:
self._selected_tool = None
self.details_text.clear()
self.btn_install.setEnabled(False)
self.btn_update.hide()
return
row = items[0].row()
name_item = self.results_table.item(row, 1)
tool = name_item.data(Qt.UserRole)
self._selected_tool = tool
self._show_tool_details(tool)
# Check if installed / update available
tool_name = tool.get("name", "")
if tool_name in self._installed_tools:
installed_version = self._installed_tools[tool_name]
registry_version = tool.get("version", "1.0.0")
if installed_version != registry_version:
self.btn_install.hide()
self.btn_update.show()
self.btn_update.setEnabled(True)
self.btn_update.setText(f"Update ({installed_version} → {registry_version})")
else:
self.btn_install.setText("Reinstall")
self.btn_install.setEnabled(True)
self.btn_install.show()
self.btn_update.hide()
else:
self.btn_install.setText("Install")
self.btn_install.setEnabled(True)
self.btn_install.show()
self.btn_update.hide()
def _show_tool_details(self, tool: dict):
"""Show tool details."""
lines = []
owner = tool.get('owner', '')
name = tool.get('name', '')
lines.append(f"<h2>{owner}/{name}</h2>")
if tool.get("description"):
lines.append(f"<p style='color: #4a5568;'>{tool.get('description')}</p>")
# Rating display
rating = tool.get("average_rating", 0) or 0
rating_count = tool.get("rating_count", 0) or 0
stars = self._rating_to_stars(rating)
lines.append(f"<p><strong>Rating:</strong> {stars} ({rating:.1f}/5 from {rating_count} reviews)</p>")
lines.append(f"<p><strong>Version:</strong> {tool.get('version', '1.0.0')}</p>")
lines.append(f"<p><strong>Downloads:</strong> {tool.get('downloads', 0):,}</p>")
if tool.get("category"):
lines.append(f"<p><strong>Category:</strong> {tool.get('category')}</p>")
# Clickable tags
if tool.get("tags"):
tags_html = []
for tag in tool.get("tags", []):
tags_html.append(
f'<a href="tag:{tag}" style="background: #edf2f7; padding: 2px 8px; '
f'border-radius: 4px; text-decoration: none; color: #4a5568; margin-right: 4px;">{tag}</a>'
)
lines.append(f"<p><strong>Tags:</strong> {''.join(tags_html)}</p>")
# Publisher info
if tool.get("publisher_reputation"):
rep = tool.get("publisher_reputation", {})
lines.append(f"<p style='margin-top: 16px; color: #718096; font-size: 12px;'>"
f"Publisher: @{owner} · {rep.get('total_tools', 0)} tools · "
f"{rep.get('total_downloads', 0):,} total downloads</p>")
# Installed status
tool_name = tool.get("name", "")
if tool_name in self._installed_tools:
installed_version = self._installed_tools[tool_name]
registry_version = tool.get("version", "1.0.0")
if installed_version != registry_version:
lines.append(f"<p style='color: #48bb78; font-weight: 600;'>"
f"✓ Installed (v{installed_version}) - Update available!</p>")
else:
lines.append(f"<p style='color: #4299e1;'>✓ Installed (v{installed_version})</p>")
self.details_text.setHtml("\n".join(lines))
def _on_tag_clicked(self, url):
"""Handle tag click to filter by tag."""
tag = url.toString().replace("tag:", "")
if tag not in self._current_tags:
self._current_tags.append(tag)
self._update_tags_display()
self._current_page = 1
self._do_search()
def _update_tags_display(self):
"""Update the active tags display."""
# Clear existing tag buttons
while self.tags_layout.count() > 2:
item = self.tags_layout.takeAt(2)
if item.widget():
item.widget().deleteLater()
if self._current_tags:
self.tags_widget.show()
for tag in self._current_tags:
tag_btn = QPushButton(f"{tag} ×")
tag_btn.setStyleSheet(
"background: #4299e1; color: white; border: none; "
"padding: 4px 8px; border-radius: 4px;"
)
tag_btn.clicked.connect(lambda checked, t=tag: self._remove_tag(t))
self.tags_layout.insertWidget(self.tags_layout.count() - 1, tag_btn)
# Add clear all button
clear_btn = QPushButton("Clear all")
clear_btn.setStyleSheet("color: #e53e3e;")
clear_btn.setFlat(True)
clear_btn.clicked.connect(self._clear_tags)
self.tags_layout.insertWidget(self.tags_layout.count() - 1, clear_btn)
else:
self.tags_widget.hide()
def _remove_tag(self, tag: str):
"""Remove a tag from the filter."""
if tag in self._current_tags:
self._current_tags.remove(tag)
self._update_tags_display()
self._current_page = 1
self._do_search()
def _clear_tags(self):
"""Clear all tag filters."""
self._current_tags = []
self._update_tags_display()
self._current_page = 1
self._do_search()
def _install_tool(self):
"""Install selected tool."""
if not self._selected_tool:
return
owner = self._selected_tool.get("owner", "")
name = self._selected_tool.get("name", "")
self.btn_install.setEnabled(False)
self.btn_update.setEnabled(False)
self.status_label.setText(f"Installing {owner}/{name}...")
self._install_worker = InstallWorker(owner, name)
self._install_worker.finished.connect(self._on_install_complete)
self._install_worker.error.connect(self._on_install_error)
self._install_worker.start()
def _on_install_complete(self, tool_id: str):
"""Handle install complete."""
self.btn_install.setEnabled(True)
self.status_label.setText(f"Installed {tool_id}")
self.main_window.show_status(f"Installed {tool_id}")
# Refresh installed tools list
self._load_installed_tools()
# Update the display
if self._selected_tool:
self._show_tool_details(self._selected_tool)
self._on_selection_changed()
# Refresh the table to update indicators
self._do_search()
QMessageBox.information(self, "Success", f"Successfully installed {tool_id}")
def _on_install_error(self, error: str):
"""Handle install error."""
self.btn_install.setEnabled(True)
self.btn_update.setEnabled(True)
self.status_label.setText(f"Error: {error}")
QMessageBox.critical(self, "Install Error", f"Failed to install tool:\n{error}")
+655
View File
@@ -0,0 +1,655 @@
"""Tool builder page - create and edit tools."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
QLineEdit, QTextEdit, QComboBox, QPushButton,
QGroupBox, QListWidget, QListWidgetItem, QLabel,
QMessageBox, QSplitter, QFrame, QStackedWidget,
QButtonGroup
)
from PySide6.QtCore import Qt
from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep,
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
)
class ToolBuilderPage(QWidget):
"""Tool builder/editor page."""
def __init__(self, main_window, tool_name: str = None):
super().__init__()
self.main_window = main_window
self.editing = tool_name is not None
self.original_name = tool_name
self._tool = None
self._flow_widget = None # Lazy-loaded
self._setup_ui()
if tool_name:
self._load_tool(tool_name)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
header = QWidget()
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("Edit Tool" if self.editing else "Create Tool")
title.setObjectName("heading")
header_layout.addWidget(title)
header_layout.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self._cancel)
header_layout.addWidget(self.btn_cancel)
self.btn_save = QPushButton("Save")
self.btn_save.clicked.connect(self._save)
header_layout.addWidget(self.btn_save)
layout.addWidget(header)
# Main form splitter
splitter = QSplitter(Qt.Horizontal)
# Left: Basic info
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(16)
# Basic info group
info_box = QGroupBox("Basic Information")
info_layout = QFormLayout(info_box)
info_layout.setSpacing(12)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("my-tool")
info_layout.addRow("Name:", self.name_input)
self.desc_input = QLineEdit()
self.desc_input.setPlaceholderText("A brief description of what this tool does")
info_layout.addRow("Description:", self.desc_input)
self.category_combo = QComboBox()
self.category_combo.setEditable(True)
for cat in DEFAULT_CATEGORIES:
self.category_combo.addItem(cat)
info_layout.addRow("Category:", self.category_combo)
left_layout.addWidget(info_box)
# Arguments group
args_box = QGroupBox("Arguments")
args_layout = QVBoxLayout(args_box)
self.args_list = QListWidget()
self.args_list.itemDoubleClicked.connect(self._edit_argument)
args_layout.addWidget(self.args_list)
args_btns = QHBoxLayout()
self.btn_add_arg = QPushButton("Add")
self.btn_add_arg.clicked.connect(self._add_argument)
args_btns.addWidget(self.btn_add_arg)
self.btn_edit_arg = QPushButton("Edit")
self.btn_edit_arg.setObjectName("secondary")
self.btn_edit_arg.clicked.connect(self._edit_argument)
args_btns.addWidget(self.btn_edit_arg)
self.btn_del_arg = QPushButton("Delete")
self.btn_del_arg.setObjectName("danger")
self.btn_del_arg.clicked.connect(self._delete_argument)
args_btns.addWidget(self.btn_del_arg)
args_btns.addStretch()
args_layout.addLayout(args_btns)
left_layout.addWidget(args_box)
splitter.addWidget(left)
# Right: Steps and output
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(16)
# Steps group with view toggle
steps_box = QGroupBox("Steps")
steps_layout = QVBoxLayout(steps_box)
# View toggle header
view_header = QHBoxLayout()
self.btn_list_view = QPushButton("List")
self.btn_list_view.setCheckable(True)
self.btn_list_view.setChecked(True)
self.btn_list_view.setObjectName("viewToggle")
self.btn_list_view.clicked.connect(lambda: self._set_view_mode(0))
self.btn_flow_view = QPushButton("Flow")
self.btn_flow_view.setCheckable(True)
self.btn_flow_view.setObjectName("viewToggle")
self.btn_flow_view.clicked.connect(lambda: self._set_view_mode(1))
# Button group for mutual exclusivity
self.view_group = QButtonGroup(self)
self.view_group.addButton(self.btn_list_view, 0)
self.view_group.addButton(self.btn_flow_view, 1)
view_header.addWidget(self.btn_list_view)
view_header.addWidget(self.btn_flow_view)
view_header.addStretch()
steps_layout.addLayout(view_header)
# Stacked widget for list/flow views
self.steps_stack = QStackedWidget()
# List view (index 0)
list_container = QWidget()
list_layout = QVBoxLayout(list_container)
list_layout.setContentsMargins(0, 0, 0, 0)
self.steps_list = QListWidget()
self.steps_list.itemDoubleClicked.connect(self._edit_step)
# Enable drag-drop reordering
self.steps_list.setDragDropMode(QListWidget.InternalMove)
self.steps_list.setDefaultDropAction(Qt.MoveAction)
self.steps_list.model().rowsMoved.connect(self._on_steps_reordered)
list_layout.addWidget(self.steps_list)
self.steps_stack.addWidget(list_container)
# Flow view placeholder (index 1) - lazy loaded
flow_placeholder = QLabel("Loading flow visualization...")
flow_placeholder.setAlignment(Qt.AlignCenter)
flow_placeholder.setStyleSheet("color: #718096; padding: 40px;")
self.steps_stack.addWidget(flow_placeholder)
steps_layout.addWidget(self.steps_stack)
# Step action buttons
steps_btns = QHBoxLayout()
self.btn_add_prompt = QPushButton("Add Prompt")
self.btn_add_prompt.clicked.connect(self._add_prompt_step)
steps_btns.addWidget(self.btn_add_prompt)
self.btn_add_code = QPushButton("Add Code")
self.btn_add_code.clicked.connect(self._add_code_step)
steps_btns.addWidget(self.btn_add_code)
self.btn_edit_step = QPushButton("Edit")
self.btn_edit_step.setObjectName("secondary")
self.btn_edit_step.clicked.connect(self._edit_step)
steps_btns.addWidget(self.btn_edit_step)
self.btn_del_step = QPushButton("Delete")
self.btn_del_step.setObjectName("danger")
self.btn_del_step.clicked.connect(self._delete_step)
steps_btns.addWidget(self.btn_del_step)
steps_btns.addStretch()
steps_layout.addLayout(steps_btns)
right_layout.addWidget(steps_box, 1) # Steps box gets stretch priority
# Output group
output_box = QGroupBox("Output Template")
output_layout = QVBoxLayout(output_box)
self.output_input = QTextEdit()
self.output_input.setPlaceholderText("Use {variable} to reference step outputs, e.g. {response}")
self.output_input.setPlainText("{response}") # Default value
self.output_input.setMinimumHeight(80)
self.output_input.setMaximumHeight(120)
output_layout.addWidget(self.output_input)
right_layout.addWidget(output_box, 0) # Output box stays fixed size
splitter.addWidget(right)
splitter.setSizes([400, 600])
layout.addWidget(splitter, 1)
def _set_view_mode(self, mode: int):
"""Switch between list (0) and flow (1) views."""
if mode == 1 and self._flow_widget is None:
# Lazy-load flow widget
self._init_flow_widget()
self.steps_stack.setCurrentIndex(mode)
# Update flow widget if visible
if mode == 1 and self._flow_widget:
self._flow_widget.set_tool(self._tool)
def _init_flow_widget(self):
"""Initialize the flow visualization widget."""
try:
from ..widgets.flow_graph import FlowGraphWidget
self._flow_widget = FlowGraphWidget()
self._flow_widget.node_double_clicked.connect(self._on_flow_node_double_clicked)
self._flow_widget.steps_deleted.connect(self._on_flow_steps_deleted)
self._flow_widget.steps_reordered.connect(self._on_flow_steps_reordered)
# Replace placeholder
old_widget = self.steps_stack.widget(1)
self.steps_stack.removeWidget(old_widget)
old_widget.deleteLater()
self.steps_stack.insertWidget(1, self._flow_widget)
# Set current tool
if self._tool:
self._flow_widget.set_tool(self._tool)
except ImportError as e:
# NodeGraphQt not available
error_label = QLabel(f"Flow visualization unavailable:\n{e}")
error_label.setAlignment(Qt.AlignCenter)
error_label.setStyleSheet("color: #e53e3e; padding: 40px;")
old_widget = self.steps_stack.widget(1)
self.steps_stack.removeWidget(old_widget)
old_widget.deleteLater()
self.steps_stack.insertWidget(1, error_label)
def _on_flow_node_double_clicked(self, step_index: int, step_type: str):
"""Handle double-click on a flow node."""
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
return
step = self._tool.steps[step_index]
self._edit_step_at_index(step_index, step)
def _on_flow_steps_deleted(self, indices: list):
"""Handle step deletion from flow view."""
if not self._tool:
return
# Delete steps in reverse order (indices are already sorted reverse)
for idx in indices:
if 0 <= idx < len(self._tool.steps):
del self._tool.steps[idx]
# Refresh the list view (flow view will be refreshed by set_tool)
self._refresh_steps()
def _on_flow_steps_reordered(self, new_order: list):
"""Handle step reordering from flow view.
Args:
new_order: List of old indices in new order.
e.g., [0, 2, 1] means step 0 stays first,
step 2 moves to second, step 1 moves to third.
"""
if not self._tool or not self._tool.steps:
return
# Reorder steps according to new_order
old_steps = self._tool.steps[:]
new_steps = [old_steps[i] for i in new_order if i < len(old_steps)]
# Validate the new order
warnings = self._validate_step_order(new_steps)
if warnings:
# Show warning but allow the reorder
warning_msg = "Variable dependency warnings:\n\n" + "\n".join(warnings)
warning_msg += "\n\nThe reorder has been applied. You may need to fix these issues."
QMessageBox.warning(self, "Dependency Warning", warning_msg)
self._tool.steps = new_steps
# Refresh both views
self._refresh_steps()
def _load_tool(self, name: str):
"""Load an existing tool for editing."""
tool = load_tool(name)
if not tool:
QMessageBox.critical(self, "Error", f"Tool '{name}' not found")
self._cancel()
return
self._tool = tool
self.name_input.setText(tool.name)
self.name_input.setEnabled(False) # Can't rename
self.desc_input.setText(tool.description or "")
# Set category
if tool.category:
idx = self.category_combo.findText(tool.category)
if idx >= 0:
self.category_combo.setCurrentIndex(idx)
else:
self.category_combo.setCurrentText(tool.category)
# Load arguments
self._refresh_arguments()
# Load steps
self._refresh_steps()
# Set output
self.output_input.setPlainText(tool.output or "{response}")
def _refresh_arguments(self):
"""Refresh arguments list."""
self.args_list.clear()
if self._tool and self._tool.arguments:
for arg in self._tool.arguments:
item = QListWidgetItem(f"{arg.flag} → ${arg.variable}")
item.setData(Qt.UserRole, arg)
self.args_list.addItem(item)
def _refresh_steps(self):
"""Refresh steps list and flow view."""
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
# Use custom name if set, otherwise default per-type naming
step_name = step.name if step.name else f"Prompt {prompt_count}"
text = f"{i}. {step_name} [{step.provider}] → ${step.output_var}"
elif isinstance(step, CodeStep):
code_count += 1
step_name = step.name if step.name else f"Code {code_count}"
text = f"{i}. {step_name} [python] → ${step.output_var}"
else:
text = f"{i}. Unknown step"
item = QListWidgetItem(text)
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:
# Mark item with warning color and tooltip
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())
# Update flow widget if initialized
if self._flow_widget:
self._flow_widget.set_tool(self._tool)
def _add_argument(self):
"""Add a new argument."""
from ..dialogs.argument_dialog import ArgumentDialog
dialog = ArgumentDialog(self)
if dialog.exec():
arg = dialog.get_argument()
if not self._tool:
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
self._tool.arguments.append(arg)
self._refresh_arguments()
def _edit_argument(self):
"""Edit selected argument."""
items = self.args_list.selectedItems()
if not items:
return
arg = items[0].data(Qt.UserRole)
idx = self.args_list.row(items[0])
from ..dialogs.argument_dialog import ArgumentDialog
dialog = ArgumentDialog(self, arg)
if dialog.exec():
self._tool.arguments[idx] = dialog.get_argument()
self._refresh_arguments()
def _delete_argument(self):
"""Delete selected argument."""
items = self.args_list.selectedItems()
if not items:
return
idx = self.args_list.row(items[0])
del self._tool.arguments[idx]
self._refresh_arguments()
def _get_available_vars(self, up_to_step: int = -1) -> list:
"""Get available variables for a step.
Args:
up_to_step: Include variables from steps before this index.
-1 means include all existing steps (for new steps).
"""
vars_list = ["input"]
# Add argument variables
if self._tool and self._tool.arguments:
for arg in self._tool.arguments:
if arg.variable and arg.variable not in vars_list:
vars_list.append(arg.variable)
# Add output variables from previous steps
if self._tool and self._tool.steps:
steps_to_check = self._tool.steps if up_to_step < 0 else self._tool.steps[:up_to_step]
for step in steps_to_check:
if hasattr(step, 'output_var') and step.output_var:
# Handle comma-separated output vars
for var in step.output_var.split(','):
var = var.strip()
if var and var not in vars_list:
vars_list.append(var)
return vars_list
def _add_prompt_step(self):
"""Add a prompt step."""
from ..dialogs.step_dialog import PromptStepDialog
dialog = PromptStepDialog(self)
if dialog.exec():
step = dialog.get_step()
if not self._tool:
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
self._tool.steps.append(step)
self._refresh_steps()
def _add_code_step(self):
"""Add a code step."""
from ..dialogs.step_dialog import CodeStepDialog
available_vars = self._get_available_vars()
dialog = CodeStepDialog(self, available_vars=available_vars)
if dialog.exec():
step = dialog.get_step()
if not self._tool:
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
self._tool.steps.append(step)
self._refresh_steps()
def _edit_step(self):
"""Edit selected step from list view."""
items = self.steps_list.selectedItems()
if not items:
return
step = items[0].data(Qt.UserRole)
idx = self.steps_list.row(items[0])
self._edit_step_at_index(idx, step)
def _edit_step_at_index(self, idx: int, step):
"""Edit a step at a specific index."""
if isinstance(step, PromptStep):
from ..dialogs.step_dialog import PromptStepDialog
dialog = PromptStepDialog(self, step)
elif isinstance(step, CodeStep):
from ..dialogs.step_dialog import CodeStepDialog
available_vars = self._get_available_vars(up_to_step=idx)
dialog = CodeStepDialog(self, step, available_vars=available_vars)
else:
return
if dialog.exec():
self._tool.steps[idx] = dialog.get_step()
self._refresh_steps()
def _delete_step(self):
"""Delete selected step."""
items = self.steps_list.selectedItems()
if not items:
return
idx = self.steps_list.row(items[0])
del self._tool.steps[idx]
self._refresh_steps()
def _on_steps_reordered(self, parent, start, end, dest, row):
"""Handle step reordering via drag-drop."""
if not self._tool or not self._tool.steps:
return
# Rebuild steps list from current list widget order
new_steps = []
for i in range(self.steps_list.count()):
item = self.steps_list.item(i)
step = item.data(Qt.UserRole)
if step:
new_steps.append(step)
# Validate the new order
warnings = self._validate_step_order(new_steps)
if warnings:
# Show warning but allow the reorder
warning_msg = "Variable dependency warnings:\n\n" + "\n".join(warnings)
warning_msg += "\n\nThe reorder has been applied. You may need to fix these issues."
QMessageBox.warning(self, "Dependency Warning", warning_msg)
# Apply the new order
self._tool.steps = new_steps
# Refresh to update numbering and flow view
self._refresh_steps()
def _validate_step_order(self, steps: list) -> list:
"""Validate step order for variable dependencies.
Returns a list of warning messages for broken dependencies.
"""
warnings = []
# Track available variables at each position
available = {"input"}
# Add argument variables
if self._tool and self._tool.arguments:
for arg in self._tool.arguments:
if arg.variable:
available.add(arg.variable)
for i, step in enumerate(steps):
# Get variables this step references
refs = self._get_step_variable_refs(step)
# Check for undefined references
for ref in refs:
if ref not in available:
step_name = step.name if step.name else f"Step {i+1}"
warnings.append(f"{step_name}: references '{{{ref}}}' which is not defined yet")
# Add this step's output to available vars
if hasattr(step, 'output_var') and step.output_var:
for var in step.output_var.split(','):
available.add(var.strip())
return warnings
def _get_step_variable_refs(self, step) -> set:
"""Extract variable references from a step.
Parses {variable} patterns from prompt templates.
Note: CodeSteps are NOT parsed because variables are available directly
as Python variables, and {var} in code is typically Python f-string
or .format() syntax, not CmdForge substitution.
"""
import re
refs = set()
# Pattern to match {variable} but not {{escaped}}
pattern = r'\{(\w+)\}'
if isinstance(step, PromptStep):
# Parse prompt template - these ARE CmdForge substitutions
matches = re.findall(pattern, step.prompt or "")
refs.update(matches)
# CodeStep: Don't parse - variables are available as Python vars directly
# and {var} syntax is typically Python string formatting
return refs
def _save(self):
"""Save the tool."""
name = self.name_input.text().strip()
if not name:
QMessageBox.warning(self, "Validation", "Tool name is required")
return
# Validate name
error = validate_tool_name(name)
if error:
QMessageBox.warning(self, "Validation", error)
return
description = self.desc_input.text().strip()
category = self.category_combo.currentText()
output = self.output_input.toPlainText().strip() or "{response}"
# Build tool object
tool = Tool(
name=name,
description=description,
category=category,
arguments=self._tool.arguments if self._tool else [],
steps=self._tool.steps if self._tool else [],
output=output
)
# Preserve source if editing
if self._tool and self._tool.source:
tool.source = self._tool.source
try:
save_tool(tool)
self.main_window.show_status(f"Saved tool '{name}'")
self.main_window.close_tool_builder()
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save tool:\n{e}")
def _cancel(self):
"""Cancel and return to tools page."""
self.main_window.close_tool_builder()
def save_tool(self):
"""Public method for keyboard shortcut to save the tool."""
self._save()
+331
View File
@@ -0,0 +1,331 @@
"""Tools page - main view for managing tools."""
from collections import defaultdict
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
QPushButton, QGroupBox, QMessageBox, QFrame
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES
)
from ...config import load_config
class ToolsPage(QWidget):
"""Main tools management page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._current_tool = None
self._setup_ui()
self.refresh()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
header = QWidget()
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("My Tools")
title.setObjectName("heading")
header_layout.addWidget(title)
header_layout.addStretch()
# Connection status
config = load_config()
if config.registry.token:
status = QLabel("Connected to Registry")
status.setStyleSheet("color: #38a169; font-weight: 500;")
else:
status = QLabel("Not connected")
status.setStyleSheet("color: #718096;")
header_layout.addWidget(status)
layout.addWidget(header)
# Main content splitter
splitter = QSplitter(Qt.Horizontal)
# Left side: Tool list
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(8)
self.tool_tree = QTreeWidget()
self.tool_tree.setHeaderHidden(True)
self.tool_tree.setIndentation(16)
self.tool_tree.setRootIsDecorated(True)
self.tool_tree.itemSelectionChanged.connect(self._on_selection_changed)
self.tool_tree.itemDoubleClicked.connect(self._on_double_click)
left_layout.addWidget(self.tool_tree, 1)
splitter.addWidget(left)
# Right side: Info panel
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(16)
# Tool info
info_box = QGroupBox("Tool Details")
info_layout = QVBoxLayout(info_box)
self.info_text = QTextEdit()
self.info_text.setReadOnly(True)
self.info_text.setPlaceholderText("Select a tool to view details")
info_layout.addWidget(self.info_text)
right_layout.addWidget(info_box, 1)
splitter.addWidget(right)
# Set splitter sizes
splitter.setSizes([350, 650])
layout.addWidget(splitter, 1)
# Action buttons
buttons = QWidget()
btn_layout = QHBoxLayout(buttons)
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.setSpacing(12)
self.btn_create = QPushButton("Create")
self.btn_create.clicked.connect(self._create_tool)
btn_layout.addWidget(self.btn_create)
self.btn_edit = QPushButton("Edit")
self.btn_edit.setObjectName("secondary")
self.btn_edit.clicked.connect(self._edit_tool)
self.btn_edit.setEnabled(False)
btn_layout.addWidget(self.btn_edit)
self.btn_delete = QPushButton("Delete")
self.btn_delete.setObjectName("danger")
self.btn_delete.clicked.connect(self._delete_tool)
self.btn_delete.setEnabled(False)
btn_layout.addWidget(self.btn_delete)
btn_layout.addStretch()
# Connect/Publish button
config = load_config()
if config.registry.token:
self.btn_publish = QPushButton("Publish")
self.btn_publish.clicked.connect(self._publish_tool)
self.btn_publish.setEnabled(False)
else:
self.btn_publish = QPushButton("Connect")
self.btn_publish.clicked.connect(self._connect_registry)
btn_layout.addWidget(self.btn_publish)
layout.addWidget(buttons)
def refresh(self):
"""Refresh the tool list."""
self.tool_tree.clear()
self._current_tool = None
self.info_text.clear()
tools = list_tools()
# Group tools by category
tools_by_category = defaultdict(list)
for name in tools:
tool = load_tool(name)
if tool:
category = tool.category if tool.category else "Other"
tools_by_category[category].append((name, tool))
# Build tree with categories
all_categories = list(DEFAULT_CATEGORIES)
for cat in tools_by_category:
if cat not in all_categories:
all_categories.append(cat)
for category in all_categories:
if category in tools_by_category and tools_by_category[category]:
# Category item
cat_item = QTreeWidgetItem([category])
cat_item.setExpanded(True)
font = cat_item.font(0)
font.setBold(True)
cat_item.setFont(0, font)
cat_item.setFlags(cat_item.flags() & ~Qt.ItemIsSelectable)
# Tools in category
for name, tool in sorted(tools_by_category[category], key=lambda x: x[0]):
tool_item = QTreeWidgetItem([name])
tool_item.setData(0, Qt.UserRole, name)
if tool.source and tool.source.type == "imported":
tool_item.setToolTip(0, f"Imported from {tool.source.url or 'registry'}")
cat_item.addChild(tool_item)
self.tool_tree.addTopLevelItem(cat_item)
# Update button states
self._update_buttons()
if not tools:
self.info_text.setPlaceholderText(
"No tools found.\n\nClick 'Create' to build your first tool, "
"or browse the Registry to install community tools."
)
def _on_selection_changed(self):
"""Handle tool selection change."""
items = self.tool_tree.selectedItems()
if not items:
self._current_tool = None
self.info_text.clear()
self._update_buttons()
return
item = items[0]
tool_name = item.data(0, Qt.UserRole)
if not tool_name:
self._current_tool = None
self.info_text.clear()
self._update_buttons()
return
tool = load_tool(tool_name)
if tool:
self._current_tool = tool
self._show_tool_info(tool)
self._update_buttons()
def _on_double_click(self, item, column):
"""Handle double-click on tool."""
tool_name = item.data(0, Qt.UserRole)
if tool_name:
self._edit_tool()
def _show_tool_info(self, tool: Tool):
"""Display tool information."""
lines = []
# Name and description
lines.append(f"<h2 style='margin: 0 0 8px 0; color: #2d3748;'>{tool.name}</h2>")
if tool.description:
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
# Source info
if tool.source:
source_type = tool.source.type
if source_type == "imported":
source_url = tool.source.url or "registry"
lines.append(f"<p style='color: #718096; font-size: 12px;'>Imported from {source_url}</p>")
elif source_type == "forked":
lines.append(f"<p style='color: #718096; font-size: 12px;'>Forked from {tool.source.original_tool}</p>")
# Arguments
if tool.arguments:
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Arguments</h3>")
lines.append("<ul style='margin: 8px 0;'>")
for arg in tool.arguments:
default = f" (default: {arg.default})" if arg.default else ""
lines.append(f"<li><code>{arg.flag}</code> → <code>${arg.variable}</code>{default}</li>")
lines.append("</ul>")
# Steps
if tool.steps:
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Steps</h3>")
lines.append("<ol style='margin: 8px 0;'>")
for i, step in enumerate(tool.steps, 1):
if isinstance(step, PromptStep):
lines.append(f"<li><strong>Prompt</strong> using <code>{step.provider}</code> → <code>${step.output_var}</code></li>")
elif isinstance(step, CodeStep):
lines.append(f"<li><strong>Code</strong> (python) → <code>${step.output_var}</code></li>")
elif isinstance(step, ToolStep):
lines.append(f"<li><strong>Tool</strong>: <code>{step.tool}</code> → <code>${step.output_var}</code></li>")
lines.append("</ol>")
# Output
if tool.output:
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Output Template</h3>")
output_escaped = tool.output.replace("<", "&lt;").replace(">", "&gt;")
lines.append(f"<pre style='background: #edf2f7; padding: 8px; border-radius: 4px;'>{output_escaped}</pre>")
# Category
if tool.category:
lines.append(f"<p style='color: #718096; font-size: 12px; margin-top: 16px;'>Category: {tool.category}</p>")
self.info_text.setHtml("\n".join(lines))
def _update_buttons(self):
"""Update button enabled states."""
has_selection = self._current_tool is not None
self.btn_edit.setEnabled(has_selection)
self.btn_delete.setEnabled(has_selection)
config = load_config()
if config.registry.token:
self.btn_publish.setEnabled(has_selection)
def _create_tool(self):
"""Create a new tool."""
self.main_window.open_tool_builder()
def _edit_tool(self):
"""Edit the selected tool."""
if self._current_tool:
self.main_window.open_tool_builder(self._current_tool.name)
def _delete_tool(self):
"""Delete the selected tool."""
if not self._current_tool:
return
reply = QMessageBox.question(
self,
"Delete Tool",
f"Are you sure you want to delete '{self._current_tool.name}'?\n\n"
"This will remove the tool configuration and wrapper script.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
try:
delete_tool(self._current_tool.name)
self.main_window.show_status(f"Deleted tool '{self._current_tool.name}'")
self.refresh()
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to delete tool:\n{e}")
def _connect_registry(self):
"""Open connect dialog."""
from ..dialogs.connect_dialog import ConnectDialog
dialog = ConnectDialog(self)
if dialog.exec():
self.refresh()
self.main_window.show_status("Connected to registry")
# Recreate publish button
self.btn_publish.setText("Publish")
self.btn_publish.clicked.disconnect()
self.btn_publish.clicked.connect(self._publish_tool)
def _publish_tool(self):
"""Publish the selected tool."""
if not self._current_tool:
return
from ..dialogs.publish_dialog import PublishDialog
dialog = PublishDialog(self, self._current_tool)
if dialog.exec():
self.main_window.show_status(f"Published '{self._current_tool.name}'")
+175
View File
@@ -0,0 +1,175 @@
"""Welcome page - CmdForge landing page."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFrame, QGridLayout, QSizePolicy
)
from PySide6.QtCore import Qt
class WelcomePage(QWidget):
"""CmdForge welcome/landing page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._setup_ui()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(48, 48, 48, 48)
layout.setSpacing(32)
# Top spacer
layout.addStretch(1)
# Logo/title
title = QLabel("CmdForge")
title.setStyleSheet("""
font-size: 48px;
font-weight: bold;
color: #667eea;
""")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# Tagline
tagline = QLabel("Build AI-powered command-line tools")
tagline.setStyleSheet("""
font-size: 18px;
color: #718096;
""")
tagline.setAlignment(Qt.AlignCenter)
layout.addWidget(tagline)
layout.addSpacing(24)
# Quick actions
actions_container = QWidget()
actions_container.setMaximumWidth(600)
actions_layout = QGridLayout(actions_container)
actions_layout.setSpacing(16)
# Create tool card
create_card = self._create_action_card(
"Create Tool",
"Build a new AI-powered command-line tool with prompts, code, and data flow",
"New Tool",
self._on_create_tool
)
actions_layout.addWidget(create_card, 0, 0)
# Browse registry card
registry_card = self._create_action_card(
"Browse Registry",
"Discover and install tools shared by the community",
"Open Registry",
self._on_browse_registry
)
actions_layout.addWidget(registry_card, 0, 1)
# Manage tools card
tools_card = self._create_action_card(
"My Tools",
"View, edit, and run your existing tools",
"Open Tools",
self._on_open_tools
)
actions_layout.addWidget(tools_card, 1, 0)
# Providers card
providers_card = self._create_action_card(
"AI Providers",
"Configure AI backends like Claude, GPT, and custom providers",
"Configure",
self._on_open_providers
)
actions_layout.addWidget(providers_card, 1, 1)
# Center the actions container
actions_wrapper = QHBoxLayout()
actions_wrapper.addStretch()
actions_wrapper.addWidget(actions_container)
actions_wrapper.addStretch()
layout.addLayout(actions_wrapper)
# Bottom spacer
layout.addStretch(2)
# Footer
footer = QLabel("CmdForge - AI Tool Builder")
footer.setStyleSheet("color: #a0aec0; font-size: 12px;")
footer.setAlignment(Qt.AlignCenter)
layout.addWidget(footer)
def _create_action_card(self, title: str, description: str, button_text: str, callback) -> QFrame:
"""Create an action card widget."""
card = QFrame()
card.setStyleSheet("""
QFrame {
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
}
QFrame:hover {
border-color: #667eea;
}
""")
card.setMinimumSize(260, 140)
layout = QVBoxLayout(card)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(8)
# Title
title_label = QLabel(title)
title_label.setStyleSheet("font-size: 16px; font-weight: 600; color: #2d3748; border: none;")
layout.addWidget(title_label)
# Description
desc_label = QLabel(description)
desc_label.setWordWrap(True)
desc_label.setStyleSheet("font-size: 13px; color: #718096; border: none;")
layout.addWidget(desc_label, 1)
# Button
btn = QPushButton(button_text)
btn.clicked.connect(callback)
btn.setStyleSheet("""
QPushButton {
background-color: #667eea;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-weight: 500;
}
QPushButton:hover {
background-color: #5a67d8;
}
""")
layout.addWidget(btn)
return card
def _on_create_tool(self):
"""Handle create tool action."""
self.main_window.open_tool_builder()
def _on_browse_registry(self):
"""Handle browse registry action."""
self.main_window.navigate_to("registry")
def _on_open_tools(self):
"""Handle open tools action."""
self.main_window.navigate_to("tools")
def _on_open_providers(self):
"""Handle open providers action."""
self.main_window.navigate_to("providers")
def refresh(self):
"""Refresh the page (nothing to refresh)."""
pass

Some files were not shown because too many files have changed in this diff Show More