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 +811 -0
- fns_cli-0.4.0.dist-info/LICENSE +21 -0
- fns_cli-0.4.0.dist-info/METADATA +225 -0
- fns_cli-0.4.0.dist-info/RECORD +7 -0
- fns_cli-0.4.0.dist-info/WHEEL +5 -0
- fns_cli-0.4.0.dist-info/entry_points.txt +2 -0
- fns_cli-0.4.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
fns
|