minder-cli 0.2.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.
Files changed (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
minder/cli.py ADDED
@@ -0,0 +1,1542 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import configparser
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tomllib
12
+ from getpass import getpass
13
+ from importlib.metadata import (
14
+ PackageNotFoundError,
15
+ metadata as package_metadata,
16
+ version as package_version,
17
+ )
18
+ from pathlib import Path
19
+ from typing import Any
20
+ from urllib.parse import urlsplit, urlunsplit
21
+
22
+ import httpx
23
+
24
+ from minder.tools.repo_scanner import RepoScanner
25
+
26
+ _DEFAULT_SERVER_URL = "http://localhost:8801/sse"
27
+ _LOCAL_MCP_TARGETS = ("vscode", "cursor", "claude-code")
28
+ _PYPI_JSON_URL = "https://pypi.org/pypi/minder/json"
29
+ _IDE_GITIGNORE_KEY = "minder-ide-bootstrap"
30
+ _DEFAULT_RELEASE_REPOSITORY_URL = "https://github.com/hiimtrung/minder"
31
+ _GITHUB_RELEASE_REPOSITORY_API = "https://api.github.com/repos"
32
+ _SERVER_RELEASE_METADATA_NAME = ".minder-release.json"
33
+
34
+
35
+ def _bootstrap_version() -> str:
36
+ return _installed_package_version() or "dev"
37
+
38
+
39
+ def _client_config_path() -> Path:
40
+ return Path.home() / ".minder" / "client.json"
41
+
42
+
43
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
46
+
47
+
48
+ def _load_json(path: Path) -> dict[str, Any]:
49
+ if not path.is_file():
50
+ return {}
51
+ return json.loads(path.read_text(encoding="utf-8"))
52
+
53
+
54
+ def _marker_pair(path: Path, key: str) -> tuple[str, str]:
55
+ if path.name == ".gitignore":
56
+ return (f"# minder:begin {key}", f"# minder:end {key}")
57
+ return (f"<!-- minder:begin {key} -->", f"<!-- minder:end {key} -->")
58
+
59
+
60
+ def _wrap_managed_block(path: Path, key: str, body: str) -> str:
61
+ start, end = _marker_pair(path, key)
62
+ normalized_body = body.strip("\n")
63
+ return f"{start}\n{normalized_body}\n{end}\n"
64
+
65
+
66
+ def _upsert_managed_block(path: Path, key: str, body: str) -> None:
67
+ block = _wrap_managed_block(path, key, body)
68
+ existing = path.read_text(encoding="utf-8") if path.is_file() else ""
69
+ start, end = _marker_pair(path, key)
70
+ if start in existing and end in existing:
71
+ before, remainder = existing.split(start, 1)
72
+ _, after = remainder.split(end, 1)
73
+ updated = before.rstrip()
74
+ if updated:
75
+ updated += "\n\n"
76
+ updated += block.rstrip("\n")
77
+ tail = after.strip("\n")
78
+ if tail:
79
+ updated += "\n\n" + tail
80
+ updated += "\n"
81
+ else:
82
+ updated = existing.rstrip("\n")
83
+ if updated:
84
+ updated += "\n\n"
85
+ updated += block
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ path.write_text(updated, encoding="utf-8")
88
+
89
+
90
+ def _remove_managed_block(path: Path, key: str) -> bool:
91
+ if not path.is_file():
92
+ return False
93
+ existing = path.read_text(encoding="utf-8")
94
+ start, end = _marker_pair(path, key)
95
+ if start not in existing or end not in existing:
96
+ return False
97
+ before, remainder = existing.split(start, 1)
98
+ _, after = remainder.split(end, 1)
99
+ updated = before.rstrip("\n")
100
+ tail = after.strip("\n")
101
+ if updated and tail:
102
+ updated = f"{updated}\n\n{tail}\n"
103
+ elif updated:
104
+ updated = f"{updated}\n"
105
+ elif tail:
106
+ updated = f"{tail}\n"
107
+ else:
108
+ updated = ""
109
+ if updated:
110
+ path.write_text(updated, encoding="utf-8")
111
+ else:
112
+ path.unlink(missing_ok=True)
113
+ return True
114
+
115
+
116
+ def _prompt_client_key() -> str:
117
+ client_key = getpass("Minder client key (mkc_...): ").strip()
118
+ if not client_key:
119
+ raise ValueError("Client key is required")
120
+ return client_key
121
+
122
+
123
+ def _require_client_settings(config_path: Path) -> dict[str, Any]:
124
+ payload = _load_json(config_path)
125
+ client_key = str(payload.get("client_api_key", "")).strip()
126
+ server_url = str(payload.get("server_url", "")).strip()
127
+ if not client_key:
128
+ raise ValueError(f"No client_api_key found in {config_path}")
129
+ if not server_url:
130
+ raise ValueError(f"No server_url found in {config_path}")
131
+ return payload
132
+
133
+
134
+ def _parse_version(raw_version: str) -> tuple[int, ...]:
135
+ normalized = raw_version.strip().lstrip("v")
136
+ parts: list[int] = []
137
+ for piece in normalized.split("."):
138
+ digits = "".join(char for char in piece if char.isdigit())
139
+ if not digits:
140
+ break
141
+ parts.append(int(digits))
142
+ return tuple(parts)
143
+
144
+
145
+ def _installed_package_version() -> str | None:
146
+ try:
147
+ return package_version("minder")
148
+ except PackageNotFoundError:
149
+ return None
150
+
151
+
152
+ def _latest_pypi_version() -> str | None:
153
+ try:
154
+ response = httpx.get(_PYPI_JSON_URL, timeout=3)
155
+ response.raise_for_status()
156
+ except Exception:
157
+ return None
158
+ payload = response.json()
159
+ info = payload.get("info", {}) if isinstance(payload, dict) else {}
160
+ version_value = info.get("version")
161
+ if not isinstance(version_value, str) or not version_value.strip():
162
+ return None
163
+ return version_value.strip()
164
+
165
+
166
+ def _maybe_print_upgrade_notice() -> None:
167
+ installed = _installed_package_version()
168
+ latest = _latest_pypi_version()
169
+ if installed is None or latest is None:
170
+ return
171
+ if _parse_version(latest) <= _parse_version(installed):
172
+ return
173
+ print(
174
+ f"A newer minder CLI is available ({installed} -> {latest}). "
175
+ "Run 'uv tool upgrade minder' or 'pipx upgrade minder'."
176
+ )
177
+
178
+
179
+ def _project_repository_url() -> str:
180
+ override = os.getenv("MINDER_RELEASE_REPOSITORY", "").strip()
181
+ if override:
182
+ if override.startswith("http://") or override.startswith("https://"):
183
+ return override
184
+ return f"https://github.com/{override.strip('/')}"
185
+ try:
186
+ metadata = package_metadata("minder")
187
+ except PackageNotFoundError:
188
+ return _DEFAULT_RELEASE_REPOSITORY_URL
189
+ for value in metadata.get_all("Project-URL") or []:
190
+ label, _, url = value.partition(",")
191
+ if label.strip().lower() == "repository" and url.strip():
192
+ return url.strip()
193
+ homepage = str(metadata.get("Home-page", "")).strip()
194
+ return homepage or _DEFAULT_RELEASE_REPOSITORY_URL
195
+
196
+
197
+ def _project_repository_slug() -> str:
198
+ repository_url = _project_repository_url().strip().rstrip("/")
199
+ if repository_url.startswith("http://") or repository_url.startswith("https://"):
200
+ parts = urlsplit(repository_url)
201
+ path_parts = [piece for piece in parts.path.strip("/").split("/") if piece]
202
+ else:
203
+ path_parts = [piece for piece in repository_url.strip("/").split("/") if piece]
204
+ if len(path_parts) < 2:
205
+ raise ValueError(
206
+ f"Unsupported repository URL for release lookup: {repository_url}"
207
+ )
208
+ return f"{path_parts[0]}/{path_parts[1].removesuffix('.git')}"
209
+
210
+
211
+ def _latest_github_release(repository_slug: str) -> dict[str, str] | None:
212
+ try:
213
+ response = httpx.get(
214
+ f"{_GITHUB_RELEASE_REPOSITORY_API}/{repository_slug}/releases/latest",
215
+ timeout=5,
216
+ headers={"Accept": "application/vnd.github+json"},
217
+ )
218
+ response.raise_for_status()
219
+ except Exception:
220
+ return None
221
+ payload = response.json()
222
+ if not isinstance(payload, dict):
223
+ return None
224
+ tag_name = payload.get("tag_name")
225
+ html_url = payload.get("html_url")
226
+ if not isinstance(tag_name, str) or not tag_name.strip():
227
+ return None
228
+ if not isinstance(html_url, str) or not html_url.strip():
229
+ html_url = f"https://github.com/{repository_slug}/releases/latest"
230
+ return {"version": tag_name.strip(), "url": html_url.strip()}
231
+
232
+
233
+ def _default_server_install_dir() -> Path:
234
+ current_link = Path.home() / ".minder" / "current"
235
+ if current_link.exists():
236
+ return current_link.resolve()
237
+ releases_dir = Path.home() / ".minder" / "releases"
238
+ if releases_dir.is_dir():
239
+ candidates = [path for path in releases_dir.iterdir() if path.is_dir()]
240
+ if candidates:
241
+ return max(candidates, key=lambda path: path.stat().st_mtime)
242
+ return current_link
243
+
244
+
245
+ def _load_env_file(path: Path) -> dict[str, str]:
246
+ if not path.is_file():
247
+ return {}
248
+ payload: dict[str, str] = {}
249
+ for line in path.read_text(encoding="utf-8").splitlines():
250
+ raw_line = line.strip()
251
+ if not raw_line or raw_line.startswith("#") or "=" not in raw_line:
252
+ continue
253
+ key, _, value = raw_line.partition("=")
254
+ payload[key.strip()] = value.strip()
255
+ return payload
256
+
257
+
258
+ def _server_release_metadata_path(install_dir: Path) -> Path:
259
+ return install_dir / _SERVER_RELEASE_METADATA_NAME
260
+
261
+
262
+ def _load_server_release_metadata(install_dir: Path) -> dict[str, Any]:
263
+ path = _server_release_metadata_path(install_dir)
264
+ if not path.is_file():
265
+ return {}
266
+ payload = json.loads(path.read_text(encoding="utf-8"))
267
+ return payload if isinstance(payload, dict) else {}
268
+
269
+
270
+ def _image_tag(image_ref: str | None) -> str | None:
271
+ if image_ref is None:
272
+ return None
273
+ _, separator, tag = image_ref.rpartition(":")
274
+ if not separator or not tag.strip():
275
+ return None
276
+ return tag.strip()
277
+
278
+
279
+ def _server_repository_slug(install_dir: Path) -> str:
280
+ metadata = _load_server_release_metadata(install_dir)
281
+ repository = metadata.get("repository")
282
+ if isinstance(repository, str) and repository.strip():
283
+ repository_url = repository.strip()
284
+ if repository_url.startswith("http://") or repository_url.startswith(
285
+ "https://"
286
+ ):
287
+ parts = urlsplit(repository_url)
288
+ pieces = [piece for piece in parts.path.strip("/").split("/") if piece]
289
+ else:
290
+ pieces = [piece for piece in repository_url.strip("/").split("/") if piece]
291
+ if len(pieces) >= 2:
292
+ return f"{pieces[0]}/{pieces[1].removesuffix('.git')}"
293
+ repo_owner = str(metadata.get("repo_owner", "")).strip()
294
+ repo_name = str(metadata.get("repo_name", "")).strip()
295
+ if repo_owner and repo_name:
296
+ return f"{repo_owner}/{repo_name.removesuffix('.git')}"
297
+ return _project_repository_slug()
298
+
299
+
300
+ def _server_current_version(install_dir: Path) -> str | None:
301
+ metadata = _load_server_release_metadata(install_dir)
302
+ release_tag = metadata.get("release_tag")
303
+ if isinstance(release_tag, str) and release_tag.strip():
304
+ return release_tag.strip()
305
+ env_payload = _load_env_file(install_dir / ".env")
306
+ return _image_tag(env_payload.get("MINDER_API_IMAGE")) or _image_tag(
307
+ env_payload.get("MINDER_DASHBOARD_IMAGE")
308
+ )
309
+
310
+
311
+ def _cli_update_available() -> tuple[str | None, str | None, bool]:
312
+ installed = _installed_package_version()
313
+ latest = _latest_pypi_version()
314
+ if installed is None or latest is None:
315
+ return installed, latest, False
316
+ return installed, latest, _parse_version(latest) > _parse_version(installed)
317
+
318
+
319
+ def _server_update_available(
320
+ install_dir: Path,
321
+ ) -> tuple[str | None, str | None, str, str | None, bool]:
322
+ repository_slug = _server_repository_slug(install_dir)
323
+ current = _server_current_version(install_dir)
324
+ latest_release = _latest_github_release(repository_slug)
325
+ latest = latest_release["version"] if latest_release is not None else None
326
+ release_url = latest_release["url"] if latest_release is not None else None
327
+ if current is None or latest is None:
328
+ return current, latest, repository_slug, release_url, False
329
+ return (
330
+ current,
331
+ latest,
332
+ repository_slug,
333
+ release_url,
334
+ _parse_version(latest) > _parse_version(current),
335
+ )
336
+
337
+
338
+ def _print_cli_update_status() -> None:
339
+ installed, latest, has_update = _cli_update_available()
340
+ status = (
341
+ f"update available ({installed} -> {latest})"
342
+ if has_update and installed and latest
343
+ else (
344
+ "up to date"
345
+ if installed and latest
346
+ else "unable to determine latest version"
347
+ )
348
+ )
349
+ print("CLI update status:")
350
+ print(f" installed: {installed or 'unknown'}")
351
+ print(f" latest: {latest or 'unknown'}")
352
+ print(f" status: {status}")
353
+
354
+
355
+ def _print_server_update_status(install_dir: Path) -> None:
356
+ if not install_dir.is_dir():
357
+ print("Server update status:")
358
+ print(f" install dir: {install_dir}")
359
+ print(" status: no local release installation found")
360
+ return
361
+ current, latest, repository_slug, release_url, has_update = (
362
+ _server_update_available(install_dir)
363
+ )
364
+ status = (
365
+ f"update available ({current} -> {latest})"
366
+ if has_update and current and latest
367
+ else (
368
+ "up to date" if current and latest else "unable to determine latest release"
369
+ )
370
+ )
371
+ print("Server update status:")
372
+ print(f" install dir: {install_dir}")
373
+ print(f" repository: {repository_slug}")
374
+ print(f" current: {current or 'unknown'}")
375
+ print(f" latest: {latest or 'unknown'}")
376
+ if release_url:
377
+ print(f" release: {release_url}")
378
+ print(f" status: {status}")
379
+
380
+
381
+ def _cli_update_commands(manager: str) -> list[list[str]]:
382
+ if manager == "uv":
383
+ return [["uv", "tool", "upgrade", "minder"]]
384
+ if manager == "pipx":
385
+ return [["pipx", "upgrade", "minder"]]
386
+ if manager == "pip":
387
+ return [[sys.executable, "-m", "pip", "install", "--upgrade", "minder"]]
388
+ return [
389
+ ["uv", "tool", "upgrade", "minder"],
390
+ ["pipx", "upgrade", "minder"],
391
+ [sys.executable, "-m", "pip", "install", "--upgrade", "minder"],
392
+ ]
393
+
394
+
395
+ def _self_update_cli(manager: str) -> None:
396
+ failures: list[str] = []
397
+ for command in _cli_update_commands(manager):
398
+ executable = command[0]
399
+ if executable != sys.executable and shutil.which(executable) is None:
400
+ failures.append(f"{' '.join(command)} -> command not available")
401
+ continue
402
+ result = subprocess.run(
403
+ command,
404
+ capture_output=True,
405
+ text=True,
406
+ check=False,
407
+ )
408
+ if result.returncode == 0:
409
+ output = result.stdout.strip()
410
+ if output:
411
+ print(output)
412
+ print(f"CLI self-update completed via: {' '.join(command)}")
413
+ return
414
+ details = (
415
+ result.stderr.strip() or result.stdout.strip() or str(result.returncode)
416
+ )
417
+ failures.append(f"{' '.join(command)} -> {details}")
418
+ raise RuntimeError("CLI self-update failed: " + "; ".join(failures))
419
+
420
+
421
+ def _installer_asset_filename(release_tag: str, *, installer: str) -> str:
422
+ if installer == "powershell":
423
+ return f"install-minder-{release_tag}.ps1"
424
+ return f"install-minder-{release_tag}.sh"
425
+
426
+
427
+ def _prefer_powershell_installer() -> bool:
428
+ return platform.system().lower() == "windows"
429
+
430
+
431
+ def _download_release_installer(
432
+ repository_slug: str,
433
+ release_tag: str,
434
+ *,
435
+ installer: str = "bash",
436
+ ) -> tuple[str, str]:
437
+ asset_name = _installer_asset_filename(release_tag, installer=installer)
438
+ installer_url = (
439
+ f"https://github.com/{repository_slug}/releases/download/{release_tag}/"
440
+ f"{asset_name}"
441
+ )
442
+ response = httpx.get(installer_url, timeout=10)
443
+ response.raise_for_status()
444
+ return installer_url, response.text
445
+
446
+
447
+ def _run_bash_installer(script: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
448
+ return subprocess.run(
449
+ ["bash"],
450
+ input=script,
451
+ capture_output=True,
452
+ text=True,
453
+ env=env,
454
+ check=False,
455
+ )
456
+
457
+
458
+ def _run_powershell_installer(
459
+ script: str, env: dict[str, str]
460
+ ) -> subprocess.CompletedProcess[str]:
461
+ executable = "powershell.exe" if platform.system().lower() == "windows" else "pwsh"
462
+ return subprocess.run(
463
+ [executable, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "-"],
464
+ input=script,
465
+ capture_output=True,
466
+ text=True,
467
+ env=env,
468
+ check=False,
469
+ )
470
+
471
+
472
+ def _self_update_server(install_dir: Path) -> None:
473
+ if not install_dir.is_dir():
474
+ raise ValueError(f"Server install directory does not exist: {install_dir}")
475
+ current, latest, repository_slug, _, has_update = _server_update_available(
476
+ install_dir
477
+ )
478
+ if latest is None:
479
+ raise RuntimeError("Could not determine the latest Minder server release")
480
+ if current is not None and not has_update:
481
+ print(f"Minder server is already up to date ({current})")
482
+ return
483
+ installer_variant = "powershell" if _prefer_powershell_installer() else "bash"
484
+ installer_url, installer_script = _download_release_installer(
485
+ repository_slug, latest, installer=installer_variant
486
+ )
487
+ env_payload = _load_env_file(install_dir / ".env")
488
+ update_env = os.environ.copy()
489
+ update_env["MINDER_INSTALL_DIR"] = str(install_dir)
490
+ for key in ("MINDER_MODELS_DIR", "MINDER_PORT", "MILVUS_PORT", "OPENAI_API_KEY"):
491
+ value = env_payload.get(key)
492
+ if value:
493
+ update_env[key] = value
494
+ if installer_variant == "powershell":
495
+ result = _run_powershell_installer(installer_script, update_env)
496
+ else:
497
+ result = _run_bash_installer(installer_script, update_env)
498
+ if result.returncode != 0:
499
+ details = (
500
+ result.stderr.strip() or result.stdout.strip() or str(result.returncode)
501
+ )
502
+ raise RuntimeError(f"Server self-update failed: {details}")
503
+ output = result.stdout.strip()
504
+ if output:
505
+ print(output)
506
+ print(
507
+ f"Server self-update completed for {install_dir}: "
508
+ f"{current or 'unknown'} -> {latest}"
509
+ )
510
+ print(f"Release installer: {installer_url}")
511
+ if current:
512
+ print(
513
+ "Rollback guidance: rerun the previous release installer for "
514
+ f"{current} with MINDER_INSTALL_DIR={install_dir}."
515
+ )
516
+
517
+
518
+ def _check_update(args: argparse.Namespace) -> int:
519
+ components = ["cli", "server"] if args.component == "all" else [args.component]
520
+ if "cli" in components:
521
+ _print_cli_update_status()
522
+ if "server" in components:
523
+ install_dir = (
524
+ Path(args.install_dir).expanduser().resolve()
525
+ if args.install_dir
526
+ else _default_server_install_dir()
527
+ )
528
+ _print_server_update_status(install_dir)
529
+ return 0
530
+
531
+
532
+ def _self_update(args: argparse.Namespace) -> int:
533
+ components = ["cli", "server"] if args.component == "all" else [args.component]
534
+ if "cli" in components:
535
+ _self_update_cli(args.manager)
536
+ if "server" in components:
537
+ install_dir = (
538
+ Path(args.install_dir).expanduser().resolve()
539
+ if args.install_dir
540
+ else _default_server_install_dir()
541
+ )
542
+ _self_update_server(install_dir)
543
+ return 0
544
+
545
+
546
+ def _repo_root(path: str) -> Path:
547
+ result = subprocess.run(
548
+ ["git", "rev-parse", "--show-toplevel"],
549
+ cwd=path,
550
+ capture_output=True,
551
+ text=True,
552
+ check=True,
553
+ )
554
+ return Path(result.stdout.strip()).resolve()
555
+
556
+
557
+ def _git_branch(repo_root: Path) -> str | None:
558
+ result = subprocess.run(
559
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
560
+ cwd=repo_root,
561
+ capture_output=True,
562
+ text=True,
563
+ check=True,
564
+ )
565
+ branch = result.stdout.strip()
566
+ return branch or None
567
+
568
+
569
+ def _git_remote_url(repo_root: Path) -> str | None:
570
+ result = subprocess.run(
571
+ ["git", "config", "--get", "remote.origin.url"],
572
+ cwd=repo_root,
573
+ capture_output=True,
574
+ text=True,
575
+ check=False,
576
+ )
577
+ remote_url = result.stdout.strip()
578
+ return remote_url or None
579
+
580
+
581
+ def _normalize_repo_remote(remote_url: str | None) -> str | None:
582
+ if remote_url is None:
583
+ return None
584
+ raw_url = remote_url.strip()
585
+ if not raw_url:
586
+ return None
587
+ if raw_url.startswith("git@"):
588
+ host_and_path = raw_url[4:]
589
+ host, separator, path = host_and_path.partition(":")
590
+ if separator and host and path:
591
+ normalized_path = path.strip().lstrip("/").removesuffix(".git")
592
+ if normalized_path:
593
+ return f"git@{host}:{normalized_path}.git"
594
+ return raw_url
595
+ if (
596
+ raw_url.startswith("ssh://")
597
+ or raw_url.startswith("http://")
598
+ or raw_url.startswith("https://")
599
+ ):
600
+ parts = urlsplit(raw_url)
601
+ host = parts.hostname or ""
602
+ path = parts.path.strip().lstrip("/").removesuffix(".git")
603
+ user = parts.username or "git"
604
+ if host and path:
605
+ return f"{user}@{host}:{path}.git"
606
+ return raw_url.rstrip("/")
607
+
608
+
609
+ def _repo_name_from_remote(remote_url: str | None) -> str | None:
610
+ normalized_remote = _normalize_repo_remote(remote_url)
611
+ if not normalized_remote:
612
+ return None
613
+ _, _, path = normalized_remote.partition(":")
614
+ repo_name = path.rsplit("/", 1)[-1].removesuffix(".git").strip()
615
+ return repo_name or None
616
+
617
+
618
+ def _git_file_delta(
619
+ repo_root: Path, diff_base: str | None = None
620
+ ) -> tuple[list[str], list[str]]:
621
+ diff_command = ["git", "diff", "--name-only", "--diff-filter=ACMRD"]
622
+ if diff_base:
623
+ diff_command.append(f"{diff_base}...HEAD")
624
+ else:
625
+ diff_command.append("HEAD")
626
+ diff_result = subprocess.run(
627
+ diff_command,
628
+ cwd=repo_root,
629
+ capture_output=True,
630
+ text=True,
631
+ check=True,
632
+ )
633
+ changed = {line.strip() for line in diff_result.stdout.splitlines() if line.strip()}
634
+
635
+ deleted_result = subprocess.run(
636
+ [
637
+ "git",
638
+ "diff",
639
+ "--name-only",
640
+ "--diff-filter=D",
641
+ *([f"{diff_base}...HEAD"] if diff_base else ["HEAD"]),
642
+ ],
643
+ cwd=repo_root,
644
+ capture_output=True,
645
+ text=True,
646
+ check=True,
647
+ )
648
+ deleted = {
649
+ line.strip() for line in deleted_result.stdout.splitlines() if line.strip()
650
+ }
651
+ changed.difference_update(deleted)
652
+
653
+ untracked_result = subprocess.run(
654
+ ["git", "ls-files", "--others", "--exclude-standard"],
655
+ cwd=repo_root,
656
+ capture_output=True,
657
+ text=True,
658
+ check=True,
659
+ )
660
+ changed.update(
661
+ line.strip() for line in untracked_result.stdout.splitlines() if line.strip()
662
+ )
663
+ return sorted(changed), sorted(deleted)
664
+
665
+
666
+ _BRANCH_TOPOLOGY_OVERRIDE_RELATIVE = Path(".minder") / "branch-topology.toml"
667
+ _ALLOWED_RELATIONSHIP_DIRECTIONS = ("outbound", "inbound", "bidirectional")
668
+
669
+
670
+ def _gitmodules_submodule_sections(gitmodules_path: Path) -> dict[str, dict[str, str]]:
671
+ """Parse ``.gitmodules`` into a mapping of submodule name to field dict."""
672
+ parser = configparser.ConfigParser()
673
+ try:
674
+ parser.read_string(gitmodules_path.read_text(encoding="utf-8"))
675
+ except (configparser.Error, OSError):
676
+ return {}
677
+
678
+ submodules: dict[str, dict[str, str]] = {}
679
+ for section in parser.sections():
680
+ stripped = section.strip()
681
+ if not stripped.lower().startswith("submodule"):
682
+ continue
683
+ _, _, quoted = stripped.partition(" ")
684
+ name = quoted.strip().strip('"').strip()
685
+ if not name:
686
+ continue
687
+ fields = {key: parser.get(section, key) for key in parser.options(section)}
688
+ submodules[name] = fields
689
+ return submodules
690
+
691
+
692
+ def _submodule_branch_relationships(
693
+ repo_root: Path, source_branch: str | None
694
+ ) -> list[dict[str, Any]]:
695
+ gitmodules = repo_root / ".gitmodules"
696
+ if not gitmodules.is_file():
697
+ return []
698
+
699
+ sections = _gitmodules_submodule_sections(gitmodules)
700
+ relationships: list[dict[str, Any]] = []
701
+ for name, fields in sections.items():
702
+ url = (fields.get("url") or "").strip()
703
+ if not url:
704
+ continue
705
+ target_branch = (fields.get("branch") or "").strip() or "main"
706
+ submodule_path = (fields.get("path") or name).strip()
707
+ target_repo_name = (
708
+ _repo_name_from_remote(url) or name.rsplit("/", 1)[-1].strip() or name
709
+ )
710
+ relationships.append(
711
+ {
712
+ "source_branch": source_branch,
713
+ "target_repo_name": target_repo_name,
714
+ "target_repo_url": _normalize_repo_remote(url),
715
+ "target_branch": target_branch,
716
+ "relation": "depends_on",
717
+ "direction": "outbound",
718
+ "confidence": 1.0,
719
+ "metadata": {
720
+ "discovered_by": "minder-cli",
721
+ "source": "gitmodules",
722
+ "submodule_path": submodule_path,
723
+ "submodule_name": name,
724
+ },
725
+ }
726
+ )
727
+ return relationships
728
+
729
+
730
+ def _normalize_relationship_entry(
731
+ entry: dict[str, Any], *, fallback_branch: str | None
732
+ ) -> dict[str, Any] | None:
733
+ target_repo_name = str(entry.get("target_repo_name") or "").strip()
734
+ target_branch = str(entry.get("target_branch") or "").strip()
735
+ if not target_repo_name or not target_branch:
736
+ return None
737
+
738
+ raw_source_branch = entry.get("source_branch")
739
+ source_branch = str(raw_source_branch or fallback_branch or "").strip() or None
740
+
741
+ raw_url = entry.get("target_repo_url")
742
+ target_repo_url = (
743
+ _normalize_repo_remote(raw_url) if isinstance(raw_url, str) else None
744
+ )
745
+
746
+ raw_id = entry.get("target_repo_id")
747
+ target_repo_id = (
748
+ raw_id.strip() if isinstance(raw_id, str) and raw_id.strip() else None
749
+ )
750
+
751
+ direction = str(entry.get("direction") or "outbound").strip() or "outbound"
752
+ if direction not in _ALLOWED_RELATIONSHIP_DIRECTIONS:
753
+ direction = "outbound"
754
+
755
+ try:
756
+ confidence = float(entry.get("confidence", 1.0))
757
+ except (TypeError, ValueError):
758
+ confidence = 1.0
759
+
760
+ raw_metadata = entry.get("metadata")
761
+ metadata = dict(raw_metadata) if isinstance(raw_metadata, dict) else {}
762
+ metadata.setdefault("discovered_by", "minder-cli")
763
+
764
+ relation = str(entry.get("relation") or "depends_on").strip() or "depends_on"
765
+
766
+ normalized: dict[str, Any] = {
767
+ "source_branch": source_branch,
768
+ "target_repo_name": target_repo_name,
769
+ "target_repo_url": target_repo_url,
770
+ "target_branch": target_branch,
771
+ "relation": relation,
772
+ "direction": direction,
773
+ "confidence": confidence,
774
+ "metadata": metadata,
775
+ }
776
+ if target_repo_id:
777
+ normalized["target_repo_id"] = target_repo_id
778
+ return normalized
779
+
780
+
781
+ def _branch_topology_override_relationships(
782
+ repo_root: Path, source_branch: str | None
783
+ ) -> list[dict[str, Any]]:
784
+ override_path = repo_root / _BRANCH_TOPOLOGY_OVERRIDE_RELATIVE
785
+ if not override_path.is_file():
786
+ return []
787
+ try:
788
+ data = tomllib.loads(override_path.read_text(encoding="utf-8"))
789
+ except (tomllib.TOMLDecodeError, OSError):
790
+ return []
791
+
792
+ raw_entries = data.get("branch_relationships")
793
+ if raw_entries is None:
794
+ raw_entries = data.get("branch_relationship")
795
+ if not isinstance(raw_entries, list):
796
+ return []
797
+
798
+ relationships: list[dict[str, Any]] = []
799
+ for entry in raw_entries:
800
+ if not isinstance(entry, dict):
801
+ continue
802
+ entry_copy = dict(entry)
803
+ raw_metadata = entry_copy.get("metadata")
804
+ metadata = dict(raw_metadata) if isinstance(raw_metadata, dict) else {}
805
+ metadata.setdefault("source", "branch-topology.toml")
806
+ entry_copy["metadata"] = metadata
807
+ normalized = _normalize_relationship_entry(
808
+ entry_copy, fallback_branch=source_branch
809
+ )
810
+ if normalized is not None:
811
+ relationships.append(normalized)
812
+ return relationships
813
+
814
+
815
+ def _dedupe_branch_relationships(
816
+ relationships: list[dict[str, Any]],
817
+ ) -> list[dict[str, Any]]:
818
+ deduped: dict[tuple[str, str, str, str, str], dict[str, Any]] = {}
819
+ for entry in relationships:
820
+ key = (
821
+ str(entry.get("source_branch") or ""),
822
+ str(entry.get("target_repo_id") or ""),
823
+ str(entry.get("target_repo_name") or ""),
824
+ str(entry.get("target_branch") or ""),
825
+ str(entry.get("relation") or "depends_on"),
826
+ )
827
+ existing = deduped.get(key)
828
+ if existing is None:
829
+ deduped[key] = dict(entry)
830
+ continue
831
+ merged_metadata = {
832
+ **dict(existing.get("metadata") or {}),
833
+ **dict(entry.get("metadata") or {}),
834
+ }
835
+ merged = {**existing, **entry, "metadata": merged_metadata}
836
+ deduped[key] = merged
837
+ return list(deduped.values())
838
+
839
+
840
+ def _detect_branch_relationships(
841
+ repo_root: Path, source_branch: str | None
842
+ ) -> list[dict[str, Any]]:
843
+ relationships = _submodule_branch_relationships(repo_root, source_branch)
844
+ relationships.extend(
845
+ _branch_topology_override_relationships(repo_root, source_branch)
846
+ )
847
+ return _dedupe_branch_relationships(relationships)
848
+
849
+
850
+ def _base_http_url(server_url: str) -> str:
851
+ parts = urlsplit(server_url)
852
+ path = parts.path.rstrip("/")
853
+ if path.endswith("/sse"):
854
+ path = path[:-4]
855
+ elif path.endswith("/mcp"):
856
+ path = path[:-4]
857
+ return urlunsplit((parts.scheme, parts.netloc, path, "", "")).rstrip("/")
858
+
859
+
860
+ def _sse_url(server_url: str) -> str:
861
+ base_url = _base_http_url(server_url)
862
+ return f"{base_url}/sse"
863
+
864
+
865
+ def _mcp_url(server_url: str) -> str:
866
+ base_url = _base_http_url(server_url)
867
+ return f"{base_url}/mcp"
868
+
869
+
870
+ def _appdata_dir() -> Path:
871
+ appdata = os.getenv("APPDATA", "").strip()
872
+ if appdata:
873
+ return Path(appdata)
874
+ return Path.home() / "AppData" / "Roaming"
875
+
876
+
877
+ def _global_target_path(target: str) -> Path:
878
+ system = platform.system().lower()
879
+ home = Path.home()
880
+ if target == "vscode":
881
+ if system == "darwin":
882
+ return (
883
+ home / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
884
+ )
885
+ if system == "windows":
886
+ return _appdata_dir() / "Code" / "User" / "mcp.json"
887
+ return home / ".config" / "Code" / "User" / "mcp.json"
888
+ if target == "cursor":
889
+ if system == "darwin":
890
+ return (
891
+ home
892
+ / "Library"
893
+ / "Application Support"
894
+ / "Cursor"
895
+ / "User"
896
+ / "mcp.json"
897
+ )
898
+ if system == "windows":
899
+ return _appdata_dir() / "Cursor" / "User" / "mcp.json"
900
+ return home / ".config" / "Cursor" / "User" / "mcp.json"
901
+ if target == "claude-code":
902
+ if system == "windows":
903
+ return home / ".claude" / "mcp.json"
904
+ return home / ".claude" / "mcp.json"
905
+ raise ValueError(f"Unsupported MCP target: {target}")
906
+
907
+
908
+ def _local_target_path(target: str, cwd: Path) -> Path:
909
+ if target == "vscode":
910
+ return cwd / ".vscode" / "mcp.json"
911
+ if target == "cursor":
912
+ return cwd / ".cursor" / "mcp.json"
913
+ if target == "claude-code":
914
+ return cwd / ".claude" / "mcp.json"
915
+ raise ValueError(f"Unsupported MCP target: {target}")
916
+
917
+
918
+ def _ide_instruction_path(target: str, cwd: Path) -> Path | None:
919
+ if target == "vscode":
920
+ return cwd / ".github" / "copilot-instructions.md"
921
+ if target == "cursor":
922
+ return cwd / ".cursor" / "rules" / "minder.mdc"
923
+ if target == "claude-code":
924
+ return cwd / "CLAUDE.md"
925
+ return None
926
+
927
+
928
+ def _ide_agent_path(target: str, cwd: Path) -> Path | None:
929
+ if target == "claude-code":
930
+ return cwd / ".claude" / "agents" / "minder-repo-guide.md"
931
+ return None
932
+
933
+
934
+ def _ide_instruction_key(target: str) -> str:
935
+ return f"minder-ide-instructions:{target}"
936
+
937
+
938
+ def _ide_agent_key(target: str) -> str:
939
+ return f"minder-ide-agent:{target}"
940
+
941
+
942
+ def _ide_bootstrap_instruction(target: str) -> str:
943
+ version = _bootstrap_version()
944
+ if target == "vscode":
945
+ return (
946
+ f"Minder repo-local instructions (version {version})\n\n"
947
+ "- Use Minder MCP tools for repository-aware search, impact, workflow, and query flows.\n"
948
+ "- Prefer `minder_workflow_get` or `minder_workflow_step` before making large changes.\n"
949
+ "- Use `minder_query`, `minder_search_graph`, and `minder_find_impact` before broad refactors.\n"
950
+ "- Run `minder sync` after structural repository changes so graph metadata stays current."
951
+ )
952
+ if target == "cursor":
953
+ return (
954
+ "---\n"
955
+ "description: Minder repository guidance\n"
956
+ "globs:\n"
957
+ "alwaysApply: false\n"
958
+ "---\n\n"
959
+ f"Use Minder repo-local automation (version {version}) for repository queries and workflow guidance.\n\n"
960
+ "- Ask Minder for impact and graph context before cross-module edits.\n"
961
+ "- Sync repo metadata with `minder sync` after structural changes.\n"
962
+ "- Keep workflow-aware MCP calls in the loop for non-trivial implementation work."
963
+ )
964
+ if target == "claude-code":
965
+ return (
966
+ f"Minder repo-local instructions (version {version})\n\n"
967
+ "- Use Minder MCP tools as the first source of repository context.\n"
968
+ "- Prefer workflow and graph lookups before broad implementation changes.\n"
969
+ "- Run `minder sync` after structural edits so future queries use fresh repo metadata."
970
+ )
971
+ raise ValueError(f"Unsupported IDE target: {target}")
972
+
973
+
974
+ def _ide_agent_content(target: str) -> str:
975
+ version = _bootstrap_version()
976
+ if target != "claude-code":
977
+ raise ValueError(f"Unsupported IDE target for agent bootstrap: {target}")
978
+ return (
979
+ "---\n"
980
+ "name: minder-repo-guide\n"
981
+ "description: Use Minder MCP tools to gather repository, workflow, and impact context before implementation.\n"
982
+ f"version: {version}\n"
983
+ "---\n\n"
984
+ "Use this agent prompt when you need to explore a repository through Minder before editing code.\n\n"
985
+ "Recommended flow:\n"
986
+ "1. Call Minder workflow and graph tools to understand the current repository state.\n"
987
+ "2. Use Minder query/search tools to narrow the affected code paths.\n"
988
+ "3. Only then move into implementation and resync metadata when structure changes."
989
+ )
990
+
991
+
992
+ def _gitignore_entries_for_targets(targets: list[str]) -> list[str]:
993
+ entries = [".minder/"]
994
+ for target in targets:
995
+ local_path = _local_target_path(target, Path("."))
996
+ entries.append(local_path.as_posix())
997
+ ordered: list[str] = []
998
+ for entry in entries:
999
+ if entry not in ordered:
1000
+ ordered.append(entry)
1001
+ return ordered
1002
+
1003
+
1004
+ def _metadata_path(cwd: Path) -> Path:
1005
+ return cwd / ".minder" / "ide-bootstrap.json"
1006
+
1007
+
1008
+ def _write_bootstrap_metadata(cwd: Path, targets: list[str]) -> None:
1009
+ _write_json(
1010
+ _metadata_path(cwd),
1011
+ {
1012
+ "version": _bootstrap_version(),
1013
+ "targets": targets,
1014
+ "mode": "repo-local",
1015
+ },
1016
+ )
1017
+
1018
+
1019
+ def _install_repo_local_ide_assets(cwd: Path, targets: list[str]) -> list[Path]:
1020
+ installed_paths: list[Path] = []
1021
+ for target in targets:
1022
+ instruction_path = _ide_instruction_path(target, cwd)
1023
+ if instruction_path is not None:
1024
+ _upsert_managed_block(
1025
+ instruction_path,
1026
+ _ide_instruction_key(target),
1027
+ _ide_bootstrap_instruction(target),
1028
+ )
1029
+ installed_paths.append(instruction_path)
1030
+ agent_path = _ide_agent_path(target, cwd)
1031
+ if agent_path is not None:
1032
+ _upsert_managed_block(
1033
+ agent_path,
1034
+ _ide_agent_key(target),
1035
+ _ide_agent_content(target),
1036
+ )
1037
+ installed_paths.append(agent_path)
1038
+
1039
+ gitignore_path = cwd / ".gitignore"
1040
+ _upsert_managed_block(
1041
+ gitignore_path,
1042
+ _IDE_GITIGNORE_KEY,
1043
+ "\n".join(_gitignore_entries_for_targets(targets)),
1044
+ )
1045
+ installed_paths.append(gitignore_path)
1046
+ _write_bootstrap_metadata(cwd, targets)
1047
+ installed_paths.append(_metadata_path(cwd))
1048
+ return installed_paths
1049
+
1050
+
1051
+ def _remove_repo_local_ide_assets(cwd: Path, targets: list[str]) -> list[Path]:
1052
+ removed_paths: list[Path] = []
1053
+ for target in targets:
1054
+ instruction_path = _ide_instruction_path(target, cwd)
1055
+ if instruction_path is not None and _remove_managed_block(
1056
+ instruction_path, _ide_instruction_key(target)
1057
+ ):
1058
+ removed_paths.append(instruction_path)
1059
+ agent_path = _ide_agent_path(target, cwd)
1060
+ if agent_path is not None and _remove_managed_block(
1061
+ agent_path, _ide_agent_key(target)
1062
+ ):
1063
+ removed_paths.append(agent_path)
1064
+
1065
+ gitignore_path = cwd / ".gitignore"
1066
+ if _remove_managed_block(gitignore_path, _IDE_GITIGNORE_KEY):
1067
+ removed_paths.append(gitignore_path)
1068
+ metadata_path = _metadata_path(cwd)
1069
+ if metadata_path.exists():
1070
+ metadata_path.unlink()
1071
+ removed_paths.append(metadata_path)
1072
+ return removed_paths
1073
+
1074
+
1075
+ def _target_root_key(target: str) -> str:
1076
+ if target == "vscode":
1077
+ return "servers"
1078
+ return "mcpServers"
1079
+
1080
+
1081
+ def _target_entry(target: str, server_url: str, client_key: str) -> dict[str, Any]:
1082
+ sse_url = _sse_url(server_url)
1083
+ mcp_url = _mcp_url(server_url)
1084
+ if target == "vscode":
1085
+ return {
1086
+ "type": "sse",
1087
+ "url": sse_url,
1088
+ "headers": {"X-Minder-Client-Key": client_key},
1089
+ }
1090
+ if target == "cursor":
1091
+ return {
1092
+ "url": mcp_url,
1093
+ "headers": {"X-Minder-Client-Key": client_key},
1094
+ }
1095
+ if target == "claude-code":
1096
+ return {
1097
+ "type": "sse",
1098
+ "url": sse_url,
1099
+ "headers": {"X-Minder-Client-Key": client_key},
1100
+ }
1101
+ raise ValueError(f"Unsupported MCP target: {target}")
1102
+
1103
+
1104
+ def _install_target(path: Path, target: str, server_url: str, client_key: str) -> None:
1105
+ payload = _load_json(path)
1106
+ root_key = _target_root_key(target)
1107
+ payload.setdefault(root_key, {})
1108
+ payload[root_key]["minder"] = _target_entry(target, server_url, client_key)
1109
+ if target == "vscode":
1110
+ payload.setdefault("inputs", [])
1111
+ _write_json(path, payload)
1112
+
1113
+
1114
+ def _uninstall_target(path: Path, target: str) -> bool:
1115
+ if not path.is_file():
1116
+ return False
1117
+ payload = _load_json(path)
1118
+ root_key = _target_root_key(target)
1119
+ servers = payload.get(root_key)
1120
+ if not isinstance(servers, dict) or "minder" not in servers:
1121
+ return False
1122
+ del servers["minder"]
1123
+ if not servers:
1124
+ payload.pop(root_key, None)
1125
+ if target == "vscode" and payload.get("inputs") == [] and len(payload) == 1:
1126
+ payload.pop("inputs", None)
1127
+ if not payload:
1128
+ path.unlink(missing_ok=True)
1129
+ return True
1130
+ _write_json(path, payload)
1131
+ return True
1132
+
1133
+
1134
+ def _parse_targets(raw_targets: list[str] | None) -> list[str]:
1135
+ if not raw_targets:
1136
+ return list(_LOCAL_MCP_TARGETS)
1137
+ parsed: list[str] = []
1138
+ for raw_target in raw_targets:
1139
+ value = raw_target.strip().lower()
1140
+ if not value:
1141
+ continue
1142
+ if value == "all":
1143
+ parsed.extend(_LOCAL_MCP_TARGETS)
1144
+ else:
1145
+ parsed.append(value)
1146
+ ordered: list[str] = []
1147
+ for target in parsed:
1148
+ if target not in _LOCAL_MCP_TARGETS:
1149
+ raise ValueError(f"Unsupported MCP target: {target}")
1150
+ if target not in ordered:
1151
+ ordered.append(target)
1152
+ return ordered
1153
+
1154
+
1155
+ def _login(args: argparse.Namespace) -> int:
1156
+ client_key = (args.client_key or "").strip() or _prompt_client_key()
1157
+ if not client_key.startswith("mkc_"):
1158
+ raise ValueError("Client key must start with 'mkc_'")
1159
+
1160
+ config_path = Path(args.config_path).expanduser()
1161
+ existing = _load_json(config_path)
1162
+ payload = {
1163
+ **existing,
1164
+ "server_url": args.server_url,
1165
+ "client_api_key": client_key,
1166
+ "default_headers": {
1167
+ "X-Minder-Client-Key": client_key,
1168
+ },
1169
+ }
1170
+ _write_json(config_path, payload)
1171
+ print(f"Stored client credentials in {config_path}")
1172
+ print(f"export MINDER_CLIENT_API_KEY={client_key}")
1173
+ return 0
1174
+
1175
+
1176
+ def _install_mcp(args: argparse.Namespace) -> int:
1177
+ config_path = Path(args.config_path).expanduser()
1178
+ settings = _require_client_settings(config_path)
1179
+ targets = _parse_targets(args.target)
1180
+ install_root = Path(args.cwd).resolve()
1181
+ installed_paths: list[Path] = []
1182
+
1183
+ for target in targets:
1184
+ path = (
1185
+ _global_target_path(target)
1186
+ if args.global_install
1187
+ else _local_target_path(target, install_root)
1188
+ )
1189
+ _install_target(
1190
+ path,
1191
+ target,
1192
+ str(settings["server_url"]),
1193
+ str(settings["client_api_key"]),
1194
+ )
1195
+ installed_paths.append(path)
1196
+
1197
+ for path in installed_paths:
1198
+ print(f"Installed Minder MCP config: {path}")
1199
+ return 0
1200
+
1201
+
1202
+ def _uninstall_mcp(args: argparse.Namespace) -> int:
1203
+ targets = _parse_targets(args.target)
1204
+ install_root = Path(args.cwd).resolve()
1205
+ removed_count = 0
1206
+
1207
+ for target in targets:
1208
+ path = (
1209
+ _global_target_path(target)
1210
+ if args.global_install
1211
+ else _local_target_path(target, install_root)
1212
+ )
1213
+ if _uninstall_target(path, target):
1214
+ removed_count += 1
1215
+ print(f"Removed Minder MCP config: {path}")
1216
+
1217
+ if removed_count == 0:
1218
+ print("No Minder MCP entries were found to remove")
1219
+ return 0
1220
+
1221
+
1222
+ def _install_ide(args: argparse.Namespace) -> int:
1223
+ config_path = Path(args.config_path).expanduser()
1224
+ settings = _require_client_settings(config_path)
1225
+ targets = _parse_targets(args.target)
1226
+ install_root = Path(args.cwd).resolve()
1227
+ installed_paths: list[Path] = []
1228
+
1229
+ for target in targets:
1230
+ path = _local_target_path(target, install_root)
1231
+ _install_target(
1232
+ path,
1233
+ target,
1234
+ str(settings["server_url"]),
1235
+ str(settings["client_api_key"]),
1236
+ )
1237
+ installed_paths.append(path)
1238
+
1239
+ installed_paths.extend(_install_repo_local_ide_assets(install_root, targets))
1240
+
1241
+ for path in installed_paths:
1242
+ print(f"Installed Minder IDE asset: {path}")
1243
+ return 0
1244
+
1245
+
1246
+ def _uninstall_ide(args: argparse.Namespace) -> int:
1247
+ targets = _parse_targets(args.target)
1248
+ install_root = Path(args.cwd).resolve()
1249
+ removed_paths: list[Path] = []
1250
+
1251
+ for target in targets:
1252
+ path = _local_target_path(target, install_root)
1253
+ if _uninstall_target(path, target):
1254
+ removed_paths.append(path)
1255
+
1256
+ removed_paths.extend(_remove_repo_local_ide_assets(install_root, targets))
1257
+
1258
+ if not removed_paths:
1259
+ print("No Minder IDE assets were found to remove")
1260
+ return 0
1261
+ for path in removed_paths:
1262
+ print(f"Removed Minder IDE asset: {path}")
1263
+ return 0
1264
+
1265
+
1266
+ def _resolve_repo_id(
1267
+ *,
1268
+ base_url: str,
1269
+ client_key: str,
1270
+ repo_root: Path,
1271
+ default_branch: str | None,
1272
+ ) -> str:
1273
+ remote_url = _normalize_repo_remote(_git_remote_url(repo_root))
1274
+ if remote_url is None:
1275
+ raise ValueError(
1276
+ "Repository remote origin SSH URL is required when --repo-id is omitted"
1277
+ )
1278
+ response = httpx.post(
1279
+ f"{base_url}/v1/client/repositories/resolve",
1280
+ headers={"X-Minder-Client-Key": client_key},
1281
+ json={
1282
+ "repo_name": _repo_name_from_remote(remote_url) or repo_root.name,
1283
+ "repo_path": str(repo_root),
1284
+ "repo_url": remote_url,
1285
+ "default_branch": default_branch,
1286
+ },
1287
+ timeout=15,
1288
+ )
1289
+ response.raise_for_status()
1290
+ payload = response.json()
1291
+ repository = payload.get("repository") if isinstance(payload, dict) else None
1292
+ repo_id = repository.get("id") if isinstance(repository, dict) else None
1293
+ if not isinstance(repo_id, str) or not repo_id.strip():
1294
+ raise ValueError("Repository resolve response did not include a repository id")
1295
+ return repo_id.strip()
1296
+
1297
+
1298
+ def _sync(args: argparse.Namespace) -> int:
1299
+ config_path = Path(args.config_path).expanduser()
1300
+ settings = _require_client_settings(config_path)
1301
+ if not args.skip_upgrade_check:
1302
+ _maybe_print_upgrade_notice()
1303
+ repo_root = _repo_root(args.repo_path)
1304
+ branch = _git_branch(repo_root)
1305
+ changed_files, deleted_files = _git_file_delta(repo_root, args.diff_base)
1306
+ branch_relationships = _detect_branch_relationships(repo_root, branch)
1307
+ payload = RepoScanner.build_sync_payload(
1308
+ str(repo_root),
1309
+ branch=branch,
1310
+ diff_base=args.diff_base,
1311
+ changed_files=changed_files,
1312
+ deleted_files=deleted_files,
1313
+ branch_relationships=branch_relationships,
1314
+ )
1315
+
1316
+ if args.dry_run:
1317
+ print(json.dumps(payload, indent=2))
1318
+ return 0
1319
+
1320
+ base_url = _base_http_url(str(settings["server_url"]))
1321
+ headers = {"X-Minder-Client-Key": str(settings["client_api_key"])}
1322
+ repo_id = args.repo_id or _resolve_repo_id(
1323
+ base_url=base_url,
1324
+ client_key=headers["X-Minder-Client-Key"],
1325
+ repo_root=repo_root,
1326
+ default_branch=branch,
1327
+ )
1328
+ sync_url = f"{base_url}/v1/client/repositories/{repo_id}/graph-sync"
1329
+ response = httpx.post(sync_url, headers=headers, json=payload, timeout=30)
1330
+ response.raise_for_status()
1331
+ print(json.dumps(response.json(), indent=2))
1332
+ return 0
1333
+
1334
+
1335
+ def build_parser() -> argparse.ArgumentParser:
1336
+ parser = argparse.ArgumentParser(description="Minder CLI")
1337
+ subparsers = parser.add_subparsers(dest="command", required=True)
1338
+
1339
+ login = subparsers.add_parser(
1340
+ "login",
1341
+ help="Store a Minder client API key for CLI-authenticated commands.",
1342
+ )
1343
+ login.add_argument(
1344
+ "--client-key",
1345
+ default=None,
1346
+ help="Client API key in mkc_... format. If omitted, prompt securely.",
1347
+ )
1348
+ login.add_argument(
1349
+ "--server-url",
1350
+ default=_DEFAULT_SERVER_URL,
1351
+ help="Default Minder server URL to pair with the stored client key.",
1352
+ )
1353
+ login.add_argument(
1354
+ "--config-path",
1355
+ default=str(_client_config_path()),
1356
+ help="Path to the persisted CLI client config file.",
1357
+ )
1358
+
1359
+ install_mcp = subparsers.add_parser(
1360
+ "install-mcp",
1361
+ help="Install Minder MCP config for the current directory or globally.",
1362
+ )
1363
+ install_mcp.add_argument(
1364
+ "--config-path",
1365
+ default=str(_client_config_path()),
1366
+ help="Path to the persisted CLI client config file.",
1367
+ )
1368
+ install_mcp.add_argument(
1369
+ "--target",
1370
+ action="append",
1371
+ help="MCP target to install: vscode, cursor, claude-code, or all. Repeatable.",
1372
+ )
1373
+ install_mcp.add_argument(
1374
+ "--global",
1375
+ dest="global_install",
1376
+ action="store_true",
1377
+ help="Install into user-level config paths instead of the current workspace.",
1378
+ )
1379
+ install_mcp.add_argument(
1380
+ "--cwd",
1381
+ default=".",
1382
+ help="Workspace directory to install local MCP config into.",
1383
+ )
1384
+
1385
+ uninstall_mcp = subparsers.add_parser(
1386
+ "uninstall-mcp",
1387
+ help="Remove Minder MCP config from the current directory or global config.",
1388
+ )
1389
+ uninstall_mcp.add_argument(
1390
+ "--target",
1391
+ action="append",
1392
+ help="MCP target to uninstall: vscode, cursor, claude-code, or all. Repeatable.",
1393
+ )
1394
+ uninstall_mcp.add_argument(
1395
+ "--global",
1396
+ dest="global_install",
1397
+ action="store_true",
1398
+ help="Remove from user-level config paths instead of the current workspace.",
1399
+ )
1400
+ uninstall_mcp.add_argument(
1401
+ "--cwd",
1402
+ default=".",
1403
+ help="Workspace directory to remove local MCP config from.",
1404
+ )
1405
+
1406
+ install_ide = subparsers.add_parser(
1407
+ "install-ide",
1408
+ help="Install repo-local Minder MCP config plus IDE instruction and agent assets.",
1409
+ )
1410
+ install_ide.add_argument(
1411
+ "--config-path",
1412
+ default=str(_client_config_path()),
1413
+ help="Path to the persisted CLI client config file.",
1414
+ )
1415
+ install_ide.add_argument(
1416
+ "--target",
1417
+ action="append",
1418
+ help="IDE target to install: vscode, cursor, claude-code, or all. Repeatable.",
1419
+ )
1420
+ install_ide.add_argument(
1421
+ "--cwd",
1422
+ default=".",
1423
+ help="Workspace directory to install repo-local IDE assets into.",
1424
+ )
1425
+
1426
+ uninstall_ide = subparsers.add_parser(
1427
+ "uninstall-ide",
1428
+ help="Remove repo-local Minder IDE bootstrap assets from the current workspace.",
1429
+ )
1430
+ uninstall_ide.add_argument(
1431
+ "--target",
1432
+ action="append",
1433
+ help="IDE target to uninstall: vscode, cursor, claude-code, or all. Repeatable.",
1434
+ )
1435
+ uninstall_ide.add_argument(
1436
+ "--cwd",
1437
+ default=".",
1438
+ help="Workspace directory to remove repo-local IDE assets from.",
1439
+ )
1440
+
1441
+ check_update = subparsers.add_parser(
1442
+ "check-update",
1443
+ help="Check for newer published Minder CLI and server releases.",
1444
+ )
1445
+ check_update.add_argument(
1446
+ "--component",
1447
+ choices=("cli", "server", "all"),
1448
+ default="all",
1449
+ help="Which installed component to inspect for available updates.",
1450
+ )
1451
+ check_update.add_argument(
1452
+ "--install-dir",
1453
+ default=None,
1454
+ help="Server install directory to inspect. Defaults to ~/.minder/current or the newest release directory.",
1455
+ )
1456
+
1457
+ self_update = subparsers.add_parser(
1458
+ "self-update",
1459
+ help="Apply a self-update for the Minder CLI, server deployment, or both.",
1460
+ )
1461
+ self_update.add_argument(
1462
+ "--component",
1463
+ choices=("cli", "server", "all"),
1464
+ default="cli",
1465
+ help="Which component to self-update.",
1466
+ )
1467
+ self_update.add_argument(
1468
+ "--manager",
1469
+ choices=("auto", "uv", "pipx", "pip"),
1470
+ default="auto",
1471
+ help="Preferred package manager for CLI self-update.",
1472
+ )
1473
+ self_update.add_argument(
1474
+ "--install-dir",
1475
+ default=None,
1476
+ help="Server install directory to update. Defaults to ~/.minder/current or the newest release directory.",
1477
+ )
1478
+
1479
+ sync = subparsers.add_parser(
1480
+ "sync",
1481
+ help="Build a delta payload from git diff and sync graph metadata using the stored client key.",
1482
+ )
1483
+ sync.add_argument(
1484
+ "--repo-id",
1485
+ required=False,
1486
+ help="Repository UUID registered on the Minder server. If omitted, the CLI resolves it from the current repo.",
1487
+ )
1488
+ sync.add_argument(
1489
+ "--repo-path",
1490
+ default=".",
1491
+ help="Path inside the git repository to sync. Defaults to the current directory.",
1492
+ )
1493
+ sync.add_argument(
1494
+ "--diff-base",
1495
+ default=None,
1496
+ help="Optional git base ref for delta calculation, e.g. origin/main.",
1497
+ )
1498
+ sync.add_argument(
1499
+ "--config-path",
1500
+ default=str(_client_config_path()),
1501
+ help="Path to the persisted CLI client config file.",
1502
+ )
1503
+ sync.add_argument(
1504
+ "--dry-run",
1505
+ action="store_true",
1506
+ help="Print the built payload instead of sending it to the server.",
1507
+ )
1508
+ sync.add_argument(
1509
+ "--skip-upgrade-check",
1510
+ action="store_true",
1511
+ help="Skip checking PyPI for a newer CLI version before syncing.",
1512
+ )
1513
+ return parser
1514
+
1515
+
1516
+ def main(argv: list[str] | None = None) -> int:
1517
+ parser = build_parser()
1518
+ args = parser.parse_args(argv)
1519
+
1520
+ if args.command == "login":
1521
+ return _login(args)
1522
+ if args.command == "install-mcp":
1523
+ return _install_mcp(args)
1524
+ if args.command == "uninstall-mcp":
1525
+ return _uninstall_mcp(args)
1526
+ if args.command == "install-ide":
1527
+ return _install_ide(args)
1528
+ if args.command == "uninstall-ide":
1529
+ return _uninstall_ide(args)
1530
+ if args.command == "check-update":
1531
+ return _check_update(args)
1532
+ if args.command == "self-update":
1533
+ return _self_update(args)
1534
+ if args.command == "sync":
1535
+ return _sync(args)
1536
+
1537
+ parser.error(f"Unknown command: {args.command}")
1538
+ return 2
1539
+
1540
+
1541
+ if __name__ == "__main__":
1542
+ raise SystemExit(main())