odcli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
odcli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: odcli
3
+ Version: 0.1.0
4
+ Summary: A small CLI for reading and writing notes in a local Obsidian vault.
5
+ Author: Chang LeHung
6
+ Project-URL: Homepage, https://github.com/Chang-LeHung/obsidian-cli
7
+ Project-URL: Repository, https://github.com/Chang-LeHung/obsidian-cli
8
+ Project-URL: Issues, https://github.com/Chang-LeHung/obsidian-cli/issues
9
+ Keywords: obsidian,cli,markdown,notes,vault
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Utilities
17
+ Classifier: Environment :: Console
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+
21
+ # odcli
22
+
23
+ 一个基于 Python 的本地 CLI,用来对 Obsidian vault 做读写操作。它直接操作 vault 里的 Markdown 文件,不依赖 Obsidian 的私有接口,因此稳定、可移植,也方便后续扩展。
24
+
25
+ ## 功能
26
+
27
+ - 校验 vault 路径是否有效
28
+ - 列出 vault 中的 Markdown 笔记
29
+ - 读取指定笔记
30
+ - 按指定行区间读取笔记
31
+ - 覆盖写入或自动创建笔记
32
+ - 按指定行区间覆盖写入
33
+ - 追加内容到笔记末尾
34
+ - 在 vault 中全文搜索
35
+
36
+ ## 使用 uv
37
+
38
+ ```bash
39
+ cd /Users/huchang/agents/obsidian_cli
40
+ uv sync
41
+ uv run obsidian-cli --help
42
+ ```
43
+
44
+ 运行测试:
45
+
46
+ ```bash
47
+ cd /Users/huchang/agents/obsidian_cli
48
+ uv run python -m unittest discover -s tests
49
+ ```
50
+
51
+ 构建分发包:
52
+
53
+ ```bash
54
+ cd /Users/huchang/agents/obsidian_cli
55
+ uv build
56
+ ```
57
+
58
+ 发布到 PyPI 后,安装包名会是 `odcli`。
59
+
60
+ ## 直接运行
61
+
62
+ ```bash
63
+ cd /Users/huchang/agents/obsidian_cli
64
+ ./obsidian-cli --help
65
+ ```
66
+
67
+ 如果你更喜欢显式调用 Python:
68
+
69
+ ```bash
70
+ PYTHONPATH=src python3 -m obsidian_cli --help
71
+ ```
72
+
73
+ ## 可选安装
74
+
75
+ 如果你想安装到虚拟环境中:
76
+
77
+ ```bash
78
+ cd /Users/huchang/agents/obsidian_cli
79
+ uv sync
80
+ ```
81
+
82
+ ## 指定 vault
83
+
84
+ 优先级如下:
85
+
86
+ 1. `--vault /path/to/vault`
87
+ 2. 环境变量 `OBSIDIAN_VAULT`
88
+
89
+ 示例:
90
+
91
+ ```bash
92
+ export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
93
+ ./obsidian-cli check
94
+ ./obsidian-cli list
95
+ ./obsidian-cli read Inbox/today.md
96
+ ./obsidian-cli read-lines Inbox/today.md 3 8
97
+ ./obsidian-cli write Inbox/today.md --content "# Today"
98
+ ./obsidian-cli write-lines Inbox/today.md 3 4 --content "- replaced\n- lines\n"
99
+ ./obsidian-cli append Inbox/today.md --content "\n- new item"
100
+ ./obsidian-cli search "project alpha"
101
+ ```
102
+
103
+ ## 命令
104
+
105
+ ### `check`
106
+
107
+ 检查 vault 是否存在,以及是否包含 `.obsidian` 目录。
108
+
109
+ ### `list`
110
+
111
+ 列出 vault 中的 Markdown 文件。
112
+
113
+ 可选参数:
114
+
115
+ - `--limit N`
116
+
117
+ ### `read`
118
+
119
+ 读取笔记内容。
120
+
121
+ 参数:
122
+
123
+ - `note_path`:相对于 vault 根目录的路径
124
+
125
+ ### `write`
126
+
127
+ 覆盖写入笔记;若父目录不存在会自动创建。
128
+
129
+ 参数:
130
+
131
+ - `note_path`
132
+ - `--content TEXT`
133
+ - `--stdin`:从标准输入读取内容
134
+
135
+ 可选参数:
136
+
137
+ - `--create-only`:若文件已存在则报错
138
+
139
+ ### `read-lines`
140
+
141
+ 读取指定行区间,行号从 `1` 开始,区间是闭区间。
142
+
143
+ 参数:
144
+
145
+ - `note_path`
146
+ - `start_line`
147
+ - `end_line`
148
+
149
+ ### `write-lines`
150
+
151
+ 用新内容替换指定行区间,行号从 `1` 开始,区间是闭区间。
152
+
153
+ 参数:
154
+
155
+ - `note_path`
156
+ - `start_line`
157
+ - `end_line`
158
+ - `--content TEXT`
159
+ - `--stdin`
160
+
161
+ ### `append`
162
+
163
+ 追加内容到笔记末尾。
164
+
165
+ 参数:
166
+
167
+ - `note_path`
168
+ - `--content TEXT`
169
+ - `--stdin`
170
+
171
+ ### `search`
172
+
173
+ 在所有 Markdown 笔记中搜索文本。
174
+
175
+ 参数:
176
+
177
+ - `query`
178
+ - `--case-sensitive`
179
+
180
+ ## 测试
181
+
182
+ ```bash
183
+ cd /Users/huchang/agents/obsidian_cli
184
+ uv run python -m unittest discover -s tests
185
+ ```
odcli-0.1.0/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # odcli
2
+
3
+ 一个基于 Python 的本地 CLI,用来对 Obsidian vault 做读写操作。它直接操作 vault 里的 Markdown 文件,不依赖 Obsidian 的私有接口,因此稳定、可移植,也方便后续扩展。
4
+
5
+ ## 功能
6
+
7
+ - 校验 vault 路径是否有效
8
+ - 列出 vault 中的 Markdown 笔记
9
+ - 读取指定笔记
10
+ - 按指定行区间读取笔记
11
+ - 覆盖写入或自动创建笔记
12
+ - 按指定行区间覆盖写入
13
+ - 追加内容到笔记末尾
14
+ - 在 vault 中全文搜索
15
+
16
+ ## 使用 uv
17
+
18
+ ```bash
19
+ cd /Users/huchang/agents/obsidian_cli
20
+ uv sync
21
+ uv run obsidian-cli --help
22
+ ```
23
+
24
+ 运行测试:
25
+
26
+ ```bash
27
+ cd /Users/huchang/agents/obsidian_cli
28
+ uv run python -m unittest discover -s tests
29
+ ```
30
+
31
+ 构建分发包:
32
+
33
+ ```bash
34
+ cd /Users/huchang/agents/obsidian_cli
35
+ uv build
36
+ ```
37
+
38
+ 发布到 PyPI 后,安装包名会是 `odcli`。
39
+
40
+ ## 直接运行
41
+
42
+ ```bash
43
+ cd /Users/huchang/agents/obsidian_cli
44
+ ./obsidian-cli --help
45
+ ```
46
+
47
+ 如果你更喜欢显式调用 Python:
48
+
49
+ ```bash
50
+ PYTHONPATH=src python3 -m obsidian_cli --help
51
+ ```
52
+
53
+ ## 可选安装
54
+
55
+ 如果你想安装到虚拟环境中:
56
+
57
+ ```bash
58
+ cd /Users/huchang/agents/obsidian_cli
59
+ uv sync
60
+ ```
61
+
62
+ ## 指定 vault
63
+
64
+ 优先级如下:
65
+
66
+ 1. `--vault /path/to/vault`
67
+ 2. 环境变量 `OBSIDIAN_VAULT`
68
+
69
+ 示例:
70
+
71
+ ```bash
72
+ export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
73
+ ./obsidian-cli check
74
+ ./obsidian-cli list
75
+ ./obsidian-cli read Inbox/today.md
76
+ ./obsidian-cli read-lines Inbox/today.md 3 8
77
+ ./obsidian-cli write Inbox/today.md --content "# Today"
78
+ ./obsidian-cli write-lines Inbox/today.md 3 4 --content "- replaced\n- lines\n"
79
+ ./obsidian-cli append Inbox/today.md --content "\n- new item"
80
+ ./obsidian-cli search "project alpha"
81
+ ```
82
+
83
+ ## 命令
84
+
85
+ ### `check`
86
+
87
+ 检查 vault 是否存在,以及是否包含 `.obsidian` 目录。
88
+
89
+ ### `list`
90
+
91
+ 列出 vault 中的 Markdown 文件。
92
+
93
+ 可选参数:
94
+
95
+ - `--limit N`
96
+
97
+ ### `read`
98
+
99
+ 读取笔记内容。
100
+
101
+ 参数:
102
+
103
+ - `note_path`:相对于 vault 根目录的路径
104
+
105
+ ### `write`
106
+
107
+ 覆盖写入笔记;若父目录不存在会自动创建。
108
+
109
+ 参数:
110
+
111
+ - `note_path`
112
+ - `--content TEXT`
113
+ - `--stdin`:从标准输入读取内容
114
+
115
+ 可选参数:
116
+
117
+ - `--create-only`:若文件已存在则报错
118
+
119
+ ### `read-lines`
120
+
121
+ 读取指定行区间,行号从 `1` 开始,区间是闭区间。
122
+
123
+ 参数:
124
+
125
+ - `note_path`
126
+ - `start_line`
127
+ - `end_line`
128
+
129
+ ### `write-lines`
130
+
131
+ 用新内容替换指定行区间,行号从 `1` 开始,区间是闭区间。
132
+
133
+ 参数:
134
+
135
+ - `note_path`
136
+ - `start_line`
137
+ - `end_line`
138
+ - `--content TEXT`
139
+ - `--stdin`
140
+
141
+ ### `append`
142
+
143
+ 追加内容到笔记末尾。
144
+
145
+ 参数:
146
+
147
+ - `note_path`
148
+ - `--content TEXT`
149
+ - `--stdin`
150
+
151
+ ### `search`
152
+
153
+ 在所有 Markdown 笔记中搜索文本。
154
+
155
+ 参数:
156
+
157
+ - `query`
158
+ - `--case-sensitive`
159
+
160
+ ## 测试
161
+
162
+ ```bash
163
+ cd /Users/huchang/agents/obsidian_cli
164
+ uv run python -m unittest discover -s tests
165
+ ```
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "odcli"
7
+ version = "0.1.0"
8
+ description = "A small CLI for reading and writing notes in a local Obsidian vault."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [
12
+ { name = "Chang LeHung" }
13
+ ]
14
+ dependencies = []
15
+ keywords = ["obsidian", "cli", "markdown", "notes", "vault"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: MacOS",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Utilities",
24
+ "Environment :: Console",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/Chang-LeHung/obsidian-cli"
29
+ Repository = "https://github.com/Chang-LeHung/obsidian-cli"
30
+ Issues = "https://github.com/Chang-LeHung/obsidian-cli/issues"
31
+
32
+ [project.scripts]
33
+ obsidian-cli = "obsidian_cli.cli:main"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "build>=1.2.2",
38
+ "twine>=6.1.0",
39
+ ]
40
+
41
+ [tool.setuptools]
42
+ package-dir = {"" = "src"}
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.uv]
48
+ package = true
odcli-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,6 @@
1
+ from obsidian_cli.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from obsidian_cli.vault import ObsidianVault, VaultError
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(
13
+ prog="obsidian-cli",
14
+ description="Read and write notes inside a local Obsidian vault.",
15
+ )
16
+ parser.add_argument(
17
+ "--vault",
18
+ help="Path to the Obsidian vault. Falls back to OBSIDIAN_VAULT.",
19
+ )
20
+
21
+ subparsers = parser.add_subparsers(dest="command", required=True)
22
+
23
+ subparsers.add_parser("check", help="Validate the vault path.")
24
+
25
+ list_parser = subparsers.add_parser("list", help="List markdown notes.")
26
+ list_parser.add_argument("--limit", type=int, default=0, help="Maximum number of notes to show.")
27
+
28
+ read_parser = subparsers.add_parser("read", help="Read a note.")
29
+ read_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
30
+
31
+ read_lines_parser = subparsers.add_parser("read-lines", help="Read a line range from a note.")
32
+ read_lines_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
33
+ read_lines_parser.add_argument("start_line", type=int, help="1-based start line, inclusive.")
34
+ read_lines_parser.add_argument("end_line", type=int, help="1-based end line, inclusive.")
35
+
36
+ write_parser = subparsers.add_parser("write", help="Write a note.")
37
+ write_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
38
+ write_parser.add_argument("--content", help="Text content to write.")
39
+ write_parser.add_argument("--stdin", action="store_true", help="Read note content from stdin.")
40
+ write_parser.add_argument(
41
+ "--create-only",
42
+ action="store_true",
43
+ help="Fail if the note already exists.",
44
+ )
45
+
46
+ append_parser = subparsers.add_parser("append", help="Append to a note.")
47
+ append_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
48
+ append_parser.add_argument("--content", help="Text content to append.")
49
+ append_parser.add_argument("--stdin", action="store_true", help="Read appended content from stdin.")
50
+
51
+ write_lines_parser = subparsers.add_parser(
52
+ "write-lines",
53
+ help="Replace a line range inside a note.",
54
+ )
55
+ write_lines_parser.add_argument("note_path", help="Path to the note relative to the vault root.")
56
+ write_lines_parser.add_argument("start_line", type=int, help="1-based start line, inclusive.")
57
+ write_lines_parser.add_argument("end_line", type=int, help="1-based end line, inclusive.")
58
+ write_lines_parser.add_argument("--content", help="Replacement text for the selected lines.")
59
+ write_lines_parser.add_argument("--stdin", action="store_true", help="Read replacement text from stdin.")
60
+
61
+ search_parser = subparsers.add_parser("search", help="Search text in notes.")
62
+ search_parser.add_argument("query", help="Text to search for.")
63
+ search_parser.add_argument(
64
+ "--case-sensitive",
65
+ action="store_true",
66
+ help="Use case-sensitive matching.",
67
+ )
68
+
69
+ return parser
70
+
71
+
72
+ def resolve_vault_path(cli_value: str | None) -> Path:
73
+ raw_value = cli_value or os.environ.get("OBSIDIAN_VAULT")
74
+ if not raw_value:
75
+ raise VaultError("vault path is required; use --vault or OBSIDIAN_VAULT")
76
+ return Path(raw_value)
77
+
78
+
79
+ def read_content_arg(content: str | None, use_stdin: bool) -> str:
80
+ if content is not None and use_stdin:
81
+ raise VaultError("use either --content or --stdin, not both")
82
+ if content is not None:
83
+ return content
84
+ if use_stdin:
85
+ return sys.stdin.read()
86
+ raise VaultError("content is required; provide --content or --stdin")
87
+
88
+
89
+ def cmd_check(vault: ObsidianVault) -> int:
90
+ if not vault.exists():
91
+ raise VaultError(f"vault does not exist: {vault.root}")
92
+ print(f"vault: {vault.root}")
93
+ if vault.is_obsidian_vault():
94
+ print("status: ok (.obsidian found)")
95
+ else:
96
+ print("status: warning (.obsidian not found)")
97
+ return 0
98
+
99
+
100
+ def cmd_list(vault: ObsidianVault, limit: int) -> int:
101
+ notes = vault.list_notes()
102
+ if limit > 0:
103
+ notes = notes[:limit]
104
+ for note in notes:
105
+ print(note)
106
+ return 0
107
+
108
+
109
+ def cmd_read(vault: ObsidianVault, note_path: str) -> int:
110
+ print(vault.read_note(note_path), end="")
111
+ return 0
112
+
113
+
114
+ def cmd_read_lines(vault: ObsidianVault, note_path: str, start_line: int, end_line: int) -> int:
115
+ print(vault.read_note_lines(note_path, start_line, end_line), end="")
116
+ return 0
117
+
118
+
119
+ def cmd_write(vault: ObsidianVault, note_path: str, content: str, create_only: bool) -> int:
120
+ path = vault.write_note(note_path, content, create_only=create_only)
121
+ print(path)
122
+ return 0
123
+
124
+
125
+ def cmd_append(vault: ObsidianVault, note_path: str, content: str) -> int:
126
+ path = vault.append_note(note_path, content)
127
+ print(path)
128
+ return 0
129
+
130
+
131
+ def cmd_write_lines(
132
+ vault: ObsidianVault,
133
+ note_path: str,
134
+ start_line: int,
135
+ end_line: int,
136
+ content: str,
137
+ ) -> int:
138
+ path = vault.write_note_lines(note_path, start_line, end_line, content)
139
+ print(path)
140
+ return 0
141
+
142
+
143
+ def cmd_search(vault: ObsidianVault, query: str, case_sensitive: bool) -> int:
144
+ matches = vault.search(query, case_sensitive=case_sensitive)
145
+ for match in matches:
146
+ print(f"{match.note_path}:{match.line_number}: {match.line_text}")
147
+ return 0 if matches else 1
148
+
149
+
150
+ def main(argv: list[str] | None = None) -> int:
151
+ parser = build_parser()
152
+ args = parser.parse_args(argv)
153
+
154
+ try:
155
+ vault = ObsidianVault(resolve_vault_path(args.vault))
156
+
157
+ if args.command == "check":
158
+ return cmd_check(vault)
159
+ if args.command == "list":
160
+ return cmd_list(vault, args.limit)
161
+ if args.command == "read":
162
+ return cmd_read(vault, args.note_path)
163
+ if args.command == "read-lines":
164
+ return cmd_read_lines(vault, args.note_path, args.start_line, args.end_line)
165
+ if args.command == "write":
166
+ content = read_content_arg(args.content, args.stdin)
167
+ return cmd_write(vault, args.note_path, content, args.create_only)
168
+ if args.command == "append":
169
+ content = read_content_arg(args.content, args.stdin)
170
+ return cmd_append(vault, args.note_path, content)
171
+ if args.command == "write-lines":
172
+ content = read_content_arg(args.content, args.stdin)
173
+ return cmd_write_lines(
174
+ vault,
175
+ args.note_path,
176
+ args.start_line,
177
+ args.end_line,
178
+ content,
179
+ )
180
+ if args.command == "search":
181
+ return cmd_search(vault, args.query, args.case_sensitive)
182
+ except VaultError as exc:
183
+ print(f"error: {exc}", file=sys.stderr)
184
+ return 2
185
+
186
+ parser.error(f"unsupported command: {args.command}")
187
+ return 2
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ class VaultError(Exception):
8
+ """Raised when the vault cannot fulfill a requested operation."""
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class SearchMatch:
13
+ note_path: str
14
+ line_number: int
15
+ line_text: str
16
+
17
+
18
+ class ObsidianVault:
19
+ def __init__(self, root: Path) -> None:
20
+ self.root = root.expanduser().resolve()
21
+
22
+ def exists(self) -> bool:
23
+ return self.root.exists() and self.root.is_dir()
24
+
25
+ def is_obsidian_vault(self) -> bool:
26
+ return (self.root / ".obsidian").exists()
27
+
28
+ def resolve_note(self, note_path: str) -> Path:
29
+ candidate = (self.root / note_path).resolve()
30
+ if candidate != self.root and self.root not in candidate.parents:
31
+ raise VaultError(f"path escapes vault root: {note_path}")
32
+ return candidate
33
+
34
+ def list_notes(self) -> list[str]:
35
+ if not self.exists():
36
+ raise VaultError(f"vault does not exist: {self.root}")
37
+ return sorted(
38
+ str(path.relative_to(self.root))
39
+ for path in self.root.rglob("*.md")
40
+ if ".obsidian" not in path.parts
41
+ )
42
+
43
+ def read_note(self, note_path: str) -> str:
44
+ path = self.resolve_note(note_path)
45
+ if not path.exists():
46
+ raise VaultError(f"note not found: {note_path}")
47
+ if not path.is_file():
48
+ raise VaultError(f"not a file: {note_path}")
49
+ return path.read_text(encoding="utf-8")
50
+
51
+ def _read_existing_markdown_path(self, note_path: str) -> Path:
52
+ path = self.resolve_note(note_path)
53
+ if path.suffix.lower() != ".md":
54
+ raise VaultError("only markdown files are supported (.md)")
55
+ if not path.exists():
56
+ raise VaultError(f"note not found: {note_path}")
57
+ if not path.is_file():
58
+ raise VaultError(f"not a file: {note_path}")
59
+ return path
60
+
61
+ def _validate_line_range(self, start_line: int, end_line: int, line_count: int) -> None:
62
+ if start_line < 1 or end_line < 1:
63
+ raise VaultError("line numbers must be >= 1")
64
+ if start_line > end_line:
65
+ raise VaultError("start line must be <= end line")
66
+ if end_line > line_count:
67
+ raise VaultError(
68
+ f"line range {start_line}-{end_line} exceeds file length {line_count}"
69
+ )
70
+
71
+ def write_note(self, note_path: str, content: str, create_only: bool = False) -> Path:
72
+ path = self.resolve_note(note_path)
73
+ if path.suffix.lower() != ".md":
74
+ raise VaultError("only markdown files are supported (.md)")
75
+ if create_only and path.exists():
76
+ raise VaultError(f"note already exists: {note_path}")
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+ path.write_text(content, encoding="utf-8")
79
+ return path
80
+
81
+ def append_note(self, note_path: str, content: str) -> Path:
82
+ path = self.resolve_note(note_path)
83
+ if path.suffix.lower() != ".md":
84
+ raise VaultError("only markdown files are supported (.md)")
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ with path.open("a", encoding="utf-8") as handle:
87
+ handle.write(content)
88
+ return path
89
+
90
+ def read_note_lines(self, note_path: str, start_line: int, end_line: int) -> str:
91
+ path = self._read_existing_markdown_path(note_path)
92
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
93
+ self._validate_line_range(start_line, end_line, len(lines))
94
+ return "".join(lines[start_line - 1 : end_line])
95
+
96
+ def write_note_lines(
97
+ self, note_path: str, start_line: int, end_line: int, content: str
98
+ ) -> Path:
99
+ path = self._read_existing_markdown_path(note_path)
100
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
101
+ self._validate_line_range(start_line, end_line, len(lines))
102
+
103
+ replacement_lines = content.splitlines(keepends=True)
104
+ lines[start_line - 1 : end_line] = replacement_lines
105
+ path.write_text("".join(lines), encoding="utf-8")
106
+ return path
107
+
108
+ def search(self, query: str, case_sensitive: bool = False) -> list[SearchMatch]:
109
+ if not query:
110
+ raise VaultError("query must not be empty")
111
+
112
+ needle = query if case_sensitive else query.lower()
113
+ matches: list[SearchMatch] = []
114
+
115
+ for note_path in self.list_notes():
116
+ content = self.read_note(note_path)
117
+ for idx, line in enumerate(content.splitlines(), start=1):
118
+ haystack = line if case_sensitive else line.lower()
119
+ if needle in haystack:
120
+ matches.append(
121
+ SearchMatch(
122
+ note_path=note_path,
123
+ line_number=idx,
124
+ line_text=line,
125
+ )
126
+ )
127
+ return matches
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: odcli
3
+ Version: 0.1.0
4
+ Summary: A small CLI for reading and writing notes in a local Obsidian vault.
5
+ Author: Chang LeHung
6
+ Project-URL: Homepage, https://github.com/Chang-LeHung/obsidian-cli
7
+ Project-URL: Repository, https://github.com/Chang-LeHung/obsidian-cli
8
+ Project-URL: Issues, https://github.com/Chang-LeHung/obsidian-cli/issues
9
+ Keywords: obsidian,cli,markdown,notes,vault
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Utilities
17
+ Classifier: Environment :: Console
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+
21
+ # odcli
22
+
23
+ 一个基于 Python 的本地 CLI,用来对 Obsidian vault 做读写操作。它直接操作 vault 里的 Markdown 文件,不依赖 Obsidian 的私有接口,因此稳定、可移植,也方便后续扩展。
24
+
25
+ ## 功能
26
+
27
+ - 校验 vault 路径是否有效
28
+ - 列出 vault 中的 Markdown 笔记
29
+ - 读取指定笔记
30
+ - 按指定行区间读取笔记
31
+ - 覆盖写入或自动创建笔记
32
+ - 按指定行区间覆盖写入
33
+ - 追加内容到笔记末尾
34
+ - 在 vault 中全文搜索
35
+
36
+ ## 使用 uv
37
+
38
+ ```bash
39
+ cd /Users/huchang/agents/obsidian_cli
40
+ uv sync
41
+ uv run obsidian-cli --help
42
+ ```
43
+
44
+ 运行测试:
45
+
46
+ ```bash
47
+ cd /Users/huchang/agents/obsidian_cli
48
+ uv run python -m unittest discover -s tests
49
+ ```
50
+
51
+ 构建分发包:
52
+
53
+ ```bash
54
+ cd /Users/huchang/agents/obsidian_cli
55
+ uv build
56
+ ```
57
+
58
+ 发布到 PyPI 后,安装包名会是 `odcli`。
59
+
60
+ ## 直接运行
61
+
62
+ ```bash
63
+ cd /Users/huchang/agents/obsidian_cli
64
+ ./obsidian-cli --help
65
+ ```
66
+
67
+ 如果你更喜欢显式调用 Python:
68
+
69
+ ```bash
70
+ PYTHONPATH=src python3 -m obsidian_cli --help
71
+ ```
72
+
73
+ ## 可选安装
74
+
75
+ 如果你想安装到虚拟环境中:
76
+
77
+ ```bash
78
+ cd /Users/huchang/agents/obsidian_cli
79
+ uv sync
80
+ ```
81
+
82
+ ## 指定 vault
83
+
84
+ 优先级如下:
85
+
86
+ 1. `--vault /path/to/vault`
87
+ 2. 环境变量 `OBSIDIAN_VAULT`
88
+
89
+ 示例:
90
+
91
+ ```bash
92
+ export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
93
+ ./obsidian-cli check
94
+ ./obsidian-cli list
95
+ ./obsidian-cli read Inbox/today.md
96
+ ./obsidian-cli read-lines Inbox/today.md 3 8
97
+ ./obsidian-cli write Inbox/today.md --content "# Today"
98
+ ./obsidian-cli write-lines Inbox/today.md 3 4 --content "- replaced\n- lines\n"
99
+ ./obsidian-cli append Inbox/today.md --content "\n- new item"
100
+ ./obsidian-cli search "project alpha"
101
+ ```
102
+
103
+ ## 命令
104
+
105
+ ### `check`
106
+
107
+ 检查 vault 是否存在,以及是否包含 `.obsidian` 目录。
108
+
109
+ ### `list`
110
+
111
+ 列出 vault 中的 Markdown 文件。
112
+
113
+ 可选参数:
114
+
115
+ - `--limit N`
116
+
117
+ ### `read`
118
+
119
+ 读取笔记内容。
120
+
121
+ 参数:
122
+
123
+ - `note_path`:相对于 vault 根目录的路径
124
+
125
+ ### `write`
126
+
127
+ 覆盖写入笔记;若父目录不存在会自动创建。
128
+
129
+ 参数:
130
+
131
+ - `note_path`
132
+ - `--content TEXT`
133
+ - `--stdin`:从标准输入读取内容
134
+
135
+ 可选参数:
136
+
137
+ - `--create-only`:若文件已存在则报错
138
+
139
+ ### `read-lines`
140
+
141
+ 读取指定行区间,行号从 `1` 开始,区间是闭区间。
142
+
143
+ 参数:
144
+
145
+ - `note_path`
146
+ - `start_line`
147
+ - `end_line`
148
+
149
+ ### `write-lines`
150
+
151
+ 用新内容替换指定行区间,行号从 `1` 开始,区间是闭区间。
152
+
153
+ 参数:
154
+
155
+ - `note_path`
156
+ - `start_line`
157
+ - `end_line`
158
+ - `--content TEXT`
159
+ - `--stdin`
160
+
161
+ ### `append`
162
+
163
+ 追加内容到笔记末尾。
164
+
165
+ 参数:
166
+
167
+ - `note_path`
168
+ - `--content TEXT`
169
+ - `--stdin`
170
+
171
+ ### `search`
172
+
173
+ 在所有 Markdown 笔记中搜索文本。
174
+
175
+ 参数:
176
+
177
+ - `query`
178
+ - `--case-sensitive`
179
+
180
+ ## 测试
181
+
182
+ ```bash
183
+ cd /Users/huchang/agents/obsidian_cli
184
+ uv run python -m unittest discover -s tests
185
+ ```
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/obsidian_cli/__init__.py
4
+ src/obsidian_cli/__main__.py
5
+ src/obsidian_cli/cli.py
6
+ src/obsidian_cli/vault.py
7
+ src/odcli.egg-info/PKG-INFO
8
+ src/odcli.egg-info/SOURCES.txt
9
+ src/odcli.egg-info/dependency_links.txt
10
+ src/odcli.egg-info/entry_points.txt
11
+ src/odcli.egg-info/top_level.txt
12
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ obsidian-cli = obsidian_cli.cli:main
@@ -0,0 +1 @@
1
+ obsidian_cli
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ import sys
7
+ from contextlib import redirect_stderr, redirect_stdout
8
+ from io import StringIO
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11
+
12
+ from obsidian_cli.cli import main
13
+ from obsidian_cli.vault import ObsidianVault, VaultError
14
+
15
+
16
+ class VaultTests(unittest.TestCase):
17
+ def setUp(self) -> None:
18
+ self.temp_dir = tempfile.TemporaryDirectory()
19
+ self.vault_root = Path(self.temp_dir.name) / "vault"
20
+ self.vault_root.mkdir()
21
+ (self.vault_root / ".obsidian").mkdir()
22
+ self.vault = ObsidianVault(self.vault_root)
23
+
24
+ def tearDown(self) -> None:
25
+ self.temp_dir.cleanup()
26
+
27
+ def test_write_and_read_note(self) -> None:
28
+ self.vault.write_note("Inbox/test.md", "# Hello")
29
+ self.assertEqual(self.vault.read_note("Inbox/test.md"), "# Hello")
30
+
31
+ def test_list_notes_excludes_obsidian_config(self) -> None:
32
+ self.vault.write_note("Note.md", "body")
33
+ (self.vault_root / ".obsidian" / "workspace.md").write_text(
34
+ "ignored", encoding="utf-8"
35
+ )
36
+ self.assertEqual(self.vault.list_notes(), ["Note.md"])
37
+
38
+ def test_search_matches_line(self) -> None:
39
+ self.vault.write_note("Note.md", "alpha\nbeta project\nomega")
40
+ matches = self.vault.search("project")
41
+ self.assertEqual(len(matches), 1)
42
+ self.assertEqual(matches[0].line_number, 2)
43
+
44
+ def test_read_note_lines(self) -> None:
45
+ self.vault.write_note("Note.md", "a\nb\nc\nd\n")
46
+ self.assertEqual(self.vault.read_note_lines("Note.md", 2, 3), "b\nc\n")
47
+
48
+ def test_write_note_lines_replaces_only_selected_range(self) -> None:
49
+ self.vault.write_note("Note.md", "a\nb\nc\nd\n")
50
+ self.vault.write_note_lines("Note.md", 2, 3, "x\ny\n")
51
+ self.assertEqual(self.vault.read_note("Note.md"), "a\nx\ny\nd\n")
52
+
53
+ def test_write_note_lines_rejects_invalid_range(self) -> None:
54
+ self.vault.write_note("Note.md", "a\nb\n")
55
+ with self.assertRaises(VaultError):
56
+ self.vault.write_note_lines("Note.md", 2, 4, "x\n")
57
+
58
+ def test_cli_check(self) -> None:
59
+ with redirect_stdout(StringIO()), redirect_stderr(StringIO()):
60
+ exit_code = main(["--vault", str(self.vault_root), "check"])
61
+ self.assertEqual(exit_code, 0)
62
+
63
+ def test_cli_write_requires_content(self) -> None:
64
+ with redirect_stdout(StringIO()), redirect_stderr(StringIO()):
65
+ exit_code = main(["--vault", str(self.vault_root), "write", "x.md"])
66
+ self.assertEqual(exit_code, 2)
67
+
68
+ def test_cli_read_lines(self) -> None:
69
+ self.vault.write_note("Note.md", "a\nb\nc\n")
70
+ stdout = StringIO()
71
+ with redirect_stdout(stdout), redirect_stderr(StringIO()):
72
+ exit_code = main(
73
+ ["--vault", str(self.vault_root), "read-lines", "Note.md", "2", "3"]
74
+ )
75
+ self.assertEqual(exit_code, 0)
76
+ self.assertEqual(stdout.getvalue(), "b\nc\n")
77
+
78
+ def test_cli_write_lines(self) -> None:
79
+ self.vault.write_note("Note.md", "a\nb\nc\n")
80
+ with redirect_stdout(StringIO()), redirect_stderr(StringIO()):
81
+ exit_code = main(
82
+ [
83
+ "--vault",
84
+ str(self.vault_root),
85
+ "write-lines",
86
+ "Note.md",
87
+ "2",
88
+ "2",
89
+ "--content",
90
+ "beta\n",
91
+ ]
92
+ )
93
+ self.assertEqual(exit_code, 0)
94
+ self.assertEqual(self.vault.read_note("Note.md"), "a\nbeta\nc\n")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()