mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-09-01 15:36:07 +08:00
Merge pull request #211 from dorukozgen/feat/add-obsidian-harness
feat: add Obsidian CLI harness
This commit is contained in:
@@ -92,6 +92,7 @@
|
||||
!/cloudcompare/
|
||||
!/openscreen/
|
||||
!/n8n/
|
||||
!/obsidian/
|
||||
|
||||
# Step 5: Inside each software dir, ignore everything (including dotfiles)
|
||||
/gimp/*
|
||||
@@ -168,6 +169,8 @@
|
||||
/exa/.*
|
||||
/n8n/*
|
||||
/n8n/.*
|
||||
/obsidian/*
|
||||
/obsidian/.*
|
||||
|
||||
# Step 6: ...except agent-harness/
|
||||
!/gimp/agent-harness/
|
||||
@@ -211,6 +214,7 @@
|
||||
!/wiremock/agent-harness/
|
||||
!/exa/agent-harness/
|
||||
!/n8n/agent-harness/
|
||||
!/obsidian/agent-harness/
|
||||
|
||||
# Step 7: Ignore build artifacts within allowed dirs
|
||||
**/__pycache__/
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Obsidian: Project-Specific Analysis & SOP
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
Obsidian is a knowledge management and note-taking app that stores notes as local Markdown files.
|
||||
The Local REST API plugin exposes vault operations via an HTTPS server on `localhost:27124`.
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ Obsidian Desktop App │
|
||||
│ ┌───────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ Vault │ │ Search │ │ Commands │ │
|
||||
│ │ Manager │ │ Engine │ │ Registry │ │
|
||||
│ └─────┬──────┘ └────┬─────┘ └───────┬──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌─────┴─────────────┴───────────────┴───────────┐ │
|
||||
│ │ Local REST API Plugin (port 27124) │ │
|
||||
│ │ /vault/ /search/ /commands/ │ │
|
||||
│ │ /active/ /periodic-notes/ │ │
|
||||
│ └───────────────────┬───────────────────────────┘ │
|
||||
└──────────────────────┼─────────────────────────────┘
|
||||
│ HTTPS + Bearer Token
|
||||
┌─────────────┴──────────────┐
|
||||
│ cli-anything-obsidian │
|
||||
│ Click CLI + REPL │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
## CLI Strategy: REST API Wrapper
|
||||
|
||||
Our CLI wraps the Obsidian Local REST API plugin with:
|
||||
|
||||
1. **requests** — HTTP client for all API calls (HTTPS, self-signed cert)
|
||||
2. **Bearer token** — Authentication via API key
|
||||
3. **Click CLI** — Structured command groups matching the API surface
|
||||
4. **REPL** — Interactive mode for exploratory use
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/` | GET | Server status/auth check |
|
||||
| `/vault/` | GET | List vault files |
|
||||
| `/vault/{path}` | GET | Read note content |
|
||||
| `/vault/{path}` | PUT | Create/update note |
|
||||
| `/vault/{path}` | DELETE | Delete note |
|
||||
| `/vault/{path}` | PATCH | Append/prepend to note |
|
||||
| `/search/` | POST | Search with Obsidian syntax |
|
||||
| `/search/simple/` | POST | Plain text search |
|
||||
| `/active/` | GET | Get active note |
|
||||
| `/active/` | PUT | Open a note |
|
||||
| `/commands/` | GET | List commands |
|
||||
| `/commands/{id}/` | POST | Execute command |
|
||||
|
||||
### Authentication
|
||||
|
||||
The Local REST API plugin generates an API key in its settings.
|
||||
Pass via `--api-key` flag or `OBSIDIAN_API_KEY` environment variable.
|
||||
All requests use HTTPS with a self-signed certificate (`verify=False`).
|
||||
|
||||
## CLI → API Mapping
|
||||
|
||||
| CLI Command | API Call |
|
||||
|-------------|----------|
|
||||
| `vault list [path]` | `GET /vault/[path]/` |
|
||||
| `vault read <path>` | `GET /vault/{path}` |
|
||||
| `vault create <path>` | `PUT /vault/{path}` |
|
||||
| `vault update <path>` | `PUT /vault/{path}` |
|
||||
| `vault delete <path>` | `DELETE /vault/{path}` |
|
||||
| `vault append <path>` | `PATCH /vault/{path}` |
|
||||
| `search query <q>` | `POST /search/` |
|
||||
| `search simple <q>` | `POST /search/simple/` |
|
||||
| `note active` | `GET /active/` |
|
||||
| `note open <path>` | `PUT /active/` |
|
||||
| `command list` | `GET /commands/` |
|
||||
| `command execute <id>` | `POST /commands/{id}/` |
|
||||
| `server status` | `GET /` |
|
||||
| `session status` | (local state) |
|
||||
@@ -0,0 +1,190 @@
|
||||
# Obsidian CLI
|
||||
|
||||
A command-line interface for knowledge management and note-taking via the Obsidian Local REST API.
|
||||
Designed for AI agents and power users who need to manage notes, search the vault, and execute commands without a GUI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- [Obsidian](https://obsidian.md) installed and running with the [Local REST API plugin](https://github.com/coddingtonbear/obsidian-local-rest-api) enabled
|
||||
- `click` (CLI framework)
|
||||
- `requests` (HTTP client)
|
||||
|
||||
Optional (for interactive REPL):
|
||||
- `prompt_toolkit`
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install click requests prompt_toolkit
|
||||
```
|
||||
|
||||
## How to Run
|
||||
|
||||
All commands are run from the `agent-harness/` directory, or via the installed entry point.
|
||||
|
||||
### One-shot commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-obsidian --help
|
||||
|
||||
# List vault files
|
||||
cli-anything-obsidian vault list
|
||||
|
||||
# Read a note
|
||||
cli-anything-obsidian vault read "Notes/my-note.md"
|
||||
|
||||
# Search the vault
|
||||
cli-anything-obsidian search simple "meeting notes"
|
||||
|
||||
# JSON output (for agent consumption)
|
||||
cli-anything-obsidian --json server status
|
||||
```
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
```bash
|
||||
cli-anything-obsidian
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
Inside the REPL, type `help` for all available commands.
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Vault
|
||||
|
||||
```bash
|
||||
vault list [path] # List files in the vault (or subdirectory)
|
||||
vault read <path> # Read note content
|
||||
vault create <path> --content "..." # Create a new note
|
||||
vault update <path> --content "..." # Overwrite a note
|
||||
vault delete <path> # Delete a note
|
||||
vault append <path> --content "..." # Append content to a note
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
search query <query> # Search using Obsidian query syntax
|
||||
search simple <query> # Plain text search across the vault
|
||||
```
|
||||
|
||||
### Note
|
||||
|
||||
```bash
|
||||
note active # Get the currently active note
|
||||
note open <path> # Open a note in Obsidian
|
||||
```
|
||||
|
||||
### Command
|
||||
|
||||
```bash
|
||||
command list # List all available Obsidian commands
|
||||
command execute <id> # Execute a command by ID
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
server status # Check if Obsidian Local REST API is running
|
||||
```
|
||||
|
||||
### Session
|
||||
|
||||
```bash
|
||||
session status # Show session state
|
||||
```
|
||||
|
||||
## JSON Mode
|
||||
|
||||
Add `--json` before the subcommand for machine-readable output:
|
||||
|
||||
```bash
|
||||
cli-anything-obsidian --json vault list
|
||||
cli-anything-obsidian --json search simple "project ideas"
|
||||
```
|
||||
|
||||
## API Key
|
||||
|
||||
Provide your Obsidian Local REST API key via flag or environment variable:
|
||||
|
||||
```bash
|
||||
# Via flag
|
||||
cli-anything-obsidian --api-key YOUR_KEY vault list
|
||||
|
||||
# Via environment variable
|
||||
export OBSIDIAN_API_KEY=YOUR_KEY
|
||||
cli-anything-obsidian vault list
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```bash
|
||||
# Check server
|
||||
cli-anything-obsidian server status
|
||||
|
||||
# List all notes
|
||||
cli-anything-obsidian vault list
|
||||
|
||||
# Read a specific note
|
||||
cli-anything-obsidian vault read "Daily Notes/2024-01-15.md"
|
||||
|
||||
# Create a new note
|
||||
cli-anything-obsidian vault create "Projects/new-project.md" --content "# New Project\n\nProject notes here."
|
||||
|
||||
# Search for notes
|
||||
cli-anything-obsidian search simple "quarterly review"
|
||||
|
||||
# Append to a note
|
||||
cli-anything-obsidian vault append "Projects/new-project.md" --content "\n## Update\nProgress notes."
|
||||
|
||||
# Open a note in Obsidian
|
||||
cli-anything-obsidian note open "Projects/new-project.md"
|
||||
|
||||
# Execute a command
|
||||
cli-anything-obsidian command list
|
||||
cli-anything-obsidian command execute "editor:toggle-bold"
|
||||
|
||||
# Clean up
|
||||
cli-anything-obsidian vault delete "Projects/new-project.md"
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-obsidian vault list
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-obsidian --json vault list
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Set `OBSIDIAN_API_KEY`** environment variable to avoid passing `--api-key` on every call
|
||||
5. **Verify Obsidian is running** with `server status` before other commands
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd agent-harness
|
||||
python -m pytest cli_anything/obsidian/tests/test_core.py -v # Unit tests (no Obsidian needed)
|
||||
python -m pytest cli_anything/obsidian/tests/test_full_e2e.py -v # E2E tests (requires Obsidian)
|
||||
python -m pytest cli_anything/obsidian/tests/ -v # All tests
|
||||
```
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1 @@
|
||||
"""Obsidian CLI - Knowledge management and note-taking."""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Allow running as python -m cli_anything.obsidian"""
|
||||
from cli_anything.obsidian.obsidian_cli import main
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Obsidian command operations — list and execute."""
|
||||
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_get, api_post
|
||||
|
||||
|
||||
def list_commands(base_url: str, api_key: str) -> dict:
|
||||
"""List available Obsidian commands."""
|
||||
return api_get(base_url, "/commands/", api_key)
|
||||
|
||||
|
||||
def execute_command(base_url: str, api_key: str, command_id: str) -> dict:
|
||||
"""Execute an Obsidian command by ID."""
|
||||
endpoint = f"/commands/{command_id}/"
|
||||
return api_post(base_url, endpoint, api_key)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Obsidian active note operations — get and open."""
|
||||
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_get, api_put
|
||||
|
||||
|
||||
def get_active(base_url: str, api_key: str) -> dict:
|
||||
"""Get the currently active (open) note in Obsidian."""
|
||||
return api_get(base_url, "/active/", api_key)
|
||||
|
||||
|
||||
def open_note(base_url: str, api_key: str, path: str) -> dict:
|
||||
"""Open a note in Obsidian."""
|
||||
return api_put(base_url, "/active/", api_key, content=path,
|
||||
content_type="text/plain")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Obsidian search operations — query and simple text search."""
|
||||
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_post
|
||||
|
||||
|
||||
def search_query(base_url: str, api_key: str, query: str) -> dict:
|
||||
"""Search vault using Obsidian's search engine.
|
||||
|
||||
Args:
|
||||
base_url: API base URL.
|
||||
api_key: Bearer token.
|
||||
query: Search query string (Obsidian search syntax).
|
||||
|
||||
Returns:
|
||||
Search results from Obsidian.
|
||||
"""
|
||||
return api_post(base_url, "/search/", api_key, data={"query": query})
|
||||
|
||||
|
||||
def search_simple(base_url: str, api_key: str, query: str,
|
||||
context_length: int = 100) -> dict:
|
||||
"""Simple text search across the vault.
|
||||
|
||||
Args:
|
||||
base_url: API base URL.
|
||||
api_key: Bearer token.
|
||||
query: Plain text to search for.
|
||||
context_length: Number of context characters around matches.
|
||||
|
||||
Returns:
|
||||
List of search results with filename and matches.
|
||||
"""
|
||||
return api_post(base_url, "/search/simple/", api_key,
|
||||
params={"query": query, "contextLength": context_length})
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Obsidian server info — status check."""
|
||||
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_get
|
||||
|
||||
|
||||
def server_status(base_url: str, api_key: str) -> dict:
|
||||
"""Check if Obsidian REST API is running and authenticated.
|
||||
|
||||
Returns:
|
||||
Dict with server status info.
|
||||
"""
|
||||
return api_get(base_url, "/", api_key)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Obsidian vault operations — list, read, create, update, delete, append."""
|
||||
|
||||
from cli_anything.obsidian.utils.obsidian_backend import (
|
||||
api_get, api_put, api_delete,
|
||||
)
|
||||
|
||||
|
||||
def list_files(base_url: str, api_key: str, path: str = "/") -> dict:
|
||||
"""List files and folders in the vault."""
|
||||
endpoint = f"/vault/{path.strip('/')}/" if path != "/" else "/vault/"
|
||||
return api_get(base_url, endpoint, api_key)
|
||||
|
||||
|
||||
def read_note(base_url: str, api_key: str, path: str) -> dict:
|
||||
"""Read a note's content."""
|
||||
endpoint = f"/vault/{path.lstrip('/')}"
|
||||
return api_get(base_url, endpoint, api_key)
|
||||
|
||||
|
||||
def create_note(base_url: str, api_key: str, path: str, content: str) -> dict:
|
||||
"""Create a new note in the vault."""
|
||||
endpoint = f"/vault/{path.lstrip('/')}"
|
||||
return api_put(base_url, endpoint, api_key, content=content)
|
||||
|
||||
|
||||
def update_note(base_url: str, api_key: str, path: str, content: str) -> dict:
|
||||
"""Update an existing note's content (overwrites)."""
|
||||
endpoint = f"/vault/{path.lstrip('/')}"
|
||||
return api_put(base_url, endpoint, api_key, content=content)
|
||||
|
||||
|
||||
def delete_note(base_url: str, api_key: str, path: str) -> dict:
|
||||
"""Delete a note from the vault."""
|
||||
endpoint = f"/vault/{path.lstrip('/')}"
|
||||
return api_delete(base_url, endpoint, api_key)
|
||||
|
||||
|
||||
def append_note(base_url: str, api_key: str, path: str, content: str,
|
||||
position: str = "end") -> dict:
|
||||
"""Append or prepend content to an existing note.
|
||||
|
||||
Reads the current content, inserts new content at the specified position,
|
||||
and writes back. This approach works with Obsidian REST API v3.x which
|
||||
changed PATCH to target-based operations.
|
||||
|
||||
Args:
|
||||
base_url: API base URL.
|
||||
api_key: Bearer token.
|
||||
path: Path to the note.
|
||||
content: Content to insert.
|
||||
position: 'end' to append, 'beginning' to prepend.
|
||||
|
||||
Returns:
|
||||
Status dict.
|
||||
"""
|
||||
existing = read_note(base_url, api_key, path)
|
||||
current = existing.get("content", "")
|
||||
if position == "beginning":
|
||||
new_content = content + current
|
||||
else:
|
||||
new_content = current + content
|
||||
return update_note(base_url, api_key, path, new_content)
|
||||
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Obsidian CLI — Knowledge management and note-taking via Obsidian Local REST API.
|
||||
|
||||
This CLI provides full access to the Obsidian REST API for managing notes,
|
||||
searching the vault, and executing Obsidian commands.
|
||||
|
||||
Usage:
|
||||
# One-shot commands
|
||||
cli-anything-obsidian --api-key YOUR_KEY vault list
|
||||
cli-anything-obsidian --api-key YOUR_KEY vault read "My Note.md"
|
||||
cli-anything-obsidian --api-key YOUR_KEY --json search query "tag:#project"
|
||||
|
||||
# Interactive REPL
|
||||
cli-anything-obsidian --api-key YOUR_KEY
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
from cli_anything.obsidian.utils.obsidian_backend import DEFAULT_BASE_URL
|
||||
from cli_anything.obsidian.core import vault as vault_mod
|
||||
from cli_anything.obsidian.core import search as search_mod
|
||||
from cli_anything.obsidian.core import note as note_mod
|
||||
from cli_anything.obsidian.core import command as cmd_mod
|
||||
from cli_anything.obsidian.core import server as server_mod
|
||||
|
||||
# Global state
|
||||
_json_output = False
|
||||
_repl_mode = False
|
||||
_host = DEFAULT_BASE_URL
|
||||
_api_key = ""
|
||||
_last_path: str = ""
|
||||
|
||||
|
||||
def output(data, message: str = ""):
|
||||
if _json_output:
|
||||
click.echo(json.dumps(data, indent=2, default=str))
|
||||
else:
|
||||
if message:
|
||||
click.echo(message)
|
||||
if isinstance(data, dict):
|
||||
_print_dict(data)
|
||||
elif isinstance(data, list):
|
||||
_print_list(data)
|
||||
else:
|
||||
click.echo(str(data))
|
||||
|
||||
|
||||
def _print_dict(d: dict, indent: int = 0):
|
||||
prefix = " " * indent
|
||||
for k, v in d.items():
|
||||
if isinstance(v, dict):
|
||||
click.echo(f"{prefix}{k}:")
|
||||
_print_dict(v, indent + 1)
|
||||
elif isinstance(v, list):
|
||||
click.echo(f"{prefix}{k}:")
|
||||
_print_list(v, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}{k}: {v}")
|
||||
|
||||
|
||||
def _print_list(items: list, indent: int = 0):
|
||||
prefix = " " * indent
|
||||
for i, item in enumerate(items):
|
||||
if isinstance(item, dict):
|
||||
click.echo(f"{prefix}[{i}]")
|
||||
_print_dict(item, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}- {item}")
|
||||
|
||||
|
||||
def handle_error(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except RuntimeError as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": "runtime_error"}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
except (ValueError, IndexError) as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
wrapper.__name__ = func.__name__
|
||||
wrapper.__doc__ = func.__doc__
|
||||
return wrapper
|
||||
|
||||
|
||||
def _require_api_key():
|
||||
"""Check that API key is set, raise error if not."""
|
||||
if not _api_key:
|
||||
raise RuntimeError("API key required. Use --api-key or set OBSIDIAN_API_KEY env var.")
|
||||
|
||||
|
||||
# ── Main CLI Group ──────────────────────────────────────────────
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
|
||||
@click.option("--host", type=str, default=None,
|
||||
help=f"Obsidian REST API URL (default: {DEFAULT_BASE_URL})")
|
||||
@click.option("--api-key", type=str, default=None,
|
||||
help="API key for authentication (or set OBSIDIAN_API_KEY env var)")
|
||||
@click.pass_context
|
||||
def cli(ctx, use_json, host, api_key):
|
||||
"""Obsidian CLI — Knowledge management and note-taking.
|
||||
|
||||
Run without a subcommand to enter interactive REPL mode.
|
||||
"""
|
||||
global _json_output, _host, _api_key
|
||||
_json_output = use_json
|
||||
_host = host if host else DEFAULT_BASE_URL
|
||||
_api_key = api_key or os.environ.get("OBSIDIAN_API_KEY", "")
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
_require_api_key()
|
||||
ctx.invoke(repl)
|
||||
|
||||
|
||||
# ── Vault Commands ──────────────────────────────────────────────
|
||||
@cli.group()
|
||||
def vault():
|
||||
"""Vault file operations."""
|
||||
pass
|
||||
|
||||
|
||||
@vault.command("list")
|
||||
@click.argument("path", default="/")
|
||||
@handle_error
|
||||
def vault_list(path):
|
||||
"""List files and folders in the vault."""
|
||||
_require_api_key()
|
||||
result = vault_mod.list_files(_host, _api_key, path)
|
||||
files = result.get("files", [])
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
if not files:
|
||||
click.echo("No files found.")
|
||||
return
|
||||
click.echo(f"{'FILE':<60}")
|
||||
click.echo("─" * 60)
|
||||
for f in files:
|
||||
click.echo(f"{f}")
|
||||
|
||||
|
||||
@vault.command("read")
|
||||
@click.argument("path")
|
||||
@handle_error
|
||||
def vault_read(path):
|
||||
"""Read a note's content."""
|
||||
_require_api_key()
|
||||
global _last_path
|
||||
_last_path = path
|
||||
result = vault_mod.read_note(_host, _api_key, path)
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
click.echo(result.get("content", ""))
|
||||
|
||||
|
||||
@vault.command("create")
|
||||
@click.argument("path")
|
||||
@click.option("--content", "-c", default="", help="Note content (markdown)")
|
||||
@click.option("--file", "-f", "input_file", type=click.Path(exists=True),
|
||||
help="Read content from file")
|
||||
@handle_error
|
||||
def vault_create(path, content, input_file):
|
||||
"""Create a new note in the vault."""
|
||||
_require_api_key()
|
||||
if input_file:
|
||||
with open(input_file, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
result = vault_mod.create_note(_host, _api_key, path, content)
|
||||
output(result, f"Created: {path}")
|
||||
|
||||
|
||||
@vault.command("update")
|
||||
@click.argument("path")
|
||||
@click.option("--content", "-c", default="", help="New note content (markdown)")
|
||||
@click.option("--file", "-f", "input_file", type=click.Path(exists=True),
|
||||
help="Read content from file")
|
||||
@handle_error
|
||||
def vault_update(path, content, input_file):
|
||||
"""Update an existing note (overwrites content)."""
|
||||
_require_api_key()
|
||||
if input_file:
|
||||
with open(input_file, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
result = vault_mod.update_note(_host, _api_key, path, content)
|
||||
output(result, f"Updated: {path}")
|
||||
|
||||
|
||||
@vault.command("delete")
|
||||
@click.argument("path")
|
||||
@handle_error
|
||||
def vault_delete(path):
|
||||
"""Delete a note from the vault."""
|
||||
_require_api_key()
|
||||
result = vault_mod.delete_note(_host, _api_key, path)
|
||||
output(result, f"Deleted: {path}")
|
||||
|
||||
|
||||
@vault.command("append")
|
||||
@click.argument("path")
|
||||
@click.option("--content", "-c", required=True, help="Content to append")
|
||||
@click.option("--position", "-p", type=click.Choice(["end", "beginning"]),
|
||||
default="end", help="Insert position (default: end)")
|
||||
@handle_error
|
||||
def vault_append(path, content, position):
|
||||
"""Append or prepend content to a note."""
|
||||
_require_api_key()
|
||||
result = vault_mod.append_note(_host, _api_key, path, content, position=position)
|
||||
output(result, f"{'Appended to' if position == 'end' else 'Prepended to'}: {path}")
|
||||
|
||||
|
||||
# ── Search Commands ─────────────────────────────────────────────
|
||||
@cli.group()
|
||||
def search():
|
||||
"""Search operations."""
|
||||
pass
|
||||
|
||||
|
||||
@search.command("query")
|
||||
@click.argument("query")
|
||||
@handle_error
|
||||
def search_query(query):
|
||||
"""Search vault using Obsidian's search engine."""
|
||||
_require_api_key()
|
||||
result = search_mod.search_query(_host, _api_key, query)
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
if isinstance(result, list):
|
||||
if not result:
|
||||
click.echo("No results found.")
|
||||
return
|
||||
for item in result:
|
||||
filename = item.get("filename", "unknown")
|
||||
score = item.get("score", "")
|
||||
click.echo(f" {filename}" + (f" (score: {score})" if score else ""))
|
||||
else:
|
||||
output(result)
|
||||
|
||||
|
||||
@search.command("simple")
|
||||
@click.argument("query")
|
||||
@click.option("--context-length", "-l", type=int, default=100,
|
||||
help="Context characters around matches (default: 100)")
|
||||
@handle_error
|
||||
def search_simple(query, context_length):
|
||||
"""Simple text search across the vault."""
|
||||
_require_api_key()
|
||||
result = search_mod.search_simple(_host, _api_key, query, context_length=context_length)
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
if isinstance(result, list):
|
||||
if not result:
|
||||
click.echo("No results found.")
|
||||
return
|
||||
for item in result:
|
||||
filename = item.get("filename", "unknown")
|
||||
matches = item.get("matches", [])
|
||||
click.echo(f" {filename} ({len(matches)} matches)")
|
||||
for match in matches[:3]:
|
||||
context = match.get("context", match.get("match", ""))
|
||||
if len(context) > 120:
|
||||
context = context[:120] + "..."
|
||||
click.echo(f" ...{context}...")
|
||||
else:
|
||||
output(result)
|
||||
|
||||
|
||||
# ── Note Commands ───────────────────────────────────────────────
|
||||
@cli.group()
|
||||
def note():
|
||||
"""Active note operations."""
|
||||
pass
|
||||
|
||||
|
||||
@note.command("active")
|
||||
@handle_error
|
||||
def note_active():
|
||||
"""Get the currently active note in Obsidian."""
|
||||
_require_api_key()
|
||||
result = note_mod.get_active(_host, _api_key)
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
click.echo(result.get("content", "(no active note)"))
|
||||
|
||||
|
||||
@note.command("open")
|
||||
@click.argument("path")
|
||||
@handle_error
|
||||
def note_open(path):
|
||||
"""Open a note in Obsidian."""
|
||||
_require_api_key()
|
||||
global _last_path
|
||||
_last_path = path
|
||||
result = note_mod.open_note(_host, _api_key, path)
|
||||
output(result, f"Opened: {path}")
|
||||
|
||||
|
||||
# ── Command Commands ────────────────────────────────────────────
|
||||
@cli.group("command")
|
||||
def command_group():
|
||||
"""Obsidian command operations."""
|
||||
pass
|
||||
|
||||
|
||||
@command_group.command("list")
|
||||
@handle_error
|
||||
def command_list():
|
||||
"""List available Obsidian commands."""
|
||||
_require_api_key()
|
||||
result = cmd_mod.list_commands(_host, _api_key)
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
commands = result.get("commands", result if isinstance(result, list) else [])
|
||||
if not commands:
|
||||
click.echo("No commands available.")
|
||||
return
|
||||
click.echo(f"{'ID':<40} {'NAME'}")
|
||||
click.echo("─" * 70)
|
||||
for cmd in commands:
|
||||
cmd_id = cmd.get("id", "")
|
||||
cmd_name = cmd.get("name", "")
|
||||
click.echo(f"{cmd_id:<40} {cmd_name}")
|
||||
|
||||
|
||||
@command_group.command("execute")
|
||||
@click.argument("command_id")
|
||||
@handle_error
|
||||
def command_execute(command_id):
|
||||
"""Execute an Obsidian command by ID."""
|
||||
_require_api_key()
|
||||
result = cmd_mod.execute_command(_host, _api_key, command_id)
|
||||
output(result, f"Executed: {command_id}")
|
||||
|
||||
|
||||
# ── Server Commands ─────────────────────────────────────────────
|
||||
@cli.group()
|
||||
def server():
|
||||
"""Server status commands."""
|
||||
pass
|
||||
|
||||
|
||||
@server.command("status")
|
||||
@handle_error
|
||||
def server_status():
|
||||
"""Check if Obsidian REST API is running."""
|
||||
_require_api_key()
|
||||
result = server_mod.server_status(_host, _api_key)
|
||||
output(result, f"Obsidian REST API at {_host}: running")
|
||||
|
||||
|
||||
# ── Session Commands ────────────────────────────────────────────
|
||||
@cli.group()
|
||||
def session():
|
||||
"""Session state commands."""
|
||||
pass
|
||||
|
||||
|
||||
@session.command("status")
|
||||
@handle_error
|
||||
def session_status():
|
||||
"""Show current session state."""
|
||||
data = {
|
||||
"host": _host,
|
||||
"api_key_set": bool(_api_key),
|
||||
"last_path": _last_path or "(none)",
|
||||
"json_output": _json_output,
|
||||
}
|
||||
output(data, "Session Status")
|
||||
|
||||
|
||||
# ── REPL ────────────────────────────────────────────────────────
|
||||
@cli.command()
|
||||
@handle_error
|
||||
def repl():
|
||||
"""Start interactive REPL session."""
|
||||
from cli_anything.obsidian.utils.repl_skin import ReplSkin
|
||||
|
||||
global _repl_mode
|
||||
_repl_mode = True
|
||||
|
||||
skin = ReplSkin("obsidian", version="1.0.0")
|
||||
skin.print_banner()
|
||||
|
||||
pt_session = skin.create_prompt_session()
|
||||
|
||||
_repl_commands = {
|
||||
"vault": "list|read|create|update|delete|append",
|
||||
"search": "query|simple",
|
||||
"note": "active|open",
|
||||
"command": "list|execute",
|
||||
"server": "status",
|
||||
"session": "status",
|
||||
"help": "Show this help",
|
||||
"quit": "Exit REPL",
|
||||
}
|
||||
|
||||
while True:
|
||||
try:
|
||||
context = _last_path if _last_path else ""
|
||||
line = skin.get_input(pt_session, project_name=context, modified=False)
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() in ("quit", "exit", "q"):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
if line.lower() == "help":
|
||||
skin.help(_repl_commands)
|
||||
continue
|
||||
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
pass
|
||||
except click.exceptions.UsageError as e:
|
||||
skin.warning(f"Usage error: {e}")
|
||||
except Exception as e:
|
||||
skin.error(f"{e}")
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
# ── Entry Point ─────────────────────────────────────────────────
|
||||
def main():
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,235 @@
|
||||
---
|
||||
name: >-
|
||||
cli-anything-obsidian
|
||||
description: >-
|
||||
Command-line interface for Obsidian — Knowledge management and note-taking via Obsidian Local REST API. Designed for AI agents and power users who need to manage notes, search the vault, and execute commands without the GUI.
|
||||
---
|
||||
|
||||
# cli-anything-obsidian
|
||||
|
||||
Knowledge management and note-taking via the Obsidian Local REST API. Designed for AI agents and power users who need to manage notes, search the vault, and execute commands without the GUI.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-obsidian package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-obsidian
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- Obsidian must be installed and running with the [Local REST API plugin](https://github.com/coddingtonbear/obsidian-local-rest-api) enabled
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-obsidian --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-obsidian
|
||||
|
||||
# List vault files
|
||||
cli-anything-obsidian vault list
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-obsidian --json vault list
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-obsidian
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
|
||||
## Command Groups
|
||||
|
||||
|
||||
### Vault
|
||||
|
||||
Vault file management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List files in the vault or a subdirectory |
|
||||
| `read` | Read the content of a note |
|
||||
| `create` | Create a new note |
|
||||
| `update` | Overwrite an existing note |
|
||||
| `delete` | Delete a note from the vault |
|
||||
| `append` | Append content to an existing note |
|
||||
|
||||
|
||||
### Search
|
||||
|
||||
Vault search commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `query` | Search using Obsidian query syntax |
|
||||
| `simple` | Plain text search across the vault |
|
||||
|
||||
|
||||
### Note
|
||||
|
||||
Active note commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `active` | Get the currently active note in Obsidian |
|
||||
| `open` | Open a note in the Obsidian editor |
|
||||
|
||||
|
||||
### Command
|
||||
|
||||
Obsidian command palette commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all available Obsidian commands |
|
||||
| `execute` | Execute a command by its ID |
|
||||
|
||||
|
||||
### Server
|
||||
|
||||
Server status and info commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Check if the Obsidian Local REST API is running |
|
||||
|
||||
|
||||
### Session
|
||||
|
||||
Session state commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show current session state |
|
||||
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
### List and Read Notes
|
||||
|
||||
```bash
|
||||
# List all vault files
|
||||
cli-anything-obsidian vault list
|
||||
|
||||
# List files in a subdirectory
|
||||
cli-anything-obsidian vault list "Daily Notes"
|
||||
|
||||
# Read a note
|
||||
cli-anything-obsidian vault read "Projects/my-project.md"
|
||||
```
|
||||
|
||||
|
||||
### Create and Update Notes
|
||||
|
||||
```bash
|
||||
# Create a new note
|
||||
cli-anything-obsidian vault create "Projects/new-project.md" --content "# New Project"
|
||||
|
||||
# Update (overwrite) a note
|
||||
cli-anything-obsidian vault update "Projects/new-project.md" --content "# Updated Content"
|
||||
|
||||
# Append to a note
|
||||
cli-anything-obsidian vault append "Projects/new-project.md" --content "\n## New Section"
|
||||
```
|
||||
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
# Plain text search
|
||||
cli-anything-obsidian search simple "meeting notes"
|
||||
|
||||
# Obsidian query syntax search (tags, links, etc.)
|
||||
cli-anything-obsidian search query "tag:#project"
|
||||
```
|
||||
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# List available commands
|
||||
cli-anything-obsidian command list
|
||||
|
||||
# Execute a command by ID
|
||||
cli-anything-obsidian command execute "editor:toggle-bold"
|
||||
```
|
||||
|
||||
|
||||
### Interactive REPL Session
|
||||
|
||||
Start an interactive session for exploratory use.
|
||||
|
||||
```bash
|
||||
cli-anything-obsidian
|
||||
# Enter commands interactively
|
||||
# Use 'help' to see available commands
|
||||
```
|
||||
|
||||
|
||||
### API Key Configuration
|
||||
|
||||
```bash
|
||||
# Via flag
|
||||
cli-anything-obsidian --api-key YOUR_KEY vault list
|
||||
|
||||
# Via environment variable (recommended for agents)
|
||||
export OBSIDIAN_API_KEY=YOUR_KEY
|
||||
cli-anything-obsidian vault list
|
||||
```
|
||||
|
||||
|
||||
## State Management
|
||||
|
||||
The CLI maintains lightweight session state:
|
||||
|
||||
- **API key**: Configurable via `--api-key` or `OBSIDIAN_API_KEY` environment variable
|
||||
- **Host URL**: Defaults to `https://localhost:27124`; configurable via `--host`
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-obsidian vault list
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-obsidian --json vault list
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Set `OBSIDIAN_API_KEY`** environment variable to avoid passing `--api-key` on every call
|
||||
5. **Verify Obsidian is running** with `server status` before other commands
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Test coverage: See TEST.md in the package
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1,36 @@
|
||||
# Test Plan — cli-anything-obsidian
|
||||
|
||||
## Unit Tests (`test_core.py`)
|
||||
|
||||
Run without any backend:
|
||||
|
||||
```bash
|
||||
python -m pytest cli_anything/obsidian/tests/test_core.py -v
|
||||
```
|
||||
|
||||
Covers:
|
||||
- Backend URL construction and auth headers
|
||||
- API client error handling (connection, timeout, HTTP errors)
|
||||
- JSON and text response parsing
|
||||
- CLI argument parsing (--help for all groups)
|
||||
- --json flag output
|
||||
- --api-key and --host flags
|
||||
- Session state management
|
||||
- Vault commands with mocked API
|
||||
- Search commands with mocked API
|
||||
- Error handling (missing API key, connection errors)
|
||||
- Core module function calls with mocked backend
|
||||
|
||||
## E2E Tests (`test_full_e2e.py`)
|
||||
|
||||
Requires Obsidian running with Local REST API plugin:
|
||||
|
||||
```bash
|
||||
OBSIDIAN_API_KEY=your-key python -m pytest cli_anything/obsidian/tests/test_full_e2e.py -v
|
||||
```
|
||||
|
||||
Covers:
|
||||
- Server status check
|
||||
- Vault list, create, read, append, delete lifecycle
|
||||
- Simple search
|
||||
- Automatic cleanup of test notes
|
||||
@@ -0,0 +1,422 @@
|
||||
"""Unit tests for cli-anything-obsidian — no Obsidian server required."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
||||
# ── Backend URL construction & API calls ─────────────────────────
|
||||
|
||||
class TestBackend:
|
||||
def test_default_base_url(self):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import DEFAULT_BASE_URL
|
||||
assert DEFAULT_BASE_URL == "https://localhost:27124"
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.get")
|
||||
def test_is_available_true(self, mock_get):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import is_available
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_get.return_value = mock_resp
|
||||
assert is_available("test-key") is True
|
||||
mock_get.assert_called_once_with(
|
||||
"https://localhost:27124/",
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.get")
|
||||
def test_is_available_false(self, mock_get):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import is_available
|
||||
import requests
|
||||
mock_get.side_effect = requests.exceptions.ConnectionError()
|
||||
assert is_available("test-key") is False
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.get")
|
||||
def test_api_get_connection_error(self, mock_get):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_get
|
||||
import requests
|
||||
mock_get.side_effect = requests.exceptions.ConnectionError()
|
||||
with pytest.raises(RuntimeError, match="Cannot connect to Obsidian"):
|
||||
api_get("https://localhost:27124", "/", "test-key")
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.post")
|
||||
def test_api_post_connection_error(self, mock_post):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_post
|
||||
import requests
|
||||
mock_post.side_effect = requests.exceptions.ConnectionError()
|
||||
with pytest.raises(RuntimeError, match="Cannot connect to Obsidian"):
|
||||
api_post("https://localhost:27124", "/search/", "test-key", data={"query": "test"})
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.delete")
|
||||
def test_api_delete_connection_error(self, mock_delete):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_delete
|
||||
import requests
|
||||
mock_delete.side_effect = requests.exceptions.ConnectionError()
|
||||
with pytest.raises(RuntimeError, match="Cannot connect to Obsidian"):
|
||||
api_delete("https://localhost:27124", "/vault/test.md", "test-key")
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.get")
|
||||
def test_api_get_json_response(self, mock_get):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_get
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.content = b'{"files": []}'
|
||||
mock_resp.headers = {"content-type": "application/json"}
|
||||
mock_resp.json.return_value = {"files": []}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_get.return_value = mock_resp
|
||||
result = api_get("https://localhost:27124", "/vault/", "test-key")
|
||||
assert result == {"files": []}
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.get")
|
||||
def test_api_get_text_response(self, mock_get):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_get
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.content = b"# My Note\nHello world"
|
||||
mock_resp.headers = {"content-type": "text/markdown"}
|
||||
mock_resp.text = "# My Note\nHello world"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_get.return_value = mock_resp
|
||||
result = api_get("https://localhost:27124", "/vault/test.md", "test-key")
|
||||
assert result == {"content": "# My Note\nHello world"}
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.get")
|
||||
def test_api_get_trailing_slash_stripped(self, mock_get):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_get
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.content = b'{"status": "ok"}'
|
||||
mock_resp.headers = {"content-type": "application/json"}
|
||||
mock_resp.json.return_value = {"status": "ok"}
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_get.return_value = mock_resp
|
||||
api_get("https://localhost:27124/", "/", "test-key")
|
||||
mock_get.assert_called_once_with(
|
||||
"https://localhost:27124/",
|
||||
headers={"Authorization": "Bearer test-key", "Accept": "application/json"},
|
||||
params=None,
|
||||
timeout=30,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.get")
|
||||
def test_api_get_timeout(self, mock_get):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_get
|
||||
import requests
|
||||
mock_get.side_effect = requests.exceptions.Timeout()
|
||||
with pytest.raises(RuntimeError, match="timed out"):
|
||||
api_get("https://localhost:27124", "/", "test-key")
|
||||
|
||||
@patch("cli_anything.obsidian.utils.obsidian_backend.requests.put")
|
||||
def test_api_put_text(self, mock_put):
|
||||
from cli_anything.obsidian.utils.obsidian_backend import api_put
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 204
|
||||
mock_resp.content = b""
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_put.return_value = mock_resp
|
||||
result = api_put("https://localhost:27124", "/vault/test.md", "test-key", content="# Hello")
|
||||
assert result == {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
# ── Core module tests ────────────────────────────────────────────
|
||||
|
||||
class TestServerModule:
|
||||
@patch("cli_anything.obsidian.core.server.api_get")
|
||||
def test_server_status(self, mock_api):
|
||||
from cli_anything.obsidian.core.server import server_status
|
||||
mock_api.return_value = {"status": "OK", "authenticated": True}
|
||||
result = server_status("https://localhost:27124", "test-key")
|
||||
assert result["status"] == "OK"
|
||||
mock_api.assert_called_once_with("https://localhost:27124", "/", "test-key")
|
||||
|
||||
|
||||
class TestVaultModule:
|
||||
@patch("cli_anything.obsidian.core.vault.api_get")
|
||||
def test_list_files_root(self, mock_api):
|
||||
from cli_anything.obsidian.core.vault import list_files
|
||||
mock_api.return_value = {"files": ["note1.md", "folder/note2.md"]}
|
||||
result = list_files("https://localhost:27124", "test-key")
|
||||
assert len(result["files"]) == 2
|
||||
mock_api.assert_called_once_with("https://localhost:27124", "/vault/", "test-key")
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_get")
|
||||
def test_list_files_subfolder(self, mock_api):
|
||||
from cli_anything.obsidian.core.vault import list_files
|
||||
mock_api.return_value = {"files": ["note2.md"]}
|
||||
result = list_files("https://localhost:27124", "test-key", path="folder")
|
||||
mock_api.assert_called_once_with("https://localhost:27124", "/vault/folder/", "test-key")
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_get")
|
||||
def test_read_note(self, mock_api):
|
||||
from cli_anything.obsidian.core.vault import read_note
|
||||
mock_api.return_value = {"content": "# Hello\nWorld"}
|
||||
result = read_note("https://localhost:27124", "test-key", "note.md")
|
||||
assert result["content"] == "# Hello\nWorld"
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_put")
|
||||
def test_create_note(self, mock_api):
|
||||
from cli_anything.obsidian.core.vault import create_note
|
||||
mock_api.return_value = {"status": "ok"}
|
||||
result = create_note("https://localhost:27124", "test-key", "new.md", "# New")
|
||||
assert result["status"] == "ok"
|
||||
mock_api.assert_called_once_with(
|
||||
"https://localhost:27124", "/vault/new.md", "test-key", content="# New"
|
||||
)
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_put")
|
||||
def test_update_note(self, mock_api):
|
||||
from cli_anything.obsidian.core.vault import update_note
|
||||
mock_api.return_value = {"status": "ok"}
|
||||
result = update_note("https://localhost:27124", "test-key", "note.md", "# Updated")
|
||||
assert result["status"] == "ok"
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_delete")
|
||||
def test_delete_note(self, mock_api):
|
||||
from cli_anything.obsidian.core.vault import delete_note
|
||||
mock_api.return_value = {"status": "ok"}
|
||||
result = delete_note("https://localhost:27124", "test-key", "note.md")
|
||||
assert result["status"] == "ok"
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_put")
|
||||
@patch("cli_anything.obsidian.core.vault.api_get")
|
||||
def test_append_note(self, mock_get, mock_put):
|
||||
from cli_anything.obsidian.core.vault import append_note
|
||||
mock_get.return_value = {"content": "# Existing"}
|
||||
mock_put.return_value = {"status": "ok"}
|
||||
result = append_note("https://localhost:27124", "test-key", "note.md",
|
||||
"\nnew content", position="end")
|
||||
assert result["status"] == "ok"
|
||||
mock_put.assert_called_once_with(
|
||||
"https://localhost:27124", "/vault/note.md", "test-key",
|
||||
content="# Existing\nnew content"
|
||||
)
|
||||
|
||||
|
||||
class TestSearchModule:
|
||||
@patch("cli_anything.obsidian.core.search.api_post")
|
||||
def test_search_query(self, mock_api):
|
||||
from cli_anything.obsidian.core.search import search_query
|
||||
mock_api.return_value = [{"filename": "note.md", "score": 0.9}]
|
||||
result = search_query("https://localhost:27124", "test-key", "test query")
|
||||
mock_api.assert_called_once_with(
|
||||
"https://localhost:27124", "/search/", "test-key",
|
||||
data={"query": "test query"}
|
||||
)
|
||||
|
||||
@patch("cli_anything.obsidian.core.search.api_post")
|
||||
def test_search_simple(self, mock_api):
|
||||
from cli_anything.obsidian.core.search import search_simple
|
||||
mock_api.return_value = [{"filename": "note.md", "matches": []}]
|
||||
result = search_simple("https://localhost:27124", "test-key", "hello", context_length=50)
|
||||
mock_api.assert_called_once_with(
|
||||
"https://localhost:27124", "/search/simple/", "test-key",
|
||||
params={"query": "hello", "contextLength": 50}
|
||||
)
|
||||
|
||||
|
||||
class TestNoteModule:
|
||||
@patch("cli_anything.obsidian.core.note.api_get")
|
||||
def test_get_active(self, mock_api):
|
||||
from cli_anything.obsidian.core.note import get_active
|
||||
mock_api.return_value = {"content": "# Active Note"}
|
||||
result = get_active("https://localhost:27124", "test-key")
|
||||
assert result["content"] == "# Active Note"
|
||||
|
||||
@patch("cli_anything.obsidian.core.note.api_put")
|
||||
def test_open_note(self, mock_api):
|
||||
from cli_anything.obsidian.core.note import open_note
|
||||
mock_api.return_value = {"status": "ok"}
|
||||
result = open_note("https://localhost:27124", "test-key", "folder/note.md")
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
class TestCommandModule:
|
||||
@patch("cli_anything.obsidian.core.command.api_get")
|
||||
def test_list_commands(self, mock_api):
|
||||
from cli_anything.obsidian.core.command import list_commands
|
||||
mock_api.return_value = {"commands": [{"id": "editor:toggle-bold", "name": "Bold"}]}
|
||||
result = list_commands("https://localhost:27124", "test-key")
|
||||
assert len(result["commands"]) == 1
|
||||
|
||||
@patch("cli_anything.obsidian.core.command.api_post")
|
||||
def test_execute_command(self, mock_api):
|
||||
from cli_anything.obsidian.core.command import execute_command
|
||||
mock_api.return_value = {"status": "ok"}
|
||||
result = execute_command("https://localhost:27124", "test-key", "editor:toggle-bold")
|
||||
assert result["status"] == "ok"
|
||||
mock_api.assert_called_once_with(
|
||||
"https://localhost:27124", "/commands/editor:toggle-bold/", "test-key"
|
||||
)
|
||||
|
||||
|
||||
# ── CLI tests ────────────────────────────────────────────────────
|
||||
|
||||
from cli_anything.obsidian.obsidian_cli import cli
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
class TestCLIParsing:
|
||||
def test_help(self, runner):
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "Obsidian CLI" in result.output
|
||||
|
||||
def test_vault_help(self, runner):
|
||||
result = runner.invoke(cli, ["vault", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "list" in result.output
|
||||
assert "read" in result.output
|
||||
assert "create" in result.output
|
||||
assert "update" in result.output
|
||||
assert "delete" in result.output
|
||||
assert "append" in result.output
|
||||
|
||||
def test_search_help(self, runner):
|
||||
result = runner.invoke(cli, ["search", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "query" in result.output
|
||||
assert "simple" in result.output
|
||||
|
||||
def test_note_help(self, runner):
|
||||
result = runner.invoke(cli, ["note", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "active" in result.output
|
||||
assert "open" in result.output
|
||||
|
||||
def test_command_help(self, runner):
|
||||
result = runner.invoke(cli, ["command", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "list" in result.output
|
||||
assert "execute" in result.output
|
||||
|
||||
def test_server_help(self, runner):
|
||||
result = runner.invoke(cli, ["server", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "status" in result.output
|
||||
|
||||
def test_session_help(self, runner):
|
||||
result = runner.invoke(cli, ["session", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "status" in result.output
|
||||
|
||||
def test_json_flag(self, runner):
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "test", "session", "status"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert "host" in data
|
||||
|
||||
def test_api_key_flag(self, runner):
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "my-key", "session", "status"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["api_key_set"] is True
|
||||
|
||||
def test_host_flag(self, runner):
|
||||
result = runner.invoke(cli, ["--host", "https://example:1234", "--api-key", "k",
|
||||
"--json", "session", "status"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["host"] == "https://example:1234"
|
||||
|
||||
|
||||
class TestSessionState:
|
||||
def test_session_status_defaults(self, runner):
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "test", "session", "status"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["api_key_set"] is True
|
||||
|
||||
|
||||
class TestVaultCommands:
|
||||
@patch("cli_anything.obsidian.core.vault.api_get")
|
||||
def test_vault_list_json(self, mock_api, runner):
|
||||
mock_api.return_value = {"files": ["note1.md", "folder/note2.md"]}
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "k", "vault", "list"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert "files" in data
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_get")
|
||||
def test_vault_read_json(self, mock_api, runner):
|
||||
mock_api.return_value = {"content": "# Hello"}
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "k", "vault", "read", "note.md"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["content"] == "# Hello"
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_put")
|
||||
def test_vault_create_json(self, mock_api, runner):
|
||||
mock_api.return_value = {"status": "ok"}
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "k", "vault", "create",
|
||||
"new.md", "--content", "# New Note"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["status"] == "ok"
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_delete")
|
||||
def test_vault_delete_json(self, mock_api, runner):
|
||||
mock_api.return_value = {"status": "ok"}
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "k", "vault", "delete", "old.md"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_put")
|
||||
@patch("cli_anything.obsidian.core.vault.api_get")
|
||||
def test_vault_append_json(self, mock_get, mock_put, runner):
|
||||
mock_get.return_value = {"content": "existing"}
|
||||
mock_put.return_value = {"status": "ok"}
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "k", "vault", "append",
|
||||
"note.md", "--content", "extra text"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestSearchCommands:
|
||||
@patch("cli_anything.obsidian.core.search.api_post")
|
||||
def test_search_query_json(self, mock_api, runner):
|
||||
mock_api.return_value = [{"filename": "note.md", "score": 0.9}]
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "k", "search", "query", "test"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("cli_anything.obsidian.core.search.api_post")
|
||||
def test_search_simple_json(self, mock_api, runner):
|
||||
mock_api.return_value = [{"filename": "note.md", "matches": []}]
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "k", "search", "simple", "hello"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
@patch("cli_anything.obsidian.core.server.api_get")
|
||||
def test_server_status_error(self, mock_api, runner):
|
||||
mock_api.side_effect = RuntimeError("Cannot connect to Obsidian")
|
||||
result = runner.invoke(cli, ["--api-key", "k", "server", "status"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("cli_anything.obsidian.core.server.api_get")
|
||||
def test_server_status_error_json(self, mock_api, runner):
|
||||
mock_api.side_effect = RuntimeError("Cannot connect to Obsidian")
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "k", "server", "status"])
|
||||
assert result.exit_code == 1
|
||||
data = json.loads(result.output)
|
||||
assert "error" in data
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_missing_api_key(self, runner):
|
||||
result = runner.invoke(cli, ["server", "status"], env={"OBSIDIAN_API_KEY": ""})
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("cli_anything.obsidian.core.vault.api_get")
|
||||
def test_vault_list_error_json(self, mock_api, runner):
|
||||
mock_api.side_effect = RuntimeError("Cannot connect to Obsidian")
|
||||
result = runner.invoke(cli, ["--json", "--api-key", "k", "vault", "list"])
|
||||
assert result.exit_code == 1
|
||||
data = json.loads(result.output)
|
||||
assert "error" in data
|
||||
@@ -0,0 +1,93 @@
|
||||
"""E2E tests for cli-anything-obsidian — requires Obsidian running with Local REST API plugin.
|
||||
|
||||
These tests interact with a real Obsidian instance. Skip if not available.
|
||||
|
||||
Usage:
|
||||
OBSIDIAN_API_KEY=your-key python -m pytest cli_anything/obsidian/tests/test_full_e2e.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from cli_anything.obsidian.utils.obsidian_backend import is_available, DEFAULT_BASE_URL
|
||||
from cli_anything.obsidian.obsidian_cli import cli
|
||||
|
||||
API_KEY = os.environ.get("OBSIDIAN_API_KEY", "")
|
||||
|
||||
# Skip all tests if Obsidian REST API is not running
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not API_KEY or not is_available(API_KEY, DEFAULT_BASE_URL),
|
||||
reason="Obsidian REST API not available (set OBSIDIAN_API_KEY and ensure Obsidian is running)"
|
||||
)
|
||||
|
||||
TEST_NOTE = "_cli_anything_test_note.md"
|
||||
TEST_CONTENT = "# Test Note\n\nCreated by cli-anything-obsidian E2E tests."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
class TestServerE2E:
|
||||
def test_server_status(self, runner):
|
||||
result = runner.invoke(cli, ["--api-key", API_KEY, "server", "status"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_server_status_json(self, runner):
|
||||
result = runner.invoke(cli, ["--json", "--api-key", API_KEY, "server", "status"])
|
||||
assert result.exit_code == 0
|
||||
import json
|
||||
data = json.loads(result.output)
|
||||
assert "status" in data or "authenticated" in data
|
||||
|
||||
|
||||
class TestVaultE2E:
|
||||
def test_vault_list(self, runner):
|
||||
result = runner.invoke(cli, ["--json", "--api-key", API_KEY, "vault", "list"])
|
||||
assert result.exit_code == 0
|
||||
import json
|
||||
data = json.loads(result.output)
|
||||
assert "files" in data
|
||||
|
||||
def test_vault_create_read_delete(self, runner):
|
||||
import json
|
||||
# Create
|
||||
result = runner.invoke(cli, ["--json", "--api-key", API_KEY, "vault", "create",
|
||||
TEST_NOTE, "--content", TEST_CONTENT])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Read
|
||||
result = runner.invoke(cli, ["--json", "--api-key", API_KEY, "vault", "read", TEST_NOTE])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert "Test Note" in data.get("content", "")
|
||||
|
||||
# Delete
|
||||
result = runner.invoke(cli, ["--json", "--api-key", API_KEY, "vault", "delete", TEST_NOTE])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_vault_append(self, runner):
|
||||
import json
|
||||
# Create note first
|
||||
runner.invoke(cli, ["--api-key", API_KEY, "vault", "create",
|
||||
TEST_NOTE, "--content", TEST_CONTENT])
|
||||
# Append
|
||||
result = runner.invoke(cli, ["--json", "--api-key", API_KEY, "vault", "append",
|
||||
TEST_NOTE, "--content", "\n\nAppended line."])
|
||||
assert result.exit_code == 0
|
||||
# Cleanup
|
||||
runner.invoke(cli, ["--api-key", API_KEY, "vault", "delete", TEST_NOTE])
|
||||
|
||||
|
||||
class TestSearchE2E:
|
||||
def test_search_simple(self, runner):
|
||||
result = runner.invoke(cli, ["--json", "--api-key", API_KEY, "search", "simple", "the"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestCleanup:
|
||||
def test_cleanup_test_note(self, runner):
|
||||
"""Clean up test note if it still exists."""
|
||||
runner.invoke(cli, ["--api-key", API_KEY, "vault", "delete", TEST_NOTE])
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Obsidian Local REST API wrapper — the single module that makes network requests.
|
||||
|
||||
Obsidian Local REST API plugin runs an HTTPS server (default: https://localhost:27124).
|
||||
Authentication is via Bearer token configured in the plugin settings.
|
||||
Uses self-signed certificate by default (verify=False).
|
||||
"""
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from typing import Any
|
||||
|
||||
# Suppress InsecureRequestWarning for self-signed certs
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# Default Obsidian Local REST API URL
|
||||
DEFAULT_BASE_URL = "https://localhost:27124"
|
||||
|
||||
|
||||
def _headers(api_key: str, accept: str = "application/json",
|
||||
content_type: str | None = None) -> dict:
|
||||
"""Build request headers with Bearer auth."""
|
||||
h = {"Authorization": f"Bearer {api_key}", "Accept": accept}
|
||||
if content_type:
|
||||
h["Content-Type"] = content_type
|
||||
return h
|
||||
|
||||
|
||||
def api_get(base_url: str, endpoint: str, api_key: str,
|
||||
params: dict | None = None, timeout: int = 30) -> Any:
|
||||
"""Perform a GET request against the Obsidian REST API."""
|
||||
url = f"{base_url.rstrip('/')}{endpoint}"
|
||||
try:
|
||||
resp = requests.get(url, headers=_headers(api_key),
|
||||
params=params, timeout=timeout, verify=False)
|
||||
resp.raise_for_status()
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return {"status": "ok"}
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
return resp.json()
|
||||
return {"content": resp.text}
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise RuntimeError(
|
||||
f"Cannot connect to Obsidian REST API at {base_url}. "
|
||||
"Is Obsidian running with the Local REST API plugin enabled?"
|
||||
) from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise RuntimeError(
|
||||
f"Obsidian API error {resp.status_code} on GET {endpoint}: {resp.text}"
|
||||
) from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise RuntimeError(
|
||||
f"Request to Obsidian timed out: GET {endpoint}"
|
||||
) from e
|
||||
|
||||
|
||||
def api_post(base_url: str, endpoint: str, api_key: str,
|
||||
data: dict | None = None, params: dict | None = None,
|
||||
timeout: int = 30) -> Any:
|
||||
"""Perform a POST request against the Obsidian REST API."""
|
||||
url = f"{base_url.rstrip('/')}{endpoint}"
|
||||
try:
|
||||
resp = requests.post(url, headers=_headers(api_key),
|
||||
json=data, params=params,
|
||||
timeout=timeout, verify=False)
|
||||
resp.raise_for_status()
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return {"status": "ok"}
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
return resp.json()
|
||||
return {"content": resp.text}
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise RuntimeError(
|
||||
f"Cannot connect to Obsidian REST API at {base_url}. "
|
||||
"Is Obsidian running with the Local REST API plugin enabled?"
|
||||
) from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise RuntimeError(
|
||||
f"Obsidian API error {resp.status_code} on POST {endpoint}: {resp.text}"
|
||||
) from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise RuntimeError(
|
||||
f"Request to Obsidian timed out: POST {endpoint}"
|
||||
) from e
|
||||
|
||||
|
||||
def api_put(base_url: str, endpoint: str, api_key: str,
|
||||
content: str = "", content_type: str = "text/markdown",
|
||||
timeout: int = 30) -> Any:
|
||||
"""Perform a PUT request (sends raw text, not JSON)."""
|
||||
url = f"{base_url.rstrip('/')}{endpoint}"
|
||||
try:
|
||||
headers = _headers(api_key, content_type=content_type)
|
||||
resp = requests.put(url, headers=headers, data=content.encode("utf-8"),
|
||||
timeout=timeout, verify=False)
|
||||
resp.raise_for_status()
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return {"status": "ok"}
|
||||
ct = resp.headers.get("content-type", "")
|
||||
if "application/json" in ct:
|
||||
return resp.json()
|
||||
return {"status": "ok"}
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise RuntimeError(
|
||||
f"Cannot connect to Obsidian REST API at {base_url}. "
|
||||
"Is Obsidian running with the Local REST API plugin enabled?"
|
||||
) from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise RuntimeError(
|
||||
f"Obsidian API error {resp.status_code} on PUT {endpoint}: {resp.text}"
|
||||
) from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise RuntimeError(
|
||||
f"Request to Obsidian timed out: PUT {endpoint}"
|
||||
) from e
|
||||
|
||||
|
||||
def api_delete(base_url: str, endpoint: str, api_key: str,
|
||||
timeout: int = 30) -> Any:
|
||||
"""Perform a DELETE request."""
|
||||
url = f"{base_url.rstrip('/')}{endpoint}"
|
||||
try:
|
||||
resp = requests.delete(url, headers=_headers(api_key),
|
||||
timeout=timeout, verify=False)
|
||||
resp.raise_for_status()
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return {"status": "ok"}
|
||||
ct = resp.headers.get("content-type", "")
|
||||
if "application/json" in ct:
|
||||
return resp.json()
|
||||
return {"status": "ok"}
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise RuntimeError(
|
||||
f"Cannot connect to Obsidian REST API at {base_url}. "
|
||||
"Is Obsidian running with the Local REST API plugin enabled?"
|
||||
) from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise RuntimeError(
|
||||
f"Obsidian API error {resp.status_code} on DELETE {endpoint}: {resp.text}"
|
||||
) from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise RuntimeError(
|
||||
f"Request to Obsidian timed out: DELETE {endpoint}"
|
||||
) from e
|
||||
|
||||
|
||||
def is_available(api_key: str, base_url: str = DEFAULT_BASE_URL) -> bool:
|
||||
"""Check if Obsidian REST API is reachable."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{base_url.rstrip('/')}/",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
return False
|
||||
@@ -0,0 +1,500 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("ollama", version="1.0.0")
|
||||
skin.print_banner()
|
||||
prompt_text = skin.prompt(project_name="llama3.2", modified=False)
|
||||
skin.success("Model pulled")
|
||||
skin.error("Connection failed")
|
||||
skin.warning("No models loaded")
|
||||
skin.info("Generating...")
|
||||
skin.status("Model", "llama3.2:latest")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
"ollama": "\033[38;5;255m", # white (Ollama branding)
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "ollama").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
from pathlib import Path
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
inner = 54
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Ollama
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
"\033[38;5;255m": "#eeeeee", # ollama white
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
setup.py for cli-anything-obsidian
|
||||
|
||||
Install with: pip install -e .
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
with open("cli_anything/obsidian/README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name="cli-anything-obsidian",
|
||||
version="1.0.0",
|
||||
author="Doruk Ozgen",
|
||||
author_email="",
|
||||
description="CLI harness for Obsidian — Knowledge management and note-taking via Obsidian Local REST API. Recommended: Obsidian with Local REST API plugin enabled",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/HKUDS/CLI-Anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
"requests>=2.28.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-obsidian=cli_anything.obsidian.obsidian_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.obsidian": ["skills/*.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
@@ -592,6 +592,20 @@
|
||||
"category": "graphics",
|
||||
"contributor": "rsasaki0109",
|
||||
"contributor_url": "https://github.com/rsasaki0109"
|
||||
},
|
||||
{
|
||||
"name": "obsidian",
|
||||
"display_name": "Obsidian",
|
||||
"version": "1.0.0",
|
||||
"description": "Knowledge management and note-taking — manage notes, search vault, execute commands via Obsidian Local REST API",
|
||||
"requires": "Obsidian desktop app with Local REST API plugin enabled",
|
||||
"homepage": "https://obsidian.md",
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=obsidian/agent-harness",
|
||||
"entry_point": "cli-anything-obsidian",
|
||||
"skill_md": "obsidian/agent-harness/cli_anything/obsidian/skills/SKILL.md",
|
||||
"category": "knowledge",
|
||||
"contributor": "dorukozgen",
|
||||
"contributor_url": "https://github.com/dorukozgen"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user