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 <noreply@anthropic.com>
This commit is contained in:
Danny Stocker 2025-11-30 20:09:19 +01:00
commit 8530f74687
22 changed files with 2486 additions and 0 deletions

61
.gitignore vendored Normal file
View file

@ -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

21
LICENSE Normal file
View file

@ -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.

174
README.md Normal file
View file

@ -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 <FILE_ID>
# Continue a conversation
openwebui chat send -m llama3.2:latest -p "Tell me more" --chat-id <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 <COLL_ID>
```
### 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

786
docs/RFC.md Executable file
View file

@ -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] <command> <subcommand> [options]
Global Options:
--uri <URL> Server URL (default: $OPENWEBUI_URI or http://localhost:8080)
--token <TOKEN> API token (default: $OPENWEBUI_TOKEN)
--profile <NAME> Use named profile from config
--format <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 <NAME> # 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 <NAME> # Create new API key
│ │ │ └── --scopes <LIST> # Comma-separated scopes
│ │ ├── revoke <KEY_ID> # Revoke API key
│ │ └── info <KEY_ID> # Show key details
│ └── whoami # Show current user info
├── chat # Chat Operations
│ ├── send # Send a message
│ │ ├── --model, -m <MODEL> # Model to use (required)
│ │ ├── --prompt, -p <TEXT> # User prompt (or stdin)
│ │ ├── --system, -s <TEXT> # System prompt
│ │ ├── --file <ID> # RAG file ID(s) for context
│ │ ├── --collection <ID> # RAG collection ID(s) for context
│ │ ├── --stream # Stream response (default: true)
│ │ ├── --no-stream # Wait for complete response
│ │ ├── --chat-id <ID> # Continue existing conversation
│ │ ├── --temperature <FLOAT> # Temperature (0.0-2.0)
│ │ ├── --max-tokens <INT> # Max response tokens
│ │ └── --json # Request JSON output
│ ├── list # List conversations
│ │ ├── --limit <N> # Number to show (default: 20)
│ │ ├── --archived # Show archived chats
│ │ └── --search <QUERY> # Search conversations
│ ├── show <CHAT_ID> # Show conversation details
│ ├── export <CHAT_ID> # Export conversation
│ │ └── --format <FMT> # json, markdown, txt
│ ├── archive <CHAT_ID> # Archive conversation
│ ├── unarchive <CHAT_ID> # Unarchive conversation
│ └── delete <CHAT_ID> # Delete conversation
├── models # Model Management
│ ├── list # List available models
│ │ ├── --provider <NAME> # Filter by provider (ollama, openai, etc.)
│ │ └── --capabilities # Show model capabilities
│ ├── info <MODEL_ID> # Show model details
│ ├── pull <MODEL_NAME> # Pull/download model (Ollama)
│ │ └── --progress # Show download progress
│ ├── delete <MODEL_NAME> # Delete model
│ └── copy <SRC> <DST> # Copy/alias model
├── rag # RAG (Retrieval-Augmented Generation)
│ ├── files # File operations
│ │ ├── list # List uploaded files
│ │ ├── upload <PATH>... # Upload file(s)
│ │ │ ├── --collection <ID> # Add to collection
│ │ │ └── --tags <LIST> # Add tags
│ │ ├── info <FILE_ID> # File details
│ │ ├── download <FILE_ID> # Download file
│ │ └── delete <FILE_ID> # Delete file
│ ├── collections # Collection operations
│ │ ├── list # List collections
│ │ ├── create <NAME> # Create collection
│ │ │ └── --description <TEXT>
│ │ ├── info <COLL_ID> # Collection details
│ │ ├── add <COLL_ID> <FILE_ID>... # Add files to collection
│ │ ├── remove <COLL_ID> <FILE_ID> # Remove file from collection
│ │ └── delete <COLL_ID> # Delete collection
│ └── query <QUERY> # Direct vector search (no LLM)
│ ├── --collection <ID> # Search in collection
│ ├── --file <ID> # Search in file
│ ├── --top-k <N> # Number of results (default: 5)
│ └── --threshold <FLOAT> # Similarity threshold
├── functions # Custom Functions
│ ├── list # List installed functions
│ │ └── --enabled-only # Show only enabled
│ ├── info <FUNC_ID> # Function details
│ ├── install <URL|PATH> # Install function
│ ├── enable <FUNC_ID> # Enable function
│ ├── disable <FUNC_ID> # Disable function
│ ├── update <FUNC_ID> # Update function
│ └── delete <FUNC_ID> # Delete function
├── pipelines # Pipeline Management
│ ├── list # List pipelines
│ ├── info <PIPE_ID> # Pipeline details
│ ├── create <NAME> # Create pipeline
│ │ └── --config <FILE> # Pipeline config file
│ ├── update <PIPE_ID> # Update pipeline
│ └── delete <PIPE_ID> # Delete pipeline
├── admin # Admin Operations (requires admin role)
│ ├── users # User management
│ │ ├── list # List users
│ │ ├── create <EMAIL> # Create user
│ │ ├── info <USER_ID> # User details
│ │ ├── update <USER_ID> # Update user
│ │ ├── delete <USER_ID> # Delete user
│ │ └── set-role <USER_ID> <ROLE>
│ ├── config # Server configuration
│ │ ├── get [KEY] # Get config value(s)
│ │ └── set <KEY> <VALUE> # Set config value
│ └── stats # Usage statistics
│ ├── --period <PERIOD> # day, week, month
│ └── --export # Export as CSV
└── config # CLI Configuration
├── init # Initialize config file
├── show # Show current config
├── set <KEY> <VALUE> # Set config value
├── get <KEY> # Get config value
└── profiles # Profile management
├── list # List profiles
├── create <NAME> # Create profile
├── use <NAME> # Set default profile
└── delete <NAME> # 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 <KEY> <VALUE>` - 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 <PATH>... [--collection <ID>]` - Upload file(s)
- `rag files delete <FILE_ID>` - Delete file
- `rag collections list` - List collections
- `rag collections create <NAME>` - Create collection
- `rag collections delete <COLL_ID>` - Delete collection
- `rag search <QUERY> --collection <ID> --top-k <N> --format json` - Vector search
### Models (v1.0)
- `models list` - List available models
- `models info <MODEL_ID>` - Show model details
*Model operations (`pull/delete/copy`) deferred to v1.1 depending on API maturity.*
### Admin (v1.0 - Minimal)
- `admin stats --format <text|json>` - 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 <KEY_ID>` - 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 <CHAT_ID>` - Show conversation details
- `chat export <CHAT_ID> [--format markdown|json]` - Export conversation
- `chat archive/delete <CHAT_ID>` - 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 <COLL_ID> <FILE_ID>...` - Add files to collection
- `rag collections ingest <COLL_ID> <PATH>...` - 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 <command>
# or
open-webui-cli <command>
```
---
## 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.*

View file

@ -0,0 +1,4 @@
"""OpenWebUI CLI - Official command-line interface for OpenWebUI."""
__version__ = "0.1.0"
__author__ = "InfraFabric Team"

View file

@ -0,0 +1 @@
"""CLI command modules."""

View file

@ -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]")

View file

@ -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)

View file

@ -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]")

View file

@ -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 <model> -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)

View file

@ -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]")

View file

@ -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)

113
openwebui_cli/config.py Normal file
View file

@ -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

60
openwebui_cli/errors.py Normal file
View file

@ -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

View file

@ -0,0 +1 @@
"""Output formatters."""

151
openwebui_cli/http.py Normal file
View file

@ -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

56
openwebui_cli/main.py Normal file
View file

@ -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()

View file

@ -0,0 +1 @@
"""Utility modules."""

72
pyproject.toml Normal file
View file

@ -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"]

1
tests/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Tests for openwebui-cli."""

52
tests/test_config.py Normal file
View file

@ -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"

47
tests/test_errors.py Normal file
View file

@ -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