From 25149e66b29abb165ffd7de8c91dc38c01ab78a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Doruk=20=C3=96zgen?= Date: Sun, 12 Apr 2026 00:29:45 +0300 Subject: [PATCH 1/3] feat: add Obsidian CLI harness Add CLI harness for Obsidian knowledge management via the Obsidian Local REST API plugin. Includes vault CRUD, search, active note operations, command execution, and interactive REPL mode. - 48 unit tests (mocked, no backend required) - 7 E2E tests (requires Obsidian with Local REST API plugin) - Full documentation (SOP, README, SKILL.md, TEST.md) - Registry entry for CLI-Hub --- .gitignore | 4 + obsidian/agent-harness/OBSIDIAN.md | 78 +++ .../agent-harness/cli_anything/__init__.py | 0 .../cli_anything/obsidian/README.md | 190 +++++++ .../cli_anything/obsidian/__init__.py | 1 + .../cli_anything/obsidian/__main__.py | 3 + .../cli_anything/obsidian/core/__init__.py | 0 .../cli_anything/obsidian/core/command.py | 14 + .../cli_anything/obsidian/core/note.py | 14 + .../cli_anything/obsidian/core/search.py | 35 ++ .../cli_anything/obsidian/core/server.py | 12 + .../cli_anything/obsidian/core/vault.py | 62 +++ .../cli_anything/obsidian/obsidian_cli.py | 452 ++++++++++++++++ .../cli_anything/obsidian/skills/SKILL.md | 235 ++++++++ .../cli_anything/obsidian/tests/TEST.md | 36 ++ .../cli_anything/obsidian/tests/__init__.py | 0 .../cli_anything/obsidian/tests/test_core.py | 434 +++++++++++++++ .../obsidian/tests/test_full_e2e.py | 93 ++++ .../cli_anything/obsidian/utils/__init__.py | 0 .../obsidian/utils/obsidian_backend.py | 192 +++++++ .../cli_anything/obsidian/utils/repl_skin.py | 500 ++++++++++++++++++ obsidian/agent-harness/setup.py | 55 ++ registry.json | 14 + 23 files changed, 2424 insertions(+) create mode 100644 obsidian/agent-harness/OBSIDIAN.md create mode 100644 obsidian/agent-harness/cli_anything/__init__.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/README.md create mode 100644 obsidian/agent-harness/cli_anything/obsidian/__init__.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/__main__.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/core/__init__.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/core/command.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/core/note.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/core/search.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/core/server.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/core/vault.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/skills/SKILL.md create mode 100644 obsidian/agent-harness/cli_anything/obsidian/tests/TEST.md create mode 100644 obsidian/agent-harness/cli_anything/obsidian/tests/__init__.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/tests/test_full_e2e.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/utils/__init__.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/utils/obsidian_backend.py create mode 100644 obsidian/agent-harness/cli_anything/obsidian/utils/repl_skin.py create mode 100644 obsidian/agent-harness/setup.py diff --git a/.gitignore b/.gitignore index fbba89872..0cd81b6a2 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/obsidian/agent-harness/OBSIDIAN.md b/obsidian/agent-harness/OBSIDIAN.md new file mode 100644 index 000000000..48f10cf49 --- /dev/null +++ b/obsidian/agent-harness/OBSIDIAN.md @@ -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 ` | `GET /vault/{path}` | +| `vault create ` | `PUT /vault/{path}` | +| `vault update ` | `PUT /vault/{path}` | +| `vault delete ` | `DELETE /vault/{path}` | +| `vault append ` | `PATCH /vault/{path}` | +| `search query ` | `POST /search/` | +| `search simple ` | `POST /search/simple/` | +| `note active` | `GET /active/` | +| `note open ` | `PUT /active/` | +| `command list` | `GET /commands/` | +| `command execute ` | `POST /commands/{id}/` | +| `server status` | `GET /` | +| `session status` | (local state) | diff --git a/obsidian/agent-harness/cli_anything/__init__.py b/obsidian/agent-harness/cli_anything/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/obsidian/agent-harness/cli_anything/obsidian/README.md b/obsidian/agent-harness/cli_anything/obsidian/README.md new file mode 100644 index 000000000..7f445d784 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/README.md @@ -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 # Read note content +vault create --content "..." # Create a new note +vault update --content "..." # Overwrite a note +vault delete # Delete a note +vault append --content "..." # Append content to a note +``` + +### Search + +```bash +search query # Search using Obsidian query syntax +search simple # Plain text search across the vault +``` + +### Note + +```bash +note active # Get the currently active note +note open # Open a note in Obsidian +``` + +### Command + +```bash +command list # List all available Obsidian commands +command execute # 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 diff --git a/obsidian/agent-harness/cli_anything/obsidian/__init__.py b/obsidian/agent-harness/cli_anything/obsidian/__init__.py new file mode 100644 index 000000000..1ba925328 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/__init__.py @@ -0,0 +1 @@ +"""Obsidian CLI - Knowledge management and note-taking.""" diff --git a/obsidian/agent-harness/cli_anything/obsidian/__main__.py b/obsidian/agent-harness/cli_anything/obsidian/__main__.py new file mode 100644 index 000000000..0b46208fa --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/__main__.py @@ -0,0 +1,3 @@ +"""Allow running as python -m cli_anything.obsidian""" +from cli_anything.obsidian.obsidian_cli import main +main() diff --git a/obsidian/agent-harness/cli_anything/obsidian/core/__init__.py b/obsidian/agent-harness/cli_anything/obsidian/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/obsidian/agent-harness/cli_anything/obsidian/core/command.py b/obsidian/agent-harness/cli_anything/obsidian/core/command.py new file mode 100644 index 000000000..59468e8a4 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/core/command.py @@ -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) diff --git a/obsidian/agent-harness/cli_anything/obsidian/core/note.py b/obsidian/agent-harness/cli_anything/obsidian/core/note.py new file mode 100644 index 000000000..7fc97ea0f --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/core/note.py @@ -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") diff --git a/obsidian/agent-harness/cli_anything/obsidian/core/search.py b/obsidian/agent-harness/cli_anything/obsidian/core/search.py new file mode 100644 index 000000000..9b6ad9723 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/core/search.py @@ -0,0 +1,35 @@ +"""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 simple text search. + + Args: + base_url: API base URL. + api_key: Bearer token. + query: Search query string. + + Returns: + List of search results with filename, score, and matches. + """ + return api_post(base_url, "/search/simple/", api_key, + params={"query": query, "contextLength": 100}) + + +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}) diff --git a/obsidian/agent-harness/cli_anything/obsidian/core/server.py b/obsidian/agent-harness/cli_anything/obsidian/core/server.py new file mode 100644 index 000000000..7a0ace8fc --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/core/server.py @@ -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) diff --git a/obsidian/agent-harness/cli_anything/obsidian/core/vault.py b/obsidian/agent-harness/cli_anything/obsidian/core/vault.py new file mode 100644 index 000000000..3ecd65681 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/core/vault.py @@ -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) diff --git a/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py b/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py new file mode 100644 index 000000000..91f7d4877 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py @@ -0,0 +1,452 @@ +#!/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) + 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 + if host: + _host = host + _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() diff --git a/obsidian/agent-harness/cli_anything/obsidian/skills/SKILL.md b/obsidian/agent-harness/cli_anything/obsidian/skills/SKILL.md new file mode 100644 index 000000000..d7e1fd9f1 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/skills/SKILL.md @@ -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 diff --git a/obsidian/agent-harness/cli_anything/obsidian/tests/TEST.md b/obsidian/agent-harness/cli_anything/obsidian/tests/TEST.md new file mode 100644 index 000000000..2079a5464 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/tests/TEST.md @@ -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 diff --git a/obsidian/agent-harness/cli_anything/obsidian/tests/__init__.py b/obsidian/agent-harness/cli_anything/obsidian/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py b/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py new file mode 100644 index 000000000..a404e7aa7 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py @@ -0,0 +1,434 @@ +"""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"} + + @patch("cli_anything.obsidian.utils.obsidian_backend.requests.patch") + def test_api_patch(self, mock_patch): + from cli_anything.obsidian.utils.obsidian_backend import api_patch + 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_patch.return_value = mock_resp + result = api_patch("https://localhost:27124", "/vault/test.md", "test-key", + content="appended text", position="end") + 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/simple/", "test-key", + params={"query": "test query", "contextLength": 100} + ) + + @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 + + def test_missing_api_key(self, runner): + result = runner.invoke(cli, ["server", "status"]) + 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 diff --git a/obsidian/agent-harness/cli_anything/obsidian/tests/test_full_e2e.py b/obsidian/agent-harness/cli_anything/obsidian/tests/test_full_e2e.py new file mode 100644 index 000000000..1b674bd37 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/tests/test_full_e2e.py @@ -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]) diff --git a/obsidian/agent-harness/cli_anything/obsidian/utils/__init__.py b/obsidian/agent-harness/cli_anything/obsidian/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/obsidian/agent-harness/cli_anything/obsidian/utils/obsidian_backend.py b/obsidian/agent-harness/cli_anything/obsidian/utils/obsidian_backend.py new file mode 100644 index 000000000..9be3275c8 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/utils/obsidian_backend.py @@ -0,0 +1,192 @@ +"""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 api_patch(base_url: str, endpoint: str, api_key: str, + content: str = "", position: str = "end", + timeout: int = 30) -> Any: + """Perform a PATCH request for appending/prepending content.""" + url = f"{base_url.rstrip('/')}{endpoint}" + try: + headers = _headers(api_key, content_type="text/markdown") + headers["Content-Insertion-Position"] = position + resp = requests.patch(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 PATCH {endpoint}: {resp.text}" + ) from e + except requests.exceptions.Timeout as e: + raise RuntimeError( + f"Request to Obsidian timed out: PATCH {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 diff --git a/obsidian/agent-harness/cli_anything/obsidian/utils/repl_skin.py b/obsidian/agent-harness/cli_anything/obsidian/utils/repl_skin.py new file mode 100644 index 000000000..b356cb835 --- /dev/null +++ b/obsidian/agent-harness/cli_anything/obsidian/utils/repl_skin.py @@ -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//utils/repl_skin.py + +Usage: + from cli_anything..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-/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 +} diff --git a/obsidian/agent-harness/setup.py b/obsidian/agent-harness/setup.py new file mode 100644 index 000000000..bf252a268 --- /dev/null +++ b/obsidian/agent-harness/setup.py @@ -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, +) diff --git a/registry.json b/registry.json index 3e8c9ea8b..13165f9e2 100644 --- a/registry.json +++ b/registry.json @@ -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" } ] } From 617fcf3d15fbbd48d818fd49be690a848402352c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Doruk=20=C3=96zgen?= Date: Sun, 12 Apr 2026 11:13:36 +0300 Subject: [PATCH 2/3] fix: correct search endpoint, remove dead api_patch, add error catch-all - search_query() now hits POST /search/ with JSON body per SOP - search_simple() stays on POST /search/simple/ with query params - Remove unused api_patch() and its test (v3.x API changed PATCH semantics) - Add generic except Exception catch-all to handle_error decorator --- .../cli_anything/obsidian/core/search.py | 9 +++-- .../cli_anything/obsidian/obsidian_cli.py | 7 ++++ .../cli_anything/obsidian/tests/test_core.py | 17 ++-------- .../obsidian/utils/obsidian_backend.py | 33 ------------------- 4 files changed, 13 insertions(+), 53 deletions(-) diff --git a/obsidian/agent-harness/cli_anything/obsidian/core/search.py b/obsidian/agent-harness/cli_anything/obsidian/core/search.py index 9b6ad9723..c237ca525 100644 --- a/obsidian/agent-harness/cli_anything/obsidian/core/search.py +++ b/obsidian/agent-harness/cli_anything/obsidian/core/search.py @@ -4,18 +4,17 @@ 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 simple text search. + """Search vault using Obsidian's search engine. Args: base_url: API base URL. api_key: Bearer token. - query: Search query string. + query: Search query string (Obsidian search syntax). Returns: - List of search results with filename, score, and matches. + Search results from Obsidian. """ - return api_post(base_url, "/search/simple/", api_key, - params={"query": query, "contextLength": 100}) + return api_post(base_url, "/search/", api_key, data={"query": query}) def search_simple(base_url: str, api_key: str, query: str, diff --git a/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py b/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py index 91f7d4877..76ad86041 100644 --- a/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py +++ b/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py @@ -89,6 +89,13 @@ def handle_error(func): 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 diff --git a/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py b/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py index a404e7aa7..0c7e2d6e4 100644 --- a/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py +++ b/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py @@ -122,19 +122,6 @@ class TestBackend: result = api_put("https://localhost:27124", "/vault/test.md", "test-key", content="# Hello") assert result == {"status": "ok"} - @patch("cli_anything.obsidian.utils.obsidian_backend.requests.patch") - def test_api_patch(self, mock_patch): - from cli_anything.obsidian.utils.obsidian_backend import api_patch - 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_patch.return_value = mock_resp - result = api_patch("https://localhost:27124", "/vault/test.md", "test-key", - content="appended text", position="end") - assert result == {"status": "ok"} # ── Core module tests ──────────────────────────────────────────── @@ -218,8 +205,8 @@ class TestSearchModule: 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/simple/", "test-key", - params={"query": "test query", "contextLength": 100} + "https://localhost:27124", "/search/", "test-key", + data={"query": "test query"} ) @patch("cli_anything.obsidian.core.search.api_post") diff --git a/obsidian/agent-harness/cli_anything/obsidian/utils/obsidian_backend.py b/obsidian/agent-harness/cli_anything/obsidian/utils/obsidian_backend.py index 9be3275c8..77f99ea5a 100644 --- a/obsidian/agent-harness/cli_anything/obsidian/utils/obsidian_backend.py +++ b/obsidian/agent-harness/cli_anything/obsidian/utils/obsidian_backend.py @@ -145,39 +145,6 @@ def api_delete(base_url: str, endpoint: str, api_key: str, ) from e -def api_patch(base_url: str, endpoint: str, api_key: str, - content: str = "", position: str = "end", - timeout: int = 30) -> Any: - """Perform a PATCH request for appending/prepending content.""" - url = f"{base_url.rstrip('/')}{endpoint}" - try: - headers = _headers(api_key, content_type="text/markdown") - headers["Content-Insertion-Position"] = position - resp = requests.patch(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 PATCH {endpoint}: {resp.text}" - ) from e - except requests.exceptions.Timeout as e: - raise RuntimeError( - f"Request to Obsidian timed out: PATCH {endpoint}" - ) from e - - def is_available(api_key: str, base_url: str = DEFAULT_BASE_URL) -> bool: """Check if Obsidian REST API is reachable.""" try: From 295dbecec87d72b8bfade68f027e222f2c392731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Doruk=20=C3=96zgen?= Date: Sun, 12 Apr 2026 11:17:15 +0300 Subject: [PATCH 3/3] fix: reset global host state between CLI invocations - Always reset _host to DEFAULT_BASE_URL when --host not provided - Fix test_missing_api_key to clear OBSIDIAN_API_KEY env var --- obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py | 3 +-- .../agent-harness/cli_anything/obsidian/tests/test_core.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py b/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py index 76ad86041..87e2d29f5 100644 --- a/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py +++ b/obsidian/agent-harness/cli_anything/obsidian/obsidian_cli.py @@ -122,8 +122,7 @@ def cli(ctx, use_json, host, api_key): """ global _json_output, _host, _api_key _json_output = use_json - if host: - _host = host + _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: diff --git a/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py b/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py index 0c7e2d6e4..6da612950 100644 --- a/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py +++ b/obsidian/agent-harness/cli_anything/obsidian/tests/test_core.py @@ -408,8 +408,9 @@ class TestErrorHandling: 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"]) + result = runner.invoke(cli, ["server", "status"], env={"OBSIDIAN_API_KEY": ""}) assert result.exit_code == 1 @patch("cli_anything.obsidian.core.vault.api_get")