fns-cli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fns.py ADDED
@@ -0,0 +1,811 @@
1
+ #!/usr/bin/env python3
2
+ """Fast Note Sync (FNS) CLI - Interact with your Obsidian FNS service from terminal."""
3
+ import sys, json, subprocess, os
4
+ from pathlib import Path
5
+ from urllib.parse import urlencode
6
+ from datetime import datetime, timezone
7
+
8
+ # Fix Windows console encoding for emoji support
9
+ if sys.platform == "win32":
10
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
11
+ if hasattr(sys.stdout, "reconfigure"):
12
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
13
+ if hasattr(sys.stderr, "reconfigure"):
14
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
15
+
16
+ import click
17
+
18
+ __version__ = "0.4.0"
19
+
20
+ # Config directory: ~/.config/fns-cli/ (cross-platform, consistent with other CLI tools)
21
+ CONFIG_DIR = Path.home() / ".config" / "fns-cli"
22
+ CONFIG_FILE = CONFIG_DIR / "config.json"
23
+ TOKEN_FILE = CONFIG_DIR / "token"
24
+
25
+ DEFAULT_BASE_URL = "" # Configure via 'fns config url'
26
+ DEFAULT_VAULT = "" # Auto-detected on first login
27
+
28
+ # Global state for output mode
29
+ _ctx = {}
30
+
31
+ def _echo(text, **kwargs):
32
+ """Print unless quiet mode is enabled."""
33
+ if not _ctx.get("quiet"):
34
+ click.echo(text, **kwargs)
35
+
36
+ def format_timestamp(ts_ms):
37
+ """Convert millisecond Unix timestamp to human-readable local time."""
38
+ if not ts_ms:
39
+ return ""
40
+ try:
41
+ dt = datetime.fromtimestamp(int(ts_ms) / 1000, tz=timezone.utc).astimezone()
42
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
43
+ except (ValueError, OSError, OverflowError):
44
+ return str(ts_ms)
45
+
46
+ def load_config():
47
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
48
+ if CONFIG_FILE.exists():
49
+ return json.loads(CONFIG_FILE.read_text())
50
+ return {"base_url": DEFAULT_BASE_URL, "vault": DEFAULT_VAULT}
51
+
52
+ def save_config(cfg):
53
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
54
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
55
+
56
+ def require_vault():
57
+ """Check if vault is configured, print hint if not."""
58
+ cfg = load_config()
59
+ vault = cfg.get("vault", "")
60
+ if not vault:
61
+ if _ctx.get("json_output"):
62
+ click.echo(json.dumps({"error": "Vault not configured", "hint": "Run: fns vaults or fns config vault <name>"}))
63
+ sys.exit(1)
64
+ click.echo("⚠️ Vault not configured. Run one of these:")
65
+ click.echo(" fns vaults # List available vaults")
66
+ click.echo(" fns config vault <name> # Set your vault")
67
+ sys.exit(1)
68
+ return vault
69
+
70
+ def get_token():
71
+ if TOKEN_FILE.exists():
72
+ return TOKEN_FILE.read_text().strip()
73
+ if _ctx.get("json_output"):
74
+ click.echo(json.dumps({"error": "Token not found", "hint": "Run: fns login"}))
75
+ sys.exit(1)
76
+ _echo("⚠️ Token not found. Run: fns login", err=True)
77
+ sys.exit(1)
78
+
79
+ def _handle_response(data, success_msg=None, error_prefix="Failed"):
80
+ """Handle API response, print success or error based on code/status."""
81
+ code = data.get("code", 0)
82
+ status = data.get("status", False)
83
+
84
+ if _ctx.get("json_output"):
85
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
86
+ # Don't exit in JSON mode for errors, let caller handle
87
+ return data
88
+
89
+ # Success: code 1-6 OR status is True
90
+ if code in range(1, 7) or status is True:
91
+ if success_msg:
92
+ _echo(success_msg)
93
+ return data
94
+ else:
95
+ msg = data.get("message", json.dumps(data, indent=2, ensure_ascii=False))
96
+ _echo(f"❌ {error_prefix}: {msg}", err=True)
97
+ sys.exit(1)
98
+
99
+ def curl_request(method, endpoint, params=None, json_data=None):
100
+ cfg = load_config()
101
+ base_url = cfg.get("base_url", "")
102
+ if not base_url:
103
+ if _ctx.get("json_output"):
104
+ click.echo(json.dumps({"error": "API URL not configured", "hint": "Run: fns config url <url>"}))
105
+ sys.exit(1)
106
+ _echo("⚠️ API URL not configured. Run: fns config url https://your-server/api", err=True)
107
+ sys.exit(1)
108
+
109
+ url = f"{base_url}{endpoint}"
110
+ if params:
111
+ url += f"?{urlencode(params)}"
112
+
113
+ cmd = ["curl", "-s", "-X", method, url,
114
+ "-H", f"Authorization: Bearer {get_token()}"]
115
+
116
+ if json_data is not None:
117
+ cmd.extend(["-H", "Content-Type: application/json",
118
+ "-d", json.dumps(json_data)])
119
+
120
+ try:
121
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15, encoding="utf-8", errors="replace")
122
+ if result.returncode != 0:
123
+ if _ctx.get("json_output"):
124
+ click.echo(json.dumps({"error": "curl error", "detail": result.stderr.strip()}))
125
+ sys.exit(1)
126
+ _echo(f"❌ curl error: {result.stderr.strip()}", err=True)
127
+ sys.exit(1)
128
+ return json.loads(result.stdout)
129
+ except subprocess.TimeoutExpired:
130
+ if _ctx.get("json_output"):
131
+ click.echo(json.dumps({"error": "Request timed out"}))
132
+ sys.exit(1)
133
+ _echo("❌ Request timed out", err=True)
134
+ sys.exit(1)
135
+ except json.JSONDecodeError:
136
+ if _ctx.get("json_output"):
137
+ click.echo(json.dumps({"error": "Invalid JSON response", "detail": result.stdout[:200]}))
138
+ sys.exit(1)
139
+ _echo(f"❌ Invalid JSON response: {result.stdout[:200]}", err=True)
140
+ sys.exit(1)
141
+
142
+ # ==================== Click CLI ====================
143
+
144
+ @click.group()
145
+ @click.version_option(__version__, prog_name="fns-cli")
146
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress non-essential output")
147
+ @click.option("--json", "json_output", is_flag=True, help="Output in JSON format")
148
+ @click.pass_context
149
+ def cli(ctx, quiet, json_output):
150
+ """📝 Fast Note Sync CLI - Interact with your Obsidian FNS service."""
151
+ _ctx["quiet"] = quiet
152
+ _ctx["json_output"] = json_output
153
+
154
+ @cli.command()
155
+ @click.argument("credentials", required=False)
156
+ @click.argument("password", required=False)
157
+ @click.option("-u", "--url", "api_url", help="Set API URL before login")
158
+ def login(credentials, password, api_url):
159
+ """Login and save token (password will be hidden if not provided)."""
160
+ cfg = load_config()
161
+
162
+ # Step 1: Ensure URL is configured
163
+ base_url = cfg.get("base_url", "")
164
+ if api_url:
165
+ url_val = api_url.rstrip("/")
166
+ if not url_val.endswith("/api"):
167
+ url_val += "/api"
168
+ cfg["base_url"] = url_val
169
+ save_config(cfg)
170
+ _echo(f"✅ API URL set to '{url_val}'")
171
+ base_url = url_val
172
+ elif not base_url:
173
+ base_url = click.prompt("Enter FNS server URL (e.g., https://your-server)")
174
+ base_url = base_url.rstrip("/")
175
+ if not base_url.endswith("/api"):
176
+ base_url += "/api"
177
+ cfg["base_url"] = base_url
178
+ save_config(cfg)
179
+ _echo(f"✅ API URL set to '{base_url}'")
180
+
181
+ # Step 2: Prompt for credentials if not provided
182
+ if not credentials:
183
+ credentials = click.prompt("Username or email")
184
+ if not password:
185
+ password = click.prompt("Password", hide_input=True)
186
+
187
+ # Step 3: Authenticate
188
+ url = f"{base_url}/user/login"
189
+ cmd = ["curl", "-s", "-X", "POST", url,
190
+ "-H", "Content-Type: application/json",
191
+ "-d", json.dumps({"Credentials": credentials, "Password": password})]
192
+ try:
193
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15, encoding="utf-8", errors="replace")
194
+ resp = json.loads(result.stdout)
195
+ if _ctx.get("json_output"):
196
+ click.echo(json.dumps(resp, indent=2, ensure_ascii=False))
197
+ return
198
+
199
+ if resp.get("status") or resp.get("code", 0) >= 1:
200
+ token = resp.get("data", {}).get("token")
201
+ if token:
202
+ TOKEN_FILE.write_text(token)
203
+ _echo(f"✅ Login successful. Token saved to {TOKEN_FILE}")
204
+
205
+ # Step 4: Handle vault selection
206
+ if not cfg.get("vault"):
207
+ vault_data = curl_request("GET", "/vault")
208
+ vaults_list = []
209
+ if isinstance(vault_data.get("data"), list):
210
+ vaults_list = vault_data["data"]
211
+ elif isinstance(vault_data.get("data"), dict):
212
+ vaults_list = vault_data["data"].get("list", [vault_data["data"]])
213
+
214
+ if len(vaults_list) == 1:
215
+ v = vaults_list[0]
216
+ vault_name = v.get("vault", v.get("name", v.get("vault_name", str(v.get("id", "")))))
217
+ cfg["vault"] = vault_name
218
+ save_config(cfg)
219
+ _echo(f"📦 Auto-set vault to '{vault_name}'")
220
+ elif vaults_list:
221
+ _echo("📦 Available vaults:")
222
+ choices = []
223
+ for i, v in enumerate(vaults_list, 1):
224
+ name = v.get("vault", v.get("name", v.get("vault_name", str(v.get("id", "")))))
225
+ choices.append(str(i))
226
+ _echo(f" {i}. {name}")
227
+ selected = click.prompt("Select vault", type=click.Choice(choices), default=choices[0])
228
+ idx = int(selected) - 1
229
+ v = vaults_list[idx]
230
+ vault_name = v.get("vault", v.get("name", v.get("vault_name", str(v.get("id", "")))))
231
+ cfg["vault"] = vault_name
232
+ save_config(cfg)
233
+ _echo(f"📦 Vault set to '{vault_name}'")
234
+ _echo("🎉 Ready! Try: fns list")
235
+ else:
236
+ _echo("❌ No token in response.", err=True)
237
+ else:
238
+ _echo(f"❌ Login failed: {resp.get('message', 'Unknown error')}", err=True)
239
+ except Exception as e:
240
+ _echo(f"❌ Error: {e}", err=True)
241
+
242
+ @cli.command()
243
+ @click.argument("path")
244
+ def read(path):
245
+ """Read a note."""
246
+ vault = require_vault()
247
+ data = curl_request("GET", "/note", params={"vault": vault, "path": path})
248
+ if _ctx.get("json_output"):
249
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
250
+ return
251
+
252
+ content = data.get("data", {}).get("content") if isinstance(data.get("data"), dict) else None
253
+ if content is not None:
254
+ _echo(f"📄 {path}\n{'-'*40}\n{content}")
255
+ else:
256
+ _echo(f"❌ Unexpected response: {json.dumps(data, indent=2, ensure_ascii=False)}", err=True)
257
+
258
+ @cli.command()
259
+ @click.argument("path")
260
+ @click.argument("content_or_file")
261
+ def write(path, content_or_file):
262
+ """Create/Update note (use @file.txt for local file)."""
263
+ vault = require_vault()
264
+ if content_or_file.startswith("@"):
265
+ file_path = content_or_file[1:]
266
+ if Path(file_path).exists():
267
+ content = Path(file_path).read_text(encoding="utf-8")
268
+ else:
269
+ _echo(f"❌ File not found: {file_path}", err=True)
270
+ return
271
+ elif Path(content_or_file).exists():
272
+ content = Path(content_or_file).read_text(encoding="utf-8")
273
+ else:
274
+ content = content_or_file
275
+
276
+ data = curl_request("POST", "/note", json_data={"vault": vault, "path": path, "content": content})
277
+ _handle_response(data, success_msg=f"✅ Note '{path}' updated. Syncing to all devices...")
278
+
279
+ @cli.command()
280
+ @click.argument("path")
281
+ @click.argument("content")
282
+ def append(path, content):
283
+ """Append text to a note (use @file.txt for local file)."""
284
+ vault = require_vault()
285
+ if content.startswith("@"):
286
+ file_path = content[1:]
287
+ if Path(file_path).exists():
288
+ content = Path(file_path).read_text(encoding="utf-8")
289
+ else:
290
+ _echo(f"❌ File not found: {file_path}", err=True)
291
+ return
292
+
293
+ params = {"vault": vault, "path": path}
294
+ read_resp = curl_request("GET", "/note", params=params)
295
+ existing = ""
296
+ if isinstance(read_resp.get("data"), dict):
297
+ existing = read_resp["data"].get("content", "")
298
+
299
+ if existing and not existing.endswith("\n\n"):
300
+ if existing.endswith("\n"):
301
+ content = "\n" + content
302
+ else:
303
+ content = "\n\n" + content
304
+
305
+ data = curl_request("POST", "/note/append", json_data={"vault": vault, "path": path, "content": content})
306
+ _handle_response(data, success_msg=f"✅ Appended to '{path}'.")
307
+
308
+ @cli.command()
309
+ @click.argument("path")
310
+ def delete(path):
311
+ """Delete a note (move to recycle bin)."""
312
+ vault = require_vault()
313
+ data = curl_request("DELETE", "/note", params={"vault": vault, "path": path})
314
+ _handle_response(data, success_msg=f"✅ Note '{path}' deleted (moved to recycle bin).")
315
+
316
+ @cli.command()
317
+ @click.argument("path")
318
+ @click.argument("content_or_file")
319
+ def prepend(path, content_or_file):
320
+ """Prepend text to a note (after frontmatter, use @file.txt for local file)."""
321
+ vault = require_vault()
322
+ if content_or_file.startswith("@"):
323
+ file_path = content_or_file[1:]
324
+ if Path(file_path).exists():
325
+ content = Path(file_path).read_text(encoding="utf-8")
326
+ else:
327
+ _echo(f"❌ File not found: {file_path}", err=True)
328
+ return
329
+ else:
330
+ content = content_or_file
331
+
332
+ data = curl_request("POST", "/note/prepend", json_data={"vault": vault, "path": path, "content": content})
333
+ _handle_response(data, success_msg=f"✅ Prepended to '{path}'.")
334
+
335
+ @cli.command()
336
+ @click.argument("path")
337
+ @click.argument("search")
338
+ @click.argument("replace_text")
339
+ def replace(path, search, replace_text):
340
+ """Find and replace in note."""
341
+ vault = require_vault()
342
+ data = curl_request("POST", "/note/replace", json_data={
343
+ "vault": vault, "path": path, "find": search, "replace": replace_text
344
+ })
345
+ if _ctx.get("json_output"):
346
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
347
+ return
348
+
349
+ if data.get("code", 0) >= 1 or data.get("status"):
350
+ replacements = data.get("data", {}).get("count", "?")
351
+ _echo(f"✅ Replaced {replacements} occurrence(s) in '{path}'.")
352
+ else:
353
+ _echo(f"❌ Failed to replace: {json.dumps(data, indent=2, ensure_ascii=False)}", err=True)
354
+
355
+ @cli.command(name="move")
356
+ @click.argument("old_path")
357
+ @click.argument("new_path")
358
+ def move_note(old_path, new_path):
359
+ """Move/rename a note."""
360
+ vault = require_vault()
361
+ data = curl_request("POST", "/note/move", json_data={
362
+ "vault": vault, "path": old_path, "destination": new_path
363
+ })
364
+ _handle_response(data, success_msg=f"✅ Moved '{old_path}' → '{new_path}'.")
365
+
366
+ @cli.command()
367
+ @click.argument("path")
368
+ @click.option("--page", default=1, help="Page number")
369
+ def history(path, page):
370
+ """Show note history."""
371
+ vault = require_vault()
372
+ data = curl_request("GET", "/note/histories", params={
373
+ "vault": vault, "path": path, "page": page, "pageSize": 20
374
+ })
375
+ if _ctx.get("json_output"):
376
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
377
+ return
378
+
379
+ histories = []
380
+ if isinstance(data.get("data"), dict):
381
+ histories = data["data"].get("list", [])
382
+ elif isinstance(data.get("data"), list):
383
+ histories = data["data"]
384
+
385
+ if histories:
386
+ _echo(f"📜 History for '{path}':\n")
387
+ for h in histories:
388
+ hid = h.get("id", h.get("historyId", ""))
389
+ mtime = h.get("mtime", h.get("updatedTimestamp", h.get("createdTimestamp", "")))
390
+ readable = format_timestamp(mtime) if mtime else ""
391
+ size = h.get("size", h.get("contentLength", ""))
392
+ _echo(f" 📄 [{hid}] {readable} ({size} bytes)")
393
+ _echo()
394
+ else:
395
+ _echo(f"📭 No history found for '{path}'.")
396
+
397
+ @cli.command("list")
398
+ @click.argument("keyword", required=False, default="")
399
+ @click.option("--page", default=1, help="Page number")
400
+ def list_notes(keyword, page):
401
+ """List notes (optional keyword search)."""
402
+ vault = require_vault()
403
+ data = curl_request("GET", "/notes", params={"vault": vault, "keyword": keyword, "page": page, "pageSize": 20})
404
+
405
+ if _ctx.get("json_output"):
406
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
407
+ return
408
+
409
+ notes = []
410
+ pager_info = {}
411
+ if isinstance(data.get("data"), dict):
412
+ notes = data["data"].get("list", [])
413
+ pager_info = data["data"].get("pager", {})
414
+ elif isinstance(data, dict):
415
+ notes = data.get("list", data.get("notes", []))
416
+
417
+ if notes:
418
+ total = pager_info.get("totalRows", len(notes)) if pager_info else len(notes)
419
+ _echo(f"📚 Notes in '{vault}' (Page {page}):\n")
420
+ for n in notes:
421
+ path = n.get("path", n.get("name", n.get("title", "unknown")))
422
+ mtime = n.get("mtime", n.get("modified", ""))
423
+ if mtime:
424
+ _echo(f" 📄 {path} ({format_timestamp(mtime)})")
425
+ else:
426
+ _echo(f" 📄 {path}")
427
+ _echo(f"\nTotal: {total}")
428
+ else:
429
+ _echo("📭 No notes found.")
430
+
431
+ @cli.command()
432
+ def vaults():
433
+ """List available vaults."""
434
+ data = curl_request("GET", "/vault")
435
+ if _ctx.get("json_output"):
436
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
437
+ return
438
+
439
+ vault_list = []
440
+ if isinstance(data.get("data"), list):
441
+ vault_list = data["data"]
442
+ elif isinstance(data.get("data"), dict):
443
+ vault_list = data["data"].get("list", [data["data"]])
444
+
445
+ if vault_list:
446
+ _echo("📦 Available vaults:")
447
+ for v in vault_list:
448
+ name = v.get("vault", v.get("name", v.get("vault_name", v.get("id", "unknown"))))
449
+ _echo(f" 🗄️ {name}")
450
+ else:
451
+ _echo(f"📭 No vaults found.")
452
+
453
+ @cli.command()
454
+ def info():
455
+ """Show current user info."""
456
+ data = curl_request("GET", "/user/info")
457
+ if _ctx.get("json_output"):
458
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
459
+ return
460
+
461
+ user = data.get("data", {})
462
+ if user:
463
+ _echo("👤 Current user:")
464
+ for key in ("username", "email", "displayName", "id", "role"):
465
+ if key in user and user[key]:
466
+ _echo(f" {key}: {user[key]}")
467
+ else:
468
+ _echo(f"❌ Failed to fetch user info.", err=True)
469
+
470
+ @cli.group()
471
+ def config():
472
+ """Manage configuration."""
473
+ pass
474
+
475
+ @config.command("show")
476
+ def config_show():
477
+ """Show current configuration."""
478
+ cfg = load_config()
479
+ base_url = cfg.get("base_url", "(not configured)")
480
+ vault = cfg.get("vault", "(not configured)")
481
+
482
+ _echo("📋 Current configuration:")
483
+ _echo(f" API URL : {base_url}")
484
+ _echo(f" Vault : {vault}")
485
+
486
+ # Fetch user info if token exists
487
+ if TOKEN_FILE.exists():
488
+ try:
489
+ data = curl_request("GET", "/user/info")
490
+ user = data.get("data", {})
491
+ if user:
492
+ username = user.get("username", user.get("email", user.get("displayName", "")))
493
+ if username:
494
+ _echo(f" User : {username}")
495
+ else:
496
+ _echo(" User : (logged in, no username)")
497
+ else:
498
+ _echo(" User : (token invalid)")
499
+ except SystemExit:
500
+ _echo(" User : (could not fetch)")
501
+ else:
502
+ _echo(" User : (not logged in)")
503
+
504
+ @config.command()
505
+ @click.argument("value")
506
+ def vault(value):
507
+ """Set vault name."""
508
+ cfg = load_config()
509
+ cfg["vault"] = value
510
+ save_config(cfg)
511
+ _echo(f"✅ Vault set to '{value}'")
512
+
513
+ @config.command()
514
+ @click.argument("value")
515
+ def url(value):
516
+ """Set API URL."""
517
+ url_val = value.rstrip("/")
518
+ if not url_val.endswith("/api"):
519
+ url_val += "/api"
520
+ cfg = load_config()
521
+ cfg["base_url"] = url_val
522
+ save_config(cfg)
523
+ _echo(f"✅ API URL set to '{url_val}'")
524
+
525
+ @cli.command()
526
+ @click.argument("path", required=False, default="")
527
+ def tree(path):
528
+ """Show vault folder tree structure."""
529
+ vault = require_vault()
530
+ params = {"vault": vault}
531
+ if path:
532
+ params["path"] = path
533
+ data = curl_request("GET", "/folder/tree", params=params)
534
+ if _ctx.get("json_output"):
535
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
536
+ return
537
+
538
+ tree_data = data.get("data", {})
539
+ if tree_data:
540
+ _echo(f"📂 Vault tree for '{vault}'" + (f" > {path}" if path else "") + ":\n")
541
+ _print_tree(tree_data, indent=0)
542
+ else:
543
+ _echo("📭 No tree data found.")
544
+
545
+ def _print_tree(node, indent=0):
546
+ """Recursively print tree structure."""
547
+ prefix = " " * indent + ("├─ " if indent > 0 else "")
548
+ name = node.get("name", node.get("path", "unknown"))
549
+ node_type = node.get("type", node.get("isFolder", "file"))
550
+ icon = "📁" if node_type in ("folder", True) or node.get("isFolder") else "📄"
551
+ _echo(f"{prefix}{icon} {name}")
552
+ children = node.get("children", node.get("sub", []))
553
+ if children:
554
+ for child in children:
555
+ _print_tree(child, indent + 1)
556
+
557
+ @cli.command()
558
+ @click.argument("path")
559
+ def backlinks(path):
560
+ """Show notes that link to this note (backlinks)."""
561
+ vault = require_vault()
562
+ data = curl_request("GET", "/note/backlinks", params={"vault": vault, "path": path})
563
+ if _ctx.get("json_output"):
564
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
565
+ return
566
+
567
+ links = data.get("data", {}).get("list", data.get("data", []))
568
+ if isinstance(links, dict):
569
+ links = links.get("list", [])
570
+ if links:
571
+ _echo(f"🔗 Backlinks for '{path}':\n")
572
+ for link in links:
573
+ link_path = link.get("path", link.get("name", "unknown"))
574
+ _echo(f" 📄 {link_path}")
575
+ _echo(f"\nTotal: {len(links)}")
576
+ else:
577
+ _echo(f"📭 No backlinks found for '{path}'.")
578
+
579
+ @cli.command()
580
+ @click.argument("path")
581
+ def outlinks(path):
582
+ """Show notes that this note links to (outlinks)."""
583
+ vault = require_vault()
584
+ data = curl_request("GET", "/note/outlinks", params={"vault": vault, "path": path})
585
+ if _ctx.get("json_output"):
586
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
587
+ return
588
+
589
+ links = data.get("data", {}).get("list", data.get("data", []))
590
+ if isinstance(links, dict):
591
+ links = links.get("list", [])
592
+ if links:
593
+ _echo(f"🔗 Outlinks from '{path}':\n")
594
+ for link in links:
595
+ link_path = link.get("path", link.get("name", "unknown"))
596
+ _echo(f" 📄 {link_path}")
597
+ _echo(f"\nTotal: {len(links)}")
598
+ else:
599
+ _echo(f"📭 No outlinks found for '{path}'.")
600
+
601
+ @cli.command()
602
+ @click.argument("path")
603
+ def restore(path):
604
+ """Restore a note from the recycle bin."""
605
+ vault = require_vault()
606
+ data = curl_request("POST", "/note/restore", json_data={"vault": vault, "path": path})
607
+ _handle_response(data, success_msg=f"✅ Restored '{path}' from recycle bin.")
608
+
609
+ @cli.command()
610
+ @click.argument("path")
611
+ @click.option("--set", "set_pairs", multiple=True, help="Set frontmatter key=value (can be repeated)")
612
+ @click.option("--remove", "remove_keys", multiple=True, help="Remove frontmatter keys (can be repeated)")
613
+ def frontmatter(path, set_pairs, remove_keys):
614
+ """View or edit note frontmatter."""
615
+ vault = require_vault()
616
+
617
+ # If no changes, just display current frontmatter
618
+ if not set_pairs and not remove_keys:
619
+ data = curl_request("GET", "/note", params={"vault": vault, "path": path})
620
+ if _ctx.get("json_output"):
621
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
622
+ return
623
+ content = data.get("data", {}).get("content", "")
624
+ if content:
625
+ # Extract frontmatter (between --- markers)
626
+ if content.startswith("---"):
627
+ parts = content.split("---", 2)
628
+ if len(parts) >= 3:
629
+ fm = parts[1].strip()
630
+ _echo(f"📋 Frontmatter for '{path}':\n{fm}")
631
+ return
632
+ _echo(f"📋 No frontmatter found for '{path}'.")
633
+ return
634
+
635
+ # Apply changes
636
+ fm_data = {}
637
+ for pair in set_pairs:
638
+ if "=" in pair:
639
+ key, val = pair.split("=", 1)
640
+ fm_data[key.strip()] = val.strip()
641
+
642
+ data = curl_request("PATCH", "/note/frontmatter", json_data={
643
+ "vault": vault, "path": path, "frontmatter": fm_data, "removeKeys": list(remove_keys)
644
+ })
645
+ _handle_response(data, success_msg=f"✅ Frontmatter updated for '{path}'.")
646
+
647
+ @cli.command()
648
+ @click.argument("path")
649
+ @click.option("--expire", help="Expiration time (ISO 8601 or duration like 24h)")
650
+ @click.option("--password", help="Access password for the shared link")
651
+ def share(path, expire, password):
652
+ """Create a shareable link for a note."""
653
+ vault = require_vault()
654
+ payload = {"vault": vault, "path": path}
655
+ if expire:
656
+ payload["expire"] = expire
657
+ if password:
658
+ payload["password"] = password
659
+
660
+ data = curl_request("POST", "/api/share", json_data=payload)
661
+ if _ctx.get("json_output"):
662
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
663
+ return
664
+
665
+ share_data = data.get("data", {})
666
+ if share_data:
667
+ token = share_data.get("token", share_data.get("shareToken", ""))
668
+ url = share_data.get("url", share_data.get("shareUrl", ""))
669
+ _echo(f"🔗 Share link created for '{path}':")
670
+ if url:
671
+ _echo(f" URL: {url}")
672
+ if token:
673
+ _echo(f" Token: {token}")
674
+ if expire:
675
+ _echo(f" Expires: {expire}")
676
+ else:
677
+ _echo(f"❌ Failed to create share link: {json.dumps(data, indent=2, ensure_ascii=False)}", err=True)
678
+
679
+ @cli.command()
680
+ @click.argument("path")
681
+ def unshare(path):
682
+ """Remove sharing for a note."""
683
+ vault = require_vault()
684
+ data = curl_request("DELETE", "/api/share", json_data={"vault": vault, "path": path})
685
+ _handle_response(data, success_msg=f"✅ Sharing removed for '{path}'.")
686
+
687
+ @cli.command()
688
+ @click.argument("vault_id", required=False, default="")
689
+ def vault_info(vault_id):
690
+ """Show vault details."""
691
+ if not vault_id:
692
+ # If no ID provided, show current vault info
693
+ vault = require_vault()
694
+ # Try to get vault info by listing vaults and finding current one
695
+ data = curl_request("GET", "/vault")
696
+ vaults_list = []
697
+ if isinstance(data.get("data"), list):
698
+ vaults_list = data["data"]
699
+ elif isinstance(data.get("data"), dict):
700
+ vaults_list = data["data"].get("list", [data["data"]])
701
+
702
+ for v in vaults_list:
703
+ name = v.get("vault", v.get("name", v.get("vault_name", str(v.get("id", "")))))
704
+ if name == vault:
705
+ vault_id = str(v.get("id", ""))
706
+ break
707
+
708
+ if not vault_id:
709
+ _echo("❌ Vault ID not found.", err=True)
710
+ return
711
+
712
+ data = curl_request("GET", "/vault/get", params={"id": vault_id})
713
+ if _ctx.get("json_output"):
714
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
715
+ return
716
+
717
+ info = data.get("data", {})
718
+ if info:
719
+ _echo(f"📦 Vault info (ID: {vault_id}):")
720
+ for key in ("vault", "name", "noteCount", "noteSize", "fileCount", "fileSize", "createdAt", "updatedAt"):
721
+ if key in info and info[key]:
722
+ _echo(f" {key}: {info[key]}")
723
+ else:
724
+ _echo(f"❌ Vault not found: {vault_id}", err=True)
725
+
726
+ @cli.command("recycle-bin")
727
+ @click.argument("path", required=False, default="")
728
+ def recycle_bin(path):
729
+ """Show notes in the recycle bin."""
730
+ vault = require_vault()
731
+ params = {"vault": vault, "isRecycle": "true", "page": 1, "pageSize": 50}
732
+ if path:
733
+ params["keyword"] = path
734
+ data = curl_request("GET", "/notes", params=params)
735
+
736
+ if _ctx.get("json_output"):
737
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
738
+ return
739
+
740
+ notes = []
741
+ if isinstance(data.get("data"), dict):
742
+ notes = data["data"].get("list", [])
743
+ elif isinstance(data, dict):
744
+ notes = data.get("list", data.get("notes", []))
745
+
746
+ if notes:
747
+ _echo("🗑️ Recycle bin:\n")
748
+ for n in notes:
749
+ note_path = n.get("path", n.get("name", n.get("title", "unknown")))
750
+ mtime = n.get("mtime", n.get("modified", ""))
751
+ readable = format_timestamp(mtime) if mtime else ""
752
+ _echo(f" 📄 {note_path} ({readable})")
753
+ _echo(f"\nTotal: {len(notes)}")
754
+ else:
755
+ _echo("📭 Recycle bin is empty.")
756
+
757
+ @cli.command()
758
+ def version():
759
+ """Show server version."""
760
+ data = curl_request("GET", "/version")
761
+ if _ctx.get("json_output"):
762
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
763
+ return
764
+
765
+ info = data.get("data", data)
766
+ if info:
767
+ _echo("📋 Server version:")
768
+ for key in ("version", "gitTag", "buildTime", "goVersion"):
769
+ if key in info and info[key]:
770
+ _echo(f" {key}: {info[key]}")
771
+ else:
772
+ _echo(f"❌ Failed to get version info.", err=True)
773
+
774
+ @cli.command()
775
+ def health():
776
+ """Check server health status."""
777
+ data = curl_request("GET", "/health")
778
+ if _ctx.get("json_output"):
779
+ click.echo(json.dumps(data, indent=2, ensure_ascii=False))
780
+ return
781
+
782
+ status = data.get("status", data.get("code", 0))
783
+ if status is True or (isinstance(status, int) and status >= 1):
784
+ _echo("✅ Server is healthy.")
785
+ else:
786
+ _echo(f"⚠️ Server health check: {json.dumps(data, indent=2, ensure_ascii=False)}")
787
+
788
+ @cli.command("vault-create")
789
+ @click.argument("name")
790
+ def vault_create(name):
791
+ """Create a new vault (requires confirmation)."""
792
+ vault = require_vault() # Ensure user is authenticated
793
+ click.confirm(f"⚠️ Create new vault '{name}'? This action cannot be undone", abort=True)
794
+
795
+ data = curl_request("POST", "/vault", json_data={"vault": name})
796
+ _handle_response(data, success_msg=f"✅ Vault '{name}' created.")
797
+
798
+ @cli.command("vault-delete")
799
+ @click.argument("vault_id")
800
+ def vault_delete(vault_id):
801
+ """Delete a vault by ID (requires double confirmation)."""
802
+ # First confirmation
803
+ click.confirm(f"⚠️ WARNING: This will permanently delete vault ID '{vault_id}' and ALL its data!", abort=True)
804
+ # Second confirmation
805
+ click.confirm("🚨 Are you absolutely sure? This action CANNOT be undone!", abort=True)
806
+
807
+ data = curl_request("DELETE", "/vault", params={"id": vault_id})
808
+ _handle_response(data, success_msg=f"✅ Vault '{vault_id}' deleted permanently.")
809
+
810
+ if __name__ == "__main__":
811
+ cli()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 crazykuma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.1
2
+ Name: fns-cli
3
+ Version: 0.4.0
4
+ Summary: CLI tool for Fast Note Sync (Obsidian)
5
+ Home-page: https://github.com/crazykuma/fns-cli
6
+ Author: crazykuma
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/crazykuma/fns-cli
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.6
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: click >=8.1
16
+
17
+ **Languages**: 🇺🇸 English | [🇨🇳 中文](README.zh.md)
18
+
19
+ ---
20
+
21
+ # FNS CLI (Fast Note Sync CLI)
22
+
23
+ > **From Local to Cloud**: Transform Obsidian notes from locally-managed files into **cloud-managed, AI-accessible knowledge**. Edit once, sync everywhere.
24
+
25
+ FNS CLI is a powerful command-line tool for interacting with the **[Fast Note Sync (FNS)](https://github.com/haierkeys/fast-note-sync-service)** service. Manage, read, write, and sync your Obsidian notes directly from the terminal — optimized for both **human workflows** and **AI Agent integration**.
26
+
27
+ ## 🎯 Design Philosophy
28
+
29
+ This tool bridges the gap between **local Obsidian editing** and **cloud-based AI knowledge management**:
30
+
31
+ ```
32
+ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
33
+ │ Obsidian App │────▶│ FNS Service │◀────│ FNS CLI (this) │
34
+ │ (Desktop/Mobile│ │ (Cloud Server) │ │ (Terminal/AI) │
35
+ │ Editor) │◀────│ │────▶│ (read/write) │
36
+ └─────────────────┘ └──────────────────┘ └─────────────────┘
37
+
38
+ ┌──────────▼──────────┐
39
+ │ AI Agents │
40
+ │ Claude Code, │
41
+ │ OpenCode, Cursor... │
42
+ └─────────────────────┘
43
+ ```
44
+
45
+ **One edit/update → all devices synced.** Whether you write in Obsidian on your desktop or manage notes via CLI/AI on a server, everything stays in sync through the FNS cloud service.
46
+
47
+ ## ✨ Features
48
+
49
+ ### Core
50
+ - **Full Note CRUD** — Create, read, update, append, prepend, move, delete
51
+ - **Smart Append** — Automatically handles newlines to prevent content merging
52
+ - **Local File Upload** — Use `@file.txt` prefix to upload any local file
53
+ - **Note History** — View and restore previous versions
54
+ - **Recycle Bin** — Recover deleted notes
55
+ - **Find & Replace** — Search and replace content (supports regex)
56
+ - **Cross-Platform** — macOS / Linux / Windows (Python 3.6+)
57
+
58
+ ### Knowledge Graph
59
+ - **Backlinks** — See which notes link to the current note
60
+ - **Outlinks** — See which notes the current note links to
61
+ - **Folder Tree** — Browse your vault's directory structure
62
+
63
+ ### Sharing & Metadata
64
+ - **Share Links** — Create shareable URLs with optional password and expiry
65
+ - **Frontmatter Editing** — View and modify note metadata (tags, title, etc.)
66
+
67
+ ### AI Agent Friendly
68
+ - **`--json` Mode** — Machine-readable output for AI parsing
69
+ - **`--quiet` Mode** — Silent operation for scripting
70
+ - **Zero interactive prompts** when arguments are provided
71
+
72
+ ### Administration
73
+ - **Vault Management** — List, view details, create, delete vaults
74
+ - **Server Status** — Check version and health
75
+ - **Auto-Setup** — Interactive guide on first login (URL → credentials → vault selection)
76
+
77
+ ## 📦 Installation
78
+
79
+ ```bash
80
+ git clone https://github.com/crazykuma/fns-cli.git
81
+ cd fns-cli
82
+ pip install -e .
83
+ ```
84
+
85
+ This installs the `fns` command globally on your system.
86
+
87
+ **Dependencies**: `click>=8.1` (installed automatically) + `curl` (pre-installed on most systems)
88
+
89
+ ## ⚙️ Quick Setup
90
+
91
+ ### First-Time (Interactive Guide)
92
+
93
+ ```bash
94
+ fns login
95
+ # Enter FNS server URL: https://your-server
96
+ # Username or email: you@example.com
97
+ # Password: **** (hidden)
98
+ # 📦 Available vaults:
99
+ # 1. defaultVault
100
+ # Select vault [1]: 1
101
+ # 🎉 Ready! Try: fns list
102
+ ```
103
+
104
+ ### Script-Friendly (Non-Interactive)
105
+
106
+ ```bash
107
+ fns login -u https://your-server username password
108
+ ```
109
+
110
+ ### Manual Configuration
111
+
112
+ ```bash
113
+ fns config url "https://your-server/api"
114
+ ```
115
+
116
+ ## 🚀 Command Reference
117
+
118
+ ### Authentication & Setup
119
+ ```bash
120
+ fns login [user] [pass] [-u URL] # Login (interactive if args omitted)
121
+ fns config show # Show current configuration
122
+ fns config url <value> # Set API URL
123
+ fns config vault <value> # Set vault name
124
+ ```
125
+
126
+ ### Note CRUD
127
+ ```bash
128
+ fns read <path> # Read a note
129
+ fns write <path> <text|@file> # Create/overwrite (use @ to upload local file)
130
+ fns append <path> <text|@file> # Append content (smart newline handling)
131
+ fns prepend <path> <text|@file> # Prepend content (after frontmatter)
132
+ fns delete <path> # Delete note (moves to recycle bin)
133
+ fns move <old> <new> # Move/rename a note
134
+ fns replace <path> <find> <replace> # Find and replace (supports regex)
135
+ fns history <path> # View revision history
136
+ fns restore <path> # Restore note from recycle bin
137
+ ```
138
+
139
+ ### Knowledge & Links
140
+ ```bash
141
+ fns list [keyword] # List/search notes
142
+ fns tree [path] # View folder tree structure
143
+ fns backlinks <path> # Notes linking to this one
144
+ fns outlinks <path> # Notes this one links to
145
+ ```
146
+
147
+ ### Sharing & Metadata
148
+ ```bash
149
+ fns share <path> [--expire 24h] [--password secret] # Create share link
150
+ fns unshare <path> # Remove sharing
151
+ fns frontmatter <path> # View frontmatter
152
+ fns frontmatter <path> --set key=value --remove key # Edit frontmatter
153
+ ```
154
+
155
+ ### Vault & Server
156
+ ```bash
157
+ fns vaults # List available vaults
158
+ fns vault-info [id] # Show vault details
159
+ fns vault-create <name> # Create vault (with confirmation)
160
+ fns vault-delete <id> # Delete vault (double confirmation)
161
+ fns recycle-bin [keyword] # View recycle bin
162
+ fns version # Show server version
163
+ fns health # Check server health
164
+ fns info # Show current user info
165
+ ```
166
+
167
+ ### Global Flags
168
+ ```bash
169
+ fns --json <command> # Output as JSON (for AI/script parsing)
170
+ fns --quiet <command> # Suppress non-essential output
171
+ fns --version / -v # Show version
172
+ fns --help # Show help
173
+ ```
174
+
175
+ ## 🤖 AI Agent Integration
176
+
177
+ This tool is designed to give AI agents **long-term memory and knowledge access**:
178
+
179
+ - **Read Context**: `fns read` specific notes before coding tasks
180
+ - **Auto-Documentation**: `fns append` changelogs to daily notes
181
+ - **Knowledge Retrieval**: `fns list` / `fns tree` to discover relevant files
182
+ - **Knowledge Graph**: `fns backlinks` / `fns outlinks` to find related notes
183
+
184
+ **Example with AI Agent:**
185
+ ```bash
186
+ # Ask AI to read context, work, then document
187
+ fns read "projects/architecture.md" --json
188
+ # ... AI works ...
189
+ fns append "daily/2024-05-20.md" "- Completed architecture review"
190
+ ```
191
+
192
+ ## 📁 File Structure
193
+
194
+ ```
195
+ fns-cli/
196
+ ├── fns.py # Main CLI logic
197
+ ├── setup.py # Installation script
198
+ ├── requirements.txt # Dependencies
199
+ ├── tests/
200
+ │ └── test_fns.py # Unit tests
201
+ ├── README.md # This file
202
+ ├── README.zh.md # Chinese version
203
+ ├── skill.md # Usage examples
204
+ ├── CHANGELOG.md # Version history
205
+ └── LICENSE # MIT License
206
+ ```
207
+
208
+ ## 📖 Usage Examples
209
+
210
+ For detailed examples, see the portable Skill at [`fns-skill/SKILL.md`](fns-skill/SKILL.md). You can copy the entire `fns-skill/` directory into your own AI agent's Skills folder (Qwen Code, Claude Code, etc.).
211
+
212
+ ## 🧪 Running Tests
213
+
214
+ ```bash
215
+ python -m unittest discover -s tests -v
216
+ ```
217
+
218
+ ## 🔗 Related Projects
219
+
220
+ - **[fast-note-sync-service](https://github.com/haierkeys/fast-note-sync-service)** — The FNS backend server
221
+ - **[obsidian-fast-note-sync](https://github.com/haierkeys/obsidian-fast-note-sync)** — Obsidian plugin
222
+
223
+ ## 📜 License
224
+
225
+ MIT License — see [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ fns.py,sha256=cuTZUE_S_h2hgHiJ27296AWcN4DBQ6GYwP1tBGTt22E,31072
2
+ fns_cli-0.4.0.dist-info/LICENSE,sha256=jLzkeEmVeifYxrjYdb7wC3uNxoni6pto78dsrrYBcJE,1087
3
+ fns_cli-0.4.0.dist-info/METADATA,sha256=EwVt58OS9-Rr4_kDHxMxrCZRgVwzVF3O48z1C2N2cdQ,8842
4
+ fns_cli-0.4.0.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
5
+ fns_cli-0.4.0.dist-info/entry_points.txt,sha256=z6L8PYKEGYrFOOCkBQ89ulMseM6x-j1trskvKQnELyg,32
6
+ fns_cli-0.4.0.dist-info/top_level.txt,sha256=erNDyA50FAObwrbMr8av13iR3x5ZzhNZQXYauns0BOg,4
7
+ fns_cli-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (72.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fns = fns:cli
@@ -0,0 +1 @@
1
+ fns