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/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()