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.
@@ -0,0 +1,385 @@
1
+ """Quarto integration for Nexus CLI."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+
11
+ @dataclass
12
+ class QuartoProject:
13
+ """A Quarto project."""
14
+
15
+ path: str
16
+ name: str
17
+ project_type: str = "default"
18
+ title: str = ""
19
+ output_dir: str = ""
20
+ formats: list[str] = None
21
+
22
+ def __post_init__(self):
23
+ if self.formats is None:
24
+ self.formats = []
25
+
26
+ def to_dict(self) -> dict:
27
+ """Convert to dictionary."""
28
+ return {
29
+ "path": self.path,
30
+ "name": self.name,
31
+ "project_type": self.project_type,
32
+ "title": self.title,
33
+ "output_dir": self.output_dir,
34
+ "formats": self.formats,
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class QuartoBuildResult:
40
+ """Result of a Quarto build."""
41
+
42
+ success: bool
43
+ output_path: str = ""
44
+ format: str = ""
45
+ error: str = ""
46
+ duration_seconds: float = 0.0
47
+
48
+ def to_dict(self) -> dict:
49
+ """Convert to dictionary."""
50
+ return {
51
+ "success": self.success,
52
+ "output_path": self.output_path,
53
+ "format": self.format,
54
+ "error": self.error,
55
+ "duration_seconds": self.duration_seconds,
56
+ }
57
+
58
+
59
+ class QuartoManager:
60
+ """Manage Quarto projects and builds."""
61
+
62
+ def __init__(self):
63
+ """Initialize Quarto manager."""
64
+ self._quarto_path = shutil.which("quarto")
65
+
66
+ def available(self) -> bool:
67
+ """Check if Quarto is available."""
68
+ return self._quarto_path is not None
69
+
70
+ def version(self) -> str | None:
71
+ """Get Quarto version."""
72
+ if not self.available():
73
+ return None
74
+
75
+ try:
76
+ result = subprocess.run(
77
+ [self._quarto_path, "--version"],
78
+ capture_output=True,
79
+ text=True,
80
+ timeout=10,
81
+ )
82
+ return result.stdout.strip()
83
+ except Exception:
84
+ return None
85
+
86
+ def is_quarto_project(self, path: Path) -> bool:
87
+ """Check if a directory is a Quarto project."""
88
+ path = Path(path).expanduser()
89
+ return (path / "_quarto.yml").exists() or (path / "_quarto.yaml").exists()
90
+
91
+ def load_project(self, path: Path) -> QuartoProject | None:
92
+ """Load a Quarto project configuration."""
93
+ path = Path(path).expanduser()
94
+
95
+ # Find config file
96
+ config_file = None
97
+ for name in ["_quarto.yml", "_quarto.yaml"]:
98
+ if (path / name).exists():
99
+ config_file = path / name
100
+ break
101
+
102
+ if not config_file:
103
+ return None
104
+
105
+ try:
106
+ with open(config_file) as f:
107
+ data = yaml.safe_load(f) or {}
108
+ except Exception:
109
+ return None
110
+
111
+ project = data.get("project", {})
112
+ book = data.get("book", {})
113
+ website = data.get("website", {})
114
+
115
+ # Get formats
116
+ formats = []
117
+ if "format" in data:
118
+ fmt = data["format"]
119
+ if isinstance(fmt, dict):
120
+ formats = list(fmt.keys())
121
+ elif isinstance(fmt, str):
122
+ formats = [fmt]
123
+
124
+ return QuartoProject(
125
+ path=str(path),
126
+ name=path.name,
127
+ project_type=project.get("type", "default"),
128
+ title=book.get("title") or website.get("title") or project.get("title", ""),
129
+ output_dir=project.get("output-dir", "_site"),
130
+ formats=formats,
131
+ )
132
+
133
+ def render(
134
+ self,
135
+ path: Path,
136
+ output_format: str | None = None,
137
+ to_file: str | None = None,
138
+ ) -> QuartoBuildResult:
139
+ """Render a Quarto document or project.
140
+
141
+ Args:
142
+ path: Path to .qmd file or project directory
143
+ output_format: Output format (html, pdf, revealjs, etc.)
144
+ to_file: Output filename
145
+
146
+ Returns:
147
+ QuartoBuildResult with success status
148
+ """
149
+ if not self.available():
150
+ return QuartoBuildResult(
151
+ success=False,
152
+ error="Quarto not installed. Run: brew install quarto",
153
+ )
154
+
155
+ path = Path(path).expanduser()
156
+ if not path.exists():
157
+ return QuartoBuildResult(
158
+ success=False,
159
+ error=f"Path not found: {path}",
160
+ )
161
+
162
+ import time
163
+
164
+ start = time.time()
165
+
166
+ # Build command
167
+ cmd = [self._quarto_path, "render", str(path)]
168
+
169
+ if output_format:
170
+ cmd.extend(["--to", output_format])
171
+
172
+ if to_file:
173
+ cmd.extend(["--output", to_file])
174
+
175
+ try:
176
+ result = subprocess.run(
177
+ cmd,
178
+ capture_output=True,
179
+ text=True,
180
+ timeout=300, # 5 minute timeout
181
+ cwd=path.parent if path.is_file() else path,
182
+ )
183
+
184
+ duration = time.time() - start
185
+
186
+ if result.returncode == 0:
187
+ # Try to find output file
188
+ output_path = ""
189
+ if path.is_file():
190
+ if output_format == "pdf":
191
+ output_path = str(path.with_suffix(".pdf"))
192
+ elif output_format in ("html", "revealjs"):
193
+ output_path = str(path.with_suffix(".html"))
194
+ else:
195
+ output_path = str(path.parent)
196
+ else:
197
+ project = self.load_project(path)
198
+ if project:
199
+ output_path = str(path / project.output_dir)
200
+
201
+ return QuartoBuildResult(
202
+ success=True,
203
+ output_path=output_path,
204
+ format=output_format or "default",
205
+ duration_seconds=duration,
206
+ )
207
+ else:
208
+ return QuartoBuildResult(
209
+ success=False,
210
+ error=result.stderr or result.stdout,
211
+ duration_seconds=duration,
212
+ )
213
+
214
+ except subprocess.TimeoutExpired:
215
+ return QuartoBuildResult(
216
+ success=False,
217
+ error="Build timed out after 5 minutes",
218
+ )
219
+ except Exception as e:
220
+ return QuartoBuildResult(
221
+ success=False,
222
+ error=str(e),
223
+ )
224
+
225
+ def preview(self, path: Path, port: int = 4200) -> dict:
226
+ """Start Quarto preview server.
227
+
228
+ Note: This starts a background process. Returns info about the server.
229
+
230
+ Args:
231
+ path: Path to .qmd file or project directory
232
+ port: Port number for preview server
233
+
234
+ Returns:
235
+ Dictionary with server info
236
+ """
237
+ if not self.available():
238
+ return {
239
+ "success": False,
240
+ "error": "Quarto not installed. Run: brew install quarto",
241
+ }
242
+
243
+ path = Path(path).expanduser()
244
+ if not path.exists():
245
+ return {
246
+ "success": False,
247
+ "error": f"Path not found: {path}",
248
+ }
249
+
250
+ cmd = [self._quarto_path, "preview", str(path), "--port", str(port)]
251
+
252
+ try:
253
+ # Start in background (non-blocking)
254
+ process = subprocess.Popen(
255
+ cmd,
256
+ stdout=subprocess.PIPE,
257
+ stderr=subprocess.PIPE,
258
+ cwd=path.parent if path.is_file() else path,
259
+ )
260
+
261
+ return {
262
+ "success": True,
263
+ "pid": process.pid,
264
+ "url": f"http://localhost:{port}",
265
+ "command": " ".join(cmd),
266
+ }
267
+
268
+ except Exception as e:
269
+ return {
270
+ "success": False,
271
+ "error": str(e),
272
+ }
273
+
274
+ def list_formats(self, path: Path) -> list[str]:
275
+ """List available output formats for a Quarto project."""
276
+ project = self.load_project(path)
277
+ if project:
278
+ return project.formats
279
+
280
+ # Default formats if not a project
281
+ return ["html", "pdf", "docx"]
282
+
283
+ def check_dependencies(self, path: Path) -> dict:
284
+ """Check if all dependencies are available for a project.
285
+
286
+ Args:
287
+ path: Path to project
288
+
289
+ Returns:
290
+ Dictionary with dependency status
291
+ """
292
+ path = Path(path).expanduser()
293
+ deps = {
294
+ "quarto": self.available(),
295
+ "quarto_version": self.version(),
296
+ }
297
+
298
+ # Check for format-specific dependencies
299
+ project = self.load_project(path)
300
+ if project:
301
+ # PDF requires tinytex or latex
302
+ if "pdf" in project.formats or "beamer" in project.formats:
303
+ deps["latex"] = shutil.which("pdflatex") is not None
304
+ deps["tinytex"] = shutil.which("tlmgr") is not None
305
+
306
+ # Typst requires typst
307
+ if "typst" in project.formats:
308
+ deps["typst"] = shutil.which("typst") is not None
309
+
310
+ return deps
311
+
312
+ def clean(self, path: Path) -> dict:
313
+ """Clean Quarto build artifacts.
314
+
315
+ Args:
316
+ path: Path to project
317
+
318
+ Returns:
319
+ Dictionary with cleaned items
320
+ """
321
+ path = Path(path).expanduser()
322
+ cleaned = []
323
+
324
+ # Common build directories to clean
325
+ build_dirs = ["_site", "_book", "_freeze", "_output"]
326
+
327
+ for dirname in build_dirs:
328
+ dir_path = path / dirname
329
+ if dir_path.exists() and dir_path.is_dir():
330
+ try:
331
+ shutil.rmtree(dir_path)
332
+ cleaned.append(str(dir_path))
333
+ except Exception:
334
+ pass
335
+
336
+ # Clean .quarto directory
337
+ quarto_dir = path / ".quarto"
338
+ if quarto_dir.exists():
339
+ try:
340
+ shutil.rmtree(quarto_dir)
341
+ cleaned.append(str(quarto_dir))
342
+ except Exception:
343
+ pass
344
+
345
+ return {
346
+ "success": True,
347
+ "cleaned": cleaned,
348
+ "count": len(cleaned),
349
+ }
350
+
351
+ def export(
352
+ self,
353
+ path: Path,
354
+ output_format: str,
355
+ output_dir: Path | None = None,
356
+ ) -> QuartoBuildResult:
357
+ """Export a document to a specific format.
358
+
359
+ Wrapper around render with better output handling.
360
+
361
+ Args:
362
+ path: Path to .qmd file
363
+ output_format: Target format
364
+ output_dir: Output directory (optional)
365
+
366
+ Returns:
367
+ QuartoBuildResult
368
+ """
369
+ path = Path(path).expanduser()
370
+
371
+ if output_dir:
372
+ output_dir = Path(output_dir).expanduser()
373
+ output_dir.mkdir(parents=True, exist_ok=True)
374
+
375
+ result = self.render(path, output_format=output_format)
376
+
377
+ # Move output to target directory if specified
378
+ if result.success and output_dir and result.output_path:
379
+ output_path = Path(result.output_path)
380
+ if output_path.exists() and output_path.is_file():
381
+ target = output_dir / output_path.name
382
+ shutil.copy2(output_path, target)
383
+ result.output_path = str(target)
384
+
385
+ return result
File without changes
nexus/utils/config.py ADDED
@@ -0,0 +1,157 @@
1
+ """Configuration management for Nexus CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+ from pydantic import BaseModel
7
+ from pydantic_settings import BaseSettings
8
+
9
+
10
+ class ZoteroConfig(BaseModel):
11
+ """Zotero configuration."""
12
+
13
+ database: str = "~/Zotero/zotero.sqlite"
14
+ storage: str = "~/Zotero/storage"
15
+
16
+
17
+ class VaultConfig(BaseModel):
18
+ """Obsidian vault configuration."""
19
+
20
+ path: str = "~/Obsidian/Nexus"
21
+ templates: str = "~/Obsidian/Nexus/_SYSTEM/templates"
22
+
23
+
24
+ class PDFConfig(BaseModel):
25
+ """PDF configuration."""
26
+
27
+ directories: list[str] = [
28
+ "~/Documents/Research/PDFs",
29
+ "~/Documents/Teaching/PDFs",
30
+ ]
31
+ index: str = "~/.nexus/pdf-index.db"
32
+
33
+
34
+ class TeachingConfig(BaseModel):
35
+ """Teaching configuration."""
36
+
37
+ courses_dir: str = "~/projects/teaching"
38
+ materials_dir: str = "~/Documents/Teaching"
39
+
40
+
41
+ class WritingConfig(BaseModel):
42
+ """Writing configuration."""
43
+
44
+ manuscripts_dir: str = "~/projects/research"
45
+ templates_dir: str = "~/.nexus/templates"
46
+
47
+
48
+ class RConfig(BaseModel):
49
+ """R configuration."""
50
+
51
+ executable: str = "/usr/local/bin/R"
52
+ packages_dir: str = "~/projects/r-packages"
53
+
54
+
55
+ class OutputConfig(BaseModel):
56
+ """Output configuration."""
57
+
58
+ format: str = "rich" # rich, json, plain
59
+ color: bool = True
60
+ pager: bool = False
61
+
62
+
63
+ class IntegrationsConfig(BaseModel):
64
+ """Integration configuration."""
65
+
66
+ aiterm: bool = True
67
+ obsidian: bool = True
68
+ claude_plugin: bool = True
69
+
70
+
71
+ class Config(BaseSettings):
72
+ """Main Nexus configuration."""
73
+
74
+ zotero: ZoteroConfig = ZoteroConfig()
75
+ vault: VaultConfig = VaultConfig()
76
+ pdf: PDFConfig = PDFConfig()
77
+ teaching: TeachingConfig = TeachingConfig()
78
+ writing: WritingConfig = WritingConfig()
79
+ r: RConfig = RConfig()
80
+ output: OutputConfig = OutputConfig()
81
+ integrations: IntegrationsConfig = IntegrationsConfig()
82
+
83
+ class Config:
84
+ env_prefix = "NEXUS_"
85
+
86
+
87
+ def get_config_path() -> Path:
88
+ """Get the configuration file path."""
89
+ return Path.home() / ".config" / "nexus" / "config.yaml"
90
+
91
+
92
+ def load_config_from_file(path: Path) -> dict:
93
+ """Load configuration from a YAML file."""
94
+ if not path.exists():
95
+ return {}
96
+ with open(path) as f:
97
+ return yaml.safe_load(f) or {}
98
+
99
+
100
+ def get_config() -> Config:
101
+ """Get the current configuration, merging defaults with file config."""
102
+ config_path = get_config_path()
103
+ file_config = load_config_from_file(config_path)
104
+
105
+ # Create config with defaults, then update with file values
106
+ config = Config()
107
+
108
+ if file_config:
109
+ # Update nested configs
110
+ if "zotero" in file_config:
111
+ config.zotero = ZoteroConfig(**file_config["zotero"])
112
+ if "vault" in file_config:
113
+ config.vault = VaultConfig(**file_config["vault"])
114
+ if "pdf" in file_config:
115
+ config.pdf = PDFConfig(**file_config["pdf"])
116
+ if "teaching" in file_config:
117
+ config.teaching = TeachingConfig(**file_config["teaching"])
118
+ if "writing" in file_config:
119
+ config.writing = WritingConfig(**file_config["writing"])
120
+ if "r" in file_config:
121
+ config.r = RConfig(**file_config["r"])
122
+ if "output" in file_config:
123
+ config.output = OutputConfig(**file_config["output"])
124
+ if "integrations" in file_config:
125
+ config.integrations = IntegrationsConfig(**file_config["integrations"])
126
+
127
+ return config
128
+
129
+
130
+ def save_config(config: Config, path: Path | None = None) -> None:
131
+ """Save configuration to file."""
132
+ if path is None:
133
+ path = get_config_path()
134
+
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+
137
+ config_dict = {
138
+ "zotero": config.zotero.model_dump(),
139
+ "vault": config.vault.model_dump(),
140
+ "pdf": config.pdf.model_dump(),
141
+ "teaching": config.teaching.model_dump(),
142
+ "writing": config.writing.model_dump(),
143
+ "r": config.r.model_dump(),
144
+ "output": config.output.model_dump(),
145
+ "integrations": config.integrations.model_dump(),
146
+ }
147
+
148
+ with open(path, "w") as f:
149
+ yaml.dump(config_dict, f, default_flow_style=False, sort_keys=False)
150
+
151
+
152
+ def init_config() -> Path:
153
+ """Initialize default configuration file if it doesn't exist."""
154
+ config_path = get_config_path()
155
+ if not config_path.exists():
156
+ save_config(Config())
157
+ return config_path
@@ -0,0 +1,12 @@
1
+ """Writing domain - Manuscripts, bibliography, LaTeX."""
2
+
3
+ from nexus.writing.bibliography import BibEntry, BibliographyManager
4
+ from nexus.writing.manuscript import Manuscript, ManuscriptManager, ManuscriptStatus
5
+
6
+ __all__ = [
7
+ "Manuscript",
8
+ "ManuscriptManager",
9
+ "ManuscriptStatus",
10
+ "BibEntry",
11
+ "BibliographyManager",
12
+ ]