nia-sync 0.1.6__py3-none-any.whl → 0.1.8__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,33 +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.")
432
+ else:
433
+ error("Failed to link.")
434
+ raise typer.Exit(1)
435
+
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
+ if stream and json_output:
505
+ error("--stream cannot be used with --json")
506
+ raise typer.Exit(1)
507
+ if markdown and json_output:
508
+ error("--markdown cannot be used with --json")
509
+ raise typer.Exit(1)
510
+
511
+ resolved_local_folders: list[str] = []
512
+ if local_folder:
513
+ with spinner("fetch", "Loading sources..."):
514
+ sources = get_sources()
515
+ for identifier in local_folder:
516
+ resolved = _resolve_source_identifier(identifier, sources)
517
+ resolved_local_folders.append(resolved.get("local_folder_id"))
518
+
519
+ payload = {
520
+ "messages": [{"role": "user", "content": query}],
521
+ "local_folders": resolved_local_folders or [],
522
+ "search_mode": "unified",
523
+ "stream": stream,
524
+ "fast_mode": False if stream else fast,
525
+ "include_sources": bool(show_sources),
526
+ }
527
+ if stream:
528
+ api_key = get_api_key()
529
+ url = f"{get_api_base_url().rstrip('/')}/v2/search/query"
530
+ sources_payload: list[dict] = []
531
+ full_content = ""
532
+ with httpx.Client(timeout=httpx.Timeout(600, connect=10)) as client:
533
+ with client.stream(
534
+ "POST",
535
+ url,
536
+ headers={"Authorization": f"Bearer {api_key}"},
537
+ json=payload,
538
+ ) as response:
539
+ if response.status_code != 200:
540
+ console.print(f"[red]API error: {response.text}[/red]")
541
+ raise typer.Exit(1)
542
+ if markdown:
543
+ with Live(Markdown(""), console=console, refresh_per_second=8) as live:
544
+ for line in response.iter_lines():
545
+ if not line or not line.startswith("data: "):
546
+ continue
547
+ data_str = line[6:]
548
+ if data_str.strip() == "[DONE]":
549
+ break
550
+ try:
551
+ event = json.loads(data_str)
552
+ except json.JSONDecodeError:
553
+ continue
554
+ if "error" in event:
555
+ console.print(f"[red]{event['error']}[/red]")
556
+ raise typer.Exit(1)
557
+ if "content" in event:
558
+ full_content += event["content"]
559
+ live.update(Markdown(full_content))
560
+ if "sources" in event:
561
+ sources_payload = event["sources"] or []
562
+ else:
563
+ for line in response.iter_lines():
564
+ if not line or not line.startswith("data: "):
565
+ continue
566
+ data_str = line[6:]
567
+ if data_str.strip() == "[DONE]":
568
+ break
569
+ try:
570
+ event = json.loads(data_str)
571
+ except json.JSONDecodeError:
572
+ continue
573
+ if "error" in event:
574
+ console.print(f"[red]{event['error']}[/red]")
575
+ raise typer.Exit(1)
576
+ if "content" in event:
577
+ print(event["content"], end="", flush=True)
578
+ if "sources" in event:
579
+ sources_payload = event["sources"] or []
580
+ print()
581
+ if show_sources and sources_payload:
582
+ for src in sources_payload[:limit]:
583
+ metadata = src.get("metadata") or {}
584
+ identifier = (
585
+ metadata.get("file_path")
586
+ or metadata.get("path")
587
+ or metadata.get("source")
588
+ or metadata.get("identifier")
589
+ or metadata.get("local_folder_name")
590
+ or "unknown"
591
+ )
592
+ snippet = (src.get("content") or "").strip().replace("\n", " ")
593
+ console.print(f"- {identifier}: {snippet[:120]}")
594
+ return
595
+
596
+ data, error = request_json("POST", "/v2/search/query", payload=payload)
597
+ if error:
598
+ console.print(f"[red]{error}[/red]")
599
+ raise typer.Exit(1)
600
+
601
+ if json_output:
602
+ print(json.dumps(data, indent=2))
603
+ return
604
+
605
+ if isinstance(data, dict):
606
+ content = (
607
+ data.get("content")
608
+ or data.get("answer")
609
+ or data.get("response")
610
+ or ""
611
+ )
612
+ if isinstance(content, str) and content.strip():
613
+ if markdown:
614
+ console.print(Markdown(content.strip()))
615
+ else:
616
+ console.print(content.strip())
617
+ else:
618
+ console.print("[yellow]No response content.[/yellow]")
619
+
620
+ if show_sources:
621
+ sources = data.get("sources") or data.get("results") or []
622
+ if isinstance(sources, list) and sources:
623
+ for src in sources[:limit]:
624
+ metadata = src.get("metadata") or {}
625
+ identifier = (
626
+ metadata.get("file_path")
627
+ or metadata.get("path")
628
+ or metadata.get("source")
629
+ or metadata.get("identifier")
630
+ or metadata.get("local_folder_name")
631
+ or "unknown"
632
+ )
633
+ snippet = (src.get("content") or src.get("text") or "").strip().replace("\n", " ")
634
+ console.print(f"- {identifier}: {snippet[:120]}")
393
635
  else:
394
- console.print("[red]Failed to link.[/red]")
636
+ console.print("[yellow]Unexpected search response.[/yellow]")
637
+
638
+
639
+ @app.command()
640
+ def whoami():
641
+ """Show the current authenticated user."""
642
+ if not is_authenticated():
643
+ console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
644
+ raise typer.Exit(1)
645
+ data, error = request_json("GET", "/v2/daemon/whoami")
646
+ if error:
647
+ console.print(f"[red]{error}[/red]")
648
+ raise typer.Exit(1)
649
+ print(json.dumps(data, indent=2))
650
+
651
+
652
+ @app.command(name="open")
653
+ def open_web(
654
+ target: str = typer.Argument("dashboard", help="dashboard|activity|api-keys|local-sync|billing|organization|docs|<source_id>"),
655
+ ):
656
+ """Open the Nia web app."""
657
+ base_url = os.getenv("NIA_WEB_URL", "https://app.trynia.ai").rstrip("/")
658
+ routes = {
659
+ "dashboard": "/overview",
660
+ "overview": "/overview",
661
+ "activity": "/activity",
662
+ "api-keys": "/api-keys",
663
+ "local-sync": "/settings/local-sync",
664
+ "billing": "/billing",
665
+ "organization": "/settings/organization",
666
+ }
667
+ if target == "docs":
668
+ url = "https://docs.trynia.ai/welcome"
669
+ elif target in routes:
670
+ url = f"{base_url}{routes[target]}"
671
+ else:
672
+ url = f"{base_url}/local-folders/{target}"
673
+ webbrowser.open(url)
674
+ console.print(f"[green]✓ Opened[/green] {url}")
675
+
676
+
677
+ @app.command()
678
+ def info(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
679
+ """Show detailed info for a source."""
680
+ if not is_authenticated():
681
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
682
+ raise typer.Exit(1)
683
+ source = _require_source(source_id)
684
+ local_folder_id = source.get("local_folder_id")
685
+
686
+ with spinner("fetch", "Loading source details..."):
687
+ folder, folder_error = request_json("GET", f"/v2/local-folders/{local_folder_id}")
688
+ if folder_error:
689
+ error(folder_error)
690
+ raise typer.Exit(1)
691
+
692
+ with spinner("fetch", "Loading sync info..."):
693
+ sync_info, sync_error = request_json("GET", f"/v2/local-folders/{local_folder_id}/continuous-sync")
694
+ if sync_error:
695
+ error(sync_error)
395
696
  raise typer.Exit(1)
396
697
 
698
+ console.print(f"[bold cyan]{folder.get('display_name', 'Local Folder')}[/bold cyan]")
699
+ table = Table(show_header=False)
700
+ table.add_row("ID", str(local_folder_id))
701
+ table.add_row("Status", str(folder.get("status")))
702
+ table.add_row("Type", str(folder.get("db_type") or "folder"))
703
+ table.add_row("Chunk count", str(folder.get("chunk_count", 0)))
704
+ table.add_row("File count", str(folder.get("file_count", 0)))
705
+ table.add_row("Last sync", str(sync_info.get("last_synced_at")))
706
+ table.add_row("Last error", str(sync_info.get("last_sync_error") or folder.get("continuous_sync", {}).get("last_sync_error", "")))
707
+ console.print(table)
708
+
709
+
710
+ @app.command()
711
+ def resync(
712
+ source_id: str | None = typer.Argument(None, help="Source ID (from 'nia status')"),
713
+ all_sources: bool = typer.Option(False, "--all", help="Resync all sources"),
714
+ ):
715
+ """Force a full resync by resetting the cursor."""
716
+ if not is_authenticated():
717
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
718
+ raise typer.Exit(1)
719
+
720
+ if all_sources:
721
+ with spinner("fetch", "Loading sources..."):
722
+ sources = get_sources()
723
+ if not sources:
724
+ warning("No sources configured.")
725
+ return
726
+ for src in sources:
727
+ local_folder_id = src.get("local_folder_id")
728
+ if not local_folder_id:
729
+ continue
730
+ _, resync_error = request_json("POST", f"/v2/daemon/sources/{local_folder_id}/resync")
731
+ if resync_error:
732
+ error(f"{src.get('display_name', local_folder_id[:8])}: {resync_error}")
733
+ else:
734
+ success(f"Resync requested: {src.get('display_name', local_folder_id[:8])}")
735
+ return
736
+
737
+ if not source_id:
738
+ error("Source ID required unless using --all.")
739
+ raise typer.Exit(1)
740
+ source = _require_source(source_id)
741
+ local_folder_id = source.get("local_folder_id")
742
+ with spinner("process", "Requesting resync..."):
743
+ _, resync_error = request_json("POST", f"/v2/daemon/sources/{local_folder_id}/resync")
744
+ if resync_error:
745
+ error(resync_error)
746
+ raise typer.Exit(1)
747
+ success("Resync requested")
748
+
749
+
750
+ def _render_logs(items: list[dict]) -> None:
751
+ if not items:
752
+ console.print("[yellow]No logs found.[/yellow]")
753
+ return
754
+ table = Table(show_header=True)
755
+ table.add_column("Time")
756
+ table.add_column("Source")
757
+ table.add_column("Status")
758
+ table.add_column("Stats")
759
+ table.add_column("Error")
760
+ for item in items:
761
+ created_at = item.get("created_at") or item.get("timestamp") or ""
762
+ source_id = item.get("local_folder_id", "")[:8]
763
+ status = item.get("status", "")
764
+ stats = item.get("stats") or {}
765
+ stats_text = ", ".join([f"{k}:{v}" for k, v in stats.items()]) if isinstance(stats, dict) else ""
766
+ error = item.get("error") or ""
767
+ table.add_row(str(created_at), source_id, status, stats_text, error[:80])
768
+ console.print(table)
769
+
770
+
771
+ @app.command()
772
+ def logs(
773
+ source_id: str | None = typer.Argument(None, help="Source ID (from 'nia status')"),
774
+ all_sources: bool = typer.Option(False, "--all", help="Show logs for all sources"),
775
+ errors_only: bool = typer.Option(False, "--errors", help="Only show error logs"),
776
+ tail: bool = typer.Option(False, "--tail", help="Tail logs"),
777
+ limit: int = typer.Option(20, "--limit", "-n", help="Max logs to show"),
778
+ interval: int = typer.Option(3, "--interval", help="Polling interval for --tail"),
779
+ ):
780
+ """Show sync logs for local folders."""
781
+ if not is_authenticated():
782
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
783
+ raise typer.Exit(1)
784
+
785
+ log_status = "error" if errors_only else None
786
+
787
+ if source_id and all_sources:
788
+ error("Use either a source ID or --all, not both.")
789
+ raise typer.Exit(1)
790
+
791
+ if source_id:
792
+ source = _require_source(source_id)
793
+ path = f"/v2/daemon/sources/{source.get('local_folder_id')}/logs"
794
+ else:
795
+ path = "/v2/daemon/logs"
796
+
797
+ def fetch_logs(since: str | None = None) -> list[dict]:
798
+ params: dict[str, Any] = {"limit": limit}
799
+ if log_status:
800
+ params["status"] = log_status
801
+ if since:
802
+ params["since"] = since
803
+ data, error = request_json("GET", path, params=params)
804
+ if error or not isinstance(data, list):
805
+ if error:
806
+ console.print(f"[red]{error}[/red]")
807
+ return []
808
+ return data
809
+
810
+ if not tail:
811
+ _render_logs(fetch_logs())
812
+ return
813
+
814
+ last_seen = None
815
+ console.print("[dim]Tailing logs... Ctrl+C to stop[/dim]")
816
+ try:
817
+ while True:
818
+ logs_batch = fetch_logs(last_seen)
819
+ if logs_batch:
820
+ _render_logs(list(reversed(logs_batch)))
821
+ last_seen = logs_batch[0].get("created_at")
822
+ time.sleep(interval)
823
+ except KeyboardInterrupt:
824
+ console.print("\n[dim]Stopped.[/dim]")
825
+
826
+
827
+ @app.command()
828
+ def pause(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
829
+ """Pause continuous sync for a source."""
830
+ if not is_authenticated():
831
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
832
+ raise typer.Exit(1)
833
+ source = _require_source(source_id)
834
+ local_folder_id = source.get("local_folder_id")
835
+
836
+ with spinner("fetch", "Loading sync settings..."):
837
+ sync_info, sync_error = request_json("GET", f"/v2/local-folders/{local_folder_id}/continuous-sync")
838
+ if sync_error:
839
+ error(sync_error)
840
+ raise typer.Exit(1)
841
+ payload = {
842
+ "enabled": False,
843
+ "interval": sync_info.get("interval", "6h"),
844
+ "watched_path": sync_info.get("watched_path"),
845
+ }
846
+ with spinner("process", "Pausing sync..."):
847
+ _, pause_error = request_json("PATCH", f"/v2/local-folders/{local_folder_id}/continuous-sync", payload=payload)
848
+ if pause_error:
849
+ error(pause_error)
850
+ raise typer.Exit(1)
851
+ success("Paused")
852
+
853
+
854
+ @app.command()
855
+ def resume(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
856
+ """Resume continuous sync for a source."""
857
+ if not is_authenticated():
858
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
859
+ raise typer.Exit(1)
860
+ source = _require_source(source_id)
861
+ local_folder_id = source.get("local_folder_id")
862
+
863
+ with spinner("fetch", "Loading sync settings..."):
864
+ sync_info, sync_error = request_json("GET", f"/v2/local-folders/{local_folder_id}/continuous-sync")
865
+ if sync_error:
866
+ error(sync_error)
867
+ raise typer.Exit(1)
868
+ payload = {
869
+ "enabled": True,
870
+ "interval": sync_info.get("interval", "6h"),
871
+ "watched_path": sync_info.get("watched_path"),
872
+ }
873
+ with spinner("process", "Resuming sync..."):
874
+ _, resume_error = request_json("PATCH", f"/v2/local-folders/{local_folder_id}/continuous-sync", payload=payload)
875
+ if resume_error:
876
+ error(resume_error)
877
+ raise typer.Exit(1)
878
+ success("Resumed")
879
+
880
+
881
+ @app.command()
882
+ def diff(
883
+ source_id: str | None = typer.Argument(None, help="Source ID (from 'nia status')"),
884
+ all_sources: bool = typer.Option(False, "--all", help="Show diffs for all sources"),
885
+ limit: int = typer.Option(200, "--limit", help="Max items to extract"),
886
+ ):
887
+ """Show what would sync without uploading."""
888
+ if not is_authenticated():
889
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
890
+ raise typer.Exit(1)
891
+
892
+ with spinner("fetch", "Loading sources..."):
893
+ sources = get_sources()
894
+ if not sources:
895
+ warning("No sources configured.")
896
+ return
897
+
898
+ if source_id and all_sources:
899
+ error("Use either a source ID or --all, not both.")
900
+ raise typer.Exit(1)
901
+
902
+ targets = sources
903
+ if source_id:
904
+ targets = [_require_source(source_id)]
905
+
906
+ for src in targets:
907
+ path = src.get("path")
908
+ if not path:
909
+ continue
910
+ path = os.path.expanduser(path)
911
+ if not os.path.exists(path):
912
+ console.print(f"[yellow]Path not found:[/yellow] {path}")
913
+ continue
914
+ detected_type = src.get("detected_type") or detect_source_type(path)
915
+ cursor = src.get("cursor", {})
916
+ result = extract_incremental(path=path, source_type=detected_type, cursor=cursor, limit=limit)
917
+ files = result.get("files", [])
918
+ console.print(f"[cyan]{src.get('display_name', path)}[/cyan] - {len(files)} items")
919
+ for item in files[:50]:
920
+ console.print(f" • {item.get('path')}")
921
+ if len(files) > 50:
922
+ console.print(" [dim]... truncated[/dim]")
923
+
924
+
925
+ @app.command()
926
+ def doctor():
927
+ """Run diagnostics for common CLI issues."""
928
+ print_header("Doctor", "Running diagnostics...")
929
+
930
+ table = Table(show_header=True)
931
+ table.add_column("Check")
932
+ table.add_column("Status")
933
+ table.add_column("Details")
934
+
935
+ with spinner("process", "Checking authentication..."):
936
+ api_key = get_api_key()
937
+ if api_key:
938
+ table.add_row("Auth", "[green]OK[/green]", "API key configured")
939
+ else:
940
+ table.add_row("Auth", "[red]Fail[/red]", "Run `nia login`")
941
+
942
+ with spinner("connect", "Testing API connection..."):
943
+ data, api_error = request_json("GET", "/v2/daemon/sources")
944
+ if api_error:
945
+ table.add_row("API", "[red]Fail[/red]", api_error)
946
+ else:
947
+ table.add_row("API", "[green]OK[/green]", f"{len(data)} source(s) found")
948
+
949
+ # Check known DB paths for access
950
+ for key, path in KNOWN_PATHS.items():
951
+ expanded = os.path.expanduser(path)
952
+ if "*" in expanded:
953
+ continue
954
+ if not os.path.exists(expanded):
955
+ continue
956
+ try:
957
+ with open(expanded, "rb") as f:
958
+ f.read(1)
959
+ table.add_row(f"{key} access", "[green]OK[/green]", expanded)
960
+ except PermissionError:
961
+ table.add_row(f"{key} access", "[red]Fail[/red]", "Grant Full Disk Access")
962
+
963
+ watch_dirs = get_watch_dirs()
964
+ missing = [d for d in watch_dirs if not os.path.isdir(os.path.expanduser(d))]
965
+ if missing:
966
+ table.add_row("Watch dirs", "[yellow]Warn[/yellow]", f"{len(missing)} missing")
967
+ else:
968
+ table.add_row("Watch dirs", "[green]OK[/green]", f"{len(watch_dirs)} configured")
969
+
970
+ console.print(table)
971
+
972
+
973
+ @app.command()
974
+ def version(check: bool = typer.Option(False, "--check", help="Check for updates")):
975
+ """Show CLI version."""
976
+ try:
977
+ current = importlib_metadata.version("nia-sync")
978
+ except importlib_metadata.PackageNotFoundError:
979
+ current = "unknown"
980
+ console.print(f"nia-sync {current}")
981
+ if not check:
982
+ return
983
+ import subprocess
984
+ import sys
985
+ try:
986
+ result = subprocess.run(
987
+ [sys.executable, "-m", "pip", "index", "versions", "nia-sync"],
988
+ capture_output=True,
989
+ text=True,
990
+ )
991
+ if result.stdout:
992
+ console.print(result.stdout.strip())
993
+ except Exception as e:
994
+ console.print(f"[yellow]Update check failed: {e}[/yellow]")
995
+
996
+
997
+ @config_app.command("list")
998
+ def config_list():
999
+ """List current config."""
1000
+ config = get_config_value("") or {}
1001
+ if not isinstance(config, dict):
1002
+ config = {}
1003
+ print(json.dumps(config, indent=2))
1004
+
1005
+
1006
+ @config_app.command("get")
1007
+ def config_get(key: str = typer.Argument(..., help="Config key")):
1008
+ value = get_config_value(key)
1009
+ print(json.dumps({key: value}, indent=2))
1010
+
1011
+
1012
+ @config_app.command("set")
1013
+ def config_set(
1014
+ key: str = typer.Argument(..., help="Config key"),
1015
+ value: str = typer.Argument(..., help="Value (JSON or string)"),
1016
+ ):
1017
+ try:
1018
+ parsed = json.loads(value)
1019
+ except json.JSONDecodeError:
1020
+ parsed = value
1021
+ set_config_value(key, parsed)
1022
+ console.print("[green]✓ Updated[/green]")
1023
+
1024
+
1025
+ @ignore_app.command("list")
1026
+ def ignore_list():
1027
+ patterns = get_ignore_patterns()
1028
+ print(json.dumps(patterns, indent=2))
1029
+
1030
+
1031
+ @ignore_app.command("add")
1032
+ def ignore_add(
1033
+ dir: str | None = typer.Option(None, "--dir", help="Directory name to ignore"),
1034
+ file: str | None = typer.Option(None, "--file", help="File name to ignore"),
1035
+ ext: str | None = typer.Option(None, "--ext", help="File extension to ignore"),
1036
+ path: str | None = typer.Option(None, "--path", help="Path substring to ignore"),
1037
+ ):
1038
+ value_map = {"dir": dir, "file": file, "ext": ext, "path": path}
1039
+ provided = [(k, v) for k, v in value_map.items() if v]
1040
+ if len(provided) != 1:
1041
+ error("Provide exactly one of --dir, --file, --ext, --path")
1042
+ raise typer.Exit(1)
1043
+ kind, value = provided[0]
1044
+ if add_ignore_pattern(kind, value):
1045
+ success("Added")
1046
+ else:
1047
+ warning("No change")
1048
+
1049
+
1050
+ @ignore_app.command("remove")
1051
+ def ignore_remove(
1052
+ dir: str | None = typer.Option(None, "--dir", help="Directory name to remove"),
1053
+ file: str | None = typer.Option(None, "--file", help="File name to remove"),
1054
+ ext: str | None = typer.Option(None, "--ext", help="File extension to remove"),
1055
+ path: str | None = typer.Option(None, "--path", help="Path substring to remove"),
1056
+ ):
1057
+ value_map = {"dir": dir, "file": file, "ext": ext, "path": path}
1058
+ provided = [(k, v) for k, v in value_map.items() if v]
1059
+ if len(provided) != 1:
1060
+ error("Provide exactly one of --dir, --file, --ext, --path")
1061
+ raise typer.Exit(1)
1062
+ kind, value = provided[0]
1063
+ if remove_ignore_pattern(kind, value):
1064
+ success("Removed")
1065
+ else:
1066
+ warning("No change")
1067
+
1068
+
1069
+ @watch_app.command("list")
1070
+ def watch_list():
1071
+ """List watched directories."""
1072
+ config_dirs = get_config_value("watch_dirs") or []
1073
+ all_dirs = get_watch_dirs()
1074
+ if not all_dirs:
1075
+ console.print("[yellow]No watch directories configured.[/yellow]")
1076
+ return
1077
+ for d in all_dirs:
1078
+ marker = "[cyan]custom[/cyan]" if d in config_dirs else "[dim]default[/dim]"
1079
+ console.print(f"{d} {marker}")
1080
+
1081
+
1082
+ @watch_app.command("add")
1083
+ def watch_add(path: str = typer.Argument(..., help="Directory to watch")):
1084
+ if add_watch_dir(path):
1085
+ console.print("[green]✓ Added[/green]")
1086
+ else:
1087
+ console.print("[yellow]Already present[/yellow]")
1088
+
1089
+
1090
+ @watch_app.command("remove")
1091
+ def watch_remove(path: str = typer.Argument(..., help="Directory to remove")):
1092
+ if remove_watch_dir(path):
1093
+ console.print("[green]✓ Removed[/green]")
1094
+ else:
1095
+ console.print("[yellow]Not found[/yellow]")
1096
+
397
1097
 
398
1098
  def _resolve_sources(sources: list[dict], log_discoveries: bool = False) -> list[dict]:
399
1099
  """Resolve paths for sources, auto-detecting known types and folders. Deduplicates by path."""
@@ -466,8 +1166,11 @@ def daemon(
466
1166
  from sync import sync_source
467
1167
 
468
1168
  if not is_authenticated():
469
- console.print("[red]Not logged in.[/red] Run [cyan]nia login[/cyan] first.")
1169
+ error("Not logged in. Run [cyan]nia login[/cyan] first.")
470
1170
  raise typer.Exit(1)
1171
+
1172
+ # Show logo on daemon start
1173
+ print_logo(compact=True)
471
1174
 
472
1175
  running = True
473
1176
  pending_syncs: set[str] = set() # source_ids pending sync
@@ -608,8 +1311,7 @@ def daemon(
608
1311
 
609
1312
  # Start directory watcher for instant folder detection
610
1313
  if dir_watcher:
611
- from config import DEFAULT_WATCH_DIRS
612
- dir_watcher.watch(DEFAULT_WATCH_DIRS, on_new_folder)
1314
+ dir_watcher.watch(get_watch_dirs(), on_new_folder)
613
1315
  dir_watcher.start()
614
1316
 
615
1317
  console.print(Panel.fit(
@@ -733,7 +1435,7 @@ def _send_heartbeat(source_ids: list[str]) -> None:
733
1435
  try:
734
1436
  with httpx.Client(timeout=10) as client:
735
1437
  client.post(
736
- f"{API_BASE_URL}/v2/daemon/heartbeat",
1438
+ f"{get_api_base_url()}/v2/daemon/heartbeat",
737
1439
  headers={"Authorization": f"Bearer {api_key}"},
738
1440
  json={"source_ids": source_ids},
739
1441
  )