deeptutor-server 1.7.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 (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,315 @@
1
+ """
2
+ Skills API Router
3
+ =================
4
+
5
+ CRUD endpoints for account-shared and workspace-owned SKILL.md packages.
6
+ The optional ``skill_workspace`` selector is validated at authentication.
7
+
8
+ Mounted at ``/api/skills``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from fastapi import APIRouter, HTTPException
14
+ from pydantic import BaseModel, Field
15
+
16
+ from deeptutor.multi_user.context import get_current_user
17
+ from deeptutor.multi_user.skill_access import (
18
+ assigned_skill_detail,
19
+ assigned_skill_ids,
20
+ assigned_skill_infos,
21
+ )
22
+ from deeptutor.services.skill import get_skill_service as get_account_skill_service
23
+ from deeptutor.services.skill.service import (
24
+ InvalidSkillNameError,
25
+ InvalidTagError,
26
+ SkillExistsError,
27
+ SkillImportError,
28
+ SkillNotFoundError,
29
+ SkillReadOnlyError,
30
+ TagExistsError,
31
+ TagNotFoundError,
32
+ )
33
+
34
+
35
+ def get_skill_service():
36
+ from deeptutor.services.skill.runtime import library_workspace, workspace_skill_service
37
+
38
+ workspace_id = library_workspace.get()
39
+ return workspace_skill_service(workspace_id) if workspace_id else get_account_skill_service()
40
+
41
+
42
+ router = APIRouter()
43
+
44
+
45
+ class CreateSkillRequest(BaseModel):
46
+ name: str = Field(..., min_length=1, max_length=64)
47
+ description: str = ""
48
+ content: str = ""
49
+ tags: list[str] = Field(default_factory=list)
50
+
51
+
52
+ class UpdateSkillRequest(BaseModel):
53
+ description: str | None = None
54
+ content: str | None = None
55
+ rename_to: str | None = None
56
+ tags: list[str] | None = None
57
+
58
+
59
+ class InstallSkillRequest(BaseModel):
60
+ """Install a hub skill into the caller's own skill layer.
61
+
62
+ ``ref`` is a ``<hub>:<slug>[@version]`` reference (the bare hub prefix
63
+ defaults to ``eduhub``). The web "import from EduHub" flow always builds an
64
+ ``eduhub:`` ref so a spoofed bridge message can't redirect the install to an
65
+ arbitrary registry.
66
+ """
67
+
68
+ ref: str = Field(..., min_length=1, max_length=256)
69
+ name: str | None = None
70
+ force: bool = False
71
+ allow_unverified: bool = False
72
+
73
+
74
+ class CreateTagRequest(BaseModel):
75
+ name: str = Field(..., min_length=1, max_length=32)
76
+
77
+
78
+ class RenameTagRequest(BaseModel):
79
+ rename_to: str = Field(..., min_length=1, max_length=32)
80
+
81
+
82
+ # ── tag routes (declared before `/{name}` so they are not shadowed) ──
83
+
84
+
85
+ @router.get("/tags/list")
86
+ async def list_tags() -> dict[str, list[str]]:
87
+ service = get_skill_service()
88
+ return {"tags": service.list_tags()}
89
+
90
+
91
+ @router.post("/tags/create")
92
+ async def create_tag(payload: CreateTagRequest) -> dict[str, str]:
93
+ service = get_skill_service()
94
+ try:
95
+ tag = service.create_tag(payload.name)
96
+ except TagExistsError as exc:
97
+ raise HTTPException(status_code=409, detail=f"Tag already exists: {exc}")
98
+ except InvalidTagError as exc:
99
+ raise HTTPException(status_code=400, detail=str(exc))
100
+ return {"name": tag}
101
+
102
+
103
+ @router.put("/tags/{tag}")
104
+ async def rename_tag(tag: str, payload: RenameTagRequest) -> dict[str, str]:
105
+ service = get_skill_service()
106
+ try:
107
+ new_tag = service.rename_tag(tag, payload.rename_to)
108
+ except TagNotFoundError:
109
+ raise HTTPException(status_code=404, detail=f"Tag not found: {tag}")
110
+ except TagExistsError as exc:
111
+ raise HTTPException(status_code=409, detail=f"Tag already exists: {exc}")
112
+ except InvalidTagError as exc:
113
+ raise HTTPException(status_code=400, detail=str(exc))
114
+ return {"name": new_tag}
115
+
116
+
117
+ @router.delete("/tags/{tag}")
118
+ async def delete_tag(tag: str) -> dict[str, str]:
119
+ service = get_skill_service()
120
+ try:
121
+ service.delete_tag(tag)
122
+ except TagNotFoundError:
123
+ raise HTTPException(status_code=404, detail=f"Tag not found: {tag}")
124
+ except InvalidTagError as exc:
125
+ raise HTTPException(status_code=400, detail=str(exc))
126
+ return {"status": "deleted", "name": tag}
127
+
128
+
129
+ # ── skill routes ───────────────────────────────────────────────────
130
+
131
+
132
+ @router.get("/list")
133
+ async def list_skills() -> dict[str, list[dict[str, object]]]:
134
+ service = get_skill_service()
135
+ own_items = [info.to_dict() for info in service.list_skills()]
136
+ user = get_current_user()
137
+ from deeptutor.services.skill.runtime import library_workspace
138
+
139
+ if user.is_admin or library_workspace.get():
140
+ return {"skills": own_items}
141
+ own_names = {item.get("name") for item in own_items}
142
+ merged = list(own_items)
143
+ for assigned in assigned_skill_infos(user.id):
144
+ if assigned.get("name") in own_names:
145
+ continue
146
+ merged.append(assigned)
147
+ return {"skills": merged}
148
+
149
+
150
+ # ── hub browse (in-app EduHub skill browser; declared before `/{name}`) ──
151
+
152
+
153
+ @router.get("/hub/catalog")
154
+ async def hub_catalog(hub: str = "eduhub", q: str = "", limit: int = 50) -> dict[str, object]:
155
+ """Proxy a skill hub's public catalog for the in-app browser.
156
+
157
+ The web "Import from EduHub" panel renders these rows in DeepTutor's own
158
+ UI — no embedded iframe, no login — so users can browse, search, and
159
+ one-click download skills. Returns ``web_url`` (the hub's site origin) so
160
+ the panel can offer a "view on EduHub" link out.
161
+ """
162
+ import asyncio
163
+
164
+ from deeptutor.services.skill.hub import ClawHubProvider, HubError, get_hub_provider
165
+
166
+ try:
167
+ provider = get_hub_provider(hub)
168
+ except HubError as exc:
169
+ raise HTTPException(status_code=400, detail=str(exc))
170
+ if not isinstance(provider, ClawHubProvider):
171
+ raise HTTPException(status_code=400, detail=f"Hub `{hub}` does not support browsing.")
172
+ try:
173
+ skills = await asyncio.to_thread(provider.catalog, query=q, limit=limit)
174
+ except HubError as exc:
175
+ raise HTTPException(status_code=502, detail=str(exc))
176
+ return {"hub": provider.name, "web_url": provider.web_origin, "skills": skills}
177
+
178
+
179
+ @router.get("/hub/detail")
180
+ async def hub_detail(slug: str, hub: str = "eduhub") -> dict[str, object]:
181
+ """Full metadata + SKILL.md body for one hub skill (browser detail view)."""
182
+ import asyncio
183
+
184
+ from deeptutor.services.skill.hub import ClawHubProvider, HubError, get_hub_provider
185
+
186
+ try:
187
+ provider = get_hub_provider(hub)
188
+ except HubError as exc:
189
+ raise HTTPException(status_code=400, detail=str(exc))
190
+ if not isinstance(provider, ClawHubProvider):
191
+ raise HTTPException(status_code=400, detail=f"Hub `{hub}` does not support browsing.")
192
+ try:
193
+ detail = await asyncio.to_thread(provider.detail, slug)
194
+ except HubError as exc:
195
+ raise HTTPException(status_code=502, detail=str(exc))
196
+ detail["web_url"] = f"{provider.web_origin}/skills/{slug}"
197
+ return detail
198
+
199
+
200
+ @router.get("/{name}")
201
+ async def get_skill(name: str) -> dict[str, object]:
202
+ service = get_skill_service()
203
+ try:
204
+ return service.get_detail(name).to_dict()
205
+ except SkillNotFoundError:
206
+ # User scope doesn't have it. Fall through to admin-assigned lookup
207
+ # below, which returns 403 if the user has no grant for it.
208
+ pass
209
+ except InvalidSkillNameError as exc:
210
+ raise HTTPException(status_code=400, detail=str(exc))
211
+
212
+ user = get_current_user()
213
+ if user.is_admin:
214
+ # Admin scope already checked above; nothing else to look at.
215
+ raise HTTPException(status_code=404, detail=f"Skill not found: {name}")
216
+ if name not in assigned_skill_ids(user.id):
217
+ raise HTTPException(status_code=403, detail="Skill is not assigned to you")
218
+ detail = assigned_skill_detail(name)
219
+ if detail is None:
220
+ raise HTTPException(status_code=404, detail=f"Skill not found: {name}")
221
+ return detail
222
+
223
+
224
+ @router.post("/create")
225
+ async def create_skill(payload: CreateSkillRequest) -> dict[str, object]:
226
+ service = get_skill_service()
227
+ try:
228
+ info = service.create(
229
+ name=payload.name,
230
+ description=payload.description,
231
+ content=payload.content,
232
+ tags=list(payload.tags or []),
233
+ )
234
+ return info.to_dict()
235
+ except SkillExistsError:
236
+ raise HTTPException(status_code=409, detail=f"Skill already exists: {payload.name}")
237
+ except InvalidSkillNameError as exc:
238
+ raise HTTPException(status_code=400, detail=str(exc))
239
+ except InvalidTagError as exc:
240
+ raise HTTPException(status_code=400, detail=str(exc))
241
+
242
+
243
+ @router.post("/install")
244
+ async def install_skill(payload: InstallSkillRequest) -> dict[str, object]:
245
+ """Import a hub skill (e.g. from EduHub) into the caller's own skill layer.
246
+
247
+ Lands the package in the same per-user dir that ``/create`` writes to, so
248
+ the imported skill shows up in this user's Skills list. The install gate
249
+ (``suspicious`` verdict abort, safe extraction, ``always`` stripping)
250
+ lives in :func:`deeptutor.services.skill.hub.install_from_hub`.
251
+ """
252
+ import asyncio
253
+
254
+ from deeptutor.services.skill.hub import HubError, install_from_hub
255
+
256
+ service = get_skill_service()
257
+ try:
258
+ outcome = await asyncio.to_thread(
259
+ install_from_hub,
260
+ payload.ref,
261
+ service=service,
262
+ rename_to=payload.name,
263
+ force=payload.force,
264
+ allow_unverified=payload.allow_unverified,
265
+ )
266
+ except SkillExistsError as exc:
267
+ raise HTTPException(status_code=409, detail=f"Skill already exists: {exc}")
268
+ except (SkillImportError, InvalidSkillNameError) as exc:
269
+ raise HTTPException(status_code=400, detail=str(exc))
270
+ except HubError as exc:
271
+ raise HTTPException(status_code=502, detail=str(exc))
272
+
273
+ return {
274
+ "skill": outcome.result.info.to_dict(),
275
+ "verdict": {"status": outcome.verdict.status, "detail": outcome.verdict.detail},
276
+ "version": outcome.ref.version,
277
+ }
278
+
279
+
280
+ @router.put("/{name}")
281
+ async def update_skill(name: str, payload: UpdateSkillRequest) -> dict[str, object]:
282
+ service = get_skill_service()
283
+ try:
284
+ info = service.update(
285
+ name,
286
+ description=payload.description,
287
+ content=payload.content,
288
+ rename_to=payload.rename_to,
289
+ tags=payload.tags,
290
+ )
291
+ return info.to_dict()
292
+ except SkillNotFoundError:
293
+ raise HTTPException(status_code=404, detail=f"Skill not found: {name}")
294
+ except SkillReadOnlyError as exc:
295
+ raise HTTPException(status_code=403, detail=str(exc))
296
+ except SkillExistsError as exc:
297
+ raise HTTPException(status_code=409, detail=str(exc))
298
+ except InvalidSkillNameError as exc:
299
+ raise HTTPException(status_code=400, detail=str(exc))
300
+ except InvalidTagError as exc:
301
+ raise HTTPException(status_code=400, detail=str(exc))
302
+
303
+
304
+ @router.delete("/{name}")
305
+ async def delete_skill(name: str) -> dict[str, str]:
306
+ service = get_skill_service()
307
+ try:
308
+ service.delete(name)
309
+ return {"status": "deleted", "name": name}
310
+ except SkillNotFoundError:
311
+ raise HTTPException(status_code=404, detail=f"Skill not found: {name}")
312
+ except SkillReadOnlyError as exc:
313
+ raise HTTPException(status_code=403, detail=str(exc))
314
+ except InvalidSkillNameError as exc:
315
+ raise HTTPException(status_code=400, detail=str(exc))
@@ -0,0 +1,222 @@
1
+ """
2
+ CLI apps API
3
+ ============
4
+
5
+ Mounted at ``/api/space/cli-apps``. Two audiences on one surface, split by
6
+ method rather than by route prefix so the page has one thing to read:
7
+
8
+ * **anyone signed in** — ``GET /apps`` (what is installed, and whether I may use
9
+ it), ``GET /catalog`` (what exists), ``PUT /apps/{id}/enabled`` (my own
10
+ preference among the apps I have been granted);
11
+ * **administrators only** — ``POST /catalog/{id}/install`` and
12
+ ``DELETE /apps/{id}``.
13
+
14
+ The admin routes are gated individually with ``Depends(require_admin)`` rather
15
+ than by an all-router dependency, because installing is the privileged half and
16
+ reading is not: installing runs a third-party ``setup.py`` in the application
17
+ container, as the application user. That is the whole reason CLI apps are not
18
+ self-service, and it is why an ordinary account's verb here is *enable*.
19
+
20
+ Enabling is a preference, not a permission. The permission is
21
+ ``grant.cli_apps``, resolved server-side in ``cli_apps.provider``; an account can
22
+ only switch off something it was granted, never switch on something it was not.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from typing import Any
29
+
30
+ from fastapi import APIRouter, Depends, HTTPException
31
+ from pydantic import BaseModel
32
+
33
+ from deeptutor.api.routers.auth import require_admin
34
+ from deeptutor.multi_user.paths import current_owner_id
35
+ from deeptutor.multi_user.tool_access import allowed_cli_apps, exec_override
36
+ from deeptutor.services.cli_apps import (
37
+ CliAppEntry,
38
+ catalog_pin,
39
+ category_counts,
40
+ get_entry,
41
+ search_catalog,
42
+ )
43
+ from deeptutor.services.cli_apps.models import TOOL_PREFIX
44
+ from deeptutor.services.cli_apps.state import (
45
+ InstalledApp,
46
+ disabled_apps,
47
+ load_installed,
48
+ set_app_enabled,
49
+ )
50
+ from deeptutor.services.i18n import t
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ router = APIRouter()
55
+
56
+
57
+ class EnabledPayload(BaseModel):
58
+ enabled: bool
59
+
60
+
61
+ @router.get("/apps")
62
+ async def list_apps() -> dict[str, Any]:
63
+ """Installed apps as *this* caller sees them."""
64
+ owner = current_owner_id()
65
+ installed = load_installed()
66
+ granted = allowed_cli_apps()
67
+ off = disabled_apps(owner)
68
+ exec_allowed = exec_override()
69
+
70
+ rows = [
71
+ _app_row(app, granted=granted, disabled=off)
72
+ for app in (installed[app_id] for app_id in sorted(installed))
73
+ ]
74
+ return {
75
+ "apps": rows,
76
+ # Why the list may be unusable even though it is not empty. Reported as a
77
+ # field rather than left for the reader to infer from every row being
78
+ # `granted: false`.
79
+ "access": {
80
+ "unrestricted": granted is None,
81
+ "exec_denied": exec_allowed is False,
82
+ },
83
+ "catalog_pin": catalog_pin(),
84
+ }
85
+
86
+
87
+ @router.put("/apps/{app_id}/enabled")
88
+ async def set_enabled(app_id: str, payload: EnabledPayload) -> dict[str, Any]:
89
+ """Switch one app on or off for the calling account.
90
+
91
+ Refused for an app the caller has not been granted: the preference file must
92
+ not become a way to record interest in something the grant denies, because a
93
+ later grant would then silently switch it on.
94
+ """
95
+ owner = current_owner_id()
96
+ installed = load_installed()
97
+ if app_id not in installed:
98
+ raise HTTPException(status_code=404, detail=t("cli_apps.not_installed", app=app_id))
99
+ granted = allowed_cli_apps()
100
+ if granted is not None and app_id not in granted:
101
+ raise HTTPException(
102
+ status_code=403,
103
+ detail={"code": "cli.not_granted", "message": t("cli_apps.entry_admin_only")},
104
+ )
105
+ set_app_enabled(owner, app_id, payload.enabled)
106
+ return await list_apps()
107
+
108
+
109
+ @router.get("/catalog")
110
+ async def get_catalog(
111
+ q: str = "",
112
+ category: str = "",
113
+ installable_only: bool = True,
114
+ cursor: str = "",
115
+ limit: int = 24,
116
+ ) -> dict[str, Any]:
117
+ """The store. Readable by anyone; installing is admin-only."""
118
+ installed = load_installed()
119
+ page = search_catalog(
120
+ q=q,
121
+ category=category,
122
+ installable_only=installable_only,
123
+ cursor=cursor,
124
+ limit=limit,
125
+ )
126
+ return {
127
+ "entries": [_catalog_row(entry, installed) for entry in page.entries],
128
+ "next_cursor": page.next_cursor,
129
+ "total": page.total,
130
+ "categories": category_counts(q=q, installable_only=installable_only),
131
+ "catalog_pin": catalog_pin(),
132
+ }
133
+
134
+
135
+ @router.post("/catalog/{app_id}/install", dependencies=[Depends(require_admin)])
136
+ async def install(app_id: str) -> dict[str, Any]:
137
+ """Install one app for the deployment. Administrator only."""
138
+ entry = get_entry(app_id)
139
+ if entry is None:
140
+ raise HTTPException(status_code=404, detail=t("cli_apps.not_in_catalog", id=app_id))
141
+
142
+ from deeptutor.services.cli_apps.installer import install_app
143
+
144
+ outcome = await install_app(entry)
145
+ if not outcome.ok:
146
+ raise HTTPException(
147
+ status_code=400,
148
+ detail={
149
+ "code": outcome.code or "cli.install_failed",
150
+ "message": outcome.message,
151
+ # The install output is the only actionable thing about a failed
152
+ # install, so it travels with the refusal instead of only landing
153
+ # in a log file on the server.
154
+ "log": outcome.log,
155
+ },
156
+ )
157
+ state = await list_apps()
158
+ state["log"] = outcome.log
159
+ return state
160
+
161
+
162
+ @router.delete("/apps/{app_id}", dependencies=[Depends(require_admin)])
163
+ async def uninstall(app_id: str) -> dict[str, Any]:
164
+ """Remove one app from the deployment. Administrator only."""
165
+ from deeptutor.services.cli_apps.installer import uninstall_app
166
+
167
+ await uninstall_app(app_id)
168
+ return await list_apps()
169
+
170
+
171
+ def _app_row(app: InstalledApp, *, granted: set[str] | None, disabled: set[str]) -> dict[str, Any]:
172
+ entry = get_entry(app.id)
173
+ is_granted = granted is None or app.id in granted
174
+ return {
175
+ "id": app.id,
176
+ "display_name": entry.display_name if entry else app.id,
177
+ "description": entry.description if entry else "",
178
+ "category": entry.category if entry else "",
179
+ "tool_name": f"{TOOL_PREFIX}{app.id}",
180
+ "entry_point": app.entry_point,
181
+ "runtime": app.runtime.value,
182
+ "installed_at": app.installed_at,
183
+ "version": app.version,
184
+ "pin": app.pin,
185
+ "trust": entry.trust.value if entry else "third-party",
186
+ "granted": is_granted,
187
+ # Only meaningful when granted; a denied app reports enabled=false so the
188
+ # UI never shows an "on" switch for something that cannot run.
189
+ "enabled": is_granted and app.id not in disabled,
190
+ # An app still installed but no longer in the snapshot: an admin can
191
+ # remove it, and the chat agent is not offered it.
192
+ "in_catalog": entry is not None,
193
+ }
194
+
195
+
196
+ def _catalog_row(entry: CliAppEntry, installed: dict[str, InstalledApp]) -> dict[str, Any]:
197
+ return {
198
+ "id": entry.id,
199
+ "display_name": entry.display_name,
200
+ "description": entry.description,
201
+ "category": entry.category,
202
+ "origin": entry.origin,
203
+ "trust": entry.trust.value,
204
+ "requires": entry.requires,
205
+ "homepage": entry.homepage,
206
+ "source_url": entry.source_url,
207
+ "entry_point": entry.entry_point,
208
+ "runtime": entry.install.runtime.value,
209
+ "install_kind": entry.install.kind.value,
210
+ "install_target": entry.install.target,
211
+ "installable": entry.install.installable,
212
+ "pinned": entry.install.pinned,
213
+ # Present only when it is not installable, and written for a person: it
214
+ # is the difference between "we did not get to this one" and "its
215
+ # published install command is a shell script we will not run".
216
+ "unsupported_reason": entry.install.reason,
217
+ "install_notes": entry.install_notes,
218
+ "installed": entry.id in installed,
219
+ }
220
+
221
+
222
+ __all__ = ["router"]