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
+13
View File
@@ -0,0 +1,13 @@
.git
.venv
__pycache__
.pytest_cache
.ruff_cache
.env
vault
*.pyc
node_modules
sdk/typescript/node_modules
sdk/typescript/dist
packages/*/node_modules
packages/*/dist
+34
View File
@@ -0,0 +1,34 @@
# Copy to .env and adjust.
# JWT signing secret. Use the same value to create JWTs with scripts/create_jwt.py.
OBSIDIAN_JWT_SECRET=change-this-long-random-secret
# Host path to your default/single Obsidian vault.
# Examples:
# OBSIDIAN_VAULT_PATH=/home/daniel/Documents/Obsidian/MainVault
# OBSIDIAN_VAULT_PATH=/mnt/storage/ObsidianVault
OBSIDIAN_VAULT_PATH=./vault
# Optional: mount a parent folder containing additional vaults at /vaults.
# Then register all vaults by name using container paths.
# Example:
# OBSIDIAN_VAULTS_ROOT=/home/daniel/Documents/Obsidian
# OBSIDIAN_VAULTS=main=/vault,work=/vaults/Work,personal=/vaults/Personal
OBSIDIAN_VAULTS_ROOT=./vaults
OBSIDIAN_VAULTS=
# Host port for the HTTP API.
OBSIDIAN_API_PORT=8080
# Official Obsidian desktop/CLI version installed in the image.
OBSIDIAN_VERSION=1.12.7
# Official CLI binary inside the container.
OBSIDIAN_CLI_BIN=obsidian
# Seconds to wait for the desktop app to expose the official CLI socket.
OBSIDIAN_STARTUP_TIMEOUT=60
# Command timeout limits in seconds.
COMMAND_TIMEOUT=60
MAX_COMMAND_TIMEOUT=300
+50
View File
@@ -0,0 +1,50 @@
name: Build and Push Docker Container
on:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
env:
IMAGE_NAME: obsidian-api
OBSIDIAN_VERSION: "1.12.7"
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Rewrite SSH submodule URLs to HTTPS for CI
run: |
git config --global url."https://x-token:${{ secrets.ACTION_ACCESS_TOKEN }}@git.yiprawr.dev/".insteadOf "git@git.yiprawr.dev:"
- name: Checkout repository
uses: actions/checkout@v6
with:
token: "${{ secrets.ACTION_ACCESS_TOKEN }}"
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker registry
uses: docker/login-action@v4
with:
registry: ${{ vars.DOCKER_REGISTRY_URL }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.ACTION_ACCESS_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64
build-args: |
OBSIDIAN_VERSION=${{ env.OBSIDIAN_VERSION }}
tags: |
${{ vars.DOCKER_REGISTRY_URL }}/daniel156161/${{ env.IMAGE_NAME }}:latest
+10
View File
@@ -0,0 +1,10 @@
obsidian_api.egg-info/
.venv/
__pycache__/
.pytest_cache/
vault/
.env
node_modules/
packages/*/node_modules/
packages/*/dist/
sdk/typescript/dist/
+67
View File
@@ -0,0 +1,67 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
ARG OBSIDIAN_VERSION=1.12.7
ARG TARGETARCH
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PATH="/app/.venv/bin:/usr/local/bin:${PATH}" \
OBSIDIAN_CLI_BIN=obsidian \
OBSIDIAN_VAULT_PATH=/vault \
OBSIDIAN_API_HOST=0.0.0.0 \
OBSIDIAN_API_PORT=8080 \
DISPLAY=:99 \
XDG_RUNTIME_DIR=/tmp/runtime-obsidian \
ELECTRON_DISABLE_SECURITY_WARNINGS=true
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
dumb-init \
xvfb \
xauth \
dbus-x11 \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libpango-1.0-0 \
libx11-xcb1 \
libxcb-dri3-0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
&& case "${TARGETARCH:-amd64}" in \
amd64) OBSIDIAN_DEB_ARCH=amd64 ;; \
arm64) OBSIDIAN_DEB_ARCH=arm64 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& curl -fsSL -o /tmp/obsidian.deb "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian_${OBSIDIAN_VERSION}_${OBSIDIAN_DEB_ARCH}.deb" \
&& apt-get install -y --no-install-recommends /tmp/obsidian.deb \
&& ln -sf /opt/Obsidian/obsidian-cli /usr/local/bin/obsidian \
&& rm -rf /tmp/obsidian.deb /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml ./
RUN uv sync --no-dev
COPY app ./app
COPY sdk ./sdk
COPY scripts ./scripts
RUN chmod +x /app/scripts/entrypoint.sh /app/scripts/create_jwt.py
EXPOSE 8080
ENTRYPOINT ["dumb-init", "--"]
CMD ["/app/scripts/entrypoint.sh"]
+271
View File
@@ -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
```
View File
+84
View File
@@ -0,0 +1,84 @@
from dataclasses import dataclass
import hashlib, base64, hmac
from typing import Any
import json, time
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.config import JWT_SECRET
from app.policy import TokenPolicy, tuple_claim
@dataclass(frozen=True)
class AuthContext:
policy:TokenPolicy|None
def error(status_code:int, detail:str) -> JSONResponse:
return JSONResponse({"detail": detail}, status_code=status_code)
def b64url_decode(value:str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode((value + padding).encode())
def decode_hs256_jwt(token:str, secret:bytes) -> dict[str, Any]:
parts = token.split(".")
if len(parts) != 3:
raise ValueError("JWT must have three parts")
header_b64, payload_b64, signature_b64 = parts
try:
header = json.loads(b64url_decode(header_b64))
payload = json.loads(b64url_decode(payload_b64))
except (json.JSONDecodeError, ValueError) as exc:
raise ValueError("JWT header or payload is invalid") from exc
if not isinstance(header, dict) or not isinstance(payload, dict):
raise ValueError("JWT header and payload must be JSON objects")
if header.get("alg") != "HS256":
raise ValueError("only HS256 JWTs are supported")
if header.get("typ") not in (None, "JWT"):
raise ValueError("JWT typ must be JWT")
signing_input = f"{header_b64}.{payload_b64}".encode()
expected = hmac.new(secret, signing_input, hashlib.sha256).digest()
supplied = b64url_decode(signature_b64)
if not hmac.compare_digest(supplied, expected):
raise ValueError("JWT signature is invalid")
now = int(time.time())
exp = payload.get("exp")
if exp is not None and (not isinstance(exp, int | float) or now >= int(exp)):
raise ValueError("JWT has expired")
nbf = payload.get("nbf")
if nbf is not None and (not isinstance(nbf, int | float) or now < int(nbf)):
raise ValueError("JWT is not active yet")
iat = payload.get("iat")
if iat is not None and (not isinstance(iat, int | float) or int(iat) > now + 300):
raise ValueError("JWT iat is in the future")
return payload
def policy_from_jwt_claims(token:str, claims:dict[str, Any]) -> TokenPolicy:
name = str(claims.get("name") or claims.get("sub") or "jwt")
return TokenPolicy(
token=token,
name=name,
vaults=tuple_claim(claims.get("vaults"), ("*",)),
paths=tuple_claim(claims.get("paths"), ("*",)),
commands=tuple_claim(claims.get("commands"), ("*",)),
)
def require_auth(request:Request) -> AuthContext|JSONResponse:
if not JWT_SECRET:
return error(500, "OBSIDIAN_JWT_SECRET is required")
header = request.headers.get("authorization", "")
if not header.startswith("Bearer "):
return error(401, "Unauthorized")
supplied = header.removeprefix("Bearer ")
try:
claims = decode_hs256_jwt(supplied, JWT_SECRET.encode())
return AuthContext(policy=policy_from_jwt_claims(supplied, claims))
except ValueError:
return error(401, "Unauthorized")
+37
View File
@@ -0,0 +1,37 @@
from pathlib import Path
import json, os
JWT_SECRET = os.getenv("OBSIDIAN_JWT_SECRET", "")
DEFAULT_TIMEOUT = float(os.getenv("COMMAND_TIMEOUT", "60"))
MAX_TIMEOUT = float(os.getenv("MAX_COMMAND_TIMEOUT", "300"))
CLI_BIN = os.getenv("OBSIDIAN_CLI_BIN", "obsidian")
VAULT_PATH = Path(os.getenv("OBSIDIAN_VAULT_PATH", "/vault")).resolve()
HOST = os.getenv("OBSIDIAN_API_HOST", "0.0.0.0")
PORT = int(os.getenv("OBSIDIAN_API_PORT", "8080"))
def parse_registered_vaults() -> dict[str, str]:
raw = os.getenv("OBSIDIAN_VAULTS", "").strip()
vaults:dict[str, str] = {}
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return {str(name): str(path) for name, path in parsed.items()}
except json.JSONDecodeError:
pass
for chunk in raw.replace("\n", ",").split(","):
chunk = chunk.strip()
if not chunk or "=" not in chunk:
continue
name, path = chunk.split("=", 1)
vaults[name.strip()] = path.strip()
if not vaults:
vaults[VAULT_PATH.name or "vault"] = str(VAULT_PATH)
return vaults
REGISTERED_VAULTS = parse_registered_vaults()
DEFAULT_VAULT_NAME = next(iter(REGISTERED_VAULTS), VAULT_PATH.name or "vault")
+122
View File
@@ -0,0 +1,122 @@
from pathlib import Path
from typing import Any
import json
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from app import auth
from app.auth import require_auth
from app.config import CLI_BIN, DEFAULT_TIMEOUT, HOST, MAX_TIMEOUT, PORT, VAULT_PATH
from app.policy import authorize_command, vault_for_path
from app.runner import execute_command
def error(status_code:int, detail:str) -> JSONResponse:
return JSONResponse({"detail": detail}, status_code=status_code)
def parse_args(value:Any) -> list[str]:
if value is None:
return []
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise ValueError("args must be a list of strings")
return value
def parse_timeout(value:Any) -> float:
if value is None:
return DEFAULT_TIMEOUT
if not isinstance(value, int | float) or value <= 0:
raise ValueError("timeout must be a positive number")
return min(float(value), MAX_TIMEOUT)
def safe_cwd(cwd:str|None) -> Path:
path = Path(cwd).resolve() if cwd else VAULT_PATH
if not path.exists() or not path.is_dir():
raise ValueError(f"cwd does not exist or is not a directory: {path}")
if vault_for_path(path) is None:
raise ValueError(f"cwd must be inside a registered vault: {path}")
return path
async def health(request:Request) -> JSONResponse:
return JSONResponse(
{
"ok": True,
"vault": str(VAULT_PATH),
"cli": CLI_BIN,
"auth": bool(auth.JWT_SECRET),
"jwt": bool(auth.JWT_SECRET),
}
)
async def run_command(request:Request) -> JSONResponse:
auth_context = require_auth(request)
if isinstance(auth_context, JSONResponse):
return auth_context
try:
payload = await request.json()
except json.JSONDecodeError:
return error(400, "invalid JSON body")
try:
if "command" in payload:
raise ValueError("command strings are not allowed; use args as a list of strings")
if "mode" in payload:
raise ValueError("mode is not allowed; only the configured Obsidian CLI can be executed")
args = parse_args(payload.get("args"))
stdin = payload.get("stdin")
if stdin is not None and not isinstance(stdin, str):
raise ValueError("stdin must be a string")
cwd_value = payload.get("cwd")
if cwd_value is not None and not isinstance(cwd_value, str):
raise ValueError("cwd must be a string")
cwd = safe_cwd(cwd_value)
timeout = parse_timeout(payload.get("timeout"))
authorize_command(auth_context.policy, args, cwd)
except ValueError as exc:
return error(400, str(exc))
except PermissionError as exc:
return error(403, str(exc))
return await execute_command(args=args, cwd=cwd, stdin=stdin, timeout=timeout)
async def typescript_sdk_example(request:Request) -> JSONResponse:
return JSONResponse(
{
"example": """
import { ObsidianCliClient } from '@obsidian-api/sdk';
const obsidian = new ObsidianCliClient({
baseUrl: 'http://localhost:8080',
token: '<jwt>',
});
await obsidian.create({
path: 'Inbox/Hello.md',
content: '# Hello from n8n/Prism',
overwrite: true,
});
const note = await obsidian.read({ path: 'Inbox/Hello.md' });
console.log(note.stdout);
""".strip()
}
)
app = Starlette(
debug=False,
routes=[
Route("/health", health, methods=["GET"]),
Route("/commands", run_command, methods=["POST"]),
Route("/sdk/typescript", typescript_sdk_example, methods=["GET"]),
],
)
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host=HOST, port=PORT)
+150
View File
@@ -0,0 +1,150 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
@dataclass(frozen=True)
class TokenPolicy:
token:str = ""
name:str = "jwt"
vaults:tuple[str, ...] = ("*",)
paths:tuple[str, ...] = ("*",)
commands:tuple[str, ...] = ("*",)
def allows_all_vaults(self) -> bool:
return "*" in self.vaults
def allows_all_paths(self) -> bool:
return "*" in self.paths
def allows_all_commands(self) -> bool:
return "*" in self.commands
@dataclass(frozen=True)
class CommandTarget:
command:str|None
vault:str|None
paths:tuple[str, ...]
PATH_KEYS = {"path", "file", "folder", "name", "to"}
PATH_REQUIRED_WHEN_RESTRICTED = {
"append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff",
"search", "search:context", "files", "folders",
"property:set", "property:remove", "property:read", "properties",
"aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task",
}
PATHLESS_ALLOWED_WHEN_RESTRICTED = {"help", "version"}
def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]:
if value is None:
return default
if isinstance(value, str):
return (value,)
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return tuple(value)
raise ValueError("JWT claims vaults, paths and commands must be strings or string arrays")
def vault_for_path(path:Path) -> str|None:
resolved = path.resolve()
best_name:str|None = None
best_len = -1
for name, vault_path in REGISTERED_VAULTS.items():
root = Path(vault_path).resolve()
try:
resolved.relative_to(root)
except ValueError:
continue
root_len = len(str(root))
if root_len > best_len:
best_name = name
best_len = root_len
return best_name
def normalize_policy_path(value:str) -> str:
normalized = value.strip().replace("\\", "/").lstrip("/")
while "//" in normalized:
normalized = normalized.replace("//", "/")
return normalized
def is_path_allowed(path:str, allowed_patterns:tuple[str, ...]) -> bool:
normalized = normalize_policy_path(path)
for pattern in allowed_patterns:
pattern_normalized = normalize_policy_path(pattern)
if pattern_normalized == "*":
return True
if pattern_normalized.endswith("/**"):
prefix = pattern_normalized[:-3].rstrip("/")
if normalized == prefix or normalized.startswith(prefix + "/"):
return True
elif pattern_normalized.endswith("/"):
if normalized.startswith(pattern_normalized):
return True
elif normalized == pattern_normalized or normalized.startswith(pattern_normalized.rstrip("/") + "/"):
return True
return False
def parse_kv_arg(arg:str) -> tuple[str, str]|None:
if "=" not in arg:
return None
key, value = arg.split("=", 1)
if not key:
return None
return key, value
def extract_command_target(args:list[str]) -> CommandTarget:
vault:str|None = None
command:str|None = None
paths:list[str] = []
for arg in args:
parsed = parse_kv_arg(arg)
if parsed and parsed[0] == "vault" and command is None:
vault = parsed[1]
continue
command = arg
break
for arg in args:
parsed = parse_kv_arg(arg)
if not parsed:
continue
key, value = parsed
if key in PATH_KEYS and value:
paths.append(value)
return CommandTarget(command=command, vault=vault, paths=tuple(paths))
def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None:
if policy is None:
return
target = extract_command_target(args)
command = target.command or ""
if not policy.allows_all_commands() and command not in policy.commands:
raise PermissionError(f"token is not allowed to run command: {command}")
if not policy.allows_all_vaults():
vault = target.vault or vault_for_path(cwd) or DEFAULT_VAULT_NAME
if vault not in policy.vaults:
raise PermissionError(f"token is not allowed to access vault: {vault}")
if policy.allows_all_paths():
return
if not target.paths:
if command in PATHLESS_ALLOWED_WHEN_RESTRICTED:
return
if command in PATH_REQUIRED_WHEN_RESTRICTED:
raise PermissionError("token has path restrictions; command must include an allowed path/file/folder/name argument")
raise PermissionError(f"token has path restrictions; command is not allowed without an explicit path: {command}")
denied = [path for path in target.paths if not is_path_allowed(path, policy.paths)]
if denied:
raise PermissionError(f"token is not allowed to access path: {denied[0]}")
+58
View File
@@ -0,0 +1,58 @@
import asyncio, uuid, time
from pathlib import Path
from starlette.responses import JSONResponse
from app.config import CLI_BIN
def error(status_code:int, detail:str) -> JSONResponse:
return JSONResponse({"detail": detail}, status_code=status_code)
async def execute_command(args:list[str], cwd:Path, stdin:str|None, timeout:float) -> JSONResponse:
command_id = str(uuid.uuid4())
command = [CLI_BIN, *args]
started = time.perf_counter()
try:
process = await asyncio.create_subprocess_exec(
*command,
cwd=str(cwd),
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
return error(500, f"configured CLI binary was not found: {CLI_BIN}")
try:
stdout_b, stderr_b = await asyncio.wait_for(
process.communicate(None if stdin is None else stdin.encode()),
timeout=timeout,
)
timed_out = False
except asyncio.TimeoutError:
process.kill()
stdout_b, stderr_b = await process.communicate()
timed_out = True
duration_ms = int((time.perf_counter() - started) * 1000)
stdout = stdout_b.decode(errors="replace")
stderr = stderr_b.decode(errors="replace")
exit_code = process.returncode if process.returncode is not None else -1
if timed_out:
exit_code = 124
stderr = (stderr + f"\nCommand timed out after {timeout}s").strip()
return JSONResponse(
{
"id": command_id,
"command": command,
"cwd": str(cwd),
"exit_code": exit_code,
"stdout": stdout,
"stderr": stderr,
"duration_ms": duration_ms,
"timed_out": timed_out,
}
)
+30
View File
@@ -0,0 +1,30 @@
services:
obsidian-api:
build:
context: .
args:
OBSIDIAN_VERSION: ${OBSIDIAN_VERSION:-1.12.7}
image: obsidian-api:local
container_name: obsidian-api
restart: unless-stopped
environment:
# JWT signing secret. Bearer tokens are HS256 JWTs with vault/path/command claims.
OBSIDIAN_JWT_SECRET: ${OBSIDIAN_JWT_SECRET:-}
OBSIDIAN_CLI_BIN: ${OBSIDIAN_CLI_BIN:-obsidian}
# Single-vault fallback. For multiple vaults, set OBSIDIAN_VAULTS below.
OBSIDIAN_VAULT_PATH: /vault
# Multi-vault syntax: name=/container/path,name2=/container/path2
# Example with the optional /vaults mount below: main=/vault,work=/vaults/work,personal=/vaults/personal
OBSIDIAN_VAULTS: ${OBSIDIAN_VAULTS:-}
OBSIDIAN_STARTUP_TIMEOUT: ${OBSIDIAN_STARTUP_TIMEOUT:-60}
COMMAND_TIMEOUT: ${COMMAND_TIMEOUT:-60}
MAX_COMMAND_TIMEOUT: ${MAX_COMMAND_TIMEOUT:-300}
volumes:
# Default/single vault mount.
- ${OBSIDIAN_VAULT_PATH:-./vault}:/vault
# Optional parent-folder mount for multiple vaults. Example:
# OBSIDIAN_VAULTS_ROOT=/home/daniel/Documents/Obsidian
# OBSIDIAN_VAULTS=main=/vault,work=/vaults/Work,personal=/vaults/Personal
- ${OBSIDIAN_VAULTS_ROOT:-./vaults}:/vaults
ports:
- "${OBSIDIAN_API_PORT:-8080}:8080"
+133
View File
@@ -0,0 +1,133 @@
# n8n usage notes
Use the TypeScript SDK from an n8n **Code** node when n8n can import local or bundled npm packages. In self-hosted n8n this usually means installing the SDK where n8n can resolve it and allowing external modules, for example with `NODE_FUNCTION_ALLOW_EXTERNAL=@obsidian-api/sdk` or `NODE_FUNCTION_ALLOW_EXTERNAL=*`.
The SDK only sends structured `args` arrays to the HTTP API. It never sends shell command strings.
## Environment variables
Recommended n8n env vars:
```env
OBSIDIAN_API_URL=http://obsidian-api:8080
OBSIDIAN_API_JWT=<jwt-from-scripts/create_jwt.py>
OBSIDIAN_VAULT=work
```
Recommended: use a restricted JWT for n8n. Create it with:
```bash
OBSIDIAN_JWT_SECRET=change-this-long-random-secret \
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
```
The JWT contains claims like:
```json
{
"sub": "n8n",
"vaults": ["work"],
"paths": ["Inbox/n8n/", "Automation.md"],
"commands": ["create", "read", "append", "search", "search:context"],
"exp": 1791536000
}
```
If n8n runs outside the Docker network, use the exposed host URL instead:
```env
OBSIDIAN_API_URL=http://localhost:8080
```
## Basic Code node
```ts
import { ObsidianCliClient } from '@obsidian-api/sdk';
const obsidian = new ObsidianCliClient({
baseUrl: process.env.OBSIDIAN_API_URL!,
token: process.env.OBSIDIAN_API_JWT!,
vault: process.env.OBSIDIAN_VAULT,
});
const title = $json.title ?? 'n8n note';
const content = `# ${title}\n\nCreated from n8n.\n`;
const result = await obsidian.create({
path: `Inbox/${title}.md`,
content,
overwrite: true,
});
return [{ json: result }];
```
## Append incoming webhook payload to a daily note
```ts
import { ObsidianCliClient } from '@obsidian-api/sdk';
const obsidian = new ObsidianCliClient({
baseUrl: process.env.OBSIDIAN_API_URL!,
token: process.env.OBSIDIAN_API_JWT!,
vault: process.env.OBSIDIAN_VAULT,
});
const payload = JSON.stringify($json, null, 2).replace(/```/g, '``\\`');
const result = await obsidian.dailyAppend(
`\n## n8n event\n\n\`\`\`json\n${payload}\n\`\`\`\n`,
);
return [{ json: result }];
```
## Search notes
```ts
import { ObsidianCliClient } from '@obsidian-api/sdk';
const obsidian = new ObsidianCliClient({
baseUrl: process.env.OBSIDIAN_API_URL!,
token: process.env.OBSIDIAN_API_JWT!,
vault: process.env.OBSIDIAN_VAULT,
});
const result = await obsidian.search($json.query, { format: 'json' });
return [{ json: { matches: JSON.parse(result.stdout) } }];
```
## Multi-vault usage
If the API container registers multiple vaults, use a scoped client per vault:
```ts
const base = new ObsidianCliClient({
baseUrl: process.env.OBSIDIAN_API_URL!,
token: process.env.OBSIDIAN_API_JWT!,
});
const work = base.inVault('work');
const personal = base.inVault('personal');
await work.create({ path: 'Inbox/Work.md', content: 'Work note', overwrite: true });
await personal.create({ path: 'Inbox/Personal.md', content: 'Personal note', overwrite: true });
```
## Without SDK
If package imports are not available in your n8n environment, call the HTTP API directly:
```ts
const response = await fetch(`${process.env.OBSIDIAN_API_URL}/commands`, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.OBSIDIAN_API_JWT}`,
'content-type': 'application/json',
},
body: JSON.stringify({
args: ['vault=work', 'create', 'path=Inbox/n8n.md', 'content=Hello from n8n', 'overwrite'],
}),
});
return [{ json: await response.json() }];
```
+79
View File
@@ -0,0 +1,79 @@
# n8n-nodes-obsidian-api
n8n community-node package for the Obsidian API Docker service.
It talks to the HTTP API, which then runs the **official Obsidian CLI** against mounted vaults. The node sends structured `args` arrays only. It does not send shell commands.
## Nodes
### Obsidian API
Resources:
- **Note**
- Create
- Read
- Append
- Prepend
- Delete
- Rename
- List Files
- **Daily Note**
- Append
- Prepend
- Read
- Path
- **Search**
- Search
- Search With Context
- **Vault**
- Info
- List Vaults
- **Raw Command**
- Safe args-array escape hatch for any official CLI command
## Credentials
Credential type: **Obsidian API**
Fields:
- **Base URL**: e.g. `http://obsidian-api:8080`
- **API Token**: JWT created with `scripts/create_jwt.py`
- **Default Vault**: optional, e.g. `work`
## Multi-vault
Set the `Vault` field on a node to override the credential default vault.
The node sends this as the official CLI prefix:
```text
vault=work
```
Raw command example:
```json
["vault=work", "read", "path=Inbox/Note.md"]
```
## Local install in self-hosted n8n
From this repo:
```bash
cd packages/n8n-nodes-obsidian-api
npm install --ignore-scripts
npm run build
npm pack
```
Then install the generated `.tgz` in your n8n custom extensions setup.
For Docker-based n8n, one common setup is mounting this package into n8n's custom extensions directory and setting:
```env
N8N_CUSTOM_EXTENSIONS=/home/node/.n8n/custom
```
Exact installation depends on your n8n deployment.
## Development
```bash
npm install --ignore-scripts
npm run typecheck
npm run build
```
`--ignore-scripts` avoids local native builds from n8n transitive dependencies on unsupported bleeding-edge Node versions. Use the Node version recommended by your n8n release for production builds.
@@ -0,0 +1,40 @@
import type {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class ObsidianApi implements ICredentialType {
name = 'obsidianApi';
displayName = 'Obsidian API';
documentationUrl = 'https://git.yiprawr.dev/Docker/obsidian-api';
properties: INodeProperties[] = [
{
displayName: 'Base URL',
name: 'baseUrl',
type: 'string',
default: 'http://obsidian-api:8080',
placeholder: 'http://obsidian-api:8080',
description: 'Base URL of the Obsidian API Docker service',
required: true,
},
{
displayName: 'API Token',
name: 'apiToken',
type: 'string',
typeOptions: {
password: true,
},
default: '',
description: 'Bearer JWT created with scripts/create_jwt.py',
required: true,
},
{
displayName: 'Default Vault',
name: 'defaultVault',
type: 'string',
default: '',
placeholder: 'work',
description: 'Optional default vault name/path. Sent as vault=<value> before every command unless the node overrides it.',
},
];
}
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,324 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { compactArgs, executeObsidianCommand, kv } from '../../shared/ObsidianApiClient';
export class ObsidianApi implements INodeType {
description: INodeTypeDescription = {
displayName: 'Obsidian API',
name: 'obsidianApi',
icon: 'file:obsidian.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"]}}',
description: 'Run official Obsidian CLI commands through the Obsidian API Docker service',
defaults: {
name: 'Obsidian API',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'obsidianApi',
required: true,
},
],
properties: [
{
displayName: 'Vault',
name: 'vault',
type: 'string',
default: '',
placeholder: 'work',
description: 'Optional vault name/path. Overrides the credential default vault. Sent as vault=<value>.',
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Daily Note', value: 'daily' },
{ name: 'Note', value: 'note' },
{ name: 'Raw Command', value: 'raw' },
{ name: 'Search', value: 'search' },
{ name: 'Vault', value: 'vault' },
],
default: 'note',
},
// Note operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['note'] } },
options: [
{ name: 'Append', value: 'append' },
{ name: 'Create', value: 'create' },
{ name: 'Delete', value: 'delete' },
{ name: 'List Files', value: 'files' },
{ name: 'Prepend', value: 'prepend' },
{ name: 'Read', value: 'read' },
{ name: 'Rename', value: 'rename' },
],
default: 'create',
},
{
displayName: 'Path',
name: 'path',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'delete', 'prepend', 'read', 'rename'] } },
default: '',
placeholder: 'Inbox/Note.md',
required: true,
},
{
displayName: 'Content',
name: 'content',
type: 'string',
typeOptions: { rows: 8 },
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'prepend'] } },
default: '',
required: true,
},
{
displayName: 'Overwrite',
name: 'overwrite',
type: 'boolean',
displayOptions: { show: { resource: ['note'], operation: ['create'] } },
default: false,
},
{
displayName: 'New Name',
name: 'newName',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['rename'] } },
default: '',
required: true,
},
{
displayName: 'Folder',
name: 'folder',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['files'] } },
default: '',
placeholder: 'Inbox',
},
// Daily note operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['daily'] } },
options: [
{ name: 'Append', value: 'dailyAppend' },
{ name: 'Path', value: 'dailyPath' },
{ name: 'Prepend', value: 'dailyPrepend' },
{ name: 'Read', value: 'dailyRead' },
],
default: 'dailyAppend',
},
{
displayName: 'Content',
name: 'dailyContent',
type: 'string',
typeOptions: { rows: 8 },
displayOptions: { show: { resource: ['daily'], operation: ['dailyAppend', 'dailyPrepend'] } },
default: '',
required: true,
},
// Search operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['search'] } },
options: [
{ name: 'Search', value: 'search' },
{ name: 'Search With Context', value: 'searchContext' },
],
default: 'search',
},
{
displayName: 'Query',
name: 'query',
type: 'string',
displayOptions: { show: { resource: ['search'] } },
default: '',
required: true,
},
{
displayName: 'Format',
name: 'format',
type: 'options',
displayOptions: { show: { resource: ['search'] } },
options: [
{ name: 'Text', value: 'text' },
{ name: 'JSON', value: 'json' },
],
default: 'text',
},
// Vault operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['vault'] } },
options: [
{ name: 'Info', value: 'vaultInfo' },
{ name: 'List Vaults', value: 'vaults' },
],
default: 'vaultInfo',
},
{
displayName: 'Verbose',
name: 'verbose',
type: 'boolean',
displayOptions: { show: { resource: ['vault'], operation: ['vaults'] } },
default: true,
},
// Raw command
{
displayName: 'Args JSON',
name: 'rawArgs',
type: 'json',
displayOptions: { show: { resource: ['raw'] } },
default: '["version"]',
description: 'Structured CLI args array. Example: ["create", "path=Inbox/Test.md", "content=Hello", "overwrite"]. Do not include shell strings.',
required: true,
},
{
displayName: 'Timeout Seconds',
name: 'timeout',
type: 'number',
default: 60,
description: 'Command timeout in seconds',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const resource = this.getNodeParameter('resource', itemIndex) as string;
const operation = this.getNodeParameter('operation', itemIndex) as string;
const vault = this.getNodeParameter('vault', itemIndex, '') as string;
const timeout = this.getNodeParameter('timeout', itemIndex, 60) as number;
let args: string[];
switch (resource) {
case 'note': {
args = buildNoteArgs(this, operation, itemIndex);
break;
}
case 'daily': {
args = buildDailyArgs(this, operation, itemIndex);
break;
}
case 'search': {
args = buildSearchArgs(this, operation, itemIndex);
break;
}
case 'vault': {
args = buildVaultArgs(this, operation, itemIndex);
break;
}
case 'raw': {
args = parseRawArgs(this, itemIndex);
break;
}
default:
throw new NodeOperationError(this.getNode(), `Unsupported resource: ${resource}`, { itemIndex });
}
const result = await executeObsidianCommand(this, itemIndex, args, { vault, timeout });
returnData.push({ json: result, pairedItem: { item: itemIndex } });
}
return [returnData];
}
}
function buildNoteArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const path = context.getNodeParameter('path', itemIndex, '') as string;
switch (operation) {
case 'create':
return compactArgs(['create', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string), kv('overwrite', context.getNodeParameter('overwrite', itemIndex) as boolean)]);
case 'read':
return compactArgs(['read', kv('path', path)]);
case 'append':
return compactArgs(['append', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
case 'prepend':
return compactArgs(['prepend', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
case 'delete':
return compactArgs(['delete', kv('path', path)]);
case 'rename':
return compactArgs(['rename', kv('path', path), kv('name', context.getNodeParameter('newName', itemIndex) as string)]);
case 'files':
return compactArgs(['files', kv('folder', context.getNodeParameter('folder', itemIndex, '') as string)]);
default:
throw new NodeOperationError(context.getNode(), `Unsupported note operation: ${operation}`, { itemIndex });
}
}
function buildDailyArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
switch (operation) {
case 'dailyAppend':
return compactArgs(['daily:append', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
case 'dailyPrepend':
return compactArgs(['daily:prepend', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
case 'dailyRead':
return ['daily:read'];
case 'dailyPath':
return ['daily:path'];
default:
throw new NodeOperationError(context.getNode(), `Unsupported daily operation: ${operation}`, { itemIndex });
}
}
function buildSearchArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const command = operation === 'searchContext' ? 'search:context' : 'search';
return compactArgs([
command,
kv('query', context.getNodeParameter('query', itemIndex) as string),
kv('format', context.getNodeParameter('format', itemIndex) as string),
]);
}
function buildVaultArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
switch (operation) {
case 'vaultInfo':
return ['vault'];
case 'vaults':
return compactArgs(['vaults', kv('verbose', context.getNodeParameter('verbose', itemIndex) as boolean)]);
default:
throw new NodeOperationError(context.getNode(), `Unsupported vault operation: ${operation}`, { itemIndex });
}
}
function parseRawArgs(context: IExecuteFunctions, itemIndex: number): string[] {
const raw = context.getNodeParameter('rawArgs', itemIndex) as string | string[];
const parsed = Array.isArray(raw) ? raw : JSON.parse(raw);
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) {
throw new NodeOperationError(context.getNode(), 'Args JSON must be an array of strings', { itemIndex });
}
return parsed;
}
@@ -0,0 +1,51 @@
<svg fill="none" height="100%" width="100%" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<radialGradient id="logo-bottom-left" cx="0" cy="0" gradientTransform="matrix(-59 -225 150 -39 161.4 470)" gradientUnits="userSpaceOnUse" r="1">
<stop offset="0" stop-color="#fff" stop-opacity=".4"/>
<stop offset="1" stop-opacity=".1"/>
</radialGradient>
<radialGradient id="logo-top-right" cx="0" cy="0" gradientTransform="matrix(50 -379 280 37 360 374.2)" gradientUnits="userSpaceOnUse" r="1">
<stop offset="0" stop-color="#fff" stop-opacity=".6"/>
<stop offset="1" stop-color="#fff" stop-opacity=".1"/>
</radialGradient>
<radialGradient id="logo-top-left" cx="0" cy="0" gradientTransform="matrix(69 -319 218 47 175.4 307)" gradientUnits="userSpaceOnUse" r="1">
<stop offset="0" stop-color="#fff" stop-opacity=".8"/>
<stop offset="1" stop-color="#fff" stop-opacity=".4"/>
</radialGradient>
<radialGradient id="logo-bottom-right" cx="0" cy="0" gradientTransform="matrix(-96 -163 187 -111 335.3 512.2)" gradientUnits="userSpaceOnUse" r="1">
<stop offset="0" stop-color="#fff" stop-opacity=".3"/>
<stop offset="1" stop-opacity=".3"/>
</radialGradient>
<radialGradient id="logo-top-edge" cx="0" cy="0" gradientTransform="matrix(-36 166 -112 -24 310 128.2)" gradientUnits="userSpaceOnUse" r="1">
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
<stop offset="1" stop-color="#fff" stop-opacity=".2"/>
</radialGradient>
<radialGradient id="logo-left-edge" cx="0" cy="0" gradientTransform="matrix(88 89 -190 187 111 220.2)" gradientUnits="userSpaceOnUse" r="1">
<stop offset="0" stop-color="#fff" stop-opacity=".2"/>
<stop offset="1" stop-color="#fff" stop-opacity=".4"/>
</radialGradient>
<radialGradient id="logo-bottom-edge" cx="0" cy="0" gradientTransform="matrix(9 130 -276 20 215 284)" gradientUnits="userSpaceOnUse" r="1">
<stop offset="0" stop-color="#fff" stop-opacity=".2"/>
<stop offset="1" stop-color="#fff" stop-opacity=".3"/>
</radialGradient>
<radialGradient id="logo-middle-edge" cx="0" cy="0" gradientTransform="matrix(-198 -104 327 -623 400 399.2)" gradientUnits="userSpaceOnUse" r="1">
<stop offset="0" stop-color="#fff" stop-opacity=".2"/>
<stop offset=".5" stop-color="#fff" stop-opacity=".2"/>
<stop offset="1" stop-color="#fff" stop-opacity=".3"/>
</radialGradient>
<clipPath id="clip">
<path d="M.2.2h512v512H.2z"/>
</clipPath>
<g clip-path="url(#clip)">
<path d="M382.3 475.6c-3.1 23.4-26 41.6-48.7 35.3-32.4-8.9-69.9-22.8-103.6-25.4l-51.7-4a34 34 0 0 1-22-10.2l-89-91.7a34 34 0 0 1-6.7-37.7s55-121 57.1-127.3c2-6.3 9.6-61.2 14-90.6 1.2-7.9 5-15 11-20.3L248 8.9a34.1 34.1 0 0 1 49.6 4.3L386 125.6a37 37 0 0 1 7.6 22.4c0 21.3 1.8 65 13.6 93.2 11.5 27.3 32.5 57 43.5 71.5a17.3 17.3 0 0 1 1.3 19.2 1494 1494 0 0 1-44.8 70.6c-15 22.3-21.9 49.9-25 73.1z" fill="#6c31e3"/>
<path d="M165.9 478.3c41.4-84 40.2-144.2 22.6-187-16.2-39.6-46.3-64.5-70-80-.6 2.3-1.3 4.4-2.2 6.5L60.6 342a34 34 0 0 0 6.6 37.7l89.1 91.7a34 34 0 0 0 9.6 7z" fill="url(#logo-bottom-left)"/>
<path d="M278.4 307.8c11.2 1.2 22.2 3.6 32.8 7.6 34 12.7 65 41.2 90.5 96.3 1.8-3.1 3.6-6.2 5.6-9.2a1536 1536 0 0 0 44.8-70.6 17 17 0 0 0-1.3-19.2c-11-14.6-32-44.2-43.5-71.5-11.8-28.2-13.5-72-13.6-93.2 0-8.1-2.6-16-7.6-22.4L297.6 13.2a34 34 0 0 0-1.5-1.7 96 96 0 0 1 2 54 198.3 198.3 0 0 1-17.6 41.3l-7.2 14.2a171 171 0 0 0-19.4 71c-1.2 29.4 4.8 66.4 24.5 115.8z" fill="url(#logo-top-right)"/>
<path d="M278.4 307.8c-19.7-49.4-25.8-86.4-24.5-115.9a171 171 0 0 1 19.4-71c2.3-4.8 4.8-9.5 7.2-14.1 7.1-13.9 14-27 17.6-41.4a96 96 0 0 0-2-54A34.1 34.1 0 0 0 248 9l-105.4 94.8a34.1 34.1 0 0 0-10.9 20.3l-12.8 85-.5 2.3c23.8 15.5 54 40.4 70.1 80a147 147 0 0 1 7.8 24.8c28-6.8 55.7-11 82.1-8.3z" fill="url(#logo-top-left)"/>
<path d="M333.6 511c22.7 6.2 45.6-12 48.7-35.4a187 187 0 0 1 19.4-63.9c-25.6-55-56.5-83.6-90.4-96.3-36-13.4-75.2-9-115 .7 8.9 40.4 3.6 93.3-30.4 162.2 4 1.8 8.1 3 12.5 3.3 0 0 24.4 2 53.6 4.1 29 2 72.4 17.1 101.6 25.2z" fill="url(#logo-bottom-right)"/>
<g clip-rule="evenodd" fill-rule="evenodd">
<path d="M254.1 190c-1.3 29.2 2.4 62.8 22.1 112.1l-6.2-.5c-17.7-51.5-21.5-78-20.2-107.6a174.7 174.7 0 0 1 20.4-72c2.4-4.9 8-14.1 10.5-18.8 7.1-13.7 11.9-21 16-33.6 5.7-17.5 4.5-25.9 3.8-34.1 4.6 29.9-12.7 56-25.7 82.4a177.1 177.1 0 0 0-20.7 72z" fill="url(#logo-top-edge)"/>
<path d="M194.3 293.4c2.4 5.4 4.6 9.8 6 16.5L195 311c-2.1-7.8-3.8-13.4-6.8-20-17.8-42-46.3-63.6-69.7-79.5 28.2 15.2 57.2 39 75.7 81.9z" fill="url(#logo-left-edge)"/>
<path d="M200.6 315.1c9.8 46-1.2 104.2-33.6 160.9 27.1-56.2 40.2-110.1 29.3-160z" fill="url(#logo-bottom-edge)"/>
<path d="M312.5 311c53.1 19.9 73.6 63.6 88.9 100-19-38.1-45.2-80.3-90.8-96-34.8-11.8-64.1-10.4-114.3 1l-1.1-5c53.2-12.1 81-13.5 117.3 0z" fill="url(#logo-middle-edge)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
{
"name": "n8n-nodes-obsidian-api",
"version": "0.1.0",
"description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.",
"license": "MIT",
"homepage": "https://git.yiprawr.dev/Docker/obsidian-api",
"repository": {
"type": "git",
"url": "git+ssh://git@git.yiprawr.dev/Docker/obsidian-api.git",
"directory": "packages/n8n-nodes-obsidian-api"
},
"keywords": [
"n8n-community-node-package",
"n8n",
"obsidian",
"obsidian-cli",
"notes"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json && mkdir -p dist/nodes/ObsidianApi && cp nodes/ObsidianApi/obsidian.svg dist/nodes/ObsidianApi/obsidian.svg",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint nodes credentials shared --ext .ts"
},
"files": [
"dist",
"nodes",
"credentials",
"shared"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/ObsidianApi.credentials.js"
],
"nodes": [
"dist/nodes/ObsidianApi/ObsidianApi.node.js"
]
},
"devDependencies": {
"@types/node": "^24.0.0",
"eslint": "^9.29.0",
"n8n-workflow": "^1.82.0",
"typescript": "^5.9.0"
},
"peerDependencies": {
"n8n-workflow": "*"
}
}
@@ -0,0 +1,77 @@
import type {
IExecuteFunctions,
IDataObject,
IHttpRequestOptions,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
export interface ObsidianCommandResult extends IDataObject {
id: string;
command: string[];
cwd: string;
exit_code: number;
stdout: string;
stderr: string;
duration_ms: number;
timed_out: boolean;
}
export interface ObsidianCredentials extends IDataObject {
baseUrl: string;
apiToken: string;
defaultVault?: string;
}
export function compactArgs(args: Array<string | undefined | null | false>): string[] {
return args.filter((arg): arg is string => typeof arg === 'string' && arg.length > 0);
}
export function kv(name: string, value: string | number | boolean | undefined | null): string | undefined {
if (value === undefined || value === null || value === '') return undefined;
if (typeof value === 'boolean') return value ? name : undefined;
return `${name}=${value}`;
}
export function withVault(args: string[], vault?: string): string[] {
if (!vault || args[0]?.startsWith('vault=')) return args;
return [`vault=${vault}`, ...args];
}
export async function executeObsidianCommand(
context: IExecuteFunctions,
itemIndex: number,
args: string[],
options: { vault?: string; timeout?: number; stdin?: string } = {},
): Promise<ObsidianCommandResult> {
const credentials = await context.getCredentials('obsidianApi') as ObsidianCredentials;
const baseUrl = String(credentials.baseUrl).replace(/\/$/, '');
const vault = options.vault || String(credentials.defaultVault || '');
const finalArgs = withVault(args, vault);
const request: IHttpRequestOptions = {
method: 'POST',
url: `${baseUrl}/commands`,
headers: {
Authorization: `Bearer ${credentials.apiToken}`,
'Content-Type': 'application/json',
},
body: {
args: finalArgs,
stdin: options.stdin || undefined,
timeout: options.timeout,
},
json: true,
};
const result = await context.helpers.httpRequest(request) as ObsidianCommandResult;
if (result.exit_code !== 0) {
throw new NodeOperationError(
context.getNode(),
`Obsidian CLI command failed with exit code ${result.exit_code}: ${result.stderr || result.stdout}`,
{ itemIndex },
);
}
return result;
}
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022", "DOM"],
"declaration": true,
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["credentials/**/*.ts", "nodes/**/*.ts", "shared/**/*.ts", "index.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
[project]
name = "obsidian-api"
version = "1.0.0"
description = "HTTP API wrapper for running Obsidian CLI commands from n8n, SDKs, or agent harnesses."
requires-python = ">=3.14"
dependencies = [
"starlette==0.50.0",
"uvicorn[standard]==0.38.0",
]
[dependency-groups]
dev = [
"pytest==9.0.2",
"httpx==0.28.1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.uv]
package = false
+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())
+59
View File
@@ -0,0 +1,59 @@
# @obsidian-api/sdk
TypeScript SDK for this HTTP wrapper around the **official Obsidian CLI**.
The SDK is built for Node 18+, n8n Code nodes, Prism, and other TypeScript agent harnesses.
## Install locally
```bash
npm install /path/to/obsidian-api/sdk/typescript
```
## Usage
```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 n8n/Prism',
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));
```
## Multiple vaults
Use the official CLI `vault=<name>` prefix directly:
```ts
await obsidian.runInVault('work', 'create', 'path=Inbox/Task.md', 'content=Hello');
```
Or create a scoped client:
```ts
const workVault = obsidian.inVault('work');
await workVault.create({ path: 'Inbox/Task.md', content: 'Hello', overwrite: true });
await workVault.read({ path: 'Inbox/Task.md' });
```
You can also set a default vault in the constructor:
```ts
const obsidian = new ObsidianCliClient({
baseUrl: 'http://localhost:8080',
token: 'change-me',
vault: 'work',
});
```
## Raw command escape hatch
Still safe: sends an args array, not a shell command string.
```ts
await obsidian.runRaw('create', 'name=Scratch', 'content=Test', 'overwrite');
```
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@obsidian-api/sdk",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@obsidian-api/sdk",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"typescript": "^5.9.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@obsidian-api/sdk",
"version": "1.0.0",
"description": "TypeScript SDK for the official Obsidian CLI HTTP wrapper. Built for n8n, Prism, and other TS/Node agent harnesses.",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "src"],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
},
"engines": {
"node": ">=18"
},
"license": "MIT",
"devDependencies": {
"typescript": "^5.9.0"
}
}
+258
View File
@@ -0,0 +1,258 @@
export type OutputFormat = "json" | "tsv" | "csv" | "text" | "yaml" | "md" | "paths" | "tree";
export type PaneType = "tab" | "split" | "window";
export type PluginFilter = "core" | "community";
export type PropertyType = "text" | "list" | "number" | "checkbox" | "date" | "datetime";
export type HistoryFilter = "local" | "sync";
export type LogLevel = "log" | "warn" | "error" | "info" | "debug";
export interface CommandResult {
id: string;
command: string[];
cwd: string;
exit_code: number;
stdout: string;
stderr: string;
duration_ms: number;
timed_out: boolean;
}
export class ObsidianApiError extends Error {
constructor(public statusCode: number, public detail: string) {
super(`HTTP ${statusCode}: ${detail}`);
this.name = "ObsidianApiError";
}
}
export interface ObsidianCliClientOptions {
baseUrl: string;
token?: string;
timeoutMs?: number;
/** Optional default vault name/path. Sent as the official CLI `vault=<name>` prefix. */
vault?: string;
}
type ParamValue = string | number | boolean | undefined | null;
function kv(name: string, value: ParamValue): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value === "boolean") return value ? name : undefined;
return `${name}=${value}`;
}
function args(...items: Array<string | undefined>): string[] {
return items.filter((item): item is string => item !== undefined);
}
export class ObsidianCliClient {
readonly baseUrl: string;
readonly token?: string;
readonly timeoutMs: number;
readonly defaultVault?: string;
constructor(options: ObsidianCliClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.token = options.token;
this.timeoutMs = options.timeoutMs ?? 60_000;
this.defaultVault = options.vault;
}
/**
* Return a scoped client that automatically prefixes every command with
* `vault=<name>`, matching the official Obsidian CLI multi-vault syntax.
*/
inVault(vault: string): ObsidianCliClient {
return new ObsidianCliClient({
baseUrl: this.baseUrl,
token: this.token,
timeoutMs: this.timeoutMs,
vault,
});
}
async run(commandArgs: string[], options: { stdin?: string; timeoutMs?: number } = {}): Promise<CommandResult> {
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs + 5_000);
try {
const response = await fetch(`${this.baseUrl}/commands`, {
method: "POST",
headers: {
"content-type": "application/json",
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
},
body: JSON.stringify({
args: this.defaultVault && !commandArgs[0]?.startsWith("vault=") ? [`vault=${this.defaultVault}`, ...commandArgs] : commandArgs,
stdin: options.stdin,
timeout: Math.ceil(timeoutMs / 1000),
}),
signal: controller.signal,
});
const data = await response.json() as CommandResult | { detail?: string };
if (!response.ok) {
throw new ObsidianApiError(response.status, "detail" in data && data.detail ? data.detail : response.statusText);
}
return data as CommandResult;
} finally {
clearTimeout(timer);
}
}
async runRaw(...commandArgs: string[]): Promise<CommandResult> {
return this.run(commandArgs);
}
async runInVault(vault: string, command: string, ...commandArgs: string[]): Promise<CommandResult> {
return this.run([`vault=${vault}`, command, ...commandArgs]);
}
// General
help = (command?: string) => this.run(args("help", command));
version = () => this.run(["version"]);
reload = () => this.run(["reload"]);
restart = () => this.run(["restart"]);
// Bases
bases = () => this.run(["bases"]);
baseViews = () => this.run(["base:views"]);
baseCreate = (o: { file?: string; path?: string; view?: string; name?: string; content?: string; open?: boolean; newtab?: boolean } = {}) =>
this.run(args("base:create", kv("file", o.file), kv("path", o.path), kv("view", o.view), kv("name", o.name), kv("content", o.content), kv("open", o.open), kv("newtab", o.newtab)));
baseQuery = (o: { file?: string; path?: string; view?: string; format?: "json" | "csv" | "tsv" | "md" | "paths" } = {}) =>
this.run(args("base:query", kv("file", o.file), kv("path", o.path), kv("view", o.view), kv("format", o.format)));
// Bookmarks / command palette / hotkeys
bookmarks = (o: { total?: boolean; verbose?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("bookmarks", kv("total", o.total), kv("verbose", o.verbose), kv("format", o.format)));
bookmark = (o: { file?: string; subpath?: string; folder?: string; search?: string; url?: string; title?: string }) => this.run(args("bookmark", kv("file", o.file), kv("subpath", o.subpath), kv("folder", o.folder), kv("search", o.search), kv("url", o.url), kv("title", o.title)));
commands = (o: { filter?: string } = {}) => this.run(args("commands", kv("filter", o.filter)));
command = (id: string) => this.run(["command", kv("id", id)!]);
hotkeys = (o: { total?: boolean; verbose?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("hotkeys", kv("total", o.total), kv("verbose", o.verbose), kv("format", o.format)));
hotkey = (id: string, o: { verbose?: boolean } = {}) => this.run(args("hotkey", kv("id", id), kv("verbose", o.verbose)));
// Daily notes
daily = (o: { paneType?: PaneType } = {}) => this.run(args("daily", kv("paneType", o.paneType)));
dailyPath = () => this.run(["daily:path"]);
dailyRead = () => this.run(["daily:read"]);
dailyAppend = (content: string, o: { paneType?: PaneType; inline?: boolean; open?: boolean } = {}) => this.run(args("daily:append", kv("content", content), kv("paneType", o.paneType), kv("inline", o.inline), kv("open", o.open)));
dailyPrepend = (content: string, o: { paneType?: PaneType; inline?: boolean; open?: boolean } = {}) => this.run(args("daily:prepend", kv("content", content), kv("paneType", o.paneType), kv("inline", o.inline), kv("open", o.open)));
// File history
diff = (o: { file?: string; path?: string; from?: number; to?: number; filter?: HistoryFilter } = {}) => this.run(args("diff", kv("file", o.file), kv("path", o.path), kv("from", o.from), kv("to", o.to), kv("filter", o.filter)));
history = (o: { file?: string; path?: string } = {}) => this.run(args("history", kv("file", o.file), kv("path", o.path)));
historyList = () => this.run(["history:list"]);
historyRead = (o: { file?: string; path?: string; version?: number } = {}) => this.run(args("history:read", kv("file", o.file), kv("path", o.path), kv("version", o.version)));
historyRestore = (version: number, o: { file?: string; path?: string } = {}) => this.run(args("history:restore", kv("file", o.file), kv("path", o.path), kv("version", version)));
historyOpen = (o: { file?: string; path?: string } = {}) => this.run(args("history:open", kv("file", o.file), kv("path", o.path)));
// Files and folders
file = (o: { file?: string; path?: string } = {}) => this.run(args("file", kv("file", o.file), kv("path", o.path)));
files = (o: { folder?: string; ext?: string; total?: boolean } = {}) => this.run(args("files", kv("folder", o.folder), kv("ext", o.ext), kv("total", o.total)));
folder = (path: string, o: { info?: "files" | "folders" | "size" } = {}) => this.run(args("folder", kv("path", path), kv("info", o.info)));
folders = (o: { folder?: string; total?: boolean } = {}) => this.run(args("folders", kv("folder", o.folder), kv("total", o.total)));
open = (o: { file?: string; path?: string; newtab?: boolean } = {}) => this.run(args("open", kv("file", o.file), kv("path", o.path), kv("newtab", o.newtab)));
create = (o: { name?: string; path?: string; content?: string; template?: string; overwrite?: boolean; open?: boolean; newtab?: boolean } = {}) => this.run(args("create", kv("name", o.name), kv("path", o.path), kv("content", o.content), kv("template", o.template), kv("overwrite", o.overwrite), kv("open", o.open), kv("newtab", o.newtab)));
read = (o: { file?: string; path?: string } = {}) => this.run(args("read", kv("file", o.file), kv("path", o.path)));
append = (content: string, o: { file?: string; path?: string; inline?: boolean } = {}) => this.run(args("append", kv("file", o.file), kv("path", o.path), kv("content", content), kv("inline", o.inline)));
prepend = (content: string, o: { file?: string; path?: string; inline?: boolean } = {}) => this.run(args("prepend", kv("file", o.file), kv("path", o.path), kv("content", content), kv("inline", o.inline)));
move = (to: string, o: { file?: string; path?: string } = {}) => this.run(args("move", kv("file", o.file), kv("path", o.path), kv("to", to)));
rename = (name: string, o: { file?: string; path?: string } = {}) => this.run(args("rename", kv("file", o.file), kv("path", o.path), kv("name", name)));
delete = (o: { file?: string; path?: string; permanent?: boolean } = {}) => this.run(args("delete", kv("file", o.file), kv("path", o.path), kv("permanent", o.permanent)));
// Links / outline
backlinks = (o: { file?: string; path?: string; counts?: boolean; total?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("backlinks", kv("file", o.file), kv("path", o.path), kv("counts", o.counts), kv("total", o.total), kv("format", o.format)));
links = (o: { file?: string; path?: string; total?: boolean } = {}) => this.run(args("links", kv("file", o.file), kv("path", o.path), kv("total", o.total)));
unresolved = (o: { total?: boolean; counts?: boolean; verbose?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("unresolved", kv("total", o.total), kv("counts", o.counts), kv("verbose", o.verbose), kv("format", o.format)));
orphans = (o: { total?: boolean } = {}) => this.run(args("orphans", kv("total", o.total)));
deadends = (o: { total?: boolean } = {}) => this.run(args("deadends", kv("total", o.total)));
outline = (o: { file?: string; path?: string; format?: "tree" | "md" | "json"; total?: boolean } = {}) => this.run(args("outline", kv("file", o.file), kv("path", o.path), kv("format", o.format), kv("total", o.total)));
// Plugins
plugins = (o: { filter?: PluginFilter; versions?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("plugins", kv("filter", o.filter), kv("versions", o.versions), kv("format", o.format)));
pluginsEnabled = (o: { filter?: PluginFilter; versions?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("plugins:enabled", kv("filter", o.filter), kv("versions", o.versions), kv("format", o.format)));
pluginsRestrict = (o: { on?: boolean; off?: boolean } = {}) => this.run(args("plugins:restrict", kv("on", o.on), kv("off", o.off)));
plugin = (id: string) => this.run(["plugin", kv("id", id)!]);
pluginEnable = (id: string, o: { filter?: PluginFilter } = {}) => this.run(args("plugin:enable", kv("id", id), kv("filter", o.filter)));
pluginDisable = (id: string, o: { filter?: PluginFilter } = {}) => this.run(args("plugin:disable", kv("id", id), kv("filter", o.filter)));
pluginInstall = (id: string, o: { enable?: boolean } = {}) => this.run(args("plugin:install", kv("id", id), kv("enable", o.enable)));
pluginUninstall = (id: string) => this.run(["plugin:uninstall", kv("id", id)!]);
pluginReload = (id: string) => this.run(["plugin:reload", kv("id", id)!]);
// Properties
aliases = (o: { file?: string; path?: string; total?: boolean; verbose?: boolean; active?: boolean } = {}) => this.run(args("aliases", kv("file", o.file), kv("path", o.path), kv("total", o.total), kv("verbose", o.verbose), kv("active", o.active)));
properties = (o: { file?: string; path?: string; name?: string; sort?: "count"; format?: "yaml" | "json" | "tsv"; total?: boolean; counts?: boolean; active?: boolean } = {}) => this.run(args("properties", kv("file", o.file), kv("path", o.path), kv("name", o.name), kv("sort", o.sort), kv("format", o.format), kv("total", o.total), kv("counts", o.counts), kv("active", o.active)));
propertySet = (name: string, value: string, o: { type?: PropertyType; file?: string; path?: string } = {}) => this.run(args("property:set", kv("name", name), kv("value", value), kv("type", o.type), kv("file", o.file), kv("path", o.path)));
propertyRemove = (name: string, o: { file?: string; path?: string } = {}) => this.run(args("property:remove", kv("name", name), kv("file", o.file), kv("path", o.path)));
propertyRead = (name: string, o: { file?: string; path?: string } = {}) => this.run(args("property:read", kv("name", name), kv("file", o.file), kv("path", o.path)));
// Publish
publishSite = () => this.run(["publish:site"]);
publishList = (o: { total?: boolean } = {}) => this.run(args("publish:list", kv("total", o.total)));
publishStatus = (o: { total?: boolean; new?: boolean; changed?: boolean; deleted?: boolean } = {}) => this.run(args("publish:status", kv("total", o.total), kv("new", o.new), kv("changed", o.changed), kv("deleted", o.deleted)));
publishAdd = (o: { file?: string; path?: string; changed?: boolean } = {}) => this.run(args("publish:add", kv("file", o.file), kv("path", o.path), kv("changed", o.changed)));
publishRemove = (o: { file?: string; path?: string } = {}) => this.run(args("publish:remove", kv("file", o.file), kv("path", o.path)));
publishOpen = (o: { file?: string; path?: string } = {}) => this.run(args("publish:open", kv("file", o.file), kv("path", o.path)));
// Random / search
random = (o: { folder?: string; newtab?: boolean } = {}) => this.run(args("random", kv("folder", o.folder), kv("newtab", o.newtab)));
randomRead = (o: { folder?: string } = {}) => this.run(args("random:read", kv("folder", o.folder)));
search = (query: string, o: { path?: string; limit?: number; format?: "text" | "json"; total?: boolean; case?: boolean } = {}) => this.run(args("search", kv("query", query), kv("path", o.path), kv("limit", o.limit), kv("format", o.format), kv("total", o.total), kv("case", o.case)));
searchContext = (query: string, o: { path?: string; limit?: number; format?: "text" | "json"; case?: boolean } = {}) => this.run(args("search:context", kv("query", query), kv("path", o.path), kv("limit", o.limit), kv("format", o.format), kv("case", o.case)));
searchOpen = (o: { query?: string } = {}) => this.run(args("search:open", kv("query", o.query)));
// Sync
sync = (o: { on?: boolean; off?: boolean } = {}) => this.run(args("sync", kv("on", o.on), kv("off", o.off)));
syncStatus = () => this.run(["sync:status"]);
syncHistory = (o: { file?: string; path?: string; total?: boolean } = {}) => this.run(args("sync:history", kv("file", o.file), kv("path", o.path), kv("total", o.total)));
syncRead = (version: number, o: { file?: string; path?: string } = {}) => this.run(args("sync:read", kv("file", o.file), kv("path", o.path), kv("version", version)));
syncRestore = (version: number, o: { file?: string; path?: string } = {}) => this.run(args("sync:restore", kv("file", o.file), kv("path", o.path), kv("version", version)));
syncOpen = (o: { file?: string; path?: string } = {}) => this.run(args("sync:open", kv("file", o.file), kv("path", o.path)));
syncDeleted = (o: { total?: boolean } = {}) => this.run(args("sync:deleted", kv("total", o.total)));
// Tags / tasks
tags = (o: { file?: string; path?: string; sort?: "count"; total?: boolean; counts?: boolean; format?: "json" | "tsv" | "csv"; active?: boolean } = {}) => this.run(args("tags", kv("file", o.file), kv("path", o.path), kv("sort", o.sort), kv("total", o.total), kv("counts", o.counts), kv("format", o.format), kv("active", o.active)));
tag = (name: string, o: { total?: boolean; verbose?: boolean } = {}) => this.run(args("tag", kv("name", name), kv("total", o.total), kv("verbose", o.verbose)));
tasks = (o: { file?: string; path?: string; status?: string; total?: boolean; done?: boolean; todo?: boolean; verbose?: boolean; format?: "json" | "tsv" | "csv"; active?: boolean; daily?: boolean } = {}) => this.run(args("tasks", kv("file", o.file), kv("path", o.path), kv("status", o.status), kv("total", o.total), kv("done", o.done), kv("todo", o.todo), kv("verbose", o.verbose), kv("format", o.format), kv("active", o.active), kv("daily", o.daily)));
task = (o: { ref?: string; file?: string; path?: string; line?: number; status?: string; toggle?: boolean; daily?: boolean; done?: boolean; todo?: boolean } = {}) => this.run(args("task", kv("ref", o.ref), kv("file", o.file), kv("path", o.path), kv("line", o.line), kv("status", o.status), kv("toggle", o.toggle), kv("daily", o.daily), kv("done", o.done), kv("todo", o.todo)));
// Templates / themes / snippets
templates = (o: { total?: boolean } = {}) => this.run(args("templates", kv("total", o.total)));
templateRead = (name: string, o: { title?: string; resolve?: boolean } = {}) => this.run(args("template:read", kv("name", name), kv("title", o.title), kv("resolve", o.resolve)));
templateInsert = (name: string) => this.run(["template:insert", kv("name", name)!]);
themes = (o: { versions?: boolean } = {}) => this.run(args("themes", kv("versions", o.versions)));
theme = (o: { name?: string } = {}) => this.run(args("theme", kv("name", o.name)));
themeSet = (name: string) => this.run(["theme:set", kv("name", name)!]);
themeInstall = (name: string, o: { enable?: boolean } = {}) => this.run(args("theme:install", kv("name", name), kv("enable", o.enable)));
themeUninstall = (name: string) => this.run(["theme:uninstall", kv("name", name)!]);
snippets = () => this.run(["snippets"]);
snippetsEnabled = () => this.run(["snippets:enabled"]);
snippetEnable = (name: string) => this.run(["snippet:enable", kv("name", name)!]);
snippetDisable = (name: string) => this.run(["snippet:disable", kv("name", name)!]);
// Unique / vault / web / wordcount / workspace
unique = (o: { name?: string; content?: string; paneType?: PaneType; open?: boolean } = {}) => this.run(args("unique", kv("name", o.name), kv("content", o.content), kv("paneType", o.paneType), kv("open", o.open)));
vault = (o: { info?: "name" | "path" | "files" | "folders" | "size" } = {}) => this.run(args("vault", kv("info", o.info)));
vaults = (o: { total?: boolean; verbose?: boolean } = {}) => this.run(args("vaults", kv("total", o.total), kv("verbose", o.verbose)));
vaultOpen = (name: string) => this.run(["vault:open", kv("name", name)!]);
web = (url: string, o: { newtab?: boolean } = {}) => this.run(args("web", kv("url", url), kv("newtab", o.newtab)));
wordcount = (o: { file?: string; path?: string; words?: boolean; characters?: boolean } = {}) => this.run(args("wordcount", kv("file", o.file), kv("path", o.path), kv("words", o.words), kv("characters", o.characters)));
workspace = (o: { ids?: boolean } = {}) => this.run(args("workspace", kv("ids", o.ids)));
workspaces = (o: { total?: boolean } = {}) => this.run(args("workspaces", kv("total", o.total)));
workspaceSave = (name: string) => this.run(["workspace:save", kv("name", name)!]);
workspaceLoad = (name: string) => this.run(["workspace:load", kv("name", name)!]);
workspaceDelete = (name: string) => this.run(["workspace:delete", kv("name", name)!]);
tabs = (o: { ids?: boolean } = {}) => this.run(args("tabs", kv("ids", o.ids)));
tabOpen = (o: { group?: string; file?: string; view?: string } = {}) => this.run(args("tab:open", kv("group", o.group), kv("file", o.file), kv("view", o.view)));
recents = (o: { total?: boolean } = {}) => this.run(args("recents", kv("total", o.total)));
// Developer commands
devtools = () => this.run(["devtools"]);
devDebug = (o: { on?: boolean; off?: boolean } = {}) => this.run(args("dev:debug", kv("on", o.on), kv("off", o.off)));
devCdp = (method: string, o: { params?: string } = {}) => this.run(args("dev:cdp", kv("method", method), kv("params", o.params)));
devErrors = (o: { clear?: boolean } = {}) => this.run(args("dev:errors", kv("clear", o.clear)));
devScreenshot = (o: { path?: string } = {}) => this.run(args("dev:screenshot", kv("path", o.path)));
devConsole = (o: { limit?: number; level?: LogLevel; clear?: boolean } = {}) => this.run(args("dev:console", kv("limit", o.limit), kv("level", o.level), kv("clear", o.clear)));
devCss = (selector: string, o: { prop?: string } = {}) => this.run(args("dev:css", kv("selector", selector), kv("prop", o.prop)));
devDom = (selector: string, o: { attr?: string; css?: string; total?: boolean; text?: boolean; inner?: boolean; all?: boolean } = {}) => this.run(args("dev:dom", kv("selector", selector), kv("attr", o.attr), kv("css", o.css), kv("total", o.total), kv("text", o.text), kv("inner", o.inner), kv("all", o.all)));
devMobile = (o: { on?: boolean; off?: boolean } = {}) => this.run(args("dev:mobile", kv("on", o.on), kv("off", o.off)));
eval = (code: string) => this.run(["eval", kv("code", code)!]);
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+167
View File
@@ -0,0 +1,167 @@
import hashlib, base64, json, hmac
import time, os
from starlette.testclient import TestClient
os.environ.setdefault("OBSIDIAN_JWT_SECRET", "test-jwt-secret")
os.environ.setdefault("OBSIDIAN_CLI_BIN", "python")
os.environ.setdefault("OBSIDIAN_VAULT_PATH", ".")
import app.auth as authmod # noqa: E402
from app.main import app # noqa: E402
client = TestClient(app)
def make_jwt(claims:dict, secret:bytes = b"test-jwt-secret") -> str:
header = {"alg": "HS256", "typ": "JWT"}
def enc(value:dict) -> str:
raw = json.dumps(value, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
signing_input = f"{enc(header)}.{enc(claims)}"
signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{base64.urlsafe_b64encode(signature).decode().rstrip('=')}"
def jwt(claims:dict|None = None) -> str:
claims = claims or {}
claims.setdefault("sub", "tests")
claims.setdefault("vaults", ["*"])
claims.setdefault("paths", ["*"])
claims.setdefault("commands", ["*"])
claims.setdefault("exp", int(time.time()) + 3600)
return make_jwt(claims)
def use_jwt_auth(monkeypatch, claims:dict) -> str:
monkeypatch.setattr(authmod, "JWT_SECRET", "test-jwt-secret")
return jwt(claims)
def test_health():
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert "vault" in body
assert body["cli"] == "python"
assert body["jwt"] is True
def test_command_requires_auth():
response = client.post("/commands", json={"args": ["--version"]})
assert response.status_code == 401
def test_command_runs_configured_cli_only():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"args": ["--version"], "timeout": 5},
)
assert response.status_code == 200
body = response.json()
assert body["exit_code"] == 0
assert body["command"] == ["python", "--version"]
assert "Python" in body["stdout"]
assert body["timed_out"] is False
def test_plain_command_string_is_rejected():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"command": "--version"},
)
assert response.status_code == 400
assert response.json()["detail"] == "command strings are not allowed; use args as a list of strings"
def test_shell_mode_is_rejected():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"mode": "shell", "args": ["echo nope"]},
)
assert response.status_code == 400
assert response.json()["detail"] == "mode is not allowed; only the configured Obsidian CLI can be executed"
def test_command_timeout():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"args": ["-c", "import time; time.sleep(2)"], "timeout": 0.1},
)
assert response.status_code == 200
body = response.json()
assert body["exit_code"] == 124
assert body["timed_out"] is True
def test_jwt_claims_become_policy():
token = jwt({"sub": "file", "vaults": ["work"], "paths": ["Inbox/"], "commands": ["read"]})
claims = authmod.decode_hs256_jwt(token, b"test-jwt-secret")
policy = authmod.policy_from_jwt_claims(token, claims)
assert policy.token == token
assert policy.name == "file"
assert policy.vaults == ("work",)
assert policy.paths == ("Inbox/",)
assert policy.commands == ("read",)
def test_token_policy_restricts_command(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "limited", "commands": ["read"], "vaults": ["*"], "paths": ["*"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["create", "path=Inbox/Nope.md", "content=Nope"]},
)
assert response.status_code == 403
assert response.json()["detail"] == "token is not allowed to run command: create"
def test_token_policy_restricts_vault(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "work", "commands": ["*"], "vaults": ["work"], "paths": ["*"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=personal", "read", "path=Inbox/Test.md"]},
)
assert response.status_code == 403
assert response.json()["detail"] == "token is not allowed to access vault: personal"
def test_token_policy_restricts_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["read", "path=Private/Secret.md"]},
)
assert response.status_code == 403
assert response.json()["detail"] == "token is not allowed to access path: Private/Secret.md"
def test_token_policy_allows_allowed_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["-c", "print('ok')"]},
)
assert response.status_code == 403
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["read", "path=Inbox/Allowed.md"]},
)
# The authorization layer allows this. The fake Python CLI then fails because
# "read" is not a Python flag, which is fine for this policy test.
assert response.status_code == 200
assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"]
Generated
+357
View File
@@ -0,0 +1,357 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "anyio"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "click"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "obsidian-api"
version = "1.0.0"
source = { virtual = "." }
dependencies = [
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "starlette", specifier = "==0.50.0" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.38.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "pytest", specifier = "==9.0.2" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
name = "uvicorn"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]