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.
- api_client.py +104 -0
- auth.py +3 -3
- config.py +99 -116
- extractor.py +30 -4
- main.py +776 -74
- {nia_sync-0.1.6.dist-info → nia_sync-0.1.8.dist-info}/METADATA +1 -1
- nia_sync-0.1.8.dist-info/RECORD +13 -0
- {nia_sync-0.1.6.dist-info → nia_sync-0.1.8.dist-info}/top_level.txt +2 -0
- sync.py +62 -6
- ui.py +119 -0
- nia_sync-0.1.6.dist-info/RECORD +0 -11
- {nia_sync-0.1.6.dist-info → nia_sync-0.1.8.dist-info}/WHEEL +0 -0
- {nia_sync-0.1.6.dist-info → nia_sync-0.1.8.dist-info}/entry_points.txt +0 -0
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
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
189
|
+
e = None
|
|
167
190
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
213
|
+
error("Not logged in. Run [cyan]nia login[/cyan] first.")
|
|
184
214
|
raise typer.Exit(1)
|
|
185
215
|
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
+
error("Not logged in. Run [cyan]nia login[/cyan] first.")
|
|
278
309
|
raise typer.Exit(1)
|
|
279
310
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
311
|
+
with spinner("fetch", "Loading sources..."):
|
|
312
|
+
sources = get_sources()
|
|
313
|
+
|
|
283
314
|
if not sources:
|
|
284
|
-
|
|
285
|
-
|
|
315
|
+
warning("No sources configured.")
|
|
316
|
+
dim("Add sources in the Nia web app first.")
|
|
286
317
|
return
|
|
287
318
|
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
if
|
|
324
|
+
sync_status = result.get("status", "unknown")
|
|
325
|
+
if sync_status == "success":
|
|
294
326
|
added = result.get("added", 0)
|
|
295
|
-
|
|
327
|
+
success(f"{path} - {added} items synced")
|
|
296
328
|
else:
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
+
info(f"Detected type: [cyan]{detected_type}[/cyan]")
|
|
319
351
|
|
|
320
352
|
# Add source via API
|
|
321
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
388
|
+
with spinner("process", "Removing source..."):
|
|
389
|
+
remove_success = remove_source(full_id)
|
|
355
390
|
|
|
356
|
-
if
|
|
357
|
-
|
|
391
|
+
if remove_success:
|
|
392
|
+
success(f"Removed: {display_name}")
|
|
358
393
|
else:
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
+
error(f"Path not found: {expanded_path}")
|
|
377
412
|
raise typer.Exit(1)
|
|
378
413
|
|
|
379
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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("[
|
|
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
|
-
|
|
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
|
-
|
|
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"{
|
|
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
|
)
|