Compare commits
213
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6908d8ee6f | ||
|
|
15943f251c | ||
|
|
97be7e7b2d | ||
|
|
c920bb5384 | ||
|
|
b673ea019c | ||
|
|
468a999b80 | ||
|
|
1087b65c50 | ||
|
|
71c6358b9d | ||
|
|
8581775002 | ||
|
|
f980fe05f1 | ||
|
|
33b807d545 | ||
|
|
5159450feb | ||
|
|
f24450c19c | ||
|
|
e83c9c15f3 | ||
|
|
f2cb9c0057 | ||
|
|
94e33c0763 | ||
|
|
23631ff4e7 | ||
|
|
a486bb6d45 | ||
|
|
58e270746d | ||
|
|
fe5b2047a9 | ||
|
|
5ff108cdb3 | ||
|
|
9a284d7d04 | ||
|
|
7022bb10f2 | ||
|
|
b1c73aba3f | ||
|
|
14b3f3d856 | ||
|
|
b00115a52e | ||
|
|
0ee21f27f7 | ||
|
|
071ade0ffb | ||
|
|
8eff55ed1e | ||
|
|
ce94ba89dc | ||
|
|
be712ddc2e | ||
|
|
b0e8bb3e65 | ||
|
|
a36c2db05c | ||
|
|
93797a450a | ||
|
|
df29b28167 | ||
|
|
9be0ab4551 | ||
|
|
3e7eaeb504 | ||
|
|
29869ef59f | ||
|
|
37e74bf339 | ||
|
|
317dd6b3b6 | ||
|
|
cb6a8df79b | ||
|
|
d230cffb5f | ||
|
|
b4ff6af84d | ||
|
|
0d2e5f5b60 | ||
|
|
def8baf344 | ||
|
|
f6bb863f60 | ||
|
|
9f3227f7ef | ||
|
|
356a47eebc | ||
|
|
df1d3ace23 | ||
|
|
992c473939 | ||
|
|
7f0dabd328 | ||
|
|
3d87d888ab | ||
|
|
b20abec39d | ||
|
|
cf82baef1d | ||
|
|
f442759344 | ||
|
|
f0d175f3c6 | ||
|
|
cdc910d7b6 | ||
|
|
d0581daa35 | ||
|
|
50ba841a62 | ||
|
|
d66446672b | ||
|
|
f07f9102f6 | ||
|
|
59e4191fd1 | ||
|
|
dc23c2d1cb | ||
|
|
70e9c7b789 | ||
|
|
0548f6e3ec | ||
|
|
0e1a5b5f62 | ||
|
|
7516a9b5ed | ||
|
|
7110e82eca | ||
|
|
4176573e9d | ||
|
|
68ab329c33 | ||
|
|
a134fb59c3 | ||
|
|
9baddeef18 | ||
|
|
3f259449d4 | ||
|
|
05a2fae94c | ||
|
|
205cd8d9cf | ||
|
|
ae6ec7cea1 | ||
|
|
2b927478a6 | ||
|
|
c35512dee9 | ||
|
|
2c2707679f | ||
|
|
0d91eb7fa0 | ||
|
|
a46add05b7 | ||
|
|
85d1212c0d | ||
|
|
66fd121ee4 | ||
|
|
adefe909c2 | ||
|
|
74b5124360 | ||
|
|
16de3e371c | ||
|
|
4756ea906c | ||
|
|
9f60e84075 | ||
|
|
66c0fc77ae | ||
|
|
d7d0019472 | ||
|
|
23c42faf75 | ||
|
|
19c1cf086f | ||
|
|
64b9e8e70d | ||
|
|
cd4c0682e8 | ||
|
|
9029286d4c | ||
|
|
12ae870b3c | ||
|
|
e1cae5ffda | ||
|
|
8471480aa7 | ||
|
|
b0bd692be5 | ||
|
|
807a0e28a5 | ||
|
|
957a65397a | ||
|
|
9035a17f21 | ||
|
|
3914ca74b3 | ||
|
|
706accc008 | ||
|
|
cb9625d613 | ||
|
|
f22e95d119 | ||
|
|
7f976d3f8c | ||
|
|
f1c462146b | ||
|
|
3fccf854be | ||
|
|
62a4b4a2da | ||
|
|
4b1f3cee52 | ||
|
|
96f7c05744 | ||
|
|
deddd8efbb | ||
|
|
0216d1d77a | ||
|
|
5e01a335c0 | ||
|
|
78025aac8e | ||
|
|
8e6b03cade | ||
|
|
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?
|
||||
+13
@@ -35,5 +35,18 @@ ENV/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Project specific
|
||||
*.log
|
||||
discussions/
|
||||
diagrams/
|
||||
*.puml
|
||||
.tmp_*
|
||||
.codex
|
||||
.envrc
|
||||
|
||||
# Documentation symlink (points to project-docs)
|
||||
docs
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Repository Guidelines
|
||||
|
||||
> **Note**: See CLAUDE.md for comprehensive project documentation. This file provides a quick reference for common patterns.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/cmdforge/` - Core Python package (CLI, tool model, runner, providers, GUI)
|
||||
- `tests/` - Pytest test suites
|
||||
- `docs/` - Infrastructure and deployment documentation
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]" # Install in dev mode
|
||||
pytest tests/ -m "not integration" # Run unit tests
|
||||
cmdforge # Launch GUI
|
||||
cf # Interactive tool picker
|
||||
```
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
- `cli/` - Routes all subcommands (list, create, run, test, providers, registry, collections, deps, install, etc.)
|
||||
- `tool.py` - Tool/step dataclasses, YAML loading, wrapper generation
|
||||
- `runner.py` - Step execution, variable substitution (`{input}`, `{varname}`)
|
||||
- `providers.py` - AI provider abstraction (subprocess, API, PTY types; auto-discovery; fallback chains)
|
||||
- `gui/` - PySide6 desktop GUI with page-based navigation
|
||||
- `web/` - Flask web UI and forum
|
||||
- `registry/` - Flask registry API (search, publish, moderation)
|
||||
|
||||
## Coding Style
|
||||
|
||||
- Python: 4-space indentation, `snake_case` for modules/variables
|
||||
- CLI tools: lowercase with hyphens (e.g., `fix-grammar`, `json-extract`)
|
||||
- Follow Unix pipe philosophy: composable tools with stdin/stdout
|
||||
|
||||
## Testing
|
||||
|
||||
- Framework: `pytest` (see `pyproject.toml`)
|
||||
- Test files: `tests/test_*.py`
|
||||
- Use `mock` provider or `--dry-run` to avoid network calls
|
||||
- Integration tests require `@pytest.mark.integration` decorator
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
- Short, imperative, sentence-case subjects (e.g., "Add provider validation")
|
||||
- Include test results or rationale if skipped
|
||||
- Add screenshots for UI changes
|
||||
|
||||
## Security
|
||||
|
||||
- Provider configs: `~/.cmdforge/providers.yaml` (never commit)
|
||||
- Tool configs: `~/.cmdforge/<toolname>/config.yaml` (user data, never commit)
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to CmdForge will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
#### System Dependencies
|
||||
- **System package dependencies for tools**: Tools can now declare system-level package dependencies (apt, brew, pacman, dnf)
|
||||
- `system_dependencies` field in tool config.yaml
|
||||
- Short form: just package name (e.g., `ffmpeg`)
|
||||
- Long form: detailed spec with description, binaries to check, and per-manager package names
|
||||
- Automatic platform detection (Linux/macOS/Windows)
|
||||
- Package manager detection (apt, brew, pacman, dnf)
|
||||
|
||||
- **New dataclass**: `SystemDependency` in tool.py
|
||||
- `name` - Package identifier
|
||||
- `description` - Why this dependency is needed
|
||||
- `binaries` - List of executables to check (defaults to [name])
|
||||
- `packages` - Package names per manager (defaults to name for all)
|
||||
- Round-trip serialization preserves short/long form
|
||||
|
||||
- **New module**: `src/cmdforge/system_deps.py`
|
||||
- `detect_os()` - Returns 'linux', 'darwin', or 'windows'
|
||||
- `detect_package_manager()` - Returns 'apt', 'brew', 'pacman', 'dnf', or None
|
||||
- `check_system_dep()` - Check if dependency is satisfied (any binary found)
|
||||
- `install_system_dep()` - Install via system package manager
|
||||
- `prompt_install_missing()` - Interactive prompt for installing missing packages
|
||||
|
||||
- **New CLI command**: `cmdforge system-deps <tool> [install]`
|
||||
- `cmdforge system-deps <tool>` - Show system dependency status
|
||||
- `cmdforge system-deps <tool> install` - Install missing system packages
|
||||
- `--yes` flag for non-interactive installation
|
||||
|
||||
- **Enhanced CLI commands**:
|
||||
- `cmdforge check <tool>` - Now shows both CmdForge and system dependencies
|
||||
- `cmdforge registry install` - Prompts to install system deps after tool install
|
||||
- `--yes` flag to auto-install without prompting
|
||||
- `--no-system-deps` flag to skip system dependency handling
|
||||
- `cmdforge install` (project) - Handles system deps for manifest and lock-based installs
|
||||
|
||||
- **GUI support**:
|
||||
- Tool Builder: New "System Dependencies" section
|
||||
- Add/Edit/Remove system dependency dialog
|
||||
- Shows name and description in list
|
||||
|
||||
- **Runtime integration**:
|
||||
- Runner warns about missing system dependencies before execution
|
||||
- Uses qualified tool refs (owner/name) for registry-installed tools
|
||||
|
||||
#### Tool Settings Files
|
||||
- **Configurable tool settings**: Tools can now ship with default settings that users can customize
|
||||
- `defaults.yaml` - Default settings published with the tool (immutable)
|
||||
- `settings.yaml` - User's local overrides (auto-created from defaults on first use)
|
||||
- Settings available in templates as `{settings.key}` (top-level scalars only)
|
||||
- Settings available in code steps as `settings['key']` (full dict access)
|
||||
|
||||
- **New CLI commands**:
|
||||
- `cmdforge settings <tool> show` - View current settings
|
||||
- `cmdforge settings <tool> edit` - Edit settings in $EDITOR
|
||||
- `cmdforge settings <tool> reset` - Reset settings to defaults
|
||||
- `cmdforge settings <tool> diff` - Show differences from defaults
|
||||
|
||||
- **GUI support**:
|
||||
- Tool Builder: New "Defaults (Optional)" collapsible section for defining default settings
|
||||
- Tools Page: "Configure" button for tools with settings
|
||||
- Settings Dialog: Edit tool settings with defaults reference panel
|
||||
|
||||
- **Registry integration**:
|
||||
- `defaults.yaml` is published alongside `config.yaml` and `README.md`
|
||||
- `defaults` field added to registry API (publish, download, info endpoints)
|
||||
- Size limit: 64KB for defaults content
|
||||
- Secret detection warning on publish (warns about api_key, password, token, etc.)
|
||||
|
||||
- **Automatic settings creation**:
|
||||
- `ensure_settings()` helper copies defaults.yaml to settings.yaml when missing
|
||||
- Called on tool load, save, and registry install for consistency
|
||||
- Fully backward compatible - tools without defaults work unchanged
|
||||
|
||||
#### Transitive Dependency Resolution
|
||||
- **Full transitive dependency resolution**: When installing dependencies, CmdForge now resolves and installs the complete dependency tree
|
||||
- `DependencyGraph` and `DependencyNode` dataclasses for structured dependency tracking
|
||||
- `DependencyGraphBuilder` class for recursive resolution with cycle detection
|
||||
- Topological sorting ensures dependencies are installed before dependents (Kahn's algorithm)
|
||||
- Version conflict detection with provenance tracking (which package requested each version)
|
||||
- Registry config caching during resolution to avoid duplicate fetches
|
||||
|
||||
- **Semver version constraint matching**: Full semantic versioning support
|
||||
- Caret constraints: `^1.2.3` (>=1.2.3 <2.0.0), `^0.2.3` (>=0.2.3 <0.3.0), `^0.0.3` (exact match)
|
||||
- Tilde constraints: `~1.2.3` (>=1.2.3 <1.3.0)
|
||||
- Comparison operators: `>=`, `<=`, `>`, `<`, `=`
|
||||
- Wildcards: `*`, `latest`
|
||||
- Prerelease version handling per semver spec
|
||||
|
||||
- **New CLI commands**:
|
||||
- `cmdforge deps tree` - Show full dependency tree with transitive dependencies
|
||||
- `cmdforge deps tree --verbose` - Show detailed resolution info
|
||||
|
||||
- **Enhanced install command**:
|
||||
- `--dry-run` - Show what would be installed without installing
|
||||
- `--force` - Install despite version conflicts (first constraint wins)
|
||||
- `--verbose` - Show detailed resolution info
|
||||
|
||||
#### Lock File Support
|
||||
- **Lock file for reproducible installs**: `cmdforge.lock` records exact versions of all dependencies
|
||||
- `Lockfile`, `LockedPackage`, `LockfileMetadata` dataclasses
|
||||
- Records direct and transitive dependencies with provenance (`required_by`)
|
||||
- Integrity verification via SHA256 hashes (uses registry `config_hash`)
|
||||
- Normalized YAML hashing via `hash_utils.compute_yaml_hash()` for consistent integrity checks
|
||||
- Platform and Python version metadata for debugging
|
||||
|
||||
- **New CLI commands**:
|
||||
- `cmdforge lock` - Generate or update `cmdforge.lock`
|
||||
- `cmdforge lock --force` - Force regenerate even if up to date
|
||||
- `cmdforge verify` - Verify installed tools match lock file
|
||||
|
||||
- **Lock-aware install**:
|
||||
- `cmdforge install` - Uses lock file when present for reproducible installs
|
||||
- `--frozen` - Fail if lock file is missing (for CI)
|
||||
- `--strict-frozen` - Fail if lock file is stale (manifest changed)
|
||||
- `--ignore-lock` - Ignore lock file and resolve fresh from manifest
|
||||
|
||||
#### Local Collections Support
|
||||
- **Local collection definitions**: Create and manage collections locally before publishing
|
||||
- Collections stored as YAML files in `~/.cmdforge/collections/`
|
||||
- `Collection` dataclass with name, display_name, description, tools, pinned versions, tags
|
||||
- `published`, `registry_name`, `pending_approval`, `pending_tools` fields for tracking state
|
||||
|
||||
- **CLI commands for local collections**:
|
||||
- `cmdforge collections list --local` - List local collections
|
||||
- `cmdforge collections create <name>` - Create new local collection
|
||||
- `cmdforge collections show <name>` - View collection details
|
||||
- `cmdforge collections add <name> <tool>` - Add tool to collection
|
||||
- `cmdforge collections remove <name> <tool>` - Remove tool from collection
|
||||
- `cmdforge collections delete <name>` - Delete local collection
|
||||
- `cmdforge collections publish <name>` - Publish collection to registry
|
||||
- `cmdforge collections status <name>` - Check tool visibility and approval status
|
||||
- Flags: `--dry-run`, `--force`, `--continue` for publish workflow
|
||||
|
||||
- **GUI Collections page**: New page for managing collections
|
||||
- Local collections tab: Create, edit, delete local collections
|
||||
- Registry collections tab: Browse and install from registry
|
||||
- Publish workflow with tool resolution analysis
|
||||
- Background workers for non-blocking install/publish operations
|
||||
|
||||
- **Collection publishing workflow**:
|
||||
- `resolve_tool_references()` function to transform local tool names to registry refs
|
||||
- Visibility checking (tools must be public)
|
||||
- Approval status checking (tools must have approved version)
|
||||
- Options: publish tools first, skip unpublished, or cancel
|
||||
- `ToolResolutionResult` dataclass for structured resolution data
|
||||
|
||||
- **New API endpoints**:
|
||||
- `GET /api/v1/me` - Get current authenticated user info
|
||||
- `GET /api/v1/tools/<owner>/<name>/approved` - Check if tool has approved public version
|
||||
- `POST /api/v1/collections` - Publish/update collection (authenticated)
|
||||
|
||||
- **New RegistryClient methods**:
|
||||
- `get_me()` - Get current user info
|
||||
- `has_approved_public_tool(owner, name)` - Check tool approval status
|
||||
- `publish_collection(data)` - Publish collection to registry
|
||||
|
||||
#### Registry Features
|
||||
- **Fork tracking and display**: Tools now track their fork origin with `forked_from` and `forked_version` metadata
|
||||
- Forked tools show a "Forked from" notice on the tool detail page
|
||||
- Original tools display a "Forks" section listing all forks
|
||||
- Fork count displayed in tool stats
|
||||
- API endpoint: `GET /api/v1/tools/<owner>/<name>/forks`
|
||||
|
||||
- **Version selector for installs**: Users can select specific versions when installing tools from the registry
|
||||
- Version dropdown in registry page populated via `GET /api/v1/tools/<owner>/<name>/versions`
|
||||
- "Latest" option plus all available versions listed
|
||||
- Selected version passed to install worker
|
||||
|
||||
- **Auto-cleanup rejected versions**: Admin maintenance feature to purge rejected tool submissions
|
||||
- API endpoint: `POST /api/v1/admin/cleanup/rejected`
|
||||
- Parameters:
|
||||
- `days` (default: 7) - grace period before deletion
|
||||
- `dry_run` (default: false) - preview mode without actual deletion
|
||||
- Admin dashboard UI with "Dry Run" and "Run Cleanup" buttons
|
||||
- Shows count of rejected versions pending cleanup
|
||||
|
||||
#### Tool Ratings, Reviews & Issue Reporting
|
||||
- **Rate and review tools from My Tools page**: Registry-installed tools show a rating bar at the bottom of the tool details panel
|
||||
- Star rating display with average score and review count
|
||||
- "Rate Tool" button opens a review dialog with 5-star selector, title, and content fields
|
||||
- Edit or delete your existing review
|
||||
- Button hidden for your own published tools (server enforces restriction)
|
||||
- **Report issues on registry tools**: "Report Issue" button next to Rate Tool
|
||||
- Issue type selector: Bug, Compatibility Issue, Security Issue
|
||||
- Severity selector: Low, Medium, High, Critical
|
||||
- Title (required) and description (optional) fields with character limits
|
||||
- **Registry owner tracking**: New `registry_owner` field in tool config
|
||||
- Saved at publish time (GUI, CLI, and collection publish paths)
|
||||
- Backfilled by background status sync for existing tools
|
||||
- Enables correct owner resolution for flat-directory tools (e.g., official tools)
|
||||
- **Registry field preservation**: `save_tool()` now preserves `registry_hash`, `registry_status`, `registry_owner`, and `registry_feedback` fields when editing tools
|
||||
- **Background slug fetch**: User slug fetched in background thread to prevent UI freeze
|
||||
- **New RegistryClient methods**: `get_tool_rating()`, `get_my_review()`, `submit_review()`, `update_review()`, `delete_review()`, `submit_issue()`
|
||||
- **New server endpoint**: `GET /api/v1/tools/<owner>/<name>/my-review` returns the authenticated user's review
|
||||
|
||||
#### GUI Improvements
|
||||
- Version display and bump buttons in publish dialog
|
||||
- Auto-fetch registry version when opening publish dialog
|
||||
- Fork detection during publish workflow
|
||||
- Always refresh tools page after publish dialog closes
|
||||
- **Startup connection validation**: GUI validates registry token on startup
|
||||
- Automatically clears invalid/revoked tokens
|
||||
- Shows status bar message when connection is cleared
|
||||
- Prevents confusing errors when trying to publish with stale credentials
|
||||
- **Help menu and quick guides**: Added Help menu with documentation guides
|
||||
- Getting Started guide with quick start steps and keyboard shortcuts
|
||||
- How to Create a Tool guide with step-by-step instructions
|
||||
- How to Install Tools guide for registry browsing
|
||||
- How to Publish Tools guide for sharing tools
|
||||
- Keyboard Shortcuts reference
|
||||
- About CmdForge dialog
|
||||
- F1 shortcut opens Getting Started guide
|
||||
- **Enhanced tooltips**: Added contextual tooltips throughout the GUI
|
||||
- Section headings in Tool Builder (Arguments, Steps, Output Template)
|
||||
- Registry page controls (search, filters, pagination, install)
|
||||
- Sidebar navigation items with keyboard shortcuts
|
||||
|
||||
#### CLI Features
|
||||
- `cmdforge config disconnect` - Clear registry token from local configuration
|
||||
|
||||
#### Admin Features
|
||||
- Maintenance section in admin dashboard
|
||||
- Rejected version count display
|
||||
- Cleanup result modal with detailed output
|
||||
|
||||
#### Fabric Sync Improvements
|
||||
- **AI review logging**: Added comprehensive logging to `run_ai_scrutiny_review()` function
|
||||
- Logs tool path checks, warning counts, timing, success/failure states
|
||||
- Logs timeout events, JSON parse errors, and all exception types
|
||||
- Previously failures were silently swallowed with `return None`
|
||||
- **`--skip-ai-review` flag**: Skip AI secondary review for large bulk imports
|
||||
- Prevents rate limiting and timeouts during initial large imports
|
||||
- Usage: `python fabric_sync.py --sync --skip-ai-review`
|
||||
- Logs when AI review is skipped with reason
|
||||
|
||||
### Fixed
|
||||
- **Registry search partial matching**: Search now supports prefix matching (e.g., "summ" matches "summarize")
|
||||
- VERSION_EXISTS error showing after successful publish (made endpoint idempotent by checking config_hash)
|
||||
- My Tools page listing every version separately (now consolidated by tool name)
|
||||
- GUI not refreshing after publish dialog closes
|
||||
|
||||
### Changed
|
||||
- Publish endpoint now returns success if same config_hash already exists (idempotent)
|
||||
- My Tools page groups versions by tool name, showing version count and list
|
||||
|
||||
---
|
||||
|
||||
## Previous Changes
|
||||
|
||||
See git history for changes prior to this changelog.
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 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
|
||||
|
||||
@@ -12,49 +12,80 @@ SmartTools is a lightweight personal tool builder for AI-powered CLI commands. I
|
||||
# Install for development
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
# Run all unit tests (excluding integration tests that need a server)
|
||||
pytest tests/ -m "not integration"
|
||||
|
||||
# Run a single test
|
||||
pytest tests/test.py::test_name
|
||||
# Run a specific test file
|
||||
pytest tests/test_runner.py -v
|
||||
|
||||
# Run the CLI
|
||||
python -m smarttools.cli
|
||||
# Run a specific test class or method
|
||||
pytest tests/test_runner.py::TestSubstituteVariables -v
|
||||
pytest tests/test_runner.py::TestSubstituteVariables::test_simple_substitution -v
|
||||
|
||||
# Launch the UI
|
||||
smarttools ui
|
||||
# Run with coverage
|
||||
pytest tests/ --cov=cmdforge --cov-report=html
|
||||
|
||||
# Run integration tests (requires local registry server at localhost:5000)
|
||||
python -m cmdforge.registry.app # Start server first
|
||||
pytest tests/test_registry_integration.py -v -m integration
|
||||
|
||||
# CLI entry points
|
||||
cmdforge # Main CLI / GUI launcher
|
||||
cf # Interactive tool picker (fzf-style)
|
||||
python -m cmdforge.cli # Alternative CLI invocation
|
||||
```
|
||||
|
||||
## 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
|
||||
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation
|
||||
- **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()`
|
||||
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.smarttools/providers.yaml`
|
||||
- **ui.py**: UI dispatcher - selects between urwid and snack implementations
|
||||
- **ui_urwid.py**: Full TUI implementation using urwid library
|
||||
- **ui_snack.py**: Fallback TUI using python-newt/snack
|
||||
- **cli/**: CLI commands entry points (`cmdforge` command). Routes subcommands: list, create, edit, delete, test, run, ui, docs, check, refresh, providers, registry, collections, deps, install, lock, verify, add, remove, init, config, settings, system-deps
|
||||
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`, `ToolStep`), 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()`, handles nested tool calls with depth limit (MAX_TOOL_DEPTH=10)
|
||||
- **resolver.py**: Tool resolution. `resolve_tool()` searches: project manifest → local tools → owner/name → registry. Returns `ResolvedTool` with path info
|
||||
- **collection.py**: Collection management (`Collection` dataclass, `resolve_tool_references()`, `classify_tool_reference()`), local collection storage in `~/.cmdforge/collections/`
|
||||
- **providers.py**: Provider abstraction. Supports subprocess CLI tools, OpenAI-compatible HTTP APIs, and experimental PTY wrappers. Auto-discovers installed providers on first run. Config in `~/.cmdforge/providers.yaml` with versioned migration.
|
||||
- **profiles.py**: AI persona profiles with system prompts, stored in `~/.cmdforge/profiles/`
|
||||
- **manifest.py**: Project manifest (`cmdforge.yaml`) for declaring tool dependencies with version constraints
|
||||
- **lockfile.py**: Lock file support for reproducible installs (`cmdforge.lock`)
|
||||
- **registry_client.py**: Client for registry API (search, publish, download, authentication)
|
||||
- **config.py**: Global configuration management (registry URL, auth tokens)
|
||||
- **system_deps.py**: System package dependency management (apt/dnf/pacman detection and installation)
|
||||
- **dependency_graph.py**: Dependency graph resolution for meta-tools
|
||||
- **hash_utils.py**: Hash verification utilities for tool integrity
|
||||
- **gui/**: PySide6 desktop GUI
|
||||
- **main_window.py**: Main application window with sidebar navigation
|
||||
- **pages/**: Welcome page, Tools page, Tool Builder, Registry browser, Collections page, Providers management, Profiles
|
||||
- **dialogs/**: Step editors (prompt/code/tool), Argument editor, Provider dialog, Provider Install dialog, Connect/Publish dialogs, Test Step dialog, Settings dialog, System Dep dialog, Help dialogs, Review/Issue dialogs
|
||||
- **widgets/**: Flow graph visualization (`flow_graph.py`), Icon utilities (`icons.py`)
|
||||
|
||||
### Key Paths
|
||||
|
||||
- **Tools storage**: `~/.smarttools/<toolname>/config.yaml`
|
||||
- **Tools storage**: `~/.cmdforge/<toolname>/config.yaml`
|
||||
- **Tool defaults**: `~/.cmdforge/<toolname>/defaults.yaml` (optional, published with tool)
|
||||
- **Tool settings**: `~/.cmdforge/<toolname>/settings.yaml` (user overrides, auto-created from defaults)
|
||||
- **Wrapper scripts**: `~/.local/bin/<toolname>` (auto-generated bash scripts)
|
||||
- **Provider config**: `~/.smarttools/providers.yaml`
|
||||
- **Provider config**: `~/.cmdforge/providers.yaml`
|
||||
- **Collections storage**: `~/.cmdforge/collections/<name>.yaml`
|
||||
|
||||
### Tool Structure
|
||||
|
||||
Tools are YAML configs with:
|
||||
- `name`, `description`, `category`
|
||||
- `arguments`: Custom flags with defaults (e.g., `--max` → `{max}`)
|
||||
- `steps`: Ordered list of `prompt` or `code` steps
|
||||
- `steps`: Ordered list of `prompt`, `code`, or `tool` steps
|
||||
- `output`: Template for final output (e.g., `"{response}"`)
|
||||
|
||||
### Step Types
|
||||
|
||||
1. **Prompt Step**: Calls AI provider with template, stores result in `output_var`
|
||||
2. **Code Step**: Executes Python code via `exec()`, captures specified variables
|
||||
- `profile`: AI persona (system prompt prefix)
|
||||
- `strip_fences`: Remove markdown code fences from output
|
||||
- `output_schema`: JSON schema for validated structured responses
|
||||
- `max_tokens`: Max output tokens (provider-dependent, e.g., 4096 for haiku)
|
||||
- `plain_text`: Bypass structured output enforcement
|
||||
2. **Code Step**: Executes Python code via `exec()`, captures specified variables (comma-separated for multiple outputs)
|
||||
3. **Tool Step**: Calls another tool (meta-tools), supports `input_template`, `args` dict, and `provider` override. Dependencies resolved via `resolver.py`
|
||||
|
||||
### Variable Flow
|
||||
|
||||
@@ -62,19 +93,143 @@ Variables are passed between steps:
|
||||
- `{input}` - always available (stdin/file content)
|
||||
- `{argname}` - from tool arguments
|
||||
- `{step_output_var}` - from previous step's `output_var`
|
||||
- `{settings.key}` - from tool's settings.yaml (top-level scalars only in templates)
|
||||
- `settings['key']` - full dict access in code steps
|
||||
|
||||
Variable substitution is simple string replacement in `runner.py:substitute_variables()`.
|
||||
Variable substitution is handled in `runner.py:substitute_variables()`. Settings are loaded from `settings.yaml` if it exists, otherwise an empty dict is used.
|
||||
|
||||
## Provider System
|
||||
|
||||
Providers are CLI tools that accept prompts via stdin and output to stdout. Defined in `~/.smarttools/providers.yaml`:
|
||||
Providers wrap AI CLIs or compatible HTTP APIs. Defined in `~/.cmdforge/providers.yaml`:
|
||||
|
||||
```yaml
|
||||
version: 2
|
||||
providers:
|
||||
- name: claude
|
||||
command: "claude -p"
|
||||
description: "Anthropic Claude"
|
||||
type: subprocess
|
||||
- name: openrouter
|
||||
command: "https://openrouter.ai/api/v1"
|
||||
description: "OpenRouter API"
|
||||
type: api
|
||||
model: openrouter/auto-beta
|
||||
api_key_env: OPENROUTER_API_KEY
|
||||
- name: mock
|
||||
command: "echo '[MOCK]'"
|
||||
command: "mock"
|
||||
description: "Mock provider for testing"
|
||||
```
|
||||
|
||||
The `mock` provider is built-in for testing without API calls.
|
||||
Provider fields:
|
||||
- `name`: Provider identifier used in tool configs
|
||||
- `command`: Shell command (subprocess/pty) or endpoint URL (api)
|
||||
- `type`: `subprocess` (default), `api` (OpenAI-compatible HTTP), or `pty` (interactive CLI, experimental)
|
||||
- `model`: Model ID for api-type providers
|
||||
- `api_key_env`: Environment variable holding the API key for api-type providers
|
||||
- `description`: Optional human-readable description
|
||||
- `fallback`: Optional provider to try if this one fails
|
||||
- `fallback_chain`: Ordered list of providers for multi-step fallback
|
||||
- `tags`: List of strings (e.g. `["free", "code", "local"]`)
|
||||
- `pty_config`: Dict with `prompt_pattern`, `response_pattern`, `exit_command` for pty providers
|
||||
- `install`: Optional structured install metadata dict
|
||||
|
||||
The `mock` provider is built-in for testing without API calls. Use `--provider mock` or `--dry-run` flags when testing tools.
|
||||
|
||||
Provider CLI commands:
|
||||
- `cmdforge providers list` - List all providers and their status
|
||||
- `cmdforge providers check` - Check which providers are available
|
||||
- `cmdforge providers add <name> <command>` - Add/update a provider (supports `--type`, `--model`, `--api-key-env`, `--tag`, `--fallback`, `--fallback-chain`)
|
||||
- `cmdforge providers remove <name>` - Remove a provider
|
||||
- `cmdforge providers test <name>` - Test a provider
|
||||
- `cmdforge providers discover [--add]` - Scan system for installed CLIs and API keys
|
||||
- `cmdforge providers install` - Interactive guide to install AI providers
|
||||
- `cmdforge providers for-tools <tool> [tools...]` - List providers used by tools (with `--warm` to pre-load local models)
|
||||
|
||||
## 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.)
|
||||
- **auth.py**: Authentication middleware and decorators
|
||||
- **sessions.py**: Session management
|
||||
- **email.py**: Email utilities (password reset, notifications)
|
||||
- **seo.py**: SEO utilities (sitemap, meta tags)
|
||||
- **filters.py**: Jinja2 filters (timeago, markdown, etc.)
|
||||
- **docs_content.py**: Documentation and tutorial content
|
||||
- **forum/**: Community forum blueprint
|
||||
- **models.py**: Forum database schema (categories, topics, replies)
|
||||
- **routes.py**: Forum routes (/forum, /forum/c/<slug>, /forum/t/<id>)
|
||||
|
||||
### Registry Modules (`src/cmdforge/registry/`)
|
||||
|
||||
- **app.py**: Flask-based Registry API (tool publishing, search, downloads, authentication, rate limiting)
|
||||
- **db.py**: SQLite schema and queries (`connect_db()`, `query_one()`, `query_all()`)
|
||||
- **embeddings.py**: Semantic search with vector embeddings for tool discovery
|
||||
- **sync.py**: Git-based tool sync from Gitea repository
|
||||
- **categorize.py**: Auto-categorization of tools based on content
|
||||
- **rate_limit.py**: Rate limiting for API endpoints
|
||||
- **scrutiny.py**: Tool vetting (honesty, transparency, scope, efficiency checks)
|
||||
- **similarity.py**: Duplicate detection via embedding similarity
|
||||
- **stats.py**: Registry statistics and metrics
|
||||
- **settings.py**: Server-side settings management
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
Tests use `pytest` without a shared conftest.py. Common patterns:
|
||||
|
||||
**Mocking strategy:**
|
||||
- **File system**: Use `tmp_path` fixture and patch module-level paths (`TOOLS_DIR`, `BIN_DIR`, `PROVIDERS_FILE`)
|
||||
- **Subprocess calls**: Mock `subprocess.run` and `shutil.which`
|
||||
- **Provider calls**: Mock `call_provider` to return `ProviderResult(text="...", success=True)`
|
||||
- **Registry calls**: Mock `requests` for API tests
|
||||
|
||||
**Example fixture pattern:**
|
||||
```python
|
||||
@pytest.fixture
|
||||
def temp_providers_file(tmp_path):
|
||||
providers_file = tmp_path / "providers.yaml"
|
||||
with patch('cmdforge.providers.PROVIDERS_FILE', providers_file):
|
||||
yield providers_file
|
||||
```
|
||||
|
||||
Integration tests are marked with `@pytest.mark.integration` and require a running registry server.
|
||||
|
||||
## 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) |
|
||||
|
||||
+47
-14
@@ -1,11 +1,11 @@
|
||||
# SmartTools - AI-powered CLI command builder
|
||||
# Build: docker build -t smarttools .
|
||||
# Run: docker run -it --rm -v ~/.smarttools:/root/.smarttools smarttools
|
||||
# CmdForge - AI-powered CLI command builder
|
||||
# Build: docker build -t cmdforge .
|
||||
# Run: docker run -it --rm -v ~/.cmdforge:/root/.cmdforge cmdforge
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL maintainer="rob"
|
||||
LABEL description="SmartTools - Personal AI-powered CLI command builder"
|
||||
LABEL description="CmdForge - Personal AI-powered CLI command builder"
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -18,10 +18,43 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
firefox-esr \
|
||||
# xdg-open for opening URLs (used by Claude CLI)
|
||||
xdg-utils \
|
||||
# X11 libraries for display
|
||||
# Qt/PySide6 dependencies (complete set)
|
||||
libgl1 \
|
||||
libegl1 \
|
||||
libglib2.0-0 \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
libxcb1 \
|
||||
libxcb-cursor0 \
|
||||
libxcb-glx0 \
|
||||
libxcb-icccm4 \
|
||||
libxcb-image0 \
|
||||
libxcb-keysyms1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-render0 \
|
||||
libxcb-render-util0 \
|
||||
libxcb-shape0 \
|
||||
libxcb-shm0 \
|
||||
libxcb-sync1 \
|
||||
libxcb-xfixes0 \
|
||||
libxcb-xinerama0 \
|
||||
libxcb-xkb1 \
|
||||
libxkbcommon0 \
|
||||
libxkbcommon-x11-0 \
|
||||
libxkbfile1 \
|
||||
libdbus-1-3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxi6 \
|
||||
libxrandr2 \
|
||||
libxtst6 \
|
||||
libnss3 \
|
||||
libasound2 \
|
||||
libpulse0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
@@ -31,26 +64,26 @@ WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ ./src/
|
||||
COPY examples/ ./examples/
|
||||
COPY docs/ ./docs/
|
||||
COPY tests/ ./tests/
|
||||
|
||||
# Install SmartTools and dependencies
|
||||
RUN pip install --no-cache-dir -e ".[dev]"
|
||||
# Install CmdForge and all dependencies (CLI, TUI, registry)
|
||||
RUN pip install --no-cache-dir -e ".[all,dev]"
|
||||
|
||||
# Create smarttools directory
|
||||
RUN mkdir -p /root/.smarttools /root/.local/bin
|
||||
# Create cmdforge directory
|
||||
RUN mkdir -p /root/.cmdforge /root/.local/bin
|
||||
|
||||
# Install example tools
|
||||
RUN python examples/install.py
|
||||
|
||||
# Generate CLI wrappers
|
||||
RUN smarttools refresh
|
||||
RUN cmdforge refresh
|
||||
|
||||
# Add local bin to PATH
|
||||
ENV PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
# Default command - show help
|
||||
CMD ["smarttools", "--help"]
|
||||
CMD ["cmdforge", "--help"]
|
||||
|
||||
# For interactive use:
|
||||
# docker run -it --rm smarttools smarttools ui
|
||||
# docker run -it --rm smarttools bash
|
||||
# docker run -it --rm cmdforge cmdforge ui
|
||||
# docker run -it --rm cmdforge bash
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# 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 \
|
||||
# Node.js for opencode
|
||||
nodejs \
|
||||
npm \
|
||||
# For providers that need browser auth
|
||||
firefox \
|
||||
xdg-utils \
|
||||
# Qt/PySide6 dependencies (complete set)
|
||||
libgl1 \
|
||||
libegl1 \
|
||||
libglib2.0-0 \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
libxcb1 \
|
||||
libxcb-cursor0 \
|
||||
libxcb-glx0 \
|
||||
libxcb-icccm4 \
|
||||
libxcb-image0 \
|
||||
libxcb-keysyms1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-render0 \
|
||||
libxcb-render-util0 \
|
||||
libxcb-shape0 \
|
||||
libxcb-shm0 \
|
||||
libxcb-sync1 \
|
||||
libxcb-xfixes0 \
|
||||
libxcb-xinerama0 \
|
||||
libxcb-xkb1 \
|
||||
libxkbcommon0 \
|
||||
libxkbcommon-x11-0 \
|
||||
libxkbfile1 \
|
||||
libdbus-1-3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxi6 \
|
||||
libxrandr2 \
|
||||
libxtst6 \
|
||||
libnss3 \
|
||||
libasound2 \
|
||||
libpulse0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install opencode (free AI coding assistant)
|
||||
RUN npm install -g opencode-ai@latest
|
||||
|
||||
# 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
|
||||
|
||||
# Configure opencode providers (installed globally via npm)
|
||||
RUN /home/user/.cmdforge-venv/bin/cmdforge providers add opencode "/usr/local/bin/opencode run" --description "OpenCode AI (default model)" && \
|
||||
/home/user/.cmdforge-venv/bin/cmdforge providers add opencode-pickle "/usr/local/bin/opencode run -m opencode/big-pickle" --description "OpenCode with Pickle model" && \
|
||||
/home/user/.cmdforge-venv/bin/cmdforge providers add opencode-grok "/usr/local/bin/opencode run -m opencode/grok-code" --description "OpenCode with Grok model" && \
|
||||
/home/user/.cmdforge-venv/bin/cmdforge providers add opencode-deepseek "/usr/local/bin/opencode run -m deepseek/deepseek-chat" --description "OpenCode with DeepSeek model"
|
||||
|
||||
# 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.**
|
||||
|
||||
@@ -20,27 +20,28 @@ echo "Price $49.99, SKU ABC-123" | json-extract --fields "price, sku"
|
||||
# Output: {"price": 49.99, "sku": "ABC-123"}
|
||||
```
|
||||
|
||||
## Why SmartTools?
|
||||
## Why CmdForge?
|
||||
|
||||
- **Unix Philosophy** - Tools that do one thing well, composable with pipes
|
||||
- **Provider Agnostic** - Works with Claude, GPT, Gemini, DeepSeek, local models
|
||||
- **No Lock-in** - Your tools are YAML files, your prompts are yours
|
||||
- **Multi-step Pipelines** - Chain AI prompts with Python code for validation
|
||||
- **Meta-tools** - Tools can call other tools for powerful composition
|
||||
- **12+ Providers Profiled** - We tested them so you don't have to
|
||||
|
||||
## Try It Now (60 seconds)
|
||||
|
||||
See SmartTools in action without installing anything on your system:
|
||||
See CmdForge in action without installing anything on your system:
|
||||
|
||||
```bash
|
||||
# Pull the container
|
||||
docker pull gitea.brrd.tech/rob/smarttools:latest
|
||||
docker pull gitea.brrd.tech/rob/cmdforge:latest
|
||||
|
||||
# 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)
|
||||
smarttools providers install # Select option 4, then 'y'
|
||||
cmdforge providers install # Select option 4, then 'y'
|
||||
source ~/.bashrc
|
||||
|
||||
# Try it! This explains the README using the free Big Pickle model
|
||||
@@ -55,21 +56,21 @@ For regular use, install natively:
|
||||
|
||||
```bash
|
||||
# Clone and install
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||
cd CmdForge
|
||||
pip install -e .
|
||||
|
||||
# Ensure ~/.local/bin is in PATH
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# Install an AI provider (interactive guide)
|
||||
smarttools providers install
|
||||
cmdforge providers install
|
||||
|
||||
# Launch the UI
|
||||
smarttools ui
|
||||
# Launch the GUI
|
||||
cmdforge
|
||||
|
||||
# Or create your first tool
|
||||
smarttools create summarize
|
||||
# Or create your first tool via CLI
|
||||
cmdforge create summarize
|
||||
```
|
||||
|
||||
## Installation
|
||||
@@ -77,24 +78,24 @@ smarttools create summarize
|
||||
### Native Install
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||
cd CmdForge
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### With Development Dependencies
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||
cd CmdForge
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md))
|
||||
- Optional: `urwid` for the TUI (`pip install urwid`)
|
||||
- At least one AI CLI tool installed (see [Provider Setup](docs/reference/providers.md))
|
||||
- PySide6 (included automatically - requires display server on Linux)
|
||||
|
||||
### Post-Install
|
||||
|
||||
@@ -107,29 +108,72 @@ export PATH="$HOME/.local/bin:$PATH"
|
||||
Then refresh wrapper scripts:
|
||||
|
||||
```bash
|
||||
smarttools refresh
|
||||
cmdforge refresh
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### UI Mode (Recommended for Beginners)
|
||||
### GUI Mode (Recommended for Beginners)
|
||||
|
||||
```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
|
||||
|
||||
```bash
|
||||
smarttools list # List all tools
|
||||
smarttools create mytool # Create new tool
|
||||
smarttools edit mytool # Edit in $EDITOR
|
||||
smarttools delete mytool # Delete tool
|
||||
smarttools run mytool # Run a tool
|
||||
smarttools test mytool # Test with mock provider
|
||||
smarttools refresh # Update executable wrappers
|
||||
# Tool Management
|
||||
cmdforge list # List all tools
|
||||
cmdforge create mytool # Create new tool
|
||||
cmdforge edit mytool # Edit in $EDITOR
|
||||
cmdforge delete mytool # Delete tool
|
||||
cmdforge run mytool # Run a tool
|
||||
cmdforge test mytool # Test with mock provider
|
||||
cmdforge check mytool # Check dependencies (meta-tools)
|
||||
cmdforge refresh # Update executable wrappers
|
||||
cmdforge docs mytool # View/create tool documentation
|
||||
|
||||
# Registry
|
||||
cmdforge registry search "keyword" # Search for tools
|
||||
cmdforge registry install owner/tool # Install a tool
|
||||
cmdforge registry publish mytool # Publish your tool
|
||||
cmdforge registry status # Check moderation status
|
||||
cmdforge registry my-tools # List your published tools
|
||||
|
||||
# Collections (curated tool bundles)
|
||||
cmdforge collections list # List registry collections
|
||||
cmdforge collections list --local # List your local collections
|
||||
cmdforge collections info starter # View collection details
|
||||
cmdforge collections install starter # Install all tools in a collection
|
||||
cmdforge collections create my-tools # Create local collection
|
||||
cmdforge collections show my-tools # View local collection
|
||||
cmdforge collections add my-tools summarize # Add tool to collection
|
||||
cmdforge collections remove my-tools summarize # Remove tool
|
||||
cmdforge collections delete my-tools # Delete local collection
|
||||
cmdforge collections publish my-tools # Publish to registry
|
||||
cmdforge collections status my-tools # Check tool statuses
|
||||
|
||||
# Project Dependencies
|
||||
cmdforge init # Create cmdforge.yaml manifest
|
||||
cmdforge add owner/tool # Add tool as dependency
|
||||
cmdforge install # Install all dependencies
|
||||
cmdforge deps # Check dependency status
|
||||
|
||||
# Tool Settings
|
||||
cmdforge settings mytool show # View tool settings
|
||||
cmdforge settings mytool edit # Edit in $EDITOR
|
||||
cmdforge settings mytool reset # Reset to defaults
|
||||
cmdforge settings mytool diff # Show changes from defaults
|
||||
|
||||
# Configuration
|
||||
cmdforge config show # Show current config
|
||||
cmdforge config connect username # Connect to registry account
|
||||
cmdforge config disconnect # Disconnect from registry
|
||||
```
|
||||
|
||||
### Running Tools
|
||||
@@ -155,10 +199,10 @@ cat file.txt | mytool --provider mock
|
||||
|
||||
### 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
|
||||
# Chain SmartTools together
|
||||
# Chain CmdForge together
|
||||
cat error.log | log-errors | summarize | translate --lang Spanish
|
||||
|
||||
# Code review pipeline: focus on changes, review, then summarize
|
||||
@@ -208,7 +252,7 @@ translate-doc() {
|
||||
|
||||
## Example Tools
|
||||
|
||||
SmartTools comes with 28 pre-built examples you can install:
|
||||
CmdForge comes with 28 pre-built examples you can install:
|
||||
|
||||
### Text Processing
|
||||
|
||||
@@ -259,14 +303,14 @@ SmartTools comes with 28 pre-built examples you can install:
|
||||
### Install Example Tools
|
||||
|
||||
```bash
|
||||
# Run the example installer (from the SmartTools directory)
|
||||
# Run the example installer (from the CmdForge directory)
|
||||
python examples/install.py
|
||||
smarttools refresh
|
||||
cmdforge refresh
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|----------|-------|----------|------|----------|
|
||||
@@ -277,11 +321,11 @@ SmartTools works with any AI CLI tool. We've profiled 12 providers:
|
||||
| `claude-opus` | 18s | 4/4 | $$$ | Highest quality |
|
||||
| `gemini` | 91s | 3/4 | Paid | Large docs (1M tokens) |
|
||||
|
||||
See [docs/PROVIDERS.md](docs/PROVIDERS.md) for setup instructions.
|
||||
See [Provider Setup](docs/reference/providers.md) for setup instructions.
|
||||
|
||||
## 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
|
||||
name: summarize
|
||||
@@ -340,13 +384,307 @@ steps:
|
||||
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 [Meta-Tools Design](docs/reference/meta-tools.md) for full documentation.
|
||||
|
||||
### Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `{input}` | The piped/file input |
|
||||
| `{argname}` | Custom argument value |
|
||||
| `{step_output}` | Output from previous step |
|
||||
| `{output_var}` | Output from previous step (prompt, code, or tool) |
|
||||
| `{settings.key}` | Value from tool's settings file (scalars only) |
|
||||
|
||||
### Tool Settings
|
||||
|
||||
Tools can ship with configurable settings that users can customize without editing the tool itself:
|
||||
|
||||
```
|
||||
~/.cmdforge/tts/
|
||||
├── config.yaml # Tool definition (published)
|
||||
├── defaults.yaml # Default settings (published with tool)
|
||||
├── settings.yaml # User's customized settings (auto-created, not published)
|
||||
└── README.md # Documentation
|
||||
```
|
||||
|
||||
**Creating a tool with settings:**
|
||||
|
||||
```yaml
|
||||
# defaults.yaml - ships with your tool
|
||||
backend: piper
|
||||
endpoint: http://localhost:5001
|
||||
api_key: '' # User fills in their own key
|
||||
voice: default
|
||||
```
|
||||
|
||||
**Using settings in steps:**
|
||||
|
||||
```yaml
|
||||
# In a prompt step (top-level scalars only)
|
||||
prompt: "Using {settings.backend} at {settings.endpoint}"
|
||||
|
||||
# In a code step (full access)
|
||||
code: |
|
||||
endpoint = settings.get('endpoint')
|
||||
api_key = settings['api_key']
|
||||
if not api_key:
|
||||
raise ValueError("Please set api_key in settings")
|
||||
```
|
||||
|
||||
**Managing settings via CLI:**
|
||||
|
||||
```bash
|
||||
cmdforge settings tts show # View current settings
|
||||
cmdforge settings tts edit # Edit in $EDITOR
|
||||
cmdforge settings tts reset # Reset to defaults
|
||||
cmdforge settings tts diff # Show changes from defaults
|
||||
```
|
||||
|
||||
Settings are fully optional - tools without a `defaults.yaml` work exactly as before.
|
||||
|
||||
## Project Dependencies
|
||||
|
||||
For projects that use multiple CmdForge tools, you can declare dependencies in a `cmdforge.yaml` manifest:
|
||||
|
||||
```yaml
|
||||
# cmdforge.yaml
|
||||
name: my-project
|
||||
description: My awesome project
|
||||
dependencies:
|
||||
- official/summarize
|
||||
- official/translate
|
||||
- myuser/custom-tool@^1.0.0
|
||||
```
|
||||
|
||||
### Managing Dependencies
|
||||
|
||||
```bash
|
||||
# Initialize a new manifest
|
||||
cmdforge init
|
||||
|
||||
# Add a dependency
|
||||
cmdforge add official/summarize
|
||||
cmdforge add myuser/tool@^2.0.0 # With version constraint
|
||||
|
||||
# Install all dependencies (including transitive)
|
||||
cmdforge install
|
||||
|
||||
# Check dependency status
|
||||
cmdforge deps
|
||||
|
||||
# Show full dependency tree
|
||||
cmdforge deps tree
|
||||
```
|
||||
|
||||
### Reproducible Installs with Lock Files
|
||||
|
||||
Lock files ensure identical tool versions across machines and over time:
|
||||
|
||||
```bash
|
||||
# Generate lock file after installing
|
||||
cmdforge lock
|
||||
|
||||
# Install exact versions from lock (reproducible)
|
||||
cmdforge install # Uses lock file if present
|
||||
|
||||
# CI/CD: Fail if lock is missing or stale
|
||||
cmdforge install --frozen # Requires lock file
|
||||
cmdforge install --strict-frozen # Requires lock + manifest match
|
||||
|
||||
# Verify installed tools match lock
|
||||
cmdforge verify
|
||||
|
||||
# Preview what would be installed
|
||||
cmdforge install --dry-run
|
||||
|
||||
# Ignore lock and resolve fresh
|
||||
cmdforge install --ignore-lock
|
||||
```
|
||||
|
||||
### Version Constraints
|
||||
|
||||
CmdForge supports semantic versioning constraints:
|
||||
|
||||
| Constraint | Meaning | Example |
|
||||
|------------|---------|---------|
|
||||
| `*` | Any version | `official/summarize` |
|
||||
| `^1.2.3` | Compatible (>=1.2.3 <2.0.0) | `tool@^1.2.3` |
|
||||
| `~1.2.3` | Approximately (>=1.2.3 <1.3.0) | `tool@~1.2.3` |
|
||||
| `>=1.0.0` | Greater or equal | `tool@>=1.0.0` |
|
||||
| `1.0.0` | Exact version | `[email protected]` |
|
||||
|
||||
This ensures all required tools are available before running your project's scripts.
|
||||
|
||||
## Collections
|
||||
|
||||
Collections are curated bundles of related tools. Install an entire workflow with one command.
|
||||
|
||||
### Registry Collections
|
||||
|
||||
Browse and install collections from the CmdForge registry:
|
||||
|
||||
```bash
|
||||
# List available collections from registry
|
||||
cmdforge collections list
|
||||
|
||||
# View what's in a collection
|
||||
cmdforge collections info starter
|
||||
# Output:
|
||||
# starter - Essential tools for getting started
|
||||
# Tools: summarize, translate, fix-grammar, explain-error, commit-msg
|
||||
# Total: 5 tools
|
||||
|
||||
# Install all tools in a collection
|
||||
cmdforge collections install starter
|
||||
```
|
||||
|
||||
### Local Collections
|
||||
|
||||
Create and manage your own collections locally:
|
||||
|
||||
```bash
|
||||
# List your local collections
|
||||
cmdforge collections list --local
|
||||
|
||||
# Create a new local collection
|
||||
cmdforge collections create my-toolkit
|
||||
|
||||
# View collection details
|
||||
cmdforge collections show my-toolkit
|
||||
|
||||
# Add tools to the collection
|
||||
cmdforge collections add my-toolkit summarize
|
||||
cmdforge collections add my-toolkit official/translate
|
||||
|
||||
# Remove tools from the collection
|
||||
cmdforge collections remove my-toolkit summarize
|
||||
|
||||
# Delete a collection
|
||||
cmdforge collections delete my-toolkit
|
||||
```
|
||||
|
||||
### Publishing Collections
|
||||
|
||||
Share your collections with the community:
|
||||
|
||||
```bash
|
||||
# Check collection status before publishing
|
||||
cmdforge collections status my-toolkit
|
||||
# Shows: tool visibility, approval status, any issues
|
||||
|
||||
# Publish to registry (requires account)
|
||||
cmdforge collections publish my-toolkit
|
||||
|
||||
# Dry run to see what would be published
|
||||
cmdforge collections publish my-toolkit --dry-run
|
||||
|
||||
# Force publish even with warnings
|
||||
cmdforge collections publish my-toolkit --force
|
||||
|
||||
# Continue publishing after fixing issues
|
||||
cmdforge collections publish my-toolkit --continue
|
||||
```
|
||||
|
||||
**Publishing requirements:**
|
||||
- All tools must be public (not private visibility)
|
||||
- All tools must have an approved version in the registry
|
||||
- Local tools will be prefixed with your username (e.g., `summarize` → `yourname/summarize`)
|
||||
|
||||
### Available Registry Collections
|
||||
|
||||
| Collection | Description | Tools |
|
||||
|------------|-------------|-------|
|
||||
| `starter` | Essential tools for beginners | 5 |
|
||||
| `developer` | Code review and development | 8 |
|
||||
| `writer` | Writing and editing tools | 6 |
|
||||
| `data` | Data extraction and conversion | 7 |
|
||||
|
||||
## Registry
|
||||
|
||||
The CmdForge Registry at [cmdforge.brrd.tech](https://cmdforge.brrd.tech) hosts community tools.
|
||||
|
||||
### Searching Tools
|
||||
|
||||
```bash
|
||||
# Basic search
|
||||
cmdforge registry search "summarize"
|
||||
|
||||
# Filter by category
|
||||
cmdforge registry search --category Developer
|
||||
|
||||
# Sort by popularity
|
||||
cmdforge registry search --sort downloads
|
||||
|
||||
# Show only new tools (last 7 days)
|
||||
cmdforge registry search --new
|
||||
|
||||
# Advanced filters
|
||||
cmdforge registry search --owner official --min-downloads 100
|
||||
```
|
||||
|
||||
### Publishing Tools
|
||||
|
||||
```bash
|
||||
# Connect your account (opens browser for authentication)
|
||||
cmdforge config connect yourusername
|
||||
|
||||
# Publish a tool
|
||||
cmdforge registry publish mytool
|
||||
|
||||
# Check moderation status
|
||||
cmdforge registry status
|
||||
# Output:
|
||||
# mytool: pending (submitted 2 hours ago)
|
||||
# other-tool: approved
|
||||
|
||||
# Sync moderation feedback to your local tool
|
||||
cmdforge registry status --sync
|
||||
```
|
||||
|
||||
Published tools go through moderation before appearing publicly. You'll receive feedback if changes are requested.
|
||||
|
||||
### Tool Documentation
|
||||
|
||||
Each tool can have its own README:
|
||||
|
||||
```bash
|
||||
# View tool documentation
|
||||
cmdforge docs mytool
|
||||
|
||||
# Create/edit documentation (opens in $EDITOR)
|
||||
cmdforge docs mytool --edit
|
||||
```
|
||||
|
||||
The documentation is stored as `README.md` in the tool's directory (`~/.cmdforge/mytool/README.md`).
|
||||
|
||||
## Shell Integration
|
||||
|
||||
@@ -377,59 +715,120 @@ vnoremap <leader>fg :!fix-grammar<CR>
|
||||
vnoremap <leader>ec :!explain-code<CR>
|
||||
```
|
||||
|
||||
## UI Navigation
|
||||
## GUI Features
|
||||
|
||||
| Action | Keys |
|
||||
|--------|------|
|
||||
| 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` |
|
||||
The graphical interface provides a modern desktop experience:
|
||||
|
||||
**Tips:**
|
||||
- Hold `Shift` while using mouse for terminal-native text selection.
|
||||
- Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons.
|
||||
- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields.
|
||||
- The code editor supports undo/redo (up to 50 states) with `Alt+U` and `Alt+R`.
|
||||
- Use the `$EDITOR` button to open code or prompts in your external editor.
|
||||
### My Tools Page
|
||||
- View all your tools organized by category (Text, Developer, Data, Other)
|
||||
- Double-click a tool to edit it
|
||||
- Create new tools with the built-in Tool Builder
|
||||
- Connect to the registry to publish your tools
|
||||
- **Rate & review** registry-installed tools with a 5-star rating system
|
||||
- **Report issues** on registry tools (bug, compatibility, security)
|
||||
|
||||
## AI-Assisted Code Generation
|
||||
### Collections Page
|
||||
- **Local Collections**: Create and manage your own tool bundles
|
||||
- Create new collections with display name and description
|
||||
- Add/remove tools from collections
|
||||
- View collection details and tool lists
|
||||
- **Registry Collections**: Browse collections from the registry
|
||||
- Search and filter available collections
|
||||
- View collection contents before installing
|
||||
- One-click install for all tools in a collection
|
||||
- **Publish Collections**: Share your collections with the community
|
||||
- Automatic tool resolution (local names → registry references)
|
||||
- Visibility and approval status checks
|
||||
- Options to publish tools first, skip unpublished, or cancel
|
||||
|
||||
When adding or editing a **Code Step**, the dialog includes an AI assist panel:
|
||||
### 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
|
||||
|
||||
```
|
||||
┌─ Code ─────────────┐ ┌─ AI Assisted Auto-adjust ─────────────┐
|
||||
│ result = input... │ │ Provider: [opencode-deepseek] [▼] │
|
||||
│ │ │ ┌─ Prompt ──────────────────────────┐ │
|
||||
│ │ │ │ Modify this code to... │ │
|
||||
│ │ │ │ {code} │ │
|
||||
│ │ │ └──────────────────────────────────┘ │
|
||||
│ │ │ ┌─ Output & Feedback ───────────────┐ │
|
||||
│ │ │ │ ✓ Code updated successfully! │ │
|
||||
│ │ │ └──────────────────────────────────┘ │
|
||||
│ │ │ < Auto-adjust > │
|
||||
└────────────────────┘ └───────────────────────────────────────┘
|
||||
### Registry Browser
|
||||
- **Browse all** tools on page load (no search required)
|
||||
- **Search** by name, description, or keyword
|
||||
- **Filter** by category (Text, Developer, Data, Other)
|
||||
- **Sort** by popularity, rating, newest, or name
|
||||
- **Star ratings** displayed in results and details
|
||||
- **Clickable tags** to filter by tag
|
||||
- **Installed indicator** (✓) shows which tools you have
|
||||
- **Update available** (↑) when newer version exists
|
||||
- **Pagination** for browsing large result sets
|
||||
- **Publisher info** showing reputation and total downloads
|
||||
|
||||
### Provider Management
|
||||
- Add and configure AI providers
|
||||
- Test provider connectivity
|
||||
- Set default providers for new tools
|
||||
|
||||
### Profiles (AI Personas)
|
||||
- **Built-in profiles**: Comedian, Technical Writer, Teacher, Concise, Creative, Code Reviewer, Analyst
|
||||
- **Custom profiles**: Create your own personas with custom system prompts
|
||||
- **Profile selector**: Choose a profile when adding prompt steps to tools
|
||||
- **Automatic injection**: Profile system prompts are prepended to your prompts during execution
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+N` | Create new tool |
|
||||
| `Ctrl+S` | Save tool (in builder) |
|
||||
| `Ctrl+R` | Refresh current page |
|
||||
| `Ctrl+1` | Go to My Tools |
|
||||
| `Ctrl+2` | Go to Registry |
|
||||
| `Ctrl+3` | Go to Collections |
|
||||
| `Ctrl+4` | Go to Providers |
|
||||
| `Ctrl+5` | 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
|
||||
- **Prompt**: Fully editable template - use `{code}` placeholder for current code
|
||||
- **Output**: Shows status, success/error messages, and provider feedback
|
||||
- **Auto-adjust**: Sends prompt to AI and replaces code with response
|
||||
|
||||
This lets you generate or modify Python code using AI directly within the tool builder.
|
||||
### AI → Code
|
||||
Generate content with AI, then validate/process with Python:
|
||||
```yaml
|
||||
steps:
|
||||
- type: prompt
|
||||
prompt: "Generate Python code to: {input}"
|
||||
output_var: generated
|
||||
- type: code
|
||||
code: |
|
||||
import ast
|
||||
try:
|
||||
ast.parse(generated) # Syntax check
|
||||
result = generated
|
||||
except SyntaxError as e:
|
||||
result = f"# Syntax Error: {e}\n{generated}"
|
||||
output_var: validated
|
||||
```
|
||||
|
||||
## Philosophy
|
||||
|
||||
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.
|
||||
|
||||
@@ -444,15 +843,15 @@ SmartTools is built on **Unix philosophy**:
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/smarttools.git
|
||||
cd smarttools
|
||||
git clone https://gitea.brrd.tech/rob/cmdforge.git
|
||||
cd cmdforge
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
If SmartTools saves you time, consider supporting the project:
|
||||
If CmdForge saves you time, consider supporting the project:
|
||||
|
||||
**Bitcoin:** `3KeLqGv2Xo8jGYkqA1i68a6nXwgQnuJoza`
|
||||
|
||||
@@ -464,33 +863,66 @@ MIT - Use it, modify it, share it.
|
||||
|
||||
## 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
|
||||
# Pull from registry
|
||||
docker pull gitea.brrd.tech/rob/smarttools:latest
|
||||
# Build from source
|
||||
docker build -f Dockerfile.ready -t cmdforge-ready .
|
||||
|
||||
# Run SmartTools
|
||||
docker run -it --rm gitea.brrd.tech/rob/smarttools smarttools --help
|
||||
# Run interactively
|
||||
docker run -it --rm cmdforge-ready
|
||||
|
||||
# List available tools
|
||||
docker run -it --rm gitea.brrd.tech/rob/smarttools smarttools list
|
||||
|
||||
# Interactive shell
|
||||
docker run -it --rm gitea.brrd.tech/rob/smarttools bash
|
||||
# With persistent storage (keeps your tools between runs)
|
||||
docker run -it --rm -v cmdforge-data:/home/user/.cmdforge cmdforge-ready
|
||||
```
|
||||
|
||||
### 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
|
||||
# Clone the repo
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
# Build the test container
|
||||
docker build -f Dockerfile.test-install -t cmdforge-test-install .
|
||||
|
||||
# Run interactively
|
||||
docker run -it --rm cmdforge-test-install
|
||||
|
||||
# Inside the container - run the installer
|
||||
cd ~/CmdForge
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### Interactive Installer
|
||||
|
||||
The `install.sh` script provides guided installation:
|
||||
|
||||
```bash
|
||||
./install.sh # Full interactive install
|
||||
./install.sh --yes # Non-interactive (accept defaults)
|
||||
./install.sh --no-venv # Install without virtual environment
|
||||
./install.sh --no-examples # Skip installing example tools
|
||||
```
|
||||
|
||||
Features:
|
||||
- Creates virtual environment at `~/.cmdforge-venv`
|
||||
- Configures PATH in your shell config (bash/zsh/fish)
|
||||
- Optionally installs 27 example tools
|
||||
- Works on macOS and Linux
|
||||
|
||||
### Legacy Container
|
||||
|
||||
The original docker-compose setup still works:
|
||||
|
||||
```bash
|
||||
# Build the container
|
||||
docker-compose build
|
||||
|
||||
@@ -498,7 +930,7 @@ docker-compose build
|
||||
docker-compose run --rm test
|
||||
|
||||
# Use the CLI
|
||||
docker-compose run --rm cli smarttools list
|
||||
docker-compose run --rm cli cmdforge list
|
||||
|
||||
# Interactive shell
|
||||
docker-compose run --rm shell
|
||||
@@ -518,12 +950,12 @@ xhost +local:docker
|
||||
docker run -it --rm \
|
||||
-e DISPLAY=$DISPLAY \
|
||||
-v /tmp/.X11-unix:/tmp/.X11-unix \
|
||||
-v smarttools-data:/root/.smarttools \
|
||||
-v cmdforge-data:/root/.cmdforge \
|
||||
--network host \
|
||||
gitea.brrd.tech/rob/smarttools bash
|
||||
gitea.brrd.tech/rob/cmdforge bash
|
||||
|
||||
# Inside the container:
|
||||
smarttools providers install
|
||||
cmdforge providers install
|
||||
```
|
||||
|
||||
**Using docker-compose (build from source):**
|
||||
@@ -533,14 +965,14 @@ xhost +local:docker
|
||||
docker-compose run --rm setup
|
||||
|
||||
# Inside the container:
|
||||
smarttools providers install
|
||||
cmdforge providers install
|
||||
```
|
||||
|
||||
**After installing a provider:**
|
||||
|
||||
```bash
|
||||
# Test the provider works
|
||||
smarttools providers test claude
|
||||
cmdforge providers test claude
|
||||
```
|
||||
|
||||
**Available Providers:**
|
||||
@@ -554,7 +986,15 @@ smarttools providers test claude
|
||||
|
||||
## Links
|
||||
|
||||
- [Installation Guide](docs/INSTALL.md)
|
||||
- [Provider Setup](docs/PROVIDERS.md)
|
||||
- [Example Tools](docs/EXAMPLES.md)
|
||||
- [Design Document](docs/DESIGN.md)
|
||||
### User Documentation
|
||||
- [Provider Setup](docs/reference/providers.md)
|
||||
- [Example Tools](docs/reference/examples.md)
|
||||
- [User Wiki](wiki/Home.md)
|
||||
|
||||
### Developer Documentation
|
||||
- [Project Overview](docs/overview.md) - Start here to understand the codebase
|
||||
- [Design Document](docs/reference/design.md)
|
||||
- [Meta-Tools Design](docs/reference/meta-tools.md) - Tools that call other tools
|
||||
- [Collections](docs/reference/collections.md) - Curated tool bundles
|
||||
- [Registry API Design](docs/reference/registry-spec.md)
|
||||
- [Web UI Design](docs/reference/web-ui-spec.md)
|
||||
|
||||
+16
-16
@@ -1,9 +1,9 @@
|
||||
# SmartTools - AI-powered CLI command builder
|
||||
# CmdForge - AI-powered CLI command builder
|
||||
#
|
||||
# Quick Start:
|
||||
# docker-compose build # Build the image
|
||||
# docker-compose run --rm test # Run tests
|
||||
# docker-compose run --rm cli smarttools list
|
||||
# docker-compose run --rm cli cmdforge list
|
||||
#
|
||||
# This is a standalone project with no external dependencies.
|
||||
|
||||
@@ -17,10 +17,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: smarttools:latest
|
||||
image: cmdforge:latest
|
||||
volumes:
|
||||
- smarttools-data:/root/.smarttools
|
||||
command: ["smarttools", "--help"]
|
||||
- cmdforge-data:/root/.cmdforge
|
||||
command: ["cmdforge", "--help"]
|
||||
|
||||
# ============================================================================
|
||||
# Tests
|
||||
@@ -29,9 +29,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: smarttools:latest
|
||||
image: cmdforge:latest
|
||||
volumes:
|
||||
- smarttools-data:/root/.smarttools
|
||||
- cmdforge-data:/root/.cmdforge
|
||||
command: ["pytest", "-v"]
|
||||
|
||||
# ============================================================================
|
||||
@@ -41,9 +41,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: smarttools:latest
|
||||
image: cmdforge:latest
|
||||
volumes:
|
||||
- smarttools-data:/root/.smarttools
|
||||
- cmdforge-data:/root/.cmdforge
|
||||
command: ["/bin/bash"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@@ -57,11 +57,11 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: smarttools:latest
|
||||
image: cmdforge:latest
|
||||
environment:
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
volumes:
|
||||
- smarttools-data:/root/.smarttools
|
||||
- cmdforge-data:/root/.cmdforge
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix:ro
|
||||
command: ["/bin/bash"]
|
||||
stdin_open: true
|
||||
@@ -69,8 +69,8 @@ services:
|
||||
network_mode: host
|
||||
|
||||
volumes:
|
||||
smarttools-data:
|
||||
# Persists ~/.smarttools between container runs
|
||||
cmdforge-data:
|
||||
# Persists ~/.cmdforge between container runs
|
||||
|
||||
# ==============================================================================
|
||||
# Usage Examples
|
||||
@@ -83,8 +83,8 @@ volumes:
|
||||
# docker-compose run --rm test
|
||||
#
|
||||
# Use CLI:
|
||||
# docker-compose run --rm cli smarttools list
|
||||
# docker-compose run --rm cli smarttools run hello-world
|
||||
# docker-compose run --rm cli cmdforge list
|
||||
# docker-compose run --rm cli cmdforge run hello-world
|
||||
#
|
||||
# Interactive shell:
|
||||
# docker-compose run --rm shell
|
||||
@@ -93,5 +93,5 @@ volumes:
|
||||
# xhost +local:docker
|
||||
# docker-compose run --rm setup
|
||||
# # Inside container:
|
||||
# smarttools providers install # Interactive guide
|
||||
# cmdforge providers install # Interactive guide
|
||||
# # Select a provider, it will install and open browser for auth
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Install all SmartTools example tools.
|
||||
Install all CmdForge example tools.
|
||||
|
||||
Usage:
|
||||
python3 install.py # Install all tools
|
||||
@@ -9,7 +9,7 @@ Usage:
|
||||
python3 install.py --category dev # Install category
|
||||
|
||||
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
|
||||
@@ -22,7 +22,7 @@ except ImportError:
|
||||
print("PyYAML required. Install with: pip install pyyaml")
|
||||
sys.exit(1)
|
||||
|
||||
TOOLS_DIR = Path.home() / ".smarttools"
|
||||
TOOLS_DIR = Path.home() / ".cmdforge"
|
||||
|
||||
# All example tool configurations
|
||||
TOOLS = {
|
||||
@@ -298,7 +298,7 @@ def list_tools():
|
||||
|
||||
|
||||
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("--list", action="store_true", help="List available tools")
|
||||
parser.add_argument("--category", "-c", choices=list(CATEGORIES.keys()), help="Install tools from category")
|
||||
@@ -335,7 +335,7 @@ def main():
|
||||
installed += 1
|
||||
|
||||
print(f"\nInstalled {installed} tools.")
|
||||
print("\nRun 'smarttools refresh' to create executable wrappers.")
|
||||
print("\nRun 'cmdforge refresh' to create executable wrappers.")
|
||||
|
||||
|
||||
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,223 @@
|
||||
# CmdForge Collections
|
||||
|
||||
> **Document Status (January 2026)**: Collections are fully implemented:
|
||||
> - ✅ API endpoints (`/api/v1/collections`, `/api/v1/admin/collections`)
|
||||
> - ✅ Web UI pages (`/collections`, `/collections/:name`)
|
||||
> - ✅ Admin management UI (`/dashboard/admin/collections`)
|
||||
> - ✅ Database schema
|
||||
> - ✅ CLI commands (`cmdforge collections list/info/install`)
|
||||
> - ❌ Registry repo sync - Not implemented (collections managed via admin UI)
|
||||
|
||||
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
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
# List available collections
|
||||
cmdforge collections list
|
||||
|
||||
# List in JSON format
|
||||
cmdforge collections list --json
|
||||
|
||||
# View collection details
|
||||
cmdforge collections info writing-toolkit
|
||||
|
||||
# View details in JSON format
|
||||
cmdforge collections info writing-toolkit --json
|
||||
|
||||
# Install all tools in a collection
|
||||
cmdforge collections install writing-toolkit
|
||||
|
||||
# Install with pinned versions from collection
|
||||
cmdforge collections install writing-toolkit --pinned
|
||||
```
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
$ cmdforge collections list
|
||||
Available collections (1):
|
||||
|
||||
development-hub
|
||||
Development-Hub
|
||||
Collection of tools for the development-hub application.
|
||||
Tools: 2
|
||||
|
||||
Install a collection with: cmdforge collections install <name>
|
||||
```
|
||||
|
||||
```
|
||||
$ cmdforge collections info development-hub
|
||||
Development-Hub
|
||||
==================================================
|
||||
|
||||
Collection of tools for the development-hub application.
|
||||
|
||||
Maintainer: official
|
||||
Tags: Development, Coding, Programming
|
||||
|
||||
Tools (2):
|
||||
- rob/audit-goals
|
||||
- rob/realign-goals
|
||||
|
||||
Install all: cmdforge collections install development-hub
|
||||
```
|
||||
|
||||
## Admin Management
|
||||
|
||||
Collections are managed via the admin dashboard at `/dashboard/admin/collections`.
|
||||
|
||||
### Admin UI Features
|
||||
|
||||
- **List collections**: View all collections with tool counts and tags
|
||||
- **Create collection**: Add new collection with name, display name, description, tools
|
||||
- **Edit collection**: Update existing collection details and tool list
|
||||
- **Delete collection**: Remove a collection (does not uninstall tools)
|
||||
|
||||
### Admin API Endpoints
|
||||
|
||||
```
|
||||
GET /api/v1/admin/collections # List all collections (admin)
|
||||
POST /api/v1/admin/collections # Create collection (admin)
|
||||
PUT /api/v1/admin/collections/:name # Update collection (admin)
|
||||
DELETE /api/v1/admin/collections/:name # Delete collection (admin)
|
||||
```
|
||||
|
||||
### Creating a Collection via API
|
||||
|
||||
```bash
|
||||
curl -X POST https://cmdforge.brrd.tech/api/v1/admin/collections \
|
||||
-H "Authorization: Bearer <admin-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "writing-toolkit",
|
||||
"display_name": "Writing Toolkit",
|
||||
"description": "Essential tools for writers",
|
||||
"maintainer": "official",
|
||||
"tools": ["official/fix-grammar", "official/simplify"],
|
||||
"pinned": {"official/fix-grammar": "1.0.0"},
|
||||
"tags": ["writing", "editing"]
|
||||
}'
|
||||
```
|
||||
|
||||
## Public API Endpoints
|
||||
|
||||
```
|
||||
GET /api/v1/collections # List all collections (summary)
|
||||
GET /api/v1/collections/:name # Get collection details with tool info
|
||||
```
|
||||
|
||||
### List Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"name": "writing-toolkit",
|
||||
"display_name": "Writing Toolkit",
|
||||
"description": "Essential tools for writers",
|
||||
"maintainer": "official",
|
||||
"icon": "pencil",
|
||||
"tags": ["writing", "editing"],
|
||||
"tool_count": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Detail Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"name": "writing-toolkit",
|
||||
"display_name": "Writing Toolkit",
|
||||
"description": "Essential tools for writers",
|
||||
"maintainer": "official",
|
||||
"icon": "pencil",
|
||||
"tags": ["writing", "editing"],
|
||||
"tools": [
|
||||
{
|
||||
"owner": "official",
|
||||
"name": "fix-grammar",
|
||||
"version": "1.0.0",
|
||||
"description": "Fix grammar issues in text",
|
||||
"category": "Writing",
|
||||
"downloads": 150,
|
||||
"pinned_version": "1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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 (e.g., ["owner/name"])
|
||||
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);
|
||||
```
|
||||
|
||||
## Collection Manifest Format
|
||||
|
||||
For reference, collections can be defined in YAML format:
|
||||
|
||||
```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"
|
||||
|
||||
tags:
|
||||
- writing
|
||||
- editing
|
||||
- grammar
|
||||
```
|
||||
|
||||
## Web UI
|
||||
|
||||
- `/collections` - Browse all collections
|
||||
- `/collections/:name` - Collection detail page with tool grid
|
||||
- Install button that shows CLI command
|
||||
- Filter by tag, maintainer
|
||||
|
||||
## Implementation Files
|
||||
|
||||
- **CLI**: `src/cmdforge/cli/collections_commands.py`
|
||||
- **Registry Client**: `src/cmdforge/registry_client.py` (`get_collections`, `get_collection`)
|
||||
- **API**: `src/cmdforge/registry/app.py` (public and admin endpoints)
|
||||
- **Admin Templates**: `src/cmdforge/web/templates/admin/collections.html`, `collection_form.html`
|
||||
@@ -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,15 @@
|
||||
# SmartTools - Design Document
|
||||
# CmdForge - Design Document
|
||||
|
||||
> A lightweight personal tool builder for AI-powered CLI commands
|
||||
|
||||
> **Document Status (January 2026)**: This is the original design document. The core concepts
|
||||
> (tool format, steps, variables, providers) remain accurate. The Terminal UI section describes
|
||||
> the original urwid TUI which has been replaced by a PySide6 desktop GUI. For the latest
|
||||
> documentation, see the project-docs system.
|
||||
|
||||
## Overview
|
||||
|
||||
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:**
|
||||
```bash
|
||||
@@ -17,7 +22,7 @@ sum -i text.txt -o summary.txt --max 512
|
||||
### Tool = Directory + Config
|
||||
|
||||
```
|
||||
~/.smarttools/
|
||||
~/.cmdforge/
|
||||
sum/
|
||||
config.yaml
|
||||
processed.py # Optional external code file
|
||||
@@ -46,6 +51,11 @@ steps:
|
||||
provider: claude
|
||||
output_var: response
|
||||
output: "{response}"
|
||||
|
||||
# Optional: declare dependencies for meta-tools
|
||||
dependencies:
|
||||
- official/summarize
|
||||
- official/translate@^1.0.0 # With version constraint
|
||||
```
|
||||
|
||||
### Step Types
|
||||
@@ -68,6 +78,17 @@ output: "{response}"
|
||||
code_file: processed.py # Optional: external file storage
|
||||
```
|
||||
|
||||
**Tool Step** - Calls another CmdForge tool (meta-tools):
|
||||
```yaml
|
||||
- type: tool
|
||||
tool: official/summarize # Tool reference (owner/name or just name for local)
|
||||
input: "{input}" # Input to pass (supports variable substitution)
|
||||
args: # Optional arguments
|
||||
max_words: "100"
|
||||
output_var: summary # Variable to store the tool's output
|
||||
provider: claude # Optional: override the called tool's provider
|
||||
```
|
||||
|
||||
Steps execute in order. Each step's `output_var` becomes available to subsequent steps.
|
||||
|
||||
### Variables
|
||||
@@ -75,6 +96,40 @@ Steps execute in order. Each step's `output_var` becomes available to subsequent
|
||||
- `{input}` - Always available, contains stdin or input file content (empty string if no input)
|
||||
- `{variable_name}` - From arguments (e.g., `{max}`)
|
||||
- `{output_var}` - From previous steps (e.g., `{response}`, `{processed}`)
|
||||
- `{settings.key}` - From tool's settings file (top-level scalars only in templates)
|
||||
|
||||
### Tool Settings
|
||||
|
||||
Tools can ship with configurable settings via `defaults.yaml`:
|
||||
|
||||
```yaml
|
||||
# ~/.cmdforge/mytool/defaults.yaml
|
||||
backend: piper
|
||||
endpoint: http://localhost:5001
|
||||
api_key: '' # User fills in
|
||||
```
|
||||
|
||||
When a tool with `defaults.yaml` is first loaded, `settings.yaml` is auto-created as a copy. Users edit `settings.yaml` to customize; `defaults.yaml` remains unchanged.
|
||||
|
||||
**In templates** (scalars only):
|
||||
```yaml
|
||||
prompt: "Using {settings.backend} at {settings.endpoint}"
|
||||
```
|
||||
|
||||
**In code steps** (full access):
|
||||
```python
|
||||
endpoint = settings.get('endpoint')
|
||||
api_key = settings['api_key']
|
||||
nested = settings['options']['timeout'] # Nested access works
|
||||
```
|
||||
|
||||
**CLI commands:**
|
||||
```bash
|
||||
cmdforge settings mytool show # View settings
|
||||
cmdforge settings mytool edit # Edit in $EDITOR
|
||||
cmdforge settings mytool reset # Reset to defaults
|
||||
cmdforge settings mytool diff # Show changes
|
||||
```
|
||||
|
||||
### Output Variables
|
||||
|
||||
@@ -128,8 +183,8 @@ sum --stdin
|
||||
# No input (empty string) - useful for tools using only arguments
|
||||
sum --max 100
|
||||
|
||||
# Or via smarttools run
|
||||
smarttools run sum -i document.txt
|
||||
# Or via cmdforge run
|
||||
cmdforge run sum -i document.txt
|
||||
```
|
||||
|
||||
### Input Handling
|
||||
@@ -158,20 +213,25 @@ smarttools run sum -i document.txt
|
||||
|
||||
```bash
|
||||
# Launch the UI to manage tools
|
||||
smarttools
|
||||
cmdforge
|
||||
|
||||
# Or use CLI directly:
|
||||
smarttools list # List all tools
|
||||
smarttools create sum # Create new tool (basic)
|
||||
smarttools edit sum # Edit tool config in $EDITOR
|
||||
smarttools delete sum # Delete tool
|
||||
smarttools test sum # Test with mock provider
|
||||
smarttools run sum # Run tool for real
|
||||
smarttools refresh # Refresh all wrapper scripts
|
||||
smarttools ui # Launch interactive UI
|
||||
cmdforge list # List all tools
|
||||
cmdforge create sum # Create new tool (basic)
|
||||
cmdforge edit sum # Edit tool config in $EDITOR
|
||||
cmdforge delete sum # Delete tool
|
||||
cmdforge test sum # Test with mock provider
|
||||
cmdforge run sum # Run tool for real
|
||||
cmdforge refresh # Refresh all wrapper scripts
|
||||
cmdforge check sum # Check dependencies for meta-tools
|
||||
cmdforge ui # Launch interactive UI
|
||||
```
|
||||
|
||||
## Terminal UI
|
||||
## Terminal UI (Historical)
|
||||
|
||||
> **Note**: The urwid TUI described below has been replaced by a modern PySide6 desktop GUI.
|
||||
> The GUI provides the same functionality with a more polished interface. Run `cmdforge` to
|
||||
> launch the GUI, or `cmdforge ui` for legacy TUI mode if available.
|
||||
|
||||
A BIOS-style terminal UI using `urwid` with full mouse support.
|
||||
|
||||
@@ -179,7 +239,7 @@ A BIOS-style terminal UI using `urwid` with full mouse support.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ SmartTools Manager │
|
||||
│ CmdForge Manager │
|
||||
├──────────────────────────────────────┤
|
||||
│ Tools: │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
@@ -256,7 +316,7 @@ The current section's title is highlighted with brackets: `[ Tool Info ]`
|
||||
|
||||
**Code Step Features:**
|
||||
- **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)
|
||||
- **Multiple output vars** - Capture multiple variables (comma-separated)
|
||||
|
||||
@@ -264,21 +324,32 @@ The current section's title is highlighted with brackets: `[ Tool Info ]`
|
||||
|
||||
### Directory Structure
|
||||
|
||||
> **Note**: The structure below is simplified. See `src/cmdforge/` for the full implementation.
|
||||
|
||||
```
|
||||
smarttools/
|
||||
src/cmdforge/
|
||||
__init__.py
|
||||
cli.py # Entry point, argument parsing
|
||||
ui.py # UI dispatcher (chooses urwid/snack/dialog)
|
||||
ui_urwid.py # Urwid-based BIOS-style UI
|
||||
ui_snack.py # Snack/newt fallback UI
|
||||
cli/ # CLI commands (list, create, edit, run, registry, etc.)
|
||||
gui/ # PySide6 desktop GUI
|
||||
main_window.py
|
||||
pages/ # Tools, Registry, Providers, Profiles pages
|
||||
dialogs/ # Step editors, Publish dialog, Connect dialog
|
||||
web/ # Flask web application
|
||||
app.py
|
||||
routes.py
|
||||
templates/
|
||||
registry/ # Registry API
|
||||
app.py
|
||||
db.py
|
||||
tool.py # Tool loading/saving/wrapper scripts
|
||||
runner.py # Execute tools
|
||||
providers.py # Provider abstraction
|
||||
profiles.py # AI persona profiles
|
||||
```
|
||||
|
||||
### Provider System
|
||||
|
||||
Providers are defined in `~/.smarttools/providers.yaml`:
|
||||
Providers are defined in `~/.cmdforge/providers.yaml`:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
@@ -297,18 +368,18 @@ The provider command receives the prompt via stdin and outputs to stdout.
|
||||
|
||||
### 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
|
||||
#!/bin/bash
|
||||
# SmartTools wrapper for 'sum'
|
||||
# CmdForge wrapper for 'sum'
|
||||
# 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -319,12 +390,12 @@ Tool names must:
|
||||
|
||||
## 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)
|
||||
|
||||
```bash
|
||||
# Chain multiple SmartTools
|
||||
# Chain multiple CmdForge
|
||||
cat logs.txt | log-errors | summarize | translate --lang Japanese
|
||||
|
||||
# Mix with standard Unix tools
|
||||
@@ -363,33 +434,38 @@ output: "{result}"
|
||||
|
||||
## What This Design Doesn't Include
|
||||
|
||||
Intentionally omitted (not needed for personal use):
|
||||
Intentionally omitted for local tool execution (not needed for personal use):
|
||||
|
||||
- Trust tiers / security levels
|
||||
- Cryptographic signing
|
||||
- Container isolation / sandboxing
|
||||
- Certification testing
|
||||
- Distribution packaging
|
||||
- PII redaction
|
||||
- Audit logging
|
||||
- Provider capability negotiation
|
||||
|
||||
**Why?** This is a personal tool builder. You write the tools, you run the tools, you accept the responsibility. Just like any bash script you write.
|
||||
|
||||
> **Note**: Some features were later added for the **registry** (community tool sharing):
|
||||
> - Trust tiers → Publisher trust scores and badges
|
||||
> - Security scanning → Scrutiny system with AI review
|
||||
> - Audit logging → Admin audit trail for moderation actions
|
||||
> - Distribution → Registry publishing with version management
|
||||
|
||||
## Dependencies
|
||||
|
||||
Required:
|
||||
- Python 3.10+
|
||||
- PyYAML
|
||||
- urwid (for TUI)
|
||||
- PySide6 (for GUI)
|
||||
|
||||
Optional fallbacks:
|
||||
- python3-newt/snack (simpler TUI)
|
||||
- dialog/whiptail (basic TUI)
|
||||
Optional (for web/registry):
|
||||
- Flask
|
||||
- SQLite with FTS5
|
||||
|
||||
Legacy TUI (no longer primary):
|
||||
- urwid
|
||||
- python3-newt/snack
|
||||
|
||||
## Example Workflow
|
||||
|
||||
1. Run `smarttools` to open UI
|
||||
1. Run `cmdforge` to open UI
|
||||
2. Select "Create" to create a new tool
|
||||
3. Fill in: name, description, output template
|
||||
4. Add arguments (e.g., `--max` with default `500`)
|
||||
@@ -1,13 +1,13 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# Install all example tools (from the SmartTools directory)
|
||||
# Install all example tools (from the CmdForge directory)
|
||||
python examples/install.py
|
||||
smarttools refresh
|
||||
cmdforge refresh
|
||||
```
|
||||
|
||||
## Text Processing Tools
|
||||
@@ -17,7 +17,7 @@ smarttools refresh
|
||||
Condense long documents to key points.
|
||||
|
||||
```yaml
|
||||
# ~/.smarttools/summarize/config.yaml
|
||||
# ~/.cmdforge/summarize/config.yaml
|
||||
name: summarize
|
||||
description: Condense long documents to key points
|
||||
arguments:
|
||||
@@ -963,7 +963,7 @@ output: "{result}"
|
||||
|
||||
## Pipeline Recipes
|
||||
|
||||
SmartTools chain together like Unix commands. Here are practical examples:
|
||||
CmdForge chain together like Unix commands. Here are practical examples:
|
||||
|
||||
### Development Workflows
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Installation Guide
|
||||
|
||||
Complete installation instructions for SmartTools.
|
||||
Complete installation instructions for CmdForge.
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||
cd CmdForge
|
||||
pip install -e .
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
smarttools providers install
|
||||
cmdforge providers install
|
||||
```
|
||||
|
||||
## Detailed Installation
|
||||
@@ -32,11 +32,11 @@ brew install [email protected]
|
||||
python3 --version # Should be 3.10 or higher
|
||||
```
|
||||
|
||||
### Step 2: Install SmartTools
|
||||
### Step 2: Install CmdForge
|
||||
|
||||
```bash
|
||||
git clone https://gitea.brrd.tech/rob/SmartTools.git
|
||||
cd SmartTools
|
||||
git clone https://gitea.brrd.tech/rob/CmdForge.git
|
||||
cd CmdForge
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
@@ -47,7 +47,7 @@ pip install -e ".[dev]"
|
||||
|
||||
### 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
|
||||
@@ -74,7 +74,7 @@ source ~/.bashrc # or restart terminal
|
||||
You need at least one AI CLI tool. The easiest way is the interactive installer:
|
||||
|
||||
```bash
|
||||
smarttools providers install
|
||||
cmdforge providers install
|
||||
```
|
||||
|
||||
This guides you through installing and authenticating providers.
|
||||
@@ -110,7 +110,7 @@ gemini # Opens browser for Google sign-in
|
||||
```bash
|
||||
curl -fsSL https://ollama.ai/install.sh | bash
|
||||
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.
|
||||
@@ -118,17 +118,17 @@ See [PROVIDERS.md](PROVIDERS.md) for full provider setup instructions.
|
||||
### Step 5: Verify Installation
|
||||
|
||||
```bash
|
||||
# Check SmartTools is installed
|
||||
smarttools --version
|
||||
# Check CmdForge is installed
|
||||
cmdforge --version
|
||||
|
||||
# List configured providers
|
||||
smarttools providers list
|
||||
cmdforge providers list
|
||||
|
||||
# Check which providers are available
|
||||
smarttools providers check
|
||||
cmdforge providers check
|
||||
|
||||
# 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
|
||||
@@ -136,7 +136,7 @@ echo "hello world" | smarttools run --provider mock
|
||||
After installation or any Python environment change:
|
||||
|
||||
```bash
|
||||
smarttools refresh
|
||||
cmdforge refresh
|
||||
```
|
||||
|
||||
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
|
||||
python examples/install.py
|
||||
smarttools refresh
|
||||
cmdforge refresh
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "smarttools: command not found"
|
||||
### "cmdforge: command not found"
|
||||
|
||||
PATH is not configured. Add to your shell config:
|
||||
```bash
|
||||
@@ -168,14 +168,14 @@ export PATH="$HOME/.local/bin:$PATH"
|
||||
### "Provider 'X' not found"
|
||||
|
||||
1. Check provider is installed: `which claude` or `which opencode`
|
||||
2. Check provider is configured: `smarttools providers`
|
||||
3. Add missing provider: `smarttools providers add`
|
||||
2. Check provider is configured: `cmdforge providers`
|
||||
3. Add missing provider: `cmdforge providers add`
|
||||
|
||||
### "Permission denied" on tool execution
|
||||
|
||||
Refresh wrapper scripts:
|
||||
```bash
|
||||
smarttools refresh
|
||||
cmdforge refresh
|
||||
```
|
||||
|
||||
### TUI doesn't start / looks broken
|
||||
@@ -187,28 +187,28 @@ pip install urwid
|
||||
|
||||
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:
|
||||
```bash
|
||||
smarttools refresh
|
||||
cmdforge refresh
|
||||
```
|
||||
|
||||
### 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
|
||||
python3.11 -m pip install smarttools
|
||||
python3.11 -m pip install cmdforge
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
pip uninstall smarttools
|
||||
pip uninstall cmdforge
|
||||
|
||||
# Remove config and tools (optional)
|
||||
rm -rf ~/.smarttools
|
||||
rm -rf ~/.cmdforge
|
||||
|
||||
# Remove wrapper scripts (optional)
|
||||
rm ~/.local/bin/{summarize,translate,fix-grammar,...}
|
||||
@@ -217,8 +217,8 @@ rm ~/.local/bin/{summarize,translate,fix-grammar,...}
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
pip install --upgrade smarttools
|
||||
smarttools refresh
|
||||
pip install --upgrade cmdforge
|
||||
cmdforge refresh
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
@@ -226,7 +226,7 @@ smarttools refresh
|
||||
After installation:
|
||||
|
||||
```
|
||||
~/.smarttools/
|
||||
~/.cmdforge/
|
||||
├── providers.yaml # Provider configurations
|
||||
├── summarize/
|
||||
│ └── config.yaml # Tool config
|
||||
@@ -236,7 +236,7 @@ After installation:
|
||||
└── ...
|
||||
|
||||
~/.local/bin/
|
||||
├── smarttools # Main command
|
||||
├── cmdforge # Main command
|
||||
├── summarize # Tool wrapper script
|
||||
├── translate # Tool wrapper script
|
||||
└── ...
|
||||
@@ -246,4 +246,4 @@ After installation:
|
||||
|
||||
1. [Set up providers](PROVIDERS.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,256 @@
|
||||
# Meta-Tools: Tools That Call Other Tools
|
||||
|
||||
> **Document Status (January 2026)**: The `tool` step type is implemented and works as described.
|
||||
> The CLI dependency commands are now implemented:
|
||||
> - `cmdforge check <tool>` - Check if dependencies are satisfied
|
||||
> - `cmdforge install` - Install dependencies from cmdforge.yaml manifest
|
||||
> - `cmdforge add <owner/name>` - Add a tool to project dependencies
|
||||
> - `--auto-install` flag on tool execution for automatic dependency installation
|
||||
|
||||
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,200 @@
|
||||
# CmdForge Project Overview
|
||||
|
||||
> **Document Status (January 2026)**: This overview is mostly accurate but the source structure
|
||||
> section is outdated. The TUI has been replaced by a PySide6 GUI. Database now has 25+ tables.
|
||||
> For current architecture, see `src/cmdforge/` directly.
|
||||
|
||||
This document provides a comprehensive overview of the CmdForge project structure, components, and how everything fits together.
|
||||
|
||||
## What is CmdForge?
|
||||
|
||||
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/ # CLI commands
|
||||
│ ├── __init__.py # Entry point, subcommand routing
|
||||
│ └── registry_commands.py # Registry CLI commands
|
||||
├── tool.py # Tool dataclasses, YAML loading
|
||||
├── runner.py # Step execution engine
|
||||
├── providers.py # AI provider abstraction
|
||||
├── profiles.py # AI persona profiles
|
||||
├── config.py # Global configuration
|
||||
│
|
||||
├── gui/ # PySide6 Desktop GUI (primary UI)
|
||||
│ ├── main_window.py # Main application window
|
||||
│ ├── pages/ # Tools, Registry, Providers, Profiles pages
|
||||
│ ├── dialogs/ # Step editors, Publish, Connect dialogs
|
||||
│ └── workers/ # Background threads for API calls
|
||||
│
|
||||
├── registry/ # Registry API Server
|
||||
│ ├── app.py # Flask API with 100+ endpoints
|
||||
│ ├── db.py # Database operations, FTS5 search (25+ tables)
|
||||
│ ├── rate_limit.py # API rate limiting
|
||||
│ ├── scrutiny.py # Automated code analysis
|
||||
│ ├── similarity.py # Duplicate detection
|
||||
│ └── sync.py # Git sync for tool submissions
|
||||
│
|
||||
├── web/ # Web UI Application
|
||||
│ ├── app.py # Flask web app
|
||||
│ ├── routes.py # Page routes and handlers
|
||||
│ ├── auth.py # Password hashing, login/register
|
||||
│ ├── sessions.py # Session management
|
||||
│ ├── forum/ # Forum blueprint
|
||||
│ ├── templates/ # Jinja HTML templates
|
||||
│ └── static/ # CSS (Tailwind), JS, images
|
||||
│
|
||||
└── registry_client.py # CLI client for registry API
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
SQLite database at `~/.cmdforge/registry.db` (or via `CMDFORGE_REGISTRY_DB` env var):
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `publishers` | User accounts with roles, banning, verification |
|
||||
| `tools` | Published tools with moderation status, fork tracking |
|
||||
| `api_tokens` | Authentication tokens for API access |
|
||||
| `download_stats` | Tool download tracking by date |
|
||||
| `reviews` | Tool reviews with 1-5 star ratings |
|
||||
| `review_votes` | Helpful/unhelpful votes on reviews |
|
||||
| `tool_issues` | Bug, security, compatibility issue reports |
|
||||
| `collections` | Curated tool groups |
|
||||
| `announcements` | Admin announcements |
|
||||
| `featured_tools` | Featured tool placement |
|
||||
| `featured_contributors` | Featured publisher placement |
|
||||
| `reports` | Abuse reports for moderation |
|
||||
| `audit_log` | Admin action audit trail |
|
||||
| `pairing_requests` | App pairing flow |
|
||||
| `web_sessions` | Session storage |
|
||||
| `consents` | User consent for analytics/ads |
|
||||
| `pageviews` | Analytics tracking |
|
||||
| `tools_fts` | Full-text search index (FTS5) |
|
||||
| `tool_stats` | Cached tool statistics |
|
||||
| `publisher_stats` | Cached publisher statistics |
|
||||
| `registry_settings` | Runtime-configurable settings |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
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,9 @@
|
||||
# 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.
|
||||
> **Document Status (January 2026)**: This guide is current and accurate. Provider benchmarks
|
||||
> may vary based on model updates. Use `cmdforge providers install` for the latest setup.
|
||||
|
||||
CmdForge works with any AI CLI tool that accepts input via stdin or arguments. This guide covers setup for the most popular providers.
|
||||
|
||||
## Provider Comparison
|
||||
|
||||
@@ -139,7 +142,7 @@ echo "Hello" | gemini --model gemini-2.5-flash
|
||||
The easiest way to install providers:
|
||||
|
||||
```bash
|
||||
smarttools providers install
|
||||
cmdforge providers install
|
||||
```
|
||||
|
||||
This interactive guide:
|
||||
@@ -151,22 +154,22 @@ This interactive guide:
|
||||
### List Providers
|
||||
|
||||
```bash
|
||||
smarttools providers list
|
||||
cmdforge providers list
|
||||
```
|
||||
|
||||
### Check Availability
|
||||
|
||||
```bash
|
||||
smarttools providers check
|
||||
cmdforge providers check
|
||||
```
|
||||
|
||||
### Add Custom Provider
|
||||
|
||||
```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
|
||||
providers:
|
||||
@@ -242,9 +245,9 @@ cat file.txt | summarize --provider claude-opus
|
||||
|
||||
### "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>`
|
||||
3. Add it: `smarttools providers add`
|
||||
3. Add it: `cmdforge providers add`
|
||||
|
||||
### "Command 'X' not found"
|
||||
|
||||
@@ -273,8 +276,8 @@ echo "Say hello" | claude -p
|
||||
echo "Say hello" | ~/.opencode/bin/opencode run
|
||||
```
|
||||
|
||||
If it works directly but not in SmartTools, check:
|
||||
1. Provider command in `~/.smarttools/providers.yaml`
|
||||
If it works directly but not in CmdForge, check:
|
||||
1. Provider command in `~/.cmdforge/providers.yaml`
|
||||
2. Environment variables are expanded correctly
|
||||
|
||||
## Cost Optimization
|
||||
+2107
File diff suppressed because it is too large
Load Diff
+1548
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)
|
||||
+43
-12
@@ -3,9 +3,9 @@ requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "smarttools"
|
||||
name = "cmdforge"
|
||||
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"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
@@ -31,33 +31,64 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"PyYAML>=6.0",
|
||||
"requests>=2.28",
|
||||
"PySide6>=6.5",
|
||||
"NodeGraphQt>=0.6.0",
|
||||
"setuptools", # Required for distutils compatibility (Python 3.12+)
|
||||
"jsonschema>=4.0", # JSON schema validation for structured output
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
tui = [
|
||||
"urwid>=2.1.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"urwid>=2.1.0",
|
||||
"tomli>=1.1; python_version < '3.11'",
|
||||
]
|
||||
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
|
||||
]
|
||||
pty = [
|
||||
"pexpect>=4.8", # For PTY providers (interactive CLIs like Aider)
|
||||
]
|
||||
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",
|
||||
"pexpect>=4.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
smarttools = "smarttools.cli:main"
|
||||
cmdforge = "cmdforge.cli:main"
|
||||
cf = "cmdforge.cli.picker:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.brrd.tech/rob/smarttools"
|
||||
Documentation = "https://gitea.brrd.tech/rob/smarttools#readme"
|
||||
Repository = "https://gitea.brrd.tech/rob/smarttools.git"
|
||||
Issues = "https://gitea.brrd.tech/rob/smarttools/issues"
|
||||
Homepage = "https://cmdforge.brrd.tech"
|
||||
Documentation = "https://cmdforge.brrd.tech/docs"
|
||||
Repository = "https://gitea.brrd.tech/rob/CmdForge.git"
|
||||
Issues = "https://gitea.brrd.tech/rob/CmdForge/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"cmdforge.web" = [
|
||||
"templates/**/*.html",
|
||||
"static/**/*",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
markers = [
|
||||
"integration: marks tests as integration tests (require running server)",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# code-review
|
||||
|
||||
Review code for issues, risks, and improvements.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
code-review --language python --focus bugs,security --severity medium < app.py
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
- `--language`: Programming language or `auto`.
|
||||
- `--focus`: Comma-separated focus areas (e.g., `bugs,security,performance`).
|
||||
- `--severity`: Minimum severity level (`low`, `medium`, `high`).
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
cat main.go | code-review --language go --focus performance --severity high
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
name: code-review
|
||||
version: "1.0.0"
|
||||
description: "Review code for issues and improvements"
|
||||
category: code-analysis
|
||||
tags:
|
||||
- code
|
||||
- review
|
||||
- quality
|
||||
arguments:
|
||||
- name: language
|
||||
flag: "--language"
|
||||
default: "auto"
|
||||
description: "Programming language (e.g., python, javascript, go)"
|
||||
- name: focus
|
||||
flag: "--focus"
|
||||
default: "bugs,security,performance"
|
||||
description: "Comma-separated focus areas"
|
||||
- name: severity
|
||||
flag: "--severity"
|
||||
default: "medium"
|
||||
description: "Minimum severity: low, medium, high"
|
||||
steps:
|
||||
- type: prompt
|
||||
provider: "claude"
|
||||
template: |
|
||||
You are a senior code reviewer.
|
||||
Language: {language}
|
||||
Focus areas: {focus}
|
||||
Minimum severity: {severity}
|
||||
|
||||
Review the following code. Identify issues, risks, and improvements.
|
||||
Provide a concise, prioritized list with suggestions.
|
||||
|
||||
Code:
|
||||
{input}
|
||||
output_var: review
|
||||
output: "{review}"
|
||||
@@ -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
+953
@@ -0,0 +1,953 @@
|
||||
#!/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"
|
||||
|
||||
|
||||
def run_ai_scrutiny_review(scrutiny_report: dict, config: dict, tool_name: str, description: str):
|
||||
"""Run AI-powered secondary review of scrutiny warnings.
|
||||
|
||||
Uses the scrutiny-ai-review tool to analyze warnings and identify false positives.
|
||||
|
||||
Args:
|
||||
scrutiny_report: The scrutiny report with findings
|
||||
config: The tool configuration dict
|
||||
tool_name: Name of the tool being reviewed
|
||||
description: Tool description
|
||||
|
||||
Returns:
|
||||
AI review result dict, or None if review fails
|
||||
"""
|
||||
# Check if the AI review tool exists
|
||||
tool_path = Path.home() / ".cmdforge" / "scrutiny-ai-review" / "config.yaml"
|
||||
if not tool_path.exists():
|
||||
logger.debug(f" AI review skipped for {tool_name}: scrutiny-ai-review tool not found at {tool_path}")
|
||||
return None
|
||||
|
||||
# Extract warnings from scrutiny report
|
||||
warnings = [
|
||||
f for f in scrutiny_report.get("findings", [])
|
||||
if f.get("result") == "warning"
|
||||
]
|
||||
|
||||
if not warnings:
|
||||
logger.debug(f" AI review skipped for {tool_name}: no warnings to review")
|
||||
return None
|
||||
|
||||
# Prepare tool config for review
|
||||
tool_config = {
|
||||
"name": tool_name,
|
||||
"description": description or "",
|
||||
"steps": config.get("steps", []),
|
||||
"arguments": config.get("arguments", []),
|
||||
}
|
||||
|
||||
# Find cmdforge executable - try venv first, then PATH
|
||||
cmdforge_paths = [
|
||||
Path(sys.executable).parent / "cmdforge", # Same venv as current Python
|
||||
Path("/srv/mergerfs/data_pool/home/rob/cmdforge-registry/venv/bin/cmdforge"), # Server venv
|
||||
"cmdforge", # PATH
|
||||
]
|
||||
|
||||
cmdforge_exe = None
|
||||
for p in cmdforge_paths:
|
||||
p = Path(p) if not isinstance(p, Path) else p
|
||||
if p.exists() if isinstance(p, Path) and p.is_absolute() else True:
|
||||
cmdforge_exe = str(p)
|
||||
break
|
||||
|
||||
if not cmdforge_exe:
|
||||
logger.warning(f" AI review skipped for {tool_name}: cmdforge executable not found")
|
||||
return None
|
||||
|
||||
# Run the tool (use -- to separate cmdforge args from tool args)
|
||||
logger.debug(f" Running AI review for {tool_name} ({len(warnings)} warnings)...")
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
cmdforge_exe, "run", "scrutiny-ai-review", "--",
|
||||
"--warnings", json.dumps(warnings),
|
||||
"--tool-config", json.dumps(tool_config),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60, # 60 second timeout
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
logger.debug(f" AI review completed for {tool_name} in {elapsed:.1f}s")
|
||||
return json.loads(result.stdout.strip())
|
||||
else:
|
||||
logger.warning(f" AI review failed for {tool_name}: returncode={result.returncode}, elapsed={elapsed:.1f}s")
|
||||
if result.stderr:
|
||||
logger.warning(f" stderr: {result.stderr[:200]}")
|
||||
return None
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
elapsed = time.time() - start_time
|
||||
logger.warning(f" AI review TIMEOUT for {tool_name} after {elapsed:.1f}s")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
elapsed = time.time() - start_time
|
||||
logger.warning(f" AI review JSON parse error for {tool_name}: {e}")
|
||||
return None
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f" AI review failed for {tool_name}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
elapsed = time.time() - start_time
|
||||
logger.warning(f" AI review unexpected error for {tool_name} after {elapsed:.1f}s: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatternState:
|
||||
"""State of a single pattern."""
|
||||
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,
|
||||
skip_ai_review: bool = False
|
||||
) -> tuple[bool, str, dict]:
|
||||
"""Publish a tool directly to the registry database with vetting.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
config_yaml: Tool configuration as YAML string
|
||||
readme: README content
|
||||
provider: AI provider name
|
||||
auto_approve: If True, auto-approve tools that pass scrutiny
|
||||
skip_ai_review: If True, skip AI secondary review (for large bulk imports)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message, scrutiny_report)
|
||||
"""
|
||||
try:
|
||||
# Add src to path for registry imports
|
||||
src_dir = Path(__file__).parent.parent / "src"
|
||||
if str(src_dir) not in sys.path:
|
||||
sys.path.insert(0, str(src_dir))
|
||||
|
||||
from cmdforge.registry.db import connect_db, query_one
|
||||
from cmdforge.registry.scrutiny import scrutinize_tool
|
||||
from cmdforge.hash_utils import compute_yaml_hash
|
||||
|
||||
# Parse config
|
||||
config = yaml.safe_load(config_yaml)
|
||||
version = config.get("version", "1.0.0")
|
||||
description = config.get("description", "")
|
||||
category = config.get("category")
|
||||
tags = config.get("tags", [])
|
||||
|
||||
conn = connect_db()
|
||||
|
||||
# Check if already exists
|
||||
existing = query_one(
|
||||
conn,
|
||||
"SELECT id, version FROM tools WHERE owner = ? AND name = ?",
|
||||
["official", name],
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Check if same version
|
||||
if existing["version"] == version:
|
||||
conn.close()
|
||||
return True, "Already exists (same version)", {}
|
||||
|
||||
# Run scrutiny
|
||||
scrutiny_report = {}
|
||||
try:
|
||||
scrutiny_report = scrutinize_tool(config_yaml, description, readme)
|
||||
except Exception as e:
|
||||
logger.warning(f"Scrutiny failed for {name}: {e}")
|
||||
|
||||
# Run AI secondary review if there are warnings (unless skipped)
|
||||
scrutiny_decision = scrutiny_report.get("decision", "review")
|
||||
if scrutiny_decision == "review" and skip_ai_review:
|
||||
logger.info(f" AI review skipped for {name} (--skip-ai-review flag)")
|
||||
scrutiny_report["ai_review_skipped"] = True
|
||||
elif scrutiny_decision == "review":
|
||||
try:
|
||||
ai_review = run_ai_scrutiny_review(scrutiny_report, config, name, description)
|
||||
if ai_review:
|
||||
scrutiny_report["ai_review"] = ai_review
|
||||
# Update decision based on AI review
|
||||
if ai_review.get("overall_verdict") == "APPROVE" and ai_review.get("confidence", 0) >= 0.8:
|
||||
scrutiny_report["decision"] = "approve"
|
||||
scrutiny_report["ai_approved"] = True
|
||||
scrutiny_decision = "approve"
|
||||
logger.info(f" AI review approved {name} (confidence: {ai_review.get('confidence', 0):.2f})")
|
||||
except Exception as e:
|
||||
scrutiny_report["ai_review_error"] = str(e)
|
||||
logger.warning(f"AI review failed for {name}: {e}")
|
||||
|
||||
# Check scrutiny decision
|
||||
if scrutiny_decision == "reject":
|
||||
fail_findings = [f for f in scrutiny_report.get("findings", []) if f.get("result") == "fail"]
|
||||
fail_msg = fail_findings[0]["message"] if fail_findings else "quality too low"
|
||||
conn.close()
|
||||
return False, f"Rejected by scrutiny: {fail_msg}", scrutiny_report
|
||||
|
||||
# Determine statuses
|
||||
if scrutiny_decision == "approve":
|
||||
scrutiny_status = "approved"
|
||||
elif scrutiny_decision == "review":
|
||||
scrutiny_status = "pending_review"
|
||||
else:
|
||||
scrutiny_status = "pending"
|
||||
|
||||
# Moderation status based on auto_approve setting
|
||||
if auto_approve and scrutiny_status == "approved":
|
||||
moderation_status = "approved"
|
||||
else:
|
||||
moderation_status = "pending"
|
||||
|
||||
# Compute hash
|
||||
config_hash = compute_yaml_hash(config_yaml)
|
||||
|
||||
# Source attribution
|
||||
source_json = json.dumps({
|
||||
"type": "imported",
|
||||
"original_tool": f"fabric/patterns/{name}",
|
||||
"url": "https://github.com/danielmiessler/fabric",
|
||||
"license": "MIT",
|
||||
"author": "Daniel Miessler"
|
||||
})
|
||||
|
||||
tags_json = json.dumps(tags) if tags else "[]"
|
||||
scrutiny_json = json.dumps(scrutiny_report) if scrutiny_report else None
|
||||
|
||||
# Ensure official publisher exists
|
||||
publisher = query_one(conn, "SELECT id FROM publishers WHERE slug = ?", ["official"])
|
||||
if not publisher:
|
||||
conn.execute(
|
||||
"INSERT INTO publishers (email, password_hash, slug, display_name, verified) VALUES (?, ?, ?, ?, ?)",
|
||||
["[email protected]", "", "official", "Official", True]
|
||||
)
|
||||
publisher_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
else:
|
||||
publisher_id = publisher["id"]
|
||||
|
||||
if existing:
|
||||
# Update existing tool
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE tools SET
|
||||
version = ?, description = ?, category = ?, tags = ?,
|
||||
config_yaml = ?, readme = ?, scrutiny_status = ?, scrutiny_report = ?,
|
||||
source_json = ?, config_hash = ?, moderation_status = ?,
|
||||
published_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
[
|
||||
version, description, category, tags_json,
|
||||
config_yaml, readme, scrutiny_status, scrutiny_json,
|
||||
source_json, config_hash, moderation_status,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
existing["id"]
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Insert new tool
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tools (
|
||||
owner, name, version, description, category, tags, config_yaml, readme,
|
||||
publisher_id, scrutiny_status, scrutiny_report, source_json,
|
||||
config_hash, visibility, moderation_status, published_at, downloads
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
"official", name, version, description, category, tags_json,
|
||||
config_yaml, readme, publisher_id, scrutiny_status, scrutiny_json,
|
||||
source_json, config_hash, "public", moderation_status,
|
||||
datetime.now(timezone.utc).isoformat(), 0
|
||||
]
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
decision = scrutiny_report.get("decision", "unknown")
|
||||
return True, f"Published (scrutiny: {decision}, moderation: {moderation_status})", scrutiny_report
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Registry publish failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False, str(e), {}
|
||||
|
||||
|
||||
def sync_pattern(
|
||||
pattern_dir: Path,
|
||||
output_dir: Path,
|
||||
provider: str,
|
||||
state: SyncState,
|
||||
dry_run: bool = False,
|
||||
publish_to_db: bool = True,
|
||||
auto_approve: bool = False,
|
||||
skip_ai_review: bool = False
|
||||
) -> bool:
|
||||
"""Sync a single pattern.
|
||||
|
||||
Args:
|
||||
pattern_dir: Path to the pattern directory
|
||||
output_dir: Output directory for local tools (if not publishing to DB)
|
||||
provider: AI provider name
|
||||
state: Sync state object
|
||||
dry_run: If True, don't make changes
|
||||
publish_to_db: If True, publish to registry database; if False, create local files
|
||||
auto_approve: If True, auto-approve tools that pass scrutiny
|
||||
skip_ai_review: If True, skip AI secondary review (for large bulk imports)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
name = pattern_dir.name
|
||||
pattern_hash = hash_pattern(pattern_dir)
|
||||
|
||||
# Read pattern content
|
||||
system_md = pattern_dir / "system.md"
|
||||
if not system_md.exists():
|
||||
logger.warning(f" ✗ {name}: No system.md found")
|
||||
return False
|
||||
|
||||
system_prompt = system_md.read_text()
|
||||
|
||||
# Import helper functions
|
||||
script_dir = Path(__file__).parent
|
||||
if str(script_dir) not in sys.path:
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
from import_fabric import create_tool_config, pattern_to_display_name
|
||||
|
||||
# Create tool config
|
||||
config = create_tool_config(name, system_prompt, provider)
|
||||
config_yaml = yaml.dump(config, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Create README with usage and attribution
|
||||
display_name = pattern_to_display_name(name)
|
||||
readme = f"""# {display_name}
|
||||
|
||||
{config.get('description', '')}
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cat input.txt | {name}
|
||||
```
|
||||
|
||||
## Source
|
||||
|
||||
This tool was imported from [Fabric](https://github.com/danielmiessler/fabric) patterns.
|
||||
|
||||
- **Original pattern**: `{name}`
|
||||
- **Author**: Daniel Miessler
|
||||
- **License**: MIT
|
||||
"""
|
||||
|
||||
if dry_run:
|
||||
# Quick vet check for dry run
|
||||
passed, reason = vet_pattern(pattern_dir, provider)
|
||||
if passed:
|
||||
logger.info(f" [DRY RUN] Would sync: {name} ({reason})")
|
||||
else:
|
||||
logger.info(f" [DRY RUN] Would skip: {name} ({reason})")
|
||||
return passed
|
||||
|
||||
if publish_to_db:
|
||||
# Publish directly to registry database
|
||||
success, message, report = publish_to_registry(name, config_yaml, readme, provider, auto_approve, skip_ai_review)
|
||||
|
||||
if success:
|
||||
logger.info(f" ✓ {name} -> registry ({message})")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "synced",
|
||||
"message": message,
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return True
|
||||
else:
|
||||
logger.warning(f" ✗ {name}: {message}")
|
||||
state.patterns[name] = {
|
||||
"name": name,
|
||||
"hash": pattern_hash,
|
||||
"status": "failed",
|
||||
"reason": message,
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return False
|
||||
else:
|
||||
# Original behavior: create local files
|
||||
passed, reason = vet_pattern(pattern_dir, provider)
|
||||
|
||||
if not passed:
|
||||
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,
|
||||
skip_ai_review: bool = False
|
||||
) -> dict:
|
||||
"""Run the sync process.
|
||||
|
||||
Args:
|
||||
sync_dir: Directory for sync data (Fabric clone)
|
||||
output_dir: Output directory for local tools (if not publishing to DB)
|
||||
state_file: Path to state file
|
||||
provider: AI provider name
|
||||
dry_run: If True, don't make changes
|
||||
force_patterns: List of pattern names to force resync
|
||||
publish_to_db: If True, publish to registry database
|
||||
auto_approve: If True, auto-approve tools that pass scrutiny
|
||||
skip_ai_review: If True, skip AI secondary review (for large bulk imports)
|
||||
|
||||
Returns:
|
||||
Summary dict with counts
|
||||
"""
|
||||
# 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, skip_ai_review):
|
||||
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,
|
||||
skip_ai_review: bool = False
|
||||
):
|
||||
"""Run sync in a loop."""
|
||||
logger.info(f"Starting daemon mode with {interval}s interval")
|
||||
|
||||
while True:
|
||||
try:
|
||||
run_sync(sync_dir, output_dir, state_file, provider,
|
||||
publish_to_db=publish_to_db, auto_approve=auto_approve,
|
||||
skip_ai_review=skip_ai_review)
|
||||
except Exception as e:
|
||||
logger.error(f"Sync failed: {e}")
|
||||
|
||||
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)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-ai-review",
|
||||
action="store_true",
|
||||
help="Skip AI secondary review (for large bulk imports to avoid rate limiting)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 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,
|
||||
skip_ai_review=args.skip_ai_review
|
||||
)
|
||||
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,
|
||||
skip_ai_review=args.skip_ai_review
|
||||
)
|
||||
|
||||
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,463 @@
|
||||
"""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 .collections_commands import cmd_collections
|
||||
from .project_commands import cmd_deps, cmd_deps_tree, cmd_install_deps, cmd_add, cmd_remove, cmd_init, cmd_lock, cmd_verify
|
||||
from .config_commands import cmd_config
|
||||
from .settings_commands import cmd_settings
|
||||
from .system_deps_commands import cmd_system_deps
|
||||
|
||||
|
||||
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("-c", "--category", choices=["Text", "Developer", "Data", "Other"], default="Other", help="Tool category")
|
||||
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("--auto-install", action="store_true", help="Automatically install missing tool dependencies")
|
||||
p_run.add_argument("tool_args", nargs=argparse.REMAINDER, help="Additional tool-specific arguments (use -- to separate)")
|
||||
p_run.set_defaults(func=cmd_run)
|
||||
|
||||
# 'ui' command (explicit)
|
||||
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 discover
|
||||
p_prov_discover = providers_sub.add_parser("discover", help="Scan system for installed CLIs and API keys")
|
||||
p_prov_discover.add_argument("--add", action="store_true", help="Add newly discovered providers to the config")
|
||||
p_prov_discover.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, or base URL for API providers")
|
||||
p_prov_add.add_argument("-d", "--description", help="Provider description")
|
||||
p_prov_add.add_argument("--type", choices=["subprocess", "api", "pty"], help="Provider type")
|
||||
p_prov_add.add_argument("--model", help="Model ID for API providers")
|
||||
p_prov_add.add_argument("--api-key-env", help="Environment variable containing the API key")
|
||||
p_prov_add.add_argument("--tag", action="append", dest="tags", help="Provider tag (repeatable)")
|
||||
p_prov_add.add_argument("--fallback", help="Fallback provider name; pass an empty value to clear")
|
||||
p_prov_add.add_argument(
|
||||
"--fallback-chain",
|
||||
help="Preset name or comma-separated provider names; pass an empty value to clear",
|
||||
)
|
||||
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)
|
||||
|
||||
# providers for-tools
|
||||
p_prov_for_tools = providers_sub.add_parser("for-tools", help="List providers used by specified tools")
|
||||
p_prov_for_tools.add_argument("tools", nargs="+", help="Tool names to check")
|
||||
p_prov_for_tools.add_argument("--warm", action="store_true", help="Warm up the providers (send minimal prompt)")
|
||||
p_prov_for_tools.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.add_argument("-y", "--yes", action="store_true", help="Auto-install system packages without prompting")
|
||||
p_reg_install.add_argument("--no-system-deps", action="store_true", help="Skip system dependency check")
|
||||
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.add_argument("-f", "--force", action="store_true", help="Skip confirmation prompts")
|
||||
p_reg_publish.add_argument("--owner", default="", help="Owner override (admin only, e.g. 'official')")
|
||||
p_reg_publish.set_defaults(func=cmd_registry)
|
||||
|
||||
# registry update-readme
|
||||
p_reg_update_readme = registry_sub.add_parser("update-readme", help="Update README for a published tool")
|
||||
p_reg_update_readme.add_argument("tool", nargs="?", default="", help="Tool name (local name, will resolve owner)")
|
||||
p_reg_update_readme.add_argument("--all", action="store_true", dest="update_all", help="Update README for all published tools that have a local README.md")
|
||||
p_reg_update_readme.set_defaults(func=cmd_registry)
|
||||
|
||||
# registry describe (semantic search)
|
||||
p_reg_describe = registry_sub.add_parser("describe", help="Find tools by describing what you need")
|
||||
p_reg_describe.add_argument("query", help="Natural language description (e.g., 'something that analyzes CSV files')")
|
||||
p_reg_describe.add_argument("-n", "--limit", type=int, default=20, help="Max results (default: 20)")
|
||||
p_reg_describe.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
p_reg_describe.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 status
|
||||
p_reg_status = registry_sub.add_parser("status", help="Check moderation status of a tool")
|
||||
p_reg_status.add_argument("tool", help="Tool name to check status for")
|
||||
p_reg_status.add_argument("--sync", action="store_true", help="Sync status from server and update local config")
|
||||
p_reg_status.set_defaults(func=cmd_registry)
|
||||
|
||||
# registry browse
|
||||
p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)")
|
||||
p_reg_browse.set_defaults(func=cmd_registry)
|
||||
|
||||
# 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)))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Collections Commands
|
||||
# -------------------------------------------------------------------------
|
||||
p_collections = subparsers.add_parser("collections", help="Manage tool collections")
|
||||
collections_sub = p_collections.add_subparsers(dest="collections_cmd", help="Collections commands")
|
||||
|
||||
# collections list
|
||||
p_coll_list = collections_sub.add_parser("list", help="List available collections")
|
||||
p_coll_list.add_argument("--local", action="store_true", help="Show local collections only")
|
||||
p_coll_list.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
p_coll_list.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections info
|
||||
p_coll_info = collections_sub.add_parser("info", help="Show registry collection details")
|
||||
p_coll_info.add_argument("name", help="Collection name")
|
||||
p_coll_info.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
p_coll_info.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections install
|
||||
p_coll_install = collections_sub.add_parser("install", help="Install all tools in a registry collection")
|
||||
p_coll_install.add_argument("name", help="Collection name")
|
||||
p_coll_install.add_argument("--pinned", action="store_true", help="Use pinned versions from collection")
|
||||
p_coll_install.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections create (local)
|
||||
p_coll_create = collections_sub.add_parser("create", help="Create a new local collection")
|
||||
p_coll_create.add_argument("name", help="Collection name (slug)")
|
||||
p_coll_create.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections show (local)
|
||||
p_coll_show = collections_sub.add_parser("show", help="Show local collection details")
|
||||
p_coll_show.add_argument("name", help="Collection name")
|
||||
p_coll_show.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections add (local)
|
||||
p_coll_add = collections_sub.add_parser("add", help="Add tool to collection")
|
||||
p_coll_add.add_argument("collection", help="Collection name")
|
||||
p_coll_add.add_argument("tool", help="Tool reference (name or owner/name)")
|
||||
p_coll_add.add_argument("--version", "-v", help="Pin to specific version")
|
||||
p_coll_add.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections remove (local)
|
||||
p_coll_remove = collections_sub.add_parser("remove", help="Remove tool from collection")
|
||||
p_coll_remove.add_argument("collection", help="Collection name")
|
||||
p_coll_remove.add_argument("tool", help="Tool reference")
|
||||
p_coll_remove.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections delete (local)
|
||||
p_coll_delete = collections_sub.add_parser("delete", help="Delete local collection")
|
||||
p_coll_delete.add_argument("name", help="Collection name")
|
||||
p_coll_delete.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
||||
p_coll_delete.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections publish
|
||||
p_coll_publish = collections_sub.add_parser("publish", help="Publish collection to registry")
|
||||
p_coll_publish.add_argument("name", help="Collection name")
|
||||
p_coll_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing")
|
||||
p_coll_publish.add_argument("--continue", dest="resume", action="store_true",
|
||||
help="Resume pending publish after tools approved")
|
||||
p_coll_publish.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections status
|
||||
p_coll_status = collections_sub.add_parser("status", help="Check pending collection status")
|
||||
p_coll_status.add_argument("name", help="Collection name")
|
||||
p_coll_status.set_defaults(func=cmd_collections)
|
||||
|
||||
# Default for collections with no subcommand
|
||||
p_collections.set_defaults(func=lambda args: cmd_collections(args) if args.collections_cmd else (setattr(args, 'collections_cmd', None) or cmd_collections(args)))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Project Commands
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# 'deps' command with subcommands
|
||||
p_deps = subparsers.add_parser("deps", help="Show project dependencies")
|
||||
deps_sub = p_deps.add_subparsers(dest="deps_cmd", help="Deps commands")
|
||||
|
||||
# deps tree
|
||||
p_deps_tree = deps_sub.add_parser("tree", help="Show dependency tree")
|
||||
p_deps_tree.add_argument("--verbose", "-v", action="store_true", help="Show detailed info")
|
||||
p_deps_tree.set_defaults(func=cmd_deps_tree)
|
||||
|
||||
# Default for deps with no subcommand shows basic deps list
|
||||
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.add_argument("--dry-run", action="store_true", help="Show what would be installed without installing")
|
||||
p_install.add_argument("--force", action="store_true", help="Install despite version conflicts or stale lock")
|
||||
p_install.add_argument("--verbose", "-v", action="store_true", help="Show detailed resolution info")
|
||||
p_install.add_argument("--ignore-lock", action="store_true", help="Ignore lock file and resolve fresh from manifest")
|
||||
p_install.add_argument("--frozen", action="store_true", help="Fail if lock file is missing (for CI)")
|
||||
p_install.add_argument("--strict-frozen", action="store_true", help="Fail if lock is missing or stale (stricter CI mode)")
|
||||
p_install.set_defaults(func=cmd_install_deps)
|
||||
|
||||
# 'lock' command
|
||||
p_lock = subparsers.add_parser("lock", help="Generate or update cmdforge.lock")
|
||||
p_lock.add_argument("--force", "-f", action="store_true", help="Force regenerate even if up to date")
|
||||
p_lock.add_argument("--verbose", "-v", action="store_true", help="Show detailed output")
|
||||
p_lock.set_defaults(func=cmd_lock)
|
||||
|
||||
# 'verify' command
|
||||
p_verify = subparsers.add_parser("verify", help="Verify installed tools match lock file")
|
||||
p_verify.set_defaults(func=cmd_verify)
|
||||
|
||||
# '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)
|
||||
|
||||
# 'remove' command
|
||||
p_remove = subparsers.add_parser("remove", help="Remove a tool from project dependencies")
|
||||
p_remove.add_argument("tool", help="Tool to remove (owner/name or just name)")
|
||||
p_remove.set_defaults(func=cmd_remove)
|
||||
|
||||
# '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)
|
||||
|
||||
# config disconnect
|
||||
p_cfg_disconnect = config_sub.add_parser("disconnect", help="Disconnect from registry (clear token)")
|
||||
p_cfg_disconnect.set_defaults(func=cmd_config)
|
||||
|
||||
# Default for config with no subcommand
|
||||
p_config.set_defaults(func=lambda args: cmd_config(args) if args.config_cmd else (setattr(args, 'config_cmd', 'show') or cmd_config(args)))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Settings Commands
|
||||
# -------------------------------------------------------------------------
|
||||
p_settings = subparsers.add_parser("settings", help="Manage tool settings")
|
||||
p_settings.add_argument("tool", help="Tool name")
|
||||
settings_sub = p_settings.add_subparsers(dest="settings_cmd")
|
||||
|
||||
# settings show (default)
|
||||
p_settings_show = settings_sub.add_parser("show", help="Show current settings")
|
||||
p_settings_show.set_defaults(func=cmd_settings)
|
||||
|
||||
# settings edit
|
||||
p_settings_edit = settings_sub.add_parser("edit", help="Edit settings in $EDITOR")
|
||||
p_settings_edit.set_defaults(func=cmd_settings)
|
||||
|
||||
# settings reset
|
||||
p_settings_reset = settings_sub.add_parser("reset", help="Reset settings to defaults")
|
||||
p_settings_reset.add_argument("-f", "--force", action="store_true", help="Skip confirmation")
|
||||
p_settings_reset.set_defaults(func=cmd_settings)
|
||||
|
||||
# settings diff
|
||||
p_settings_diff = settings_sub.add_parser("diff", help="Show differences from defaults")
|
||||
p_settings_diff.set_defaults(func=cmd_settings)
|
||||
|
||||
# Default for settings with no subcommand
|
||||
p_settings.set_defaults(func=cmd_settings)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# System Dependencies Commands
|
||||
# -------------------------------------------------------------------------
|
||||
p_sysdeps = subparsers.add_parser("system-deps", help="Manage system package dependencies")
|
||||
p_sysdeps.add_argument("tool", help="Tool name")
|
||||
sysdeps_sub = p_sysdeps.add_subparsers(dest="system_deps_cmd")
|
||||
|
||||
# system-deps install
|
||||
p_sysdeps_install = sysdeps_sub.add_parser("install", help="Install missing system packages")
|
||||
p_sysdeps_install.add_argument("-y", "--yes", action="store_true", help="Install without prompting")
|
||||
p_sysdeps_install.set_defaults(func=cmd_system_deps)
|
||||
|
||||
# Default for system-deps with no subcommand (show status)
|
||||
p_sysdeps.set_defaults(func=cmd_system_deps)
|
||||
|
||||
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,924 @@
|
||||
"""Collections commands for CmdForge CLI."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
from ..resolver import install_from_registry
|
||||
|
||||
|
||||
def cmd_collections(args):
|
||||
"""Handle collections subcommands."""
|
||||
cmd = args.collections_cmd
|
||||
|
||||
# Existing commands (with --local flag support added)
|
||||
if cmd == "list":
|
||||
return _cmd_collections_list(args)
|
||||
elif cmd == "info":
|
||||
return _cmd_collections_info(args)
|
||||
elif cmd == "install":
|
||||
return _cmd_collections_install(args)
|
||||
|
||||
# Local management commands
|
||||
elif cmd == "create":
|
||||
return cmd_collections_create(args)
|
||||
elif cmd == "show":
|
||||
return cmd_collections_show(args)
|
||||
elif cmd == "add":
|
||||
return cmd_collections_add(args)
|
||||
elif cmd == "remove":
|
||||
return cmd_collections_remove(args)
|
||||
elif cmd == "delete":
|
||||
return cmd_collections_delete(args)
|
||||
|
||||
# Publishing commands
|
||||
elif cmd == "publish":
|
||||
return cmd_collections_publish(args)
|
||||
elif cmd == "status":
|
||||
return cmd_collections_status(args)
|
||||
|
||||
else:
|
||||
# Default: show help
|
||||
print("Collections commands:")
|
||||
print(" list List available collections (registry)")
|
||||
print(" info <name> Show registry collection details")
|
||||
print(" install <name> Install all tools in a registry collection")
|
||||
print()
|
||||
print("Local collection management:")
|
||||
print(" create <name> Create a new local collection")
|
||||
print(" show <name> Show local collection details")
|
||||
print(" add <coll> <tool> Add a tool to a collection")
|
||||
print(" remove <coll> <tool> Remove a tool from a collection")
|
||||
print(" delete <name> Delete a local collection")
|
||||
print()
|
||||
print("Publishing:")
|
||||
print(" publish <name> Publish a collection to the registry")
|
||||
print(" status <name> Check status of a pending collection")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_collections_list(args):
|
||||
"""List available collections (registry or local)."""
|
||||
import json as json_mod
|
||||
from ..collection import list_collections, get_collection
|
||||
|
||||
# Handle --local flag
|
||||
if getattr(args, 'local', False):
|
||||
local_colls = list_collections()
|
||||
|
||||
if getattr(args, 'json', False):
|
||||
data = []
|
||||
for name in local_colls:
|
||||
coll = get_collection(name)
|
||||
if coll:
|
||||
data.append(coll.to_dict())
|
||||
print(json_mod.dumps({"collections": data}, indent=2))
|
||||
return 0
|
||||
|
||||
if not local_colls:
|
||||
print("No local collections.")
|
||||
print("Create one with: cmdforge collections create <name>")
|
||||
return 0
|
||||
|
||||
print(f"Local collections ({len(local_colls)}):\n")
|
||||
for name in local_colls:
|
||||
coll = get_collection(name)
|
||||
if coll:
|
||||
status = "pending" if coll.pending_approval else ("published" if coll.published else "local")
|
||||
print(f" {coll.display_name}")
|
||||
print(f" {coll.name} ({len(coll.tools)} tools) [{status}]")
|
||||
print()
|
||||
return 0
|
||||
|
||||
# Registry listing
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
collections = client.get_collections()
|
||||
|
||||
# JSON output
|
||||
if getattr(args, 'json', False):
|
||||
print(json.dumps({"collections": collections}, indent=2))
|
||||
return 0
|
||||
|
||||
if not collections:
|
||||
print("No collections available.")
|
||||
return 0
|
||||
|
||||
print(f"Available collections ({len(collections)}):\n")
|
||||
for coll in collections:
|
||||
name = coll.get("name", "")
|
||||
display_name = coll.get("display_name", name)
|
||||
description = coll.get("description", "")
|
||||
# Use tool_count if available, fall back to counting tools list
|
||||
tools = coll.get("tools", [])
|
||||
tool_count = coll.get("tool_count", len(tools) if isinstance(tools, list) else 0)
|
||||
|
||||
print(f" {name}")
|
||||
print(f" {display_name}")
|
||||
if description:
|
||||
desc_short = description[:60] + ('...' if len(description) > 60 else '')
|
||||
print(f" {desc_short}")
|
||||
print(f" Tools: {tool_count}")
|
||||
print()
|
||||
|
||||
print("Install a collection with: cmdforge collections install <name>")
|
||||
|
||||
except RegistryError as e:
|
||||
if e.code == "CONNECTION_ERROR":
|
||||
print("Could not connect to the registry.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error: {e.message}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_collections_info(args):
|
||||
"""Show collection details."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
name = args.name
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
coll = client.get_collection(name)
|
||||
|
||||
# JSON output
|
||||
if getattr(args, 'json', False):
|
||||
print(json.dumps(coll, indent=2))
|
||||
return 0
|
||||
|
||||
display_name = coll.get("display_name", name)
|
||||
description = coll.get("description", "")
|
||||
maintainer = coll.get("maintainer", "")
|
||||
tools = coll.get("tools", [])
|
||||
pinned = coll.get("pinned", {})
|
||||
tags = coll.get("tags", [])
|
||||
|
||||
print(f"{display_name}")
|
||||
print("=" * 50)
|
||||
if description:
|
||||
print(f"\n{description}\n")
|
||||
if maintainer:
|
||||
print(f"Maintainer: {maintainer}")
|
||||
if tags:
|
||||
print(f"Tags: {', '.join(tags)}")
|
||||
|
||||
print(f"\nTools ({len(tools)}):")
|
||||
for tool in tools:
|
||||
# Handle both object format (from API) and string format
|
||||
if isinstance(tool, dict):
|
||||
tool_ref = f"{tool['owner']}/{tool['name']}"
|
||||
version_constraint = tool.get('pinned_version', '')
|
||||
else:
|
||||
tool_ref = tool
|
||||
version_constraint = pinned.get(tool_ref, '')
|
||||
|
||||
if version_constraint:
|
||||
print(f" - {tool_ref} @ {version_constraint}")
|
||||
else:
|
||||
print(f" - {tool_ref}")
|
||||
|
||||
print(f"\nInstall all: cmdforge collections install {name}")
|
||||
|
||||
except RegistryError as e:
|
||||
if e.code == "COLLECTION_NOT_FOUND":
|
||||
print(f"Collection '{name}' not found.", file=sys.stderr)
|
||||
print("List available collections: cmdforge collections list", file=sys.stderr)
|
||||
elif e.code == "CONNECTION_ERROR":
|
||||
print("Could not connect to the registry.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error: {e.message}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_collections_install(args):
|
||||
"""Install all tools in a collection."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
name = args.name
|
||||
use_pinned = getattr(args, 'pinned', False)
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
coll = client.get_collection(name)
|
||||
|
||||
tools = coll.get("tools", [])
|
||||
pinned = coll.get("pinned", {}) if use_pinned else {}
|
||||
display_name = coll.get("display_name", name)
|
||||
|
||||
if not tools:
|
||||
print(f"Collection '{name}' has no tools.")
|
||||
return 0
|
||||
|
||||
print(f"Installing collection: {display_name}")
|
||||
print(f"Tools to install: {len(tools)}")
|
||||
if use_pinned and pinned:
|
||||
print(f"Using pinned versions: {len(pinned)} tools")
|
||||
print()
|
||||
|
||||
installed = 0
|
||||
failed = 0
|
||||
|
||||
for tool in tools:
|
||||
# Handle both object format (from API) and string format
|
||||
if isinstance(tool, dict):
|
||||
tool_ref = f"{tool['owner']}/{tool['name']}"
|
||||
version = tool.get('pinned_version') if use_pinned else None
|
||||
else:
|
||||
tool_ref = tool
|
||||
version = pinned.get(tool_ref) if use_pinned else None
|
||||
|
||||
try:
|
||||
print(f" Installing {tool_ref}...", end=" ", flush=True)
|
||||
resolved = install_from_registry(tool_ref, version)
|
||||
print(f"v{resolved.version}")
|
||||
installed += 1
|
||||
except RegistryError as e:
|
||||
print(f"FAILED: {e.message}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
failed += 1
|
||||
|
||||
print()
|
||||
print(f"Installed: {installed}/{len(tools)}")
|
||||
if failed:
|
||||
print(f"Failed: {failed}")
|
||||
return 1
|
||||
|
||||
print(f"\nCollection '{name}' installed successfully!")
|
||||
|
||||
except RegistryError as e:
|
||||
if e.code == "COLLECTION_NOT_FOUND":
|
||||
print(f"Collection '{name}' not found.", file=sys.stderr)
|
||||
print("List available collections: cmdforge collections list", file=sys.stderr)
|
||||
elif e.code == "CONNECTION_ERROR":
|
||||
print("Could not connect to the registry.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error: {e.message}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local Collection Management Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_collections_create(args):
|
||||
"""Create a new local collection."""
|
||||
from ..collection import Collection, COLLECTIONS_DIR, list_collections
|
||||
|
||||
name = args.name.strip().lower()
|
||||
|
||||
# Validate name
|
||||
if not name or not re.match(r'^[a-z0-9-]+$', name):
|
||||
print("Error: Collection name must be lowercase alphanumeric with hyphens only")
|
||||
return 1
|
||||
|
||||
# Check if exists
|
||||
if name in list_collections():
|
||||
print(f"Error: Collection '{name}' already exists")
|
||||
print(f"Use: cmdforge collections show {name}")
|
||||
print(f"Or delete it first: cmdforge collections delete {name}")
|
||||
return 1
|
||||
|
||||
# Interactive prompts
|
||||
display_name = input(f"Display name [{name.replace('-', ' ').title()}]: ").strip()
|
||||
if not display_name:
|
||||
display_name = name.replace("-", " ").title()
|
||||
|
||||
description = input("Description: ").strip()
|
||||
tags_input = input("Tags (comma-separated): ").strip()
|
||||
tags = [t.strip() for t in tags_input.split(",") if t.strip()]
|
||||
|
||||
collection = Collection(
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
description=description,
|
||||
tags=tags,
|
||||
)
|
||||
collection.save()
|
||||
|
||||
print(f"\nCreated collection: {name}")
|
||||
print(f" Location: {COLLECTIONS_DIR / f'{name}.yaml'}")
|
||||
print(f"\nAdd tools with: cmdforge collections add {name} <tool>")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_collections_add(args):
|
||||
"""Add a tool to a collection."""
|
||||
from ..collection import get_collection
|
||||
from ..tool import list_tools
|
||||
|
||||
collection = get_collection(args.collection)
|
||||
if not collection:
|
||||
print(f"Error: Collection '{args.collection}' not found")
|
||||
return 1
|
||||
|
||||
tool_ref = args.tool
|
||||
|
||||
# Validate tool exists (local or suggest registry format)
|
||||
if "/" not in tool_ref:
|
||||
# Local tool reference
|
||||
if tool_ref not in list_tools():
|
||||
print(f"Warning: Local tool '{tool_ref}' not found")
|
||||
confirm = input("Add anyway? [y/N]: ").strip().lower()
|
||||
if confirm != "y":
|
||||
return 1
|
||||
|
||||
if tool_ref in collection.tools:
|
||||
print(f"Tool '{tool_ref}' already in collection")
|
||||
return 0
|
||||
|
||||
collection.tools.append(tool_ref)
|
||||
|
||||
# Optional: set pinned version
|
||||
if hasattr(args, 'version') and args.version:
|
||||
collection.pinned[tool_ref] = args.version
|
||||
|
||||
collection.save()
|
||||
print(f"Added '{tool_ref}' to collection '{collection.name}'")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_collections_remove(args):
|
||||
"""Remove a tool from a collection."""
|
||||
from ..collection import get_collection
|
||||
|
||||
collection = get_collection(args.collection)
|
||||
if not collection:
|
||||
print(f"Error: Collection '{args.collection}' not found")
|
||||
return 1
|
||||
|
||||
tool_ref = args.tool
|
||||
|
||||
if tool_ref not in collection.tools:
|
||||
print(f"Tool '{tool_ref}' not in collection")
|
||||
return 1
|
||||
|
||||
collection.tools.remove(tool_ref)
|
||||
collection.pinned.pop(tool_ref, None)
|
||||
collection.save()
|
||||
|
||||
print(f"Removed '{tool_ref}' from collection '{collection.name}'")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_collections_show(args):
|
||||
"""Show local collection details."""
|
||||
from ..collection import get_collection, classify_tool_reference
|
||||
from ..tool import list_tools
|
||||
|
||||
collection = get_collection(args.name)
|
||||
if not collection:
|
||||
print(f"Error: Collection '{args.name}' not found")
|
||||
return 1
|
||||
|
||||
local_tools = set(list_tools())
|
||||
|
||||
print(f"\n{collection.display_name}")
|
||||
print("=" * 50)
|
||||
if collection.description:
|
||||
print(f"\n{collection.description}\n")
|
||||
|
||||
if collection.tags:
|
||||
print(f"Tags: {', '.join(collection.tags)}")
|
||||
|
||||
# Publishing status
|
||||
if collection.pending_approval:
|
||||
print(f"\nStatus: Pending (waiting for tool approvals)")
|
||||
elif collection.published:
|
||||
print(f"\nStatus: Published as '{collection.registry_name}'")
|
||||
else:
|
||||
print(f"\nStatus: Local only (not published)")
|
||||
|
||||
print(f"\nTools ({len(collection.tools)}):")
|
||||
for tool_ref in collection.tools:
|
||||
owner, name, is_local = classify_tool_reference(tool_ref)
|
||||
pinned = collection.pinned.get(tool_ref, "")
|
||||
version_str = f" @ {pinned}" if pinned else ""
|
||||
|
||||
if is_local:
|
||||
exists = name in local_tools
|
||||
status = "" if exists else " (not found)"
|
||||
print(f" - {name}{version_str} [local]{status}")
|
||||
else:
|
||||
print(f" - {tool_ref}{version_str} [registry]")
|
||||
|
||||
if collection.pending_tools:
|
||||
print(f"\nPending tool approvals:")
|
||||
for tool in collection.pending_tools:
|
||||
print(f" - {tool}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_collections_delete(args):
|
||||
"""Delete a local collection."""
|
||||
from ..collection import get_collection
|
||||
|
||||
collection = get_collection(args.name)
|
||||
if not collection:
|
||||
print(f"Error: Collection '{args.name}' not found")
|
||||
return 1
|
||||
|
||||
if not getattr(args, 'force', False):
|
||||
confirm = input(f"Delete collection '{args.name}'? [y/N]: ").strip().lower()
|
||||
if confirm != "y":
|
||||
print("Cancelled")
|
||||
return 0
|
||||
|
||||
collection.delete()
|
||||
print(f"Deleted collection '{args.name}'")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publishing Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_collections_publish(args):
|
||||
"""Publish a collection to the registry."""
|
||||
from ..collection import get_collection, resolve_tool_references
|
||||
from ..registry_client import get_client, RegistryError
|
||||
|
||||
collection = get_collection(args.name)
|
||||
if not collection:
|
||||
print(f"Error: Collection '{args.name}' not found")
|
||||
return 1
|
||||
|
||||
if not collection.tools:
|
||||
print("Error: Collection has no tools")
|
||||
return 1
|
||||
|
||||
# Get user info
|
||||
client = get_client()
|
||||
is_valid, error = client.validate_token()
|
||||
if not is_valid:
|
||||
print(f"Error: Not authenticated. Run: cmdforge config connect")
|
||||
return 1
|
||||
|
||||
# Get user's registry slug
|
||||
try:
|
||||
user_info = client.get_me()
|
||||
user_slug = user_info.get("slug", "")
|
||||
except RegistryError as e:
|
||||
print(f"Error getting user info: {e.message}")
|
||||
return 1
|
||||
|
||||
if not user_slug:
|
||||
print("Error: Could not determine your registry username")
|
||||
return 1
|
||||
|
||||
# Check if resuming a pending publish
|
||||
if getattr(args, 'resume', False) and collection.pending_approval:
|
||||
return _resume_collection_publish(collection, client, user_slug)
|
||||
|
||||
# Resolve tool references (uses authenticated endpoint for user's own tools)
|
||||
print(f"Analyzing collection '{collection.display_name}'...")
|
||||
result = resolve_tool_references(
|
||||
collection.tools, collection.pinned, user_slug, client
|
||||
)
|
||||
registry_refs = result.registry_refs
|
||||
transformed_pinned = result.transformed_pinned
|
||||
unpublished = result.local_unpublished
|
||||
published = result.local_published # List of (name, status, has_approved) tuples
|
||||
visibility_issues = result.visibility_issues
|
||||
registry_tool_issues = result.registry_tool_issues
|
||||
|
||||
# Check for tools where latest version is pending
|
||||
pending_latest = [(name, status) for name, status, has_approved in published if status != "approved"]
|
||||
|
||||
# Dry run mode
|
||||
if getattr(args, 'dry_run', False):
|
||||
print(f"\n[DRY RUN] Would publish collection: {collection.name}")
|
||||
print(f" Display name: {collection.display_name}")
|
||||
print(f" Tools: {len(collection.tools)}")
|
||||
if visibility_issues:
|
||||
print(f"\n BLOCKING: Non-public local tools ({len(visibility_issues)}):")
|
||||
for name, visibility in visibility_issues:
|
||||
print(f" - {name} (visibility: {visibility})")
|
||||
if registry_tool_issues:
|
||||
print(f"\n BLOCKING: Registry tool issues ({len(registry_tool_issues)}):")
|
||||
for ref, reason in registry_tool_issues:
|
||||
print(f" - {ref}: {reason}")
|
||||
if unpublished:
|
||||
print(f"\n Unpublished local tools ({len(unpublished)}):")
|
||||
for tool in unpublished:
|
||||
print(f" - {tool} -> {user_slug}/{tool}")
|
||||
if pending_latest:
|
||||
print(f"\n Pending approval ({len(pending_latest)}):")
|
||||
for name, status in pending_latest:
|
||||
print(f" - {user_slug}/{name} ({status})")
|
||||
if transformed_pinned:
|
||||
print(f"\n Version pins (transformed):")
|
||||
for ref, version in transformed_pinned.items():
|
||||
print(f" - {ref}: {version}")
|
||||
print(f"\n Final tool references:")
|
||||
for ref in registry_refs:
|
||||
print(f" - {ref}")
|
||||
return 0
|
||||
|
||||
# Handle visibility issues FIRST (local tools that aren't public)
|
||||
if visibility_issues:
|
||||
print(f"\n{len(visibility_issues)} local tool(s) are not public:")
|
||||
for name, visibility in visibility_issues:
|
||||
print(f" - {name} (visibility: {visibility})")
|
||||
|
||||
print(f"\nCollections can only include PUBLIC tools.")
|
||||
print(f"Update these tools to be public before including in a collection:")
|
||||
print(f" Edit the tool's config.yaml and set: visibility: public")
|
||||
print(f" Then republish with: cmdforge registry publish <toolname>")
|
||||
return 1
|
||||
|
||||
# Handle registry tool issues (external tools that aren't public/approved)
|
||||
if registry_tool_issues:
|
||||
print(f"\n{len(registry_tool_issues)} registry tool(s) cannot be included:")
|
||||
for ref, reason in registry_tool_issues:
|
||||
print(f" - {ref}: {reason}")
|
||||
|
||||
print(f"\nCollections can only include PUBLIC, APPROVED tools.")
|
||||
print(f"Remove these tools from the collection or wait for them to be approved.")
|
||||
return 1
|
||||
|
||||
# Handle tools where latest version is pending (warn but don't block)
|
||||
if pending_latest:
|
||||
print(f"\n{len(pending_latest)} tool(s) have pending latest versions:")
|
||||
for name, status in pending_latest:
|
||||
print(f" - {user_slug}/{name} ({status})")
|
||||
print(f"\n (An older approved version may still be usable - will attempt publish)")
|
||||
|
||||
# Handle unpublished tools
|
||||
if unpublished:
|
||||
print(f"\nThis collection contains {len(unpublished)} unpublished local tool(s):")
|
||||
for tool in unpublished:
|
||||
print(f" - {tool}")
|
||||
|
||||
print(f"\nThese must be published as '{user_slug}/<name>' before the collection.")
|
||||
print("\nNOTE: Only PUBLIC, APPROVED tools can be included in collections.")
|
||||
print("\nOptions:")
|
||||
print(" [1] Publish all tools now, then collection (recommended)")
|
||||
print(" [2] Remove unpublished tools and publish remaining")
|
||||
print(" [3] Cancel")
|
||||
|
||||
choice = input("\nChoice [1/2/3]: ").strip()
|
||||
|
||||
if choice == "1":
|
||||
# Publish tools first
|
||||
pending_tools = []
|
||||
for tool_name in unpublished:
|
||||
print(f"\nPublishing tool: {tool_name}...")
|
||||
result = _publish_single_tool(tool_name, client)
|
||||
if result.get("pending"):
|
||||
pending_tools.append(tool_name)
|
||||
print(f" -> Submitted for review (pending approval)")
|
||||
elif result.get("success"):
|
||||
print(f" -> Published as {user_slug}/{tool_name}")
|
||||
else:
|
||||
print(f" -> Failed: {result.get('error')}")
|
||||
return 1
|
||||
|
||||
if pending_tools:
|
||||
# Save pending state
|
||||
collection.pending_approval = True
|
||||
collection.pending_tools = pending_tools
|
||||
collection.maintainer = user_slug
|
||||
collection.save()
|
||||
|
||||
print(f"\nSome tools are pending approval:")
|
||||
for tool in pending_tools:
|
||||
print(f" - {user_slug}/{tool}")
|
||||
print(f"\nCollection will be published once tools are approved.")
|
||||
print(f"Check status with: cmdforge collections status {collection.name}")
|
||||
print(f"Resume with: cmdforge collections publish {collection.name} --continue")
|
||||
return 0
|
||||
|
||||
# All tools approved immediately (or auto-approved)
|
||||
# Fall through to publish collection
|
||||
|
||||
elif choice == "2":
|
||||
# Remove unpublished tools from refs and pinned
|
||||
unpublished_full_refs = {f"{user_slug}/{u}" for u in unpublished}
|
||||
registry_refs = [ref for ref in registry_refs if ref not in unpublished_full_refs]
|
||||
# Also remove from transformed_pinned to avoid orphaned keys
|
||||
for u in unpublished:
|
||||
full_ref = f"{user_slug}/{u}"
|
||||
transformed_pinned.pop(full_ref, None)
|
||||
|
||||
if not registry_refs:
|
||||
print("Error: No tools remaining after removal")
|
||||
return 1
|
||||
|
||||
# Update local collection to match what we're publishing
|
||||
collection.tools = [t for t in collection.tools if t not in unpublished]
|
||||
for u in unpublished:
|
||||
collection.pinned.pop(u, None)
|
||||
collection.save()
|
||||
|
||||
print(f"\nProceeding with {len(registry_refs)} tool(s)")
|
||||
|
||||
else:
|
||||
print("Cancelled")
|
||||
return 0
|
||||
|
||||
# Publish collection (using transformed pinned keys)
|
||||
return _publish_collection_to_registry(collection, registry_refs, transformed_pinned, client, user_slug)
|
||||
|
||||
|
||||
def _bump_patch_version(version: str) -> str:
|
||||
"""Bump the patch version of a semver string."""
|
||||
import re
|
||||
match = re.match(r'^(\d+)\.(\d+)\.(\d+)(.*)?$', version)
|
||||
if match:
|
||||
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
suffix = match.group(4) or ""
|
||||
return f"{major}.{minor}.{patch + 1}{suffix}"
|
||||
# Fallback: append .1 if not valid semver
|
||||
return f"{version}.1"
|
||||
|
||||
|
||||
def _publish_single_tool(tool_name: str, client) -> dict:
|
||||
"""
|
||||
Publish a single tool. Returns status dict.
|
||||
|
||||
Updates local tool config with registry_hash and registry_status
|
||||
to maintain consistency with direct `cmdforge registry publish` flow.
|
||||
|
||||
If the tool was previously rejected, automatically bumps the version
|
||||
to allow resubmission.
|
||||
"""
|
||||
from ..tool import load_tool, get_tools_dir
|
||||
from ..registry_client import RegistryError
|
||||
import yaml
|
||||
|
||||
# For publishing, prefer qualified (owned) path first, then fall back to unqualified.
|
||||
# This ensures we publish the explicitly-owned version when both exist.
|
||||
tool = None
|
||||
try:
|
||||
me = client.get_me()
|
||||
owner = me.get("slug", "")
|
||||
except Exception:
|
||||
owner = ""
|
||||
|
||||
if owner:
|
||||
tool = load_tool(f"{owner}/{tool_name}")
|
||||
if not tool:
|
||||
tool = load_tool(tool_name)
|
||||
if not tool:
|
||||
return {"success": False, "error": f"Tool '{tool_name}' not found"}
|
||||
|
||||
tool_dir = tool.path.parent if tool.path else (get_tools_dir() / tool_name)
|
||||
config_path = tool_dir / "config.yaml"
|
||||
|
||||
# Check if tool was previously rejected - if so, bump version
|
||||
version_bumped = False
|
||||
try:
|
||||
status_info = client.get_my_tool_status(tool_name)
|
||||
current_status = status_info.get("status", "")
|
||||
if current_status == "rejected":
|
||||
# Bump version to allow resubmission
|
||||
old_version = tool.version
|
||||
new_version = _bump_patch_version(old_version)
|
||||
|
||||
# Update local config
|
||||
if config_path.exists():
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
config_data["version"] = new_version
|
||||
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
|
||||
|
||||
# Reload tool from same file (not by name, to preserve path)
|
||||
from ..tool import Tool
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
tool = Tool.from_dict(config_data)
|
||||
tool.path = config_path
|
||||
version_bumped = True
|
||||
print(f" (Version bumped {old_version} -> {new_version} due to previous rejection)")
|
||||
except RegistryError as e:
|
||||
if e.code != "TOOL_NOT_FOUND":
|
||||
pass # Other errors - continue with publish attempt
|
||||
|
||||
# Load README/defaults if exists
|
||||
readme_path = tool_dir / "README.md"
|
||||
readme = readme_path.read_text() if readme_path.exists() else ""
|
||||
defaults_path = tool_dir / "defaults.yaml"
|
||||
defaults = defaults_path.read_text() if defaults_path.exists() else ""
|
||||
|
||||
try:
|
||||
config_yaml = yaml.safe_dump(tool.to_dict(), sort_keys=False)
|
||||
result = client.publish_tool(config_yaml, readme=readme, defaults=defaults, dry_run=False)
|
||||
|
||||
# Update local tool config with registry metadata (use same config_path from above)
|
||||
config_hash = result.get("config_hash")
|
||||
moderation_status = result.get("status", "pending")
|
||||
|
||||
if config_hash and config_path.exists():
|
||||
try:
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
config_data["registry_hash"] = config_hash
|
||||
config_data["registry_status"] = moderation_status
|
||||
registry_owner = result.get("owner")
|
||||
if registry_owner:
|
||||
config_data["registry_owner"] = registry_owner
|
||||
# Clear any old feedback when republishing
|
||||
if "registry_feedback" in config_data:
|
||||
del config_data["registry_feedback"]
|
||||
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
|
||||
# Check if pending moderation
|
||||
is_pending = moderation_status == "pending" or result.get("pr_url")
|
||||
return {
|
||||
"success": True,
|
||||
"pending": is_pending,
|
||||
"status": moderation_status,
|
||||
"config_hash": config_hash,
|
||||
"pr_url": result.get("pr_url"),
|
||||
"version_bumped": version_bumped,
|
||||
"version": tool.version if tool else None
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _resume_collection_publish(collection, client, user_slug) -> int:
|
||||
"""Resume publishing a collection after tools are approved."""
|
||||
from ..registry_client import RegistryError
|
||||
|
||||
print(f"Checking status of pending tools...")
|
||||
|
||||
still_pending = []
|
||||
for tool_name in collection.pending_tools:
|
||||
try:
|
||||
# Use authenticated endpoint to check user's own tools
|
||||
status_info = client.get_my_tool_status(tool_name)
|
||||
status = status_info.get("status", "pending")
|
||||
if status != "approved":
|
||||
still_pending.append(tool_name)
|
||||
print(f" {user_slug}/{tool_name}: {status}")
|
||||
else:
|
||||
print(f" {user_slug}/{tool_name}: approved")
|
||||
except RegistryError:
|
||||
still_pending.append(tool_name)
|
||||
print(f" {user_slug}/{tool_name}: not found")
|
||||
|
||||
if still_pending:
|
||||
collection.pending_tools = still_pending
|
||||
collection.save()
|
||||
print(f"\nStill waiting for {len(still_pending)} tool(s) to be approved")
|
||||
return 0
|
||||
|
||||
# All approved - publish collection
|
||||
print(f"\nAll tools approved! Publishing collection...")
|
||||
|
||||
# Rebuild registry refs AND transform pinned keys
|
||||
registry_refs = []
|
||||
transformed_pinned = {}
|
||||
for tool_ref in collection.tools:
|
||||
if "/" in tool_ref:
|
||||
registry_refs.append(tool_ref)
|
||||
# Copy pinned if exists
|
||||
if tool_ref in collection.pinned:
|
||||
transformed_pinned[tool_ref] = collection.pinned[tool_ref]
|
||||
else:
|
||||
full_ref = f"{user_slug}/{tool_ref}"
|
||||
registry_refs.append(full_ref)
|
||||
# Transform pinned key from local name to owner/name
|
||||
if tool_ref in collection.pinned:
|
||||
transformed_pinned[full_ref] = collection.pinned[tool_ref]
|
||||
|
||||
return _publish_collection_to_registry(collection, registry_refs, transformed_pinned, client, user_slug)
|
||||
|
||||
|
||||
def _publish_collection_to_registry(collection, registry_refs, transformed_pinned, client, user_slug) -> int:
|
||||
"""Actually publish the collection to registry."""
|
||||
print(f"\nPublishing collection '{collection.display_name}'...")
|
||||
|
||||
payload = {
|
||||
"name": collection.name,
|
||||
"display_name": collection.display_name,
|
||||
"description": collection.description,
|
||||
"maintainer": user_slug,
|
||||
"tools": registry_refs,
|
||||
"pinned": transformed_pinned,
|
||||
"tags": collection.tags,
|
||||
}
|
||||
|
||||
try:
|
||||
result = client.publish_collection(payload)
|
||||
|
||||
# Update local state
|
||||
collection.published = True
|
||||
collection.registry_name = collection.name
|
||||
collection.pending_approval = False
|
||||
collection.pending_tools = []
|
||||
collection.maintainer = user_slug
|
||||
collection.save()
|
||||
|
||||
print(f"\nCollection published: {collection.name}")
|
||||
print(f" View at: {collection.get_registry_url()}")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nError publishing collection: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_collections_status(args):
|
||||
"""Check status of a pending collection publish."""
|
||||
from ..collection import get_collection
|
||||
from ..registry_client import get_client, RegistryError
|
||||
|
||||
collection = get_collection(args.name)
|
||||
if not collection:
|
||||
print(f"Error: Collection '{args.name}' not found")
|
||||
return 1
|
||||
|
||||
if not collection.pending_approval:
|
||||
if collection.published:
|
||||
print(f"Collection '{collection.name}' is published")
|
||||
else:
|
||||
print(f"Collection '{collection.name}' is local (not published)")
|
||||
return 0
|
||||
|
||||
# Check pending tools using authenticated endpoint
|
||||
client = get_client()
|
||||
|
||||
# Validate auth first
|
||||
is_valid, error = client.validate_token()
|
||||
if not is_valid:
|
||||
print(f"Error: Not authenticated. Run: cmdforge config connect")
|
||||
return 1
|
||||
|
||||
user_slug = collection.maintainer
|
||||
|
||||
print(f"\nCollection: {collection.display_name}")
|
||||
print(f"Status: Pending tool approvals\n")
|
||||
|
||||
all_approved = True
|
||||
has_rejected = False
|
||||
has_changes_requested = False
|
||||
rejected_tools = []
|
||||
changes_tools = []
|
||||
|
||||
for tool_name in collection.pending_tools:
|
||||
try:
|
||||
# Use authenticated endpoint to see pending/private tools
|
||||
status_info = client.get_my_tool_status(tool_name)
|
||||
status = status_info.get("status", "pending")
|
||||
if status == "approved":
|
||||
print(f" {user_slug}/{tool_name}: approved")
|
||||
else:
|
||||
print(f" {user_slug}/{tool_name}: {status}")
|
||||
all_approved = False
|
||||
if status == "rejected":
|
||||
has_rejected = True
|
||||
rejected_tools.append(tool_name)
|
||||
elif status == "changes_requested":
|
||||
has_changes_requested = True
|
||||
changes_tools.append(tool_name)
|
||||
except RegistryError as e:
|
||||
print(f" {user_slug}/{tool_name}: {e.message}")
|
||||
all_approved = False
|
||||
|
||||
if all_approved:
|
||||
print(f"\nAll tools approved!")
|
||||
print(f"Run: cmdforge collections publish {collection.name} --continue")
|
||||
elif has_rejected:
|
||||
print(f"\nSome tools were rejected:")
|
||||
for t in rejected_tools:
|
||||
print(f" - {t}")
|
||||
print(f"\nTo proceed, you need to:")
|
||||
print(f" 1. Fix the issues with rejected tools")
|
||||
print(f" 2. Republish them: cmdforge registry publish <toolname>")
|
||||
print(f" 3. Then republish collection: cmdforge collections publish {collection.name}")
|
||||
|
||||
# Clear pending state since tools were rejected
|
||||
collection.pending_approval = False
|
||||
collection.pending_tools = []
|
||||
collection.save()
|
||||
print(f"\n(Collection pending state cleared)")
|
||||
elif has_changes_requested:
|
||||
print(f"\nSome tools need changes:")
|
||||
for t in changes_tools:
|
||||
print(f" - {t}")
|
||||
print(f"\nAddress the feedback and republish the tools.")
|
||||
else:
|
||||
print(f"\nWaiting for tool approvals...")
|
||||
|
||||
return 0
|
||||
@@ -0,0 +1,185 @@
|
||||
"""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)
|
||||
elif args.config_cmd == "disconnect":
|
||||
return _cmd_config_disconnect(args)
|
||||
else:
|
||||
print("Config commands:")
|
||||
print(" show Show current configuration")
|
||||
print(" connect <username> Connect this app to your CmdForge account")
|
||||
print(" disconnect Disconnect from registry (clear token)")
|
||||
print(" set-token <token> Set registry authentication token")
|
||||
print(" set <key> <value> Set a configuration value")
|
||||
return 0
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _cmd_config_disconnect(args):
|
||||
"""Disconnect from registry by clearing the token."""
|
||||
config = load_config()
|
||||
|
||||
if not config.registry.token:
|
||||
print("Not connected to registry (no token set).")
|
||||
return 0
|
||||
|
||||
# Clear the token
|
||||
set_registry_token(None)
|
||||
print("Disconnected from registry.")
|
||||
print("Token has been cleared from local configuration.")
|
||||
return 0
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Interactive fuzzy tool picker for CmdForge - inline dropdown style."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import tty
|
||||
import termios
|
||||
from typing import List, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..tool import list_tools, load_tool
|
||||
|
||||
|
||||
@dataclass
|
||||
class PickerResult:
|
||||
"""Result from the picker."""
|
||||
tool_name: str
|
||||
arguments: dict
|
||||
|
||||
|
||||
# ANSI escape codes
|
||||
CLEAR_LINE = "\033[2K"
|
||||
MOVE_UP = "\033[A"
|
||||
HIDE_CURSOR = "\033[?25l"
|
||||
SHOW_CURSOR = "\033[?25h"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
CYAN = "\033[36m"
|
||||
YELLOW = "\033[33m"
|
||||
GREEN = "\033[32m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
MAX_VISIBLE = 8 # Show at most 8 items
|
||||
|
||||
# Output stream for UI (stderr when stdout is piped, stdout otherwise)
|
||||
_ui_out = None
|
||||
|
||||
|
||||
def _write(text: str):
|
||||
"""Write to UI output stream."""
|
||||
_ui_out.write(text)
|
||||
_ui_out.flush()
|
||||
|
||||
|
||||
def fuzzy_match(query: str, text: str) -> Tuple[bool, int]:
|
||||
"""Fuzzy match with scoring."""
|
||||
if not query:
|
||||
return True, 0
|
||||
|
||||
query = query.lower()
|
||||
text = text.lower()
|
||||
|
||||
if query in text:
|
||||
if text.startswith(query):
|
||||
return True, 1000 + len(query)
|
||||
return True, 500 + len(query)
|
||||
|
||||
# Character-by-character fuzzy match
|
||||
qi = 0
|
||||
score = 0
|
||||
for i, c in enumerate(text):
|
||||
if qi < len(query) and c == query[qi]:
|
||||
score += 10 if i == 0 or text[i-1] in ' -_' else 1
|
||||
qi += 1
|
||||
|
||||
return (qi == len(query), score) if qi == len(query) else (False, 0)
|
||||
|
||||
|
||||
def get_tools() -> List[dict]:
|
||||
"""Get all tools with info."""
|
||||
tools = []
|
||||
for name in list_tools():
|
||||
tool = load_tool(name)
|
||||
if tool:
|
||||
tools.append({
|
||||
"name": name,
|
||||
"desc": (tool.description or "")[:50],
|
||||
"args": [{
|
||||
"flag": a.flag,
|
||||
"default": a.default or "",
|
||||
"desc": a.description or ""
|
||||
} for a in tool.arguments]
|
||||
})
|
||||
return sorted(tools, key=lambda t: t["name"])
|
||||
|
||||
|
||||
class TTYInput:
|
||||
"""Read from /dev/tty for keyboard input, even when stdin is piped."""
|
||||
|
||||
def __init__(self):
|
||||
self.tty = None
|
||||
self.fd = None
|
||||
self.old_settings = None
|
||||
|
||||
def __enter__(self):
|
||||
self.tty = open('/dev/tty', 'r')
|
||||
self.fd = self.tty.fileno()
|
||||
self.old_settings = termios.tcgetattr(self.fd)
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
if self.old_settings:
|
||||
termios.tcsetattr(self.fd, termios.TCSANOW, self.old_settings)
|
||||
if self.tty:
|
||||
self.tty.close()
|
||||
|
||||
def getch(self):
|
||||
"""Read a single character."""
|
||||
tty.setraw(self.fd)
|
||||
try:
|
||||
ch = self.tty.read(1)
|
||||
if ch == '\x1b':
|
||||
# Read escape sequence - bytes arrive together from terminal
|
||||
ch2 = self.tty.read(1)
|
||||
if ch2 == '[':
|
||||
ch3 = self.tty.read(1)
|
||||
if ch3 == 'A': return 'UP'
|
||||
if ch3 == 'B': return 'DOWN'
|
||||
if ch3 == 'C': return 'RIGHT'
|
||||
if ch3 == 'D': return 'LEFT'
|
||||
return ch
|
||||
finally:
|
||||
termios.tcsetattr(self.fd, termios.TCSANOW, self.old_settings)
|
||||
|
||||
|
||||
def clear_dropdown(n_lines: int):
|
||||
"""Clear the dropdown lines we drew."""
|
||||
for _ in range(n_lines):
|
||||
_write(MOVE_UP + CLEAR_LINE)
|
||||
_write('\r')
|
||||
|
||||
|
||||
def run_picker(tty_input: TTYInput) -> Optional[PickerResult]:
|
||||
"""Run inline picker."""
|
||||
tools = get_tools()
|
||||
if not tools:
|
||||
_write("No tools. Create one: cmdforge\n")
|
||||
return None
|
||||
|
||||
query = ""
|
||||
selected = 0
|
||||
scroll = 0
|
||||
last_drawn = 0
|
||||
|
||||
_write(HIDE_CURSOR)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Filter
|
||||
matches = []
|
||||
for t in tools:
|
||||
ok, score = fuzzy_match(query, t["name"])
|
||||
if not ok:
|
||||
ok, score = fuzzy_match(query, t["desc"])
|
||||
score = score // 2
|
||||
if ok:
|
||||
matches.append((t, score))
|
||||
matches.sort(key=lambda x: -x[1])
|
||||
filtered = [m[0] for m in matches]
|
||||
|
||||
if selected >= len(filtered):
|
||||
selected = max(0, len(filtered) - 1)
|
||||
|
||||
# Adjust scroll to keep selection visible
|
||||
if selected < scroll:
|
||||
scroll = selected
|
||||
elif selected >= scroll + MAX_VISIBLE:
|
||||
scroll = selected - MAX_VISIBLE + 1
|
||||
|
||||
# Clear previous
|
||||
if last_drawn:
|
||||
clear_dropdown(last_drawn)
|
||||
|
||||
# Draw
|
||||
visible = filtered[scroll:scroll + MAX_VISIBLE]
|
||||
lines = []
|
||||
|
||||
# Query line
|
||||
prompt = f"{DIM}>{RESET} {YELLOW}{query}{RESET}▌"
|
||||
lines.append(prompt)
|
||||
|
||||
# Items
|
||||
for i, t in enumerate(visible):
|
||||
actual_idx = scroll + i
|
||||
if actual_idx == selected:
|
||||
prefix = f"{CYAN}{BOLD}▸ {t['name']}{RESET}"
|
||||
else:
|
||||
prefix = f" {t['name']}"
|
||||
|
||||
# Add arg indicator
|
||||
if t['args']:
|
||||
prefix += f" {GREEN}⚙{RESET}"
|
||||
|
||||
# Add description
|
||||
if t['desc']:
|
||||
prefix += f" {DIM}- {t['desc']}{RESET}"
|
||||
|
||||
lines.append(prefix)
|
||||
|
||||
# Print
|
||||
_write('\n'.join(lines) + '\n')
|
||||
last_drawn = len(lines)
|
||||
|
||||
# Input
|
||||
ch = tty_input.getch()
|
||||
|
||||
if ch in ('\r', '\n'): # Enter - run
|
||||
if filtered:
|
||||
clear_dropdown(last_drawn)
|
||||
_write(SHOW_CURSOR)
|
||||
return PickerResult(filtered[selected]["name"], {})
|
||||
|
||||
elif ch == '\t': # Tab - configure args or run
|
||||
if filtered:
|
||||
tool = filtered[selected]
|
||||
clear_dropdown(last_drawn)
|
||||
if tool['args']:
|
||||
args = pick_args(tty_input, tool)
|
||||
_write(SHOW_CURSOR)
|
||||
return PickerResult(tool["name"], args) if args is not None else None
|
||||
_write(SHOW_CURSOR)
|
||||
return PickerResult(tool["name"], {})
|
||||
|
||||
elif ch == '\x1b' or ch == '\x03': # Esc or Ctrl+C
|
||||
clear_dropdown(last_drawn)
|
||||
_write(SHOW_CURSOR)
|
||||
return None
|
||||
|
||||
elif ch == 'UP':
|
||||
selected = max(0, selected - 1)
|
||||
elif ch == 'DOWN':
|
||||
selected = min(len(filtered) - 1, selected + 1)
|
||||
|
||||
elif ch == '\x7f' or ch == '\b': # Backspace
|
||||
query = query[:-1]
|
||||
selected = 0
|
||||
scroll = 0
|
||||
|
||||
elif ch.isprintable():
|
||||
query += ch
|
||||
selected = 0
|
||||
scroll = 0
|
||||
|
||||
except Exception:
|
||||
_write(SHOW_CURSOR)
|
||||
raise
|
||||
|
||||
|
||||
def pick_args(tty_input: TTYInput, tool: dict) -> Optional[dict]:
|
||||
"""Inline argument picker."""
|
||||
args = tool['args']
|
||||
values = {a['flag']: a['default'] for a in args}
|
||||
selected = 0
|
||||
editing = None
|
||||
edit_buf = ""
|
||||
last_drawn = 0
|
||||
|
||||
_write(HIDE_CURSOR)
|
||||
|
||||
try:
|
||||
while True:
|
||||
if last_drawn:
|
||||
clear_dropdown(last_drawn)
|
||||
|
||||
lines = []
|
||||
lines.append(f"{BOLD}{tool['name']}{RESET} arguments:")
|
||||
|
||||
for i, arg in enumerate(args):
|
||||
flag = arg['flag']
|
||||
val = edit_buf if editing == i else values[flag]
|
||||
|
||||
if i == selected:
|
||||
if editing == i:
|
||||
line = f"{CYAN}▸ {flag}: {YELLOW}{val}▌{RESET}"
|
||||
else:
|
||||
line = f"{CYAN}{BOLD}▸ {flag}:{RESET} {val or f'{DIM}(empty){RESET}'}"
|
||||
else:
|
||||
line = f" {flag}: {val or f'{DIM}(empty){RESET}'}"
|
||||
|
||||
if arg['desc'] and editing != i:
|
||||
line += f" {DIM}# {arg['desc'][:30]}{RESET}"
|
||||
|
||||
lines.append(line)
|
||||
|
||||
if editing is None:
|
||||
lines.append(f"{DIM}Tab:edit Enter:run Esc:back{RESET}")
|
||||
|
||||
_write('\n'.join(lines) + '\n')
|
||||
last_drawn = len(lines)
|
||||
|
||||
ch = tty_input.getch()
|
||||
|
||||
if editing is not None:
|
||||
if ch in ('\r', '\n'): # Save
|
||||
values[args[editing]['flag']] = edit_buf
|
||||
editing = None
|
||||
edit_buf = ""
|
||||
elif ch == '\x1b': # Cancel edit
|
||||
editing = None
|
||||
edit_buf = ""
|
||||
elif ch == '\x7f' or ch == '\b':
|
||||
edit_buf = edit_buf[:-1]
|
||||
elif ch.isprintable():
|
||||
edit_buf += ch
|
||||
else:
|
||||
if ch in ('\r', '\n'): # Enter - run
|
||||
clear_dropdown(last_drawn)
|
||||
return values
|
||||
elif ch == '\t': # Tab - edit
|
||||
editing = selected
|
||||
edit_buf = values[args[selected]['flag']]
|
||||
elif ch == '\x1b' or ch == '\x03': # Back
|
||||
clear_dropdown(last_drawn)
|
||||
return None
|
||||
elif ch == 'UP':
|
||||
selected = max(0, selected - 1)
|
||||
elif ch == 'DOWN':
|
||||
selected = min(len(args) - 1, selected + 1)
|
||||
|
||||
except Exception:
|
||||
_write(SHOW_CURSOR)
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for cf command."""
|
||||
global _ui_out
|
||||
import subprocess
|
||||
import signal
|
||||
|
||||
# Handle Ctrl+C gracefully
|
||||
def handle_sigint(sig, frame):
|
||||
# Restore cursor and exit cleanly
|
||||
sys.stderr.write(SHOW_CURSOR)
|
||||
sys.stderr.flush()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
|
||||
# Read piped input if stdin is not a tty
|
||||
piped_input = None
|
||||
if not sys.stdin.isatty():
|
||||
try:
|
||||
piped_input = sys.stdin.read()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
|
||||
# Check if we have a terminal available
|
||||
if not os.path.exists('/dev/tty'):
|
||||
print("cf requires a terminal", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Use stderr for UI if stdout is piped, so tool output stays clean
|
||||
_ui_out = sys.stderr if not sys.stdout.isatty() else sys.stdout
|
||||
|
||||
try:
|
||||
with TTYInput() as tty_input:
|
||||
result = run_picker(tty_input)
|
||||
except KeyboardInterrupt:
|
||||
_write(SHOW_CURSOR)
|
||||
sys.exit(0)
|
||||
except OSError as e:
|
||||
print(f"cf requires a terminal: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if result:
|
||||
# Build command
|
||||
cmd = [result.tool_name]
|
||||
for flag, val in result.arguments.items():
|
||||
if val:
|
||||
cmd.extend([flag, val])
|
||||
|
||||
# Show what we're running and flush immediately
|
||||
sys.stderr.write(f"{BOLD}{' '.join(cmd)}{RESET}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
# Run it - use subprocess if we have piped input, exec otherwise
|
||||
tool_path = os.path.expanduser(f"~/.local/bin/{result.tool_name}")
|
||||
|
||||
if piped_input is not None:
|
||||
# Use subprocess so we can pass stdin
|
||||
if os.path.exists(tool_path):
|
||||
proc = subprocess.run([tool_path] + cmd[1:], input=piped_input, text=True)
|
||||
else:
|
||||
proc = subprocess.run(
|
||||
["python", "-m", "cmdforge.runner"] + cmd,
|
||||
input=piped_input, text=True
|
||||
)
|
||||
sys.exit(proc.returncode)
|
||||
else:
|
||||
# No piped input - use exec for efficiency
|
||||
if os.path.exists(tool_path):
|
||||
os.execv(tool_path, cmd)
|
||||
else:
|
||||
os.execlp("python", "python", "-m", "cmdforge.runner", *cmd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,602 @@
|
||||
"""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."""
|
||||
# If 'tree' subcommand, delegate to that
|
||||
if hasattr(args, 'deps_cmd') and args.deps_cmd == 'tree':
|
||||
return cmd_deps_tree(args)
|
||||
|
||||
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_deps_tree(args):
|
||||
"""Show dependency tree with transitive dependencies."""
|
||||
from ..dependency_graph import DependencyGraphBuilder
|
||||
from ..resolver import ToolResolver
|
||||
|
||||
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 in cmdforge.yaml")
|
||||
return 0
|
||||
|
||||
verbose = getattr(args, 'verbose', False)
|
||||
|
||||
resolver = ToolResolver(auto_fetch=False)
|
||||
builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose)
|
||||
|
||||
print("Resolving dependencies...")
|
||||
graph = builder.build(manifest.dependencies)
|
||||
|
||||
def print_tree(qualified_name: str, indent: int = 0, seen: set = None):
|
||||
seen = seen or set()
|
||||
prefix = " " * indent
|
||||
node = graph.get_node(qualified_name)
|
||||
|
||||
if not node:
|
||||
print(f"{prefix}- {qualified_name} (not found)")
|
||||
return
|
||||
|
||||
# Status indicator
|
||||
if node.source in ("local", "global"):
|
||||
status = "installed"
|
||||
elif node.source == "registry" and node.path:
|
||||
status = "installed"
|
||||
elif node.source == "registry":
|
||||
status = "registry"
|
||||
else:
|
||||
status = "missing"
|
||||
|
||||
# Show resolved version if available, otherwise fall back to constraint
|
||||
if node.resolved_version:
|
||||
version = f"@{node.resolved_version}"
|
||||
elif node.version_constraint:
|
||||
version = f"@{node.version_constraint}"
|
||||
else:
|
||||
version = ""
|
||||
circular = " (circular)" if qualified_name in seen else ""
|
||||
|
||||
print(f"{prefix}- {node.qualified_name}{version} [{status}]{circular}")
|
||||
|
||||
if qualified_name not in seen:
|
||||
seen.add(qualified_name)
|
||||
for child in node.children:
|
||||
print_tree(child, indent + 1, seen.copy())
|
||||
|
||||
print()
|
||||
print("Dependency tree:")
|
||||
for root in graph.root_dependencies:
|
||||
print_tree(root)
|
||||
|
||||
if graph.conflicts:
|
||||
print()
|
||||
print("Version conflicts:")
|
||||
for tool, v1, from1, v2, from2 in graph.conflicts:
|
||||
print(f" {tool}: {v1} (from {from1}) vs {v2} (from {from2})")
|
||||
|
||||
if graph.cycles:
|
||||
print()
|
||||
print("Circular dependencies:")
|
||||
for cycle in graph.cycles:
|
||||
print(f" {' -> '.join(cycle)}")
|
||||
|
||||
# Summary
|
||||
print()
|
||||
total = len(graph.nodes)
|
||||
installed = sum(1 for n in graph.nodes.values() if n.source in ("local", "global") or (n.source == "registry" and n.path))
|
||||
to_install = sum(1 for n in graph.nodes.values() if n.source == "registry" and n.path is None)
|
||||
missing = sum(1 for n in graph.nodes.values() if not n.is_resolved)
|
||||
|
||||
print(f"Total: {total} tools ({installed} installed, {to_install} to install, {missing} missing)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_install_deps(args):
|
||||
"""Install all dependencies from cmdforge.yaml with transitive resolution."""
|
||||
from ..dependency_graph import DependencyGraphBuilder
|
||||
from ..resolver import ToolResolver, install_from_registry, ToolNotFoundError
|
||||
from ..registry_client import RegistryError
|
||||
from ..lockfile import Lockfile
|
||||
from ..system_deps import prompt_install_missing
|
||||
|
||||
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
|
||||
|
||||
verbose = getattr(args, 'verbose', False)
|
||||
dry_run = getattr(args, 'dry_run', False)
|
||||
force = getattr(args, 'force', False)
|
||||
frozen = getattr(args, 'frozen', False)
|
||||
strict_frozen = getattr(args, 'strict_frozen', False)
|
||||
ignore_lock = getattr(args, 'ignore_lock', False)
|
||||
|
||||
# Check for lock file
|
||||
lock = Lockfile.load()
|
||||
|
||||
if frozen or strict_frozen:
|
||||
# Frozen mode: require lock file
|
||||
if lock is None:
|
||||
print("Error: --frozen requires cmdforge.lock")
|
||||
print("Run 'cmdforge lock' first.")
|
||||
return 1
|
||||
if strict_frozen and lock.is_stale():
|
||||
print("Error: cmdforge.lock is stale (manifest changed)")
|
||||
print("Run 'cmdforge lock' to update, or drop --strict-frozen")
|
||||
return 1
|
||||
|
||||
if lock and not ignore_lock:
|
||||
return _install_from_lock(lock, args)
|
||||
|
||||
# Fall through to manifest-based installation
|
||||
# Build dependency graph
|
||||
resolver = ToolResolver(auto_fetch=False)
|
||||
builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose)
|
||||
|
||||
print("Resolving dependencies...")
|
||||
graph = builder.build(manifest.dependencies)
|
||||
|
||||
# Check for issues
|
||||
if graph.has_cycles():
|
||||
print()
|
||||
print("Error: Circular dependencies detected:")
|
||||
for cycle in graph.cycles:
|
||||
print(f" {' -> '.join(cycle)}")
|
||||
return 1
|
||||
|
||||
if graph.has_conflicts():
|
||||
print()
|
||||
print("Warning: Version conflicts detected:")
|
||||
for tool, v1, from1, v2, from2 in graph.conflicts:
|
||||
print(f" {tool}: {v1} (from {from1}) vs {v2} (from {from2})")
|
||||
if not force:
|
||||
print()
|
||||
print("Use --force to install anyway (first constraint wins)")
|
||||
return 1
|
||||
unresolved = graph.get_unresolved()
|
||||
if unresolved:
|
||||
print()
|
||||
print("Error: Missing dependencies:")
|
||||
for node in unresolved:
|
||||
constraint = f"@{node.version_constraint}" if node.version_constraint else ""
|
||||
print(f" {node.qualified_name}{constraint}")
|
||||
return 1
|
||||
|
||||
# Determine what needs installation (in correct order)
|
||||
to_install = [
|
||||
node for node in graph.get_install_order()
|
||||
if node.source == "registry" and node.path is None
|
||||
]
|
||||
|
||||
if not to_install:
|
||||
print("All dependencies already installed.")
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
print()
|
||||
print(f"Would install {len(to_install)} tool(s):")
|
||||
for node in to_install:
|
||||
version = node.resolved_version or "latest"
|
||||
print(f" {node.qualified_name}@{version}")
|
||||
return 0
|
||||
|
||||
print()
|
||||
print(f"Installing {len(to_install)} tool(s) in dependency order:")
|
||||
for node in to_install:
|
||||
version = node.resolved_version or "latest"
|
||||
print(f" {node.qualified_name}@{version}")
|
||||
|
||||
# Install in topological order (dependencies first)
|
||||
installed = 0
|
||||
failed = []
|
||||
|
||||
print()
|
||||
for node in to_install:
|
||||
try:
|
||||
print(f"Installing {node.qualified_name}...", end=" ", flush=True)
|
||||
spec = node.qualified_name
|
||||
install_version = node.resolved_version or node.version_constraint
|
||||
resolved = install_from_registry(spec, version=install_version)
|
||||
print(f"v{node.resolved_version or 'latest'}")
|
||||
installed += 1
|
||||
if resolved.tool.system_dependencies:
|
||||
print()
|
||||
tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name
|
||||
prompt_install_missing(resolved.tool.system_dependencies, tool_ref)
|
||||
except RegistryError as e:
|
||||
print(f"FAILED: {e.message}")
|
||||
failed.append((node.qualified_name, e.message))
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
failed.append((node.qualified_name, str(e)))
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print(f"Installed: {installed}/{len(to_install)}")
|
||||
if failed:
|
||||
print("Failed:")
|
||||
for name, err in failed:
|
||||
print(f" {name}: {err}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _install_from_lock(lock: "Lockfile", args) -> int:
|
||||
"""Install exact versions from lock file."""
|
||||
from ..resolver import ToolResolver, ToolNotFoundError, install_from_registry
|
||||
from ..registry_client import RegistryError
|
||||
from ..system_deps import prompt_install_missing
|
||||
|
||||
verbose = getattr(args, 'verbose', False)
|
||||
dry_run = getattr(args, 'dry_run', False)
|
||||
force = getattr(args, 'force', False)
|
||||
|
||||
print("Installing from cmdforge.lock (reproducible)...")
|
||||
|
||||
if lock.is_stale() and not force:
|
||||
print("Warning: Lock file may be outdated (manifest changed)")
|
||||
print("Run 'cmdforge lock' to update, or use --force to continue")
|
||||
return 1
|
||||
|
||||
# Check what needs installing
|
||||
resolver = ToolResolver(auto_fetch=False)
|
||||
resolver.manifest = None
|
||||
to_install = []
|
||||
|
||||
for name, locked in lock.packages.items():
|
||||
try:
|
||||
resolved = resolver.resolve(name)
|
||||
# Check if version matches
|
||||
resolved_version = resolved.version or ""
|
||||
locked_version = locked.version or ""
|
||||
if resolved_version != locked_version:
|
||||
to_install.append(locked)
|
||||
except ToolNotFoundError:
|
||||
to_install.append(locked)
|
||||
except Exception:
|
||||
to_install.append(locked)
|
||||
|
||||
if not to_install:
|
||||
print("All locked packages already installed.")
|
||||
return 0
|
||||
|
||||
print()
|
||||
print(f"Installing {len(to_install)} package(s):")
|
||||
for pkg in to_install:
|
||||
print(f" {pkg.name}@{pkg.version}")
|
||||
|
||||
if dry_run:
|
||||
return 0
|
||||
|
||||
# Install exact versions
|
||||
installed = 0
|
||||
failed = []
|
||||
|
||||
print()
|
||||
for pkg in to_install:
|
||||
if pkg.source == "local":
|
||||
print(f"{pkg.name}: skipping (local)")
|
||||
continue
|
||||
|
||||
try:
|
||||
print(f"Installing {pkg.name}@{pkg.version}...", end=" ", flush=True)
|
||||
# Use correct API: install_from_registry(spec, version)
|
||||
resolved = install_from_registry(pkg.name, version=pkg.version)
|
||||
print("OK")
|
||||
installed += 1
|
||||
if resolved.tool.system_dependencies:
|
||||
print()
|
||||
tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name
|
||||
prompt_install_missing(resolved.tool.system_dependencies, tool_ref)
|
||||
except RegistryError as e:
|
||||
print(f"FAILED: {e.message}")
|
||||
failed.append((pkg.name, e.message))
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
failed.append((pkg.name, str(e)))
|
||||
|
||||
print()
|
||||
print(f"Installed: {installed}/{len(to_install)}")
|
||||
if failed:
|
||||
print("Failed:")
|
||||
for name, err in failed:
|
||||
print(f" {name}: {err}")
|
||||
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
|
||||
|
||||
# Check if already installed locally before trying registry
|
||||
local_tool = find_tool(tool_spec)
|
||||
|
||||
# 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:
|
||||
# If already installed locally, no need to fetch from registry
|
||||
if local_tool:
|
||||
source_label = "locally" if local_tool.source == "local" else "globally"
|
||||
version_info = f"@{local_tool.version}" if local_tool.version else ""
|
||||
print(f"Already installed {source_label}: {local_tool.full_name}{version_info}")
|
||||
else:
|
||||
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"Not found in registry: {full_name}", file=sys.stderr)
|
||||
print(f"Dependency added - install manually or create the tool locally.", file=sys.stderr)
|
||||
elif e.code == "VERSION_NOT_FOUND":
|
||||
print(f"No published version of '{full_name}' found in registry.", file=sys.stderr)
|
||||
print(f"Dependency added - publish the tool or install manually.", 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_remove(args):
|
||||
"""Remove a tool from project dependencies."""
|
||||
tool_spec = args.tool
|
||||
|
||||
# Find manifest
|
||||
manifest_path = find_manifest()
|
||||
if not manifest_path:
|
||||
print("No cmdforge.yaml found in current project.")
|
||||
print("Nothing to remove.")
|
||||
return 1
|
||||
|
||||
manifest = load_manifest(manifest_path)
|
||||
|
||||
# Parse tool spec to get the name
|
||||
parsed = ToolSpec.parse(tool_spec)
|
||||
tool_name = parsed.full_name
|
||||
|
||||
# Remove from manifest
|
||||
if manifest.remove_dependency(tool_name):
|
||||
save_manifest(manifest, manifest_path)
|
||||
print(f"Removed {tool_name} from {manifest_path.name}")
|
||||
else:
|
||||
# Try without owner prefix
|
||||
if manifest.remove_dependency(parsed.name):
|
||||
save_manifest(manifest, manifest_path)
|
||||
print(f"Removed {parsed.name} from {manifest_path.name}")
|
||||
else:
|
||||
print(f"Dependency '{tool_name}' not found in {manifest_path.name}")
|
||||
return 1
|
||||
|
||||
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
|
||||
|
||||
|
||||
def cmd_lock(args):
|
||||
"""Generate or update cmdforge.lock file."""
|
||||
from ..lockfile import Lockfile, generate_lockfile
|
||||
from ..dependency_graph import DependencyGraphBuilder
|
||||
from ..resolver import ToolResolver
|
||||
from ..registry_client import get_client
|
||||
|
||||
manifest = load_manifest()
|
||||
if manifest is None:
|
||||
print("No cmdforge.yaml found. Run 'cmdforge init' first.")
|
||||
return 1
|
||||
|
||||
verbose = getattr(args, 'verbose', False)
|
||||
force = getattr(args, 'force', False)
|
||||
|
||||
# Check for existing lock
|
||||
existing_lock = Lockfile.load()
|
||||
if existing_lock and not force:
|
||||
if not existing_lock.is_stale():
|
||||
print("Lock file is up to date. Use --force to regenerate.")
|
||||
return 0
|
||||
print("Lock file is stale (manifest changed), regenerating...")
|
||||
|
||||
# Resolve all dependencies
|
||||
print("Resolving dependencies...")
|
||||
resolver = ToolResolver(auto_fetch=True) # Allow fetching for resolution
|
||||
builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose)
|
||||
|
||||
try:
|
||||
graph = builder.build(manifest.dependencies)
|
||||
except Exception as e:
|
||||
print(f"Error resolving dependencies: {e}")
|
||||
return 1
|
||||
|
||||
if graph.has_cycles():
|
||||
print("Error: Circular dependencies detected")
|
||||
for cycle in graph.cycles:
|
||||
print(f" {' -> '.join(cycle)}")
|
||||
return 1
|
||||
|
||||
unresolved = graph.get_unresolved()
|
||||
if unresolved and not force:
|
||||
print("Error: Some dependencies could not be resolved:")
|
||||
for node in unresolved:
|
||||
constraint = f"@{node.version_constraint}" if node.version_constraint else ""
|
||||
print(f" {node.qualified_name}{constraint}")
|
||||
print("Fix missing deps or re-run with --force to lock what can be resolved.")
|
||||
return 1
|
||||
if unresolved and force:
|
||||
print("Warning: Some dependencies could not be resolved and will be skipped:")
|
||||
for node in unresolved:
|
||||
constraint = f"@{node.version_constraint}" if node.version_constraint else ""
|
||||
print(f" {node.qualified_name}{constraint}")
|
||||
|
||||
# Generate lock file
|
||||
print("Generating lock file...")
|
||||
client = get_client()
|
||||
lock = generate_lockfile(manifest, graph, client)
|
||||
lock.save()
|
||||
|
||||
# Summary
|
||||
direct = sum(1 for p in lock.packages.values() if p.direct)
|
||||
transitive = len(lock.packages) - direct
|
||||
print()
|
||||
print(f"Locked {len(lock.packages)} package(s):")
|
||||
print(f" Direct: {direct}")
|
||||
print(f" Transitive: {transitive}")
|
||||
print()
|
||||
print("Wrote cmdforge.lock")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(args):
|
||||
"""Verify installed tools match lock file."""
|
||||
from ..lockfile import Lockfile, verify_lockfile
|
||||
from ..registry_client import get_client
|
||||
|
||||
lock = Lockfile.load()
|
||||
if lock is None:
|
||||
print("No cmdforge.lock found. Run 'cmdforge lock' first.")
|
||||
return 1
|
||||
|
||||
if lock.is_stale():
|
||||
print("Warning: Lock file may be outdated (manifest changed)")
|
||||
print()
|
||||
|
||||
print("Verifying lock file...")
|
||||
client = get_client()
|
||||
errors = verify_lockfile(lock, client)
|
||||
|
||||
if not errors:
|
||||
print(f"All {len(lock.packages)} packages verified OK")
|
||||
return 0
|
||||
|
||||
print()
|
||||
print(f"{len(errors)} verification error(s):")
|
||||
for error in errors:
|
||||
print(f" {error}")
|
||||
return 1
|
||||
@@ -0,0 +1,575 @@
|
||||
"""Provider management commands."""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ..providers import (
|
||||
PRESET_CHAINS,
|
||||
Provider,
|
||||
add_provider,
|
||||
call_provider,
|
||||
delete_provider,
|
||||
get_provider,
|
||||
load_providers,
|
||||
save_providers,
|
||||
)
|
||||
|
||||
|
||||
PROVIDER_INSTALL_INFO = {
|
||||
"opencode": {
|
||||
"group": "OpenCode (6+ FREE models)",
|
||||
"install_cmd": "curl -fsSL https://opencode.ai/install | bash",
|
||||
"requires": "curl, bash",
|
||||
"setup": "Run 'opencode' to connect API keys for paid models. Free models work without keys.",
|
||||
"cost": "6 FREE models included (Big Pickle, DeepSeek V4 Flash, Nemotron, Mimo, etc.)",
|
||||
"variants": ["opencode-pickle", "opencode-deepseek", "opencode-reasoner", "opencode-free"],
|
||||
"post_install_note": "Test: cmdforge providers test opencode-pickle",
|
||||
},
|
||||
"claude": {
|
||||
"group": "Anthropic Claude",
|
||||
"install_cmd": "curl -fsSL https://claude.ai/install.sh | bash",
|
||||
"requires": "curl, bash (or: brew install --cask claude-code)",
|
||||
"setup": "Run 'claude' - opens browser for sign-in (subscription or API key required)",
|
||||
"cost": "Paid subscription or API key. Non-US users may not access frontier models.",
|
||||
"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 (or: brew install --cask codex)",
|
||||
"setup": "Run 'codex' - opens browser for sign-in. ChatGPT Plus/Pro includes Codex access.",
|
||||
"cost": "Free tier available (ChatGPT Free). ChatGPT Plus/Pro includes more usage.",
|
||||
"variants": ["codex"],
|
||||
},
|
||||
"agy": {
|
||||
"group": "Google Antigravity (replaces Gemini CLI)",
|
||||
"install_cmd": None,
|
||||
"requires": "Google Antigravity desktop application",
|
||||
"setup": "Install from https://antigravity.google/download, then run 'agy install' to configure your PATH.",
|
||||
"cost": "FREE tier (1,000 req/day). Paid Code Assist license for higher limits.",
|
||||
"variants": ["agy"],
|
||||
"binary_name": "agy",
|
||||
"post_install_note": "Test: cmdforge providers test agy",
|
||||
},
|
||||
"crush": {
|
||||
"group": "Crush (Charm) - multi-model agent",
|
||||
"install_cmd": "npm install -g @charmland/crush",
|
||||
"requires": "Node.js 18+ and npm (or go install, brew, AUR)",
|
||||
"setup": "Run 'crush' to configure API keys for your preferred providers (OpenAI, Anthropic, Gemini, etc.)",
|
||||
"cost": "Hyper free tier (limited credits). Use your own API keys for unlimited access via crush config.",
|
||||
"variants": ["crush"],
|
||||
"post_install_note": "Test: cmdforge providers test crush\nYou may need to configure API keys in ~/.config/crush/crush.json or subscribe to Hyper.",
|
||||
},
|
||||
"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.2' to download a model, then add provider",
|
||||
"cost": "FREE (runs entirely on your machine)",
|
||||
"variants": [],
|
||||
"custom": True,
|
||||
"post_install_note": "After installing, pull a model and add the provider:\n ollama pull llama3.2\n cmdforge providers add ollama 'ollama run llama3.2' -d 'Local Llama 3.2'",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _provider_status(provider):
|
||||
"""Return (available, status) without trying to call the provider."""
|
||||
if provider.name.lower() == "mock":
|
||||
return True, "OK (built-in)"
|
||||
|
||||
if provider.type == "api":
|
||||
env_var = provider.api_key_env or f"{provider.name.upper().replace('-', '_')}_API_KEY"
|
||||
if os.environ.get(env_var):
|
||||
return True, f"OK (API key set: {env_var})"
|
||||
return False, f"API KEY NOT SET ({env_var})"
|
||||
|
||||
try:
|
||||
parts = shlex.split(os.path.expandvars(provider.command))
|
||||
except ValueError:
|
||||
return False, "INVALID COMMAND"
|
||||
if not parts:
|
||||
return False, "INVALID COMMAND"
|
||||
|
||||
executable = os.path.expanduser(parts[0])
|
||||
if shutil.which(executable) or Path(executable).is_file():
|
||||
return True, "OK"
|
||||
return False, f"NOT FOUND ({parts[0]})"
|
||||
|
||||
|
||||
def cmd_providers(args):
|
||||
"""Manage AI providers."""
|
||||
if args.providers_cmd == "install":
|
||||
return _cmd_providers_install(args)
|
||||
elif args.providers_cmd == "discover":
|
||||
return _cmd_providers_discover(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)
|
||||
elif args.providers_cmd == "for-tools":
|
||||
return _cmd_providers_for_tools(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" or p.type == "api":
|
||||
continue
|
||||
available, _ = _provider_status(p)
|
||||
if available:
|
||||
# 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']}")
|
||||
if info["install_cmd"]:
|
||||
print(f"Install command: {info['install_cmd']}")
|
||||
else:
|
||||
print("Install method: Manual download")
|
||||
print(f"Post-install: {info['setup']}")
|
||||
print()
|
||||
|
||||
if not info["install_cmd"]:
|
||||
print("CmdForge cannot install this provider automatically.")
|
||||
print(info["setup"])
|
||||
return 0
|
||||
|
||||
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_discover(args):
|
||||
"""Scan the system for installed AI CLIs and API keys."""
|
||||
from ..providers import discover_installed_providers
|
||||
|
||||
print("=" * 60)
|
||||
print("CmdForge Provider Discovery")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Scanning PATH for installed CLIs and environment for API keys...\n")
|
||||
|
||||
found = discover_installed_providers()
|
||||
|
||||
if not found:
|
||||
print("No AI providers detected on this system.")
|
||||
print("\nTo get started, install one of these free providers:")
|
||||
print(" opencode: curl -fsSL https://opencode.ai/install | bash")
|
||||
print(" agy: https://antigravity.google/download (then run 'agy install')")
|
||||
print(" codex: npm install -g @openai/codex")
|
||||
print(" crush: npm install -g @charmland/crush")
|
||||
print(" ollama: curl -fsSL https://ollama.ai/install.sh | bash")
|
||||
print("\nThen run: cmdforge providers discover")
|
||||
return 0
|
||||
|
||||
# Group by source
|
||||
cli_providers = [p for p in found if p["source"] == "cli"]
|
||||
api_providers = [p for p in found if p["source"] == "api-key"]
|
||||
ollama_models = [p for p in found if p["source"] == "ollama-model"]
|
||||
|
||||
if cli_providers:
|
||||
print(f"Installed CLIs ({len(cli_providers)}):")
|
||||
for p in cli_providers:
|
||||
print(f" [+] {p['binary']:12s} {p['path']}")
|
||||
print(f" -> provider: {p['name']}")
|
||||
print()
|
||||
|
||||
if api_providers:
|
||||
print(f"API keys detected ({len(api_providers)}):")
|
||||
for p in api_providers:
|
||||
print(f" [+] {p['env_var']}")
|
||||
print(f" -> provider: {p['name']} (model: {p.get('model', 'auto')})")
|
||||
print()
|
||||
|
||||
if ollama_models:
|
||||
# Only show first 10 to avoid flooding output
|
||||
shown = ollama_models[:10]
|
||||
print(f"Local Ollama models ({len(ollama_models)} total, showing first 10):")
|
||||
for p in shown:
|
||||
print(f" [+] {p['command']}")
|
||||
if len(ollama_models) > 10:
|
||||
print(f" ... and {len(ollama_models) - 10} more")
|
||||
print()
|
||||
|
||||
print(f"Total discovered: {len(found)} provider(s)")
|
||||
print()
|
||||
|
||||
if getattr(args, "add", False):
|
||||
providers = load_providers()
|
||||
configured = {provider.name for provider in providers}
|
||||
added = []
|
||||
for item in found:
|
||||
if item["name"] in configured:
|
||||
continue
|
||||
providers.append(Provider(
|
||||
name=item["name"],
|
||||
command=item["command"],
|
||||
description=item.get("description", ""),
|
||||
type=item.get("type", "subprocess"),
|
||||
model=item.get("model"),
|
||||
tags=item.get("tags", []),
|
||||
api_key_env=item.get("env_var"),
|
||||
))
|
||||
configured.add(item["name"])
|
||||
added.append(item["name"])
|
||||
if added:
|
||||
save_providers(providers)
|
||||
print(f"Added {len(added)} provider(s): {', '.join(added)}")
|
||||
else:
|
||||
print("All discovered providers are already configured.")
|
||||
print()
|
||||
else:
|
||||
print("To add all newly discovered providers:")
|
||||
print(" cmdforge providers discover --add")
|
||||
print()
|
||||
|
||||
print("To test a provider:")
|
||||
print(" cmdforge providers test <name>")
|
||||
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:
|
||||
exists, status = _provider_status(p)
|
||||
status_icon = "+" if exists else "-"
|
||||
|
||||
print(f" [{status_icon}] {p.name}")
|
||||
label = "Endpoint" if p.type == "api" else "Command"
|
||||
value = "(built-in)" if p.name.lower() == "mock" else p.command
|
||||
print(f" {label}: {value}")
|
||||
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
|
||||
existing = get_provider(name)
|
||||
description = args.description if args.description is not None else (existing.description if existing else "")
|
||||
provider_type = args.type or (existing.type if existing else "subprocess")
|
||||
model = args.model if args.model is not None else (existing.model if existing else None)
|
||||
api_key_env = args.api_key_env if args.api_key_env is not None else (existing.api_key_env if existing else None)
|
||||
tags = args.tags if args.tags is not None else (existing.tags if existing else [])
|
||||
fallback = args.fallback if args.fallback is not None else (existing.fallback if existing else None)
|
||||
fallback = fallback or None
|
||||
|
||||
if args.fallback_chain is None:
|
||||
fallback_chain = existing.fallback_chain if existing else None
|
||||
elif args.fallback_chain in PRESET_CHAINS:
|
||||
fallback_chain = list(PRESET_CHAINS[args.fallback_chain])
|
||||
else:
|
||||
fallback_chain = [item.strip() for item in args.fallback_chain.split(",") if item.strip()]
|
||||
fallback_chain = fallback_chain or None
|
||||
|
||||
if fallback == name or (fallback_chain and name in fallback_chain):
|
||||
print("A provider cannot fall back to itself.")
|
||||
return 1
|
||||
|
||||
provider = Provider(
|
||||
name=name,
|
||||
command=command,
|
||||
description=description,
|
||||
fallback=fallback,
|
||||
type=provider_type,
|
||||
model=model,
|
||||
tags=tags,
|
||||
install=existing.install if existing else None,
|
||||
fallback_chain=fallback_chain,
|
||||
api_key_env=api_key_env,
|
||||
pty_config=existing.pty_config if existing else None,
|
||||
)
|
||||
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_for_tools(args):
|
||||
"""List (and optionally warm) providers used by specified tools."""
|
||||
import sys
|
||||
import re
|
||||
from ..tool import load_tool
|
||||
from ..resolver import resolve_tool, ToolNotFoundError
|
||||
|
||||
# Cloud providers don't need warming - they're always "warm"
|
||||
CLOUD_PREFIXES = ('claude', 'opencode', 'agy', 'codex', 'gpt', 'openai', 'openrouter', 'deepseek', 'crush')
|
||||
|
||||
def is_local_provider(name):
|
||||
"""Check if provider is local (needs warming) vs cloud."""
|
||||
provider = get_provider(name)
|
||||
if provider and provider.type == "api":
|
||||
return False
|
||||
if provider and "local" in provider.tags:
|
||||
return True
|
||||
name_lower = name.lower()
|
||||
return not any(name_lower.startswith(prefix) for prefix in CLOUD_PREFIXES)
|
||||
|
||||
def estimate_model_size(provider_name):
|
||||
"""Estimate model size in billions from provider name/command for sorting.
|
||||
|
||||
Larger models should be warmed first to claim contiguous GPU memory.
|
||||
Returns estimated size in billions (e.g., 70 for 70b model).
|
||||
"""
|
||||
# Check provider command for model size hints
|
||||
provider = get_provider(provider_name)
|
||||
text_to_check = f"{provider_name} {provider.command if provider else ''}"
|
||||
|
||||
# Look for patterns like "70b", "14b", "7b", "3b", "1.5b"
|
||||
match = re.search(r'(\d+(?:\.\d+)?)\s*[bB]', text_to_check)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
|
||||
# Known large model keywords
|
||||
if any(kw in text_to_check.lower() for kw in ['opus', 'large', 'big', '32b', '70b']):
|
||||
return 70.0
|
||||
if any(kw in text_to_check.lower() for kw in ['medium', '14b', '13b']):
|
||||
return 14.0
|
||||
if any(kw in text_to_check.lower() for kw in ['small', 'fast', '3b', '1b', 'tiny', 'mini']):
|
||||
return 3.0
|
||||
|
||||
# Default to medium size
|
||||
return 7.0
|
||||
|
||||
tool_names = args.tools
|
||||
providers_used = set()
|
||||
|
||||
for tool_name in tool_names:
|
||||
try:
|
||||
resolved = resolve_tool(tool_name)
|
||||
tool = resolved.tool
|
||||
except ToolNotFoundError:
|
||||
print(f"Warning: Tool '{tool_name}' not found, skipping", file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Extract providers from prompt steps
|
||||
for step in tool.steps:
|
||||
if hasattr(step, 'provider') and step.provider:
|
||||
providers_used.add(step.provider)
|
||||
|
||||
# Also check tool steps for nested tools (recursive would be complex, just note dependency)
|
||||
for step in tool.steps:
|
||||
if hasattr(step, 'tool') and step.tool:
|
||||
# Could recurse here, but for now just note it
|
||||
pass
|
||||
|
||||
# Sort for consistent output
|
||||
providers_list = sorted(providers_used)
|
||||
|
||||
if args.warm:
|
||||
# Only warm local providers (Ollama, etc.)
|
||||
local_providers = [p for p in providers_list if is_local_provider(p)]
|
||||
cloud_providers = [p for p in providers_list if not is_local_provider(p)]
|
||||
|
||||
if cloud_providers:
|
||||
print(f"Skipping {len(cloud_providers)} cloud provider(s): {', '.join(cloud_providers)}", file=sys.stderr)
|
||||
|
||||
if local_providers:
|
||||
# Sort by model size DESCENDING - warm largest models first
|
||||
# This lets large models claim contiguous GPU memory before small models fragment it
|
||||
local_providers_sorted = sorted(
|
||||
local_providers,
|
||||
key=lambda p: estimate_model_size(p),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
print(f"Warming {len(local_providers_sorted)} local provider(s) (largest first)...", file=sys.stderr)
|
||||
for provider_name in local_providers_sorted:
|
||||
size_est = estimate_model_size(provider_name)
|
||||
try:
|
||||
result = call_provider(provider_name, "Hi", timeout=120)
|
||||
if result.success:
|
||||
print(f" [+] {provider_name} (~{size_est:.0f}B): warm", file=sys.stderr)
|
||||
else:
|
||||
print(f" [-] {provider_name} (~{size_est:.0f}B): failed ({result.error[:50]}...)", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f" [-] {provider_name} (~{size_est:.0f}B): error ({e})", file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
# Output provider names (one per line for easy parsing)
|
||||
for p in providers_list:
|
||||
print(p)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_providers_check(args):
|
||||
"""Check which providers are available."""
|
||||
providers = load_providers()
|
||||
print("Checking all providers...\n")
|
||||
available = []
|
||||
missing = []
|
||||
|
||||
for p in providers:
|
||||
exists, status = _provider_status(p)
|
||||
|
||||
if exists:
|
||||
available.append(p.name)
|
||||
print(f" [+] {p.name}: {status}")
|
||||
else:
|
||||
missing.append(p.name)
|
||||
print(f" [-] {p.name}: {status}")
|
||||
|
||||
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" - opencode: curl -fsSL https://opencode.ai/install | bash")
|
||||
print(f" - agy: https://antigravity.google/download, then run 'agy install'")
|
||||
print(f" - codex: npm install -g @openai/codex (FREE tier available)")
|
||||
print(f" - crush: npm install -g @charmland/crush")
|
||||
print(f" - ollama: curl -fsSL https://ollama.ai/install.sh | bash (local, FREE)")
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
"""Settings management commands."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ..tool import load_tool
|
||||
|
||||
|
||||
def cmd_settings(args):
|
||||
"""Manage tool settings."""
|
||||
if not hasattr(args, 'settings_cmd') or not args.settings_cmd:
|
||||
args.settings_cmd = "show"
|
||||
|
||||
if args.settings_cmd == "show":
|
||||
return _cmd_settings_show(args)
|
||||
elif args.settings_cmd == "edit":
|
||||
return _cmd_settings_edit(args)
|
||||
elif args.settings_cmd == "reset":
|
||||
return _cmd_settings_reset(args)
|
||||
elif args.settings_cmd == "diff":
|
||||
return _cmd_settings_diff(args)
|
||||
else:
|
||||
print("Settings commands:")
|
||||
print(" show Show current settings")
|
||||
print(" edit Edit settings in $EDITOR")
|
||||
print(" reset Reset settings to defaults")
|
||||
print(" diff Show differences from defaults")
|
||||
return 0
|
||||
|
||||
|
||||
def _get_tool_paths(tool_name: str):
|
||||
"""Get paths for tool's settings files."""
|
||||
tool = load_tool(tool_name)
|
||||
if not tool or not tool.path:
|
||||
return None, None, None
|
||||
|
||||
tool_dir = tool.path.parent
|
||||
return (
|
||||
tool_dir / "defaults.yaml",
|
||||
tool_dir / "settings.yaml",
|
||||
tool_dir
|
||||
)
|
||||
|
||||
|
||||
def _cmd_settings_show(args):
|
||||
"""Show current settings for a tool."""
|
||||
defaults_path, settings_path, _ = _get_tool_paths(args.tool)
|
||||
|
||||
if not settings_path:
|
||||
print(f"Tool '{args.tool}' not found")
|
||||
return 1
|
||||
|
||||
if not settings_path.exists():
|
||||
if defaults_path and defaults_path.exists():
|
||||
print(f"No settings.yaml yet. Defaults from defaults.yaml:")
|
||||
print(defaults_path.read_text())
|
||||
else:
|
||||
print(f"Tool '{args.tool}' has no configurable settings")
|
||||
return 0
|
||||
|
||||
print(settings_path.read_text())
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_settings_edit(args):
|
||||
"""Open settings in $EDITOR."""
|
||||
defaults_path, settings_path, tool_dir = _get_tool_paths(args.tool)
|
||||
|
||||
if not settings_path:
|
||||
print(f"Tool '{args.tool}' not found")
|
||||
return 1
|
||||
|
||||
# Create from defaults if doesn't exist
|
||||
if not settings_path.exists():
|
||||
if defaults_path and defaults_path.exists():
|
||||
shutil.copy(defaults_path, settings_path)
|
||||
print(f"Created settings.yaml from defaults")
|
||||
else:
|
||||
print(f"Tool '{args.tool}' has no configurable settings")
|
||||
return 1
|
||||
|
||||
editor = os.environ.get('EDITOR', 'nano')
|
||||
return subprocess.call([editor, str(settings_path)])
|
||||
|
||||
|
||||
def _cmd_settings_reset(args):
|
||||
"""Reset settings to defaults."""
|
||||
defaults_path, settings_path, _ = _get_tool_paths(args.tool)
|
||||
|
||||
if not defaults_path or not defaults_path.exists():
|
||||
print(f"Tool '{args.tool}' has no defaults.yaml")
|
||||
return 1
|
||||
|
||||
if settings_path.exists() and not getattr(args, 'force', False):
|
||||
print("This will overwrite your current settings.")
|
||||
try:
|
||||
confirm = input("Continue? [y/N] ")
|
||||
if confirm.lower() != 'y':
|
||||
print("Cancelled")
|
||||
return 0
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nCancelled")
|
||||
return 0
|
||||
|
||||
shutil.copy(defaults_path, settings_path)
|
||||
print(f"Reset settings to defaults")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_settings_diff(args):
|
||||
"""Show diff between current settings and defaults."""
|
||||
defaults_path, settings_path, _ = _get_tool_paths(args.tool)
|
||||
|
||||
if not defaults_path or not defaults_path.exists():
|
||||
print(f"Tool '{args.tool}' has no defaults.yaml")
|
||||
return 1
|
||||
|
||||
if not settings_path or not settings_path.exists():
|
||||
print("No settings.yaml yet (using defaults)")
|
||||
return 0
|
||||
|
||||
# Use diff if available, otherwise show both
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['diff', '-u', str(defaults_path), str(settings_path)],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
else:
|
||||
print("No differences (settings match defaults)")
|
||||
return 0
|
||||
except FileNotFoundError:
|
||||
print("=== defaults.yaml ===")
|
||||
print(defaults_path.read_text())
|
||||
print("\n=== settings.yaml ===")
|
||||
print(settings_path.read_text())
|
||||
return 0
|
||||
@@ -0,0 +1,141 @@
|
||||
"""System dependencies commands."""
|
||||
|
||||
import sys
|
||||
|
||||
from ..tool import load_tool
|
||||
from ..system_deps import (
|
||||
detect_os, detect_package_manager,
|
||||
check_and_report, format_dep_status,
|
||||
install_system_dep, get_install_command_string
|
||||
)
|
||||
|
||||
|
||||
def cmd_system_deps(args):
|
||||
"""Handle system-deps subcommands."""
|
||||
tool_name = args.tool
|
||||
|
||||
tool = load_tool(tool_name)
|
||||
if not tool:
|
||||
print(f"Error: Tool '{tool_name}' not found.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not tool.system_dependencies:
|
||||
print(f"Tool '{tool_name}' has no system dependencies declared.")
|
||||
return 0
|
||||
|
||||
subcmd = getattr(args, 'system_deps_cmd', None)
|
||||
|
||||
if subcmd == "install":
|
||||
return _cmd_install(args, tool)
|
||||
else:
|
||||
# Default: show status
|
||||
return _cmd_status(args, tool)
|
||||
|
||||
|
||||
def _cmd_status(args, tool):
|
||||
"""Show system dependency status for a tool."""
|
||||
print(f"System Dependencies for '{tool.name}':")
|
||||
print()
|
||||
|
||||
pkg_manager = detect_package_manager()
|
||||
installed, missing = check_and_report(tool.system_dependencies)
|
||||
|
||||
for dep in tool.system_dependencies:
|
||||
status = format_dep_status(dep, pkg_manager if dep in missing else None)
|
||||
print(status)
|
||||
|
||||
print()
|
||||
|
||||
if missing:
|
||||
if pkg_manager:
|
||||
print(f"Package manager: {pkg_manager}")
|
||||
print(f"Install missing with: cmdforge system-deps {tool.name} install")
|
||||
else:
|
||||
os_type = detect_os()
|
||||
if os_type == "windows":
|
||||
print("Note: Automatic installation not supported on Windows.")
|
||||
print("Please install the packages manually.")
|
||||
else:
|
||||
print("Warning: No supported package manager found.")
|
||||
return 1
|
||||
else:
|
||||
print("All system dependencies satisfied.")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_install(args, tool):
|
||||
"""Install missing system dependencies."""
|
||||
os_type = detect_os()
|
||||
|
||||
if os_type == "windows":
|
||||
print("Automatic installation not supported on Windows.", file=sys.stderr)
|
||||
print()
|
||||
print("Please install these packages manually:")
|
||||
for dep in tool.system_dependencies:
|
||||
print(f" - {dep.name}")
|
||||
if dep.description:
|
||||
print(f" {dep.description}")
|
||||
return 1
|
||||
|
||||
pkg_manager = detect_package_manager()
|
||||
if not pkg_manager:
|
||||
print("Error: No supported package manager found.", file=sys.stderr)
|
||||
print("Supported: apt, brew, pacman, dnf", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
installed, missing = check_and_report(tool.system_dependencies)
|
||||
|
||||
if not missing:
|
||||
print("All system dependencies are already installed.")
|
||||
return 0
|
||||
|
||||
print(f"Missing system packages ({len(missing)}):")
|
||||
for dep in missing:
|
||||
desc = f" - {dep.description}" if dep.description else ""
|
||||
print(f" {dep.name}{desc}")
|
||||
print(f" Install: {get_install_command_string(dep, pkg_manager)}")
|
||||
print()
|
||||
|
||||
# Check for --yes flag
|
||||
auto_yes = getattr(args, 'yes', False)
|
||||
|
||||
if not auto_yes:
|
||||
try:
|
||||
confirm = input(f"Install {len(missing)} package(s)? [Y/n] ")
|
||||
if confirm.lower() not in ('', 'y', 'yes'):
|
||||
print("Cancelled.")
|
||||
return 0
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nCancelled.")
|
||||
return 0
|
||||
|
||||
print()
|
||||
print(f"Installing packages with {pkg_manager}...")
|
||||
print()
|
||||
|
||||
# Track results
|
||||
succeeded = []
|
||||
failed = []
|
||||
|
||||
for dep in missing:
|
||||
print(f"Installing {dep.name}...")
|
||||
success, msg = install_system_dep(dep, pkg_manager)
|
||||
if success:
|
||||
print(f" \u2713 {msg}")
|
||||
succeeded.append(dep)
|
||||
else:
|
||||
print(f" \u2717 {msg}")
|
||||
failed.append((dep, msg))
|
||||
|
||||
print()
|
||||
|
||||
if failed:
|
||||
print(f"Installed {len(succeeded)}/{len(missing)} packages.")
|
||||
print()
|
||||
print("Failed packages:")
|
||||
for dep, msg in failed:
|
||||
print(f" {dep.name}: {msg}")
|
||||
return 1
|
||||
else:
|
||||
print(f"Successfully installed {len(succeeded)} package(s).")
|
||||
return 0
|
||||
@@ -0,0 +1,430 @@
|
||||
"""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, validate_tool_name
|
||||
)
|
||||
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
|
||||
|
||||
# Validate tool name
|
||||
is_valid, error_msg = validate_tool_name(name)
|
||||
if not is_valid:
|
||||
print(f"Error: Invalid tool name '{name}': {error_msg}")
|
||||
return 1
|
||||
|
||||
# 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 "",
|
||||
category=args.category or "Other",
|
||||
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 collect_custom_args, create_argument_parser, run_tool
|
||||
|
||||
tool = load_tool(args.name)
|
||||
if not tool:
|
||||
print(f"Error: Tool '{args.name}' not found.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
tool_args = list(args.tool_args or [])
|
||||
if tool_args and tool_args[0] == '--':
|
||||
tool_args = tool_args[1:]
|
||||
|
||||
tool_parser = create_argument_parser(tool)
|
||||
parsed_tool_args = tool_parser.parse_args(tool_args)
|
||||
|
||||
# Read input
|
||||
input_file = args.input or parsed_tool_args.input_file
|
||||
if input_file:
|
||||
# Read from file
|
||||
input_path = Path(input_file)
|
||||
if not input_path.exists():
|
||||
print(f"Error: Input file not found: {input_file}", file=sys.stderr)
|
||||
return 1
|
||||
input_text = input_path.read_text()
|
||||
elif args.stdin or parsed_tool_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 the same parser used by wrapper scripts.
|
||||
custom_args = collect_custom_args(tool, parsed_tool_args)
|
||||
|
||||
provider_override = args.provider or parsed_tool_args.provider
|
||||
dry_run = args.dry_run or parsed_tool_args.dry_run
|
||||
show_prompt = args.show_prompt or parsed_tool_args.show_prompt
|
||||
verbose = args.verbose or parsed_tool_args.verbose
|
||||
auto_install = args.auto_install or parsed_tool_args.auto_install
|
||||
|
||||
# Run tool
|
||||
output, code = run_tool(
|
||||
tool=tool,
|
||||
input_text=input_text,
|
||||
custom_args=custom_args,
|
||||
provider_override=provider_override,
|
||||
dry_run=dry_run,
|
||||
show_prompt=show_prompt,
|
||||
verbose=verbose,
|
||||
auto_install=auto_install
|
||||
)
|
||||
|
||||
# Write output
|
||||
if code == 0 and output:
|
||||
output_file = args.output or parsed_tool_args.output_file
|
||||
if output_file:
|
||||
Path(output_file).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 and system packages)."""
|
||||
from ..runner import check_dependencies
|
||||
from ..tool import ToolStep
|
||||
from ..system_deps import (
|
||||
check_and_report, detect_package_manager, format_dep_status
|
||||
)
|
||||
|
||||
tool = load_tool(args.name)
|
||||
if not tool:
|
||||
print(f"Error: Tool '{args.name}' not found.")
|
||||
return 1
|
||||
|
||||
print(f"Tool: {args.name}")
|
||||
print()
|
||||
|
||||
has_issues = False
|
||||
|
||||
# Check if this is a meta-tool
|
||||
has_tool_steps = any(isinstance(s, ToolStep) for s in tool.steps)
|
||||
has_deps = bool(tool.dependencies)
|
||||
has_sys_deps = bool(tool.system_dependencies)
|
||||
|
||||
# CmdForge Dependencies section
|
||||
if has_deps or has_tool_steps:
|
||||
print("CmdForge Dependencies:")
|
||||
|
||||
# Show declared dependencies
|
||||
if tool.dependencies:
|
||||
missing = check_dependencies(tool)
|
||||
for dep in tool.dependencies:
|
||||
if dep in missing:
|
||||
print(f" \u2717 {dep} (not installed)")
|
||||
else:
|
||||
print(f" \u2713 {dep} (installed)")
|
||||
|
||||
# Show tool steps
|
||||
if has_tool_steps:
|
||||
for i, step in enumerate(tool.steps):
|
||||
if isinstance(step, ToolStep):
|
||||
# Check if already shown as explicit dependency
|
||||
if step.tool not in (tool.dependencies or []):
|
||||
from ..resolver import resolve_tool, ToolNotFoundError
|
||||
try:
|
||||
resolve_tool(step.tool)
|
||||
print(f" \u2713 {step.tool} (step {i+1}, installed)")
|
||||
except ToolNotFoundError:
|
||||
print(f" \u2717 {step.tool} (step {i+1}, not installed)")
|
||||
|
||||
# Check which are missing
|
||||
missing = check_dependencies(tool)
|
||||
if missing:
|
||||
has_issues = True
|
||||
print()
|
||||
print(f" Install missing tools with:")
|
||||
print(f" cmdforge install {' '.join(missing)}")
|
||||
|
||||
print()
|
||||
|
||||
# System Dependencies section
|
||||
if has_sys_deps:
|
||||
print("System Dependencies:")
|
||||
pkg_manager = detect_package_manager()
|
||||
|
||||
installed, missing = check_and_report(tool.system_dependencies)
|
||||
|
||||
for dep in tool.system_dependencies:
|
||||
status = format_dep_status(dep, pkg_manager if dep in missing else None)
|
||||
print(status)
|
||||
|
||||
if missing:
|
||||
has_issues = True
|
||||
print()
|
||||
print(f" Install missing packages with:")
|
||||
print(f" cmdforge system-deps {args.name} install")
|
||||
|
||||
print()
|
||||
|
||||
if not has_deps and not has_tool_steps and not has_sys_deps:
|
||||
print("No dependencies declared.")
|
||||
return 0
|
||||
|
||||
if has_issues:
|
||||
return 1
|
||||
else:
|
||||
print("All dependencies satisfied.")
|
||||
return 0
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Collection management for CmdForge."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from .config import get_registry_url
|
||||
|
||||
COLLECTIONS_DIR = Path.home() / ".cmdforge" / "collections"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Collection:
|
||||
"""A collection of related tools."""
|
||||
name: str
|
||||
display_name: str
|
||||
description: str = ""
|
||||
maintainer: str = ""
|
||||
tools: List[str] = field(default_factory=list)
|
||||
pinned: Dict[str, str] = field(default_factory=dict)
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
# Publishing state
|
||||
published: bool = False
|
||||
registry_name: str = ""
|
||||
pending_approval: bool = False
|
||||
pending_tools: List[str] = field(default_factory=list) # Tools awaiting approval
|
||||
|
||||
@classmethod
|
||||
def load(cls, name: str) -> Optional["Collection"]:
|
||||
"""Load a collection from disk."""
|
||||
path = COLLECTIONS_DIR / f"{name}.yaml"
|
||||
if not path.exists():
|
||||
return None
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return cls(
|
||||
name=data.get("name", name),
|
||||
display_name=data.get("display_name", name),
|
||||
description=data.get("description", ""),
|
||||
maintainer=data.get("maintainer", ""),
|
||||
tools=data.get("tools", []),
|
||||
pinned=data.get("pinned", {}),
|
||||
tags=data.get("tags", []),
|
||||
published=data.get("published", False),
|
||||
registry_name=data.get("registry_name", ""),
|
||||
pending_approval=data.get("pending_approval", False),
|
||||
pending_tools=data.get("pending_tools", []),
|
||||
)
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save collection to disk."""
|
||||
COLLECTIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = COLLECTIONS_DIR / f"{self.name}.yaml"
|
||||
with open(path, "w") as f:
|
||||
yaml.safe_dump(self.to_dict(), f, sort_keys=False)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for YAML serialization."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"display_name": self.display_name,
|
||||
"description": self.description,
|
||||
"maintainer": self.maintainer,
|
||||
"tools": self.tools,
|
||||
"pinned": self.pinned,
|
||||
"tags": self.tags,
|
||||
"published": self.published,
|
||||
"registry_name": self.registry_name,
|
||||
"pending_approval": self.pending_approval,
|
||||
"pending_tools": self.pending_tools,
|
||||
}
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete collection file."""
|
||||
path = COLLECTIONS_DIR / f"{self.name}.yaml"
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def get_registry_url(self) -> str:
|
||||
"""Get the URL for this collection on the registry."""
|
||||
base_url = get_registry_url()
|
||||
# Remove /api/v1 suffix if present (using proper suffix check, not rstrip)
|
||||
if base_url.endswith("/api/v1"):
|
||||
base_url = base_url[:-7] # len("/api/v1") == 7
|
||||
base_url = base_url.rstrip("/")
|
||||
return f"{base_url}/collections/{self.registry_name or self.name}"
|
||||
|
||||
|
||||
def list_collections() -> List[str]:
|
||||
"""List all local collection names."""
|
||||
if not COLLECTIONS_DIR.exists():
|
||||
return []
|
||||
return sorted([p.stem for p in COLLECTIONS_DIR.glob("*.yaml")])
|
||||
|
||||
|
||||
def get_collection(name: str) -> Optional[Collection]:
|
||||
"""Get a collection by name."""
|
||||
return Collection.load(name)
|
||||
|
||||
|
||||
def classify_tool_reference(ref: str) -> tuple[str, str, bool]:
|
||||
"""
|
||||
Classify a tool reference.
|
||||
|
||||
Returns: (owner, name, is_local)
|
||||
- Local tool "summarize" -> ("", "summarize", True)
|
||||
- Registry tool "rob/summarize" -> ("rob", "summarize", False)
|
||||
"""
|
||||
if "/" in ref:
|
||||
owner, name = ref.split("/", 1)
|
||||
return (owner, name, False)
|
||||
return ("", ref, True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResolutionResult:
|
||||
"""Result of resolving tool references for collection publishing."""
|
||||
registry_refs: List[str] # Fully qualified owner/name references
|
||||
transformed_pinned: Dict[str, str] # Pinned dict with transformed keys
|
||||
local_unpublished: List[str] # Local tools not in registry
|
||||
local_published: List[tuple] # (name, status, has_approved_version) tuples
|
||||
visibility_issues: List[tuple] # (name, visibility) - local tools that aren't public
|
||||
registry_tool_issues: List[tuple] # (ref, reason) - registry tools that aren't public/approved
|
||||
|
||||
|
||||
def _get_tool_visibility_from_yaml(tool_name: str, my_owner: str = None) -> str:
|
||||
"""Read visibility from the Tool dataclass.
|
||||
|
||||
Args:
|
||||
tool_name: The tool name (unqualified)
|
||||
my_owner: Current user's slug for resolving owned tool paths
|
||||
"""
|
||||
from .tool import load_tool
|
||||
|
||||
if my_owner:
|
||||
tool = load_tool(my_owner + "/" + tool_name)
|
||||
if tool and tool.visibility:
|
||||
return tool.visibility
|
||||
tool = load_tool(tool_name)
|
||||
if tool and tool.visibility:
|
||||
return tool.visibility
|
||||
return "public"
|
||||
|
||||
|
||||
def resolve_tool_references(
|
||||
tools: List[str],
|
||||
pinned: Dict[str, str],
|
||||
user_slug: str,
|
||||
client # RegistryClient instance (authenticated)
|
||||
) -> ToolResolutionResult:
|
||||
"""
|
||||
Resolve tool references for publishing.
|
||||
|
||||
Args:
|
||||
tools: List of tool references
|
||||
pinned: Dict of tool_ref -> version constraint
|
||||
user_slug: User's registry slug (e.g., "rob")
|
||||
client: Authenticated RegistryClient instance
|
||||
|
||||
Returns:
|
||||
ToolResolutionResult with all resolution data including visibility issues
|
||||
|
||||
NOTE on visibility checking:
|
||||
- Local tools: Read visibility from raw YAML (Tool model doesn't have this field)
|
||||
- Registry tools: Check via has_approved_public_tool() for approved public version
|
||||
"""
|
||||
from .tool import load_tool
|
||||
from .registry_client import RegistryError
|
||||
|
||||
registry_refs = []
|
||||
transformed_pinned = {}
|
||||
local_unpublished = []
|
||||
local_published = [] # List of (name, status, has_approved_version) tuples
|
||||
visibility_issues = [] # List of (name, visibility) tuples - local tools
|
||||
registry_tool_issues = [] # List of (ref, reason) tuples - registry tools
|
||||
|
||||
for ref in tools:
|
||||
owner, name, is_local = classify_tool_reference(ref)
|
||||
|
||||
if not is_local:
|
||||
# Already a registry reference - validate it exists and is accessible
|
||||
registry_refs.append(ref)
|
||||
# Copy pinned version if exists
|
||||
if ref in pinned:
|
||||
transformed_pinned[ref] = pinned[ref]
|
||||
|
||||
# Check if this registry tool has an approved public version
|
||||
try:
|
||||
ref_owner, ref_name = ref.split("/", 1)
|
||||
ok = client.has_approved_public_tool(ref_owner, ref_name)
|
||||
if not ok:
|
||||
registry_tool_issues.append((ref, "no approved public version"))
|
||||
except RegistryError as e:
|
||||
if e.code == "TOOL_NOT_FOUND":
|
||||
registry_tool_issues.append((ref, "not found"))
|
||||
else:
|
||||
registry_tool_issues.append((ref, f"not accessible ({e.message})"))
|
||||
else:
|
||||
# Local tool - check visibility from raw YAML config
|
||||
local_visibility = _get_tool_visibility_from_yaml(name, user_slug)
|
||||
if local_visibility != 'public':
|
||||
visibility_issues.append((name, local_visibility))
|
||||
|
||||
# Check if published using authenticated endpoint
|
||||
full_ref = f"{user_slug}/{name}"
|
||||
try:
|
||||
# Use authenticated endpoint to see pending/private tools too
|
||||
status_info = client.get_my_tool_status(name)
|
||||
status = status_info.get("status", "pending")
|
||||
|
||||
# Check if there's an approved version
|
||||
has_approved = (status == "approved")
|
||||
|
||||
registry_refs.append(full_ref)
|
||||
local_published.append((name, status, has_approved))
|
||||
except RegistryError:
|
||||
# Not in registry at all
|
||||
local_unpublished.append(name)
|
||||
registry_refs.append(full_ref) # Will be this after publish
|
||||
|
||||
# Transform pinned key from local name to owner/name
|
||||
if name in pinned:
|
||||
transformed_pinned[full_ref] = pinned[name]
|
||||
|
||||
return ToolResolutionResult(
|
||||
registry_refs=registry_refs,
|
||||
transformed_pinned=transformed_pinned,
|
||||
local_unpublished=local_unpublished,
|
||||
local_published=local_published,
|
||||
visibility_issues=visibility_issues,
|
||||
registry_tool_issues=registry_tool_issues
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DepCheckResult:
|
||||
"""Result of dependency checking."""
|
||||
unpublished: List[str] # Local deps not yet in registry
|
||||
publish_order: List[str] # Topological order for publishing
|
||||
cycles: List[List[str]] # Detected cycles (for warning)
|
||||
skipped: List[str] # Deps we couldn't check (network errors)
|
||||
|
||||
|
||||
def gather_local_unpublished_deps(
|
||||
tool_names: List[str],
|
||||
client,
|
||||
my_owner: str = None
|
||||
) -> DepCheckResult:
|
||||
"""
|
||||
Gather all local dependencies and check which are unpublished.
|
||||
|
||||
Args:
|
||||
tool_names: List of local tool names to check
|
||||
client: Authenticated registry client
|
||||
my_owner: Current user's slug (from client.get_me())
|
||||
|
||||
Returns:
|
||||
DepCheckResult with unpublished deps, publish order, cycles, and skipped
|
||||
"""
|
||||
from .tool import ToolStep, TOOLS_DIR
|
||||
from .registry_client import RegistryError
|
||||
|
||||
# Get my_owner if not provided
|
||||
if my_owner is None:
|
||||
try:
|
||||
me = client.get_me()
|
||||
my_owner = me.get("slug", "")
|
||||
except Exception:
|
||||
my_owner = ""
|
||||
|
||||
all_deps = set()
|
||||
visited = set()
|
||||
dep_graph = {}
|
||||
cycles = []
|
||||
|
||||
def get_my_local_tool_path(name: str):
|
||||
"""Get path to a local tool owned by me, or None if not found.
|
||||
|
||||
Resolution order:
|
||||
1. ~/.cmdforge/<my_owner>/<name>/ (preferred if my_owner is set)
|
||||
2. ~/.cmdforge/<name>/ (fallback for unqualified local)
|
||||
"""
|
||||
# Prefer owned local if my_owner is set
|
||||
if my_owner:
|
||||
owned_path = TOOLS_DIR / my_owner / name / "config.yaml"
|
||||
if owned_path.exists():
|
||||
return owned_path.parent
|
||||
# Fallback to unqualified local
|
||||
unqualified_path = TOOLS_DIR / name / "config.yaml"
|
||||
if unqualified_path.exists():
|
||||
return unqualified_path.parent
|
||||
return None
|
||||
|
||||
def is_my_local_tool(name: str) -> bool:
|
||||
"""Check if a tool is a local tool owned by me."""
|
||||
return get_my_local_tool_path(name) is not None
|
||||
|
||||
cyclic_nodes = set() # Track nodes involved in cycles
|
||||
|
||||
def traverse(name: str, path: List[str] = None):
|
||||
path = path or []
|
||||
|
||||
# Cycle detection
|
||||
if name in path:
|
||||
cycle_start = path.index(name)
|
||||
cycle = path[cycle_start:] + [name]
|
||||
cycles.append(cycle)
|
||||
cyclic_nodes.update(cycle)
|
||||
return
|
||||
|
||||
if name in visited:
|
||||
return
|
||||
visited.add(name)
|
||||
|
||||
# Use explicit path resolution and load by path
|
||||
tool_path = get_my_local_tool_path(name)
|
||||
if not tool_path:
|
||||
return
|
||||
|
||||
# Load tool directly from resolved path (not by name)
|
||||
from .tool import Tool
|
||||
config_file = tool_path / "config.yaml"
|
||||
try:
|
||||
with open(config_file) as f:
|
||||
data = yaml.safe_load(f)
|
||||
tool = Tool.from_dict(data)
|
||||
tool.path = config_file # Set path for consistency with load_tool()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
dep_graph[name] = []
|
||||
new_path = path + [name]
|
||||
|
||||
# Get explicit dependencies
|
||||
for dep in tool.dependencies:
|
||||
if '/' in dep:
|
||||
continue # Skip registry refs
|
||||
if not is_my_local_tool(dep):
|
||||
continue # Skip tools I don't own
|
||||
all_deps.add(dep)
|
||||
dep_graph[name].append(dep)
|
||||
traverse(dep, new_path)
|
||||
|
||||
# Get implicit dependencies from ToolStep
|
||||
for step in tool.steps:
|
||||
if isinstance(step, ToolStep):
|
||||
if '/' in step.tool:
|
||||
continue
|
||||
if not is_my_local_tool(step.tool):
|
||||
continue
|
||||
all_deps.add(step.tool)
|
||||
dep_graph[name].append(step.tool)
|
||||
traverse(step.tool, new_path)
|
||||
|
||||
for name in tool_names:
|
||||
traverse(name)
|
||||
|
||||
# Check which are unpublished or not approved (distinguish 404 from other errors)
|
||||
# Check BOTH dependencies AND the original tools (they might be rejected too)
|
||||
unpublished = []
|
||||
skipped = []
|
||||
tools_to_check = set(all_deps) | set(tool_names)
|
||||
tools_to_check = {t for t in tools_to_check if is_my_local_tool(t)}
|
||||
|
||||
for dep in tools_to_check:
|
||||
try:
|
||||
status_info = client.get_my_tool_status(dep)
|
||||
status = status_info.get("status", "")
|
||||
# Only "approved" tools are actually usable
|
||||
# "pending", "rejected", "changes_requested" all need (re)publishing
|
||||
if status != "approved":
|
||||
unpublished.append(dep)
|
||||
except RegistryError as e:
|
||||
if e.code == "TOOL_NOT_FOUND":
|
||||
unpublished.append(dep)
|
||||
else:
|
||||
# Network/auth error - skip this check, don't assume unpublished
|
||||
skipped.append(dep)
|
||||
except Exception:
|
||||
skipped.append(dep)
|
||||
|
||||
# Topological sort (excluding cyclic nodes)
|
||||
def topo_sort(names):
|
||||
result = []
|
||||
perm_visited = set()
|
||||
|
||||
def visit(n):
|
||||
if n in perm_visited or n in cyclic_nodes:
|
||||
return # Skip cyclic nodes
|
||||
perm_visited.add(n)
|
||||
for child in dep_graph.get(n, []):
|
||||
if child not in perm_visited:
|
||||
visit(child)
|
||||
result.append(n)
|
||||
|
||||
for n in names:
|
||||
if n not in perm_visited:
|
||||
visit(n)
|
||||
return result
|
||||
|
||||
# Filter out cyclic nodes from unpublished
|
||||
unpublished_safe = [u for u in unpublished if u not in cyclic_nodes]
|
||||
|
||||
# Order: unpublished deps first, then original tools
|
||||
all_to_publish = unpublished_safe + list(tool_names)
|
||||
ordered = topo_sort(all_to_publish)
|
||||
|
||||
return DepCheckResult(
|
||||
unpublished=unpublished_safe, # Excludes cyclic
|
||||
publish_order=ordered,
|
||||
cycles=cycles,
|
||||
skipped=skipped
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""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, mode=0o700)
|
||||
try:
|
||||
CONFIG_DIR.chmod(0o700)
|
||||
except OSError:
|
||||
pass
|
||||
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))
|
||||
try:
|
||||
config_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
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,547 @@
|
||||
"""Dependency graph for transitive dependency resolution.
|
||||
|
||||
Builds a complete dependency graph with:
|
||||
- Transitive closure (A -> B -> C)
|
||||
- Cycle detection
|
||||
- Version conflict detection with provenance
|
||||
- Topological sorting for install order
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from .tool import TOOLS_DIR
|
||||
from .manifest import Dependency
|
||||
from .version import matches_constraint
|
||||
|
||||
|
||||
# Default owner for unqualified tool references
|
||||
DEFAULT_OWNER = "official"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyNode:
|
||||
"""A node in the dependency graph."""
|
||||
owner: str # e.g., "official" (resolved), "" if unresolved
|
||||
name: str # e.g., "summarize"
|
||||
version_constraint: Optional[str] # e.g., "^1.0.0", "*", None
|
||||
resolved_version: Optional[str] # e.g., "1.2.3" (after resolution)
|
||||
source: Optional[str] # "local", "global", "registry", None (unresolved)
|
||||
path: Optional[Path] # Path to installed tool (if installed)
|
||||
children: List[str] = field(default_factory=list) # Qualified names of dependencies
|
||||
requested_by: str = "root" # Parent that introduced this dep
|
||||
|
||||
@property
|
||||
def qualified_name(self) -> str:
|
||||
"""Consistent key for graph lookups."""
|
||||
return f"{self.owner}/{self.name}" if self.owner else self.name
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
"""Check if this node has been resolved to a source."""
|
||||
return self.source is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyGraph:
|
||||
"""Complete dependency graph with transitive closure."""
|
||||
root_dependencies: List[str] = field(default_factory=list) # Direct deps (qualified names)
|
||||
nodes: Dict[str, DependencyNode] = field(default_factory=dict) # qualified_name -> node
|
||||
resolution_order: List[str] = field(default_factory=list) # Topological sort (install order)
|
||||
conflicts: List[Tuple[str, str, str, str, str]] = field(default_factory=list) # (tool, c1, from1, c2, from2)
|
||||
cycles: List[List[str]] = field(default_factory=list) # Detected cycles
|
||||
|
||||
def add_node(self, node: DependencyNode, requested_by: str = "root") -> None:
|
||||
"""Add or update a node. Detects version conflicts with provenance."""
|
||||
key = node.qualified_name
|
||||
node.requested_by = requested_by
|
||||
|
||||
if key in self.nodes:
|
||||
existing = self.nodes[key]
|
||||
# Check for version conflict
|
||||
if (existing.version_constraint and node.version_constraint and
|
||||
existing.version_constraint != node.version_constraint):
|
||||
self.conflicts.append((
|
||||
key,
|
||||
existing.version_constraint,
|
||||
existing.requested_by,
|
||||
node.version_constraint,
|
||||
requested_by
|
||||
))
|
||||
self.nodes[key] = node
|
||||
|
||||
def get_node(self, qualified_name: str) -> Optional[DependencyNode]:
|
||||
"""Get a node by qualified name."""
|
||||
return self.nodes.get(qualified_name)
|
||||
|
||||
def get_install_order(self) -> List[DependencyNode]:
|
||||
"""Return nodes in installation order (dependencies first)."""
|
||||
return [self.nodes[name] for name in self.resolution_order if name in self.nodes]
|
||||
|
||||
def has_conflicts(self) -> bool:
|
||||
"""Check if any version conflicts were detected."""
|
||||
return len(self.conflicts) > 0
|
||||
|
||||
def has_cycles(self) -> bool:
|
||||
"""Check if any circular dependencies were detected."""
|
||||
return len(self.cycles) > 0
|
||||
|
||||
def get_unresolved(self) -> List[DependencyNode]:
|
||||
"""Get all unresolved nodes."""
|
||||
return [node for node in self.nodes.values() if not node.is_resolved]
|
||||
|
||||
def get_registry_nodes(self) -> List[DependencyNode]:
|
||||
"""Get nodes that need to be installed from registry."""
|
||||
return [
|
||||
node for node in self.nodes.values()
|
||||
if node.source == "registry" and node.path is None
|
||||
]
|
||||
|
||||
|
||||
class DependencyGraphBuilder:
|
||||
"""Builds a complete dependency graph with transitive resolution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolver: Optional["ToolResolver"] = None,
|
||||
client: Optional["RegistryClient"] = None,
|
||||
max_depth: int = 50,
|
||||
verbose: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize the graph builder.
|
||||
|
||||
Args:
|
||||
resolver: ToolResolver for local/global lookups (created if not provided)
|
||||
client: RegistryClient for registry lookups (created if not provided)
|
||||
max_depth: Maximum dependency depth (prevents infinite recursion)
|
||||
verbose: Print debug info
|
||||
"""
|
||||
self.resolver = resolver
|
||||
self.client = client
|
||||
self.max_depth = max_depth
|
||||
self.verbose = verbose
|
||||
self._visited: Set[str] = set() # qualified names
|
||||
self._stack: List[str] = [] # For cycle detection
|
||||
self._registry_cache: Dict[str, "DownloadResult"] = {} # cache_key -> DownloadResult
|
||||
self._resolved_count = 0
|
||||
self.include_implicit = True
|
||||
|
||||
def _get_resolver(self) -> "ToolResolver":
|
||||
"""Get or create the tool resolver."""
|
||||
if self.resolver is None:
|
||||
from .resolver import ToolResolver
|
||||
self.resolver = ToolResolver(auto_fetch=False)
|
||||
return self.resolver
|
||||
|
||||
def _get_client(self) -> "RegistryClient":
|
||||
"""Get or create the registry client."""
|
||||
if self.client is None:
|
||||
from .registry_client import get_client
|
||||
self.client = get_client()
|
||||
return self.client
|
||||
|
||||
def build(
|
||||
self,
|
||||
dependencies: List[Dependency],
|
||||
include_implicit: bool = True
|
||||
) -> DependencyGraph:
|
||||
"""
|
||||
Build complete dependency graph.
|
||||
|
||||
Args:
|
||||
dependencies: Direct dependencies to resolve
|
||||
include_implicit: Whether to include ToolStep dependencies
|
||||
|
||||
Returns:
|
||||
DependencyGraph with all transitive dependencies
|
||||
"""
|
||||
graph = DependencyGraph()
|
||||
self.include_implicit = include_implicit
|
||||
|
||||
# Reset state for new build
|
||||
self._visited.clear()
|
||||
self._stack.clear()
|
||||
self._resolved_count = 0
|
||||
|
||||
# Track original names to resolve qualified names after
|
||||
original_names = []
|
||||
for dep in dependencies:
|
||||
original_names.append(dep.name)
|
||||
self._resolve_recursive(dep.name, dep.version, graph, depth=0)
|
||||
|
||||
# Update root_dependencies with qualified names from resolved nodes
|
||||
for orig_name in original_names:
|
||||
# Find the resolved node - check both qualified and unqualified
|
||||
node = graph.get_node(orig_name)
|
||||
if node:
|
||||
graph.root_dependencies.append(node.qualified_name)
|
||||
else:
|
||||
# Try to find by matching tool name across all nodes
|
||||
found = False
|
||||
for qname, n in graph.nodes.items():
|
||||
if n.name == orig_name or qname == orig_name:
|
||||
graph.root_dependencies.append(n.qualified_name)
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
# Unresolved - keep original name for error reporting
|
||||
graph.root_dependencies.append(orig_name)
|
||||
|
||||
graph.resolution_order = self._topological_sort(graph)
|
||||
return graph
|
||||
|
||||
def _qualify_name(self, name: str, resolved_owner: Optional[str] = None) -> str:
|
||||
"""Return qualified name after resolution (local-first)."""
|
||||
if "/" in name:
|
||||
return name
|
||||
owner = resolved_owner or DEFAULT_OWNER
|
||||
return f"{owner}/{name}"
|
||||
|
||||
def _parse_qualified(self, qualified: str) -> Tuple[Optional[str], str]:
|
||||
"""Split qualified name into owner and name (owner may be None)."""
|
||||
if "/" in qualified:
|
||||
parts = qualified.split("/", 1)
|
||||
return (parts[0], parts[1])
|
||||
return (None, qualified)
|
||||
|
||||
def _resolve_recursive(
|
||||
self,
|
||||
qualified_name: str,
|
||||
version_constraint: Optional[str],
|
||||
graph: DependencyGraph,
|
||||
depth: int
|
||||
) -> Optional[DependencyNode]:
|
||||
"""Recursively resolve a dependency and its children."""
|
||||
|
||||
# Cycle detection
|
||||
if qualified_name in self._stack:
|
||||
cycle = self._stack[self._stack.index(qualified_name):] + [qualified_name]
|
||||
graph.cycles.append(cycle)
|
||||
if self.verbose:
|
||||
print(f" Cycle detected: {' -> '.join(cycle)}")
|
||||
return None
|
||||
|
||||
# Already resolved - check for version conflicts
|
||||
if qualified_name in self._visited:
|
||||
existing = graph.get_node(qualified_name)
|
||||
if existing and version_constraint and existing.version_constraint != version_constraint:
|
||||
graph.conflicts.append((
|
||||
qualified_name,
|
||||
existing.version_constraint or "*",
|
||||
existing.requested_by or "unknown",
|
||||
version_constraint or "*",
|
||||
self._stack[-1] if self._stack else "unknown"
|
||||
))
|
||||
return existing
|
||||
|
||||
# Depth limit
|
||||
if depth > self.max_depth:
|
||||
raise RecursionError(f"Dependency depth exceeded {self.max_depth}")
|
||||
|
||||
self._visited.add(qualified_name)
|
||||
self._stack.append(qualified_name)
|
||||
|
||||
try:
|
||||
# Resolve the tool
|
||||
node = self._resolve_single(qualified_name, version_constraint)
|
||||
if node is None:
|
||||
return None
|
||||
|
||||
# Also track the resolved qualified name to prevent duplicates
|
||||
if node.qualified_name != qualified_name:
|
||||
self._visited.add(node.qualified_name)
|
||||
|
||||
graph.add_node(node, requested_by=self._stack[-2] if len(self._stack) > 1 else "root")
|
||||
self._resolved_count += 1
|
||||
if self.verbose:
|
||||
print(f" Resolved {self._resolved_count}: {node.qualified_name}")
|
||||
|
||||
# Get child dependencies (only if this node is resolved)
|
||||
if node.is_resolved:
|
||||
child_deps = self._get_tool_dependencies(node)
|
||||
else:
|
||||
child_deps = []
|
||||
|
||||
# Recurse into children and capture resolved keys
|
||||
node.children = []
|
||||
for child in child_deps:
|
||||
child_node = self._resolve_recursive(child.name, child.version, graph, depth + 1)
|
||||
if child_node and child_node.is_resolved:
|
||||
node.children.append(child_node.qualified_name)
|
||||
elif child_node:
|
||||
# Child exists but is unresolved - still track for error reporting
|
||||
node.children.append(child_node.qualified_name)
|
||||
if self.verbose:
|
||||
print(f" Warning: Unresolved dependency {child.name}")
|
||||
|
||||
return node
|
||||
|
||||
finally:
|
||||
self._stack.pop()
|
||||
|
||||
def _resolve_single(
|
||||
self,
|
||||
qualified_name: str,
|
||||
version_constraint: Optional[str]
|
||||
) -> Optional[DependencyNode]:
|
||||
"""Resolve a single dependency to a node."""
|
||||
owner, name = self._parse_qualified(qualified_name)
|
||||
owner_hint = None
|
||||
|
||||
# Try local/global first via resolver
|
||||
try:
|
||||
from .resolver import ToolNotFoundError
|
||||
resolver = self._get_resolver()
|
||||
resolved = resolver.resolve(qualified_name)
|
||||
owner_hint = resolved.owner
|
||||
if version_constraint and version_constraint not in ("", "*", "latest"):
|
||||
resolved_version = resolved.version or ""
|
||||
if not resolved_version or not matches_constraint(resolved_version, version_constraint):
|
||||
if self.verbose:
|
||||
print(
|
||||
f" Local/global {qualified_name}@{resolved_version or 'unknown'} "
|
||||
f"does not satisfy {version_constraint}, resolving from registry"
|
||||
)
|
||||
else:
|
||||
return DependencyNode(
|
||||
owner=resolved.owner or owner or "",
|
||||
name=resolved.tool.name,
|
||||
version_constraint=version_constraint,
|
||||
resolved_version=resolved.version,
|
||||
source=resolved.source,
|
||||
path=resolved.path,
|
||||
children=[]
|
||||
)
|
||||
else:
|
||||
return DependencyNode(
|
||||
owner=resolved.owner or owner or "",
|
||||
name=resolved.tool.name,
|
||||
version_constraint=version_constraint,
|
||||
resolved_version=resolved.version,
|
||||
source=resolved.source,
|
||||
path=resolved.path,
|
||||
children=[]
|
||||
)
|
||||
except ToolNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Resolver error for {qualified_name}: {e}")
|
||||
|
||||
# Try registry
|
||||
try:
|
||||
reg_owner = owner or owner_hint or DEFAULT_OWNER
|
||||
result = self._download_registry_config(
|
||||
reg_owner,
|
||||
name,
|
||||
version_constraint
|
||||
)
|
||||
return DependencyNode(
|
||||
owner=reg_owner,
|
||||
name=name,
|
||||
version_constraint=version_constraint,
|
||||
resolved_version=result.resolved_version or "",
|
||||
source="registry",
|
||||
path=None, # Not installed yet
|
||||
children=[]
|
||||
)
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Could not resolve {qualified_name}: {e}")
|
||||
# Record unresolved node so graph reflects missing dependency
|
||||
return DependencyNode(
|
||||
owner=owner or owner_hint or "",
|
||||
name=name,
|
||||
version_constraint=version_constraint,
|
||||
resolved_version=None,
|
||||
source=None,
|
||||
path=None,
|
||||
children=[]
|
||||
)
|
||||
|
||||
def _get_tool_dependencies(self, node: DependencyNode) -> List[Dependency]:
|
||||
"""Get dependencies for a resolved tool node."""
|
||||
deps = []
|
||||
|
||||
if node.source in ("local", "global") and node.path:
|
||||
# Read from local config file
|
||||
deps.extend(self._read_deps_from_config(node.path))
|
||||
elif node.source == "registry":
|
||||
# Fetch config from registry without installing
|
||||
deps.extend(self._read_deps_from_registry(node.owner, node.name, node.resolved_version or node.version_constraint))
|
||||
|
||||
return deps
|
||||
|
||||
def _read_deps_from_config(self, tool_path: Path) -> List[Dependency]:
|
||||
"""Read dependencies from a local tool config."""
|
||||
deps = []
|
||||
config_path = tool_path / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return deps
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text()) or {}
|
||||
|
||||
# Explicit dependencies
|
||||
for dep in data.get("dependencies", []):
|
||||
deps.append(Dependency.from_dict(dep))
|
||||
|
||||
# Implicit dependencies from ToolSteps
|
||||
if self.include_implicit:
|
||||
for step in data.get("steps", []):
|
||||
if step.get("type") == "tool":
|
||||
tool_ref = step.get("tool")
|
||||
if tool_ref:
|
||||
deps.append(Dependency.from_dict(tool_ref))
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Could not read deps from {config_path}: {e}")
|
||||
|
||||
return deps
|
||||
|
||||
def _read_deps_from_registry(self, owner: str, name: str, version: Optional[str]) -> List[Dependency]:
|
||||
"""Read dependencies from registry tool config."""
|
||||
deps = []
|
||||
|
||||
try:
|
||||
# Download config without installing (cache by qualified name + version/constraint)
|
||||
result = self._download_registry_config(owner, name, version)
|
||||
config_yaml = result.config_yaml
|
||||
|
||||
data = yaml.safe_load(config_yaml) or {}
|
||||
|
||||
# Explicit dependencies
|
||||
for dep in data.get("dependencies", []):
|
||||
deps.append(Dependency.from_dict(dep))
|
||||
|
||||
# Implicit dependencies from ToolSteps
|
||||
if self.include_implicit:
|
||||
for step in data.get("steps", []):
|
||||
if step.get("type") == "tool":
|
||||
tool_ref = step.get("tool")
|
||||
if tool_ref:
|
||||
deps.append(Dependency.from_dict(tool_ref))
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Could not read deps from registry {owner}/{name}: {e}")
|
||||
|
||||
return deps
|
||||
|
||||
def _download_registry_config(
|
||||
self,
|
||||
owner: str,
|
||||
name: str,
|
||||
version: Optional[str]
|
||||
) -> "DownloadResult":
|
||||
"""Download registry config with caching (version or constraint aware)."""
|
||||
cache_key = f"{owner}/{name}@{version or ''}"
|
||||
if cache_key in self._registry_cache:
|
||||
return self._registry_cache[cache_key]
|
||||
|
||||
client = self._get_client()
|
||||
result = client.download_tool(owner, name, version=version, install=False)
|
||||
self._registry_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def _topological_sort(self, graph: DependencyGraph) -> List[str]:
|
||||
"""Return nodes in installation order (dependencies first)."""
|
||||
# Kahn's algorithm
|
||||
in_degree = {name: 0 for name in graph.nodes}
|
||||
|
||||
# Build in-degree counts
|
||||
for node in graph.nodes.values():
|
||||
for child in node.children:
|
||||
if child in in_degree:
|
||||
in_degree[child] += 1
|
||||
|
||||
# Start with nodes that have no dependencies
|
||||
queue = [name for name, deg in in_degree.items() if deg == 0]
|
||||
result = []
|
||||
|
||||
while queue:
|
||||
name = queue.pop(0)
|
||||
result.append(name)
|
||||
node = graph.nodes.get(name)
|
||||
if node:
|
||||
for child in node.children:
|
||||
if child in in_degree:
|
||||
in_degree[child] -= 1
|
||||
if in_degree[child] == 0:
|
||||
queue.append(child)
|
||||
|
||||
# In our graph, A.children contains B means "A depends on B".
|
||||
# Kahn's algorithm processes nodes with in_degree=0 first.
|
||||
# Nodes with in_degree=0 are roots (no one depends on them).
|
||||
# So the raw output is: dependents first, then dependencies.
|
||||
# We reverse to get: dependencies first, then dependents (correct install order).
|
||||
return list(reversed(result))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolutionResult:
|
||||
"""Result of dependency resolution."""
|
||||
graph: DependencyGraph
|
||||
to_install: List[DependencyNode] # Tools needing installation
|
||||
already_installed: List[DependencyNode] # Already present
|
||||
failed: List[Tuple[str, str]] # (qualified_name, error_message)
|
||||
warnings: List[str] # Non-fatal issues
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""Check if resolution was successful."""
|
||||
return not self.graph.has_cycles() and not self.failed
|
||||
|
||||
|
||||
def resolve_dependencies(
|
||||
dependencies: List[Dependency],
|
||||
verbose: bool = False,
|
||||
include_implicit: bool = True
|
||||
) -> ResolutionResult:
|
||||
"""
|
||||
Convenience function to resolve dependencies and categorize results.
|
||||
|
||||
Args:
|
||||
dependencies: Direct dependencies to resolve
|
||||
verbose: Print debug info
|
||||
include_implicit: Include ToolStep dependencies
|
||||
|
||||
Returns:
|
||||
ResolutionResult with categorized nodes
|
||||
"""
|
||||
builder = DependencyGraphBuilder(verbose=verbose)
|
||||
graph = builder.build(dependencies, include_implicit=include_implicit)
|
||||
|
||||
to_install = []
|
||||
already_installed = []
|
||||
failed = []
|
||||
warnings = []
|
||||
|
||||
for node in graph.get_install_order():
|
||||
if not node.is_resolved:
|
||||
failed.append((node.qualified_name, "Tool not found"))
|
||||
elif node.source == "registry" and node.path is None:
|
||||
to_install.append(node)
|
||||
else:
|
||||
already_installed.append(node)
|
||||
|
||||
if graph.has_conflicts():
|
||||
for tool, v1, from1, v2, from2 in graph.conflicts:
|
||||
warnings.append(f"Version conflict for {tool}: {v1} (from {from1}) vs {v2} (from {from2})")
|
||||
|
||||
if graph.has_cycles():
|
||||
for cycle in graph.cycles:
|
||||
warnings.append(f"Circular dependency: {' -> '.join(cycle)}")
|
||||
|
||||
return ResolutionResult(
|
||||
graph=graph,
|
||||
to_install=to_install,
|
||||
already_installed=already_installed,
|
||||
failed=failed,
|
||||
warnings=warnings
|
||||
)
|
||||
@@ -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,363 @@
|
||||
"""Help dialogs for CmdForge GUI."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QTextBrowser
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
|
||||
# Help content for quick guides
|
||||
HELP_CONTENT = {
|
||||
"getting_started": {
|
||||
"title": "Getting Started with CmdForge",
|
||||
"content": """
|
||||
<h3>Welcome to CmdForge!</h3>
|
||||
<p>CmdForge lets you create AI-powered command-line tools that work like any Unix command.</p>
|
||||
|
||||
<h4>Quick Start Steps:</h4>
|
||||
<ol>
|
||||
<li><strong>Configure a Provider</strong> - Go to <em>Providers</em> in the sidebar and add an AI backend (like Claude or GPT). This is required before running AI tools.</li>
|
||||
<li><strong>Browse the Registry</strong> - Check out the <em>Registry</em> to discover tools shared by the community.</li>
|
||||
<li><strong>Install Tools</strong> - Click on any tool and hit <em>Install</em> to add it to your system.</li>
|
||||
<li><strong>Create Your Own</strong> - Use the <em>Tool Builder</em> to create custom AI-powered commands.</li>
|
||||
<li><strong>Use in Terminal</strong> - Run your tools like any command: <code>my-tool < input.txt</code></li>
|
||||
</ol>
|
||||
|
||||
<h4>Key Concepts:</h4>
|
||||
<ul>
|
||||
<li><strong>Steps</strong> - Tools are made of steps: Prompt (AI call), Code (Python), or Tool (chain another tool)</li>
|
||||
<li><strong>Variables</strong> - Use <code>{variable}</code> to pass data between steps</li>
|
||||
<li><strong>Arguments</strong> - Add flags like <code>--format</code> that users can set when running the tool</li>
|
||||
</ul>
|
||||
|
||||
<h4>Keyboard Shortcuts:</h4>
|
||||
<table>
|
||||
<tr><td><code>Ctrl+N</code></td><td>Create new tool</td></tr>
|
||||
<tr><td><code>Ctrl+S</code></td><td>Save current tool</td></tr>
|
||||
<tr><td><code>Ctrl+R</code></td><td>Refresh current page</td></tr>
|
||||
<tr><td><code>Ctrl+1-4</code></td><td>Navigate to pages</td></tr>
|
||||
<tr><td><code>Ctrl+Q</code></td><td>Quit</td></tr>
|
||||
<tr><td><code>Escape</code></td><td>Close tool builder</td></tr>
|
||||
</table>
|
||||
"""
|
||||
},
|
||||
"create_tool": {
|
||||
"title": "How to Create a Tool",
|
||||
"content": """
|
||||
<h3>Creating a New Tool</h3>
|
||||
|
||||
<h4>Step 1: Start the Tool Builder</h4>
|
||||
<p>Click <em>Create Tool</em> on the Welcome page, or press <code>Ctrl+N</code> from anywhere.</p>
|
||||
|
||||
<h4>Step 2: Fill in Basic Info</h4>
|
||||
<ul>
|
||||
<li><strong>Name</strong> - Use lowercase letters, numbers, and hyphens (e.g., <code>summarize-text</code>)</li>
|
||||
<li><strong>Description</strong> - Brief explanation shown in listings</li>
|
||||
<li><strong>Category</strong> - Pick from Text, Developer, Data, or add your own</li>
|
||||
</ul>
|
||||
|
||||
<h4>Step 3: Add Arguments (Optional)</h4>
|
||||
<p>Arguments let users customize tool behavior:</p>
|
||||
<ul>
|
||||
<li><strong>Flag</strong> - The command-line flag (e.g., <code>--max</code>)</li>
|
||||
<li><strong>Variable</strong> - Name to use in templates (e.g., <code>max</code>)</li>
|
||||
<li><strong>Default</strong> - Value when flag isn't provided</li>
|
||||
</ul>
|
||||
|
||||
<h4>Step 4: Add Steps</h4>
|
||||
<p>Steps define what your tool does:</p>
|
||||
<ul>
|
||||
<li><strong>Prompt Step</strong> - Calls an AI provider with your template. Use <code>{input}</code> for stdin content.</li>
|
||||
<li><strong>Code Step</strong> - Runs Python code. Variables are available directly (e.g., <code>input</code>, <code>response</code>).</li>
|
||||
<li><strong>Tool Step</strong> - Calls another CmdForge tool, enabling composition.</li>
|
||||
</ul>
|
||||
|
||||
<h4>Step 5: Set Output Template</h4>
|
||||
<p>The output template defines what your tool prints. Use <code>{variable}</code> to include step outputs.</p>
|
||||
<p>Example: <code>{response}</code> prints the last AI response.</p>
|
||||
|
||||
<h4>Step 6: Save</h4>
|
||||
<p>Press <code>Ctrl+S</code> or click <em>Save</em>. Your tool is now available as a command!</p>
|
||||
|
||||
<h4>Example: Simple Summarizer</h4>
|
||||
<pre>
|
||||
Name: summarize
|
||||
Steps:
|
||||
1. Prompt [claude] -> response
|
||||
"Summarize this text: {input}"
|
||||
Output: {response}
|
||||
</pre>
|
||||
<p>Run it: <code>echo "Long text..." | summarize</code></p>
|
||||
"""
|
||||
},
|
||||
"install_tools": {
|
||||
"title": "How to Install Tools",
|
||||
"content": """
|
||||
<h3>Installing Tools from the Registry</h3>
|
||||
|
||||
<h4>Step 1: Open the Registry</h4>
|
||||
<p>Click <em>Registry</em> in the sidebar to browse community tools.</p>
|
||||
|
||||
<h4>Step 2: Find a Tool</h4>
|
||||
<ul>
|
||||
<li><strong>Search</strong> - Type keywords in the search box</li>
|
||||
<li><strong>Filter by Category</strong> - Use the dropdown to narrow results</li>
|
||||
<li><strong>Sort</strong> - Order by popularity, newest, or name</li>
|
||||
<li><strong>Tags</strong> - Click tags on tools to filter by tag</li>
|
||||
</ul>
|
||||
|
||||
<h4>Step 3: Review Tool Details</h4>
|
||||
<p>Click on a tool to see:</p>
|
||||
<ul>
|
||||
<li>Description and usage information</li>
|
||||
<li>Rating and download count</li>
|
||||
<li>Publisher reputation</li>
|
||||
<li>Available versions</li>
|
||||
</ul>
|
||||
|
||||
<h4>Step 4: Install</h4>
|
||||
<ul>
|
||||
<li>Select a version (or use <em>Latest</em>)</li>
|
||||
<li>Click <em>Install</em></li>
|
||||
<li>Wait for confirmation</li>
|
||||
</ul>
|
||||
|
||||
<h4>Step 5: Use the Tool</h4>
|
||||
<p>Installed tools are available immediately in your terminal:</p>
|
||||
<pre>
|
||||
echo "text" | tool-name
|
||||
cat file.txt | tool-name --flag value
|
||||
tool-name --help
|
||||
</pre>
|
||||
|
||||
<h4>Updates</h4>
|
||||
<p>Tools with available updates show a green arrow (<span style="color: #48bb78;">↑</span>) in the list. Click <em>Update</em> to get the latest version.</p>
|
||||
|
||||
<h4>Managing Installed Tools</h4>
|
||||
<p>Go to <em>Tools</em> in the sidebar to see all your installed tools, edit them, or delete ones you no longer need.</p>
|
||||
"""
|
||||
},
|
||||
"publish_tools": {
|
||||
"title": "How to Publish Tools",
|
||||
"content": """
|
||||
<h3>Publishing Your Tools</h3>
|
||||
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>A tool you've created and tested</li>
|
||||
<li>A registry account (sign up at cmdforge.brrd.tech)</li>
|
||||
</ul>
|
||||
|
||||
<h4>Step 1: Connect Your Account</h4>
|
||||
<ol>
|
||||
<li>Go to the <em>Providers</em> page</li>
|
||||
<li>Click <em>Connect to Registry</em></li>
|
||||
<li>Log in or register</li>
|
||||
</ol>
|
||||
|
||||
<h4>Step 2: Prepare Your Tool</h4>
|
||||
<ul>
|
||||
<li>Give it a clear, descriptive name</li>
|
||||
<li>Write a helpful description</li>
|
||||
<li>Choose the right category</li>
|
||||
<li>Test it thoroughly</li>
|
||||
</ul>
|
||||
|
||||
<h4>Step 3: Publish</h4>
|
||||
<ol>
|
||||
<li>Go to the <em>Tools</em> page</li>
|
||||
<li>Select the tool you want to publish</li>
|
||||
<li>Click <em>Publish</em></li>
|
||||
<li>Add tags to help users find it</li>
|
||||
<li>Set the initial version number</li>
|
||||
<li>Click <em>Submit</em></li>
|
||||
</ol>
|
||||
|
||||
<h4>Versioning</h4>
|
||||
<p>When you update a published tool:</p>
|
||||
<ul>
|
||||
<li><strong>Patch</strong> (1.0.0 → 1.0.1) - Bug fixes</li>
|
||||
<li><strong>Minor</strong> (1.0.0 → 1.1.0) - New features, backward compatible</li>
|
||||
<li><strong>Major</strong> (1.0.0 → 2.0.0) - Breaking changes</li>
|
||||
</ul>
|
||||
|
||||
<h4>Best Practices</h4>
|
||||
<ul>
|
||||
<li>Write clear descriptions and examples</li>
|
||||
<li>Use semantic versioning</li>
|
||||
<li>Test before publishing updates</li>
|
||||
<li>Respond to user feedback</li>
|
||||
</ul>
|
||||
"""
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"content": """
|
||||
<h3>Keyboard Shortcuts</h3>
|
||||
|
||||
<h4>Global</h4>
|
||||
<table style="width: 100%;">
|
||||
<tr><td style="width: 40%;"><code>Ctrl+N</code></td><td>Create new tool</td></tr>
|
||||
<tr><td><code>Ctrl+S</code></td><td>Save current tool</td></tr>
|
||||
<tr><td><code>Ctrl+R</code></td><td>Refresh current page</td></tr>
|
||||
<tr><td><code>Ctrl+Q</code></td><td>Quit application</td></tr>
|
||||
<tr><td><code>F1</code></td><td>Show help</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Navigation</h4>
|
||||
<table style="width: 100%;">
|
||||
<tr><td style="width: 40%;"><code>Ctrl+1</code></td><td>Go to Welcome page</td></tr>
|
||||
<tr><td><code>Ctrl+2</code></td><td>Go to Tools page</td></tr>
|
||||
<tr><td><code>Ctrl+3</code></td><td>Go to Registry page</td></tr>
|
||||
<tr><td><code>Ctrl+4</code></td><td>Go to Providers page</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Tool Builder</h4>
|
||||
<table style="width: 100%;">
|
||||
<tr><td style="width: 40%;"><code>Escape</code></td><td>Close builder / cancel</td></tr>
|
||||
<tr><td><code>Ctrl+S</code></td><td>Save tool</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Lists and Tables</h4>
|
||||
<table style="width: 100%;">
|
||||
<tr><td style="width: 40%;"><code>Double-click</code></td><td>Edit selected item</td></tr>
|
||||
<tr><td><code>Drag & Drop</code></td><td>Reorder steps</td></tr>
|
||||
</table>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class QuickGuideDialog(QDialog):
|
||||
"""Dialog showing a quick guide on a topic."""
|
||||
|
||||
def __init__(self, guide_key: str, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
guide = HELP_CONTENT.get(guide_key, {})
|
||||
self.setWindowTitle(guide.get("title", "Help"))
|
||||
self.setMinimumSize(550, 450)
|
||||
self.resize(600, 500)
|
||||
self.setModal(True)
|
||||
|
||||
self._setup_ui(guide)
|
||||
|
||||
def _setup_ui(self, guide: dict):
|
||||
"""Set up the dialog UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Content browser with styled HTML
|
||||
browser = QTextBrowser()
|
||||
browser.setOpenExternalLinks(True)
|
||||
browser.setStyleSheet("""
|
||||
QTextBrowser {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
""")
|
||||
|
||||
# Build styled content
|
||||
content = f"""
|
||||
<style>
|
||||
h3 {{ color: #2d3748; margin-bottom: 8px; }}
|
||||
h4 {{ color: #4a5568; margin-top: 16px; margin-bottom: 8px; }}
|
||||
p {{ color: #4a5568; margin-bottom: 8px; line-height: 1.5; }}
|
||||
ul, ol {{ color: #4a5568; margin-bottom: 8px; }}
|
||||
li {{ margin-bottom: 4px; }}
|
||||
code {{ background: #edf2f7; padding: 2px 6px; border-radius: 3px; font-family: monospace; }}
|
||||
pre {{ background: #edf2f7; padding: 12px; border-radius: 6px; font-family: monospace; overflow-x: auto; }}
|
||||
table {{ border-collapse: collapse; margin: 8px 0; }}
|
||||
td {{ padding: 6px 12px; }}
|
||||
em {{ color: #667eea; }}
|
||||
</style>
|
||||
{guide.get("content", "")}
|
||||
"""
|
||||
browser.setHtml(content)
|
||||
layout.addWidget(browser, 1)
|
||||
|
||||
# Close button
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
btn_close = QPushButton("Close")
|
||||
btn_close.setMinimumWidth(100)
|
||||
btn_close.clicked.connect(self.accept)
|
||||
btn_layout.addWidget(btn_close)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
|
||||
class AboutDialog(QDialog):
|
||||
"""About CmdForge dialog."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("About CmdForge")
|
||||
self.setFixedSize(400, 300)
|
||||
self.setModal(True)
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the dialog UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(32, 32, 32, 32)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Logo/title
|
||||
title = QLabel("CmdForge")
|
||||
title.setStyleSheet("""
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
""")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# Tagline
|
||||
tagline = QLabel("AI-Powered CLI Tool Builder")
|
||||
tagline.setStyleSheet("font-size: 14px; color: #718096;")
|
||||
tagline.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(tagline)
|
||||
|
||||
layout.addSpacing(16)
|
||||
|
||||
# Version info
|
||||
try:
|
||||
from cmdforge import __version__
|
||||
version = __version__
|
||||
except ImportError:
|
||||
version = "unknown"
|
||||
|
||||
version_label = QLabel(f"Version {version}")
|
||||
version_label.setStyleSheet("color: #4a5568;")
|
||||
version_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(version_label)
|
||||
|
||||
# Description
|
||||
desc = QLabel(
|
||||
"Create custom terminal commands that call AI providers,\n"
|
||||
"chain prompts with Python code, and use them like\n"
|
||||
"any Unix pipe command."
|
||||
)
|
||||
desc.setStyleSheet("color: #718096; font-size: 12px;")
|
||||
desc.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(desc)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Links
|
||||
links = QLabel(
|
||||
'<a href="https://cmdforge.brrd.tech" style="color: #667eea;">Website</a>'
|
||||
)
|
||||
links.setOpenExternalLinks(True)
|
||||
links.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(links)
|
||||
|
||||
# Close button
|
||||
btn_close = QPushButton("Close")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
layout.addWidget(btn_close)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Issue report dialog for registry tools."""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QLineEdit, QPlainTextEdit, QComboBox,
|
||||
QFormLayout, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
|
||||
# Maps display labels to API values
|
||||
ISSUE_TYPES = [
|
||||
("Bug", "bug"),
|
||||
("Compatibility Issue", "compatibility"),
|
||||
("Security Issue", "security"),
|
||||
]
|
||||
|
||||
SEVERITY_LEVELS = [
|
||||
("Low", "low"),
|
||||
("Medium", "medium"),
|
||||
("High", "high"),
|
||||
("Critical", "critical"),
|
||||
]
|
||||
|
||||
|
||||
class IssueDialog(QDialog):
|
||||
"""Dialog for reporting an issue on a registry tool."""
|
||||
|
||||
def __init__(self, parent, owner: str, name: str):
|
||||
super().__init__(parent)
|
||||
self._owner = owner
|
||||
self._name = name
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
self.setWindowTitle(f"Report Issue - {self._owner}/{self._name}")
|
||||
self.setMinimumSize(450, 400)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(14)
|
||||
|
||||
# Title
|
||||
title = QLabel(f"Report an issue with {self._owner}/{self._name}")
|
||||
title.setStyleSheet("font-size: 15px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(10)
|
||||
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.setMinimumHeight(28)
|
||||
for label, _ in ISSUE_TYPES:
|
||||
self.type_combo.addItem(label)
|
||||
form.addRow("Issue type:", self.type_combo)
|
||||
|
||||
self.severity_combo = QComboBox()
|
||||
self.severity_combo.setMinimumHeight(28)
|
||||
for label, _ in SEVERITY_LEVELS:
|
||||
self.severity_combo.addItem(label)
|
||||
self.severity_combo.setCurrentIndex(1) # Default: Medium
|
||||
form.addRow("Severity:", self.severity_combo)
|
||||
|
||||
self.title_input = QLineEdit()
|
||||
self.title_input.setMaxLength(200)
|
||||
self.title_input.setMinimumHeight(28)
|
||||
self.title_input.setPlaceholderText("Brief description of the issue")
|
||||
form.addRow("Title:", self.title_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Description
|
||||
desc_label = QLabel("Description (optional):")
|
||||
desc_label.setStyleSheet("font-weight: 500;")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
self.desc_input = QPlainTextEdit()
|
||||
self.desc_input.setPlaceholderText("Provide details about the issue, steps to reproduce, etc.")
|
||||
self.desc_input.setMinimumHeight(120)
|
||||
self.desc_input.setMaximumHeight(200)
|
||||
layout.addWidget(self.desc_input)
|
||||
|
||||
# Character count
|
||||
self._char_count = QLabel("0 / 5000")
|
||||
self._char_count.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
self._char_count.setAlignment(Qt.AlignRight)
|
||||
self.desc_input.textChanged.connect(self._update_char_count)
|
||||
layout.addWidget(self._char_count)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
btn_cancel = QPushButton("Cancel")
|
||||
btn_cancel.setObjectName("secondary")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(btn_cancel)
|
||||
|
||||
self.btn_submit = QPushButton("Submit")
|
||||
self.btn_submit.clicked.connect(self._on_submit)
|
||||
btn_layout.addWidget(self.btn_submit)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _update_char_count(self):
|
||||
count = len(self.desc_input.toPlainText())
|
||||
self._char_count.setText(f"{count} / 5000")
|
||||
if count > 5000:
|
||||
self._char_count.setStyleSheet("color: #e53e3e; font-size: 11px;")
|
||||
else:
|
||||
self._char_count.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
|
||||
def _on_submit(self):
|
||||
title = self.title_input.text().strip()
|
||||
if not title:
|
||||
QMessageBox.warning(self, "Validation", "Title is required.")
|
||||
return
|
||||
|
||||
desc = self.desc_input.toPlainText().strip()
|
||||
if len(desc) > 5000:
|
||||
QMessageBox.warning(self, "Validation", "Description must be 5000 characters or less.")
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_issue_data(self) -> Dict:
|
||||
"""Get the issue data entered by the user."""
|
||||
type_idx = self.type_combo.currentIndex()
|
||||
severity_idx = self.severity_combo.currentIndex()
|
||||
return {
|
||||
"issue_type": ISSUE_TYPES[type_idx][1],
|
||||
"severity": SEVERITY_LEVELS[severity_idx][1],
|
||||
"title": self.title_input.text().strip(),
|
||||
"description": self.desc_input.toPlainText().strip()[:5000],
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Provider editor dialog."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
||||
QPushButton, QHBoxLayout, QLabel, QComboBox
|
||||
)
|
||||
|
||||
from ...providers import Provider, add_provider, load_providers
|
||||
|
||||
|
||||
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._provider = provider
|
||||
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)
|
||||
|
||||
self.fallback_combo = QComboBox()
|
||||
self.fallback_combo.addItem("(none)", None)
|
||||
# Populate with available providers
|
||||
for p in sorted(load_providers(), key=lambda x: x.name):
|
||||
self.fallback_combo.addItem(p.name, p.name)
|
||||
form.addRow("Fallback:", self.fallback_combo)
|
||||
|
||||
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\n"
|
||||
"Fallback: Provider to use if this one fails (timeout, error, offline)"
|
||||
)
|
||||
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 "")
|
||||
# Set fallback selection
|
||||
if provider.fallback:
|
||||
idx = self.fallback_combo.findData(provider.fallback)
|
||||
if idx >= 0:
|
||||
self.fallback_combo.setCurrentIndex(idx)
|
||||
|
||||
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()
|
||||
fallback = self.fallback_combo.currentData()
|
||||
|
||||
# Prevent self-referential fallback
|
||||
if fallback == name:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
QMessageBox.warning(self, "Invalid", "A provider cannot fall back to itself.")
|
||||
return
|
||||
|
||||
try:
|
||||
existing = self._provider
|
||||
add_provider(Provider(
|
||||
name=name,
|
||||
command=command,
|
||||
description=description,
|
||||
fallback=fallback,
|
||||
type=existing.type if existing else "subprocess",
|
||||
model=existing.model if existing else None,
|
||||
tags=existing.tags if existing else [],
|
||||
install=existing.install if existing else None,
|
||||
fallback_chain=existing.fallback_chain if existing else None,
|
||||
api_key_env=existing.api_key_env if existing else None,
|
||||
pty_config=existing.pty_config if existing else None,
|
||||
))
|
||||
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,499 @@
|
||||
"""Provider installation wizard dialog."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QListWidget, QListWidgetItem, QTextEdit, QProgressBar,
|
||||
QMessageBox, QGroupBox, QWidget, QStackedWidget
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QProcess
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
from ...providers import load_providers, add_provider, Provider
|
||||
|
||||
|
||||
# Provider installation info (shared with CLI)
|
||||
PROVIDER_INSTALL_INFO = {
|
||||
"claude": {
|
||||
"group": "Anthropic Claude",
|
||||
"install_cmd": "curl -fsSL https://claude.ai/install.sh | bash",
|
||||
"requires": "curl and bash",
|
||||
"setup": "Run 'claude' to sign in with a subscription or API key.",
|
||||
"cost": "Paid subscription or API key",
|
||||
"variants": [
|
||||
("claude", "claude -p", "Claude (default model)"),
|
||||
("claude-haiku", "claude -p --model haiku", "Claude Haiku (fast)"),
|
||||
("claude-sonnet", "claude -p --model sonnet", "Claude Sonnet (balanced)"),
|
||||
("claude-opus", "claude -p --model opus", "Claude Opus (highest quality)"),
|
||||
],
|
||||
},
|
||||
"codex": {
|
||||
"group": "OpenAI Codex",
|
||||
"install_cmd": "npm install -g @openai/codex",
|
||||
"requires": "Node.js 18+ and npm",
|
||||
"setup": "Run 'codex' to sign in.",
|
||||
"cost": "Free tier or ChatGPT subscription, subject to account limits",
|
||||
"variants": [
|
||||
("codex", "codex exec -", "OpenAI Codex"),
|
||||
],
|
||||
},
|
||||
"agy": {
|
||||
"group": "Google Antigravity",
|
||||
"install_cmd": None,
|
||||
"requires": "Google Antigravity desktop application",
|
||||
"setup": "Install from https://antigravity.google/download, then run 'agy install' to configure your PATH.",
|
||||
"cost": "Free tier available, subject to account limits",
|
||||
"variants": [
|
||||
("agy", "agy -p", "Google Antigravity (automatic model selection)"),
|
||||
],
|
||||
},
|
||||
"opencode": {
|
||||
"group": "OpenCode",
|
||||
"install_cmd": "curl -fsSL https://opencode.ai/install | bash",
|
||||
"requires": "curl, bash",
|
||||
"setup": "Run 'opencode' and use /connect to configure providers.",
|
||||
"cost": "Free models available; paid providers require credentials",
|
||||
"variants": [
|
||||
("opencode-pickle", "opencode run --model opencode/big-pickle", "Big Pickle (free)"),
|
||||
("opencode-free", "opencode run --model opencode/deepseek-v4-flash-free", "DeepSeek V4 Flash (free)"),
|
||||
("opencode-deepseek", "opencode run --model deepseek/deepseek-chat", "DeepSeek Chat (API key required)"),
|
||||
("opencode-reasoner", "opencode run --model deepseek/deepseek-reasoner", "DeepSeek Reasoner (API key required)"),
|
||||
],
|
||||
},
|
||||
"crush": {
|
||||
"group": "Crush (Charm)",
|
||||
"install_cmd": "npm install -g @charmland/crush",
|
||||
"requires": "Node.js 18+ and npm",
|
||||
"setup": "Run 'crush' to configure Hyper or your provider API keys.",
|
||||
"cost": "Limited Hyper credits or your own provider billing",
|
||||
"variants": [
|
||||
("crush", "crush run --quiet", "Crush multi-model agent"),
|
||||
],
|
||||
},
|
||||
"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.2' to download a model.",
|
||||
"cost": "FREE (runs entirely on your machine)",
|
||||
"variants": [
|
||||
("ollama-llama3", "ollama run llama3.2", "Llama 3.2 (general purpose)"),
|
||||
("ollama-codellama", "ollama run codellama", "CodeLlama (coding)"),
|
||||
("ollama-mistral", "ollama run mistral", "Mistral (fast)"),
|
||||
],
|
||||
"custom": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class InstallWorker(QThread):
|
||||
"""Background worker for running installation commands."""
|
||||
output = Signal(str)
|
||||
finished = Signal(bool, str)
|
||||
|
||||
def __init__(self, command: str):
|
||||
super().__init__()
|
||||
self.command = command
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
self.command,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
for line in process.stdout:
|
||||
self.output.emit(line)
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
self.finished.emit(True, "Installation completed successfully!")
|
||||
else:
|
||||
self.finished.emit(False, f"Installation failed (exit code {process.returncode})")
|
||||
|
||||
except Exception as e:
|
||||
self.finished.emit(False, f"Error: {e}")
|
||||
|
||||
|
||||
class ProviderInstallDialog(QDialog):
|
||||
"""Dialog for installing AI provider CLI tools."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Install AI Provider")
|
||||
self.setMinimumSize(700, 500)
|
||||
self.resize(750, 550)
|
||||
|
||||
self._worker = None
|
||||
self._selected_provider = None
|
||||
|
||||
self._setup_ui()
|
||||
self._load_providers()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the dialog UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Stacked widget for pages
|
||||
self.stack = QStackedWidget()
|
||||
layout.addWidget(self.stack, 1)
|
||||
|
||||
# Page 1: Provider selection
|
||||
self._setup_selection_page()
|
||||
|
||||
# Page 2: Installation
|
||||
self._setup_install_page()
|
||||
|
||||
# Page 3: Add variants
|
||||
self._setup_variants_page()
|
||||
|
||||
def _setup_selection_page(self):
|
||||
"""Set up the provider selection page."""
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Title
|
||||
title = QLabel("Select an AI Provider to Install")
|
||||
title.setObjectName("heading")
|
||||
font = QFont()
|
||||
font.setPointSize(14)
|
||||
font.setBold(True)
|
||||
title.setFont(font)
|
||||
layout.addWidget(title)
|
||||
|
||||
desc = QLabel(
|
||||
"CmdForge works with various AI providers. Select one below to install it. "
|
||||
"Providers marked as [INSTALLED] are already available on your system."
|
||||
)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #666; margin-bottom: 12px;")
|
||||
layout.addWidget(desc)
|
||||
|
||||
# Provider list
|
||||
self.provider_list = QListWidget()
|
||||
self.provider_list.setSpacing(4)
|
||||
self.provider_list.itemClicked.connect(self._on_provider_selected)
|
||||
self.provider_list.itemDoubleClicked.connect(self._on_provider_double_clicked)
|
||||
layout.addWidget(self.provider_list, 1)
|
||||
|
||||
# Details panel
|
||||
self.details_group = QGroupBox("Provider Details")
|
||||
details_layout = QVBoxLayout(self.details_group)
|
||||
|
||||
self.details_label = QLabel("Select a provider to see details")
|
||||
self.details_label.setWordWrap(True)
|
||||
details_layout.addWidget(self.details_label)
|
||||
|
||||
layout.addWidget(self.details_group)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_install = QPushButton("Install Selected")
|
||||
self.btn_install.setEnabled(False)
|
||||
self.btn_install.clicked.connect(self._start_install)
|
||||
btn_layout.addWidget(self.btn_install)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.stack.addWidget(page)
|
||||
|
||||
def _setup_install_page(self):
|
||||
"""Set up the installation progress page."""
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Title
|
||||
self.install_title = QLabel("Installing...")
|
||||
font = QFont()
|
||||
font.setPointSize(14)
|
||||
font.setBold(True)
|
||||
self.install_title.setFont(font)
|
||||
layout.addWidget(self.install_title)
|
||||
|
||||
# Command being run
|
||||
self.install_cmd_label = QLabel()
|
||||
self.install_cmd_label.setStyleSheet("color: #666; font-family: monospace;")
|
||||
layout.addWidget(self.install_cmd_label)
|
||||
|
||||
# Progress
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 0) # Indeterminate
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
# Output log
|
||||
self.output_text = QTextEdit()
|
||||
self.output_text.setReadOnly(True)
|
||||
self.output_text.setStyleSheet("font-family: monospace; font-size: 11px;")
|
||||
layout.addWidget(self.output_text, 1)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
self.btn_back = QPushButton("Back")
|
||||
self.btn_back.setEnabled(False)
|
||||
self.btn_back.clicked.connect(lambda: self.stack.setCurrentIndex(0))
|
||||
btn_layout.addWidget(self.btn_back)
|
||||
|
||||
self.btn_next = QPushButton("Next: Add Providers")
|
||||
self.btn_next.setEnabled(False)
|
||||
self.btn_next.clicked.connect(lambda: self.stack.setCurrentIndex(2))
|
||||
btn_layout.addWidget(self.btn_next)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.stack.addWidget(page)
|
||||
|
||||
def _setup_variants_page(self):
|
||||
"""Set up the variants/add providers page."""
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Title
|
||||
title = QLabel("Add Provider Variants")
|
||||
font = QFont()
|
||||
font.setPointSize(14)
|
||||
font.setBold(True)
|
||||
title.setFont(font)
|
||||
layout.addWidget(title)
|
||||
|
||||
desc = QLabel(
|
||||
"Select which provider variants to add to CmdForge. "
|
||||
"Each variant uses the same CLI tool but with different models or settings."
|
||||
)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #666; margin-bottom: 12px;")
|
||||
layout.addWidget(desc)
|
||||
|
||||
# Variants list
|
||||
self.variants_list = QListWidget()
|
||||
self.variants_list.setSelectionMode(QListWidget.MultiSelection)
|
||||
layout.addWidget(self.variants_list, 1)
|
||||
|
||||
# Next steps
|
||||
self.next_steps_group = QGroupBox("Next Steps")
|
||||
next_steps_layout = QVBoxLayout(self.next_steps_group)
|
||||
self.next_steps_label = QLabel()
|
||||
self.next_steps_label.setWordWrap(True)
|
||||
next_steps_layout.addWidget(self.next_steps_label)
|
||||
layout.addWidget(self.next_steps_group)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
self.btn_skip = QPushButton("Skip")
|
||||
self.btn_skip.clicked.connect(self.accept)
|
||||
btn_layout.addWidget(self.btn_skip)
|
||||
|
||||
self.btn_add = QPushButton("Add Selected Providers")
|
||||
self.btn_add.clicked.connect(self._add_selected_variants)
|
||||
btn_layout.addWidget(self.btn_add)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.stack.addWidget(page)
|
||||
|
||||
def _load_providers(self):
|
||||
"""Load and display available providers."""
|
||||
# Get currently configured providers
|
||||
configured = {p.name for p in load_providers()}
|
||||
|
||||
# Check what's installed on the system
|
||||
for key, info in PROVIDER_INSTALL_INFO.items():
|
||||
item = QListWidgetItem()
|
||||
|
||||
# Check if CLI tool is installed
|
||||
is_installed = self._check_installed(key)
|
||||
status = " [INSTALLED]" if is_installed else ""
|
||||
|
||||
item.setText(f"{info['group']}{status}")
|
||||
item.setData(Qt.UserRole, key)
|
||||
item.setData(Qt.UserRole + 1, is_installed)
|
||||
|
||||
# Style installed items differently
|
||||
if is_installed:
|
||||
item.setForeground(Qt.darkGreen)
|
||||
|
||||
self.provider_list.addItem(item)
|
||||
|
||||
def _check_installed(self, provider_key: str) -> bool:
|
||||
"""Check if a provider's CLI tool is installed."""
|
||||
info = PROVIDER_INSTALL_INFO[provider_key]
|
||||
|
||||
# Check common locations based on provider
|
||||
if provider_key == "opencode":
|
||||
opencode_path = Path.home() / ".opencode" / "bin" / "opencode"
|
||||
return opencode_path.exists() or shutil.which("opencode") is not None
|
||||
elif provider_key == "ollama":
|
||||
return shutil.which("ollama") is not None
|
||||
elif provider_key in ("claude", "codex", "agy", "crush"):
|
||||
return shutil.which(provider_key) is not None
|
||||
|
||||
return False
|
||||
|
||||
def _on_provider_selected(self, item: QListWidgetItem):
|
||||
"""Handle provider selection."""
|
||||
key = item.data(Qt.UserRole)
|
||||
is_installed = item.data(Qt.UserRole + 1)
|
||||
info = PROVIDER_INSTALL_INFO[key]
|
||||
|
||||
self._selected_provider = key
|
||||
self.btn_install.setEnabled(True)
|
||||
|
||||
# Update details
|
||||
install_method = info['install_cmd'] or "Manual download"
|
||||
details = f"""<b>{info['group']}</b><br><br>
|
||||
<b>Cost:</b> {info['cost']}<br>
|
||||
<b>Requirements:</b> {info['requires']}<br>
|
||||
<b>Install method:</b> <code>{install_method}</code><br>
|
||||
<b>After install:</b> {info['setup']}<br><br>
|
||||
<b>Available models:</b> {', '.join(v[0] for v in info['variants'])}"""
|
||||
|
||||
if is_installed:
|
||||
details += "<br><br><span style='color: green;'>This provider is already installed. Click Install to reinstall or update.</span>"
|
||||
|
||||
self.details_label.setText(details)
|
||||
|
||||
# Change button text based on installed status
|
||||
if info["install_cmd"]:
|
||||
self.btn_install.setText("Reinstall" if is_installed else "Install Selected")
|
||||
else:
|
||||
self.btn_install.setText("Add Providers" if is_installed else "Setup Instructions")
|
||||
|
||||
def _on_provider_double_clicked(self, item: QListWidgetItem):
|
||||
"""Handle double-click to start install."""
|
||||
self._on_provider_selected(item)
|
||||
self._start_install()
|
||||
|
||||
def _start_install(self):
|
||||
"""Start the installation process."""
|
||||
if not self._selected_provider:
|
||||
return
|
||||
|
||||
info = PROVIDER_INSTALL_INFO[self._selected_provider]
|
||||
|
||||
if not info["install_cmd"]:
|
||||
if self._check_installed(self._selected_provider):
|
||||
self._populate_variants()
|
||||
self.stack.setCurrentIndex(2)
|
||||
else:
|
||||
QMessageBox.information(self, "Manual Setup", info["setup"])
|
||||
return
|
||||
|
||||
# Switch to install page
|
||||
self.stack.setCurrentIndex(1)
|
||||
self.install_title.setText(f"Installing {info['group']}...")
|
||||
self.install_cmd_label.setText(f"Command: {info['install_cmd']}")
|
||||
self.output_text.clear()
|
||||
self.progress.setRange(0, 0)
|
||||
self.btn_back.setEnabled(False)
|
||||
self.btn_next.setEnabled(False)
|
||||
|
||||
# Start installation
|
||||
self._worker = InstallWorker(info['install_cmd'])
|
||||
self._worker.output.connect(self._on_install_output)
|
||||
self._worker.finished.connect(self._on_install_finished)
|
||||
self._worker.start()
|
||||
|
||||
def _on_install_output(self, text: str):
|
||||
"""Handle installation output."""
|
||||
self.output_text.append(text.rstrip())
|
||||
# Auto-scroll to bottom
|
||||
scrollbar = self.output_text.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
def _on_install_finished(self, success: bool, message: str):
|
||||
"""Handle installation completion."""
|
||||
self.progress.setRange(0, 1)
|
||||
self.progress.setValue(1 if success else 0)
|
||||
self.btn_back.setEnabled(True)
|
||||
|
||||
if success:
|
||||
self.install_title.setText("Installation Complete!")
|
||||
self.output_text.append(f"\n{'='*50}\n{message}")
|
||||
self.btn_next.setEnabled(True)
|
||||
|
||||
# Populate variants page
|
||||
self._populate_variants()
|
||||
else:
|
||||
self.install_title.setText("Installation Failed")
|
||||
self.output_text.append(f"\n{'='*50}\nERROR: {message}")
|
||||
|
||||
QMessageBox.warning(
|
||||
self, "Installation Failed",
|
||||
f"The installation did not complete successfully.\n\n{message}\n\n"
|
||||
"You may need to install manually. Check the output for details."
|
||||
)
|
||||
|
||||
def _populate_variants(self):
|
||||
"""Populate the variants list for the selected provider."""
|
||||
if not self._selected_provider:
|
||||
return
|
||||
|
||||
info = PROVIDER_INSTALL_INFO[self._selected_provider]
|
||||
self.variants_list.clear()
|
||||
|
||||
# Get currently configured provider names
|
||||
configured = {p.name for p in load_providers()}
|
||||
|
||||
for name, command, description in info['variants']:
|
||||
item = QListWidgetItem(f"{name} - {description}")
|
||||
item.setData(Qt.UserRole, (name, command, description))
|
||||
|
||||
# Pre-select if not already configured
|
||||
if name not in configured:
|
||||
item.setSelected(True)
|
||||
else:
|
||||
item.setForeground(Qt.gray)
|
||||
item.setText(f"{name} - {description} [already configured]")
|
||||
|
||||
self.variants_list.addItem(item)
|
||||
|
||||
# Update next steps
|
||||
self.next_steps_label.setText(
|
||||
f"<b>Important:</b> Before using these providers, you need to authenticate:<br><br>"
|
||||
f"1. Open a new terminal so PATH changes are loaded.<br>"
|
||||
f"2. {info['setup']}"
|
||||
)
|
||||
|
||||
def _add_selected_variants(self):
|
||||
"""Add selected variants as providers."""
|
||||
added = []
|
||||
|
||||
for i in range(self.variants_list.count()):
|
||||
item = self.variants_list.item(i)
|
||||
if item.isSelected():
|
||||
data = item.data(Qt.UserRole)
|
||||
if data:
|
||||
name, command, description = data
|
||||
provider = Provider(name, command, description)
|
||||
add_provider(provider)
|
||||
added.append(name)
|
||||
|
||||
if added:
|
||||
QMessageBox.information(
|
||||
self, "Providers Added",
|
||||
f"Added {len(added)} provider(s):\n\n" + "\n".join(f" - {name}" for name in added) +
|
||||
"\n\nRemember to authenticate before using them!"
|
||||
)
|
||||
|
||||
self.accept()
|
||||
@@ -0,0 +1,687 @@
|
||||
"""Publish tool dialog."""
|
||||
|
||||
from typing import Optional, List
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLabel, QPushButton,
|
||||
QHBoxLayout, QLineEdit, QTextEdit, QFormLayout,
|
||||
QProgressBar, QMessageBox, QComboBox, QFrame, QWidget
|
||||
)
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
import yaml
|
||||
|
||||
from ...tool import Tool, get_all_categories
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
from ...config import load_config
|
||||
|
||||
|
||||
def parse_version(version: str) -> tuple:
|
||||
"""Parse version string into tuple of ints."""
|
||||
try:
|
||||
parts = version.split(".")
|
||||
return tuple(int(p) for p in parts[:3])
|
||||
except (ValueError, AttributeError):
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def bump_version(version: str, bump_type: str) -> str:
|
||||
"""Bump version by type (patch, minor, major)."""
|
||||
major, minor, patch = parse_version(version)
|
||||
if bump_type == "major":
|
||||
return f"{major + 1}.0.0"
|
||||
elif bump_type == "minor":
|
||||
return f"{major}.{minor + 1}.0"
|
||||
else: # patch
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
|
||||
|
||||
class VersionFetchWorker(QThread):
|
||||
"""Background worker to fetch registry version info."""
|
||||
finished = Signal(dict) # Dict with 'my_version', 'original_versions', and 'user_role'
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, name: str, original_owner: Optional[str] = None):
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.original_owner = original_owner
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
result = {
|
||||
"my_version": None,
|
||||
"my_status": None,
|
||||
"original_versions": [],
|
||||
"original_owner": self.original_owner,
|
||||
"user_role": "user",
|
||||
"user_slug": "",
|
||||
}
|
||||
|
||||
# Get my published version of this tool and user info
|
||||
if config.registry.token:
|
||||
try:
|
||||
# Get user info including role
|
||||
me = client.get_me()
|
||||
result["user_role"] = me.get("role", "user")
|
||||
result["user_slug"] = me.get("slug", "")
|
||||
except RegistryError:
|
||||
pass
|
||||
|
||||
try:
|
||||
status = client.get_my_tool_status(self.name)
|
||||
result["my_version"] = status.get("version")
|
||||
result["my_status"] = status.get("status")
|
||||
except RegistryError:
|
||||
pass # Tool not published by me yet
|
||||
|
||||
# If this was forked from another owner, get their versions too
|
||||
if self.original_owner:
|
||||
try:
|
||||
result["original_versions"] = client.get_tool_versions(
|
||||
self.original_owner, self.name
|
||||
)
|
||||
except RegistryError:
|
||||
pass
|
||||
|
||||
self.finished.emit(result)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class PublishWorker(QThread):
|
||||
"""Background worker for publishing."""
|
||||
success = Signal(dict)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, config_yaml: str, readme: str = "", defaults: str = "", owner: str = ""):
|
||||
super().__init__()
|
||||
self.config_yaml = config_yaml
|
||||
self.readme = readme
|
||||
self.defaults = defaults
|
||||
self.owner = owner
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
result = client.publish_tool(self.config_yaml, self.readme, self.defaults, owner=self.owner)
|
||||
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, 580)
|
||||
self._tool = tool
|
||||
self._worker = None
|
||||
self._version_worker = None
|
||||
self._my_registry_version: Optional[str] = None
|
||||
self._my_registry_status: Optional[str] = None
|
||||
self._original_versions: List[str] = []
|
||||
self._local_version = self._get_local_version()
|
||||
self._original_owner = self._get_original_owner()
|
||||
self._user_role: str = "user"
|
||||
self._user_slug: str = ""
|
||||
self._setup_ui()
|
||||
self._fetch_registry_versions()
|
||||
|
||||
def _get_local_version(self) -> str:
|
||||
"""Get the version from local tool config."""
|
||||
if hasattr(self._tool, 'path') and self._tool.path:
|
||||
try:
|
||||
config = yaml.safe_load(self._tool.path.read_text())
|
||||
return config.get("version", "1.0.0")
|
||||
except Exception:
|
||||
pass
|
||||
return "1.0.0"
|
||||
|
||||
def _get_original_owner(self) -> Optional[str]:
|
||||
"""Get the original owner if this tool was installed from registry."""
|
||||
if hasattr(self._tool, 'path') and self._tool.path:
|
||||
try:
|
||||
config = yaml.safe_load(self._tool.path.read_text())
|
||||
# Check if it has forked_from or was installed from registry
|
||||
if config.get("forked_from"):
|
||||
return config["forked_from"].split("/")[0]
|
||||
if config.get("installed_from"):
|
||||
return config["installed_from"].split("/")[0]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _fetch_registry_versions(self):
|
||||
"""Fetch versions from registry in background."""
|
||||
self._version_worker = VersionFetchWorker(self._tool.name, self._original_owner)
|
||||
self._version_worker.finished.connect(self._on_versions_fetched)
|
||||
self._version_worker.error.connect(self._on_versions_error)
|
||||
self._version_worker.start()
|
||||
|
||||
def _on_versions_fetched(self, result: dict):
|
||||
"""Handle registry versions response."""
|
||||
self._my_registry_version = result.get("my_version")
|
||||
self._my_registry_status = result.get("my_status")
|
||||
self._original_versions = result.get("original_versions", [])
|
||||
self._user_role = result.get("user_role", "user")
|
||||
self._user_slug = result.get("user_slug", "")
|
||||
self._update_version_info()
|
||||
self._update_admin_fields()
|
||||
|
||||
def _on_versions_error(self, error: str):
|
||||
"""Handle version fetch error (non-critical)."""
|
||||
self._my_registry_version = None
|
||||
self._original_versions = []
|
||||
self._update_version_info()
|
||||
|
||||
def _update_version_info(self):
|
||||
"""Update version info display."""
|
||||
if self._my_registry_version:
|
||||
# Already published by me - show my latest version
|
||||
self.registry_version_label.setText(
|
||||
f"Registry: v{self._my_registry_version} ({self._my_registry_status or 'unknown'})"
|
||||
)
|
||||
if self._my_registry_status == "approved":
|
||||
self.registry_version_label.setStyleSheet("color: #38a169;") # Green
|
||||
elif self._my_registry_status == "pending":
|
||||
self.registry_version_label.setStyleSheet("color: #d69e2e;") # Yellow
|
||||
else:
|
||||
self.registry_version_label.setStyleSheet("color: #4299e1;") # Blue
|
||||
# Suggest next version
|
||||
suggested = bump_version(self._my_registry_version, "patch")
|
||||
self.version_input.setText(suggested)
|
||||
self._show_bump_buttons(True)
|
||||
elif self._original_versions:
|
||||
# This is a fork - show original tool info
|
||||
latest_original = self._original_versions[0]
|
||||
self.registry_version_label.setText(
|
||||
f"Fork of {self._original_owner}/{self._tool.name} v{latest_original}"
|
||||
)
|
||||
self.registry_version_label.setStyleSheet("color: #805ad5;") # Purple for fork
|
||||
self.version_input.setText("1.0.0") # Start fresh for forks
|
||||
self._show_bump_buttons(False)
|
||||
else:
|
||||
self.registry_version_label.setText("Registry: not published")
|
||||
self.registry_version_label.setStyleSheet("color: #718096;")
|
||||
self.version_input.setText(self._local_version)
|
||||
self._show_bump_buttons(False)
|
||||
|
||||
def _show_bump_buttons(self, show: bool):
|
||||
"""Show or hide version bump buttons."""
|
||||
self.bump_widget.setVisible(show)
|
||||
|
||||
def _update_admin_fields(self):
|
||||
"""Show/hide admin-only fields based on user role."""
|
||||
is_admin = self._user_role == "admin"
|
||||
self.owner_widget.setVisible(is_admin)
|
||||
self.owner_label.setVisible(is_admin)
|
||||
|
||||
if is_admin and self.owner_combo.count() == 0:
|
||||
# Only populate once to avoid resetting user's selection
|
||||
self.owner_combo.addItem(self._user_slug)
|
||||
self.owner_combo.addItem("official")
|
||||
self.owner_combo.setCurrentIndex(0)
|
||||
|
||||
def _bump_patch(self):
|
||||
"""Bump patch version."""
|
||||
if self._my_registry_version:
|
||||
self.version_input.setText(bump_version(self._my_registry_version, "patch"))
|
||||
|
||||
def _bump_minor(self):
|
||||
"""Bump minor version."""
|
||||
if self._my_registry_version:
|
||||
self.version_input.setText(bump_version(self._my_registry_version, "minor"))
|
||||
|
||||
def _bump_major(self):
|
||||
"""Bump major version."""
|
||||
if self._my_registry_version:
|
||||
self.version_input.setText(bump_version(self._my_registry_version, "major"))
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
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)
|
||||
|
||||
# Version info section
|
||||
version_frame = QFrame()
|
||||
version_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #f7fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
version_info_layout = QHBoxLayout(version_frame)
|
||||
version_info_layout.setContentsMargins(12, 8, 12, 8)
|
||||
|
||||
self.local_version_label = QLabel(f"Local: v{self._local_version}")
|
||||
self.local_version_label.setStyleSheet("color: #2d3748; font-weight: 500;")
|
||||
version_info_layout.addWidget(self.local_version_label)
|
||||
|
||||
version_info_layout.addSpacing(20)
|
||||
|
||||
self.registry_version_label = QLabel("Registry: checking...")
|
||||
self.registry_version_label.setStyleSheet("color: #718096;")
|
||||
version_info_layout.addWidget(self.registry_version_label)
|
||||
|
||||
version_info_layout.addStretch()
|
||||
layout.addWidget(version_frame)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
# Version input with bump buttons
|
||||
version_row = QHBoxLayout()
|
||||
self.version_input = QLineEdit()
|
||||
self.version_input.setText(self._local_version)
|
||||
self.version_input.setPlaceholderText("1.0.0")
|
||||
self.version_input.setMaximumWidth(100)
|
||||
self.version_input.setMinimumHeight(28)
|
||||
version_row.addWidget(self.version_input)
|
||||
|
||||
# Bump buttons (hidden until we know registry version)
|
||||
self.bump_widget = QWidget()
|
||||
bump_layout = QHBoxLayout(self.bump_widget)
|
||||
bump_layout.setContentsMargins(8, 0, 0, 0)
|
||||
bump_layout.setSpacing(4)
|
||||
|
||||
btn_patch = QPushButton("+Patch")
|
||||
btn_patch.setObjectName("secondary")
|
||||
btn_patch.setFixedWidth(75)
|
||||
btn_patch.setToolTip("Bump patch version (bug fixes)")
|
||||
btn_patch.clicked.connect(self._bump_patch)
|
||||
bump_layout.addWidget(btn_patch)
|
||||
|
||||
btn_minor = QPushButton("+Minor")
|
||||
btn_minor.setObjectName("secondary")
|
||||
btn_minor.setFixedWidth(75)
|
||||
btn_minor.setToolTip("Bump minor version (new features)")
|
||||
btn_minor.clicked.connect(self._bump_minor)
|
||||
bump_layout.addWidget(btn_minor)
|
||||
|
||||
btn_major = QPushButton("+Major")
|
||||
btn_major.setObjectName("secondary")
|
||||
btn_major.setFixedWidth(75)
|
||||
btn_major.setToolTip("Bump major version (breaking changes)")
|
||||
btn_major.clicked.connect(self._bump_major)
|
||||
bump_layout.addWidget(btn_major)
|
||||
|
||||
self.bump_widget.setVisible(False)
|
||||
version_row.addWidget(self.bump_widget)
|
||||
|
||||
version_row.addStretch()
|
||||
form.addRow("Version:", version_row)
|
||||
|
||||
# Owner (admin only) - hidden by default
|
||||
self.owner_widget = QWidget()
|
||||
owner_layout = QHBoxLayout(self.owner_widget)
|
||||
owner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.owner_combo = QComboBox()
|
||||
self.owner_combo.setMinimumWidth(150)
|
||||
self.owner_combo.setMinimumHeight(28)
|
||||
# Make it editable but read-only to fix hover styling issues
|
||||
self.owner_combo.setEditable(True)
|
||||
self.owner_combo.lineEdit().setReadOnly(True)
|
||||
owner_layout.addWidget(self.owner_combo)
|
||||
|
||||
owner_hint = QLabel("(admin only)")
|
||||
owner_hint.setStyleSheet("color: #805ad5; font-size: 11px;")
|
||||
owner_layout.addWidget(owner_hint)
|
||||
owner_layout.addStretch()
|
||||
|
||||
self.owner_widget.setVisible(False) # Hidden until we know user is admin
|
||||
self.owner_label = QLabel("Publish as:")
|
||||
self.owner_label.setVisible(False)
|
||||
form.addRow(self.owner_label, self.owner_widget)
|
||||
|
||||
# Category - populated from defaults + local tools
|
||||
self.category_combo = QComboBox()
|
||||
self.category_combo.setEditable(True)
|
||||
self.category_combo.setMinimumHeight(28)
|
||||
self.category_combo.addItems(get_all_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 - load from config.yaml if available
|
||||
self.tags_input = QLineEdit()
|
||||
self.tags_input.setMinimumHeight(28)
|
||||
self.tags_input.setPlaceholderText("ai, text, productivity (comma separated)")
|
||||
if self._tool.path:
|
||||
try:
|
||||
config_data = yaml.safe_load(self._tool.path.read_text()) or {}
|
||||
existing_tags = config_data.get("tags", [])
|
||||
if existing_tags:
|
||||
self.tags_input.setText(", ".join(existing_tags))
|
||||
except Exception:
|
||||
pass # No existing tags or error reading
|
||||
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.setMinimumHeight(80)
|
||||
self.desc_input.setMaximumHeight(120)
|
||||
layout.addWidget(self.desc_input)
|
||||
|
||||
# Status
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Progress - use fixed height container so layout doesn't shift
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 0)
|
||||
self.progress.setFixedHeight(20)
|
||||
self.progress.setVisible(False)
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
# Spacer that matches progress bar height when hidden
|
||||
self.progress_spacer = QWidget()
|
||||
self.progress_spacer.setFixedHeight(20)
|
||||
layout.addWidget(self.progress_spacer)
|
||||
|
||||
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 _check_unpublished_dependencies(self):
|
||||
"""Check for unpublished dependencies."""
|
||||
from ...collection import gather_local_unpublished_deps, DepCheckResult
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
if not client.token:
|
||||
# Can't check without auth, skip the check
|
||||
return DepCheckResult([], [], [], [])
|
||||
|
||||
# Get user slug for proper path resolution
|
||||
try:
|
||||
me = client.get_me()
|
||||
my_owner = me.get("slug", "")
|
||||
except Exception:
|
||||
my_owner = None
|
||||
|
||||
return gather_local_unpublished_deps([self._tool.name], client, my_owner)
|
||||
except Exception:
|
||||
return DepCheckResult([], [], [], [])
|
||||
|
||||
def _publish_with_dependencies(self, dep_result):
|
||||
"""Publish dependencies first, then the main tool."""
|
||||
from ...cli.collections_commands import _publish_single_tool
|
||||
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
# Get tools to publish in order (only unpublished deps, exclude main tool)
|
||||
tools_to_publish = [
|
||||
t for t in dep_result.publish_order
|
||||
if t in dep_result.unpublished and t != self._tool.name
|
||||
]
|
||||
|
||||
if not tools_to_publish:
|
||||
# No deps to publish, proceed with main publish
|
||||
self._do_publish()
|
||||
return
|
||||
|
||||
# Publish dependencies first
|
||||
self.btn_publish.setEnabled(False)
|
||||
self.btn_cancel.setEnabled(False)
|
||||
self.progress.setVisible(True)
|
||||
self.progress_spacer.setVisible(False)
|
||||
|
||||
failed = []
|
||||
for tool_name in tools_to_publish:
|
||||
self.status_label.setText(f"Publishing dependency: {tool_name}...")
|
||||
result = _publish_single_tool(tool_name, client)
|
||||
if not result.get("success"):
|
||||
failed.append((tool_name, result.get("error", "Unknown error")))
|
||||
|
||||
if failed:
|
||||
self.progress.setVisible(False)
|
||||
self.progress_spacer.setVisible(True)
|
||||
self.btn_publish.setEnabled(True)
|
||||
self.btn_cancel.setEnabled(True)
|
||||
errors = "\n".join(f" - {name}: {err}" for name, err in failed)
|
||||
QMessageBox.warning(
|
||||
self, "Dependency Publish Failed",
|
||||
f"Failed to publish some dependencies:\n\n{errors}\n\n"
|
||||
"Fix these issues and try again."
|
||||
)
|
||||
return
|
||||
|
||||
# Now publish the main tool
|
||||
self.status_label.setText("Publishing main tool...")
|
||||
self._do_publish()
|
||||
|
||||
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
|
||||
|
||||
# Check for unpublished dependencies
|
||||
dep_result = self._check_unpublished_dependencies()
|
||||
# Filter out the tool itself - we're about to publish it
|
||||
unpublished_deps = [d for d in dep_result.unpublished if d != self._tool.name]
|
||||
if unpublished_deps:
|
||||
dialog = QMessageBox(self)
|
||||
dialog.setIcon(QMessageBox.Warning)
|
||||
dialog.setWindowTitle("Unpublished Dependencies")
|
||||
|
||||
message = "This tool depends on unpublished tools:\n\n"
|
||||
message += "\n".join(f" - {t}" for t in unpublished_deps)
|
||||
message += "\n\n"
|
||||
|
||||
if dep_result.cycles:
|
||||
message += "Warning: Circular dependencies detected:\n"
|
||||
for cycle in dep_result.cycles:
|
||||
message += f" {' -> '.join(cycle)}\n"
|
||||
message += "(Cyclic deps excluded from auto-publish)\n\n"
|
||||
|
||||
if dep_result.skipped:
|
||||
message += f"Note: Could not check {len(dep_result.skipped)} dep(s).\n\n"
|
||||
|
||||
message += "Choose how to proceed:"
|
||||
dialog.setText(message)
|
||||
|
||||
btn_publish = dialog.addButton("Publish Dependencies First", QMessageBox.AcceptRole)
|
||||
btn_anyway = dialog.addButton("Publish Anyway", QMessageBox.DestructiveRole)
|
||||
btn_cancel = dialog.addButton("Cancel", QMessageBox.RejectRole)
|
||||
|
||||
for btn in [btn_publish, btn_anyway, btn_cancel]:
|
||||
btn.setMinimumWidth(btn.fontMetrics().horizontalAdvance(btn.text()) + 30)
|
||||
|
||||
dialog.exec()
|
||||
clicked = dialog.clickedButton()
|
||||
|
||||
if clicked == btn_cancel:
|
||||
return
|
||||
elif clicked == btn_publish:
|
||||
self._publish_with_dependencies(dep_result)
|
||||
return
|
||||
# btn_anyway falls through to normal publish
|
||||
|
||||
self._do_publish()
|
||||
|
||||
def _do_publish(self):
|
||||
"""Actually perform the publish operation."""
|
||||
version = self.version_input.text().strip()
|
||||
description = self.desc_input.toPlainText().strip()
|
||||
|
||||
# Check if tool was previously rejected - if so, bump version
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
if client.token:
|
||||
status_info = client.get_my_tool_status(self._tool.name)
|
||||
current_status = status_info.get("status", "")
|
||||
if current_status == "rejected":
|
||||
new_version = bump_version(version, "patch")
|
||||
self.version_input.setText(new_version)
|
||||
version = new_version
|
||||
self.status_label.setText(f"Version bumped to {new_version} (previous was rejected)")
|
||||
except RegistryError as e:
|
||||
if e.code != "TOOL_NOT_FOUND":
|
||||
pass # Other errors - continue
|
||||
except Exception:
|
||||
pass # Continue with publish
|
||||
|
||||
category = self.category_combo.currentText()
|
||||
tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()]
|
||||
|
||||
# Build complete tool config from the Tool object
|
||||
tool_config = self._tool.to_dict() if hasattr(self._tool, 'to_dict') else {}
|
||||
|
||||
# Update with publish dialog values
|
||||
tool_config["name"] = self._tool.name
|
||||
tool_config["version"] = version
|
||||
tool_config["description"] = description
|
||||
tool_config["category"] = category
|
||||
if tags:
|
||||
tool_config["tags"] = tags
|
||||
|
||||
# Add fork metadata if this tool originated from another owner
|
||||
if self._original_owner and self._original_versions:
|
||||
tool_config["forked_from"] = f"{self._original_owner}/{self._tool.name}"
|
||||
# Record which version we forked from (use local version or latest original)
|
||||
forked_version = self._local_version
|
||||
if forked_version in self._original_versions:
|
||||
tool_config["forked_version"] = forked_version
|
||||
else:
|
||||
# Use the latest original version as reference
|
||||
tool_config["forked_version"] = self._original_versions[0]
|
||||
|
||||
# Convert to YAML string
|
||||
config_yaml = yaml.dump(tool_config, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Get README if it exists
|
||||
readme = ""
|
||||
defaults = ""
|
||||
tool_dir = self._tool.path.parent if hasattr(self._tool, 'path') and self._tool.path else None
|
||||
if tool_dir:
|
||||
readme_path = tool_dir / "README.md"
|
||||
if readme_path.exists():
|
||||
readme = readme_path.read_text()
|
||||
defaults_path = tool_dir / "defaults.yaml"
|
||||
if defaults_path.exists():
|
||||
defaults = defaults_path.read_text()
|
||||
|
||||
self.btn_publish.setEnabled(False)
|
||||
self.btn_cancel.setEnabled(False)
|
||||
self.progress.setVisible(True)
|
||||
self.progress_spacer.setVisible(False)
|
||||
self.status_label.setText("Publishing...")
|
||||
|
||||
# Store the config we're publishing so we can save it on success
|
||||
self._published_config = tool_config
|
||||
|
||||
# Get owner if admin selected a different one
|
||||
owner = ""
|
||||
if self._user_role == "admin" and self.owner_widget.isVisible():
|
||||
selected_text = self.owner_combo.currentText().strip()
|
||||
if selected_text == "official":
|
||||
owner = "official"
|
||||
|
||||
self._worker = PublishWorker(config_yaml, readme, defaults, owner=owner)
|
||||
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.setVisible(False)
|
||||
self.progress_spacer.setVisible(True)
|
||||
self.status_label.setText("Published successfully!")
|
||||
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
|
||||
|
||||
# Save the published config with registry tracking fields
|
||||
# This ensures local file matches exactly what was sent to server (for hash consistency)
|
||||
config_hash = result.get("config_hash")
|
||||
moderation_status = result.get("status", "pending")
|
||||
if config_hash and hasattr(self._tool, 'path') and self._tool.path:
|
||||
try:
|
||||
config_path = self._tool.path
|
||||
if config_path.exists() and hasattr(self, '_published_config'):
|
||||
# Use the exact config we published, add registry tracking fields
|
||||
config_data = self._published_config.copy()
|
||||
config_data["registry_hash"] = config_hash
|
||||
config_data["registry_status"] = moderation_status
|
||||
# Save the registry owner so rating lookups work
|
||||
registry_owner = result.get("owner")
|
||||
if registry_owner:
|
||||
config_data["registry_owner"] = registry_owner
|
||||
# Clear any old feedback
|
||||
config_data.pop("registry_feedback", None)
|
||||
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
|
||||
except Exception:
|
||||
pass # Non-critical - just won't show publish state
|
||||
|
||||
status = result.get("status", "pending")
|
||||
if status == "approved":
|
||||
msg = f"Tool '{self._tool.name}' has been published and is now live!"
|
||||
else:
|
||||
msg = f"Tool '{self._tool.name}' has been published.\n\nIt will be available after review."
|
||||
|
||||
QMessageBox.information(self, "Success", msg)
|
||||
self.accept()
|
||||
|
||||
def _on_error(self, error: str):
|
||||
"""Handle publish error."""
|
||||
self.progress.setVisible(False)
|
||||
self.progress_spacer.setVisible(True)
|
||||
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,209 @@
|
||||
"""Review dialog for rating registry tools."""
|
||||
|
||||
from typing import Optional, Dict
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QLineEdit, QPlainTextEdit, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
|
||||
class ReviewDialog(QDialog):
|
||||
"""Dialog for submitting or editing a tool review."""
|
||||
|
||||
# Set when user clicks Delete - caller checks this after reject()
|
||||
delete_requested = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
owner: str,
|
||||
name: str,
|
||||
existing_review: Optional[Dict] = None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._owner = owner
|
||||
self._name = name
|
||||
self._existing = existing_review
|
||||
self._rating = existing_review["rating"] if existing_review else 0
|
||||
self.delete_requested = False
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
editing = self._existing is not None
|
||||
self.setWindowTitle(f"{'Edit Review' if editing else 'Rate Tool'} - {self._owner}/{self._name}")
|
||||
self.setMinimumSize(420, 380)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(14)
|
||||
|
||||
# Title
|
||||
title = QLabel(f"{'Edit your review' if editing else 'Rate'} {self._owner}/{self._name}")
|
||||
title.setStyleSheet("font-size: 15px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Star selector
|
||||
star_label = QLabel("Rating:")
|
||||
star_label.setStyleSheet("font-weight: 500;")
|
||||
layout.addWidget(star_label)
|
||||
|
||||
star_row = QHBoxLayout()
|
||||
star_row.setSpacing(4)
|
||||
self._star_buttons = []
|
||||
for i in range(1, 6):
|
||||
btn = QPushButton("☆")
|
||||
btn.setFixedSize(40, 40)
|
||||
btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
font-size: 22px;
|
||||
padding: 0px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
""")
|
||||
btn.setCursor(Qt.PointingHandCursor)
|
||||
btn.clicked.connect(lambda checked, n=i: self._set_rating(n))
|
||||
self._star_buttons.append(btn)
|
||||
star_row.addWidget(btn)
|
||||
star_row.addStretch()
|
||||
layout.addLayout(star_row)
|
||||
|
||||
# Pre-fill rating
|
||||
if self._rating:
|
||||
self._set_rating(self._rating)
|
||||
|
||||
# Title input
|
||||
title_label = QLabel("Title (optional):")
|
||||
title_label.setStyleSheet("font-weight: 500;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
self.title_input = QLineEdit()
|
||||
self.title_input.setMaxLength(100)
|
||||
self.title_input.setPlaceholderText("Brief summary of your review")
|
||||
self.title_input.setMinimumHeight(28)
|
||||
if self._existing and self._existing.get("title"):
|
||||
self.title_input.setText(self._existing["title"])
|
||||
layout.addWidget(self.title_input)
|
||||
|
||||
# Content input
|
||||
content_label = QLabel("Review (optional, min 10 chars if provided):")
|
||||
content_label.setStyleSheet("font-weight: 500;")
|
||||
layout.addWidget(content_label)
|
||||
|
||||
self.content_input = QPlainTextEdit()
|
||||
self.content_input.setPlaceholderText("Share your experience with this tool...")
|
||||
self.content_input.setMinimumHeight(100)
|
||||
self.content_input.setMaximumHeight(180)
|
||||
if self._existing and self._existing.get("content"):
|
||||
self.content_input.setPlainText(self._existing["content"])
|
||||
layout.addWidget(self.content_input)
|
||||
|
||||
# Character count
|
||||
self._char_count = QLabel("0 / 2000")
|
||||
self._char_count.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
self._char_count.setAlignment(Qt.AlignRight)
|
||||
self.content_input.textChanged.connect(self._update_char_count)
|
||||
self._update_char_count()
|
||||
layout.addWidget(self._char_count)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
if editing:
|
||||
self.btn_delete = QPushButton("Delete Review")
|
||||
self.btn_delete.setObjectName("danger")
|
||||
self.btn_delete.clicked.connect(self._on_delete)
|
||||
btn_layout.addWidget(self.btn_delete)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
btn_cancel = QPushButton("Cancel")
|
||||
btn_cancel.setObjectName("secondary")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(btn_cancel)
|
||||
|
||||
submit_text = "Update" if editing else "Submit"
|
||||
self.btn_submit = QPushButton(submit_text)
|
||||
self.btn_submit.clicked.connect(self._on_submit)
|
||||
btn_layout.addWidget(self.btn_submit)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _set_rating(self, n: int):
|
||||
self._rating = n
|
||||
for i, btn in enumerate(self._star_buttons):
|
||||
if i < n:
|
||||
btn.setText("★")
|
||||
btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
font-size: 22px;
|
||||
padding: 0px;
|
||||
border: 1px solid #d69e2e;
|
||||
border-radius: 4px;
|
||||
background: #fefcbf;
|
||||
color: #d69e2e;
|
||||
}
|
||||
QPushButton:hover { background: #fef3c7; }
|
||||
""")
|
||||
else:
|
||||
btn.setText("☆")
|
||||
btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
font-size: 22px;
|
||||
padding: 0px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
QPushButton:hover { background: #edf2f7; }
|
||||
""")
|
||||
|
||||
def _update_char_count(self):
|
||||
count = len(self.content_input.toPlainText())
|
||||
self._char_count.setText(f"{count} / 2000")
|
||||
if count > 2000:
|
||||
self._char_count.setStyleSheet("color: #e53e3e; font-size: 11px;")
|
||||
else:
|
||||
self._char_count.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
|
||||
def _on_submit(self):
|
||||
if self._rating < 1:
|
||||
QMessageBox.warning(self, "Validation", "Please select a rating (1-5 stars).")
|
||||
return
|
||||
|
||||
content = self.content_input.toPlainText().strip()
|
||||
if content and len(content) < 10:
|
||||
QMessageBox.warning(self, "Validation", "Review content must be at least 10 characters.")
|
||||
return
|
||||
|
||||
if len(content) > 2000:
|
||||
QMessageBox.warning(self, "Validation", "Review content must be 2000 characters or less.")
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def _on_delete(self):
|
||||
reply = QMessageBox.question(
|
||||
self, "Delete Review",
|
||||
"Are you sure you want to delete your review?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.delete_requested = True
|
||||
self.reject()
|
||||
|
||||
def get_review_data(self) -> Dict:
|
||||
"""Get the review data entered by the user."""
|
||||
return {
|
||||
"rating": self._rating,
|
||||
"title": self.title_input.text().strip(),
|
||||
"content": self.content_input.toPlainText().strip()[:2000],
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Settings dialog for editing tool settings."""
|
||||
|
||||
import yaml
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox,
|
||||
QPlainTextEdit, QPushButton, QMessageBox, QLabel
|
||||
)
|
||||
|
||||
from ...tool import load_tool
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Dialog for editing tool settings."""
|
||||
|
||||
def __init__(self, parent, tool_name: str):
|
||||
super().__init__(parent)
|
||||
self.tool_name = tool_name
|
||||
self.setWindowTitle(f"Settings: {tool_name}")
|
||||
self.setMinimumSize(500, 400)
|
||||
self._setup_ui()
|
||||
self._load_settings()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Reference panel (shows defaults)
|
||||
ref_group = QGroupBox("Defaults (reference)")
|
||||
ref_layout = QVBoxLayout(ref_group)
|
||||
ref_help = QLabel("These are the original defaults. Your settings below will override them.")
|
||||
ref_help.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
ref_help.setWordWrap(True)
|
||||
ref_layout.addWidget(ref_help)
|
||||
self.defaults_view = QPlainTextEdit()
|
||||
self.defaults_view.setReadOnly(True)
|
||||
self.defaults_view.setMaximumHeight(120)
|
||||
ref_layout.addWidget(self.defaults_view)
|
||||
layout.addWidget(ref_group)
|
||||
|
||||
# Settings editor
|
||||
edit_group = QGroupBox("Your Settings")
|
||||
edit_layout = QVBoxLayout(edit_group)
|
||||
edit_help = QLabel(
|
||||
"Edit your settings below. Access in templates as {settings.key} "
|
||||
"or in code as settings['key']."
|
||||
)
|
||||
edit_help.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
edit_help.setWordWrap(True)
|
||||
edit_layout.addWidget(edit_help)
|
||||
self.settings_editor = QPlainTextEdit()
|
||||
edit_layout.addWidget(self.settings_editor)
|
||||
layout.addWidget(edit_group, 1)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
self.reset_btn = QPushButton("Reset to Defaults")
|
||||
self.reset_btn.setObjectName("secondary")
|
||||
self.reset_btn.clicked.connect(self._reset)
|
||||
btn_layout.addWidget(self.reset_btn)
|
||||
btn_layout.addStretch()
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn.setObjectName("secondary")
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(self.cancel_btn)
|
||||
|
||||
self.save_btn = QPushButton("Save")
|
||||
self.save_btn.clicked.connect(self._save)
|
||||
self.save_btn.setDefault(True)
|
||||
btn_layout.addWidget(self.save_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _load_settings(self):
|
||||
tool = load_tool(self.tool_name)
|
||||
if not tool or not tool.path:
|
||||
return
|
||||
|
||||
defaults_path = tool.path.parent / "defaults.yaml"
|
||||
settings_path = tool.path.parent / "settings.yaml"
|
||||
|
||||
if defaults_path.exists():
|
||||
self.defaults_view.setPlainText(defaults_path.read_text())
|
||||
|
||||
if settings_path.exists():
|
||||
self.settings_editor.setPlainText(settings_path.read_text())
|
||||
elif defaults_path.exists():
|
||||
self.settings_editor.setPlainText(defaults_path.read_text())
|
||||
|
||||
def _reset(self):
|
||||
"""Reset settings to defaults."""
|
||||
self.settings_editor.setPlainText(self.defaults_view.toPlainText())
|
||||
|
||||
def _save(self):
|
||||
"""Save settings."""
|
||||
tool = load_tool(self.tool_name)
|
||||
if not tool or not tool.path:
|
||||
QMessageBox.warning(self, "Error", f"Tool '{self.tool_name}' not found")
|
||||
return
|
||||
|
||||
content = self.settings_editor.toPlainText()
|
||||
|
||||
# Validate YAML
|
||||
try:
|
||||
yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
QMessageBox.warning(self, "Invalid YAML", f"Settings YAML is invalid:\n{e}")
|
||||
return
|
||||
|
||||
settings_path = tool.path.parent / "settings.yaml"
|
||||
settings_path.write_text(content)
|
||||
self.accept()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
"""System dependency editor dialog."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
||||
QPushButton, QHBoxLayout, QLabel, QGroupBox
|
||||
)
|
||||
|
||||
from ...tool import SystemDependency
|
||||
|
||||
|
||||
class SystemDependencyDialog(QDialog):
|
||||
"""Dialog for editing system dependencies."""
|
||||
|
||||
def __init__(self, parent, dep: SystemDependency = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit System Dependency" if dep else "Add System Dependency")
|
||||
self.setMinimumWidth(450)
|
||||
self._dep = dep
|
||||
self._setup_ui()
|
||||
|
||||
if dep:
|
||||
self._load_dep(dep)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Basic info
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("ffmpeg")
|
||||
self.name_input.setToolTip("Package name (used as default for all package managers)")
|
||||
form.addRow("Name:", self.name_input)
|
||||
|
||||
self.desc_input = QLineEdit()
|
||||
self.desc_input.setPlaceholderText("Audio/video processing (optional)")
|
||||
self.desc_input.setToolTip("Human-readable description of why this package is needed")
|
||||
form.addRow("Description:", self.desc_input)
|
||||
|
||||
self.binaries_input = QLineEdit()
|
||||
self.binaries_input.setPlaceholderText("ffplay, ffmpeg (optional, comma-separated)")
|
||||
self.binaries_input.setToolTip(
|
||||
"Executables to check for. If any exist, dependency is satisfied.\n"
|
||||
"Leave empty to check for the package name itself."
|
||||
)
|
||||
form.addRow("Binaries:", self.binaries_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Package names (advanced)
|
||||
pkg_group = QGroupBox("Package Names (Advanced)")
|
||||
pkg_group.setToolTip("Override package name for specific package managers")
|
||||
pkg_layout = QFormLayout(pkg_group)
|
||||
pkg_layout.setSpacing(8)
|
||||
|
||||
self.apt_input = QLineEdit()
|
||||
self.apt_input.setPlaceholderText("(uses name if empty)")
|
||||
pkg_layout.addRow("apt:", self.apt_input)
|
||||
|
||||
self.brew_input = QLineEdit()
|
||||
self.brew_input.setPlaceholderText("(uses name if empty)")
|
||||
pkg_layout.addRow("brew:", self.brew_input)
|
||||
|
||||
self.pacman_input = QLineEdit()
|
||||
self.pacman_input.setPlaceholderText("(uses name if empty)")
|
||||
pkg_layout.addRow("pacman:", self.pacman_input)
|
||||
|
||||
self.dnf_input = QLineEdit()
|
||||
self.dnf_input.setPlaceholderText("(uses name if empty)")
|
||||
pkg_layout.addRow("dnf:", self.dnf_input)
|
||||
|
||||
layout.addWidget(pkg_group)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"System dependencies are installed via the system package manager\n"
|
||||
"(apt, brew, pacman, dnf). Users will be prompted to install missing\n"
|
||||
"packages when they install your tool from the registry."
|
||||
)
|
||||
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_dep(self, dep: SystemDependency):
|
||||
"""Load dependency data into form."""
|
||||
self.name_input.setText(dep.name)
|
||||
self.desc_input.setText(dep.description or "")
|
||||
|
||||
if dep.binaries:
|
||||
self.binaries_input.setText(", ".join(dep.binaries))
|
||||
|
||||
# Package overrides
|
||||
if dep.packages:
|
||||
self.apt_input.setText(dep.packages.get("apt", ""))
|
||||
self.brew_input.setText(dep.packages.get("brew", ""))
|
||||
self.pacman_input.setText(dep.packages.get("pacman", ""))
|
||||
self.dnf_input.setText(dep.packages.get("dnf", ""))
|
||||
|
||||
def _validate_and_accept(self):
|
||||
"""Validate input and accept."""
|
||||
name = self.name_input.text().strip()
|
||||
|
||||
if not name:
|
||||
self.name_input.setFocus()
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_dependency(self) -> SystemDependency:
|
||||
"""Get the dependency from form data."""
|
||||
name = self.name_input.text().strip()
|
||||
description = self.desc_input.text().strip()
|
||||
|
||||
# Parse binaries
|
||||
binaries_text = self.binaries_input.text().strip()
|
||||
binaries = []
|
||||
if binaries_text:
|
||||
binaries = [b.strip() for b in binaries_text.split(",") if b.strip()]
|
||||
|
||||
# Collect package overrides (only non-empty values)
|
||||
packages = {}
|
||||
if self.apt_input.text().strip():
|
||||
packages["apt"] = self.apt_input.text().strip()
|
||||
if self.brew_input.text().strip():
|
||||
packages["brew"] = self.brew_input.text().strip()
|
||||
if self.pacman_input.text().strip():
|
||||
packages["pacman"] = self.pacman_input.text().strip()
|
||||
if self.dnf_input.text().strip():
|
||||
packages["dnf"] = self.dnf_input.text().strip()
|
||||
|
||||
# Determine original format for round-trip serialization
|
||||
# Use short form if only name is provided
|
||||
original_format = "short" if (not description and not binaries and not packages) else "long"
|
||||
|
||||
return SystemDependency(
|
||||
name=name,
|
||||
description=description,
|
||||
binaries=binaries,
|
||||
packages=packages,
|
||||
_original_format=original_format
|
||||
)
|
||||
@@ -0,0 +1,576 @@
|
||||
"""Test Step Dialog for interactive step testing."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Union
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox,
|
||||
QLabel, QLineEdit, QPlainTextEdit, QPushButton, QComboBox,
|
||||
QTextEdit, QTableWidget, QTableWidgetItem, QHeaderView,
|
||||
QSplitter, QWidget, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
from ...tool import PromptStep, CodeStep, ToolStep
|
||||
from ...runner import execute_prompt_step, execute_code_step, execute_tool_step
|
||||
from ...providers import load_providers
|
||||
|
||||
|
||||
class StepTestWorker(QThread):
|
||||
"""Background worker for executing step tests."""
|
||||
finished = Signal(dict) # Emits result dict
|
||||
|
||||
def __init__(self, step: Union[PromptStep, CodeStep, ToolStep], variables: dict, provider_override: str = None):
|
||||
super().__init__()
|
||||
self.step = step
|
||||
self.variables = variables
|
||||
self.provider_override = provider_override
|
||||
|
||||
def run(self):
|
||||
result = {
|
||||
"success": False,
|
||||
"output": "",
|
||||
"output_vars": {},
|
||||
"error": None,
|
||||
"elapsed_ms": 0
|
||||
}
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if isinstance(self.step, PromptStep):
|
||||
output, success = execute_prompt_step(
|
||||
self.step, self.variables, self.provider_override
|
||||
)
|
||||
result["success"] = success
|
||||
result["output"] = output
|
||||
result["output_vars"] = {self.step.output_var: output}
|
||||
if not success:
|
||||
result["error"] = "Provider call failed"
|
||||
|
||||
elif isinstance(self.step, CodeStep):
|
||||
outputs, success = execute_code_step(
|
||||
self.step, self.variables, step_num=1
|
||||
)
|
||||
result["success"] = success
|
||||
result["output_vars"] = outputs
|
||||
result["output"] = "\n".join(f"{k} = {v}" for k, v in outputs.items())
|
||||
if not success:
|
||||
result["error"] = "Code execution failed"
|
||||
|
||||
elif isinstance(self.step, ToolStep):
|
||||
output, success = execute_tool_step(
|
||||
self.step, self.variables,
|
||||
depth=0,
|
||||
provider_override=self.provider_override,
|
||||
dry_run=False,
|
||||
verbose=False
|
||||
)
|
||||
result["success"] = success
|
||||
result["output"] = output
|
||||
result["output_vars"] = {self.step.output_var: output}
|
||||
if not success:
|
||||
result["error"] = f"Tool '{self.step.tool}' execution failed"
|
||||
|
||||
except Exception as e:
|
||||
result["success"] = False
|
||||
result["error"] = str(e)
|
||||
|
||||
result["elapsed_ms"] = int((time.time() - start_time) * 1000)
|
||||
self.finished.emit(result)
|
||||
|
||||
|
||||
class TestStepDialog(QDialog):
|
||||
"""Dialog for interactively testing a single step."""
|
||||
|
||||
# Assertion types available
|
||||
ASSERTION_TYPES = [
|
||||
("not_empty", "Not Empty", "Output must not be empty"),
|
||||
("contains", "Contains", "Output must contain the specified text"),
|
||||
("not_contains", "Does Not Contain", "Output must NOT contain the specified text"),
|
||||
("equals", "Equals", "Output must exactly equal the expected value"),
|
||||
("valid_json", "Valid JSON", "Output must be valid JSON"),
|
||||
("valid_python", "Valid Python", "Output must be valid Python syntax"),
|
||||
("matches_regex", "Matches Regex", "Output must match the regular expression"),
|
||||
("min_length", "Min Length", "Output must be at least N characters"),
|
||||
("max_length", "Max Length", "Output must be at most N characters"),
|
||||
]
|
||||
|
||||
def __init__(self, parent, step: Union[PromptStep, CodeStep, ToolStep], available_vars: list = None):
|
||||
super().__init__(parent)
|
||||
self.step = step
|
||||
self.available_vars = available_vars or ["input"]
|
||||
self._worker = None
|
||||
|
||||
# Determine step type for title
|
||||
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"
|
||||
|
||||
step_name = step.name if step.name else step_type
|
||||
self.setWindowTitle(f"Test Step: {step_name}")
|
||||
self.setMinimumSize(800, 700)
|
||||
|
||||
self._setup_ui()
|
||||
self._detect_variables()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the dialog UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# Main splitter: top (inputs) | bottom (output)
|
||||
splitter = QSplitter(Qt.Vertical)
|
||||
|
||||
# Top section: Variables and Assertions
|
||||
top_widget = QWidget()
|
||||
top_layout = QHBoxLayout(top_widget)
|
||||
top_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Left: Variables input
|
||||
vars_group = QGroupBox()
|
||||
vars_layout = QVBoxLayout(vars_group)
|
||||
|
||||
vars_title = QLabel("Input Variables")
|
||||
vars_title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
vars_title.setToolTip(
|
||||
"<p><b>Input Variables</b></p>"
|
||||
"<p>These are the variables referenced by this step using {variable} syntax.</p>"
|
||||
"<p>Enter test values to simulate what the step would receive during actual execution.</p>"
|
||||
"<p><b>{input}</b> - The main input text (stdin or piped content)<br>"
|
||||
"<b>{argname}</b> - Values from tool arguments<br>"
|
||||
"<b>{prev_output}</b> - Output from previous steps</p>"
|
||||
)
|
||||
vars_layout.addWidget(vars_title)
|
||||
|
||||
vars_help = QLabel("Provide test values for variables used in this step:")
|
||||
vars_help.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
vars_layout.addWidget(vars_help)
|
||||
|
||||
# Variables form
|
||||
self.vars_form = QFormLayout()
|
||||
self.vars_form.setSpacing(8)
|
||||
self.var_inputs = {} # variable name -> QLineEdit or QPlainTextEdit
|
||||
vars_layout.addLayout(self.vars_form)
|
||||
vars_layout.addStretch()
|
||||
|
||||
top_layout.addWidget(vars_group, 1)
|
||||
|
||||
# Right: Assertions
|
||||
assert_group = QGroupBox()
|
||||
assert_layout = QVBoxLayout(assert_group)
|
||||
|
||||
assert_title = QLabel("Assertions (Optional)")
|
||||
assert_title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
assert_title.setToolTip(
|
||||
"<p><b>Assertions</b></p>"
|
||||
"<p>Define checks to automatically validate the step output.</p>"
|
||||
"<p><b>Available assertion types:</b><br>"
|
||||
"• <b>Not Empty</b> - Output must contain text<br>"
|
||||
"• <b>Contains</b> - Output must include specific text<br>"
|
||||
"• <b>Does Not Contain</b> - Output must NOT include text<br>"
|
||||
"• <b>Equals</b> - Output must exactly match expected value<br>"
|
||||
"• <b>Valid JSON</b> - Output must be parseable JSON<br>"
|
||||
"• <b>Valid Python</b> - Output must be valid Python syntax<br>"
|
||||
"• <b>Matches Regex</b> - Output must match a pattern<br>"
|
||||
"• <b>Min/Max Length</b> - Output length constraints</p>"
|
||||
)
|
||||
assert_layout.addWidget(assert_title)
|
||||
|
||||
assert_help = QLabel("Define checks to validate the step output:")
|
||||
assert_help.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
assert_layout.addWidget(assert_help)
|
||||
|
||||
# Assertions table
|
||||
self.assertions_table = QTableWidget(0, 3)
|
||||
self.assertions_table.setHorizontalHeaderLabels(["Type", "Value", ""])
|
||||
self.assertions_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed)
|
||||
self.assertions_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.assertions_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed)
|
||||
self.assertions_table.setColumnWidth(0, 130)
|
||||
self.assertions_table.setColumnWidth(2, 40)
|
||||
self.assertions_table.verticalHeader().setVisible(False)
|
||||
self.assertions_table.setMaximumHeight(220)
|
||||
assert_layout.addWidget(self.assertions_table)
|
||||
|
||||
# Add assertion button
|
||||
btn_add_assertion = QPushButton("+ Add Assertion")
|
||||
btn_add_assertion.setToolTip("Add a new assertion to validate the step output")
|
||||
btn_add_assertion.clicked.connect(self._add_assertion_row)
|
||||
assert_layout.addWidget(btn_add_assertion)
|
||||
|
||||
top_layout.addWidget(assert_group, 1)
|
||||
|
||||
splitter.addWidget(top_widget)
|
||||
|
||||
# Bottom section: Controls and Output
|
||||
bottom_widget = QWidget()
|
||||
bottom_layout = QVBoxLayout(bottom_widget)
|
||||
bottom_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Controls row
|
||||
controls_layout = QHBoxLayout()
|
||||
|
||||
# Provider override (for prompt and tool steps)
|
||||
if isinstance(self.step, (PromptStep, ToolStep)):
|
||||
provider_label = QLabel("Provider:")
|
||||
provider_label.setToolTip("Override the AI provider for this test run")
|
||||
controls_layout.addWidget(provider_label)
|
||||
self.provider_combo = QComboBox()
|
||||
self.provider_combo.setToolTip(
|
||||
"<p><b>Provider Override</b></p>"
|
||||
"<p>Select a different provider for testing:</p>"
|
||||
"<p>• <b>(use step's default)</b> - Use the provider configured in the step<br>"
|
||||
"• <b>mock</b> - Fast testing without API calls (returns debug info)<br>"
|
||||
"• Other providers will make real API calls</p>"
|
||||
)
|
||||
self.provider_combo.addItem("(use step's default)")
|
||||
providers = load_providers()
|
||||
for provider in sorted(providers, key=lambda p: p.name):
|
||||
self.provider_combo.addItem(provider.name)
|
||||
# Add common defaults
|
||||
for default in ["mock"]:
|
||||
if self.provider_combo.findText(default) < 0:
|
||||
self.provider_combo.addItem(default)
|
||||
self.provider_combo.setMinimumWidth(150)
|
||||
controls_layout.addWidget(self.provider_combo)
|
||||
else:
|
||||
self.provider_combo = None
|
||||
|
||||
controls_layout.addStretch()
|
||||
|
||||
# Run button
|
||||
self.btn_run = QPushButton("Run Step")
|
||||
self.btn_run.setToolTip("Execute this step with the provided input variables and check assertions")
|
||||
self.btn_run.setMinimumHeight(36)
|
||||
self.btn_run.setMinimumWidth(120)
|
||||
self.btn_run.clicked.connect(self._run_test)
|
||||
controls_layout.addWidget(self.btn_run)
|
||||
|
||||
bottom_layout.addLayout(controls_layout)
|
||||
|
||||
# Output section
|
||||
output_group = QGroupBox()
|
||||
output_layout = QVBoxLayout(output_group)
|
||||
|
||||
output_title = QLabel("Output")
|
||||
output_title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
output_title.setToolTip(
|
||||
"<p><b>Output</b></p>"
|
||||
"<p>Shows the results after running the step:</p>"
|
||||
"<p>• <b>Status</b> - Success/failure and execution time<br>"
|
||||
"• <b>Output text</b> - The actual output produced by the step<br>"
|
||||
"• <b>Output variables</b> - Variables that would be available to subsequent steps<br>"
|
||||
"• <b>Assertion results</b> - Pass/fail status for each assertion</p>"
|
||||
)
|
||||
output_layout.addWidget(output_title)
|
||||
|
||||
# Status line
|
||||
self.status_label = QLabel("Click 'Run Step' to test this step")
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
output_layout.addWidget(self.status_label)
|
||||
|
||||
# Output display
|
||||
self.output_display = QTextEdit()
|
||||
self.output_display.setReadOnly(True)
|
||||
self.output_display.setPlaceholderText("Step output will appear here...")
|
||||
font = self.output_display.font()
|
||||
font.setFamily("Consolas, Monaco, monospace")
|
||||
self.output_display.setFont(font)
|
||||
output_layout.addWidget(self.output_display)
|
||||
|
||||
# Assertion results
|
||||
self.assertion_results = QLabel("")
|
||||
self.assertion_results.setWordWrap(True)
|
||||
output_layout.addWidget(self.assertion_results)
|
||||
|
||||
bottom_layout.addWidget(output_group)
|
||||
|
||||
splitter.addWidget(bottom_widget)
|
||||
splitter.setSizes([300, 400])
|
||||
|
||||
layout.addWidget(splitter)
|
||||
|
||||
# Dialog buttons
|
||||
buttons_layout = QHBoxLayout()
|
||||
buttons_layout.addStretch()
|
||||
|
||||
btn_close = QPushButton("Close")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
buttons_layout.addWidget(btn_close)
|
||||
|
||||
layout.addLayout(buttons_layout)
|
||||
|
||||
def _detect_variables(self):
|
||||
"""Detect variables used in the step and create input fields."""
|
||||
# Get template text based on step type
|
||||
template = ""
|
||||
if isinstance(self.step, PromptStep):
|
||||
template = self.step.prompt
|
||||
elif isinstance(self.step, CodeStep):
|
||||
template = self.step.code
|
||||
elif isinstance(self.step, ToolStep):
|
||||
template = self.step.input_template
|
||||
# Also add args values
|
||||
for value in self.step.args.values():
|
||||
template += " " + str(value)
|
||||
|
||||
# Find all {variable} references (excluding escaped {{ }})
|
||||
# Simple regex: match {word} but not {{ or }}
|
||||
var_pattern = r'\{([a-zA-Z_][a-zA-Z0-9_]*)\}'
|
||||
found_vars = set(re.findall(var_pattern, template))
|
||||
|
||||
# Combine with available_vars (from previous steps)
|
||||
all_vars = sorted(set(self.available_vars) | found_vars)
|
||||
|
||||
# Create input fields for each variable
|
||||
for var_name in all_vars:
|
||||
if var_name == "input":
|
||||
# Use multiline for input
|
||||
widget = QPlainTextEdit()
|
||||
widget.setPlaceholderText("Enter test input text...")
|
||||
widget.setMaximumHeight(80)
|
||||
else:
|
||||
widget = QLineEdit()
|
||||
widget.setPlaceholderText(f"Value for {{{var_name}}}")
|
||||
|
||||
self.var_inputs[var_name] = widget
|
||||
self.vars_form.addRow(f"{{{var_name}}}:", widget)
|
||||
|
||||
def _add_assertion_row(self):
|
||||
"""Add a new assertion row to the table."""
|
||||
row = self.assertions_table.rowCount()
|
||||
self.assertions_table.insertRow(row)
|
||||
|
||||
# Set row height tall enough for widgets
|
||||
self.assertions_table.setRowHeight(row, 50)
|
||||
|
||||
# Type dropdown
|
||||
type_combo = QComboBox()
|
||||
type_combo.setMinimumWidth(120)
|
||||
type_combo.setMinimumHeight(34)
|
||||
for type_id, display_name, tooltip in self.ASSERTION_TYPES:
|
||||
type_combo.addItem(display_name, type_id)
|
||||
idx = type_combo.count() - 1
|
||||
type_combo.setItemData(idx, tooltip, Qt.ToolTipRole)
|
||||
self.assertions_table.setCellWidget(row, 0, type_combo)
|
||||
|
||||
# Value input
|
||||
value_edit = QLineEdit()
|
||||
value_edit.setPlaceholderText("Expected value (if applicable)")
|
||||
value_edit.setMinimumHeight(34)
|
||||
self.assertions_table.setCellWidget(row, 1, value_edit)
|
||||
|
||||
# Remove button - red X for clear delete action
|
||||
btn_remove = QPushButton("✕")
|
||||
btn_remove.setToolTip("Remove this assertion")
|
||||
btn_remove.setFixedHeight(28)
|
||||
btn_remove.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #fed7d7;
|
||||
color: #c53030;
|
||||
border: 1px solid #fc8181;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
padding: 0px 6px;
|
||||
margin: 0px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #feb2b2;
|
||||
border-color: #f56565;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #fc8181;
|
||||
}
|
||||
""")
|
||||
btn_remove.clicked.connect(lambda: self._remove_assertion_row(row))
|
||||
self.assertions_table.setCellWidget(row, 2, btn_remove)
|
||||
|
||||
def _remove_assertion_row(self, row: int):
|
||||
"""Remove an assertion row."""
|
||||
self.assertions_table.removeRow(row)
|
||||
# Update remove button connections for remaining rows
|
||||
for i in range(self.assertions_table.rowCount()):
|
||||
btn = self.assertions_table.cellWidget(i, 2)
|
||||
if btn:
|
||||
btn.clicked.disconnect()
|
||||
btn.clicked.connect(lambda checked=False, r=i: self._remove_assertion_row(r))
|
||||
|
||||
def _get_assertions(self) -> list:
|
||||
"""Get list of assertions from the table."""
|
||||
assertions = []
|
||||
for row in range(self.assertions_table.rowCount()):
|
||||
type_combo = self.assertions_table.cellWidget(row, 0)
|
||||
value_edit = self.assertions_table.cellWidget(row, 1)
|
||||
if type_combo:
|
||||
assertions.append({
|
||||
"type": type_combo.currentData(),
|
||||
"display": type_combo.currentText(),
|
||||
"value": value_edit.text() if value_edit else ""
|
||||
})
|
||||
return assertions
|
||||
|
||||
def _run_test(self):
|
||||
"""Run the step test."""
|
||||
# Collect variable values
|
||||
variables = {}
|
||||
for var_name, widget in self.var_inputs.items():
|
||||
if isinstance(widget, QPlainTextEdit):
|
||||
variables[var_name] = widget.toPlainText()
|
||||
else:
|
||||
variables[var_name] = widget.text()
|
||||
|
||||
# Get provider override
|
||||
provider_override = None
|
||||
if self.provider_combo and self.provider_combo.currentIndex() > 0:
|
||||
provider_override = self.provider_combo.currentText()
|
||||
|
||||
# Disable run button and show loading
|
||||
self.btn_run.setEnabled(False)
|
||||
self.btn_run.setText("Running...")
|
||||
self.status_label.setText("Executing step...")
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
self.output_display.clear()
|
||||
self.assertion_results.clear()
|
||||
|
||||
# Start worker thread
|
||||
self._worker = StepTestWorker(self.step, variables, provider_override)
|
||||
self._worker.finished.connect(self._on_test_finished)
|
||||
self._worker.start()
|
||||
|
||||
def _on_test_finished(self, result: dict):
|
||||
"""Handle test completion."""
|
||||
self.btn_run.setEnabled(True)
|
||||
self.btn_run.setText("Run Step")
|
||||
|
||||
# Display result
|
||||
if result["success"]:
|
||||
self.status_label.setText(f"✓ Step completed in {result['elapsed_ms']}ms")
|
||||
self.status_label.setStyleSheet("color: #38a169; font-weight: bold;")
|
||||
|
||||
# Show output
|
||||
output_text = result["output"]
|
||||
if result["output_vars"]:
|
||||
output_text += "\n\n--- Output Variables ---\n"
|
||||
for var, value in result["output_vars"].items():
|
||||
preview = value[:200] + "..." if len(value) > 200 else value
|
||||
output_text += f"{var} = {preview}\n"
|
||||
self.output_display.setPlainText(output_text)
|
||||
else:
|
||||
self.status_label.setText(f"✗ Step failed ({result['elapsed_ms']}ms)")
|
||||
self.status_label.setStyleSheet("color: #e53e3e; font-weight: bold;")
|
||||
|
||||
error_text = result.get("error", "Unknown error")
|
||||
self.output_display.setHtml(f"<span style='color: #e53e3e;'><b>Error:</b> {error_text}</span>")
|
||||
|
||||
# Run assertions
|
||||
assertions = self._get_assertions()
|
||||
if assertions and result["success"]:
|
||||
self._run_assertions(result["output"], assertions)
|
||||
|
||||
def _run_assertions(self, output: str, assertions: list):
|
||||
"""Run assertions against the output."""
|
||||
results = []
|
||||
all_passed = True
|
||||
|
||||
for assertion in assertions:
|
||||
a_type = assertion["type"]
|
||||
a_value = assertion["value"]
|
||||
a_display = assertion["display"]
|
||||
passed = False
|
||||
message = ""
|
||||
|
||||
try:
|
||||
if a_type == "not_empty":
|
||||
passed = bool(output.strip())
|
||||
message = "Output is not empty" if passed else "Output is empty"
|
||||
|
||||
elif a_type == "contains":
|
||||
passed = a_value in output
|
||||
message = f"Output contains '{a_value}'" if passed else f"Output does not contain '{a_value}'"
|
||||
|
||||
elif a_type == "not_contains":
|
||||
passed = a_value not in output
|
||||
message = f"Output does not contain '{a_value}'" if passed else f"Output contains '{a_value}'"
|
||||
|
||||
elif a_type == "equals":
|
||||
passed = output.strip() == a_value.strip()
|
||||
message = "Output equals expected" if passed else "Output does not equal expected"
|
||||
|
||||
elif a_type == "valid_json":
|
||||
try:
|
||||
json.loads(output)
|
||||
passed = True
|
||||
message = "Output is valid JSON"
|
||||
except json.JSONDecodeError as e:
|
||||
passed = False
|
||||
message = f"Invalid JSON: {e}"
|
||||
|
||||
elif a_type == "valid_python":
|
||||
try:
|
||||
import ast
|
||||
ast.parse(output)
|
||||
passed = True
|
||||
message = "Output is valid Python"
|
||||
except SyntaxError as e:
|
||||
passed = False
|
||||
message = f"Invalid Python: {e}"
|
||||
|
||||
elif a_type == "matches_regex":
|
||||
try:
|
||||
passed = bool(re.search(a_value, output))
|
||||
message = f"Output matches regex" if passed else f"Output does not match regex"
|
||||
except re.error as e:
|
||||
passed = False
|
||||
message = f"Invalid regex: {e}"
|
||||
|
||||
elif a_type == "min_length":
|
||||
try:
|
||||
min_len = int(a_value)
|
||||
passed = len(output) >= min_len
|
||||
message = f"Length {len(output)} >= {min_len}" if passed else f"Length {len(output)} < {min_len}"
|
||||
except ValueError:
|
||||
passed = False
|
||||
message = "Invalid minimum length value"
|
||||
|
||||
elif a_type == "max_length":
|
||||
try:
|
||||
max_len = int(a_value)
|
||||
passed = len(output) <= max_len
|
||||
message = f"Length {len(output)} <= {max_len}" if passed else f"Length {len(output)} > {max_len}"
|
||||
except ValueError:
|
||||
passed = False
|
||||
message = "Invalid maximum length value"
|
||||
|
||||
except Exception as e:
|
||||
passed = False
|
||||
message = f"Error: {e}"
|
||||
|
||||
if not passed:
|
||||
all_passed = False
|
||||
|
||||
results.append((a_display, passed, message))
|
||||
|
||||
# Display results
|
||||
result_html = "<b>Assertion Results:</b><br>"
|
||||
for display, passed, message in results:
|
||||
icon = "✓" if passed else "✗"
|
||||
color = "#38a169" if passed else "#e53e3e"
|
||||
result_html += f"<span style='color: {color};'>{icon} {display}: {message}</span><br>"
|
||||
|
||||
if all_passed:
|
||||
result_html = f"<span style='color: #38a169; font-weight: bold;'>All {len(results)} assertion(s) passed!</span><br>" + result_html
|
||||
else:
|
||||
failed_count = sum(1 for _, p, _ in results if not p)
|
||||
result_html = f"<span style='color: #e53e3e; font-weight: bold;'>{failed_count} of {len(results)} assertion(s) failed</span><br>" + result_html
|
||||
|
||||
self.assertion_results.setText(result_html)
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Main window for CmdForge GUI."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
|
||||
QListWidget, QListWidgetItem, QStackedWidget,
|
||||
QStatusBar, QLabel, QSplitter, QMenuBar, QMenu
|
||||
)
|
||||
from PySide6.QtCore import Qt, QSize, QSettings, QTimer
|
||||
from PySide6.QtGui import QIcon, QFont, QShortcut, QKeySequence, QAction, QActionGroup
|
||||
|
||||
from .styles import load_theme, get_available_themes, get_custom_theme_path
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Settings for persistence
|
||||
self._settings = QSettings("CmdForge", "CmdForge")
|
||||
|
||||
# Load and apply theme
|
||||
self._current_theme = self._settings.value("theme", "light")
|
||||
self._apply_theme(self._current_theme)
|
||||
|
||||
# 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()
|
||||
|
||||
# Setup menu bar
|
||||
self._setup_menu_bar()
|
||||
|
||||
# Restore window geometry
|
||||
self._restore_geometry()
|
||||
|
||||
# Validate registry connection after window shows
|
||||
QTimer.singleShot(500, self._validate_registry_connection)
|
||||
|
||||
def _validate_registry_connection(self):
|
||||
"""Check if registry connection is valid on startup, clear if invalid."""
|
||||
try:
|
||||
from ..registry_client import RegistryClient
|
||||
from ..config import set_registry_token, load_config
|
||||
|
||||
config = load_config()
|
||||
if not config.registry.token:
|
||||
return # No connection configured, nothing to validate
|
||||
|
||||
client = RegistryClient()
|
||||
is_valid, error = client.validate_token()
|
||||
|
||||
if not is_valid:
|
||||
# Token is invalid, clear it
|
||||
set_registry_token(None)
|
||||
self.status_bar.showMessage(
|
||||
f"Registry connection cleared: {error}. Please reconnect.",
|
||||
10000 # Show for 10 seconds
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't crash on validation errors, just log to status
|
||||
self.status_bar.showMessage(f"Could not validate registry connection: {e}", 5000)
|
||||
|
||||
def _setup_sidebar(self):
|
||||
"""Set up sidebar navigation items."""
|
||||
items = [
|
||||
("CmdForge", "Welcome page - Quick actions and getting started (Ctrl+1)"),
|
||||
("Tools", "View, edit, delete, and test your local tools (Ctrl+2)"),
|
||||
("Collections", "Manage and publish tool collections (Ctrl+3)"),
|
||||
("Registry", "Browse and install tools from the community (Ctrl+4)"),
|
||||
("Providers", "Configure AI backends like Claude, GPT, etc. (Ctrl+5)"),
|
||||
("Profiles", "AI persona profiles for customizing tool behavior (Ctrl+6)"),
|
||||
]
|
||||
|
||||
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.collections_page import CollectionsPage
|
||||
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.collections_page = CollectionsPage(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.collections_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,
|
||||
"collections": 2,
|
||||
"registry": 3,
|
||||
"providers": 4,
|
||||
"profiles": 5,
|
||||
}
|
||||
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 - set both sidebar and stacked widget explicitly
|
||||
# to avoid race conditions with widget removal
|
||||
self.pages.setCurrentWidget(self.tools_page)
|
||||
self.sidebar.setCurrentRow(1)
|
||||
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/5/6: 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))
|
||||
|
||||
shortcut_page5 = QShortcut(QKeySequence("Ctrl+5"), self)
|
||||
shortcut_page5.activated.connect(lambda: self.sidebar.setCurrentRow(4))
|
||||
|
||||
shortcut_page6 = QShortcut(QKeySequence("Ctrl+6"), self)
|
||||
shortcut_page6.activated.connect(lambda: self.sidebar.setCurrentRow(5))
|
||||
|
||||
# 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)
|
||||
|
||||
# F1: Help
|
||||
shortcut_help = QShortcut(QKeySequence("F1"), self)
|
||||
shortcut_help.activated.connect(self._show_getting_started)
|
||||
|
||||
def _apply_theme(self, theme_name: str):
|
||||
"""Apply a theme to the application."""
|
||||
stylesheet = load_theme(theme_name)
|
||||
self.setStyleSheet(stylesheet)
|
||||
self._current_theme = theme_name
|
||||
self._settings.setValue("theme", theme_name)
|
||||
|
||||
def _setup_menu_bar(self):
|
||||
"""Set up the menu bar with Tools, View and Help menus."""
|
||||
menubar = self.menuBar()
|
||||
|
||||
# Tools menu
|
||||
tools_menu = menubar.addMenu("&Tools")
|
||||
|
||||
# Install Provider
|
||||
action_install_provider = QAction("&Install AI Provider...", self)
|
||||
action_install_provider.triggered.connect(self._show_provider_install)
|
||||
tools_menu.addAction(action_install_provider)
|
||||
|
||||
tools_menu.addSeparator()
|
||||
|
||||
# New Tool
|
||||
action_new_tool = QAction("&New Tool", self)
|
||||
action_new_tool.setShortcut("Ctrl+N")
|
||||
action_new_tool.triggered.connect(self._shortcut_new_tool)
|
||||
tools_menu.addAction(action_new_tool)
|
||||
|
||||
# View menu
|
||||
view_menu = menubar.addMenu("&View")
|
||||
|
||||
# Theme submenu
|
||||
theme_menu = view_menu.addMenu("&Theme")
|
||||
theme_group = QActionGroup(self)
|
||||
theme_group.setExclusive(True)
|
||||
|
||||
# Add theme options
|
||||
for theme_name in get_available_themes():
|
||||
display_name = theme_name.capitalize()
|
||||
if theme_name == "custom":
|
||||
display_name = f"Custom ({get_custom_theme_path().name})"
|
||||
|
||||
action = QAction(display_name, self)
|
||||
action.setCheckable(True)
|
||||
action.setChecked(theme_name == self._current_theme)
|
||||
action.setData(theme_name)
|
||||
# Use default argument to capture theme_name by value
|
||||
action.triggered.connect(lambda checked=False, t=theme_name: self._apply_theme(t))
|
||||
theme_group.addAction(action)
|
||||
theme_menu.addAction(action)
|
||||
|
||||
# Add hint about custom themes
|
||||
theme_menu.addSeparator()
|
||||
hint_action = QAction(f"Custom: ~/.cmdforge/theme.qss", self)
|
||||
hint_action.setEnabled(False)
|
||||
theme_menu.addAction(hint_action)
|
||||
|
||||
view_menu.addSeparator()
|
||||
|
||||
# Refresh action
|
||||
action_refresh = QAction("&Refresh", self)
|
||||
action_refresh.setShortcut("Ctrl+R")
|
||||
action_refresh.triggered.connect(self._shortcut_refresh)
|
||||
view_menu.addAction(action_refresh)
|
||||
|
||||
# Help menu
|
||||
help_menu = menubar.addMenu("&Help")
|
||||
|
||||
# Getting Started
|
||||
action_getting_started = QAction("&Getting Started", self)
|
||||
action_getting_started.setShortcut("F1")
|
||||
action_getting_started.triggered.connect(self._show_getting_started)
|
||||
help_menu.addAction(action_getting_started)
|
||||
|
||||
help_menu.addSeparator()
|
||||
|
||||
# Quick Guides
|
||||
action_create_tool = QAction("How to &Create a Tool", self)
|
||||
action_create_tool.triggered.connect(self._show_create_tool_guide)
|
||||
help_menu.addAction(action_create_tool)
|
||||
|
||||
action_install_tools = QAction("How to &Install Tools", self)
|
||||
action_install_tools.triggered.connect(self._show_install_tools_guide)
|
||||
help_menu.addAction(action_install_tools)
|
||||
|
||||
action_publish = QAction("How to &Publish Tools", self)
|
||||
action_publish.triggered.connect(self._show_publish_guide)
|
||||
help_menu.addAction(action_publish)
|
||||
|
||||
help_menu.addSeparator()
|
||||
|
||||
# Keyboard Shortcuts
|
||||
action_shortcuts = QAction("&Keyboard Shortcuts", self)
|
||||
action_shortcuts.triggered.connect(self._show_keyboard_shortcuts)
|
||||
help_menu.addAction(action_shortcuts)
|
||||
|
||||
help_menu.addSeparator()
|
||||
|
||||
# About
|
||||
action_about = QAction("&About CmdForge", self)
|
||||
action_about.triggered.connect(self._show_about)
|
||||
help_menu.addAction(action_about)
|
||||
|
||||
def _show_getting_started(self):
|
||||
"""Show Getting Started guide."""
|
||||
from .dialogs.help_dialogs import QuickGuideDialog
|
||||
dialog = QuickGuideDialog("getting_started", self)
|
||||
dialog.exec()
|
||||
|
||||
def _show_create_tool_guide(self):
|
||||
"""Show Create Tool guide."""
|
||||
from .dialogs.help_dialogs import QuickGuideDialog
|
||||
dialog = QuickGuideDialog("create_tool", self)
|
||||
dialog.exec()
|
||||
|
||||
def _show_install_tools_guide(self):
|
||||
"""Show Install Tools guide."""
|
||||
from .dialogs.help_dialogs import QuickGuideDialog
|
||||
dialog = QuickGuideDialog("install_tools", self)
|
||||
dialog.exec()
|
||||
|
||||
def _show_publish_guide(self):
|
||||
"""Show Publish Tools guide."""
|
||||
from .dialogs.help_dialogs import QuickGuideDialog
|
||||
dialog = QuickGuideDialog("publish_tools", self)
|
||||
dialog.exec()
|
||||
|
||||
def _show_keyboard_shortcuts(self):
|
||||
"""Show Keyboard Shortcuts guide."""
|
||||
from .dialogs.help_dialogs import QuickGuideDialog
|
||||
dialog = QuickGuideDialog("keyboard_shortcuts", self)
|
||||
dialog.exec()
|
||||
|
||||
def _show_about(self):
|
||||
"""Show About dialog."""
|
||||
from .dialogs.help_dialogs import AboutDialog
|
||||
dialog = AboutDialog(self)
|
||||
dialog.exec()
|
||||
|
||||
def _show_provider_install(self):
|
||||
"""Show Provider Install dialog."""
|
||||
from .dialogs.provider_install_dialog import ProviderInstallDialog
|
||||
dialog = ProviderInstallDialog(self)
|
||||
if dialog.exec():
|
||||
# Refresh providers page if we added any
|
||||
self.providers_page.refresh()
|
||||
self.show_status("Providers updated")
|
||||
|
||||
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"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,230 @@
|
||||
"""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_install = QPushButton("Install Provider...")
|
||||
self.btn_install.setToolTip("Install a new AI provider CLI tool")
|
||||
self.btn_install.clicked.connect(self._install_provider)
|
||||
header_layout.addWidget(self.btn_install)
|
||||
|
||||
self.btn_add = QPushButton("Add Provider")
|
||||
self.btn_add.setToolTip("Manually add a provider configuration")
|
||||
self.btn_add.clicked.connect(self._add_provider)
|
||||
header_layout.addWidget(self.btn_add)
|
||||
|
||||
layout.addWidget(header)
|
||||
|
||||
# Description
|
||||
desc = QLabel(
|
||||
"Providers connect CmdForge tools to AI CLIs or compatible HTTP APIs. "
|
||||
"CLI providers accept input on stdin and write responses to stdout."
|
||||
)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #718096;")
|
||||
layout.addWidget(desc)
|
||||
|
||||
# Providers table
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(4)
|
||||
self.table.setHorizontalHeaderLabels(["Name", "Command", "Description", "Fallback"])
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
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.table.setItem(row, 3, QTableWidgetItem(provider.fallback 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 _install_provider(self):
|
||||
"""Open the provider installation wizard."""
|
||||
from ..dialogs.provider_install_dialog import ProviderInstallDialog
|
||||
dialog = ProviderInstallDialog(self)
|
||||
if dialog.exec():
|
||||
self.refresh()
|
||||
self.main_window.show_status("Providers updated")
|
||||
|
||||
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}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,937 @@
|
||||
"""Theme stylesheets for CmdForge GUI."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Light theme (default)
|
||||
LIGHT_THEME = """
|
||||
/* 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;
|
||||
}
|
||||
|
||||
QLabel#sectionHeading {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Menu bar */
|
||||
QMenuBar {
|
||||
background-color: #f7fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
QMenuBar::item {
|
||||
padding: 6px 12px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QMenuBar::item:selected {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
QMenu {
|
||||
background-color: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
QMenu::item {
|
||||
padding: 8px 24px;
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background-color: #e2e8f0;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
"""
|
||||
|
||||
# Dark theme
|
||||
DARK_THEME = """
|
||||
/* Main Window */
|
||||
QMainWindow {
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
background-color: #16213e;
|
||||
border: none;
|
||||
min-width: 180px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
#sidebar::item {
|
||||
color: #a0aec0;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
#sidebar::item:selected {
|
||||
background-color: #1f3460;
|
||||
border-left: 3px solid #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#sidebar::item:hover:!selected {
|
||||
background-color: #1a2d50;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
QWidget#content_area {
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* 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: #7c8ff0;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #5a67d8;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
background-color: #4a5568;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
QPushButton#secondary {
|
||||
background-color: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QPushButton#secondary:hover {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
|
||||
QPushButton#danger {
|
||||
background-color: #e53e3e;
|
||||
}
|
||||
|
||||
QPushButton#danger:hover {
|
||||
background-color: #fc5c65;
|
||||
}
|
||||
|
||||
/* View toggle buttons */
|
||||
QPushButton#viewToggle {
|
||||
background-color: #2d3748;
|
||||
color: #a0aec0;
|
||||
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: #4a5568;
|
||||
}
|
||||
|
||||
/* Input fields */
|
||||
QLineEdit, QTextEdit, QPlainTextEdit {
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: #2d3748;
|
||||
color: #e2e8f0;
|
||||
selection-background-color: #667eea;
|
||||
}
|
||||
|
||||
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {
|
||||
border-color: #667eea;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Combo boxes */
|
||||
QComboBox {
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: #2d3748;
|
||||
color: #e2e8f0;
|
||||
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 #a0aec0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
border: 1px solid #4a5568;
|
||||
background-color: #2d3748;
|
||||
color: #e2e8f0;
|
||||
selection-background-color: #667eea;
|
||||
}
|
||||
|
||||
/* Lists and Trees */
|
||||
QListWidget, QTreeWidget, QTableWidget {
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 6px;
|
||||
background-color: #2d3748;
|
||||
color: #e2e8f0;
|
||||
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: #4a5568;
|
||||
}
|
||||
|
||||
/* Table Widget */
|
||||
QTableWidget {
|
||||
gridline-color: #4a5568;
|
||||
}
|
||||
|
||||
QTableWidget::item {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
QTableWidget::item:selected {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #1a1a2e;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #4a5568;
|
||||
font-weight: 600;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* Group boxes */
|
||||
QGroupBox {
|
||||
font-weight: 600;
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
background-color: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
left: 12px;
|
||||
padding: 0 8px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
QLabel {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QLabel#heading {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
QLabel#subheading {
|
||||
font-size: 14px;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
QLabel#label {
|
||||
font-weight: 500;
|
||||
color: #cbd5e0;
|
||||
}
|
||||
|
||||
QLabel#sectionHeading {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
QScrollBar:vertical {
|
||||
background-color: #1a1a2e;
|
||||
width: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background-color: #4a5568;
|
||||
border-radius: 6px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background-color: #718096;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background-color: #1a1a2e;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background-color: #4a5568;
|
||||
border-radius: 6px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background-color: #718096;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
QStatusBar {
|
||||
background-color: #16213e;
|
||||
border-top: 1px solid #4a5568;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* Splitter */
|
||||
QSplitter::handle {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
|
||||
QSplitter::handle:horizontal {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 6px;
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #1a1a2e;
|
||||
border: 1px solid #4a5568;
|
||||
border-bottom: none;
|
||||
padding: 8px 16px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: #2d3748;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: #16213e;
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
QDialog {
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Message boxes */
|
||||
QMessageBox {
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
|
||||
QMessageBox QPushButton {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Tool tips */
|
||||
QToolTip {
|
||||
background-color: #4a5568;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
QProgressBar {
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 6px;
|
||||
background-color: #2d3748;
|
||||
text-align: center;
|
||||
height: 20px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background-color: #667eea;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Spin boxes */
|
||||
QSpinBox, QDoubleSpinBox {
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QSpinBox:focus, QDoubleSpinBox:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* Check boxes and radio buttons */
|
||||
QCheckBox, QRadioButton {
|
||||
spacing: 8px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QCheckBox::indicator, QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #4a5568;
|
||||
border-radius: 4px;
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 4px;
|
||||
background-color: #667eea;
|
||||
}
|
||||
|
||||
QRadioButton::indicator:unchecked {
|
||||
border: 2px solid #4a5568;
|
||||
border-radius: 9px;
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
QRadioButton::indicator:checked {
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 9px;
|
||||
background-color: #667eea;
|
||||
}
|
||||
|
||||
/* Menu bar */
|
||||
QMenuBar {
|
||||
background-color: #16213e;
|
||||
border-bottom: 1px solid #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QMenuBar::item {
|
||||
padding: 6px 12px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QMenuBar::item:selected {
|
||||
background-color: #1f3460;
|
||||
}
|
||||
|
||||
QMenu {
|
||||
background-color: #2d3748;
|
||||
border: 1px solid #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
QMenu::item {
|
||||
padding: 8px 24px;
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background-color: #4a5568;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
"""
|
||||
|
||||
# Available themes
|
||||
THEMES = {
|
||||
"light": LIGHT_THEME,
|
||||
"dark": DARK_THEME,
|
||||
}
|
||||
|
||||
# For backwards compatibility
|
||||
STYLESHEET = LIGHT_THEME
|
||||
|
||||
|
||||
def get_custom_theme_path() -> Path:
|
||||
"""Get path to custom theme file."""
|
||||
return Path.home() / ".cmdforge" / "theme.qss"
|
||||
|
||||
|
||||
def load_theme(theme_name: str = "light") -> str:
|
||||
"""
|
||||
Load a theme stylesheet.
|
||||
|
||||
Args:
|
||||
theme_name: Name of theme ("light", "dark") or "custom" for external file
|
||||
|
||||
Returns:
|
||||
The stylesheet string
|
||||
"""
|
||||
# Check for custom theme file first
|
||||
custom_path = get_custom_theme_path()
|
||||
if theme_name == "custom" and custom_path.exists():
|
||||
try:
|
||||
return custom_path.read_text()
|
||||
except Exception:
|
||||
pass # Fall back to light theme
|
||||
|
||||
# Return built-in theme
|
||||
return THEMES.get(theme_name, LIGHT_THEME)
|
||||
|
||||
|
||||
def get_available_themes() -> list:
|
||||
"""Get list of available theme names."""
|
||||
themes = list(THEMES.keys())
|
||||
if get_custom_theme_path().exists():
|
||||
themes.append("custom")
|
||||
return themes
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Custom widgets for CmdForge GUI."""
|
||||
|
||||
from .flow_graph import FlowGraphWidget
|
||||
|
||||
__all__ = ['FlowGraphWidget']
|
||||
@@ -0,0 +1,535 @@
|
||||
"""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, ToolStep
|
||||
from .icons import (
|
||||
get_prompt_icon_path, get_code_icon_path,
|
||||
get_input_icon_path, get_output_icon_path, get_tool_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 ToolNode(CmdForgeBaseNode):
|
||||
"""Node representing a tool step (calling another tool)."""
|
||||
|
||||
NODE_NAME = 'Tool'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_color(159, 122, 234) # Purple
|
||||
self.set_icon(get_tool_icon_path(16))
|
||||
self.add_input('in', color=(220, 180, 250))
|
||||
self.add_output('out', color=(250, 180, 220))
|
||||
|
||||
# Add properties
|
||||
self.add_text_input('tool_ref', 'Tool', text='')
|
||||
self.add_text_input('output_var', 'Output', text='tool_output')
|
||||
|
||||
def set_step(self, step: ToolStep, index: int):
|
||||
"""Configure node from a ToolStep."""
|
||||
self._step_data = step
|
||||
self._step_index = index
|
||||
self.set_property('tool_ref', step.tool or '')
|
||||
self.set_property('output_var', step.output_var or 'tool_output')
|
||||
# Update output port name
|
||||
if self.output_ports():
|
||||
self.output_ports()[0]._name = step.output_var or 'tool_output'
|
||||
|
||||
|
||||
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(ToolNode)
|
||||
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
|
||||
tool_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)
|
||||
elif isinstance(step, ToolStep):
|
||||
tool_count += 1
|
||||
# Use custom name if set, otherwise default to "Tool N" (per type)
|
||||
node_name = step.name if step.name else f'Tool {tool_count}'
|
||||
node = self._graph.create_node(
|
||||
'cmdforge.ToolNode',
|
||||
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:
|
||||
if isinstance(node, PromptNode):
|
||||
step_type = 'prompt'
|
||||
elif isinstance(node, ToolNode):
|
||||
step_type = 'tool'
|
||||
else:
|
||||
step_type = '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,335 @@
|
||||
"""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]
|
||||
|
||||
|
||||
def create_tool_icon(size: int = 24, color: QColor = None) -> QIcon:
|
||||
"""Create a puzzle piece icon for tool steps (calling another tool).
|
||||
|
||||
Args:
|
||||
size: Icon size in pixels
|
||||
color: Icon color (defaults to purple #9f7aea)
|
||||
"""
|
||||
if color is None:
|
||||
color = QColor(159, 122, 234) # Purple
|
||||
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
pen = QPen(color, 1.5)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(QBrush(color))
|
||||
|
||||
# Draw a simple puzzle piece shape
|
||||
margin = size * 0.12
|
||||
w = size - 2 * margin
|
||||
h = size - 2 * margin
|
||||
|
||||
path = QPainterPath()
|
||||
# Start from top-left, going clockwise
|
||||
path.moveTo(margin, margin + h * 0.3)
|
||||
# Top edge with bump
|
||||
path.lineTo(margin + w * 0.35, margin + h * 0.3)
|
||||
path.arcTo(QRectF(margin + w * 0.35 - w * 0.1, margin, w * 0.3, h * 0.3), 180, -180)
|
||||
path.lineTo(margin + w, margin + h * 0.3)
|
||||
# Right edge
|
||||
path.lineTo(margin + w, margin + h * 0.7)
|
||||
# Bottom edge with notch
|
||||
path.lineTo(margin + w * 0.65, margin + h * 0.7)
|
||||
path.arcTo(QRectF(margin + w * 0.35, margin + h * 0.7, w * 0.3, h * 0.3), 0, 180)
|
||||
path.lineTo(margin, margin + h * 0.7)
|
||||
# Left edge back to start
|
||||
path.closeSubpath()
|
||||
|
||||
painter.drawPath(path)
|
||||
painter.end()
|
||||
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def get_tool_icon(size: int = 24) -> QIcon:
|
||||
"""Get cached tool icon."""
|
||||
key = ('tool', size)
|
||||
if key not in _icon_cache:
|
||||
_icon_cache[key] = create_tool_icon(size)
|
||||
return _icon_cache[key]
|
||||
|
||||
|
||||
def get_tool_icon_path(size: int = 16) -> str:
|
||||
"""Get file path to tool icon (for NodeGraphQt)."""
|
||||
return _get_icon_path('tool', size, create_tool_icon)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Content hash utilities for tool integrity verification.
|
||||
|
||||
This module provides consistent SHA256 hashing for tool content,
|
||||
used for:
|
||||
- Publish state tracking (detect local modifications)
|
||||
- Download integrity verification
|
||||
- Registry content verification
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Optional, Dict, Any
|
||||
import yaml
|
||||
|
||||
|
||||
def compute_config_hash(config: Dict[str, Any], exclude_fields: Optional[list] = None) -> str:
|
||||
"""Compute SHA256 hash of tool configuration.
|
||||
|
||||
The hash is computed from a normalized YAML representation to ensure
|
||||
consistent hashing regardless of field order or formatting.
|
||||
|
||||
Args:
|
||||
config: Tool configuration dictionary
|
||||
exclude_fields: Fields to exclude from hashing (e.g., 'published_hash', 'registry_hash')
|
||||
|
||||
Returns:
|
||||
Hash string in format "sha256:<64-char-hex>"
|
||||
"""
|
||||
if exclude_fields is None:
|
||||
# Exclude registry metadata and publication metadata from hash
|
||||
# These fields are added during publish but aren't part of tool content
|
||||
exclude_fields = [
|
||||
'published_hash', 'registry_hash', 'registry_status', 'registry_feedback', # registry metadata
|
||||
'version', 'tags', # publication metadata (added by publish dialog)
|
||||
]
|
||||
|
||||
# Create a copy without excluded fields
|
||||
config_copy = {k: v for k, v in config.items() if k not in exclude_fields}
|
||||
|
||||
# Normalize to YAML with sorted keys for consistent ordering
|
||||
normalized = yaml.dump(config_copy, sort_keys=True, default_flow_style=False)
|
||||
|
||||
# Compute SHA256
|
||||
hash_bytes = hashlib.sha256(normalized.encode('utf-8')).hexdigest()
|
||||
|
||||
return f"sha256:{hash_bytes}"
|
||||
|
||||
|
||||
def compute_yaml_hash(yaml_content: str, exclude_fields: Optional[list] = None) -> str:
|
||||
"""Compute SHA256 hash of YAML content string.
|
||||
|
||||
Parses the YAML, normalizes it, and computes hash.
|
||||
Useful for hashing raw config.yaml content.
|
||||
|
||||
Args:
|
||||
yaml_content: Raw YAML string
|
||||
exclude_fields: Fields to exclude from hashing
|
||||
|
||||
Returns:
|
||||
Hash string in format "sha256:<64-char-hex>"
|
||||
"""
|
||||
config = yaml.safe_load(yaml_content)
|
||||
if config is None:
|
||||
config = {}
|
||||
return compute_config_hash(config, exclude_fields)
|
||||
|
||||
|
||||
def compute_file_hash(file_path: str) -> str:
|
||||
"""Compute SHA256 hash of a file's contents.
|
||||
|
||||
Used for hashing pattern files (e.g., Fabric's system.md).
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
|
||||
Returns:
|
||||
Hash string in format "sha256:<64-char-hex>"
|
||||
"""
|
||||
with open(file_path, 'rb') as f:
|
||||
hash_bytes = hashlib.sha256(f.read()).hexdigest()
|
||||
return f"sha256:{hash_bytes}"
|
||||
|
||||
|
||||
def verify_hash(content: str, expected_hash: str) -> bool:
|
||||
"""Verify content matches expected hash.
|
||||
|
||||
Args:
|
||||
content: Content to verify (YAML string)
|
||||
expected_hash: Expected hash in format "sha256:<hex>"
|
||||
|
||||
Returns:
|
||||
True if hash matches, False otherwise
|
||||
"""
|
||||
if not expected_hash or not expected_hash.startswith("sha256:"):
|
||||
return False
|
||||
|
||||
computed = compute_yaml_hash(content)
|
||||
return computed == expected_hash
|
||||
|
||||
|
||||
def extract_hash_value(hash_string: str) -> Optional[str]:
|
||||
"""Extract the hex value from a hash string.
|
||||
|
||||
Args:
|
||||
hash_string: Hash in format "sha256:<hex>"
|
||||
|
||||
Returns:
|
||||
The hex value without prefix, or None if invalid
|
||||
"""
|
||||
if not hash_string or not hash_string.startswith("sha256:"):
|
||||
return None
|
||||
return hash_string[7:] # Remove "sha256:" prefix
|
||||
|
||||
|
||||
def short_hash(hash_string: str, length: int = 8) -> str:
|
||||
"""Get a shortened version of a hash for display.
|
||||
|
||||
Args:
|
||||
hash_string: Full hash string
|
||||
length: Number of hex chars to include
|
||||
|
||||
Returns:
|
||||
Shortened hash (e.g., "sha256:abc123...")
|
||||
"""
|
||||
hex_value = extract_hash_value(hash_string)
|
||||
if hex_value:
|
||||
return f"sha256:{hex_value[:length]}..."
|
||||
return hash_string[:length + 10] + "..."
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Lock file for reproducible dependency installation.
|
||||
|
||||
Provides cmdforge.lock file support for:
|
||||
- Recording exact versions of all dependencies (direct + transitive)
|
||||
- Reproducible installs across machines
|
||||
- Integrity verification via content hashes
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class LockedPackage:
|
||||
"""A locked dependency with exact version and integrity."""
|
||||
name: str # Qualified: owner/name
|
||||
version: str # Exact resolved version
|
||||
constraint: str # Original constraint
|
||||
integrity: str # sha256 hash (from registry config_hash)
|
||||
source: str # "registry", "local", "global"
|
||||
direct: bool # True if in manifest
|
||||
required_by: List[str] = field(default_factory=list) # Parent packages
|
||||
path: Optional[str] = None # Relative path for local tools
|
||||
|
||||
@property
|
||||
def owner(self) -> str:
|
||||
"""Extract owner from qualified name."""
|
||||
return self.name.split("/")[0] if "/" in self.name else ""
|
||||
|
||||
@property
|
||||
def tool_name(self) -> str:
|
||||
"""Extract tool name from qualified name."""
|
||||
return self.name.split("/")[1] if "/" in self.name else self.name
|
||||
|
||||
|
||||
@dataclass
|
||||
class LockfileMetadata:
|
||||
"""Metadata about when/how the lock file was generated."""
|
||||
generated_at: str
|
||||
cmdforge_version: str
|
||||
manifest_hash: str
|
||||
platform: str
|
||||
python_version: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lockfile:
|
||||
"""Complete lock file representation."""
|
||||
lockfile_version: int = 1
|
||||
metadata: Optional[LockfileMetadata] = None
|
||||
packages: Dict[str, LockedPackage] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path = None) -> Optional["Lockfile"]:
|
||||
"""Load lock file from disk.
|
||||
|
||||
Args:
|
||||
path: Path to lock file (default: ./cmdforge.lock)
|
||||
|
||||
Returns:
|
||||
Lockfile object, or None if not found
|
||||
"""
|
||||
path = path or Path("cmdforge.lock")
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return cls._from_dict(data)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load lock file: {e}")
|
||||
return None
|
||||
|
||||
def save(self, path: Path = None) -> None:
|
||||
"""Save lock file to disk.
|
||||
|
||||
Args:
|
||||
path: Path to save to (default: ./cmdforge.lock)
|
||||
"""
|
||||
path = path or Path("cmdforge.lock")
|
||||
with open(path, "w") as f:
|
||||
# Add header comment
|
||||
f.write("# cmdforge.lock\n")
|
||||
f.write("# Auto-generated by 'cmdforge lock' - do not edit manually\n")
|
||||
f.write("# To update: cmdforge lock --force\n\n")
|
||||
yaml.safe_dump(
|
||||
self._to_dict(),
|
||||
f,
|
||||
sort_keys=False,
|
||||
default_flow_style=False
|
||||
)
|
||||
|
||||
def get_package(self, name: str) -> Optional[LockedPackage]:
|
||||
"""Get a locked package by qualified name."""
|
||||
return self.packages.get(name)
|
||||
|
||||
def is_stale(self, manifest_path: Path = None) -> bool:
|
||||
"""Check if lock file is outdated relative to manifest.
|
||||
|
||||
Note: This only checks if the manifest hash changed.
|
||||
It does NOT detect if local tools were edited after locking.
|
||||
Use verify_lockfile() for full integrity checking.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest (default: ./cmdforge.yaml)
|
||||
|
||||
Returns:
|
||||
True if lock file is stale
|
||||
"""
|
||||
manifest_path = manifest_path or Path("cmdforge.yaml")
|
||||
if not manifest_path.exists():
|
||||
return True
|
||||
if not self.metadata:
|
||||
return True
|
||||
current_hash = compute_file_hash(manifest_path)
|
||||
return current_hash != self.metadata.manifest_hash
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, data: dict) -> "Lockfile":
|
||||
"""Parse lock file from dict."""
|
||||
metadata = None
|
||||
if "metadata" in data:
|
||||
m = data["metadata"]
|
||||
metadata = LockfileMetadata(
|
||||
generated_at=m.get("generated_at", ""),
|
||||
cmdforge_version=m.get("cmdforge_version", ""),
|
||||
manifest_hash=m.get("manifest_hash", ""),
|
||||
platform=m.get("platform", ""),
|
||||
python_version=m.get("python_version", "")
|
||||
)
|
||||
|
||||
packages = {}
|
||||
for name, pkg_data in data.get("packages", {}).items():
|
||||
packages[name] = LockedPackage(
|
||||
name=name,
|
||||
version=pkg_data.get("version", ""),
|
||||
constraint=pkg_data.get("constraint", "*"),
|
||||
integrity=pkg_data.get("integrity", ""),
|
||||
source=pkg_data.get("source", "registry"),
|
||||
direct=pkg_data.get("direct", False),
|
||||
required_by=pkg_data.get("required_by", []),
|
||||
path=pkg_data.get("path")
|
||||
)
|
||||
|
||||
return cls(
|
||||
lockfile_version=data.get("lockfile_version", 1),
|
||||
metadata=metadata,
|
||||
packages=packages
|
||||
)
|
||||
|
||||
def _to_dict(self) -> dict:
|
||||
"""Convert to dict for YAML serialization."""
|
||||
d = {"lockfile_version": self.lockfile_version}
|
||||
|
||||
if self.metadata:
|
||||
d["metadata"] = {
|
||||
"generated_at": self.metadata.generated_at,
|
||||
"cmdforge_version": self.metadata.cmdforge_version,
|
||||
"manifest_hash": self.metadata.manifest_hash,
|
||||
"platform": self.metadata.platform,
|
||||
"python_version": self.metadata.python_version
|
||||
}
|
||||
|
||||
d["packages"] = {}
|
||||
for name, pkg in self.packages.items():
|
||||
pkg_dict = {
|
||||
"version": pkg.version,
|
||||
"constraint": pkg.constraint,
|
||||
"integrity": pkg.integrity,
|
||||
"source": pkg.source,
|
||||
"direct": pkg.direct
|
||||
}
|
||||
if pkg.required_by:
|
||||
pkg_dict["required_by"] = pkg.required_by
|
||||
if pkg.path:
|
||||
pkg_dict["path"] = pkg.path
|
||||
d["packages"][name] = pkg_dict
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def compute_file_hash(path: Path) -> str:
|
||||
"""Compute SHA256 hash of raw file bytes.
|
||||
|
||||
Used ONLY for manifest hash (to detect any change, including formatting).
|
||||
For tool config integrity, use hash_utils.compute_yaml_hash() instead.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
|
||||
Returns:
|
||||
Hash string in format "sha256:<64-char-hex>"
|
||||
"""
|
||||
sha256 = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
sha256.update(chunk)
|
||||
return f"sha256:{sha256.hexdigest()}"
|
||||
|
||||
|
||||
def generate_lockfile(
|
||||
manifest: "Manifest",
|
||||
graph: "DependencyGraph",
|
||||
client: "RegistryClient"
|
||||
) -> Lockfile:
|
||||
"""
|
||||
Generate a lock file from resolved dependencies.
|
||||
|
||||
Args:
|
||||
manifest: Project manifest with constraints
|
||||
graph: Resolved dependency graph (from DependencyGraphBuilder)
|
||||
client: Registry client for fetching config_hash
|
||||
|
||||
Returns:
|
||||
Complete Lockfile ready to save
|
||||
"""
|
||||
import platform
|
||||
import sys
|
||||
from . import __version__
|
||||
|
||||
# Compute manifest hash
|
||||
manifest_path = Path("cmdforge.yaml")
|
||||
manifest_hash = compute_file_hash(manifest_path) if manifest_path.exists() else ""
|
||||
|
||||
metadata = LockfileMetadata(
|
||||
generated_at=datetime.now().astimezone().isoformat(),
|
||||
cmdforge_version=__version__,
|
||||
manifest_hash=manifest_hash,
|
||||
platform=platform.system().lower(),
|
||||
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||
)
|
||||
|
||||
lock = Lockfile(metadata=metadata)
|
||||
|
||||
# Get direct dependency names for marking
|
||||
direct_names = {d.name for d in manifest.dependencies}
|
||||
|
||||
# Process all nodes in the graph
|
||||
for qualified_name, node in graph.nodes.items():
|
||||
if not node.is_resolved:
|
||||
continue
|
||||
integrity = _get_integrity_hash(node, client)
|
||||
|
||||
pkg = LockedPackage(
|
||||
name=qualified_name,
|
||||
version=node.resolved_version or "",
|
||||
constraint=node.version_constraint or "*",
|
||||
integrity=integrity,
|
||||
source=node.source or "registry",
|
||||
direct=qualified_name in direct_names or _is_direct(qualified_name, direct_names),
|
||||
required_by=_find_parents(qualified_name, graph),
|
||||
path=str(node.path) if node.source == "local" and node.path else None
|
||||
)
|
||||
lock.packages[qualified_name] = pkg
|
||||
|
||||
return lock
|
||||
|
||||
|
||||
def _get_integrity_hash(node: "DependencyNode", client: "RegistryClient") -> str:
|
||||
"""Get integrity hash for a dependency node."""
|
||||
if node.source in ("local", "global") and node.path:
|
||||
# For local/global tools, hash normalized config
|
||||
config_path = node.path / "config.yaml"
|
||||
if config_path.exists():
|
||||
from .hash_utils import compute_yaml_hash
|
||||
return compute_yaml_hash(config_path.read_text())
|
||||
return ""
|
||||
|
||||
elif node.source == "registry":
|
||||
# For registry tools, get the config_hash from registry
|
||||
# This is the hash BEFORE any local modifications
|
||||
try:
|
||||
result = client.download_tool(
|
||||
node.owner,
|
||||
node.name,
|
||||
version=node.resolved_version,
|
||||
install=False # Don't count as install, just get config
|
||||
)
|
||||
# Use the config_hash from registry response
|
||||
if hasattr(result, 'config_hash') and result.config_hash:
|
||||
# config_hash already includes "sha256:" prefix
|
||||
return result.config_hash
|
||||
# Fallback: hash the config_yaml content using normalized hashing
|
||||
if hasattr(result, 'config_yaml') and result.config_yaml:
|
||||
from .hash_utils import compute_yaml_hash
|
||||
return compute_yaml_hash(result.config_yaml)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _is_direct(qualified_name: str, direct_names: set) -> bool:
|
||||
"""Check if a qualified name matches any direct dependency."""
|
||||
# Handle case where manifest has unqualified names
|
||||
tool_name = qualified_name.split("/")[1] if "/" in qualified_name else qualified_name
|
||||
return qualified_name in direct_names or tool_name in direct_names
|
||||
|
||||
|
||||
def _find_parents(qualified_name: str, graph: "DependencyGraph") -> List[str]:
|
||||
"""Find packages that depend on this one."""
|
||||
parents = []
|
||||
for name, node in graph.nodes.items():
|
||||
if qualified_name in node.children:
|
||||
parents.append(name)
|
||||
return parents
|
||||
|
||||
|
||||
def verify_lockfile(
|
||||
lock: Lockfile,
|
||||
client: "RegistryClient"
|
||||
) -> List[str]:
|
||||
"""
|
||||
Verify installed tools match lock file.
|
||||
|
||||
Args:
|
||||
lock: Lock file to verify against
|
||||
client: Registry client for hash verification
|
||||
|
||||
Returns:
|
||||
List of verification errors (empty if all OK)
|
||||
"""
|
||||
from .resolver import ToolResolver, ToolNotFoundError
|
||||
|
||||
errors = []
|
||||
# Create resolver that doesn't use manifest (bypass version overrides)
|
||||
resolver = ToolResolver(auto_fetch=False)
|
||||
resolver.manifest = None
|
||||
|
||||
for name, locked in lock.packages.items():
|
||||
# Check tool exists
|
||||
try:
|
||||
resolved = resolver.resolve(name)
|
||||
except ToolNotFoundError:
|
||||
errors.append(f"{name}: not installed")
|
||||
continue
|
||||
except Exception:
|
||||
errors.append(f"{name}: not installed")
|
||||
continue
|
||||
|
||||
# Check version matches
|
||||
resolved_version = resolved.version or ""
|
||||
locked_version = locked.version or ""
|
||||
if resolved_version and locked_version and resolved_version != locked_version:
|
||||
errors.append(
|
||||
f"{name}: version mismatch "
|
||||
f"(installed: {resolved_version}, locked: {locked_version})"
|
||||
)
|
||||
|
||||
# Check integrity against installed tool config
|
||||
if locked.integrity and resolved.path:
|
||||
try:
|
||||
config_path = resolved.path / "config.yaml"
|
||||
if config_path.exists():
|
||||
from .hash_utils import compute_yaml_hash
|
||||
current_hash = compute_yaml_hash(config_path.read_text())
|
||||
if current_hash != locked.integrity:
|
||||
errors.append(
|
||||
f"{name}: integrity mismatch "
|
||||
f"(installed tool differs from lock)"
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"{name}: could not verify integrity ({e})")
|
||||
|
||||
return errors
|
||||
@@ -0,0 +1,301 @@
|
||||
"""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 remove_dependency(self, name: str) -> bool:
|
||||
"""Remove a dependency by name.
|
||||
|
||||
Args:
|
||||
name: Tool name (can be qualified like 'official/tool' or just 'tool')
|
||||
|
||||
Returns:
|
||||
True if dependency was removed, False if not found
|
||||
"""
|
||||
# Try exact match first
|
||||
for i, dep in enumerate(self.dependencies):
|
||||
if dep.name == name:
|
||||
self.dependencies.pop(i)
|
||||
return True
|
||||
|
||||
# Try matching just the tool name part (without owner)
|
||||
short_name = name.split("/")[-1] if "/" in name else name
|
||||
for i, dep in enumerate(self.dependencies):
|
||||
dep_short = dep.name.split("/")[-1] if "/" in dep.name else dep.name
|
||||
if dep_short == short_name:
|
||||
self.dependencies.pop(i)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
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()]
|
||||
@@ -0,0 +1,826 @@
|
||||
"""Provider abstraction for AI CLI tools."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# Regex to strip ANSI escape codes (spinners, colors, cursor movements)
|
||||
# Handles: CSI sequences (\x1b[...X), including private modes (\x1b[?...X), OSC sequences, and \r
|
||||
ANSI_ESCAPE_RE = re.compile(r'\x1b\[[\x20-\x3f]*[\x40-\x7e]|\x1b\].*?\x07|\r')
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Strip ANSI escape codes from text (e.g., Ollama spinner output)."""
|
||||
return ANSI_ESCAPE_RE.sub('', text)
|
||||
|
||||
|
||||
# Default providers config location
|
||||
PROVIDERS_FILE = Path.home() / ".cmdforge" / "providers.yaml"
|
||||
|
||||
|
||||
# Fallback chain templates for callers and manual providers.yaml configuration.
|
||||
PRESET_CHAINS = {
|
||||
"premium": ["claude-opus", "claude-sonnet", "claude-haiku", "codex", "crush", "mock"],
|
||||
"free": ["opencode-free", "agy", "codex", "crush", "ollama", "mock"],
|
||||
"fast": ["agy", "claude-haiku", "opencode-free", "crush", "mock"],
|
||||
"reasoning": ["claude-opus", "opencode-reasoner", "deepseek-api", "crush", "mock"],
|
||||
"balanced": ["opencode-pickle", "codex", "agy", "claude-haiku", "crush", "ollama", "mock"],
|
||||
}
|
||||
|
||||
PROVIDERS_CONFIG_VERSION = 2
|
||||
V2_DEFAULT_PROVIDER_NAMES = {
|
||||
"opencode-free",
|
||||
"agy",
|
||||
"crush",
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"deepseek-api",
|
||||
}
|
||||
|
||||
|
||||
# Known CLIs that CmdForge can auto-discover on the user's PATH.
|
||||
# Maps binary name -> default provider config (used during first-run setup).
|
||||
KNOWN_PROVIDER_CLIS = {
|
||||
"opencode": {
|
||||
"name": "opencode-pickle",
|
||||
"command": "opencode run --model opencode/big-pickle",
|
||||
"description": "OpenCode - Big Pickle (free general model)",
|
||||
"tags": ["free", "code", "general"],
|
||||
"install_group": "opencode",
|
||||
},
|
||||
"agy": {
|
||||
"name": "agy",
|
||||
"command": "agy -p",
|
||||
"description": "Antigravity - Google free tier, Gemini models",
|
||||
"tags": ["free-tier", "code", "large-context"],
|
||||
"install_group": "agy",
|
||||
},
|
||||
"codex": {
|
||||
"name": "codex",
|
||||
"command": "codex exec -",
|
||||
"description": "Codex CLI - OpenAI free tier available",
|
||||
"tags": ["free-tier", "code", "general"],
|
||||
"install_group": "codex",
|
||||
},
|
||||
"claude": {
|
||||
"name": "claude",
|
||||
"command": "claude -p",
|
||||
"description": "Claude Code - auto-routes to best model",
|
||||
"tags": ["paid", "subscription", "code"],
|
||||
"install_group": "claude",
|
||||
},
|
||||
"crush": {
|
||||
"name": "crush",
|
||||
"command": "crush run --quiet",
|
||||
"description": "Crush - multi-model via Hyper credits or API keys",
|
||||
"tags": ["free-tier", "multi", "code"],
|
||||
"install_group": "crush",
|
||||
},
|
||||
"ollama": {
|
||||
"name": "ollama",
|
||||
"command": "ollama run llama3.2",
|
||||
"description": "Ollama - local, private, free",
|
||||
"tags": ["free", "local", "private"],
|
||||
"install_group": "ollama",
|
||||
},
|
||||
}
|
||||
|
||||
# Known API key environment variables -> provider config
|
||||
KNOWN_API_KEYS = {
|
||||
"OPENROUTER_API_KEY": {
|
||||
"name": "openrouter",
|
||||
"command": "https://openrouter.ai/api/v1",
|
||||
"model": "openrouter/auto-beta",
|
||||
"description": "OpenRouter - 300+ models, one API key",
|
||||
"tags": ["api", "per-token", "multi"],
|
||||
},
|
||||
"DEEPSEEK_API_KEY": {
|
||||
"name": "deepseek-api",
|
||||
"command": "https://api.deepseek.com/v1",
|
||||
"model": "deepseek-chat",
|
||||
"description": "DeepSeek API - inexpensive per-token access",
|
||||
"tags": ["api", "per-token", "cheap"],
|
||||
},
|
||||
"OPENAI_API_KEY": {
|
||||
"name": "openai-api",
|
||||
"command": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o",
|
||||
"description": "OpenAI API - direct GPT access",
|
||||
"tags": ["api", "per-token", "code"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def discover_installed_providers() -> List[dict]:
|
||||
"""Scan the system for installed AI CLIs and configured API keys.
|
||||
|
||||
Returns a list of discovery results, each with keys:
|
||||
- source: "cli" or "api-key"
|
||||
- name: provider name
|
||||
- command: provider command
|
||||
- description, tags, etc.
|
||||
"""
|
||||
found = []
|
||||
|
||||
# Check PATH for known CLIs
|
||||
for binary, info in KNOWN_PROVIDER_CLIS.items():
|
||||
path = shutil.which(binary)
|
||||
if path:
|
||||
found.append({
|
||||
"source": "cli",
|
||||
"binary": binary,
|
||||
"path": path,
|
||||
**{k: v for k, v in info.items() if k != "install_group"},
|
||||
})
|
||||
|
||||
# Check environment for API keys
|
||||
for env_var, info in KNOWN_API_KEYS.items():
|
||||
if os.environ.get(env_var):
|
||||
found.append({
|
||||
"source": "api-key",
|
||||
"env_var": env_var,
|
||||
"type": "api",
|
||||
**info,
|
||||
})
|
||||
|
||||
# Check Ollama for available local models (if installed).
|
||||
if shutil.which("ollama"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ollama", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
used_names = {item["name"] for item in found}
|
||||
# Parse model list (skip header line).
|
||||
for line in result.stdout.strip().split("\n")[1:]:
|
||||
if line.strip():
|
||||
model_name = line.split()[0]
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", model_name.lower()).strip("-")
|
||||
provider_name = f"ollama-{slug}"
|
||||
suffix = 2
|
||||
while provider_name in used_names:
|
||||
provider_name = f"ollama-{slug}-{suffix}"
|
||||
suffix += 1
|
||||
used_names.add(provider_name)
|
||||
found.append({
|
||||
"source": "ollama-model",
|
||||
"binary": "ollama",
|
||||
"name": provider_name,
|
||||
"command": f"ollama run {model_name}",
|
||||
"description": f"Ollama local model: {model_name}",
|
||||
"tags": ["free", "local", "private"],
|
||||
})
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return found
|
||||
|
||||
|
||||
@dataclass
|
||||
class Provider:
|
||||
"""Definition of an AI provider.
|
||||
|
||||
Types:
|
||||
- "subprocess" (default): CLI tool invoked via subprocess.run with stdin.
|
||||
`command` is the shell command (e.g. "claude -p", "opencode run --model X").
|
||||
- "api": HTTP POST to an OpenAI-compatible endpoint.
|
||||
`command` is the endpoint URL, `model` is the model ID, `api_key_env`
|
||||
names the environment variable holding the API key.
|
||||
- "pty": Interactive CLI wrapped in a pseudo-terminal (experimental).
|
||||
`command` is the CLI invocation; pty_config dict holds patterns.
|
||||
"""
|
||||
|
||||
name: str
|
||||
command: str
|
||||
description: str = ""
|
||||
fallback: Optional[str] = None
|
||||
type: str = "subprocess" # "subprocess" | "api" | "pty"
|
||||
model: Optional[str] = None # Model ID (required for api-type providers)
|
||||
tags: List[str] = field(default_factory=list) # e.g. ["free", "code", "reasoning"]
|
||||
install: Optional[dict] = None # Structured install metadata
|
||||
fallback_chain: Optional[List[str]] = None # Ordered multi-step fallback (new)
|
||||
api_key_env: Optional[str] = None # Env var name for api-type providers
|
||||
pty_config: Optional[dict] = None # Patterns for pty-type providers
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {
|
||||
"name": self.name,
|
||||
"command": self.command,
|
||||
"description": self.description,
|
||||
}
|
||||
# Only include type if not default (preserves backward-compat with old YAML)
|
||||
if self.type and self.type != "subprocess":
|
||||
d["type"] = self.type
|
||||
if self.model:
|
||||
d["model"] = self.model
|
||||
if self.fallback:
|
||||
d["fallback"] = self.fallback
|
||||
if self.tags:
|
||||
d["tags"] = self.tags
|
||||
if self.install:
|
||||
d["install"] = self.install
|
||||
if self.fallback_chain:
|
||||
d["fallback_chain"] = self.fallback_chain
|
||||
if self.api_key_env:
|
||||
d["api_key_env"] = self.api_key_env
|
||||
if self.pty_config:
|
||||
d["pty_config"] = self.pty_config
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Provider":
|
||||
return cls(
|
||||
name=data["name"],
|
||||
command=data["command"],
|
||||
description=data.get("description", ""),
|
||||
type=data.get("type", "subprocess"),
|
||||
model=data.get("model"),
|
||||
fallback=data.get("fallback"),
|
||||
tags=data.get("tags", []) or [],
|
||||
install=data.get("install"),
|
||||
fallback_chain=data.get("fallback_chain"),
|
||||
api_key_env=data.get("api_key_env"),
|
||||
pty_config=data.get("pty_config"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResult:
|
||||
"""Result from a provider call."""
|
||||
text: str
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# Default providers that come pre-configured
|
||||
# Live-tested July 2026: opencode run, codex exec -, agy -p, ollama run, crush run
|
||||
DEFAULT_PROVIDERS = [
|
||||
# OPENCODE - best free models (binary auto-updates, 6+ free models)
|
||||
Provider("opencode-deepseek", "opencode run --model deepseek/deepseek-chat",
|
||||
"DeepSeek V3 - cheap, fast, accurate (paid API key)",
|
||||
tags=["paid", "code", "reasoning"]),
|
||||
Provider("opencode-pickle", "opencode run --model opencode/big-pickle",
|
||||
"Big Pickle - best free general model",
|
||||
tags=["free", "code", "general"]),
|
||||
Provider("opencode-reasoner", "opencode run --model deepseek/deepseek-reasoner",
|
||||
"DeepSeek R1 - complex reasoning, cheap (paid API key)",
|
||||
tags=["paid", "reasoning"]),
|
||||
Provider("opencode-free", "opencode run --model opencode/deepseek-v4-flash-free",
|
||||
"DeepSeek V4 Flash - fast, free",
|
||||
tags=["free", "fast"]),
|
||||
|
||||
# ANTHROPIC CLAUDE - paid, high quality (requires subscription or API key)
|
||||
Provider("claude", "claude -p",
|
||||
"Claude Code - auto-routes to best model",
|
||||
tags=["paid", "subscription", "code", "balanced"]),
|
||||
Provider("claude-haiku", "claude -p --model haiku",
|
||||
"Claude Haiku - fast, cheapest Claude",
|
||||
tags=["paid", "subscription", "fast"]),
|
||||
Provider("claude-sonnet", "claude -p --model sonnet",
|
||||
"Claude Sonnet - balanced quality/speed",
|
||||
tags=["paid", "subscription", "code"]),
|
||||
Provider("claude-opus", "claude -p --model opus",
|
||||
"Claude Opus - highest quality, expensive",
|
||||
tags=["paid", "subscription", "reasoning", "quality"]),
|
||||
|
||||
# OPENAI CODEX - free tier available
|
||||
Provider("codex", "codex exec -",
|
||||
"Codex CLI - reliable, auto-routes, free tier available",
|
||||
tags=["free-tier", "subscription", "code", "general"]),
|
||||
|
||||
# GOOGLE ANTIGRAVITY - replaces Gemini CLI (free tier: 1,000 req/day, 60 req/min)
|
||||
Provider("agy", "agy -p",
|
||||
"Antigravity (Google) - free tier, Gemini models, large context",
|
||||
tags=["free-tier", "code", "large-context"]),
|
||||
|
||||
# CRUSH - multi-provider agent via Hyper credits or API keys
|
||||
Provider("crush", "crush run --quiet",
|
||||
"Crush - multi-model, requires Hyper credits or API keys",
|
||||
tags=["free-tier", "multi", "code"]),
|
||||
|
||||
# LOCAL MODELS
|
||||
Provider("ollama", "ollama run llama3.2",
|
||||
"Ollama - local, private, free (GPU recommended)",
|
||||
tags=["free", "local", "private"]),
|
||||
|
||||
# API-TYPE PROVIDERS (pay-per-token, fallback when no CLI covers model)
|
||||
Provider("openrouter",
|
||||
"https://openrouter.ai/api/v1",
|
||||
"OpenRouter - 300+ models, one API key, auto-routing",
|
||||
type="api",
|
||||
model="openrouter/auto-beta",
|
||||
api_key_env="OPENROUTER_API_KEY",
|
||||
tags=["api", "per-token", "multi", "fallback"]),
|
||||
Provider("deepseek-api",
|
||||
"https://api.deepseek.com/v1",
|
||||
"DeepSeek API - inexpensive per-token access",
|
||||
type="api",
|
||||
model="deepseek-chat",
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
tags=["api", "per-token", "cheap", "reasoning"]),
|
||||
|
||||
# Mock for testing
|
||||
Provider("mock", "mock", "Mock provider for testing",
|
||||
tags=["testing"]),
|
||||
]
|
||||
|
||||
|
||||
def get_providers_file() -> Path:
|
||||
"""Get the providers config file, creating one on first run."""
|
||||
if not PROVIDERS_FILE.exists():
|
||||
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
discovered = discover_installed_providers()
|
||||
selected = []
|
||||
if discovered:
|
||||
import sys
|
||||
print("=" * 60, file=sys.stderr)
|
||||
print("CmdForge First-Run Provider Setup", file=sys.stderr)
|
||||
print("=" * 60, file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
cli_found = [d for d in discovered if d["source"] == "cli"]
|
||||
api_found = [d for d in discovered if d["source"] == "api-key"]
|
||||
ollama_found = [d for d in discovered if d["source"] == "ollama-model"]
|
||||
|
||||
if cli_found:
|
||||
print(f"CLIs found on PATH ({len(cli_found)}):", file=sys.stderr)
|
||||
for d in cli_found:
|
||||
name = d["name"]
|
||||
desc = d.get("description", "")
|
||||
selected.append(Provider(
|
||||
name=name, command=d["command"], description=desc,
|
||||
tags=d.get("tags", []),
|
||||
))
|
||||
print(f" [+] {name:20s} {desc}", file=sys.stderr)
|
||||
|
||||
if api_found:
|
||||
print(f"\nAPI keys detected ({len(api_found)}):", file=sys.stderr)
|
||||
for d in api_found:
|
||||
name = d["name"]
|
||||
desc = d.get("description", "")
|
||||
model = d.get("model", "auto")
|
||||
env_var = d.get("env_var", "")
|
||||
selected.append(Provider(
|
||||
name=name, command=d["command"], description=desc,
|
||||
type="api", model=model, api_key_env=env_var,
|
||||
tags=d.get("tags", []),
|
||||
))
|
||||
print(f" [+] {name:20s} ({model})", file=sys.stderr)
|
||||
|
||||
if ollama_found:
|
||||
print(f"\nLocal Ollama models ({len(ollama_found)}):", file=sys.stderr)
|
||||
for d in ollama_found[:10]:
|
||||
name = d["name"]
|
||||
desc = d.get("description", "")
|
||||
selected.append(Provider(
|
||||
name=name, command=d["command"], description=desc,
|
||||
tags=d.get("tags", []),
|
||||
))
|
||||
print(f" [+] {name:30s} {d['command']}", file=sys.stderr)
|
||||
if len(ollama_found) > 10:
|
||||
print(f" ... and {len(ollama_found) - 10} more", file=sys.stderr)
|
||||
|
||||
if selected:
|
||||
save_providers(selected)
|
||||
print(f"\nConfigured {len(selected)} provider(s) from discovery.", file=sys.stderr)
|
||||
print(f"Run 'cmdforge providers discover' to re-scan at any time.", file=sys.stderr)
|
||||
else:
|
||||
save_providers(DEFAULT_PROVIDERS)
|
||||
print("\nNo AI providers detected. Default providers written.", file=sys.stderr)
|
||||
print("Run 'cmdforge providers install' for an interactive setup guide.", file=sys.stderr)
|
||||
|
||||
return PROVIDERS_FILE
|
||||
|
||||
|
||||
def load_providers() -> List[Provider]:
|
||||
"""Load all defined providers."""
|
||||
providers_file = get_providers_file()
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(providers_file.read_text())
|
||||
if not data or "providers" not in data:
|
||||
return DEFAULT_PROVIDERS.copy()
|
||||
providers = [Provider.from_dict(p) for p in data["providers"]]
|
||||
except Exception:
|
||||
return DEFAULT_PROVIDERS.copy()
|
||||
|
||||
config_version = data.get("version", 1)
|
||||
if not isinstance(config_version, int):
|
||||
config_version = 1
|
||||
if config_version < PROVIDERS_CONFIG_VERSION:
|
||||
providers = _merge_missing_defaults(providers)
|
||||
try:
|
||||
save_providers(providers)
|
||||
except OSError:
|
||||
# A read-only legacy config should still remain usable.
|
||||
pass
|
||||
return providers
|
||||
|
||||
|
||||
def _merge_missing_defaults(providers: List[Provider]) -> List[Provider]:
|
||||
"""Add defaults introduced since the legacy unversioned config format."""
|
||||
merged = list(providers)
|
||||
names = {provider.name for provider in merged}
|
||||
for default in DEFAULT_PROVIDERS:
|
||||
if default.name in V2_DEFAULT_PROVIDER_NAMES and default.name not in names:
|
||||
merged.append(Provider.from_dict(default.to_dict()))
|
||||
return merged
|
||||
|
||||
|
||||
def save_providers(providers: List[Provider]):
|
||||
"""Save providers to config file."""
|
||||
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
data = {
|
||||
"version": PROVIDERS_CONFIG_VERSION,
|
||||
"providers": [p.to_dict() for p in providers],
|
||||
}
|
||||
PROVIDERS_FILE.write_text(yaml.safe_dump(data, default_flow_style=False, sort_keys=False))
|
||||
try:
|
||||
PROVIDERS_FILE.parent.chmod(0o700)
|
||||
PROVIDERS_FILE.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def get_provider(name: str) -> Optional[Provider]:
|
||||
"""Get a provider by name."""
|
||||
providers = load_providers()
|
||||
for p in providers:
|
||||
if p.name == name:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def add_provider(provider: Provider) -> bool:
|
||||
"""Add or update a provider."""
|
||||
providers = load_providers()
|
||||
|
||||
# Update if exists, otherwise add
|
||||
for i, p in enumerate(providers):
|
||||
if p.name == provider.name:
|
||||
providers[i] = provider
|
||||
save_providers(providers)
|
||||
return True
|
||||
|
||||
providers.append(provider)
|
||||
save_providers(providers)
|
||||
return True
|
||||
|
||||
|
||||
def delete_provider(name: str) -> bool:
|
||||
"""Delete a provider by name."""
|
||||
providers = load_providers()
|
||||
original_len = len(providers)
|
||||
providers = [p for p in providers if p.name != name]
|
||||
|
||||
if len(providers) < original_len:
|
||||
save_providers(providers)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def call_provider(provider_name: str, prompt: str, timeout: int = 300, max_tokens: Optional[int] = None, _tried: Optional[set] = None) -> ProviderResult:
|
||||
"""
|
||||
Call an AI provider with the given prompt.
|
||||
|
||||
Dispatches to call_provider_subprocess, call_provider_api, or
|
||||
call_provider_pty based on the provider's `type` field. Falls back
|
||||
to the provider's fallback (or fallback_chain) on failure.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider to use
|
||||
prompt: The prompt to send
|
||||
timeout: Maximum execution time in seconds
|
||||
max_tokens: Optional max output tokens (appends provider-specific flag)
|
||||
_tried: Internal set of already-tried providers (prevents infinite loops)
|
||||
|
||||
Returns:
|
||||
ProviderResult with the response text or error
|
||||
"""
|
||||
# Track which providers we've tried to prevent infinite fallback loops
|
||||
if _tried is None:
|
||||
_tried = set()
|
||||
_tried.add(provider_name)
|
||||
|
||||
# Handle mock provider specially
|
||||
if provider_name.lower() == "mock":
|
||||
return mock_provider(prompt)
|
||||
|
||||
# Look up provider
|
||||
provider = get_provider(provider_name)
|
||||
if not provider:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Provider '{provider_name}' not found. Use 'cmdforge providers' to manage providers."
|
||||
)
|
||||
|
||||
if max_tokens is not None:
|
||||
try:
|
||||
max_tokens = int(max_tokens)
|
||||
except (TypeError, ValueError):
|
||||
return ProviderResult(text="", success=False, error="max_tokens must be an integer")
|
||||
if max_tokens <= 0 or max_tokens > 1_000_000:
|
||||
return ProviderResult(
|
||||
text="", success=False, error="max_tokens must be between 1 and 1000000"
|
||||
)
|
||||
|
||||
# Helper to try fallback provider(s) if available
|
||||
def try_fallback(error_msg: str) -> ProviderResult:
|
||||
import sys
|
||||
last_error = error_msg
|
||||
# Walk fallback_chain first (ordered, multi-step)
|
||||
if provider.fallback_chain:
|
||||
for fb in provider.fallback_chain:
|
||||
if fb not in _tried:
|
||||
print(f"[fallback] {provider_name} failed, trying {fb}...", file=sys.stderr)
|
||||
result = call_provider(fb, prompt, timeout, max_tokens, _tried)
|
||||
if result.success:
|
||||
return result
|
||||
last_error = result.error or last_error
|
||||
# Fall back to single fallback (backward-compat)
|
||||
if provider.fallback and provider.fallback not in _tried:
|
||||
print(f"[fallback] {provider_name} failed, trying {provider.fallback}...", file=sys.stderr)
|
||||
result = call_provider(provider.fallback, prompt, timeout, max_tokens, _tried)
|
||||
if result.success:
|
||||
return result
|
||||
last_error = result.error or last_error
|
||||
return ProviderResult(text="", success=False, error=last_error)
|
||||
|
||||
# Dispatch by provider type
|
||||
ptype = getattr(provider, "type", None) or "subprocess"
|
||||
try:
|
||||
if ptype == "subprocess":
|
||||
result = call_provider_subprocess(provider, prompt, timeout, max_tokens)
|
||||
elif ptype == "api":
|
||||
result = call_provider_api(provider, prompt, timeout, max_tokens)
|
||||
elif ptype == "pty":
|
||||
result = call_provider_pty(provider, prompt, timeout, max_tokens)
|
||||
else:
|
||||
return try_fallback(f"Unknown provider type: {ptype}")
|
||||
|
||||
if result.success:
|
||||
return result
|
||||
return try_fallback(result.error or "Provider call failed")
|
||||
except Exception as e:
|
||||
return try_fallback(f"Provider error: {str(e)}")
|
||||
|
||||
|
||||
def call_provider_subprocess(provider: "Provider", prompt: str, timeout: int, max_tokens: Optional[int]) -> ProviderResult:
|
||||
"""Invoke a CLI provider via subprocess.run with stdin piping."""
|
||||
cmd = os.path.expandvars(provider.command)
|
||||
|
||||
# Append max_tokens flags only for CLIs whose interfaces support them.
|
||||
if max_tokens is not None:
|
||||
name_lower = provider.name.lower()
|
||||
if name_lower.startswith("claude") or "claude" in cmd.lower():
|
||||
cmd = f"{cmd} --max-tokens {max_tokens}"
|
||||
elif name_lower.startswith("gemini") or "gemini" in cmd.lower():
|
||||
cmd = f"{cmd} --max-output-tokens {max_tokens}"
|
||||
# opencode, agy, codex, and crush use their configured/default limits.
|
||||
|
||||
# Check if base command exists (use shlex for proper quote handling)
|
||||
try:
|
||||
cmd_parts = shlex.split(cmd)
|
||||
base_cmd = cmd_parts[0] if cmd_parts else cmd.split()[0]
|
||||
except ValueError:
|
||||
base_cmd = cmd.split()[0]
|
||||
|
||||
base_cmd_expanded = os.path.expanduser(base_cmd)
|
||||
if not shutil.which(base_cmd_expanded) and not os.path.isfile(base_cmd_expanded):
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"Command '{base_cmd}' not found. Is it installed and in PATH?\n\nTo install AI providers, run: cmdforge providers install"
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr_clean = strip_ansi(result.stderr)
|
||||
error_msg = f"Provider exited with code {result.returncode}: {stderr_clean}"
|
||||
if "not found" in stderr_clean.lower() or "not installed" in stderr_clean.lower():
|
||||
error_msg += "\n\nTo install AI providers, run: cmdforge providers install"
|
||||
return ProviderResult(text="", success=False, error=error_msg)
|
||||
|
||||
# Warn if output is empty (provider ran but returned nothing)
|
||||
clean_stdout = strip_ansi(result.stdout)
|
||||
if not clean_stdout.strip():
|
||||
stderr = strip_ansi(result.stderr).strip()
|
||||
|
||||
# Check for OpenCode's ProviderModelNotFoundError
|
||||
if "ProviderModelNotFoundError" in stderr or "ModelNotFoundError" in stderr:
|
||||
provider_match = re.search(r'providerID:\s*"([^"]+)"', stderr)
|
||||
model_match = re.search(r'modelID:\s*"([^"]+)"', stderr)
|
||||
provider_id = provider_match.group(1) if provider_match else "unknown"
|
||||
model_id = model_match.group(1) if model_match else "unknown"
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=(
|
||||
f"Model '{model_id}' from provider '{provider_id}' is not available.\n\n"
|
||||
f"To fix this, either:\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" 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 "")
|
||||
return ProviderResult(
|
||||
text="",
|
||||
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: cmdforge providers install"
|
||||
)
|
||||
|
||||
return ProviderResult(text=clean_stdout, success=True)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return ProviderResult(text="", success=False, error=f"Provider timed out after {timeout} seconds")
|
||||
except Exception as e:
|
||||
return ProviderResult(text="", success=False, error=f"Provider error: {str(e)}")
|
||||
|
||||
|
||||
def call_provider_api(provider: "Provider", prompt: str, timeout: int, max_tokens: Optional[int]) -> ProviderResult:
|
||||
"""Call an OpenAI-compatible HTTP API endpoint.
|
||||
|
||||
The provider.command is the endpoint URL (e.g. https://api.openrouter.ai/api/v1/chat/completions).
|
||||
The provider.model is the model ID (e.g. "deepseek/deepseek-chat").
|
||||
The provider.api_key_env names the environment variable holding the API key.
|
||||
"""
|
||||
if not provider.model:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"API provider '{provider.name}' has no model configured."
|
||||
)
|
||||
|
||||
env_var = provider.api_key_env or _infer_api_key_env(provider.name)
|
||||
api_key = os.environ.get(env_var) if env_var else None
|
||||
if not api_key:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"API key not set. Set the {env_var} environment variable to use '{provider.name}'."
|
||||
)
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error="The 'requests' package is required for API providers. Install with: pip install requests"
|
||||
)
|
||||
|
||||
endpoint = os.path.expandvars(provider.command)
|
||||
if not endpoint.endswith("/chat/completions"):
|
||||
endpoint = endpoint.rstrip("/") + "/chat/completions"
|
||||
|
||||
body = {
|
||||
"model": provider.model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
if max_tokens is not None:
|
||||
body["max_tokens"] = max_tokens
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(endpoint, json=body, headers=headers, timeout=timeout)
|
||||
if response.status_code != 200:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"API returned HTTP {response.status_code}: {response.text[:500]}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
if not text:
|
||||
return ProviderResult(text="", success=False, error="API returned empty response")
|
||||
|
||||
return ProviderResult(text=text, success=True)
|
||||
except requests.Timeout:
|
||||
return ProviderResult(text="", success=False, error=f"API timed out after {timeout} seconds")
|
||||
except Exception as e:
|
||||
return ProviderResult(text="", success=False, error=f"API error: {str(e)}")
|
||||
|
||||
|
||||
def _infer_api_key_env(provider_name: str) -> str:
|
||||
"""Guess the API key env var name from provider name (fallback if api_key_env not set)."""
|
||||
name_upper = provider_name.upper().replace("-", "_")
|
||||
return f"{name_upper}_API_KEY"
|
||||
|
||||
|
||||
def call_provider_pty(provider: "Provider", prompt: str, timeout: int, max_tokens: Optional[int]) -> ProviderResult:
|
||||
"""Wrap an interactive CLI via a pseudo-terminal (experimental).
|
||||
|
||||
Requires pty_config on the provider with:
|
||||
- prompt_pattern: regex/expect pattern for the CLI's ready prompt
|
||||
- response_pattern: regex/expect pattern marking end of response
|
||||
- exit_command: command to cleanly exit the CLI (e.g. "/exit", "\\q")
|
||||
"""
|
||||
if not provider.pty_config:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error=f"PTY provider '{provider.name}' has no pty_config set. Cannot wrap interactive CLI."
|
||||
)
|
||||
|
||||
try:
|
||||
import pexpect
|
||||
except ImportError:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error="The 'pexpect' package is required for PTY providers. Install with: pip install pexpect"
|
||||
)
|
||||
|
||||
cfg = provider.pty_config
|
||||
prompt_pattern = cfg.get("prompt_pattern")
|
||||
response_pattern = cfg.get("response_pattern")
|
||||
exit_command = cfg.get("exit_command", "/exit")
|
||||
|
||||
if not prompt_pattern or not response_pattern:
|
||||
return ProviderResult(
|
||||
text="",
|
||||
success=False,
|
||||
error="pty_config must include prompt_pattern and response_pattern"
|
||||
)
|
||||
|
||||
cmd = os.path.expandvars(provider.command)
|
||||
|
||||
child = None
|
||||
try:
|
||||
child = pexpect.spawn(cmd, timeout=timeout, encoding='utf-8')
|
||||
# Wait for the CLI's ready prompt
|
||||
child.expect(prompt_pattern)
|
||||
# Send the user's prompt
|
||||
child.sendline(prompt)
|
||||
# Wait for the response to complete
|
||||
child.expect(response_pattern)
|
||||
text = child.before
|
||||
# Cleanly exit
|
||||
child.sendline(exit_command)
|
||||
return ProviderResult(text=strip_ansi(text), success=True)
|
||||
except pexpect.TIMEOUT:
|
||||
return ProviderResult(text="", success=False, error=f"PTY provider timed out after {timeout} seconds")
|
||||
except pexpect.EOF:
|
||||
return ProviderResult(text="", success=False, error="PTY provider exited unexpectedly")
|
||||
except Exception as e:
|
||||
return ProviderResult(text="", success=False, error=f"PTY provider error: {str(e)}")
|
||||
finally:
|
||||
if child is not None and child.isalive():
|
||||
child.close(force=True)
|
||||
|
||||
|
||||
def mock_provider(prompt: str) -> ProviderResult:
|
||||
"""
|
||||
Return a mock response for testing.
|
||||
|
||||
Returns structured JSON that matches the default output schema,
|
||||
allowing tools to be tested without real AI providers.
|
||||
|
||||
Args:
|
||||
prompt: The prompt (used for generating mock response)
|
||||
|
||||
Returns:
|
||||
ProviderResult with mock JSON response
|
||||
"""
|
||||
import json
|
||||
|
||||
lines = prompt.strip().split('\n')
|
||||
preview = lines[0][:50] + "..." if len(lines[0]) > 50 else lines[0]
|
||||
|
||||
# Return structured JSON matching default schema
|
||||
mock_response = {
|
||||
"output": f"[MOCK] {preview}",
|
||||
"reasoning": f"Mock response for prompt with {len(prompt)} chars, {len(lines)} lines."
|
||||
}
|
||||
|
||||
return ProviderResult(
|
||||
text=json.dumps(mock_response),
|
||||
success=True
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Registry API server package."""
|
||||
|
||||
__all__ = ["app"]
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user