nexus-cli 0.3.0__py3-none-any.whl
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.
- nexus/__init__.py +8 -0
- nexus/cli.py +1914 -0
- nexus/integrations/__init__.py +0 -0
- nexus/knowledge/__init__.py +13 -0
- nexus/knowledge/search.py +233 -0
- nexus/knowledge/vault.py +662 -0
- nexus/research/__init__.py +12 -0
- nexus/research/pdf.py +497 -0
- nexus/research/zotero.py +521 -0
- nexus/teaching/__init__.py +14 -0
- nexus/teaching/courses.py +388 -0
- nexus/teaching/quarto.py +385 -0
- nexus/utils/__init__.py +0 -0
- nexus/utils/config.py +157 -0
- nexus/writing/__init__.py +12 -0
- nexus/writing/bibliography.py +339 -0
- nexus/writing/manuscript.py +397 -0
- nexus_cli-0.3.0.dist-info/METADATA +369 -0
- nexus_cli-0.3.0.dist-info/RECORD +21 -0
- nexus_cli-0.3.0.dist-info/WHEEL +4 -0
- nexus_cli-0.3.0.dist-info/entry_points.txt +2 -0
nexus/cli.py
ADDED
|
@@ -0,0 +1,1914 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nexus CLI - Knowledge workflow for research, teaching, and writing.
|
|
3
|
+
|
|
4
|
+
Claude is the brain, Nexus is the body.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from nexus import __version__
|
|
16
|
+
from nexus.utils.config import get_config
|
|
17
|
+
|
|
18
|
+
# Create the main app
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="nexus",
|
|
21
|
+
help="Knowledge workflow CLI for research, teaching, and writing",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
rich_markup_mode="rich",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
# Domain subcommands
|
|
29
|
+
research_app = typer.Typer(help="[bold blue]🔬 Research[/] - Literature, Zotero, PDFs, simulations")
|
|
30
|
+
teach_app = typer.Typer(help="[bold green]📚 Teaching[/] - Courses, materials, Quarto")
|
|
31
|
+
write_app = typer.Typer(help="[bold yellow]✍️ Writing[/] - Manuscripts, bibliography, LaTeX")
|
|
32
|
+
knowledge_app = typer.Typer(help="[bold magenta]🧠 Knowledge[/] - Vault, search, connections")
|
|
33
|
+
integrate_app = typer.Typer(help="[bold cyan]🔌 Integrate[/] - aiterm, R, Git integrations")
|
|
34
|
+
|
|
35
|
+
# Register subcommands
|
|
36
|
+
app.add_typer(research_app, name="research")
|
|
37
|
+
app.add_typer(teach_app, name="teach")
|
|
38
|
+
app.add_typer(write_app, name="write")
|
|
39
|
+
app.add_typer(knowledge_app, name="knowledge")
|
|
40
|
+
app.add_typer(integrate_app, name="integrate")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
# Core Commands
|
|
45
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def version_callback(value: bool) -> None:
|
|
49
|
+
"""Show version and exit."""
|
|
50
|
+
if value:
|
|
51
|
+
console.print(f"[bold]nexus[/] version [cyan]{__version__}[/]")
|
|
52
|
+
raise typer.Exit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.callback()
|
|
56
|
+
def main(
|
|
57
|
+
version: Annotated[
|
|
58
|
+
bool | None,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--version",
|
|
61
|
+
"-v",
|
|
62
|
+
callback=version_callback,
|
|
63
|
+
is_eager=True,
|
|
64
|
+
help="Show version and exit",
|
|
65
|
+
),
|
|
66
|
+
] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""
|
|
69
|
+
[bold]Nexus[/] - Knowledge workflow CLI for research, teaching, and writing.
|
|
70
|
+
|
|
71
|
+
[dim]Claude is the brain, Nexus is the body.[/]
|
|
72
|
+
"""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def doctor() -> None:
|
|
78
|
+
"""Check Nexus health and integrations."""
|
|
79
|
+
config = get_config()
|
|
80
|
+
|
|
81
|
+
console.print(
|
|
82
|
+
Panel.fit(
|
|
83
|
+
"[bold]Nexus Health Check[/]",
|
|
84
|
+
border_style="blue",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
table = Table(show_header=True, header_style="bold")
|
|
89
|
+
table.add_column("Component", style="cyan")
|
|
90
|
+
table.add_column("Status")
|
|
91
|
+
table.add_column("Path/Info", style="dim")
|
|
92
|
+
|
|
93
|
+
# Check Zotero
|
|
94
|
+
zotero_db = Path(config.zotero.database).expanduser()
|
|
95
|
+
if zotero_db.exists():
|
|
96
|
+
table.add_row("Zotero", "[green]✓ Found[/]", str(zotero_db))
|
|
97
|
+
else:
|
|
98
|
+
table.add_row("Zotero", "[red]✗ Not found[/]", str(zotero_db))
|
|
99
|
+
|
|
100
|
+
# Check Vault
|
|
101
|
+
vault_path = Path(config.vault.path).expanduser()
|
|
102
|
+
if vault_path.exists():
|
|
103
|
+
note_count = len(list(vault_path.rglob("*.md")))
|
|
104
|
+
table.add_row("Vault", "[green]✓ Found[/]", f"{note_count} notes")
|
|
105
|
+
else:
|
|
106
|
+
table.add_row("Vault", "[red]✗ Not found[/]", str(vault_path))
|
|
107
|
+
|
|
108
|
+
# Check PDF directories
|
|
109
|
+
pdf_dirs = config.pdf.directories
|
|
110
|
+
found_pdfs = 0
|
|
111
|
+
for pdf_dir in pdf_dirs:
|
|
112
|
+
path = Path(pdf_dir).expanduser()
|
|
113
|
+
if path.exists():
|
|
114
|
+
found_pdfs += len(list(path.rglob("*.pdf")))
|
|
115
|
+
if found_pdfs > 0:
|
|
116
|
+
table.add_row("PDFs", "[green]✓ Found[/]", f"{found_pdfs} files")
|
|
117
|
+
else:
|
|
118
|
+
table.add_row("PDFs", "[yellow]? No PDFs[/]", "Check config")
|
|
119
|
+
|
|
120
|
+
# Check R
|
|
121
|
+
import shutil
|
|
122
|
+
|
|
123
|
+
r_path = shutil.which("R")
|
|
124
|
+
if r_path:
|
|
125
|
+
table.add_row("R", "[green]✓ Found[/]", r_path)
|
|
126
|
+
else:
|
|
127
|
+
table.add_row("R", "[yellow]○ Optional[/]", "Not in PATH")
|
|
128
|
+
|
|
129
|
+
# Check ripgrep (for search)
|
|
130
|
+
rg_path = shutil.which("rg")
|
|
131
|
+
if rg_path:
|
|
132
|
+
table.add_row("ripgrep", "[green]✓ Found[/]", rg_path)
|
|
133
|
+
else:
|
|
134
|
+
table.add_row("ripgrep", "[red]✗ Required[/]", "brew install ripgrep")
|
|
135
|
+
|
|
136
|
+
# Check pdftotext (for PDF extraction)
|
|
137
|
+
pdftotext_path = shutil.which("pdftotext")
|
|
138
|
+
if pdftotext_path:
|
|
139
|
+
table.add_row("pdftotext", "[green]✓ Found[/]", pdftotext_path)
|
|
140
|
+
else:
|
|
141
|
+
table.add_row("pdftotext", "[yellow]○ Optional[/]", "brew install poppler")
|
|
142
|
+
|
|
143
|
+
# Check Quarto (for teaching)
|
|
144
|
+
quarto_path = shutil.which("quarto")
|
|
145
|
+
if quarto_path:
|
|
146
|
+
table.add_row("Quarto", "[green]✓ Found[/]", quarto_path)
|
|
147
|
+
else:
|
|
148
|
+
table.add_row("Quarto", "[yellow]○ Optional[/]", "brew install quarto")
|
|
149
|
+
|
|
150
|
+
# Check Teaching directory
|
|
151
|
+
teaching_dir = Path(config.teaching.courses_dir).expanduser()
|
|
152
|
+
if teaching_dir.exists():
|
|
153
|
+
course_count = len([d for d in teaching_dir.iterdir() if d.is_dir() and not d.name.startswith(".")])
|
|
154
|
+
table.add_row("Courses", "[green]✓ Found[/]", f"{course_count} courses")
|
|
155
|
+
else:
|
|
156
|
+
table.add_row("Courses", "[yellow]○ Not configured[/]", str(teaching_dir))
|
|
157
|
+
|
|
158
|
+
console.print(table)
|
|
159
|
+
console.print()
|
|
160
|
+
|
|
161
|
+
# Summary
|
|
162
|
+
console.print("[dim]Run[/] [cyan]nexus config[/] [dim]to view/edit configuration.[/]")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command()
|
|
166
|
+
def config(
|
|
167
|
+
key: Annotated[
|
|
168
|
+
str | None,
|
|
169
|
+
typer.Argument(help="Config key to view/set (e.g., 'vault.path')"),
|
|
170
|
+
] = None,
|
|
171
|
+
value: Annotated[
|
|
172
|
+
str | None,
|
|
173
|
+
typer.Argument(help="Value to set"),
|
|
174
|
+
] = None,
|
|
175
|
+
edit: Annotated[
|
|
176
|
+
bool,
|
|
177
|
+
typer.Option("--edit", "-e", help="Open config in editor"),
|
|
178
|
+
] = False,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""View or edit Nexus configuration."""
|
|
181
|
+
config_path = Path.home() / ".config" / "nexus" / "config.yaml"
|
|
182
|
+
|
|
183
|
+
if edit:
|
|
184
|
+
import os
|
|
185
|
+
import subprocess
|
|
186
|
+
|
|
187
|
+
editor = os.environ.get("EDITOR", "vim")
|
|
188
|
+
subprocess.run([editor, str(config_path)])
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
if key is None:
|
|
192
|
+
# Show full config
|
|
193
|
+
console.print(
|
|
194
|
+
Panel.fit(
|
|
195
|
+
f"[bold]Configuration[/]\n[dim]{config_path}[/]",
|
|
196
|
+
border_style="blue",
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
cfg = get_config()
|
|
201
|
+
|
|
202
|
+
table = Table(show_header=True, header_style="bold")
|
|
203
|
+
table.add_column("Section", style="cyan")
|
|
204
|
+
table.add_column("Key")
|
|
205
|
+
table.add_column("Value", style="green")
|
|
206
|
+
|
|
207
|
+
# Flatten config for display
|
|
208
|
+
table.add_row("zotero", "database", str(cfg.zotero.database))
|
|
209
|
+
table.add_row("zotero", "storage", str(cfg.zotero.storage))
|
|
210
|
+
table.add_row("vault", "path", str(cfg.vault.path))
|
|
211
|
+
table.add_row("vault", "templates", str(cfg.vault.templates))
|
|
212
|
+
table.add_row("pdf", "directories", ", ".join(cfg.pdf.directories[:2]) + "...")
|
|
213
|
+
table.add_row("teaching", "courses_dir", str(cfg.teaching.courses_dir))
|
|
214
|
+
table.add_row("writing", "manuscripts_dir", str(cfg.writing.manuscripts_dir))
|
|
215
|
+
table.add_row("output", "format", cfg.output.format)
|
|
216
|
+
|
|
217
|
+
console.print(table)
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
if value is not None:
|
|
221
|
+
# Set config value
|
|
222
|
+
console.print(f"[yellow]Setting {key} = {value}[/]")
|
|
223
|
+
console.print("[dim]Config update not yet implemented[/]")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# Get specific key
|
|
227
|
+
cfg = get_config()
|
|
228
|
+
parts = key.split(".")
|
|
229
|
+
obj = cfg
|
|
230
|
+
for part in parts:
|
|
231
|
+
obj = getattr(obj, part, None)
|
|
232
|
+
if obj is None:
|
|
233
|
+
console.print(f"[red]Unknown config key: {key}[/]")
|
|
234
|
+
raise typer.Exit(1)
|
|
235
|
+
console.print(f"[cyan]{key}[/] = [green]{obj}[/]")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
# Research Domain
|
|
240
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _get_zotero_client():
|
|
244
|
+
"""Get configured ZoteroClient instance."""
|
|
245
|
+
from nexus.research.zotero import ZoteroClient
|
|
246
|
+
|
|
247
|
+
cfg = get_config()
|
|
248
|
+
return ZoteroClient(
|
|
249
|
+
db_path=Path(cfg.zotero.database).expanduser(),
|
|
250
|
+
storage_path=Path(cfg.zotero.storage).expanduser(),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _get_pdf_extractor():
|
|
255
|
+
"""Get configured PDFExtractor instance."""
|
|
256
|
+
from nexus.research.pdf import PDFExtractor
|
|
257
|
+
|
|
258
|
+
cfg = get_config()
|
|
259
|
+
return PDFExtractor(directories=[Path(d) for d in cfg.pdf.directories])
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# Zotero subcommand
|
|
263
|
+
zotero_app = typer.Typer(help="Zotero library operations")
|
|
264
|
+
research_app.add_typer(zotero_app, name="zotero")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@zotero_app.command("search")
|
|
268
|
+
def zotero_search(
|
|
269
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
270
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 20,
|
|
271
|
+
tag: Annotated[str | None, typer.Option("--tag", "-t", help="Filter by tag")] = None,
|
|
272
|
+
item_type: Annotated[str | None, typer.Option("--type", help="Filter by type")] = None,
|
|
273
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Search Zotero library for papers."""
|
|
276
|
+
import json
|
|
277
|
+
|
|
278
|
+
client = _get_zotero_client()
|
|
279
|
+
|
|
280
|
+
if not client.exists():
|
|
281
|
+
console.print(f"[red]Zotero database not found:[/] {client.db_path}")
|
|
282
|
+
raise typer.Exit(1)
|
|
283
|
+
|
|
284
|
+
results = client.search(query, limit=limit, tag=tag, item_type=item_type)
|
|
285
|
+
|
|
286
|
+
if json_output:
|
|
287
|
+
console.print(json.dumps([r.to_dict() for r in results], indent=2))
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
if not results:
|
|
291
|
+
console.print(f"[yellow]No results found for:[/] {query}")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
console.print(f"[dim]Found {len(results)} results for:[/] [cyan]{query}[/]\n")
|
|
295
|
+
|
|
296
|
+
table = Table(show_header=True, header_style="bold")
|
|
297
|
+
table.add_column("Key", style="dim", width=10)
|
|
298
|
+
table.add_column("Title", style="cyan")
|
|
299
|
+
table.add_column("Authors")
|
|
300
|
+
table.add_column("Year", width=6)
|
|
301
|
+
|
|
302
|
+
for item in results:
|
|
303
|
+
# Truncate title
|
|
304
|
+
title = item.title[:50] + "..." if len(item.title) > 50 else item.title
|
|
305
|
+
# Format authors
|
|
306
|
+
if item.authors:
|
|
307
|
+
if len(item.authors) > 2:
|
|
308
|
+
authors = f"{item.authors[0]} et al."
|
|
309
|
+
else:
|
|
310
|
+
authors = ", ".join(item.authors)
|
|
311
|
+
else:
|
|
312
|
+
authors = ""
|
|
313
|
+
authors = authors[:30] + "..." if len(authors) > 30 else authors
|
|
314
|
+
# Get year
|
|
315
|
+
year = item.date[:4] if item.date else ""
|
|
316
|
+
|
|
317
|
+
table.add_row(item.key, title, authors, year)
|
|
318
|
+
|
|
319
|
+
console.print(table)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@zotero_app.command("get")
|
|
323
|
+
def zotero_get(
|
|
324
|
+
key: Annotated[str, typer.Argument(help="Zotero item key")],
|
|
325
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Get details for a specific Zotero item."""
|
|
328
|
+
import json
|
|
329
|
+
|
|
330
|
+
client = _get_zotero_client()
|
|
331
|
+
|
|
332
|
+
if not client.exists():
|
|
333
|
+
console.print(f"[red]Zotero database not found:[/] {client.db_path}")
|
|
334
|
+
raise typer.Exit(1)
|
|
335
|
+
|
|
336
|
+
item = client.get(key)
|
|
337
|
+
|
|
338
|
+
if not item:
|
|
339
|
+
console.print(f"[red]Item not found:[/] {key}")
|
|
340
|
+
raise typer.Exit(1)
|
|
341
|
+
|
|
342
|
+
if json_output:
|
|
343
|
+
console.print(json.dumps(item.to_dict(), indent=2))
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
console.print(
|
|
347
|
+
Panel(
|
|
348
|
+
f"[bold]{item.title}[/]\n[dim]{item.key} • {item.item_type}[/]",
|
|
349
|
+
border_style="blue",
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if item.authors:
|
|
354
|
+
console.print(f"[dim]Authors:[/] {', '.join(item.authors)}")
|
|
355
|
+
|
|
356
|
+
if item.date:
|
|
357
|
+
console.print(f"[dim]Date:[/] {item.date}")
|
|
358
|
+
|
|
359
|
+
if item.doi:
|
|
360
|
+
console.print(f"[dim]DOI:[/] {item.doi}")
|
|
361
|
+
|
|
362
|
+
if item.url:
|
|
363
|
+
console.print(f"[dim]URL:[/] {item.url}")
|
|
364
|
+
|
|
365
|
+
if item.tags:
|
|
366
|
+
console.print(f"[dim]Tags:[/] {', '.join(item.tags)}")
|
|
367
|
+
|
|
368
|
+
if item.collections:
|
|
369
|
+
console.print(f"[dim]Collections:[/] {', '.join(item.collections)}")
|
|
370
|
+
|
|
371
|
+
if item.abstract:
|
|
372
|
+
console.print("\n[dim]─── Abstract ───[/]")
|
|
373
|
+
console.print(item.abstract[:500] + "..." if len(item.abstract) > 500 else item.abstract)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@zotero_app.command("cite")
|
|
377
|
+
def zotero_cite(
|
|
378
|
+
key: Annotated[str, typer.Argument(help="Zotero item key")],
|
|
379
|
+
style: Annotated[str, typer.Option("--style", "-s", help="Citation style: apa, bibtex")] = "apa",
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Generate citation for a Zotero item."""
|
|
382
|
+
client = _get_zotero_client()
|
|
383
|
+
|
|
384
|
+
if not client.exists():
|
|
385
|
+
console.print(f"[red]Zotero database not found:[/] {client.db_path}")
|
|
386
|
+
raise typer.Exit(1)
|
|
387
|
+
|
|
388
|
+
item = client.get(key)
|
|
389
|
+
|
|
390
|
+
if not item:
|
|
391
|
+
console.print(f"[red]Item not found:[/] {key}")
|
|
392
|
+
raise typer.Exit(1)
|
|
393
|
+
|
|
394
|
+
if style.lower() == "bibtex":
|
|
395
|
+
console.print(item.citation_bibtex())
|
|
396
|
+
else:
|
|
397
|
+
console.print(item.citation_apa())
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@zotero_app.command("recent")
|
|
401
|
+
def zotero_recent(
|
|
402
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 10,
|
|
403
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
404
|
+
) -> None:
|
|
405
|
+
"""Show recently modified Zotero items."""
|
|
406
|
+
import json
|
|
407
|
+
|
|
408
|
+
client = _get_zotero_client()
|
|
409
|
+
|
|
410
|
+
if not client.exists():
|
|
411
|
+
console.print(f"[red]Zotero database not found:[/] {client.db_path}")
|
|
412
|
+
raise typer.Exit(1)
|
|
413
|
+
|
|
414
|
+
results = client.recent(limit=limit)
|
|
415
|
+
|
|
416
|
+
if json_output:
|
|
417
|
+
console.print(json.dumps([r.to_dict() for r in results], indent=2))
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
console.print(f"[dim]Recent items (last {limit}):[/]\n")
|
|
421
|
+
|
|
422
|
+
for i, item in enumerate(results, 1):
|
|
423
|
+
year = item.date[:4] if item.date else "n.d."
|
|
424
|
+
authors = item.authors[0].split()[-1] if item.authors else "Unknown"
|
|
425
|
+
console.print(f" {i}. [cyan]{authors} ({year})[/] {item.title[:60]}...")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@zotero_app.command("tags")
|
|
429
|
+
def zotero_tags(
|
|
430
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max tags")] = 30,
|
|
431
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
432
|
+
) -> None:
|
|
433
|
+
"""List all tags with counts."""
|
|
434
|
+
import json
|
|
435
|
+
|
|
436
|
+
client = _get_zotero_client()
|
|
437
|
+
|
|
438
|
+
if not client.exists():
|
|
439
|
+
console.print(f"[red]Zotero database not found:[/] {client.db_path}")
|
|
440
|
+
raise typer.Exit(1)
|
|
441
|
+
|
|
442
|
+
tags = client.tags(limit=limit)
|
|
443
|
+
|
|
444
|
+
if json_output:
|
|
445
|
+
console.print(json.dumps([{"tag": t[0], "count": t[1]} for t in tags], indent=2))
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
console.print(f"[dim]Top {len(tags)} tags:[/]\n")
|
|
449
|
+
|
|
450
|
+
table = Table(show_header=True, header_style="bold")
|
|
451
|
+
table.add_column("Tag", style="cyan")
|
|
452
|
+
table.add_column("Count", justify="right")
|
|
453
|
+
|
|
454
|
+
for tag, count in tags:
|
|
455
|
+
table.add_row(tag, str(count))
|
|
456
|
+
|
|
457
|
+
console.print(table)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@zotero_app.command("collections")
|
|
461
|
+
def zotero_collections(
|
|
462
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
463
|
+
) -> None:
|
|
464
|
+
"""List all collections with counts."""
|
|
465
|
+
import json
|
|
466
|
+
|
|
467
|
+
client = _get_zotero_client()
|
|
468
|
+
|
|
469
|
+
if not client.exists():
|
|
470
|
+
console.print(f"[red]Zotero database not found:[/] {client.db_path}")
|
|
471
|
+
raise typer.Exit(1)
|
|
472
|
+
|
|
473
|
+
collections = client.collections()
|
|
474
|
+
|
|
475
|
+
if json_output:
|
|
476
|
+
console.print(json.dumps([{"name": c[0], "count": c[1]} for c in collections], indent=2))
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
console.print("[dim]Collections:[/]\n")
|
|
480
|
+
|
|
481
|
+
for name, count in collections:
|
|
482
|
+
console.print(f" • [cyan]{name}[/] ({count} items)")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@zotero_app.command("by-tag")
|
|
486
|
+
def zotero_by_tag(
|
|
487
|
+
tag: Annotated[str, typer.Argument(help="Tag to search for")],
|
|
488
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 20,
|
|
489
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
490
|
+
) -> None:
|
|
491
|
+
"""Get items with a specific tag."""
|
|
492
|
+
import json
|
|
493
|
+
|
|
494
|
+
client = _get_zotero_client()
|
|
495
|
+
|
|
496
|
+
if not client.exists():
|
|
497
|
+
console.print(f"[red]Zotero database not found:[/] {client.db_path}")
|
|
498
|
+
raise typer.Exit(1)
|
|
499
|
+
|
|
500
|
+
results = client.by_tag(tag, limit=limit)
|
|
501
|
+
|
|
502
|
+
if json_output:
|
|
503
|
+
console.print(json.dumps([r.to_dict() for r in results], indent=2))
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
if not results:
|
|
507
|
+
console.print(f"[yellow]No items found with tag:[/] {tag}")
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
console.print(f"[dim]Found {len(results)} items with tag:[/] [cyan]{tag}[/]\n")
|
|
511
|
+
|
|
512
|
+
for i, item in enumerate(results, 1):
|
|
513
|
+
year = item.date[:4] if item.date else "n.d."
|
|
514
|
+
console.print(f" {i}. [{item.key}] {item.title[:60]}... ({year})")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# PDF subcommand
|
|
518
|
+
pdf_app = typer.Typer(help="PDF operations")
|
|
519
|
+
research_app.add_typer(pdf_app, name="pdf")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@pdf_app.command("extract")
|
|
523
|
+
def pdf_extract(
|
|
524
|
+
path: Annotated[Path, typer.Argument(help="Path to PDF file")],
|
|
525
|
+
pages: Annotated[str | None, typer.Option("--pages", "-p", help="Page range (e.g., 1-5)")] = None,
|
|
526
|
+
layout: Annotated[bool, typer.Option("--layout", "-l", help="Preserve layout")] = False,
|
|
527
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
528
|
+
) -> None:
|
|
529
|
+
"""Extract text from a PDF file."""
|
|
530
|
+
import json
|
|
531
|
+
|
|
532
|
+
extractor = _get_pdf_extractor()
|
|
533
|
+
|
|
534
|
+
if not extractor.available():
|
|
535
|
+
console.print("[red]pdftotext not installed[/]")
|
|
536
|
+
console.print("[dim]Install with:[/] brew install poppler")
|
|
537
|
+
raise typer.Exit(1)
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
doc = extractor.extract(path, pages=pages, layout=layout)
|
|
541
|
+
except FileNotFoundError:
|
|
542
|
+
console.print(f"[red]PDF not found:[/] {path}")
|
|
543
|
+
raise typer.Exit(1)
|
|
544
|
+
except Exception as e:
|
|
545
|
+
console.print(f"[red]Extraction failed:[/] {e}")
|
|
546
|
+
raise typer.Exit(1)
|
|
547
|
+
|
|
548
|
+
if json_output:
|
|
549
|
+
console.print(json.dumps(doc.to_dict(), indent=2))
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
console.print(
|
|
553
|
+
Panel(
|
|
554
|
+
f"[bold]{doc.filename}[/]\n[dim]{doc.page_count} pages • {doc.size_bytes // 1024} KB[/]",
|
|
555
|
+
border_style="blue",
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
if pages:
|
|
560
|
+
console.print(f"[dim]Pages:[/] {pages}\n")
|
|
561
|
+
|
|
562
|
+
console.print(doc.text)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@pdf_app.command("search")
|
|
566
|
+
def pdf_search(
|
|
567
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
568
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 20,
|
|
569
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
570
|
+
) -> None:
|
|
571
|
+
"""Search across all PDFs."""
|
|
572
|
+
import json
|
|
573
|
+
|
|
574
|
+
extractor = _get_pdf_extractor()
|
|
575
|
+
|
|
576
|
+
results = extractor.search(query, limit=limit)
|
|
577
|
+
|
|
578
|
+
if json_output:
|
|
579
|
+
console.print(json.dumps([r.to_dict() for r in results], indent=2))
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
if not results:
|
|
583
|
+
console.print(f"[yellow]No PDFs found matching:[/] {query}")
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
console.print(f"[dim]Found {len(results)} PDFs matching:[/] [cyan]{query}[/]\n")
|
|
587
|
+
|
|
588
|
+
table = Table(show_header=True, header_style="bold")
|
|
589
|
+
table.add_column("File", style="cyan")
|
|
590
|
+
table.add_column("Context")
|
|
591
|
+
|
|
592
|
+
for result in results:
|
|
593
|
+
context = result.context[:60] + "..." if len(result.context) > 60 else result.context
|
|
594
|
+
table.add_row(result.filename, context)
|
|
595
|
+
|
|
596
|
+
console.print(table)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@pdf_app.command("list")
|
|
600
|
+
def pdf_list(
|
|
601
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max files")] = 20,
|
|
602
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
603
|
+
) -> None:
|
|
604
|
+
"""List PDFs in configured directories."""
|
|
605
|
+
import json
|
|
606
|
+
|
|
607
|
+
extractor = _get_pdf_extractor()
|
|
608
|
+
|
|
609
|
+
pdfs = extractor.list_pdfs(limit=limit)
|
|
610
|
+
|
|
611
|
+
if json_output:
|
|
612
|
+
console.print(json.dumps(pdfs, indent=2))
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
# Show directory summary first
|
|
616
|
+
summaries = extractor.summarize_directories()
|
|
617
|
+
total = sum(s["count"] for s in summaries)
|
|
618
|
+
|
|
619
|
+
console.print(f"[dim]PDF directories ({total} total files):[/]\n")
|
|
620
|
+
|
|
621
|
+
for s in summaries:
|
|
622
|
+
status = "[green]✓[/]" if s["exists"] else "[red]✗[/]"
|
|
623
|
+
console.print(f" {status} {s['directory']} ({s['count']} PDFs)")
|
|
624
|
+
|
|
625
|
+
if pdfs:
|
|
626
|
+
console.print(f"\n[dim]Recent PDFs (showing {len(pdfs)}):[/]\n")
|
|
627
|
+
|
|
628
|
+
for pdf in pdfs[:limit]:
|
|
629
|
+
size_kb = pdf["size_bytes"] // 1024
|
|
630
|
+
console.print(f" • [cyan]{pdf['filename']}[/] ({size_kb} KB)")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@pdf_app.command("info")
|
|
634
|
+
def pdf_info(
|
|
635
|
+
path: Annotated[Path, typer.Argument(help="Path to PDF file")],
|
|
636
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
637
|
+
) -> None:
|
|
638
|
+
"""Show information about a PDF file."""
|
|
639
|
+
import json
|
|
640
|
+
|
|
641
|
+
extractor = _get_pdf_extractor()
|
|
642
|
+
|
|
643
|
+
path = Path(path).expanduser()
|
|
644
|
+
|
|
645
|
+
if not path.exists():
|
|
646
|
+
console.print(f"[red]PDF not found:[/] {path}")
|
|
647
|
+
raise typer.Exit(1)
|
|
648
|
+
|
|
649
|
+
try:
|
|
650
|
+
doc = extractor.extract(path, pages="1")
|
|
651
|
+
except Exception as e:
|
|
652
|
+
console.print(f"[red]Failed to read PDF:[/] {e}")
|
|
653
|
+
raise typer.Exit(1)
|
|
654
|
+
|
|
655
|
+
if json_output:
|
|
656
|
+
console.print(json.dumps(doc.to_dict(), indent=2))
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
console.print(
|
|
660
|
+
Panel(
|
|
661
|
+
f"[bold]{doc.filename}[/]",
|
|
662
|
+
border_style="blue",
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
console.print(f"[dim]Path:[/] {doc.path}")
|
|
667
|
+
console.print(f"[dim]Pages:[/] {doc.page_count}")
|
|
668
|
+
console.print(f"[dim]Size:[/] {doc.size_bytes // 1024} KB")
|
|
669
|
+
|
|
670
|
+
if doc.title and doc.title != doc.filename:
|
|
671
|
+
console.print(f"[dim]Title:[/] {doc.title}")
|
|
672
|
+
|
|
673
|
+
console.print("\n[dim]─── First page preview ───[/]")
|
|
674
|
+
preview = doc.text[:500] + "..." if len(doc.text) > 500 else doc.text
|
|
675
|
+
console.print(preview)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
679
|
+
# Knowledge Domain
|
|
680
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
681
|
+
|
|
682
|
+
vault_app = typer.Typer(help="Obsidian vault operations")
|
|
683
|
+
knowledge_app.add_typer(vault_app, name="vault")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _get_vault_manager():
|
|
687
|
+
"""Get configured VaultManager instance."""
|
|
688
|
+
from nexus.knowledge.vault import VaultManager
|
|
689
|
+
|
|
690
|
+
cfg = get_config()
|
|
691
|
+
return VaultManager(
|
|
692
|
+
vault_path=Path(cfg.vault.path).expanduser(),
|
|
693
|
+
templates_path=Path(cfg.vault.templates).expanduser(),
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@vault_app.command("search")
|
|
698
|
+
def vault_search(
|
|
699
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
700
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 20,
|
|
701
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
702
|
+
) -> None:
|
|
703
|
+
"""Search vault notes using ripgrep."""
|
|
704
|
+
import json
|
|
705
|
+
|
|
706
|
+
vault = _get_vault_manager()
|
|
707
|
+
|
|
708
|
+
if not vault.exists():
|
|
709
|
+
console.print(f"[red]Vault not found:[/] {vault.vault_path}")
|
|
710
|
+
console.print("[dim]Run[/] [cyan]nexus config vault.path[/] [dim]to check path[/]")
|
|
711
|
+
raise typer.Exit(1)
|
|
712
|
+
|
|
713
|
+
results = vault.search(query, limit=limit)
|
|
714
|
+
|
|
715
|
+
if json_output:
|
|
716
|
+
console.print(json.dumps([r.to_dict() for r in results], indent=2))
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
if not results:
|
|
720
|
+
console.print(f"[yellow]No results found for:[/] {query}")
|
|
721
|
+
return
|
|
722
|
+
|
|
723
|
+
console.print(f"[dim]Found {len(results)} results for:[/] [cyan]{query}[/]\n")
|
|
724
|
+
|
|
725
|
+
table = Table(show_header=True, header_style="bold")
|
|
726
|
+
table.add_column("File", style="cyan", no_wrap=True)
|
|
727
|
+
table.add_column("Line", style="dim", justify="right")
|
|
728
|
+
table.add_column("Content")
|
|
729
|
+
|
|
730
|
+
for result in results:
|
|
731
|
+
# Truncate content for display
|
|
732
|
+
content = result.content[:80] + "..." if len(result.content) > 80 else result.content
|
|
733
|
+
table.add_row(result.path, str(result.line_number), content)
|
|
734
|
+
|
|
735
|
+
console.print(table)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@vault_app.command("read")
|
|
739
|
+
def vault_read(
|
|
740
|
+
path: Annotated[str, typer.Argument(help="Note path (relative to vault)")],
|
|
741
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
742
|
+
frontmatter_only: Annotated[bool, typer.Option("--frontmatter", "-f", help="Show only frontmatter")] = False,
|
|
743
|
+
) -> None:
|
|
744
|
+
"""Read a note from the vault."""
|
|
745
|
+
import json
|
|
746
|
+
|
|
747
|
+
vault = _get_vault_manager()
|
|
748
|
+
|
|
749
|
+
try:
|
|
750
|
+
note = vault.read(path)
|
|
751
|
+
except FileNotFoundError:
|
|
752
|
+
console.print(f"[red]Note not found:[/] {path}")
|
|
753
|
+
raise typer.Exit(1)
|
|
754
|
+
|
|
755
|
+
if json_output:
|
|
756
|
+
console.print(json.dumps(note.to_dict(), indent=2))
|
|
757
|
+
return
|
|
758
|
+
|
|
759
|
+
if frontmatter_only:
|
|
760
|
+
if note.frontmatter:
|
|
761
|
+
import yaml
|
|
762
|
+
|
|
763
|
+
console.print(
|
|
764
|
+
Panel(
|
|
765
|
+
yaml.dump(note.frontmatter, default_flow_style=False),
|
|
766
|
+
title=f"[cyan]{note.title}[/] frontmatter",
|
|
767
|
+
border_style="dim",
|
|
768
|
+
)
|
|
769
|
+
)
|
|
770
|
+
else:
|
|
771
|
+
console.print("[dim]No frontmatter[/]")
|
|
772
|
+
return
|
|
773
|
+
|
|
774
|
+
# Show full note
|
|
775
|
+
console.print(
|
|
776
|
+
Panel(
|
|
777
|
+
f"[bold]{note.title}[/]\n[dim]{note.path}[/]",
|
|
778
|
+
border_style="blue",
|
|
779
|
+
)
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
if note.frontmatter:
|
|
783
|
+
import yaml
|
|
784
|
+
|
|
785
|
+
console.print("[dim]─── Frontmatter ───[/]")
|
|
786
|
+
console.print(yaml.dump(note.frontmatter, default_flow_style=False))
|
|
787
|
+
|
|
788
|
+
if note.tags:
|
|
789
|
+
console.print(f"[dim]Tags:[/] {', '.join(f'#{t}' for t in note.tags)}")
|
|
790
|
+
|
|
791
|
+
if note.links:
|
|
792
|
+
console.print(f"[dim]Links:[/] {', '.join(f'[[{l}]]' for l in note.links[:5])}")
|
|
793
|
+
if len(note.links) > 5:
|
|
794
|
+
console.print(f"[dim] ... and {len(note.links) - 5} more[/]")
|
|
795
|
+
|
|
796
|
+
console.print("\n[dim]─── Content ───[/]")
|
|
797
|
+
console.print(note.content)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
@vault_app.command("write")
|
|
801
|
+
def vault_write(
|
|
802
|
+
path: Annotated[str, typer.Argument(help="Note path (relative to vault)")],
|
|
803
|
+
content: Annotated[str | None, typer.Option("--content", "-c", help="Note content")] = None,
|
|
804
|
+
stdin: Annotated[bool, typer.Option("--stdin", help="Read content from stdin")] = False,
|
|
805
|
+
title: Annotated[str | None, typer.Option("--title", "-t", help="Note title for frontmatter")] = None,
|
|
806
|
+
) -> None:
|
|
807
|
+
"""Write content to a note in the vault."""
|
|
808
|
+
import sys
|
|
809
|
+
|
|
810
|
+
vault = _get_vault_manager()
|
|
811
|
+
|
|
812
|
+
if stdin:
|
|
813
|
+
content = sys.stdin.read()
|
|
814
|
+
elif content is None:
|
|
815
|
+
console.print("[red]Must provide --content or --stdin[/]")
|
|
816
|
+
raise typer.Exit(1)
|
|
817
|
+
|
|
818
|
+
# Build frontmatter
|
|
819
|
+
frontmatter = {}
|
|
820
|
+
if title:
|
|
821
|
+
frontmatter["title"] = title
|
|
822
|
+
frontmatter["created"] = __import__("datetime").date.today().isoformat()
|
|
823
|
+
|
|
824
|
+
result_path = vault.write(path, content, frontmatter=frontmatter if frontmatter else None)
|
|
825
|
+
console.print(f"[green]✓ Written:[/] {result_path}")
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
@vault_app.command("daily")
|
|
829
|
+
def vault_daily(
|
|
830
|
+
open_note: Annotated[bool, typer.Option("--open", "-o", help="Open in default app")] = False,
|
|
831
|
+
) -> None:
|
|
832
|
+
"""Open or create today's daily note."""
|
|
833
|
+
vault = _get_vault_manager()
|
|
834
|
+
|
|
835
|
+
if not vault.exists():
|
|
836
|
+
console.print(f"[red]Vault not found:[/] {vault.vault_path}")
|
|
837
|
+
raise typer.Exit(1)
|
|
838
|
+
|
|
839
|
+
daily_path = vault.daily()
|
|
840
|
+
console.print(f"[green]✓ Daily note:[/] {daily_path}")
|
|
841
|
+
|
|
842
|
+
if open_note:
|
|
843
|
+
import subprocess
|
|
844
|
+
|
|
845
|
+
subprocess.run(["open", str(daily_path)])
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
@vault_app.command("backlinks")
|
|
849
|
+
def vault_backlinks(
|
|
850
|
+
path: Annotated[str, typer.Argument(help="Note path to find backlinks for")],
|
|
851
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
852
|
+
) -> None:
|
|
853
|
+
"""Find notes that link to this note."""
|
|
854
|
+
import json
|
|
855
|
+
|
|
856
|
+
vault = _get_vault_manager()
|
|
857
|
+
|
|
858
|
+
if not vault.exists():
|
|
859
|
+
console.print(f"[red]Vault not found:[/] {vault.vault_path}")
|
|
860
|
+
raise typer.Exit(1)
|
|
861
|
+
|
|
862
|
+
backlinks = vault.backlinks(path)
|
|
863
|
+
|
|
864
|
+
if json_output:
|
|
865
|
+
console.print(json.dumps(backlinks, indent=2))
|
|
866
|
+
return
|
|
867
|
+
|
|
868
|
+
if not backlinks:
|
|
869
|
+
console.print(f"[yellow]No backlinks found for:[/] {path}")
|
|
870
|
+
return
|
|
871
|
+
|
|
872
|
+
console.print(f"[dim]Found {len(backlinks)} notes linking to:[/] [cyan]{path}[/]\n")
|
|
873
|
+
|
|
874
|
+
for link in backlinks:
|
|
875
|
+
console.print(f" • [cyan]{link}[/]")
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
@vault_app.command("recent")
|
|
879
|
+
def vault_recent(
|
|
880
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 10,
|
|
881
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
882
|
+
) -> None:
|
|
883
|
+
"""Show recently modified notes."""
|
|
884
|
+
import json
|
|
885
|
+
|
|
886
|
+
vault = _get_vault_manager()
|
|
887
|
+
|
|
888
|
+
if not vault.exists():
|
|
889
|
+
console.print(f"[red]Vault not found:[/] {vault.vault_path}")
|
|
890
|
+
raise typer.Exit(1)
|
|
891
|
+
|
|
892
|
+
recent = vault.recent(limit=limit)
|
|
893
|
+
|
|
894
|
+
if json_output:
|
|
895
|
+
console.print(json.dumps(recent, indent=2))
|
|
896
|
+
return
|
|
897
|
+
|
|
898
|
+
console.print(f"[dim]Recent notes (last {limit}):[/]\n")
|
|
899
|
+
|
|
900
|
+
for i, path in enumerate(recent, 1):
|
|
901
|
+
console.print(f" {i}. [cyan]{path}[/]")
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
@vault_app.command("orphans")
|
|
905
|
+
def vault_orphans(
|
|
906
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
907
|
+
) -> None:
|
|
908
|
+
"""Find orphan notes (not linked from anywhere)."""
|
|
909
|
+
import json
|
|
910
|
+
|
|
911
|
+
vault = _get_vault_manager()
|
|
912
|
+
|
|
913
|
+
if not vault.exists():
|
|
914
|
+
console.print(f"[red]Vault not found:[/] {vault.vault_path}")
|
|
915
|
+
raise typer.Exit(1)
|
|
916
|
+
|
|
917
|
+
orphans = vault.orphans()
|
|
918
|
+
|
|
919
|
+
if json_output:
|
|
920
|
+
console.print(json.dumps(orphans, indent=2))
|
|
921
|
+
return
|
|
922
|
+
|
|
923
|
+
if not orphans:
|
|
924
|
+
console.print("[green]✓ No orphan notes found[/]")
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
console.print(f"[yellow]Found {len(orphans)} orphan notes:[/]\n")
|
|
928
|
+
|
|
929
|
+
for path in orphans[:20]:
|
|
930
|
+
console.print(f" • [dim]{path}[/]")
|
|
931
|
+
|
|
932
|
+
if len(orphans) > 20:
|
|
933
|
+
console.print(f"\n[dim] ... and {len(orphans) - 20} more[/]")
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
@vault_app.command("template")
|
|
937
|
+
def vault_template(
|
|
938
|
+
template_name: Annotated[str, typer.Argument(help="Template name")],
|
|
939
|
+
dest: Annotated[str, typer.Option("--dest", "-d", help="Destination path")] = "",
|
|
940
|
+
list_templates: Annotated[bool, typer.Option("--list", "-l", help="List available templates")] = False,
|
|
941
|
+
) -> None:
|
|
942
|
+
"""Create a note from a template."""
|
|
943
|
+
vault = _get_vault_manager()
|
|
944
|
+
|
|
945
|
+
if list_templates or not template_name:
|
|
946
|
+
templates = vault.list_templates()
|
|
947
|
+
if not templates:
|
|
948
|
+
console.print("[yellow]No templates found[/]")
|
|
949
|
+
console.print(f"[dim]Templates directory:[/] {vault.templates_path}")
|
|
950
|
+
return
|
|
951
|
+
|
|
952
|
+
console.print("[dim]Available templates:[/]\n")
|
|
953
|
+
for t in templates:
|
|
954
|
+
console.print(f" • [cyan]{t}[/]")
|
|
955
|
+
return
|
|
956
|
+
|
|
957
|
+
if not dest:
|
|
958
|
+
console.print("[red]Must provide --dest path[/]")
|
|
959
|
+
raise typer.Exit(1)
|
|
960
|
+
|
|
961
|
+
try:
|
|
962
|
+
result_path = vault.template(template_name, dest)
|
|
963
|
+
console.print(f"[green]✓ Created from template:[/] {result_path}")
|
|
964
|
+
except FileNotFoundError:
|
|
965
|
+
console.print(f"[red]Template not found:[/] {template_name}")
|
|
966
|
+
console.print("[dim]Use[/] [cyan]nexus knowledge vault template --list[/] [dim]to see available templates[/]")
|
|
967
|
+
raise typer.Exit(1)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
@vault_app.command("graph")
|
|
971
|
+
def vault_graph(
|
|
972
|
+
limit: Annotated[int | None, typer.Option("--limit", "-n", help="Limit nodes (keeps most connected)")] = None,
|
|
973
|
+
include_tags: Annotated[bool, typer.Option("--tags", "-t", help="Include tag nodes")] = False,
|
|
974
|
+
stats_only: Annotated[bool, typer.Option("--stats", "-s", help="Show statistics only")] = False,
|
|
975
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
976
|
+
) -> None:
|
|
977
|
+
"""Generate graph visualization data for the vault.
|
|
978
|
+
|
|
979
|
+
Creates a graph representation showing notes and their connections (links).
|
|
980
|
+
Optionally includes tags as nodes. Output can be used with graph visualization tools.
|
|
981
|
+
"""
|
|
982
|
+
import json
|
|
983
|
+
|
|
984
|
+
vault = _get_vault_manager()
|
|
985
|
+
|
|
986
|
+
if stats_only:
|
|
987
|
+
stats = vault.graph_stats()
|
|
988
|
+
|
|
989
|
+
if json_output:
|
|
990
|
+
console.print(json.dumps(stats, indent=2))
|
|
991
|
+
return
|
|
992
|
+
|
|
993
|
+
console.print("\n[bold cyan]Vault Graph Statistics[/]\n")
|
|
994
|
+
|
|
995
|
+
console.print(f" [dim]Total Notes:[/] {stats['total_notes']}")
|
|
996
|
+
console.print(f" [dim]Total Tags:[/] {stats['total_tags']}")
|
|
997
|
+
console.print(f" [dim]Total Connections:[/] {stats['total_connections']}")
|
|
998
|
+
console.print(f" [dim]Average Connections:[/] {stats['avg_connections']}")
|
|
999
|
+
console.print(f" [dim]Graph Density:[/] {stats['density']}")
|
|
1000
|
+
|
|
1001
|
+
console.print("\n[dim]Most Connected Notes:[/]\n")
|
|
1002
|
+
|
|
1003
|
+
table = Table(show_header=True, header_style="bold")
|
|
1004
|
+
table.add_column("Note", style="cyan")
|
|
1005
|
+
table.add_column("Connections", justify="right", style="yellow")
|
|
1006
|
+
|
|
1007
|
+
for note in stats["most_connected"][:10]:
|
|
1008
|
+
table.add_row(note["label"], str(note["connections"]))
|
|
1009
|
+
|
|
1010
|
+
console.print(table)
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
# Generate full graph
|
|
1014
|
+
graph = vault.graph(limit=limit, include_tags=include_tags)
|
|
1015
|
+
|
|
1016
|
+
if json_output:
|
|
1017
|
+
console.print(json.dumps(graph, indent=2))
|
|
1018
|
+
return
|
|
1019
|
+
|
|
1020
|
+
console.print(f"\n[bold cyan]Vault Graph[/]\n")
|
|
1021
|
+
console.print(f" [dim]Nodes:[/] {len(graph['nodes'])}")
|
|
1022
|
+
console.print(f" [dim]Edges:[/] {len(graph['edges'])}")
|
|
1023
|
+
|
|
1024
|
+
if include_tags:
|
|
1025
|
+
tag_nodes = [n for n in graph["nodes"] if n.get("type") == "tag"]
|
|
1026
|
+
console.print(f" [dim]Tag Nodes:[/] {len(tag_nodes)}")
|
|
1027
|
+
|
|
1028
|
+
console.print("\n[dim]Use[/] [cyan]--json[/] [dim]to export graph data for visualization tools[/]")
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
@knowledge_app.command("search")
|
|
1032
|
+
def knowledge_search(
|
|
1033
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
1034
|
+
source: Annotated[
|
|
1035
|
+
str | None,
|
|
1036
|
+
typer.Option("--source", "-s", help="Limit to source: zotero, vault, pdf"),
|
|
1037
|
+
] = None,
|
|
1038
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 20,
|
|
1039
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1040
|
+
) -> None:
|
|
1041
|
+
"""Unified search across all knowledge sources."""
|
|
1042
|
+
import json
|
|
1043
|
+
|
|
1044
|
+
from nexus.knowledge.search import UnifiedSearch
|
|
1045
|
+
|
|
1046
|
+
cfg = get_config()
|
|
1047
|
+
|
|
1048
|
+
searcher = UnifiedSearch(
|
|
1049
|
+
vault_path=Path(cfg.vault.path).expanduser(),
|
|
1050
|
+
zotero_db=Path(cfg.zotero.database).expanduser(),
|
|
1051
|
+
pdf_dirs=[Path(d).expanduser() for d in cfg.pdf.directories],
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
sources_list = source.split(",") if source else None
|
|
1055
|
+
results = searcher.search(query, sources=sources_list, limit=limit)
|
|
1056
|
+
|
|
1057
|
+
if json_output:
|
|
1058
|
+
console.print(json.dumps([r.to_dict() for r in results], indent=2))
|
|
1059
|
+
return
|
|
1060
|
+
|
|
1061
|
+
available = searcher.available_sources()
|
|
1062
|
+
if not available:
|
|
1063
|
+
console.print("[yellow]No knowledge sources available[/]")
|
|
1064
|
+
console.print("[dim]Run[/] [cyan]nexus doctor[/] [dim]to check configuration[/]")
|
|
1065
|
+
return
|
|
1066
|
+
|
|
1067
|
+
if not results:
|
|
1068
|
+
console.print(f"[yellow]No results found for:[/] {query}")
|
|
1069
|
+
console.print(f"[dim]Searched:[/] {', '.join(sources_list or available)}")
|
|
1070
|
+
return
|
|
1071
|
+
|
|
1072
|
+
console.print(f"[dim]Found {len(results)} results for:[/] [cyan]{query}[/]\n")
|
|
1073
|
+
|
|
1074
|
+
table = Table(show_header=True, header_style="bold")
|
|
1075
|
+
table.add_column("Source", style="magenta", width=8)
|
|
1076
|
+
table.add_column("Title", style="cyan")
|
|
1077
|
+
table.add_column("Snippet")
|
|
1078
|
+
|
|
1079
|
+
for result in results:
|
|
1080
|
+
snippet = result.snippet[:60] + "..." if len(result.snippet) > 60 else result.snippet
|
|
1081
|
+
table.add_row(result.source, result.title, snippet)
|
|
1082
|
+
|
|
1083
|
+
console.print(table)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1087
|
+
# Teaching Domain
|
|
1088
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _get_course_manager():
|
|
1092
|
+
"""Get configured CourseManager instance."""
|
|
1093
|
+
from nexus.teaching.courses import CourseManager
|
|
1094
|
+
|
|
1095
|
+
cfg = get_config()
|
|
1096
|
+
return CourseManager(
|
|
1097
|
+
courses_dir=Path(cfg.teaching.courses_dir).expanduser(),
|
|
1098
|
+
materials_dir=Path(cfg.teaching.materials_dir).expanduser(),
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _get_quarto_manager():
|
|
1103
|
+
"""Get QuartoManager instance."""
|
|
1104
|
+
from nexus.teaching.quarto import QuartoManager
|
|
1105
|
+
|
|
1106
|
+
return QuartoManager()
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
# Course subcommands
|
|
1110
|
+
course_app = typer.Typer(help="Course management")
|
|
1111
|
+
teach_app.add_typer(course_app, name="course")
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
@course_app.command("list")
|
|
1115
|
+
def course_list(
|
|
1116
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1117
|
+
) -> None:
|
|
1118
|
+
"""List all courses."""
|
|
1119
|
+
import json
|
|
1120
|
+
|
|
1121
|
+
manager = _get_course_manager()
|
|
1122
|
+
|
|
1123
|
+
if not manager.exists():
|
|
1124
|
+
console.print(f"[red]Courses directory not found:[/] {manager.courses_dir}")
|
|
1125
|
+
raise typer.Exit(1)
|
|
1126
|
+
|
|
1127
|
+
courses = manager.list_courses()
|
|
1128
|
+
|
|
1129
|
+
if json_output:
|
|
1130
|
+
console.print(json.dumps([c.to_dict() for c in courses], indent=2))
|
|
1131
|
+
return
|
|
1132
|
+
|
|
1133
|
+
if not courses:
|
|
1134
|
+
console.print("[yellow]No courses found[/]")
|
|
1135
|
+
console.print(f"[dim]Courses directory:[/] {manager.courses_dir}")
|
|
1136
|
+
return
|
|
1137
|
+
|
|
1138
|
+
console.print(f"[dim]Found {len(courses)} courses:[/]\n")
|
|
1139
|
+
|
|
1140
|
+
table = Table(show_header=True, header_style="bold")
|
|
1141
|
+
table.add_column("Course", style="cyan")
|
|
1142
|
+
table.add_column("Status")
|
|
1143
|
+
table.add_column("Progress", justify="right")
|
|
1144
|
+
table.add_column("Lectures", justify="right")
|
|
1145
|
+
table.add_column("Next Action")
|
|
1146
|
+
|
|
1147
|
+
for course in courses:
|
|
1148
|
+
# Status color
|
|
1149
|
+
status_color = {
|
|
1150
|
+
"active": "green",
|
|
1151
|
+
"complete": "blue",
|
|
1152
|
+
"paused": "yellow",
|
|
1153
|
+
"archived": "dim",
|
|
1154
|
+
}.get(course.status.lower(), "white")
|
|
1155
|
+
|
|
1156
|
+
progress_bar = "█" * (course.progress // 10) + "░" * (10 - course.progress // 10)
|
|
1157
|
+
|
|
1158
|
+
table.add_row(
|
|
1159
|
+
course.name,
|
|
1160
|
+
f"[{status_color}]{course.status}[/]",
|
|
1161
|
+
f"{progress_bar} {course.progress}%",
|
|
1162
|
+
str(course.lecture_count),
|
|
1163
|
+
course.next_action[:40] + "..." if len(course.next_action) > 40 else course.next_action,
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
console.print(table)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
@course_app.command("show")
|
|
1170
|
+
def course_show(
|
|
1171
|
+
name: Annotated[str, typer.Argument(help="Course name (e.g., stat-440)")],
|
|
1172
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1173
|
+
) -> None:
|
|
1174
|
+
"""Show course details."""
|
|
1175
|
+
import json
|
|
1176
|
+
|
|
1177
|
+
manager = _get_course_manager()
|
|
1178
|
+
course = manager.get_course(name)
|
|
1179
|
+
|
|
1180
|
+
if not course:
|
|
1181
|
+
console.print(f"[red]Course not found:[/] {name}")
|
|
1182
|
+
raise typer.Exit(1)
|
|
1183
|
+
|
|
1184
|
+
if json_output:
|
|
1185
|
+
console.print(json.dumps(course.to_dict(), indent=2))
|
|
1186
|
+
return
|
|
1187
|
+
|
|
1188
|
+
# Show course panel
|
|
1189
|
+
console.print(
|
|
1190
|
+
Panel(
|
|
1191
|
+
f"[bold]{course.title or course.name}[/]\n[dim]{course.path}[/]",
|
|
1192
|
+
border_style="blue",
|
|
1193
|
+
)
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
console.print(f"[dim]Status:[/] {course.status}")
|
|
1197
|
+
console.print(f"[dim]Progress:[/] {course.progress}%")
|
|
1198
|
+
|
|
1199
|
+
if course.week:
|
|
1200
|
+
console.print(f"[dim]Current Week:[/] {course.week}")
|
|
1201
|
+
|
|
1202
|
+
if course.formats:
|
|
1203
|
+
console.print(f"[dim]Formats:[/] {', '.join(course.formats)}")
|
|
1204
|
+
|
|
1205
|
+
console.print(f"[dim]Lectures:[/] {course.lecture_count}")
|
|
1206
|
+
console.print(f"[dim]Assignments:[/] {course.assignment_count}")
|
|
1207
|
+
|
|
1208
|
+
if course.next_action:
|
|
1209
|
+
console.print(f"\n[bold]Next:[/] {course.next_action}")
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
@course_app.command("lectures")
|
|
1213
|
+
def course_lectures(
|
|
1214
|
+
name: Annotated[str, typer.Argument(help="Course name")],
|
|
1215
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1216
|
+
) -> None:
|
|
1217
|
+
"""List lectures for a course."""
|
|
1218
|
+
import json
|
|
1219
|
+
|
|
1220
|
+
manager = _get_course_manager()
|
|
1221
|
+
lectures = manager.list_lectures(name)
|
|
1222
|
+
|
|
1223
|
+
if json_output:
|
|
1224
|
+
console.print(json.dumps([l.to_dict() for l in lectures], indent=2))
|
|
1225
|
+
return
|
|
1226
|
+
|
|
1227
|
+
if not lectures:
|
|
1228
|
+
console.print(f"[yellow]No lectures found for:[/] {name}")
|
|
1229
|
+
return
|
|
1230
|
+
|
|
1231
|
+
console.print(f"[dim]Lectures for {name}:[/]\n")
|
|
1232
|
+
|
|
1233
|
+
table = Table(show_header=True, header_style="bold")
|
|
1234
|
+
table.add_column("Week", style="dim", width=6)
|
|
1235
|
+
table.add_column("Title", style="cyan")
|
|
1236
|
+
table.add_column("File")
|
|
1237
|
+
|
|
1238
|
+
for lecture in lectures:
|
|
1239
|
+
week = str(lecture.week) if lecture.week else "-"
|
|
1240
|
+
table.add_row(week, lecture.title, lecture.name)
|
|
1241
|
+
|
|
1242
|
+
console.print(table)
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
@course_app.command("materials")
|
|
1246
|
+
def course_materials(
|
|
1247
|
+
name: Annotated[str, typer.Argument(help="Course name")],
|
|
1248
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1249
|
+
) -> None:
|
|
1250
|
+
"""List all materials for a course."""
|
|
1251
|
+
import json
|
|
1252
|
+
|
|
1253
|
+
manager = _get_course_manager()
|
|
1254
|
+
materials = manager.get_materials(name)
|
|
1255
|
+
|
|
1256
|
+
if json_output:
|
|
1257
|
+
console.print(json.dumps(materials, indent=2))
|
|
1258
|
+
return
|
|
1259
|
+
|
|
1260
|
+
if not materials:
|
|
1261
|
+
console.print(f"[yellow]No materials found for:[/] {name}")
|
|
1262
|
+
return
|
|
1263
|
+
|
|
1264
|
+
# Group by type
|
|
1265
|
+
by_type: dict[str, list] = {}
|
|
1266
|
+
for mat in materials:
|
|
1267
|
+
mat_type = mat["type"]
|
|
1268
|
+
if mat_type not in by_type:
|
|
1269
|
+
by_type[mat_type] = []
|
|
1270
|
+
by_type[mat_type].append(mat)
|
|
1271
|
+
|
|
1272
|
+
console.print(f"[dim]Materials for {name}:[/]\n")
|
|
1273
|
+
|
|
1274
|
+
for mat_type, items in by_type.items():
|
|
1275
|
+
console.print(f"[bold]{mat_type.title()}[/] ({len(items)})")
|
|
1276
|
+
for item in items[:10]:
|
|
1277
|
+
console.print(f" • [cyan]{item['name']}[/].{item['format']}")
|
|
1278
|
+
if len(items) > 10:
|
|
1279
|
+
console.print(f" [dim]... and {len(items) - 10} more[/]")
|
|
1280
|
+
console.print()
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
@course_app.command("syllabus")
|
|
1284
|
+
def course_syllabus(
|
|
1285
|
+
name: Annotated[str, typer.Argument(help="Course name")],
|
|
1286
|
+
) -> None:
|
|
1287
|
+
"""Show course syllabus."""
|
|
1288
|
+
manager = _get_course_manager()
|
|
1289
|
+
syllabus = manager.get_syllabus(name)
|
|
1290
|
+
|
|
1291
|
+
if not syllabus:
|
|
1292
|
+
console.print(f"[yellow]No syllabus found for:[/] {name}")
|
|
1293
|
+
return
|
|
1294
|
+
|
|
1295
|
+
console.print(
|
|
1296
|
+
Panel(
|
|
1297
|
+
f"[bold]Syllabus: {name}[/]",
|
|
1298
|
+
border_style="blue",
|
|
1299
|
+
)
|
|
1300
|
+
)
|
|
1301
|
+
console.print(syllabus)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
# Quarto subcommands
|
|
1305
|
+
quarto_app = typer.Typer(help="Quarto operations")
|
|
1306
|
+
teach_app.add_typer(quarto_app, name="quarto")
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
@quarto_app.command("build")
|
|
1310
|
+
def quarto_build(
|
|
1311
|
+
path: Annotated[Path, typer.Argument(help="Path to .qmd file or project directory")],
|
|
1312
|
+
format: Annotated[str | None, typer.Option("--format", "-f", help="Output format (html, pdf, revealjs)")] = None,
|
|
1313
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1314
|
+
) -> None:
|
|
1315
|
+
"""Build a Quarto document or project."""
|
|
1316
|
+
import json
|
|
1317
|
+
|
|
1318
|
+
manager = _get_quarto_manager()
|
|
1319
|
+
|
|
1320
|
+
if not manager.available():
|
|
1321
|
+
console.print("[red]Quarto not installed[/]")
|
|
1322
|
+
console.print("[dim]Install with:[/] brew install quarto")
|
|
1323
|
+
raise typer.Exit(1)
|
|
1324
|
+
|
|
1325
|
+
console.print(f"[dim]Building:[/] {path}")
|
|
1326
|
+
if format:
|
|
1327
|
+
console.print(f"[dim]Format:[/] {format}")
|
|
1328
|
+
|
|
1329
|
+
result = manager.render(path, output_format=format)
|
|
1330
|
+
|
|
1331
|
+
if json_output:
|
|
1332
|
+
console.print(json.dumps(result.to_dict(), indent=2))
|
|
1333
|
+
return
|
|
1334
|
+
|
|
1335
|
+
if result.success:
|
|
1336
|
+
console.print(f"[green]✓ Build successful[/] ({result.duration_seconds:.1f}s)")
|
|
1337
|
+
if result.output_path:
|
|
1338
|
+
console.print(f"[dim]Output:[/] {result.output_path}")
|
|
1339
|
+
else:
|
|
1340
|
+
console.print("[red]✗ Build failed[/]")
|
|
1341
|
+
console.print(result.error)
|
|
1342
|
+
raise typer.Exit(1)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
@quarto_app.command("preview")
|
|
1346
|
+
def quarto_preview(
|
|
1347
|
+
path: Annotated[Path, typer.Argument(help="Path to .qmd file or project directory")],
|
|
1348
|
+
port: Annotated[int, typer.Option("--port", "-p", help="Preview server port")] = 4200,
|
|
1349
|
+
) -> None:
|
|
1350
|
+
"""Start Quarto preview server."""
|
|
1351
|
+
manager = _get_quarto_manager()
|
|
1352
|
+
|
|
1353
|
+
if not manager.available():
|
|
1354
|
+
console.print("[red]Quarto not installed[/]")
|
|
1355
|
+
console.print("[dim]Install with:[/] brew install quarto")
|
|
1356
|
+
raise typer.Exit(1)
|
|
1357
|
+
|
|
1358
|
+
console.print(f"[dim]Starting preview server for:[/] {path}")
|
|
1359
|
+
|
|
1360
|
+
result = manager.preview(path, port=port)
|
|
1361
|
+
|
|
1362
|
+
if result.get("success"):
|
|
1363
|
+
console.print("[green]✓ Preview server started[/]")
|
|
1364
|
+
console.print(f"[dim]URL:[/] [link={result['url']}]{result['url']}[/link]")
|
|
1365
|
+
console.print(f"[dim]PID:[/] {result['pid']}")
|
|
1366
|
+
console.print("\n[dim]Press Ctrl+C in the preview window to stop[/]")
|
|
1367
|
+
else:
|
|
1368
|
+
console.print("[red]✗ Failed to start preview[/]")
|
|
1369
|
+
console.print(result.get("error", "Unknown error"))
|
|
1370
|
+
raise typer.Exit(1)
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
@quarto_app.command("info")
|
|
1374
|
+
def quarto_info(
|
|
1375
|
+
path: Annotated[Path | None, typer.Argument(help="Path to project")] = None,
|
|
1376
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1377
|
+
) -> None:
|
|
1378
|
+
"""Show Quarto installation and project info."""
|
|
1379
|
+
import json
|
|
1380
|
+
|
|
1381
|
+
manager = _get_quarto_manager()
|
|
1382
|
+
|
|
1383
|
+
info = {
|
|
1384
|
+
"installed": manager.available(),
|
|
1385
|
+
"version": manager.version(),
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if path:
|
|
1389
|
+
project = manager.load_project(path)
|
|
1390
|
+
if project:
|
|
1391
|
+
info["project"] = project.to_dict()
|
|
1392
|
+
deps = manager.check_dependencies(path)
|
|
1393
|
+
info["dependencies"] = deps
|
|
1394
|
+
|
|
1395
|
+
if json_output:
|
|
1396
|
+
console.print(json.dumps(info, indent=2))
|
|
1397
|
+
return
|
|
1398
|
+
|
|
1399
|
+
console.print(
|
|
1400
|
+
Panel.fit(
|
|
1401
|
+
"[bold]Quarto Info[/]",
|
|
1402
|
+
border_style="blue",
|
|
1403
|
+
)
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
if info["installed"]:
|
|
1407
|
+
console.print(f"[green]✓ Quarto installed[/] (version {info['version']})")
|
|
1408
|
+
else:
|
|
1409
|
+
console.print("[red]✗ Quarto not installed[/]")
|
|
1410
|
+
return
|
|
1411
|
+
|
|
1412
|
+
if path and "project" in info:
|
|
1413
|
+
proj = info["project"]
|
|
1414
|
+
console.print(f"\n[bold]Project:[/] {proj['name']}")
|
|
1415
|
+
console.print(f"[dim]Type:[/] {proj['project_type']}")
|
|
1416
|
+
if proj["title"]:
|
|
1417
|
+
console.print(f"[dim]Title:[/] {proj['title']}")
|
|
1418
|
+
if proj["formats"]:
|
|
1419
|
+
console.print(f"[dim]Formats:[/] {', '.join(proj['formats'])}")
|
|
1420
|
+
|
|
1421
|
+
if "dependencies" in info:
|
|
1422
|
+
console.print("\n[bold]Dependencies:[/]")
|
|
1423
|
+
for dep, available in info["dependencies"].items():
|
|
1424
|
+
if dep == "quarto_version":
|
|
1425
|
+
continue
|
|
1426
|
+
status = "[green]✓[/]" if available else "[red]✗[/]"
|
|
1427
|
+
console.print(f" {status} {dep}")
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
@quarto_app.command("clean")
|
|
1431
|
+
def quarto_clean(
|
|
1432
|
+
path: Annotated[Path, typer.Argument(help="Path to project directory")],
|
|
1433
|
+
) -> None:
|
|
1434
|
+
"""Clean Quarto build artifacts."""
|
|
1435
|
+
manager = _get_quarto_manager()
|
|
1436
|
+
|
|
1437
|
+
result = manager.clean(path)
|
|
1438
|
+
|
|
1439
|
+
if result["count"] > 0:
|
|
1440
|
+
console.print(f"[green]✓ Cleaned {result['count']} directories[/]")
|
|
1441
|
+
for cleaned in result["cleaned"]:
|
|
1442
|
+
console.print(f" [dim]Removed:[/] {cleaned}")
|
|
1443
|
+
else:
|
|
1444
|
+
console.print("[dim]No build artifacts to clean[/]")
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
@quarto_app.command("formats")
|
|
1448
|
+
def quarto_formats(
|
|
1449
|
+
path: Annotated[Path, typer.Argument(help="Path to project")],
|
|
1450
|
+
) -> None:
|
|
1451
|
+
"""List available output formats for a project."""
|
|
1452
|
+
manager = _get_quarto_manager()
|
|
1453
|
+
|
|
1454
|
+
formats = manager.list_formats(path)
|
|
1455
|
+
|
|
1456
|
+
if formats:
|
|
1457
|
+
console.print("[dim]Available formats:[/]\n")
|
|
1458
|
+
for fmt in formats:
|
|
1459
|
+
console.print(f" • [cyan]{fmt}[/]")
|
|
1460
|
+
else:
|
|
1461
|
+
console.print("[dim]Default formats: html, pdf, docx[/]")
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1465
|
+
# Writing Domain
|
|
1466
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def _get_manuscript_manager():
|
|
1470
|
+
"""Get configured ManuscriptManager instance."""
|
|
1471
|
+
from nexus.writing.manuscript import ManuscriptManager
|
|
1472
|
+
|
|
1473
|
+
cfg = get_config()
|
|
1474
|
+
return ManuscriptManager(
|
|
1475
|
+
manuscripts_dir=Path(cfg.writing.manuscripts_dir).expanduser(),
|
|
1476
|
+
templates_dir=Path(cfg.writing.templates_dir).expanduser(),
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _get_bibliography_manager():
|
|
1481
|
+
"""Get configured BibliographyManager instance."""
|
|
1482
|
+
from nexus.writing.bibliography import BibliographyManager
|
|
1483
|
+
|
|
1484
|
+
cfg = get_config()
|
|
1485
|
+
return BibliographyManager(
|
|
1486
|
+
zotero_db=Path(cfg.zotero.database).expanduser(),
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
# Manuscript subcommands
|
|
1491
|
+
manuscript_app = typer.Typer(help="Manuscript management")
|
|
1492
|
+
write_app.add_typer(manuscript_app, name="manuscript")
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
@manuscript_app.command("list")
|
|
1496
|
+
def manuscript_list(
|
|
1497
|
+
all_manuscripts: Annotated[bool, typer.Option("--all", "-a", help="Include archived/complete")] = False,
|
|
1498
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1499
|
+
) -> None:
|
|
1500
|
+
"""List all manuscripts."""
|
|
1501
|
+
import json
|
|
1502
|
+
|
|
1503
|
+
manager = _get_manuscript_manager()
|
|
1504
|
+
|
|
1505
|
+
if not manager.exists():
|
|
1506
|
+
console.print(f"[red]Manuscripts directory not found:[/] {manager.manuscripts_dir}")
|
|
1507
|
+
raise typer.Exit(1)
|
|
1508
|
+
|
|
1509
|
+
manuscripts = manager.list_manuscripts(include_archived=all_manuscripts)
|
|
1510
|
+
|
|
1511
|
+
if json_output:
|
|
1512
|
+
console.print(json.dumps([m.to_dict() for m in manuscripts], indent=2))
|
|
1513
|
+
return
|
|
1514
|
+
|
|
1515
|
+
if not manuscripts:
|
|
1516
|
+
console.print("[yellow]No manuscripts found[/]")
|
|
1517
|
+
console.print(f"[dim]Manuscripts directory:[/] {manager.manuscripts_dir}")
|
|
1518
|
+
return
|
|
1519
|
+
|
|
1520
|
+
console.print(f"[dim]Found {len(manuscripts)} manuscripts:[/]\n")
|
|
1521
|
+
|
|
1522
|
+
table = Table(show_header=True, header_style="bold")
|
|
1523
|
+
table.add_column("", width=2) # Status emoji
|
|
1524
|
+
table.add_column("Manuscript", style="cyan")
|
|
1525
|
+
table.add_column("Status")
|
|
1526
|
+
table.add_column("Progress", justify="right")
|
|
1527
|
+
table.add_column("Target")
|
|
1528
|
+
|
|
1529
|
+
for ms in manuscripts:
|
|
1530
|
+
# Status color
|
|
1531
|
+
status_color = {
|
|
1532
|
+
"active": "green",
|
|
1533
|
+
"draft": "yellow",
|
|
1534
|
+
"under review": "blue",
|
|
1535
|
+
"revision": "magenta",
|
|
1536
|
+
"complete": "dim",
|
|
1537
|
+
"published": "dim",
|
|
1538
|
+
}.get(ms.status.lower(), "white")
|
|
1539
|
+
|
|
1540
|
+
progress_bar = "█" * (ms.progress // 10) + "░" * (10 - ms.progress // 10)
|
|
1541
|
+
|
|
1542
|
+
table.add_row(
|
|
1543
|
+
ms.status_emoji,
|
|
1544
|
+
ms.name,
|
|
1545
|
+
f"[{status_color}]{ms.status}[/]",
|
|
1546
|
+
f"{progress_bar} {ms.progress}%",
|
|
1547
|
+
ms.target or "-",
|
|
1548
|
+
)
|
|
1549
|
+
|
|
1550
|
+
console.print(table)
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
@manuscript_app.command("show")
|
|
1554
|
+
def manuscript_show(
|
|
1555
|
+
name: Annotated[str, typer.Argument(help="Manuscript name")],
|
|
1556
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1557
|
+
) -> None:
|
|
1558
|
+
"""Show manuscript details."""
|
|
1559
|
+
import json
|
|
1560
|
+
|
|
1561
|
+
manager = _get_manuscript_manager()
|
|
1562
|
+
manuscript = manager.get_manuscript(name)
|
|
1563
|
+
|
|
1564
|
+
if not manuscript:
|
|
1565
|
+
console.print(f"[red]Manuscript not found:[/] {name}")
|
|
1566
|
+
raise typer.Exit(1)
|
|
1567
|
+
|
|
1568
|
+
if json_output:
|
|
1569
|
+
console.print(json.dumps(manuscript.to_dict(), indent=2))
|
|
1570
|
+
return
|
|
1571
|
+
|
|
1572
|
+
# Show manuscript panel
|
|
1573
|
+
console.print(
|
|
1574
|
+
Panel(
|
|
1575
|
+
f"[bold]{manuscript.title}[/]\n[dim]{manuscript.path}[/]",
|
|
1576
|
+
border_style="blue",
|
|
1577
|
+
)
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
console.print(f"[dim]Status:[/] {manuscript.status_emoji} {manuscript.status}")
|
|
1581
|
+
console.print(f"[dim]Progress:[/] {manuscript.progress}%")
|
|
1582
|
+
|
|
1583
|
+
if manuscript.target:
|
|
1584
|
+
console.print(f"[dim]Target:[/] {manuscript.target}")
|
|
1585
|
+
|
|
1586
|
+
if manuscript.authors:
|
|
1587
|
+
console.print(f"[dim]Authors:[/] {', '.join(manuscript.authors)}")
|
|
1588
|
+
|
|
1589
|
+
console.print(f"[dim]Format:[/] {manuscript.format_type}")
|
|
1590
|
+
|
|
1591
|
+
if manuscript.main_file:
|
|
1592
|
+
console.print(f"[dim]Main file:[/] {manuscript.main_file}")
|
|
1593
|
+
|
|
1594
|
+
if manuscript.word_count > 0:
|
|
1595
|
+
console.print(f"[dim]Word count:[/] ~{manuscript.word_count:,}")
|
|
1596
|
+
|
|
1597
|
+
if manuscript.last_modified:
|
|
1598
|
+
console.print(f"[dim]Last modified:[/] {manuscript.last_modified.strftime('%Y-%m-%d %H:%M')}")
|
|
1599
|
+
|
|
1600
|
+
if manuscript.next_action:
|
|
1601
|
+
console.print(f"\n[bold]Next:[/] {manuscript.next_action}")
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
@manuscript_app.command("active")
|
|
1605
|
+
def manuscript_active(
|
|
1606
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1607
|
+
) -> None:
|
|
1608
|
+
"""Show actively worked manuscripts."""
|
|
1609
|
+
import json
|
|
1610
|
+
|
|
1611
|
+
manager = _get_manuscript_manager()
|
|
1612
|
+
manuscripts = manager.get_active()
|
|
1613
|
+
|
|
1614
|
+
if json_output:
|
|
1615
|
+
console.print(json.dumps([m.to_dict() for m in manuscripts], indent=2))
|
|
1616
|
+
return
|
|
1617
|
+
|
|
1618
|
+
if not manuscripts:
|
|
1619
|
+
console.print("[dim]No active manuscripts[/]")
|
|
1620
|
+
return
|
|
1621
|
+
|
|
1622
|
+
console.print(f"[dim]Active manuscripts ({len(manuscripts)}):[/]\n")
|
|
1623
|
+
|
|
1624
|
+
for ms in manuscripts:
|
|
1625
|
+
progress_bar = "█" * (ms.progress // 10) + "░" * (10 - ms.progress // 10)
|
|
1626
|
+
console.print(f" {ms.status_emoji} [cyan]{ms.name}[/] {progress_bar} {ms.progress}%")
|
|
1627
|
+
if ms.next_action:
|
|
1628
|
+
console.print(f" [dim]Next:[/] {ms.next_action}")
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
@manuscript_app.command("search")
|
|
1632
|
+
def manuscript_search(
|
|
1633
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
1634
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1635
|
+
) -> None:
|
|
1636
|
+
"""Search manuscripts by name or title."""
|
|
1637
|
+
import json
|
|
1638
|
+
|
|
1639
|
+
manager = _get_manuscript_manager()
|
|
1640
|
+
results = manager.search(query)
|
|
1641
|
+
|
|
1642
|
+
if json_output:
|
|
1643
|
+
console.print(json.dumps([m.to_dict() for m in results], indent=2))
|
|
1644
|
+
return
|
|
1645
|
+
|
|
1646
|
+
if not results:
|
|
1647
|
+
console.print(f"[yellow]No manuscripts found matching:[/] {query}")
|
|
1648
|
+
return
|
|
1649
|
+
|
|
1650
|
+
console.print(f"[dim]Found {len(results)} manuscripts matching:[/] [cyan]{query}[/]\n")
|
|
1651
|
+
|
|
1652
|
+
for ms in results:
|
|
1653
|
+
console.print(f" {ms.status_emoji} [cyan]{ms.name}[/] - {ms.status}")
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
@manuscript_app.command("stats")
|
|
1657
|
+
def manuscript_stats(
|
|
1658
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1659
|
+
) -> None:
|
|
1660
|
+
"""Show manuscript statistics."""
|
|
1661
|
+
import json
|
|
1662
|
+
|
|
1663
|
+
manager = _get_manuscript_manager()
|
|
1664
|
+
stats = manager.get_statistics()
|
|
1665
|
+
|
|
1666
|
+
if json_output:
|
|
1667
|
+
console.print(json.dumps(stats, indent=2))
|
|
1668
|
+
return
|
|
1669
|
+
|
|
1670
|
+
console.print(
|
|
1671
|
+
Panel.fit(
|
|
1672
|
+
"[bold]Manuscript Statistics[/]",
|
|
1673
|
+
border_style="blue",
|
|
1674
|
+
)
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
console.print(f"[dim]Total manuscripts:[/] {stats['total']}")
|
|
1678
|
+
console.print(f"[dim]Total words:[/] ~{stats['total_words']:,}")
|
|
1679
|
+
|
|
1680
|
+
if stats["by_status"]:
|
|
1681
|
+
console.print("\n[bold]By Status:[/]")
|
|
1682
|
+
for status, count in sorted(stats["by_status"].items(), key=lambda x: -x[1]):
|
|
1683
|
+
console.print(f" • {status}: {count}")
|
|
1684
|
+
|
|
1685
|
+
if stats["by_format"]:
|
|
1686
|
+
console.print("\n[bold]By Format:[/]")
|
|
1687
|
+
for fmt, count in sorted(stats["by_format"].items(), key=lambda x: -x[1]):
|
|
1688
|
+
console.print(f" • {fmt}: {count}")
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
@manuscript_app.command("deadlines")
|
|
1692
|
+
def manuscript_deadlines(
|
|
1693
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1694
|
+
) -> None:
|
|
1695
|
+
"""Show manuscripts with targets/deadlines."""
|
|
1696
|
+
import json
|
|
1697
|
+
|
|
1698
|
+
manager = _get_manuscript_manager()
|
|
1699
|
+
deadlines = manager.get_deadlines()
|
|
1700
|
+
|
|
1701
|
+
if json_output:
|
|
1702
|
+
console.print(json.dumps(deadlines, indent=2))
|
|
1703
|
+
return
|
|
1704
|
+
|
|
1705
|
+
if not deadlines:
|
|
1706
|
+
console.print("[dim]No manuscripts with targets[/]")
|
|
1707
|
+
return
|
|
1708
|
+
|
|
1709
|
+
console.print("[dim]Manuscripts with targets:[/]\n")
|
|
1710
|
+
|
|
1711
|
+
table = Table(show_header=True, header_style="bold")
|
|
1712
|
+
table.add_column("Manuscript", style="cyan")
|
|
1713
|
+
table.add_column("Target")
|
|
1714
|
+
table.add_column("Status")
|
|
1715
|
+
table.add_column("Progress", justify="right")
|
|
1716
|
+
|
|
1717
|
+
for d in deadlines:
|
|
1718
|
+
table.add_row(d["name"], d["target"], d["status"], f"{d['progress']}%")
|
|
1719
|
+
|
|
1720
|
+
console.print(table)
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
# Bibliography subcommands
|
|
1724
|
+
bib_app = typer.Typer(help="Bibliography operations")
|
|
1725
|
+
write_app.add_typer(bib_app, name="bib")
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
@bib_app.command("list")
|
|
1729
|
+
def bib_list(
|
|
1730
|
+
manuscript: Annotated[str, typer.Argument(help="Manuscript name")],
|
|
1731
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1732
|
+
) -> None:
|
|
1733
|
+
"""List bibliography entries for a manuscript."""
|
|
1734
|
+
import json
|
|
1735
|
+
|
|
1736
|
+
ms_manager = _get_manuscript_manager()
|
|
1737
|
+
bib_manager = _get_bibliography_manager()
|
|
1738
|
+
|
|
1739
|
+
manuscript_obj = ms_manager.get_manuscript(manuscript)
|
|
1740
|
+
if not manuscript_obj:
|
|
1741
|
+
console.print(f"[red]Manuscript not found:[/] {manuscript}")
|
|
1742
|
+
raise typer.Exit(1)
|
|
1743
|
+
|
|
1744
|
+
entries = bib_manager.get_manuscript_bibliography(Path(manuscript_obj.path))
|
|
1745
|
+
|
|
1746
|
+
if json_output:
|
|
1747
|
+
console.print(json.dumps([e.to_dict() for e in entries], indent=2))
|
|
1748
|
+
return
|
|
1749
|
+
|
|
1750
|
+
if not entries:
|
|
1751
|
+
console.print(f"[yellow]No bibliography entries found for:[/] {manuscript}")
|
|
1752
|
+
return
|
|
1753
|
+
|
|
1754
|
+
console.print(f"[dim]Found {len(entries)} entries for:[/] [cyan]{manuscript}[/]\n")
|
|
1755
|
+
|
|
1756
|
+
table = Table(show_header=True, header_style="bold")
|
|
1757
|
+
table.add_column("Key", style="cyan", width=20)
|
|
1758
|
+
table.add_column("Authors", width=25)
|
|
1759
|
+
table.add_column("Year", width=6)
|
|
1760
|
+
table.add_column("Title")
|
|
1761
|
+
|
|
1762
|
+
for entry in entries[:30]:
|
|
1763
|
+
authors = entry.authors[0] if entry.authors else ""
|
|
1764
|
+
if len(entry.authors) > 1:
|
|
1765
|
+
authors += " et al."
|
|
1766
|
+
title = entry.title[:40] + "..." if len(entry.title) > 40 else entry.title
|
|
1767
|
+
table.add_row(entry.key, authors, entry.year, title)
|
|
1768
|
+
|
|
1769
|
+
console.print(table)
|
|
1770
|
+
|
|
1771
|
+
if len(entries) > 30:
|
|
1772
|
+
console.print(f"\n[dim]... and {len(entries) - 30} more entries[/]")
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
@bib_app.command("search")
|
|
1776
|
+
def bib_search(
|
|
1777
|
+
manuscript: Annotated[str, typer.Argument(help="Manuscript name")],
|
|
1778
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
1779
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1780
|
+
) -> None:
|
|
1781
|
+
"""Search bibliography entries."""
|
|
1782
|
+
import json
|
|
1783
|
+
|
|
1784
|
+
ms_manager = _get_manuscript_manager()
|
|
1785
|
+
bib_manager = _get_bibliography_manager()
|
|
1786
|
+
|
|
1787
|
+
manuscript_obj = ms_manager.get_manuscript(manuscript)
|
|
1788
|
+
if not manuscript_obj:
|
|
1789
|
+
console.print(f"[red]Manuscript not found:[/] {manuscript}")
|
|
1790
|
+
raise typer.Exit(1)
|
|
1791
|
+
|
|
1792
|
+
entries = bib_manager.search_bibliography(Path(manuscript_obj.path), query)
|
|
1793
|
+
|
|
1794
|
+
if json_output:
|
|
1795
|
+
console.print(json.dumps([e.to_dict() for e in entries], indent=2))
|
|
1796
|
+
return
|
|
1797
|
+
|
|
1798
|
+
if not entries:
|
|
1799
|
+
console.print(f"[yellow]No entries found matching:[/] {query}")
|
|
1800
|
+
return
|
|
1801
|
+
|
|
1802
|
+
console.print(f"[dim]Found {len(entries)} entries matching:[/] [cyan]{query}[/]\n")
|
|
1803
|
+
|
|
1804
|
+
for entry in entries:
|
|
1805
|
+
console.print(f"[cyan]{entry.key}[/]")
|
|
1806
|
+
console.print(f" {entry.format_apa()}")
|
|
1807
|
+
console.print()
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
@bib_app.command("check")
|
|
1811
|
+
def bib_check(
|
|
1812
|
+
manuscript: Annotated[str, typer.Argument(help="Manuscript name")],
|
|
1813
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1814
|
+
) -> None:
|
|
1815
|
+
"""Check for missing or unused citations."""
|
|
1816
|
+
import json
|
|
1817
|
+
|
|
1818
|
+
ms_manager = _get_manuscript_manager()
|
|
1819
|
+
bib_manager = _get_bibliography_manager()
|
|
1820
|
+
|
|
1821
|
+
manuscript_obj = ms_manager.get_manuscript(manuscript)
|
|
1822
|
+
if not manuscript_obj:
|
|
1823
|
+
console.print(f"[red]Manuscript not found:[/] {manuscript}")
|
|
1824
|
+
raise typer.Exit(1)
|
|
1825
|
+
|
|
1826
|
+
result = bib_manager.check_citations(Path(manuscript_obj.path))
|
|
1827
|
+
|
|
1828
|
+
if json_output:
|
|
1829
|
+
console.print(json.dumps(result, indent=2))
|
|
1830
|
+
return
|
|
1831
|
+
|
|
1832
|
+
console.print(
|
|
1833
|
+
Panel.fit(
|
|
1834
|
+
f"[bold]Citation Check: {manuscript}[/]",
|
|
1835
|
+
border_style="blue",
|
|
1836
|
+
)
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
console.print(f"[dim]Citations in text:[/] {result['cited_count']}")
|
|
1840
|
+
console.print(f"[dim]Entries in bibliography:[/] {result['bibliography_count']}")
|
|
1841
|
+
|
|
1842
|
+
if result["all_good"]:
|
|
1843
|
+
console.print("\n[green]✓ All citations have bibliography entries[/]")
|
|
1844
|
+
else:
|
|
1845
|
+
if result["missing"]:
|
|
1846
|
+
console.print(f"\n[red]Missing from bibliography ({len(result['missing'])}):[/]")
|
|
1847
|
+
for key in result["missing"][:10]:
|
|
1848
|
+
console.print(f" • {key}")
|
|
1849
|
+
if len(result["missing"]) > 10:
|
|
1850
|
+
console.print(f" [dim]... and {len(result['missing']) - 10} more[/]")
|
|
1851
|
+
|
|
1852
|
+
if result["unused"]:
|
|
1853
|
+
console.print(f"\n[yellow]Unused entries ({len(result['unused'])}):[/]")
|
|
1854
|
+
for key in result["unused"][:10]:
|
|
1855
|
+
console.print(f" • [dim]{key}[/]")
|
|
1856
|
+
if len(result["unused"]) > 10:
|
|
1857
|
+
console.print(f" [dim]... and {len(result['unused']) - 10} more[/]")
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
@bib_app.command("zotero")
|
|
1861
|
+
def bib_zotero(
|
|
1862
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
1863
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 10,
|
|
1864
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1865
|
+
) -> None:
|
|
1866
|
+
"""Search Zotero for bibliography entries."""
|
|
1867
|
+
import json
|
|
1868
|
+
|
|
1869
|
+
bib_manager = _get_bibliography_manager()
|
|
1870
|
+
entries = bib_manager.search_zotero(query, limit=limit)
|
|
1871
|
+
|
|
1872
|
+
if json_output:
|
|
1873
|
+
console.print(json.dumps([e.to_dict() for e in entries], indent=2))
|
|
1874
|
+
return
|
|
1875
|
+
|
|
1876
|
+
if not entries:
|
|
1877
|
+
console.print(f"[yellow]No Zotero entries found matching:[/] {query}")
|
|
1878
|
+
return
|
|
1879
|
+
|
|
1880
|
+
console.print(f"[dim]Found {len(entries)} Zotero entries for:[/] [cyan]{query}[/]\n")
|
|
1881
|
+
|
|
1882
|
+
for entry in entries:
|
|
1883
|
+
console.print(f"[cyan]{entry.key}[/]")
|
|
1884
|
+
console.print(f" {entry.format_apa()}")
|
|
1885
|
+
if entry.doi:
|
|
1886
|
+
console.print(f" [dim]DOI:[/] {entry.doi}")
|
|
1887
|
+
console.print()
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1891
|
+
# Integration Commands (placeholder)
|
|
1892
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
@integrate_app.command("aiterm")
|
|
1896
|
+
def integrate_aiterm(
|
|
1897
|
+
action: Annotated[str, typer.Argument(help="Action: install, status, remove")] = "status",
|
|
1898
|
+
) -> None:
|
|
1899
|
+
"""Manage aiterm integration."""
|
|
1900
|
+
console.print(f"[dim]aiterm integration:[/] [cyan]{action}[/]")
|
|
1901
|
+
console.print("[yellow]Not yet implemented - Phase 5[/]")
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
@integrate_app.command("claude")
|
|
1905
|
+
def integrate_claude(
|
|
1906
|
+
action: Annotated[str, typer.Argument(help="Action: install, status, remove")] = "status",
|
|
1907
|
+
) -> None:
|
|
1908
|
+
"""Manage Claude Code plugin integration."""
|
|
1909
|
+
console.print(f"[dim]Claude Code plugin:[/] [cyan]{action}[/]")
|
|
1910
|
+
console.print("[yellow]Not yet implemented - Phase 5[/]")
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
if __name__ == "__main__":
|
|
1914
|
+
app()
|