odcli 0.1.0__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.
odcli-0.1.3/PKG-INFO ADDED
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: odcli
3
+ Version: 0.1.3
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: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Utilities
18
+ Classifier: Environment :: Console
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+
22
+ # odcli
23
+
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.
26
+
27
+ ## Features
28
+
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
38
+
39
+ ## Using uv
40
+
41
+ ```bash
42
+ cd /Users/huchang/agents/obsidian_cli
43
+ uv sync
44
+ uv run odcli --help
45
+ ```
46
+
47
+ Run tests:
48
+
49
+ ```bash
50
+ cd /Users/huchang/agents/obsidian_cli
51
+ uv run python -m unittest discover -s tests
52
+ ```
53
+
54
+ Build distributions:
55
+
56
+ ```bash
57
+ cd /Users/huchang/agents/obsidian_cli
58
+ uv build
59
+ ```
60
+
61
+ The published package name on PyPI is `odcli`.
62
+ After installation, both `odcli` and `obsidian-cli` are available as command names.
63
+
64
+ ## Run Locally
65
+
66
+ ```bash
67
+ cd /Users/huchang/agents/obsidian_cli
68
+ ./odcli --help
69
+ ```
70
+
71
+ The compatibility entry point is still available:
72
+
73
+ ```bash
74
+ cd /Users/huchang/agents/obsidian_cli
75
+ ./obsidian-cli --help
76
+ ```
77
+
78
+ If you prefer module execution:
79
+
80
+ ```bash
81
+ PYTHONPATH=src python3 -m obsidian_cli --help
82
+ ```
83
+
84
+ ## Vault Resolution
85
+
86
+ Resolution priority:
87
+
88
+ 1. `--vault /path/to/vault`
89
+ 2. `OBSIDIAN_VAULT`
90
+ 3. The most recently opened vault recorded by local Obsidian config
91
+ 4. Common default directories
92
+
93
+ Built-in default locations:
94
+
95
+ - macOS: `~/Documents/Obsidian Vault`
96
+ - macOS: `~/Documents/Obsidian`
97
+ - macOS iCloud: `~/Library/Mobile Documents/iCloud~md~obsidian/Documents`
98
+ - Windows: `%USERPROFILE%\\Documents\\Obsidian Vault`
99
+ - Windows: `%USERPROFILE%\\Documents\\Obsidian`
100
+
101
+ Example:
102
+
103
+ ```bash
104
+ export OBSIDIAN_VAULT="/Users/your-name/Documents/MyVault"
105
+ ./odcli check
106
+ ./odcli list
107
+ ./odcli read Inbox/today.md
108
+ ./odcli read-lines Inbox/today.md 3 8
109
+ ./odcli write Inbox/today.md --content "# Today"
110
+ ./odcli write-lines Inbox/today.md 3 4 --content "- replaced\n- lines\n"
111
+ ./odcli append Inbox/today.md --content "\n- new item"
112
+ ./odcli search "project alpha"
113
+ ```
114
+
115
+ ## Commands
116
+
117
+ ### `check`
118
+
119
+ Validate that the vault exists and report whether `.obsidian` is present.
120
+
121
+ ### `list`
122
+
123
+ List Markdown notes in the vault.
124
+
125
+ Optional arguments:
126
+
127
+ - `--limit N`
128
+
129
+ ### `read`
130
+
131
+ Read a note.
132
+
133
+ Arguments:
134
+
135
+ - `note_path`: path relative to the vault root
136
+
137
+ ### `write`
138
+
139
+ Overwrite a note. Parent directories are created automatically if needed.
140
+
141
+ Arguments:
142
+
143
+ - `note_path`
144
+ - `--content TEXT`
145
+ - `--stdin`
146
+
147
+ Optional arguments:
148
+
149
+ - `--create-only`
150
+
151
+ ### `read-lines`
152
+
153
+ Read a line range. Line numbers are 1-based and inclusive.
154
+
155
+ Arguments:
156
+
157
+ - `note_path`
158
+ - `start_line`
159
+ - `end_line`
160
+
161
+ ### `write-lines`
162
+
163
+ Replace a line range. Line numbers are 1-based and inclusive.
164
+
165
+ Arguments:
166
+
167
+ - `note_path`
168
+ - `start_line`
169
+ - `end_line`
170
+ - `--content TEXT`
171
+ - `--stdin`
172
+
173
+ ### `append`
174
+
175
+ Append content to the end of a note.
176
+
177
+ Arguments:
178
+
179
+ - `note_path`
180
+ - `--content TEXT`
181
+ - `--stdin`
182
+
183
+ ### `search`
184
+
185
+ Search across all Markdown notes in the vault.
186
+
187
+ Arguments:
188
+
189
+ - `query`
190
+ - `--case-sensitive`
191
+
192
+ ## Testing
193
+
194
+ ```bash
195
+ cd /Users/huchang/agents/obsidian_cli
196
+ uv run python -m unittest discover -s tests
197
+ ```
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.0"
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",
@@ -30,11 +31,13 @@ Repository = "https://github.com/Chang-LeHung/obsidian-cli"
30
31
  Issues = "https://github.com/Chang-LeHung/obsidian-cli/issues"
31
32
 
32
33
  [project.scripts]
34
+ odcli = "obsidian_cli.cli:main"
33
35
  obsidian-cli = "obsidian_cli.cli:main"
34
36
 
35
37
  [dependency-groups]
36
38
  dev = [
37
39
  "build>=1.2.2",
40
+ "ruff>=0.14.0",
38
41
  "twine>=6.1.0",
39
42
  ]
40
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