Merge pull request #311 from hito0512/feat/add-siyuan-cli

feat: add SiYuan CLI (cli-anything-siyuan)
This commit is contained in:
Yuhao
2026-06-11 18:19:41 +08:00
committed by GitHub
20 changed files with 3152 additions and 0 deletions
+4
View File
@@ -100,6 +100,7 @@
!/3MF/
!/calibre/
!/rekordbox/
!/siyuan/
# Step 5: Inside each software dir, ignore everything (including dotfiles)
/gimp/*
/gimp/.*
@@ -207,6 +208,8 @@
/calibre/.*
/rekordbox/*
/rekordbox/.*
/siyuan/*
/siyuan/.*
# Step 6: ...except agent-harness/
!/gimp/agent-harness/
@@ -272,6 +275,7 @@
!/quietshrink/agent-harness/
!/mailchimp/agent-harness/
!/rekordbox/agent-harness/
!/siyuan/agent-harness/
# Exclude non-gedit demo macros from macrocli (local only)
/macrocli/agent-harness/cli_anything/macrocli/macro_definitions/demo/flameshot*
+20
View File
@@ -1303,6 +1303,26 @@
"url": "https://github.com/octo-patch"
}
]
},
{
"name": "siyuan",
"display_name": "SiYuan CLI",
"version": "1.0.0",
"description": "Knowledge management and note-taking via SiYuan HTTP API — manage notebooks, documents, blocks, and search content from the command line",
"category": "knowledge-management",
"requires": "SiYuan running with HTTP API enabled (default http://127.0.0.1:6806)",
"homepage": "https://github.com/siyuan-note/siyuan",
"source_url": null,
"install_strategy": "pip",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=siyuan/agent-harness",
"skill_md": "skills/cli-anything-siyuan/SKILL.md",
"entry_point": "cli-anything-siyuan",
"contributors": [
{
"name": "hito0512",
"url": "https://github.com/hito0512"
}
]
}
]
}
+159
View File
@@ -0,0 +1,159 @@
# SiYuan (思源笔记) — Agent CLI Harness Analysis
## Overview
SiYuan is a local-first, privacy-focused knowledge management and note-taking application.
It uses a Go backend with an Electron/TypeScript frontend. Data is stored as `.sy` files
(Siyuan JSON format) with a SQLite database for indexing and search.
## Backend
- **Language**: Go
- **HTTP Server**: Gin framework, runs on `http://127.0.0.1:6806`
- **Auth**: Token-based (`Authorization: Token xxx`)
- **Entry point**: `kernel/main.go` → starts HTTP server + background jobs
## Data Model
```
Notebook (Box)
└── Document (tree node, .sy file)
└── Blocks (paragraphs, headings, lists, code, etc.)
└── Attributes (key-value, custom-* prefix)
```
- **Notebook**: Top-level container, identified by ID (e.g., `20210817205410-2kvfpfn`)
- **Document**: Tree node in a notebook, stored as `.sy` file
- **Block**: Content unit (paragraph, heading, list item, etc.), each has unique ID
- **SQLite Database**: blocks, refs, attributes, history, asset_content tables
- **IDs**: Timestamp-based IDs like `20210817205410-2kvfpfn` (datetime + random suffix)
## API Surface
Base URL: `http://127.0.0.1:6806`
### Notebook API (`/api/notebook/*`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `lsNotebooks` | POST | List all notebooks |
| `openNotebook` | POST | Open a notebook |
| `closeNotebook` | POST | Close a notebook |
| `createNotebook` | POST | Create new notebook |
| `removeNotebook` | POST | Delete notebook |
| `renameNotebook` | POST | Rename notebook |
| `getNotebookConf` | POST | Get notebook config |
| `setNotebookConf` | POST | Set notebook config |
| `setNotebookIcon` | POST | Set notebook icon |
### Document/Filetree API (`/api/filetree/*`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `createDocWithMd` | POST | Create doc with Markdown content |
| `createDailyNote` | POST | Create daily note |
| `renameDoc` | POST | Rename doc by path |
| `renameDocByID` | POST | Rename doc by ID |
| `removeDoc` | POST | Delete doc by path |
| `removeDocByID` | POST | Delete doc by ID |
| `moveDocs` | POST | Move docs by paths |
| `moveDocsByID` | POST | Move docs by IDs |
| `getHPathByID` | POST | Get human-readable path by ID |
| `getHPathByPath` | POST | Get human-readable path by path |
| `getIDsByHPath` | POST | Get IDs by human-readable path |
| `getPathByID` | POST | Get storage path by ID |
| `listDocsByPath` | POST | List docs at a path |
| `listDocTree` | POST | List full document tree |
| `searchDocs` | POST | Search document names |
### Block API (`/api/block/*`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `insertBlock` | POST | Insert block at position |
| `prependBlock` | POST | Insert as first child |
| `appendBlock` | POST | Insert as last child |
| `updateBlock` | POST | Update block content |
| `deleteBlock` | POST | Delete a block |
| `moveBlock` | POST | Move a block |
| `foldBlock` | POST | Fold/collapse a block |
| `unfoldBlock` | POST | Unfold/expand a block |
| `getBlockKramdown` | POST | Get block kramdown source |
| `getChildBlocks` | POST | Get child blocks |
### Attribute API (`/api/attr/*`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `setBlockAttrs` | POST | Set block attributes |
| `getBlockAttrs` | POST | Get block attributes |
### SQL Query API (`/api/query/sql`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `sql` | POST | Execute SQL query on block database |
### Search API (`/api/search/*`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `fullTextSearchBlock` | POST | Full-text search in blocks |
| `searchTag` | POST | Search tags |
| `findReplace` | POST | Find and replace |
### Export API (`/api/export/*`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `exportMdContent` | POST | Export doc as Markdown |
| `exportResources` | POST | Export files as ZIP |
### Asset API (`/api/asset/*`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `upload` | POST | Upload asset file |
### Tag API (`/api/tag/*`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `getTag` | POST | List tags |
| `renameTag` | POST | Rename tag |
| `removeTag` | POST | Remove tag |
### System API (`/api/system/*`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `version` | GET/POST | Get system version |
| `currentTime` | POST | Get current server time |
## Existing CLI Tools
None. SiYuan ships with only the desktop GUI (Electron) and mobile apps.
The kernel starts automatically with the GUI; there is no standalone CLI mode.
## CLI Harness Strategy
Since SiYuan has no headless CLI backend, the harness will:
1. **Connect to an already-running SiYuan instance** via its HTTP API
2. **Provide commands for all major API groups** (notebooks, documents, blocks, search, export)
3. **Store connection state** (host, port, token) for multi-session use
4. **Support SQL queries** for advanced data access
5. **Support import/export** of Markdown content
The SiYuan instance must be running with the API server enabled (default: port 6806).
The API token can be found in Settings → About.
## CLI Usage Notes
### Block insert/update with multi-line content
`block insert` and `block update` accept data via stdin pipe when the argument is `-` or omitted:
```bash
# Pipe multi-line content
cat note.md | cli-anything-siyuan block insert --parent <block-id>
# Explicit stdin marker
echo "hello" | cli-anything-siyuan block update <block-id> -
# Requires one of: --parent <id>, --previous <id>, --next <id>
```
### Tag list
`tag list` sends `ignoreMaxListHint: true` to ensure all tags are returned regardless of `Conf.FileTree.MaxListCount`. Tag-heavy workspaces return the complete list.
@@ -0,0 +1,143 @@
# cli-anything-siyuan
A CLI harness for [SiYuan](https://github.com/siyuan-note/siyuan) (思源笔记) —
interact with your knowledge base from the terminal.
## Prerequisites
- **SiYuan** must be running (version 3.x). Download from [b3log.org/siyuan](https://b3log.org/siyuan).
- **Python 3.10+**
## Installation
```bash
cd agent-harness
pip install -e .
```
For REPL support (recommended):
```bash
pip install -e ".[repl]"
```
## Configuration
Create `~/.siyuan-cli.json`:
```json
{
"host": "127.0.0.1",
"port": 6806,
"token": "your-api-token-here"
}
```
Or use environment variables:
```bash
export SIYUAN_HOST=127.0.0.1
export SIYUAN_PORT=6806
export SIYUAN_TOKEN=your-token
```
**Finding your API token:** Open SiYuan → Settings → About → API Token.
## Usage
### One-shot commands
```bash
# List notebooks
cli-anything-siyuan notebook list
# List notebooks (JSON output)
cli-anything-siyuan --json notebook list
# Get version
cli-anything-siyuan version
# Execute SQL query
cli-anything-siyuan sql "SELECT * FROM blocks LIMIT 5"
# Search blocks
cli-anything-siyuan search "keyword"
# Export document as Markdown
cli-anything-siyuan export md <doc-id>
# Show status
cli-anything-siyuan status
# Insert a block (multi-line content via stdin)
cat note.md | cli-anything-siyuan block insert --parent <block-id>
# Update a block content via stdin
echo "new content" | cli-anything-siyuan block update <block-id>
```
### REPL mode
```bash
# Just run without arguments to enter REPL
cli-anything-siyuan
```
Inside the REPL:
```
◆ cli-anything · Siyuan v1.0.0
◇ Install: npx skills add HKUDS/CLI-Anything --skill cli-anything-siyuan -g -y
◇ Global skill: ~/.agents/skills/cli-anything-siyuan/SKILL.md
Type help for commands, quit to exit
siyuan ❯ notebook list
siyuan ❯ doc tree <notebook-id>
siyuan ❯ search "meeting notes"
siyuan ❯ help
siyuan ❯ quit
```
## Command Groups
| Command | Description |
|---------|-------------|
| `notebook list` | List all notebooks |
| `notebook create <name>` | Create a notebook |
| `notebook rename <id> <name>` | Rename a notebook |
| `notebook remove <id>` | Delete a notebook |
| `notebook open <id>` | Open a notebook |
| `doc create <notebook-id> <path>` | Create a document |
| `doc list <notebook-id> [path]` | List documents |
| `doc tree <notebook-id>` | Show document tree |
| `doc get <id>` | Get document path by ID |
| `doc rename <id> <title>` | Rename a document |
| `doc remove <id>` | Delete a document |
| `block insert <data>` | Insert a block (use `-` or omit for stdin pipe) |
| `block update <id> <data>` | Update block content (use `-` or omit for stdin pipe) |
| `block delete <id>` | Delete a block |
| `block get <id>` | Get block kramdown source |
| `block children <id>` | Get child blocks |
| `sql <stmt>` | Execute SQL query |
| `search <query>` | Full-text search |
| `export md <doc-id>` | Export as Markdown |
| `tag list` | List all tags |
| `version` | Show SiYuan version |
| `status` | Show connection status |
## Running Tests
```bash
# Unit tests (no external dependencies)
cd agent-harness
pip install -e ".[test]"
python -m pytest cli_anything/siyuan/tests/test_core.py -v
# E2E tests (requires running SiYuan)
python -m pytest cli_anything/siyuan/tests/test_full_e2e.py -v -s
```
## License
AGPL-3.0 (same as SiYuan)
@@ -0,0 +1 @@
# cli-anything-siyuan: SiYuan (思源笔记) CLI harness
@@ -0,0 +1,5 @@
"""Allow running as: python -m cli_anything.siyuan"""
from cli_anything.siyuan.siyuan_cli import cli
if __name__ == "__main__":
cli()
@@ -0,0 +1,307 @@
"""SiYuan HTTP API client.
Handles connection, authentication, and request/response to the SiYuan kernel.
"""
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import requests
@dataclass(frozen=True)
class SiYuanConfig:
host: str = "127.0.0.1"
port: int = 6806
token: str = ""
@property
def base_url(self) -> str:
return f"http://{self.host}:{self.port}"
def load_config(config_path: str | None = None) -> SiYuanConfig:
"""Load SiYuan connection config from file or environment.
Priority: explicit path -> env vars -> defaults.
Config file is JSON: {"host": "...", "port": 6806, "token": "..."}
"""
if config_path:
config_file = Path(config_path)
else:
config_file = Path.home() / ".siyuan-cli.json"
if config_file.is_file():
try:
data = json.loads(config_file.read_text(encoding="utf-8-sig"))
except (json.JSONDecodeError, OSError):
data = None
if data is not None:
# File values as base, env vars override
return SiYuanConfig(
host=os.environ.get("SIYUAN_HOST", data.get("host", "127.0.0.1")),
port=int(os.environ.get("SIYUAN_PORT", data.get("port", 6806))),
token=os.environ.get("SIYUAN_TOKEN", data.get("token", "")),
)
return SiYuanConfig(
host=os.environ.get("SIYUAN_HOST", "127.0.0.1"),
port=int(os.environ.get("SIYUAN_PORT", "6806")),
token=os.environ.get("SIYUAN_TOKEN", ""),
)
class SiYuanClientError(Exception):
"""Raised when the SiYuan API returns an error."""
class SiYuanClient:
"""HTTP client for the SiYuan kernel API."""
def __init__(self, config: SiYuanConfig | None = None):
self.config = config or load_config()
self._session = requests.Session()
self._session.trust_env = False
if self.config.token:
self._session.headers["Authorization"] = f"Token {self.config.token}"
self._session.headers["Content-Type"] = "application/json"
def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> Any:
"""Make a POST request to the SiYuan API."""
url = f"{self.config.base_url}{endpoint}"
try:
resp = self._session.post(url, json=data or {}, timeout=30)
except requests.ConnectionError as e:
raise SiYuanClientError(
f"Cannot connect to SiYuan at {self.config.base_url}. "
f"Is SiYuan running? ({e})"
) from e
except requests.Timeout as e:
raise SiYuanClientError(
f"Request to SiYuan timed out after 30s ({e})"
) from e
except requests.RequestException as e:
raise SiYuanClientError(
f"Request to SiYuan failed: {e}"
) from e
if resp.status_code != 200:
raise SiYuanClientError(
f"API returned status {resp.status_code}: {resp.text[:200]}"
)
body = resp.json()
if body.get("code", 0) != 0:
raise SiYuanClientError(
f"API error: {body.get('msg', 'unknown error')}"
)
return body.get("data")
def ping(self) -> bool:
"""Check if SiYuan kernel is reachable."""
try:
return self._post("/api/system/version") is not None
except SiYuanClientError:
return False
# ── Notebook API ───────────────────────────────────────────────────
def list_notebooks(self) -> list[dict[str, Any]]:
"""List all notebooks."""
data = self._post("/api/notebook/lsNotebooks")
return data.get("notebooks", [])
def open_notebook(self, notebook_id: str) -> None:
self._post("/api/notebook/openNotebook", {"notebook": notebook_id})
def close_notebook(self, notebook_id: str) -> None:
self._post("/api/notebook/closeNotebook", {"notebook": notebook_id})
def create_notebook(self, name: str) -> dict[str, Any]:
data = self._post("/api/notebook/createNotebook", {"name": name})
if isinstance(data, dict) and "notebook" in data:
return data["notebook"]
return data
def remove_notebook(self, notebook_id: str) -> None:
self._post("/api/notebook/removeNotebook", {"notebook": notebook_id})
def rename_notebook(self, notebook_id: str, name: str) -> None:
self._post("/api/notebook/renameNotebook", {"notebook": notebook_id, "name": name})
def get_notebook_conf(self, notebook_id: str) -> dict[str, Any]:
return self._post("/api/notebook/getNotebookConf", {"notebook": notebook_id})
def set_notebook_conf(self, notebook_id: str, conf: dict[str, Any]) -> dict[str, Any]:
return self._post("/api/notebook/setNotebookConf", {"notebook": notebook_id, "conf": conf})
def set_notebook_icon(self, notebook_id: str, icon: str) -> None:
self._post("/api/notebook/setNotebookIcon", {"notebook": notebook_id, "icon": icon})
def get_notebook_info(self, notebook_id: str) -> dict[str, Any]:
return self._post("/api/notebook/getNotebookInfo", {"notebook": notebook_id})
# ── Document / Filetree API ────────────────────────────────────────
def create_doc_with_md(self, notebook_id: str, path: str, markdown: str = "") -> str:
"""Create a document with Markdown content. Returns the doc ID."""
return self._post("/api/filetree/createDocWithMd", {
"notebook": notebook_id, "path": path, "markdown": markdown,
})
def rename_doc(self, notebook_id: str, path: str, title: str) -> None:
self._post("/api/filetree/renameDoc", {"notebook": notebook_id, "path": path, "title": title})
def rename_doc_by_id(self, doc_id: str, title: str) -> None:
self._post("/api/filetree/renameDocByID", {"id": doc_id, "title": title})
def remove_doc(self, notebook_id: str, path: str) -> None:
self._post("/api/filetree/removeDoc", {"notebook": notebook_id, "path": path})
def remove_doc_by_id(self, doc_id: str) -> None:
self._post("/api/filetree/removeDocByID", {"id": doc_id})
def move_docs(self, from_paths: list[str], to_notebook: str, to_path: str) -> None:
self._post("/api/filetree/moveDocs", {
"fromPaths": from_paths, "toNotebook": to_notebook, "toPath": to_path,
})
def move_docs_by_id(self, from_ids: list[str], to_id: str) -> None:
self._post("/api/filetree/moveDocsByID", {"fromIDs": from_ids, "toID": to_id})
def get_hpath_by_id(self, block_id: str) -> str:
return self._post("/api/filetree/getHPathByID", {"id": block_id})
def get_path_by_id(self, block_id: str) -> dict[str, str]:
return self._post("/api/filetree/getPathByID", {"id": block_id})
def get_ids_by_hpath(self, notebook_id: str, path: str) -> list[str]:
return self._post("/api/filetree/getIDsByHPath", {"notebook": notebook_id, "path": path})
def list_docs_by_path(self, notebook_id: str, path: str) -> list[dict[str, Any]]:
return self._post("/api/filetree/listDocsByPath", {
"notebook": notebook_id, "path": path, "maxListCount": 0,
})
def list_doc_tree(self, notebook_id: str, path: str = "/", max_depth: int = -1, sort: int = 0) -> list[dict[str, Any]]:
return self._post("/api/filetree/listDocTree", {
"notebook": notebook_id, "path": path, "maxDepth": max_depth, "sort": sort,
})
def search_docs(self, keyword: str) -> list[dict[str, Any]]:
return self._post("/api/filetree/searchDocs", {"k": keyword})
def create_daily_note(self, notebook_id: str) -> dict[str, Any]:
return self._post("/api/filetree/createDailyNote", {"notebook": notebook_id})
# ── Block API ──────────────────────────────────────────────────────
def insert_block(self, data_type: str, data: str,
parent_id: str = "", previous_id: str = "", next_id: str = "") -> list[dict[str, Any]]:
params = {"dataType": data_type, "data": data}
if parent_id:
params["parentID"] = parent_id
if previous_id:
params["previousID"] = previous_id
if next_id:
params["nextID"] = next_id
return self._post("/api/block/insertBlock", params)
def prepend_block(self, data_type: str, data: str, parent_id: str) -> list[dict[str, Any]]:
return self._post("/api/block/prependBlock", {
"dataType": data_type, "data": data, "parentID": parent_id,
})
def append_block(self, data_type: str, data: str, parent_id: str) -> list[dict[str, Any]]:
return self._post("/api/block/appendBlock", {
"dataType": data_type, "data": data, "parentID": parent_id,
})
def update_block(self, data_type: str, data: str, block_id: str) -> list[dict[str, Any]]:
return self._post("/api/block/updateBlock", {
"dataType": data_type, "data": data, "id": block_id,
})
def delete_block(self, block_id: str) -> list[dict[str, Any]]:
return self._post("/api/block/deleteBlock", {"id": block_id})
def move_block(self, block_id: str, previous_id: str = "", parent_id: str = "") -> list[dict[str, Any]]:
params = {"id": block_id}
if previous_id:
params["previousID"] = previous_id
if parent_id:
params["parentID"] = parent_id
return self._post("/api/block/moveBlock", params)
def fold_block(self, block_id: str) -> None:
self._post("/api/block/foldBlock", {"id": block_id})
def unfold_block(self, block_id: str) -> None:
self._post("/api/block/unfoldBlock", {"id": block_id})
def get_block_kramdown(self, block_id: str) -> str:
data = self._post("/api/block/getBlockKramdown", {"id": block_id})
return data.get("kramdown", "")
def get_child_blocks(self, block_id: str) -> list[dict[str, Any]]:
return self._post("/api/block/getChildBlocks", {"id": block_id})
# ── Attribute API ──────────────────────────────────────────────────
def set_block_attrs(self, block_id: str, attrs: dict[str, str]) -> None:
self._post("/api/attr/setBlockAttrs", {"id": block_id, "attrs": attrs})
def get_block_attrs(self, block_id: str) -> dict[str, str]:
return self._post("/api/attr/getBlockAttrs", {"id": block_id})
# ── SQL Query API ──────────────────────────────────────────────────
def query_sql(self, stmt: str) -> list[dict[str, Any]]:
return self._post("/api/query/sql", {"stmt": stmt})
# ── Search API ─────────────────────────────────────────────────────
def search_blocks(self, query: str) -> list[dict[str, Any]]:
return self._post("/api/search/fullTextSearchBlock", {"query": query})
def search_tag(self, tag: str = "") -> list[str]:
"""Search tags. Returns list of tag name strings."""
data = self._post("/api/search/searchTag", {"k": tag})
if isinstance(data, dict):
return data.get("tags", [])
return data or []
def find_replace(self, keyword: str, replacement: str, ids: list[str]) -> None:
"""Search and replace text in blocks by ID."""
self._post("/api/search/findReplace", {
"k": keyword, "r": replacement, "ids": ids,
})
# ── Export API ─────────────────────────────────────────────────────
def export_md_content(self, doc_id: str) -> dict[str, str]:
return self._post("/api/export/exportMdContent", {"id": doc_id})
def export_resources(self, paths: list[str], name: str = "") -> str:
params = {"paths": paths}
if name:
params["name"] = name
data = self._post("/api/export/exportResources", params)
return data.get("path", "")
# ── Tag API ────────────────────────────────────────────────────────
def get_tags(self) -> list[dict[str, Any]]:
return self._post("/api/tag/getTag", {"ignoreMaxListHint": True})
# ── System API ─────────────────────────────────────────────────────
def get_version(self) -> str:
return self._post("/api/system/version")
def get_current_time(self) -> int:
return self._post("/api/system/currentTime")
@@ -0,0 +1,75 @@
"""Session state management for the SiYuan CLI REPL."""
import json
import os
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any
@dataclass
class SessionState:
"""Mutable session state — represents current notebook/doc context."""
current_notebook_id: str = ""
current_notebook_name: str = ""
current_doc_id: str = ""
current_doc_path: str = ""
connected: bool = False
history: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SessionState":
return cls(
current_notebook_id=data.get("current_notebook_id", ""),
current_notebook_name=data.get("current_notebook_name", ""),
current_doc_id=data.get("current_doc_id", ""),
current_doc_path=data.get("current_doc_path", ""),
connected=data.get("connected", False),
history=data.get("history", []),
)
class SessionManager:
"""Manages session persistence for the REPL."""
def __init__(self, state_dir: str | None = None):
if state_dir is None:
state_dir = str(Path.home() / ".cli-anything-siyuan")
self.state_dir = Path(state_dir)
self.state_dir.mkdir(parents=True, exist_ok=True)
self.session_path = self.state_dir / "session.json"
self.state = SessionState()
self._dirty = False
def load(self) -> SessionState:
"""Load session state from disk."""
if self.session_path.is_file():
try:
data = json.loads(self.session_path.read_text(encoding="utf-8"))
self.state = SessionState.from_dict(data)
except (json.JSONDecodeError, KeyError):
self.state = SessionState()
return self.state
def save(self) -> None:
"""Save current session state to disk."""
self.session_path.write_text(
json.dumps(self.state.to_dict(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
def update(self, **kwargs: Any) -> None:
"""Update session state fields and mark dirty."""
for key, value in kwargs.items():
if hasattr(self.state, key):
setattr(self.state, key, value)
self._dirty = True
def flush(self) -> None:
"""Write to disk if dirty."""
if self._dirty:
self.save()
self._dirty = False
@@ -0,0 +1,802 @@
"""cli-anything-siyuan — SiYuan (思源笔记) CLI harness.
Connects to a running SiYuan kernel via its HTTP API and provides
commands for notebooks, documents, blocks, search, and export.
"""
import json
import os
import shlex
import sys
from typing import Any
import click
from cli_anything.siyuan.core.client import (
SiYuanClient,
SiYuanClientError,
SiYuanConfig,
load_config,
)
from cli_anything.siyuan.core.session import SessionManager
def _read_stdin() -> str:
"""Read stdin as raw bytes and decode as UTF-8 (with BOM handling).
PowerShell pipes text using the console code page (e.g. GBK on Chinese
Windows), which mangles CJK characters before Python sees them. Reading
raw bytes then decoding as UTF-8-sig is the most robust approach.
"""
if sys.stdin.isatty():
raise click.UsageError(
"stdin pipe expected (e.g. echo 'content' | sy block insert --parent pid -)"
)
raw = sys.stdin.buffer.read()
try:
return raw.decode('utf-8-sig')
except UnicodeDecodeError:
return raw.decode('utf-8-sig', errors='replace')
# ── Click context ─────────────────────────────────────────────────────
class SiYuanContext:
def __init__(self, client: SiYuanClient | None = None,
session: SessionManager | None = None,
json_output: bool = False):
self.json_output = json_output
self.client = client
self.session = session or SessionManager()
class _CatchErrors(click.Group):
"""Click Group that converts SiYuanClientError to clean CLI errors."""
def invoke(self, ctx: click.Context) -> object:
try:
return super().invoke(ctx)
except SiYuanClientError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
@click.group(cls=_CatchErrors, invoke_without_command=True)
@click.option("--json", "json_output", is_flag=True, help="Output in JSON format")
@click.option("--host", default="", help="SiYuan host (default: 127.0.0.1)")
@click.option("--port", default=0, type=int, help="SiYuan port (default: 6806)")
@click.option("--token", default="", help="SiYuan API token")
@click.option("--config", "config_path", default="", help="Config file path")
@click.pass_context
def cli(ctx: click.Context, json_output: bool, host: str, port: int,
token: str, config_path: str):
# Force UTF-8 output to handle CJK characters on Windows
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, 'reconfigure'):
stream.reconfigure(encoding='utf-8')
"""CLI for SiYuan (思源笔记) — interact with your knowledge base.
Connects to a running SiYuan instance via its HTTP API.
Default: http://127.0.0.1:6806
Configure connection via ~/.siyuan-cli.json, env vars
(SIYUAN_HOST, SIYUAN_PORT, SIYUAN_TOKEN), or CLI flags.
"""
session = SessionManager()
session.load()
cfg = load_config(config_path or None)
if host or port or token:
cfg = SiYuanConfig(
host=host or cfg.host,
port=port or cfg.port,
token=token or cfg.token,
)
client = SiYuanClient(cfg)
ctx.obj = SiYuanContext(client=client, session=session, json_output=json_output)
if ctx.invoked_subcommand is None:
if not client.ping():
click.echo("Error: Cannot connect to SiYuan. Is it running?", err=True)
click.echo(" Configure via: --host --port --token", err=True)
click.echo(" Or set: SIYUAN_HOST, SIYUAN_PORT, SIYUAN_TOKEN", err=True)
sys.exit(1)
ctx.invoke(repl)
def _walk_tree(items: list[dict], depth: int = 0) -> list[dict]:
"""Recursively flatten a doc tree, setting depth on each entry.
Returns new dicts; does not mutate the originals.
"""
result: list[dict] = []
for t in items:
entry = {**t, "depth": depth}
result.append(entry)
children = t.get("children")
if children:
result.extend(_walk_tree(children, depth + 1))
return result
# ── REPL ───────────────────────────────────────────────────────────────
def _build_repl_commands() -> dict[str, str]:
return {
"notebook list": "List all notebooks",
"notebook create <name>": "Create a notebook",
"notebook rename <id> <name>": "Rename a notebook",
"notebook remove <id>": "Remove a notebook",
"doc create <notebook> <path>": "Create a document with optional --md content",
"doc list <notebook> <path>": "List documents at path",
"doc tree <notebook>": "List full document tree",
"doc get <id>": "Get document info by ID",
"block insert <parent> <data>": "Insert a block (use '-' for stdin)",
"block update <id> <data>": "Update a block (use '-' for stdin)",
"block delete <id>": "Delete a block",
"block get <id>": "Get block kramdown source",
"sql <stmt>": "Execute SQL query",
"search <query>": "Full-text search",
"export md <doc-id>": "Export doc as Markdown",
"status": "Show connection and session status",
"help": "Show this help",
"quit": "Exit REPL",
}
@cli.command()
@click.pass_context
def repl(ctx: click.Context):
"""Start interactive REPL mode."""
from cli_anything.siyuan.utils.repl_skin import ReplSkin
obj: SiYuanContext = ctx.obj
try:
kernel_version = obj.client.get_version()
except SiYuanClientError:
kernel_version = "?"
skin = ReplSkin("siyuan", version=kernel_version)
skin.print_banner()
pt_session = skin.create_prompt_session()
commands = _build_repl_commands()
state = obj.session.state
while True:
ctx_str = ""
if state.current_notebook_name:
ctx_str = state.current_notebook_name
user_input = skin.get_input(
pt_session,
project_name=state.current_doc_path or "",
modified=False,
context=ctx_str,
)
if not user_input:
continue
if user_input in ("quit", "exit", "q"):
obj.session.flush()
break
if user_input == "help":
skin.help(commands)
continue
if user_input == "status":
connected = obj.client.ping()
skin.status_block({
"Connected": str(connected),
"Notebook": state.current_notebook_name or "(none)",
"Document": state.current_doc_path or "(none)",
}, title="Status")
continue
try:
_dispatch_repl(skin, obj, user_input)
except SiYuanClientError as e:
skin.error(str(e))
except Exception as e:
skin.error(f"Error: {e}")
skin.print_goodbye()
def _dispatch_repl(skin: Any, ctx: SiYuanContext, cmd: str) -> None:
"""Parse REPL command and route to the appropriate handler."""
parts = shlex.split(cmd.strip())
if not parts:
return
json_mode = "--json" in parts
parts = [p for p in parts if p != "--json"]
client = ctx.client
session = ctx.session
command = parts[0]
if command == "notebook":
_handle_notebook_repl(skin, client, session, parts, json_mode)
elif command == "doc":
_handle_doc_repl(skin, client, session, parts, json_mode)
elif command == "block":
_handle_block_repl(skin, client, parts, json_mode)
elif command == "sql" and len(parts) >= 2:
_handle_sql_repl(skin, client, parts, json_mode)
elif command == "search" and len(parts) >= 2:
_handle_search_repl(skin, client, parts, json_mode)
elif command == "export":
_handle_export_repl(skin, client, parts, json_mode)
else:
skin.error(f"Unknown command: {parts[0]}")
# ── REPL sub-handlers ──────────────────────────────────────────────────
def _handle_notebook_repl(skin: Any, client: SiYuanClient,
session: SessionManager, parts: list[str],
json_mode: bool) -> None:
if len(parts) < 2:
skin.error("Usage: notebook <list|create|rename|remove>")
return
sub = parts[1]
if sub == "list":
notebooks = client.list_notebooks()
if json_mode:
click.echo(json.dumps(notebooks, ensure_ascii=False))
else:
skin.table(["ID", "Name", "Icon", "Closed"],
[[n["id"], n["name"], n.get("icon", ""), str(n.get("closed", ""))]
for n in notebooks])
elif sub == "create" and len(parts) >= 3:
name = " ".join(parts[2:])
nb = client.create_notebook(name)
session.update(current_notebook_id=nb["id"], current_notebook_name=nb["name"])
client.open_notebook(nb["id"])
skin.success(f'Created notebook: {nb["name"]} ({nb["id"]})')
elif sub == "rename" and len(parts) >= 4:
client.rename_notebook(parts[2], " ".join(parts[3:]))
skin.success("Renamed")
elif sub == "remove" and len(parts) >= 3:
client.remove_notebook(parts[2])
skin.success("Removed")
else:
skin.error("Invalid notebook command")
def _handle_doc_repl(skin: Any, client: SiYuanClient,
session: SessionManager, parts: list[str],
json_mode: bool) -> None:
if len(parts) < 2:
skin.error("Usage: doc <create|list|tree|get|rename|remove|export>")
return
sub = parts[1]
if sub == "create" and len(parts) >= 4:
md = ""
if "--md" in parts:
idx = parts.index("--md") + 1
if idx < len(parts):
md = parts[idx]
parts = parts[:parts.index("--md")]
if md == "-":
md = _read_stdin()
nb_id = parts[2]
doc_path = parts[3]
doc_id = client.create_doc_with_md(nb_id, doc_path, md)
session.update(current_doc_id=doc_id, current_doc_path=doc_path)
if json_mode:
click.echo(json.dumps({"id": doc_id}, ensure_ascii=False))
else:
skin.success(f"Created doc: {doc_id}")
elif sub == "list" and len(parts) >= 4:
docs = client.list_docs_by_path(parts[2], parts[3])
items = docs.get("files", []) if isinstance(docs, dict) else docs
if json_mode:
click.echo(json.dumps(items, ensure_ascii=False))
else:
skin.table(["ID", "Name", "Type"],
[[d.get("id", ""), d.get("name", ""), d.get("type", "")]
for d in items])
elif sub == "tree" and len(parts) >= 3:
tree = client.list_doc_tree(parts[2])
if isinstance(tree, dict):
items = tree.get("files") or tree.get("tree") or []
else:
items = tree
if json_mode:
click.echo(json.dumps(items, ensure_ascii=False))
else:
skin.table(["ID", "Name", "Path"],
[[t.get("id", ""), " " * t.get("depth", 0) + t.get("name", ""), t.get("path", "")]
for t in _walk_tree(items)])
elif sub == "get" and len(parts) >= 3:
hpath = client.get_hpath_by_id(parts[2])
if json_mode:
click.echo(json.dumps({"hpath": hpath}, ensure_ascii=False))
else:
skin.success(f"Path: {hpath}")
elif sub == "rename" and len(parts) >= 4:
client.rename_doc_by_id(parts[2], " ".join(parts[3:]))
skin.success("Renamed")
elif sub == "remove" and len(parts) >= 3:
client.remove_doc_by_id(parts[2])
skin.success("Removed")
elif sub == "export" and len(parts) >= 3:
md = client.export_md_content(parts[2])
if json_mode:
click.echo(json.dumps(md, ensure_ascii=False))
else:
skin.section(md.get("hPath", ""))
click.echo(md.get("content", ""))
else:
skin.error("Invalid doc command")
def _handle_block_repl(skin: Any, client: SiYuanClient,
parts: list[str], json_mode: bool) -> None:
if len(parts) < 2:
skin.error("Usage: block <insert|prepend|append|update|delete|get|child>")
return
sub = parts[1]
if sub == "insert":
if len(parts) < 4:
skin.error("Usage: block insert <parent_id> <data>")
return
data = parts[3]
if data == "-":
data = _read_stdin()
result = client.insert_block("markdown", data, parent_id=parts[2])
if json_mode:
click.echo(json.dumps(result, ensure_ascii=False))
else:
skin.success("Block inserted")
elif sub == "prepend" and len(parts) >= 4:
data = parts[3]
if data == "-":
data = _read_stdin()
client.prepend_block("markdown", data, parts[2])
skin.success("Block prepended")
elif sub == "append" and len(parts) >= 4:
data = parts[3]
if data == "-":
data = _read_stdin()
client.append_block("markdown", data, parts[2])
skin.success("Block appended")
elif sub == "update" and len(parts) >= 4:
data = parts[3]
if data == "-":
data = _read_stdin()
client.update_block("markdown", data, parts[2])
skin.success("Block updated")
elif sub == "delete" and len(parts) >= 3:
client.delete_block(parts[2])
skin.success("Block deleted")
elif sub == "get" and len(parts) >= 3:
kramdown = client.get_block_kramdown(parts[2])
if json_mode:
click.echo(json.dumps({"kramdown": kramdown}, ensure_ascii=False))
else:
click.echo(kramdown)
elif sub == "child" and len(parts) >= 3:
children = client.get_child_blocks(parts[2])
if json_mode:
click.echo(json.dumps(children, ensure_ascii=False))
else:
skin.table(["ID", "Type", "SubType"],
[[c.get("id", ""), c.get("type", ""), c.get("subType", "")]
for c in children])
else:
skin.error("Invalid block command")
def _handle_sql_repl(skin: Any, client: SiYuanClient,
parts: list[str], json_mode: bool) -> None:
stmt = " ".join(parts[1:])
results = client.query_sql(stmt)
if json_mode:
click.echo(json.dumps(results, ensure_ascii=False))
elif not results:
skin.info("No results")
else:
headers = list(results[0].keys())
rows = [[str(r.get(h, "")) for h in headers] for r in results]
skin.table(headers, rows)
def _handle_search_repl(skin: Any, client: SiYuanClient,
parts: list[str], json_mode: bool) -> None:
query = " ".join(parts[1:])
data = client.search_blocks(query)
blocks = data.get("blocks", []) if isinstance(data, dict) else data
if json_mode:
click.echo(json.dumps(blocks, ensure_ascii=False))
elif not blocks:
skin.info("No results")
else:
skin.table(["ID", "Content"],
[[r.get("id", ""), r.get("content", "")[:80]] for r in blocks])
def _handle_export_repl(skin: Any, client: SiYuanClient,
parts: list[str], json_mode: bool) -> None:
if parts[1] == "md" and len(parts) >= 3:
md = client.export_md_content(parts[2])
if json_mode:
click.echo(json.dumps(md, ensure_ascii=False))
else:
skin.section(md.get("hPath", ""))
click.echo(md.get("content", ""))
else:
skin.error("Usage: export md <doc-id>")
# ── Notebook commands ──────────────────────────────────────────────────
@cli.group()
def notebook():
"""Manage notebooks (笔记本)."""
@notebook.command("list")
@click.pass_obj
def notebook_list(ctx: SiYuanContext):
"""List all notebooks."""
notebooks = ctx.client.list_notebooks()
if ctx.json_output:
click.echo(json.dumps(notebooks, ensure_ascii=False))
else:
click.echo(f"{'ID':<30} {'Name':<30} {'Closed':<8}")
click.echo("-" * 70)
for nb in notebooks:
click.echo(f"{nb['id']:<30} {nb['name']:<30} {str(nb.get('closed', '')):<8}")
@notebook.command("create")
@click.argument("name")
@click.pass_obj
def notebook_create(ctx: SiYuanContext, name: str):
"""Create a new notebook."""
nb = ctx.client.create_notebook(name)
if ctx.json_output:
click.echo(json.dumps(nb, ensure_ascii=False))
else:
click.echo(f"Created notebook: {nb['name']} ({nb['id']})")
@notebook.command("remove")
@click.argument("notebook_id")
@click.pass_obj
def notebook_remove(ctx: SiYuanContext, notebook_id: str):
"""Remove a notebook by ID."""
ctx.client.remove_notebook(notebook_id)
click.echo(f"Removed notebook: {notebook_id}")
@notebook.command("rename")
@click.argument("notebook_id")
@click.argument("name")
@click.pass_obj
def notebook_rename(ctx: SiYuanContext, notebook_id: str, name: str):
"""Rename a notebook."""
ctx.client.rename_notebook(notebook_id, name)
click.echo(f"Renamed notebook {notebook_id} to: {name}")
@notebook.command("open")
@click.argument("notebook_id")
@click.pass_obj
def notebook_open(ctx: SiYuanContext, notebook_id: str):
"""Open a notebook."""
ctx.client.open_notebook(notebook_id)
name = notebook_id
for nb in ctx.client.list_notebooks():
if nb.get("id") == notebook_id:
name = nb.get("name", notebook_id)
break
ctx.session.update(current_notebook_id=notebook_id, current_notebook_name=name)
ctx.session.flush()
click.echo(f"Opened notebook: {name} ({notebook_id})")
# ── Document commands ──────────────────────────────────────────────────
@cli.group()
def doc():
"""Manage documents (文档)."""
@doc.command("create")
@click.argument("notebook_id")
@click.argument("path")
@click.option("--md", default="", help="Markdown content. Use '-' to read from stdin.")
@click.pass_obj
def doc_create(ctx: SiYuanContext, notebook_id: str, path: str, md: str):
"""Create a document with optional Markdown content.
To avoid shell escaping issues with special characters
(backticks, quotes, parentheses), pipe Markdown via stdin:
echo "## Title" | sy doc create nb1 /test --md -
"""
if md == "-":
md = _read_stdin()
doc_id = ctx.client.create_doc_with_md(notebook_id, path, md)
if ctx.json_output:
click.echo(json.dumps({"id": doc_id}, ensure_ascii=False))
else:
click.echo(f"Created doc: {doc_id}")
@doc.command("list")
@click.argument("notebook_id")
@click.argument("path", default="/")
@click.pass_obj
def doc_list(ctx: SiYuanContext, notebook_id: str, path: str):
"""List documents at a path."""
docs = ctx.client.list_docs_by_path(notebook_id, path)
items = docs.get("files", []) if isinstance(docs, dict) else docs
if ctx.json_output:
click.echo(json.dumps(items, ensure_ascii=False))
else:
click.echo(f"{'ID':<30} {'Name':<30} {'Type':<10}")
click.echo("-" * 70)
for d in items:
click.echo(f"{d.get('id', ''):<30} {d.get('name', ''):<30} {d.get('type', ''):<10}")
@doc.command("tree")
@click.argument("notebook_id")
@click.option("--path", default="/", help="Root path")
@click.option("--depth", default=-1, type=int, help="Max depth")
@click.pass_obj
def doc_tree(ctx: SiYuanContext, notebook_id: str, path: str, depth: int):
"""List document tree."""
tree = ctx.client.list_doc_tree(notebook_id, path=path, max_depth=depth)
if isinstance(tree, dict):
items = tree.get("files") or tree.get("tree") or []
else:
items = tree
if ctx.json_output:
click.echo(json.dumps(items, ensure_ascii=False))
else:
for t in _walk_tree(items):
indent = " " * t.get("depth", 0)
click.echo(f"{indent}{t.get('name', '')} ({t.get('id', '')})")
@doc.command("get")
@click.argument("doc_id")
@click.pass_obj
def doc_get(ctx: SiYuanContext, doc_id: str):
"""Get document info by ID."""
hpath = ctx.client.get_hpath_by_id(doc_id)
if ctx.json_output:
click.echo(json.dumps({"hpath": hpath}, ensure_ascii=False))
else:
click.echo(f"Path: {hpath}")
@doc.command("rename")
@click.argument("doc_id")
@click.argument("title")
@click.pass_obj
def doc_rename(ctx: SiYuanContext, doc_id: str, title: str):
"""Rename a document."""
ctx.client.rename_doc_by_id(doc_id, title)
click.echo(f"Renamed {doc_id} to: {title}")
@doc.command("remove")
@click.argument("doc_id")
@click.pass_obj
def doc_remove(ctx: SiYuanContext, doc_id: str):
"""Remove a document."""
ctx.client.remove_doc_by_id(doc_id)
click.echo(f"Removed: {doc_id}")
# ── Block commands ─────────────────────────────────────────────────────
@cli.group()
def block():
"""Manage blocks (内容块)."""
@block.command("insert")
@click.argument("data", required=False)
@click.option("--previous", default="", help="Previous block ID")
@click.option("--parent", default="", help="Parent block ID")
@click.option("--next", "next_", default="", help="Next block ID")
@click.option("--data-type", default="markdown", help="Data type (markdown/dom)")
@click.pass_obj
def block_insert(ctx: SiYuanContext, data: str | None, previous: str, parent: str, next_: str, data_type: str):
"""Insert a block. Data reads from stdin when '-' or omitted."""
if not parent and not previous and not next_:
raise click.UsageError("An anchor is required: --parent, --previous, or --next")
if not data or data == "-":
data = _read_stdin()
result = ctx.client.insert_block(data_type, data, parent_id=parent, previous_id=previous, next_id=next_)
if ctx.json_output:
click.echo(json.dumps(result, ensure_ascii=False))
else:
click.echo("Block inserted")
@block.command("update")
@click.argument("block_id")
@click.argument("data", required=False)
@click.option("--data-type", default="markdown", help="Data type")
@click.pass_obj
def block_update(ctx: SiYuanContext, block_id: str, data: str | None, data_type: str):
"""Update a block's content. Data reads from stdin when '-' or omitted."""
if not data or data == "-":
data = _read_stdin()
ctx.client.update_block(data_type, data, block_id)
click.echo(f"Updated block: {block_id}")
@block.command("delete")
@click.argument("block_id")
@click.pass_obj
def block_delete(ctx: SiYuanContext, block_id: str):
"""Delete a block."""
ctx.client.delete_block(block_id)
click.echo(f"Deleted block: {block_id}")
@block.command("get")
@click.argument("block_id")
@click.pass_obj
def block_get(ctx: SiYuanContext, block_id: str):
"""Get block kramdown source."""
kramdown = ctx.client.get_block_kramdown(block_id)
if ctx.json_output:
click.echo(json.dumps({"kramdown": kramdown}, ensure_ascii=False))
else:
click.echo(kramdown)
@block.command("children")
@click.argument("block_id")
@click.pass_obj
def block_children(ctx: SiYuanContext, block_id: str):
"""Get child blocks."""
children = ctx.client.get_child_blocks(block_id)
if ctx.json_output:
click.echo(json.dumps(children, ensure_ascii=False))
else:
for c in children:
click.echo(f"{c.get('id', ''):<30} {c.get('type', ''):<8} {c.get('subType', '')}")
# ── SQL command ────────────────────────────────────────────────────────
@cli.command()
@click.argument("stmt")
@click.pass_obj
def sql(ctx: SiYuanContext, stmt: str):
"""Execute a SQL query on the block database.
Example: sql "SELECT * FROM blocks WHERE content LIKE '%keyword%' LIMIT 10"
"""
results = ctx.client.query_sql(stmt)
if ctx.json_output:
click.echo(json.dumps(results, ensure_ascii=False))
elif not results:
click.echo("No results")
else:
for r in results:
click.echo(json.dumps(r, ensure_ascii=False))
# ── Search commands ────────────────────────────────────────────────────
@cli.command()
@click.argument("query")
@click.pass_obj
def search(ctx: SiYuanContext, query: str):
"""Full-text search across all blocks."""
data = ctx.client.search_blocks(query)
blocks = data.get("blocks", []) if isinstance(data, dict) else data
if ctx.json_output:
click.echo(json.dumps(blocks, ensure_ascii=False))
elif not blocks:
click.echo("No results")
else:
for r in blocks[:20]:
click.echo(f"- {r.get('id', '')}: {r.get('content', '')[:120]}")
# ── Export commands ────────────────────────────────────────────────────
@cli.group()
def export():
"""Export content from SiYuan."""
@export.command("md")
@click.argument("doc_id")
@click.pass_obj
def export_md(ctx: SiYuanContext, doc_id: str):
"""Export a document as Markdown."""
md = ctx.client.export_md_content(doc_id)
if ctx.json_output:
click.echo(json.dumps(md, ensure_ascii=False))
else:
click.echo(f"# {md.get('hPath', '')}")
click.echo("")
click.echo(md.get("content", ""))
# ── System commands ────────────────────────────────────────────────────
@cli.command()
@click.pass_obj
def version(ctx: SiYuanContext):
"""Show SiYuan kernel version."""
ver = ctx.client.get_version()
if ctx.json_output:
click.echo(json.dumps({"version": ver}, ensure_ascii=False))
else:
click.echo(f"SiYuan version: {ver}")
@cli.command()
@click.pass_obj
def status(ctx: SiYuanContext):
"""Show connection and session status."""
connected = ctx.client.ping()
siyuan_ver = ctx.client.get_version() if connected else ""
session = ctx.session
state = session.state
info = {
"connected": connected,
"siyuan_version": siyuan_ver,
"host": ctx.client.config.host,
"port": ctx.client.config.port,
"current_notebook": state.current_notebook_name or "",
"current_doc": state.current_doc_path or "",
}
if ctx.json_output:
click.echo(json.dumps(info, ensure_ascii=False))
else:
click.echo(f" Connected: {info['connected']}")
click.echo(f" SiYuan: v{info['siyuan_version']}")
click.echo(f" Host: {info['host']}:{info['port']}")
click.echo(f" Notebook: {info['current_notebook']}")
click.echo(f" Document: {info['current_doc']}")
# ── Tag commands ───────────────────────────────────────────────────────
@cli.group()
def tag():
"""Manage tags (标签)."""
def _print_tags(tags: list[dict], indent: int = 0) -> None:
"""Recursively print tags with hierarchical indentation."""
pad = " " * indent
for t in tags:
if indent == 0:
click.echo(f"{t.get('name', ''):<30} ({t.get('count', 0)})")
else:
click.echo(f"{pad}{t.get('name', ''):<{30 - 2 * indent}} ({t.get('count', 0)})")
children = t.get("children")
if children:
_print_tags(children, indent + 1)
@tag.command("list")
@click.pass_obj
def tag_list(ctx: SiYuanContext):
"""List all tags (including nested tags)."""
tags = ctx.client.get_tags()
if ctx.json_output:
click.echo(json.dumps(tags, ensure_ascii=False))
else:
_print_tags(tags)
@@ -0,0 +1,106 @@
---
name: cli-anything-siyuan
description: SiYuan (思源笔记) CLI — manage notebooks, documents, blocks, and search your knowledge base from the terminal.
---
# cli-anything-siyuan
CLI harness for [SiYuan](https://github.com/siyuan-note/siyuan) (思源笔记),
a local-first knowledge management and note-taking application.
This CLI connects to a running SiYuan kernel via its HTTP API
(`http://127.0.0.1:6806`) and provides structured access to notebooks,
documents, blocks, search, and export.
## Prerequisites
- SiYuan must be running (Settings → About shows the API token)
- Python 3.10+
- Install: `pip install cli-anything-siyuan` or `pip install -e .` from agent-harness/
## Command Groups
### notebook — Notebook management
| Subcommand | Description |
|------------|-------------|
| `list` | List all notebooks |
| `create <name>` | Create a new notebook |
| `rename <id> <name>` | Rename a notebook |
| `remove <id>` | Delete a notebook |
| `open <id>` | Open a notebook |
### doc — Document management
| Subcommand | Description |
|------------|-------------|
| `create <notebook> <path> [--md "content"]` | Create a document |
| `list <notebook> [path]` | List documents |
| `tree <notebook> [--path / --depth]` | Show doc tree |
| `get <id>` | Get document path by ID |
| `rename <id> <title>` | Rename a document |
| `remove <id>` | Delete a document |
### block — Block operations
| Subcommand | Description |
|------------|-------------|
| `insert <data> [--previous / --parent]` | Insert a block |
| `update <id> <data>` | Update a block |
| `delete <id>` | Delete a block |
| `get <id>` | Get block kramdown source |
| `children <id>` | Get child blocks |
### Other commands
| Command | Description |
|---------|-------------|
| `sql <stmt>` | Execute SQL on the block database |
| `search <query>` | Full-text search across blocks |
| `export md <doc-id>` | Export document as Markdown |
| `tag list` | List all tags |
| `version` | Show SiYuan kernel version |
| `status` | Show connection and session status |
| `repl` | Start interactive REPL |
## Agent Guidance
- Always use `--json` for machine-readable output
- Power search: use `sql "SELECT * FROM blocks WHERE content LIKE '%keyword%'"` for SQL-level access
- Document IDs look like `20210817205410-2kvfpfn` (timestamp-based)
- API token can be found in SiYuan Settings → About
- Connection defaults: `http://127.0.0.1:6806`
## Examples
```bash
# List notebooks (JSON)
cli-anything-siyuan --json notebook list
# Create a document with Markdown (--md flag, watch shell escaping)
cli-anything-siyuan doc create nb1 /projects/new --md "## Title\n\nContent"
# Create a document — pipe Markdown via stdin (avoids shell escaping)
# PowerShell here-string (literal, no escaping needed):
@'
## Title
Content with `backticks` and (parentheses) and "quotes"
'@ | cli-anything-siyuan doc create nb1 /projects/new --md -
# Bash heredoc:
# cat <<'EOF' | cli-anything-siyuan doc create nb1 /projects/new --md -
# ## Title
# Content with `backticks` and (parentheses)
# EOF
# SQL search
cli-anything-siyuan sql "SELECT id, content FROM blocks WHERE content LIKE '%meeting%' LIMIT 5"
# Export
cli-anything-siyuan export md doc123
# Enter REPL
cli-anything-siyuan
```
## Error Handling
- Connection errors: check that SiYuan is running and the API token is correct
- API errors: returned as `{"code": N, "msg": "..."}` — check the message field
- Auth errors: verify the token in `~/.siyuan-cli.json` or `SIYUAN_TOKEN` env var
@@ -0,0 +1,408 @@
"""Unit tests for cli-anything-siyuan CLI commands.
Tests CLI output formatting using Click's CliRunner with mocked client.
No external dependencies or running SiYuan instance required.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from cli_anything.siyuan.siyuan_cli import cli
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_ctx():
"""Create a mock SiYuanContext with a mock client."""
ctx = MagicMock()
ctx.json_output = False
return ctx
# ── Search command ─────────────────────────────────────────────────────
class TestSearchCommand:
def test_search_returns_blocks_list(self, runner, mock_ctx):
"""search handles API returning list of blocks directly."""
mock_ctx.client.search_blocks.return_value = [
{"id": "b1", "content": "Dit模型训练结果如何"},
{"id": "b2", "content": "训练完成,准确率90%"},
]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["search", "Dit模型"])
assert result.exit_code == 0
assert "b1" in result.output
assert "Dit模型训练结果如何" in result.output
assert "b2" in result.output
def test_search_returns_dict_with_blocks_key(self, runner, mock_ctx):
"""search handles API returning dict with 'blocks' key (real SiYuan format).
This is the fix for KeyError: slice — real SiYuan API returns
{"blocks": [...], "rootBlocks": {...}} not a flat list.
"""
mock_ctx.client.search_blocks.return_value = {
"blocks": [
{"id": "b1", "content": "Dit模型训练完成"},
{"id": "b2", "content": "loss=0.02"},
],
"rootBlocks": {},
}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["search", "Dit模型"])
assert result.exit_code == 0
assert "b1" in result.output
assert "Dit模型训练完成" in result.output
def test_search_json_output(self, runner, mock_ctx):
"""--json search returns raw data."""
mock_ctx.json_output = True
mock_ctx.client.search_blocks.return_value = {
"blocks": [{"id": "b1", "content": "test"}],
}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["--json", "search", "test"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data[0]["id"] == "b1"
def test_search_no_results(self, runner, mock_ctx):
"""search shows 'No results' when empty list."""
mock_ctx.client.search_blocks.return_value = []
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["search", "nonexistent"])
assert result.exit_code == 0
assert "No results" in result.output
def test_search_no_results_dict(self, runner, mock_ctx):
"""search shows 'No results' when dict with empty blocks list."""
mock_ctx.client.search_blocks.return_value = {"blocks": [], "rootBlocks": {}}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["search", "nonexistent"])
assert result.exit_code == 0
assert "No results" in result.output
# ── Doc list command ───────────────────────────────────────────────────
class TestDocListCommand:
def test_doc_list_returns_files(self, runner, mock_ctx):
"""doc list handles API returning dict with 'files' key."""
mock_ctx.client.list_docs_by_path.return_value = {
"box": "nb1",
"files": [
{"id": "doc1", "name": "测试文档", "type": "d"},
{"id": "doc2", "name": "笔记", "type": "d"},
],
"path": "/",
}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["doc", "list", "nb1"])
assert result.exit_code == 0
assert "doc1" in result.output
assert "测试文档" in result.output
assert "doc2" in result.output
def test_doc_list_empty(self, runner, mock_ctx):
"""doc list handles empty files list."""
mock_ctx.client.list_docs_by_path.return_value = {
"box": "nb1", "files": [], "path": "/",
}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["doc", "list", "nb1"])
assert result.exit_code == 0
# Should show header but no data rows
assert "ID" in result.output
assert "Name" in result.output
def test_doc_list_json(self, runner, mock_ctx):
"""--json doc list returns raw files array."""
mock_ctx.json_output = True
mock_ctx.client.list_docs_by_path.return_value = {
"box": "nb1", "files": [{"id": "doc1", "name": "Test"}], "path": "/",
}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["--json", "doc", "list", "nb1"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data[0]["id"] == "doc1"
# ── Doc tree command ───────────────────────────────────────────────────
class TestDocTreeCommand:
def test_doc_tree_returns_files(self, runner, mock_ctx):
"""doc tree handles API returning dict with 'files' key."""
mock_ctx.client.list_doc_tree.return_value = {
"files": [
{"id": "doc1", "name": "根文档", "depth": 0},
{"id": "doc2", "name": "子文档", "depth": 1},
],
}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["doc", "tree", "nb1"])
assert result.exit_code == 0
assert "根文档" in result.output
assert "子文档" in result.output
def test_doc_tree_json(self, runner, mock_ctx):
"""--json doc tree returns raw files array."""
mock_ctx.json_output = True
mock_ctx.client.list_doc_tree.return_value = {
"files": [{"id": "doc1", "name": "Root"}],
}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["--json", "doc", "tree", "nb1"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data[0]["id"] == "doc1"
# ── Notebook list command ──────────────────────────────────────────────
class TestNotebookListCommand:
def test_notebook_list(self, runner, mock_ctx):
"""notebook list returns formatted table."""
mock_ctx.client.list_notebooks.return_value = [
{"id": "nb1", "name": "工作笔记", "closed": False},
{"id": "nb2", "name": "归档", "closed": True},
]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["notebook", "list"])
assert result.exit_code == 0
assert "工作笔记" in result.output
assert "nb1" in result.output
# ── Tag list command ───────────────────────────────────────────────────
class TestTagListCommand:
def test_tag_list(self, runner, mock_ctx):
"""tag list shows tag names with counts."""
mock_ctx.client.get_tags.return_value = [
{"name": "AI", "count": 5},
{"name": "Python", "count": 12},
{"name": "笔记", "count": 3},
]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["tag", "list"])
assert result.exit_code == 0
assert "AI" in result.output
assert "5" in result.output
assert "Python" in result.output
assert "12" in result.output
# ── SQL command ────────────────────────────────────────────────────────
class TestSQLCommand:
def test_sql_query(self, runner, mock_ctx):
"""sql returns query results."""
mock_ctx.client.query_sql.return_value = [
{"id": "b1", "content": "hello"},
{"id": "b2", "content": "world"},
]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["sql", "SELECT * FROM blocks"])
assert result.exit_code == 0
assert "b1" in result.output
assert "hello" in result.output
# ── Status command ─────────────────────────────────────────────────────
class TestStatusCommand:
def test_status(self, runner, mock_ctx):
"""status shows connection info."""
mock_ctx.client.status.return_value = {
"connected": True,
"version": "3.6.5",
}
mock_ctx.client.get_version.return_value = "3.6.5"
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
mock_session = MagicMock()
mock_session.state.connected = True
mock_session.state.current_notebook_id = "nb1"
mock_session.state.current_notebook_name = "工作"
mock_session.state.current_doc_id = "doc1"
mock_ctx.client.get_version.return_value = "3.6.5"
mock_ctx.session = mock_session
mock_ctx.current_notebook_id = "nb1"
mock_ctx.current_notebook_name = "工作"
mock_ctx.current_doc_id = "doc1"
result = runner.invoke(cli, ["status"])
assert result.exit_code == 0
assert "Connected" in result.output or "connected" in result.output.lower()
# ── Block insert command ────────────────────────────────────────────────
class TestBlockInsertCommand:
def test_block_insert_with_parent(self, runner, mock_ctx):
"""block insert with --parent succeeds."""
mock_ctx.client.insert_block.return_value = [{"id": "new-block"}]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["block", "insert", "hello", "--parent", "pid"])
assert result.exit_code == 0
assert "Block inserted" in result.output
mock_ctx.client.insert_block.assert_called_once_with(
"markdown", "hello", parent_id="pid", previous_id="", next_id=""
)
def test_block_insert_with_previous(self, runner, mock_ctx):
"""block insert with --previous succeeds."""
mock_ctx.client.insert_block.return_value = [{"id": "new-block"}]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["block", "insert", "hello", "--previous", "prev"])
assert result.exit_code == 0
assert "Block inserted" in result.output
mock_ctx.client.insert_block.assert_called_once_with(
"markdown", "hello", parent_id="", previous_id="prev", next_id=""
)
def test_block_insert_with_next(self, runner, mock_ctx):
"""block insert with --next succeeds."""
mock_ctx.client.insert_block.return_value = [{"id": "new-block"}]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["block", "insert", "hello", "--next", "nid"])
assert result.exit_code == 0
assert "Block inserted" in result.output
mock_ctx.client.insert_block.assert_called_once_with(
"markdown", "hello", parent_id="", previous_id="", next_id="nid"
)
def test_block_insert_without_anchor_errors(self, runner, mock_ctx):
"""block insert without any anchor raises UsageError."""
mock_ctx.client.insert_block.return_value = [{"id": "new-block"}]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["block", "insert", "hello"])
assert result.exit_code == 2
assert "anchor" in result.output.lower() or "Error" in result.output
def test_block_insert_json_output(self, runner, mock_ctx):
"""--json block insert returns raw data."""
mock_ctx.json_output = True
mock_ctx.client.insert_block.return_value = [{"id": "new-block"}]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["--json", "block", "insert", "hello", "--parent", "pid"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data[0]["id"] == "new-block"
# ── Doc get command ────────────────────────────────────────────────────
class TestDocGetCommand:
def test_doc_get(self, runner, mock_ctx):
"""doc get shows document path."""
mock_ctx.client.get_hpath_by_id.return_value = "/我的文档/测试"
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["doc", "get", "doc123"])
assert result.exit_code == 0
assert "/我的文档/测试" in result.output
# ── Doc create command ──────────────────────────────────────────────────
class TestDocCreateCommand:
def test_doc_create_without_md(self, runner, mock_ctx):
"""doc create without --md does not read stdin (passes empty string)."""
mock_ctx.client.create_doc_with_md.return_value = "doc123"
with (
patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx),
):
result = runner.invoke(cli, ["doc", "create", "nb1", "/test"])
assert result.exit_code == 0
assert "doc123" in result.output
def test_doc_create_with_md(self, runner, mock_ctx):
"""doc create with --md passes the markdown content."""
mock_ctx.client.create_doc_with_md.return_value = "doc123"
with (
patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx),
):
result = runner.invoke(cli, ["doc", "create", "nb1", "/test", "--md", "# Hello"])
assert result.exit_code == 0
mock_ctx.client.create_doc_with_md.assert_called_with("nb1", "/test", "# Hello")
assert "doc123" in result.output
def test_doc_create_json_output(self, runner, mock_ctx):
"""--json doc create returns doc ID."""
mock_ctx.json_output = True
mock_ctx.client.create_doc_with_md.return_value = "doc123"
with (
patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx),
):
result = runner.invoke(cli, ["--json", "doc", "create", "nb1", "/test"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["id"] == "doc123"
# ── Doc tree recursive rendering ──────────────────────────────────────
class TestDocTreeRecursiveCommand:
def test_doc_tree_handle_nested_children(self, runner, mock_ctx):
"""doc tree recursively renders nested children."""
mock_ctx.client.list_doc_tree.return_value = {
"files": [
{
"id": "doc1", "name": "Root", "depth": 0,
"children": [
{"id": "doc2", "name": "Child", "depth": 1, "children": []},
],
},
{"id": "doc3", "name": "Sibling", "depth": 0, "children": []},
],
}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["doc", "tree", "nb1"])
assert result.exit_code == 0
assert "Root" in result.output
assert "Child" in result.output
assert "Sibling" in result.output
assert "doc1" in result.output
assert "doc2" in result.output
def test_doc_tree_flat_items(self, runner, mock_ctx):
"""doc tree also works with flat items (no children key)."""
mock_ctx.client.list_doc_tree.return_value = [
{"id": "doc1", "name": "Flat1", "depth": 0},
{"id": "doc2", "name": "Flat2", "depth": 1},
]
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["doc", "tree", "nb1"])
assert result.exit_code == 0
assert "Flat1" in result.output
assert "Flat2" in result.output
def test_doc_tree_nested_json_output(self, runner, mock_ctx):
"""--json doc tree with nested children returns raw data."""
mock_ctx.json_output = True
mock_ctx.client.list_doc_tree.return_value = {
"files": [{"id": "doc1", "name": "Root", "children": []}],
}
with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx):
result = runner.invoke(cli, ["--json", "doc", "tree", "nb1"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data[0]["id"] == "doc1"
@@ -0,0 +1,204 @@
"""Unit tests for cli-anything-siyuan core modules.
Tests use synthetic data and mocking — no external dependencies.
"""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from cli_anything.siyuan.core.client import SiYuanClient, SiYuanConfig, load_config
from cli_anything.siyuan.core.session import SessionManager, SessionState
# ── Client config tests ────────────────────────────────────────────────
class TestClientConfig:
def test_default_config(self):
"""Default config has correct default values."""
config = SiYuanConfig()
assert config.host == "127.0.0.1"
assert config.port == 6806
assert config.token == ""
assert config.base_url == "http://127.0.0.1:6806"
def test_custom_config(self):
"""Custom config values are used."""
config = SiYuanConfig(host="localhost", port=6807, token="abc123")
assert config.host == "localhost"
assert config.port == 6807
assert config.token == "abc123"
assert config.base_url == "http://localhost:6807"
@patch.dict(os.environ, {"SIYUAN_HOST": "192.168.1.100", "SIYUAN_PORT": "6808", "SIYUAN_TOKEN": "envtoken"}, clear=True)
@patch("cli_anything.siyuan.core.client.Path.home")
def test_load_config_from_env(self, mock_home):
"""load_config reads from environment variables."""
mock_home.return_value = Path(tempfile.mkdtemp())
config = load_config()
assert config.host == "192.168.1.100"
assert config.port == 6808
assert config.token == "envtoken"
def test_load_config_from_file(self):
"""load_config reads from JSON config file."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8") as f:
json.dump({"host": "10.0.0.1", "port": 6809, "token": "filetoken"}, f)
fname = f.name
try:
config = load_config(fname)
assert config.host == "10.0.0.1"
assert config.port == 6809
assert config.token == "filetoken"
finally:
os.unlink(fname)
# ── Client API tests ───────────────────────────────────────────────────
class TestSiYuanClient:
@pytest.fixture
def client(self):
return SiYuanClient(SiYuanConfig(token="test-token"))
def test_ping_success(self, client):
"""ping returns True when server responds."""
mock_session = MagicMock()
mock_session.post.return_value.status_code = 200
mock_session.post.return_value.json.return_value = {"code": 0, "data": "3.6.5"}
client._session = mock_session
assert client.ping() is True
def test_ping_failure(self, client):
"""ping returns False when server is unreachable."""
mock_session = MagicMock()
mock_session.post.side_effect = __import__("requests").ConnectionError("refused")
client._session = mock_session
assert client.ping() is False
def test_list_notebooks(self, client):
"""list_notebooks parses API response correctly."""
mock_data = {
"notebooks": [
{"id": "nb1", "name": "Test Notebook", "icon": "1f4d4", "closed": False},
{"id": "nb2", "name": "Daily Notes", "icon": "1f4cb", "closed": True},
]
}
mock_session = MagicMock()
mock_session.post.return_value.status_code = 200
mock_session.post.return_value.json.return_value = {"code": 0, "data": mock_data}
client._session = mock_session
notebooks = client.list_notebooks()
assert len(notebooks) == 2
assert notebooks[0]["name"] == "Test Notebook"
assert notebooks[1]["id"] == "nb2"
def test_create_doc_with_md(self, client):
"""create_doc_with_md returns the doc ID."""
mock_session = MagicMock()
mock_session.post.return_value.status_code = 200
mock_session.post.return_value.json.return_value = {"code": 0, "data": "doc123"}
client._session = mock_session
doc_id = client.create_doc_with_md("nb1", "/test/path", "# Hello")
assert doc_id == "doc123"
# Verify correct API call (requests uses json= kwarg, not data=)
call_kwargs = mock_session.post.call_args[1]
body = call_kwargs["json"]
assert body["notebook"] == "nb1"
assert body["path"] == "/test/path"
assert body["markdown"] == "# Hello"
def test_query_sql(self, client):
"""query_sql returns list of rows."""
mock_session = MagicMock()
mock_session.post.return_value.status_code = 200
mock_session.post.return_value.json.return_value = {
"code": 0, "data": [{"id": "b1", "content": "hello"}, {"id": "b2", "content": "world"}],
}
client._session = mock_session
results = client.query_sql("SELECT * FROM blocks")
assert len(results) == 2
assert results[0]["content"] == "hello"
# ── Session tests ──────────────────────────────────────────────────────
class TestSessionManager:
@pytest.fixture
def mgr(self):
tmp_dir = tempfile.mkdtemp()
return SessionManager(state_dir=tmp_dir)
def test_default_state(self, mgr):
"""Fresh session has correct defaults."""
assert mgr.state.connected is False
assert mgr.state.current_notebook_id == ""
assert mgr.state.current_notebook_name == ""
assert mgr.state.current_doc_id == ""
def test_save_load_roundtrip(self, mgr):
"""Save then load preserves all state."""
mgr.update(current_notebook_id="nb1", current_notebook_name="Test", connected=True)
mgr.save()
mgr2 = SessionManager(state_dir=str(mgr.state_dir))
mgr2.load()
assert mgr2.state.current_notebook_id == "nb1"
assert mgr2.state.current_notebook_name == "Test"
assert mgr2.state.connected is True
def test_partial_update(self, mgr):
"""Update only changes specified fields."""
mgr.update(current_notebook_id="nb1")
assert mgr.state.current_notebook_id == "nb1"
assert mgr.state.connected is False # unchanged
class TestFindReplace:
"""Tests for find_replace using SiYuan API k/r/ids payload."""
@pytest.fixture
def client(self):
return SiYuanClient(SiYuanConfig(token="test-token"))
def test_find_replace_uses_k_r_ids_payload(self, client):
"""find_replace sends k/r/ids payload keys."""
mock_session = MagicMock()
mock_session.post.return_value.status_code = 200
mock_session.post.return_value.json.return_value = {"code": 0, "data": {"count": 3}}
client._session = mock_session
client.find_replace("old", "new", ["id1", "id2"])
call_kwargs = mock_session.post.call_args[1]
body = call_kwargs["json"]
assert "k" in body
assert "r" in body
assert "ids" in body
assert body["k"] == "old"
assert body["r"] == "new"
assert body["ids"] == ["id1", "id2"]
class TestUncappedListings:
"""Tests for list_docs_by_path with maxListCount."""
@pytest.fixture
def client(self):
return SiYuanClient(SiYuanConfig(token="test-token"))
def test_list_docs_by_path_sends_max_list_count(self, client):
"""list_docs_by_path sends maxListCount: 0 for uncapped listings."""
mock_session = MagicMock()
mock_session.post.return_value.status_code = 200
mock_session.post.return_value.json.return_value = {"code": 0, "data": {"files": [{"id": "doc1", "name": "test"}]}}
client._session = mock_session
client.list_docs_by_path("nb1", "/")
call_kwargs = mock_session.post.call_args[1]
body = call_kwargs["json"]
assert "maxListCount" in body
assert body["maxListCount"] == 0
@@ -0,0 +1,122 @@
"""E2E tests for cli-anything-siyuan.
Requires a running SiYuan instance at http://127.0.0.1:6806.
Set SIYUAN_TOKEN env var if authentication is enabled.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
from cli_anything.siyuan.core.client import SiYuanClient, SiYuanClientError, load_config
def _resolve_cli(name: str) -> list[str]:
"""Resolve installed CLI command; falls back to python -m for dev."""
import shutil
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
path = shutil.which(name)
if path:
print(f"[_resolve_cli] Using installed command: {path}")
return [path]
if force:
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
module = name.replace("cli-anything-", "cli_anything.") # e.g. cli_anything.siyuan (has __main__.py)
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
return [sys.executable, "-m", module]
CLI_BASE = _resolve_cli("cli-anything-siyuan")
# ── Fixtures ───────────────────────────────────────────────────────────
@pytest.fixture(scope="session")
def client() -> SiYuanClient:
"""Create a client connected to the real SiYuan instance."""
config = load_config()
c = SiYuanClient(config)
if not c.ping():
pytest.skip("SiYuan is not running — E2E tests require a running instance")
return c
@pytest.fixture
def tmp_dir() -> Path:
"""Create a temporary directory for test artifacts."""
import tempfile
path = Path(tempfile.mkdtemp(prefix="siyuan_e2e_"))
yield path
import shutil
shutil.rmtree(path, ignore_errors=True)
class TestCLISubprocess:
CLI_BASE = CLI_BASE
def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(
self.CLI_BASE + args,
capture_output=True, text=True, check=check,
)
def test_help(self):
"""--help returns 0 and shows usage."""
result = self._run(["--help"])
assert result.returncode == 0
assert "Usage:" in result.stdout
print(f"\n --help: OK ({len(result.stdout)} chars)")
def test_version_json(self, client: SiYuanClient):
"""--json version returns valid JSON with version string."""
result = self._run(["--json", "version"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert "version" in data
assert data["version"]
print(f"\n SiYuan version: {data['version']}")
def test_status_json(self, client: SiYuanClient):
"""--json status returns valid JSON with connection info."""
result = self._run(["--json", "status"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert "siyuan_version" in data
assert data["siyuan_version"]
print(f"\n Status: connected to SiYuan v{data['siyuan_version']}")
class TestBackendE2E:
"""Tests that hit the real SiYuan API via Python client."""
def test_get_version(self, client: SiYuanClient):
"""Get the SiYuan kernel version from a running instance."""
ver = client.get_version()
assert ver
assert isinstance(ver, str)
assert len(ver) > 0
print(f"\n SiYuan kernel version: {ver}")
def test_list_notebooks(self, client: SiYuanClient):
"""List all notebooks from the running instance."""
notebooks = client.list_notebooks()
assert isinstance(notebooks, list)
if notebooks:
nb = notebooks[0]
assert "id" in nb
assert "name" in nb
print(f"\n Notebooks found: {len(notebooks)}")
print(f" First: {nb['name']} ({nb['id']})")
else:
print("\n No notebooks found (empty instance)")
def test_get_current_time(self, client: SiYuanClient):
"""Get current time from server."""
t = client.get_current_time()
assert t > 0
assert isinstance(t, int)
print(f"\n Server time (ts): {t}")
@@ -0,0 +1,567 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
from pathlib import Path
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = "─"
_V_LINE = "│"
_TL = "╭"
_TR = "╮"
_BL = "╰"
_BR = "╯"
_T_DOWN = "┬"
_T_UP = "┴"
_T_RIGHT = "├"
_T_LEFT = "┤"
_CROSS = "┼"
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
def _display_home_path(path: str) -> str:
"""Display a path relative to the home directory when possible."""
expanded = Path(path).expanduser().resolve()
home = Path.home().resolve()
try:
relative = expanded.relative_to(home)
return f"~/{relative.as_posix()}"
except ValueError:
return str(expanded)
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the repo-root skills/ tree when present,
otherwise from the package's skills/ directory.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
software_aliases = {"iterm2_ctl": "iterm2"}
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
self.skill_id = f"cli-anything-{self.skill_slug}"
self.skill_install_cmd = (
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
)
global_skill_root = Path(
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
).expanduser()
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
# inside the CLI-Anything monorepo. Fall back to the packaged
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
if skill_path is None:
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
repo_skill = None
for parent in Path(__file__).resolve().parents:
candidate = parent / "skills" / self.skill_id / "SKILL.md"
if candidate.is_file():
repo_skill = candidate
break
if repo_skill and repo_skill.is_file():
skill_path = str(repo_skill)
elif package_skill.is_file():
skill_path = str(package_skill)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
import textwrap
inner = 72
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
def _meta_lines(label: str, value: str) -> list[str]:
"""Wrap a metadata line for the banner box."""
icon = self._c(_MAGENTA, "◇")
label_text = self._c(_DARK_GRAY, label)
prefix = f" {icon} {label_text} "
available = max(12, inner - _visible_len(prefix))
wrapped = textwrap.wrap(
value,
width=available,
break_long_words=True,
break_on_hyphens=False,
) or [""]
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
continuation_prefix = " " * _visible_len(prefix)
for chunk in wrapped[1:]:
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
return lines
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "◆")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
meta_lines: list[str] = []
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
print(top)
print(_box_line(title))
print(_box_line(ver))
for line in meta_lines:
print(_box_line(line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}◆{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " ❯ "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", "◆ "))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " ❯ "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "✓")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "✗")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "⚠")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "●")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "█" * filled + "░" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", " │ "))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
@@ -0,0 +1,82 @@
"""SiYuan backend integration.
Provides helper functions to find a running SiYuan instance and verify
connectivity. Since SiYuan has no headless CLI mode, this module handles
the connection check and configuration discovery.
"""
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
from cli_anything.siyuan.core.client import SiYuanClient, SiYuanClientError, SiYuanConfig
def find_siyuan_data_dir() -> str | None:
"""Try to locate the SiYuan data directory.
Checks platform-specific default locations:
- Windows: %USERPROFILE%\\SiYuan\\data\\
- Linux: ~/.config/siyuan/data/
- macOS: ~/.config/siyuan/data/
"""
home = Path.home()
candidates = []
if sys.platform == "win32":
candidates.append(home / "SiYuan" / "data")
else:
candidates.append(home / ".config" / "siyuan" / "data")
for candidate in candidates:
if candidate.is_dir():
return str(candidate.resolve())
return None
def check_siyuan_running(config: SiYuanConfig | None = None) -> dict[str, Any]:
"""Check if a SiYuan instance is reachable and return its status.
Returns:
dict with keys: connected (bool), version (str), error (str)
"""
if config is None:
from cli_anything.siyuan.core.client import load_config
config = load_config()
client = SiYuanClient(config)
try:
version = client.get_version()
return {"connected": True, "version": version, "error": ""}
except SiYuanClientError as e:
return {"connected": False, "version": "", "error": str(e)}
def get_api_token_from_conf() -> str:
"""Try to read the API token from SiYuan config files.
Returns empty string if not found.
"""
home = Path.home()
candidates = []
if sys.platform == "win32":
candidates.append(home / "SiYuan" / "conf" / "conf.json")
else:
candidates.append(home / ".config" / "siyuan" / "conf" / "conf.json")
import json
for conf_path in candidates:
if conf_path.is_file():
try:
data = json.loads(conf_path.read_text(encoding="utf-8"))
token = data.get("api", {}).get("token", "")
if token:
return token
except (json.JSONDecodeError, KeyError, OSError):
continue
return ""
+41
View File
@@ -0,0 +1,41 @@
from setuptools import setup, find_namespace_packages
with open("cli_anything/siyuan/README.md", encoding="utf-8") as f:
long_description = f.read()
setup(
name="cli-anything-siyuan",
version="1.0.0",
description="SiYuan (思源笔记) CLI — manage notebooks, documents, blocks from the terminal",
long_description=long_description,
long_description_content_type="text/markdown",
author="cli-anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
package_data={
"cli_anything.siyuan": ["skills/*.md"],
},
include_package_data=True,
python_requires=">=3.10",
install_requires=[
"click>=8.0",
"requests>=2.28",
],
extras_require={
"repl": ["prompt_toolkit>=3.0"],
"test": ["pytest>=7.0"],
},
entry_points={
"console_scripts": [
"cli-anything-siyuan=cli_anything.siyuan.siyuan_cli:cli",
],
},
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
)
+106
View File
@@ -0,0 +1,106 @@
---
name: "cli-anything-siyuan"
description: SiYuan (思源笔记) CLI — manage notebooks, documents, blocks, and search your knowledge base from the terminal.
---
# cli-anything-siyuan
CLI harness for [SiYuan](https://github.com/siyuan-note/siyuan) (思源笔记),
a local-first knowledge management and note-taking application.
This CLI connects to a running SiYuan kernel via its HTTP API
(`http://127.0.0.1:6806`) and provides structured access to notebooks,
documents, blocks, search, and export.
## Prerequisites
- SiYuan must be running (Settings → About shows the API token)
- Python 3.10+
- Install: `pip install cli-anything-siyuan` or `pip install -e .` from agent-harness/
## Command Groups
### notebook — Notebook management
| Subcommand | Description |
|------------|-------------|
| `list` | List all notebooks |
| `create <name>` | Create a new notebook |
| `rename <id> <name>` | Rename a notebook |
| `remove <id>` | Delete a notebook |
| `open <id>` | Open a notebook |
### doc — Document management
| Subcommand | Description |
|------------|-------------|
| `create <notebook> <path> [--md "content"]` | Create a document |
| `list <notebook> [path]` | List documents |
| `tree <notebook> [--path / --depth]` | Show doc tree |
| `get <id>` | Get document path by ID |
| `rename <id> <title>` | Rename a document |
| `remove <id>` | Delete a document |
### block — Block operations
| Subcommand | Description |
|------------|-------------|
| `insert <data> [--previous / --parent]` | Insert a block |
| `update <id> <data>` | Update a block |
| `delete <id>` | Delete a block |
| `get <id>` | Get block kramdown source |
| `children <id>` | Get child blocks |
### Other commands
| Command | Description |
|---------|-------------|
| `sql <stmt>` | Execute SQL on the block database |
| `search <query>` | Full-text search across blocks |
| `export md <doc-id>` | Export document as Markdown |
| `tag list` | List all tags |
| `version` | Show SiYuan kernel version |
| `status` | Show connection and session status |
| `repl` | Start interactive REPL |
## Agent Guidance
- Always use `--json` for machine-readable output
- Power search: use `sql "SELECT * FROM blocks WHERE content LIKE '%keyword%'"` for SQL-level access
- Document IDs look like `20210817205410-2kvfpfn` (timestamp-based)
- API token can be found in SiYuan Settings → About
- Connection defaults: `http://127.0.0.1:6806`
## Examples
```bash
# List notebooks (JSON)
cli-anything-siyuan --json notebook list
# Create a document with Markdown (--md flag, watch shell escaping)
cli-anything-siyuan doc create nb1 /projects/new --md "## Title\n\nContent"
# Create a document — pipe Markdown via stdin (avoids shell escaping)
# PowerShell here-string (literal, no escaping needed):
@'
## Title
Content with `backticks` and (parentheses) and "quotes"
'@ | cli-anything-siyuan doc create nb1 /projects/new --md -
# Bash heredoc:
# cat <<'EOF' | cli-anything-siyuan doc create nb1 /projects/new --md -
# ## Title
# Content with `backticks` and (parentheses)
# EOF
# SQL search
cli-anything-siyuan sql "SELECT id, content FROM blocks WHERE content LIKE '%meeting%' LIMIT 5"
# Export
cli-anything-siyuan export md doc123
# Enter REPL
cli-anything-siyuan
```
## Error Handling
- Connection errors: check that SiYuan is running and the API token is correct
- API errors: returned as `{"code": N, "msg": "..."}` — check the message field
- Auth errors: verify the token in `~/.siyuan-cli.json` or `SIYUAN_TOKEN` env var