syncanything 0.1.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.
@@ -0,0 +1,3 @@
1
+ """SyncAnything: one local index for conversations across AI coding tools."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Run SyncAnything with ``python -m syncanything``."""
2
+
3
+ from syncanything.cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
syncanything/cli.py ADDED
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from syncanything import __version__
11
+ from syncanything.index import ConversationIndex, default_db_path
12
+ from syncanything.mcp import run_mcp
13
+ from syncanything.service import SyncAnythingService
14
+ from syncanything.web import serve
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="syncanything",
20
+ description="Search and reference local conversations across AI coding tools.",
21
+ )
22
+ parser.add_argument(
23
+ "--version",
24
+ action="version",
25
+ version=f"%(prog)s {__version__}",
26
+ )
27
+ parser.add_argument("--db", help="Override the SQLite index path.")
28
+ subparsers = parser.add_subparsers(dest="command", required=True)
29
+
30
+ index_parser = subparsers.add_parser("index", help="Refresh the local conversation index.")
31
+ index_parser.add_argument("--force", action="store_true", help="Reparse unchanged files.")
32
+ index_parser.add_argument("--json", action="store_true")
33
+
34
+ search_parser = subparsers.add_parser("search", help="Search indexed sessions.")
35
+ search_parser.add_argument("query")
36
+ search_parser.add_argument(
37
+ "--source", choices=["claude", "codex", "kimi", "pi", "citeanything"]
38
+ )
39
+ search_parser.add_argument("--limit", type=int, default=20)
40
+ search_parser.add_argument("--json", action="store_true")
41
+
42
+ list_parser = subparsers.add_parser("list", help="List recent sessions.")
43
+ list_parser.add_argument(
44
+ "--source", choices=["claude", "codex", "kimi", "pi", "citeanything"]
45
+ )
46
+ list_parser.add_argument("--limit", type=int, default=30)
47
+ list_parser.add_argument("--json", action="store_true")
48
+
49
+ show_parser = subparsers.add_parser("show", help="Render one session as readable Markdown.")
50
+ show_parser.add_argument("session_id")
51
+ show_parser.add_argument("--last", type=int, dest="last_messages")
52
+ show_parser.add_argument("--max-chars", type=int, default=50_000)
53
+ show_parser.add_argument("--json", action="store_true")
54
+
55
+ path_parser = subparsers.add_parser("reference", help="Return a session URI and original path.")
56
+ path_parser.add_argument("session_id")
57
+ path_parser.add_argument("--json", action="store_true")
58
+
59
+ status_parser = subparsers.add_parser("status", help="Show index statistics.")
60
+ status_parser.add_argument("--json", action="store_true")
61
+
62
+ serve_parser = subparsers.add_parser("serve", help="Start the local search interface.")
63
+ serve_parser.add_argument("--host", default="127.0.0.1")
64
+ serve_parser.add_argument("--port", type=int, default=7331)
65
+ serve_parser.add_argument("--no-index", action="store_true")
66
+
67
+ subparsers.add_parser("mcp", help="Run the agent-native MCP server over stdio.")
68
+ return parser
69
+
70
+
71
+ def _print_table(results: list[dict[str, Any]]) -> None:
72
+ for result in results:
73
+ updated = (result.get("updated_at") or "")[:19].replace("T", " ")
74
+ print(f"{result['id']}\t{updated}\t{result['title']}")
75
+ snippet = result.get("snippet")
76
+ if snippet:
77
+ clean = snippet.replace("<mark>", "").replace("</mark>", "").replace("\n", " ")
78
+ print(f" {clean[:220]}")
79
+
80
+
81
+ def _configure_home_from_db(db_path: Path) -> None:
82
+ if "SYNCANYTHING_HOME" not in os.environ:
83
+ os.environ["SYNCANYTHING_HOME"] = str(db_path.parent)
84
+
85
+
86
+ def main(argv: list[str] | None = None) -> int:
87
+ args = build_parser().parse_args(argv)
88
+
89
+ db_path = Path(args.db).expanduser() if args.db else default_db_path()
90
+ if args.db:
91
+ _configure_home_from_db(db_path)
92
+ with ConversationIndex(db_path) as index:
93
+ service = SyncAnythingService(index)
94
+ if args.command == "index":
95
+ report = index.index_all(force=args.force)
96
+ if args.json:
97
+ print(json.dumps(report, ensure_ascii=False, indent=2))
98
+ else:
99
+ print(
100
+ f"Indexed {report['indexed']}; unchanged {report['skipped']}; "
101
+ f"removed {report['removed']}; errors {len(report['errors'])}"
102
+ )
103
+ for source, state in report["sources"].items():
104
+ print(
105
+ f" {source}: found {state['discovered']}, indexed {state['indexed']}, "
106
+ f"unchanged {state['skipped']}, errors {state['errors']}"
107
+ )
108
+ if state.get("sync_error"):
109
+ print(f" connection warning: {state['sync_error']}")
110
+ return 1 if report["errors"] else 0
111
+ if args.command == "search":
112
+ results = service.search_sessions(args.query, source=args.source, limit=args.limit)
113
+ if args.json:
114
+ print(json.dumps(results, ensure_ascii=False, indent=2))
115
+ else:
116
+ _print_table(results)
117
+ return 0
118
+ if args.command == "list":
119
+ results = service.list_sessions(source=args.source, limit=args.limit)
120
+ if args.json:
121
+ print(json.dumps(results, ensure_ascii=False, indent=2))
122
+ else:
123
+ _print_table(results)
124
+ return 0
125
+ if args.command == "show":
126
+ session = service.get_session(
127
+ args.session_id, last_messages=args.last_messages, max_chars=args.max_chars
128
+ )
129
+ if session is None:
130
+ print(f"Session not found: {args.session_id}", file=sys.stderr)
131
+ return 2
132
+ if args.json:
133
+ print(json.dumps(session, ensure_ascii=False, indent=2))
134
+ else:
135
+ print(service.render_markdown(session), end="")
136
+ return 0
137
+ if args.command == "reference":
138
+ reference = service.get_reference(args.session_id)
139
+ if reference is None:
140
+ print(f"Session not found: {args.session_id}", file=sys.stderr)
141
+ return 2
142
+ if args.json:
143
+ print(json.dumps(reference, ensure_ascii=False, indent=2))
144
+ else:
145
+ print(reference["uri"])
146
+ print(reference["path"])
147
+ return 0
148
+ if args.command == "status":
149
+ stats = index.stats()
150
+ if args.json:
151
+ print(json.dumps(stats, ensure_ascii=False, indent=2))
152
+ else:
153
+ print(f"{stats['sessions']} sessions · {stats['messages']} messages · {stats['database']}")
154
+ for source in stats["sources"]:
155
+ print(f" {source['source']}: {source['sessions']} sessions, {source['messages']} messages")
156
+ return 0
157
+ if args.command == "serve":
158
+ if not args.no_index:
159
+ index.index_all()
160
+ serve(index, host=args.host, port=args.port)
161
+ return 0
162
+ if args.command == "mcp":
163
+ if index.stats()["sessions"] == 0:
164
+ index.index_all()
165
+ run_mcp(index)
166
+ return 0
167
+ return 0
168
+
169
+
170
+ if __name__ == "__main__":
171
+ raise SystemExit(main())
@@ -0,0 +1,292 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import ctypes
5
+ import json
6
+ import os
7
+ import platform
8
+ import re
9
+ import subprocess
10
+ import uuid
11
+ from ctypes import wintypes
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ KEYCHAIN_SERVICE = "SyncAnything.CiteAnything"
18
+ SITE_URLS = {
19
+ "international": "https://citeanything.veri-glow.com",
20
+ "china": "https://citeanything.cn",
21
+ }
22
+
23
+
24
+ def syncanything_home() -> Path:
25
+ configured = os.environ.get("SYNCANYTHING_HOME")
26
+ if configured:
27
+ return Path(configured).expanduser()
28
+ try:
29
+ return Path.home() / ".syncanything"
30
+ except RuntimeError:
31
+ return Path.cwd() / ".syncanything"
32
+
33
+
34
+ class DATA_BLOB(ctypes.Structure):
35
+ _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte))]
36
+
37
+
38
+ CRYPTPROTECT_UI_FORBIDDEN = 0x1
39
+
40
+
41
+ def _windows_crypto() -> tuple[Any, Any, Any]:
42
+ """Return type-safe DPAPI functions, loaded only on Windows."""
43
+ crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
44
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
45
+
46
+ protect = crypt32.CryptProtectData
47
+ protect.argtypes = [
48
+ ctypes.POINTER(DATA_BLOB),
49
+ wintypes.LPCWSTR,
50
+ ctypes.POINTER(DATA_BLOB),
51
+ ctypes.c_void_p,
52
+ ctypes.c_void_p,
53
+ wintypes.DWORD,
54
+ ctypes.POINTER(DATA_BLOB),
55
+ ]
56
+ protect.restype = wintypes.BOOL
57
+
58
+ unprotect = crypt32.CryptUnprotectData
59
+ unprotect.argtypes = [
60
+ ctypes.POINTER(DATA_BLOB),
61
+ ctypes.POINTER(wintypes.LPWSTR),
62
+ ctypes.POINTER(DATA_BLOB),
63
+ ctypes.c_void_p,
64
+ ctypes.c_void_p,
65
+ wintypes.DWORD,
66
+ ctypes.POINTER(DATA_BLOB),
67
+ ]
68
+ unprotect.restype = wintypes.BOOL
69
+
70
+ local_free = kernel32.LocalFree
71
+ local_free.argtypes = [ctypes.c_void_p]
72
+ local_free.restype = ctypes.c_void_p
73
+ return protect, unprotect, local_free
74
+
75
+
76
+ def _windows_secret_path(home: Path, connection_id: str) -> Path:
77
+ safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", connection_id)
78
+ return home / "secrets" / f"citeanything-{safe_id}.dpapi"
79
+
80
+
81
+ def _blob_from_bytes(data: bytes) -> tuple[DATA_BLOB, ctypes.Array[ctypes.c_ubyte]]:
82
+ buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data)
83
+ return DATA_BLOB(len(data), buffer), buffer
84
+
85
+
86
+ def _bytes_from_blob(blob: DATA_BLOB, local_free: Any) -> bytes:
87
+ try:
88
+ return ctypes.string_at(blob.pbData, blob.cbData)
89
+ finally:
90
+ local_free(ctypes.cast(blob.pbData, ctypes.c_void_p))
91
+
92
+
93
+ def _protect_windows_secret(secret: str) -> str:
94
+ protect, _, local_free = _windows_crypto()
95
+ plain_blob, plain_buffer = _blob_from_bytes(secret.encode("utf-8"))
96
+ encrypted_blob = DATA_BLOB()
97
+ ok = protect(
98
+ ctypes.byref(plain_blob),
99
+ "SyncAnything CiteAnything API key",
100
+ None,
101
+ None,
102
+ None,
103
+ CRYPTPROTECT_UI_FORBIDDEN,
104
+ ctypes.byref(encrypted_blob),
105
+ )
106
+ _ = plain_buffer
107
+ if not ok:
108
+ raise ctypes.WinError()
109
+ return base64.b64encode(_bytes_from_blob(encrypted_blob, local_free)).decode("ascii")
110
+
111
+
112
+ def _unprotect_windows_secret(encoded: str) -> str:
113
+ _, unprotect, local_free = _windows_crypto()
114
+ encrypted = base64.b64decode(encoded.encode("ascii"))
115
+ encrypted_blob, encrypted_buffer = _blob_from_bytes(encrypted)
116
+ plain_blob = DATA_BLOB()
117
+ ok = unprotect(
118
+ ctypes.byref(encrypted_blob),
119
+ None,
120
+ None,
121
+ None,
122
+ None,
123
+ CRYPTPROTECT_UI_FORBIDDEN,
124
+ ctypes.byref(plain_blob),
125
+ )
126
+ _ = encrypted_buffer
127
+ if not ok:
128
+ return ""
129
+ return _bytes_from_blob(plain_blob, local_free).decode("utf-8")
130
+
131
+
132
+ @dataclass(slots=True)
133
+ class CiteAnythingConnection:
134
+ id: str
135
+ name: str
136
+ base_url: str
137
+ site: str = "custom"
138
+
139
+ def public_dict(self, connected: bool) -> dict[str, Any]:
140
+ return {**asdict(self), "connected": connected}
141
+
142
+
143
+ class ConnectionStore:
144
+ def __init__(self, home: Path | None = None) -> None:
145
+ self.home = home or syncanything_home()
146
+ self.config_path = self.home / "connections.json"
147
+
148
+ def list_citeanything(self) -> list[CiteAnythingConnection]:
149
+ if not self.config_path.exists():
150
+ return []
151
+ try:
152
+ payload = json.loads(self.config_path.read_text(encoding="utf-8"))
153
+ except (OSError, json.JSONDecodeError):
154
+ return []
155
+ connections = payload.get("citeanything", []) if isinstance(payload, dict) else []
156
+ result: list[CiteAnythingConnection] = []
157
+ for item in connections:
158
+ if not isinstance(item, dict):
159
+ continue
160
+ try:
161
+ result.append(
162
+ CiteAnythingConnection(
163
+ id=str(item["id"]),
164
+ name=str(item["name"]),
165
+ base_url=str(item["base_url"]).rstrip("/"),
166
+ site=str(item.get("site") or "custom"),
167
+ )
168
+ )
169
+ except KeyError:
170
+ continue
171
+ return result
172
+
173
+ def public_connections(self) -> list[dict[str, Any]]:
174
+ return [
175
+ connection.public_dict(bool(self.get_secret(connection.id)))
176
+ for connection in self.list_citeanything()
177
+ ]
178
+
179
+ def add_citeanything(
180
+ self, name: str, base_url: str, api_key: str, site: str = "custom"
181
+ ) -> CiteAnythingConnection:
182
+ name = name.strip()
183
+ base_url = base_url.strip().rstrip("/")
184
+ api_key = api_key.strip()
185
+ if not name or not base_url.startswith(("https://", "http://")) or not api_key:
186
+ raise ValueError("连接名称、站点地址和 API key 都不能为空")
187
+ safe_name = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:30] or "account"
188
+ connection = CiteAnythingConnection(
189
+ id=f"{safe_name}-{uuid.uuid4().hex[:8]}",
190
+ name=name,
191
+ base_url=base_url,
192
+ site=site,
193
+ )
194
+ self.set_secret(connection.id, api_key)
195
+ connections = self.list_citeanything()
196
+ connections.append(connection)
197
+ self._write(connections)
198
+ return connection
199
+
200
+ def remove_citeanything(self, connection_id: str) -> bool:
201
+ connections = self.list_citeanything()
202
+ kept = [item for item in connections if item.id != connection_id]
203
+ if len(kept) == len(connections):
204
+ return False
205
+ self.delete_secret(connection_id)
206
+ self._write(kept)
207
+ return True
208
+
209
+ def get_secret(self, connection_id: str) -> str:
210
+ if platform.system() == "Windows":
211
+ path = _windows_secret_path(self.home, connection_id)
212
+ try:
213
+ return _unprotect_windows_secret(path.read_text(encoding="ascii").strip())
214
+ except (OSError, ValueError, UnicodeDecodeError):
215
+ return ""
216
+ if platform.system() != "Darwin":
217
+ return ""
218
+ result = subprocess.run(
219
+ [
220
+ "security",
221
+ "find-generic-password",
222
+ "-a",
223
+ connection_id,
224
+ "-s",
225
+ KEYCHAIN_SERVICE,
226
+ "-w",
227
+ ],
228
+ capture_output=True,
229
+ text=True,
230
+ check=False,
231
+ )
232
+ return result.stdout.strip() if result.returncode == 0 else ""
233
+
234
+ def set_secret(self, connection_id: str, api_key: str) -> None:
235
+ if platform.system() == "Windows":
236
+ path = _windows_secret_path(self.home, connection_id)
237
+ path.parent.mkdir(parents=True, exist_ok=True)
238
+ temporary = path.with_suffix(".dpapi.tmp")
239
+ temporary.write_text(_protect_windows_secret(api_key) + "\n", encoding="ascii")
240
+ temporary.replace(path)
241
+ return
242
+ if platform.system() != "Darwin":
243
+ raise RuntimeError("当前版本仅支持在 macOS 钥匙串中保存连接密钥")
244
+ result = subprocess.run(
245
+ [
246
+ "security",
247
+ "add-generic-password",
248
+ "-U",
249
+ "-a",
250
+ connection_id,
251
+ "-s",
252
+ KEYCHAIN_SERVICE,
253
+ "-w",
254
+ api_key,
255
+ ],
256
+ capture_output=True,
257
+ text=True,
258
+ check=False,
259
+ )
260
+ if result.returncode != 0:
261
+ raise RuntimeError(result.stderr.strip() or "无法写入 macOS 钥匙串")
262
+
263
+ def delete_secret(self, connection_id: str) -> None:
264
+ if platform.system() == "Windows":
265
+ try:
266
+ _windows_secret_path(self.home, connection_id).unlink()
267
+ except FileNotFoundError:
268
+ pass
269
+ return
270
+ if platform.system() == "Darwin":
271
+ subprocess.run(
272
+ [
273
+ "security",
274
+ "delete-generic-password",
275
+ "-a",
276
+ connection_id,
277
+ "-s",
278
+ KEYCHAIN_SERVICE,
279
+ ],
280
+ capture_output=True,
281
+ check=False,
282
+ )
283
+
284
+ def _write(self, connections: list[CiteAnythingConnection]) -> None:
285
+ self.home.mkdir(parents=True, exist_ok=True)
286
+ payload = {"version": 1, "citeanything": [asdict(item) for item in connections]}
287
+ temporary = self.config_path.with_suffix(".json.tmp")
288
+ temporary.write_text(
289
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
290
+ )
291
+ os.chmod(temporary, 0o600)
292
+ temporary.replace(self.config_path)