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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odcli
3
- Version: 0.1.6
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "odcli"
7
- version = "0.1.6"
7
+ version = "0.1.7"
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"
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.1.6"
3
+ __version__ = "0.1.7"
@@ -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 ObsidianVault, VaultError
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 ObsidianVault, VaultError
3
+ from obsidian_cli.vault import VaultBackend, VaultError
4
4
 
5
5
 
6
6
  class CommandRunner:
7
- def __init__(self, vault: ObsidianVault) -> None:
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 or os.environ)
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.6
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