lava-cmd 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.11"
17
+
18
+ - name: Build
19
+ run: |
20
+ pip install build
21
+ python -m build
22
+
23
+ - name: Publish
24
+ uses: pypa/gh-action-pypi-publish@release/v1
25
+ with:
26
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+
9
+ # Environments
10
+ .venv/
11
+ venv/
12
+ .env
13
+
14
+ # macOS
15
+ .DS_Store
16
+
17
+ # Tools
18
+ .claude/
19
+ .ruff_cache/
20
+ .mypy_cache/
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: lava-cmd
3
+ Version: 0.1.0
4
+ Summary: A CLI toolkit for interacting with Obsidian vaults
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: networkx
7
+ Requires-Dist: platformdirs
8
+ Requires-Dist: python-frontmatter
9
+ Requires-Dist: rank-bm25
10
+ Requires-Dist: rich
11
+ Requires-Dist: textual
12
+ Requires-Dist: toml
13
+ Requires-Dist: typer
@@ -0,0 +1,3 @@
1
+ """lava — A CLI toolkit for interacting with Obsidian vaults."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,17 @@
1
+ """lava — app singleton, console, and shared constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from lava import __version__ as _LAVA_VERSION
9
+
10
+ app = typer.Typer(
11
+ name="lava",
12
+ help="A CLI toolkit for interacting with Obsidian vaults.",
13
+ add_completion=False,
14
+ no_args_is_help=True,
15
+ )
16
+
17
+ console = Console()
@@ -0,0 +1,68 @@
1
+ """lava — shared helpers used across multiple command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.prompt import Prompt
8
+
9
+ from lava import config as cfg
10
+ from lava import vault as vlt
11
+ from lava import ui
12
+ from lava._app import console
13
+
14
+
15
+ def _cwd(vault_path: Path) -> Path:
16
+ """Return the current working directory within the vault."""
17
+ rel = cfg.get_vault_cwd()
18
+ if not rel:
19
+ return vault_path
20
+ candidate = vault_path / rel
21
+ if candidate.exists() and candidate.is_dir():
22
+ return candidate
23
+ # cwd no longer valid (folder deleted), reset to root
24
+ cfg.set_vault_cwd("")
25
+ return vault_path
26
+
27
+
28
+ def _cwd_label(vault_path: Path) -> str:
29
+ cwd = _cwd(vault_path)
30
+ if cwd == vault_path:
31
+ return f"[bold orange1]{vault_path.name}[/bold orange1] [dim]/[/dim]"
32
+ rel = cwd.relative_to(vault_path)
33
+ parts = rel.parts
34
+ trail = "/".join(f"[orange1]{p}[/orange1]" for p in parts)
35
+ return f"[dim]{vault_path.name}/[/dim]{trail} [dim]/[/dim]"
36
+
37
+
38
+ def _pick_move_destination(vault_path: Path, source: Path) -> str | None:
39
+ """Interactive folder picker + optional rename."""
40
+ folders = vlt.list_folders(vault_path)
41
+
42
+ console.print(f"\n[bold orange1]Move:[/bold orange1] [white]{source.stem}[/white]\n")
43
+ console.print(f" [dim] 0.[/dim] [white]/ (vault root)[/white]")
44
+ for i, folder in enumerate(folders, 1):
45
+ rel = folder.relative_to(vault_path)
46
+ console.print(f" [dim]{i:2}.[/dim] [white]{rel}[/white]")
47
+
48
+ choice = Prompt.ask("\nDestination folder", default="0")
49
+ try:
50
+ idx = int(choice)
51
+ except ValueError:
52
+ ui.print_error("Invalid choice.")
53
+ return None
54
+
55
+ if idx == 0:
56
+ folder_path = vault_path
57
+ elif 1 <= idx <= len(folders):
58
+ folder_path = folders[idx - 1]
59
+ else:
60
+ ui.print_error("Invalid choice.")
61
+ return None
62
+
63
+ new_name = Prompt.ask("New name", default=source.stem)
64
+
65
+ rel_folder = folder_path.relative_to(vault_path)
66
+ if str(rel_folder) == ".":
67
+ return new_name
68
+ return str(rel_folder / new_name)
@@ -0,0 +1,54 @@
1
+ """Textual-based TUI editor for lava."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.widgets import Footer, Header, TextArea
10
+
11
+
12
+ class LavaEditorApp(App):
13
+ """A minimal TUI editor for editing vault notes."""
14
+
15
+ TITLE = "lava editor"
16
+ BINDINGS = [
17
+ Binding("ctrl+s", "save", "Save", show=True),
18
+ Binding("ctrl+q", "save_quit", "Save & Quit", show=True),
19
+ Binding("ctrl+c", "quit_no_save", "Quit without saving", show=True),
20
+ ]
21
+
22
+ def __init__(self, path: Path) -> None:
23
+ super().__init__()
24
+ self._path = path
25
+ self._content = path.read_text(encoding="utf-8") if path.exists() else ""
26
+ self._saved = False
27
+
28
+ def compose(self) -> ComposeResult:
29
+ yield Header()
30
+ yield TextArea(self._content, id="editor", language="markdown")
31
+ yield Footer()
32
+
33
+ def on_mount(self) -> None:
34
+ self.title = f"lava — {self._path.name}"
35
+ self.query_one("#editor", TextArea).focus()
36
+
37
+ def action_save(self) -> None:
38
+ content = self.query_one("#editor", TextArea).text
39
+ self._path.write_text(content, encoding="utf-8")
40
+ self._saved = True
41
+ self.notify(f"Saved {self._path.name}", severity="information")
42
+
43
+ def action_save_quit(self) -> None:
44
+ self.action_save()
45
+ self.exit()
46
+
47
+ def action_quit_no_save(self) -> None:
48
+ self.exit()
49
+
50
+
51
+ def run_tui_editor(path: Path) -> None:
52
+ """Launch the Textual TUI editor for a file."""
53
+ app = LavaEditorApp(path)
54
+ app.run()
File without changes
@@ -0,0 +1,310 @@
1
+ """lava — Configuration commands: dir_app, config_app, version, uninstall."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Annotated, Optional
9
+
10
+ import typer
11
+ from rich.prompt import Confirm, Prompt
12
+ from rich.table import Table
13
+
14
+ from lava import config as cfg
15
+ from lava import ui
16
+ from lava._app import app, console, _LAVA_VERSION
17
+ from lava.ui import C_PRIMARY
18
+
19
+
20
+ dir_app = typer.Typer(
21
+ name="dir",
22
+ help="Manage the active vault directory.",
23
+ invoke_without_command=True,
24
+ no_args_is_help=False,
25
+ )
26
+
27
+ config_app = typer.Typer(
28
+ name="config",
29
+ help="View and edit lava configuration.",
30
+ invoke_without_command=True,
31
+ no_args_is_help=False,
32
+ )
33
+
34
+
35
+ @dir_app.callback(invoke_without_command=True)
36
+ def dir_callback(
37
+ ctx: typer.Context,
38
+ history: Annotated[bool, typer.Option("--history", help="Show recent vaults")] = False,
39
+ list_vaults: Annotated[bool, typer.Option("--list", help="Show recent vaults (alias for --history)")] = False,
40
+ pick: Annotated[bool, typer.Option("--pick", help="Pick vault from history")] = False,
41
+ clear: Annotated[bool, typer.Option("--clear", help="Unset the active vault path")] = False,
42
+ ) -> None:
43
+ """Manage the active vault directory."""
44
+ if ctx.invoked_subcommand is not None:
45
+ return
46
+
47
+ if clear:
48
+ cfg.set_vault_path("")
49
+ ui.print_success("Vault path cleared.")
50
+ return
51
+
52
+ if history or list_vaults:
53
+ _dir_history()
54
+ return
55
+
56
+ if pick:
57
+ _dir_pick()
58
+ return
59
+
60
+ _dir_set_prompt()
61
+
62
+
63
+ def _dir_set_prompt() -> None:
64
+ """Interactively set the active vault path."""
65
+ current = cfg.get_vault_path()
66
+ if current:
67
+ console.print(f"[dim]Current vault:[/dim] [orange1]{current}[/orange1]")
68
+ else:
69
+ console.print("[dim]No vault set.[/dim]")
70
+
71
+ console.print(
72
+ f"\n [dim]Enter a path, or [/dim][bold white]f[/bold white][dim] to browse folders[/dim]"
73
+ )
74
+ raw = Prompt.ask("[orange1]Vault path[/orange1]", default="").strip()
75
+
76
+ if not raw:
77
+ return
78
+
79
+ if raw.lower() == "f":
80
+ location = _pick_fs_folder()
81
+ if location is None:
82
+ return
83
+ resolved = str(location)
84
+ else:
85
+ resolved = str(Path(raw).expanduser().resolve())
86
+
87
+ if not Path(resolved).exists():
88
+ ui.print_error(f"Path does not exist: {resolved}")
89
+ raise typer.Exit(1)
90
+
91
+ cfg.set_vault_path(resolved)
92
+ cfg.add_to_history(resolved)
93
+ ui.print_success(f"Vault set to: {resolved}")
94
+
95
+
96
+ def _dir_history() -> None:
97
+ config = cfg.load_config()
98
+ history = config.get("vault", {}).get("history", [])
99
+ if not history:
100
+ console.print("[dim]No vault history.[/dim]")
101
+ return
102
+
103
+ table = Table(title="Recent Vaults", header_style="bold orange1")
104
+ table.add_column("#", style="dim", width=4)
105
+ table.add_column("Path", style="white")
106
+ current = cfg.get_vault_path()
107
+ for i, p in enumerate(history, 1):
108
+ marker = " [gold1](active)[/gold1]" if p == current else ""
109
+ table.add_row(str(i), p + marker)
110
+ console.print(table)
111
+
112
+ choice = Prompt.ask("Switch to vault # (or Enter to skip)", default="").strip()
113
+ if not choice:
114
+ return
115
+ try:
116
+ idx = int(choice) - 1
117
+ selected = history[idx]
118
+ except (ValueError, IndexError):
119
+ ui.print_error("Invalid selection.")
120
+ return
121
+ cfg.set_vault_path(selected)
122
+ cfg.add_to_history(selected)
123
+ ui.print_success(f"Vault set to: {selected}")
124
+
125
+
126
+ def _dir_pick() -> None:
127
+ config = cfg.load_config()
128
+ history = config.get("vault", {}).get("history", [])
129
+ if not history:
130
+ ui.print_error("No vault history to pick from.")
131
+ raise typer.Exit(1)
132
+
133
+ console.print("[bold orange1]Recent vaults:[/bold orange1]")
134
+ for i, p in enumerate(history, 1):
135
+ console.print(f" [dim]{i}.[/dim] {p}")
136
+
137
+ choice = Prompt.ask("Pick a vault number", default="1")
138
+ try:
139
+ idx = int(choice) - 1
140
+ selected = history[idx]
141
+ except (ValueError, IndexError):
142
+ ui.print_error("Invalid selection.")
143
+ raise typer.Exit(1)
144
+
145
+ cfg.set_vault_path(selected)
146
+ cfg.add_to_history(selected)
147
+ ui.print_success(f"Vault set to: {selected}")
148
+
149
+
150
+ def _pick_fs_folder() -> Path | None:
151
+ """Navigate the real filesystem level-by-level (folders only) and return chosen path."""
152
+ current = Path.home()
153
+
154
+ while True:
155
+ subdirs = sorted(
156
+ [p for p in current.iterdir() if p.is_dir() and not p.name.startswith(".")],
157
+ key=lambda p: p.name.lower(),
158
+ )
159
+
160
+ console.print(f"\n[bold {C_PRIMARY}]{current}[/bold {C_PRIMARY}]\n")
161
+ console.print(f" [dim] 0.[/dim] [dim]✓ use this folder[/dim]")
162
+ if current != current.anchor:
163
+ console.print(f" [dim] b.[/dim] [dim].. (go up)[/dim]")
164
+ for i, d in enumerate(subdirs, 1):
165
+ console.print(f" [dim]{i:2}.[/dim] [orange1]{d.name}/[/orange1]")
166
+ console.print()
167
+
168
+ choice = Prompt.ask("Pick folder or #", default="").strip().lower()
169
+ if not choice:
170
+ return None
171
+ if choice == "0":
172
+ return current
173
+ if choice == "b" and current != Path(current.anchor):
174
+ current = current.parent
175
+ continue
176
+ try:
177
+ idx = int(choice)
178
+ if 1 <= idx <= len(subdirs):
179
+ current = subdirs[idx - 1]
180
+ else:
181
+ ui.print_error("Invalid choice.")
182
+ except ValueError:
183
+ ui.print_error("Invalid choice.")
184
+
185
+
186
+ @dir_app.command("init")
187
+ def dir_init(
188
+ path: Annotated[Optional[str], typer.Argument(help="Path for the new vault. Omit to create in current directory.")] = None,
189
+ pick: Annotated[bool, typer.Option("--pick", help="Navigate filesystem to pick location")] = False,
190
+ ) -> None:
191
+ """Create a new vault directory and set it as the active vault."""
192
+ if pick:
193
+ location = _pick_fs_folder()
194
+ if location is None:
195
+ raise typer.Exit(0)
196
+ vault_name = Prompt.ask("[orange1]Vault name[/orange1]").strip()
197
+ if not vault_name:
198
+ raise typer.Exit(0)
199
+ resolved = location / vault_name
200
+ elif path:
201
+ resolved = Path(path).expanduser().resolve()
202
+ else:
203
+ vault_name = Prompt.ask("[orange1]Vault name[/orange1]").strip()
204
+ if not vault_name:
205
+ raise typer.Exit(0)
206
+ resolved = Path.cwd() / vault_name
207
+
208
+ if resolved.exists() and any(resolved.iterdir()):
209
+ ui.print_error(f"Directory already exists and is not empty: {resolved}")
210
+ raise typer.Exit(1)
211
+
212
+ resolved.mkdir(parents=True, exist_ok=True)
213
+
214
+ cfg.set_vault_path(str(resolved))
215
+ cfg.add_to_history(str(resolved))
216
+
217
+ console.print(f"\n[bold {C_PRIMARY}]Vault created:[/bold {C_PRIMARY}] {resolved}")
218
+ console.print(f"[dim]Active vault set. Run [white]lava new[/white] to get started.[/dim]")
219
+
220
+
221
+ @dir_app.command("set")
222
+ def dir_set(
223
+ path: Annotated[str, typer.Argument(help="Vault path, or 'current' for cwd")],
224
+ ) -> None:
225
+ """Set the active vault path."""
226
+ if path.lower() == "current":
227
+ resolved = str(Path.cwd())
228
+ else:
229
+ resolved = str(Path(path).expanduser().resolve())
230
+
231
+ if not Path(resolved).exists():
232
+ ui.print_error(f"Path does not exist: {resolved}")
233
+ raise typer.Exit(1)
234
+
235
+ cfg.set_vault_path(resolved)
236
+ cfg.add_to_history(resolved)
237
+ ui.print_success(f"Vault set to: {resolved}")
238
+
239
+
240
+ @config_app.callback(invoke_without_command=True)
241
+ def config_callback(
242
+ ctx: typer.Context,
243
+ edit: Annotated[bool, typer.Option("--edit", help="Open config in $EDITOR")] = False,
244
+ ) -> None:
245
+ """View or edit the lava configuration."""
246
+ if ctx.invoked_subcommand is not None:
247
+ return
248
+
249
+ config = cfg.load_config()
250
+
251
+ if edit:
252
+ config_path = cfg.get_config_path()
253
+ editor = config.get("editor", "") or os.environ.get("EDITOR", "")
254
+ if editor:
255
+ subprocess.run([editor, str(config_path)])
256
+ else:
257
+ console.print(f"[orange1]Config file:[/orange1] {config_path}")
258
+ console.print("[dim]Set $EDITOR or lava config set editor <editor> to open it.[/dim]")
259
+ return
260
+
261
+ ui.config_panel(config)
262
+
263
+
264
+ @config_app.command("set")
265
+ def config_set(
266
+ key: Annotated[Optional[str], typer.Argument(help="Dotted config key (e.g. pagination, editor)")] = None,
267
+ value: Annotated[Optional[str], typer.Argument(help="Value to set")] = None,
268
+ link_folder: Annotated[bool, typer.Option("--link-folder", is_flag=True, help="Set the links folder to the current working directory.")] = False,
269
+ link_folder_path: Annotated[Optional[str], typer.Option("--link-folder-path", help="Set the links folder to a specific path.")] = None,
270
+ ) -> None:
271
+ """Set a config value, or configure the links folder."""
272
+ if link_folder_path is not None:
273
+ cfg.set_dotted_key("links_folder", link_folder_path)
274
+ ui.print_success(f"Links folder set to: {link_folder_path}")
275
+ return
276
+
277
+ if link_folder:
278
+ path_val = cfg.get_vault_cwd() or "."
279
+ cfg.set_dotted_key("links_folder", path_val)
280
+ ui.print_success(f"Links folder set to: {path_val}")
281
+ return
282
+
283
+ if key is None or value is None:
284
+ ui.print_error("Usage: lava config set <key> <value> or lava config set --link-folder")
285
+ raise typer.Exit(1)
286
+
287
+ try:
288
+ cfg.set_dotted_key(key, value)
289
+ ui.print_success(f"Config updated: {key} = {value}")
290
+ except Exception as e:
291
+ ui.print_error(str(e))
292
+ raise typer.Exit(1)
293
+
294
+
295
+ @app.command("version", rich_help_panel="Configuration")
296
+ def cmd_version() -> None:
297
+ """Show the installed lava version."""
298
+ console.print(f"lava [orange1]{_LAVA_VERSION}[/orange1]")
299
+
300
+
301
+ @app.command("uninstall", rich_help_panel="Other")
302
+ def cmd_uninstall() -> None:
303
+ """Uninstall lava from the current Python environment."""
304
+ confirmed = Confirm.ask(
305
+ f"Uninstall [orange1]lava {_LAVA_VERSION}[/orange1] from this environment?",
306
+ default=False,
307
+ )
308
+ if not confirmed:
309
+ raise typer.Exit(0)
310
+ subprocess.run(["pip", "uninstall", "lava", "-y"], check=True)