commit 8530f74687f57276440d725f5489ff6b9c8b8459 Author: Danny Stocker Date: Sun Nov 30 20:09:19 2025 +0100 Initial CLI scaffolding with v1.0 MVP structure Complete Python CLI for OpenWebUI with: - Auth commands (login, logout, whoami, token, refresh) - Chat send with streaming support - RAG files/collections management - Models list/info - Admin stats (minimal v1.0) - Config management with profiles and keyring Stack: typer, httpx, rich, pydantic, keyring Based on RFC v1.2 with 22-step implementation checklist 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9dfd961 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Testing +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.nox/ + +# Type checking +.mypy_cache/ + +# Build +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# OS +.DS_Store +Thumbs.db + +# Project specific +config.yaml +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf18e4b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 InfraFabric Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f338dd9 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# OpenWebUI CLI + +Official command-line interface for [OpenWebUI](https://github.com/open-webui/open-webui). + +> **Status:** Alpha - v0.1.0 MVP in development + +## Features + +- **Authentication** - Login, logout, token management with secure keyring storage +- **Chat** - Send messages with streaming support, continue conversations +- **RAG** - Upload files, manage collections, vector search +- **Models** - List and inspect available models +- **Admin** - Server stats and diagnostics (admin role required) +- **Profiles** - Multiple server configurations + +## Installation + +```bash +pip install openwebui-cli +``` + +Or from source: + +```bash +git clone https://github.com/dannystocker/openwebui-cli.git +cd openwebui-cli +pip install -e . +``` + +## Quick Start + +```bash +# Initialize configuration +openwebui config init + +# Login to your OpenWebUI instance +openwebui auth login + +# Chat with a model +openwebui chat send -m llama3.2:latest -p "Hello, world!" + +# Upload a file for RAG +openwebui rag files upload ./document.pdf + +# List available models +openwebui models list +``` + +## Usage + +### Authentication + +```bash +# Interactive login +openwebui auth login + +# Show current user +openwebui auth whoami + +# Logout +openwebui auth logout +``` + +### Chat + +```bash +# Simple chat (streaming by default) +openwebui chat send -m llama3.2:latest -p "Explain quantum computing" + +# Non-streaming mode +openwebui chat send -m llama3.2:latest -p "Hello" --no-stream + +# With RAG context +openwebui chat send -m llama3.2:latest -p "Summarize this document" --file + +# Continue a conversation +openwebui chat send -m llama3.2:latest -p "Tell me more" --chat-id +``` + +### RAG (Retrieval-Augmented Generation) + +```bash +# Upload files +openwebui rag files upload ./docs/*.pdf + +# Create a collection +openwebui rag collections create "Project Docs" + +# Search within a collection +openwebui rag search "authentication flow" --collection +``` + +### Models + +```bash +# List all models +openwebui models list + +# Get model details +openwebui models info llama3.2:latest +``` + +### Configuration + +```bash +# Initialize config +openwebui config init + +# Show current config +openwebui config show + +# Use a specific profile +openwebui --profile production chat send -m gpt-4 -p "Hello" +``` + +## Configuration File + +Location: `~/.config/openwebui/config.yaml` (Linux/macOS) or `%APPDATA%\openwebui\config.yaml` (Windows) + +```yaml +version: 1 +default_profile: local + +profiles: + local: + uri: http://localhost:8080 + production: + uri: https://openwebui.example.com + +defaults: + model: llama3.2:latest + format: text + stream: true +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error | +| 2 | Usage/argument error | +| 3 | Authentication error | +| 4 | Network error | +| 5 | Server error (5xx) | + +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Type checking +mypy openwebui_cli + +# Linting +ruff check openwebui_cli +``` + +## Contributing + +Contributions welcome! Please read the [RFC proposal](docs/RFC.md) for design details. + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +## Acknowledgments + +- [OpenWebUI](https://github.com/open-webui/open-webui) team +- [mitchty/open-webui-cli](https://github.com/mitchty/open-webui-cli) for inspiration diff --git a/docs/RFC.md b/docs/RFC.md new file mode 100755 index 0000000..0c2c8a8 --- /dev/null +++ b/docs/RFC.md @@ -0,0 +1,786 @@ +# OpenWebUI Official CLI - RFC Proposal + +**Document Version:** 1.2 +**Date:** November 30, 2025 +**Author:** InfraFabric Team +**Target:** OpenWebUI Core Team Review +**Status:** DRAFT - Reviewed & Implementation-Ready + +--- + +## Executive Summary + +We propose building an **official CLI for OpenWebUI** to complement the web interface. This CLI would enable developers, DevOps engineers, and power users to interact with OpenWebUI programmatically from the command line, enabling automation, scripting, and integration into CI/CD pipelines. + +**Why Now:** +- No official CLI exists (only `open-webui serve` to start server) +- Community demand evidenced by third-party CLI projects +- API surface is mature and well-documented +- Enables new use cases: automation, headless operation, batch processing + +--- + +## Resources Available for Development + +### ChromaDB Knowledge Base (Proxmox Server) + +We have ingested the **entire OpenWebUI ecosystem** into a semantic search database: + +| Collection | Documents | Content | +|------------|-----------|---------| +| `openwebui_core` | 8,014 | Full source code (backend + frontend) | +| `openwebui_docs` | 1,005 | Official documentation | +| `openwebui_functions` | 315 | Functions repository | +| `openwebui_pipelines` | 274 | Pipelines repository | +| `openwebui_pain_points` | 219 | GitHub issues (bug reports, feature requests) | +| `openwebui_careers` | 4 | Team/culture context | +| **TOTAL** | **9,832 chunks** | Complete ecosystem knowledge | + +**Query Example:** +```python +import chromadb +c = chromadb.PersistentClient('/root/openwebui-knowledge/chromadb') +results = c.get_collection('openwebui_core').query( + query_texts=['API endpoint authentication'], + n_results=10 +) +``` + +This knowledge base allows us to: +- Understand all API endpoints and their parameters +- Identify user pain points from GitHub issues +- Follow existing code patterns and conventions +- Ensure compatibility with current architecture + +### Discovered API Surface (from source analysis) + +**Router Files Found:** +- `auths.py` - Authentication, tokens, API keys +- `models.py` - Model management +- `ollama.py` - Ollama integration +- `functions.py` - Custom functions +- `pipelines.py` - Pipeline management +- `evaluations.py` - Model evaluations +- `scim.py` - SCIM provisioning + +**Key Endpoints Identified:** +``` +POST /api/v1/chat/completions - Main chat endpoint (OpenAI-compatible) +POST /v1/chat/completions - Ollama passthrough +POST /api/v1/tasks/*/completions - Various task endpoints +GET /api/models - List models +POST /api/pull - Pull model +POST /rag/upload - Upload RAG file +GET /api/v1/knowledge - List collections +``` + +### Pain Points from GitHub Issues + +| Issue | Problem | CLI Solution | +|-------|---------|--------------| +| #19420 | Unable to create API keys - 403 | `openwebui auth keys create` with proper scopes | +| #19401 | Redis Sentinel auth bug | Config profiles with full connection strings | +| #19403 | API keys refactor needed | First-class API key management in CLI | +| #18948 | OAuth/OIDC complexity | `openwebui auth login --oauth` flow | +| #19131 | OIDC client_id issues | Proper OAuth parameter handling | + +### Existing Third-Party CLI Analysis + +**mitchty/open-webui-cli** (Rust): +- 40 commits, actively maintained (v0.1.2, Nov 2024) +- ~1000 lines of Rust code +- Features: chat, models, RAG collections, file upload + +**What Works:** +- Bearer token authentication +- Collection/file-based RAG queries +- Model list/pull operations +- Clean command structure + +**What's Missing:** +- No streaming support +- No OAuth flow +- No config file (env vars only) +- No admin operations +- No function/pipeline management +- Rust = different stack than OpenWebUI (Python) + +--- + +## Proposed CLI Architecture + +### Command Structure + +``` +openwebui [global-options] [options] + +Global Options: + --uri Server URL (default: $OPENWEBUI_URI or http://localhost:8080) + --token API token (default: $OPENWEBUI_TOKEN) + --profile Use named profile from config + --format Output format: text, json, yaml (default: text) + --quiet Suppress non-essential output + --verbose Enable debug logging + --version Show version + --help Show help +``` + +### Complete Command Tree + +``` +openwebui +│ +├── auth # Authentication & Authorization +│ ├── login # Interactive login (user/pass or OAuth) +│ │ ├── --username, -u # Username for basic auth +│ │ ├── --password, -p # Password (prompt if not provided) +│ │ ├── --oauth # Use OAuth/OIDC flow +│ │ └── --provider # OAuth provider name +│ ├── logout # Revoke current token +│ ├── token # Show current token info +│ │ ├── --refresh # Refresh token +│ │ └── --export # Export token for scripts +│ ├── keys # API key management +│ │ ├── list # List API keys +│ │ ├── create # Create new API key +│ │ │ └── --scopes # Comma-separated scopes +│ │ ├── revoke # Revoke API key +│ │ └── info # Show key details +│ └── whoami # Show current user info +│ +├── chat # Chat Operations +│ ├── send # Send a message +│ │ ├── --model, -m # Model to use (required) +│ │ ├── --prompt, -p # User prompt (or stdin) +│ │ ├── --system, -s # System prompt +│ │ ├── --file # RAG file ID(s) for context +│ │ ├── --collection # RAG collection ID(s) for context +│ │ ├── --stream # Stream response (default: true) +│ │ ├── --no-stream # Wait for complete response +│ │ ├── --chat-id # Continue existing conversation +│ │ ├── --temperature # Temperature (0.0-2.0) +│ │ ├── --max-tokens # Max response tokens +│ │ └── --json # Request JSON output +│ ├── list # List conversations +│ │ ├── --limit # Number to show (default: 20) +│ │ ├── --archived # Show archived chats +│ │ └── --search # Search conversations +│ ├── show # Show conversation details +│ ├── export # Export conversation +│ │ └── --format # json, markdown, txt +│ ├── archive # Archive conversation +│ ├── unarchive # Unarchive conversation +│ └── delete # Delete conversation +│ +├── models # Model Management +│ ├── list # List available models +│ │ ├── --provider # Filter by provider (ollama, openai, etc.) +│ │ └── --capabilities # Show model capabilities +│ ├── info # Show model details +│ ├── pull # Pull/download model (Ollama) +│ │ └── --progress # Show download progress +│ ├── delete # Delete model +│ └── copy # Copy/alias model +│ +├── rag # RAG (Retrieval-Augmented Generation) +│ ├── files # File operations +│ │ ├── list # List uploaded files +│ │ ├── upload ... # Upload file(s) +│ │ │ ├── --collection # Add to collection +│ │ │ └── --tags # Add tags +│ │ ├── info # File details +│ │ ├── download # Download file +│ │ └── delete # Delete file +│ ├── collections # Collection operations +│ │ ├── list # List collections +│ │ ├── create # Create collection +│ │ │ └── --description +│ │ ├── info # Collection details +│ │ ├── add ... # Add files to collection +│ │ ├── remove # Remove file from collection +│ │ └── delete # Delete collection +│ └── query # Direct vector search (no LLM) +│ ├── --collection # Search in collection +│ ├── --file # Search in file +│ ├── --top-k # Number of results (default: 5) +│ └── --threshold # Similarity threshold +│ +├── functions # Custom Functions +│ ├── list # List installed functions +│ │ └── --enabled-only # Show only enabled +│ ├── info # Function details +│ ├── install # Install function +│ ├── enable # Enable function +│ ├── disable # Disable function +│ ├── update # Update function +│ └── delete # Delete function +│ +├── pipelines # Pipeline Management +│ ├── list # List pipelines +│ ├── info # Pipeline details +│ ├── create # Create pipeline +│ │ └── --config # Pipeline config file +│ ├── update # Update pipeline +│ └── delete # Delete pipeline +│ +├── admin # Admin Operations (requires admin role) +│ ├── users # User management +│ │ ├── list # List users +│ │ ├── create # Create user +│ │ ├── info # User details +│ │ ├── update # Update user +│ │ ├── delete # Delete user +│ │ └── set-role +│ ├── config # Server configuration +│ │ ├── get [KEY] # Get config value(s) +│ │ └── set # Set config value +│ └── stats # Usage statistics +│ ├── --period # day, week, month +│ └── --export # Export as CSV +│ +└── config # CLI Configuration + ├── init # Initialize config file + ├── show # Show current config + ├── set # Set config value + ├── get # Get config value + └── profiles # Profile management + ├── list # List profiles + ├── create # Create profile + ├── use # Set default profile + └── delete # Delete profile +``` + +--- + +## Technical Implementation + +### Technology Stack + +| Component | Choice | Rationale | +|-----------|--------|-----------| +| Language | Python 3.11+ | Matches OpenWebUI backend | +| CLI Framework | `typer` | Modern, type-hints, auto-completion | +| HTTP Client | `httpx` | Async support, streaming | +| Output Formatting | `rich` | Beautiful terminal output | +| Config | `pydantic` + YAML | Type-safe configuration | +| Auth | `keyring` | Secure token storage | + +### Project Structure + +``` +openwebui-cli/ +├── pyproject.toml +├── README.md +├── LICENSE +├── src/ +│ └── openwebui_cli/ +│ ├── __init__.py +│ ├── __main__.py # Entry point +│ ├── cli.py # Main CLI app +│ ├── config.py # Configuration management +│ ├── client.py # HTTP client wrapper +│ ├── auth.py # Authentication logic +│ ├── commands/ +│ │ ├── __init__.py +│ │ ├── auth.py # auth subcommands +│ │ ├── chat.py # chat subcommands +│ │ ├── models.py # models subcommands +│ │ ├── rag.py # rag subcommands +│ │ ├── functions.py # functions subcommands +│ │ ├── pipelines.py # pipelines subcommands +│ │ ├── admin.py # admin subcommands +│ │ └── config_cmd.py # config subcommands +│ ├── formatters/ +│ │ ├── __init__.py +│ │ ├── text.py # Plain text output +│ │ ├── json.py # JSON output +│ │ └── table.py # Table output +│ └── utils/ +│ ├── __init__.py +│ ├── streaming.py # SSE handling +│ └── progress.py # Progress bars +└── tests/ + ├── __init__.py + ├── test_auth.py + ├── test_chat.py + └── ... +``` + +### Configuration File + +**Location:** `~/.openwebui/config.yaml` + +```yaml +# OpenWebUI CLI Configuration +version: 1 + +# Default profile +default_profile: local + +# Profiles for different servers +profiles: + local: + uri: http://localhost:8080 + # Token stored in system keyring + + production: + uri: https://openwebui.example.com + # Token stored in system keyring + +# CLI defaults +defaults: + model: llama3.2:latest + format: text + stream: true + +# Output preferences +output: + colors: true + progress_bars: true + timestamps: false +``` + +### Authentication Flow + +``` +1. First-time setup: + $ openwebui auth login + > Enter username: user@example.com + > Enter password: ******** + > Token saved to keyring + +2. OAuth flow: + $ openwebui auth login --oauth --provider google + > Opening browser for authentication... + > Token saved to keyring + +3. API key (for scripts): + $ openwebui auth keys create my-script --scopes chat,models + > API Key: sk-xxxxxxxxxxxx + > (Use with OPENWEBUI_TOKEN env var) +``` + +### Streaming Implementation + +```python +async def stream_chat(client, model, prompt, **kwargs): + """Stream chat completion with real-time output.""" + async with client.stream( + "POST", + "/api/v1/chat/completions", + json={"model": model, "messages": [{"role": "user", "content": prompt}], "stream": True}, + ) as response: + async for line in response.aiter_lines(): + if line.startswith("data: "): + data = json.loads(line[6:]) + if content := data.get("choices", [{}])[0].get("delta", {}).get("content"): + print(content, end="", flush=True) + print() # Newline at end +``` + +--- + +## v1.0 MVP Scope (Ship This First) + +These are the commands included in the **first PR / first release**: + +### Top-level & Shared + +**Global flags:** +- `--profile` - Use named profile from config +- `--uri` - Server URL +- `--format` - Output format (text, json, yaml) +- `--quiet` - Suppress non-essential output +- `--verbose/--debug` - Enable debug logging +- `--timeout` - Request timeout + +**Config (v1.0):** +- `config init` - Initialize config file (interactive) +- `config show` - Show current config (redact secrets) +- `config set ` - Set config value (optional) + +### Auth (v1.0) + +- `auth login` - Interactive login (username/password) +- `auth logout` - Revoke current token +- `auth whoami` - Show current user info +- `auth token` - Show token info (type, expiry, not raw) +- `auth refresh` - Refresh token if available +- Token storage via `keyring` (no plaintext tokens in config) + +*API keys (`auth keys`) deferred to v1.1 for smaller first cut.* + +### Chat (v1.0) + +- `chat send` + - `--model | -m` - Model to use (required) + - `--prompt | -p` - User prompt (or stdin) + - `--chat-id` - Continue existing conversation + - `--history-file` - Load history from file + - `--no-stream` - Wait for complete response + - `--format` + `--json` - Output format options + - **Streaming ON by default** (from config) + +*Chat history commands (`list/show/export/archive/delete`) deferred to v1.1.* + +### RAG (v1.0 - Minimal) + +- `rag files list` - List uploaded files +- `rag files upload ... [--collection ]` - Upload file(s) +- `rag files delete ` - Delete file +- `rag collections list` - List collections +- `rag collections create ` - Create collection +- `rag collections delete ` - Delete collection +- `rag search --collection --top-k --format json` - Vector search + +### Models (v1.0) + +- `models list` - List available models +- `models info ` - Show model details + +*Model operations (`pull/delete/copy`) deferred to v1.1 depending on API maturity.* + +### Admin (v1.0 - Minimal) + +- `admin stats --format ` - Usage statistics +- RBAC enforced via server token +- Exit code `3` on permission errors + +*Admin user/config management deferred to v1.1.* + +--- + +## v1.1+ Enhancements (Next Iterations) + +These are ideal follow-ups once v1.0 is stable: + +### Auth & Keys (v1.1) + +- `auth keys list` - List API keys +- `auth keys create [--name --scope ...]` - Create API key +- `auth keys revoke ` - Revoke API key +- More structured scope model & docs (e.g. `read:chat`, `write:rag`, `admin:*`) + +### Chat Quality-of-Life (v1.1) + +- `chat list` - List conversations +- `chat show ` - Show conversation details +- `chat export [--format markdown|json]` - Export conversation +- `chat archive/delete ` - Archive or delete +- `--system-prompt` or `--meta` support once server-side API supports rich metadata + +### Models (v1.1) + +- `models pull` - Pull/download model +- `models delete` - Delete model +- `models copy` - Copy/alias model +- Clear handling of local vs remote model registries + +### RAG UX (v1.1+) + +- `rag collections add ...` - Add files to collection +- `rag collections ingest ...` - Upload + add in one go (v1.2+) + +### Functions & Pipelines (v1.1+) + +- `functions list/install/enable/disable/delete` +- `pipelines list/create/delete/run` +- Official JSON schema for pipeline configs & function manifests + +### Developer Ergonomics (v1.1+) + +- Shell completions: `openwebui --install-completion` / `--show-completion` +- Better error pretty-printing with `rich` (esp. for validation errors) + +--- + +## Implementation Checklist (22 Concrete Steps) + +Use this as a PR checklist: + +### A. Skeleton & CLI Wiring + +1. **Create package layout** in monorepo: + ```text + open-webui/ + cli/ + pyproject.toml + openwebui_cli/ + __init__.py + main.py + config.py + auth.py + chat.py + rag.py + models.py + admin.py + http.py + errors.py + ``` + +2. **Wire Typer app** in `main.py`: + - Main `app = typer.Typer()` + - Sub-apps: `auth_app`, `chat_app`, `rag_app`, `models_app`, `admin_app`, `config_app` + - Global options (profile, uri, format, quiet, verbose, timeout) + +3. **Implement central HTTP client helper** in `http.py`: + - Builds `httpx.Client` from resolved URI, timeout, auth headers + - Token from keyring + - Standard error translation → `CLIError` subclasses + +### B. Config & Profiles + +4. **Implement config path resolution:** + - Unix: XDG → `~/.config/openwebui/config.yaml` + - Windows: `%APPDATA%\openwebui\config.yaml` + +5. **Implement config commands:** + - `config init` (interactive: ask URI, default model, default format) + - `config show` (redact secrets, e.g. token placeholders) + +6. **Implement config loading & precedence:** + - Load file → apply profile → apply env → override with CLI flags + +### C. Auth Flow + +7. **Implement token storage using `keyring`:** + - Key name: `openwebui:{profile}:{uri}` + +8. **`auth login`:** + - Prompt for username/password + - Exchange for token using server's auth endpoint + - Future: add browser-based OAuth once endpoints are known + +9. **`auth logout`:** + - Delete token from keyring + +10. **`auth whoami`:** + - Call `/me`/`/users/me` style endpoint + - Print name, email, roles + +11. **`auth token`:** + - Show minimal info: token type, expiry + - Not the full raw token (or show only if `--debug`) + +12. **`auth refresh`:** + - Call refresh endpoint if available + - Update token in keyring + - Exit code `3` if refresh fails due to auth + +### D. Chat Send + Streaming + +13. **Implement `chat send`:** + - Resolve model, prompt, chat ID, history file + - If streaming: + - Use HTTP streaming endpoint + - Print tokens as they arrive + - Handle Ctrl-C gracefully + - If `--no-stream`: + - Wait for full response + - Respect `--format`/`--json`: + - `text`: print body content only + - `json`: print full JSON once + +14. **Ensure exit codes follow the table:** + - Usage errors → 2 + - Auth failures → 3 + - Network errors → 4 + - Server error (e.g., 500) → 5 + +### E. RAG Minimal API + +15. **Implement `rag files list/upload/delete`:** + - Upload: handle multiple paths; show IDs + - `--collection` optional; if set, also attach uploaded files + +16. **Implement `rag collections list/create/delete`** + +17. **Implement `rag search`:** + - Non-streaming only (v1.0) + - Default `--format json`; text mode optionally summarized + - Return exit code `0` even for empty results; use `1` only when *error* + +### F. Models & Admin + +18. **Models:** + - Implement `models list` and `models info` wired to existing endpoints + +19. **Admin:** + - Implement `admin stats` as a thin wrapper + - Check permission errors → exit code `3` with clear message: + > "Admin command requires admin privileges; your current user is 'X' with roles: [user]." + +### G. Tests & Docs + +20. **Add unit tests:** + - Config precedence + - Exit code mapping + - Basic command parsing (Typer's test runner) + +21. **Add smoke test script for maintainers:** + - `openwebui --help` + - `openwebui chat send --help` + - `openwebui rag search --help` + +22. **Add minimal README for the CLI:** + - Install (`pip install openwebui-cli` / `open-webui[cli]`) + - Basic `auth login`, `chat send`, `rag search` examples + +--- + +## Usage Examples + +### Basic Chat +```bash +# Simple chat +$ openwebui chat send -m llama3.2:latest -p "What is the capital of France?" +Paris is the capital of France. + +# With system prompt +$ openwebui chat send -m llama3.2:latest \ + -s "You are a helpful coding assistant" \ + -p "Write a Python function to calculate fibonacci" + +# From stdin (pipe) +$ echo "Explain quantum computing" | openwebui chat send -m llama3.2:latest + +# Continue conversation +$ openwebui chat send -m llama3.2:latest --chat-id abc123 -p "Tell me more" +``` + +### RAG Workflow +```bash +# Upload documents +$ openwebui rag files upload ./docs/*.pdf +Uploaded: doc1.pdf (id: file-abc123) +Uploaded: doc2.pdf (id: file-def456) + +# Create collection +$ openwebui rag collections create "Project Docs" --description "Project documentation" +Created collection: coll-xyz789 + +# Add files to collection +$ openwebui rag collections add coll-xyz789 file-abc123 file-def456 +Added 2 files to collection + +# Chat with RAG context +$ openwebui chat send -m llama3.2:latest \ + --collection coll-xyz789 \ + -p "Summarize the main points from these documents" +``` + +### Scripting & Automation +```bash +# Export chat history +$ openwebui chat export abc123 --format json > chat_backup.json + +# Batch model pull +$ cat models.txt | xargs -I {} openwebui models pull {} + +# List all collections as JSON (for scripting) +$ openwebui rag collections list --format json | jq '.[] | .id' + +# Health check script +$ openwebui admin stats --format json | jq '.status' +``` + +--- + +## Contribution Strategy + +### For OpenWebUI Core Team + +1. **Proposal Review:** This document for initial feedback +2. **RFC Discussion:** Open GitHub Discussion for community input +3. **Implementation:** In `open-webui/cli` or separate repo initially +4. **Integration:** Merge into main repo as `openwebui-cli` package +5. **Release:** Alongside next minor version + +### Packaging + +```bash +# Install from PyPI (target) +pip install openwebui-cli + +# Or with OpenWebUI +pip install open-webui[cli] + +# Command available as +openwebui +# or +open-webui-cli +``` + +--- + +## Open Questions for Review + +1. **Command naming:** `openwebui` vs `owui` vs `webui`? +2. **Scope:** Include admin commands in v1.0 or defer? +3. **Repository:** Separate repo or monorepo with open-webui? +4. **Streaming default:** On by default or opt-in? +5. **Config location:** `~/.openwebui/` vs `~/.config/openwebui/`? + +--- + +## Appendix: API Endpoint Reference + +Based on ChromaDB analysis of OpenWebUI source code: + +### Authentication +``` +POST /api/v1/auths/signin - Sign in +POST /api/v1/auths/signup - Sign up +POST /api/v1/auths/signout - Sign out +GET /api/v1/auths/ - Current user info +POST /api/v1/auths/api_key - Create API key +DELETE /api/v1/auths/api_key - Delete API key +``` + +### Chat +``` +POST /api/v1/chat/completions - Chat completion (OpenAI-compatible) +GET /api/v1/chats/ - List chats +GET /api/v1/chats/{id} - Get chat +DELETE /api/v1/chats/{id} - Delete chat +POST /api/v1/chats/{id}/archive - Archive chat +``` + +### Models +``` +GET /api/models - List models +GET /api/models/{id} - Model info +POST /api/pull - Pull model (Ollama) +DELETE /api/models/{id} - Delete model +``` + +### RAG +``` +POST /api/v1/files/ - Upload file +GET /api/v1/files/ - List files +DELETE /api/v1/files/{id} - Delete file +GET /api/v1/knowledge/ - List collections +POST /api/v1/knowledge/ - Create collection +DELETE /api/v1/knowledge/{id} - Delete collection +``` + +### Functions & Pipelines +``` +GET /api/v1/functions/ - List functions +POST /api/v1/functions/ - Create function +GET /api/v1/pipelines/ - List pipelines +POST /api/v1/pipelines/ - Create pipeline +``` + +--- + +## Document Control + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-11-30 | InfraFabric | Initial RFC | +| 1.2 | 2025-11-30 | InfraFabric + Grok Review | Added v1.0 MVP scope, v1.1+ roadmap, 22-step implementation checklist, exit code table | + +--- + +*This proposal leverages 9,832 chunks of OpenWebUI source code, documentation, and issue analysis from our ChromaDB knowledge base to ensure comprehensive API coverage and alignment with user needs.* diff --git a/openwebui_cli/__init__.py b/openwebui_cli/__init__.py new file mode 100644 index 0000000..edff636 --- /dev/null +++ b/openwebui_cli/__init__.py @@ -0,0 +1,4 @@ +"""OpenWebUI CLI - Official command-line interface for OpenWebUI.""" + +__version__ = "0.1.0" +__author__ = "InfraFabric Team" diff --git a/openwebui_cli/commands/__init__.py b/openwebui_cli/commands/__init__.py new file mode 100644 index 0000000..9937ab9 --- /dev/null +++ b/openwebui_cli/commands/__init__.py @@ -0,0 +1 @@ +"""CLI command modules.""" diff --git a/openwebui_cli/commands/admin.py b/openwebui_cli/commands/admin.py new file mode 100644 index 0000000..7811de8 --- /dev/null +++ b/openwebui_cli/commands/admin.py @@ -0,0 +1,78 @@ +"""Admin commands (requires admin role).""" + +import json + +import typer +from rich.console import Console +from rich.table import Table + +from ..http import create_client, handle_response, handle_request_error +from ..errors import AuthError + +app = typer.Typer(no_args_is_help=True) +console = Console() + + +@app.command() +def stats( + ctx: typer.Context, + period: str = typer.Option("day", "--period", "-p", help="Period: day, week, month"), +) -> None: + """Show usage statistics.""" + obj = ctx.obj or {} + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + ) as client: + # Try to get stats from various endpoints + try: + response = client.get("/api/v1/admin/stats") + data = handle_response(response) + except Exception: + # Fallback to basic info + response = client.get("/api/v1/auths/") + user_data = handle_response(response) + + if user_data.get("role") != "admin": + raise AuthError( + f"Admin command requires admin privileges; " + f"your current user is '{user_data.get('name')}' with role: [{user_data.get('role')}]" + ) + + # Build basic stats + data = { + "user": user_data.get("name"), + "role": user_data.get("role"), + "status": "connected", + } + + if obj.get("format") == "json": + console.print(json.dumps(data, indent=2)) + else: + table = Table(title="Server Statistics") + table.add_column("Metric", style="cyan") + table.add_column("Value", style="green") + + for key, value in data.items(): + table.add_row(str(key), str(value)) + + console.print(table) + + except AuthError: + raise + except Exception as e: + handle_request_error(e) + + +@app.command() +def users(ctx: typer.Context) -> None: + """List users (v1.1 feature - placeholder).""" + console.print("[yellow]Admin users will be available in v1.1[/yellow]") + + +@app.command() +def config(ctx: typer.Context) -> None: + """Server configuration (v1.1 feature - placeholder).""" + console.print("[yellow]Admin config will be available in v1.1[/yellow]") diff --git a/openwebui_cli/commands/auth.py b/openwebui_cli/commands/auth.py new file mode 100644 index 0000000..9b886a2 --- /dev/null +++ b/openwebui_cli/commands/auth.py @@ -0,0 +1,123 @@ +"""Authentication commands.""" + +import typer +from rich.console import Console +from rich.prompt import Prompt + +from ..config import get_effective_config +from ..http import create_client, set_token, delete_token, get_token, handle_response, handle_request_error +from ..errors import AuthError + +app = typer.Typer(no_args_is_help=True) +console = Console() + + +@app.command() +def login( + ctx: typer.Context, + username: str | None = typer.Option(None, "--username", "-u", help="Username or email"), + password: str | None = typer.Option(None, "--password", "-p", help="Password (will prompt if not provided)"), +) -> None: + """Login to OpenWebUI instance.""" + obj = ctx.obj or {} + uri, profile = get_effective_config(obj.get("profile"), obj.get("uri")) + + # Prompt for credentials if not provided + if username is None: + username = Prompt.ask("Username or email") + if password is None: + password = Prompt.ask("Password", password=True) + + try: + with create_client(profile=profile, uri=uri) as client: + response = client.post( + "/api/v1/auths/signin", + json={"email": username, "password": password}, + ) + data = handle_response(response) + + token = data.get("token") + if not token: + raise AuthError("No token received from server") + + # Store token in keyring + set_token(profile, uri, token) + + user_name = data.get("name", username) + console.print(f"[green]Successfully logged in as {user_name}[/green]") + console.print(f"Token saved to system keyring for profile: {profile}") + + except Exception as e: + handle_request_error(e) + + +@app.command() +def logout(ctx: typer.Context) -> None: + """Logout and remove stored token.""" + obj = ctx.obj or {} + uri, profile = get_effective_config(obj.get("profile"), obj.get("uri")) + + delete_token(profile, uri) + console.print(f"[green]Logged out from profile: {profile}[/green]") + + +@app.command() +def whoami(ctx: typer.Context) -> None: + """Show current user information.""" + obj = ctx.obj or {} + + try: + with create_client(profile=obj.get("profile"), uri=obj.get("uri")) as client: + response = client.get("/api/v1/auths/") + data = handle_response(response) + + console.print(f"[bold]User:[/bold] {data.get('name', 'Unknown')}") + console.print(f"[bold]Email:[/bold] {data.get('email', 'Unknown')}") + console.print(f"[bold]Role:[/bold] {data.get('role', 'Unknown')}") + + except Exception as e: + handle_request_error(e) + + +@app.command() +def token( + ctx: typer.Context, + show: bool = typer.Option(False, "--show", help="Show full token (careful!)"), +) -> None: + """Show token information.""" + obj = ctx.obj or {} + uri, profile = get_effective_config(obj.get("profile"), obj.get("uri")) + + stored_token = get_token(profile, uri) + if stored_token: + if show: + console.print(f"[bold]Token:[/bold] {stored_token}") + else: + masked = stored_token[:8] + "..." + stored_token[-4:] if len(stored_token) > 12 else "***" + console.print(f"[bold]Token:[/bold] {masked}") + console.print(f"[bold]Profile:[/bold] {profile}") + console.print(f"[bold]URI:[/bold] {uri}") + else: + console.print("[yellow]No token found. Run 'openwebui auth login' first.[/yellow]") + + +@app.command() +def refresh(ctx: typer.Context) -> None: + """Refresh the authentication token.""" + obj = ctx.obj or {} + + try: + with create_client(profile=obj.get("profile"), uri=obj.get("uri")) as client: + response = client.post("/api/v1/auths/refresh") + data = handle_response(response) + + uri, profile = get_effective_config(obj.get("profile"), obj.get("uri")) + token = data.get("token") + if token: + set_token(profile, uri, token) + console.print("[green]Token refreshed successfully[/green]") + else: + console.print("[yellow]No new token received[/yellow]") + + except Exception as e: + handle_request_error(e) diff --git a/openwebui_cli/commands/chat.py b/openwebui_cli/commands/chat.py new file mode 100644 index 0000000..2e1b5c3 --- /dev/null +++ b/openwebui_cli/commands/chat.py @@ -0,0 +1,149 @@ +"""Chat commands.""" + +import json +import sys + +import typer +from rich.console import Console +from rich.live import Live +from rich.text import Text + +from ..config import load_config +from ..http import create_client, handle_response, handle_request_error + +app = typer.Typer(no_args_is_help=True) +console = Console() + + +@app.command() +def send( + ctx: typer.Context, + model: str = typer.Option(..., "--model", "-m", help="Model to use"), + prompt: str | None = typer.Option(None, "--prompt", "-p", help="User prompt (or use stdin)"), + system: str | None = typer.Option(None, "--system", "-s", help="System prompt"), + chat_id: str | None = typer.Option(None, "--chat-id", help="Continue existing conversation"), + file: list[str] | None = typer.Option(None, "--file", help="RAG file ID(s) for context"), + collection: list[str] | None = typer.Option(None, "--collection", help="RAG collection ID(s) for context"), + no_stream: bool = typer.Option(False, "--no-stream", help="Wait for complete response"), + temperature: float | None = typer.Option(None, "--temperature", "-T", help="Temperature (0.0-2.0)"), + max_tokens: int | None = typer.Option(None, "--max-tokens", help="Max response tokens"), + json_output: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """Send a chat message.""" + obj = ctx.obj or {} + config = load_config() + + # Get prompt from stdin if not provided + if prompt is None: + if not sys.stdin.isatty(): + prompt = sys.stdin.read().strip() + else: + console.print("[red]Error: Prompt required. Use -p or pipe input.[/red]") + raise typer.Exit(2) + + # Build messages + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + # Build request body + body: dict = { + "model": model, + "messages": messages, + "stream": not no_stream and config.defaults.stream, + } + + if temperature is not None: + body["temperature"] = temperature + if max_tokens is not None: + body["max_tokens"] = max_tokens + + # Add RAG context if specified + files_context = [] + if file: + for f in file: + files_context.append({"type": "file", "id": f}) + if collection: + for c in collection: + files_context.append({"type": "collection", "id": c}) + if files_context: + body["files"] = files_context + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + timeout=obj.get("timeout"), + ) as client: + if body.get("stream"): + # Streaming response + with client.stream( + "POST", + "/api/v1/chat/completions", + json=body, + ) as response: + if response.status_code >= 400: + handle_response(response) + + full_content = "" + for line in response.iter_lines(): + if line.startswith("data: "): + data_str = line[6:] + if data_str.strip() == "[DONE]": + break + try: + data = json.loads(data_str) + delta = data.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content", "") + if content: + print(content, end="", flush=True) + full_content += content + except json.JSONDecodeError: + continue + print() # Final newline + + if json_output: + console.print(json.dumps({"content": full_content}, indent=2)) + else: + # Non-streaming response + response = client.post("/api/v1/chat/completions", json=body) + data = handle_response(response) + + if json_output or obj.get("format") == "json": + console.print(json.dumps(data, indent=2)) + else: + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + console.print(content) + + except Exception as e: + handle_request_error(e) + + +@app.command("list") +def list_chats( + ctx: typer.Context, + limit: int = typer.Option(20, "--limit", "-n", help="Number of chats to show"), + archived: bool = typer.Option(False, "--archived", help="Show archived chats"), +) -> None: + """List conversations (v1.1 feature - placeholder).""" + console.print("[yellow]Chat list will be available in v1.1[/yellow]") + + +@app.command() +def show( + ctx: typer.Context, + chat_id: str = typer.Argument(..., help="Chat ID to show"), +) -> None: + """Show conversation details (v1.1 feature - placeholder).""" + console.print("[yellow]Chat show will be available in v1.1[/yellow]") + + +@app.command() +def export( + ctx: typer.Context, + chat_id: str = typer.Argument(..., help="Chat ID to export"), + format: str = typer.Option("json", "--format", "-f", help="Export format: json, markdown"), +) -> None: + """Export conversation (v1.1 feature - placeholder).""" + console.print("[yellow]Chat export will be available in v1.1[/yellow]") diff --git a/openwebui_cli/commands/config_cmd.py b/openwebui_cli/commands/config_cmd.py new file mode 100644 index 0000000..9fbb323 --- /dev/null +++ b/openwebui_cli/commands/config_cmd.py @@ -0,0 +1,173 @@ +"""CLI configuration commands.""" + +import typer +from rich.console import Console +from rich.prompt import Prompt +from rich.table import Table + +from ..config import ( + Config, + ProfileConfig, + get_config_path, + load_config, + save_config, +) + +app = typer.Typer(no_args_is_help=True) +console = Console() + + +@app.command() +def init( + ctx: typer.Context, + force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config"), +) -> None: + """Initialize configuration file interactively.""" + config_path = get_config_path() + + if config_path.exists() and not force: + console.print(f"[yellow]Config already exists at: {config_path}[/yellow]") + console.print("Use --force to overwrite") + raise typer.Exit(1) + + console.print("[bold]OpenWebUI CLI Configuration Setup[/bold]\n") + + # Get server URI + uri = Prompt.ask( + "Server URI", + default="http://localhost:8080", + ) + + # Get default model + default_model = Prompt.ask( + "Default model (optional)", + default="", + ) + + # Get output format + default_format = Prompt.ask( + "Default output format", + choices=["text", "json", "yaml"], + default="text", + ) + + # Build config + config = Config( + default_profile="default", + profiles={ + "default": ProfileConfig(uri=uri), + }, + ) + + if default_model: + config.defaults.model = default_model + config.defaults.format = default_format + + # Save config + save_config(config) + console.print(f"\n[green]Configuration saved to: {config_path}[/green]") + console.print("\nNext steps:") + console.print(" 1. Run 'openwebui auth login' to authenticate") + console.print(" 2. Run 'openwebui models list' to see available models") + console.print(" 3. Run 'openwebui chat send -m -p \"Hello\"' to chat") + + +@app.command() +def show(ctx: typer.Context) -> None: + """Show current configuration.""" + config_path = get_config_path() + + if not config_path.exists(): + console.print("[yellow]No config file found. Run 'openwebui config init' first.[/yellow]") + raise typer.Exit(1) + + config = load_config() + + console.print(f"[bold]Config file:[/bold] {config_path}\n") + + # Show profiles + table = Table(title="Profiles") + table.add_column("Name", style="cyan") + table.add_column("URI", style="green") + table.add_column("Default", style="yellow") + + for name, profile in config.profiles.items(): + is_default = "✓" if name == config.default_profile else "" + table.add_row(name, profile.uri, is_default) + + console.print(table) + + # Show defaults + console.print("\n[bold]Defaults:[/bold]") + console.print(f" Model: {config.defaults.model or '(not set)'}") + console.print(f" Format: {config.defaults.format}") + console.print(f" Stream: {config.defaults.stream}") + console.print(f" Timeout: {config.defaults.timeout}s") + + +@app.command("set") +def set_value( + ctx: typer.Context, + key: str = typer.Argument(..., help="Config key (e.g., 'defaults.model')"), + value: str = typer.Argument(..., help="Value to set"), +) -> None: + """Set a configuration value.""" + config = load_config() + + parts = key.split(".") + if len(parts) == 2: + section, field = parts + if section == "defaults": + if field == "model": + config.defaults.model = value + elif field == "format": + config.defaults.format = value + elif field == "stream": + config.defaults.stream = value.lower() in ("true", "1", "yes") + elif field == "timeout": + config.defaults.timeout = int(value) + else: + console.print(f"[red]Unknown defaults field: {field}[/red]") + raise typer.Exit(1) + else: + console.print(f"[red]Unknown section: {section}[/red]") + raise typer.Exit(1) + else: + console.print("[red]Key format: section.field (e.g., 'defaults.model')[/red]") + raise typer.Exit(1) + + save_config(config) + console.print(f"[green]Set {key} = {value}[/green]") + + +@app.command("get") +def get_value( + ctx: typer.Context, + key: str = typer.Argument(..., help="Config key to get"), +) -> None: + """Get a configuration value.""" + config = load_config() + + parts = key.split(".") + if len(parts) == 2: + section, field = parts + if section == "defaults": + value = getattr(config.defaults, field, None) + if value is not None: + console.print(str(value)) + else: + console.print(f"[red]Unknown field: {field}[/red]") + raise typer.Exit(1) + elif section == "profiles": + profile = config.profiles.get(field) + if profile: + console.print(f"uri: {profile.uri}") + else: + console.print(f"[red]Unknown profile: {field}[/red]") + raise typer.Exit(1) + else: + console.print(f"[red]Unknown section: {section}[/red]") + raise typer.Exit(1) + else: + console.print("[red]Key format: section.field (e.g., 'defaults.model')[/red]") + raise typer.Exit(1) diff --git a/openwebui_cli/commands/models.py b/openwebui_cli/commands/models.py new file mode 100644 index 0000000..b292dd2 --- /dev/null +++ b/openwebui_cli/commands/models.py @@ -0,0 +1,106 @@ +"""Model management commands.""" + +import json + +import typer +from rich.console import Console +from rich.table import Table + +from ..http import create_client, handle_response, handle_request_error + +app = typer.Typer(no_args_is_help=True) +console = Console() + + +@app.command("list") +def list_models( + ctx: typer.Context, + provider: str | None = typer.Option(None, "--provider", "-p", help="Filter by provider"), +) -> None: + """List available models.""" + obj = ctx.obj or {} + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + ) as client: + response = client.get("/api/models") + data = handle_response(response) + + models = data.get("data", data.get("models", [])) + + if obj.get("format") == "json": + console.print(json.dumps(models, indent=2)) + else: + table = Table(title="Available Models") + table.add_column("ID", style="cyan") + table.add_column("Name", style="green") + table.add_column("Provider", style="yellow") + + for model in models: + model_id = model.get("id", model.get("model", "Unknown")) + name = model.get("name", model_id) + model_provider = model.get("owned_by", model.get("provider", "-")) + + if provider and provider.lower() not in model_provider.lower(): + continue + + table.add_row(model_id, name, model_provider) + + console.print(table) + + except Exception as e: + handle_request_error(e) + + +@app.command() +def info( + ctx: typer.Context, + model_id: str = typer.Argument(..., help="Model ID to inspect"), +) -> None: + """Show model details.""" + obj = ctx.obj or {} + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + ) as client: + response = client.get(f"/api/models/{model_id}") + data = handle_response(response) + + if obj.get("format") == "json": + console.print(json.dumps(data, indent=2)) + else: + console.print(f"[bold]Model:[/bold] {data.get('id', model_id)}") + console.print(f"[bold]Name:[/bold] {data.get('name', '-')}") + console.print(f"[bold]Provider:[/bold] {data.get('owned_by', '-')}") + + if params := data.get("parameters"): + console.print(f"[bold]Parameters:[/bold] {params}") + if context := data.get("context_length"): + console.print(f"[bold]Context Length:[/bold] {context}") + + except Exception as e: + handle_request_error(e) + + +@app.command() +def pull( + ctx: typer.Context, + model_name: str = typer.Argument(..., help="Model name to pull"), + progress: bool = typer.Option(True, "--progress/--no-progress", help="Show download progress"), +) -> None: + """Pull/download a model (v1.1 feature - placeholder).""" + console.print("[yellow]Model pull will be available in v1.1[/yellow]") + + +@app.command() +def delete( + ctx: typer.Context, + model_name: str = typer.Argument(..., help="Model name to delete"), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"), +) -> None: + """Delete a model (v1.1 feature - placeholder).""" + console.print("[yellow]Model delete will be available in v1.1[/yellow]") diff --git a/openwebui_cli/commands/rag.py b/openwebui_cli/commands/rag.py new file mode 100644 index 0000000..35f213c --- /dev/null +++ b/openwebui_cli/commands/rag.py @@ -0,0 +1,256 @@ +"""RAG (Retrieval-Augmented Generation) commands.""" + +import json +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +from ..http import create_client, handle_response, handle_request_error + +app = typer.Typer(no_args_is_help=True) +console = Console() + +# Sub-apps +files_app = typer.Typer(help="File operations") +collections_app = typer.Typer(help="Collection operations") + +app.add_typer(files_app, name="files") +app.add_typer(collections_app, name="collections") + + +# Files commands +@files_app.command("list") +def list_files(ctx: typer.Context) -> None: + """List uploaded files.""" + obj = ctx.obj or {} + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + ) as client: + response = client.get("/api/v1/files/") + data = handle_response(response) + + files = data if isinstance(data, list) else data.get("files", []) + + if obj.get("format") == "json": + console.print(json.dumps(files, indent=2)) + else: + table = Table(title="Uploaded Files") + table.add_column("ID", style="cyan") + table.add_column("Filename", style="green") + table.add_column("Size", style="yellow") + + for f in files: + file_id = f.get("id", "-") + filename = f.get("filename", f.get("name", "-")) + size = f.get("size", "-") + if isinstance(size, int): + size = f"{size / 1024:.1f} KB" + table.add_row(file_id, filename, str(size)) + + console.print(table) + + except Exception as e: + handle_request_error(e) + + +@files_app.command() +def upload( + ctx: typer.Context, + paths: list[Path] = typer.Argument(..., help="File path(s) to upload"), + collection: str | None = typer.Option(None, "--collection", "-c", help="Add to collection"), +) -> None: + """Upload file(s) for RAG.""" + obj = ctx.obj or {} + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + timeout=300, # Longer timeout for uploads + ) as client: + for path in paths: + if not path.exists(): + console.print(f"[red]File not found: {path}[/red]") + continue + + with open(path, "rb") as f: + files = {"file": (path.name, f)} + response = client.post("/api/v1/files/", files=files) + + data = handle_response(response) + file_id = data.get("id", "unknown") + console.print(f"[green]Uploaded:[/green] {path.name} (id: {file_id})") + + # Add to collection if specified + if collection and file_id != "unknown": + try: + client.post( + f"/api/v1/knowledge/{collection}/file/add", + json={"file_id": file_id}, + ) + console.print(f" Added to collection: {collection}") + except Exception as e: + console.print(f" [yellow]Warning: Could not add to collection: {e}[/yellow]") + + except Exception as e: + handle_request_error(e) + + +@files_app.command() +def delete( + ctx: typer.Context, + file_id: str = typer.Argument(..., help="File ID to delete"), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"), +) -> None: + """Delete an uploaded file.""" + obj = ctx.obj or {} + + if not force: + confirm = typer.confirm(f"Delete file {file_id}?") + if not confirm: + raise typer.Abort() + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + ) as client: + response = client.delete(f"/api/v1/files/{file_id}") + handle_response(response) + console.print(f"[green]Deleted file: {file_id}[/green]") + + except Exception as e: + handle_request_error(e) + + +# Collections commands +@collections_app.command("list") +def list_collections(ctx: typer.Context) -> None: + """List knowledge collections.""" + obj = ctx.obj or {} + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + ) as client: + response = client.get("/api/v1/knowledge/") + data = handle_response(response) + + collections = data if isinstance(data, list) else data.get("collections", []) + + if obj.get("format") == "json": + console.print(json.dumps(collections, indent=2)) + else: + table = Table(title="Knowledge Collections") + table.add_column("ID", style="cyan") + table.add_column("Name", style="green") + table.add_column("Description", style="yellow") + + for c in collections: + coll_id = c.get("id", "-") + name = c.get("name", "-") + desc = c.get("description", "-")[:50] + table.add_row(coll_id, name, desc) + + console.print(table) + + except Exception as e: + handle_request_error(e) + + +@collections_app.command() +def create( + ctx: typer.Context, + name: str = typer.Argument(..., help="Collection name"), + description: str = typer.Option("", "--description", "-d", help="Collection description"), +) -> None: + """Create a knowledge collection.""" + obj = ctx.obj or {} + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + ) as client: + response = client.post( + "/api/v1/knowledge/", + json={"name": name, "description": description}, + ) + data = handle_response(response) + coll_id = data.get("id", "unknown") + console.print(f"[green]Created collection:[/green] {name} (id: {coll_id})") + + except Exception as e: + handle_request_error(e) + + +@collections_app.command() +def delete( + ctx: typer.Context, + collection_id: str = typer.Argument(..., help="Collection ID to delete"), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"), +) -> None: + """Delete a knowledge collection.""" + obj = ctx.obj or {} + + if not force: + confirm = typer.confirm(f"Delete collection {collection_id}?") + if not confirm: + raise typer.Abort() + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + ) as client: + response = client.delete(f"/api/v1/knowledge/{collection_id}") + handle_response(response) + console.print(f"[green]Deleted collection: {collection_id}[/green]") + + except Exception as e: + handle_request_error(e) + + +# Search command +@app.command() +def search( + ctx: typer.Context, + query: str = typer.Argument(..., help="Search query"), + collection: str = typer.Option(..., "--collection", "-c", help="Collection ID to search"), + top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results"), +) -> None: + """Search within a collection (vector search).""" + obj = ctx.obj or {} + + try: + with create_client( + profile=obj.get("profile"), + uri=obj.get("uri"), + ) as client: + response = client.post( + f"/api/v1/knowledge/{collection}/query", + json={"query": query, "k": top_k}, + ) + data = handle_response(response) + + results = data.get("results", data.get("documents", [])) + + if obj.get("format") == "json": + console.print(json.dumps(results, indent=2)) + else: + console.print(f"[bold]Search results for:[/bold] {query}\n") + for i, result in enumerate(results, 1): + content = result.get("content", result.get("text", str(result)))[:200] + score = result.get("score", result.get("distance", "-")) + console.print(f"[cyan]{i}.[/cyan] (score: {score})") + console.print(f" {content}...") + console.print() + + except Exception as e: + handle_request_error(e) diff --git a/openwebui_cli/config.py b/openwebui_cli/config.py new file mode 100644 index 0000000..50a4450 --- /dev/null +++ b/openwebui_cli/config.py @@ -0,0 +1,113 @@ +"""Configuration management for OpenWebUI CLI.""" + +import os +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings + + +def get_config_dir() -> Path: + """Get the configuration directory path (XDG-compliant).""" + if os.name == "nt": # Windows + base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + else: # Unix/Linux/macOS + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return base / "openwebui" + + +def get_config_path() -> Path: + """Get the configuration file path.""" + return get_config_dir() / "config.yaml" + + +class ProfileConfig(BaseModel): + """Configuration for a single server profile.""" + + uri: str = "http://localhost:8080" + # Token stored in keyring, not in config file + + +class DefaultsConfig(BaseModel): + """Default settings for CLI commands.""" + + model: str | None = None + format: str = "text" + stream: bool = True + timeout: int = 30 + + +class OutputConfig(BaseModel): + """Output formatting preferences.""" + + colors: bool = True + progress_bars: bool = True + timestamps: bool = False + + +class Config(BaseModel): + """Main configuration model.""" + + version: int = 1 + default_profile: str = "default" + profiles: dict[str, ProfileConfig] = Field(default_factory=lambda: {"default": ProfileConfig()}) + defaults: DefaultsConfig = Field(default_factory=DefaultsConfig) + output: OutputConfig = Field(default_factory=OutputConfig) + + +class Settings(BaseSettings): + """Environment-based settings that override config file.""" + + openwebui_uri: str | None = None + openwebui_token: str | None = None + openwebui_profile: str | None = None + + class Config: + env_prefix = "" + case_sensitive = False + + +def load_config() -> Config: + """Load configuration from file, with defaults for missing values.""" + config_path = get_config_path() + + if config_path.exists(): + with open(config_path) as f: + data = yaml.safe_load(f) or {} + return Config(**data) + else: + return Config() + + +def save_config(config: Config) -> None: + """Save configuration to file.""" + config_path = get_config_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + + with open(config_path, "w") as f: + yaml.dump(config.model_dump(), f, default_flow_style=False, sort_keys=False) + + +def get_effective_config( + profile: str | None = None, + uri: str | None = None, +) -> tuple[str, str | None]: + """ + Get effective URI and profile name, respecting precedence: + CLI flags > env vars > config file > defaults + """ + config = load_config() + settings = Settings() + + # Determine profile + effective_profile = profile or settings.openwebui_profile or config.default_profile + + # Get profile config + profile_config = config.profiles.get(effective_profile, ProfileConfig()) + + # Determine URI with precedence + effective_uri = uri or settings.openwebui_uri or profile_config.uri + + return effective_uri, effective_profile diff --git a/openwebui_cli/errors.py b/openwebui_cli/errors.py new file mode 100644 index 0000000..b2e8bf2 --- /dev/null +++ b/openwebui_cli/errors.py @@ -0,0 +1,60 @@ +"""CLI error classes with standardized exit codes.""" + +import sys +from enum import IntEnum + + +class ExitCode(IntEnum): + """Standardized exit codes for the CLI.""" + + SUCCESS = 0 + GENERAL_ERROR = 1 + USAGE_ERROR = 2 + AUTH_ERROR = 3 + NETWORK_ERROR = 4 + SERVER_ERROR = 5 + + +class CLIError(Exception): + """Base exception for CLI errors.""" + + exit_code: int = ExitCode.GENERAL_ERROR + + def __init__(self, message: str, exit_code: int | None = None): + super().__init__(message) + if exit_code is not None: + self.exit_code = exit_code + + +class UsageError(CLIError): + """Invalid command usage or arguments.""" + + exit_code = ExitCode.USAGE_ERROR + + +class AuthError(CLIError): + """Authentication or authorization failure.""" + + exit_code = ExitCode.AUTH_ERROR + + +class NetworkError(CLIError): + """Network connectivity or timeout error.""" + + exit_code = ExitCode.NETWORK_ERROR + + +class ServerError(CLIError): + """Server returned an error (5xx).""" + + exit_code = ExitCode.SERVER_ERROR + + +def handle_error(error: Exception) -> int: + """Handle an error and return the appropriate exit code.""" + if isinstance(error, CLIError): + print(f"Error: {error}", file=sys.stderr) + return error.exit_code + else: + print(f"Unexpected error: {error}", file=sys.stderr) + return ExitCode.GENERAL_ERROR diff --git a/openwebui_cli/formatters/__init__.py b/openwebui_cli/formatters/__init__.py new file mode 100644 index 0000000..2bec1e4 --- /dev/null +++ b/openwebui_cli/formatters/__init__.py @@ -0,0 +1 @@ +"""Output formatters.""" diff --git a/openwebui_cli/http.py b/openwebui_cli/http.py new file mode 100644 index 0000000..07a6581 --- /dev/null +++ b/openwebui_cli/http.py @@ -0,0 +1,151 @@ +"""HTTP client wrapper for OpenWebUI API.""" + +from typing import Any, AsyncIterator + +import httpx +import keyring + +from .config import get_effective_config, load_config +from .errors import AuthError, NetworkError, ServerError + + +KEYRING_SERVICE = "openwebui-cli" + + +def get_token(profile: str, uri: str) -> str | None: + """Retrieve token from system keyring.""" + key = f"{profile}:{uri}" + return keyring.get_password(KEYRING_SERVICE, key) + + +def set_token(profile: str, uri: str, token: str) -> None: + """Store token in system keyring.""" + key = f"{profile}:{uri}" + keyring.set_password(KEYRING_SERVICE, key, token) + + +def delete_token(profile: str, uri: str) -> None: + """Delete token from system keyring.""" + key = f"{profile}:{uri}" + try: + keyring.delete_password(KEYRING_SERVICE, key) + except keyring.errors.PasswordDeleteError: + pass # Token doesn't exist, that's fine + + +def create_client( + profile: str | None = None, + uri: str | None = None, + token: str | None = None, + timeout: float | None = None, +) -> httpx.Client: + """ + Create an HTTP client configured for OpenWebUI API. + + Args: + profile: Profile name to use + uri: Override server URI + token: Override token (otherwise uses keyring) + timeout: Request timeout in seconds + + Returns: + Configured httpx.Client + """ + effective_uri, effective_profile = get_effective_config(profile, uri) + config = load_config() + + # Get token from keyring if not provided + if token is None: + token = get_token(effective_profile, effective_uri) + + # Build headers + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + # Use config timeout if not specified + if timeout is None: + timeout = config.defaults.timeout + + return httpx.Client( + base_url=effective_uri, + headers=headers, + timeout=timeout, + ) + + +def create_async_client( + profile: str | None = None, + uri: str | None = None, + token: str | None = None, + timeout: float | None = None, +) -> httpx.AsyncClient: + """Create an async HTTP client configured for OpenWebUI API.""" + effective_uri, effective_profile = get_effective_config(profile, uri) + config = load_config() + + if token is None: + token = get_token(effective_profile, effective_uri) + + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + if timeout is None: + timeout = config.defaults.timeout + + return httpx.AsyncClient( + base_url=effective_uri, + headers=headers, + timeout=timeout, + ) + + +def handle_response(response: httpx.Response) -> dict[str, Any]: + """ + Handle API response, raising appropriate errors for failures. + + Returns: + Parsed JSON response + + Raises: + AuthError: For 401/403 responses + ServerError: For 5xx responses + CLIError: For other error responses + """ + if response.status_code == 401: + raise AuthError("Authentication required. Please run 'openwebui auth login'") + elif response.status_code == 403: + raise AuthError("Permission denied. Check your access rights.") + elif response.status_code >= 500: + raise ServerError(f"Server error: {response.status_code} - {response.text}") + elif response.status_code >= 400: + try: + error_data = response.json() + message = error_data.get("detail", error_data.get("message", response.text)) + except Exception: + message = response.text + raise ServerError(f"API error: {response.status_code} - {message}") + + try: + return response.json() + except Exception: + return {"text": response.text} + + +def handle_request_error(error: Exception) -> None: + """Convert httpx errors to CLI errors.""" + if isinstance(error, httpx.ConnectError): + raise NetworkError(f"Could not connect to server: {error}") + elif isinstance(error, httpx.TimeoutException): + raise NetworkError(f"Request timed out: {error}") + elif isinstance(error, httpx.RequestError): + raise NetworkError(f"Request failed: {error}") + else: + raise error diff --git a/openwebui_cli/main.py b/openwebui_cli/main.py new file mode 100644 index 0000000..73bdffc --- /dev/null +++ b/openwebui_cli/main.py @@ -0,0 +1,56 @@ +"""Main CLI entry point.""" + +import typer +from rich.console import Console + +from . import __version__ +from .commands import auth, chat, config_cmd, models, rag, admin + +# Create main app +app = typer.Typer( + name="openwebui", + help="Official CLI for OpenWebUI", + no_args_is_help=True, + rich_markup_mode="rich", +) + +# Create console for rich output +console = Console() + +# Register sub-commands +app.add_typer(auth.app, name="auth", help="Authentication commands") +app.add_typer(chat.app, name="chat", help="Chat operations") +app.add_typer(models.app, name="models", help="Model management") +app.add_typer(rag.app, name="rag", help="RAG file and collection operations") +app.add_typer(admin.app, name="admin", help="Admin operations (requires admin role)") +app.add_typer(config_cmd.app, name="config", help="CLI configuration") + + +@app.callback(invoke_without_command=True) +def main( + ctx: typer.Context, + version: bool = typer.Option(False, "--version", "-v", help="Show version"), + profile: str | None = typer.Option(None, "--profile", "-P", help="Use named profile"), + uri: str | None = typer.Option(None, "--uri", "-U", help="Server URI"), + format: str | None = typer.Option(None, "--format", "-f", help="Output format: text, json, yaml"), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-essential output"), + verbose: bool = typer.Option(False, "--verbose", "--debug", help="Enable debug logging"), + timeout: int | None = typer.Option(None, "--timeout", "-t", help="Request timeout in seconds"), +) -> None: + """OpenWebUI CLI - interact with your OpenWebUI instance from the command line.""" + if version: + console.print(f"openwebui-cli version {__version__}") + raise typer.Exit() + + # Store global options in context for subcommands + ctx.ensure_object(dict) + ctx.obj["profile"] = profile + ctx.obj["uri"] = uri + ctx.obj["format"] = format or "text" + ctx.obj["quiet"] = quiet + ctx.obj["verbose"] = verbose + ctx.obj["timeout"] = timeout + + +if __name__ == "__main__": + app() diff --git a/openwebui_cli/utils/__init__.py b/openwebui_cli/utils/__init__.py new file mode 100644 index 0000000..183c974 --- /dev/null +++ b/openwebui_cli/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules.""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c187299 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,72 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "openwebui-cli" +version = "0.1.0" +description = "Official CLI for OpenWebUI - interact with your OpenWebUI instance from the command line" +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +authors = [ + { name = "InfraFabric Team" }, +] +keywords = ["openwebui", "cli", "llm", "ai", "chat"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "typer>=0.9.0", + "httpx>=0.25.0", + "rich>=13.0.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "pyyaml>=6.0", + "keyring>=24.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.0.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] + +[project.scripts] +openwebui = "openwebui_cli.main:app" + +[project.urls] +Homepage = "https://github.com/dannystocker/openwebui-cli" +Documentation = "https://github.com/dannystocker/openwebui-cli#readme" +Repository = "https://github.com/dannystocker/openwebui-cli.git" +Issues = "https://github.com/dannystocker/openwebui-cli/issues" + +[tool.hatch.build.targets.wheel] +packages = ["openwebui_cli"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.mypy] +python_version = "3.11" +strict = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..910c4dd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for openwebui-cli.""" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..423881d --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,52 @@ +"""Tests for configuration module.""" + +import tempfile +from pathlib import Path + +import pytest + +from openwebui_cli.config import Config, ProfileConfig, load_config, save_config + + +def test_default_config(): + """Test default configuration values.""" + config = Config() + + assert config.version == 1 + assert config.default_profile == "default" + assert "default" in config.profiles + assert config.defaults.format == "text" + assert config.defaults.stream is True + assert config.defaults.timeout == 30 + + +def test_profile_config(): + """Test profile configuration.""" + profile = ProfileConfig(uri="https://example.com") + + assert profile.uri == "https://example.com" + + +def test_config_roundtrip(tmp_path, monkeypatch): + """Test saving and loading config.""" + config_dir = tmp_path / "openwebui" + config_path = config_dir / "config.yaml" + + # Monkey-patch the config path functions + monkeypatch.setattr("openwebui_cli.config.get_config_dir", lambda: config_dir) + monkeypatch.setattr("openwebui_cli.config.get_config_path", lambda: config_path) + + # Create and save config + config = Config( + default_profile="test", + profiles={"test": ProfileConfig(uri="https://test.example.com")}, + ) + config.defaults.model = "test-model" + + save_config(config) + + # Load and verify + loaded = load_config() + assert loaded.default_profile == "test" + assert loaded.profiles["test"].uri == "https://test.example.com" + assert loaded.defaults.model == "test-model" diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..4db4913 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,47 @@ +"""Tests for error handling.""" + +import pytest + +from openwebui_cli.errors import ( + AuthError, + CLIError, + ExitCode, + NetworkError, + ServerError, + UsageError, + handle_error, +) + + +def test_exit_codes(): + """Test exit code values.""" + assert ExitCode.SUCCESS == 0 + assert ExitCode.GENERAL_ERROR == 1 + assert ExitCode.USAGE_ERROR == 2 + assert ExitCode.AUTH_ERROR == 3 + assert ExitCode.NETWORK_ERROR == 4 + assert ExitCode.SERVER_ERROR == 5 + + +def test_cli_error(): + """Test base CLI error.""" + error = CLIError("Test error") + assert str(error) == "Test error" + assert error.exit_code == ExitCode.GENERAL_ERROR + + +def test_error_classes(): + """Test specific error classes have correct exit codes.""" + assert UsageError("test").exit_code == ExitCode.USAGE_ERROR + assert AuthError("test").exit_code == ExitCode.AUTH_ERROR + assert NetworkError("test").exit_code == ExitCode.NETWORK_ERROR + assert ServerError("test").exit_code == ExitCode.SERVER_ERROR + + +def test_handle_error(): + """Test error handling returns correct exit codes.""" + assert handle_error(UsageError("test")) == ExitCode.USAGE_ERROR + assert handle_error(AuthError("test")) == ExitCode.AUTH_ERROR + assert handle_error(NetworkError("test")) == ExitCode.NETWORK_ERROR + assert handle_error(ServerError("test")) == ExitCode.SERVER_ERROR + assert handle_error(Exception("test")) == ExitCode.GENERAL_ERROR