nia-sync 0.1.7__py3-none-any.whl → 0.1.9__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.
main.py CHANGED
@@ -13,18 +13,39 @@ Usage:
13
13
  """
14
14
  import os
15
15
  import json
16
+ import time
16
17
  import typer
17
18
  import httpx
18
19
  import logging
20
+ import webbrowser
21
+ from typing import Any
22
+ from importlib import metadata as importlib_metadata
19
23
  from rich.console import Console
24
+ from rich.live import Live
25
+ from rich.markdown import Markdown
20
26
  from rich.panel import Panel
21
27
  from rich.table import Table
22
28
 
23
29
  from auth import login as do_login, logout as do_logout, is_authenticated, get_api_key
24
- from config import get_sources, add_source, remove_source, enable_source_sync, NIA_SYNC_DIR, find_folder_path, API_BASE_URL, get_api_key
30
+ from api_client import get_sources, add_source, remove_source, enable_source_sync, request_json
31
+ from ui import print_logo, print_header, spinner, success, error, warning, info, dim, NIA_LOGO_MINI
32
+ from config import (
33
+ NIA_SYNC_DIR,
34
+ find_folder_path,
35
+ get_api_base_url,
36
+ get_watch_dirs,
37
+ add_watch_dir,
38
+ remove_watch_dir,
39
+ get_ignore_patterns,
40
+ add_ignore_pattern,
41
+ remove_ignore_pattern,
42
+ get_config_value,
43
+ set_config_value,
44
+ )
25
45
  from sync import sync_all_sources
26
46
  from extractor import (
27
47
  detect_source_type,
48
+ extract_incremental,
28
49
  TYPE_FOLDER,
29
50
  TYPE_TELEGRAM,
30
51
  TYPE_GENERIC_DB,
@@ -39,8 +60,15 @@ app = typer.Typer(
39
60
  help="[cyan]Nia Sync Engine[/cyan] — Keep local folders in sync with Nia cloud",
40
61
  no_args_is_help=False,
41
62
  rich_markup_mode="rich",
42
- epilog="[dim]Quick start: [cyan]nia login[/cyan] → [cyan]nia status[/cyan] → [cyan]nia[/cyan][/dim]",
63
+ epilog=f"[dim]Quick start: [cyan]nia login[/cyan] → [cyan]nia status[/cyan] → [cyan]nia[/cyan][/dim]",
43
64
  )
65
+ config_app = typer.Typer(help="Manage CLI configuration")
66
+ ignore_app = typer.Typer(help="Manage local ignore patterns")
67
+ watch_app = typer.Typer(help="Manage watched directories")
68
+
69
+ app.add_typer(config_app, name="config")
70
+ app.add_typer(ignore_app, name="ignore")
71
+ app.add_typer(watch_app, name="watch")
44
72
  console = Console()
45
73
  logger = logging.getLogger(__name__)
46
74
 
@@ -57,23 +85,22 @@ def main(ctx: typer.Context):
57
85
  def login():
58
86
  """Authenticate with Nia using browser-based login."""
59
87
  if is_authenticated():
60
- console.print("[yellow]Already logged in.[/yellow]")
61
- console.print(f"Config stored at: {NIA_SYNC_DIR}")
88
+ warning("Already logged in.")
89
+ dim(f"Config stored at: {NIA_SYNC_DIR}")
62
90
  _check_local_sources()
63
91
  return
64
92
 
65
- console.print(Panel.fit(
66
- "[bold cyan]Nia[/bold cyan]\n\n"
67
- "Opening browser to authenticate...",
68
- border_style="cyan",
69
- ))
93
+ print_logo(compact=True)
94
+ console.print("[dim]Opening browser to authenticate...[/dim]\n")
70
95
 
71
- success = do_login()
72
- if success:
73
- console.print("[green]Successfully logged in![/green]")
96
+ with spinner("login", "Waiting for authentication..."):
97
+ login_success = do_login()
98
+
99
+ if login_success:
100
+ success("Successfully logged in!")
74
101
  _check_local_sources()
75
102
  else:
76
- console.print("[red]Login failed. Please try again.[/red]")
103
+ error("Login failed. Please try again.")
77
104
  raise typer.Exit(1)
78
105
 
79
106
 
@@ -149,27 +176,30 @@ def upgrade():
149
176
  import subprocess
150
177
  import sys
151
178
 
152
- console.print("[dim]Checking for updates...[/dim]")
153
-
154
- try:
155
- result = subprocess.run(
156
- [sys.executable, "-m", "pip", "install", "--upgrade", "nia-sync"],
157
- capture_output=True,
158
- text=True,
159
- )
160
-
161
- if "Successfully installed" in result.stdout:
162
- console.print("[green]✓ Upgraded to latest version[/green]")
163
- elif "Requirement already satisfied" in result.stdout:
164
- console.print("[green]✓ Already on latest version[/green]")
179
+ with spinner("upgrade", "Checking for updates...") as status:
180
+ try:
181
+ result = subprocess.run(
182
+ [sys.executable, "-m", "pip", "install", "--upgrade", "nia-sync"],
183
+ capture_output=True,
184
+ text=True,
185
+ )
186
+ except Exception as e:
187
+ pass
165
188
  else:
166
- console.print("[yellow]No updates available[/yellow]")
189
+ e = None
167
190
 
168
- except Exception as e:
169
- console.print(f"[red]Upgrade failed: {e}[/red]")
170
- console.print("[dim]Try manually: pip install --upgrade nia-sync[/dim]")
191
+ if e:
192
+ error(f"Upgrade failed: {e}")
193
+ dim("Try manually: pip install --upgrade nia-sync")
171
194
  raise typer.Exit(1)
172
195
 
196
+ if "Successfully installed" in result.stdout:
197
+ success("Upgraded to latest version")
198
+ elif "Requirement already satisfied" in result.stdout:
199
+ success("Already on latest version")
200
+ else:
201
+ warning("No updates available")
202
+
173
203
 
174
204
  @app.command()
175
205
  def status(
@@ -180,10 +210,11 @@ def status(
180
210
  if json_output:
181
211
  print(json.dumps({"error": "Not logged in"}))
182
212
  else:
183
- console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
213
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
184
214
  raise typer.Exit(1)
185
215
 
186
- sources = get_sources()
216
+ with spinner("fetch", "Loading sources..."):
217
+ sources = get_sources()
187
218
 
188
219
  if json_output:
189
220
  output = []
@@ -218,7 +249,7 @@ def status(
218
249
  print(json.dumps({"sources": output}, indent=2))
219
250
  return
220
251
 
221
- console.print("[bold cyan]Nia Sync Status[/bold cyan]\n")
252
+ print_header("Status", "Sync status and configured sources")
222
253
 
223
254
  if not sources:
224
255
  console.print("[yellow]No sources configured.[/yellow]")
@@ -274,35 +305,36 @@ def status(
274
305
  def sync():
275
306
  """Run a one-time sync (then exit)."""
276
307
  if not is_authenticated():
277
- console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
308
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
278
309
  raise typer.Exit(1)
279
310
 
280
- console.print("[bold]Starting sync...[/bold]")
281
-
282
- sources = get_sources()
311
+ with spinner("fetch", "Loading sources..."):
312
+ sources = get_sources()
313
+
283
314
  if not sources:
284
- console.print("[yellow]No sources configured.[/yellow]")
285
- console.print("Add sources in the Nia web app first.")
315
+ warning("No sources configured.")
316
+ dim("Add sources in the Nia web app first.")
286
317
  return
287
318
 
288
- results = sync_all_sources(sources)
319
+ with spinner("sync", "Syncing files..."):
320
+ results = sync_all_sources(sources)
289
321
 
290
322
  for result in results:
291
323
  path = result.get("path", "unknown")
292
- status = result.get("status", "unknown")
293
- if status == "success":
324
+ sync_status = result.get("status", "unknown")
325
+ if sync_status == "success":
294
326
  added = result.get("added", 0)
295
- console.print(f"[green]✓ {path}[/green] - {added} items synced")
327
+ success(f"{path} - {added} items synced")
296
328
  else:
297
- error = result.get("error", "unknown error")
298
- console.print(f"[red]✗ {path}[/red] - {error}")
329
+ err = result.get("error", "unknown error")
330
+ error(f"{path} - {err}")
299
331
 
300
332
 
301
333
  @app.command()
302
334
  def add(path: str = typer.Argument(..., help="Path to sync (folder or database)")):
303
335
  """Add a new source to sync."""
304
336
  if not is_authenticated():
305
- console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
337
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
306
338
  raise typer.Exit(1)
307
339
 
308
340
  # Expand path
@@ -310,24 +342,25 @@ def add(path: str = typer.Argument(..., help="Path to sync (folder or database)"
310
342
 
311
343
  # Check if path exists
312
344
  if not os.path.exists(expanded_path):
313
- console.print(f"[red]Path does not exist: {expanded_path}[/red]")
345
+ error(f"Path does not exist: {expanded_path}")
314
346
  raise typer.Exit(1)
315
347
 
316
348
  # Detect source type
317
349
  detected_type = detect_source_type(expanded_path)
318
- console.print(f"Detected type: [cyan]{detected_type}[/cyan]")
350
+ info(f"Detected type: [cyan]{detected_type}[/cyan]")
319
351
 
320
352
  # Add source via API
321
- result = add_source(path, detected_type)
353
+ with spinner("connect", "Adding source..."):
354
+ result = add_source(path, detected_type)
322
355
 
323
356
  if result:
324
357
  folder_id = result.get('local_folder_id', '')
325
358
  short_id = folder_id[:8] if folder_id else 'unknown'
326
- console.print(f"[green]✓ Added:[/green] {result.get('display_name', path)}")
327
- console.print(f"[dim]ID: {short_id}[/dim]")
328
- console.print("\n[dim]Run [cyan]nia[/cyan] to start syncing.[/dim]")
359
+ success(f"Added: {result.get('display_name', path)}")
360
+ dim(f"ID: {short_id}")
361
+ dim("Run [cyan]nia[/cyan] to start syncing.")
329
362
  else:
330
- console.print("[red]Failed to add source.[/red]")
363
+ error("Failed to add source.")
331
364
  raise typer.Exit(1)
332
365
 
333
366
 
@@ -335,28 +368,30 @@ def add(path: str = typer.Argument(..., help="Path to sync (folder or database)"
335
368
  def remove(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
336
369
  """Remove a source from syncing."""
337
370
  if not is_authenticated():
338
- console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
371
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
339
372
  raise typer.Exit(1)
340
373
 
341
374
  # Expand partial ID to full UUID
342
- sources = get_sources()
375
+ with spinner("fetch", "Loading sources..."):
376
+ sources = get_sources()
343
377
  matching = [s for s in sources if s.get("local_folder_id", "").startswith(source_id)]
344
378
 
345
379
  if not matching:
346
- console.print(f"[red]Source not found: {source_id}[/red]")
347
- console.print("[dim]Run [cyan]nia status[/cyan] to see sources.[/dim]")
380
+ error(f"Source not found: {source_id}")
381
+ dim("Run [cyan]nia status[/cyan] to see sources.")
348
382
  raise typer.Exit(1)
349
383
 
350
384
  source = matching[0]
351
385
  full_id = source["local_folder_id"]
352
386
  display_name = source.get("display_name", source_id)
353
387
 
354
- success = remove_source(full_id)
388
+ with spinner("process", "Removing source..."):
389
+ remove_success = remove_source(full_id)
355
390
 
356
- if success:
357
- console.print(f"[green]✓ Removed:[/green] {display_name}")
391
+ if remove_success:
392
+ success(f"Removed: {display_name}")
358
393
  else:
359
- console.print("[red]Failed to remove. Check the ID with [cyan]nia status[/cyan][/red]")
394
+ error("Failed to remove. Check the ID with [cyan]nia status[/cyan]")
360
395
  raise typer.Exit(1)
361
396
 
362
397
 
@@ -367,34 +402,698 @@ def link(
367
402
  ):
368
403
  """Link a cloud source to a local folder."""
369
404
  if not is_authenticated():
370
- console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
405
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
371
406
  raise typer.Exit(1)
372
407
 
373
408
  expanded_path = os.path.expanduser(path)
374
409
 
375
410
  if not os.path.exists(expanded_path):
376
- console.print(f"[red]Path not found: {expanded_path}[/red]")
411
+ error(f"Path not found: {expanded_path}")
377
412
  raise typer.Exit(1)
378
413
 
379
- sources = get_sources()
414
+ with spinner("fetch", "Loading sources..."):
415
+ sources = get_sources()
380
416
  matching = [s for s in sources if s.get("local_folder_id", "").startswith(source_id)]
381
417
 
382
418
  if not matching:
383
- console.print(f"[red]Source not found: {source_id}[/red]")
384
- console.print("[dim]Run [cyan]nia status[/cyan] to see sources.[/dim]")
419
+ error(f"Source not found: {source_id}")
420
+ dim("Run [cyan]nia status[/cyan] to see sources.")
385
421
  raise typer.Exit(1)
386
422
 
387
423
  source = matching[0]
388
424
  full_id = source["local_folder_id"]
389
425
 
390
- if enable_source_sync(full_id, expanded_path):
391
- console.print(f"[green]✓ Linked:[/green] {source.get('display_name', source_id)} → {expanded_path}")
392
- console.print("\n[dim]Run [cyan]nia[/cyan] to start syncing.[/dim]")
426
+ with spinner("connect", "Linking source..."):
427
+ link_success = enable_source_sync(full_id, expanded_path)
428
+
429
+ if link_success:
430
+ success(f"Linked: {source.get('display_name', source_id)} → {expanded_path}")
431
+ dim("Run [cyan]nia[/cyan] to start syncing.")
393
432
  else:
394
- console.print("[red]Failed to link.[/red]")
433
+ error("Failed to link.")
395
434
  raise typer.Exit(1)
396
435
 
397
436
 
437
+ def _resolve_source_prefix(source_id: str) -> tuple[dict | None, list[dict]]:
438
+ sources = get_sources()
439
+ matching = [s for s in sources if s.get("local_folder_id", "").startswith(source_id)]
440
+ if not matching:
441
+ return None, []
442
+ exact = [s for s in matching if s.get("local_folder_id") == source_id]
443
+ if exact:
444
+ return exact[0], matching
445
+ return matching[0], matching
446
+
447
+
448
+ def _require_source(source_id: str) -> dict:
449
+ source, matching = _resolve_source_prefix(source_id)
450
+ if not source:
451
+ console.print(f"[red]Source not found: {source_id}[/red]")
452
+ console.print("[dim]Run [cyan]nia status[/cyan] to see sources.[/dim]")
453
+ raise typer.Exit(1)
454
+ if len(matching) > 1 and source.get("local_folder_id") != source_id:
455
+ console.print(f"[red]Ambiguous source ID prefix: {source_id}[/red]")
456
+ for match in matching[:5]:
457
+ console.print(f" • {match.get('local_folder_id')[:8]} {match.get('display_name')}")
458
+ raise typer.Exit(1)
459
+ return source
460
+
461
+
462
+ def _resolve_source_identifier(identifier: str, sources: list[dict]) -> dict:
463
+ if not identifier:
464
+ raise typer.Exit(1)
465
+ matching = [s for s in sources if s.get("local_folder_id", "").startswith(identifier)]
466
+ if matching:
467
+ if len(matching) > 1 and matching[0].get("local_folder_id") != identifier:
468
+ console.print(f"[red]Ambiguous source ID prefix: {identifier}[/red]")
469
+ for match in matching[:5]:
470
+ console.print(f" • {match.get('local_folder_id')[:8]} {match.get('display_name')}")
471
+ raise typer.Exit(1)
472
+ return matching[0]
473
+ name_matches = [
474
+ s for s in sources
475
+ if str(s.get("display_name", "")).lower() == identifier.lower()
476
+ ]
477
+ if name_matches:
478
+ if len(name_matches) > 1:
479
+ console.print(f"[red]Ambiguous source name: {identifier}[/red]")
480
+ for match in name_matches[:5]:
481
+ console.print(f" • {match.get('local_folder_id')[:8]} {match.get('display_name')}")
482
+ raise typer.Exit(1)
483
+ return name_matches[0]
484
+ console.print(f"[red]Source not found: {identifier}[/red]")
485
+ console.print("[dim]Run [cyan]nia status[/cyan] to see sources.[/dim]")
486
+ raise typer.Exit(1)
487
+
488
+
489
+ @app.command()
490
+ def search(
491
+ query: str = typer.Argument(..., help="Search query"),
492
+ local_folder: list[str] = typer.Option(None, "--local-folder", "-l", help="Local folder ID(s) to search"),
493
+ fast: bool = typer.Option(False, "--fast/--full", help="Fast mode (skip LLM synthesis)"),
494
+ stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream AI response"),
495
+ markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render response as Markdown"),
496
+ show_sources: bool = typer.Option(False, "--sources", help="Show source snippets"),
497
+ json_output: bool = typer.Option(False, "--json", "-j", help="Output raw JSON"),
498
+ limit: int = typer.Option(10, "--limit", "-n", help="Max results to display"),
499
+ ):
500
+ """Search your indexed sources from the CLI."""
501
+ if not is_authenticated():
502
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
503
+ raise typer.Exit(1)
504
+
505
+ # Auto-disable stream and markdown when JSON output is requested
506
+ if json_output:
507
+ stream = False
508
+ markdown = False
509
+
510
+ resolved_local_folders: list[str] = []
511
+ if local_folder:
512
+ with spinner("fetch", "Loading sources..."):
513
+ sources = get_sources()
514
+ for identifier in local_folder:
515
+ resolved = _resolve_source_identifier(identifier, sources)
516
+ resolved_local_folders.append(resolved.get("local_folder_id"))
517
+
518
+ payload = {
519
+ "messages": [{"role": "user", "content": query}],
520
+ "local_folders": resolved_local_folders or [],
521
+ "search_mode": "unified",
522
+ "stream": stream,
523
+ "fast_mode": False if stream else fast,
524
+ "include_sources": bool(show_sources),
525
+ }
526
+ if stream:
527
+ api_key = get_api_key()
528
+ url = f"{get_api_base_url().rstrip('/')}/v2/search/query"
529
+ sources_payload: list[dict] = []
530
+ full_content = ""
531
+ with httpx.Client(timeout=httpx.Timeout(600, connect=10)) as client:
532
+ with client.stream(
533
+ "POST",
534
+ url,
535
+ headers={"Authorization": f"Bearer {api_key}"},
536
+ json=payload,
537
+ ) as response:
538
+ if response.status_code != 200:
539
+ console.print(f"[red]API error: {response.text}[/red]")
540
+ raise typer.Exit(1)
541
+ if markdown:
542
+ with Live(Markdown(""), console=console, refresh_per_second=8) as live:
543
+ for line in response.iter_lines():
544
+ if not line or not line.startswith("data: "):
545
+ continue
546
+ data_str = line[6:]
547
+ if data_str.strip() == "[DONE]":
548
+ break
549
+ try:
550
+ event = json.loads(data_str)
551
+ except json.JSONDecodeError:
552
+ continue
553
+ if "error" in event:
554
+ console.print(f"[red]{event['error']}[/red]")
555
+ raise typer.Exit(1)
556
+ if "content" in event:
557
+ full_content += event["content"]
558
+ live.update(Markdown(full_content))
559
+ if "sources" in event:
560
+ sources_payload = event["sources"] or []
561
+ else:
562
+ for line in response.iter_lines():
563
+ if not line or not line.startswith("data: "):
564
+ continue
565
+ data_str = line[6:]
566
+ if data_str.strip() == "[DONE]":
567
+ break
568
+ try:
569
+ event = json.loads(data_str)
570
+ except json.JSONDecodeError:
571
+ continue
572
+ if "error" in event:
573
+ console.print(f"[red]{event['error']}[/red]")
574
+ raise typer.Exit(1)
575
+ if "content" in event:
576
+ print(event["content"], end="", flush=True)
577
+ if "sources" in event:
578
+ sources_payload = event["sources"] or []
579
+ print()
580
+ if show_sources and sources_payload:
581
+ for src in sources_payload[:limit]:
582
+ metadata = src.get("metadata") or {}
583
+ identifier = (
584
+ metadata.get("file_path")
585
+ or metadata.get("path")
586
+ or metadata.get("source")
587
+ or metadata.get("identifier")
588
+ or metadata.get("local_folder_name")
589
+ or "unknown"
590
+ )
591
+ snippet = (src.get("content") or "").strip().replace("\n", " ")
592
+ console.print(f"- {identifier}: {snippet[:120]}")
593
+ return
594
+
595
+ data, error = request_json("POST", "/v2/search/query", payload=payload)
596
+ if error:
597
+ console.print(f"[red]{error}[/red]")
598
+ raise typer.Exit(1)
599
+
600
+ if json_output:
601
+ print(json.dumps(data, indent=2))
602
+ return
603
+
604
+ if isinstance(data, dict):
605
+ content = (
606
+ data.get("content")
607
+ or data.get("answer")
608
+ or data.get("response")
609
+ or ""
610
+ )
611
+ if isinstance(content, str) and content.strip():
612
+ if markdown:
613
+ console.print(Markdown(content.strip()))
614
+ else:
615
+ console.print(content.strip())
616
+ else:
617
+ console.print("[yellow]No response content.[/yellow]")
618
+
619
+ if show_sources:
620
+ sources = data.get("sources") or data.get("results") or []
621
+ if isinstance(sources, list) and sources:
622
+ for src in sources[:limit]:
623
+ metadata = src.get("metadata") or {}
624
+ identifier = (
625
+ metadata.get("file_path")
626
+ or metadata.get("path")
627
+ or metadata.get("source")
628
+ or metadata.get("identifier")
629
+ or metadata.get("local_folder_name")
630
+ or "unknown"
631
+ )
632
+ snippet = (src.get("content") or src.get("text") or "").strip().replace("\n", " ")
633
+ console.print(f"- {identifier}: {snippet[:120]}")
634
+ else:
635
+ console.print("[yellow]Unexpected search response.[/yellow]")
636
+
637
+
638
+ @app.command()
639
+ def whoami():
640
+ """Show the current authenticated user."""
641
+ if not is_authenticated():
642
+ console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
643
+ raise typer.Exit(1)
644
+ data, error = request_json("GET", "/v2/daemon/whoami")
645
+ if error:
646
+ console.print(f"[red]{error}[/red]")
647
+ raise typer.Exit(1)
648
+ print(json.dumps(data, indent=2))
649
+
650
+
651
+ @app.command(name="open")
652
+ def open_web(
653
+ target: str = typer.Argument("dashboard", help="dashboard|activity|api-keys|local-sync|billing|organization|docs|<source_id>"),
654
+ ):
655
+ """Open the Nia web app."""
656
+ base_url = os.getenv("NIA_WEB_URL", "https://app.trynia.ai").rstrip("/")
657
+ routes = {
658
+ "dashboard": "/overview",
659
+ "overview": "/overview",
660
+ "activity": "/activity",
661
+ "api-keys": "/api-keys",
662
+ "local-sync": "/settings/local-sync",
663
+ "billing": "/billing",
664
+ "organization": "/settings/organization",
665
+ }
666
+ if target == "docs":
667
+ url = "https://docs.trynia.ai/welcome"
668
+ elif target in routes:
669
+ url = f"{base_url}{routes[target]}"
670
+ else:
671
+ url = f"{base_url}/local-folders/{target}"
672
+ webbrowser.open(url)
673
+ console.print(f"[green]✓ Opened[/green] {url}")
674
+
675
+
676
+ @app.command()
677
+ def info(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
678
+ """Show detailed info for a source."""
679
+ if not is_authenticated():
680
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
681
+ raise typer.Exit(1)
682
+ source = _require_source(source_id)
683
+ local_folder_id = source.get("local_folder_id")
684
+
685
+ with spinner("fetch", "Loading source details..."):
686
+ folder, folder_error = request_json("GET", f"/v2/local-folders/{local_folder_id}")
687
+ if folder_error:
688
+ error(folder_error)
689
+ raise typer.Exit(1)
690
+
691
+ with spinner("fetch", "Loading sync info..."):
692
+ sync_info, sync_error = request_json("GET", f"/v2/local-folders/{local_folder_id}/continuous-sync")
693
+ if sync_error:
694
+ error(sync_error)
695
+ raise typer.Exit(1)
696
+
697
+ console.print(f"[bold cyan]{folder.get('display_name', 'Local Folder')}[/bold cyan]")
698
+ table = Table(show_header=False)
699
+ table.add_row("ID", str(local_folder_id))
700
+ table.add_row("Status", str(folder.get("status")))
701
+ table.add_row("Type", str(folder.get("db_type") or "folder"))
702
+ table.add_row("Chunk count", str(folder.get("chunk_count", 0)))
703
+ table.add_row("File count", str(folder.get("file_count", 0)))
704
+ table.add_row("Last sync", str(sync_info.get("last_synced_at")))
705
+ table.add_row("Last error", str(sync_info.get("last_sync_error") or folder.get("continuous_sync", {}).get("last_sync_error", "")))
706
+ console.print(table)
707
+
708
+
709
+ @app.command()
710
+ def resync(
711
+ source_id: str | None = typer.Argument(None, help="Source ID (from 'nia status')"),
712
+ all_sources: bool = typer.Option(False, "--all", help="Resync all sources"),
713
+ ):
714
+ """Force a full resync by resetting the cursor."""
715
+ if not is_authenticated():
716
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
717
+ raise typer.Exit(1)
718
+
719
+ if all_sources:
720
+ with spinner("fetch", "Loading sources..."):
721
+ sources = get_sources()
722
+ if not sources:
723
+ warning("No sources configured.")
724
+ return
725
+ for src in sources:
726
+ local_folder_id = src.get("local_folder_id")
727
+ if not local_folder_id:
728
+ continue
729
+ _, resync_error = request_json("POST", f"/v2/daemon/sources/{local_folder_id}/resync")
730
+ if resync_error:
731
+ error(f"{src.get('display_name', local_folder_id[:8])}: {resync_error}")
732
+ else:
733
+ success(f"Resync requested: {src.get('display_name', local_folder_id[:8])}")
734
+ return
735
+
736
+ if not source_id:
737
+ error("Source ID required unless using --all.")
738
+ raise typer.Exit(1)
739
+ source = _require_source(source_id)
740
+ local_folder_id = source.get("local_folder_id")
741
+ with spinner("process", "Requesting resync..."):
742
+ _, resync_error = request_json("POST", f"/v2/daemon/sources/{local_folder_id}/resync")
743
+ if resync_error:
744
+ error(resync_error)
745
+ raise typer.Exit(1)
746
+ success("Resync requested")
747
+
748
+
749
+ def _render_logs(items: list[dict]) -> None:
750
+ if not items:
751
+ console.print("[yellow]No logs found.[/yellow]")
752
+ return
753
+ table = Table(show_header=True)
754
+ table.add_column("Time")
755
+ table.add_column("Source")
756
+ table.add_column("Status")
757
+ table.add_column("Stats")
758
+ table.add_column("Error")
759
+ for item in items:
760
+ created_at = item.get("created_at") or item.get("timestamp") or ""
761
+ source_id = item.get("local_folder_id", "")[:8]
762
+ status = item.get("status", "")
763
+ stats = item.get("stats") or {}
764
+ stats_text = ", ".join([f"{k}:{v}" for k, v in stats.items()]) if isinstance(stats, dict) else ""
765
+ error = item.get("error") or ""
766
+ table.add_row(str(created_at), source_id, status, stats_text, error[:80])
767
+ console.print(table)
768
+
769
+
770
+ @app.command()
771
+ def logs(
772
+ source_id: str | None = typer.Argument(None, help="Source ID (from 'nia status')"),
773
+ all_sources: bool = typer.Option(False, "--all", help="Show logs for all sources"),
774
+ errors_only: bool = typer.Option(False, "--errors", help="Only show error logs"),
775
+ tail: bool = typer.Option(False, "--tail", help="Tail logs"),
776
+ limit: int = typer.Option(20, "--limit", "-n", help="Max logs to show"),
777
+ interval: int = typer.Option(3, "--interval", help="Polling interval for --tail"),
778
+ ):
779
+ """Show sync logs for local folders."""
780
+ if not is_authenticated():
781
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
782
+ raise typer.Exit(1)
783
+
784
+ log_status = "error" if errors_only else None
785
+
786
+ if source_id and all_sources:
787
+ error("Use either a source ID or --all, not both.")
788
+ raise typer.Exit(1)
789
+
790
+ if source_id:
791
+ source = _require_source(source_id)
792
+ path = f"/v2/daemon/sources/{source.get('local_folder_id')}/logs"
793
+ else:
794
+ path = "/v2/daemon/logs"
795
+
796
+ def fetch_logs(since: str | None = None) -> list[dict]:
797
+ params: dict[str, Any] = {"limit": limit}
798
+ if log_status:
799
+ params["status"] = log_status
800
+ if since:
801
+ params["since"] = since
802
+ data, error = request_json("GET", path, params=params)
803
+ if error or not isinstance(data, list):
804
+ if error:
805
+ console.print(f"[red]{error}[/red]")
806
+ return []
807
+ return data
808
+
809
+ if not tail:
810
+ _render_logs(fetch_logs())
811
+ return
812
+
813
+ last_seen = None
814
+ console.print("[dim]Tailing logs... Ctrl+C to stop[/dim]")
815
+ try:
816
+ while True:
817
+ logs_batch = fetch_logs(last_seen)
818
+ if logs_batch:
819
+ _render_logs(list(reversed(logs_batch)))
820
+ last_seen = logs_batch[0].get("created_at")
821
+ time.sleep(interval)
822
+ except KeyboardInterrupt:
823
+ console.print("\n[dim]Stopped.[/dim]")
824
+
825
+
826
+ @app.command()
827
+ def pause(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
828
+ """Pause continuous sync for a source."""
829
+ if not is_authenticated():
830
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
831
+ raise typer.Exit(1)
832
+ source = _require_source(source_id)
833
+ local_folder_id = source.get("local_folder_id")
834
+
835
+ with spinner("fetch", "Loading sync settings..."):
836
+ sync_info, sync_error = request_json("GET", f"/v2/local-folders/{local_folder_id}/continuous-sync")
837
+ if sync_error:
838
+ error(sync_error)
839
+ raise typer.Exit(1)
840
+ payload = {
841
+ "enabled": False,
842
+ "interval": sync_info.get("interval", "6h"),
843
+ "watched_path": sync_info.get("watched_path"),
844
+ }
845
+ with spinner("process", "Pausing sync..."):
846
+ _, pause_error = request_json("PATCH", f"/v2/local-folders/{local_folder_id}/continuous-sync", payload=payload)
847
+ if pause_error:
848
+ error(pause_error)
849
+ raise typer.Exit(1)
850
+ success("Paused")
851
+
852
+
853
+ @app.command()
854
+ def resume(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
855
+ """Resume continuous sync for a source."""
856
+ if not is_authenticated():
857
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
858
+ raise typer.Exit(1)
859
+ source = _require_source(source_id)
860
+ local_folder_id = source.get("local_folder_id")
861
+
862
+ with spinner("fetch", "Loading sync settings..."):
863
+ sync_info, sync_error = request_json("GET", f"/v2/local-folders/{local_folder_id}/continuous-sync")
864
+ if sync_error:
865
+ error(sync_error)
866
+ raise typer.Exit(1)
867
+ payload = {
868
+ "enabled": True,
869
+ "interval": sync_info.get("interval", "6h"),
870
+ "watched_path": sync_info.get("watched_path"),
871
+ }
872
+ with spinner("process", "Resuming sync..."):
873
+ _, resume_error = request_json("PATCH", f"/v2/local-folders/{local_folder_id}/continuous-sync", payload=payload)
874
+ if resume_error:
875
+ error(resume_error)
876
+ raise typer.Exit(1)
877
+ success("Resumed")
878
+
879
+
880
+ @app.command()
881
+ def diff(
882
+ source_id: str | None = typer.Argument(None, help="Source ID (from 'nia status')"),
883
+ all_sources: bool = typer.Option(False, "--all", help="Show diffs for all sources"),
884
+ limit: int = typer.Option(200, "--limit", help="Max items to extract"),
885
+ ):
886
+ """Show what would sync without uploading."""
887
+ if not is_authenticated():
888
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
889
+ raise typer.Exit(1)
890
+
891
+ with spinner("fetch", "Loading sources..."):
892
+ sources = get_sources()
893
+ if not sources:
894
+ warning("No sources configured.")
895
+ return
896
+
897
+ if source_id and all_sources:
898
+ error("Use either a source ID or --all, not both.")
899
+ raise typer.Exit(1)
900
+
901
+ targets = sources
902
+ if source_id:
903
+ targets = [_require_source(source_id)]
904
+
905
+ for src in targets:
906
+ path = src.get("path")
907
+ if not path:
908
+ continue
909
+ path = os.path.expanduser(path)
910
+ if not os.path.exists(path):
911
+ console.print(f"[yellow]Path not found:[/yellow] {path}")
912
+ continue
913
+ detected_type = src.get("detected_type") or detect_source_type(path)
914
+ cursor = src.get("cursor", {})
915
+ result = extract_incremental(path=path, source_type=detected_type, cursor=cursor, limit=limit)
916
+ files = result.get("files", [])
917
+ console.print(f"[cyan]{src.get('display_name', path)}[/cyan] - {len(files)} items")
918
+ for item in files[:50]:
919
+ console.print(f" • {item.get('path')}")
920
+ if len(files) > 50:
921
+ console.print(" [dim]... truncated[/dim]")
922
+
923
+
924
+ @app.command()
925
+ def doctor():
926
+ """Run diagnostics for common CLI issues."""
927
+ print_header("Doctor", "Running diagnostics...")
928
+
929
+ table = Table(show_header=True)
930
+ table.add_column("Check")
931
+ table.add_column("Status")
932
+ table.add_column("Details")
933
+
934
+ with spinner("process", "Checking authentication..."):
935
+ api_key = get_api_key()
936
+ if api_key:
937
+ table.add_row("Auth", "[green]OK[/green]", "API key configured")
938
+ else:
939
+ table.add_row("Auth", "[red]Fail[/red]", "Run `nia login`")
940
+
941
+ with spinner("connect", "Testing API connection..."):
942
+ data, api_error = request_json("GET", "/v2/daemon/sources")
943
+ if api_error:
944
+ table.add_row("API", "[red]Fail[/red]", api_error)
945
+ else:
946
+ table.add_row("API", "[green]OK[/green]", f"{len(data)} source(s) found")
947
+
948
+ # Check known DB paths for access
949
+ for key, path in KNOWN_PATHS.items():
950
+ expanded = os.path.expanduser(path)
951
+ if "*" in expanded:
952
+ continue
953
+ if not os.path.exists(expanded):
954
+ continue
955
+ try:
956
+ with open(expanded, "rb") as f:
957
+ f.read(1)
958
+ table.add_row(f"{key} access", "[green]OK[/green]", expanded)
959
+ except PermissionError:
960
+ table.add_row(f"{key} access", "[red]Fail[/red]", "Grant Full Disk Access")
961
+
962
+ watch_dirs = get_watch_dirs()
963
+ missing = [d for d in watch_dirs if not os.path.isdir(os.path.expanduser(d))]
964
+ if missing:
965
+ table.add_row("Watch dirs", "[yellow]Warn[/yellow]", f"{len(missing)} missing")
966
+ else:
967
+ table.add_row("Watch dirs", "[green]OK[/green]", f"{len(watch_dirs)} configured")
968
+
969
+ console.print(table)
970
+
971
+
972
+ @app.command()
973
+ def version(check: bool = typer.Option(False, "--check", help="Check for updates")):
974
+ """Show CLI version."""
975
+ try:
976
+ current = importlib_metadata.version("nia-sync")
977
+ except importlib_metadata.PackageNotFoundError:
978
+ current = "unknown"
979
+ console.print(f"nia-sync {current}")
980
+ if not check:
981
+ return
982
+ import subprocess
983
+ import sys
984
+ try:
985
+ result = subprocess.run(
986
+ [sys.executable, "-m", "pip", "index", "versions", "nia-sync"],
987
+ capture_output=True,
988
+ text=True,
989
+ )
990
+ if result.stdout:
991
+ console.print(result.stdout.strip())
992
+ except Exception as e:
993
+ console.print(f"[yellow]Update check failed: {e}[/yellow]")
994
+
995
+
996
+ @config_app.command("list")
997
+ def config_list():
998
+ """List current config."""
999
+ config = get_config_value("") or {}
1000
+ if not isinstance(config, dict):
1001
+ config = {}
1002
+ print(json.dumps(config, indent=2))
1003
+
1004
+
1005
+ @config_app.command("get")
1006
+ def config_get(key: str = typer.Argument(..., help="Config key")):
1007
+ value = get_config_value(key)
1008
+ print(json.dumps({key: value}, indent=2))
1009
+
1010
+
1011
+ @config_app.command("set")
1012
+ def config_set(
1013
+ key: str = typer.Argument(..., help="Config key"),
1014
+ value: str = typer.Argument(..., help="Value (JSON or string)"),
1015
+ ):
1016
+ try:
1017
+ parsed = json.loads(value)
1018
+ except json.JSONDecodeError:
1019
+ parsed = value
1020
+ set_config_value(key, parsed)
1021
+ console.print("[green]✓ Updated[/green]")
1022
+
1023
+
1024
+ @ignore_app.command("list")
1025
+ def ignore_list():
1026
+ patterns = get_ignore_patterns()
1027
+ print(json.dumps(patterns, indent=2))
1028
+
1029
+
1030
+ @ignore_app.command("add")
1031
+ def ignore_add(
1032
+ dir: str | None = typer.Option(None, "--dir", help="Directory name to ignore"),
1033
+ file: str | None = typer.Option(None, "--file", help="File name to ignore"),
1034
+ ext: str | None = typer.Option(None, "--ext", help="File extension to ignore"),
1035
+ path: str | None = typer.Option(None, "--path", help="Path substring to ignore"),
1036
+ ):
1037
+ value_map = {"dir": dir, "file": file, "ext": ext, "path": path}
1038
+ provided = [(k, v) for k, v in value_map.items() if v]
1039
+ if len(provided) != 1:
1040
+ error("Provide exactly one of --dir, --file, --ext, --path")
1041
+ raise typer.Exit(1)
1042
+ kind, value = provided[0]
1043
+ if add_ignore_pattern(kind, value):
1044
+ success("Added")
1045
+ else:
1046
+ warning("No change")
1047
+
1048
+
1049
+ @ignore_app.command("remove")
1050
+ def ignore_remove(
1051
+ dir: str | None = typer.Option(None, "--dir", help="Directory name to remove"),
1052
+ file: str | None = typer.Option(None, "--file", help="File name to remove"),
1053
+ ext: str | None = typer.Option(None, "--ext", help="File extension to remove"),
1054
+ path: str | None = typer.Option(None, "--path", help="Path substring to remove"),
1055
+ ):
1056
+ value_map = {"dir": dir, "file": file, "ext": ext, "path": path}
1057
+ provided = [(k, v) for k, v in value_map.items() if v]
1058
+ if len(provided) != 1:
1059
+ error("Provide exactly one of --dir, --file, --ext, --path")
1060
+ raise typer.Exit(1)
1061
+ kind, value = provided[0]
1062
+ if remove_ignore_pattern(kind, value):
1063
+ success("Removed")
1064
+ else:
1065
+ warning("No change")
1066
+
1067
+
1068
+ @watch_app.command("list")
1069
+ def watch_list():
1070
+ """List watched directories."""
1071
+ config_dirs = get_config_value("watch_dirs") or []
1072
+ all_dirs = get_watch_dirs()
1073
+ if not all_dirs:
1074
+ console.print("[yellow]No watch directories configured.[/yellow]")
1075
+ return
1076
+ for d in all_dirs:
1077
+ marker = "[cyan]custom[/cyan]" if d in config_dirs else "[dim]default[/dim]"
1078
+ console.print(f"{d} {marker}")
1079
+
1080
+
1081
+ @watch_app.command("add")
1082
+ def watch_add(path: str = typer.Argument(..., help="Directory to watch")):
1083
+ if add_watch_dir(path):
1084
+ console.print("[green]✓ Added[/green]")
1085
+ else:
1086
+ console.print("[yellow]Already present[/yellow]")
1087
+
1088
+
1089
+ @watch_app.command("remove")
1090
+ def watch_remove(path: str = typer.Argument(..., help="Directory to remove")):
1091
+ if remove_watch_dir(path):
1092
+ console.print("[green]✓ Removed[/green]")
1093
+ else:
1094
+ console.print("[yellow]Not found[/yellow]")
1095
+
1096
+
398
1097
  def _resolve_sources(sources: list[dict], log_discoveries: bool = False) -> list[dict]:
399
1098
  """Resolve paths for sources, auto-detecting known types and folders. Deduplicates by path."""
400
1099
  resolved = []
@@ -466,8 +1165,11 @@ def daemon(
466
1165
  from sync import sync_source
467
1166
 
468
1167
  if not is_authenticated():
469
- console.print("[red]Not logged in.[/red] Run [cyan]nia login[/cyan] first.")
1168
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
470
1169
  raise typer.Exit(1)
1170
+
1171
+ # Show logo on daemon start
1172
+ print_logo(compact=True)
471
1173
 
472
1174
  running = True
473
1175
  pending_syncs: set[str] = set() # source_ids pending sync
@@ -608,8 +1310,7 @@ def daemon(
608
1310
 
609
1311
  # Start directory watcher for instant folder detection
610
1312
  if dir_watcher:
611
- from config import DEFAULT_WATCH_DIRS
612
- dir_watcher.watch(DEFAULT_WATCH_DIRS, on_new_folder)
1313
+ dir_watcher.watch(get_watch_dirs(), on_new_folder)
613
1314
  dir_watcher.start()
614
1315
 
615
1316
  console.print(Panel.fit(
@@ -733,7 +1434,7 @@ def _send_heartbeat(source_ids: list[str]) -> None:
733
1434
  try:
734
1435
  with httpx.Client(timeout=10) as client:
735
1436
  client.post(
736
- f"{API_BASE_URL}/v2/daemon/heartbeat",
1437
+ f"{get_api_base_url()}/v2/daemon/heartbeat",
737
1438
  headers={"Authorization": f"Bearer {api_key}"},
738
1439
  json={"source_ids": source_ids},
739
1440
  )