sahara-memory 0.2.1__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.
sahara/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Sahara — extended storage, searchable memory and instant retrieval."""
2
+
3
+ __version__ = "0.2.1"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,238 @@
1
+ """Claude Desktop MCP configuration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import sys
9
+ import tempfile
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ __all__ = [
15
+ "ClaudeDesktopInstallResult",
16
+ "detect_claude_config_path",
17
+ "install_claude_server",
18
+ "resolve_sahara_executable",
19
+ ]
20
+
21
+ _CONFIG_FILENAME = "claude_desktop_config.json"
22
+ _WINDOWS_PACKAGE_NAMES = (
23
+ "Claude_pzs8sxrjxfjjc",
24
+ "Anthropic.ClaudeDesktop_h6f0761",
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ClaudeDesktopInstallResult:
30
+ """Result of adding Sahara to Claude Desktop."""
31
+
32
+ config_path: Path
33
+ executable_path: Path
34
+ backup_path: Path | None
35
+ changed: bool
36
+
37
+
38
+ def detect_claude_config_path(
39
+ *,
40
+ platform: str | None = None,
41
+ environ: Mapping[str, str] | None = None,
42
+ home: Path | None = None,
43
+ ) -> Path:
44
+ """Return the Claude Desktop config path for macOS or Windows."""
45
+ current_platform = platform or sys.platform
46
+ env = os.environ if environ is None else environ
47
+ user_home = home or Path.home()
48
+
49
+ if current_platform == "darwin":
50
+ return (
51
+ user_home
52
+ / "Library"
53
+ / "Application Support"
54
+ / "Claude"
55
+ / _CONFIG_FILENAME
56
+ )
57
+
58
+ if current_platform == "win32":
59
+ msix_path = _detect_windows_msix_config(env)
60
+ if msix_path is not None:
61
+ return msix_path
62
+
63
+ appdata = env.get("APPDATA")
64
+ if not appdata:
65
+ raise RuntimeError(
66
+ "APPDATA is not set, so the Claude Desktop config path "
67
+ "cannot be detected."
68
+ )
69
+ return Path(appdata) / "Claude" / _CONFIG_FILENAME
70
+
71
+ raise RuntimeError(
72
+ "Claude Desktop local MCP installation is supported on macOS and Windows."
73
+ )
74
+
75
+
76
+ def _detect_windows_msix_config(environ: Mapping[str, str]) -> Path | None:
77
+ local_appdata = environ.get("LOCALAPPDATA")
78
+ if not local_appdata:
79
+ return None
80
+
81
+ packages_dir = Path(local_appdata) / "Packages"
82
+ if not packages_dir.is_dir():
83
+ return None
84
+
85
+ package_dirs: list[Path] = []
86
+ for name in _WINDOWS_PACKAGE_NAMES:
87
+ candidate = packages_dir / name
88
+ if candidate.is_dir():
89
+ package_dirs.append(candidate)
90
+
91
+ known = {path.name.casefold() for path in package_dirs}
92
+ try:
93
+ discovered = sorted(
94
+ (
95
+ path
96
+ for path in packages_dir.iterdir()
97
+ if path.is_dir()
98
+ and "claude" in path.name.casefold()
99
+ and path.name.casefold() not in known
100
+ ),
101
+ key=lambda path: path.name.casefold(),
102
+ )
103
+ except OSError:
104
+ discovered = []
105
+ package_dirs.extend(discovered)
106
+
107
+ config_paths = [
108
+ package
109
+ / "LocalCache"
110
+ / "Roaming"
111
+ / "Claude"
112
+ / _CONFIG_FILENAME
113
+ for package in package_dirs
114
+ ]
115
+ for config_path in config_paths:
116
+ if config_path.is_file():
117
+ return config_path
118
+ return config_paths[0] if config_paths else None
119
+
120
+
121
+ def resolve_sahara_executable(
122
+ executable: Path | None = None,
123
+ *,
124
+ argv0: str | None = None,
125
+ ) -> Path:
126
+ """Resolve the executable Claude Desktop should launch."""
127
+ if executable is not None:
128
+ candidate = executable.expanduser().resolve()
129
+ else:
130
+ invoked_as = argv0 or sys.argv[0]
131
+ located = shutil.which(invoked_as) or shutil.which("sahara")
132
+ candidate = Path(located or invoked_as).expanduser().resolve()
133
+
134
+ if not candidate.is_file():
135
+ raise RuntimeError(
136
+ f"Could not find the Sahara executable at {candidate}. "
137
+ "Reinstall Sahara or pass --executable with its absolute path."
138
+ )
139
+ return candidate
140
+
141
+
142
+ def install_claude_server(
143
+ config_path: Path,
144
+ executable_path: Path,
145
+ *,
146
+ sahara_config_path: Path | None = None,
147
+ ) -> ClaudeDesktopInstallResult:
148
+ """Merge Sahara's stdio MCP server into Claude Desktop configuration."""
149
+ config_path = config_path.expanduser().resolve()
150
+ executable_path = executable_path.expanduser().resolve()
151
+ existing = _read_config(config_path)
152
+
153
+ mcp_servers = existing.get("mcpServers")
154
+ if mcp_servers is None:
155
+ mcp_servers = {}
156
+ existing["mcpServers"] = mcp_servers
157
+ elif not isinstance(mcp_servers, dict):
158
+ raise RuntimeError(
159
+ f"{config_path} has a non-object 'mcpServers' value. "
160
+ "Fix that value before installing Sahara."
161
+ )
162
+
163
+ args: list[str] = []
164
+ if sahara_config_path is not None:
165
+ args.extend(
166
+ [
167
+ "--config",
168
+ str(sahara_config_path.expanduser().resolve()),
169
+ ]
170
+ )
171
+ args.extend(["mcp", "serve", "--transport", "stdio"])
172
+
173
+ server_config = {
174
+ "command": str(executable_path),
175
+ "args": args,
176
+ }
177
+ changed = mcp_servers.get("sahara") != server_config
178
+ backup_path = None
179
+ if changed:
180
+ mcp_servers["sahara"] = server_config
181
+ if config_path.is_file():
182
+ backup_path = config_path.with_name(
183
+ f"{config_path.name}.sahara-backup"
184
+ )
185
+ shutil.copy2(config_path, backup_path)
186
+ _write_config(config_path, existing)
187
+
188
+ return ClaudeDesktopInstallResult(
189
+ config_path=config_path,
190
+ executable_path=executable_path,
191
+ backup_path=backup_path,
192
+ changed=changed,
193
+ )
194
+
195
+
196
+ def _read_config(config_path: Path) -> dict[str, object]:
197
+ if not config_path.exists():
198
+ return {}
199
+ try:
200
+ content = config_path.read_text(encoding="utf-8")
201
+ except OSError as exc:
202
+ raise RuntimeError(f"Could not read {config_path}: {exc}") from exc
203
+ if not content.strip():
204
+ return {}
205
+ try:
206
+ parsed = json.loads(content)
207
+ except json.JSONDecodeError as exc:
208
+ raise RuntimeError(
209
+ f"{config_path} contains invalid JSON at line {exc.lineno}, "
210
+ f"column {exc.colno}. It was not changed."
211
+ ) from exc
212
+ if not isinstance(parsed, dict):
213
+ raise RuntimeError(
214
+ f"{config_path} must contain a JSON object. It was not changed."
215
+ )
216
+ return parsed
217
+
218
+
219
+ def _write_config(config_path: Path, config: dict[str, object]) -> None:
220
+ config_path.parent.mkdir(parents=True, exist_ok=True)
221
+ temporary_path: Path | None = None
222
+ try:
223
+ with tempfile.NamedTemporaryFile(
224
+ "w",
225
+ encoding="utf-8",
226
+ dir=config_path.parent,
227
+ prefix=f".{config_path.name}.",
228
+ suffix=".tmp",
229
+ delete=False,
230
+ ) as temporary:
231
+ json.dump(config, temporary, indent=2, ensure_ascii=True)
232
+ temporary.write("\n")
233
+ temporary_path = Path(temporary.name)
234
+ os.replace(temporary_path, config_path)
235
+ except OSError as exc:
236
+ if temporary_path is not None:
237
+ temporary_path.unlink(missing_ok=True)
238
+ raise RuntimeError(f"Could not write {config_path}: {exc}") from exc