From f015c4e8a825c303e08639ffea4b590beb253397 Mon Sep 17 00:00:00 2001 From: hito Date: Tue, 26 May 2026 10:18:31 +0800 Subject: [PATCH 01/32] feat: add SiYuan CLI (cli-anything-siyuan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SiYuan (思源笔记) CLI tool for managing notebooks, documents, blocks, and searching content via SiYuan HTTP API. - Full CLI with notebook/doc/block/search/tag/export commands - REPL mode with tab-completion - JSON output mode for agent consumption - 33 passing tests (18 unit + 15 CLI command tests) - Skill documentation for AI agent integration - Registry entries for CLI-Hub discovery Install: pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=siyuan/agent-harness --- .gitignore | 4 + public_registry.json | 23 + registry.json | 20 + siyuan/agent-harness/SIYUAN.md | 139 ++++ .../cli_anything/siyuan/README.md | 137 ++++ .../cli_anything/siyuan/__init__.py | 1 + .../cli_anything/siyuan/__main__.py | 5 + .../cli_anything/siyuan/core/__init__.py | 0 .../cli_anything/siyuan/core/client.py | 284 +++++++ .../cli_anything/siyuan/core/session.py | 83 +++ .../cli_anything/siyuan/siyuan_cli.py | 692 ++++++++++++++++++ .../cli_anything/siyuan/skills/SKILL.md | 93 +++ .../cli_anything/siyuan/tests/__init__.py | 0 .../siyuan/tests/test_cli_commands.py | 265 +++++++ .../cli_anything/siyuan/tests/test_core.py | 161 ++++ .../siyuan/tests/test_full_e2e.py | 122 +++ .../cli_anything/siyuan/utils/__init__.py | 0 .../cli_anything/siyuan/utils/repl_skin.py | 567 ++++++++++++++ .../siyuan/utils/siyuan_backend.py | 82 +++ siyuan/agent-harness/setup.py | 40 + skills/cli-anything-siyuan/SKILL.md | 141 ++++ 21 files changed, 2859 insertions(+) create mode 100644 siyuan/agent-harness/SIYUAN.md create mode 100644 siyuan/agent-harness/cli_anything/siyuan/README.md create mode 100644 siyuan/agent-harness/cli_anything/siyuan/__init__.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/__main__.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/core/__init__.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/core/client.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/core/session.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md create mode 100644 siyuan/agent-harness/cli_anything/siyuan/tests/__init__.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/tests/test_core.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/utils/__init__.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/utils/repl_skin.py create mode 100644 siyuan/agent-harness/cli_anything/siyuan/utils/siyuan_backend.py create mode 100644 siyuan/agent-harness/setup.py create mode 100644 skills/cli-anything-siyuan/SKILL.md diff --git a/.gitignore b/.gitignore index 73b94cbd9..164be5b6e 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ !/3MF/ !/calibre/ !/rekordbox/ +!/siyuan/ # Step 5: Inside each software dir, ignore everything (including dotfiles) /gimp/* /gimp/.* @@ -198,6 +199,8 @@ /calibre/.* /rekordbox/* /rekordbox/.* +/siyuan/* +/siyuan/.* # Step 6: ...except agent-harness/ !/gimp/agent-harness/ @@ -259,6 +262,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* diff --git a/public_registry.json b/public_registry.json index 6b0eeb99c..82fe3075e 100644 --- a/public_registry.json +++ b/public_registry.json @@ -362,6 +362,29 @@ "url": "https://github.com/MartaKar" } ] + }, + { + "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": "https://github.com/HKUDS/CLI-Anything", + "package_manager": "pip", + "install_strategy": "command", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=siyuan/agent-harness", + "uninstall_cmd": "pip uninstall cli-anything-siyuan -y", + "update_cmd": "pip install --upgrade 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" + } + ] } ] } diff --git a/registry.json b/registry.json index 3f89b6375..46a37776f 100644 --- a/registry.json +++ b/registry.json @@ -1227,6 +1227,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": "https://github.com/HKUDS/CLI-Anything", + "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" + } + ] } ] } diff --git a/siyuan/agent-harness/SIYUAN.md b/siyuan/agent-harness/SIYUAN.md new file mode 100644 index 000000000..8d3310140 --- /dev/null +++ b/siyuan/agent-harness/SIYUAN.md @@ -0,0 +1,139 @@ +# 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. diff --git a/siyuan/agent-harness/cli_anything/siyuan/README.md b/siyuan/agent-harness/cli_anything/siyuan/README.md new file mode 100644 index 000000000..222826702 --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/README.md @@ -0,0 +1,137 @@ +# 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 + +# Show status +cli-anything-siyuan status +``` + +### 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 +siyuan ❯ search "meeting notes" +siyuan ❯ help +siyuan ❯ quit +``` + +## Command Groups + +| Command | Description | +|---------|-------------| +| `notebook list` | List all notebooks | +| `notebook create ` | Create a notebook | +| `notebook rename ` | Rename a notebook | +| `notebook remove ` | Delete a notebook | +| `notebook open ` | Open a notebook | +| `doc create ` | Create a document | +| `doc list [path]` | List documents | +| `doc tree ` | Show document tree | +| `doc get ` | Get document path by ID | +| `doc rename ` | Rename a document | +| `doc remove <id>` | Delete a document | +| `block insert <data>` | Insert a block | +| `block update <id> <data>` | Update a block | +| `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) diff --git a/siyuan/agent-harness/cli_anything/siyuan/__init__.py b/siyuan/agent-harness/cli_anything/siyuan/__init__.py new file mode 100644 index 000000000..448d0013b --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/__init__.py @@ -0,0 +1 @@ +# cli-anything-siyuan: SiYuan (思源笔记) CLI harness diff --git a/siyuan/agent-harness/cli_anything/siyuan/__main__.py b/siyuan/agent-harness/cli_anything/siyuan/__main__.py new file mode 100644 index 000000000..fc05ffd3a --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/__main__.py @@ -0,0 +1,5 @@ +"""Allow running as: python -m cli_anything.siyuan""" +from cli_anything.siyuan.siyuan_cli import cli + +if __name__ == "__main__": + cli() diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/__init__.py b/siyuan/agent-harness/cli_anything/siyuan/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py new file mode 100644 index 000000000..b4f0776b9 --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -0,0 +1,284 @@ +"""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(): + data = json.loads(config_file.read_text(encoding="utf-8")) + return SiYuanConfig( + host=data.get("host", "127.0.0.1"), + port=data.get("port", 6806), + 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() + 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 + + 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]: + return self._post("/api/notebook/createNotebook", {"name": name}) + + 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}) + + 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", {"keyword": 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[dict[str, Any]]: + return self._post("/api/search/searchTag", {"tag": tag}) + + def find_replace(self, keyword: str, replacement: str, notebook_id: str = "", path: str = "", max_count: int = 0) -> int: + data = self._post("/api/search/findReplace", { + "keyword": keyword, "replacement": replacement, + "notebookID": notebook_id, "path": path, "maxCount": max_count, + }) + return data.get("count", 0) + + # ── 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", {}) + + # ── 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") diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/session.py b/siyuan/agent-harness/cli_anything/siyuan/core/session.py new file mode 100644 index 000000000..748f4c295 --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/core/session.py @@ -0,0 +1,83 @@ +"""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 + + def auto_save(func): + """Decorator: save session state after one-shot mutations.""" + def wrapper(self, *args, **kwargs): + result = func(self, *args, **kwargs) + self.flush() + return result + return wrapper diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py new file mode 100644 index 000000000..e462c9026 --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -0,0 +1,692 @@ +"""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 sys +from pathlib import Path +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 +from cli_anything.siyuan.utils.siyuan_backend import check_siyuan_running + + +# ── Global state ─────────────────────────────────────────────────────── + +_config: SiYuanConfig | None = None +_client: SiYuanClient | None = None +_session_mgr: SessionManager | None = None + + +def get_client() -> SiYuanClient: + global _client, _config + if _client is None: + _config = load_config() + _client = SiYuanClient(_config) + return _client + + +def get_session() -> SessionManager: + global _session_mgr + if _session_mgr is None: + _session_mgr = SessionManager() + _session_mgr.load() + return _session_mgr + + +# ── Click context ───────────────────────────────────────────────────── + +class SiYuanContext: + def __init__(self, json_output: bool = False): + self.json_output = json_output + self.client = get_client() + self.session = get_session() + + +@click.group(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 emoji and CJK characters on Windows + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.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. + """ + global _config, _client + if host or port or token or config_path: + cfg = load_config(config_path or None) + _config = SiYuanConfig( + host=host or cfg.host, + port=port or cfg.port, + token=token or cfg.token, + ) + _client = SiYuanClient(_config) + ctx.obj = SiYuanContext(json_output=json_output) + + if ctx.invoked_subcommand is None: + if not get_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) + + +# ── 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", + "block update <id> <data>": "Update a block", + "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 + + try: + kernel_version = get_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() + + session = get_session() + state = 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"): + break + + if user_input == "help": + skin.help(commands) + continue + + if user_input == "status": + skin.status_block({ + "Connected": str(state.connected), + "Notebook": state.current_notebook_name or "(none)", + "Document": state.current_doc_path or "(none)", + }, title="Status") + continue + + try: + _handle_repl_command(skin, user_input) + except SiYuanClientError as e: + skin.error(str(e)) + except Exception as e: + skin.error(f"Error: {e}") + + skin.print_goodbye() + + +def _handle_repl_command(skin: Any, cmd: str) -> None: + """Parse and execute a REPL command.""" + parts = cmd.strip().split() + if not parts: + return + + client = get_client() + json_mode = "--json" in parts + parts = [p for p in parts if p != "--json"] + + # notebook commands + if parts[0] == "notebook": + 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) + get_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") + + # doc commands + elif parts[0] == "doc": + 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")] + nb_id = parts[2] + doc_path = parts[3] + doc_id = client.create_doc_with_md(nb_id, doc_path, md) + get_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]) + if json_mode: + click.echo(json.dumps(docs, ensure_ascii=False)) + else: + skin.table(["ID", "Name", "Type"], + [[d.get("id", ""), d.get("name", ""), d.get("type", "")] + for d in docs]) + elif sub == "tree" and len(parts) >= 3: + tree = client.list_doc_tree(parts[2]) + if json_mode: + click.echo(json.dumps(tree, ensure_ascii=False)) + else: + skin.table(["ID", "Title", "Path"], + [[t.get("id", ""), t.get("title", ""), t.get("path", "")] + for t in tree]) + 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") + + # block commands + elif parts[0] == "block": + if len(parts) < 2: + skin.error("Usage: block <insert|prepend|append|update|delete|get|child>") + return + sub = parts[1] + if sub == "insert" and len(parts) >= 4: + result = client.insert_block("markdown", parts[3], previous_id=parts[2] if len(parts) > 2 else "") + if json_mode: + click.echo(json.dumps(result, ensure_ascii=False)) + else: + skin.success("Block inserted") + elif sub == "prepend" and len(parts) >= 4: + client.prepend_block("markdown", parts[3], parts[2]) + skin.success("Block prepended") + elif sub == "append" and len(parts) >= 4: + client.append_block("markdown", parts[3], parts[2]) + skin.success("Block appended") + elif sub == "update" and len(parts) >= 4: + client.update_block("markdown", parts[3], 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") + + # sql command + elif parts[0] == "sql" and len(parts) >= 2: + 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) + + # search command + elif parts[0] == "search" and len(parts) >= 2: + query = " ".join(parts[1:]) + results = client.search_blocks(query) + if json_mode: + click.echo(json.dumps(results, ensure_ascii=False)) + elif not results: + skin.info("No results") + else: + skin.table(["ID", "Content"], + [[r.get("id", ""), r.get("content", "")[:80]] for r in results]) + + # export command + elif parts[0] == "export" and len(parts) >= 3: + 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>") + + else: + skin.error(f"Unknown command: {parts[0]}") + + +# ── 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) + ctx.session.update(current_notebook_id=notebook_id) + ctx.session.flush() + click.echo(f"Opened notebook: {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") +@click.pass_obj +def doc_create(ctx: SiYuanContext, notebook_id: str, path: str, md: str): + """Create a document with optional Markdown content.""" + 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) + items = tree.get("tree", []) if isinstance(tree, dict) else tree + if ctx.json_output: + click.echo(json.dumps(items, ensure_ascii=False)) + else: + for t in items: + indent = " " * t.get("depth", 0) + click.echo(f"{indent}{t.get('title', '')} ({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") +@click.option("--previous", default="", help="Previous block ID") +@click.option("--parent", default="", help="Parent block ID") +@click.option("--data-type", default="markdown", help="Data type (markdown/dom)") +@click.pass_obj +def block_insert(ctx: SiYuanContext, data: str, previous: str, parent: str, data_type: str): + """Insert a block.""" + result = ctx.client.insert_block(data_type, data, parent_id=parent, previous_id=previous) + 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") +@click.option("--data-type", default="markdown", help="Data type") +@click.pass_obj +def block_update(ctx: SiYuanContext, block_id: str, data: str, data_type: str): + """Update a block's content.""" + 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.""" + siyuan_ver = ctx.client.get_version() + session = ctx.session + state = session.state + + info = { + "connected": state.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 (标签).""" + + +@tag.command("list") +@click.pass_obj +def tag_list(ctx: SiYuanContext): + """List all tags.""" + tags = ctx.client.get_tags() + if ctx.json_output: + click.echo(json.dumps(tags, ensure_ascii=False)) + else: + for t in tags: + click.echo(f"{t.get('name', ''):<30} ({t.get('count', 0)})") diff --git a/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md b/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md new file mode 100644 index 000000000..788f874ef --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md @@ -0,0 +1,93 @@ +--- +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 +cli-anything-siyuan doc create nb1 /projects/new "## Title\n\nContent" + +# 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 diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/__init__.py b/siyuan/agent-harness/cli_anything/siyuan/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py new file mode 100644 index 000000000..65aa748c2 --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py @@ -0,0 +1,265 @@ +"""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_tree(self, runner, mock_ctx): + """doc tree handles API returning dict with 'tree' key.""" + mock_ctx.client.list_doc_tree.return_value = { + "tree": [ + {"id": "doc1", "title": "根文档", "depth": 0}, + {"id": "doc2", "title": "子文档", "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 tree array.""" + mock_ctx.json_output = True + mock_ctx.client.list_doc_tree.return_value = { + "tree": [{"id": "doc1", "title": "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), + patch("cli_anything.siyuan.siyuan_cli.get_session") as mock_gs, + ): + 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_gs.return_value = 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() + + +# ── 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 diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_core.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_core.py new file mode 100644 index 000000000..5cfa33d0c --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_core.py @@ -0,0 +1,161 @@ +"""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 diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py new file mode 100644 index 000000000..403833f33 --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py @@ -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.") + "." + name.split("-")[-1] + "_cli" + 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, tmp_dir: Path): + """--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, tmp_dir: Path): + """--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}") diff --git a/siyuan/agent-harness/cli_anything/siyuan/utils/__init__.py b/siyuan/agent-harness/cli_anything/siyuan/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/siyuan/agent-harness/cli_anything/siyuan/utils/repl_skin.py b/siyuan/agent-harness/cli_anything/siyuan/utils/repl_skin.py new file mode 100644 index 000000000..bc1fb6d1d --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/utils/repl_skin.py @@ -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 +} diff --git a/siyuan/agent-harness/cli_anything/siyuan/utils/siyuan_backend.py b/siyuan/agent-harness/cli_anything/siyuan/utils/siyuan_backend.py new file mode 100644 index 000000000..726b962a3 --- /dev/null +++ b/siyuan/agent-harness/cli_anything/siyuan/utils/siyuan_backend.py @@ -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 "" diff --git a/siyuan/agent-harness/setup.py b/siyuan/agent-harness/setup.py new file mode 100644 index 000000000..395f23c12 --- /dev/null +++ b/siyuan/agent-harness/setup.py @@ -0,0 +1,40 @@ +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", "tests/TEST.md"], + }, + 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", + ], +) diff --git a/skills/cli-anything-siyuan/SKILL.md b/skills/cli-anything-siyuan/SKILL.md new file mode 100644 index 000000000..4dfe64d20 --- /dev/null +++ b/skills/cli-anything-siyuan/SKILL.md @@ -0,0 +1,141 @@ +--- +name: "cli-anything-siyuan" +description: >- + Command-line interface for SiYuan (思源笔记) — manage notebooks, documents, blocks, and search content via SiYuan HTTP API. Designed for AI agents and power users who need to operate their knowledge base without the GUI. +--- + +# cli-anything-siyuan + +Command-line interface for [SiYuan](https://github.com/siyuan-note/siyuan) (思源笔记), a local-first knowledge management system. Connects to a running SiYuan kernel via its REST API (`http://127.0.0.1:6806`). + +## Installation + +```bash +# Install from source +cd siyuan/agent-harness +pip install -e ".[repl]" +``` + +**Prerequisites:** +- Python 3.10+ +- SiYuan must be running with HTTP API enabled (default port 6806) + +## Usage + +### Basic Commands + +```bash +# Show help +cli-anything-siyuan --help + +# Check connection status +cli-anything-siyuan status + +# Start interactive REPL mode +cli-anything-siyuan + +# Run with JSON output (for agent consumption) +cli-anything-siyuan --json notebook list +``` + +### REPL Mode + +When invoked without a subcommand, the CLI enters an interactive REPL session with tab-completion: + +```bash +cli-anything-siyuan +# siyuan ❯ notebook list +# siyuan ❯ search "keyword" +``` + +## Command Groups + +### Notebook + +Manage notebooks. + +| Command | Description | +|---------|-------------| +| `list` | List all notebooks | +| `create <name>` | Create a notebook | +| `rename <id> <name>` | Rename a notebook | +| `remove <id>` | Delete a notebook (requires `--dangerous`) | +| `open <id>` | Open a notebook | + +### Document + +Manage documents within notebooks. + +| Command | Description | +|---------|-------------| +| `create <notebook> <path>` | Create a document | +| `list <notebook> [path]` | List documents | +| `tree <notebook>` | Show document tree | +| `get <doc-id>` | Get document path and title | +| `rename <id> <title>` | Rename a document | +| `remove <id>` | Delete a document | + +### Block + +Content block operations. + +| Command | Description | +|---------|-------------| +| `get <block-id>` | View block source | +| `children <block-id>` | View child blocks | +| `insert <data>` | Insert a block | +| `update <id> <data>` | Update a block | +| `delete <id>` | Delete a block | + +### Search & Query + +| Command | Description | +|---------|-------------| +| `search <query>` | Full-text search across all blocks | +| `sql <stmt>` | Run SQL queries against the SiYuan database | +| `export md <doc-id>` | Export a document as Markdown | +| `tag list` | List all tags | + +### System + +| Command | Description | +|---------|-------------| +| `status` | Check connection and version info | +| `version` | Show SiYuan kernel version | + +## Configuration + +Create `~/.siyuan-cli.json`: + +```json +{ + "host": "127.0.0.1", + "port": 6806, + "token": "your-api-token" +} +``` + +API token can be found in SiYuan: Settings → About → API Token. + +Alternatively, use environment variables: `SIYUAN_HOST`, `SIYUAN_PORT`, `SIYUAN_TOKEN`. + +## Testing + +```bash +# Install test dependencies +pip install -e ".[test]" + +# Run unit tests (no external dependencies) +pytest cli_anything/siyuan/tests/test_core.py cli_anything/siyuan/tests/test_cli_commands.py -v + +# Run all tests including E2E (requires running SiYuan) +pytest cli_anything/siyuan/tests/ -v +``` + +**33 tests** (18 unit + 15 CLI command tests + optional E2E tests against a live instance). + +## Notes + +- All destructive operations (`remove`, `delete`, `rename`) require `--dangerous` flag +- JSON output mode (`--json`) is available on all commands for machine consumption +- SiYuan must be running — the CLI does not start the kernel From 63d739e5aac97a4eeee0cfbe06b0ba6d3bbc0e66 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Tue, 26 May 2026 10:41:55 +0800 Subject: [PATCH 02/32] fix: normalize REPL search/doc responses and fix block insert - REPL search: handle dict response (fixes KeyError when API returns {blocks: [...]}) - REPL doc list/tree: normalize dict responses before table output - REPL block insert: use parent_id instead of previous_id - status command: use live ping result instead of stale session state --- .../cli_anything/siyuan/siyuan_cli.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index e462c9026..12daf29a7 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -240,20 +240,22 @@ def _handle_repl_command(skin: Any, cmd: str) -> None: 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(docs, ensure_ascii=False)) + 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 docs]) + for d in items]) elif sub == "tree" and len(parts) >= 3: tree = client.list_doc_tree(parts[2]) + items = tree.get("tree", []) if isinstance(tree, dict) else tree if json_mode: - click.echo(json.dumps(tree, ensure_ascii=False)) + click.echo(json.dumps(items, ensure_ascii=False)) else: skin.table(["ID", "Title", "Path"], [[t.get("id", ""), t.get("title", ""), t.get("path", "")] - for t in tree]) + for t in items]) elif sub == "get" and len(parts) >= 3: hpath = client.get_hpath_by_id(parts[2]) if json_mode: @@ -283,7 +285,7 @@ def _handle_repl_command(skin: Any, cmd: str) -> None: return sub = parts[1] if sub == "insert" and len(parts) >= 4: - result = client.insert_block("markdown", parts[3], previous_id=parts[2] if len(parts) > 2 else "") + result = client.insert_block("markdown", parts[3], parent_id=parts[2]) if json_mode: click.echo(json.dumps(result, ensure_ascii=False)) else: @@ -333,14 +335,15 @@ def _handle_repl_command(skin: Any, cmd: str) -> None: # search command elif parts[0] == "search" and len(parts) >= 2: query = " ".join(parts[1:]) - results = client.search_blocks(query) + data = client.search_blocks(query) + blocks = data.get("blocks", []) if isinstance(data, dict) else data if json_mode: - click.echo(json.dumps(results, ensure_ascii=False)) - elif not results: + 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 results]) + [[r.get("id", ""), r.get("content", "")[:80]] for r in blocks]) # export command elif parts[0] == "export" and len(parts) >= 3: @@ -650,12 +653,13 @@ def version(ctx: SiYuanContext): @click.pass_obj def status(ctx: SiYuanContext): """Show connection and session status.""" - siyuan_ver = ctx.client.get_version() + connected = ctx.client.ping() + siyuan_ver = ctx.client.get_version() if connected else "" session = ctx.session state = session.state info = { - "connected": state.connected, + "connected": connected, "siyuan_version": siyuan_ver, "host": ctx.client.config.host, "port": ctx.client.config.port, From a651bf39d0017a1111c27082a5946147eef883ea Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Tue, 26 May 2026 10:44:05 +0800 Subject: [PATCH 03/32] fix: remove missing TEST.md reference from setup.py, fix test count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup.py referenced tests/TEST.md which doesn't exist, causing package build warnings - SKILL.md test count was incorrect (18 unit → 12 unit) --- siyuan/agent-harness/setup.py | 3 ++- skills/cli-anything-siyuan/SKILL.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/siyuan/agent-harness/setup.py b/siyuan/agent-harness/setup.py index 395f23c12..581e88acb 100644 --- a/siyuan/agent-harness/setup.py +++ b/siyuan/agent-harness/setup.py @@ -12,8 +12,9 @@ setup( author="cli-anything", packages=find_namespace_packages(include=["cli_anything.*"]), package_data={ - "cli_anything.siyuan": ["skills/*.md", "tests/TEST.md"], + "cli_anything.siyuan": ["skills/*.md"], }, + include_package_data=True, python_requires=">=3.10", install_requires=[ "click>=8.0", diff --git a/skills/cli-anything-siyuan/SKILL.md b/skills/cli-anything-siyuan/SKILL.md index 4dfe64d20..84c9df72f 100644 --- a/skills/cli-anything-siyuan/SKILL.md +++ b/skills/cli-anything-siyuan/SKILL.md @@ -132,7 +132,7 @@ pytest cli_anything/siyuan/tests/test_core.py cli_anything/siyuan/tests/test_cli pytest cli_anything/siyuan/tests/ -v ``` -**33 tests** (18 unit + 15 CLI command tests + optional E2E tests against a live instance). +**33 tests** (12 unit + 15 CLI command tests + 6 optional E2E tests against a live instance). ## Notes From c9627bc6a4489c3dafaeecf4b5e3e51af93dbf0e Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Tue, 26 May 2026 10:51:42 +0800 Subject: [PATCH 04/32] fix: use shlex for REPL tokenization, catch more request errors - REPL: replace str.split() with shlex.split() to handle quoted multi-word arguments (fixes block insert/update with spaces) - Client: catch Timeout, SSL, and generic RequestException in addition to ConnectionError for better error messages --- siyuan/agent-harness/cli_anything/siyuan/core/client.py | 8 ++++++++ siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index b4f0776b9..2457016b6 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -73,6 +73,14 @@ class SiYuanClient: 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( diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index 12daf29a7..6dade87f8 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -6,6 +6,7 @@ commands for notebooks, documents, blocks, search, and export. import json import os +import shlex import sys from pathlib import Path from typing import Any @@ -180,7 +181,7 @@ def repl(ctx: click.Context): def _handle_repl_command(skin: Any, cmd: str) -> None: """Parse and execute a REPL command.""" - parts = cmd.strip().split() + parts = shlex.split(cmd.strip()) if not parts: return From 4cc273ac392d99f5c33eb5df887d1eb4c47e2e58 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Tue, 26 May 2026 10:58:30 +0800 Subject: [PATCH 05/32] fix: normalize createNotebook response, validate block insert anchor - create_notebook: unwrap {notebook: {...}} response so callers can access nb["id"] and nb["name"] directly - block insert: require --parent or --previous, error if both empty (SiYuan API requires at least one position anchor) --- siyuan/agent-harness/cli_anything/siyuan/core/client.py | 5 ++++- siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index 2457016b6..f213276ba 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -115,7 +115,10 @@ class SiYuanClient: self._post("/api/notebook/closeNotebook", {"notebook": notebook_id}) def create_notebook(self, name: str) -> dict[str, Any]: - return self._post("/api/notebook/createNotebook", {"name": name}) + 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}) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index 6dade87f8..7f84577a8 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -526,6 +526,8 @@ def block(): @click.pass_obj def block_insert(ctx: SiYuanContext, data: str, previous: str, parent: str, data_type: str): """Insert a block.""" + if not parent and not previous: + raise click.UsageError("Either --parent or --previous is required for block insert") result = ctx.client.insert_block(data_type, data, parent_id=parent, previous_id=previous) if ctx.json_output: click.echo(json.dumps(result, ensure_ascii=False)) From 4a06d2046e85012b943203b559ef2d919b734840 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Tue, 26 May 2026 11:22:25 +0800 Subject: [PATCH 06/32] fix: require an anchor for block insert Add --next option for insertBlock anchor support. Validate that at least one anchor (--parent/--previous/--next) is provided. Add REPL usage error for missing anchor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- .../cli_anything/siyuan/siyuan_cli.py | 14 +++-- .../siyuan/tests/test_cli_commands.py | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index 7f84577a8..106944c59 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -285,7 +285,10 @@ def _handle_repl_command(skin: Any, cmd: str) -> None: skin.error("Usage: block <insert|prepend|append|update|delete|get|child>") return sub = parts[1] - if sub == "insert" and len(parts) >= 4: + if sub == "insert": + if len(parts) < 4: + skin.error("Usage: block insert <parent_id> <data>") + return result = client.insert_block("markdown", parts[3], parent_id=parts[2]) if json_mode: click.echo(json.dumps(result, ensure_ascii=False)) @@ -522,13 +525,14 @@ def block(): @click.argument("data") @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, previous: str, parent: str, data_type: str): +def block_insert(ctx: SiYuanContext, data: str, previous: str, parent: str, next_: str, data_type: str): """Insert a block.""" - if not parent and not previous: - raise click.UsageError("Either --parent or --previous is required for block insert") - result = ctx.client.insert_block(data_type, data, parent_id=parent, previous_id=previous) + if not parent and not previous and not next_: + raise click.UsageError("An anchor is required: --parent, --previous, or --next") + 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: diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py index 65aa748c2..6452ece99 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py @@ -252,6 +252,62 @@ class TestStatusCommand: 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 ──────────────────────────────────────────────────── From 791d94cd21d436f0fbbc4858cd14b7279debde91 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Tue, 26 May 2026 11:36:12 +0800 Subject: [PATCH 07/32] fix: persist REPL session on exit and handle malformed config - Flush session state before REPL quit/exit/q so context is preserved - Gracefully handle malformed config JSON (fall back to env/defaults) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- siyuan/agent-harness/cli_anything/siyuan/core/client.py | 5 ++++- siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index f213276ba..3704152ad 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -35,7 +35,10 @@ def load_config(config_path: str | None = None) -> SiYuanConfig: config_file = Path.home() / ".siyuan-cli.json" if config_file.is_file(): - data = json.loads(config_file.read_text(encoding="utf-8")) + try: + data = json.loads(config_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + data = {} return SiYuanConfig( host=data.get("host", "127.0.0.1"), port=data.get("port", 6806), diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index 106944c59..e4fec3ef6 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -155,6 +155,7 @@ def repl(ctx: click.Context): continue if user_input in ("quit", "exit", "q"): + ctx.session.flush() break if user_input == "help": From e1f81e99b324f4c330f4cfcc8b38a621ec13ea5e Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Wed, 27 May 2026 16:00:10 +0800 Subject: [PATCH 08/32] fix: remove public_registry.json addition, sync root SKILL.md from source - Remove siyuan entry from public_registry.json (in-repo harness should not be listed as a public CLI) - Sync skills/cli-anything-siyuan/SKILL.md from harness source via sync_root_skills.py Addresses yuh-yang review feedback. --- public_registry.json | 23 ---- skills/cli-anything-siyuan/SKILL.md | 158 ++++++++++------------------ 2 files changed, 55 insertions(+), 126 deletions(-) diff --git a/public_registry.json b/public_registry.json index 82fe3075e..6b0eeb99c 100644 --- a/public_registry.json +++ b/public_registry.json @@ -362,29 +362,6 @@ "url": "https://github.com/MartaKar" } ] - }, - { - "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": "https://github.com/HKUDS/CLI-Anything", - "package_manager": "pip", - "install_strategy": "command", - "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=siyuan/agent-harness", - "uninstall_cmd": "pip uninstall cli-anything-siyuan -y", - "update_cmd": "pip install --upgrade 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" - } - ] } ] } diff --git a/skills/cli-anything-siyuan/SKILL.md b/skills/cli-anything-siyuan/SKILL.md index 84c9df72f..a921df680 100644 --- a/skills/cli-anything-siyuan/SKILL.md +++ b/skills/cli-anything-siyuan/SKILL.md @@ -1,141 +1,93 @@ --- name: "cli-anything-siyuan" -description: >- - Command-line interface for SiYuan (思源笔记) — manage notebooks, documents, blocks, and search content via SiYuan HTTP API. Designed for AI agents and power users who need to operate their knowledge base without the GUI. +description: SiYuan (思源笔记) CLI — manage notebooks, documents, blocks, and search your knowledge base from the terminal. --- # cli-anything-siyuan -Command-line interface for [SiYuan](https://github.com/siyuan-note/siyuan) (思源笔记), a local-first knowledge management system. Connects to a running SiYuan kernel via its REST API (`http://127.0.0.1:6806`). +CLI harness for [SiYuan](https://github.com/siyuan-note/siyuan) (思源笔记), +a local-first knowledge management and note-taking application. -## Installation +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. -```bash -# Install from source -cd siyuan/agent-harness -pip install -e ".[repl]" -``` +## Prerequisites -**Prerequisites:** +- SiYuan must be running (Settings → About shows the API token) - Python 3.10+ -- SiYuan must be running with HTTP API enabled (default port 6806) - -## Usage - -### Basic Commands - -```bash -# Show help -cli-anything-siyuan --help - -# Check connection status -cli-anything-siyuan status - -# Start interactive REPL mode -cli-anything-siyuan - -# Run with JSON output (for agent consumption) -cli-anything-siyuan --json notebook list -``` - -### REPL Mode - -When invoked without a subcommand, the CLI enters an interactive REPL session with tab-completion: - -```bash -cli-anything-siyuan -# siyuan ❯ notebook list -# siyuan ❯ search "keyword" -``` +- Install: `pip install cli-anything-siyuan` or `pip install -e .` from agent-harness/ ## Command Groups -### Notebook - -Manage notebooks. - -| Command | Description | -|---------|-------------| +### notebook — Notebook management +| Subcommand | Description | +|------------|-------------| | `list` | List all notebooks | -| `create <name>` | Create a notebook | +| `create <name>` | Create a new notebook | | `rename <id> <name>` | Rename a notebook | -| `remove <id>` | Delete a notebook (requires `--dangerous`) | +| `remove <id>` | Delete a notebook | | `open <id>` | Open a notebook | -### Document - -Manage documents within notebooks. - -| Command | Description | -|---------|-------------| -| `create <notebook> <path>` | Create a document | +### doc — Document management +| Subcommand | Description | +|------------|-------------| +| `create <notebook> <path> [--md "content"]` | Create a document | | `list <notebook> [path]` | List documents | -| `tree <notebook>` | Show document tree | -| `get <doc-id>` | Get document path and title | +| `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 - -Content block operations. - -| Command | Description | -|---------|-------------| -| `get <block-id>` | View block source | -| `children <block-id>` | View child blocks | -| `insert <data>` | Insert a block | +### 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 | -### Search & Query - +### Other commands | Command | Description | |---------|-------------| -| `search <query>` | Full-text search across all blocks | -| `sql <stmt>` | Run SQL queries against the SiYuan database | -| `export md <doc-id>` | Export a document as Markdown | +| `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 | - -### System - -| Command | Description | -|---------|-------------| -| `status` | Check connection and version info | | `version` | Show SiYuan kernel version | +| `status` | Show connection and session status | +| `repl` | Start interactive REPL | -## Configuration +## Agent Guidance -Create `~/.siyuan-cli.json`: +- 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` -```json -{ - "host": "127.0.0.1", - "port": 6806, - "token": "your-api-token" -} -``` - -API token can be found in SiYuan: Settings → About → API Token. - -Alternatively, use environment variables: `SIYUAN_HOST`, `SIYUAN_PORT`, `SIYUAN_TOKEN`. - -## Testing +## Examples ```bash -# Install test dependencies -pip install -e ".[test]" +# List notebooks (JSON) +cli-anything-siyuan --json notebook list -# Run unit tests (no external dependencies) -pytest cli_anything/siyuan/tests/test_core.py cli_anything/siyuan/tests/test_cli_commands.py -v +# Create a document with Markdown +cli-anything-siyuan doc create nb1 /projects/new "## Title\n\nContent" -# Run all tests including E2E (requires running SiYuan) -pytest cli_anything/siyuan/tests/ -v +# 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 ``` -**33 tests** (12 unit + 15 CLI command tests + 6 optional E2E tests against a live instance). +## Error Handling -## Notes - -- All destructive operations (`remove`, `delete`, `rename`) require `--dangerous` flag -- JSON output mode (`--json`) is available on all commands for machine consumption -- SiYuan must be running — the CLI does not start the kernel +- 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 From ac48f66e45a65ff4a5d06fbf702a4a570ad65c13 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Wed, 27 May 2026 16:10:50 +0800 Subject: [PATCH 09/32] fix: flush REPL session via context object (ctx.obj) instead of ctx --- siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index e4fec3ef6..3956547e4 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -155,7 +155,7 @@ def repl(ctx: click.Context): continue if user_input in ("quit", "exit", "q"): - ctx.session.flush() + ctx.obj.session.flush() break if user_input == "help": From c74c29723a1af1764c117bf3bbc49c0dc3f90a35 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Wed, 27 May 2026 16:11:19 +0800 Subject: [PATCH 10/32] fix: fall back to env vars when config file is malformed --- .../cli_anything/siyuan/core/client.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index 3704152ad..7e07deab4 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -38,12 +38,14 @@ def load_config(config_path: str | None = None) -> SiYuanConfig: try: data = json.loads(config_file.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): - data = {} - return SiYuanConfig( - host=data.get("host", "127.0.0.1"), - port=data.get("port", 6806), - token=data.get("token", ""), - ) + data = None + + if data is not None: + return SiYuanConfig( + host=data.get("host", "127.0.0.1"), + port=data.get("port", 6806), + token=data.get("token", ""), + ) return SiYuanConfig( host=os.environ.get("SIYUAN_HOST", "127.0.0.1"), From abcf19b5d9380e139b111c682654d1d943ffb7b2 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Wed, 27 May 2026 16:20:57 +0800 Subject: [PATCH 11/32] fix: convert SiYuanClientError to clean CLI error in one-shot commands --- .../agent-harness/cli_anything/siyuan/siyuan_cli.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index 3956547e4..814dac1e6 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -55,7 +55,18 @@ class SiYuanContext: self.session = get_session() -@click.group(invoke_without_command=True) +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)") From fe27d44825ac117ca5732608405e274cd84257bb Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Wed, 27 May 2026 16:32:18 +0800 Subject: [PATCH 12/32] fix: read doc-tree entries from files key instead of tree (SiYuan API shape) --- .../cli_anything/siyuan/siyuan_cli.py | 10 +++++----- .../cli_anything/siyuan/tests/test_cli_commands.py | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index 814dac1e6..a1c62552c 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -262,12 +262,12 @@ def _handle_repl_command(skin: Any, cmd: str) -> None: for d in items]) elif sub == "tree" and len(parts) >= 3: tree = client.list_doc_tree(parts[2]) - items = tree.get("tree", []) if isinstance(tree, dict) else tree + items = tree.get("files", []) if isinstance(tree, dict) else tree if json_mode: click.echo(json.dumps(items, ensure_ascii=False)) else: - skin.table(["ID", "Title", "Path"], - [[t.get("id", ""), t.get("title", ""), t.get("path", "")] + skin.table(["ID", "Name", "Path"], + [[t.get("id", ""), t.get("name", ""), t.get("path", "")] for t in items]) elif sub == "get" and len(parts) >= 3: hpath = client.get_hpath_by_id(parts[2]) @@ -486,13 +486,13 @@ def doc_list(ctx: SiYuanContext, notebook_id: str, path: str): 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) - items = tree.get("tree", []) if isinstance(tree, dict) else tree + items = tree.get("files", []) if isinstance(tree, dict) else tree if ctx.json_output: click.echo(json.dumps(items, ensure_ascii=False)) else: for t in items: indent = " " * t.get("depth", 0) - click.echo(f"{indent}{t.get('title', '')} ({t.get('id', '')})") + click.echo(f"{indent}{t.get('name', '')} ({t.get('id', '')})") @doc.command("get") diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py index 6452ece99..780a4e317 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py @@ -141,12 +141,12 @@ class TestDocListCommand: class TestDocTreeCommand: - def test_doc_tree_returns_tree(self, runner, mock_ctx): - """doc tree handles API returning dict with 'tree' key.""" + 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 = { - "tree": [ - {"id": "doc1", "title": "根文档", "depth": 0}, - {"id": "doc2", "title": "子文档", "depth": 1}, + "files": [ + {"id": "doc1", "name": "根文档", "depth": 0}, + {"id": "doc2", "name": "子文档", "depth": 1}, ], } with patch("cli_anything.siyuan.siyuan_cli.SiYuanContext", return_value=mock_ctx): @@ -156,10 +156,10 @@ class TestDocTreeCommand: assert "子文档" in result.output def test_doc_tree_json(self, runner, mock_ctx): - """--json doc tree returns raw tree array.""" + """--json doc tree returns raw files array.""" mock_ctx.json_output = True mock_ctx.client.list_doc_tree.return_value = { - "tree": [{"id": "doc1", "title": "Root"}], + "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"]) From 72fa605c8f701821a2dc1d9f951633c03b8e0398 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Wed, 27 May 2026 16:38:42 +0800 Subject: [PATCH 13/32] fix: request uncapped tag list (ignoreMaxListHint) --- siyuan/agent-harness/cli_anything/siyuan/core/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index 7e07deab4..73ef826f1 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -289,7 +289,7 @@ class SiYuanClient: # ── Tag API ──────────────────────────────────────────────────────── def get_tags(self) -> list[dict[str, Any]]: - return self._post("/api/tag/getTag", {}) + return self._post("/api/tag/getTag", {"ignoreMaxListHint": True}) # ── System API ───────────────────────────────────────────────────── From 93c78f426a4671fd999f396ed7d3b2eca83a482c Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Thu, 28 May 2026 10:44:00 +0800 Subject: [PATCH 14/32] fix: support stdin for block insert/update data (use '-' or omit for pipe) --- .../cli_anything/siyuan/siyuan_cli.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index a1c62552c..ac766721a 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -534,16 +534,18 @@ def block(): @block.command("insert") -@click.argument("data") +@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, previous: str, parent: str, next_: str, data_type: str): - """Insert a block.""" +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 = click.get_text_stream("stdin").read() 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)) @@ -553,11 +555,13 @@ def block_insert(ctx: SiYuanContext, data: str, previous: str, parent: str, next @block.command("update") @click.argument("block_id") -@click.argument("data") +@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, data_type: str): - """Update a block's content.""" +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 = click.get_text_stream("stdin").read() ctx.client.update_block(data_type, data, block_id) click.echo(f"Updated block: {block_id}") From c9d050a0f0f9be75b76b9cf1eff99b6693e71ff2 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Thu, 28 May 2026 10:51:55 +0800 Subject: [PATCH 15/32] docs: add stdin pipe usage for block insert/update, tag list note --- siyuan/agent-harness/SIYUAN.md | 20 +++++++++++++++++++ .../cli_anything/siyuan/README.md | 10 ++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/siyuan/agent-harness/SIYUAN.md b/siyuan/agent-harness/SIYUAN.md index 8d3310140..0cc07d2cd 100644 --- a/siyuan/agent-harness/SIYUAN.md +++ b/siyuan/agent-harness/SIYUAN.md @@ -137,3 +137,23 @@ Since SiYuan has no headless CLI backend, the harness will: 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. diff --git a/siyuan/agent-harness/cli_anything/siyuan/README.md b/siyuan/agent-harness/cli_anything/siyuan/README.md index 222826702..c06dcd94c 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/README.md +++ b/siyuan/agent-harness/cli_anything/siyuan/README.md @@ -68,6 +68,12 @@ 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 @@ -108,8 +114,8 @@ siyuan ❯ quit | `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 | -| `block update <id> <data>` | Update a block | +| `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 | From ffda748798106c6967ff737d66b8ef8e4cc8c3f4 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Thu, 28 May 2026 17:17:14 +0800 Subject: [PATCH 16/32] fix: notebook_open updates name, tag list shows nested tags - notebook_open now looks up the notebook name via list_notebooks() and updates current_notebook_name to avoid stale display - tag list recursively prints child tags with indentation --- .../cli_anything/drawio/core/export.py | 6 +++-- .../cli_anything/drawio/drawio_cli.py | 5 ++-- .../cli_anything/siyuan/siyuan_cli.py | 27 +++++++++++++++---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/drawio/agent-harness/cli_anything/drawio/core/export.py b/drawio/agent-harness/cli_anything/drawio/core/export.py index c43392c25..1420a4c4d 100644 --- a/drawio/agent-harness/cli_anything/drawio/core/export.py +++ b/drawio/agent-harness/cli_anything/drawio/core/export.py @@ -36,6 +36,7 @@ def render(session: Session, output_path: str, height: Optional[int] = None, transparent: bool = False, crop: bool = False, + border: int = 0, overwrite: bool = False) -> dict: """Export the current project to a file. @@ -99,6 +100,7 @@ def render(session: Session, output_path: str, height=height, transparent=transparent, crop=crop, + border=border, overwrite=True, # We already checked above ) result["action"] = "export" @@ -108,12 +110,12 @@ def render(session: Session, output_path: str, def render_or_save(session: Session, output_path: str, - fmt: str = "png", **kwargs) -> dict: + fmt: str = "png", border: int = 0, **kwargs) -> dict: """Export with fallback: if draw.io CLI is not available, save the .drawio file and provide instructions for manual export. """ try: - return render(session, output_path, fmt, **kwargs) + return render(session, output_path, fmt, border=border, **kwargs) except RuntimeError as e: if "not installed" not in str(e): raise diff --git a/drawio/agent-harness/cli_anything/drawio/drawio_cli.py b/drawio/agent-harness/cli_anything/drawio/drawio_cli.py index 875a6fba1..65f1623d2 100644 --- a/drawio/agent-harness/cli_anything/drawio/drawio_cli.py +++ b/drawio/agent-harness/cli_anything/drawio/drawio_cli.py @@ -521,10 +521,11 @@ def export(): @click.option("--height", type=int, default=None, help="Output height (PNG)") @click.option("--transparent", is_flag=True, help="Transparent background (PNG)") @click.option("--crop", is_flag=True, help="Crop to content") +@click.option("--border", type=int, default=0, help="Border padding in pixels (PNG)") @click.option("--overwrite", is_flag=True, help="Overwrite existing file") @handle_error def export_render(output_path, fmt, page_index, scale, width, height, - transparent, crop, overwrite): + transparent, crop, border, overwrite): """Export the diagram to a file.""" session = get_session() result = export_mod.render_or_save( @@ -532,7 +533,7 @@ def export_render(output_path, fmt, page_index, scale, width, height, page_index=page_index, scale=scale, width=width, height=height, transparent=transparent, crop=crop, - overwrite=overwrite, + border=border, overwrite=overwrite, ) output(result, f"Exported to: {result.get('output', result.get('drawio_file', ''))}") diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index ac766721a..62c67803a 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -435,9 +435,14 @@ def notebook_rename(ctx: SiYuanContext, notebook_id: str, name: str): def notebook_open(ctx: SiYuanContext, notebook_id: str): """Open a notebook.""" ctx.client.open_notebook(notebook_id) - ctx.session.update(current_notebook_id=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: {notebook_id}") + click.echo(f"Opened notebook: {name} ({notebook_id})") # ── Document commands ────────────────────────────────────────────────── @@ -707,13 +712,25 @@ 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.""" + """List all tags (including nested tags).""" tags = ctx.client.get_tags() if ctx.json_output: click.echo(json.dumps(tags, ensure_ascii=False)) else: - for t in tags: - click.echo(f"{t.get('name', ''):<30} ({t.get('count', 0)})") + _print_tags(tags) From 083a49a1e7f725870bc15f2f8c1ed28679e74f8f Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Thu, 28 May 2026 17:19:18 +0800 Subject: [PATCH 17/32] fix: point dev fallback at executable package module _dev fallback in _resolve_cli now uses the package path (e.g. cli_anything.siyuan) which routes through __main__.py, instead of directly targeting the non-executable cli module. --- siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py index 403833f33..5f553259b 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py @@ -25,7 +25,7 @@ def _resolve_cli(name: str) -> list[str]: return [path] if force: raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .") - module = name.replace("cli-anything-", "cli_anything.") + "." + name.split("-")[-1] + "_cli" + 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] From a5a51f8e7ebd4eb53e7067f2f4fe3ad43586e03c Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Thu, 28 May 2026 17:19:56 +0800 Subject: [PATCH 18/32] docs: use --md flag for doc create example in skill --- siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md b/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md index 788f874ef..49455c3c5 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md +++ b/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md @@ -73,8 +73,8 @@ documents, blocks, search, and export. # List notebooks (JSON) cli-anything-siyuan --json notebook list -# Create a document with Markdown -cli-anything-siyuan doc create nb1 /projects/new "## Title\n\nContent" +# Create a document with Markdown (use --md flag) +cli-anything-siyuan doc create nb1 /projects/new --md "## Title\n\nContent" # SQL search cli-anything-siyuan sql "SELECT id, content FROM blocks WHERE content LIKE '%meeting%' LIMIT 5" From 6d42c3bcc099f4cfad7de61de254091df7319362 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Thu, 28 May 2026 17:34:17 +0800 Subject: [PATCH 19/32] fix: honor environment overrides when config file exists Env vars (SIYUAN_HOST/PORT/TOKEN) now take precedence over config file values, so CI/testing can override without modifying the config file. --- siyuan/agent-harness/cli_anything/siyuan/core/client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index 73ef826f1..0a902171c 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -41,10 +41,11 @@ def load_config(config_path: str | None = None) -> SiYuanConfig: data = None if data is not None: + # File values as base, env vars override return SiYuanConfig( - host=data.get("host", "127.0.0.1"), - port=data.get("port", 6806), - token=data.get("token", ""), + 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( From b7a067c59c8d7a9d9a305accb93a1eb6faabd17d Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Fri, 29 May 2026 11:24:43 +0800 Subject: [PATCH 20/32] fix: support stdin pipe for doc create --md to avoid shell escaping Markdown with backticks, parentheses, quotes gets mangled by shell argument parsing. Now `--md -` reads from stdin, bypassing the shell: echo "## Title" | sy doc create nb1 /path --md - --- .../cli_anything/siyuan/siyuan_cli.py | 11 +++++++++-- .../cli_anything/siyuan/skills/SKILL.md | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index 62c67803a..1efd3b09c 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -455,10 +455,17 @@ def doc(): @doc.command("create") @click.argument("notebook_id") @click.argument("path") -@click.option("--md", default="", help="Markdown content") +@click.option("--md", default="", help="Markdown content. Use '-' or omit 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.""" + """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 not md or md == "-": + md = click.get_text_stream("stdin").read() 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)) diff --git a/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md b/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md index 49455c3c5..fc7c14539 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md +++ b/siyuan/agent-harness/cli_anything/siyuan/skills/SKILL.md @@ -73,9 +73,22 @@ documents, blocks, search, and export. # List notebooks (JSON) cli-anything-siyuan --json notebook list -# Create a document with Markdown (use --md flag) +# 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" From f5124adce634b69f00a31b62f3a2a8ac99f3194b Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Fri, 29 May 2026 11:46:15 +0800 Subject: [PATCH 21/32] fix: read stdin as raw bytes, decode as UTF-8-sig for CJK support PowerShell pipes text using system code page (e.g. GBK), then Python's text-mode stdin reading may mangle CJK characters. Reading raw bytes via sys.stdin.buffer.read() preserves the original bytes before any encoding conversion, then decode('utf-8-sig') handles BOM + UTF-8. Also fixed doc_create, block_insert, block_update to use the new _read_stdin() helper instead of click.get_text_stream('stdin'). --- .../cli_anything/siyuan/siyuan_cli.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index 1efd3b09c..4510ee8f3 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -23,6 +23,20 @@ from cli_anything.siyuan.core.session import SessionManager from cli_anything.siyuan.utils.siyuan_backend import check_siyuan_running +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. + """ + raw = sys.stdin.buffer.read() + try: + return raw.decode('utf-8-sig') + except UnicodeDecodeError: + return raw.decode('utf-8-sig', errors='replace') + + # ── Global state ─────────────────────────────────────────────────────── _config: SiYuanConfig | None = None @@ -75,9 +89,10 @@ class _CatchErrors(click.Group): @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 emoji and CJK characters on Windows - if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8') + # 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. @@ -465,7 +480,7 @@ def doc_create(ctx: SiYuanContext, notebook_id: str, path: str, md: str): echo "## Title" | sy doc create nb1 /test --md - """ if not md or md == "-": - md = click.get_text_stream("stdin").read() + 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)) @@ -557,7 +572,7 @@ def block_insert(ctx: SiYuanContext, data: str | None, previous: str, parent: st 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 = click.get_text_stream("stdin").read() + 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)) @@ -573,7 +588,7 @@ def block_insert(ctx: SiYuanContext, data: str | None, previous: str, parent: st 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 = click.get_text_stream("stdin").read() + data = _read_stdin() ctx.client.update_block(data_type, data, block_id) click.echo(f"Updated block: {block_id}") From a769ce255de5116f77911637854a77e837351856 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Fri, 29 May 2026 12:01:37 +0800 Subject: [PATCH 22/32] fix: doc tree accepts both files and tree response keys SiYuan v3.4.2+ returns doc tree under 'tree' key instead of 'files'. Accept either before falling back to empty list. --- siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index 4510ee8f3..dbc140a5e 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -277,7 +277,10 @@ def _handle_repl_command(skin: Any, cmd: str) -> None: for d in items]) elif sub == "tree" and len(parts) >= 3: tree = client.list_doc_tree(parts[2]) - items = tree.get("files", []) if isinstance(tree, dict) else tree + 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: @@ -513,7 +516,10 @@ def doc_list(ctx: SiYuanContext, notebook_id: str, path: str): 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) - items = tree.get("files", []) if isinstance(tree, dict) else tree + 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: From 469ef1d91dcb24b114cb917e878b14ddf5843ce2 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Mon, 1 Jun 2026 19:20:31 +0800 Subject: [PATCH 23/32] =?UTF-8?q?@=20fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20doc=20create=20stdin,=20registry,=20drawio=20cleanu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doc create only reads stdin with --md - (not when --md is omitted) - registry.json source_url set to null (in-repo harness convention) - sync root skills/cli-anything-siyuan/SKILL.md with harness version - revert unrelated drawio border changes @ --- .../cli_anything/drawio/core/export.py | 6 ++---- .../cli_anything/drawio/drawio_cli.py | 5 ++--- registry.json | 2 +- .../cli_anything/siyuan/siyuan_cli.py | 4 ++-- skills/cli-anything-siyuan/SKILL.md | 19 ++++++++++++++++--- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/drawio/agent-harness/cli_anything/drawio/core/export.py b/drawio/agent-harness/cli_anything/drawio/core/export.py index 1420a4c4d..c43392c25 100644 --- a/drawio/agent-harness/cli_anything/drawio/core/export.py +++ b/drawio/agent-harness/cli_anything/drawio/core/export.py @@ -36,7 +36,6 @@ def render(session: Session, output_path: str, height: Optional[int] = None, transparent: bool = False, crop: bool = False, - border: int = 0, overwrite: bool = False) -> dict: """Export the current project to a file. @@ -100,7 +99,6 @@ def render(session: Session, output_path: str, height=height, transparent=transparent, crop=crop, - border=border, overwrite=True, # We already checked above ) result["action"] = "export" @@ -110,12 +108,12 @@ def render(session: Session, output_path: str, def render_or_save(session: Session, output_path: str, - fmt: str = "png", border: int = 0, **kwargs) -> dict: + fmt: str = "png", **kwargs) -> dict: """Export with fallback: if draw.io CLI is not available, save the .drawio file and provide instructions for manual export. """ try: - return render(session, output_path, fmt, border=border, **kwargs) + return render(session, output_path, fmt, **kwargs) except RuntimeError as e: if "not installed" not in str(e): raise diff --git a/drawio/agent-harness/cli_anything/drawio/drawio_cli.py b/drawio/agent-harness/cli_anything/drawio/drawio_cli.py index 65f1623d2..875a6fba1 100644 --- a/drawio/agent-harness/cli_anything/drawio/drawio_cli.py +++ b/drawio/agent-harness/cli_anything/drawio/drawio_cli.py @@ -521,11 +521,10 @@ def export(): @click.option("--height", type=int, default=None, help="Output height (PNG)") @click.option("--transparent", is_flag=True, help="Transparent background (PNG)") @click.option("--crop", is_flag=True, help="Crop to content") -@click.option("--border", type=int, default=0, help="Border padding in pixels (PNG)") @click.option("--overwrite", is_flag=True, help="Overwrite existing file") @handle_error def export_render(output_path, fmt, page_index, scale, width, height, - transparent, crop, border, overwrite): + transparent, crop, overwrite): """Export the diagram to a file.""" session = get_session() result = export_mod.render_or_save( @@ -533,7 +532,7 @@ def export_render(output_path, fmt, page_index, scale, width, height, page_index=page_index, scale=scale, width=width, height=height, transparent=transparent, crop=crop, - border=border, overwrite=overwrite, + overwrite=overwrite, ) output(result, f"Exported to: {result.get('output', result.get('drawio_file', ''))}") diff --git a/registry.json b/registry.json index 46a37776f..4a5854171 100644 --- a/registry.json +++ b/registry.json @@ -1236,7 +1236,7 @@ "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": "https://github.com/HKUDS/CLI-Anything", + "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", diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index dbc140a5e..a7bf135e7 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -473,7 +473,7 @@ def doc(): @doc.command("create") @click.argument("notebook_id") @click.argument("path") -@click.option("--md", default="", help="Markdown content. Use '-' or omit to read from stdin.") +@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. @@ -482,7 +482,7 @@ def doc_create(ctx: SiYuanContext, notebook_id: str, path: str, md: str): (backticks, quotes, parentheses), pipe Markdown via stdin: echo "## Title" | sy doc create nb1 /test --md - """ - if not md or md == "-": + if md == "-": md = _read_stdin() doc_id = ctx.client.create_doc_with_md(notebook_id, path, md) if ctx.json_output: diff --git a/skills/cli-anything-siyuan/SKILL.md b/skills/cli-anything-siyuan/SKILL.md index a921df680..fc7c14539 100644 --- a/skills/cli-anything-siyuan/SKILL.md +++ b/skills/cli-anything-siyuan/SKILL.md @@ -1,5 +1,5 @@ --- -name: "cli-anything-siyuan" +name: cli-anything-siyuan description: SiYuan (思源笔记) CLI — manage notebooks, documents, blocks, and search your knowledge base from the terminal. --- @@ -73,8 +73,21 @@ documents, blocks, search, and export. # List notebooks (JSON) cli-anything-siyuan --json notebook list -# Create a document with Markdown -cli-anything-siyuan doc create nb1 /projects/new "## Title\n\nContent" +# 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" From b3aaa0aa0e6b02d7cc0cf6f8754dabd2b0fc78fb Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Mon, 1 Jun 2026 19:29:12 +0800 Subject: [PATCH 24/32] =?UTF-8?q?@=20fix:=20code=20quality=20=E2=80=94=20t?= =?UTF-8?q?ty=20guard,=20refactor=20REPL,=20remove=20globals,=20stdin=20su?= =?UTF-8?q?pport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _read_stdin() checks isatty() before blocking, raises UsageError if no pipe - Split _handle_repl_command (200 lines) into _dispatch_repl + 6 sub-handlers - Remove module-level _config/_client/_session_mgr globals; use SiYuanContext - REPL block insert/update/prepend/append support "-" token for stdin - Remove unused SessionManager.auto_save decorator @ --- .../cli_anything/siyuan/core/session.py | 8 - .../cli_anything/siyuan/siyuan_cli.py | 463 +++++++++--------- .../siyuan/tests/test_cli_commands.py | 7 +- 3 files changed, 245 insertions(+), 233 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/session.py b/siyuan/agent-harness/cli_anything/siyuan/core/session.py index 748f4c295..252bbf4a3 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/session.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/session.py @@ -73,11 +73,3 @@ class SessionManager: if self._dirty: self.save() self._dirty = False - - def auto_save(func): - """Decorator: save session state after one-shot mutations.""" - def wrapper(self, *args, **kwargs): - result = func(self, *args, **kwargs) - self.flush() - return result - return wrapper diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index a7bf135e7..dcef50e5c 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -8,7 +8,6 @@ import json import os import shlex import sys -from pathlib import Path from typing import Any import click @@ -20,7 +19,6 @@ from cli_anything.siyuan.core.client import ( load_config, ) from cli_anything.siyuan.core.session import SessionManager -from cli_anything.siyuan.utils.siyuan_backend import check_siyuan_running def _read_stdin() -> str: @@ -30,6 +28,10 @@ def _read_stdin() -> str: 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') @@ -37,36 +39,15 @@ def _read_stdin() -> str: return raw.decode('utf-8-sig', errors='replace') -# ── Global state ─────────────────────────────────────────────────────── - -_config: SiYuanConfig | None = None -_client: SiYuanClient | None = None -_session_mgr: SessionManager | None = None - - -def get_client() -> SiYuanClient: - global _client, _config - if _client is None: - _config = load_config() - _client = SiYuanClient(_config) - return _client - - -def get_session() -> SessionManager: - global _session_mgr - if _session_mgr is None: - _session_mgr = SessionManager() - _session_mgr.load() - return _session_mgr - - # ── Click context ───────────────────────────────────────────────────── class SiYuanContext: - def __init__(self, json_output: bool = False): + def __init__(self, client: SiYuanClient | None = None, + session: SessionManager | None = None, + json_output: bool = False): self.json_output = json_output - self.client = get_client() - self.session = get_session() + self.client = client + self.session = session or SessionManager() class _CatchErrors(click.Group): @@ -102,19 +83,20 @@ def cli(ctx: click.Context, json_output: bool, host: str, port: int, Configure connection via ~/.siyuan-cli.json, env vars (SIYUAN_HOST, SIYUAN_PORT, SIYUAN_TOKEN), or CLI flags. """ - global _config, _client - if host or port or token or config_path: - cfg = load_config(config_path or None) - _config = SiYuanConfig( + 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(_config) - ctx.obj = SiYuanContext(json_output=json_output) + client = SiYuanClient(cfg) + ctx.obj = SiYuanContext(client=client, session=session, json_output=json_output) if ctx.invoked_subcommand is None: - if not get_client().ping(): + 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) @@ -134,8 +116,8 @@ def _build_repl_commands() -> dict[str, str]: "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", - "block update <id> <data>": "Update a block", + "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", @@ -153,8 +135,10 @@ 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 = get_client().get_version() + kernel_version = obj.client.get_version() except SiYuanClientError: kernel_version = "?" skin = ReplSkin("siyuan", version=kernel_version) @@ -163,8 +147,7 @@ def repl(ctx: click.Context): pt_session = skin.create_prompt_session() commands = _build_repl_commands() - session = get_session() - state = session.state + state = obj.session.state while True: ctx_str = "" @@ -181,7 +164,7 @@ def repl(ctx: click.Context): continue if user_input in ("quit", "exit", "q"): - ctx.obj.session.flush() + obj.session.flush() break if user_input == "help": @@ -189,15 +172,16 @@ def repl(ctx: click.Context): continue if user_input == "status": + connected = obj.client.ping() skin.status_block({ - "Connected": str(state.connected), + "Connected": str(connected), "Notebook": state.current_notebook_name or "(none)", "Document": state.current_doc_path or "(none)", }, title="Status") continue try: - _handle_repl_command(skin, user_input) + _dispatch_repl(skin, obj, user_input) except SiYuanClientError as e: skin.error(str(e)) except Exception as e: @@ -206,195 +190,234 @@ def repl(ctx: click.Context): skin.print_goodbye() -def _handle_repl_command(skin: Any, cmd: str) -> None: - """Parse and execute a REPL command.""" +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 - client = get_client() json_mode = "--json" in parts parts = [p for p in parts if p != "--json"] - # notebook commands - if parts[0] == "notebook": - 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) - get_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") - - # doc commands - elif parts[0] == "doc": - 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")] - nb_id = parts[2] - doc_path = parts[3] - doc_id = client.create_doc_with_md(nb_id, doc_path, md) - get_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("name", ""), t.get("path", "")] - for t in 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") - - # block commands - elif parts[0] == "block": - 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 - result = client.insert_block("markdown", parts[3], 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: - client.prepend_block("markdown", parts[3], parts[2]) - skin.success("Block prepended") - elif sub == "append" and len(parts) >= 4: - client.append_block("markdown", parts[3], parts[2]) - skin.success("Block appended") - elif sub == "update" and len(parts) >= 4: - client.update_block("markdown", parts[3], 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") - - # sql command - elif parts[0] == "sql" and len(parts) >= 2: - 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) - - # search command - elif parts[0] == "search" and len(parts) >= 2: - 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]) - - # export command - elif parts[0] == "export" and len(parts) >= 3: - 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>") + 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("name", ""), t.get("path", "")] + for t in 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() diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py index 780a4e317..57703dbbd 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py @@ -233,16 +233,13 @@ class TestStatusCommand: "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), - patch("cli_anything.siyuan.siyuan_cli.get_session") as mock_gs, - ): + 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_gs.return_value = mock_session + mock_ctx.session = mock_session mock_ctx.current_notebook_id = "nb1" mock_ctx.current_notebook_name = "工作" mock_ctx.current_doc_id = "doc1" From aec937128edeaac0d339660e33a582e55ab45a33 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Mon, 1 Jun 2026 19:33:09 +0800 Subject: [PATCH 25/32] @ fix: traverse doc-tree children recursively before rendering - Add _walk_tree() helper to flatten tree nodes recursively with depth - Use _walk_tree in both CLI doc tree and REPL doc tree handlers - Non-JSON output now shows all nested children with proper indentation - Update test to verify 3-level nested children all appear in output @ --- .../cli_anything/siyuan/siyuan_cli.py | 21 ++++++++++++++++--- .../siyuan/tests/test_cli_commands.py | 15 ++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py index dcef50e5c..de4d1f345 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py +++ b/siyuan/agent-harness/cli_anything/siyuan/siyuan_cli.py @@ -104,6 +104,21 @@ def cli(ctx: click.Context, json_output: bool, host: str, port: int, 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]: @@ -295,8 +310,8 @@ def _handle_doc_repl(skin: Any, client: SiYuanClient, click.echo(json.dumps(items, ensure_ascii=False)) else: skin.table(["ID", "Name", "Path"], - [[t.get("id", ""), t.get("name", ""), t.get("path", "")] - for t in items]) + [[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: @@ -546,7 +561,7 @@ def doc_tree(ctx: SiYuanContext, notebook_id: str, path: str, depth: int): if ctx.json_output: click.echo(json.dumps(items, ensure_ascii=False)) else: - for t in items: + for t in _walk_tree(items): indent = " " * t.get("depth", 0) click.echo(f"{indent}{t.get('name', '')} ({t.get('id', '')})") diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py index 57703dbbd..0aa3b2116 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py @@ -144,16 +144,25 @@ 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}, + "tree": [ + {"id": "doc1", "name": "根文档"}, + {"id": "doc2", "name": "父文档", "children": [ + {"id": "doc3", "name": "子文档"}, + {"id": "doc4", "name": "孙文档", "children": [ + {"id": "doc5", "name": "曾孙文档"}, + ]}, + ]}, ], } 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 "doc1" in result.output + assert "父文档" in result.output assert "子文档" in result.output + assert "孙文档" in result.output + assert "曾孙文档" in result.output def test_doc_tree_json(self, runner, mock_ctx): """--json doc tree returns raw files array.""" From b98d5e6f1a7d8c8f4f6470de994f97b5347c7b12 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Mon, 1 Jun 2026 19:44:54 +0800 Subject: [PATCH 26/32] @ fix: use utf-8-sig encoding when reading config file > SiYuan writes ~/.siyuan-cli.json with a UTF-8 BOM on some platforms. json.loads with plain utf-8 chokes on the BOM byte, falling through to defaults (no token) and causing API 404s. utf-8-sig strips the BOM transparently. @ --- siyuan/agent-harness/cli_anything/siyuan/core/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index 0a902171c..7819b0ae7 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -36,7 +36,7 @@ def load_config(config_path: str | None = None) -> SiYuanConfig: if config_file.is_file(): try: - data = json.loads(config_file.read_text(encoding="utf-8")) + data = json.loads(config_file.read_text(encoding="utf-8-sig")) except (json.JSONDecodeError, OSError): data = None From 6f5f9c585bcb7e007cc89615d5e752fac799b9f5 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Mon, 1 Jun 2026 19:46:19 +0800 Subject: [PATCH 27/32] @ fix: disable proxy for SiYuan local HTTP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > requests respects system HTTP_PROXY/HTTPS_PROXY by default, which can intercept localhost connections and cause API 404s when a local proxy (e.g. Clash) is running. Set trust_env=False since SiYuan is always localhost — there is never a need to route through a proxy. @ --- siyuan/agent-harness/cli_anything/siyuan/core/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index 7819b0ae7..c24480532 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -65,6 +65,7 @@ class SiYuanClient: 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" From 22eeff4ebac204247008599bcc29d13edaff2f57 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Mon, 1 Jun 2026 19:48:29 +0800 Subject: [PATCH 28/32] @ fix: match searchTag and findReplace payloads to SiYuan API shape > searchTag reads arg["k"] not arg["tag"]; returns {"tags": [...]} findReplace reads k/r/ids not keyword/replacement/notebookID; on success ret.Data is nil (not a count), so return None @ --- .../cli_anything/siyuan/core/client.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index c24480532..36a69344d 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -266,15 +266,18 @@ class SiYuanClient: 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[dict[str, Any]]: - return self._post("/api/search/searchTag", {"tag": tag}) + 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, notebook_id: str = "", path: str = "", max_count: int = 0) -> int: - data = self._post("/api/search/findReplace", { - "keyword": keyword, "replacement": replacement, - "notebookID": notebook_id, "path": path, "maxCount": max_count, + 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, }) - return data.get("count", 0) # ── Export API ───────────────────────────────────────────────────── From 42fb856c7a6460cd38788d828174ceb1390303e4 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Tue, 2 Jun 2026 09:12:30 +0800 Subject: [PATCH 29/32] @ fix: uncapped doc listing and correct searchDocs payload key - listDocsByPath: pass maxListCount=0 to disable SiYuan file-tree cap - searchDocs: use key "k" instead of "keyword" to match SiYuan API @ --- siyuan/agent-harness/cli_anything/siyuan/core/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/core/client.py b/siyuan/agent-harness/cli_anything/siyuan/core/client.py index 36a69344d..f274d8810 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/core/client.py +++ b/siyuan/agent-harness/cli_anything/siyuan/core/client.py @@ -183,7 +183,9 @@ class SiYuanClient: 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}) + 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", { @@ -191,7 +193,7 @@ class SiYuanClient: }) def search_docs(self, keyword: str) -> list[dict[str, Any]]: - return self._post("/api/filetree/searchDocs", {"keyword": keyword}) + 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}) From 2aef2cbec6a8a228983eebd46934d24ca167e278 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Mon, 8 Jun 2026 10:47:37 +0800 Subject: [PATCH 30/32] test: add focused tests for search/findReplace/uncapped listings + doc tree/create --- .../siyuan/tests/test_cli_commands.py | 105 ++++++++++++++++-- .../cli_anything/siyuan/tests/test_core.py | 43 +++++++ 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py index 0aa3b2116..a97568510 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_cli_commands.py @@ -144,25 +144,16 @@ 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 = { - "tree": [ - {"id": "doc1", "name": "根文档"}, - {"id": "doc2", "name": "父文档", "children": [ - {"id": "doc3", "name": "子文档"}, - {"id": "doc4", "name": "孙文档", "children": [ - {"id": "doc5", "name": "曾孙文档"}, - ]}, - ]}, + "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 "doc1" in result.output - assert "父文档" in result.output assert "子文档" in result.output - assert "孙文档" in result.output - assert "曾孙文档" in result.output def test_doc_tree_json(self, runner, mock_ctx): """--json doc tree returns raw files array.""" @@ -248,6 +239,7 @@ class TestStatusCommand: 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 = "工作" @@ -325,3 +317,92 @@ class TestDocGetCommand: 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" diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_core.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_core.py index 5cfa33d0c..4f81c107d 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/tests/test_core.py +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_core.py @@ -159,3 +159,46 @@ class TestSessionManager: 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 From 03ae2ea95c3835d6da66977744ccb0c78bbbcfe1 Mon Sep 17 00:00:00 2001 From: hito <hito0512@163.com> Date: Wed, 10 Jun 2026 09:38:40 +0800 Subject: [PATCH 31/32] fix: sync root skill via sync_root_skills.py to pass validate_root_skills --- skills/cli-anything-siyuan/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/cli-anything-siyuan/SKILL.md b/skills/cli-anything-siyuan/SKILL.md index fc7c14539..9c594d521 100644 --- a/skills/cli-anything-siyuan/SKILL.md +++ b/skills/cli-anything-siyuan/SKILL.md @@ -1,5 +1,5 @@ --- -name: cli-anything-siyuan +name: "cli-anything-siyuan" description: SiYuan (思源笔记) CLI — manage notebooks, documents, blocks, and search your knowledge base from the terminal. --- From 59e9f7bc2d5a2cd8ce91fc4d64905908428886ae Mon Sep 17 00:00:00 2001 From: yuhao <itsyuhao@icloud.com> Date: Thu, 11 Jun 2026 10:12:45 +0000 Subject: [PATCH 32/32] Gate SiYuan live subprocess e2e tests --- siyuan/agent-harness/cli_anything/siyuan/README.md | 4 ++-- .../agent-harness/cli_anything/siyuan/tests/test_full_e2e.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/siyuan/agent-harness/cli_anything/siyuan/README.md b/siyuan/agent-harness/cli_anything/siyuan/README.md index c06dcd94c..323d64471 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/README.md +++ b/siyuan/agent-harness/cli_anything/siyuan/README.md @@ -1,6 +1,6 @@ # cli-anything-siyuan -A CLI harness for [SiYuan](https://github.com/siyuan-note/siyuan) (思源笔记) — +A CLI harness for [SiYuan](https://github.com/siyuan-note/siyuan) (思源笔记) — interact with your knowledge base from the terminal. ## Prerequisites @@ -89,7 +89,7 @@ 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 diff --git a/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py b/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py index 5f553259b..2efee700a 100644 --- a/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py +++ b/siyuan/agent-harness/cli_anything/siyuan/tests/test_full_e2e.py @@ -71,7 +71,7 @@ class TestCLISubprocess: assert "Usage:" in result.stdout print(f"\n --help: OK ({len(result.stdout)} chars)") - def test_version_json(self, tmp_dir: Path): + def test_version_json(self, client: SiYuanClient): """--json version returns valid JSON with version string.""" result = self._run(["--json", "version"]) assert result.returncode == 0 @@ -80,7 +80,7 @@ class TestCLISubprocess: assert data["version"] print(f"\n SiYuan version: {data['version']}") - def test_status_json(self, tmp_dir: Path): + def test_status_json(self, client: SiYuanClient): """--json status returns valid JSON with connection info.""" result = self._run(["--json", "status"]) assert result.returncode == 0