Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e01a335c0 | ||
|
|
78025aac8e | ||
|
|
8e6b03cade | ||
|
|
bc970fb9f7 | ||
|
|
683a8a6d8f | ||
|
|
d1f0c2f893 | ||
|
|
260ebf1b2f | ||
|
|
b8760eb208 | ||
|
|
8ab0fba67c | ||
|
|
827f0f9eef | ||
|
|
fb879d09ea | ||
|
|
4d9a0e5943 | ||
|
|
88c9a8a1e7 | ||
|
|
3c39cc9cda | ||
|
|
ad9a59283c | ||
|
|
042be8b49b | ||
|
|
a5e0948881 | ||
|
|
d1eb4231a7 | ||
|
|
436d6292ff | ||
|
|
d0ff6c0c35 | ||
|
|
2438aec831 | ||
|
|
bee74061b1 | ||
|
|
9588c3f8b0 | ||
|
|
9d56f703cd | ||
|
|
ebb70f5865 | ||
|
|
305c09a929 | ||
|
|
518a04a8b0 | ||
|
|
4fe2d26244 | ||
|
|
604b473806 | ||
|
|
d2c668dc99 | ||
|
|
367fac204b | ||
|
|
3fe7b07c70 | ||
|
|
155905b61e | ||
|
|
eacfd0d74a | ||
|
|
a4ed9ed730 | ||
|
|
50326a11c6 | ||
|
|
58f187ccad | ||
|
|
946a5933b4 | ||
|
|
b1d1a3692d | ||
|
|
63149aec07 | ||
|
|
0462750b88 | ||
|
|
4d09c3f912 | ||
|
|
a70267cf53 | ||
|
|
e18a575f76 | ||
|
|
79739369f0 | ||
|
|
86d82fcd72 | ||
|
|
19e5be7e5a | ||
|
|
c89bd44be8 | ||
|
|
b40f26622f | ||
|
|
14448408af | ||
|
|
614b145f5f | ||
|
|
f86db9e0d9 | ||
|
|
9141a3eedf | ||
|
|
62f06813a4 | ||
|
|
b324d67a31 | ||
|
|
9fbe45244d | ||
|
|
1caa3454f0 | ||
|
|
2c139b3982 | ||
|
|
497fe87fc5 | ||
|
|
2939dd806b | ||
|
|
3efed0bf33 | ||
|
|
a80adcedfd | ||
|
|
e2d1002d2e | ||
|
|
9699717443 | ||
|
|
0a1c03ce09 | ||
|
|
01995392da | ||
|
|
62e0ddeabd | ||
|
|
d85eb36a72 | ||
|
|
55d46d1f61 | ||
|
|
6acbafc061 | ||
|
|
427dfcb669 | ||
|
|
d072d7ccad | ||
|
|
c1eb425457 | ||
|
|
02842e4287 | ||
|
|
940f91d445 | ||
|
|
cdb17db43a | ||
|
|
97f9de52e6 | ||
|
|
4fb229858f | ||
|
|
e7937361f3 | ||
|
|
26ae319815 | ||
|
|
357997dc7d | ||
|
|
8c7c22b958 | ||
|
|
4318b6cec5 | ||
|
|
1ac3054aaa | ||
|
|
096c0739d8 | ||
|
|
9a33d57de1 | ||
|
|
e029166ba6 | ||
|
|
9070029d31 | ||
|
|
24c91b8de7 | ||
|
|
b26ae5092b | ||
|
|
c41d2eace2 | ||
|
|
16554c0694 | ||
|
|
0200ad4f6e | ||
|
|
b90e632ec7 | ||
|
|
d6519bde66 | ||
|
|
c8a937ec15 | ||
|
|
cfb9858967 | ||
|
|
34428f0e72 | ||
|
|
284e9981bc |
@@ -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
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
@@ -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?
|
||||||
+11
@@ -35,5 +35,16 @@ ENV/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Project specific
|
# Project specific
|
||||||
*.log
|
*.log
|
||||||
|
discussions/
|
||||||
|
diagrams/
|
||||||
|
*.puml
|
||||||
|
.tmp_*
|
||||||
|
|
||||||
|
# Documentation symlink (points to project-docs)
|
||||||
|
docs
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Auto-deploy test Tue 13 Jan 2026 03:09:44 AM AST
|
||||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
SmartTools 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.
|
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
|
## Development Commands
|
||||||
|
|
||||||
@@ -19,29 +19,30 @@ pytest
|
|||||||
pytest tests/test.py::test_name
|
pytest tests/test.py::test_name
|
||||||
|
|
||||||
# Run the CLI
|
# Run the CLI
|
||||||
python -m smarttools.cli
|
python -m cmdforge.cli
|
||||||
|
|
||||||
# Launch the UI
|
# Launch the GUI
|
||||||
smarttools ui
|
cmdforge
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Core Modules (`src/smarttools/`)
|
### Core Modules (`src/cmdforge/`)
|
||||||
|
|
||||||
- **cli.py**: Entry point (`smarttools` command). Routes subcommands: list, create, edit, delete, test, run, ui, refresh
|
- **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
|
- **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()`
|
- **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 `~/.smarttools/providers.yaml`
|
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.cmdforge/providers.yaml`
|
||||||
- **ui.py**: UI dispatcher - selects between urwid and snack implementations
|
- **gui/**: PySide6 desktop GUI
|
||||||
- **ui_urwid.py**: Full TUI implementation using urwid library
|
- **main_window.py**: Main application window with sidebar navigation
|
||||||
- **ui_snack.py**: Fallback TUI using python-newt/snack
|
- **pages/**: Tools page, Tool Builder, Registry browser, Providers management
|
||||||
|
- **dialogs/**: Step editors, Argument editor, Provider dialog, Connect/Publish dialogs
|
||||||
|
|
||||||
### Key Paths
|
### Key Paths
|
||||||
|
|
||||||
- **Tools storage**: `~/.smarttools/<toolname>/config.yaml`
|
- **Tools storage**: `~/.cmdforge/<toolname>/config.yaml`
|
||||||
- **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts)
|
- **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts)
|
||||||
- **Provider config**: `~/.smarttools/providers.yaml`
|
- **Provider config**: `~/.cmdforge/providers.yaml`
|
||||||
|
|
||||||
### Tool Structure
|
### Tool Structure
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ Variable substitution is simple string replacement in `runner.py:substitute_vari
|
|||||||
|
|
||||||
## Provider System
|
## Provider System
|
||||||
|
|
||||||
Providers are CLI tools that accept prompts via stdin and output to stdout. Defined in `~/.smarttools/providers.yaml`:
|
Providers are CLI tools that accept prompts via stdin and output to stdout. Defined in `~/.cmdforge/providers.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
providers:
|
providers:
|
||||||
@@ -78,3 +79,59 @@ providers:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The `mock` provider is built-in for testing without API calls.
|
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) |
|
||||||
|
|||||||
+13
-13
@@ -1,11 +1,11 @@
|
|||||||
# SmartTools - AI-powered CLI command builder
|
# CmdForge - AI-powered CLI command builder
|
||||||
# Build: docker build -t smarttools .
|
# Build: docker build -t cmdforge .
|
||||||
# Run: docker run -it --rm -v ~/.smarttools:/root/.smarttools smarttools
|
# Run: docker run -it --rm -v ~/.cmdforge:/root/.cmdforge cmdforge
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
LABEL maintainer="rob"
|
LABEL maintainer="rob"
|
||||||
LABEL description="SmartTools - Personal AI-powered CLI command builder"
|
LABEL description="CmdForge - Personal AI-powered CLI command builder"
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -31,26 +31,26 @@ WORKDIR /app
|
|||||||
COPY pyproject.toml README.md ./
|
COPY pyproject.toml README.md ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY examples/ ./examples/
|
COPY examples/ ./examples/
|
||||||
COPY docs/ ./docs/
|
COPY tests/ ./tests/
|
||||||
|
|
||||||
# Install SmartTools and dependencies
|
# Install CmdForge and all dependencies (CLI, TUI, registry)
|
||||||
RUN pip install --no-cache-dir -e ".[dev]"
|
RUN pip install --no-cache-dir -e ".[all,dev]"
|
||||||
|
|
||||||
# Create smarttools directory
|
# Create cmdforge directory
|
||||||
RUN mkdir -p /root/.smarttools /root/.local/bin
|
RUN mkdir -p /root/.cmdforge /root/.local/bin
|
||||||
|
|
||||||
# Install example tools
|
# Install example tools
|
||||||
RUN python examples/install.py
|
RUN python examples/install.py
|
||||||
|
|
||||||
# Generate CLI wrappers
|
# Generate CLI wrappers
|
||||||
RUN smarttools refresh
|
RUN cmdforge refresh
|
||||||
|
|
||||||
# Add local bin to PATH
|
# Add local bin to PATH
|
||||||
ENV PATH="/root/.local/bin:${PATH}"
|
ENV PATH="/root/.local/bin:${PATH}"
|
||||||
|
|
||||||
# Default command - show help
|
# Default command - show help
|
||||||
CMD ["smarttools", "--help"]
|
CMD ["cmdforge", "--help"]
|
||||||
|
|
||||||
# For interactive use:
|
# For interactive use:
|
||||||
# docker run -it --rm smarttools smarttools ui
|
# docker run -it --rm cmdforge cmdforge ui
|
||||||
# docker run -it --rm smarttools bash
|
# docker run -it --rm cmdforge bash
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# SmartTools
|
# CmdForge
|
||||||
|
|
||||||
**A lightweight personal tool builder for AI-powered CLI commands.**
|
**A lightweight personal tool builder for AI-powered CLI commands.**
|
||||||
|
|
||||||
@@ -20,27 +20,28 @@ echo "Price $49.99, SKU ABC-123" | json-extract --fields "price, sku"
|
|||||||
# Output: {"price": 49.99, "sku": "ABC-123"}
|
# Output: {"price": 49.99, "sku": "ABC-123"}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why SmartTools?
|
## Why CmdForge?
|
||||||
|
|
||||||
- **Unix Philosophy** - Tools that do one thing well, composable with pipes
|
- **Unix Philosophy** - Tools that do one thing well, composable with pipes
|
||||||
- **Provider Agnostic** - Works with Claude, GPT, Gemini, DeepSeek, local models
|
- **Provider Agnostic** - Works with Claude, GPT, Gemini, DeepSeek, local models
|
||||||
- **No Lock-in** - Your tools are YAML files, your prompts are yours
|
- **No Lock-in** - Your tools are YAML files, your prompts are yours
|
||||||
- **Multi-step Pipelines** - Chain AI prompts with Python code for validation
|
- **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
|
- **12+ Providers Profiled** - We tested them so you don't have to
|
||||||
|
|
||||||
## Try It Now (60 seconds)
|
## Try It Now (60 seconds)
|
||||||
|
|
||||||
See SmartTools in action without installing anything on your system:
|
See CmdForge in action without installing anything on your system:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pull the container
|
# Pull the container
|
||||||
docker pull gitea.brrd.tech/rob/smarttools:latest
|
docker pull gitea.brrd.tech/rob/cmdforge:latest
|
||||||
|
|
||||||
# Run it
|
# Run it
|
||||||
docker run -it --rm gitea.brrd.tech/rob/smarttools bash
|
docker run -it --rm gitea.brrd.tech/rob/cmdforge bash
|
||||||
|
|
||||||
# Inside the container - install OpenCode (includes free AI models)
|
# Inside the container - install OpenCode (includes free AI models)
|
||||||
smarttools providers install # Select option 4, then 'y'
|
cmdforge providers install # Select option 4, then 'y'
|
||||||
source ~/.bashrc
|
source ~/.bashrc
|
||||||
|
|
||||||
# Try it! This explains the README using the free Big Pickle model
|
# Try it! This explains the README using the free Big Pickle model
|
||||||
@@ -55,21 +56,21 @@ For regular use, install natively:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and install
|
# Clone and install
|
||||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||||
cd SmartTools
|
cd CmdForge
|
||||||
pip install -e .
|
pip install -e .
|
||||||
|
|
||||||
# Ensure ~/.local/bin is in PATH
|
# Ensure ~/.local/bin is in PATH
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
# Install an AI provider (interactive guide)
|
# Install an AI provider (interactive guide)
|
||||||
smarttools providers install
|
cmdforge providers install
|
||||||
|
|
||||||
# Launch the UI
|
# Launch the GUI
|
||||||
smarttools ui
|
cmdforge
|
||||||
|
|
||||||
# Or create your first tool
|
# Or create your first tool via CLI
|
||||||
smarttools create summarize
|
cmdforge create summarize
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -77,16 +78,16 @@ smarttools create summarize
|
|||||||
### Native Install
|
### Native Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||||
cd SmartTools
|
cd CmdForge
|
||||||
pip install -e .
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
### With Development Dependencies
|
### With Development Dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||||
cd SmartTools
|
cd CmdForge
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ pip install -e ".[dev]"
|
|||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md))
|
- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md))
|
||||||
- Optional: `urwid` for the TUI (`pip install urwid`)
|
- PySide6 (included automatically - requires display server on Linux)
|
||||||
|
|
||||||
### Post-Install
|
### Post-Install
|
||||||
|
|
||||||
@@ -107,29 +108,33 @@ export PATH="$HOME/.local/bin:$PATH"
|
|||||||
Then refresh wrapper scripts:
|
Then refresh wrapper scripts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
smarttools refresh
|
cmdforge refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### UI Mode (Recommended for Beginners)
|
### GUI Mode (Recommended for Beginners)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
smarttools ui
|
cmdforge
|
||||||
```
|
```
|
||||||
|
|
||||||
Navigate with arrow keys, Tab, Enter, and mouse. Create tools visually with the built-in prompt editor.
|
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
|
### CLI Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
smarttools list # List all tools
|
cmdforge list # List all tools
|
||||||
smarttools create mytool # Create new tool
|
cmdforge create mytool # Create new tool
|
||||||
smarttools edit mytool # Edit in $EDITOR
|
cmdforge edit mytool # Edit in $EDITOR
|
||||||
smarttools delete mytool # Delete tool
|
cmdforge delete mytool # Delete tool
|
||||||
smarttools run mytool # Run a tool
|
cmdforge run mytool # Run a tool
|
||||||
smarttools test mytool # Test with mock provider
|
cmdforge test mytool # Test with mock provider
|
||||||
smarttools refresh # Update executable wrappers
|
cmdforge check mytool # Check dependencies (meta-tools)
|
||||||
|
cmdforge refresh # Update executable wrappers
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Tools
|
### Running Tools
|
||||||
@@ -155,10 +160,10 @@ cat file.txt | mytool --provider mock
|
|||||||
|
|
||||||
### Composing Tools
|
### Composing Tools
|
||||||
|
|
||||||
SmartTools follows Unix philosophy: each tool does one thing well, and tools chain together for complex workflows.
|
CmdForge follows Unix philosophy: each tool does one thing well, and tools chain together for complex workflows.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Chain SmartTools together
|
# Chain CmdForge together
|
||||||
cat error.log | log-errors | summarize | translate --lang Spanish
|
cat error.log | log-errors | summarize | translate --lang Spanish
|
||||||
|
|
||||||
# Code review pipeline: focus on changes, review, then summarize
|
# Code review pipeline: focus on changes, review, then summarize
|
||||||
@@ -208,7 +213,7 @@ translate-doc() {
|
|||||||
|
|
||||||
## Example Tools
|
## Example Tools
|
||||||
|
|
||||||
SmartTools comes with 28 pre-built examples you can install:
|
CmdForge comes with 28 pre-built examples you can install:
|
||||||
|
|
||||||
### Text Processing
|
### Text Processing
|
||||||
|
|
||||||
@@ -259,14 +264,14 @@ SmartTools comes with 28 pre-built examples you can install:
|
|||||||
### Install Example Tools
|
### Install Example Tools
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run the example installer (from the SmartTools directory)
|
# Run the example installer (from the CmdForge directory)
|
||||||
python examples/install.py
|
python examples/install.py
|
||||||
smarttools refresh
|
cmdforge refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Providers
|
## Providers
|
||||||
|
|
||||||
SmartTools works with any AI CLI tool. We've profiled 12 providers:
|
CmdForge works with any AI CLI tool. We've profiled 12 providers:
|
||||||
|
|
||||||
| Provider | Speed | Accuracy | Cost | Best For |
|
| Provider | Speed | Accuracy | Cost | Best For |
|
||||||
|----------|-------|----------|------|----------|
|
|----------|-------|----------|------|----------|
|
||||||
@@ -281,7 +286,7 @@ See [docs/PROVIDERS.md](docs/PROVIDERS.md) for setup instructions.
|
|||||||
|
|
||||||
## Tool Anatomy
|
## Tool Anatomy
|
||||||
|
|
||||||
A tool is a YAML config file in `~/.smarttools/<name>/config.yaml`:
|
A tool is a YAML config file in `~/.cmdforge/<name>/config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: summarize
|
name: summarize
|
||||||
@@ -340,13 +345,44 @@ steps:
|
|||||||
output: "{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
|
### Variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `{input}` | The piped/file input |
|
| `{input}` | The piped/file input |
|
||||||
| `{argname}` | Custom argument value |
|
| `{argname}` | Custom argument value |
|
||||||
| `{step_output}` | Output from previous step |
|
| `{output_var}` | Output from previous step (prompt, code, or tool) |
|
||||||
|
|
||||||
## Shell Integration
|
## Shell Integration
|
||||||
|
|
||||||
@@ -377,59 +413,103 @@ vnoremap <leader>fg :!fix-grammar<CR>
|
|||||||
vnoremap <leader>ec :!explain-code<CR>
|
vnoremap <leader>ec :!explain-code<CR>
|
||||||
```
|
```
|
||||||
|
|
||||||
## UI Navigation
|
## GUI Features
|
||||||
|
|
||||||
| Action | Keys |
|
The graphical interface provides a modern desktop experience:
|
||||||
|--------|------|
|
|
||||||
| Cycle sections | `Tab` |
|
|
||||||
| Go back | `Escape` |
|
|
||||||
| Select | `Enter` or click |
|
|
||||||
| Navigate | Arrow keys |
|
|
||||||
| **Scroll content** | Mouse wheel |
|
|
||||||
| **Scroll up** | Click top of scrollbar |
|
|
||||||
| **Scroll down** | Click bottom of scrollbar |
|
|
||||||
| **Page up/down** | Click middle of scrollbar |
|
|
||||||
| **Select text** | `Shift` + mouse drag |
|
|
||||||
| **Copy** | Terminal native (with Shift) |
|
|
||||||
| **Paste** | `Ctrl+Shift+V` |
|
|
||||||
| **Undo** (code editor) | `Alt+U` |
|
|
||||||
| **Redo** (code editor) | `Alt+R` |
|
|
||||||
|
|
||||||
**Tips:**
|
### My Tools Page
|
||||||
- Hold `Shift` while using mouse for terminal-native text selection.
|
- View all your tools organized by category (Text, Developer, Data, Other)
|
||||||
- Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons.
|
- Double-click a tool to edit it
|
||||||
- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields.
|
- Create new tools with the built-in Tool Builder
|
||||||
- The code editor supports undo/redo (up to 50 states) with `Alt+U` and `Alt+R`.
|
- Connect to the registry to publish your tools
|
||||||
- Use the `$EDITOR` button to open code or prompts in your external editor.
|
|
||||||
|
|
||||||
## AI-Assisted Code Generation
|
### 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
|
||||||
|
|
||||||
When adding or editing a **Code Step**, the dialog includes an AI assist panel:
|
### 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
|
||||||
┌─ Code ─────────────┐ ┌─ AI Assisted Auto-adjust ─────────────┐
|
- Add and configure AI providers
|
||||||
│ result = input... │ │ Provider: [opencode-deepseek] [▼] │
|
- Test provider connectivity
|
||||||
│ │ │ ┌─ Prompt ──────────────────────────┐ │
|
- Set default providers for new tools
|
||||||
│ │ │ │ Modify this code to... │ │
|
|
||||||
│ │ │ │ {code} │ │
|
### Profiles (AI Personas)
|
||||||
│ │ │ └──────────────────────────────────┘ │
|
- **Built-in profiles**: Comedian, Technical Writer, Teacher, Concise, Creative, Code Reviewer, Analyst
|
||||||
│ │ │ ┌─ Output & Feedback ───────────────┐ │
|
- **Custom profiles**: Create your own personas with custom system prompts
|
||||||
│ │ │ │ ✓ Code updated successfully! │ │
|
- **Profile selector**: Choose a profile when adding prompt steps to tools
|
||||||
│ │ │ └──────────────────────────────────┘ │
|
- **Automatic injection**: Profile system prompts are prepended to your prompts during execution
|
||||||
│ │ │ < Auto-adjust > │
|
|
||||||
└────────────────────┘ └───────────────────────────────────────┘
|
### 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
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Provider**: Select any configured AI provider
|
### AI → Code
|
||||||
- **Prompt**: Fully editable template - use `{code}` placeholder for current code
|
Generate content with AI, then validate/process with Python:
|
||||||
- **Output**: Shows status, success/error messages, and provider feedback
|
```yaml
|
||||||
- **Auto-adjust**: Sends prompt to AI and replaces code with response
|
steps:
|
||||||
|
- type: prompt
|
||||||
This lets you generate or modify Python code using AI directly within the tool builder.
|
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
|
## Philosophy
|
||||||
|
|
||||||
SmartTools is built on **Unix 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.
|
1. **Do one thing well** - Each tool solves one problem. `summarize` summarizes. `translate` translates. No bloated mega-tools.
|
||||||
|
|
||||||
@@ -444,15 +524,15 @@ SmartTools is built on **Unix philosophy**:
|
|||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.brrd.tech/rob/smarttools.git
|
git clone https://gitea.brrd.tech/rob/cmdforge.git
|
||||||
cd smarttools
|
cd cmdforge
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
If SmartTools saves you time, consider supporting the project:
|
If CmdForge saves you time, consider supporting the project:
|
||||||
|
|
||||||
**Bitcoin:** `3KeLqGv2Xo8jGYkqA1i68a6nXwgQnuJoza`
|
**Bitcoin:** `3KeLqGv2Xo8jGYkqA1i68a6nXwgQnuJoza`
|
||||||
|
|
||||||
@@ -464,33 +544,66 @@ MIT - Use it, modify it, share it.
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
### Pre-built Container (Fastest)
|
### Pre-installed Container (Recommended)
|
||||||
|
|
||||||
Pull and run the pre-built image directly:
|
Ready-to-use container with CmdForge and 27 example tools pre-installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pull from registry
|
# Build from source
|
||||||
docker pull gitea.brrd.tech/rob/smarttools:latest
|
docker build -f Dockerfile.ready -t cmdforge-ready .
|
||||||
|
|
||||||
# Run SmartTools
|
# Run interactively
|
||||||
docker run -it --rm gitea.brrd.tech/rob/smarttools smarttools --help
|
docker run -it --rm cmdforge-ready
|
||||||
|
|
||||||
# List available tools
|
# With persistent storage (keeps your tools between runs)
|
||||||
docker run -it --rm gitea.brrd.tech/rob/smarttools smarttools list
|
docker run -it --rm -v cmdforge-data:/home/user/.cmdforge cmdforge-ready
|
||||||
|
|
||||||
# Interactive shell
|
|
||||||
docker run -it --rm gitea.brrd.tech/rob/smarttools bash
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build from Source
|
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
|
||||||
|
```
|
||||||
|
|
||||||
Alternatively, build the container yourself:
|
### Test Installation Container
|
||||||
|
|
||||||
|
Simulates a fresh computer for testing the installation process:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
# Build the test container
|
||||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
docker build -f Dockerfile.test-install -t cmdforge-test-install .
|
||||||
cd SmartTools
|
|
||||||
|
|
||||||
|
# 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
|
# Build the container
|
||||||
docker-compose build
|
docker-compose build
|
||||||
|
|
||||||
@@ -498,7 +611,7 @@ docker-compose build
|
|||||||
docker-compose run --rm test
|
docker-compose run --rm test
|
||||||
|
|
||||||
# Use the CLI
|
# Use the CLI
|
||||||
docker-compose run --rm cli smarttools list
|
docker-compose run --rm cli cmdforge list
|
||||||
|
|
||||||
# Interactive shell
|
# Interactive shell
|
||||||
docker-compose run --rm shell
|
docker-compose run --rm shell
|
||||||
@@ -518,12 +631,12 @@ xhost +local:docker
|
|||||||
docker run -it --rm \
|
docker run -it --rm \
|
||||||
-e DISPLAY=$DISPLAY \
|
-e DISPLAY=$DISPLAY \
|
||||||
-v /tmp/.X11-unix:/tmp/.X11-unix \
|
-v /tmp/.X11-unix:/tmp/.X11-unix \
|
||||||
-v smarttools-data:/root/.smarttools \
|
-v cmdforge-data:/root/.cmdforge \
|
||||||
--network host \
|
--network host \
|
||||||
gitea.brrd.tech/rob/smarttools bash
|
gitea.brrd.tech/rob/cmdforge bash
|
||||||
|
|
||||||
# Inside the container:
|
# Inside the container:
|
||||||
smarttools providers install
|
cmdforge providers install
|
||||||
```
|
```
|
||||||
|
|
||||||
**Using docker-compose (build from source):**
|
**Using docker-compose (build from source):**
|
||||||
@@ -533,14 +646,14 @@ xhost +local:docker
|
|||||||
docker-compose run --rm setup
|
docker-compose run --rm setup
|
||||||
|
|
||||||
# Inside the container:
|
# Inside the container:
|
||||||
smarttools providers install
|
cmdforge providers install
|
||||||
```
|
```
|
||||||
|
|
||||||
**After installing a provider:**
|
**After installing a provider:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test the provider works
|
# Test the provider works
|
||||||
smarttools providers test claude
|
cmdforge providers test claude
|
||||||
```
|
```
|
||||||
|
|
||||||
**Available Providers:**
|
**Available Providers:**
|
||||||
@@ -554,7 +667,17 @@ smarttools providers test claude
|
|||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
|
### User Documentation
|
||||||
- [Installation Guide](docs/INSTALL.md)
|
- [Installation Guide](docs/INSTALL.md)
|
||||||
- [Provider Setup](docs/PROVIDERS.md)
|
- [Provider Setup](docs/PROVIDERS.md)
|
||||||
- [Example Tools](docs/EXAMPLES.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)
|
- [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/)
|
||||||
|
|||||||
+16
-16
@@ -1,9 +1,9 @@
|
|||||||
# SmartTools - AI-powered CLI command builder
|
# CmdForge - AI-powered CLI command builder
|
||||||
#
|
#
|
||||||
# Quick Start:
|
# Quick Start:
|
||||||
# docker-compose build # Build the image
|
# docker-compose build # Build the image
|
||||||
# docker-compose run --rm test # Run tests
|
# docker-compose run --rm test # Run tests
|
||||||
# docker-compose run --rm cli smarttools list
|
# docker-compose run --rm cli cmdforge list
|
||||||
#
|
#
|
||||||
# This is a standalone project with no external dependencies.
|
# This is a standalone project with no external dependencies.
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: smarttools:latest
|
image: cmdforge:latest
|
||||||
volumes:
|
volumes:
|
||||||
- smarttools-data:/root/.smarttools
|
- cmdforge-data:/root/.cmdforge
|
||||||
command: ["smarttools", "--help"]
|
command: ["cmdforge", "--help"]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Tests
|
# Tests
|
||||||
@@ -29,9 +29,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: smarttools:latest
|
image: cmdforge:latest
|
||||||
volumes:
|
volumes:
|
||||||
- smarttools-data:/root/.smarttools
|
- cmdforge-data:/root/.cmdforge
|
||||||
command: ["pytest", "-v"]
|
command: ["pytest", "-v"]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -41,9 +41,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: smarttools:latest
|
image: cmdforge:latest
|
||||||
volumes:
|
volumes:
|
||||||
- smarttools-data:/root/.smarttools
|
- cmdforge-data:/root/.cmdforge
|
||||||
command: ["/bin/bash"]
|
command: ["/bin/bash"]
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
@@ -57,11 +57,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: smarttools:latest
|
image: cmdforge:latest
|
||||||
environment:
|
environment:
|
||||||
- DISPLAY=${DISPLAY:-:0}
|
- DISPLAY=${DISPLAY:-:0}
|
||||||
volumes:
|
volumes:
|
||||||
- smarttools-data:/root/.smarttools
|
- cmdforge-data:/root/.cmdforge
|
||||||
- /tmp/.X11-unix:/tmp/.X11-unix:ro
|
- /tmp/.X11-unix:/tmp/.X11-unix:ro
|
||||||
command: ["/bin/bash"]
|
command: ["/bin/bash"]
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
@@ -69,8 +69,8 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
smarttools-data:
|
cmdforge-data:
|
||||||
# Persists ~/.smarttools between container runs
|
# Persists ~/.cmdforge between container runs
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Usage Examples
|
# Usage Examples
|
||||||
@@ -83,8 +83,8 @@ volumes:
|
|||||||
# docker-compose run --rm test
|
# docker-compose run --rm test
|
||||||
#
|
#
|
||||||
# Use CLI:
|
# Use CLI:
|
||||||
# docker-compose run --rm cli smarttools list
|
# docker-compose run --rm cli cmdforge list
|
||||||
# docker-compose run --rm cli smarttools run hello-world
|
# docker-compose run --rm cli cmdforge run hello-world
|
||||||
#
|
#
|
||||||
# Interactive shell:
|
# Interactive shell:
|
||||||
# docker-compose run --rm shell
|
# docker-compose run --rm shell
|
||||||
@@ -93,5 +93,5 @@ volumes:
|
|||||||
# xhost +local:docker
|
# xhost +local:docker
|
||||||
# docker-compose run --rm setup
|
# docker-compose run --rm setup
|
||||||
# # Inside container:
|
# # Inside container:
|
||||||
# smarttools providers install # Interactive guide
|
# cmdforge providers install # Interactive guide
|
||||||
# # Select a provider, it will install and open browser for auth
|
# # Select a provider, it will install and open browser for auth
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Install all SmartTools example tools.
|
Install all CmdForge example tools.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 install.py # Install all tools
|
python3 install.py # Install all tools
|
||||||
@@ -9,7 +9,7 @@ Usage:
|
|||||||
python3 install.py --category dev # Install category
|
python3 install.py --category dev # Install category
|
||||||
|
|
||||||
Run from anywhere:
|
Run from anywhere:
|
||||||
curl -sSL https://gitea.brrd.tech/rob/smarttools/raw/branch/main/examples/install.py | python3
|
curl -sSL https://gitea.brrd.tech/rob/cmdforge/raw/branch/main/examples/install.py | python3
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -22,7 +22,7 @@ except ImportError:
|
|||||||
print("PyYAML required. Install with: pip install pyyaml")
|
print("PyYAML required. Install with: pip install pyyaml")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
TOOLS_DIR = Path.home() / ".smarttools"
|
TOOLS_DIR = Path.home() / ".cmdforge"
|
||||||
|
|
||||||
# All example tool configurations
|
# All example tool configurations
|
||||||
TOOLS = {
|
TOOLS = {
|
||||||
@@ -298,7 +298,7 @@ def list_tools():
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Install SmartTools example tools")
|
parser = argparse.ArgumentParser(description="Install CmdForge example tools")
|
||||||
parser.add_argument("tools", nargs="*", help="Specific tools to install (default: all)")
|
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("--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("--category", "-c", choices=list(CATEGORIES.keys()), help="Install tools from category")
|
||||||
@@ -335,7 +335,7 @@ def main():
|
|||||||
installed += 1
|
installed += 1
|
||||||
|
|
||||||
print(f"\nInstalled {installed} tools.")
|
print(f"\nInstalled {installed} tools.")
|
||||||
print("\nRun 'smarttools refresh' to create executable wrappers.")
|
print("\nRun 'cmdforge refresh' to create executable wrappers.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+413
@@ -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 ""
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# SmartTools - Design Document
|
# CmdForge - Design Document
|
||||||
|
|
||||||
> A lightweight personal tool builder for AI-powered CLI commands
|
> A lightweight personal tool builder for AI-powered CLI commands
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
SmartTools lets you create custom AI-powered terminal commands. You define a tool once (name, steps, provider), then use it like any Linux command.
|
CmdForge lets you create custom AI-powered terminal commands. You define a tool once (name, steps, provider), then use it like any Linux command.
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```bash
|
```bash
|
||||||
@@ -17,7 +17,7 @@ sum -i text.txt -o summary.txt --max 512
|
|||||||
### Tool = Directory + Config
|
### Tool = Directory + Config
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.smarttools/
|
~/.cmdforge/
|
||||||
sum/
|
sum/
|
||||||
config.yaml
|
config.yaml
|
||||||
processed.py # Optional external code file
|
processed.py # Optional external code file
|
||||||
@@ -46,6 +46,11 @@ steps:
|
|||||||
provider: claude
|
provider: claude
|
||||||
output_var: response
|
output_var: response
|
||||||
output: "{response}"
|
output: "{response}"
|
||||||
|
|
||||||
|
# Optional: declare dependencies for meta-tools
|
||||||
|
dependencies:
|
||||||
|
- official/summarize
|
||||||
|
- official/translate@^1.0.0 # With version constraint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step Types
|
### Step Types
|
||||||
@@ -68,6 +73,17 @@ output: "{response}"
|
|||||||
code_file: processed.py # Optional: external file storage
|
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.
|
Steps execute in order. Each step's `output_var` becomes available to subsequent steps.
|
||||||
|
|
||||||
### Variables
|
### Variables
|
||||||
@@ -128,8 +144,8 @@ sum --stdin
|
|||||||
# No input (empty string) - useful for tools using only arguments
|
# No input (empty string) - useful for tools using only arguments
|
||||||
sum --max 100
|
sum --max 100
|
||||||
|
|
||||||
# Or via smarttools run
|
# Or via cmdforge run
|
||||||
smarttools run sum -i document.txt
|
cmdforge run sum -i document.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### Input Handling
|
### Input Handling
|
||||||
@@ -158,17 +174,18 @@ smarttools run sum -i document.txt
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Launch the UI to manage tools
|
# Launch the UI to manage tools
|
||||||
smarttools
|
cmdforge
|
||||||
|
|
||||||
# Or use CLI directly:
|
# Or use CLI directly:
|
||||||
smarttools list # List all tools
|
cmdforge list # List all tools
|
||||||
smarttools create sum # Create new tool (basic)
|
cmdforge create sum # Create new tool (basic)
|
||||||
smarttools edit sum # Edit tool config in $EDITOR
|
cmdforge edit sum # Edit tool config in $EDITOR
|
||||||
smarttools delete sum # Delete tool
|
cmdforge delete sum # Delete tool
|
||||||
smarttools test sum # Test with mock provider
|
cmdforge test sum # Test with mock provider
|
||||||
smarttools run sum # Run tool for real
|
cmdforge run sum # Run tool for real
|
||||||
smarttools refresh # Refresh all wrapper scripts
|
cmdforge refresh # Refresh all wrapper scripts
|
||||||
smarttools ui # Launch interactive UI
|
cmdforge check sum # Check dependencies for meta-tools
|
||||||
|
cmdforge ui # Launch interactive UI
|
||||||
```
|
```
|
||||||
|
|
||||||
## Terminal UI
|
## Terminal UI
|
||||||
@@ -179,7 +196,7 @@ A BIOS-style terminal UI using `urwid` with full mouse support.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────┐
|
┌──────────────────────────────────────┐
|
||||||
│ SmartTools Manager │
|
│ CmdForge Manager │
|
||||||
├──────────────────────────────────────┤
|
├──────────────────────────────────────┤
|
||||||
│ Tools: │
|
│ Tools: │
|
||||||
│ ┌────────────────────────────────┐ │
|
│ ┌────────────────────────────────┐ │
|
||||||
@@ -256,7 +273,7 @@ The current section's title is highlighted with brackets: `[ Tool Info ]`
|
|||||||
|
|
||||||
**Code Step Features:**
|
**Code Step Features:**
|
||||||
- **Multiline editor** - Write multi-line Python code
|
- **Multiline editor** - Write multi-line Python code
|
||||||
- **External file storage** - Code is auto-saved to `~/.smarttools/<toolname>/<filename>` on OK
|
- **External file storage** - Code is auto-saved to `~/.cmdforge/<toolname>/<filename>` on OK
|
||||||
- **Load button** - Load code from external file (with confirmation)
|
- **Load button** - Load code from external file (with confirmation)
|
||||||
- **Multiple output vars** - Capture multiple variables (comma-separated)
|
- **Multiple output vars** - Capture multiple variables (comma-separated)
|
||||||
|
|
||||||
@@ -265,7 +282,7 @@ The current section's title is highlighted with brackets: `[ Tool Info ]`
|
|||||||
### Directory Structure
|
### Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
smarttools/
|
cmdforge/
|
||||||
__init__.py
|
__init__.py
|
||||||
cli.py # Entry point, argument parsing
|
cli.py # Entry point, argument parsing
|
||||||
ui.py # UI dispatcher (chooses urwid/snack/dialog)
|
ui.py # UI dispatcher (chooses urwid/snack/dialog)
|
||||||
@@ -278,7 +295,7 @@ smarttools/
|
|||||||
|
|
||||||
### Provider System
|
### Provider System
|
||||||
|
|
||||||
Providers are defined in `~/.smarttools/providers.yaml`:
|
Providers are defined in `~/.cmdforge/providers.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
providers:
|
providers:
|
||||||
@@ -297,18 +314,18 @@ The provider command receives the prompt via stdin and outputs to stdout.
|
|||||||
|
|
||||||
### Wrapper Scripts
|
### Wrapper Scripts
|
||||||
|
|
||||||
When you save a tool, SmartTools creates a wrapper script in `~/.local/bin/`:
|
When you save a tool, CmdForge creates a wrapper script in `~/.local/bin/`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# SmartTools wrapper for 'sum'
|
# CmdForge wrapper for 'sum'
|
||||||
# Auto-generated - do not edit
|
# Auto-generated - do not edit
|
||||||
exec /path/to/python -m smarttools.runner sum "$@"
|
exec /path/to/python -m cmdforge.runner sum "$@"
|
||||||
```
|
```
|
||||||
|
|
||||||
The wrapper uses the full Python path to ensure the correct environment is used.
|
The wrapper uses the full Python path to ensure the correct environment is used.
|
||||||
|
|
||||||
Use `smarttools refresh` to regenerate all wrapper scripts (e.g., after changing Python environments).
|
Use `cmdforge refresh` to regenerate all wrapper scripts (e.g., after changing Python environments).
|
||||||
|
|
||||||
### Tool Name Validation
|
### Tool Name Validation
|
||||||
|
|
||||||
@@ -319,12 +336,12 @@ Tool names must:
|
|||||||
|
|
||||||
## Tool Composition
|
## Tool Composition
|
||||||
|
|
||||||
SmartTools are designed to chain together like any Unix command.
|
CmdForge are designed to chain together like any Unix command.
|
||||||
|
|
||||||
### External Pipelines (Tool-to-Tool)
|
### External Pipelines (Tool-to-Tool)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Chain multiple SmartTools
|
# Chain multiple CmdForge
|
||||||
cat logs.txt | log-errors | summarize | translate --lang Japanese
|
cat logs.txt | log-errors | summarize | translate --lang Japanese
|
||||||
|
|
||||||
# Mix with standard Unix tools
|
# Mix with standard Unix tools
|
||||||
@@ -389,7 +406,7 @@ Optional fallbacks:
|
|||||||
|
|
||||||
## Example Workflow
|
## Example Workflow
|
||||||
|
|
||||||
1. Run `smarttools` to open UI
|
1. Run `cmdforge` to open UI
|
||||||
2. Select "Create" to create a new tool
|
2. Select "Create" to create a new tool
|
||||||
3. Fill in: name, description, output template
|
3. Fill in: name, description, output template
|
||||||
4. Add arguments (e.g., `--max` with default `500`)
|
4. Add arguments (e.g., `--max` with default `500`)
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# Example Tools
|
# Example Tools
|
||||||
|
|
||||||
This document contains all 28 pre-built SmartTools with their full configurations.
|
This document contains all 28 pre-built CmdForge with their full configurations.
|
||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install all example tools (from the SmartTools directory)
|
# Install all example tools (from the CmdForge directory)
|
||||||
python examples/install.py
|
python examples/install.py
|
||||||
smarttools refresh
|
cmdforge refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Text Processing Tools
|
## Text Processing Tools
|
||||||
@@ -17,7 +17,7 @@ smarttools refresh
|
|||||||
Condense long documents to key points.
|
Condense long documents to key points.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# ~/.smarttools/summarize/config.yaml
|
# ~/.cmdforge/summarize/config.yaml
|
||||||
name: summarize
|
name: summarize
|
||||||
description: Condense long documents to key points
|
description: Condense long documents to key points
|
||||||
arguments:
|
arguments:
|
||||||
@@ -963,7 +963,7 @@ output: "{result}"
|
|||||||
|
|
||||||
## Pipeline Recipes
|
## Pipeline Recipes
|
||||||
|
|
||||||
SmartTools chain together like Unix commands. Here are practical examples:
|
CmdForge chain together like Unix commands. Here are practical examples:
|
||||||
|
|
||||||
### Development Workflows
|
### Development Workflows
|
||||||
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
# Installation Guide
|
# Installation Guide
|
||||||
|
|
||||||
Complete installation instructions for SmartTools.
|
Complete installation instructions for CmdForge.
|
||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||||
cd SmartTools
|
cd CmdForge
|
||||||
pip install -e .
|
pip install -e .
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
smarttools providers install
|
cmdforge providers install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Detailed Installation
|
## Detailed Installation
|
||||||
@@ -32,11 +32,11 @@ brew install [email protected]
|
|||||||
python3 --version # Should be 3.10 or higher
|
python3 --version # Should be 3.10 or higher
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Install SmartTools
|
### Step 2: Install CmdForge
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||||
cd SmartTools
|
cd CmdForge
|
||||||
pip install -e .
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ pip install -e ".[dev]"
|
|||||||
|
|
||||||
### Step 3: Configure PATH
|
### Step 3: Configure PATH
|
||||||
|
|
||||||
SmartTools creates executable wrappers in `~/.local/bin/`. Add this to your shell config:
|
CmdForge creates executable wrappers in `~/.local/bin/`. Add this to your shell config:
|
||||||
|
|
||||||
**Bash (~/.bashrc):**
|
**Bash (~/.bashrc):**
|
||||||
```bash
|
```bash
|
||||||
@@ -74,7 +74,7 @@ source ~/.bashrc # or restart terminal
|
|||||||
You need at least one AI CLI tool. The easiest way is the interactive installer:
|
You need at least one AI CLI tool. The easiest way is the interactive installer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
smarttools providers install
|
cmdforge providers install
|
||||||
```
|
```
|
||||||
|
|
||||||
This guides you through installing and authenticating providers.
|
This guides you through installing and authenticating providers.
|
||||||
@@ -110,7 +110,7 @@ gemini # Opens browser for Google sign-in
|
|||||||
```bash
|
```bash
|
||||||
curl -fsSL https://ollama.ai/install.sh | bash
|
curl -fsSL https://ollama.ai/install.sh | bash
|
||||||
ollama pull llama3
|
ollama pull llama3
|
||||||
smarttools providers add ollama "ollama run llama3" -d "Local Llama 3"
|
cmdforge providers add ollama "ollama run llama3" -d "Local Llama 3"
|
||||||
```
|
```
|
||||||
|
|
||||||
See [PROVIDERS.md](PROVIDERS.md) for full provider setup instructions.
|
See [PROVIDERS.md](PROVIDERS.md) for full provider setup instructions.
|
||||||
@@ -118,17 +118,17 @@ See [PROVIDERS.md](PROVIDERS.md) for full provider setup instructions.
|
|||||||
### Step 5: Verify Installation
|
### Step 5: Verify Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check SmartTools is installed
|
# Check CmdForge is installed
|
||||||
smarttools --version
|
cmdforge --version
|
||||||
|
|
||||||
# List configured providers
|
# List configured providers
|
||||||
smarttools providers list
|
cmdforge providers list
|
||||||
|
|
||||||
# Check which providers are available
|
# Check which providers are available
|
||||||
smarttools providers check
|
cmdforge providers check
|
||||||
|
|
||||||
# Test with mock provider (no AI needed)
|
# Test with mock provider (no AI needed)
|
||||||
echo "hello world" | smarttools run --provider mock
|
echo "hello world" | cmdforge run --provider mock
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 6: Create Wrapper Scripts
|
### Step 6: Create Wrapper Scripts
|
||||||
@@ -136,7 +136,7 @@ echo "hello world" | smarttools run --provider mock
|
|||||||
After installation or any Python environment change:
|
After installation or any Python environment change:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
smarttools refresh
|
cmdforge refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates executable scripts in `~/.local/bin/` for all your tools.
|
This creates executable scripts in `~/.local/bin/` for all your tools.
|
||||||
@@ -147,18 +147,18 @@ This creates executable scripts in `~/.local/bin/` for all your tools.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/install.py
|
python examples/install.py
|
||||||
smarttools refresh
|
cmdforge refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Install
|
### Manual Install
|
||||||
|
|
||||||
Copy individual tool configs to `~/.smarttools/<toolname>/config.yaml`.
|
Copy individual tool configs to `~/.cmdforge/<toolname>/config.yaml`.
|
||||||
|
|
||||||
See [EXAMPLES.md](EXAMPLES.md) for all tool configurations.
|
See [EXAMPLES.md](EXAMPLES.md) for all tool configurations.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "smarttools: command not found"
|
### "cmdforge: command not found"
|
||||||
|
|
||||||
PATH is not configured. Add to your shell config:
|
PATH is not configured. Add to your shell config:
|
||||||
```bash
|
```bash
|
||||||
@@ -168,14 +168,14 @@ export PATH="$HOME/.local/bin:$PATH"
|
|||||||
### "Provider 'X' not found"
|
### "Provider 'X' not found"
|
||||||
|
|
||||||
1. Check provider is installed: `which claude` or `which opencode`
|
1. Check provider is installed: `which claude` or `which opencode`
|
||||||
2. Check provider is configured: `smarttools providers`
|
2. Check provider is configured: `cmdforge providers`
|
||||||
3. Add missing provider: `smarttools providers add`
|
3. Add missing provider: `cmdforge providers add`
|
||||||
|
|
||||||
### "Permission denied" on tool execution
|
### "Permission denied" on tool execution
|
||||||
|
|
||||||
Refresh wrapper scripts:
|
Refresh wrapper scripts:
|
||||||
```bash
|
```bash
|
||||||
smarttools refresh
|
cmdforge refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
### TUI doesn't start / looks broken
|
### TUI doesn't start / looks broken
|
||||||
@@ -187,28 +187,28 @@ pip install urwid
|
|||||||
|
|
||||||
Ensure your terminal supports mouse (most modern terminals do).
|
Ensure your terminal supports mouse (most modern terminals do).
|
||||||
|
|
||||||
### Tools work with `smarttools run` but not directly
|
### Tools work with `cmdforge run` but not directly
|
||||||
|
|
||||||
The wrapper scripts may be outdated. Refresh them:
|
The wrapper scripts may be outdated. Refresh them:
|
||||||
```bash
|
```bash
|
||||||
smarttools refresh
|
cmdforge refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python version mismatch
|
### Python version mismatch
|
||||||
|
|
||||||
If you have multiple Python versions, ensure SmartTools uses the right one:
|
If you have multiple Python versions, ensure CmdForge uses the right one:
|
||||||
```bash
|
```bash
|
||||||
python3.11 -m pip install smarttools
|
python3.11 -m pip install cmdforge
|
||||||
```
|
```
|
||||||
|
|
||||||
## Uninstalling
|
## Uninstalling
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Remove package
|
# Remove package
|
||||||
pip uninstall smarttools
|
pip uninstall cmdforge
|
||||||
|
|
||||||
# Remove config and tools (optional)
|
# Remove config and tools (optional)
|
||||||
rm -rf ~/.smarttools
|
rm -rf ~/.cmdforge
|
||||||
|
|
||||||
# Remove wrapper scripts (optional)
|
# Remove wrapper scripts (optional)
|
||||||
rm ~/.local/bin/{summarize,translate,fix-grammar,...}
|
rm ~/.local/bin/{summarize,translate,fix-grammar,...}
|
||||||
@@ -217,8 +217,8 @@ rm ~/.local/bin/{summarize,translate,fix-grammar,...}
|
|||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install --upgrade smarttools
|
pip install --upgrade cmdforge
|
||||||
smarttools refresh
|
cmdforge refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
@@ -226,7 +226,7 @@ smarttools refresh
|
|||||||
After installation:
|
After installation:
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.smarttools/
|
~/.cmdforge/
|
||||||
├── providers.yaml # Provider configurations
|
├── providers.yaml # Provider configurations
|
||||||
├── summarize/
|
├── summarize/
|
||||||
│ └── config.yaml # Tool config
|
│ └── config.yaml # Tool config
|
||||||
@@ -236,7 +236,7 @@ After installation:
|
|||||||
└── ...
|
└── ...
|
||||||
|
|
||||||
~/.local/bin/
|
~/.local/bin/
|
||||||
├── smarttools # Main command
|
├── cmdforge # Main command
|
||||||
├── summarize # Tool wrapper script
|
├── summarize # Tool wrapper script
|
||||||
├── translate # Tool wrapper script
|
├── translate # Tool wrapper script
|
||||||
└── ...
|
└── ...
|
||||||
@@ -246,4 +246,4 @@ After installation:
|
|||||||
|
|
||||||
1. [Set up providers](PROVIDERS.md)
|
1. [Set up providers](PROVIDERS.md)
|
||||||
2. [Browse example tools](EXAMPLES.md)
|
2. [Browse example tools](EXAMPLES.md)
|
||||||
3. Run `smarttools ui` to create your first tool
|
3. Run `cmdforge ui` to create your first tool
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Provider Setup Guide
|
# Provider Setup Guide
|
||||||
|
|
||||||
SmartTools works with any AI CLI tool that accepts input via stdin or arguments. This guide covers setup for the most popular providers.
|
CmdForge works with any AI CLI tool that accepts input via stdin or arguments. This guide covers setup for the most popular providers.
|
||||||
|
|
||||||
## Provider Comparison
|
## Provider Comparison
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ echo "Hello" | gemini --model gemini-2.5-flash
|
|||||||
The easiest way to install providers:
|
The easiest way to install providers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
smarttools providers install
|
cmdforge providers install
|
||||||
```
|
```
|
||||||
|
|
||||||
This interactive guide:
|
This interactive guide:
|
||||||
@@ -151,22 +151,22 @@ This interactive guide:
|
|||||||
### List Providers
|
### List Providers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
smarttools providers list
|
cmdforge providers list
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Availability
|
### Check Availability
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
smarttools providers check
|
cmdforge providers check
|
||||||
```
|
```
|
||||||
|
|
||||||
### Add Custom Provider
|
### Add Custom Provider
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
smarttools providers add myname "my-command --args" -d "Description"
|
cmdforge providers add myname "my-command --args" -d "Description"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or edit `~/.smarttools/providers.yaml`:
|
Or edit `~/.cmdforge/providers.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
providers:
|
providers:
|
||||||
@@ -242,9 +242,9 @@ cat file.txt | summarize --provider claude-opus
|
|||||||
|
|
||||||
### "Provider 'X' not found"
|
### "Provider 'X' not found"
|
||||||
|
|
||||||
1. Check it's in your providers list: `smarttools providers`
|
1. Check it's in your providers list: `cmdforge providers`
|
||||||
2. Verify the command works: `echo "test" | <command>`
|
2. Verify the command works: `echo "test" | <command>`
|
||||||
3. Add it: `smarttools providers add`
|
3. Add it: `cmdforge providers add`
|
||||||
|
|
||||||
### "Command 'X' not found"
|
### "Command 'X' not found"
|
||||||
|
|
||||||
@@ -273,8 +273,8 @@ echo "Say hello" | claude -p
|
|||||||
echo "Say hello" | ~/.opencode/bin/opencode run
|
echo "Say hello" | ~/.opencode/bin/opencode run
|
||||||
```
|
```
|
||||||
|
|
||||||
If it works directly but not in SmartTools, check:
|
If it works directly but not in CmdForge, check:
|
||||||
1. Provider command in `~/.smarttools/providers.yaml`
|
1. Provider command in `~/.cmdforge/providers.yaml`
|
||||||
2. Environment variables are expanded correctly
|
2. Environment variables are expanded correctly
|
||||||
|
|
||||||
## Cost Optimization
|
## Cost Optimization
|
||||||
+2023
File diff suppressed because it is too large
Load Diff
+1462
File diff suppressed because it is too large
Load Diff
Generated
+1015
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
+28
-12
@@ -3,9 +3,9 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "smarttools"
|
name = "cmdforge"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A lightweight personal tool builder for AI-powered CLI commands"
|
description = "Build custom AI-powered CLI commands in YAML"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -31,29 +31,42 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
|
"requests>=2.28",
|
||||||
|
"PySide6>=6.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
tui = [
|
|
||||||
"urwid>=2.1.0",
|
|
||||||
]
|
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
"pytest-cov>=4.0",
|
"pytest-cov>=4.0",
|
||||||
"urwid>=2.1.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 = [
|
all = [
|
||||||
"urwid>=2.1.0",
|
"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]
|
[project.scripts]
|
||||||
smarttools = "smarttools.cli:main"
|
cmdforge = "cmdforge.cli:main"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://gitea.brrd.tech/rob/smarttools"
|
Homepage = "https://cmdforge.brrd.tech"
|
||||||
Documentation = "https://gitea.brrd.tech/rob/smarttools#readme"
|
Documentation = "https://cmdforge.brrd.tech/docs"
|
||||||
Repository = "https://gitea.brrd.tech/rob/smarttools.git"
|
Repository = "https://gitea.brrd.tech/rob/CmdForge.git"
|
||||||
Issues = "https://gitea.brrd.tech/rob/smarttools/issues"
|
Issues = "https://gitea.brrd.tech/rob/CmdForge/issues"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
@@ -61,3 +74,6 @@ where = ["src"]
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["src"]
|
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}"
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+812
@@ -0,0 +1,812 @@
|
|||||||
|
#!/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 publish_to_registry(
|
||||||
|
name: str,
|
||||||
|
config_yaml: str,
|
||||||
|
readme: str,
|
||||||
|
provider: str,
|
||||||
|
auto_approve: bool = False
|
||||||
|
) -> tuple[bool, str, dict]:
|
||||||
|
"""Publish a tool directly to the registry database with vetting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message, scrutiny_report)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Add src to path for registry imports
|
||||||
|
src_dir = Path(__file__).parent.parent / "src"
|
||||||
|
if str(src_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(src_dir))
|
||||||
|
|
||||||
|
from cmdforge.registry.db import connect_db, query_one
|
||||||
|
from cmdforge.registry.scrutiny import scrutinize_tool
|
||||||
|
from cmdforge.hash_utils import compute_yaml_hash
|
||||||
|
|
||||||
|
# Parse config
|
||||||
|
config = yaml.safe_load(config_yaml)
|
||||||
|
version = config.get("version", "1.0.0")
|
||||||
|
description = config.get("description", "")
|
||||||
|
category = config.get("category")
|
||||||
|
tags = config.get("tags", [])
|
||||||
|
|
||||||
|
conn = connect_db()
|
||||||
|
|
||||||
|
# Check if already exists
|
||||||
|
existing = query_one(
|
||||||
|
conn,
|
||||||
|
"SELECT id, version FROM tools WHERE owner = ? AND name = ?",
|
||||||
|
["official", name],
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Check if same version
|
||||||
|
if existing["version"] == version:
|
||||||
|
conn.close()
|
||||||
|
return True, "Already exists (same version)", {}
|
||||||
|
|
||||||
|
# Run scrutiny
|
||||||
|
scrutiny_report = {}
|
||||||
|
try:
|
||||||
|
scrutiny_report = scrutinize_tool(config_yaml, description, readme)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Scrutiny failed for {name}: {e}")
|
||||||
|
|
||||||
|
# Check scrutiny decision
|
||||||
|
scrutiny_decision = scrutiny_report.get("decision", "review")
|
||||||
|
if scrutiny_decision == "reject":
|
||||||
|
fail_findings = [f for f in scrutiny_report.get("findings", []) if f.get("result") == "fail"]
|
||||||
|
fail_msg = fail_findings[0]["message"] if fail_findings else "quality too low"
|
||||||
|
conn.close()
|
||||||
|
return False, f"Rejected by scrutiny: {fail_msg}", scrutiny_report
|
||||||
|
|
||||||
|
# Determine statuses
|
||||||
|
if scrutiny_decision == "approve":
|
||||||
|
scrutiny_status = "approved"
|
||||||
|
elif scrutiny_decision == "review":
|
||||||
|
scrutiny_status = "pending_review"
|
||||||
|
else:
|
||||||
|
scrutiny_status = "pending"
|
||||||
|
|
||||||
|
# Moderation status based on auto_approve setting
|
||||||
|
if auto_approve and scrutiny_status == "approved":
|
||||||
|
moderation_status = "approved"
|
||||||
|
else:
|
||||||
|
moderation_status = "pending"
|
||||||
|
|
||||||
|
# Compute hash
|
||||||
|
config_hash = compute_yaml_hash(config_yaml)
|
||||||
|
|
||||||
|
# Source attribution
|
||||||
|
source_json = json.dumps({
|
||||||
|
"type": "imported",
|
||||||
|
"original_tool": f"fabric/patterns/{name}",
|
||||||
|
"url": "https://github.com/danielmiessler/fabric",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Daniel Miessler"
|
||||||
|
})
|
||||||
|
|
||||||
|
tags_json = json.dumps(tags) if tags else "[]"
|
||||||
|
scrutiny_json = json.dumps(scrutiny_report) if scrutiny_report else None
|
||||||
|
|
||||||
|
# Ensure official publisher exists
|
||||||
|
publisher = query_one(conn, "SELECT id FROM publishers WHERE slug = ?", ["official"])
|
||||||
|
if not publisher:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO publishers (email, password_hash, slug, display_name, verified) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
["[email protected]", "", "official", "Official", True]
|
||||||
|
)
|
||||||
|
publisher_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
else:
|
||||||
|
publisher_id = publisher["id"]
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing tool
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tools SET
|
||||||
|
version = ?, description = ?, category = ?, tags = ?,
|
||||||
|
config_yaml = ?, readme = ?, scrutiny_status = ?, scrutiny_report = ?,
|
||||||
|
source_json = ?, config_hash = ?, moderation_status = ?,
|
||||||
|
published_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
version, description, category, tags_json,
|
||||||
|
config_yaml, readme, scrutiny_status, scrutiny_json,
|
||||||
|
source_json, config_hash, moderation_status,
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
existing["id"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Insert new tool
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tools (
|
||||||
|
owner, name, version, description, category, tags, config_yaml, readme,
|
||||||
|
publisher_id, scrutiny_status, scrutiny_report, source_json,
|
||||||
|
config_hash, visibility, moderation_status, published_at, downloads
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
"official", name, version, description, category, tags_json,
|
||||||
|
config_yaml, readme, publisher_id, scrutiny_status, scrutiny_json,
|
||||||
|
source_json, config_hash, "public", moderation_status,
|
||||||
|
datetime.now(timezone.utc).isoformat(), 0
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
decision = scrutiny_report.get("decision", "unknown")
|
||||||
|
return True, f"Published (scrutiny: {decision}, moderation: {moderation_status})", scrutiny_report
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Registry publish failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False, str(e), {}
|
||||||
|
|
||||||
|
|
||||||
|
def sync_pattern(
|
||||||
|
pattern_dir: Path,
|
||||||
|
output_dir: Path,
|
||||||
|
provider: str,
|
||||||
|
state: SyncState,
|
||||||
|
dry_run: bool = False,
|
||||||
|
publish_to_db: bool = True,
|
||||||
|
auto_approve: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Sync a single pattern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern_dir: Path to the pattern directory
|
||||||
|
output_dir: Output directory for local tools (if not publishing to DB)
|
||||||
|
provider: AI provider name
|
||||||
|
state: Sync state object
|
||||||
|
dry_run: If True, don't make changes
|
||||||
|
publish_to_db: If True, publish to registry database; if False, create local files
|
||||||
|
auto_approve: If True, auto-approve tools that pass scrutiny
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
name = pattern_dir.name
|
||||||
|
pattern_hash = hash_pattern(pattern_dir)
|
||||||
|
|
||||||
|
# Read pattern content
|
||||||
|
system_md = pattern_dir / "system.md"
|
||||||
|
if not system_md.exists():
|
||||||
|
logger.warning(f" ✗ {name}: No system.md found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
system_prompt = system_md.read_text()
|
||||||
|
|
||||||
|
# Import helper functions
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
if str(script_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(script_dir))
|
||||||
|
|
||||||
|
from import_fabric import create_tool_config, pattern_to_display_name
|
||||||
|
|
||||||
|
# Create tool config
|
||||||
|
config = create_tool_config(name, system_prompt, provider)
|
||||||
|
config_yaml = yaml.dump(config, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
# Create README with usage and attribution
|
||||||
|
display_name = pattern_to_display_name(name)
|
||||||
|
readme = f"""# {display_name}
|
||||||
|
|
||||||
|
{config.get('description', '')}
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat input.txt | {name}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
This tool was imported from [Fabric](https://github.com/danielmiessler/fabric) patterns.
|
||||||
|
|
||||||
|
- **Original pattern**: `{name}`
|
||||||
|
- **Author**: Daniel Miessler
|
||||||
|
- **License**: MIT
|
||||||
|
"""
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
# Quick vet check for dry run
|
||||||
|
passed, reason = vet_pattern(pattern_dir, provider)
|
||||||
|
if passed:
|
||||||
|
logger.info(f" [DRY RUN] Would sync: {name} ({reason})")
|
||||||
|
else:
|
||||||
|
logger.info(f" [DRY RUN] Would skip: {name} ({reason})")
|
||||||
|
return passed
|
||||||
|
|
||||||
|
if publish_to_db:
|
||||||
|
# Publish directly to registry database
|
||||||
|
success, message, report = publish_to_registry(name, config_yaml, readme, provider, auto_approve)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f" ✓ {name} -> registry ({message})")
|
||||||
|
state.patterns[name] = {
|
||||||
|
"name": name,
|
||||||
|
"hash": pattern_hash,
|
||||||
|
"status": "synced",
|
||||||
|
"message": message,
|
||||||
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f" ✗ {name}: {message}")
|
||||||
|
state.patterns[name] = {
|
||||||
|
"name": name,
|
||||||
|
"hash": pattern_hash,
|
||||||
|
"status": "failed",
|
||||||
|
"reason": message,
|
||||||
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Original behavior: create local files
|
||||||
|
passed, reason = vet_pattern(pattern_dir, provider)
|
||||||
|
|
||||||
|
if not passed:
|
||||||
|
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
|
||||||
|
|
||||||
|
try:
|
||||||
|
from import_fabric import import_pattern
|
||||||
|
|
||||||
|
success = import_pattern(
|
||||||
|
name,
|
||||||
|
pattern_dir.parent,
|
||||||
|
output_dir,
|
||||||
|
provider,
|
||||||
|
dry_run=False,
|
||||||
|
registry_format=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f" ✓ {name} -> {output_dir}/{name}")
|
||||||
|
state.patterns[name] = {
|
||||||
|
"name": name,
|
||||||
|
"hash": pattern_hash,
|
||||||
|
"status": "synced",
|
||||||
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f" ✗ {name}: Import failed")
|
||||||
|
state.patterns[name] = {
|
||||||
|
"name": name,
|
||||||
|
"hash": pattern_hash,
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "Import failed",
|
||||||
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" ✗ {name}: {e}")
|
||||||
|
state.patterns[name] = {
|
||||||
|
"name": name,
|
||||||
|
"hash": pattern_hash,
|
||||||
|
"status": "failed",
|
||||||
|
"reason": str(e),
|
||||||
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync(
|
||||||
|
sync_dir: Path,
|
||||||
|
output_dir: Path,
|
||||||
|
state_file: Path,
|
||||||
|
provider: str,
|
||||||
|
dry_run: bool = False,
|
||||||
|
force_patterns: list[str] = None,
|
||||||
|
publish_to_db: bool = True,
|
||||||
|
auto_approve: bool = False
|
||||||
|
) -> dict:
|
||||||
|
"""Run the sync process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sync_dir: Directory for sync data (Fabric clone)
|
||||||
|
output_dir: Output directory for local tools (if not publishing to DB)
|
||||||
|
state_file: Path to state file
|
||||||
|
provider: AI provider name
|
||||||
|
dry_run: If True, don't make changes
|
||||||
|
force_patterns: List of pattern names to force resync
|
||||||
|
publish_to_db: If True, publish to registry database
|
||||||
|
auto_approve: If True, auto-approve tools that pass scrutiny
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary dict with counts
|
||||||
|
"""
|
||||||
|
# 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:
|
||||||
|
dest = "registry database" if publish_to_db else output_dir
|
||||||
|
logger.info(f"\nSyncing {len(to_sync)} patterns to {dest}...")
|
||||||
|
for name in to_sync:
|
||||||
|
pattern_dir = patterns_dir / name
|
||||||
|
if sync_pattern(pattern_dir, output_dir, provider, state, dry_run, publish_to_db, auto_approve):
|
||||||
|
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,
|
||||||
|
publish_to_db: bool = True,
|
||||||
|
auto_approve: bool = False
|
||||||
|
):
|
||||||
|
"""Run sync in a loop."""
|
||||||
|
logger.info(f"Starting daemon mode with {interval}s interval")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
run_sync(sync_dir, output_dir, state_file, provider,
|
||||||
|
publish_to_db=publish_to_db, auto_approve=auto_approve)
|
||||||
|
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})"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--local-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Create local tools only (don't publish to registry database)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--auto-approve",
|
||||||
|
action="store_true",
|
||||||
|
help="Auto-approve tools that pass scrutiny (skip moderation queue)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
publish_to_db=not args.local_only,
|
||||||
|
auto_approve=args.auto_approve
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
publish_to_db=not args.local_only,
|
||||||
|
auto_approve=args.auto_approve
|
||||||
|
)
|
||||||
|
|
||||||
|
if summary["failed"] > 0:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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())
|
||||||
Executable
+469
@@ -0,0 +1,469 @@
|
|||||||
|
#!/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 extract_description(system_prompt: str, pattern_name: str, max_length: int = 200) -> str:
|
||||||
|
"""Extract a meaningful description from the system prompt.
|
||||||
|
|
||||||
|
Looks for common patterns in Fabric prompts:
|
||||||
|
1. "# IDENTITY and PURPOSE" section - most common
|
||||||
|
2. "# IDENTITY" section
|
||||||
|
3. First paragraph after any title
|
||||||
|
4. First sentence of the prompt
|
||||||
|
"""
|
||||||
|
lines = system_prompt.strip().split('\n')
|
||||||
|
display_name = pattern_to_display_name(pattern_name)
|
||||||
|
|
||||||
|
# Look for IDENTITY and PURPOSE section
|
||||||
|
in_identity = False
|
||||||
|
identity_lines = []
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
line_lower = line.lower().strip()
|
||||||
|
|
||||||
|
# Check for identity section header
|
||||||
|
if 'identity' in line_lower and ('purpose' in line_lower or line_lower.startswith('#')):
|
||||||
|
in_identity = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# End of identity section (next header)
|
||||||
|
if in_identity and line.strip().startswith('#'):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Skip filler lines in identity section
|
||||||
|
if in_identity:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped and not stripped.lower().startswith((
|
||||||
|
'take a deep breath',
|
||||||
|
'think step by step',
|
||||||
|
'follow these steps',
|
||||||
|
'use the following'
|
||||||
|
)):
|
||||||
|
identity_lines.append(stripped)
|
||||||
|
|
||||||
|
if identity_lines:
|
||||||
|
# Use only the first meaningful line(s) that describe the identity
|
||||||
|
desc = identity_lines[0]
|
||||||
|
# Remove common filler phrases
|
||||||
|
desc = re.sub(r'^You are (an? )?', '', desc, flags=re.IGNORECASE)
|
||||||
|
desc = re.sub(r'^You\'re (an? )?', '', desc, flags=re.IGNORECASE)
|
||||||
|
desc = desc.strip()
|
||||||
|
if desc:
|
||||||
|
# Capitalize first letter
|
||||||
|
desc = desc[0].upper() + desc[1:] if len(desc) > 1 else desc.upper()
|
||||||
|
# Truncate if needed
|
||||||
|
if len(desc) > max_length:
|
||||||
|
desc = desc[:max_length-3].rsplit(' ', 1)[0] + '...'
|
||||||
|
return desc
|
||||||
|
|
||||||
|
# Fallback: Look for first meaningful paragraph
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
# Skip headers, empty lines, and short lines
|
||||||
|
if line.startswith('#') or not line or len(line) < 20:
|
||||||
|
continue
|
||||||
|
# Skip common instruction patterns and metadata
|
||||||
|
line_lower = line.lower()
|
||||||
|
if line_lower.startswith((
|
||||||
|
'take a deep breath', 'think step by step', 'input:',
|
||||||
|
'title:', 'introduction'
|
||||||
|
)):
|
||||||
|
continue
|
||||||
|
# Found a content line - use it
|
||||||
|
desc = line
|
||||||
|
# Remove "Title:" prefix if present
|
||||||
|
desc = re.sub(r'^Title:\s*', '', desc, flags=re.IGNORECASE)
|
||||||
|
if len(desc) > max_length:
|
||||||
|
desc = desc[:max_length-3].rsplit(' ', 1)[0] + '...'
|
||||||
|
return desc
|
||||||
|
|
||||||
|
# Last fallback - generic description
|
||||||
|
return f"{display_name} - AI-powered tool from Fabric patterns"
|
||||||
|
|
||||||
|
|
||||||
|
def create_tool_config(
|
||||||
|
pattern_name: str,
|
||||||
|
system_prompt: str,
|
||||||
|
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)
|
||||||
|
description = extract_description(system_prompt, pattern_name)
|
||||||
|
|
||||||
|
# Build the full prompt with input placeholder
|
||||||
|
full_prompt = f"{cleaned_prompt}\n\n{{input}}"
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"name": pattern_name,
|
||||||
|
"description": description,
|
||||||
|
"version": "1.0.2",
|
||||||
|
"category": category,
|
||||||
|
"tags": ["fabric", "imported"],
|
||||||
|
|
||||||
|
# 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())
|
||||||
@@ -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())
|
||||||
Executable
+507
@@ -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())
|
||||||
Executable
+441
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
Executable
+379
@@ -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())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""CmdForge - A lightweight personal tool builder for AI-powered CLI commands."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Allow running the CLI as a module."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from . import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""GUI dialogs."""
|
||||||
@@ -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 ""
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
@@ -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;")
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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")
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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}")
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
"""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
|
||||||
|
)
|
||||||
|
from ..widgets.icons import get_prompt_icon, get_code_icon
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
self._flow_widget.step_name_changed.connect(self._on_flow_step_name_changed)
|
||||||
|
|
||||||
|
# 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_step_name_changed(self, step_index: int, new_name: str):
|
||||||
|
"""Handle step name change from flow view inline editing."""
|
||||||
|
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
|
||||||
|
return
|
||||||
|
|
||||||
|
step = self._tool.steps[step_index]
|
||||||
|
step.name = new_name
|
||||||
|
|
||||||
|
# Refresh list view to show updated name
|
||||||
|
self._refresh_steps_list_only()
|
||||||
|
|
||||||
|
def _on_flow_steps_reordered(self, new_order: list):
|
||||||
|
"""Handle step reordering from flow view.
|
||||||
|
|
||||||
|
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"{step_name} [{step.provider}] → ${step.output_var}"
|
||||||
|
icon = get_prompt_icon(20)
|
||||||
|
elif isinstance(step, CodeStep):
|
||||||
|
code_count += 1
|
||||||
|
step_name = step.name if step.name else f"Code {code_count}"
|
||||||
|
text = f"{step_name} [python] → ${step.output_var}"
|
||||||
|
icon = get_code_icon(20)
|
||||||
|
else:
|
||||||
|
text = f"Unknown step"
|
||||||
|
icon = None
|
||||||
|
|
||||||
|
item = QListWidgetItem(text)
|
||||||
|
if icon:
|
||||||
|
item.setIcon(icon)
|
||||||
|
item.setData(Qt.UserRole, step)
|
||||||
|
|
||||||
|
# Check for broken dependencies
|
||||||
|
refs = self._get_step_variable_refs(step)
|
||||||
|
broken_refs = [r for r in refs if r not in available_vars]
|
||||||
|
if broken_refs:
|
||||||
|
# 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 _refresh_steps_list_only(self):
|
||||||
|
"""Refresh only the steps list (not flow view).
|
||||||
|
|
||||||
|
Used when a change originated from the flow view to avoid
|
||||||
|
rebuilding the graph unnecessarily.
|
||||||
|
"""
|
||||||
|
self.steps_list.clear()
|
||||||
|
if self._tool and self._tool.steps:
|
||||||
|
# Count steps by type for default naming
|
||||||
|
prompt_count = 0
|
||||||
|
code_count = 0
|
||||||
|
|
||||||
|
# Track available variables for dependency checking
|
||||||
|
available_vars = {"input"}
|
||||||
|
if self._tool.arguments:
|
||||||
|
for arg in self._tool.arguments:
|
||||||
|
if arg.variable:
|
||||||
|
available_vars.add(arg.variable)
|
||||||
|
|
||||||
|
for i, step in enumerate(self._tool.steps, 1):
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
|
prompt_count += 1
|
||||||
|
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
||||||
|
text = f"{step_name} [{step.provider}] → ${step.output_var}"
|
||||||
|
icon = get_prompt_icon(20)
|
||||||
|
elif isinstance(step, CodeStep):
|
||||||
|
code_count += 1
|
||||||
|
step_name = step.name if step.name else f"Code {code_count}"
|
||||||
|
text = f"{step_name} [python] → ${step.output_var}"
|
||||||
|
icon = get_code_icon(20)
|
||||||
|
else:
|
||||||
|
text = f"Unknown step"
|
||||||
|
icon = None
|
||||||
|
|
||||||
|
item = QListWidgetItem(text)
|
||||||
|
if icon:
|
||||||
|
item.setIcon(icon)
|
||||||
|
item.setData(Qt.UserRole, step)
|
||||||
|
|
||||||
|
# Check for broken dependencies
|
||||||
|
refs = self._get_step_variable_refs(step)
|
||||||
|
broken_refs = [r for r in refs if r not in available_vars]
|
||||||
|
if broken_refs:
|
||||||
|
item.setForeground(Qt.red)
|
||||||
|
item.setToolTip(f"Missing variables: {', '.join(broken_refs)}")
|
||||||
|
|
||||||
|
self.steps_list.addItem(item)
|
||||||
|
|
||||||
|
# Add this step's output to available vars for next iteration
|
||||||
|
if hasattr(step, 'output_var') and step.output_var:
|
||||||
|
for var in step.output_var.split(','):
|
||||||
|
available_vars.add(var.strip())
|
||||||
|
|
||||||
|
def _add_argument(self):
|
||||||
|
"""Add a new argument."""
|
||||||
|
from ..dialogs.argument_dialog import ArgumentDialog
|
||||||
|
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()
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""Tools page - main view for managing tools."""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
||||||
|
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
|
||||||
|
QPushButton, QGroupBox, QMessageBox, QFrame
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QFont, QColor, QBrush
|
||||||
|
|
||||||
|
from ...tool import (
|
||||||
|
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
||||||
|
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES,
|
||||||
|
get_tools_dir
|
||||||
|
)
|
||||||
|
from ...config import load_config
|
||||||
|
from ...hash_utils import compute_config_hash
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Get the publish state of a tool.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (state, registry_hash) where state is:
|
||||||
|
- "published" - has registry_hash and current hash matches
|
||||||
|
- "modified" - has registry_hash but current hash differs
|
||||||
|
- "local" - no registry_hash (never published/downloaded)
|
||||||
|
"""
|
||||||
|
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||||
|
if not config_path.exists():
|
||||||
|
return ("local", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = yaml.safe_load(config_path.read_text())
|
||||||
|
registry_hash = config.get("registry_hash")
|
||||||
|
|
||||||
|
if not registry_hash:
|
||||||
|
return ("local", None)
|
||||||
|
|
||||||
|
# Compute current hash (excluding hash fields)
|
||||||
|
current_hash = compute_config_hash(config)
|
||||||
|
|
||||||
|
if current_hash == registry_hash:
|
||||||
|
return ("published", registry_hash)
|
||||||
|
else:
|
||||||
|
return ("modified", registry_hash)
|
||||||
|
except Exception:
|
||||||
|
return ("local", None)
|
||||||
|
|
||||||
|
|
||||||
|
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]):
|
||||||
|
# Get publish state
|
||||||
|
state, registry_hash = get_tool_publish_state(name)
|
||||||
|
|
||||||
|
# Show state indicator in display name
|
||||||
|
if state == "published":
|
||||||
|
display_name = f"{name} ✓"
|
||||||
|
tooltip = "Published to registry - up to date"
|
||||||
|
color = QColor(56, 161, 105) # Green
|
||||||
|
elif state == "modified":
|
||||||
|
display_name = f"{name} ●"
|
||||||
|
tooltip = "Published to registry - local modifications"
|
||||||
|
color = QColor(221, 107, 32) # Orange
|
||||||
|
else:
|
||||||
|
display_name = name
|
||||||
|
tooltip = "Local tool - not published"
|
||||||
|
color = None
|
||||||
|
|
||||||
|
tool_item = QTreeWidgetItem([display_name])
|
||||||
|
tool_item.setData(0, Qt.UserRole, name)
|
||||||
|
|
||||||
|
if color:
|
||||||
|
tool_item.setForeground(0, QBrush(color))
|
||||||
|
|
||||||
|
# Build tooltip
|
||||||
|
if tool.source and tool.source.type == "imported":
|
||||||
|
tooltip = f"Imported from {tool.source.url or 'registry'}"
|
||||||
|
tool_item.setToolTip(0, tooltip)
|
||||||
|
|
||||||
|
cat_item.addChild(tool_item)
|
||||||
|
|
||||||
|
self.tool_tree.addTopLevelItem(cat_item)
|
||||||
|
|
||||||
|
# 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>")
|
||||||
|
|
||||||
|
# Publish state
|
||||||
|
state, registry_hash = get_tool_publish_state(tool.name)
|
||||||
|
if state == "published":
|
||||||
|
lines.append(
|
||||||
|
"<p style='background: #c6f6d5; color: #276749; padding: 6px 10px; "
|
||||||
|
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
||||||
|
"✓ Published to registry - up to date</p>"
|
||||||
|
)
|
||||||
|
elif state == "modified":
|
||||||
|
lines.append(
|
||||||
|
"<p style='background: #feebc8; color: #c05621; padding: 6px 10px; "
|
||||||
|
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
||||||
|
"● Modified since last publish - republish to update registry</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("<", "<").replace(">", ">")
|
||||||
|
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}'")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"""Modern stylesheet for CmdForge GUI."""
|
||||||
|
|
||||||
|
STYLESHEET = """
|
||||||
|
/* Main Window */
|
||||||
|
QMainWindow {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
#sidebar {
|
||||||
|
background-color: #2d3748;
|
||||||
|
border: none;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar::item {
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar::item:selected {
|
||||||
|
background-color: #4a5568;
|
||||||
|
border-left: 3px solid #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar::item:hover:!selected {
|
||||||
|
background-color: #3d4a5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
QWidget#content_area {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
QPushButton {
|
||||||
|
background-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #5a67d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:pressed {
|
||||||
|
background-color: #4c51bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:disabled {
|
||||||
|
background-color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#secondary {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#secondary:hover {
|
||||||
|
background-color: #cbd5e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#danger {
|
||||||
|
background-color: #e53e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#danger:hover {
|
||||||
|
background-color: #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View toggle buttons */
|
||||||
|
QPushButton#viewToggle {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
color: #4a5568;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
min-height: 24px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#viewToggle:checked {
|
||||||
|
background-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#viewToggle:hover:!checked {
|
||||||
|
background-color: #cbd5e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input fields */
|
||||||
|
QLineEdit, QTextEdit, QPlainTextEdit {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: white;
|
||||||
|
selection-background-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Combo boxes */
|
||||||
|
QComboBox {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: white;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QComboBox:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QComboBox::drop-down {
|
||||||
|
border: none;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QComboBox::down-arrow {
|
||||||
|
image: none;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
border-top: 6px solid #718096;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QComboBox QAbstractItemView {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background-color: white;
|
||||||
|
selection-background-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists and Trees */
|
||||||
|
QListWidget, QTreeWidget, QTableWidget {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: white;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
QListWidget::item, QTreeWidget::item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
QListWidget::item:selected, QTreeWidget::item:selected {
|
||||||
|
background-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QListWidget::item:hover:!selected, QTreeWidget::item:hover:!selected {
|
||||||
|
background-color: #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Widget */
|
||||||
|
QTableWidget {
|
||||||
|
gridline-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTableWidget::item {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTableWidget::item:selected {
|
||||||
|
background-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHeaderView::section {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group boxes */
|
||||||
|
QGroupBox {
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QGroupBox::title {
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
subcontrol-position: top left;
|
||||||
|
left: 12px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
QLabel {
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel#heading {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel#subheading {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel#label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars */
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
width: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background-color: #cbd5e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:vertical:hover {
|
||||||
|
background-color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar:horizontal {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:horizontal {
|
||||||
|
background-color: #cbd5e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:horizontal:hover {
|
||||||
|
background-color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status bar */
|
||||||
|
QStatusBar {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Splitter */
|
||||||
|
QSplitter::handle {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSplitter::handle:horizontal {
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSplitter::handle:vertical {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
QTabWidget::pane {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab {
|
||||||
|
background-color: #edf2f7;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin-right: 2px;
|
||||||
|
border-top-left-radius: 6px;
|
||||||
|
border-top-right-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background-color: white;
|
||||||
|
border-bottom: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTabBar::tab:hover:!selected {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialogs */
|
||||||
|
QDialog {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message boxes */
|
||||||
|
QMessageBox {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMessageBox QPushButton {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool tips */
|
||||||
|
QToolTip {
|
||||||
|
background-color: #2d3748;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
QProgressBar {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #edf2f7;
|
||||||
|
text-align: center;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: #667eea;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spin boxes */
|
||||||
|
QSpinBox, QDoubleSpinBox {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSpinBox:focus, QDoubleSpinBox:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Check boxes and radio buttons */
|
||||||
|
QCheckBox, QRadioButton {
|
||||||
|
spacing: 8px;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
QCheckBox::indicator, QRadioButton::indicator {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QCheckBox::indicator:unchecked {
|
||||||
|
border: 2px solid #cbd5e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QCheckBox::indicator:checked {
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
QRadioButton::indicator:unchecked {
|
||||||
|
border: 2px solid #cbd5e0;
|
||||||
|
border-radius: 9px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
QRadioButton::indicator:checked {
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
border-radius: 9px;
|
||||||
|
background-color: #667eea;
|
||||||
|
}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Custom widgets for CmdForge GUI."""
|
||||||
|
|
||||||
|
from .flow_graph import FlowGraphWidget
|
||||||
|
|
||||||
|
__all__ = ['FlowGraphWidget']
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
"""Flow visualization widget using NodeGraphQt."""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QMenu
|
||||||
|
from PySide6.QtCore import Signal, Qt, QTimer, QEvent, QPropertyAnimation, QEasingCurve
|
||||||
|
from PySide6.QtGui import QKeyEvent, QAction
|
||||||
|
|
||||||
|
from NodeGraphQt import NodeGraph, BaseNode
|
||||||
|
|
||||||
|
from ...tool import Tool, PromptStep, CodeStep
|
||||||
|
from .icons import (
|
||||||
|
get_prompt_icon_path, get_code_icon_path,
|
||||||
|
get_input_icon_path, get_output_icon_path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Custom Node Types
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CmdForgeBaseNode(BaseNode):
|
||||||
|
"""Base class for CmdForge nodes with common styling."""
|
||||||
|
|
||||||
|
__identifier__ = 'cmdforge'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
# Store reference to the step data
|
||||||
|
self._step_data = None
|
||||||
|
self._step_index = -1
|
||||||
|
|
||||||
|
|
||||||
|
class InputNode(CmdForgeBaseNode):
|
||||||
|
"""Node representing tool input (stdin and arguments)."""
|
||||||
|
|
||||||
|
NODE_NAME = 'Input'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(90, 90, 160) # Indigo
|
||||||
|
self.set_icon(get_input_icon_path(16))
|
||||||
|
# Default output for stdin
|
||||||
|
self.add_output('input', color=(180, 180, 250))
|
||||||
|
|
||||||
|
def set_arguments(self, arguments: list):
|
||||||
|
"""Add output ports for each argument variable."""
|
||||||
|
for arg in arguments:
|
||||||
|
if arg.variable:
|
||||||
|
# Check if port already exists
|
||||||
|
existing = [p.name() for p in self.output_ports()]
|
||||||
|
if arg.variable not in existing:
|
||||||
|
self.add_output(arg.variable, color=(180, 180, 250))
|
||||||
|
|
||||||
|
|
||||||
|
class PromptNode(CmdForgeBaseNode):
|
||||||
|
"""Node representing a prompt step."""
|
||||||
|
|
||||||
|
NODE_NAME = 'Prompt'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(102, 126, 234) # Indigo (matching CmdForge theme)
|
||||||
|
self.set_icon(get_prompt_icon_path(16))
|
||||||
|
self.add_input('in', color=(180, 180, 250))
|
||||||
|
self.add_output('out', color=(180, 250, 180))
|
||||||
|
|
||||||
|
# Add properties for display
|
||||||
|
self.add_text_input('provider', 'Provider', text='claude')
|
||||||
|
self.add_text_input('output_var', 'Output', text='response')
|
||||||
|
|
||||||
|
def set_step(self, step: PromptStep, index: int):
|
||||||
|
"""Configure node from a PromptStep."""
|
||||||
|
self._step_data = step
|
||||||
|
self._step_index = index
|
||||||
|
self.set_property('provider', step.provider or 'claude')
|
||||||
|
self.set_property('output_var', step.output_var or 'response')
|
||||||
|
# Update output port name
|
||||||
|
if self.output_ports():
|
||||||
|
self.output_ports()[0]._name = step.output_var or 'response'
|
||||||
|
|
||||||
|
|
||||||
|
class CodeNode(CmdForgeBaseNode):
|
||||||
|
"""Node representing a code step."""
|
||||||
|
|
||||||
|
NODE_NAME = 'Code'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(72, 187, 120) # Green
|
||||||
|
self.set_icon(get_code_icon_path(16))
|
||||||
|
self.add_input('in', color=(180, 250, 180))
|
||||||
|
self.add_output('out', color=(250, 220, 180))
|
||||||
|
|
||||||
|
# Add properties
|
||||||
|
self.add_text_input('output_var', 'Output', text='result')
|
||||||
|
|
||||||
|
def set_step(self, step: CodeStep, index: int):
|
||||||
|
"""Configure node from a CodeStep."""
|
||||||
|
self._step_data = step
|
||||||
|
self._step_index = index
|
||||||
|
self.set_property('output_var', step.output_var or 'result')
|
||||||
|
# Update output port name
|
||||||
|
if self.output_ports():
|
||||||
|
self.output_ports()[0]._name = step.output_var or 'result'
|
||||||
|
|
||||||
|
|
||||||
|
class OutputNode(CmdForgeBaseNode):
|
||||||
|
"""Node representing tool output."""
|
||||||
|
|
||||||
|
NODE_NAME = 'Output'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.set_color(237, 137, 54) # Orange
|
||||||
|
self.set_icon(get_output_icon_path(16))
|
||||||
|
self.add_input('in', color=(250, 220, 180))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Flow Graph Widget
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class FlowGraphWidget(QWidget):
|
||||||
|
"""Widget for visualizing tool flow as a node graph.
|
||||||
|
|
||||||
|
Note: Currently the flow view is read-only for connections. Reordering
|
||||||
|
steps by manipulating connections is a future enhancement (TODO).
|
||||||
|
Use the list view for reordering steps via drag-drop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Emitted when a node is double-clicked (step_index, step_type)
|
||||||
|
node_double_clicked = Signal(int, str)
|
||||||
|
|
||||||
|
# Emitted when the flow structure changes
|
||||||
|
flow_changed = Signal()
|
||||||
|
|
||||||
|
# Emitted when steps are deleted from flow view (list of step indices)
|
||||||
|
steps_deleted = Signal(list)
|
||||||
|
|
||||||
|
# Emitted when steps are reordered (new order as list of step indices)
|
||||||
|
steps_reordered = Signal(list)
|
||||||
|
|
||||||
|
# Emitted when a step name is changed (step_index, new_name)
|
||||||
|
step_name_changed = Signal(int, str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._tool: Optional[Tool] = None
|
||||||
|
self._graph: Optional[NodeGraph] = None
|
||||||
|
self._input_node = None
|
||||||
|
self._output_node = None
|
||||||
|
self._step_nodes: List[CmdForgeBaseNode] = []
|
||||||
|
self._rebuilding = False # Flag to ignore signals during rebuild
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Set up the UI."""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Create node graph
|
||||||
|
self._graph = NodeGraph()
|
||||||
|
|
||||||
|
# Register custom node types
|
||||||
|
self._graph.register_node(InputNode)
|
||||||
|
self._graph.register_node(PromptNode)
|
||||||
|
self._graph.register_node(CodeNode)
|
||||||
|
self._graph.register_node(OutputNode)
|
||||||
|
|
||||||
|
# Connect signals
|
||||||
|
self._graph.node_double_clicked.connect(self._on_node_double_clicked)
|
||||||
|
self._graph.nodes_deleted.connect(self._on_nodes_deleted)
|
||||||
|
self._graph.property_changed.connect(self._on_property_changed)
|
||||||
|
|
||||||
|
# Add graph widget
|
||||||
|
layout.addWidget(self._graph.widget, 1)
|
||||||
|
|
||||||
|
# Install event filter to catch F key and focus events on the graph widget
|
||||||
|
self._graph.widget.installEventFilter(self)
|
||||||
|
|
||||||
|
# Create floating help banner (overlay on graph widget)
|
||||||
|
self._help_banner = QLabel(self._graph.widget)
|
||||||
|
self._help_banner.setText(
|
||||||
|
"Pan: Middle-drag | "
|
||||||
|
"Zoom: Scroll | "
|
||||||
|
"Select: Click/drag | "
|
||||||
|
"Edit: Double-click | "
|
||||||
|
"Select All: A | "
|
||||||
|
"Fit: F"
|
||||||
|
)
|
||||||
|
self._help_banner.setStyleSheet("""
|
||||||
|
QLabel {
|
||||||
|
background-color: rgba(45, 55, 72, 0.9);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self._help_banner.adjustSize()
|
||||||
|
self._help_banner.hide()
|
||||||
|
|
||||||
|
# Animation for fading
|
||||||
|
self._fade_animation = QPropertyAnimation(self._help_banner, b"windowOpacity")
|
||||||
|
self._fade_animation.setDuration(500)
|
||||||
|
self._fade_animation.setEasingCurve(QEasingCurve.InOutQuad)
|
||||||
|
|
||||||
|
# Timer for auto-hide
|
||||||
|
self._hide_timer = QTimer(self)
|
||||||
|
self._hide_timer.setSingleShot(True)
|
||||||
|
self._hide_timer.timeout.connect(self._fade_out_banner)
|
||||||
|
|
||||||
|
# Track if mouse is over banner
|
||||||
|
self._help_banner.setMouseTracking(True)
|
||||||
|
self._help_banner.enterEvent = self._on_banner_enter
|
||||||
|
self._help_banner.leaveEvent = self._on_banner_leave
|
||||||
|
|
||||||
|
# Set up context menu
|
||||||
|
self._graph.widget.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self._graph.widget.customContextMenuRequested.connect(self._show_context_menu)
|
||||||
|
|
||||||
|
def set_tool(self, tool: Optional[Tool]):
|
||||||
|
"""Set the tool to visualize."""
|
||||||
|
self._tool = tool
|
||||||
|
self._rebuild_graph()
|
||||||
|
|
||||||
|
def _rebuild_graph(self):
|
||||||
|
"""Rebuild the graph from the tool data."""
|
||||||
|
if not self._graph:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set flag to ignore signals during rebuild
|
||||||
|
self._rebuilding = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear existing nodes
|
||||||
|
self._graph.clear_session()
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore errors during clear
|
||||||
|
|
||||||
|
self._input_node = None
|
||||||
|
self._output_node = None
|
||||||
|
self._step_nodes = []
|
||||||
|
|
||||||
|
if not self._tool:
|
||||||
|
self._rebuilding = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create input node
|
||||||
|
self._input_node = self._graph.create_node(
|
||||||
|
'cmdforge.InputNode',
|
||||||
|
name='Input',
|
||||||
|
pos=[-300, 0]
|
||||||
|
)
|
||||||
|
if self._tool.arguments:
|
||||||
|
self._input_node.set_arguments(self._tool.arguments)
|
||||||
|
|
||||||
|
# Create step nodes
|
||||||
|
x_pos = 0
|
||||||
|
prev_node = self._input_node
|
||||||
|
|
||||||
|
# Count steps by type for default naming
|
||||||
|
prompt_count = 0
|
||||||
|
code_count = 0
|
||||||
|
|
||||||
|
for i, step in enumerate(self._tool.steps or []):
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
|
prompt_count += 1
|
||||||
|
# Use custom name if set, otherwise default to "Prompt N" (per type)
|
||||||
|
node_name = step.name if step.name else f'Prompt {prompt_count}'
|
||||||
|
node = self._graph.create_node(
|
||||||
|
'cmdforge.PromptNode',
|
||||||
|
name=node_name,
|
||||||
|
pos=[x_pos, 0]
|
||||||
|
)
|
||||||
|
node.set_step(step, i)
|
||||||
|
elif isinstance(step, CodeStep):
|
||||||
|
code_count += 1
|
||||||
|
# Use custom name if set, otherwise default to "Code N" (per type)
|
||||||
|
node_name = step.name if step.name else f'Code {code_count}'
|
||||||
|
node = self._graph.create_node(
|
||||||
|
'cmdforge.CodeNode',
|
||||||
|
name=node_name,
|
||||||
|
pos=[x_pos, 0]
|
||||||
|
)
|
||||||
|
node.set_step(step, i)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._step_nodes.append(node)
|
||||||
|
|
||||||
|
# Connect to previous node
|
||||||
|
if prev_node and prev_node.output_ports():
|
||||||
|
prev_node.output_ports()[0].connect_to(node.input_ports()[0])
|
||||||
|
|
||||||
|
prev_node = node
|
||||||
|
x_pos += 250
|
||||||
|
|
||||||
|
# Create output node
|
||||||
|
self._output_node = self._graph.create_node(
|
||||||
|
'cmdforge.OutputNode',
|
||||||
|
name='Output',
|
||||||
|
pos=[x_pos, 0]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Connect last step to output
|
||||||
|
if prev_node and prev_node.output_ports():
|
||||||
|
prev_node.output_ports()[0].connect_to(self._output_node.input_ports()[0])
|
||||||
|
|
||||||
|
# Auto-layout and fit view
|
||||||
|
self._graph.auto_layout_nodes()
|
||||||
|
|
||||||
|
# Select all nodes then fit to selection, then clear selection
|
||||||
|
all_nodes = self._graph.all_nodes()
|
||||||
|
for node in all_nodes:
|
||||||
|
node.set_selected(True)
|
||||||
|
|
||||||
|
# Use a timer to fit after the widget is fully rendered
|
||||||
|
# Also clears the rebuilding flag after everything settles
|
||||||
|
QTimer.singleShot(50, self._fit_and_clear_selection)
|
||||||
|
|
||||||
|
def _fit_and_clear_selection(self):
|
||||||
|
"""Fit view to all nodes and clear selection."""
|
||||||
|
if self._graph:
|
||||||
|
self._graph.fit_to_selection()
|
||||||
|
# Clear selection
|
||||||
|
self._graph.clear_selection()
|
||||||
|
|
||||||
|
# Clear rebuilding flag - now safe to handle user interactions
|
||||||
|
self._rebuilding = False
|
||||||
|
|
||||||
|
def select_all_nodes(self):
|
||||||
|
"""Select all nodes in the graph."""
|
||||||
|
if not self._graph:
|
||||||
|
return
|
||||||
|
|
||||||
|
all_nodes = self._graph.all_nodes()
|
||||||
|
for node in all_nodes:
|
||||||
|
node.set_selected(True)
|
||||||
|
|
||||||
|
def fit_selection(self):
|
||||||
|
"""Fit view to show selected nodes (or all if none selected)."""
|
||||||
|
if not self._graph:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = self._graph.selected_nodes()
|
||||||
|
if not selected:
|
||||||
|
# No selection - fit all nodes
|
||||||
|
all_nodes = self._graph.all_nodes()
|
||||||
|
if not all_nodes:
|
||||||
|
return
|
||||||
|
for node in all_nodes:
|
||||||
|
node.set_selected(True)
|
||||||
|
self._graph.fit_to_selection()
|
||||||
|
self._graph.clear_selection()
|
||||||
|
else:
|
||||||
|
# Fit to current selection (don't clear it)
|
||||||
|
self._graph.fit_to_selection()
|
||||||
|
|
||||||
|
def keyPressEvent(self, event: QKeyEvent):
|
||||||
|
"""Handle keyboard shortcuts."""
|
||||||
|
if event.key() == Qt.Key_F:
|
||||||
|
self.fit_selection()
|
||||||
|
event.accept()
|
||||||
|
elif event.key() == Qt.Key_A:
|
||||||
|
self.select_all_nodes()
|
||||||
|
event.accept()
|
||||||
|
else:
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
"""Handle widget becoming visible."""
|
||||||
|
super().showEvent(event)
|
||||||
|
# Show help banner when flow view becomes visible
|
||||||
|
QTimer.singleShot(100, self._show_help_banner)
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
"""Filter events from the graph widget to catch keyboard shortcuts."""
|
||||||
|
if event.type() == QEvent.KeyPress:
|
||||||
|
if event.key() == Qt.Key_F:
|
||||||
|
self.fit_selection()
|
||||||
|
return True # Event handled
|
||||||
|
elif event.key() == Qt.Key_A:
|
||||||
|
self.select_all_nodes()
|
||||||
|
return True # Event handled
|
||||||
|
elif event.type() == QEvent.Enter:
|
||||||
|
# Show banner when mouse enters the graph area
|
||||||
|
self._show_help_banner()
|
||||||
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
def _show_help_banner(self):
|
||||||
|
"""Show the help banner with fade effect."""
|
||||||
|
if not self._help_banner or not self._graph:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Make sure size is calculated
|
||||||
|
self._help_banner.adjustSize()
|
||||||
|
|
||||||
|
# Position at top center of the graph widget
|
||||||
|
parent_width = self._graph.widget.width()
|
||||||
|
banner_width = self._help_banner.width()
|
||||||
|
x = max(10, (parent_width - banner_width) // 2)
|
||||||
|
self._help_banner.move(x, 10)
|
||||||
|
|
||||||
|
# Raise to front and show
|
||||||
|
self._help_banner.raise_()
|
||||||
|
self._help_banner.show()
|
||||||
|
|
||||||
|
# Start auto-hide timer
|
||||||
|
self._hide_timer.start(3000) # Hide after 3 seconds
|
||||||
|
|
||||||
|
def _fade_out_banner(self):
|
||||||
|
"""Fade out the help banner."""
|
||||||
|
if self._help_banner and self._help_banner.isVisible():
|
||||||
|
# Use a simple hide with timer since windowOpacity doesn't work well on child widgets
|
||||||
|
self._help_banner.hide()
|
||||||
|
|
||||||
|
def _on_banner_enter(self, event):
|
||||||
|
"""Mouse entered banner - stop hide timer."""
|
||||||
|
self._hide_timer.stop()
|
||||||
|
|
||||||
|
def _on_banner_leave(self, event):
|
||||||
|
"""Mouse left banner - restart hide timer."""
|
||||||
|
self._hide_timer.start(1500) # Hide 1.5 seconds after mouse leaves
|
||||||
|
|
||||||
|
def _show_context_menu(self, pos):
|
||||||
|
"""Show context menu."""
|
||||||
|
menu = QMenu(self._graph.widget)
|
||||||
|
|
||||||
|
select_all_action = QAction("Select All (A)", menu)
|
||||||
|
select_all_action.triggered.connect(self.select_all_nodes)
|
||||||
|
menu.addAction(select_all_action)
|
||||||
|
|
||||||
|
fit_action = QAction("Fit Selection (F)", menu)
|
||||||
|
fit_action.triggered.connect(self.fit_selection)
|
||||||
|
menu.addAction(fit_action)
|
||||||
|
|
||||||
|
menu.exec_(self._graph.widget.mapToGlobal(pos))
|
||||||
|
|
||||||
|
def _on_node_double_clicked(self, node):
|
||||||
|
"""Handle node double-click."""
|
||||||
|
if hasattr(node, '_step_index') and node._step_index >= 0:
|
||||||
|
step_type = 'prompt' if isinstance(node, PromptNode) else 'code'
|
||||||
|
self.node_double_clicked.emit(node._step_index, step_type)
|
||||||
|
|
||||||
|
def _on_nodes_deleted(self, nodes):
|
||||||
|
"""Handle node deletion from the graph."""
|
||||||
|
# Ignore during rebuild
|
||||||
|
if self._rebuilding:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Collect step indices of deleted step nodes (not Input/Output nodes)
|
||||||
|
deleted_indices = []
|
||||||
|
for node in nodes:
|
||||||
|
if hasattr(node, '_step_index') and node._step_index >= 0:
|
||||||
|
deleted_indices.append(node._step_index)
|
||||||
|
|
||||||
|
if deleted_indices:
|
||||||
|
# Sort in reverse order so we delete from end first
|
||||||
|
deleted_indices.sort(reverse=True)
|
||||||
|
self.steps_deleted.emit(deleted_indices)
|
||||||
|
self.flow_changed.emit()
|
||||||
|
|
||||||
|
def _on_property_changed(self, node, prop_name, value):
|
||||||
|
"""Handle node property change.
|
||||||
|
|
||||||
|
Filters for 'name' property to detect step name changes.
|
||||||
|
"""
|
||||||
|
# Ignore during rebuild
|
||||||
|
if self._rebuilding:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only interested in name changes
|
||||||
|
if prop_name != 'name':
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only handle step nodes (not Input/Output)
|
||||||
|
if hasattr(node, '_step_index') and node._step_index >= 0:
|
||||||
|
self.step_name_changed.emit(node._step_index, value)
|
||||||
|
self.flow_changed.emit()
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""Refresh the graph from current tool data."""
|
||||||
|
self._rebuild_graph()
|
||||||
|
|
||||||
|
def get_graph(self) -> Optional[NodeGraph]:
|
||||||
|
"""Get the underlying NodeGraph instance."""
|
||||||
|
return self._graph
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Step type icons for CmdForge GUI."""
|
||||||
|
|
||||||
|
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QPen, QBrush, QPainterPath, QFont
|
||||||
|
from PySide6.QtCore import Qt, QRect, QRectF
|
||||||
|
|
||||||
|
|
||||||
|
def create_prompt_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create a speech bubble icon for prompt steps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to indigo #667eea)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(102, 126, 234) # Indigo
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
# Draw speech bubble
|
||||||
|
pen = QPen(color, 1.5)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(QBrush(color))
|
||||||
|
|
||||||
|
# Bubble body
|
||||||
|
margin = size * 0.1
|
||||||
|
bubble_rect = QRectF(margin, margin, size - 2 * margin, size * 0.65)
|
||||||
|
painter.drawRoundedRect(bubble_rect, 4, 4)
|
||||||
|
|
||||||
|
# Bubble tail (triangle pointing down-left)
|
||||||
|
path = QPainterPath()
|
||||||
|
tail_x = margin + size * 0.2
|
||||||
|
tail_y = bubble_rect.bottom()
|
||||||
|
path.moveTo(tail_x, tail_y - 2)
|
||||||
|
path.lineTo(tail_x - size * 0.1, tail_y + size * 0.2)
|
||||||
|
path.lineTo(tail_x + size * 0.15, tail_y - 2)
|
||||||
|
path.closeSubpath()
|
||||||
|
painter.drawPath(path)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def create_code_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create a code brackets icon for code steps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to green #48bb78)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(72, 187, 120) # Green
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
pen = QPen(color, 2.0)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
# Draw < > brackets
|
||||||
|
margin = size * 0.15
|
||||||
|
mid_y = size / 2
|
||||||
|
bracket_height = size * 0.5
|
||||||
|
|
||||||
|
# Left bracket <
|
||||||
|
left_x = margin + size * 0.1
|
||||||
|
painter.drawLine(
|
||||||
|
int(left_x + size * 0.2), int(mid_y - bracket_height / 2),
|
||||||
|
int(left_x), int(mid_y)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(left_x), int(mid_y),
|
||||||
|
int(left_x + size * 0.2), int(mid_y + bracket_height / 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Right bracket >
|
||||||
|
right_x = size - margin - size * 0.1
|
||||||
|
painter.drawLine(
|
||||||
|
int(right_x - size * 0.2), int(mid_y - bracket_height / 2),
|
||||||
|
int(right_x), int(mid_y)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(right_x), int(mid_y),
|
||||||
|
int(right_x - size * 0.2), int(mid_y + bracket_height / 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Slash in middle /
|
||||||
|
slash_width = size * 0.12
|
||||||
|
painter.drawLine(
|
||||||
|
int(size / 2 + slash_width), int(mid_y - bracket_height / 2.5),
|
||||||
|
int(size / 2 - slash_width), int(mid_y + bracket_height / 2.5)
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def create_input_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create an input icon (arrow pointing right into box).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to indigo #5a5a9f)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(90, 90, 160) # Indigo
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
pen = QPen(color, 2.0)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
margin = size * 0.15
|
||||||
|
mid_y = size / 2
|
||||||
|
|
||||||
|
# Arrow pointing right
|
||||||
|
arrow_start = margin
|
||||||
|
arrow_end = size - margin - size * 0.15
|
||||||
|
|
||||||
|
# Arrow shaft
|
||||||
|
painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y))
|
||||||
|
|
||||||
|
# Arrow head
|
||||||
|
head_size = size * 0.2
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y - head_size)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y + head_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def create_output_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||||
|
"""Create an output icon (arrow pointing right out of box).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Icon size in pixels
|
||||||
|
color: Icon color (defaults to orange #ed8936)
|
||||||
|
"""
|
||||||
|
if color is None:
|
||||||
|
color = QColor(237, 137, 54) # Orange
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
pen = QPen(color, 2.0)
|
||||||
|
pen.setCapStyle(Qt.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
margin = size * 0.15
|
||||||
|
mid_y = size / 2
|
||||||
|
|
||||||
|
# Arrow pointing right (export style)
|
||||||
|
arrow_start = margin + size * 0.15
|
||||||
|
arrow_end = size - margin
|
||||||
|
|
||||||
|
# Arrow shaft
|
||||||
|
painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y))
|
||||||
|
|
||||||
|
# Arrow head
|
||||||
|
head_size = size * 0.2
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y - head_size)
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(arrow_end), int(mid_y),
|
||||||
|
int(arrow_end - head_size), int(mid_y + head_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
# Cached icons for reuse
|
||||||
|
_icon_cache = {}
|
||||||
|
|
||||||
|
# Cached icon file paths for NodeGraphQt (which requires file paths)
|
||||||
|
_icon_path_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_icon_path(icon_type: str, size: int, create_func) -> str:
|
||||||
|
"""Get or create a cached icon file path.
|
||||||
|
|
||||||
|
NodeGraphQt requires file paths for icons, so we save to temp files.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
key = (icon_type, size)
|
||||||
|
if key not in _icon_path_cache:
|
||||||
|
icon = create_func(size)
|
||||||
|
pixmap = icon.pixmap(size, size)
|
||||||
|
|
||||||
|
# Save to temp directory with a stable name
|
||||||
|
temp_dir = os.path.join(tempfile.gettempdir(), 'cmdforge_icons')
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
path = os.path.join(temp_dir, f'{icon_type}_{size}.png')
|
||||||
|
pixmap.save(path, 'PNG')
|
||||||
|
_icon_path_cache[key] = path
|
||||||
|
|
||||||
|
return _icon_path_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to prompt icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('prompt', size, create_prompt_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_code_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to code icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('code', size, create_code_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_input_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to input icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('input', size, create_input_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_icon_path(size: int = 16) -> str:
|
||||||
|
"""Get file path to output icon (for NodeGraphQt)."""
|
||||||
|
return _get_icon_path('output', size, create_output_icon)
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached prompt icon."""
|
||||||
|
key = ('prompt', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_prompt_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_code_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached code icon."""
|
||||||
|
key = ('code', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_code_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_input_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached input icon."""
|
||||||
|
key = ('input', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_input_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_icon(size: int = 24) -> QIcon:
|
||||||
|
"""Get cached output icon."""
|
||||||
|
key = ('output', size)
|
||||||
|
if key not in _icon_cache:
|
||||||
|
_icon_cache[key] = create_output_icon(size)
|
||||||
|
return _icon_cache[key]
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Content hash utilities for tool integrity verification.
|
||||||
|
|
||||||
|
This module provides consistent SHA256 hashing for tool content,
|
||||||
|
used for:
|
||||||
|
- Publish state tracking (detect local modifications)
|
||||||
|
- Download integrity verification
|
||||||
|
- Registry content verification
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def compute_config_hash(config: Dict[str, Any], exclude_fields: Optional[list] = None) -> str:
|
||||||
|
"""Compute SHA256 hash of tool configuration.
|
||||||
|
|
||||||
|
The hash is computed from a normalized YAML representation to ensure
|
||||||
|
consistent hashing regardless of field order or formatting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Tool configuration dictionary
|
||||||
|
exclude_fields: Fields to exclude from hashing (e.g., 'published_hash', 'registry_hash')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hash string in format "sha256:<64-char-hex>"
|
||||||
|
"""
|
||||||
|
if exclude_fields is None:
|
||||||
|
exclude_fields = ['published_hash', 'registry_hash']
|
||||||
|
|
||||||
|
# Create a copy without excluded fields
|
||||||
|
config_copy = {k: v for k, v in config.items() if k not in exclude_fields}
|
||||||
|
|
||||||
|
# Normalize to YAML with sorted keys for consistent ordering
|
||||||
|
normalized = yaml.dump(config_copy, sort_keys=True, default_flow_style=False)
|
||||||
|
|
||||||
|
# Compute SHA256
|
||||||
|
hash_bytes = hashlib.sha256(normalized.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
return f"sha256:{hash_bytes}"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_yaml_hash(yaml_content: str, exclude_fields: Optional[list] = None) -> str:
|
||||||
|
"""Compute SHA256 hash of YAML content string.
|
||||||
|
|
||||||
|
Parses the YAML, normalizes it, and computes hash.
|
||||||
|
Useful for hashing raw config.yaml content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
yaml_content: Raw YAML string
|
||||||
|
exclude_fields: Fields to exclude from hashing
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hash string in format "sha256:<64-char-hex>"
|
||||||
|
"""
|
||||||
|
config = yaml.safe_load(yaml_content)
|
||||||
|
if config is None:
|
||||||
|
config = {}
|
||||||
|
return compute_config_hash(config, exclude_fields)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_file_hash(file_path: str) -> str:
|
||||||
|
"""Compute SHA256 hash of a file's contents.
|
||||||
|
|
||||||
|
Used for hashing pattern files (e.g., Fabric's system.md).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hash string in format "sha256:<64-char-hex>"
|
||||||
|
"""
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
hash_bytes = hashlib.sha256(f.read()).hexdigest()
|
||||||
|
return f"sha256:{hash_bytes}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hash(content: str, expected_hash: str) -> bool:
|
||||||
|
"""Verify content matches expected hash.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content to verify (YAML string)
|
||||||
|
expected_hash: Expected hash in format "sha256:<hex>"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if hash matches, False otherwise
|
||||||
|
"""
|
||||||
|
if not expected_hash or not expected_hash.startswith("sha256:"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
computed = compute_yaml_hash(content)
|
||||||
|
return computed == expected_hash
|
||||||
|
|
||||||
|
|
||||||
|
def extract_hash_value(hash_string: str) -> Optional[str]:
|
||||||
|
"""Extract the hex value from a hash string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hash_string: Hash in format "sha256:<hex>"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The hex value without prefix, or None if invalid
|
||||||
|
"""
|
||||||
|
if not hash_string or not hash_string.startswith("sha256:"):
|
||||||
|
return None
|
||||||
|
return hash_string[7:] # Remove "sha256:" prefix
|
||||||
|
|
||||||
|
|
||||||
|
def short_hash(hash_string: str, length: int = 8) -> str:
|
||||||
|
"""Get a shortened version of a hash for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hash_string: Full hash string
|
||||||
|
length: Number of hex chars to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shortened hash (e.g., "sha256:abc123...")
|
||||||
|
"""
|
||||||
|
hex_value = extract_hash_value(hash_string)
|
||||||
|
if hex_value:
|
||||||
|
return f"sha256:{hex_value[:length]}..."
|
||||||
|
return hash_string[:length + 10] + "..."
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Project manifest (cmdforge.yaml) handling.
|
||||||
|
|
||||||
|
Manages project-level tool dependencies and overrides.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
MANIFEST_FILENAME = "cmdforge.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Dependency:
|
||||||
|
"""A tool dependency declaration."""
|
||||||
|
name: str # owner/name format (e.g., "rob/summarize")
|
||||||
|
version: str = "*" # Version constraint (e.g., ">=1.0.0", "^1.2.0")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def owner(self) -> Optional[str]:
|
||||||
|
"""Extract owner from name if present."""
|
||||||
|
if "/" in self.name:
|
||||||
|
return self.name.split("/")[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tool_name(self) -> str:
|
||||||
|
"""Extract tool name without owner."""
|
||||||
|
if "/" in self.name:
|
||||||
|
return self.name.split("/")[1]
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"version": self.version
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Dependency":
|
||||||
|
if isinstance(data, str):
|
||||||
|
# Simple string format: "rob/summarize" or "rob/summarize@^1.0.0"
|
||||||
|
if "@" in data:
|
||||||
|
name, version = data.rsplit("@", 1)
|
||||||
|
return cls(name=name, version=version)
|
||||||
|
return cls(name=data)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
name=data["name"],
|
||||||
|
version=data.get("version", "*")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolOverride:
|
||||||
|
"""Runtime overrides for a tool."""
|
||||||
|
provider: Optional[str] = None
|
||||||
|
# Future: other overrides like timeout, retries, etc.
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = {}
|
||||||
|
if self.provider:
|
||||||
|
d["provider"] = self.provider
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ToolOverride":
|
||||||
|
return cls(
|
||||||
|
provider=data.get("provider")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Manifest:
|
||||||
|
"""Project manifest (cmdforge.yaml)."""
|
||||||
|
name: str = "my-project"
|
||||||
|
version: str = "1.0.0"
|
||||||
|
dependencies: List[Dependency] = field(default_factory=list)
|
||||||
|
overrides: Dict[str, ToolOverride] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = {
|
||||||
|
"name": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.dependencies:
|
||||||
|
d["dependencies"] = [dep.to_dict() for dep in self.dependencies]
|
||||||
|
|
||||||
|
if self.overrides:
|
||||||
|
d["overrides"] = {
|
||||||
|
name: override.to_dict()
|
||||||
|
for name, override in self.overrides.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Manifest":
|
||||||
|
dependencies = []
|
||||||
|
for dep in data.get("dependencies", []):
|
||||||
|
dependencies.append(Dependency.from_dict(dep))
|
||||||
|
|
||||||
|
overrides = {}
|
||||||
|
for name, override_data in data.get("overrides", {}).items():
|
||||||
|
overrides[name] = ToolOverride.from_dict(override_data)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
name=data.get("name", "my-project"),
|
||||||
|
version=data.get("version", "1.0.0"),
|
||||||
|
dependencies=dependencies,
|
||||||
|
overrides=overrides
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_override(self, tool_name: str) -> Optional[ToolOverride]:
|
||||||
|
"""Get override for a tool by name (checks both full and short names)."""
|
||||||
|
# Try exact match first
|
||||||
|
if tool_name in self.overrides:
|
||||||
|
return self.overrides[tool_name]
|
||||||
|
|
||||||
|
# Try matching just the tool name part (without owner)
|
||||||
|
short_name = tool_name.split("/")[-1] if "/" in tool_name else tool_name
|
||||||
|
for override_name, override in self.overrides.items():
|
||||||
|
override_short = override_name.split("/")[-1] if "/" in override_name else override_name
|
||||||
|
if override_short == short_name:
|
||||||
|
return override
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_dependency(self, name: str, version: str = "*") -> None:
|
||||||
|
"""Add or update a dependency."""
|
||||||
|
# Check if already exists
|
||||||
|
for dep in self.dependencies:
|
||||||
|
if dep.name == name:
|
||||||
|
dep.version = version
|
||||||
|
return
|
||||||
|
|
||||||
|
self.dependencies.append(Dependency(name=name, version=version))
|
||||||
|
|
||||||
|
|
||||||
|
def find_manifest(start_dir: Optional[Path] = None) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Find cmdforge.yaml by searching up from start_dir.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_dir: Directory to start searching from (default: cwd)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to manifest file, or None if not found
|
||||||
|
"""
|
||||||
|
if start_dir is None:
|
||||||
|
start_dir = Path.cwd()
|
||||||
|
|
||||||
|
current = start_dir.resolve()
|
||||||
|
|
||||||
|
while current != current.parent:
|
||||||
|
manifest_path = current / MANIFEST_FILENAME
|
||||||
|
if manifest_path.exists():
|
||||||
|
return manifest_path
|
||||||
|
current = current.parent
|
||||||
|
|
||||||
|
# Check root
|
||||||
|
manifest_path = current / MANIFEST_FILENAME
|
||||||
|
if manifest_path.exists():
|
||||||
|
return manifest_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(path: Optional[Path] = None) -> Optional[Manifest]:
|
||||||
|
"""
|
||||||
|
Load a project manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to manifest file, or None to search
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Manifest object, or None if not found
|
||||||
|
"""
|
||||||
|
if path is None:
|
||||||
|
path = find_manifest()
|
||||||
|
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(path.read_text()) or {}
|
||||||
|
return Manifest.from_dict(data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Error loading manifest: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_manifest(manifest: Manifest, path: Optional[Path] = None) -> Path:
|
||||||
|
"""
|
||||||
|
Save a manifest to disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Manifest to save
|
||||||
|
path: Path to save to (default: ./cmdforge.yaml)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path where manifest was saved
|
||||||
|
"""
|
||||||
|
if path is None:
|
||||||
|
path = Path.cwd() / MANIFEST_FILENAME
|
||||||
|
|
||||||
|
path.write_text(yaml.dump(manifest.to_dict(), default_flow_style=False, sort_keys=False))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def create_manifest(
|
||||||
|
name: str = "my-project",
|
||||||
|
version: str = "1.0.0",
|
||||||
|
path: Optional[Path] = None
|
||||||
|
) -> Manifest:
|
||||||
|
"""
|
||||||
|
Create a new manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Project name
|
||||||
|
version: Project version
|
||||||
|
path: Path to save to (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Manifest object
|
||||||
|
"""
|
||||||
|
manifest = Manifest(name=name, version=version)
|
||||||
|
|
||||||
|
if path is not None:
|
||||||
|
save_manifest(manifest, path)
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version_constraint(constraint: str) -> dict:
|
||||||
|
"""
|
||||||
|
Parse a version constraint string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
constraint: Version constraint (e.g., ">=1.0.0", "^1.2.3", "~1.2.0")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with operator and version parts
|
||||||
|
"""
|
||||||
|
constraint = constraint.strip()
|
||||||
|
|
||||||
|
# Exact version
|
||||||
|
if re.match(r'^\d+\.\d+\.\d+', constraint):
|
||||||
|
return {"operator": "=", "version": constraint}
|
||||||
|
|
||||||
|
# Any version
|
||||||
|
if constraint == "*" or constraint == "latest":
|
||||||
|
return {"operator": "*", "version": None}
|
||||||
|
|
||||||
|
# Range operators
|
||||||
|
patterns = [
|
||||||
|
(r'^>=(.+)$', ">="),
|
||||||
|
(r'^<=(.+)$', "<="),
|
||||||
|
(r'^>(.+)$', ">"),
|
||||||
|
(r'^<(.+)$', "<"),
|
||||||
|
(r'^\^(.+)$', "^"), # Compatible (same major)
|
||||||
|
(r'^~(.+)$', "~"), # Approximately (same minor)
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern, operator in patterns:
|
||||||
|
match = re.match(pattern, constraint)
|
||||||
|
if match:
|
||||||
|
return {"operator": operator, "version": match.group(1)}
|
||||||
|
|
||||||
|
# Default to exact match
|
||||||
|
return {"operator": "=", "version": constraint}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""AI persona profiles for prompt injection."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .config import get_config_dir
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Profile:
|
||||||
|
"""An AI persona profile with system instructions."""
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
system_prompt: str = ""
|
||||||
|
tags: List[str] = field(default_factory=list)
|
||||||
|
builtin: bool = False # True for default profiles
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"system_prompt": self.system_prompt,
|
||||||
|
}
|
||||||
|
if self.tags:
|
||||||
|
d["tags"] = self.tags
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict, builtin: bool = False) -> "Profile":
|
||||||
|
return cls(
|
||||||
|
name=data.get("name", ""),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
system_prompt=data.get("system_prompt", ""),
|
||||||
|
tags=data.get("tags", []),
|
||||||
|
builtin=builtin
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Built-in default profiles
|
||||||
|
DEFAULT_PROFILES = [
|
||||||
|
Profile(
|
||||||
|
name="None",
|
||||||
|
description="No profile - use prompt as-is",
|
||||||
|
system_prompt="",
|
||||||
|
builtin=True
|
||||||
|
),
|
||||||
|
Profile(
|
||||||
|
name="Comedian",
|
||||||
|
description="Witty and humorous, but stays informative",
|
||||||
|
system_prompt="""You are a witty and humorous assistant who uses humor to make information more engaging and memorable. You're able to be serious when it matters, but you naturally inject levity and clever observations into your responses. You are an expert at explaining things clearly while keeping the reader entertained.""",
|
||||||
|
tags=["creative", "engaging"],
|
||||||
|
builtin=True
|
||||||
|
),
|
||||||
|
Profile(
|
||||||
|
name="Technical Writer",
|
||||||
|
description="Precise, structured, uses proper terminology",
|
||||||
|
system_prompt="""You are a professional technical writer who prioritizes clarity, precision, and proper structure. You use correct terminology, organize information logically with headers and bullet points when appropriate, and ensure accuracy in all technical details. You write documentation that is both comprehensive and accessible.""",
|
||||||
|
tags=["technical", "documentation"],
|
||||||
|
builtin=True
|
||||||
|
),
|
||||||
|
Profile(
|
||||||
|
name="Teacher",
|
||||||
|
description="Patient, explains step-by-step, uses analogies",
|
||||||
|
system_prompt="""You are a patient and experienced teacher who excels at breaking down complex topics into understandable parts. You explain concepts step-by-step, use relatable analogies and examples, and check for understanding. You adapt your explanations to the learner's level and never make them feel bad for not knowing something.""",
|
||||||
|
tags=["educational", "patient"],
|
||||||
|
builtin=True
|
||||||
|
),
|
||||||
|
Profile(
|
||||||
|
name="Concise",
|
||||||
|
description="Brief responses, bullet points, no fluff",
|
||||||
|
system_prompt="""You are a direct and efficient communicator who values brevity. You get straight to the point, use bullet points and short sentences, and avoid unnecessary filler words or lengthy explanations. Every word you write serves a purpose. You provide complete information in the most compact form possible.""",
|
||||||
|
tags=["brief", "efficient"],
|
||||||
|
builtin=True
|
||||||
|
),
|
||||||
|
Profile(
|
||||||
|
name="Creative",
|
||||||
|
description="Imaginative, thinks outside the box",
|
||||||
|
system_prompt="""You are a highly creative thinker who approaches problems from unexpected angles. You generate novel ideas, make unique connections between concepts, and aren't afraid to suggest unconventional solutions. You balance creativity with practicality, ensuring your ideas are both innovative and actionable.""",
|
||||||
|
tags=["creative", "innovative"],
|
||||||
|
builtin=True
|
||||||
|
),
|
||||||
|
Profile(
|
||||||
|
name="Code Reviewer",
|
||||||
|
description="Critical eye, suggests improvements, follows best practices",
|
||||||
|
system_prompt="""You are an experienced code reviewer with a keen eye for quality. You identify potential bugs, security issues, and performance problems. You suggest improvements based on best practices and design patterns. You explain the reasoning behind your suggestions and prioritize the most impactful changes. You're constructive, not harsh.""",
|
||||||
|
tags=["developer", "code"],
|
||||||
|
builtin=True
|
||||||
|
),
|
||||||
|
Profile(
|
||||||
|
name="Analyst",
|
||||||
|
description="Data-driven, objective, thorough analysis",
|
||||||
|
system_prompt="""You are a meticulous analyst who examines information thoroughly and objectively. You consider multiple perspectives, identify patterns and trends, and support your conclusions with evidence. You present findings in a clear, structured manner and acknowledge uncertainties or limitations in your analysis.""",
|
||||||
|
tags=["analytical", "objective"],
|
||||||
|
builtin=True
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_profiles_dir() -> Path:
|
||||||
|
"""Get the profiles directory path."""
|
||||||
|
profiles_dir = Path(get_config_dir()) / "profiles"
|
||||||
|
profiles_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return profiles_dir
|
||||||
|
|
||||||
|
|
||||||
|
def load_profile(name: str) -> Optional[Profile]:
|
||||||
|
"""Load a profile by name."""
|
||||||
|
# Check built-in profiles first
|
||||||
|
for profile in DEFAULT_PROFILES:
|
||||||
|
if profile.name.lower() == name.lower():
|
||||||
|
return profile
|
||||||
|
|
||||||
|
# Check user profiles
|
||||||
|
profile_path = get_profiles_dir() / f"{name}.yaml"
|
||||||
|
if profile_path.exists():
|
||||||
|
try:
|
||||||
|
with open(profile_path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
return Profile.from_dict(data)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_profile(profile: Profile) -> None:
|
||||||
|
"""Save a user profile."""
|
||||||
|
if profile.builtin:
|
||||||
|
raise ValueError("Cannot save built-in profiles")
|
||||||
|
|
||||||
|
profile_path = get_profiles_dir() / f"{profile.name}.yaml"
|
||||||
|
with open(profile_path, "w") as f:
|
||||||
|
yaml.safe_dump(profile.to_dict(), f, default_flow_style=False)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_profile(name: str) -> bool:
|
||||||
|
"""Delete a user profile."""
|
||||||
|
# Can't delete built-in profiles
|
||||||
|
for profile in DEFAULT_PROFILES:
|
||||||
|
if profile.name.lower() == name.lower():
|
||||||
|
return False
|
||||||
|
|
||||||
|
profile_path = get_profiles_dir() / f"{name}.yaml"
|
||||||
|
if profile_path.exists():
|
||||||
|
profile_path.unlink()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles() -> List[Profile]:
|
||||||
|
"""List all available profiles (built-in + user)."""
|
||||||
|
profiles = list(DEFAULT_PROFILES)
|
||||||
|
|
||||||
|
# Load user profiles
|
||||||
|
profiles_dir = get_profiles_dir()
|
||||||
|
if profiles_dir.exists():
|
||||||
|
for path in profiles_dir.glob("*.yaml"):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
profile = Profile.from_dict(data)
|
||||||
|
# Don't add duplicates of built-in names
|
||||||
|
if not any(p.name.lower() == profile.name.lower() for p in profiles):
|
||||||
|
profiles.append(profile)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_names() -> List[str]:
|
||||||
|
"""Get list of all profile names."""
|
||||||
|
return [p.name for p in list_profiles()]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Provider abstraction for AI CLI tools."""
|
"""Provider abstraction for AI CLI tools."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -11,7 +12,7 @@ import yaml
|
|||||||
|
|
||||||
|
|
||||||
# Default providers config location
|
# Default providers config location
|
||||||
PROVIDERS_FILE = Path.home() / ".smarttools" / "providers.yaml"
|
PROVIDERS_FILE = Path.home() / ".cmdforge" / "providers.yaml"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -160,21 +161,27 @@ def call_provider(provider_name: str, prompt: str, timeout: int = 300) -> Provid
|
|||||||
return ProviderResult(
|
return ProviderResult(
|
||||||
text="",
|
text="",
|
||||||
success=False,
|
success=False,
|
||||||
error=f"Provider '{provider_name}' not found. Use 'smarttools providers' to manage providers."
|
error=f"Provider '{provider_name}' not found. Use 'cmdforge providers' to manage providers."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse command (expand environment variables)
|
# Parse command (expand environment variables)
|
||||||
cmd = os.path.expandvars(provider.command)
|
cmd = os.path.expandvars(provider.command)
|
||||||
|
|
||||||
# Check if base command exists
|
# Check if base command exists (use shlex for proper quote handling)
|
||||||
base_cmd = cmd.split()[0]
|
try:
|
||||||
|
cmd_parts = shlex.split(cmd)
|
||||||
|
base_cmd = cmd_parts[0] if cmd_parts else cmd.split()[0]
|
||||||
|
except ValueError:
|
||||||
|
# shlex failed (unbalanced quotes, etc.) - fall back to simple split
|
||||||
|
base_cmd = cmd.split()[0]
|
||||||
|
|
||||||
# Expand ~ for the which check
|
# Expand ~ for the which check
|
||||||
base_cmd_expanded = os.path.expanduser(base_cmd)
|
base_cmd_expanded = os.path.expanduser(base_cmd)
|
||||||
if not shutil.which(base_cmd_expanded) and not os.path.isfile(base_cmd_expanded):
|
if not shutil.which(base_cmd_expanded) and not os.path.isfile(base_cmd_expanded):
|
||||||
return ProviderResult(
|
return ProviderResult(
|
||||||
text="",
|
text="",
|
||||||
success=False,
|
success=False,
|
||||||
error=f"Command '{base_cmd}' not found. Is it installed and in PATH?\n\nTo install AI providers, run: smarttools providers install"
|
error=f"Command '{base_cmd}' not found. Is it installed and in PATH?\n\nTo install AI providers, run: cmdforge providers install"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -190,7 +197,7 @@ def call_provider(provider_name: str, prompt: str, timeout: int = 300) -> Provid
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
error_msg = f"Provider exited with code {result.returncode}: {result.stderr}"
|
error_msg = f"Provider exited with code {result.returncode}: {result.stderr}"
|
||||||
if "not found" in result.stderr.lower() or "not installed" in result.stderr.lower():
|
if "not found" in result.stderr.lower() or "not installed" in result.stderr.lower():
|
||||||
error_msg += "\n\nTo install AI providers, run: smarttools providers install"
|
error_msg += "\n\nTo install AI providers, run: cmdforge providers install"
|
||||||
return ProviderResult(
|
return ProviderResult(
|
||||||
text="",
|
text="",
|
||||||
success=False,
|
success=False,
|
||||||
@@ -217,14 +224,14 @@ def call_provider(provider_name: str, prompt: str, timeout: int = 300) -> Provid
|
|||||||
f"To fix this, either:\n"
|
f"To fix this, either:\n"
|
||||||
f" 1. Run 'opencode' to connect the {provider_id} provider\n"
|
f" 1. Run 'opencode' to connect the {provider_id} provider\n"
|
||||||
f" 2. Use --provider to pick a different model (e.g., --provider opencode-pickle)\n"
|
f" 2. Use --provider to pick a different model (e.g., --provider opencode-pickle)\n"
|
||||||
f" 3. Run 'smarttools ui' to edit the tool's default provider"
|
f" 3. Run 'cmdforge ui' to edit the tool's default provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
stderr_hint = f" (stderr: {stderr[:200]}...)" if len(stderr) > 200 else (f" (stderr: {stderr})" if stderr else "")
|
stderr_hint = f" (stderr: {stderr[:200]}...)" if len(stderr) > 200 else (f" (stderr: {stderr})" if stderr else "")
|
||||||
return ProviderResult(
|
return ProviderResult(
|
||||||
text="",
|
text="",
|
||||||
success=False,
|
success=False,
|
||||||
error=f"Provider returned empty output{stderr_hint}.\n\nThis may mean the model is not available. Try a different provider or run: smarttools providers install"
|
error=f"Provider returned empty output{stderr_hint}.\n\nThis may mean the model is not available. Try a different provider or run: cmdforge providers install"
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProviderResult(text=result.stdout, success=True)
|
return ProviderResult(text=result.stdout, success=True)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Registry API server package."""
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
"""Category suggestion helpers for registry tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def load_categories(categories_path: Path) -> List[Dict]:
|
||||||
|
data = yaml.safe_load(categories_path.read_text(encoding="utf-8")) or {}
|
||||||
|
return data.get("categories", [])
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_categories(
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
tags: List[str],
|
||||||
|
categories_path: Path,
|
||||||
|
) -> List[Tuple[str, float]]:
|
||||||
|
"""Suggest categories ranked by confidence.
|
||||||
|
|
||||||
|
Uses keyword matching against name/description/tags.
|
||||||
|
Returns a list of (category_name, confidence).
|
||||||
|
"""
|
||||||
|
categories = load_categories(categories_path)
|
||||||
|
text = f"{name} {description} {' '.join(tags)}".lower()
|
||||||
|
suggestions: List[Tuple[str, float]] = []
|
||||||
|
|
||||||
|
for cat in categories:
|
||||||
|
cat_name = cat.get("name")
|
||||||
|
keywords = [str(k).lower() for k in cat.get("keywords", []) if k]
|
||||||
|
if not cat_name or not keywords:
|
||||||
|
continue
|
||||||
|
hits = sum(1 for k in keywords if k in text)
|
||||||
|
confidence = hits / max(len(keywords), 1)
|
||||||
|
if hits:
|
||||||
|
suggestions.append((cat_name, round(confidence, 3)))
|
||||||
|
|
||||||
|
suggestions.sort(key=lambda item: item[1], reverse=True)
|
||||||
|
return suggestions
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
"""SQLite storage and schema setup for the registry API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS publishers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
bio TEXT,
|
||||||
|
website TEXT,
|
||||||
|
verified BOOLEAN DEFAULT FALSE,
|
||||||
|
locked_until TIMESTAMP,
|
||||||
|
failed_login_attempts INTEGER DEFAULT 0,
|
||||||
|
role TEXT DEFAULT 'user',
|
||||||
|
banned INTEGER DEFAULT 0,
|
||||||
|
banned_at TIMESTAMP,
|
||||||
|
banned_by TEXT,
|
||||||
|
ban_reason TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
last_used_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revoked_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tools (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
config_yaml TEXT NOT NULL,
|
||||||
|
readme TEXT,
|
||||||
|
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
|
||||||
|
deprecated BOOLEAN DEFAULT FALSE,
|
||||||
|
deprecated_message TEXT,
|
||||||
|
replacement TEXT,
|
||||||
|
downloads INTEGER DEFAULT 0,
|
||||||
|
scrutiny_status TEXT DEFAULT 'pending',
|
||||||
|
scrutiny_report TEXT,
|
||||||
|
source TEXT,
|
||||||
|
source_url TEXT,
|
||||||
|
source_json TEXT,
|
||||||
|
config_hash TEXT,
|
||||||
|
visibility TEXT DEFAULT 'public',
|
||||||
|
moderation_status TEXT DEFAULT 'pending',
|
||||||
|
moderation_note TEXT,
|
||||||
|
moderated_by TEXT,
|
||||||
|
moderated_at TIMESTAMP,
|
||||||
|
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(owner, name, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS download_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tool_id INTEGER NOT NULL REFERENCES tools(id),
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
downloaded_at DATE NOT NULL,
|
||||||
|
UNIQUE(tool_id, client_id, downloaded_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS tools_fts USING fts5(
|
||||||
|
name, description, tags, readme,
|
||||||
|
content='tools',
|
||||||
|
content_rowid='id'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS tools_ai AFTER INSERT ON tools BEGIN
|
||||||
|
INSERT INTO tools_fts(rowid, name, description, tags, readme)
|
||||||
|
VALUES (new.id, new.name, new.description, new.tags, new.readme);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS tools_ad AFTER DELETE ON tools BEGIN
|
||||||
|
INSERT INTO tools_fts(tools_fts, rowid, name, description, tags, readme)
|
||||||
|
VALUES ('delete', old.id, old.name, old.description, old.tags, old.readme);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS tools_au AFTER UPDATE ON tools BEGIN
|
||||||
|
INSERT INTO tools_fts(tools_fts, rowid, name, description, tags, readme)
|
||||||
|
VALUES ('delete', old.id, old.name, old.description, old.tags, old.readme);
|
||||||
|
INSERT INTO tools_fts(rowid, name, description, tags, readme)
|
||||||
|
VALUES (new.id, new.name, new.description, new.tags, new.readme);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_prs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
pr_number INTEGER NOT NULL,
|
||||||
|
pr_url TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(owner, name, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webhook_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
delivery_id TEXT UNIQUE NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS web_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT UNIQUE NOT NULL,
|
||||||
|
publisher_id INTEGER REFERENCES publishers(id),
|
||||||
|
data TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tools_owner_name ON tools(owner, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tools_published_at ON tools(published_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tools_downloads ON tools(downloads DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tools_owner_name_version ON tools(owner, name, version);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tools_sort_stable ON tools(downloads DESC, published_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_publishers_slug ON publishers(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_publishers_email ON publishers(email);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_publisher ON api_tokens(publisher_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_web_sessions_id ON web_sessions(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions(expires_at);
|
||||||
|
|
||||||
|
-- Web UI tables (Phase 7)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS announcements (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
published BOOLEAN DEFAULT FALSE,
|
||||||
|
published_at TIMESTAMP,
|
||||||
|
created_by INTEGER REFERENCES publishers(id),
|
||||||
|
updated_by INTEGER REFERENCES publishers(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS featured_tools (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tool_id INTEGER NOT NULL REFERENCES tools(id),
|
||||||
|
placement TEXT NOT NULL DEFAULT 'homepage',
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
start_at TIMESTAMP,
|
||||||
|
end_at TIMESTAMP,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
created_by INTEGER REFERENCES publishers(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS featured_contributors (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
|
||||||
|
bio_override TEXT,
|
||||||
|
placement TEXT NOT NULL DEFAULT 'homepage',
|
||||||
|
start_at TIMESTAMP,
|
||||||
|
end_at TIMESTAMP,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
created_by INTEGER REFERENCES publishers(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tool_id INTEGER NOT NULL REFERENCES tools(id),
|
||||||
|
reporter_id INTEGER REFERENCES publishers(id),
|
||||||
|
reporter_ip TEXT,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
resolved_by INTEGER REFERENCES publishers(id),
|
||||||
|
resolved_at TIMESTAMP,
|
||||||
|
resolution_note TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_id TEXT NOT NULL,
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- App pairing for simplified connection flow
|
||||||
|
CREATE TABLE IF NOT EXISTS pairing_requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
|
||||||
|
pairing_code TEXT UNIQUE NOT NULL,
|
||||||
|
token_hash TEXT,
|
||||||
|
token_plain TEXT,
|
||||||
|
hostname TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
claimed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pairing_code ON pairing_requests(pairing_code, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pairing_publisher ON pairing_requests(publisher_id, status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS consents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_id TEXT,
|
||||||
|
publisher_id INTEGER REFERENCES publishers(id),
|
||||||
|
analytics_consent BOOLEAN DEFAULT FALSE,
|
||||||
|
ads_consent BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(client_id),
|
||||||
|
UNIQUE(publisher_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'doc',
|
||||||
|
body TEXT,
|
||||||
|
published BOOLEAN DEFAULT FALSE,
|
||||||
|
published_at TIMESTAMP,
|
||||||
|
created_by INTEGER REFERENCES publishers(id),
|
||||||
|
updated_by INTEGER REFERENCES publishers(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_announcements_published ON announcements(published, published_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_featured_tools_placement ON featured_tools(placement, status, priority DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_featured_contributors_placement ON featured_contributors(placement, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_pages_type ON content_pages(content_type, published);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||||
|
-- Note: idx_tools_moderation, idx_publishers_role, idx_publishers_banned are created
|
||||||
|
-- in migrate_db() to support upgrading existing databases without these columns
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pageviews (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
referrer TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_hash TEXT,
|
||||||
|
viewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pageviews_path ON pageviews(path, viewed_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pageviews_date ON pageviews(date(viewed_at), path);
|
||||||
|
|
||||||
|
-- Collections (groups of tools that can be installed together)
|
||||||
|
CREATE TABLE IF NOT EXISTS 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,
|
||||||
|
pinned TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collections_name ON collections(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collections_maintainer ON collections(maintainer);
|
||||||
|
|
||||||
|
-- Reviews and Ratings System
|
||||||
|
CREATE TABLE IF NOT EXISTS reviews (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
|
||||||
|
reviewer_id INTEGER NOT NULL REFERENCES publishers(id),
|
||||||
|
rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5),
|
||||||
|
title TEXT,
|
||||||
|
content TEXT,
|
||||||
|
helpful_count INTEGER DEFAULT 0,
|
||||||
|
unhelpful_count INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'published',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(tool_id, reviewer_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reviews_tool ON reviews(tool_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reviews_reviewer ON reviews(reviewer_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS review_votes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||||
|
voter_id TEXT NOT NULL,
|
||||||
|
vote_type TEXT NOT NULL CHECK(vote_type IN ('helpful', 'unhelpful')),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(review_id, voter_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_review_votes_review ON review_votes(review_id);
|
||||||
|
|
||||||
|
-- Issue Tracking (bugs, security, compatibility)
|
||||||
|
CREATE TABLE IF NOT EXISTS tool_issues (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
|
||||||
|
reporter_id INTEGER REFERENCES publishers(id),
|
||||||
|
issue_type TEXT NOT NULL CHECK(issue_type IN ('bug', 'security', 'compatibility')),
|
||||||
|
severity TEXT DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT DEFAULT 'open' CHECK(status IN ('open', 'confirmed', 'fixed', 'wontfix', 'duplicate')),
|
||||||
|
resolved_by INTEGER REFERENCES publishers(id),
|
||||||
|
resolved_at TIMESTAMP,
|
||||||
|
resolution_note TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issues_tool ON tool_issues(tool_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issues_type ON tool_issues(issue_type, severity);
|
||||||
|
|
||||||
|
-- Tool Stats Cache (for fast queries)
|
||||||
|
CREATE TABLE IF NOT EXISTS tool_stats (
|
||||||
|
tool_id INTEGER PRIMARY KEY REFERENCES tools(id) ON DELETE CASCADE,
|
||||||
|
average_rating REAL DEFAULT 0,
|
||||||
|
rating_count INTEGER DEFAULT 0,
|
||||||
|
rating_1 INTEGER DEFAULT 0,
|
||||||
|
rating_2 INTEGER DEFAULT 0,
|
||||||
|
rating_3 INTEGER DEFAULT 0,
|
||||||
|
rating_4 INTEGER DEFAULT 0,
|
||||||
|
rating_5 INTEGER DEFAULT 0,
|
||||||
|
unique_users INTEGER DEFAULT 0,
|
||||||
|
open_issues INTEGER DEFAULT 0,
|
||||||
|
security_issues INTEGER DEFAULT 0,
|
||||||
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Publisher Stats Cache (for fast queries)
|
||||||
|
CREATE TABLE IF NOT EXISTS publisher_stats (
|
||||||
|
publisher_id INTEGER PRIMARY KEY REFERENCES publishers(id) ON DELETE CASCADE,
|
||||||
|
tool_count INTEGER DEFAULT 0,
|
||||||
|
total_downloads INTEGER DEFAULT 0,
|
||||||
|
average_rating REAL DEFAULT 0,
|
||||||
|
total_reviews INTEGER DEFAULT 0,
|
||||||
|
trust_score REAL DEFAULT 0,
|
||||||
|
badges TEXT,
|
||||||
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tool Usage Tracking (unique users)
|
||||||
|
CREATE TABLE IF NOT EXISTS tool_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
|
||||||
|
user_hash TEXT NOT NULL,
|
||||||
|
first_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
use_count INTEGER DEFAULT 1,
|
||||||
|
UNIQUE(tool_id, user_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_tool ON tool_usage(tool_id);
|
||||||
|
|
||||||
|
-- Admin Configuration Settings
|
||||||
|
CREATE TABLE IF NOT EXISTS registry_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
value_type TEXT DEFAULT 'string',
|
||||||
|
description TEXT,
|
||||||
|
category TEXT DEFAULT 'general',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_settings_category ON registry_settings(category);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_path() -> Path:
|
||||||
|
default_path = Path.home() / ".cmdforge" / "registry" / "registry.db"
|
||||||
|
return Path(os.environ.get("CMDFORGE_REGISTRY_DB", default_path))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_db_directory(path: Path) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def connect_db(path: Path | None = None) -> sqlite3.Connection:
|
||||||
|
db_path = path or get_db_path()
|
||||||
|
ensure_db_directory(db_path)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
# WAL mode provides better concurrency but may not work on all filesystems
|
||||||
|
# (e.g., some FUSE filesystems like mergerfs). Fall back gracefully.
|
||||||
|
try:
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Fall back to DELETE journal mode which works everywhere
|
||||||
|
conn.execute("PRAGMA journal_mode=DELETE;")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON;")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
conn.commit()
|
||||||
|
# Run migrations for existing databases
|
||||||
|
migrate_db(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_db(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Add missing columns to existing tables (for schema updates)."""
|
||||||
|
# Get existing columns in tools table
|
||||||
|
cursor = conn.execute("PRAGMA table_info(tools)")
|
||||||
|
tools_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
# Get existing columns in publishers table
|
||||||
|
cursor = conn.execute("PRAGMA table_info(publishers)")
|
||||||
|
publishers_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
# Tools table migrations (column_name, type, default)
|
||||||
|
tools_migrations = [
|
||||||
|
("scrutiny_status", "TEXT", "'pending'"),
|
||||||
|
("scrutiny_report", "TEXT", "NULL"),
|
||||||
|
("source", "TEXT", "NULL"),
|
||||||
|
("source_url", "TEXT", "NULL"),
|
||||||
|
("source_json", "TEXT", "NULL"),
|
||||||
|
("config_hash", "TEXT", "NULL"),
|
||||||
|
("visibility", "TEXT", "'public'"),
|
||||||
|
("moderation_status", "TEXT", "'pending'"),
|
||||||
|
("moderation_note", "TEXT", "NULL"),
|
||||||
|
("moderated_by", "TEXT", "NULL"),
|
||||||
|
("moderated_at", "TIMESTAMP", "NULL"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_name, col_type, default in tools_migrations:
|
||||||
|
if col_name not in tools_cols:
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE tools ADD COLUMN {col_name} {col_type} DEFAULT {default}")
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column might already exist or other issue
|
||||||
|
|
||||||
|
# Publishers table migrations
|
||||||
|
publishers_migrations = [
|
||||||
|
("role", "TEXT", "'user'"),
|
||||||
|
("banned", "INTEGER", "0"),
|
||||||
|
("banned_at", "TIMESTAMP", "NULL"),
|
||||||
|
("banned_by", "TEXT", "NULL"),
|
||||||
|
("ban_reason", "TEXT", "NULL"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_name, col_type, default in publishers_migrations:
|
||||||
|
if col_name not in publishers_cols:
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE publishers ADD COLUMN {col_name} {col_type} DEFAULT {default}")
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# API tokens table migrations (for Connected Apps feature)
|
||||||
|
cursor = conn.execute("PRAGMA table_info(api_tokens)")
|
||||||
|
tokens_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
tokens_migrations = [
|
||||||
|
("hostname", "TEXT", "NULL"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_name, col_type, default in tokens_migrations:
|
||||||
|
if col_name not in tokens_cols:
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE api_tokens ADD COLUMN {col_name} {col_type} DEFAULT {default}")
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL
|
||||||
|
# This ensures existing tools remain visible after migration (one-time migration)
|
||||||
|
# Note: Only applies to NULL, NOT to 'pending' - pending tools need manual review
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE tools
|
||||||
|
SET moderation_status = 'approved'
|
||||||
|
WHERE moderation_status IS NULL
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ensure indexes exist
|
||||||
|
try:
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_moderation ON tools(moderation_status, visibility)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_hash ON tools(config_hash)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_role ON publishers(role)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_banned ON publishers(banned)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)")
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def query_one(conn: sqlite3.Connection, sql: str, params: Iterable | None = None):
|
||||||
|
cur = conn.execute(sql, params or [])
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def query_all(conn: sqlite3.Connection, sql: str, params: Iterable | None = None):
|
||||||
|
cur = conn.execute(sql, params or [])
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def execute(conn: sqlite3.Connection, sql: str, params: Iterable | None = None) -> None:
|
||||||
|
conn.execute(sql, params or [])
|
||||||
|
conn.commit()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""In-memory rate limiting for the registry API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RateLimitState:
|
||||||
|
reset_at: float
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._state: Dict[Tuple[str, str], RateLimitState] = {}
|
||||||
|
|
||||||
|
def check(self, scope_key: str, limit: int, window_seconds: int) -> Tuple[bool, RateLimitState]:
|
||||||
|
now = time.time()
|
||||||
|
key = (scope_key, str(window_seconds))
|
||||||
|
state = self._state.get(key)
|
||||||
|
if not state or now >= state.reset_at:
|
||||||
|
state = RateLimitState(reset_at=now + window_seconds, count=0)
|
||||||
|
self._state[key] = state
|
||||||
|
state.count += 1
|
||||||
|
allowed = state.count <= limit
|
||||||
|
return allowed, state
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
"""Tool scrutiny system for automated review of published tools.
|
||||||
|
|
||||||
|
This module analyzes tools on publish to verify:
|
||||||
|
1. Honesty - Does the tool do what its description claims?
|
||||||
|
2. Transparency - Is behavior visible or hidden/obfuscated?
|
||||||
|
3. Scope - Does the code stay within expected bounds?
|
||||||
|
4. Efficiency - Are AI calls necessary or wasteful?
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class CheckResult(Enum):
|
||||||
|
PASS = "pass"
|
||||||
|
WARNING = "warning"
|
||||||
|
FAIL = "fail"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Finding:
|
||||||
|
"""A single finding from scrutiny analysis."""
|
||||||
|
check: str
|
||||||
|
result: CheckResult
|
||||||
|
message: str
|
||||||
|
suggestion: Optional[str] = None
|
||||||
|
location: Optional[str] = None # e.g., "step 2", "code block"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScrutinyReport:
|
||||||
|
"""Complete scrutiny report for a tool."""
|
||||||
|
tool_name: str
|
||||||
|
findings: List[Finding] = field(default_factory=list)
|
||||||
|
optimizations: List[Any] = field(default_factory=list) # OptimizationSuggestion list
|
||||||
|
|
||||||
|
@property
|
||||||
|
def passed(self) -> bool:
|
||||||
|
"""Tool passes if no FAIL results."""
|
||||||
|
return not any(f.result == CheckResult.FAIL for f in self.findings)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_warnings(self) -> bool:
|
||||||
|
return any(f.result == CheckResult.WARNING for f in self.findings)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def decision(self) -> str:
|
||||||
|
"""Auto-approve, flag for review, or reject."""
|
||||||
|
if any(f.result == CheckResult.FAIL for f in self.findings):
|
||||||
|
return "reject"
|
||||||
|
if any(f.result == CheckResult.WARNING for f in self.findings):
|
||||||
|
return "review"
|
||||||
|
return "approve"
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
result = {
|
||||||
|
"tool_name": self.tool_name,
|
||||||
|
"decision": self.decision,
|
||||||
|
"passed": self.passed,
|
||||||
|
"has_warnings": self.has_warnings,
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"check": f.check,
|
||||||
|
"result": f.result.value,
|
||||||
|
"message": f.message,
|
||||||
|
"suggestion": f.suggestion,
|
||||||
|
"location": f.location,
|
||||||
|
}
|
||||||
|
for f in self.findings
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add optimization suggestions if any
|
||||||
|
if self.optimizations:
|
||||||
|
result["optimizations"] = [
|
||||||
|
{
|
||||||
|
"operation": opt.operation,
|
||||||
|
"current_prompt": opt.current_prompt,
|
||||||
|
"optimized_code": opt.optimized_code,
|
||||||
|
"tradeoffs": opt.tradeoffs,
|
||||||
|
"location": opt.location,
|
||||||
|
"action": "optional",
|
||||||
|
}
|
||||||
|
for opt in self.optimizations
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Keywords that indicate specific behaviors
|
||||||
|
BEHAVIOR_KEYWORDS = {
|
||||||
|
"summarize": ["summary", "summarize", "condense", "brief", "shorten", "tldr"],
|
||||||
|
"translate": ["translate", "translation", "language", "convert to"],
|
||||||
|
"explain": ["explain", "clarify", "describe", "what is", "how does"],
|
||||||
|
"fix": ["fix", "correct", "repair", "grammar", "spelling"],
|
||||||
|
"generate": ["generate", "create", "write", "produce", "make"],
|
||||||
|
"extract": ["extract", "pull", "find", "identify", "parse"],
|
||||||
|
"analyze": ["analyze", "review", "examine", "check", "audit"],
|
||||||
|
"convert": ["convert", "transform", "format", "change to"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Patterns that suggest obfuscation or suspicious behavior
|
||||||
|
SUSPICIOUS_PATTERNS = [
|
||||||
|
(r'base64\.(b64decode|decodebytes)', "Base64 decoding detected"),
|
||||||
|
(r'exec\s*\(', "Dynamic code execution with exec()"),
|
||||||
|
(r'eval\s*\(', "Dynamic code execution with eval()"),
|
||||||
|
(r'__import__\s*\(', "Dynamic import detected"),
|
||||||
|
(r'subprocess\.(run|call|Popen)', "Subprocess execution detected"),
|
||||||
|
(r'os\.(system|popen|exec)', "OS command execution detected"),
|
||||||
|
(r'requests\.(get|post|put|delete)', "Network request detected"),
|
||||||
|
(r'urllib', "URL library usage detected"),
|
||||||
|
(r'socket\.', "Raw socket usage detected"),
|
||||||
|
(r'\\x[0-9a-fA-F]{2}', "Hex-encoded strings detected"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# File/system access patterns
|
||||||
|
SCOPE_PATTERNS = [
|
||||||
|
(r'open\s*\([^)]*["\']/', "Absolute path file access"),
|
||||||
|
(r'open\s*\([^)]*~', "Home directory file access"),
|
||||||
|
(r'\.ssh', "SSH directory access"),
|
||||||
|
(r'\.aws', "AWS credentials access"),
|
||||||
|
(r'\.env', "Environment file access"),
|
||||||
|
(r'/etc/', "System config access"),
|
||||||
|
(r'os\.environ', "Environment variable access"),
|
||||||
|
(r'keyring|password|credential', "Credential-related access"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Operations that could be done with code (with tradeoffs)
|
||||||
|
# Format: (prompt_pattern, operation_name, code_template, code_benefit, ai_benefit)
|
||||||
|
CODE_OPTIMIZATIONS = [
|
||||||
|
(
|
||||||
|
r'count (the |how many )?words',
|
||||||
|
"Word counting",
|
||||||
|
'word_count = len(input.split())',
|
||||||
|
"Faster, no API cost, deterministic",
|
||||||
|
"Handles edge cases (hyphenated words, contractions, numbers as words)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'count (the |how many )?(lines|line)',
|
||||||
|
"Line counting",
|
||||||
|
'line_count = len(input.strip().split("\\n"))',
|
||||||
|
"Instant, no API cost",
|
||||||
|
"Could interpret 'meaningful lines' vs blank lines",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'count (the |how many )?(characters?|chars?)',
|
||||||
|
"Character counting",
|
||||||
|
'char_count = len(input)',
|
||||||
|
"Instant, no API cost",
|
||||||
|
"Could exclude whitespace or count 'visible' characters",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'convert.*(to|into) json',
|
||||||
|
"JSON conversion",
|
||||||
|
'import json\nresult = json.dumps(data, indent=2)',
|
||||||
|
"Reliable, no API cost, handles valid input perfectly",
|
||||||
|
"Can interpret messy/partial data and fix formatting issues",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'parse.*(json|the json)',
|
||||||
|
"JSON parsing",
|
||||||
|
'import json\nresult = json.loads(input)',
|
||||||
|
"Fast, no API cost, strict validation",
|
||||||
|
"Can fix malformed JSON, handle comments, trailing commas",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'convert.*(to|into) csv',
|
||||||
|
"CSV conversion",
|
||||||
|
'import csv\nimport io\n# ... csv.writer conversion',
|
||||||
|
"Reliable for structured data",
|
||||||
|
"Can infer structure from unstructured text",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(extract|find|get) (all )?(emails?|email addresses)',
|
||||||
|
"Email extraction",
|
||||||
|
'import re\nemails = re.findall(r"[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}", input)',
|
||||||
|
"Fast, consistent pattern matching",
|
||||||
|
"Handles obfuscated emails (user [at] domain), context-aware",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(extract|find|get) (all )?(urls?|links?)',
|
||||||
|
"URL extraction",
|
||||||
|
'import re\nurls = re.findall(r"https?://[^\\s<>\"]+", input)',
|
||||||
|
"Fast, consistent pattern matching",
|
||||||
|
"Handles partial URLs, context-aware link detection",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(uppercase|to upper|make upper)',
|
||||||
|
"Uppercase conversion",
|
||||||
|
'result = input.upper()',
|
||||||
|
"Instant, no API cost",
|
||||||
|
"Language-aware (Turkish İ/i, Greek σ/ς edge cases)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(lowercase|to lower|make lower)',
|
||||||
|
"Lowercase conversion",
|
||||||
|
'result = input.lower()',
|
||||||
|
"Instant, no API cost",
|
||||||
|
"Language-aware case handling",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(capitalize|title case)',
|
||||||
|
"Title case conversion",
|
||||||
|
'result = input.title()',
|
||||||
|
"Instant, no API cost",
|
||||||
|
"Knows style rules (articles, prepositions)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(reverse|reversed?) (the |this )?(text|string|order)',
|
||||||
|
"Text reversal",
|
||||||
|
'result = input[::-1]',
|
||||||
|
"Instant, no API cost",
|
||||||
|
"Could reverse by word, sentence, or paragraph intelligently",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(sort|sorted?) (the |these |this )?(lines?|items?|list)',
|
||||||
|
"Sorting",
|
||||||
|
'result = "\\n".join(sorted(input.strip().split("\\n")))',
|
||||||
|
"Fast, deterministic alphabetical sort",
|
||||||
|
"Can sort by meaning ('sort by importance', 'by date mentioned')",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'remove (duplicate|duplicated?) (lines?|items?)',
|
||||||
|
"Deduplication",
|
||||||
|
'seen = set()\nresult = "\\n".join(x for x in input.split("\\n") if not (x in seen or seen.add(x)))',
|
||||||
|
"Fast, exact matching",
|
||||||
|
"Can detect semantic duplicates ('USA' vs 'United States')",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'(remove|strip|trim) (whitespace|spaces|blank)',
|
||||||
|
"Whitespace removal",
|
||||||
|
'result = " ".join(input.split())',
|
||||||
|
"Instant, predictable",
|
||||||
|
"Could preserve meaningful spacing, format-aware",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r'split (by|on|into)',
|
||||||
|
"Text splitting",
|
||||||
|
'# result = input.split(delimiter)',
|
||||||
|
"Fast, exact delimiter matching",
|
||||||
|
"Can split by meaning ('split into paragraphs about each topic')",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_tool(config_yaml: str, description: str) -> ScrutinyReport:
|
||||||
|
"""Run full scrutiny analysis on a tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_yaml: The tool's YAML configuration
|
||||||
|
description: The tool's description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScrutinyReport with all findings
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = yaml.safe_load(config_yaml) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return ScrutinyReport(
|
||||||
|
tool_name="unknown",
|
||||||
|
findings=[Finding(
|
||||||
|
check="parse",
|
||||||
|
result=CheckResult.FAIL,
|
||||||
|
message="Invalid YAML configuration",
|
||||||
|
)]
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_name = config.get("name", "unknown")
|
||||||
|
report = ScrutinyReport(tool_name=tool_name)
|
||||||
|
|
||||||
|
# Run all checks
|
||||||
|
report.findings.extend(check_honesty(config, description))
|
||||||
|
report.findings.extend(check_transparency(config))
|
||||||
|
report.findings.extend(check_scope(config))
|
||||||
|
|
||||||
|
# Efficiency check returns findings and optimizations
|
||||||
|
efficiency_findings, optimizations = check_efficiency(config)
|
||||||
|
report.findings.extend(efficiency_findings)
|
||||||
|
report.optimizations = optimizations
|
||||||
|
|
||||||
|
# If no findings, add a pass
|
||||||
|
if not report.findings:
|
||||||
|
report.findings.append(Finding(
|
||||||
|
check="overall",
|
||||||
|
result=CheckResult.PASS,
|
||||||
|
message="All checks passed",
|
||||||
|
))
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def check_honesty(config: Dict[str, Any], description: str) -> List[Finding]:
|
||||||
|
"""Check if tool behavior matches its description.
|
||||||
|
|
||||||
|
Analyzes prompts and compares against description claims.
|
||||||
|
"""
|
||||||
|
findings = []
|
||||||
|
steps = config.get("steps", [])
|
||||||
|
description_lower = description.lower()
|
||||||
|
|
||||||
|
# Extract all prompt text
|
||||||
|
all_prompts = ""
|
||||||
|
for step in steps:
|
||||||
|
if step.get("type") == "prompt":
|
||||||
|
all_prompts += " " + (step.get("prompt") or "").lower()
|
||||||
|
|
||||||
|
# Detect what the description claims
|
||||||
|
claimed_behaviors = set()
|
||||||
|
for behavior, keywords in BEHAVIOR_KEYWORDS.items():
|
||||||
|
if any(kw in description_lower for kw in keywords):
|
||||||
|
claimed_behaviors.add(behavior)
|
||||||
|
|
||||||
|
# Detect what prompts actually do
|
||||||
|
actual_behaviors = set()
|
||||||
|
for behavior, keywords in BEHAVIOR_KEYWORDS.items():
|
||||||
|
if any(kw in all_prompts for kw in keywords):
|
||||||
|
actual_behaviors.add(behavior)
|
||||||
|
|
||||||
|
# Check for mismatches
|
||||||
|
claimed_not_done = claimed_behaviors - actual_behaviors
|
||||||
|
done_not_claimed = actual_behaviors - claimed_behaviors
|
||||||
|
|
||||||
|
if claimed_not_done:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="honesty",
|
||||||
|
result=CheckResult.WARNING,
|
||||||
|
message=f"Description claims '{', '.join(claimed_not_done)}' but prompts don't reflect this",
|
||||||
|
suggestion="Update description to accurately reflect tool behavior",
|
||||||
|
))
|
||||||
|
|
||||||
|
if done_not_claimed and claimed_behaviors:
|
||||||
|
# Only warn if tool claims to do something specific but also does other things
|
||||||
|
findings.append(Finding(
|
||||||
|
check="honesty",
|
||||||
|
result=CheckResult.WARNING,
|
||||||
|
message=f"Prompts include '{', '.join(done_not_claimed)}' not mentioned in description",
|
||||||
|
suggestion="Update description to include all tool capabilities",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for empty or minimal prompts
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
if step.get("type") == "prompt":
|
||||||
|
prompt = step.get("prompt", "").strip()
|
||||||
|
if len(prompt) < 10:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="honesty",
|
||||||
|
result=CheckResult.WARNING,
|
||||||
|
message="Prompt is unusually short",
|
||||||
|
location=f"step {i + 1}",
|
||||||
|
))
|
||||||
|
|
||||||
|
if not findings:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="honesty",
|
||||||
|
result=CheckResult.PASS,
|
||||||
|
message="Description matches observed behavior",
|
||||||
|
))
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def check_transparency(config: Dict[str, Any]) -> List[Finding]:
|
||||||
|
"""Check for obfuscated or hidden behavior.
|
||||||
|
|
||||||
|
Looks for encoded strings, dynamic execution, etc.
|
||||||
|
"""
|
||||||
|
findings = []
|
||||||
|
steps = config.get("steps", [])
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
if step.get("type") == "code":
|
||||||
|
code = step.get("code", "")
|
||||||
|
location = f"step {i + 1}"
|
||||||
|
|
||||||
|
# Check for suspicious patterns
|
||||||
|
for pattern, description in SUSPICIOUS_PATTERNS:
|
||||||
|
if re.search(pattern, code, re.IGNORECASE):
|
||||||
|
# Determine severity
|
||||||
|
if "exec" in pattern or "eval" in pattern:
|
||||||
|
result = CheckResult.FAIL
|
||||||
|
msg = f"{description} - potential code injection risk"
|
||||||
|
elif "socket" in pattern or "subprocess" in pattern:
|
||||||
|
result = CheckResult.WARNING
|
||||||
|
msg = f"{description} - flagged for manual review"
|
||||||
|
else:
|
||||||
|
result = CheckResult.WARNING
|
||||||
|
msg = description
|
||||||
|
|
||||||
|
findings.append(Finding(
|
||||||
|
check="transparency",
|
||||||
|
result=result,
|
||||||
|
message=msg,
|
||||||
|
location=location,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for obfuscated variable names
|
||||||
|
obfuscated = re.findall(r'\b([a-z])\1{2,}\b|\b[a-z]{1,2}\d+\b', code)
|
||||||
|
if len(obfuscated) > 3:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="transparency",
|
||||||
|
result=CheckResult.WARNING,
|
||||||
|
message="Multiple obfuscated variable names detected",
|
||||||
|
location=location,
|
||||||
|
suggestion="Use descriptive variable names",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for very long single lines (possible obfuscation)
|
||||||
|
for line_num, line in enumerate(code.split('\n'), 1):
|
||||||
|
if len(line) > 500:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="transparency",
|
||||||
|
result=CheckResult.WARNING,
|
||||||
|
message=f"Unusually long code line ({len(line)} chars)",
|
||||||
|
location=f"{location}, line {line_num}",
|
||||||
|
))
|
||||||
|
|
||||||
|
if not findings:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="transparency",
|
||||||
|
result=CheckResult.PASS,
|
||||||
|
message="No obfuscation detected",
|
||||||
|
))
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def check_scope(config: Dict[str, Any]) -> List[Finding]:
|
||||||
|
"""Check if code accesses unexpected resources.
|
||||||
|
|
||||||
|
Flags file system, network, or credential access.
|
||||||
|
"""
|
||||||
|
findings = []
|
||||||
|
steps = config.get("steps", [])
|
||||||
|
tool_name = config.get("name", "").lower()
|
||||||
|
description = config.get("description", "").lower()
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
if step.get("type") == "code":
|
||||||
|
code = step.get("code", "")
|
||||||
|
location = f"step {i + 1}"
|
||||||
|
|
||||||
|
for pattern, description_text in SCOPE_PATTERNS:
|
||||||
|
if re.search(pattern, code, re.IGNORECASE):
|
||||||
|
# Check if this access seems expected based on tool name/description
|
||||||
|
is_expected = False
|
||||||
|
if "ssh" in pattern and ("ssh" in tool_name or "ssh" in description):
|
||||||
|
is_expected = True
|
||||||
|
if "env" in pattern and ("env" in tool_name or "environment" in description):
|
||||||
|
is_expected = True
|
||||||
|
if "file" in description_text.lower() and ("file" in tool_name or "file" in description):
|
||||||
|
is_expected = True
|
||||||
|
|
||||||
|
if not is_expected:
|
||||||
|
# Credential access is always flagged
|
||||||
|
if "credential" in pattern or ".ssh" in pattern or ".aws" in pattern:
|
||||||
|
result = CheckResult.FAIL
|
||||||
|
suggestion = "Remove credential access or justify in description"
|
||||||
|
else:
|
||||||
|
result = CheckResult.WARNING
|
||||||
|
suggestion = "Document this access in tool description"
|
||||||
|
|
||||||
|
findings.append(Finding(
|
||||||
|
check="scope",
|
||||||
|
result=result,
|
||||||
|
message=description_text,
|
||||||
|
location=location,
|
||||||
|
suggestion=suggestion,
|
||||||
|
))
|
||||||
|
|
||||||
|
if not findings:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="scope",
|
||||||
|
result=CheckResult.PASS,
|
||||||
|
message="No unexpected resource access",
|
||||||
|
))
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OptimizationSuggestion:
|
||||||
|
"""A code optimization suggestion for an AI prompt."""
|
||||||
|
operation: str
|
||||||
|
current_prompt: str
|
||||||
|
optimized_code: str
|
||||||
|
tradeoffs: Dict[str, str]
|
||||||
|
location: str
|
||||||
|
|
||||||
|
|
||||||
|
def check_efficiency(config: Dict[str, Any]) -> List[Finding]:
|
||||||
|
"""Check for AI calls that could optionally be pure code.
|
||||||
|
|
||||||
|
Generates optimization suggestions with tradeoffs - these are
|
||||||
|
informational, not penalties. Authors can choose to keep AI
|
||||||
|
for valid reasons.
|
||||||
|
"""
|
||||||
|
findings = []
|
||||||
|
steps = config.get("steps", [])
|
||||||
|
optimizations = []
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
if step.get("type") == "prompt":
|
||||||
|
prompt = step.get("prompt", "")
|
||||||
|
prompt_lower = prompt.lower()
|
||||||
|
location = f"step {i + 1}"
|
||||||
|
|
||||||
|
# Check against known optimizable patterns
|
||||||
|
for pattern, operation, code, code_benefit, ai_benefit in CODE_OPTIMIZATIONS:
|
||||||
|
if re.search(pattern, prompt_lower):
|
||||||
|
optimizations.append(OptimizationSuggestion(
|
||||||
|
operation=operation,
|
||||||
|
current_prompt=prompt.strip()[:200], # Truncate for display
|
||||||
|
optimized_code=code,
|
||||||
|
tradeoffs={
|
||||||
|
"code": code_benefit,
|
||||||
|
"ai": ai_benefit,
|
||||||
|
},
|
||||||
|
location=location,
|
||||||
|
))
|
||||||
|
break # One suggestion per step
|
||||||
|
|
||||||
|
# Add optimization suggestions as findings (not warnings - optional)
|
||||||
|
for opt in optimizations:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="efficiency",
|
||||||
|
result=CheckResult.PASS, # Not a warning - it's a suggestion
|
||||||
|
message=f"Optional optimization: {opt.operation} could use code",
|
||||||
|
location=opt.location,
|
||||||
|
suggestion=f"Code: {opt.optimized_code}",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for all-AI tools (informational, not a warning)
|
||||||
|
code_steps = [s for s in steps if s.get("type") == "code"]
|
||||||
|
prompt_steps = [s for s in steps if s.get("type") == "prompt"]
|
||||||
|
|
||||||
|
if prompt_steps and not code_steps and len(prompt_steps) > 3:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="efficiency",
|
||||||
|
result=CheckResult.PASS, # Informational
|
||||||
|
message=f"Tool has {len(prompt_steps)} AI steps - consider if any could be code",
|
||||||
|
suggestion="Mixing AI and code steps can reduce API costs",
|
||||||
|
))
|
||||||
|
|
||||||
|
if not findings:
|
||||||
|
findings.append(Finding(
|
||||||
|
check="efficiency",
|
||||||
|
result=CheckResult.PASS,
|
||||||
|
message="AI usage appears appropriate",
|
||||||
|
))
|
||||||
|
|
||||||
|
return findings, optimizations
|
||||||
|
|
||||||
|
|
||||||
|
def get_optimization_details(optimizations: List[OptimizationSuggestion]) -> List[Dict[str, Any]]:
|
||||||
|
"""Convert optimization suggestions to serializable format."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"operation": opt.operation,
|
||||||
|
"current_prompt": opt.current_prompt,
|
||||||
|
"optimized_code": opt.optimized_code,
|
||||||
|
"tradeoffs": opt.tradeoffs,
|
||||||
|
"location": opt.location,
|
||||||
|
"action": "optional", # Author chooses
|
||||||
|
}
|
||||||
|
for opt in optimizations
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def scrutinize_tool(config_yaml: str, description: str, readme: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Main entry point for tool scrutiny.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_yaml: The tool's YAML configuration
|
||||||
|
description: The tool's description
|
||||||
|
readme: Optional README content (for additional context)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with scrutiny results
|
||||||
|
"""
|
||||||
|
report = analyze_tool(config_yaml, description)
|
||||||
|
return report.to_dict()
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
"""Registry settings management.
|
||||||
|
|
||||||
|
Provides configurable settings for vetting, moderation, and sync.
|
||||||
|
Settings are stored in the database and can be modified via admin API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .db import query_one, query_all
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Setting:
|
||||||
|
"""A single configuration setting."""
|
||||||
|
key: str
|
||||||
|
value: Any
|
||||||
|
value_type: str # string, int, float, bool, json
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
updated_by: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Default settings with descriptions
|
||||||
|
DEFAULT_SETTINGS: List[Setting] = [
|
||||||
|
# Vetting thresholds
|
||||||
|
Setting(
|
||||||
|
key="vetting.approve_threshold",
|
||||||
|
value=0.8,
|
||||||
|
value_type="float",
|
||||||
|
description="Quality score threshold for auto-approval (0.0-1.0)",
|
||||||
|
category="vetting",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="vetting.reject_threshold",
|
||||||
|
value=0.3,
|
||||||
|
value_type="float",
|
||||||
|
description="Quality score threshold for auto-rejection (0.0-1.0)",
|
||||||
|
category="vetting",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Vetting weights
|
||||||
|
Setting(
|
||||||
|
key="vetting.weight.honesty",
|
||||||
|
value=0.25,
|
||||||
|
value_type="float",
|
||||||
|
description="Weight for honesty check (description matches behavior)",
|
||||||
|
category="vetting",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="vetting.weight.transparency",
|
||||||
|
value=0.20,
|
||||||
|
value_type="float",
|
||||||
|
description="Weight for transparency check (no obfuscation)",
|
||||||
|
category="vetting",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="vetting.weight.scope",
|
||||||
|
value=0.15,
|
||||||
|
value_type="float",
|
||||||
|
description="Weight for scope check (appropriate boundaries)",
|
||||||
|
category="vetting",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="vetting.weight.efficiency",
|
||||||
|
value=0.15,
|
||||||
|
value_type="float",
|
||||||
|
description="Weight for efficiency check (prompt quality)",
|
||||||
|
category="vetting",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="vetting.weight.safety",
|
||||||
|
value=0.25,
|
||||||
|
value_type="float",
|
||||||
|
description="Weight for safety check (no dangerous patterns)",
|
||||||
|
category="vetting",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Similarity detection
|
||||||
|
Setting(
|
||||||
|
key="similarity.duplicate_threshold",
|
||||||
|
value=0.9,
|
||||||
|
value_type="float",
|
||||||
|
description="Similarity score to consider tools as duplicates (0.0-1.0)",
|
||||||
|
category="similarity",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="similarity.similar_threshold",
|
||||||
|
value=0.7,
|
||||||
|
value_type="float",
|
||||||
|
description="Similarity score to flag tools as similar (0.0-1.0)",
|
||||||
|
category="similarity",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="similarity.related_threshold",
|
||||||
|
value=0.5,
|
||||||
|
value_type="float",
|
||||||
|
description="Similarity score to flag tools as related (0.0-1.0)",
|
||||||
|
category="similarity",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Fabric sync
|
||||||
|
Setting(
|
||||||
|
key="sync.enabled",
|
||||||
|
value=False,
|
||||||
|
value_type="bool",
|
||||||
|
description="Enable scheduled Fabric pattern sync",
|
||||||
|
category="sync",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="sync.interval_hours",
|
||||||
|
value=24,
|
||||||
|
value_type="int",
|
||||||
|
description="Hours between Fabric sync runs",
|
||||||
|
category="sync",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="sync.auto_approve",
|
||||||
|
value=False,
|
||||||
|
value_type="bool",
|
||||||
|
description="Auto-approve synced Fabric patterns (skip moderation)",
|
||||||
|
category="sync",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="sync.default_provider",
|
||||||
|
value="claude",
|
||||||
|
value_type="string",
|
||||||
|
description="Default AI provider for synced patterns",
|
||||||
|
category="sync",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Moderation
|
||||||
|
Setting(
|
||||||
|
key="moderation.require_review",
|
||||||
|
value=True,
|
||||||
|
value_type="bool",
|
||||||
|
description="Require manual review for public tools",
|
||||||
|
category="moderation",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="moderation.auto_approve_private",
|
||||||
|
value=True,
|
||||||
|
value_type="bool",
|
||||||
|
description="Auto-approve private/unlisted tools",
|
||||||
|
category="moderation",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="moderation.max_pending_per_user",
|
||||||
|
value=10,
|
||||||
|
value_type="int",
|
||||||
|
description="Maximum pending tools per user before rate limiting",
|
||||||
|
category="moderation",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Rate limits (can override defaults)
|
||||||
|
Setting(
|
||||||
|
key="rate_limit.publish.limit",
|
||||||
|
value=20,
|
||||||
|
value_type="int",
|
||||||
|
description="Max publish requests per window",
|
||||||
|
category="rate_limits",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="rate_limit.publish.window",
|
||||||
|
value=3600,
|
||||||
|
value_type="int",
|
||||||
|
description="Publish rate limit window in seconds",
|
||||||
|
category="rate_limits",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_value(value: str, value_type: str) -> Any:
|
||||||
|
"""Convert stored string value to appropriate type."""
|
||||||
|
if value_type == "int":
|
||||||
|
return int(value)
|
||||||
|
elif value_type == "float":
|
||||||
|
return float(value)
|
||||||
|
elif value_type == "bool":
|
||||||
|
return value.lower() in ("true", "1", "yes")
|
||||||
|
elif value_type == "json":
|
||||||
|
return json.loads(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_value(value: Any, value_type: str) -> str:
|
||||||
|
"""Serialize value for storage."""
|
||||||
|
if value_type == "bool":
|
||||||
|
return "true" if value else "false"
|
||||||
|
elif value_type == "json":
|
||||||
|
return json.dumps(value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(conn: sqlite3.Connection, key: str) -> Optional[Any]:
|
||||||
|
"""Get a single setting value.
|
||||||
|
|
||||||
|
Returns the default if not set in database.
|
||||||
|
"""
|
||||||
|
row = query_one(
|
||||||
|
conn,
|
||||||
|
"SELECT value, value_type FROM registry_settings WHERE key = ?",
|
||||||
|
[key],
|
||||||
|
)
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return _convert_value(row["value"], row["value_type"])
|
||||||
|
|
||||||
|
# Return default if exists
|
||||||
|
for setting in DEFAULT_SETTINGS:
|
||||||
|
if setting.key == key:
|
||||||
|
return setting.value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_by_category(conn: sqlite3.Connection, category: str) -> Dict[str, Any]:
|
||||||
|
"""Get all settings for a category as a dict."""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Start with defaults
|
||||||
|
for setting in DEFAULT_SETTINGS:
|
||||||
|
if setting.category == category:
|
||||||
|
result[setting.key] = setting.value
|
||||||
|
|
||||||
|
# Override with DB values
|
||||||
|
rows = query_all(
|
||||||
|
conn,
|
||||||
|
"SELECT key, value, value_type FROM registry_settings WHERE category = ?",
|
||||||
|
[category],
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
result[row["key"]] = _convert_value(row["value"], row["value_type"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_settings(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all settings with their current values and metadata."""
|
||||||
|
result = []
|
||||||
|
|
||||||
|
# Get all DB settings
|
||||||
|
db_settings = {}
|
||||||
|
rows = query_all(conn, "SELECT * FROM registry_settings")
|
||||||
|
for row in rows:
|
||||||
|
db_settings[row["key"]] = dict(row)
|
||||||
|
|
||||||
|
# Build combined list
|
||||||
|
for default in DEFAULT_SETTINGS:
|
||||||
|
if default.key in db_settings:
|
||||||
|
row = db_settings[default.key]
|
||||||
|
result.append({
|
||||||
|
"key": default.key,
|
||||||
|
"value": _convert_value(row["value"], row["value_type"]),
|
||||||
|
"value_type": row["value_type"],
|
||||||
|
"description": default.description,
|
||||||
|
"category": default.category,
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
"updated_by": row["updated_by"],
|
||||||
|
"is_default": False,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
result.append({
|
||||||
|
"key": default.key,
|
||||||
|
"value": default.value,
|
||||||
|
"value_type": default.value_type,
|
||||||
|
"description": default.description,
|
||||||
|
"category": default.category,
|
||||||
|
"updated_at": None,
|
||||||
|
"updated_by": None,
|
||||||
|
"is_default": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
updated_by: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Set a setting value.
|
||||||
|
|
||||||
|
Returns True if successful, False if key is not a valid setting.
|
||||||
|
"""
|
||||||
|
# Find the setting definition
|
||||||
|
setting_def = None
|
||||||
|
for s in DEFAULT_SETTINGS:
|
||||||
|
if s.key == key:
|
||||||
|
setting_def = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not setting_def:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Validate and convert value
|
||||||
|
try:
|
||||||
|
if setting_def.value_type == "int":
|
||||||
|
value = int(value)
|
||||||
|
elif setting_def.value_type == "float":
|
||||||
|
value = float(value)
|
||||||
|
# Validate range for thresholds
|
||||||
|
if "threshold" in key or "weight" in key:
|
||||||
|
if not 0.0 <= value <= 1.0:
|
||||||
|
return False
|
||||||
|
elif setting_def.value_type == "bool":
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.lower() in ("true", "1", "yes")
|
||||||
|
else:
|
||||||
|
value = bool(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
serialized = _serialize_value(value, setting_def.value_type)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO registry_settings (key, value, value_type, description, category, updated_at, updated_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
updated_by = excluded.updated_by
|
||||||
|
""",
|
||||||
|
[key, serialized, setting_def.value_type, setting_def.description,
|
||||||
|
setting_def.category, now, updated_by],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def reset_setting(conn: sqlite3.Connection, key: str) -> bool:
|
||||||
|
"""Reset a setting to its default value (remove from DB)."""
|
||||||
|
conn.execute("DELETE FROM registry_settings WHERE key = ?", [key])
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def reset_all_settings(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Reset all settings to defaults. Returns count of reset settings."""
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM registry_settings")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
conn.execute("DELETE FROM registry_settings")
|
||||||
|
conn.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def get_vetting_config(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||||
|
"""Get vetting configuration as a structured dict.
|
||||||
|
|
||||||
|
This is the main entry point for the scrutiny system to get config.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"approve_threshold": get_setting(conn, "vetting.approve_threshold"),
|
||||||
|
"reject_threshold": get_setting(conn, "vetting.reject_threshold"),
|
||||||
|
"weights": {
|
||||||
|
"honesty": get_setting(conn, "vetting.weight.honesty"),
|
||||||
|
"transparency": get_setting(conn, "vetting.weight.transparency"),
|
||||||
|
"scope": get_setting(conn, "vetting.weight.scope"),
|
||||||
|
"efficiency": get_setting(conn, "vetting.weight.efficiency"),
|
||||||
|
"safety": get_setting(conn, "vetting.weight.safety"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_similarity_config(conn: sqlite3.Connection) -> Dict[str, float]:
|
||||||
|
"""Get similarity detection configuration."""
|
||||||
|
return {
|
||||||
|
"duplicate_threshold": get_setting(conn, "similarity.duplicate_threshold"),
|
||||||
|
"similar_threshold": get_setting(conn, "similarity.similar_threshold"),
|
||||||
|
"related_threshold": get_setting(conn, "similarity.related_threshold"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_config(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||||
|
"""Get Fabric sync configuration."""
|
||||||
|
return {
|
||||||
|
"enabled": get_setting(conn, "sync.enabled"),
|
||||||
|
"interval_hours": get_setting(conn, "sync.interval_hours"),
|
||||||
|
"auto_approve": get_setting(conn, "sync.auto_approve"),
|
||||||
|
"default_provider": get_setting(conn, "sync.default_provider"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_moderation_config(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||||
|
"""Get moderation configuration."""
|
||||||
|
return {
|
||||||
|
"require_review": get_setting(conn, "moderation.require_review"),
|
||||||
|
"auto_approve_private": get_setting(conn, "moderation.auto_approve_private"),
|
||||||
|
"max_pending_per_user": get_setting(conn, "moderation.max_pending_per_user"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Similarity detection for registry tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(text: str) -> List[str]:
|
||||||
|
return [t for t in re_split_nonword(text.lower()) if t]
|
||||||
|
|
||||||
|
|
||||||
|
def re_split_nonword(text: str) -> List[str]:
|
||||||
|
token = ""
|
||||||
|
tokens = []
|
||||||
|
for ch in text:
|
||||||
|
if ch.isalnum():
|
||||||
|
token += ch
|
||||||
|
else:
|
||||||
|
if token:
|
||||||
|
tokens.append(token)
|
||||||
|
token = ""
|
||||||
|
if token:
|
||||||
|
tokens.append(token)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def jaccard(a: List[str], b: List[str]) -> float:
|
||||||
|
set_a = set(a)
|
||||||
|
set_b = set(b)
|
||||||
|
if not set_a and not set_b:
|
||||||
|
return 0.0
|
||||||
|
return len(set_a & set_b) / max(len(set_a | set_b), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def name_similarity(name_a: str, name_b: str) -> float:
|
||||||
|
return SequenceMatcher(None, name_a.lower(), name_b.lower()).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def description_similarity(desc_a: str, desc_b: str) -> float:
|
||||||
|
return jaccard(_tokenize(desc_a), _tokenize(desc_b))
|
||||||
|
|
||||||
|
|
||||||
|
def tags_similarity(tags_a: List[str], tags_b: List[str]) -> float:
|
||||||
|
return jaccard([t.lower() for t in tags_a], [t.lower() for t in tags_b])
|
||||||
|
|
||||||
|
|
||||||
|
def score_similarity(
|
||||||
|
candidate: Dict,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
tags: List[str],
|
||||||
|
category: str | None,
|
||||||
|
) -> float:
|
||||||
|
name_score = name_similarity(name, candidate.get("name", ""))
|
||||||
|
desc_score = description_similarity(description, candidate.get("description", ""))
|
||||||
|
tags_score = tags_similarity(tags, candidate.get("tags", []))
|
||||||
|
category_bonus = 0.1 if category and candidate.get("category") == category else 0.0
|
||||||
|
score = (0.5 * name_score) + (0.3 * desc_score) + (0.2 * tags_score) + category_bonus
|
||||||
|
return min(score, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def find_similar_tools(
|
||||||
|
tools: List[Dict],
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
tags: List[str],
|
||||||
|
category: str | None,
|
||||||
|
threshold: float = 0.6,
|
||||||
|
) -> List[Tuple[Dict, float]]:
|
||||||
|
results = []
|
||||||
|
for tool in tools:
|
||||||
|
score = score_similarity(tool, name, description, tags, category)
|
||||||
|
if score >= threshold:
|
||||||
|
results.append((tool, round(score, 3)))
|
||||||
|
results.sort(key=lambda item: item[1], reverse=True)
|
||||||
|
return results
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
"""Stats calculation functions for tools and publishers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
# Badge definitions
|
||||||
|
BADGES = {
|
||||||
|
"verified": {
|
||||||
|
"name": "Verified",
|
||||||
|
"icon": "shield-check",
|
||||||
|
"color": "blue",
|
||||||
|
"description": "Verified publisher identity",
|
||||||
|
},
|
||||||
|
"trusted": {
|
||||||
|
"name": "Trusted",
|
||||||
|
"icon": "award",
|
||||||
|
"color": "gold",
|
||||||
|
"description": "Trust score above 80",
|
||||||
|
},
|
||||||
|
"prolific": {
|
||||||
|
"name": "Prolific",
|
||||||
|
"icon": "layers",
|
||||||
|
"color": "purple",
|
||||||
|
"description": "Published 10+ tools",
|
||||||
|
},
|
||||||
|
"veteran": {
|
||||||
|
"name": "Veteran",
|
||||||
|
"icon": "clock",
|
||||||
|
"color": "gray",
|
||||||
|
"description": "Member for 1+ year",
|
||||||
|
},
|
||||||
|
"popular": {
|
||||||
|
"name": "Popular",
|
||||||
|
"icon": "trending-up",
|
||||||
|
"color": "green",
|
||||||
|
"description": "1000+ total downloads",
|
||||||
|
},
|
||||||
|
"responsive": {
|
||||||
|
"name": "Responsive",
|
||||||
|
"icon": "message-circle",
|
||||||
|
"color": "cyan",
|
||||||
|
"description": "Resolves 90%+ of issues",
|
||||||
|
},
|
||||||
|
"top_rated": {
|
||||||
|
"name": "Top Rated",
|
||||||
|
"icon": "star",
|
||||||
|
"color": "yellow",
|
||||||
|
"description": "Average rating 4.5+",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_tool_stats(conn: sqlite3.Connection, tool_id: int) -> Dict[str, Any]:
|
||||||
|
"""Recalculate and cache stats for a tool.
|
||||||
|
|
||||||
|
Returns the updated stats dict.
|
||||||
|
"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get rating stats
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as rating_count,
|
||||||
|
COALESCE(AVG(rating), 0) as average_rating,
|
||||||
|
SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END) as rating_1,
|
||||||
|
SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END) as rating_2,
|
||||||
|
SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) as rating_3,
|
||||||
|
SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END) as rating_4,
|
||||||
|
SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END) as rating_5
|
||||||
|
FROM reviews
|
||||||
|
WHERE tool_id = ? AND status = 'published'
|
||||||
|
""", [tool_id])
|
||||||
|
rating_row = cursor.fetchone()
|
||||||
|
|
||||||
|
# Get unique users count
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(DISTINCT user_hash) as unique_users
|
||||||
|
FROM tool_usage
|
||||||
|
WHERE tool_id = ?
|
||||||
|
""", [tool_id])
|
||||||
|
usage_row = cursor.fetchone()
|
||||||
|
|
||||||
|
# Get issue counts
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_issues,
|
||||||
|
SUM(CASE WHEN status = 'open' AND issue_type = 'security' THEN 1 ELSE 0 END) as security_issues
|
||||||
|
FROM tool_issues
|
||||||
|
WHERE tool_id = ?
|
||||||
|
""", [tool_id])
|
||||||
|
issues_row = cursor.fetchone()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"tool_id": tool_id,
|
||||||
|
"average_rating": round(rating_row[1], 2) if rating_row else 0,
|
||||||
|
"rating_count": rating_row[0] if rating_row else 0,
|
||||||
|
"rating_1": rating_row[2] or 0 if rating_row else 0,
|
||||||
|
"rating_2": rating_row[3] or 0 if rating_row else 0,
|
||||||
|
"rating_3": rating_row[4] or 0 if rating_row else 0,
|
||||||
|
"rating_4": rating_row[5] or 0 if rating_row else 0,
|
||||||
|
"rating_5": rating_row[6] or 0 if rating_row else 0,
|
||||||
|
"unique_users": usage_row[0] if usage_row else 0,
|
||||||
|
"open_issues": issues_row[0] or 0 if issues_row else 0,
|
||||||
|
"security_issues": issues_row[1] or 0 if issues_row else 0,
|
||||||
|
"last_updated": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upsert into tool_stats
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO tool_stats (
|
||||||
|
tool_id, average_rating, rating_count,
|
||||||
|
rating_1, rating_2, rating_3, rating_4, rating_5,
|
||||||
|
unique_users, open_issues, security_issues, last_updated
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(tool_id) DO UPDATE SET
|
||||||
|
average_rating = excluded.average_rating,
|
||||||
|
rating_count = excluded.rating_count,
|
||||||
|
rating_1 = excluded.rating_1,
|
||||||
|
rating_2 = excluded.rating_2,
|
||||||
|
rating_3 = excluded.rating_3,
|
||||||
|
rating_4 = excluded.rating_4,
|
||||||
|
rating_5 = excluded.rating_5,
|
||||||
|
unique_users = excluded.unique_users,
|
||||||
|
open_issues = excluded.open_issues,
|
||||||
|
security_issues = excluded.security_issues,
|
||||||
|
last_updated = excluded.last_updated
|
||||||
|
""", [
|
||||||
|
tool_id, stats["average_rating"], stats["rating_count"],
|
||||||
|
stats["rating_1"], stats["rating_2"], stats["rating_3"],
|
||||||
|
stats["rating_4"], stats["rating_5"],
|
||||||
|
stats["unique_users"], stats["open_issues"], stats["security_issues"],
|
||||||
|
stats["last_updated"],
|
||||||
|
])
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_stats(conn: sqlite3.Connection, tool_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get cached stats for a tool, refreshing if stale or missing."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT * FROM tool_stats WHERE tool_id = ?
|
||||||
|
""", [tool_id])
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
# No cached stats, calculate fresh
|
||||||
|
return refresh_tool_stats(conn, tool_id)
|
||||||
|
|
||||||
|
# Check if stale (older than 1 hour)
|
||||||
|
try:
|
||||||
|
last_updated = datetime.fromisoformat(row[11]) # last_updated column
|
||||||
|
if datetime.utcnow() - last_updated > timedelta(hours=1):
|
||||||
|
return refresh_tool_stats(conn, tool_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return refresh_tool_stats(conn, tool_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tool_id": row[0],
|
||||||
|
"average_rating": row[1],
|
||||||
|
"rating_count": row[2],
|
||||||
|
"rating_1": row[3],
|
||||||
|
"rating_2": row[4],
|
||||||
|
"rating_3": row[5],
|
||||||
|
"rating_4": row[6],
|
||||||
|
"rating_5": row[7],
|
||||||
|
"unique_users": row[8],
|
||||||
|
"open_issues": row[9],
|
||||||
|
"security_issues": row[10],
|
||||||
|
"last_updated": row[11],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_trust_score(
|
||||||
|
tool_count: int,
|
||||||
|
total_downloads: int,
|
||||||
|
average_rating: float,
|
||||||
|
total_reviews: int,
|
||||||
|
issues_resolved_pct: float,
|
||||||
|
tenure_days: int,
|
||||||
|
is_verified: bool,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate publisher trust score (0-100).
|
||||||
|
|
||||||
|
Scoring breakdown:
|
||||||
|
- Tenure: max 20 points (5 points per year, max 4 years)
|
||||||
|
- Tools: max 20 points (2 points per tool, max 10 tools)
|
||||||
|
- Rating: max 30 points ((avg_rating / 5) * 30, requires min 3 reviews)
|
||||||
|
- Downloads: max 15 points (logarithmic scale)
|
||||||
|
- Issue response: max 10 points (resolved % * 10)
|
||||||
|
- Verified bonus: 5 points
|
||||||
|
"""
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Tenure score (max 20)
|
||||||
|
years = min(tenure_days / 365, 4)
|
||||||
|
score += years * 5
|
||||||
|
|
||||||
|
# Tools contribution (max 20)
|
||||||
|
score += min(tool_count, 10) * 2
|
||||||
|
|
||||||
|
# Rating score (max 30, requires minimum reviews)
|
||||||
|
if total_reviews >= 3:
|
||||||
|
score += (average_rating / 5) * 30
|
||||||
|
elif total_reviews > 0:
|
||||||
|
# Partial credit for few reviews
|
||||||
|
score += (average_rating / 5) * 15
|
||||||
|
|
||||||
|
# Downloads score (max 15, logarithmic)
|
||||||
|
if total_downloads > 0:
|
||||||
|
import math
|
||||||
|
# log10(1000) = 3, so 1000 downloads = 15 points
|
||||||
|
download_score = min(math.log10(total_downloads + 1) * 5, 15)
|
||||||
|
score += download_score
|
||||||
|
|
||||||
|
# Issue response score (max 10)
|
||||||
|
score += issues_resolved_pct * 10
|
||||||
|
|
||||||
|
# Verified bonus (5 points)
|
||||||
|
if is_verified:
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
return round(min(100, score), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_badges(
|
||||||
|
is_verified: bool,
|
||||||
|
trust_score: float,
|
||||||
|
tool_count: int,
|
||||||
|
tenure_days: int,
|
||||||
|
total_downloads: int,
|
||||||
|
issues_resolved_pct: float,
|
||||||
|
average_rating: float,
|
||||||
|
total_reviews: int,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Determine which badges a publisher has earned."""
|
||||||
|
earned = []
|
||||||
|
|
||||||
|
if is_verified:
|
||||||
|
earned.append("verified")
|
||||||
|
|
||||||
|
if trust_score >= 80:
|
||||||
|
earned.append("trusted")
|
||||||
|
|
||||||
|
if tool_count >= 10:
|
||||||
|
earned.append("prolific")
|
||||||
|
|
||||||
|
if tenure_days >= 365:
|
||||||
|
earned.append("veteran")
|
||||||
|
|
||||||
|
if total_downloads >= 1000:
|
||||||
|
earned.append("popular")
|
||||||
|
|
||||||
|
if issues_resolved_pct >= 0.9:
|
||||||
|
earned.append("responsive")
|
||||||
|
|
||||||
|
if average_rating >= 4.5 and total_reviews >= 5:
|
||||||
|
earned.append("top_rated")
|
||||||
|
|
||||||
|
return earned
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_publisher_stats(conn: sqlite3.Connection, publisher_id: int) -> Dict[str, Any]:
|
||||||
|
"""Recalculate and cache stats for a publisher.
|
||||||
|
|
||||||
|
Returns the updated stats dict.
|
||||||
|
"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get publisher info
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT verified, created_at FROM publishers WHERE id = ?
|
||||||
|
""", [publisher_id])
|
||||||
|
pub_row = cursor.fetchone()
|
||||||
|
if not pub_row:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
is_verified = bool(pub_row[0])
|
||||||
|
try:
|
||||||
|
created_at = datetime.fromisoformat(pub_row[1].replace("Z", "+00:00").replace("+00:00", ""))
|
||||||
|
tenure_days = (datetime.utcnow() - created_at).days
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
tenure_days = 0
|
||||||
|
|
||||||
|
# Get tool stats
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT t.id) as tool_count,
|
||||||
|
COALESCE(SUM(t.downloads), 0) as total_downloads
|
||||||
|
FROM tools t
|
||||||
|
WHERE t.publisher_id = ? AND t.moderation_status = 'approved'
|
||||||
|
""", [publisher_id])
|
||||||
|
tools_row = cursor.fetchone()
|
||||||
|
tool_count = tools_row[0] if tools_row else 0
|
||||||
|
total_downloads = tools_row[1] if tools_row else 0
|
||||||
|
|
||||||
|
# Get aggregate rating across all tools
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_reviews,
|
||||||
|
COALESCE(AVG(r.rating), 0) as average_rating
|
||||||
|
FROM reviews r
|
||||||
|
JOIN tools t ON r.tool_id = t.id
|
||||||
|
WHERE t.publisher_id = ? AND r.status = 'published'
|
||||||
|
""", [publisher_id])
|
||||||
|
reviews_row = cursor.fetchone()
|
||||||
|
total_reviews = reviews_row[0] if reviews_row else 0
|
||||||
|
average_rating = round(reviews_row[1], 2) if reviews_row else 0
|
||||||
|
|
||||||
|
# Get issue resolution rate
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_issues,
|
||||||
|
SUM(CASE WHEN i.status IN ('fixed', 'wontfix', 'duplicate') THEN 1 ELSE 0 END) as resolved_issues
|
||||||
|
FROM tool_issues i
|
||||||
|
JOIN tools t ON i.tool_id = t.id
|
||||||
|
WHERE t.publisher_id = ?
|
||||||
|
""", [publisher_id])
|
||||||
|
issues_row = cursor.fetchone()
|
||||||
|
total_issues = issues_row[0] if issues_row else 0
|
||||||
|
resolved_issues = issues_row[1] or 0 if issues_row else 0
|
||||||
|
issues_resolved_pct = resolved_issues / total_issues if total_issues > 0 else 1.0
|
||||||
|
|
||||||
|
# Calculate trust score
|
||||||
|
trust_score = calculate_trust_score(
|
||||||
|
tool_count=tool_count,
|
||||||
|
total_downloads=total_downloads,
|
||||||
|
average_rating=average_rating,
|
||||||
|
total_reviews=total_reviews,
|
||||||
|
issues_resolved_pct=issues_resolved_pct,
|
||||||
|
tenure_days=tenure_days,
|
||||||
|
is_verified=is_verified,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate badges
|
||||||
|
badges = calculate_badges(
|
||||||
|
is_verified=is_verified,
|
||||||
|
trust_score=trust_score,
|
||||||
|
tool_count=tool_count,
|
||||||
|
tenure_days=tenure_days,
|
||||||
|
total_downloads=total_downloads,
|
||||||
|
issues_resolved_pct=issues_resolved_pct,
|
||||||
|
average_rating=average_rating,
|
||||||
|
total_reviews=total_reviews,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"publisher_id": publisher_id,
|
||||||
|
"tool_count": tool_count,
|
||||||
|
"total_downloads": total_downloads,
|
||||||
|
"average_rating": average_rating,
|
||||||
|
"total_reviews": total_reviews,
|
||||||
|
"trust_score": trust_score,
|
||||||
|
"badges": badges,
|
||||||
|
"tenure_days": tenure_days,
|
||||||
|
"is_verified": is_verified,
|
||||||
|
"last_updated": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upsert into publisher_stats
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO publisher_stats (
|
||||||
|
publisher_id, tool_count, total_downloads, average_rating,
|
||||||
|
total_reviews, trust_score, badges, last_updated
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(publisher_id) DO UPDATE SET
|
||||||
|
tool_count = excluded.tool_count,
|
||||||
|
total_downloads = excluded.total_downloads,
|
||||||
|
average_rating = excluded.average_rating,
|
||||||
|
total_reviews = excluded.total_reviews,
|
||||||
|
trust_score = excluded.trust_score,
|
||||||
|
badges = excluded.badges,
|
||||||
|
last_updated = excluded.last_updated
|
||||||
|
""", [
|
||||||
|
publisher_id, tool_count, total_downloads, average_rating,
|
||||||
|
total_reviews, trust_score, json.dumps(badges),
|
||||||
|
stats["last_updated"],
|
||||||
|
])
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def get_publisher_stats(conn: sqlite3.Connection, publisher_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get cached stats for a publisher, refreshing if stale or missing."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT * FROM publisher_stats WHERE publisher_id = ?
|
||||||
|
""", [publisher_id])
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
# No cached stats, calculate fresh
|
||||||
|
return refresh_publisher_stats(conn, publisher_id)
|
||||||
|
|
||||||
|
# Check if stale (older than 1 hour)
|
||||||
|
try:
|
||||||
|
last_updated = datetime.fromisoformat(row[7]) # last_updated column
|
||||||
|
if datetime.utcnow() - last_updated > timedelta(hours=1):
|
||||||
|
return refresh_publisher_stats(conn, publisher_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return refresh_publisher_stats(conn, publisher_id)
|
||||||
|
|
||||||
|
badges = []
|
||||||
|
try:
|
||||||
|
badges = json.loads(row[6]) if row[6] else []
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"publisher_id": row[0],
|
||||||
|
"tool_count": row[1],
|
||||||
|
"total_downloads": row[2],
|
||||||
|
"average_rating": row[3],
|
||||||
|
"total_reviews": row[4],
|
||||||
|
"trust_score": row[5],
|
||||||
|
"badges": badges,
|
||||||
|
"last_updated": row[7],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def track_tool_usage(conn: sqlite3.Connection, tool_id: int, client_id: str) -> None:
|
||||||
|
"""Track a tool usage (download/run) for unique user counting."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# Hash the client_id for privacy
|
||||||
|
user_hash = hashlib.sha256(client_id.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO tool_usage (tool_id, user_hash, first_used_at, last_used_at, use_count)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
|
||||||
|
ON CONFLICT(tool_id, user_hash) DO UPDATE SET
|
||||||
|
last_used_at = CURRENT_TIMESTAMP,
|
||||||
|
use_count = use_count + 1
|
||||||
|
""", [tool_id, user_hash])
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def format_count(count: int) -> str:
|
||||||
|
"""Format a count for display (e.g., 1234 -> '1.2K')."""
|
||||||
|
if count < 1000:
|
||||||
|
return str(count)
|
||||||
|
elif count < 10000:
|
||||||
|
return f"{count / 1000:.1f}K"
|
||||||
|
elif count < 1000000:
|
||||||
|
return f"{count // 1000}K"
|
||||||
|
else:
|
||||||
|
return f"{count / 1000000:.1f}M"
|
||||||
|
|
||||||
|
|
||||||
|
def get_badge_info(badge_id: str) -> Optional[Dict[str, str]]:
|
||||||
|
"""Get display info for a badge."""
|
||||||
|
return BADGES.get(badge_id)
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Registry repository sync and webhook processing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .db import connect_db, query_one
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_dir() -> Path:
|
||||||
|
default_dir = Path.home() / ".cmdforge" / "registry" / "repo"
|
||||||
|
return Path(os.environ.get("CMDFORGE_REGISTRY_REPO_DIR", default_dir))
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_url() -> str:
|
||||||
|
return os.environ.get("CMDFORGE_REGISTRY_REPO_URL", "https://gitea.brrd.tech/rob/CmdForge-Registry.git")
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_branch() -> str:
|
||||||
|
return os.environ.get("CMDFORGE_REGISTRY_REPO_BRANCH", "main")
|
||||||
|
|
||||||
|
|
||||||
|
def get_categories_cache_path() -> Path:
|
||||||
|
return Path(os.environ.get(
|
||||||
|
"CMDFORGE_REGISTRY_CATEGORIES_CACHE",
|
||||||
|
Path.home() / ".cmdforge" / "registry" / "categories_cache.json",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hmac(body: bytes, signature: str | None, secret: str) -> bool:
|
||||||
|
if not signature:
|
||||||
|
return False
|
||||||
|
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(signature, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def clone_or_update_repo(repo_dir: Path) -> None:
|
||||||
|
repo_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not repo_dir.exists():
|
||||||
|
subprocess.run(
|
||||||
|
["git", "clone", "--depth", "1", "--branch", get_repo_branch(), get_repo_url(), str(repo_dir)],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(["git", "-C", str(repo_dir), "fetch", "origin"], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_dir), "reset", "--hard", f"origin/{get_repo_branch()}"]
|
||||||
|
, check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(path: Path) -> Dict[str, Any]:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return yaml.safe_load(handle) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_publisher(conn, owner: str) -> int:
|
||||||
|
row = query_one(conn, "SELECT id FROM publishers WHERE slug = ?", [owner])
|
||||||
|
if row:
|
||||||
|
return int(row["id"])
|
||||||
|
placeholder_email = f"{owner}@registry.local"
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO publishers (email, password_hash, slug, display_name, verified)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[placeholder_email, "", owner, owner, False],
|
||||||
|
)
|
||||||
|
return int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tags(tags_value: Any) -> str:
|
||||||
|
if not tags_value:
|
||||||
|
return "[]"
|
||||||
|
if isinstance(tags_value, list):
|
||||||
|
return json.dumps(tags_value)
|
||||||
|
return json.dumps([str(tags_value)])
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_tool(conn, owner: str, name: str, data: Dict[str, Any], config_text: str, readme_text: str | None) -> None:
|
||||||
|
version = data.get("version")
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
publisher_id = ensure_publisher(conn, owner)
|
||||||
|
registry_meta = data.get("registry", {}) or {}
|
||||||
|
|
||||||
|
tags = normalize_tags(data.get("tags"))
|
||||||
|
description = data.get("description")
|
||||||
|
category = data.get("category")
|
||||||
|
deprecated = bool(data.get("deprecated", False))
|
||||||
|
deprecated_message = data.get("deprecated_message")
|
||||||
|
replacement = data.get("replacement")
|
||||||
|
downloads = registry_meta.get("downloads")
|
||||||
|
published_at = registry_meta.get("published_at")
|
||||||
|
|
||||||
|
existing = query_one(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM tools WHERE owner = ? AND name = ? AND version = ?",
|
||||||
|
[owner, name, version],
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tools
|
||||||
|
SET description = ?, category = ?, tags = ?, config_yaml = ?, readme = ?,
|
||||||
|
deprecated = ?, deprecated_message = ?, replacement = ?,
|
||||||
|
downloads = COALESCE(?, downloads), published_at = COALESCE(?, published_at)
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
config_text,
|
||||||
|
readme_text,
|
||||||
|
int(deprecated),
|
||||||
|
deprecated_message,
|
||||||
|
replacement,
|
||||||
|
downloads,
|
||||||
|
published_at,
|
||||||
|
existing["id"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
config_text,
|
||||||
|
readme_text,
|
||||||
|
publisher_id,
|
||||||
|
int(deprecated),
|
||||||
|
deprecated_message,
|
||||||
|
replacement,
|
||||||
|
downloads or 0,
|
||||||
|
published_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_categories(repo_dir: Path) -> None:
|
||||||
|
categories_path = repo_dir / "categories" / "categories.yaml"
|
||||||
|
if not categories_path.exists():
|
||||||
|
return
|
||||||
|
payload = load_yaml(categories_path)
|
||||||
|
cache_path = get_categories_cache_path()
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_collections(conn, repo_dir: Path) -> None:
|
||||||
|
"""Sync collections from registry repo to database."""
|
||||||
|
collections_dir = repo_dir / "collections"
|
||||||
|
if not collections_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
for collection_path in collections_dir.glob("*.yaml"):
|
||||||
|
try:
|
||||||
|
data = load_yaml(collection_path)
|
||||||
|
name = data.get("name") or collection_path.stem
|
||||||
|
display_name = data.get("display_name") or name
|
||||||
|
description = data.get("description")
|
||||||
|
icon = data.get("icon")
|
||||||
|
maintainer = data.get("maintainer") or "official"
|
||||||
|
tools = data.get("tools") or []
|
||||||
|
pinned = data.get("pinned") or {}
|
||||||
|
tags = data.get("tags") or []
|
||||||
|
|
||||||
|
tools_json = json.dumps(tools)
|
||||||
|
pinned_json = json.dumps(pinned) if pinned else None
|
||||||
|
tags_json = json.dumps(tags) if tags else None
|
||||||
|
|
||||||
|
existing = query_one(conn, "SELECT id FROM collections WHERE name = ?", [name])
|
||||||
|
if existing:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE collections
|
||||||
|
SET display_name = ?, description = ?, icon = ?, maintainer = ?,
|
||||||
|
tools = ?, pinned = ?, tags = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
[display_name, description, icon, maintainer, tools_json,
|
||||||
|
pinned_json, tags_json, datetime.utcnow().isoformat(), existing["id"]],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO collections (name, display_name, description, icon, maintainer, tools, pinned, tags)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[name, display_name, description, icon, maintainer, tools_json, pinned_json, tags_json],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def sync_from_repo() -> Tuple[bool, str]:
|
||||||
|
repo_dir = get_repo_dir()
|
||||||
|
clone_or_update_repo(repo_dir)
|
||||||
|
|
||||||
|
tools_root = repo_dir / "tools"
|
||||||
|
if not tools_root.exists():
|
||||||
|
return False, "missing_tools_dir"
|
||||||
|
|
||||||
|
conn = connect_db()
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
for config_path in tools_root.glob("*/*/config.yaml"):
|
||||||
|
owner = config_path.parent.parent.name
|
||||||
|
name = config_path.parent.name
|
||||||
|
try:
|
||||||
|
config_text = config_path.read_text(encoding="utf-8")
|
||||||
|
data = yaml.safe_load(config_text) or {}
|
||||||
|
readme_path = config_path.parent / "README.md"
|
||||||
|
readme_text = readme_path.read_text(encoding="utf-8") if readme_path.exists() else None
|
||||||
|
upsert_tool(conn, owner, name, data, config_text, readme_text)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
sync_collections(conn, repo_dir)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
sync_categories(repo_dir)
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def record_webhook_delivery(conn, delivery_id: str, event_type: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO webhook_log (delivery_id, event_type, processed_at) VALUES (?, ?, ?)",
|
||||||
|
[delivery_id, event_type, datetime.utcnow().isoformat()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_delivery_processed(conn, delivery_id: str) -> bool:
|
||||||
|
row = query_one(conn, "SELECT 1 FROM webhook_log WHERE delivery_id = ?", [delivery_id])
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_lock(lock_path: Path, timeout: int) -> bool:
|
||||||
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
try:
|
||||||
|
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def release_lock(lock_path: Path) -> None:
|
||||||
|
if lock_path.exists():
|
||||||
|
lock_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def process_webhook(body: bytes, headers: Dict[str, str], secret: str, timeout: int = 300) -> Tuple[int, Dict[str, Any]]:
|
||||||
|
delivery_id = headers.get("X-Gitea-Delivery")
|
||||||
|
signature = headers.get("X-Gitea-Signature")
|
||||||
|
event_type = headers.get("X-Gitea-Event", "unknown")
|
||||||
|
|
||||||
|
if not delivery_id:
|
||||||
|
return 400, {"error": {"code": "VALIDATION_ERROR", "message": "Missing X-Gitea-Delivery"}}
|
||||||
|
|
||||||
|
if not verify_hmac(body, signature, secret):
|
||||||
|
return 401, {"error": {"code": "UNAUTHORIZED", "message": "Invalid webhook signature"}}
|
||||||
|
|
||||||
|
conn = connect_db()
|
||||||
|
try:
|
||||||
|
if is_delivery_processed(conn, delivery_id):
|
||||||
|
return 200, {"data": {"status": "already_processed"}}
|
||||||
|
|
||||||
|
lock_path = Path.home() / ".cmdforge" / "registry" / "locks" / "webhook.lock"
|
||||||
|
if not acquire_lock(lock_path, timeout):
|
||||||
|
return 200, {"data": {"status": "skipped", "reason": "sync_in_progress"}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_delivery_processed(conn, delivery_id):
|
||||||
|
return 200, {"data": {"status": "already_processed"}}
|
||||||
|
ok, reason = sync_from_repo()
|
||||||
|
if ok:
|
||||||
|
record_webhook_delivery(conn, delivery_id, event_type)
|
||||||
|
conn.commit()
|
||||||
|
return 200, {"data": {"status": "processed"}}
|
||||||
|
return 500, {"error": {"code": "SERVER_ERROR", "message": f"Sync failed: {reason}"}}
|
||||||
|
finally:
|
||||||
|
release_lock(lock_path)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
"""Registry API client for CmdForge.
|
||||||
|
|
||||||
|
Handles all HTTP communication with the registry server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from urllib.parse import urljoin, urlencode
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
load_config,
|
||||||
|
get_registry_url,
|
||||||
|
get_registry_token,
|
||||||
|
get_client_id,
|
||||||
|
CONFIG_DIR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Local cache directory
|
||||||
|
CACHE_DIR = CONFIG_DIR / "registry"
|
||||||
|
INDEX_CACHE_FILE = CACHE_DIR / "index.json"
|
||||||
|
INDEX_CACHE_MAX_AGE = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegistryError(Exception):
|
||||||
|
"""Base exception for registry errors."""
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
details: Optional[Dict] = None
|
||||||
|
http_status: int = 0
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.code}: {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RateLimitError(RegistryError):
|
||||||
|
"""Raised when rate limited by the registry."""
|
||||||
|
retry_after: int = 60
|
||||||
|
|
||||||
|
def __init__(self, retry_after: int = 60):
|
||||||
|
super().__init__(
|
||||||
|
code="RATE_LIMITED",
|
||||||
|
message=f"Rate limited. Retry after {retry_after} seconds.",
|
||||||
|
http_status=429
|
||||||
|
)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaginatedResponse:
|
||||||
|
"""Paginated API response."""
|
||||||
|
data: List[Dict]
|
||||||
|
page: int = 1
|
||||||
|
per_page: int = 20
|
||||||
|
total: int = 0
|
||||||
|
total_pages: int = 0
|
||||||
|
facets: Optional[Dict] = None # Category/tag/owner counts when requested
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolInfo:
|
||||||
|
"""Tool information from the registry."""
|
||||||
|
owner: str
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: str = ""
|
||||||
|
category: str = ""
|
||||||
|
tags: List[str] = field(default_factory=list)
|
||||||
|
downloads: int = 0
|
||||||
|
deprecated: bool = False
|
||||||
|
deprecated_message: str = ""
|
||||||
|
replacement: str = ""
|
||||||
|
published_at: str = ""
|
||||||
|
readme: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self) -> str:
|
||||||
|
return f"{self.owner}/{self.name}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ToolInfo":
|
||||||
|
return cls(
|
||||||
|
owner=data.get("owner", ""),
|
||||||
|
name=data.get("name", ""),
|
||||||
|
version=data.get("version", ""),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
category=data.get("category", ""),
|
||||||
|
tags=data.get("tags", []),
|
||||||
|
downloads=data.get("downloads", 0),
|
||||||
|
deprecated=data.get("deprecated", False),
|
||||||
|
deprecated_message=data.get("deprecated_message", ""),
|
||||||
|
replacement=data.get("replacement", ""),
|
||||||
|
published_at=data.get("published_at", ""),
|
||||||
|
readme=data.get("readme", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadResult:
|
||||||
|
"""Result of downloading a tool."""
|
||||||
|
owner: str
|
||||||
|
name: str
|
||||||
|
resolved_version: str
|
||||||
|
config_yaml: str
|
||||||
|
readme: str = ""
|
||||||
|
config_hash: str = "" # Registry hash for integrity verification
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryClient:
|
||||||
|
"""Client for interacting with the CmdForge registry API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
token: Optional[str] = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
max_retries: int = 3
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the registry client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Registry API base URL (default: from config)
|
||||||
|
token: Auth token for authenticated requests (default: from config)
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
max_retries: Maximum number of retries for failed requests
|
||||||
|
"""
|
||||||
|
self.base_url = base_url or get_registry_url()
|
||||||
|
self.token = token or get_registry_token()
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.client_id = get_client_id()
|
||||||
|
|
||||||
|
# Session for connection pooling
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({
|
||||||
|
"User-Agent": "CmdForge-CLI/1.0",
|
||||||
|
"X-CmdForge-Client": "cli/1.0.0",
|
||||||
|
"Accept": "application/json"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add client ID header
|
||||||
|
if self.client_id:
|
||||||
|
self._session.headers["X-Client-ID"] = self.client_id
|
||||||
|
|
||||||
|
def _url(self, path: str) -> str:
|
||||||
|
"""Build full URL from path."""
|
||||||
|
# Ensure base_url ends without /api/v1 duplication
|
||||||
|
base = self.base_url.rstrip("/")
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
return base + path
|
||||||
|
|
||||||
|
def _auth_headers(self) -> Dict[str, str]:
|
||||||
|
"""Get authentication headers if token is available."""
|
||||||
|
if self.token:
|
||||||
|
return {"Authorization": f"Bearer {self.token}"}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
params: Optional[Dict] = None,
|
||||||
|
json_data: Optional[Dict] = None,
|
||||||
|
require_auth: bool = False,
|
||||||
|
etag: Optional[str] = None
|
||||||
|
) -> requests.Response:
|
||||||
|
"""
|
||||||
|
Make an HTTP request with retry logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method
|
||||||
|
path: API path
|
||||||
|
params: Query parameters
|
||||||
|
json_data: JSON body data
|
||||||
|
require_auth: Whether auth is required
|
||||||
|
etag: ETag for conditional requests
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RegistryError: On API errors
|
||||||
|
RateLimitError: When rate limited
|
||||||
|
"""
|
||||||
|
url = self._url(path)
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
if require_auth:
|
||||||
|
if not self.token:
|
||||||
|
raise RegistryError(
|
||||||
|
code="UNAUTHORIZED",
|
||||||
|
message="Authentication required. Set registry token with 'cmdforge config set-token'",
|
||||||
|
http_status=401
|
||||||
|
)
|
||||||
|
headers.update(self._auth_headers())
|
||||||
|
|
||||||
|
if etag:
|
||||||
|
headers["If-None-Match"] = etag
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
response = self._session.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
json=json_data,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle rate limiting
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_after = int(response.headers.get("Retry-After", 60))
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
time.sleep(min(retry_after, 30)) # Cap wait at 30s per attempt
|
||||||
|
continue
|
||||||
|
raise RateLimitError(retry_after=retry_after)
|
||||||
|
|
||||||
|
# Handle server errors with retry
|
||||||
|
if response.status_code >= 500:
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
time.sleep(2 ** attempt) # Exponential backoff
|
||||||
|
continue
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
last_error = RegistryError(
|
||||||
|
code="TIMEOUT",
|
||||||
|
message="Request timed out"
|
||||||
|
)
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
last_error = RegistryError(
|
||||||
|
code="CONNECTION_ERROR",
|
||||||
|
message="Could not connect to registry"
|
||||||
|
)
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise last_error or RegistryError(
|
||||||
|
code="REQUEST_FAILED",
|
||||||
|
message="Request failed after retries"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_error_response(self, response: requests.Response) -> None:
|
||||||
|
"""Parse and raise appropriate error from error response."""
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
error = data.get("error", {})
|
||||||
|
raise RegistryError(
|
||||||
|
code=error.get("code", "UNKNOWN_ERROR"),
|
||||||
|
message=error.get("message", "Unknown error"),
|
||||||
|
details=error.get("details"),
|
||||||
|
http_status=response.status_code
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
raise RegistryError(
|
||||||
|
code="UNKNOWN_ERROR",
|
||||||
|
message=f"HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
http_status=response.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Public API Methods
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_tools(
|
||||||
|
self,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 20,
|
||||||
|
sort: str = "downloads",
|
||||||
|
order: str = "desc"
|
||||||
|
) -> PaginatedResponse:
|
||||||
|
"""
|
||||||
|
List tools from the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Filter by category
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
per_page: Items per page (max 100)
|
||||||
|
sort: Sort field (downloads, published_at, name)
|
||||||
|
order: Sort order (asc, desc)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse with tool data
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"page": page,
|
||||||
|
"per_page": min(per_page, 100),
|
||||||
|
"sort": sort,
|
||||||
|
"order": order
|
||||||
|
}
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
|
||||||
|
response = self._request("GET", "/tools", params=params)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
meta = data.get("meta", {})
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
data=data.get("data", []),
|
||||||
|
page=meta.get("page", page),
|
||||||
|
per_page=meta.get("per_page", per_page),
|
||||||
|
total=meta.get("total", 0),
|
||||||
|
total_pages=meta.get("total_pages", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_tools(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
categories: Optional[List[str]] = None,
|
||||||
|
tags: Optional[List[str]] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
min_downloads: Optional[int] = None,
|
||||||
|
max_downloads: Optional[int] = None,
|
||||||
|
published_after: Optional[str] = None,
|
||||||
|
published_before: Optional[str] = None,
|
||||||
|
include_deprecated: bool = False,
|
||||||
|
include_facets: bool = False,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 20,
|
||||||
|
sort: str = "relevance"
|
||||||
|
) -> PaginatedResponse:
|
||||||
|
"""
|
||||||
|
Search for tools in the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query
|
||||||
|
category: Filter by single category (backward compat)
|
||||||
|
categories: Filter by multiple categories (OR logic)
|
||||||
|
tags: Filter by tags (AND logic - must have all)
|
||||||
|
owner: Filter by publisher/owner
|
||||||
|
min_downloads: Minimum download count
|
||||||
|
max_downloads: Maximum download count
|
||||||
|
published_after: Published after date (ISO format)
|
||||||
|
published_before: Published before date (ISO format)
|
||||||
|
include_deprecated: Include deprecated tools
|
||||||
|
include_facets: Include category/tag/owner counts in response
|
||||||
|
page: Page number
|
||||||
|
per_page: Items per page
|
||||||
|
sort: Sort field (relevance, downloads, published_at)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse with matching tools (and facets if requested)
|
||||||
|
"""
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"q": query,
|
||||||
|
"page": page,
|
||||||
|
"per_page": min(per_page, 100),
|
||||||
|
"sort": sort
|
||||||
|
}
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
if categories:
|
||||||
|
params["categories"] = ",".join(categories)
|
||||||
|
if tags:
|
||||||
|
params["tags"] = ",".join(tags)
|
||||||
|
if owner:
|
||||||
|
params["owner"] = owner
|
||||||
|
if min_downloads is not None:
|
||||||
|
params["min_downloads"] = min_downloads
|
||||||
|
if max_downloads is not None:
|
||||||
|
params["max_downloads"] = max_downloads
|
||||||
|
if published_after:
|
||||||
|
params["published_after"] = published_after
|
||||||
|
if published_before:
|
||||||
|
params["published_before"] = published_before
|
||||||
|
if include_deprecated:
|
||||||
|
params["deprecated"] = "true"
|
||||||
|
if include_facets:
|
||||||
|
params["include_facets"] = "true"
|
||||||
|
|
||||||
|
response = self._request("GET", "/tools/search", params=params)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
meta = data.get("meta", {})
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
data=data.get("data", []),
|
||||||
|
page=meta.get("page", page),
|
||||||
|
per_page=meta.get("per_page", per_page),
|
||||||
|
total=meta.get("total", 0),
|
||||||
|
total_pages=meta.get("total_pages", 0),
|
||||||
|
facets=data.get("facets")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_tags(
|
||||||
|
self,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get all tags with usage counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Filter tags by category
|
||||||
|
limit: Maximum tags to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tag objects with name and count
|
||||||
|
"""
|
||||||
|
params: Dict[str, Any] = {"limit": min(limit, 500)}
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
|
||||||
|
response = self._request("GET", "/tags", params=params)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
def get_tool(self, owner: str, name: str) -> ToolInfo:
|
||||||
|
"""
|
||||||
|
Get detailed information about a tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Tool owner (namespace)
|
||||||
|
name: Tool name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ToolInfo object
|
||||||
|
"""
|
||||||
|
response = self._request("GET", f"/tools/{owner}/{name}")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise RegistryError(
|
||||||
|
code="TOOL_NOT_FOUND",
|
||||||
|
message=f"Tool '{owner}/{name}' not found",
|
||||||
|
http_status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
data = response.json().get("data", {})
|
||||||
|
return ToolInfo.from_dict(data)
|
||||||
|
|
||||||
|
def get_tool_versions(self, owner: str, name: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get all versions of a tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Tool owner
|
||||||
|
name: Tool name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of version strings (sorted newest first)
|
||||||
|
"""
|
||||||
|
response = self._request("GET", f"/tools/{owner}/{name}/versions")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise RegistryError(
|
||||||
|
code="TOOL_NOT_FOUND",
|
||||||
|
message=f"Tool '{owner}/{name}' not found",
|
||||||
|
http_status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", {}).get("versions", [])
|
||||||
|
|
||||||
|
def download_tool(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
name: str,
|
||||||
|
version: Optional[str] = None,
|
||||||
|
install: bool = True
|
||||||
|
) -> DownloadResult:
|
||||||
|
"""
|
||||||
|
Download a tool's configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Tool owner
|
||||||
|
name: Tool name
|
||||||
|
version: Version or constraint (default: latest)
|
||||||
|
install: Whether to count as install for stats
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DownloadResult with config YAML
|
||||||
|
"""
|
||||||
|
params = {"install": str(install).lower()}
|
||||||
|
if version:
|
||||||
|
params["version"] = version
|
||||||
|
|
||||||
|
response = self._request(
|
||||||
|
"GET",
|
||||||
|
f"/tools/{owner}/{name}/download",
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
error_data = {}
|
||||||
|
try:
|
||||||
|
error_data = response.json().get("error", {})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
code = error_data.get("code", "TOOL_NOT_FOUND")
|
||||||
|
message = error_data.get("message", f"Tool '{owner}/{name}' not found")
|
||||||
|
|
||||||
|
raise RegistryError(
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
details=error_data.get("details"),
|
||||||
|
http_status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
data = response.json().get("data", {})
|
||||||
|
|
||||||
|
return DownloadResult(
|
||||||
|
owner=data.get("owner", owner),
|
||||||
|
name=data.get("name", name),
|
||||||
|
resolved_version=data.get("resolved_version", ""),
|
||||||
|
config_yaml=data.get("config", ""),
|
||||||
|
readme=data.get("readme", ""),
|
||||||
|
config_hash=data.get("config_hash", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_categories(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get list of tool categories.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of category dicts with name, description, icon
|
||||||
|
"""
|
||||||
|
response = self._request("GET", "/categories")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
return response.json().get("data", [])
|
||||||
|
|
||||||
|
def publish_tool(
|
||||||
|
self,
|
||||||
|
config_yaml: str,
|
||||||
|
readme: str = "",
|
||||||
|
dry_run: bool = False,
|
||||||
|
visibility: str = "public"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Publish a tool to the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_yaml: Tool configuration YAML content
|
||||||
|
readme: README.md content
|
||||||
|
dry_run: If True, validate without publishing
|
||||||
|
visibility: Tool visibility - "public", "private", or "unlisted"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with PR URL or validation results
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"config": config_yaml,
|
||||||
|
"readme": readme,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"visibility": visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self._request(
|
||||||
|
"POST",
|
||||||
|
"/tools",
|
||||||
|
json_data=payload,
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 409:
|
||||||
|
# Version already exists
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
return response.json().get("data", {})
|
||||||
|
|
||||||
|
def get_my_tools(self) -> List[ToolInfo]:
|
||||||
|
"""
|
||||||
|
Get tools published by the authenticated user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ToolInfo objects
|
||||||
|
"""
|
||||||
|
response = self._request("GET", "/me/tools", require_auth=True)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
tools = response.json().get("data", [])
|
||||||
|
return [ToolInfo.from_dict(t) for t in tools]
|
||||||
|
|
||||||
|
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
|
||||||
|
"""
|
||||||
|
Get most popular tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of tools to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ToolInfo objects
|
||||||
|
"""
|
||||||
|
response = self._request(
|
||||||
|
"GET",
|
||||||
|
"/stats/popular",
|
||||||
|
params={"limit": limit}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
tools = response.json().get("data", [])
|
||||||
|
return [ToolInfo.from_dict(t) for t in tools]
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Index Caching
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_index(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get the full tool index, using cache when possible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_refresh: Force refresh from server
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Index dict with tools list
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
if not force_refresh:
|
||||||
|
cached = self._load_cached_index()
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Fetch from server
|
||||||
|
etag = self._get_cached_etag()
|
||||||
|
response = self._request("GET", "/index.json", etag=etag)
|
||||||
|
|
||||||
|
if response.status_code == 304:
|
||||||
|
# Not modified, use cache
|
||||||
|
cached = self._load_cached_index()
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
# Try to use stale cache on error
|
||||||
|
cached = self._load_cached_index(ignore_age=True)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Cache the response
|
||||||
|
new_etag = response.headers.get("ETag")
|
||||||
|
self._save_cached_index(data, new_etag)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _load_cached_index(self, ignore_age: bool = False) -> Optional[Dict]:
|
||||||
|
"""Load cached index if valid."""
|
||||||
|
if not INDEX_CACHE_FILE.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache_data = json.loads(INDEX_CACHE_FILE.read_text())
|
||||||
|
|
||||||
|
# Check age
|
||||||
|
if not ignore_age:
|
||||||
|
cached_at = datetime.fromisoformat(cache_data.get("_cached_at", ""))
|
||||||
|
if datetime.now() - cached_at > INDEX_CACHE_MAX_AGE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify checksum
|
||||||
|
if not self._verify_index_checksum(cache_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cache_data
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _save_cached_index(self, data: Dict, etag: Optional[str] = None) -> None:
|
||||||
|
"""Save index to cache."""
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
data["_cached_at"] = datetime.now().isoformat()
|
||||||
|
if etag:
|
||||||
|
data["_etag"] = etag
|
||||||
|
|
||||||
|
INDEX_CACHE_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
def _get_cached_etag(self) -> Optional[str]:
|
||||||
|
"""Get ETag from cached index."""
|
||||||
|
if not INDEX_CACHE_FILE.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache_data = json.loads(INDEX_CACHE_FILE.read_text())
|
||||||
|
return cache_data.get("_etag")
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _verify_index_checksum(self, data: Dict) -> bool:
|
||||||
|
"""Verify cached index integrity."""
|
||||||
|
checksum = data.get("checksum", "")
|
||||||
|
if not checksum:
|
||||||
|
return True # No checksum to verify
|
||||||
|
|
||||||
|
# Compute checksum of tools list
|
||||||
|
tools = data.get("tools", [])
|
||||||
|
content = json.dumps(tools, sort_keys=True)
|
||||||
|
computed = "sha256:" + hashlib.sha256(content.encode()).hexdigest()
|
||||||
|
|
||||||
|
return computed == checksum
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the local index cache."""
|
||||||
|
if INDEX_CACHE_FILE.exists():
|
||||||
|
INDEX_CACHE_FILE.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Convenience functions
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_client() -> RegistryClient:
|
||||||
|
"""Get a configured registry client instance."""
|
||||||
|
return RegistryClient()
|
||||||
|
|
||||||
|
|
||||||
|
def search(query: str, **kwargs) -> PaginatedResponse:
|
||||||
|
"""Search the registry for tools."""
|
||||||
|
return get_client().search_tools(query, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def install_tool(tool_spec: str, version: Optional[str] = None) -> DownloadResult:
|
||||||
|
"""
|
||||||
|
Download a tool for installation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_spec: Tool specification (owner/name or just name)
|
||||||
|
version: Version constraint
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DownloadResult with config YAML
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
# Parse tool spec
|
||||||
|
if "/" in tool_spec:
|
||||||
|
owner, name = tool_spec.split("/", 1)
|
||||||
|
else:
|
||||||
|
# Shorthand - try official namespace first
|
||||||
|
owner = "official"
|
||||||
|
name = tool_spec
|
||||||
|
|
||||||
|
try:
|
||||||
|
return client.download_tool(owner, name, version=version, install=True)
|
||||||
|
except RegistryError as e:
|
||||||
|
if e.code == "TOOL_NOT_FOUND" and owner == "official":
|
||||||
|
# Fall back to searching for most popular tool with this name
|
||||||
|
results = client.search_tools(name, per_page=1)
|
||||||
|
if results.data:
|
||||||
|
first = results.data[0]
|
||||||
|
return client.download_tool(
|
||||||
|
first["owner"],
|
||||||
|
first["name"],
|
||||||
|
version=version,
|
||||||
|
install=True
|
||||||
|
)
|
||||||
|
raise
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
"""Tool resolution with proper search order.
|
||||||
|
|
||||||
|
Implements the tool resolution order:
|
||||||
|
1. Local project: ./.cmdforge/<owner>/<name>/config.yaml
|
||||||
|
2. Global user: ~/.cmdforge/<owner>/<name>/config.yaml
|
||||||
|
3. Registry: Fetch from API, install to global, then run
|
||||||
|
4. Error if not found
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .tool import Tool, TOOLS_DIR, get_bin_dir, BIN_DIR
|
||||||
|
from .config import is_auto_fetch_enabled, load_config
|
||||||
|
from .manifest import load_manifest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Local project tools directories (support both legacy and documented paths)
|
||||||
|
LOCAL_TOOLS_DIRS = [Path(".cmdforge"), Path("cmdforge")]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolSpec:
|
||||||
|
"""Parsed tool specification."""
|
||||||
|
owner: Optional[str] # None for unqualified names like "summarize"
|
||||||
|
name: str
|
||||||
|
version: Optional[str] = None # Version constraint
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self) -> str:
|
||||||
|
"""Get full owner/name format."""
|
||||||
|
if self.owner:
|
||||||
|
return f"{self.owner}/{self.name}"
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_qualified(self) -> bool:
|
||||||
|
"""Check if this is a fully qualified name (owner/name)."""
|
||||||
|
return self.owner is not None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, spec: str) -> "ToolSpec":
|
||||||
|
"""
|
||||||
|
Parse a tool specification string.
|
||||||
|
|
||||||
|
Formats:
|
||||||
|
- "summarize" -> owner=None, name="summarize"
|
||||||
|
- "rob/summarize" -> owner="rob", name="summarize"
|
||||||
|
- "[email protected]" -> name="summarize", version="1.0.0"
|
||||||
|
- "rob/summarize@^1.0.0" -> owner="rob", name="summarize", version="^1.0.0"
|
||||||
|
"""
|
||||||
|
version = None
|
||||||
|
|
||||||
|
# Extract version if present
|
||||||
|
if "@" in spec:
|
||||||
|
spec, version = spec.rsplit("@", 1)
|
||||||
|
|
||||||
|
# Extract owner if present
|
||||||
|
if "/" in spec:
|
||||||
|
owner, name = spec.split("/", 1)
|
||||||
|
else:
|
||||||
|
owner = None
|
||||||
|
name = spec
|
||||||
|
|
||||||
|
return cls(owner=owner, name=name, version=version)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedTool:
|
||||||
|
"""Result of tool resolution."""
|
||||||
|
tool: Tool
|
||||||
|
source: str # "local", "global", "registry"
|
||||||
|
path: Path
|
||||||
|
owner: Optional[str] = None
|
||||||
|
version: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self) -> str:
|
||||||
|
if self.owner:
|
||||||
|
return f"{self.owner}/{self.tool.name}"
|
||||||
|
return self.tool.name
|
||||||
|
|
||||||
|
|
||||||
|
class ToolNotFoundError(Exception):
|
||||||
|
"""Raised when a tool cannot be found."""
|
||||||
|
def __init__(self, spec: ToolSpec, searched_paths: list):
|
||||||
|
self.spec = spec
|
||||||
|
self.searched_paths = searched_paths
|
||||||
|
super().__init__(f"Tool '{spec.full_name}' not found")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolResolver:
|
||||||
|
"""Resolves tool specifications to actual tool configs."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_dir: Optional[Path] = None,
|
||||||
|
auto_fetch: Optional[bool] = None,
|
||||||
|
verbose: bool = False
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the resolver.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_dir: Project root directory (default: cwd)
|
||||||
|
auto_fetch: Override auto-fetch setting
|
||||||
|
verbose: Print debug info
|
||||||
|
"""
|
||||||
|
self.project_dir = project_dir or Path.cwd()
|
||||||
|
self.verbose = verbose
|
||||||
|
|
||||||
|
# Determine auto-fetch setting
|
||||||
|
if auto_fetch is not None:
|
||||||
|
self.auto_fetch = auto_fetch
|
||||||
|
else:
|
||||||
|
self.auto_fetch = is_auto_fetch_enabled()
|
||||||
|
|
||||||
|
# Load project manifest if present
|
||||||
|
self.manifest = load_manifest()
|
||||||
|
|
||||||
|
def resolve(self, spec: str | ToolSpec) -> ResolvedTool:
|
||||||
|
"""
|
||||||
|
Resolve a tool specification to an actual tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec: Tool specification (string or ToolSpec)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedTool with loaded tool and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ToolNotFoundError: If tool cannot be found
|
||||||
|
"""
|
||||||
|
if isinstance(spec, str):
|
||||||
|
spec = ToolSpec.parse(spec)
|
||||||
|
|
||||||
|
searched_paths = []
|
||||||
|
|
||||||
|
# Get version constraint from manifest if available
|
||||||
|
version = spec.version
|
||||||
|
if not version and self.manifest:
|
||||||
|
for dep in self.manifest.dependencies:
|
||||||
|
if dep.tool_name == spec.name:
|
||||||
|
version = dep.version
|
||||||
|
if not spec.owner and dep.owner:
|
||||||
|
spec = ToolSpec(owner=dep.owner, name=spec.name, version=version)
|
||||||
|
break
|
||||||
|
|
||||||
|
# 1. Check local project directory
|
||||||
|
result = self._find_in_local(spec, searched_paths)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 2. Check global user directory
|
||||||
|
result = self._find_in_global(spec, searched_paths)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 3. Try fetching from registry
|
||||||
|
if self.auto_fetch:
|
||||||
|
result = self._fetch_from_registry(spec, version)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Not found
|
||||||
|
raise ToolNotFoundError(spec, searched_paths)
|
||||||
|
|
||||||
|
def _find_in_local(
|
||||||
|
self,
|
||||||
|
spec: ToolSpec,
|
||||||
|
searched_paths: list
|
||||||
|
) -> Optional[ResolvedTool]:
|
||||||
|
"""Search for tool in local project directory."""
|
||||||
|
local_dirs = [self.project_dir / path for path in LOCAL_TOOLS_DIRS]
|
||||||
|
local_dirs = [path for path in local_dirs if path.exists()]
|
||||||
|
if not local_dirs:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for local_dir in local_dirs:
|
||||||
|
# Try qualified path first if owner is specified
|
||||||
|
if spec.owner:
|
||||||
|
path = local_dir / spec.owner / spec.name / "config.yaml"
|
||||||
|
searched_paths.append(str(path))
|
||||||
|
if path.exists():
|
||||||
|
tool = self._load_tool_from_path(path)
|
||||||
|
if tool:
|
||||||
|
return ResolvedTool(
|
||||||
|
tool=tool,
|
||||||
|
source="local",
|
||||||
|
path=path.parent,
|
||||||
|
owner=spec.owner
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try unqualified path
|
||||||
|
path = local_dir / spec.name / "config.yaml"
|
||||||
|
searched_paths.append(str(path))
|
||||||
|
if path.exists():
|
||||||
|
tool = self._load_tool_from_path(path)
|
||||||
|
if tool:
|
||||||
|
return ResolvedTool(
|
||||||
|
tool=tool,
|
||||||
|
source="local",
|
||||||
|
path=path.parent
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search all owner directories for this tool name
|
||||||
|
# Priority: official first, then alphabetical for deterministic resolution
|
||||||
|
owner_dirs = [d for d in local_dir.iterdir() if d.is_dir() and not d.name.startswith(".")]
|
||||||
|
|
||||||
|
def owner_priority(d: Path) -> tuple:
|
||||||
|
if d.name == "official":
|
||||||
|
return (0, d.name)
|
||||||
|
return (1, d.name)
|
||||||
|
|
||||||
|
owner_dirs.sort(key=owner_priority)
|
||||||
|
|
||||||
|
for owner_dir in owner_dirs:
|
||||||
|
tool_dir = owner_dir / spec.name
|
||||||
|
config_path = tool_dir / "config.yaml"
|
||||||
|
if config_path.exists():
|
||||||
|
tool = self._load_tool_from_path(config_path)
|
||||||
|
if tool:
|
||||||
|
return ResolvedTool(
|
||||||
|
tool=tool,
|
||||||
|
source="local",
|
||||||
|
path=tool_dir,
|
||||||
|
owner=owner_dir.name
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_in_global(
|
||||||
|
self,
|
||||||
|
spec: ToolSpec,
|
||||||
|
searched_paths: list
|
||||||
|
) -> Optional[ResolvedTool]:
|
||||||
|
"""Search for tool in global user directory."""
|
||||||
|
global_dir = TOOLS_DIR
|
||||||
|
|
||||||
|
if not global_dir.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try qualified path first if owner is specified
|
||||||
|
if spec.owner:
|
||||||
|
path = global_dir / spec.owner / spec.name / "config.yaml"
|
||||||
|
searched_paths.append(str(path))
|
||||||
|
if path.exists():
|
||||||
|
tool = self._load_tool_from_path(path)
|
||||||
|
if tool:
|
||||||
|
return ResolvedTool(
|
||||||
|
tool=tool,
|
||||||
|
source="global",
|
||||||
|
path=path.parent,
|
||||||
|
owner=spec.owner
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try unqualified path (old-style tools without owner)
|
||||||
|
path = global_dir / spec.name / "config.yaml"
|
||||||
|
searched_paths.append(str(path))
|
||||||
|
if path.exists():
|
||||||
|
tool = self._load_tool_from_path(path)
|
||||||
|
if tool:
|
||||||
|
return ResolvedTool(
|
||||||
|
tool=tool,
|
||||||
|
source="global",
|
||||||
|
path=path.parent
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search all owner directories for this tool name
|
||||||
|
# Priority: official first, then alphabetical for deterministic resolution
|
||||||
|
owner_dirs = [
|
||||||
|
d for d in global_dir.iterdir()
|
||||||
|
if d.is_dir() and not d.name.startswith(".") and d.name not in ("registry",)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sort with official first, then alphabetical
|
||||||
|
def owner_priority(d: Path) -> tuple:
|
||||||
|
if d.name == "official":
|
||||||
|
return (0, d.name)
|
||||||
|
return (1, d.name)
|
||||||
|
|
||||||
|
owner_dirs.sort(key=owner_priority)
|
||||||
|
|
||||||
|
for owner_dir in owner_dirs:
|
||||||
|
tool_dir = owner_dir / spec.name
|
||||||
|
config_path = tool_dir / "config.yaml"
|
||||||
|
if config_path.exists():
|
||||||
|
tool = self._load_tool_from_path(config_path)
|
||||||
|
if tool:
|
||||||
|
return ResolvedTool(
|
||||||
|
tool=tool,
|
||||||
|
source="global",
|
||||||
|
path=tool_dir,
|
||||||
|
owner=owner_dir.name
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fetch_from_registry(
|
||||||
|
self,
|
||||||
|
spec: ToolSpec,
|
||||||
|
version: Optional[str] = None
|
||||||
|
) -> Optional[ResolvedTool]:
|
||||||
|
"""Fetch and install tool from registry."""
|
||||||
|
try:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from .registry_client import get_client, RegistryError
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(f"Fetching '{spec.full_name}' from registry...", file=sys.stderr)
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
# Determine owner for registry lookup
|
||||||
|
owner = spec.owner or "official"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = client.download_tool(
|
||||||
|
owner=owner,
|
||||||
|
name=spec.name,
|
||||||
|
version=version,
|
||||||
|
install=True
|
||||||
|
)
|
||||||
|
except RegistryError as e:
|
||||||
|
if e.code == "TOOL_NOT_FOUND" and not spec.owner:
|
||||||
|
# Try searching for most popular tool with this name
|
||||||
|
results = client.search_tools(spec.name, per_page=1)
|
||||||
|
if results.data:
|
||||||
|
first = results.data[0]
|
||||||
|
result = client.download_tool(
|
||||||
|
owner=first["owner"],
|
||||||
|
name=first["name"],
|
||||||
|
version=version,
|
||||||
|
install=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Install the tool locally
|
||||||
|
resolved = self._install_from_registry(
|
||||||
|
owner=result.owner,
|
||||||
|
name=result.name,
|
||||||
|
version=result.resolved_version,
|
||||||
|
config_yaml=result.config_yaml,
|
||||||
|
readme=result.readme,
|
||||||
|
config_hash=result.config_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(
|
||||||
|
f"Installed: {result.owner}/{result.name}@{result.resolved_version}",
|
||||||
|
file=sys.stderr
|
||||||
|
)
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Registry client not available
|
||||||
|
logger.debug("Registry client not available")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Registry fetch failed: {e}")
|
||||||
|
if self.verbose:
|
||||||
|
print(f"Registry fetch failed: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _install_from_registry(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
name: str,
|
||||||
|
version: str,
|
||||||
|
config_yaml: str,
|
||||||
|
readme: str = "",
|
||||||
|
config_hash: str = ""
|
||||||
|
) -> ResolvedTool:
|
||||||
|
"""Install a tool fetched from registry to global directory."""
|
||||||
|
# Verify hash if provided
|
||||||
|
if config_hash:
|
||||||
|
from .hash_utils import compute_yaml_hash
|
||||||
|
computed_hash = compute_yaml_hash(config_yaml)
|
||||||
|
if computed_hash != config_hash:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Hash mismatch for {owner}/{name}: expected {config_hash[:20]}..., "
|
||||||
|
f"got {computed_hash[:20]}... - content may have been tampered with"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create directory structure
|
||||||
|
tool_dir = TOOLS_DIR / owner / name
|
||||||
|
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Add registry_hash to config so we can track publish state
|
||||||
|
if config_hash:
|
||||||
|
parsed_config = yaml.safe_load(config_yaml)
|
||||||
|
parsed_config["registry_hash"] = config_hash
|
||||||
|
config_yaml = yaml.dump(parsed_config, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
# Write config
|
||||||
|
config_path = tool_dir / "config.yaml"
|
||||||
|
config_path.write_text(config_yaml)
|
||||||
|
|
||||||
|
# Write README if present
|
||||||
|
if readme:
|
||||||
|
readme_path = tool_dir / "README.md"
|
||||||
|
readme_path.write_text(readme)
|
||||||
|
|
||||||
|
# Load the tool
|
||||||
|
tool = self._load_tool_from_path(config_path)
|
||||||
|
|
||||||
|
# Create wrapper script (handling collisions)
|
||||||
|
self._create_wrapper_script(owner, name)
|
||||||
|
|
||||||
|
return ResolvedTool(
|
||||||
|
tool=tool,
|
||||||
|
source="registry",
|
||||||
|
path=tool_dir,
|
||||||
|
owner=owner,
|
||||||
|
version=version
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_wrapper_script(self, owner: str, name: str) -> Path:
|
||||||
|
"""Create wrapper script with collision handling."""
|
||||||
|
import stat
|
||||||
|
|
||||||
|
bin_dir = get_bin_dir()
|
||||||
|
|
||||||
|
# Check if short name wrapper exists
|
||||||
|
short_wrapper = bin_dir / name
|
||||||
|
|
||||||
|
if short_wrapper.exists():
|
||||||
|
# Check if it belongs to the same owner
|
||||||
|
existing_owner = self._get_wrapper_owner(short_wrapper)
|
||||||
|
if existing_owner and existing_owner != owner:
|
||||||
|
# Collision - use owner-name format
|
||||||
|
wrapper_name = f"{owner}-{name}"
|
||||||
|
else:
|
||||||
|
wrapper_name = name
|
||||||
|
else:
|
||||||
|
wrapper_name = name
|
||||||
|
|
||||||
|
wrapper_path = bin_dir / wrapper_name
|
||||||
|
|
||||||
|
# Generate wrapper script
|
||||||
|
import sys
|
||||||
|
python_path = sys.executable
|
||||||
|
|
||||||
|
script = f"""#!/bin/bash
|
||||||
|
# CmdForge wrapper for '{owner}/{name}'
|
||||||
|
# Auto-generated - do not edit
|
||||||
|
exec {python_path} -m cmdforge.runner {owner}/{name} "$@"
|
||||||
|
"""
|
||||||
|
|
||||||
|
wrapper_path.write_text(script)
|
||||||
|
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|
||||||
|
return wrapper_path
|
||||||
|
|
||||||
|
def _get_wrapper_owner(self, wrapper_path: Path) -> Optional[str]:
|
||||||
|
"""Extract owner from existing wrapper script."""
|
||||||
|
try:
|
||||||
|
content = wrapper_path.read_text()
|
||||||
|
# Look for pattern: cmdforge.runner owner/name
|
||||||
|
# Owner slugs can contain lowercase alphanumeric and hyphens
|
||||||
|
match = re.search(r'cmdforge\.runner\s+([a-z0-9][a-z0-9-]*)/([a-zA-Z0-9_-]+)', content)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_tool_from_path(self, config_path: Path) -> Optional[Tool]:
|
||||||
|
"""Load a tool from a specific config path."""
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(config_path.read_text())
|
||||||
|
|
||||||
|
# Handle legacy format
|
||||||
|
if "prompt" in data and "steps" not in data:
|
||||||
|
data = self._convert_legacy_format(data)
|
||||||
|
|
||||||
|
return Tool.from_dict(data)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
logger.warning(f"YAML error in {config_path}: {e}")
|
||||||
|
if self.verbose:
|
||||||
|
print(f"Error loading tool from '{config_path}': YAML syntax error", file=sys.stderr)
|
||||||
|
if hasattr(e, 'problem_mark') and e.problem_mark:
|
||||||
|
mark = e.problem_mark
|
||||||
|
print(f" Line {mark.line + 1}, column {mark.column + 1}", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
lines = config_path.read_text().split('\n')
|
||||||
|
if mark.line < len(lines):
|
||||||
|
print(file=sys.stderr)
|
||||||
|
if mark.line > 0:
|
||||||
|
print(f" {mark.line}: {lines[mark.line - 1]}", file=sys.stderr)
|
||||||
|
print(f" > {mark.line + 1}: {lines[mark.line]}", file=sys.stderr)
|
||||||
|
print(f" {' ' * (mark.column + 4)}^", file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if hasattr(e, 'problem') and e.problem:
|
||||||
|
print(f"\n Problem: {e.problem}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error loading tool from {config_path}: {e}")
|
||||||
|
if self.verbose:
|
||||||
|
print(f"Error loading tool from {config_path}: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _convert_legacy_format(self, data: dict) -> dict:
|
||||||
|
"""Convert legacy tool format to new format."""
|
||||||
|
steps = []
|
||||||
|
if data.get("prompt"):
|
||||||
|
steps.append({
|
||||||
|
"type": "prompt",
|
||||||
|
"prompt": data["prompt"],
|
||||||
|
"provider": data.get("provider", "mock"),
|
||||||
|
"output_var": "response"
|
||||||
|
})
|
||||||
|
|
||||||
|
arguments = []
|
||||||
|
for inp in data.get("inputs", []):
|
||||||
|
arguments.append({
|
||||||
|
"flag": inp.get("flag", f"--{inp['name']}"),
|
||||||
|
"variable": inp["name"],
|
||||||
|
"default": inp.get("default"),
|
||||||
|
"description": inp.get("description", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": data["name"],
|
||||||
|
"description": data.get("description", ""),
|
||||||
|
"arguments": arguments,
|
||||||
|
"steps": steps,
|
||||||
|
"output": "{response}" if steps else "{input}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Convenience functions
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def resolve_tool(spec: str, auto_fetch: Optional[bool] = None) -> ResolvedTool:
|
||||||
|
"""
|
||||||
|
Resolve a tool specification to an actual tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec: Tool specification (e.g., "summarize", "rob/[email protected]")
|
||||||
|
auto_fetch: Override auto-fetch setting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedTool with loaded tool and metadata
|
||||||
|
"""
|
||||||
|
resolver = ToolResolver(auto_fetch=auto_fetch)
|
||||||
|
return resolver.resolve(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def find_tool(name: str) -> Optional[ResolvedTool]:
|
||||||
|
"""
|
||||||
|
Find a tool by name without auto-fetching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Tool name or owner/name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedTool if found, None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resolver = ToolResolver(auto_fetch=False)
|
||||||
|
return resolver.resolve(name)
|
||||||
|
except ToolNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def install_from_registry(spec: str, version: Optional[str] = None) -> ResolvedTool:
|
||||||
|
"""
|
||||||
|
Install a tool from the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec: Tool specification
|
||||||
|
version: Version constraint
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedTool for installed tool
|
||||||
|
"""
|
||||||
|
from .registry_client import get_client
|
||||||
|
|
||||||
|
parsed = ToolSpec.parse(spec)
|
||||||
|
if version:
|
||||||
|
parsed.version = version
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
owner = parsed.owner or "official"
|
||||||
|
|
||||||
|
result = client.download_tool(
|
||||||
|
owner=owner,
|
||||||
|
name=parsed.name,
|
||||||
|
version=parsed.version,
|
||||||
|
install=True
|
||||||
|
)
|
||||||
|
|
||||||
|
resolver = ToolResolver(auto_fetch=False)
|
||||||
|
return resolver._install_from_registry(
|
||||||
|
owner=result.owner,
|
||||||
|
name=result.name,
|
||||||
|
version=result.resolved_version,
|
||||||
|
config_yaml=result.config_yaml,
|
||||||
|
readme=result.readme,
|
||||||
|
config_hash=result.config_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def uninstall_tool(spec: str) -> bool:
|
||||||
|
"""
|
||||||
|
Uninstall a tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec: Tool specification
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if tool was uninstalled
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
parsed = ToolSpec.parse(spec)
|
||||||
|
|
||||||
|
# Find the tool first
|
||||||
|
resolved = find_tool(spec)
|
||||||
|
if not resolved:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Remove tool directory
|
||||||
|
if resolved.path.exists():
|
||||||
|
shutil.rmtree(resolved.path)
|
||||||
|
|
||||||
|
# Remove wrapper script(s)
|
||||||
|
bin_dir = get_bin_dir()
|
||||||
|
|
||||||
|
# Remove short name wrapper if it belongs to this tool
|
||||||
|
short_wrapper = bin_dir / parsed.name
|
||||||
|
if short_wrapper.exists():
|
||||||
|
resolver = ToolResolver(auto_fetch=False)
|
||||||
|
wrapper_owner = resolver._get_wrapper_owner(short_wrapper)
|
||||||
|
if wrapper_owner == resolved.owner or wrapper_owner is None:
|
||||||
|
short_wrapper.unlink()
|
||||||
|
|
||||||
|
# Remove owner-name wrapper
|
||||||
|
if resolved.owner:
|
||||||
|
long_wrapper = bin_dir / f"{resolved.owner}-{parsed.name}"
|
||||||
|
if long_wrapper.exists():
|
||||||
|
long_wrapper.unlink()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_installed_tools() -> list[ResolvedTool]:
|
||||||
|
"""
|
||||||
|
List all installed tools (global only).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ResolvedTool objects
|
||||||
|
"""
|
||||||
|
tools = []
|
||||||
|
|
||||||
|
if not TOOLS_DIR.exists():
|
||||||
|
return tools
|
||||||
|
|
||||||
|
# Check owner directories
|
||||||
|
for item in TOOLS_DIR.iterdir():
|
||||||
|
if item.is_dir() and not item.name.startswith("."):
|
||||||
|
# Skip non-owner directories
|
||||||
|
if item.name in ("registry",):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this is an owner directory (contains tool subdirectories)
|
||||||
|
has_subtools = False
|
||||||
|
for subitem in item.iterdir():
|
||||||
|
if subitem.is_dir():
|
||||||
|
config = subitem / "config.yaml"
|
||||||
|
if config.exists():
|
||||||
|
has_subtools = True
|
||||||
|
try:
|
||||||
|
tool = Tool.from_dict(yaml.safe_load(config.read_text()))
|
||||||
|
tools.append(ResolvedTool(
|
||||||
|
tool=tool,
|
||||||
|
source="global",
|
||||||
|
path=subitem,
|
||||||
|
owner=item.name
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If no subtools, this might be an old-style tool directory
|
||||||
|
if not has_subtools:
|
||||||
|
config = item / "config.yaml"
|
||||||
|
if config.exists():
|
||||||
|
try:
|
||||||
|
tool = Tool.from_dict(yaml.safe_load(config.read_text()))
|
||||||
|
tools.append(ResolvedTool(
|
||||||
|
tool=tool,
|
||||||
|
source="global",
|
||||||
|
path=item
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return sorted(tools, key=lambda t: t.full_name)
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
"""Tool execution engine."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .tool import Tool, PromptStep, CodeStep, ToolStep
|
||||||
|
from .providers import call_provider, mock_provider
|
||||||
|
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec
|
||||||
|
from .manifest import load_manifest
|
||||||
|
from .profiles import load_profile
|
||||||
|
|
||||||
|
# Maximum recursion depth for nested tool calls
|
||||||
|
MAX_TOOL_DEPTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
def check_dependencies(tool: Tool, checked: set = None) -> list[str]:
|
||||||
|
"""
|
||||||
|
Check if all dependencies for a tool are available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool: Tool to check dependencies for
|
||||||
|
checked: Set of already checked tools (prevents infinite loops)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of missing dependency tool references
|
||||||
|
"""
|
||||||
|
if checked is None:
|
||||||
|
checked = set()
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
|
||||||
|
# Check explicit dependencies
|
||||||
|
for dep in tool.dependencies:
|
||||||
|
if dep in checked:
|
||||||
|
continue
|
||||||
|
checked.add(dep)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = resolve_tool(dep)
|
||||||
|
# Recursively check nested dependencies
|
||||||
|
nested_missing = check_dependencies(resolved.tool, checked)
|
||||||
|
missing.extend(nested_missing)
|
||||||
|
except ToolNotFoundError:
|
||||||
|
missing.append(dep)
|
||||||
|
|
||||||
|
# Also check tool steps for implicit dependencies
|
||||||
|
for step in tool.steps:
|
||||||
|
if isinstance(step, ToolStep):
|
||||||
|
tool_ref = step.tool
|
||||||
|
if tool_ref in checked:
|
||||||
|
continue
|
||||||
|
checked.add(tool_ref)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = resolve_tool(tool_ref)
|
||||||
|
# Recursively check nested dependencies
|
||||||
|
nested_missing = check_dependencies(resolved.tool, checked)
|
||||||
|
missing.extend(nested_missing)
|
||||||
|
except ToolNotFoundError:
|
||||||
|
missing.append(tool_ref)
|
||||||
|
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def substitute_variables(template: str, variables: dict) -> str:
|
||||||
|
"""
|
||||||
|
Substitute {variable} placeholders in a template.
|
||||||
|
|
||||||
|
Supports escaping: use {{ for literal { and }} for literal }
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: String with {var} placeholders
|
||||||
|
variables: Dict of variable name -> value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String with placeholders replaced
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> substitute_variables("Hello {name}", {"name": "World"})
|
||||||
|
'Hello World'
|
||||||
|
>>> substitute_variables("Use {{braces}}", {"braces": "nope"})
|
||||||
|
'Use {braces}'
|
||||||
|
"""
|
||||||
|
# Use unique placeholders for escaped braces
|
||||||
|
ESCAPE_OPEN = "\x00\x01OPEN\x01\x00"
|
||||||
|
ESCAPE_CLOSE = "\x00\x01CLOSE\x01\x00"
|
||||||
|
|
||||||
|
# First, replace escaped braces with placeholders
|
||||||
|
result = template.replace("{{", ESCAPE_OPEN).replace("}}", ESCAPE_CLOSE)
|
||||||
|
|
||||||
|
# Now do variable substitution
|
||||||
|
for name, value in variables.items():
|
||||||
|
result = result.replace(f"{{{name}}}", str(value) if value else "")
|
||||||
|
|
||||||
|
# Finally, restore escaped braces as single braces
|
||||||
|
result = result.replace(ESCAPE_OPEN, "{").replace(ESCAPE_CLOSE, "}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def execute_prompt_step(step: PromptStep, variables: dict, provider_override: str = None) -> tuple[str, bool]:
|
||||||
|
"""
|
||||||
|
Execute a prompt step.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
step: The prompt step to execute
|
||||||
|
variables: Current variable values
|
||||||
|
provider_override: Override the step's provider
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (output_value, success)
|
||||||
|
"""
|
||||||
|
# Build prompt with variable substitution
|
||||||
|
prompt = substitute_variables(step.prompt, variables)
|
||||||
|
|
||||||
|
# Inject profile system prompt if specified
|
||||||
|
if step.profile:
|
||||||
|
profile = load_profile(step.profile)
|
||||||
|
if profile and profile.system_prompt:
|
||||||
|
# Prepend system prompt to user prompt
|
||||||
|
prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}"
|
||||||
|
|
||||||
|
# Call provider
|
||||||
|
provider = provider_override or step.provider
|
||||||
|
|
||||||
|
if provider.lower() == "mock":
|
||||||
|
result = mock_provider(prompt)
|
||||||
|
else:
|
||||||
|
result = call_provider(provider, prompt)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
print(f"Error in prompt step: {result.error}", file=sys.stderr)
|
||||||
|
return "", False
|
||||||
|
|
||||||
|
return result.text, True
|
||||||
|
|
||||||
|
|
||||||
|
def execute_code_step(step: CodeStep, variables: dict, step_num: int = 0) -> tuple[dict, bool]:
|
||||||
|
"""
|
||||||
|
Execute a code step.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
step: The code step to execute
|
||||||
|
variables: Current variable values (available in code)
|
||||||
|
step_num: Step number for error reporting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (output_vars_dict, success)
|
||||||
|
"""
|
||||||
|
# Substitute variables in code (like {outputfile} -> actual value)
|
||||||
|
code = substitute_variables(step.code, variables)
|
||||||
|
|
||||||
|
# Create execution environment with variables
|
||||||
|
local_vars = dict(variables)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute the code with substituted variables
|
||||||
|
exec(code, {"__builtins__": __builtins__}, local_vars)
|
||||||
|
|
||||||
|
# Support comma-separated output vars (e.g., "a, b, c")
|
||||||
|
output_vars = [v.strip() for v in step.output_var.split(',')]
|
||||||
|
outputs = {}
|
||||||
|
for var in output_vars:
|
||||||
|
outputs[var] = str(local_vars.get(var, ""))
|
||||||
|
|
||||||
|
return outputs, True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
code_lines = code.split('\n')
|
||||||
|
|
||||||
|
# Extract line number from traceback
|
||||||
|
error_line = None
|
||||||
|
tb = traceback.extract_tb(e.__traceback__)
|
||||||
|
for frame in tb:
|
||||||
|
if frame.filename == '<string>': # exec'd code
|
||||||
|
error_line = frame.lineno
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"Error in code step (step {step_num}):", file=sys.stderr)
|
||||||
|
print(f" {type(e).__name__}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if error_line and 0 < error_line <= len(code_lines):
|
||||||
|
print(file=sys.stderr)
|
||||||
|
# Show context: line before, error line, line after
|
||||||
|
start = max(0, error_line - 2)
|
||||||
|
end = min(len(code_lines), error_line + 1)
|
||||||
|
for i in range(start, end):
|
||||||
|
marker = ">>>" if i == error_line - 1 else " "
|
||||||
|
print(f" {marker} {i+1}: {code_lines[i]}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(file=sys.stderr)
|
||||||
|
print(f" Available variables: {list(variables.keys())}", file=sys.stderr)
|
||||||
|
return {}, False
|
||||||
|
|
||||||
|
|
||||||
|
def _print_call_stack(call_stack: list, error_msg: str) -> None:
|
||||||
|
"""Print the tool call stack for error reporting."""
|
||||||
|
if len(call_stack) > 1:
|
||||||
|
print("Error in tool chain:", file=sys.stderr)
|
||||||
|
for i, (name, step_num) in enumerate(call_stack):
|
||||||
|
indent = " " * i
|
||||||
|
arrow = "-> " if i > 0 else ""
|
||||||
|
step_info = f" (step {step_num})" if step_num else ""
|
||||||
|
print(f"{indent}{arrow}{name}{step_info}", file=sys.stderr)
|
||||||
|
print(f"{' ' * len(call_stack)}{error_msg}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"Error: {error_msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_tool_step(
|
||||||
|
step: ToolStep,
|
||||||
|
variables: dict,
|
||||||
|
depth: int = 0,
|
||||||
|
provider_override: Optional[str] = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
verbose: bool = False,
|
||||||
|
call_stack: Optional[list] = None
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""
|
||||||
|
Execute a tool step by calling another tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
step: The tool step to execute
|
||||||
|
variables: Current variable values
|
||||||
|
depth: Current recursion depth
|
||||||
|
provider_override: Override provider for nested calls
|
||||||
|
dry_run: Just show what would happen
|
||||||
|
verbose: Show debug info
|
||||||
|
call_stack: List of (tool_name, step_num) tuples for error reporting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (output_value, success)
|
||||||
|
"""
|
||||||
|
if call_stack is None:
|
||||||
|
call_stack = []
|
||||||
|
|
||||||
|
if depth >= MAX_TOOL_DEPTH:
|
||||||
|
_print_call_stack(call_stack, f"Maximum tool nesting depth ({MAX_TOOL_DEPTH}) exceeded")
|
||||||
|
return "", False
|
||||||
|
|
||||||
|
# Resolve the tool reference
|
||||||
|
try:
|
||||||
|
resolved = resolve_tool(step.tool)
|
||||||
|
nested_tool = resolved.tool
|
||||||
|
except ToolNotFoundError as e:
|
||||||
|
_print_call_stack(call_stack, f"Tool '{step.tool}' not found")
|
||||||
|
print(f"Hint: Install it with: cmdforge install {step.tool}", file=sys.stderr)
|
||||||
|
return "", False
|
||||||
|
|
||||||
|
# Prepare input by substituting variables
|
||||||
|
input_text = substitute_variables(step.input_template, variables)
|
||||||
|
|
||||||
|
# Prepare arguments by substituting variables in arg values
|
||||||
|
custom_args = {}
|
||||||
|
for key, value in step.args.items():
|
||||||
|
custom_args[key] = substitute_variables(str(value), variables)
|
||||||
|
|
||||||
|
# Determine effective provider (step override > parent override)
|
||||||
|
effective_provider = step.provider or provider_override
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f"[verbose] Tool step: calling {step.tool}", file=sys.stderr)
|
||||||
|
print(f"[verbose] Input length: {len(input_text)} chars", file=sys.stderr)
|
||||||
|
print(f"[verbose] Args: {list(custom_args.keys())}", file=sys.stderr)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return f"[DRY RUN - would call tool {step.tool}]", True
|
||||||
|
|
||||||
|
# Run the nested tool
|
||||||
|
output, exit_code = run_tool(
|
||||||
|
tool=nested_tool,
|
||||||
|
input_text=input_text,
|
||||||
|
custom_args=custom_args,
|
||||||
|
provider_override=effective_provider,
|
||||||
|
dry_run=dry_run,
|
||||||
|
show_prompt=False,
|
||||||
|
verbose=verbose,
|
||||||
|
_depth=depth + 1,
|
||||||
|
_call_stack=call_stack
|
||||||
|
)
|
||||||
|
|
||||||
|
return output, exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_tool(
|
||||||
|
tool: Tool,
|
||||||
|
input_text: str,
|
||||||
|
custom_args: dict,
|
||||||
|
provider_override: Optional[str] = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
show_prompt: bool = False,
|
||||||
|
verbose: bool = False,
|
||||||
|
_depth: int = 0,
|
||||||
|
_call_stack: Optional[list] = None
|
||||||
|
) -> tuple[str, int]:
|
||||||
|
"""
|
||||||
|
Execute a tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool: Tool definition
|
||||||
|
input_text: Input content
|
||||||
|
custom_args: Custom argument values
|
||||||
|
provider_override: Override all providers
|
||||||
|
dry_run: Just show what would happen
|
||||||
|
show_prompt: Show prompts in addition to output
|
||||||
|
verbose: Show debug info
|
||||||
|
_depth: Internal recursion depth tracker
|
||||||
|
_call_stack: Internal call stack for error reporting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (output_text, exit_code)
|
||||||
|
"""
|
||||||
|
# Initialize call stack
|
||||||
|
if _call_stack is None:
|
||||||
|
_call_stack = []
|
||||||
|
current_stack = _call_stack + [(tool.name, None)]
|
||||||
|
|
||||||
|
# Check dependencies on first level only (not for nested calls)
|
||||||
|
if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)):
|
||||||
|
missing = check_dependencies(tool)
|
||||||
|
if missing:
|
||||||
|
print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr)
|
||||||
|
print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr)
|
||||||
|
# Continue anyway - the actual step execution will fail with a better error
|
||||||
|
|
||||||
|
# Initialize variables with input and arguments
|
||||||
|
variables = {"input": input_text}
|
||||||
|
|
||||||
|
# Add argument values (with defaults)
|
||||||
|
for arg in tool.arguments:
|
||||||
|
value = custom_args.get(arg.variable, arg.default)
|
||||||
|
variables[arg.variable] = value
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f"[verbose] Tool: {tool.name}", file=sys.stderr)
|
||||||
|
print(f"[verbose] Variables: {list(variables.keys())}", file=sys.stderr)
|
||||||
|
print(f"[verbose] Steps: {len(tool.steps)}", file=sys.stderr)
|
||||||
|
|
||||||
|
# If no steps, just substitute output template
|
||||||
|
if not tool.steps:
|
||||||
|
output = substitute_variables(tool.output, variables)
|
||||||
|
return output, 0
|
||||||
|
|
||||||
|
# Execute each step
|
||||||
|
for i, step in enumerate(tool.steps):
|
||||||
|
if verbose:
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
|
step_type = "PROMPT"
|
||||||
|
elif isinstance(step, CodeStep):
|
||||||
|
step_type = "CODE"
|
||||||
|
elif isinstance(step, ToolStep):
|
||||||
|
step_type = f"TOOL({step.tool})"
|
||||||
|
else:
|
||||||
|
step_type = "UNKNOWN"
|
||||||
|
print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
|
||||||
|
|
||||||
|
if isinstance(step, PromptStep):
|
||||||
|
# Show prompt if requested
|
||||||
|
if show_prompt or dry_run:
|
||||||
|
prompt = substitute_variables(step.prompt, variables)
|
||||||
|
print(f"=== PROMPT (Step {i+1}, provider={step.provider}) ===", file=sys.stderr)
|
||||||
|
print(prompt, file=sys.stderr)
|
||||||
|
print("=== END PROMPT ===", file=sys.stderr)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
variables[step.output_var] = f"[DRY RUN - would call {step.provider}]"
|
||||||
|
else:
|
||||||
|
output, success = execute_prompt_step(step, variables, provider_override)
|
||||||
|
if not success:
|
||||||
|
return "", 2
|
||||||
|
variables[step.output_var] = output
|
||||||
|
|
||||||
|
elif isinstance(step, CodeStep):
|
||||||
|
if verbose or dry_run:
|
||||||
|
print(f"=== CODE (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
|
||||||
|
print(step.code, file=sys.stderr)
|
||||||
|
print("=== END CODE ===", file=sys.stderr)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
# Handle comma-separated output vars for dry run
|
||||||
|
for var in [v.strip() for v in step.output_var.split(',')]:
|
||||||
|
variables[var] = "[DRY RUN - would execute code]"
|
||||||
|
else:
|
||||||
|
outputs, success = execute_code_step(step, variables, step_num=i+1)
|
||||||
|
if not success:
|
||||||
|
return "", 1
|
||||||
|
# Merge all output vars into variables
|
||||||
|
variables.update(outputs)
|
||||||
|
|
||||||
|
elif isinstance(step, ToolStep):
|
||||||
|
if verbose or dry_run:
|
||||||
|
print(f"=== TOOL (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
|
||||||
|
print(f" Calling: {step.tool}", file=sys.stderr)
|
||||||
|
print(f" Args: {step.args}", file=sys.stderr)
|
||||||
|
print("=== END TOOL ===", file=sys.stderr)
|
||||||
|
|
||||||
|
# Update call stack with current step number
|
||||||
|
step_stack = _call_stack + [(tool.name, i + 1)]
|
||||||
|
output, success = execute_tool_step(
|
||||||
|
step,
|
||||||
|
variables,
|
||||||
|
depth=_depth,
|
||||||
|
provider_override=provider_override,
|
||||||
|
dry_run=dry_run,
|
||||||
|
verbose=verbose,
|
||||||
|
call_stack=step_stack
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
return "", 3
|
||||||
|
variables[step.output_var] = output
|
||||||
|
|
||||||
|
# Generate final output
|
||||||
|
output = substitute_variables(tool.output, variables)
|
||||||
|
|
||||||
|
return output, 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
|
||||||
|
"""
|
||||||
|
Create an argument parser for a tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool: Tool definition
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured ArgumentParser
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog=tool.name,
|
||||||
|
description=tool.description or f"CmdForge: {tool.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Universal flags
|
||||||
|
parser.add_argument("-i", "--input", dest="input_file",
|
||||||
|
help="Input file (reads from stdin if piped)")
|
||||||
|
parser.add_argument("--stdin", action="store_true",
|
||||||
|
help="Read input interactively from stdin (type then Ctrl+D)")
|
||||||
|
parser.add_argument("-o", "--output", dest="output_file",
|
||||||
|
help="Output file (writes to stdout if omitted)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="Show what would happen without executing")
|
||||||
|
parser.add_argument("--show-prompt", action="store_true",
|
||||||
|
help="Show prompts in addition to output")
|
||||||
|
parser.add_argument("-p", "--provider",
|
||||||
|
help="Override provider (e.g., --provider mock)")
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true",
|
||||||
|
help="Show debug information")
|
||||||
|
|
||||||
|
# Tool-specific flags from arguments
|
||||||
|
for arg in tool.arguments:
|
||||||
|
parser.add_argument(
|
||||||
|
arg.flag,
|
||||||
|
dest=arg.variable,
|
||||||
|
default=arg.default,
|
||||||
|
help=arg.description or f"{arg.variable} (default: {arg.default})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point for tool execution via wrapper script."""
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python -m cmdforge.runner <tool_name> [args...]", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
tool_spec = sys.argv[1]
|
||||||
|
|
||||||
|
# Resolve tool using new resolution order
|
||||||
|
try:
|
||||||
|
resolved = resolve_tool(tool_spec)
|
||||||
|
tool = resolved.tool
|
||||||
|
except ToolNotFoundError as e:
|
||||||
|
print(f"Error: Tool '{tool_spec}' not found", file=sys.stderr)
|
||||||
|
print(f"Searched: {', '.join(e.searched_paths[:3])}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Check for manifest overrides
|
||||||
|
manifest = load_manifest()
|
||||||
|
provider_override_from_manifest = None
|
||||||
|
if manifest:
|
||||||
|
override = manifest.get_override(tool_spec)
|
||||||
|
if override and override.provider:
|
||||||
|
provider_override_from_manifest = override.provider
|
||||||
|
|
||||||
|
# Parse remaining arguments
|
||||||
|
parser = create_argument_parser(tool)
|
||||||
|
args = parser.parse_args(sys.argv[2:])
|
||||||
|
|
||||||
|
# Read input
|
||||||
|
if args.input_file:
|
||||||
|
# Read from file
|
||||||
|
input_path = Path(args.input_file)
|
||||||
|
if not input_path.exists():
|
||||||
|
print(f"Error: Input file not found: {args.input_file}", file=sys.stderr)
|
||||||
|
sys.exit(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
|
||||||
|
custom_args = {}
|
||||||
|
for arg in tool.arguments:
|
||||||
|
value = getattr(args, arg.variable, None)
|
||||||
|
if value is not None:
|
||||||
|
custom_args[arg.variable] = value
|
||||||
|
|
||||||
|
# Determine provider override (CLI flag takes precedence over manifest)
|
||||||
|
effective_provider = args.provider or provider_override_from_manifest
|
||||||
|
|
||||||
|
# Run tool
|
||||||
|
output, exit_code = run_tool(
|
||||||
|
tool=tool,
|
||||||
|
input_text=input_text,
|
||||||
|
custom_args=custom_args,
|
||||||
|
provider_override=effective_provider,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
show_prompt=args.show_prompt,
|
||||||
|
verbose=args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write output
|
||||||
|
if exit_code == 0 and output:
|
||||||
|
if args.output_file:
|
||||||
|
Path(args.output_file).write_text(output)
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -10,7 +10,7 @@ import yaml
|
|||||||
|
|
||||||
|
|
||||||
# Default tools directory
|
# Default tools directory
|
||||||
TOOLS_DIR = Path.home() / ".smarttools"
|
TOOLS_DIR = Path.home() / ".cmdforge"
|
||||||
|
|
||||||
# Default bin directory for wrapper scripts
|
# Default bin directory for wrapper scripts
|
||||||
BIN_DIR = Path.home() / ".local" / "bin"
|
BIN_DIR = Path.home() / ".local" / "bin"
|
||||||
@@ -49,6 +49,8 @@ class PromptStep:
|
|||||||
provider: str # Provider name
|
provider: str # Provider name
|
||||||
output_var: str # Variable to store output
|
output_var: str # Variable to store output
|
||||||
prompt_file: Optional[str] = None # Optional filename for external prompt
|
prompt_file: Optional[str] = None # Optional filename for external prompt
|
||||||
|
profile: Optional[str] = None # Optional AI persona profile name
|
||||||
|
name: Optional[str] = None # Optional display name for the step
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = {
|
d = {
|
||||||
@@ -59,6 +61,10 @@ class PromptStep:
|
|||||||
}
|
}
|
||||||
if self.prompt_file:
|
if self.prompt_file:
|
||||||
d["prompt_file"] = self.prompt_file
|
d["prompt_file"] = self.prompt_file
|
||||||
|
if self.profile:
|
||||||
|
d["profile"] = self.profile
|
||||||
|
if self.name:
|
||||||
|
d["name"] = self.name
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -67,7 +73,9 @@ class PromptStep:
|
|||||||
prompt=data["prompt"],
|
prompt=data["prompt"],
|
||||||
provider=data["provider"],
|
provider=data["provider"],
|
||||||
output_var=data["output_var"],
|
output_var=data["output_var"],
|
||||||
prompt_file=data.get("prompt_file")
|
prompt_file=data.get("prompt_file"),
|
||||||
|
profile=data.get("profile"),
|
||||||
|
name=data.get("name")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +85,7 @@ class CodeStep:
|
|||||||
code: str # Python code (inline or loaded from file)
|
code: str # Python code (inline or loaded from file)
|
||||||
output_var: str # Variable name(s) to capture (comma-separated for multiple)
|
output_var: str # Variable name(s) to capture (comma-separated for multiple)
|
||||||
code_file: Optional[str] = None # Optional filename for external code
|
code_file: Optional[str] = None # Optional filename for external code
|
||||||
|
name: Optional[str] = None # Optional display name for the step
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = {
|
d = {
|
||||||
@@ -86,6 +95,8 @@ class CodeStep:
|
|||||||
}
|
}
|
||||||
if self.code_file:
|
if self.code_file:
|
||||||
d["code_file"] = self.code_file
|
d["code_file"] = self.code_file
|
||||||
|
if self.name:
|
||||||
|
d["name"] = self.name
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -93,11 +104,82 @@ class CodeStep:
|
|||||||
return cls(
|
return cls(
|
||||||
code=data.get("code", ""),
|
code=data.get("code", ""),
|
||||||
output_var=data["output_var"],
|
output_var=data["output_var"],
|
||||||
code_file=data.get("code_file")
|
code_file=data.get("code_file"),
|
||||||
|
name=data.get("name")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
Step = PromptStep | CodeStep
|
@dataclass
|
||||||
|
class ToolStep:
|
||||||
|
"""A step that calls another tool."""
|
||||||
|
tool: str # Tool reference (owner/name or just name)
|
||||||
|
output_var: str # Variable to store output
|
||||||
|
input_template: str = "{input}" # Input template (supports variable substitution)
|
||||||
|
args: dict = field(default_factory=dict) # Arguments to pass to the tool
|
||||||
|
provider: Optional[str] = None # Provider override for the called tool
|
||||||
|
name: Optional[str] = None # Optional display name for the step
|
||||||
|
|
||||||
|
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
|
||||||
|
if self.name:
|
||||||
|
d["name"] = self.name
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ToolStep":
|
||||||
|
return cls(
|
||||||
|
tool=data["tool"],
|
||||||
|
output_var=data["output_var"],
|
||||||
|
input_template=data.get("input", "{input}"),
|
||||||
|
args=data.get("args", {}),
|
||||||
|
provider=data.get("provider"),
|
||||||
|
name=data.get("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Step = PromptStep | CodeStep | ToolStep
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolSource:
|
||||||
|
"""Attribution and source information for imported/external tools."""
|
||||||
|
type: str = "original" # "original", "imported", "forked"
|
||||||
|
license: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
author: Optional[str] = None
|
||||||
|
original_tool: Optional[str] = None # e.g., "fabric/patterns/extract_wisdom"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = {"type": self.type}
|
||||||
|
if self.license:
|
||||||
|
d["license"] = self.license
|
||||||
|
if self.url:
|
||||||
|
d["url"] = self.url
|
||||||
|
if self.author:
|
||||||
|
d["author"] = self.author
|
||||||
|
if self.original_tool:
|
||||||
|
d["original_tool"] = self.original_tool
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ToolSource":
|
||||||
|
return cls(
|
||||||
|
type=data.get("type", "original"),
|
||||||
|
license=data.get("license"),
|
||||||
|
url=data.get("url"),
|
||||||
|
author=data.get("author"),
|
||||||
|
original_tool=data.get("original_tool"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Default categories for organizing tools
|
# Default categories for organizing tools
|
||||||
@@ -106,13 +188,16 @@ DEFAULT_CATEGORIES = ["Text", "Developer", "Data", "Other"]
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Tool:
|
class Tool:
|
||||||
"""A SmartTools tool definition."""
|
"""A CmdForge tool definition."""
|
||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
category: str = "Other" # Tool category for organization
|
category: str = "Other" # Tool category for organization
|
||||||
arguments: List[ToolArgument] = field(default_factory=list)
|
arguments: List[ToolArgument] = field(default_factory=list)
|
||||||
steps: List[Step] = field(default_factory=list)
|
steps: List[Step] = field(default_factory=list)
|
||||||
output: str = "{input}" # Output template
|
output: str = "{input}" # Output template
|
||||||
|
dependencies: List[str] = field(default_factory=list) # Required tools for meta-tools
|
||||||
|
source: Optional[ToolSource] = None # Attribution for imported/external tools
|
||||||
|
version: str = "" # Tool version
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "Tool":
|
def from_dict(cls, data: dict) -> "Tool":
|
||||||
@@ -126,6 +211,13 @@ class Tool:
|
|||||||
steps.append(PromptStep.from_dict(step))
|
steps.append(PromptStep.from_dict(step))
|
||||||
elif step.get("type") == "code":
|
elif step.get("type") == "code":
|
||||||
steps.append(CodeStep.from_dict(step))
|
steps.append(CodeStep.from_dict(step))
|
||||||
|
elif step.get("type") == "tool":
|
||||||
|
steps.append(ToolStep.from_dict(step))
|
||||||
|
|
||||||
|
# Parse source attribution if present
|
||||||
|
source = None
|
||||||
|
if "source" in data:
|
||||||
|
source = ToolSource.from_dict(data["source"])
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
@@ -133,7 +225,10 @@ class Tool:
|
|||||||
category=data.get("category", "Other"),
|
category=data.get("category", "Other"),
|
||||||
arguments=arguments,
|
arguments=arguments,
|
||||||
steps=steps,
|
steps=steps,
|
||||||
output=data.get("output", "{input}")
|
output=data.get("output", "{input}"),
|
||||||
|
dependencies=data.get("dependencies", []),
|
||||||
|
source=source,
|
||||||
|
version=data.get("version", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
@@ -141,9 +236,16 @@ class Tool:
|
|||||||
"name": self.name,
|
"name": self.name,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
}
|
}
|
||||||
|
if self.version:
|
||||||
|
d["version"] = self.version
|
||||||
# Only include category if it's not the default
|
# Only include category if it's not the default
|
||||||
if self.category and self.category != "Other":
|
if self.category and self.category != "Other":
|
||||||
d["category"] = self.category
|
d["category"] = self.category
|
||||||
|
# Include source attribution if present
|
||||||
|
if self.source:
|
||||||
|
d["source"] = self.source.to_dict()
|
||||||
|
if self.dependencies:
|
||||||
|
d["dependencies"] = self.dependencies
|
||||||
d["arguments"] = [arg.to_dict() for arg in self.arguments]
|
d["arguments"] = [arg.to_dict() for arg in self.arguments]
|
||||||
d["steps"] = [step.to_dict() for step in self.steps]
|
d["steps"] = [step.to_dict() for step in self.steps]
|
||||||
d["output"] = self.output
|
d["output"] = self.output
|
||||||
@@ -230,8 +332,34 @@ def load_tool(name: str) -> Optional[Tool]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Tool.from_dict(data)
|
return Tool.from_dict(data)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
import sys
|
||||||
|
print(f"Error loading tool '{name}': YAML syntax error", file=sys.stderr)
|
||||||
|
if hasattr(e, 'problem_mark') and e.problem_mark:
|
||||||
|
mark = e.problem_mark
|
||||||
|
print(f" Line {mark.line + 1}, column {mark.column + 1}", file=sys.stderr)
|
||||||
|
# Show the problematic line with context
|
||||||
|
try:
|
||||||
|
lines = config_path.read_text().split('\n')
|
||||||
|
if mark.line < len(lines):
|
||||||
|
print(file=sys.stderr)
|
||||||
|
# Show line before for context
|
||||||
|
if mark.line > 0:
|
||||||
|
print(f" {mark.line}: {lines[mark.line - 1]}", file=sys.stderr)
|
||||||
|
print(f" > {mark.line + 1}: {lines[mark.line]}", file=sys.stderr)
|
||||||
|
print(f" {' ' * (mark.column + 4)}^", file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if hasattr(e, 'problem') and e.problem:
|
||||||
|
print(f"\n Problem: {e.problem}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except KeyError as e:
|
||||||
|
import sys
|
||||||
|
print(f"Error loading tool '{name}': Missing required field {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading tool {name}: {e}")
|
import sys
|
||||||
|
print(f"Error loading tool '{name}': {e}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -275,13 +403,13 @@ def create_wrapper_script(name: str) -> Path:
|
|||||||
bin_dir = get_bin_dir()
|
bin_dir = get_bin_dir()
|
||||||
wrapper_path = bin_dir / name
|
wrapper_path = bin_dir / name
|
||||||
|
|
||||||
# Use the current Python interpreter to ensure smarttools is available
|
# Use the current Python interpreter to ensure cmdforge is available
|
||||||
python_path = sys.executable
|
python_path = sys.executable
|
||||||
|
|
||||||
script = f"""#!/bin/bash
|
script = f"""#!/bin/bash
|
||||||
# SmartTools wrapper for '{name}'
|
# CmdForge wrapper for '{name}'
|
||||||
# Auto-generated - do not edit
|
# Auto-generated - do not edit
|
||||||
exec {python_path} -m smarttools.runner {name} "$@"
|
exec {python_path} -m cmdforge.runner {name} "$@"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
wrapper_path.write_text(script)
|
wrapper_path.write_text(script)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Web UI blueprint for CmdForge."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
# Use absolute paths for templates and static folders
|
||||||
|
_pkg_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
web_bp = Blueprint(
|
||||||
|
"web",
|
||||||
|
__name__,
|
||||||
|
template_folder=os.path.join(_pkg_dir, "templates"),
|
||||||
|
static_folder=os.path.join(_pkg_dir, "static"),
|
||||||
|
static_url_path="/web-static",
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Web app factory for CmdForge UI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from flask import Flask, render_template, session
|
||||||
|
|
||||||
|
# Initialize Sentry before importing Flask app (if DSN is configured)
|
||||||
|
_sentry_dsn = os.environ.get("SENTRY_DSN")
|
||||||
|
if _sentry_dsn:
|
||||||
|
try:
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
|
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=_sentry_dsn,
|
||||||
|
integrations=[FlaskIntegration()],
|
||||||
|
traces_sample_rate=float(os.environ.get("SENTRY_TRACES_RATE", "0.1")),
|
||||||
|
profiles_sample_rate=float(os.environ.get("SENTRY_PROFILES_RATE", "0.1")),
|
||||||
|
environment=os.environ.get("CMDFORGE_ENV", "development"),
|
||||||
|
send_default_pii=False, # Privacy: don't send user IPs, emails, etc.
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
pass # sentry-sdk not installed
|
||||||
|
|
||||||
|
from cmdforge.registry import app as registry_app
|
||||||
|
|
||||||
|
from . import web_bp
|
||||||
|
from .auth import login, register, logout
|
||||||
|
from .filters import register_filters
|
||||||
|
from .forum import forum_bp
|
||||||
|
from .sessions import SQLiteSessionInterface, cleanup_expired_sessions
|
||||||
|
|
||||||
|
|
||||||
|
def create_web_app() -> Flask:
|
||||||
|
app = registry_app.create_app()
|
||||||
|
|
||||||
|
# Import routes BEFORE registering blueprint
|
||||||
|
from . import routes # noqa: F401
|
||||||
|
|
||||||
|
app.register_blueprint(web_bp)
|
||||||
|
app.register_blueprint(forum_bp)
|
||||||
|
|
||||||
|
# Session configuration
|
||||||
|
app.session_interface = SQLiteSessionInterface(cookie_name="cmdforge_session")
|
||||||
|
app.config["SESSION_COOKIE_NAME"] = "cmdforge_session"
|
||||||
|
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("CMDFORGE_ENV") == "production"
|
||||||
|
app.config["SHOW_ADS"] = os.environ.get("CMDFORGE_SHOW_ADS", "").lower() == "true"
|
||||||
|
|
||||||
|
# CSRF token generator
|
||||||
|
app.config["CSRF_GENERATOR"] = lambda: secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# Jinja globals
|
||||||
|
def _csrf_token():
|
||||||
|
token = session.get("csrf_token")
|
||||||
|
if not token:
|
||||||
|
token = app.config["CSRF_GENERATOR"]()
|
||||||
|
session["csrf_token"] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
app.jinja_env.globals["csrf_token"] = _csrf_token
|
||||||
|
register_filters(app)
|
||||||
|
|
||||||
|
cleanup_expired_sessions()
|
||||||
|
|
||||||
|
# Error handlers
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found_error(error):
|
||||||
|
return render_template("errors/404.html"), 404
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(error):
|
||||||
|
return render_template("errors/500.html"), 500
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_web_app().run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Web UI authentication routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from flask import current_app, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
|
from . import web_bp
|
||||||
|
|
||||||
|
|
||||||
|
def _api_post(path: str, payload: Dict) -> Dict:
|
||||||
|
client = current_app.test_client()
|
||||||
|
response = client.post(path, json=payload)
|
||||||
|
return {
|
||||||
|
"status": response.status_code,
|
||||||
|
"data": response.get_json(silent=True) or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf_token() -> str:
|
||||||
|
token = session.get("csrf_token")
|
||||||
|
if not token:
|
||||||
|
token = current_app.config["CSRF_GENERATOR"]()
|
||||||
|
session["csrf_token"] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_csrf() -> bool:
|
||||||
|
form_token = request.form.get("csrf_token", "")
|
||||||
|
session_token = session.get("csrf_token", "")
|
||||||
|
return bool(form_token and session_token and form_token == session_token)
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
next_url = request.args.get("next") or request.form.get("next")
|
||||||
|
if request.method == "POST":
|
||||||
|
if not _validate_csrf():
|
||||||
|
return render_template(
|
||||||
|
"pages/login.html",
|
||||||
|
errors=["Invalid CSRF token"],
|
||||||
|
next_url=next_url,
|
||||||
|
)
|
||||||
|
email = request.form.get("email", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
result = _api_post("/api/v1/login", {"email": email, "password": password})
|
||||||
|
if result["status"] == 200:
|
||||||
|
data = result["data"].get("data", {})
|
||||||
|
session.clear()
|
||||||
|
session["auth_token"] = data.get("token")
|
||||||
|
session["publisher"] = data.get("publisher", {})
|
||||||
|
session["user"] = data.get("publisher", {})
|
||||||
|
current_app.session_interface.rotate_session(session)
|
||||||
|
if next_url and next_url.startswith("/"):
|
||||||
|
return redirect(next_url)
|
||||||
|
return redirect(url_for("web.dashboard"))
|
||||||
|
error = result["data"].get("error", {}).get("message", "Login failed")
|
||||||
|
return render_template(
|
||||||
|
"pages/login.html",
|
||||||
|
errors=[error],
|
||||||
|
email=email,
|
||||||
|
next_url=next_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template("pages/login.html", next_url=next_url)
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
if request.method == "POST":
|
||||||
|
if not _validate_csrf():
|
||||||
|
return render_template(
|
||||||
|
"pages/register.html",
|
||||||
|
errors=["Invalid CSRF token"],
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"email": request.form.get("email", "").strip(),
|
||||||
|
"password": request.form.get("password", ""),
|
||||||
|
"slug": request.form.get("slug", "").strip(),
|
||||||
|
"display_name": request.form.get("display_name", "").strip(),
|
||||||
|
}
|
||||||
|
result = _api_post("/api/v1/register", payload)
|
||||||
|
if result["status"] == 201:
|
||||||
|
return redirect(url_for("web.login"))
|
||||||
|
error = result["data"].get("error", {}).get("message", "Registration failed")
|
||||||
|
return render_template(
|
||||||
|
"pages/register.html",
|
||||||
|
errors=[error],
|
||||||
|
email=payload["email"],
|
||||||
|
slug=payload["slug"],
|
||||||
|
display_name=payload["display_name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template("pages/register.html")
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.route("/logout", methods=["POST"])
|
||||||
|
def logout():
|
||||||
|
if not _validate_csrf():
|
||||||
|
return redirect(url_for("web.login"))
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("web.login"))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user