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.
- minder/__init__.py +12 -0
- minder/api/routers/prompts.py +177 -0
- minder/application/__init__.py +1 -0
- minder/application/admin/__init__.py +11 -0
- minder/application/admin/dto.py +453 -0
- minder/application/admin/jobs.py +327 -0
- minder/application/admin/use_cases.py +1895 -0
- minder/auth/__init__.py +12 -0
- minder/auth/context.py +26 -0
- minder/auth/middleware.py +70 -0
- minder/auth/principal.py +59 -0
- minder/auth/rate_limiter.py +89 -0
- minder/auth/rbac.py +60 -0
- minder/auth/service.py +541 -0
- minder/bootstrap/__init__.py +9 -0
- minder/bootstrap/providers.py +109 -0
- minder/bootstrap/transport.py +807 -0
- minder/cache/__init__.py +10 -0
- minder/cache/providers.py +140 -0
- minder/chunking/__init__.py +4 -0
- minder/chunking/code_splitter.py +184 -0
- minder/chunking/splitter.py +136 -0
- minder/cli.py +1542 -0
- minder/config.py +179 -0
- minder/continuity.py +363 -0
- minder/dev.py +160 -0
- minder/embedding/__init__.py +9 -0
- minder/embedding/base.py +7 -0
- minder/embedding/local.py +65 -0
- minder/embedding/openai.py +7 -0
- minder/graph/__init__.py +11 -0
- minder/graph/edges.py +13 -0
- minder/graph/executor.py +127 -0
- minder/graph/graph.py +263 -0
- minder/graph/nodes/__init__.py +27 -0
- minder/graph/nodes/evaluator.py +21 -0
- minder/graph/nodes/guard.py +64 -0
- minder/graph/nodes/llm.py +59 -0
- minder/graph/nodes/planning.py +30 -0
- minder/graph/nodes/reasoning.py +87 -0
- minder/graph/nodes/reranker.py +141 -0
- minder/graph/nodes/retriever.py +86 -0
- minder/graph/nodes/verification.py +230 -0
- minder/graph/nodes/workflow_planner.py +250 -0
- minder/graph/runtime.py +15 -0
- minder/graph/state.py +26 -0
- minder/llm/__init__.py +5 -0
- minder/llm/base.py +14 -0
- minder/llm/local.py +381 -0
- minder/llm/openai.py +89 -0
- minder/models/__init__.py +109 -0
- minder/models/base.py +10 -0
- minder/models/client.py +137 -0
- minder/models/document.py +34 -0
- minder/models/error.py +32 -0
- minder/models/graph.py +114 -0
- minder/models/history.py +32 -0
- minder/models/job.py +62 -0
- minder/models/prompt.py +41 -0
- minder/models/repository.py +62 -0
- minder/models/rule.py +68 -0
- minder/models/session.py +51 -0
- minder/models/skill.py +52 -0
- minder/models/user.py +41 -0
- minder/models/workflow.py +35 -0
- minder/observability/__init__.py +57 -0
- minder/observability/audit.py +243 -0
- minder/observability/logging.py +253 -0
- minder/observability/metrics.py +448 -0
- minder/observability/tracing.py +215 -0
- minder/presentation/__init__.py +1 -0
- minder/presentation/http/__init__.py +1 -0
- minder/presentation/http/admin/__init__.py +3 -0
- minder/presentation/http/admin/api.py +1309 -0
- minder/presentation/http/admin/context.py +94 -0
- minder/presentation/http/admin/dashboard.py +111 -0
- minder/presentation/http/admin/jobs.py +208 -0
- minder/presentation/http/admin/memories.py +185 -0
- minder/presentation/http/admin/prompts.py +219 -0
- minder/presentation/http/admin/routes.py +127 -0
- minder/presentation/http/admin/runtime.py +650 -0
- minder/presentation/http/admin/search.py +368 -0
- minder/presentation/http/admin/skills.py +230 -0
- minder/prompts/__init__.py +646 -0
- minder/prompts/formatter.py +142 -0
- minder/resources/__init__.py +318 -0
- minder/retrieval/__init__.py +5 -0
- minder/retrieval/hybrid.py +178 -0
- minder/retrieval/mmr.py +116 -0
- minder/retrieval/multi_hop.py +115 -0
- minder/runtime.py +15 -0
- minder/server.py +145 -0
- minder/store/__init__.py +64 -0
- minder/store/document.py +115 -0
- minder/store/error.py +82 -0
- minder/store/feedback.py +114 -0
- minder/store/graph.py +588 -0
- minder/store/history.py +57 -0
- minder/store/interfaces.py +512 -0
- minder/store/milvus/__init__.py +11 -0
- minder/store/milvus/client.py +26 -0
- minder/store/milvus/collections.py +15 -0
- minder/store/milvus/vector_store.py +232 -0
- minder/store/mongodb/__init__.py +11 -0
- minder/store/mongodb/client.py +49 -0
- minder/store/mongodb/indexes.py +90 -0
- minder/store/mongodb/operational_store.py +993 -0
- minder/store/relational.py +1087 -0
- minder/store/repo_state.py +58 -0
- minder/store/rule.py +93 -0
- minder/store/vector.py +79 -0
- minder/tools/__init__.py +47 -0
- minder/tools/auth.py +94 -0
- minder/tools/graph.py +839 -0
- minder/tools/ingest.py +353 -0
- minder/tools/memory.py +381 -0
- minder/tools/query.py +307 -0
- minder/tools/registry.py +269 -0
- minder/tools/repo_scanner.py +1266 -0
- minder/tools/search.py +15 -0
- minder/tools/session.py +316 -0
- minder/tools/skills.py +899 -0
- minder/tools/workflow.py +215 -0
- minder/transport/__init__.py +4 -0
- minder/transport/base.py +286 -0
- minder/transport/sse.py +252 -0
- minder/transport/stdio.py +29 -0
- minder_cli-0.2.0.dist-info/METADATA +318 -0
- minder_cli-0.2.0.dist-info/RECORD +132 -0
- minder_cli-0.2.0.dist-info/WHEEL +4 -0
- minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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())
|