feat: add Obsidian CLI API service
Build and Push Docker Container / build-and-push (push) Successful in 4m26s

- Add Docker image for the official Obsidian desktop app and CLI.

- Start Obsidian headlessly with Xvfb and DBus setup.

- Expose a safe structured HTTP command API without shell execution.

- Add JWT-based vault, path, and command authorization.

- Support single-vault and multi-vault container mounts.

- Add TypeScript SDK helpers for Obsidian CLI commands.

- Add n8n community node package with Obsidian operations.

- Add docs, compose config, tests, and production image workflow.
This commit is contained in:
2026-06-24 09:11:13 +02:00
commit 902a3df8b5
34 changed files with 5153 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Create an HS256 JWT for the Obsidian API.
Example:
OBSIDIAN_JWT_SECRET=change-this-long-random-secret \
python scripts/create_jwt.py \
--sub n8n \
--vault work \
--path Inbox/n8n/ \
--command create --command read --command append \
--days 365
"""
from __future__ import annotations
import argparse, hashlib, base64
import json, hmac, time, os
from typing import Any
def b64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode().rstrip("=")
def sign_jwt(claims: dict[str, Any], secret: bytes) -> str:
header = {"alg": "HS256", "typ": "JWT"}
header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = b64url(json.dumps(claims, separators=(",", ":")).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
signature = hmac.new(secret, signing_input, hashlib.sha256).digest()
return f"{header_b64}.{payload_b64}.{b64url(signature)}"
def main() -> int:
parser = argparse.ArgumentParser(description="Create an HS256 JWT for obsidian-api")
parser.add_argument("--secret", help="JWT signing secret. Defaults to OBSIDIAN_JWT_SECRET env.")
parser.add_argument("--sub", required=True, help="Subject/name for the token, e.g. n8n")
parser.add_argument("--name", help="Display name claim")
parser.add_argument("--vault", action="append", dest="vaults", default=[], help="Allowed vault. Repeatable. Default: *")
parser.add_argument("--path", action="append", dest="paths", default=[], help="Allowed path/prefix. Repeatable. Default: *")
parser.add_argument("--command", action="append", dest="commands", default=[], help="Allowed CLI command. Repeatable. Default: *")
parser.add_argument("--days", type=int, default=365, help="Days until expiry")
parser.add_argument("--claim", action="append", default=[], help="Extra claim as key=value. Repeatable.")
args = parser.parse_args()
secret = args.secret or os.getenv("OBSIDIAN_JWT_SECRET", "")
if not secret:
raise SystemExit("Missing JWT secret. Set OBSIDIAN_JWT_SECRET or pass --secret.")
now = int(time.time())
claims: dict[str, Any] = {
"sub": args.sub,
"iat": now,
"exp": now + args.days * 24 * 60 * 60,
"vaults": args.vaults or ["*"],
"paths": args.paths or ["*"],
"commands": args.commands or ["*"],
}
if args.name:
claims["name"] = args.name
for item in args.claim:
if "=" not in item:
raise SystemExit(f"Invalid --claim {item!r}; expected key=value")
key, value = item.split("=", 1)
claims[key] = value
print(sign_jwt(claims, secret.encode()))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env sh
set -eu
mkdir -p "$XDG_RUNTIME_DIR" /root/.config /root/.config/obsidian /root/.cache /root/.local/share /run/dbus
chmod 700 "$XDG_RUNTIME_DIR"
# Electron apps are happier with a DBus session, even under Xvfb.
if command -v dbus-launch >/dev/null 2>&1; then
eval "$(dbus-launch --sh-syntax)"
export DBUS_SESSION_BUS_ADDRESS DBUS_SESSION_BUS_PID
fi
# Enable the official Obsidian CLI and register mounted vaults in global desktop-app settings.
# This is the same CLI setting toggled by Settings > General > Advanced > Command line interface.
DEFAULT_VAULT_PATH="$(python /app/scripts/render_config.py | tail -n 1)"
# Start a virtual X server for the official Obsidian desktop app.
Xvfb "$DISPLAY" -screen 0 "${XVFB_SCREEN:-1280x1024x24}" -nolisten tcp >/tmp/xvfb.log 2>&1 &
XVFB_PID=$!
cleanup() {
if [ -n "${OBSIDIAN_PID:-}" ] && kill -0 "$OBSIDIAN_PID" 2>/dev/null; then
kill "$OBSIDIAN_PID" 2>/dev/null || true
fi
kill "$XVFB_PID" 2>/dev/null || true
}
trap cleanup INT TERM EXIT
# Open the mounted vault using the official desktop app. The official CLI talks
# to this running app through $XDG_RUNTIME_DIR/.obsidian-cli.sock.
/opt/Obsidian/obsidian \
--no-sandbox \
--disable-gpu \
--disable-software-rasterizer \
--disable-dev-shm-usage \
--disable-features=UseOzonePlatform \
"${DEFAULT_VAULT_PATH}" \
>/tmp/obsidian.log 2>&1 &
OBSIDIAN_PID=$!
SOCKET="$XDG_RUNTIME_DIR/.obsidian-cli.sock"
WAIT_SECONDS="${OBSIDIAN_STARTUP_TIMEOUT:-60}"
STARTED=0
while [ "$STARTED" -lt "$WAIT_SECONDS" ]; do
if [ -S "$SOCKET" ]; then
break
fi
STARTED=$((STARTED + 1))
sleep 1
done
if [ ! -S "$SOCKET" ]; then
echo "WARNING: Official Obsidian CLI socket was not created at $SOCKET after ${WAIT_SECONDS}s." >&2
echo "The HTTP API will still start, but /commands may fail until Obsidian exposes the CLI socket." >&2
echo "Obsidian log tail:" >&2
tail -80 /tmp/obsidian.log >&2 || true
fi
exec uv run python -m app.main
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Render the global Obsidian desktop config (~/.config/obsidian/obsidian.json).
Enables the official CLI and registers every mounted vault so the official
`obsidian` CLI can target them with `vault=<name>`.
Vaults are read from the environment:
* ``OBSIDIAN_VAULTS`` (preferred, multi-vault):
- JSON object: {"work": "/vaults/work", "personal": "/vaults/personal"}
- or pairs: work=/vaults/work,personal=/vaults/personal
* ``OBSIDIAN_VAULT_PATH`` (single-vault fallback): /vault
The first registered vault becomes the default (highest ``ts``), which is the
vault used when a CLI command omits ``vault=<name>``.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def _parse_vaults() -> "list[tuple[str, str]]":
raw = os.environ.get("OBSIDIAN_VAULTS", "").strip()
pairs: list[tuple[str, str]] = []
if raw:
# Try JSON object first.
try:
data = json.loads(raw)
if isinstance(data, dict):
return [(str(name), str(path)) for name, path in data.items()]
except json.JSONDecodeError:
pass
# Fall back to comma/newline separated name=path pairs.
for chunk in raw.replace("\n", ",").split(","):
chunk = chunk.strip()
if not chunk:
continue
if "=" not in chunk:
print(f"WARNING: ignoring malformed OBSIDIAN_VAULTS entry: {chunk!r}", file=sys.stderr)
continue
name, path = chunk.split("=", 1)
pairs.append((name.strip(), path.strip()))
if not pairs:
single = os.environ.get("OBSIDIAN_VAULT_PATH", "/vault").strip() or "/vault"
name = Path(single).name or "vault"
pairs.append((name, single))
return pairs
def main() -> int:
pairs = _parse_vaults()
vaults: dict[str, dict] = {}
# Descending timestamps so the first declared vault sorts as most-recent
# (the desktop app treats the highest ts as the active/default vault).
base_ts = 1893456000000
for index, (name, path) in enumerate(pairs):
vaults[name] = {
"path": path,
"ts": base_ts - index,
"open": True,
}
config = {"cli": True, "vaults": vaults}
config_path = Path(os.environ.get("OBSIDIAN_CONFIG_PATH", "/root/.config/obsidian/obsidian.json"))
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
names = ", ".join(f"{name} -> {data['path']}" for name, data in vaults.items())
print(f"Registered {len(vaults)} vault(s): {names}")
# First declared vault is the default for commands without vault=<name>.
print(pairs[0][1]) # default vault path, consumed by the entrypoint
return 0
if __name__ == "__main__":
raise SystemExit(main())