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
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Any, Callable
5
+
6
+ from starlette.requests import Request
7
+ from starlette.responses import JSONResponse
8
+ from starlette.routing import BaseRoute, Route
9
+
10
+ from minder.config import MinderConfig
11
+ from minder.embedding.local import LocalEmbeddingProvider
12
+ from minder.observability.metrics import record_admin_operation
13
+ from minder.prompts import PromptRegistry
14
+ from minder.tools.memory import MemoryTools
15
+ from minder.tools.skills import SkillTools
16
+
17
+ from .context import AdminRouteContext
18
+ from .memories import _serialize_memory
19
+ from .prompts import _serialize_prompt
20
+
21
+
22
+ def _config_from_request(request: Request) -> MinderConfig:
23
+ config = getattr(request.app.state, "config", None)
24
+ if isinstance(config, MinderConfig):
25
+ return config
26
+ return MinderConfig()
27
+
28
+
29
+ def _tokenize(value: str) -> set[str]:
30
+ return {
31
+ token
32
+ for token in value.lower()
33
+ .replace("/", " ")
34
+ .replace("_", " ")
35
+ .replace("-", " ")
36
+ .split()
37
+ if token
38
+ }
39
+
40
+
41
+ def _cosine_similarity(left: list[float], right: list[float]) -> float:
42
+ if not left or not right or len(left) != len(right):
43
+ return 0.0
44
+ numerator = sum(a * b for a, b in zip(left, right, strict=False))
45
+ left_norm = math.sqrt(sum(value * value for value in left))
46
+ right_norm = math.sqrt(sum(value * value for value in right))
47
+ if left_norm == 0 or right_norm == 0:
48
+ return 0.0
49
+ return numerator / (left_norm * right_norm)
50
+
51
+
52
+ def _lexical_score(query: str, candidate: str) -> float:
53
+ query_lower = query.lower()
54
+ candidate_lower = candidate.lower()
55
+ query_tokens = _tokenize(query)
56
+ candidate_tokens = _tokenize(candidate)
57
+ overlap = 0.0
58
+ if query_tokens:
59
+ overlap = len(query_tokens & candidate_tokens) / max(len(query_tokens), 1)
60
+ contains = 1.0 if query_lower in candidate_lower else 0.0
61
+ prefix = 1.0 if candidate_lower.startswith(query_lower) else 0.0
62
+ return (overlap * 0.6) + (contains * 0.25) + (prefix * 0.15)
63
+
64
+
65
+ def _rank_serialized_items(
66
+ *,
67
+ items: list[Any],
68
+ query: str,
69
+ text_builder: Callable[[Any], str],
70
+ config: MinderConfig,
71
+ ) -> list[Any]:
72
+ if not query.strip():
73
+ return items
74
+
75
+ embedder = LocalEmbeddingProvider(
76
+ config.embedding.model_path,
77
+ dimensions=min(config.embedding.dimensions, 16),
78
+ runtime="auto",
79
+ )
80
+ query_embedding = embedder.embed(query)
81
+ ranked: list[Any] = []
82
+ for item in items:
83
+ candidate_text = text_builder(item).strip()
84
+ if not candidate_text:
85
+ continue
86
+ candidate_embedding = embedder.embed(candidate_text)
87
+ semantic_score = _cosine_similarity(query_embedding, candidate_embedding)
88
+ lexical_score = _lexical_score(query, candidate_text)
89
+ score = (semantic_score * 0.72) + (lexical_score * 0.28)
90
+ next_item = {**item, "search_score": round(score, 4)}
91
+ ranked.append(next_item)
92
+ ranked.sort(
93
+ key=lambda item: (
94
+ -float(item.get("search_score", 0.0) or 0.0),
95
+ str(item.get("title") or item.get("name") or item.get("id") or "").lower(),
96
+ )
97
+ )
98
+ return ranked
99
+
100
+
101
+ def _rank_skill_items(
102
+ *,
103
+ items: list[dict[str, Any]],
104
+ query: str,
105
+ config: MinderConfig,
106
+ ) -> list[dict[str, Any]]:
107
+ if not query.strip():
108
+ return items
109
+
110
+ embedder = LocalEmbeddingProvider(
111
+ config.embedding.model_path,
112
+ dimensions=min(config.embedding.dimensions, 16),
113
+ runtime="auto",
114
+ )
115
+ query_embedding = embedder.embed(query)
116
+ query_lower = query.strip().lower()
117
+ query_tokens = _tokenize(query)
118
+ ranked: list[dict[str, Any]] = []
119
+
120
+ for item in items:
121
+ title = str(item.get("title", "") or "")
122
+ language = str(item.get("language", "") or "")
123
+ tags = [str(tag) for tag in list(item.get("tags", []) or [])]
124
+ candidate_text = " ".join(
125
+ [
126
+ title,
127
+ language,
128
+ " ".join(tags),
129
+ " ".join(item.get("workflow_step_tags", [])),
130
+ " ".join(item.get("artifact_type_tags", [])),
131
+ str(item.get("content", "") or ""),
132
+ ]
133
+ ).strip()
134
+ if not candidate_text:
135
+ continue
136
+ semantic_score = _cosine_similarity(
137
+ query_embedding,
138
+ embedder.embed(candidate_text),
139
+ )
140
+ lexical_score = _lexical_score(query, candidate_text)
141
+ title_lower = title.lower()
142
+ language_lower = language.lower()
143
+ tag_tokens = {tag.lower() for tag in tags}
144
+ exact_boost = 0.0
145
+ if title_lower == query_lower:
146
+ exact_boost += 2.2
147
+ if language_lower == query_lower:
148
+ exact_boost += 2.8
149
+ if query_lower in tag_tokens:
150
+ exact_boost += 1.9
151
+ if title_lower.startswith(query_lower):
152
+ exact_boost += 0.8
153
+ if any(token == language_lower for token in query_tokens):
154
+ exact_boost += 1.4
155
+ if any(token in tag_tokens for token in query_tokens):
156
+ exact_boost += 0.8
157
+ score = (
158
+ (lexical_score * 0.62)
159
+ + (semantic_score * 0.18)
160
+ + exact_boost
161
+ + (min(float(item.get("quality_score", 0.0) or 0.0), 1.0) * 0.05)
162
+ )
163
+ ranked.append(
164
+ {
165
+ **item,
166
+ "search_score": round(score, 4),
167
+ "lexical_score": round(lexical_score, 4),
168
+ "semantic_score": round(semantic_score, 4),
169
+ "exact_match_score": round(exact_boost, 4),
170
+ }
171
+ )
172
+
173
+ if (
174
+ query_tokens
175
+ and len(query_tokens) <= 2
176
+ and any(
177
+ float(item.get("exact_match_score", 0.0) or 0.0) > 0
178
+ or float(item.get("lexical_score", 0.0) or 0.0) >= 0.45
179
+ for item in ranked
180
+ )
181
+ ):
182
+ ranked = [
183
+ item
184
+ for item in ranked
185
+ if float(item.get("exact_match_score", 0.0) or 0.0) > 0
186
+ or float(item.get("lexical_score", 0.0) or 0.0) > 0
187
+ ]
188
+
189
+ ranked.sort(
190
+ key=lambda item: (
191
+ -float(item.get("search_score", 0.0) or 0.0),
192
+ -float(item.get("exact_match_score", 0.0) or 0.0),
193
+ str(item.get("title") or "").lower(),
194
+ )
195
+ )
196
+ return ranked
197
+
198
+
199
+ def _slice_items(
200
+ items: list[Any],
201
+ *,
202
+ limit: int,
203
+ offset: int,
204
+ ) -> dict[str, Any]:
205
+ total = len(items)
206
+ return {
207
+ "items": items[offset : offset + limit],
208
+ "total": total,
209
+ "limit": limit,
210
+ "offset": offset,
211
+ }
212
+
213
+
214
+ async def _prompt_items(context: AdminRouteContext) -> list[dict[str, Any]]:
215
+ prompt_index = {
216
+ prompt.name: prompt for prompt in PromptRegistry.builtin_prompt_models()
217
+ }
218
+ for prompt in await context.store.list_prompts():
219
+ prompt_index[prompt.name] = prompt
220
+ ordered = sorted(
221
+ prompt_index.values(),
222
+ key=lambda prompt: (
223
+ not bool(getattr(prompt, "is_builtin", False)),
224
+ str(getattr(prompt, "name", "")).lower(),
225
+ ),
226
+ )
227
+ return [_serialize_prompt(prompt) for prompt in ordered]
228
+
229
+
230
+ def build_search_routes(context: AdminRouteContext) -> list[BaseRoute]:
231
+ async def admin_catalog_search(request: Request) -> JSONResponse:
232
+ try:
233
+ await context.admin_user_from_request(request)
234
+ except PermissionError:
235
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
236
+ except Exception as exc:
237
+ return JSONResponse({"error": str(exc)}, status_code=401)
238
+
239
+ resource = str(request.path_params.get("resource", "")).strip().lower()
240
+ query = (request.query_params.get("query") or "").strip()
241
+ try:
242
+ limit = max(1, min(int(request.query_params.get("limit", "100")), 200))
243
+ offset = max(0, int(request.query_params.get("offset", "0")))
244
+ except ValueError:
245
+ return JSONResponse({"error": "Invalid limit or offset"}, status_code=400)
246
+
247
+ await record_admin_operation(
248
+ operation=f"search_{resource or 'catalog'}",
249
+ outcome="success",
250
+ actor_id="system",
251
+ store=context.store,
252
+ )
253
+
254
+ config = _config_from_request(request)
255
+
256
+ ranked: list[Any] = []
257
+
258
+ if resource == "clients":
259
+ client_items = (await context.use_cases.list_clients())["clients"]
260
+ ranked = _rank_serialized_items(
261
+ items=client_items,
262
+ query=query,
263
+ text_builder=lambda item: " ".join(
264
+ [
265
+ str(item.get("name", "")),
266
+ str(item.get("slug", "")),
267
+ str(item.get("description", "")),
268
+ " ".join(item.get("tool_scopes", [])),
269
+ " ".join(item.get("repo_scopes", [])),
270
+ " ".join(item.get("workflow_scopes", [])),
271
+ ]
272
+ ),
273
+ config=config,
274
+ )
275
+ elif resource == "repositories":
276
+ repo_items = (await context.use_cases.list_repositories())["repositories"]
277
+ ranked = _rank_serialized_items(
278
+ items=repo_items,
279
+ query=query,
280
+ text_builder=lambda item: " ".join(
281
+ [
282
+ str(item.get("name", "")),
283
+ str(item.get("path", "")),
284
+ str(item.get("remote_url", "")),
285
+ str(item.get("default_branch", "")),
286
+ str(item.get("workflow_name", "")),
287
+ str(item.get("workflow_state", "")),
288
+ str(item.get("current_step", "")),
289
+ " ".join(item.get("tracked_branches", [])),
290
+ ]
291
+ ),
292
+ config=config,
293
+ )
294
+ elif resource == "workflows":
295
+ workflow_items = (await context.use_cases.list_workflows())["workflows"]
296
+ ranked = _rank_serialized_items(
297
+ items=workflow_items,
298
+ query=query,
299
+ text_builder=lambda item: " ".join(
300
+ [
301
+ str(item.get("name", "")),
302
+ str(item.get("description", "")),
303
+ str(item.get("enforcement", "")),
304
+ " ".join(
305
+ " ".join(
306
+ [
307
+ str(step.get("name", "")),
308
+ str(step.get("description", "")),
309
+ str(step.get("gate", "")),
310
+ ]
311
+ )
312
+ for step in item.get("steps", [])
313
+ ),
314
+ ]
315
+ ),
316
+ config=config,
317
+ )
318
+ elif resource == "prompts":
319
+ prompt_items = await _prompt_items(context)
320
+ ranked = _rank_serialized_items(
321
+ items=prompt_items,
322
+ query=query,
323
+ text_builder=lambda item: " ".join(
324
+ [
325
+ str(item.get("name", "")),
326
+ str(item.get("title", "")),
327
+ str(item.get("description", "")),
328
+ str(item.get("content_template", "")),
329
+ " ".join(item.get("arguments", [])),
330
+ ]
331
+ ),
332
+ config=config,
333
+ )
334
+ elif resource == "skills":
335
+ skill_tools = SkillTools(context.store, config)
336
+ all_skill_items = await skill_tools.minder_skill_list()
337
+ ranked = _rank_skill_items(
338
+ items=all_skill_items,
339
+ query=query,
340
+ config=config,
341
+ )
342
+ elif resource == "memories":
343
+ memory_tools = MemoryTools(context.store, config)
344
+ all_memory_items = [
345
+ _serialize_memory(skill)
346
+ for skill in sorted(
347
+ await context.store.list_skills(),
348
+ key=lambda skill: str(getattr(skill, "title", "")).lower(),
349
+ )
350
+ ]
351
+ ranked = (
352
+ await memory_tools.minder_memory_recall(
353
+ query, limit=max(len(all_memory_items), 1)
354
+ )
355
+ if query
356
+ else all_memory_items
357
+ )
358
+ else:
359
+ return JSONResponse({"error": "Unsupported resource"}, status_code=404)
360
+
361
+ payload = _slice_items(ranked, limit=limit, offset=offset)
362
+ payload["resource"] = resource
363
+ payload["query"] = query
364
+ return JSONResponse(payload)
365
+
366
+ return [
367
+ Route("/v1/admin/search/{resource}", admin_catalog_search, methods=["GET"]),
368
+ ]
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+ from starlette.requests import Request
9
+ from starlette.responses import JSONResponse
10
+ from starlette.routing import BaseRoute, Route
11
+
12
+ from minder.config import MinderConfig
13
+ from minder.observability.metrics import record_admin_operation
14
+ from minder.tools.skills import SkillTools
15
+
16
+ from .context import AdminRouteContext
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class SkillCreateRequest(BaseModel):
22
+ title: str
23
+ content: str
24
+ language: str
25
+ tags: list[str] = Field(default_factory=list)
26
+ workflow_steps: list[str] = Field(default_factory=list)
27
+ artifact_types: list[str] = Field(default_factory=list)
28
+ provenance: str | None = None
29
+ quality_score: float = 0.0
30
+ source: dict[str, Any] | None = None
31
+ excerpt_kind: str = "none"
32
+
33
+
34
+ class SkillUpdateRequest(BaseModel):
35
+ title: str | None = None
36
+ content: str | None = None
37
+ language: str | None = None
38
+ tags: list[str] | None = None
39
+ workflow_steps: list[str] | None = None
40
+ artifact_types: list[str] | None = None
41
+ provenance: str | None = None
42
+ quality_score: float | None = None
43
+ source: dict[str, Any] | None = None
44
+ excerpt_kind: str | None = None
45
+
46
+
47
+ class SkillImportRequest(BaseModel):
48
+ repo_url: str
49
+ path: str = "skills"
50
+ ref: str | None = None
51
+ provider: str | None = None
52
+ excerpt_kind: str = "none"
53
+
54
+
55
+ def _config_from_request(request: Request) -> MinderConfig:
56
+ config = getattr(request.app.state, "config", None)
57
+ if isinstance(config, MinderConfig):
58
+ return config
59
+ return MinderConfig()
60
+
61
+
62
+ def _artifact_tags() -> set[str]:
63
+ return set(SkillTools._ARTIFACT_TAGS)
64
+
65
+
66
+ def _serialize_skill(skill: Any) -> dict[str, Any]:
67
+ tags = list(getattr(skill, "tags", []) or [])
68
+ artifact_tags = _artifact_tags()
69
+ source_metadata = getattr(skill, "source_metadata", None)
70
+ return {
71
+ "id": str(skill.id),
72
+ "title": str(skill.title),
73
+ "content": str(skill.content),
74
+ "language": str(getattr(skill, "language", "")),
75
+ "tags": tags,
76
+ "quality_score": round(float(getattr(skill, "quality_score", 0.0) or 0.0), 4),
77
+ "usage_count": int(getattr(skill, "usage_count", 0) or 0),
78
+ "workflow_step_tags": [
79
+ tag for tag in tags if ":" not in tag and tag not in artifact_tags
80
+ ],
81
+ "artifact_type_tags": [tag for tag in tags if tag in artifact_tags],
82
+ "provenance": next(
83
+ (tag.split(":", 1)[1] for tag in tags if tag.startswith("source:")),
84
+ None,
85
+ ),
86
+ "source": dict(source_metadata) if isinstance(source_metadata, dict) else None,
87
+ "excerpt_kind": str(getattr(skill, "excerpt_kind", "none") or "none"),
88
+ "created_at": skill.created_at.isoformat() if skill.created_at else None,
89
+ "updated_at": skill.updated_at.isoformat() if skill.updated_at else None,
90
+ }
91
+
92
+
93
+ def build_skills_routes(context: AdminRouteContext) -> list[BaseRoute]:
94
+ async def list_skills(request: Request) -> JSONResponse:
95
+ del request
96
+ await record_admin_operation(
97
+ operation="list_skills",
98
+ outcome="success",
99
+ actor_id="system",
100
+ store=context.store,
101
+ )
102
+ try:
103
+ skills = sorted(
104
+ await context.store.list_skills(),
105
+ key=lambda skill: (
106
+ -float(getattr(skill, "quality_score", 0.0) or 0.0),
107
+ str(getattr(skill, "title", "")).lower(),
108
+ ),
109
+ )
110
+ return JSONResponse([_serialize_skill(skill) for skill in skills])
111
+ except Exception as exc:
112
+ logger.exception("Failed to list skills", exc_info=exc)
113
+ return JSONResponse({"error": str(exc)}, status_code=500)
114
+
115
+ async def get_skill(request: Request) -> JSONResponse:
116
+ skill_id = request.path_params["skill_id"]
117
+ await record_admin_operation(
118
+ operation="get_skill",
119
+ outcome="success",
120
+ actor_id="system",
121
+ store=context.store,
122
+ )
123
+ try:
124
+ skill = await context.store.get_skill_by_id(uuid.UUID(skill_id))
125
+ if not skill:
126
+ return JSONResponse({"error": "Skill not found"}, status_code=404)
127
+ return JSONResponse(_serialize_skill(skill))
128
+ except Exception as exc:
129
+ logger.exception("Failed to get skill", exc_info=exc)
130
+ return JSONResponse({"error": str(exc)}, status_code=500)
131
+
132
+ async def create_skill(request: Request) -> JSONResponse:
133
+ await record_admin_operation(
134
+ operation="create_skill",
135
+ outcome="success",
136
+ actor_id="system",
137
+ store=context.store,
138
+ )
139
+ try:
140
+ payload = SkillCreateRequest(**(await request.json()))
141
+ tools = SkillTools(context.store, _config_from_request(request))
142
+ skill = await tools.minder_skill_store(
143
+ title=payload.title,
144
+ content=payload.content,
145
+ language=payload.language,
146
+ tags=payload.tags,
147
+ workflow_steps=payload.workflow_steps,
148
+ artifact_types=payload.artifact_types,
149
+ provenance=payload.provenance,
150
+ quality_score=payload.quality_score,
151
+ source_metadata=payload.source,
152
+ excerpt_kind=payload.excerpt_kind,
153
+ )
154
+ return JSONResponse(skill, status_code=201)
155
+ except Exception as exc:
156
+ logger.exception("Failed to create skill", exc_info=exc)
157
+ return JSONResponse({"error": str(exc)}, status_code=400)
158
+
159
+ async def update_skill(request: Request) -> JSONResponse:
160
+ skill_id = request.path_params["skill_id"]
161
+ await record_admin_operation(
162
+ operation="update_skill",
163
+ outcome="success",
164
+ actor_id="system",
165
+ store=context.store,
166
+ )
167
+ try:
168
+ payload = SkillUpdateRequest(**(await request.json()))
169
+ tools = SkillTools(context.store, _config_from_request(request))
170
+ update_data = payload.model_dump(exclude={"source"}, exclude_unset=True)
171
+ skill = await tools.minder_skill_update(
172
+ skill_id,
173
+ **update_data,
174
+ source_metadata=payload.source,
175
+ )
176
+ return JSONResponse(skill)
177
+ except ValueError as exc:
178
+ return JSONResponse({"error": str(exc)}, status_code=404)
179
+ except Exception as exc:
180
+ logger.exception("Failed to update skill", exc_info=exc)
181
+ return JSONResponse({"error": str(exc)}, status_code=400)
182
+
183
+ async def import_skills(request: Request) -> JSONResponse:
184
+ await record_admin_operation(
185
+ operation="import_skills",
186
+ outcome="success",
187
+ actor_id="system",
188
+ store=context.store,
189
+ )
190
+ try:
191
+ payload = SkillImportRequest(**(await request.json()))
192
+ tools = SkillTools(context.store, _config_from_request(request))
193
+ summary = await tools.minder_skill_import_git(
194
+ repo_url=payload.repo_url,
195
+ source_path=payload.path,
196
+ ref=payload.ref,
197
+ provider=payload.provider,
198
+ excerpt_kind=payload.excerpt_kind,
199
+ )
200
+ return JSONResponse(summary, status_code=201)
201
+ except Exception as exc:
202
+ logger.exception("Failed to import skills", exc_info=exc)
203
+ return JSONResponse({"error": str(exc)}, status_code=400)
204
+
205
+ async def delete_skill(request: Request) -> JSONResponse:
206
+ skill_id = request.path_params["skill_id"]
207
+ await record_admin_operation(
208
+ operation="delete_skill",
209
+ outcome="success",
210
+ actor_id="system",
211
+ store=context.store,
212
+ )
213
+ try:
214
+ skill = await context.store.get_skill_by_id(uuid.UUID(skill_id))
215
+ if skill is None:
216
+ return JSONResponse({"error": "Skill not found"}, status_code=404)
217
+ await context.store.delete_skill(uuid.UUID(skill_id))
218
+ return JSONResponse({"status": "deleted"}, status_code=200)
219
+ except Exception as exc:
220
+ logger.exception("Failed to delete skill", exc_info=exc)
221
+ return JSONResponse({"error": str(exc)}, status_code=500)
222
+
223
+ return [
224
+ Route("/api/v1/skills", list_skills, methods=["GET"]),
225
+ Route("/api/v1/skills", create_skill, methods=["POST"]),
226
+ Route("/api/v1/skills/imports", import_skills, methods=["POST"]),
227
+ Route("/api/v1/skills/{skill_id}", get_skill, methods=["GET"]),
228
+ Route("/api/v1/skills/{skill_id}", update_skill, methods=["PATCH", "PUT"]),
229
+ Route("/api/v1/skills/{skill_id}", delete_skill, methods=["DELETE"]),
230
+ ]