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.
- syncanything/__init__.py +3 -0
- syncanything/__main__.py +7 -0
- syncanything/cli.py +171 -0
- syncanything/connections.py +292 -0
- syncanything/index.py +417 -0
- syncanything/mcp.py +194 -0
- syncanything/models.py +29 -0
- syncanything/service.py +167 -0
- syncanything/sources/__init__.py +21 -0
- syncanything/sources/base.py +105 -0
- syncanything/sources/citeanything.py +262 -0
- syncanything/sources/claude.py +72 -0
- syncanything/sources/codex.py +85 -0
- syncanything/sources/kimi.py +100 -0
- syncanything/sources/pi.py +75 -0
- syncanything/static/__init__.py +1 -0
- syncanything/static/app.js +293 -0
- syncanything/static/index.html +127 -0
- syncanything/static/logo.svg +11 -0
- syncanything/static/styles.css +155 -0
- syncanything/web.py +191 -0
- syncanything-0.1.0.dist-info/METADATA +202 -0
- syncanything-0.1.0.dist-info/RECORD +27 -0
- syncanything-0.1.0.dist-info/WHEEL +5 -0
- syncanything-0.1.0.dist-info/entry_points.txt +2 -0
- syncanything-0.1.0.dist-info/licenses/LICENSE +21 -0
- syncanything-0.1.0.dist-info/top_level.txt +1 -0
syncanything/__init__.py
ADDED
syncanything/__main__.py
ADDED
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)
|