odcli 0.1.6__tar.gz → 0.1.7__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.6 → odcli-0.1.7}/PKG-INFO +50 -1
- {odcli-0.1.6 → odcli-0.1.7}/README.md +49 -0
- {odcli-0.1.6 → odcli-0.1.7}/pyproject.toml +1 -1
- {odcli-0.1.6 → odcli-0.1.7}/src/obsidian_cli/__init__.py +1 -1
- {odcli-0.1.6 → odcli-0.1.7}/src/obsidian_cli/cli.py +29 -4
- {odcli-0.1.6 → odcli-0.1.7}/src/obsidian_cli/commands.py +2 -2
- {odcli-0.1.6 → odcli-0.1.7}/src/obsidian_cli/discovery.py +14 -1
- odcli-0.1.7/src/obsidian_cli/vault.py +328 -0
- {odcli-0.1.6 → odcli-0.1.7}/src/odcli.egg-info/PKG-INFO +50 -1
- {odcli-0.1.6 → odcli-0.1.7}/tests/test_cli.py +76 -1
- odcli-0.1.6/src/obsidian_cli/vault.py +0 -131
- {odcli-0.1.6 → odcli-0.1.7}/setup.cfg +0 -0
- {odcli-0.1.6 → odcli-0.1.7}/src/obsidian_cli/__main__.py +0 -0
- {odcli-0.1.6 → odcli-0.1.7}/src/obsidian_cli/plugins.py +0 -0
- {odcli-0.1.6 → odcli-0.1.7}/src/odcli.egg-info/SOURCES.txt +0 -0
- {odcli-0.1.6 → odcli-0.1.7}/src/odcli.egg-info/dependency_links.txt +0 -0
- {odcli-0.1.6 → odcli-0.1.7}/src/odcli.egg-info/entry_points.txt +0 -0
- {odcli-0.1.6 → odcli-0.1.7}/src/odcli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: odcli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: A small CLI for reading and writing notes in a local Obsidian vault.
|
|
5
5
|
Author: odcli contributors
|
|
6
6
|
Keywords: obsidian,cli,markdown,notes,vault
|
|
@@ -65,6 +65,47 @@ You can also override the vault per command:
|
|
|
65
65
|
odcli --vault "/path/to/MyVault" list
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
## Remote Vault over SSH
|
|
69
|
+
|
|
70
|
+
You can operate on an Obsidian vault stored on another machine over SSH.
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
odcli \
|
|
76
|
+
--ssh-host your-server \
|
|
77
|
+
--ssh-user your-user \
|
|
78
|
+
--vault /path/to/ObsidianVault \
|
|
79
|
+
list
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Read a remote note:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
odcli \
|
|
86
|
+
--ssh-host your-server \
|
|
87
|
+
--ssh-user your-user \
|
|
88
|
+
--vault /path/to/ObsidianVault \
|
|
89
|
+
read Inbox/today.md
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Write a remote note:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
odcli \
|
|
96
|
+
--ssh-host your-server \
|
|
97
|
+
--ssh-user your-user \
|
|
98
|
+
--vault /path/to/ObsidianVault \
|
|
99
|
+
write Inbox/today.md --content "# Remote note"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Optional SSH flags:
|
|
103
|
+
|
|
104
|
+
- `--ssh-port`
|
|
105
|
+
- `--ssh-identity`
|
|
106
|
+
|
|
107
|
+
In SSH mode, `--vault` or `OBSIDIAN_VAULT` should point to the remote vault path.
|
|
108
|
+
|
|
68
109
|
## Common Commands
|
|
69
110
|
|
|
70
111
|
Read a note:
|
|
@@ -224,6 +265,14 @@ Arguments:
|
|
|
224
265
|
- `query`
|
|
225
266
|
- `--case-sensitive`
|
|
226
267
|
|
|
268
|
+
## Global Options
|
|
269
|
+
|
|
270
|
+
- `--vault`
|
|
271
|
+
- `--ssh-host`
|
|
272
|
+
- `--ssh-user`
|
|
273
|
+
- `--ssh-port`
|
|
274
|
+
- `--ssh-identity`
|
|
275
|
+
|
|
227
276
|
## For Developers
|
|
228
277
|
|
|
229
278
|
Run from source:
|
|
@@ -47,6 +47,47 @@ You can also override the vault per command:
|
|
|
47
47
|
odcli --vault "/path/to/MyVault" list
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## Remote Vault over SSH
|
|
51
|
+
|
|
52
|
+
You can operate on an Obsidian vault stored on another machine over SSH.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
odcli \
|
|
58
|
+
--ssh-host your-server \
|
|
59
|
+
--ssh-user your-user \
|
|
60
|
+
--vault /path/to/ObsidianVault \
|
|
61
|
+
list
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Read a remote note:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
odcli \
|
|
68
|
+
--ssh-host your-server \
|
|
69
|
+
--ssh-user your-user \
|
|
70
|
+
--vault /path/to/ObsidianVault \
|
|
71
|
+
read Inbox/today.md
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Write a remote note:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
odcli \
|
|
78
|
+
--ssh-host your-server \
|
|
79
|
+
--ssh-user your-user \
|
|
80
|
+
--vault /path/to/ObsidianVault \
|
|
81
|
+
write Inbox/today.md --content "# Remote note"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Optional SSH flags:
|
|
85
|
+
|
|
86
|
+
- `--ssh-port`
|
|
87
|
+
- `--ssh-identity`
|
|
88
|
+
|
|
89
|
+
In SSH mode, `--vault` or `OBSIDIAN_VAULT` should point to the remote vault path.
|
|
90
|
+
|
|
50
91
|
## Common Commands
|
|
51
92
|
|
|
52
93
|
Read a note:
|
|
@@ -206,6 +247,14 @@ Arguments:
|
|
|
206
247
|
- `query`
|
|
207
248
|
- `--case-sensitive`
|
|
208
249
|
|
|
250
|
+
## Global Options
|
|
251
|
+
|
|
252
|
+
- `--vault`
|
|
253
|
+
- `--ssh-host`
|
|
254
|
+
- `--ssh-user`
|
|
255
|
+
- `--ssh-port`
|
|
256
|
+
- `--ssh-identity`
|
|
257
|
+
|
|
209
258
|
## For Developers
|
|
210
259
|
|
|
211
260
|
Run from source:
|
|
@@ -6,7 +6,13 @@ import sys
|
|
|
6
6
|
from obsidian_cli.commands import CommandRunner
|
|
7
7
|
from obsidian_cli.discovery import VaultLocator
|
|
8
8
|
from obsidian_cli.plugins import SkillInstaller
|
|
9
|
-
from obsidian_cli.vault import
|
|
9
|
+
from obsidian_cli.vault import (
|
|
10
|
+
ObsidianVault,
|
|
11
|
+
SshConfig,
|
|
12
|
+
SshObsidianVault,
|
|
13
|
+
VaultBackend,
|
|
14
|
+
VaultError,
|
|
15
|
+
)
|
|
10
16
|
|
|
11
17
|
|
|
12
18
|
class ObsidianCLI:
|
|
@@ -22,9 +28,7 @@ class ObsidianCLI:
|
|
|
22
28
|
if args.command == "plugin":
|
|
23
29
|
return self._run_plugin_command(args)
|
|
24
30
|
|
|
25
|
-
runner = CommandRunner(
|
|
26
|
-
ObsidianVault(self._vault_locator.resolve(args.vault))
|
|
27
|
-
)
|
|
31
|
+
runner = CommandRunner(self._build_vault(args))
|
|
28
32
|
|
|
29
33
|
if args.command == "check":
|
|
30
34
|
return runner.check()
|
|
@@ -72,6 +76,13 @@ class ObsidianCLI:
|
|
|
72
76
|
"--vault",
|
|
73
77
|
help="Path to the Obsidian vault. Falls back to OBSIDIAN_VAULT.",
|
|
74
78
|
)
|
|
79
|
+
parser.add_argument("--ssh-host", help="SSH host for a remote vault.")
|
|
80
|
+
parser.add_argument("--ssh-user", help="SSH username for a remote vault.")
|
|
81
|
+
parser.add_argument("--ssh-port", type=int, help="SSH port for a remote vault.")
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--ssh-identity",
|
|
84
|
+
help="SSH identity file used when connecting to a remote vault.",
|
|
85
|
+
)
|
|
75
86
|
|
|
76
87
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
77
88
|
subparsers.add_parser("check", help="Validate the vault path.")
|
|
@@ -175,6 +186,20 @@ class ObsidianCLI:
|
|
|
175
186
|
self._parser.error(f"unsupported plugin command: {args.plugin_command}")
|
|
176
187
|
return 2
|
|
177
188
|
|
|
189
|
+
def _build_vault(self, args: argparse.Namespace) -> VaultBackend:
|
|
190
|
+
if args.ssh_host:
|
|
191
|
+
ssh_root = self._vault_locator.resolve_configured(args.vault)
|
|
192
|
+
return SshObsidianVault(
|
|
193
|
+
str(ssh_root),
|
|
194
|
+
SshConfig(
|
|
195
|
+
host=args.ssh_host,
|
|
196
|
+
user=args.ssh_user,
|
|
197
|
+
port=args.ssh_port,
|
|
198
|
+
identity_file=args.ssh_identity,
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
return ObsidianVault(self._vault_locator.resolve(args.vault))
|
|
202
|
+
|
|
178
203
|
@staticmethod
|
|
179
204
|
def _read_content_arg(content: str | None, use_stdin: bool) -> str:
|
|
180
205
|
if content is not None and use_stdin:
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from obsidian_cli.vault import
|
|
3
|
+
from obsidian_cli.vault import VaultBackend, VaultError
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class CommandRunner:
|
|
7
|
-
def __init__(self, vault:
|
|
7
|
+
def __init__(self, vault: VaultBackend) -> None:
|
|
8
8
|
self._vault = vault
|
|
9
9
|
|
|
10
10
|
def check(self) -> int:
|
|
@@ -18,7 +18,7 @@ class VaultLocator:
|
|
|
18
18
|
def __init__(
|
|
19
19
|
self, env: dict[str, str] | None = None, home: Path | None = None
|
|
20
20
|
) -> None:
|
|
21
|
-
self._env = dict(env
|
|
21
|
+
self._env = dict(os.environ if env is None else env)
|
|
22
22
|
self._home = (home or Path.home()).expanduser()
|
|
23
23
|
|
|
24
24
|
def resolve(self, cli_value: str | None) -> Path:
|
|
@@ -38,6 +38,19 @@ class VaultLocator:
|
|
|
38
38
|
"vault path is required; use --vault, OBSIDIAN_VAULT, or place your vault in a default Obsidian location"
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
+
def resolve_configured(self, cli_value: str | None) -> Path:
|
|
42
|
+
cli_path = self._path_from_string(cli_value)
|
|
43
|
+
if cli_path is not None:
|
|
44
|
+
return cli_path
|
|
45
|
+
|
|
46
|
+
env_path = self._path_from_string(self._env.get("OBSIDIAN_VAULT"))
|
|
47
|
+
if env_path is not None:
|
|
48
|
+
return env_path
|
|
49
|
+
|
|
50
|
+
raise VaultError(
|
|
51
|
+
"vault path is required for SSH mode; use --vault or OBSIDIAN_VAULT"
|
|
52
|
+
)
|
|
53
|
+
|
|
41
54
|
def discover_default_vault(self) -> VaultCandidate | None:
|
|
42
55
|
config_candidate = self._discover_from_obsidian_config()
|
|
43
56
|
if config_candidate is not None:
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VaultError(Exception):
|
|
11
|
+
"""Raised when the vault cannot fulfill a requested operation."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class SearchMatch:
|
|
16
|
+
note_path: str
|
|
17
|
+
line_number: int
|
|
18
|
+
line_text: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class SshConfig:
|
|
23
|
+
host: str
|
|
24
|
+
user: str | None = None
|
|
25
|
+
port: int | None = None
|
|
26
|
+
identity_file: str | None = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def destination(self) -> str:
|
|
30
|
+
return f"{self.user}@{self.host}" if self.user else self.host
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VaultBackend:
|
|
34
|
+
root: Path | str
|
|
35
|
+
|
|
36
|
+
def exists(self) -> bool:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def is_obsidian_vault(self) -> bool:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def list_notes(self) -> list[str]:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def read_note(self, note_path: str) -> str:
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
def write_note(
|
|
49
|
+
self, note_path: str, content: str, create_only: bool = False
|
|
50
|
+
) -> Path | str:
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def append_note(self, note_path: str, content: str) -> Path | str:
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def read_note_lines(self, note_path: str, start_line: int, end_line: int) -> str:
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
def write_note_lines(
|
|
60
|
+
self, note_path: str, start_line: int, end_line: int, content: str
|
|
61
|
+
) -> Path | str:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
def search(self, query: str, case_sensitive: bool = False) -> list[SearchMatch]:
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ObsidianVault(VaultBackend):
|
|
69
|
+
def __init__(self, root: Path) -> None:
|
|
70
|
+
self.root = root.expanduser().resolve()
|
|
71
|
+
|
|
72
|
+
def exists(self) -> bool:
|
|
73
|
+
return self.root.exists() and self.root.is_dir()
|
|
74
|
+
|
|
75
|
+
def is_obsidian_vault(self) -> bool:
|
|
76
|
+
return (self.root / ".obsidian").exists()
|
|
77
|
+
|
|
78
|
+
def resolve_note(self, note_path: str) -> Path:
|
|
79
|
+
candidate = (self.root / note_path).resolve()
|
|
80
|
+
if candidate != self.root and self.root not in candidate.parents:
|
|
81
|
+
raise VaultError(f"path escapes vault root: {note_path}")
|
|
82
|
+
return candidate
|
|
83
|
+
|
|
84
|
+
def list_notes(self) -> list[str]:
|
|
85
|
+
if not self.exists():
|
|
86
|
+
raise VaultError(f"vault does not exist: {self.root}")
|
|
87
|
+
return sorted(
|
|
88
|
+
str(path.relative_to(self.root))
|
|
89
|
+
for path in self.root.rglob("*.md")
|
|
90
|
+
if ".obsidian" not in path.parts
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def read_note(self, note_path: str) -> str:
|
|
94
|
+
path = self.resolve_note(note_path)
|
|
95
|
+
if not path.exists():
|
|
96
|
+
raise VaultError(f"note not found: {note_path}")
|
|
97
|
+
if not path.is_file():
|
|
98
|
+
raise VaultError(f"not a file: {note_path}")
|
|
99
|
+
return path.read_text(encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
def _read_existing_markdown_path(self, note_path: str) -> Path:
|
|
102
|
+
path = self.resolve_note(note_path)
|
|
103
|
+
self._validate_markdown_extension(path, note_path)
|
|
104
|
+
if not path.exists():
|
|
105
|
+
raise VaultError(f"note not found: {note_path}")
|
|
106
|
+
if not path.is_file():
|
|
107
|
+
raise VaultError(f"not a file: {note_path}")
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _validate_markdown_extension(path: Path, note_path: str) -> None:
|
|
112
|
+
if path.suffix.lower() != ".md":
|
|
113
|
+
raise VaultError(f"only markdown files are supported (.md): {note_path}")
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _validate_line_range(start_line: int, end_line: int, line_count: int) -> None:
|
|
117
|
+
if start_line < 1 or end_line < 1:
|
|
118
|
+
raise VaultError("line numbers must be >= 1")
|
|
119
|
+
if start_line > end_line:
|
|
120
|
+
raise VaultError("start line must be <= end line")
|
|
121
|
+
if end_line > line_count:
|
|
122
|
+
raise VaultError(
|
|
123
|
+
f"line range {start_line}-{end_line} exceeds file length {line_count}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def write_note(
|
|
127
|
+
self, note_path: str, content: str, create_only: bool = False
|
|
128
|
+
) -> Path:
|
|
129
|
+
path = self.resolve_note(note_path)
|
|
130
|
+
self._validate_markdown_extension(path, note_path)
|
|
131
|
+
if create_only and path.exists():
|
|
132
|
+
raise VaultError(f"note already exists: {note_path}")
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
path.write_text(content, encoding="utf-8")
|
|
135
|
+
return path
|
|
136
|
+
|
|
137
|
+
def append_note(self, note_path: str, content: str) -> Path:
|
|
138
|
+
path = self.resolve_note(note_path)
|
|
139
|
+
self._validate_markdown_extension(path, note_path)
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
142
|
+
handle.write(content)
|
|
143
|
+
return path
|
|
144
|
+
|
|
145
|
+
def read_note_lines(self, note_path: str, start_line: int, end_line: int) -> str:
|
|
146
|
+
path = self._read_existing_markdown_path(note_path)
|
|
147
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
148
|
+
self._validate_line_range(start_line, end_line, len(lines))
|
|
149
|
+
return "".join(lines[start_line - 1 : end_line])
|
|
150
|
+
|
|
151
|
+
def write_note_lines(
|
|
152
|
+
self, note_path: str, start_line: int, end_line: int, content: str
|
|
153
|
+
) -> Path:
|
|
154
|
+
path = self._read_existing_markdown_path(note_path)
|
|
155
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
156
|
+
self._validate_line_range(start_line, end_line, len(lines))
|
|
157
|
+
|
|
158
|
+
replacement_lines = content.splitlines(keepends=True)
|
|
159
|
+
lines[start_line - 1 : end_line] = replacement_lines
|
|
160
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
161
|
+
return path
|
|
162
|
+
|
|
163
|
+
def search(self, query: str, case_sensitive: bool = False) -> list[SearchMatch]:
|
|
164
|
+
if not query:
|
|
165
|
+
raise VaultError("query must not be empty")
|
|
166
|
+
|
|
167
|
+
needle = query if case_sensitive else query.lower()
|
|
168
|
+
matches: list[SearchMatch] = []
|
|
169
|
+
|
|
170
|
+
for note_path in self.list_notes():
|
|
171
|
+
content = self.read_note(note_path)
|
|
172
|
+
for idx, line in enumerate(content.splitlines(), start=1):
|
|
173
|
+
haystack = line if case_sensitive else line.lower()
|
|
174
|
+
if needle in haystack:
|
|
175
|
+
matches.append(
|
|
176
|
+
SearchMatch(
|
|
177
|
+
note_path=note_path,
|
|
178
|
+
line_number=idx,
|
|
179
|
+
line_text=line,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return matches
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class SshObsidianVault(VaultBackend):
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
root: str,
|
|
189
|
+
ssh_config: SshConfig,
|
|
190
|
+
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
191
|
+
) -> None:
|
|
192
|
+
self._root = PurePosixPath(root)
|
|
193
|
+
self.root = f"{ssh_config.destination}:{self._root}"
|
|
194
|
+
self._ssh_config = ssh_config
|
|
195
|
+
self._runner = runner
|
|
196
|
+
|
|
197
|
+
def exists(self) -> bool:
|
|
198
|
+
return self._remote_test(f"test -d {self._quote(self._root)}")
|
|
199
|
+
|
|
200
|
+
def is_obsidian_vault(self) -> bool:
|
|
201
|
+
return self._remote_test(f"test -d {self._quote(self._root / '.obsidian')}")
|
|
202
|
+
|
|
203
|
+
def list_notes(self) -> list[str]:
|
|
204
|
+
if not self.exists():
|
|
205
|
+
raise VaultError(f"vault does not exist: {self.root}")
|
|
206
|
+
command = (
|
|
207
|
+
f"cd {self._quote(self._root)} && "
|
|
208
|
+
"find . -path './.obsidian' -prune -o -type f -name '*.md' -print | "
|
|
209
|
+
"sed 's#^\\./##' | sort"
|
|
210
|
+
)
|
|
211
|
+
output = self._run_ssh(command)
|
|
212
|
+
return [line for line in output.splitlines() if line]
|
|
213
|
+
|
|
214
|
+
def read_note(self, note_path: str) -> str:
|
|
215
|
+
path = self._resolve_note(note_path)
|
|
216
|
+
self._ensure_remote_markdown(path, note_path)
|
|
217
|
+
self._ensure_remote_exists(path, note_path)
|
|
218
|
+
return self._run_ssh(f"cat {self._quote(path)}", strip_output=False)
|
|
219
|
+
|
|
220
|
+
def write_note(
|
|
221
|
+
self, note_path: str, content: str, create_only: bool = False
|
|
222
|
+
) -> str:
|
|
223
|
+
path = self._resolve_note(note_path)
|
|
224
|
+
self._ensure_remote_markdown(path, note_path)
|
|
225
|
+
if create_only and self._remote_test(f"test -e {self._quote(path)}"):
|
|
226
|
+
raise VaultError(f"note already exists: {note_path}")
|
|
227
|
+
self._run_ssh(
|
|
228
|
+
f"mkdir -p {self._quote(path.parent)} && cat > {self._quote(path)}",
|
|
229
|
+
input_text=content,
|
|
230
|
+
strip_output=False,
|
|
231
|
+
)
|
|
232
|
+
return str(path)
|
|
233
|
+
|
|
234
|
+
def append_note(self, note_path: str, content: str) -> str:
|
|
235
|
+
path = self._resolve_note(note_path)
|
|
236
|
+
self._ensure_remote_markdown(path, note_path)
|
|
237
|
+
self._run_ssh(
|
|
238
|
+
f"mkdir -p {self._quote(path.parent)} && cat >> {self._quote(path)}",
|
|
239
|
+
input_text=content,
|
|
240
|
+
strip_output=False,
|
|
241
|
+
)
|
|
242
|
+
return str(path)
|
|
243
|
+
|
|
244
|
+
def read_note_lines(self, note_path: str, start_line: int, end_line: int) -> str:
|
|
245
|
+
content = self.read_note(note_path)
|
|
246
|
+
lines = content.splitlines(keepends=True)
|
|
247
|
+
ObsidianVault._validate_line_range(start_line, end_line, len(lines))
|
|
248
|
+
return "".join(lines[start_line - 1 : end_line])
|
|
249
|
+
|
|
250
|
+
def write_note_lines(
|
|
251
|
+
self, note_path: str, start_line: int, end_line: int, content: str
|
|
252
|
+
) -> str:
|
|
253
|
+
existing = self.read_note(note_path)
|
|
254
|
+
lines = existing.splitlines(keepends=True)
|
|
255
|
+
ObsidianVault._validate_line_range(start_line, end_line, len(lines))
|
|
256
|
+
lines[start_line - 1 : end_line] = content.splitlines(keepends=True)
|
|
257
|
+
return self.write_note(note_path, "".join(lines))
|
|
258
|
+
|
|
259
|
+
def search(self, query: str, case_sensitive: bool = False) -> list[SearchMatch]:
|
|
260
|
+
if not query:
|
|
261
|
+
raise VaultError("query must not be empty")
|
|
262
|
+
|
|
263
|
+
needle = query if case_sensitive else query.lower()
|
|
264
|
+
matches: list[SearchMatch] = []
|
|
265
|
+
for note_path in self.list_notes():
|
|
266
|
+
content = self.read_note(note_path)
|
|
267
|
+
for idx, line in enumerate(content.splitlines(), start=1):
|
|
268
|
+
haystack = line if case_sensitive else line.lower()
|
|
269
|
+
if needle in haystack:
|
|
270
|
+
matches.append(
|
|
271
|
+
SearchMatch(
|
|
272
|
+
note_path=note_path,
|
|
273
|
+
line_number=idx,
|
|
274
|
+
line_text=line,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
return matches
|
|
278
|
+
|
|
279
|
+
def _ensure_remote_exists(self, path: PurePosixPath, note_path: str) -> None:
|
|
280
|
+
if not self._remote_test(f"test -f {self._quote(path)}"):
|
|
281
|
+
raise VaultError(f"note not found: {note_path}")
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _ensure_remote_markdown(path: PurePosixPath, note_path: str) -> None:
|
|
285
|
+
if path.suffix.lower() != ".md":
|
|
286
|
+
raise VaultError(f"only markdown files are supported (.md): {note_path}")
|
|
287
|
+
|
|
288
|
+
def _resolve_note(self, note_path: str) -> PurePosixPath:
|
|
289
|
+
relative = PurePosixPath(note_path)
|
|
290
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
291
|
+
raise VaultError(f"path escapes vault root: {note_path}")
|
|
292
|
+
return self._root / relative
|
|
293
|
+
|
|
294
|
+
def _remote_test(self, command: str) -> bool:
|
|
295
|
+
ssh_command = self._build_ssh_command(command)
|
|
296
|
+
result = self._runner(ssh_command, text=True, capture_output=True)
|
|
297
|
+
return result.returncode == 0
|
|
298
|
+
|
|
299
|
+
def _run_ssh(
|
|
300
|
+
self, command: str, input_text: str | None = None, strip_output: bool = True
|
|
301
|
+
) -> str:
|
|
302
|
+
ssh_command = self._build_ssh_command(command)
|
|
303
|
+
result = self._runner(
|
|
304
|
+
ssh_command,
|
|
305
|
+
text=True,
|
|
306
|
+
capture_output=True,
|
|
307
|
+
input=input_text,
|
|
308
|
+
)
|
|
309
|
+
if result.returncode != 0:
|
|
310
|
+
message = (
|
|
311
|
+
result.stderr.strip() or result.stdout.strip() or "ssh command failed"
|
|
312
|
+
)
|
|
313
|
+
raise VaultError(message)
|
|
314
|
+
return result.stdout.strip() if strip_output else result.stdout
|
|
315
|
+
|
|
316
|
+
def _build_ssh_command(self, remote_command: str) -> list[str]:
|
|
317
|
+
command = ["ssh"]
|
|
318
|
+
if self._ssh_config.port is not None:
|
|
319
|
+
command.extend(["-p", str(self._ssh_config.port)])
|
|
320
|
+
if self._ssh_config.identity_file:
|
|
321
|
+
command.extend(["-i", self._ssh_config.identity_file])
|
|
322
|
+
command.append(self._ssh_config.destination)
|
|
323
|
+
command.append(remote_command)
|
|
324
|
+
return command
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _quote(path: PurePosixPath) -> str:
|
|
328
|
+
return shlex.quote(str(path))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: odcli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: A small CLI for reading and writing notes in a local Obsidian vault.
|
|
5
5
|
Author: odcli contributors
|
|
6
6
|
Keywords: obsidian,cli,markdown,notes,vault
|
|
@@ -65,6 +65,47 @@ You can also override the vault per command:
|
|
|
65
65
|
odcli --vault "/path/to/MyVault" list
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
## Remote Vault over SSH
|
|
69
|
+
|
|
70
|
+
You can operate on an Obsidian vault stored on another machine over SSH.
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
odcli \
|
|
76
|
+
--ssh-host your-server \
|
|
77
|
+
--ssh-user your-user \
|
|
78
|
+
--vault /path/to/ObsidianVault \
|
|
79
|
+
list
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Read a remote note:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
odcli \
|
|
86
|
+
--ssh-host your-server \
|
|
87
|
+
--ssh-user your-user \
|
|
88
|
+
--vault /path/to/ObsidianVault \
|
|
89
|
+
read Inbox/today.md
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Write a remote note:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
odcli \
|
|
96
|
+
--ssh-host your-server \
|
|
97
|
+
--ssh-user your-user \
|
|
98
|
+
--vault /path/to/ObsidianVault \
|
|
99
|
+
write Inbox/today.md --content "# Remote note"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Optional SSH flags:
|
|
103
|
+
|
|
104
|
+
- `--ssh-port`
|
|
105
|
+
- `--ssh-identity`
|
|
106
|
+
|
|
107
|
+
In SSH mode, `--vault` or `OBSIDIAN_VAULT` should point to the remote vault path.
|
|
108
|
+
|
|
68
109
|
## Common Commands
|
|
69
110
|
|
|
70
111
|
Read a note:
|
|
@@ -224,6 +265,14 @@ Arguments:
|
|
|
224
265
|
- `query`
|
|
225
266
|
- `--case-sensitive`
|
|
226
267
|
|
|
268
|
+
## Global Options
|
|
269
|
+
|
|
270
|
+
- `--vault`
|
|
271
|
+
- `--ssh-host`
|
|
272
|
+
- `--ssh-user`
|
|
273
|
+
- `--ssh-port`
|
|
274
|
+
- `--ssh-identity`
|
|
275
|
+
|
|
227
276
|
## For Developers
|
|
228
277
|
|
|
229
278
|
Run from source:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
import subprocess
|
|
4
5
|
import tempfile
|
|
5
6
|
import unittest
|
|
6
7
|
from contextlib import redirect_stderr, redirect_stdout
|
|
@@ -13,7 +14,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
|
13
14
|
from obsidian_cli.cli import ObsidianCLI, main
|
|
14
15
|
from obsidian_cli.discovery import VaultCandidate, VaultLocator
|
|
15
16
|
from obsidian_cli.plugins import SkillInstaller
|
|
16
|
-
from obsidian_cli.vault import ObsidianVault, VaultError
|
|
17
|
+
from obsidian_cli.vault import ObsidianVault, SshConfig, SshObsidianVault, VaultError
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class VaultTests(unittest.TestCase):
|
|
@@ -102,6 +103,11 @@ class VaultTests(unittest.TestCase):
|
|
|
102
103
|
)
|
|
103
104
|
self.assertEqual(locator.resolve(None), self.vault_root)
|
|
104
105
|
|
|
106
|
+
def test_vault_locator_resolve_configured_requires_explicit_or_env(self) -> None:
|
|
107
|
+
locator = VaultLocator(env={}, home=self.vault_root)
|
|
108
|
+
with self.assertRaises(VaultError):
|
|
109
|
+
locator.resolve_configured(None)
|
|
110
|
+
|
|
105
111
|
def test_vault_locator_discovers_default_vault(self) -> None:
|
|
106
112
|
locator = VaultLocator(env={}, home=self.vault_root.parent)
|
|
107
113
|
with patch.object(locator, "_discover_from_obsidian_config", return_value=None):
|
|
@@ -160,6 +166,75 @@ class VaultTests(unittest.TestCase):
|
|
|
160
166
|
self.assertIn("codex-skill:", output)
|
|
161
167
|
self.assertIn("claude-skill:", output)
|
|
162
168
|
|
|
169
|
+
def test_ssh_vault_list_notes(self) -> None:
|
|
170
|
+
def fake_runner(
|
|
171
|
+
command: list[str], **_: object
|
|
172
|
+
) -> subprocess.CompletedProcess[str]:
|
|
173
|
+
if "test -d" in command[-1]:
|
|
174
|
+
return subprocess.CompletedProcess(command, 0, "", "")
|
|
175
|
+
if "find ." in command[-1]:
|
|
176
|
+
return subprocess.CompletedProcess(
|
|
177
|
+
command,
|
|
178
|
+
0,
|
|
179
|
+
"Inbox/today.md\nProjects/alpha.md\n",
|
|
180
|
+
"",
|
|
181
|
+
)
|
|
182
|
+
return subprocess.CompletedProcess(command, 1, "", "unexpected command")
|
|
183
|
+
|
|
184
|
+
vault = SshObsidianVault(
|
|
185
|
+
"/vault", SshConfig(host="example.com"), runner=fake_runner
|
|
186
|
+
)
|
|
187
|
+
self.assertEqual(vault.list_notes(), ["Inbox/today.md", "Projects/alpha.md"])
|
|
188
|
+
|
|
189
|
+
def test_ssh_vault_write_and_read_note(self) -> None:
|
|
190
|
+
written: dict[str, str] = {}
|
|
191
|
+
|
|
192
|
+
def fake_runner(
|
|
193
|
+
command: list[str], **kwargs: object
|
|
194
|
+
) -> subprocess.CompletedProcess[str]:
|
|
195
|
+
remote_command = command[-1]
|
|
196
|
+
if "test -f" in remote_command:
|
|
197
|
+
exists = "/vault/Inbox/test.md" in written
|
|
198
|
+
return subprocess.CompletedProcess(command, 0 if exists else 1, "", "")
|
|
199
|
+
if "mkdir -p" in remote_command and "cat >" in remote_command:
|
|
200
|
+
written["/vault/Inbox/test.md"] = str(kwargs.get("input", ""))
|
|
201
|
+
return subprocess.CompletedProcess(command, 0, "", "")
|
|
202
|
+
if remote_command == "cat /vault/Inbox/test.md":
|
|
203
|
+
return subprocess.CompletedProcess(
|
|
204
|
+
command,
|
|
205
|
+
0,
|
|
206
|
+
written["/vault/Inbox/test.md"],
|
|
207
|
+
"",
|
|
208
|
+
)
|
|
209
|
+
return subprocess.CompletedProcess(command, 1, "", "unexpected command")
|
|
210
|
+
|
|
211
|
+
vault = SshObsidianVault(
|
|
212
|
+
"/vault", SshConfig(host="example.com"), runner=fake_runner
|
|
213
|
+
)
|
|
214
|
+
vault.write_note("Inbox/test.md", "# Hello\n")
|
|
215
|
+
self.assertEqual(vault.read_note("Inbox/test.md"), "# Hello\n")
|
|
216
|
+
|
|
217
|
+
def test_cli_builds_ssh_vault_when_requested(self) -> None:
|
|
218
|
+
cli = ObsidianCLI(
|
|
219
|
+
vault_locator=VaultLocator(
|
|
220
|
+
env={"OBSIDIAN_VAULT": "/remote/vault"}, home=self.vault_root
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
vault = cli._build_vault(
|
|
224
|
+
type(
|
|
225
|
+
"Args",
|
|
226
|
+
(),
|
|
227
|
+
{
|
|
228
|
+
"vault": None,
|
|
229
|
+
"ssh_host": "example.com",
|
|
230
|
+
"ssh_user": "alice",
|
|
231
|
+
"ssh_port": 2222,
|
|
232
|
+
"ssh_identity": "~/.ssh/id_ed25519",
|
|
233
|
+
},
|
|
234
|
+
)()
|
|
235
|
+
)
|
|
236
|
+
self.assertIsInstance(vault, SshObsidianVault)
|
|
237
|
+
|
|
163
238
|
|
|
164
239
|
if __name__ == "__main__":
|
|
165
240
|
unittest.main()
|
|
@@ -1,131 +0,0 @@
|
|
|
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(
|
|
62
|
-
self, start_line: int, end_line: int, line_count: int
|
|
63
|
-
) -> None:
|
|
64
|
-
if start_line < 1 or end_line < 1:
|
|
65
|
-
raise VaultError("line numbers must be >= 1")
|
|
66
|
-
if start_line > end_line:
|
|
67
|
-
raise VaultError("start line must be <= end line")
|
|
68
|
-
if end_line > line_count:
|
|
69
|
-
raise VaultError(
|
|
70
|
-
f"line range {start_line}-{end_line} exceeds file length {line_count}"
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
def write_note(
|
|
74
|
-
self, note_path: str, content: str, create_only: bool = False
|
|
75
|
-
) -> Path:
|
|
76
|
-
path = self.resolve_note(note_path)
|
|
77
|
-
if path.suffix.lower() != ".md":
|
|
78
|
-
raise VaultError("only markdown files are supported (.md)")
|
|
79
|
-
if create_only and path.exists():
|
|
80
|
-
raise VaultError(f"note already exists: {note_path}")
|
|
81
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
-
path.write_text(content, encoding="utf-8")
|
|
83
|
-
return path
|
|
84
|
-
|
|
85
|
-
def append_note(self, note_path: str, content: str) -> Path:
|
|
86
|
-
path = self.resolve_note(note_path)
|
|
87
|
-
if path.suffix.lower() != ".md":
|
|
88
|
-
raise VaultError("only markdown files are supported (.md)")
|
|
89
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
-
with path.open("a", encoding="utf-8") as handle:
|
|
91
|
-
handle.write(content)
|
|
92
|
-
return path
|
|
93
|
-
|
|
94
|
-
def read_note_lines(self, note_path: str, start_line: int, end_line: int) -> str:
|
|
95
|
-
path = self._read_existing_markdown_path(note_path)
|
|
96
|
-
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
97
|
-
self._validate_line_range(start_line, end_line, len(lines))
|
|
98
|
-
return "".join(lines[start_line - 1 : end_line])
|
|
99
|
-
|
|
100
|
-
def write_note_lines(
|
|
101
|
-
self, note_path: str, start_line: int, end_line: int, content: str
|
|
102
|
-
) -> Path:
|
|
103
|
-
path = self._read_existing_markdown_path(note_path)
|
|
104
|
-
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
105
|
-
self._validate_line_range(start_line, end_line, len(lines))
|
|
106
|
-
|
|
107
|
-
replacement_lines = content.splitlines(keepends=True)
|
|
108
|
-
lines[start_line - 1 : end_line] = replacement_lines
|
|
109
|
-
path.write_text("".join(lines), encoding="utf-8")
|
|
110
|
-
return path
|
|
111
|
-
|
|
112
|
-
def search(self, query: str, case_sensitive: bool = False) -> list[SearchMatch]:
|
|
113
|
-
if not query:
|
|
114
|
-
raise VaultError("query must not be empty")
|
|
115
|
-
|
|
116
|
-
needle = query if case_sensitive else query.lower()
|
|
117
|
-
matches: list[SearchMatch] = []
|
|
118
|
-
|
|
119
|
-
for note_path in self.list_notes():
|
|
120
|
-
content = self.read_note(note_path)
|
|
121
|
-
for idx, line in enumerate(content.splitlines(), start=1):
|
|
122
|
-
haystack = line if case_sensitive else line.lower()
|
|
123
|
-
if needle in haystack:
|
|
124
|
-
matches.append(
|
|
125
|
-
SearchMatch(
|
|
126
|
-
note_path=note_path,
|
|
127
|
-
line_number=idx,
|
|
128
|
-
line_text=line,
|
|
129
|
-
)
|
|
130
|
-
)
|
|
131
|
-
return matches
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|