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.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- 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"]
|