odcli 0.1.2__tar.gz → 0.1.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odcli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: A small CLI for reading and writing notes in a local Obsidian vault.
5
5
  Author: Chang LeHung
6
6
  Project-URL: Homepage, https://github.com/Chang-LeHung/obsidian-cli
@@ -10,6 +10,7 @@ Keywords: obsidian,cli,markdown,notes,vault
10
10
  Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: Microsoft :: Windows
13
14
  Classifier: Programming Language :: Python :: 3
14
15
  Classifier: Programming Language :: Python :: 3.11
15
16
  Classifier: Programming Language :: Python :: 3.12
@@ -20,20 +21,22 @@ Description-Content-Type: text/markdown
20
21
 
21
22
  # odcli
22
23
 
23
- 一个基于 Python 的本地 CLI,用来对 Obsidian vault 做读写操作。它直接操作 vault 里的 Markdown 文件,不依赖 Obsidian 的私有接口,因此稳定、可移植,也方便后续扩展。
24
+ `odcli` is a local Python CLI for reading and writing notes in an Obsidian vault.
25
+ It works directly on Markdown files inside the vault, so it does not depend on private Obsidian APIs and remains portable and easy to extend.
24
26
 
25
- ## 功能
27
+ ## Features
26
28
 
27
- - 校验 vault 路径是否有效
28
- - 列出 vault 中的 Markdown 笔记
29
- - 读取指定笔记
30
- - 按指定行区间读取笔记
31
- - 覆盖写入或自动创建笔记
32
- - 按指定行区间覆盖写入
33
- - 追加内容到笔记末尾
34
- - vault 中全文搜索
29
+ - Validate whether a vault path is available
30
+ - List Markdown notes in the vault
31
+ - Read a specific note
32
+ - Read a specific line range from a note
33
+ - Overwrite a note or create it automatically
34
+ - Replace a specific line range in a note
35
+ - Append content to a note
36
+ - Full-text search across the vault
37
+ - Auto-discover the default vault from Obsidian config or common macOS and Windows locations
35
38
 
36
- ## 使用 uv
39
+ ## Using uv
37
40
 
38
41
  ```bash
39
42
  cd /Users/huchang/agents/obsidian_cli
@@ -41,62 +44,53 @@ uv sync
41
44
  uv run odcli --help
42
45
  ```
43
46
 
44
- 运行测试:
47
+ Run tests:
45
48
 
46
49
  ```bash
47
50
  cd /Users/huchang/agents/obsidian_cli
48
51
  uv run python -m unittest discover -s tests
49
52
  ```
50
53
 
51
- 构建分发包:
54
+ Build distributions:
52
55
 
53
56
  ```bash
54
57
  cd /Users/huchang/agents/obsidian_cli
55
58
  uv build
56
59
  ```
57
60
 
58
- 发布到 PyPI 后,安装包名会是 `odcli`。
59
- 安装后可执行命令同时支持 `odcli` `obsidian-cli`。
61
+ The published package name on PyPI is `odcli`.
62
+ After installation, both `odcli` and `obsidian-cli` are available as command names.
60
63
 
61
- ## 直接运行
64
+ ## Run Locally
62
65
 
63
66
  ```bash
64
67
  cd /Users/huchang/agents/obsidian_cli
65
68
  ./odcli --help
66
69
  ```
67
70
 
68
- 兼容入口仍然保留:
71
+ The compatibility entry point is still available:
69
72
 
70
73
  ```bash
71
74
  cd /Users/huchang/agents/obsidian_cli
72
75
  ./obsidian-cli --help
73
76
  ```
74
77
 
75
- 如果你更喜欢显式调用 Python:
78
+ If you prefer module execution:
76
79
 
77
80
  ```bash
78
81
  PYTHONPATH=src python3 -m obsidian_cli --help
79
82
  ```
80
83
 
81
- ## 可选安装
84
+ ## Vault Resolution
82
85
 
83
- 如果你想安装到虚拟环境中:
84
-
85
- ```bash
86
- cd /Users/huchang/agents/obsidian_cli
87
- uv sync
88
- ```
89
-
90
- ## 指定 vault
91
-
92
- 优先级如下:
86
+ Resolution priority:
93
87
 
94
88
  1. `--vault /path/to/vault`
95
- 2. 环境变量 `OBSIDIAN_VAULT`
96
- 3. Obsidian 本地配置里最近打开的 vault
97
- 4. 常见默认目录
89
+ 2. `OBSIDIAN_VAULT`
90
+ 3. The most recently opened vault recorded by local Obsidian config
91
+ 4. Common default directories
98
92
 
99
- 当前内置的默认目录包括:
93
+ Built-in default locations:
100
94
 
101
95
  - macOS: `~/Documents/Obsidian Vault`
102
96
  - macOS: `~/Documents/Obsidian`
@@ -104,7 +98,7 @@ uv sync
104
98
  - Windows: `%USERPROFILE%\\Documents\\Obsidian Vault`
105
99
  - Windows: `%USERPROFILE%\\Documents\\Obsidian`
106
100
 
107
- 示例:
101
+ Example:
108
102
 
109
103
  ```bash
110
104
  export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
@@ -118,47 +112,47 @@ export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
118
112
  ./odcli search "project alpha"
119
113
  ```
120
114
 
121
- ## 命令
115
+ ## Commands
122
116
 
123
117
  ### `check`
124
118
 
125
- 检查 vault 是否存在,以及是否包含 `.obsidian` 目录。
119
+ Validate that the vault exists and report whether `.obsidian` is present.
126
120
 
127
121
  ### `list`
128
122
 
129
- 列出 vault 中的 Markdown 文件。
123
+ List Markdown notes in the vault.
130
124
 
131
- 可选参数:
125
+ Optional arguments:
132
126
 
133
127
  - `--limit N`
134
128
 
135
129
  ### `read`
136
130
 
137
- 读取笔记内容。
131
+ Read a note.
138
132
 
139
- 参数:
133
+ Arguments:
140
134
 
141
- - `note_path`:相对于 vault 根目录的路径
135
+ - `note_path`: path relative to the vault root
142
136
 
143
137
  ### `write`
144
138
 
145
- 覆盖写入笔记;若父目录不存在会自动创建。
139
+ Overwrite a note. Parent directories are created automatically if needed.
146
140
 
147
- 参数:
141
+ Arguments:
148
142
 
149
143
  - `note_path`
150
144
  - `--content TEXT`
151
- - `--stdin`:从标准输入读取内容
145
+ - `--stdin`
152
146
 
153
- 可选参数:
147
+ Optional arguments:
154
148
 
155
- - `--create-only`:若文件已存在则报错
149
+ - `--create-only`
156
150
 
157
151
  ### `read-lines`
158
152
 
159
- 读取指定行区间,行号从 `1` 开始,区间是闭区间。
153
+ Read a line range. Line numbers are 1-based and inclusive.
160
154
 
161
- 参数:
155
+ Arguments:
162
156
 
163
157
  - `note_path`
164
158
  - `start_line`
@@ -166,9 +160,9 @@ export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
166
160
 
167
161
  ### `write-lines`
168
162
 
169
- 用新内容替换指定行区间,行号从 `1` 开始,区间是闭区间。
163
+ Replace a line range. Line numbers are 1-based and inclusive.
170
164
 
171
- 参数:
165
+ Arguments:
172
166
 
173
167
  - `note_path`
174
168
  - `start_line`
@@ -178,9 +172,9 @@ export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
178
172
 
179
173
  ### `append`
180
174
 
181
- 追加内容到笔记末尾。
175
+ Append content to the end of a note.
182
176
 
183
- 参数:
177
+ Arguments:
184
178
 
185
179
  - `note_path`
186
180
  - `--content TEXT`
@@ -188,14 +182,14 @@ export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
188
182
 
189
183
  ### `search`
190
184
 
191
- 在所有 Markdown 笔记中搜索文本。
185
+ Search across all Markdown notes in the vault.
192
186
 
193
- 参数:
187
+ Arguments:
194
188
 
195
189
  - `query`
196
190
  - `--case-sensitive`
197
191
 
198
- ## 测试
192
+ ## Testing
199
193
 
200
194
  ```bash
201
195
  cd /Users/huchang/agents/obsidian_cli
odcli-0.1.3/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # odcli
2
+
3
+ `odcli` is a local Python CLI for reading and writing notes in an Obsidian vault.
4
+ It works directly on Markdown files inside the vault, so it does not depend on private Obsidian APIs and remains portable and easy to extend.
5
+
6
+ ## Features
7
+
8
+ - Validate whether a vault path is available
9
+ - List Markdown notes in the vault
10
+ - Read a specific note
11
+ - Read a specific line range from a note
12
+ - Overwrite a note or create it automatically
13
+ - Replace a specific line range in a note
14
+ - Append content to a note
15
+ - Full-text search across the vault
16
+ - Auto-discover the default vault from Obsidian config or common macOS and Windows locations
17
+
18
+ ## Using uv
19
+
20
+ ```bash
21
+ cd /Users/huchang/agents/obsidian_cli
22
+ uv sync
23
+ uv run odcli --help
24
+ ```
25
+
26
+ Run tests:
27
+
28
+ ```bash
29
+ cd /Users/huchang/agents/obsidian_cli
30
+ uv run python -m unittest discover -s tests
31
+ ```
32
+
33
+ Build distributions:
34
+
35
+ ```bash
36
+ cd /Users/huchang/agents/obsidian_cli
37
+ uv build
38
+ ```
39
+
40
+ The published package name on PyPI is `odcli`.
41
+ After installation, both `odcli` and `obsidian-cli` are available as command names.
42
+
43
+ ## Run Locally
44
+
45
+ ```bash
46
+ cd /Users/huchang/agents/obsidian_cli
47
+ ./odcli --help
48
+ ```
49
+
50
+ The compatibility entry point is still available:
51
+
52
+ ```bash
53
+ cd /Users/huchang/agents/obsidian_cli
54
+ ./obsidian-cli --help
55
+ ```
56
+
57
+ If you prefer module execution:
58
+
59
+ ```bash
60
+ PYTHONPATH=src python3 -m obsidian_cli --help
61
+ ```
62
+
63
+ ## Vault Resolution
64
+
65
+ Resolution priority:
66
+
67
+ 1. `--vault /path/to/vault`
68
+ 2. `OBSIDIAN_VAULT`
69
+ 3. The most recently opened vault recorded by local Obsidian config
70
+ 4. Common default directories
71
+
72
+ Built-in default locations:
73
+
74
+ - macOS: `~/Documents/Obsidian Vault`
75
+ - macOS: `~/Documents/Obsidian`
76
+ - macOS iCloud: `~/Library/Mobile Documents/iCloud~md~obsidian/Documents`
77
+ - Windows: `%USERPROFILE%\\Documents\\Obsidian Vault`
78
+ - Windows: `%USERPROFILE%\\Documents\\Obsidian`
79
+
80
+ Example:
81
+
82
+ ```bash
83
+ export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
84
+ ./odcli check
85
+ ./odcli list
86
+ ./odcli read Inbox/today.md
87
+ ./odcli read-lines Inbox/today.md 3 8
88
+ ./odcli write Inbox/today.md --content "# Today"
89
+ ./odcli write-lines Inbox/today.md 3 4 --content "- replaced\n- lines\n"
90
+ ./odcli append Inbox/today.md --content "\n- new item"
91
+ ./odcli search "project alpha"
92
+ ```
93
+
94
+ ## Commands
95
+
96
+ ### `check`
97
+
98
+ Validate that the vault exists and report whether `.obsidian` is present.
99
+
100
+ ### `list`
101
+
102
+ List Markdown notes in the vault.
103
+
104
+ Optional arguments:
105
+
106
+ - `--limit N`
107
+
108
+ ### `read`
109
+
110
+ Read a note.
111
+
112
+ Arguments:
113
+
114
+ - `note_path`: path relative to the vault root
115
+
116
+ ### `write`
117
+
118
+ Overwrite a note. Parent directories are created automatically if needed.
119
+
120
+ Arguments:
121
+
122
+ - `note_path`
123
+ - `--content TEXT`
124
+ - `--stdin`
125
+
126
+ Optional arguments:
127
+
128
+ - `--create-only`
129
+
130
+ ### `read-lines`
131
+
132
+ Read a line range. Line numbers are 1-based and inclusive.
133
+
134
+ Arguments:
135
+
136
+ - `note_path`
137
+ - `start_line`
138
+ - `end_line`
139
+
140
+ ### `write-lines`
141
+
142
+ Replace a line range. Line numbers are 1-based and inclusive.
143
+
144
+ Arguments:
145
+
146
+ - `note_path`
147
+ - `start_line`
148
+ - `end_line`
149
+ - `--content TEXT`
150
+ - `--stdin`
151
+
152
+ ### `append`
153
+
154
+ Append content to the end of a note.
155
+
156
+ Arguments:
157
+
158
+ - `note_path`
159
+ - `--content TEXT`
160
+ - `--stdin`
161
+
162
+ ### `search`
163
+
164
+ Search across all Markdown notes in the vault.
165
+
166
+ Arguments:
167
+
168
+ - `query`
169
+ - `--case-sensitive`
170
+
171
+ ## Testing
172
+
173
+ ```bash
174
+ cd /Users/huchang/agents/obsidian_cli
175
+ uv run python -m unittest discover -s tests
176
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "odcli"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "A small CLI for reading and writing notes in a local Obsidian vault."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -17,6 +17,7 @@ classifiers = [
17
17
  "Development Status :: 3 - Alpha",
18
18
  "Intended Audience :: Developers",
19
19
  "Operating System :: MacOS",
20
+ "Operating System :: Microsoft :: Windows",
20
21
  "Programming Language :: Python :: 3",
21
22
  "Programming Language :: Python :: 3.11",
22
23
  "Programming Language :: Python :: 3.12",
@@ -36,6 +37,7 @@ obsidian-cli = "obsidian_cli.cli:main"
36
37
  [dependency-groups]
37
38
  dev = [
38
39
  "build>=1.2.2",
40
+ "ruff>=0.14.0",
39
41
  "twine>=6.1.0",
40
42
  ]
41
43
 
@@ -1,4 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.1.0"
4
-
3
+ __version__ = "0.1.3"
@@ -3,4 +3,3 @@ from obsidian_cli.cli import main
3
3
 
4
4
  if __name__ == "__main__":
5
5
  raise SystemExit(main())
6
-
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from obsidian_cli.commands import CommandRunner
7
+ from obsidian_cli.discovery import VaultLocator
8
+ from obsidian_cli.vault import ObsidianVault, VaultError
9
+
10
+
11
+ class ObsidianCLI:
12
+ def __init__(self, vault_locator: VaultLocator | None = None) -> None:
13
+ self._vault_locator = vault_locator or VaultLocator()
14
+ self._parser = self._build_parser()
15
+
16
+ def run(self, argv: list[str] | None = None) -> int:
17
+ args = self._parser.parse_args(argv)
18
+
19
+ try:
20
+ runner = CommandRunner(
21
+ ObsidianVault(self._vault_locator.resolve(args.vault))
22
+ )
23
+
24
+ if args.command == "check":
25
+ return runner.check()
26
+ if args.command == "list":
27
+ return runner.list_notes(args.limit)
28
+ if args.command == "read":
29
+ return runner.read_note(args.note_path)
30
+ if args.command == "read-lines":
31
+ return runner.read_note_lines(
32
+ args.note_path, args.start_line, args.end_line
33
+ )
34
+ if args.command == "write":
35
+ return runner.write_note(
36
+ args.note_path,
37
+ self._read_content_arg(args.content, args.stdin),
38
+ args.create_only,
39
+ )
40
+ if args.command == "append":
41
+ return runner.append_note(
42
+ args.note_path,
43
+ self._read_content_arg(args.content, args.stdin),
44
+ )
45
+ if args.command == "write-lines":
46
+ return runner.write_note_lines(
47
+ args.note_path,
48
+ args.start_line,
49
+ args.end_line,
50
+ self._read_content_arg(args.content, args.stdin),
51
+ )
52
+ if args.command == "search":
53
+ return runner.search(args.query, args.case_sensitive)
54
+ except VaultError as exc:
55
+ print(f"error: {exc}", file=sys.stderr)
56
+ return 2
57
+
58
+ self._parser.error(f"unsupported command: {args.command}")
59
+ return 2
60
+
61
+ def _build_parser(self) -> argparse.ArgumentParser:
62
+ parser = argparse.ArgumentParser(
63
+ prog="obsidian-cli",
64
+ description="Read and write notes inside a local Obsidian vault.",
65
+ )
66
+ parser.add_argument(
67
+ "--vault",
68
+ help="Path to the Obsidian vault. Falls back to OBSIDIAN_VAULT.",
69
+ )
70
+
71
+ subparsers = parser.add_subparsers(dest="command", required=True)
72
+ subparsers.add_parser("check", help="Validate the vault path.")
73
+
74
+ list_parser = subparsers.add_parser("list", help="List markdown notes.")
75
+ list_parser.add_argument(
76
+ "--limit", type=int, default=0, help="Maximum number of notes to show."
77
+ )
78
+
79
+ read_parser = subparsers.add_parser("read", help="Read a note.")
80
+ read_parser.add_argument(
81
+ "note_path", help="Path to the note relative to the vault root."
82
+ )
83
+
84
+ read_lines_parser = subparsers.add_parser(
85
+ "read-lines", help="Read a line range from a note."
86
+ )
87
+ read_lines_parser.add_argument(
88
+ "note_path", help="Path to the note relative to the vault root."
89
+ )
90
+ read_lines_parser.add_argument(
91
+ "start_line", type=int, help="1-based start line, inclusive."
92
+ )
93
+ read_lines_parser.add_argument(
94
+ "end_line", type=int, help="1-based end line, inclusive."
95
+ )
96
+
97
+ write_parser = subparsers.add_parser("write", help="Write a note.")
98
+ write_parser.add_argument(
99
+ "note_path", help="Path to the note relative to the vault root."
100
+ )
101
+ write_parser.add_argument("--content", help="Text content to write.")
102
+ write_parser.add_argument(
103
+ "--stdin", action="store_true", help="Read note content from stdin."
104
+ )
105
+ write_parser.add_argument(
106
+ "--create-only",
107
+ action="store_true",
108
+ help="Fail if the note already exists.",
109
+ )
110
+
111
+ append_parser = subparsers.add_parser("append", help="Append to a note.")
112
+ append_parser.add_argument(
113
+ "note_path", help="Path to the note relative to the vault root."
114
+ )
115
+ append_parser.add_argument("--content", help="Text content to append.")
116
+ append_parser.add_argument(
117
+ "--stdin", action="store_true", help="Read appended content from stdin."
118
+ )
119
+
120
+ write_lines_parser = subparsers.add_parser(
121
+ "write-lines",
122
+ help="Replace a line range inside a note.",
123
+ )
124
+ write_lines_parser.add_argument(
125
+ "note_path", help="Path to the note relative to the vault root."
126
+ )
127
+ write_lines_parser.add_argument(
128
+ "start_line", type=int, help="1-based start line, inclusive."
129
+ )
130
+ write_lines_parser.add_argument(
131
+ "end_line", type=int, help="1-based end line, inclusive."
132
+ )
133
+ write_lines_parser.add_argument(
134
+ "--content", help="Replacement text for the selected lines."
135
+ )
136
+ write_lines_parser.add_argument(
137
+ "--stdin", action="store_true", help="Read replacement text from stdin."
138
+ )
139
+
140
+ search_parser = subparsers.add_parser("search", help="Search text in notes.")
141
+ search_parser.add_argument("query", help="Text to search for.")
142
+ search_parser.add_argument(
143
+ "--case-sensitive",
144
+ action="store_true",
145
+ help="Use case-sensitive matching.",
146
+ )
147
+
148
+ return parser
149
+
150
+ @staticmethod
151
+ def _read_content_arg(content: str | None, use_stdin: bool) -> str:
152
+ if content is not None and use_stdin:
153
+ raise VaultError("use either --content or --stdin, not both")
154
+ if content is not None:
155
+ return content
156
+ if use_stdin:
157
+ return sys.stdin.read()
158
+ raise VaultError("content is required; provide --content or --stdin")
159
+
160
+
161
+ def main(argv: list[str] | None = None) -> int:
162
+ return ObsidianCLI().run(argv)
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from obsidian_cli.vault import ObsidianVault, VaultError
4
+
5
+
6
+ class CommandRunner:
7
+ def __init__(self, vault: ObsidianVault) -> None:
8
+ self._vault = vault
9
+
10
+ def check(self) -> int:
11
+ if not self._vault.exists():
12
+ raise VaultError(f"vault does not exist: {self._vault.root}")
13
+ print(f"vault: {self._vault.root}")
14
+ if self._vault.is_obsidian_vault():
15
+ print("status: ok (.obsidian found)")
16
+ else:
17
+ print("status: warning (.obsidian not found)")
18
+ return 0
19
+
20
+ def list_notes(self, limit: int) -> int:
21
+ notes = self._vault.list_notes()
22
+ if limit > 0:
23
+ notes = notes[:limit]
24
+ for note in notes:
25
+ print(note)
26
+ return 0
27
+
28
+ def read_note(self, note_path: str) -> int:
29
+ print(self._vault.read_note(note_path), end="")
30
+ return 0
31
+
32
+ def read_note_lines(self, note_path: str, start_line: int, end_line: int) -> int:
33
+ print(self._vault.read_note_lines(note_path, start_line, end_line), end="")
34
+ return 0
35
+
36
+ def write_note(self, note_path: str, content: str, create_only: bool) -> int:
37
+ print(self._vault.write_note(note_path, content, create_only=create_only))
38
+ return 0
39
+
40
+ def append_note(self, note_path: str, content: str) -> int:
41
+ print(self._vault.append_note(note_path, content))
42
+ return 0
43
+
44
+ def write_note_lines(
45
+ self,
46
+ note_path: str,
47
+ start_line: int,
48
+ end_line: int,
49
+ content: str,
50
+ ) -> int:
51
+ print(self._vault.write_note_lines(note_path, start_line, end_line, content))
52
+ return 0
53
+
54
+ def search(self, query: str, case_sensitive: bool) -> int:
55
+ matches = self._vault.search(query, case_sensitive=case_sensitive)
56
+ for match in matches:
57
+ print(f"{match.note_path}:{match.line_number}: {match.line_text}")
58
+ return 0 if matches else 1