feat: add Obsidian CLI API service
Build and Push Docker Container / build-and-push (push) Successful in 4m26s
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:
@@ -0,0 +1,271 @@
|
||||
# Obsidian API
|
||||
Docker service that exposes an HTTP API for the **official Obsidian CLI**.
|
||||
|
||||
It is meant for:
|
||||
- n8n custom nodes, HTTP Request nodes, and Code nodes
|
||||
- Prism / TypeScript agent harnesses
|
||||
- local automation against a mounted Obsidian vault
|
||||
|
||||
## Important: official CLI architecture
|
||||
This uses the real Obsidian CLI from `obsidian.md/help/cli`.
|
||||
|
||||
That CLI is not an npm package. It ships inside the Obsidian desktop app and requires the app to be running. Therefore this container runs:
|
||||
- official Obsidian desktop app
|
||||
- Xvfb virtual display
|
||||
- official `/opt/Obsidian/obsidian-cli` exposed as `obsidian`
|
||||
- this Starlette HTTP API
|
||||
|
||||
Because Electron is required, the image is Debian-based and larger than Alpine.
|
||||
|
||||
## Security model
|
||||
The HTTP API does **not** accept shell commands and does **not** accept plain command strings.
|
||||
|
||||
Allowed:
|
||||
```json
|
||||
{
|
||||
"args": ["read", "path=Inbox/Note.md"]
|
||||
}
|
||||
```
|
||||
|
||||
Rejected:
|
||||
```json
|
||||
{
|
||||
"command": "read path=Inbox/Note.md"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "shell",
|
||||
"args": ["ls -la"]
|
||||
}
|
||||
```
|
||||
|
||||
The service always runs the configured binary directly with `exec` semantics:
|
||||
|
||||
```bash
|
||||
obsidian <args...>
|
||||
```
|
||||
|
||||
No shell is involved.
|
||||
|
||||
### JWT access policies
|
||||
Auth uses signed HS256 JWT bearer tokens. The token itself contains the policy claims.
|
||||
|
||||
Create a signing secret:
|
||||
```bash
|
||||
export OBSIDIAN_JWT_SECRET="$(openssl rand -base64 48)"
|
||||
```
|
||||
|
||||
Generate a token with the same secret:
|
||||
```bash
|
||||
python scripts/create_jwt.py \
|
||||
--sub n8n \
|
||||
--vault work \
|
||||
--path Inbox/n8n/ \
|
||||
--path Automation.md \
|
||||
--command create \
|
||||
--command read \
|
||||
--command append \
|
||||
--command search \
|
||||
--command search:context \
|
||||
--days 365
|
||||
```
|
||||
|
||||
Set the same secret for the API:
|
||||
```env
|
||||
OBSIDIAN_JWT_SECRET=change-this-long-random-secret
|
||||
```
|
||||
|
||||
JWT claims:
|
||||
```json
|
||||
{
|
||||
"sub": "n8n",
|
||||
"vaults": ["work"],
|
||||
"paths": ["Inbox/n8n/", "Automation.md"],
|
||||
"commands": ["create", "read", "append", "search", "search:context"],
|
||||
"iat": 1760000000,
|
||||
"exp": 1791536000
|
||||
}
|
||||
```
|
||||
|
||||
Policy claims:
|
||||
- `vaults`: allowed Obsidian vault names, e.g. `["work"]`
|
||||
- `paths`: allowed note/path prefixes, e.g. `["Inbox/n8n/", "Automation.md"]`
|
||||
- `commands`: allowed official CLI commands, e.g. `["read", "create", "append"]`
|
||||
- `exp`: token expiry timestamp
|
||||
- `nbf`: optional not-before timestamp
|
||||
- `iat`: optional issued-at timestamp
|
||||
|
||||
Use `["*"]` for unrestricted access in a dimension.
|
||||
|
||||
Example request:
|
||||
```bash
|
||||
JWT="$(python scripts/create_jwt.py --sub n8n --vault work --path Inbox/n8n/ --command create --days 365)"
|
||||
|
||||
curl -X POST http://localhost:8080/commands \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"args":["vault=work","create","path=Inbox/n8n/Event.md","content=Hello","overwrite"]}'
|
||||
```
|
||||
|
||||
Path restrictions are enforced before the Obsidian CLI process starts. If a token has path restrictions, commands that read/write notes must include an explicit `path=`, `file=`, `folder=`, `name=` or `to=` argument.
|
||||
|
||||
## Start
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit:
|
||||
```env
|
||||
OBSIDIAN_JWT_SECRET=change-this-long-random-secret
|
||||
OBSIDIAN_VAULT_PATH=/absolute/path/to/your/vault
|
||||
OBSIDIAN_API_PORT=8080
|
||||
```
|
||||
|
||||
### Multiple vaults
|
||||
Single vault is the default:
|
||||
```env
|
||||
OBSIDIAN_VAULT_PATH=/home/daniel/Documents/Obsidian/Main
|
||||
```
|
||||
|
||||
For more vaults, mount a parent folder and register container paths:
|
||||
```env
|
||||
OBSIDIAN_VAULT_PATH=/home/daniel/Documents/Obsidian/Main
|
||||
OBSIDIAN_VAULTS_ROOT=/home/daniel/Documents/Obsidian
|
||||
OBSIDIAN_VAULTS=main=/vault,work=/vaults/Work,personal=/vaults/Personal
|
||||
```
|
||||
|
||||
Then target a vault with the official CLI prefix:
|
||||
```json
|
||||
{
|
||||
"args": ["vault=work", "read", "path=Inbox/Note.md"]
|
||||
}
|
||||
```
|
||||
|
||||
If no `vault=<name>` prefix is provided, the first vault in `OBSIDIAN_VAULTS` is used as the default.
|
||||
|
||||
Start:
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Logs:
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Health
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
### Run official Obsidian CLI command
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/commands \
|
||||
-H "Authorization: Bearer change-me" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"args":["version"]}'
|
||||
```
|
||||
|
||||
Create a note:
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/commands \
|
||||
-H "Authorization: Bearer change-me" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"args":["create","path=Inbox/Hello.md","content=# Hello","overwrite"]}'
|
||||
```
|
||||
|
||||
Read a note:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/commands \
|
||||
-H "Authorization: Bearer change-me" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"args":["read","path=Inbox/Hello.md"]}'
|
||||
```
|
||||
|
||||
Search:
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/commands \
|
||||
-H "Authorization: Bearer change-me" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"args":["search","query=Hello","format=json"]}'
|
||||
```
|
||||
|
||||
## TypeScript SDK
|
||||
|
||||
SDK path:
|
||||
```text
|
||||
sdk/typescript
|
||||
```
|
||||
|
||||
Install locally:
|
||||
```bash
|
||||
npm install ./sdk/typescript
|
||||
```
|
||||
|
||||
Use in TypeScript / Node / Prism:
|
||||
```ts
|
||||
import { ObsidianCliClient } from '@obsidian-api/sdk';
|
||||
|
||||
const obsidian = new ObsidianCliClient({
|
||||
baseUrl: 'http://localhost:8080',
|
||||
token: 'change-me',
|
||||
});
|
||||
|
||||
await obsidian.create({
|
||||
path: 'Inbox/Hello.md',
|
||||
content: '# Hello from TypeScript',
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
const note = await obsidian.read({ path: 'Inbox/Hello.md' });
|
||||
console.log(note.stdout);
|
||||
|
||||
const results = await obsidian.search('Hello', { format: 'json' });
|
||||
console.log(JSON.parse(results.stdout));
|
||||
|
||||
// Multi-vault: scoped client
|
||||
const work = obsidian.inVault('work');
|
||||
await work.create({ path: 'Inbox/Work.md', content: 'Hello work', overwrite: true });
|
||||
```
|
||||
|
||||
n8n options:
|
||||
- real community-node package: [`packages/n8n-nodes-obsidian-api`](packages/n8n-nodes-obsidian-api)
|
||||
- Code node / HTTP examples: [`docs/n8n.md`](docs/n8n.md)
|
||||
|
||||
Raw escape hatch, still safe because it sends an args array:
|
||||
```ts
|
||||
await obsidian.runRaw('daily:append', 'content=- [ ] Buy groceries');
|
||||
```
|
||||
|
||||
## Official CLI command examples
|
||||
The SDK covers the official command groups from `obsidian.md/help/cli`, including:
|
||||
- General: `help`, `version`, `reload`, `restart`
|
||||
- Files: `create`, `read`, `append`, `prepend`, `move`, `rename`, `delete`, `files`, `folders`
|
||||
- Daily notes: `daily`, `daily:path`, `daily:read`, `daily:append`, `daily:prepend`
|
||||
- Search: `search`, `search:context`, `search:open`
|
||||
- Links: `backlinks`, `links`, `unresolved`, `orphans`, `deadends`
|
||||
- Tags/tasks/properties/templates
|
||||
- Plugins/themes/snippets
|
||||
- Sync/Publish commands
|
||||
- Workspace/tabs/recents
|
||||
- Developer commands: `dev:*`, `eval`
|
||||
|
||||
## Development
|
||||
Python API tests:
|
||||
```bash
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
TypeScript SDK typecheck:
|
||||
```bash
|
||||
cd sdk/typescript
|
||||
npm install
|
||||
npm run typecheck
|
||||
```
|
||||
Reference in New Issue
Block a user