Merge pull request #211 from dorukozgen/feat/add-obsidian-harness

feat: add Obsidian CLI harness
This commit is contained in:
Yuhao
2026-04-13 14:35:39 +08:00
committed by GitHub
23 changed files with 2384 additions and 0 deletions
+4
View File
@@ -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__/
+78
View File
@@ -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
}
+55
View File
@@ -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,
)
+14
View File
@@ -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"
}
]
}