NeerajCodz commited on
Commit
942fc42
·
1 Parent(s): 5c03d80

feat: add modular plugin system for extensions

Browse files
backend/app/api/routes/plugins.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plugin management API routes."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from pydantic import BaseModel, Field
7
+
8
+ router = APIRouter(prefix="/plugins", tags=["plugins"])
9
+
10
+ # Plugin registry - available plugins
11
+ PLUGIN_REGISTRY = {
12
+ # API Providers
13
+ "apis": [
14
+ {
15
+ "id": "openai-api",
16
+ "name": "OpenAI API",
17
+ "category": "apis",
18
+ "description": "GPT-4, GPT-4o, and embedding models",
19
+ "version": "1.0.0",
20
+ "size": "50KB",
21
+ "installed": False,
22
+ "requires_key": True,
23
+ },
24
+ {
25
+ "id": "anthropic-api",
26
+ "name": "Anthropic API",
27
+ "category": "apis",
28
+ "description": "Claude 3.5 Sonnet and Opus models",
29
+ "version": "1.0.0",
30
+ "size": "45KB",
31
+ "installed": False,
32
+ "requires_key": True,
33
+ },
34
+ {
35
+ "id": "google-api",
36
+ "name": "Google Gemini API",
37
+ "category": "apis",
38
+ "description": "Gemini Pro and Flash models",
39
+ "version": "1.0.0",
40
+ "size": "48KB",
41
+ "installed": True, # Pre-installed
42
+ "requires_key": True,
43
+ },
44
+ {
45
+ "id": "groq-api",
46
+ "name": "Groq API",
47
+ "category": "apis",
48
+ "description": "Fast inference for open models",
49
+ "version": "1.0.0",
50
+ "size": "40KB",
51
+ "installed": True, # Pre-installed
52
+ "requires_key": True,
53
+ },
54
+ {
55
+ "id": "ollama-api",
56
+ "name": "Ollama (Local)",
57
+ "category": "apis",
58
+ "description": "Run models locally with Ollama",
59
+ "version": "1.0.0",
60
+ "size": "35KB",
61
+ "installed": False,
62
+ "requires_key": False,
63
+ },
64
+ ],
65
+ # MCP Tools
66
+ "mcps": [
67
+ {
68
+ "id": "mcp-browser",
69
+ "name": "Browser Tools",
70
+ "category": "mcps",
71
+ "description": "Playwright-based browser automation",
72
+ "version": "1.0.0",
73
+ "size": "120KB",
74
+ "installed": True,
75
+ "requires_key": False,
76
+ },
77
+ {
78
+ "id": "mcp-search",
79
+ "name": "Search Tools",
80
+ "category": "mcps",
81
+ "description": "Google, Bing, DuckDuckGo search",
82
+ "version": "1.0.0",
83
+ "size": "80KB",
84
+ "installed": True,
85
+ "requires_key": False,
86
+ },
87
+ {
88
+ "id": "mcp-html",
89
+ "name": "HTML Processing",
90
+ "category": "mcps",
91
+ "description": "Parse, extract, and transform HTML",
92
+ "version": "1.0.0",
93
+ "size": "60KB",
94
+ "installed": True,
95
+ "requires_key": False,
96
+ },
97
+ {
98
+ "id": "mcp-screenshot",
99
+ "name": "Screenshot Tools",
100
+ "category": "mcps",
101
+ "description": "Capture and analyze page screenshots",
102
+ "version": "1.0.0",
103
+ "size": "90KB",
104
+ "installed": False,
105
+ "requires_key": False,
106
+ },
107
+ {
108
+ "id": "mcp-pdf",
109
+ "name": "PDF Tools",
110
+ "category": "mcps",
111
+ "description": "Extract data from PDF documents",
112
+ "version": "1.0.0",
113
+ "size": "150KB",
114
+ "installed": False,
115
+ "requires_key": False,
116
+ },
117
+ {
118
+ "id": "mcp-database",
119
+ "name": "Database Tools",
120
+ "category": "mcps",
121
+ "description": "Connect to SQL/NoSQL databases",
122
+ "version": "1.0.0",
123
+ "size": "100KB",
124
+ "installed": False,
125
+ "requires_key": False,
126
+ },
127
+ ],
128
+ # Skills/Agents
129
+ "skills": [
130
+ {
131
+ "id": "skill-planner",
132
+ "name": "Planner Agent",
133
+ "category": "skills",
134
+ "description": "Strategic task planning",
135
+ "version": "1.0.0",
136
+ "size": "75KB",
137
+ "installed": True,
138
+ "requires_key": False,
139
+ },
140
+ {
141
+ "id": "skill-navigator",
142
+ "name": "Navigator Agent",
143
+ "category": "skills",
144
+ "description": "Web navigation and interaction",
145
+ "version": "1.0.0",
146
+ "size": "85KB",
147
+ "installed": True,
148
+ "requires_key": False,
149
+ },
150
+ {
151
+ "id": "skill-extractor",
152
+ "name": "Extractor Agent",
153
+ "category": "skills",
154
+ "description": "Data extraction and parsing",
155
+ "version": "1.0.0",
156
+ "size": "95KB",
157
+ "installed": True,
158
+ "requires_key": False,
159
+ },
160
+ {
161
+ "id": "skill-verifier",
162
+ "name": "Verifier Agent",
163
+ "category": "skills",
164
+ "description": "Data validation and verification",
165
+ "version": "1.0.0",
166
+ "size": "70KB",
167
+ "installed": True,
168
+ "requires_key": False,
169
+ },
170
+ {
171
+ "id": "skill-captcha",
172
+ "name": "Captcha Solver",
173
+ "category": "skills",
174
+ "description": "Solve CAPTCHAs and challenges",
175
+ "version": "1.0.0",
176
+ "size": "200KB",
177
+ "installed": False,
178
+ "requires_key": True,
179
+ },
180
+ {
181
+ "id": "skill-stealth",
182
+ "name": "Stealth Mode",
183
+ "category": "skills",
184
+ "description": "Anti-detection and fingerprint masking",
185
+ "version": "1.0.0",
186
+ "size": "180KB",
187
+ "installed": False,
188
+ "requires_key": False,
189
+ },
190
+ ],
191
+ # Data Processors
192
+ "processors": [
193
+ {
194
+ "id": "proc-json",
195
+ "name": "JSON Processor",
196
+ "category": "processors",
197
+ "description": "Parse and transform JSON data",
198
+ "version": "1.0.0",
199
+ "size": "25KB",
200
+ "installed": True,
201
+ "requires_key": False,
202
+ },
203
+ {
204
+ "id": "proc-csv",
205
+ "name": "CSV Processor",
206
+ "category": "processors",
207
+ "description": "Parse and export CSV files",
208
+ "version": "1.0.0",
209
+ "size": "30KB",
210
+ "installed": True,
211
+ "requires_key": False,
212
+ },
213
+ {
214
+ "id": "proc-excel",
215
+ "name": "Excel Processor",
216
+ "category": "processors",
217
+ "description": "Read/write Excel files",
218
+ "version": "1.0.0",
219
+ "size": "500KB",
220
+ "installed": False,
221
+ "requires_key": False,
222
+ },
223
+ {
224
+ "id": "proc-xml",
225
+ "name": "XML Processor",
226
+ "category": "processors",
227
+ "description": "Parse and transform XML data",
228
+ "version": "1.0.0",
229
+ "size": "40KB",
230
+ "installed": False,
231
+ "requires_key": False,
232
+ },
233
+ ],
234
+ }
235
+
236
+ # Track installed plugins (in-memory, would be persistent in production)
237
+ _installed_plugins: set[str] = {
238
+ "google-api",
239
+ "groq-api",
240
+ "mcp-browser",
241
+ "mcp-search",
242
+ "mcp-html",
243
+ "skill-planner",
244
+ "skill-navigator",
245
+ "skill-extractor",
246
+ "skill-verifier",
247
+ "proc-json",
248
+ "proc-csv",
249
+ }
250
+
251
+
252
+ class PluginAction(BaseModel):
253
+ """Request to install/uninstall a plugin."""
254
+
255
+ plugin_id: str = Field(..., description="Plugin identifier")
256
+
257
+
258
+ class PluginResponse(BaseModel):
259
+ """Plugin information response."""
260
+
261
+ id: str
262
+ name: str
263
+ category: str
264
+ description: str
265
+ version: str
266
+ size: str
267
+ installed: bool
268
+ requires_key: bool
269
+
270
+
271
+ @router.get("/")
272
+ async def list_plugins(category: str | None = None) -> dict[str, Any]:
273
+ """List all available plugins, optionally filtered by category."""
274
+ result = {}
275
+
276
+ for cat, plugins in PLUGIN_REGISTRY.items():
277
+ if category and cat != category:
278
+ continue
279
+
280
+ result[cat] = [
281
+ {**plugin, "installed": plugin["id"] in _installed_plugins} for plugin in plugins
282
+ ]
283
+
284
+ # Calculate stats
285
+ total = sum(len(plugins) for plugins in PLUGIN_REGISTRY.values())
286
+ installed = len(_installed_plugins)
287
+
288
+ return {
289
+ "plugins": result,
290
+ "categories": list(PLUGIN_REGISTRY.keys()),
291
+ "stats": {
292
+ "total": total,
293
+ "installed": installed,
294
+ "available": total - installed,
295
+ },
296
+ }
297
+
298
+
299
+ @router.get("/installed")
300
+ async def list_installed_plugins() -> dict[str, Any]:
301
+ """List only installed plugins."""
302
+ installed = []
303
+
304
+ for plugins in PLUGIN_REGISTRY.values():
305
+ for plugin in plugins:
306
+ if plugin["id"] in _installed_plugins:
307
+ installed.append({**plugin, "installed": True})
308
+
309
+ return {
310
+ "plugins": installed,
311
+ "count": len(installed),
312
+ }
313
+
314
+
315
+ @router.get("/{plugin_id}")
316
+ async def get_plugin(plugin_id: str) -> PluginResponse:
317
+ """Get details about a specific plugin."""
318
+ for plugins in PLUGIN_REGISTRY.values():
319
+ for plugin in plugins:
320
+ if plugin["id"] == plugin_id:
321
+ return PluginResponse(**{**plugin, "installed": plugin_id in _installed_plugins})
322
+
323
+ raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}")
324
+
325
+
326
+ @router.post("/install")
327
+ async def install_plugin(action: PluginAction) -> dict[str, Any]:
328
+ """Install a plugin."""
329
+ plugin_id = action.plugin_id
330
+
331
+ # Find the plugin
332
+ plugin = None
333
+ for plugins in PLUGIN_REGISTRY.values():
334
+ for p in plugins:
335
+ if p["id"] == plugin_id:
336
+ plugin = p
337
+ break
338
+
339
+ if not plugin:
340
+ raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}")
341
+
342
+ if plugin_id in _installed_plugins:
343
+ return {
344
+ "status": "already_installed",
345
+ "message": f"Plugin {plugin['name']} is already installed",
346
+ "plugin": {**plugin, "installed": True},
347
+ }
348
+
349
+ # Install the plugin (in production, this would download/configure)
350
+ _installed_plugins.add(plugin_id)
351
+
352
+ return {
353
+ "status": "success",
354
+ "message": f"Plugin {plugin['name']} installed successfully",
355
+ "plugin": {**plugin, "installed": True},
356
+ }
357
+
358
+
359
+ @router.post("/uninstall")
360
+ async def uninstall_plugin(action: PluginAction) -> dict[str, Any]:
361
+ """Uninstall a plugin."""
362
+ plugin_id = action.plugin_id
363
+
364
+ # Find the plugin
365
+ plugin = None
366
+ for plugins in PLUGIN_REGISTRY.values():
367
+ for p in plugins:
368
+ if p["id"] == plugin_id:
369
+ plugin = p
370
+ break
371
+
372
+ if not plugin:
373
+ raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}")
374
+
375
+ if plugin_id not in _installed_plugins:
376
+ return {
377
+ "status": "not_installed",
378
+ "message": f"Plugin {plugin['name']} is not installed",
379
+ "plugin": {**plugin, "installed": False},
380
+ }
381
+
382
+ # Check if it's a core plugin
383
+ core_plugins = {"mcp-browser", "mcp-search", "mcp-html", "skill-planner", "skill-navigator", "skill-extractor", "skill-verifier", "proc-json"}
384
+ if plugin_id in core_plugins:
385
+ raise HTTPException(
386
+ status_code=400,
387
+ detail=f"Cannot uninstall core plugin: {plugin['name']}",
388
+ )
389
+
390
+ # Uninstall the plugin
391
+ _installed_plugins.discard(plugin_id)
392
+
393
+ return {
394
+ "status": "success",
395
+ "message": f"Plugin {plugin['name']} uninstalled successfully",
396
+ "plugin": {**plugin, "installed": False},
397
+ }
398
+
399
+
400
+ @router.get("/categories")
401
+ async def get_categories() -> dict[str, Any]:
402
+ """Get plugin categories with descriptions."""
403
+ return {
404
+ "categories": [
405
+ {"id": "apis", "name": "API Providers", "description": "LLM and AI service providers", "icon": "🔌"},
406
+ {"id": "mcps", "name": "MCP Tools", "description": "Model Context Protocol tools", "icon": "🔧"},
407
+ {"id": "skills", "name": "Skills/Agents", "description": "Specialized agent capabilities", "icon": "🤖"},
408
+ {"id": "processors", "name": "Data Processors", "description": "Data transformation tools", "icon": "📊"},
409
+ ],
410
+ }
backend/tests/test_api/test_plugins.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for plugins API routes."""
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+
6
+
7
+ class TestPluginsAPI:
8
+ """Test plugins API endpoints."""
9
+
10
+ def test_list_all_plugins(self, client: TestClient) -> None:
11
+ """Test GET /api/plugins returns all plugins."""
12
+ response = client.get("/api/plugins")
13
+
14
+ assert response.status_code == 200
15
+ data = response.json()
16
+
17
+ # Check response structure
18
+ assert "plugins" in data
19
+ assert "categories" in data
20
+ assert "stats" in data
21
+
22
+ # Check stats structure
23
+ stats = data["stats"]
24
+ assert "total" in stats
25
+ assert "installed" in stats
26
+ assert "available" in stats
27
+ assert stats["total"] >= 0
28
+ assert stats["installed"] >= 0
29
+ assert stats["available"] >= 0
30
+
31
+ def test_list_plugins_by_category(self, client: TestClient) -> None:
32
+ """Test GET /api/plugins?category=apis filters by category."""
33
+ response = client.get("/api/plugins?category=apis")
34
+
35
+ assert response.status_code == 200
36
+ data = response.json()
37
+
38
+ # Should only contain the filtered category
39
+ plugins = data["plugins"]
40
+ if "apis" in plugins:
41
+ # All plugins in the response should be from apis category
42
+ for plugin in plugins["apis"]:
43
+ assert plugin["category"] == "apis"
44
+
45
+ def test_list_installed_plugins(self, client: TestClient) -> None:
46
+ """Test GET /api/plugins/installed returns only installed plugins."""
47
+ response = client.get("/api/plugins/installed")
48
+
49
+ assert response.status_code == 200
50
+ data = response.json()
51
+
52
+ assert "plugins" in data
53
+ assert "count" in data
54
+
55
+ # All returned plugins should be installed
56
+ for plugin in data["plugins"]:
57
+ assert plugin["installed"] is True
58
+
59
+ # Count should match number of plugins
60
+ assert data["count"] == len(data["plugins"])
61
+
62
+ def test_get_specific_plugin_exists(self, client: TestClient) -> None:
63
+ """Test GET /api/plugins/{plugin_id} for existing plugin."""
64
+ # First get list of plugins to find a valid ID
65
+ list_response = client.get("/api/plugins")
66
+ assert list_response.status_code == 200
67
+
68
+ plugins_data = list_response.json()
69
+
70
+ # Find first plugin from any category
71
+ plugin_id = None
72
+ for category, plugins in plugins_data["plugins"].items():
73
+ if plugins:
74
+ plugin_id = plugins[0]["id"]
75
+ break
76
+
77
+ if plugin_id:
78
+ response = client.get(f"/api/plugins/{plugin_id}")
79
+ assert response.status_code == 200
80
+
81
+ data = response.json()
82
+ assert data["id"] == plugin_id
83
+ assert "name" in data
84
+ assert "category" in data
85
+ assert "installed" in data
86
+
87
+ def test_get_nonexistent_plugin(self, client: TestClient) -> None:
88
+ """Test GET /api/plugins/{plugin_id} for non-existent plugin."""
89
+ response = client.get("/api/plugins/nonexistent-plugin")
90
+
91
+ assert response.status_code == 404
92
+ data = response.json()
93
+ assert "not found" in data["detail"].lower()
94
+
95
+ def test_install_plugin_success(self, client: TestClient) -> None:
96
+ """Test POST /api/plugins/install for valid plugin."""
97
+ # First get a plugin that's not installed
98
+ list_response = client.get("/api/plugins")
99
+ assert list_response.status_code == 200
100
+
101
+ plugins_data = list_response.json()
102
+
103
+ # Find an uninstalled plugin
104
+ plugin_id = None
105
+ for category, plugins in plugins_data["plugins"].items():
106
+ for plugin in plugins:
107
+ if not plugin["installed"]:
108
+ plugin_id = plugin["id"]
109
+ break
110
+ if plugin_id:
111
+ break
112
+
113
+ if plugin_id:
114
+ payload = {"plugin_id": plugin_id}
115
+ response = client.post("/api/plugins/install", json=payload)
116
+
117
+ assert response.status_code == 200
118
+ data = response.json()
119
+
120
+ assert data["status"] == "success"
121
+ assert data["plugin"]["id"] == plugin_id
122
+ assert data["plugin"]["installed"] is True
123
+ assert "installed successfully" in data["message"]
124
+
125
+ def test_install_already_installed_plugin(self, client: TestClient) -> None:
126
+ """Test installing a plugin that's already installed."""
127
+ # First install a plugin
128
+ list_response = client.get("/api/plugins")
129
+ assert list_response.status_code == 200
130
+
131
+ plugins_data = list_response.json()
132
+
133
+ # Find an uninstalled plugin to install first
134
+ plugin_id = None
135
+ for category, plugins in plugins_data["plugins"].items():
136
+ for plugin in plugins:
137
+ if not plugin["installed"]:
138
+ plugin_id = plugin["id"]
139
+ break
140
+ if plugin_id:
141
+ break
142
+
143
+ if plugin_id:
144
+ # Install it
145
+ payload = {"plugin_id": plugin_id}
146
+ response = client.post("/api/plugins/install", json=payload)
147
+ assert response.status_code == 200
148
+
149
+ # Try to install again
150
+ response = client.post("/api/plugins/install", json=payload)
151
+ assert response.status_code == 200
152
+
153
+ data = response.json()
154
+ assert data["status"] == "already_installed"
155
+ assert "already installed" in data["message"]
156
+
157
+ def test_install_nonexistent_plugin(self, client: TestClient) -> None:
158
+ """Test installing a non-existent plugin."""
159
+ payload = {"plugin_id": "nonexistent-plugin"}
160
+ response = client.post("/api/plugins/install", json=payload)
161
+
162
+ assert response.status_code == 404
163
+ data = response.json()
164
+ assert "not found" in data["detail"].lower()
165
+
166
+ def test_uninstall_plugin_success(self, client: TestClient) -> None:
167
+ """Test POST /api/plugins/uninstall for installed non-core plugin."""
168
+ # First install a non-core plugin
169
+ list_response = client.get("/api/plugins")
170
+ assert list_response.status_code == 200
171
+
172
+ plugins_data = list_response.json()
173
+
174
+ # Find a non-core plugin to install and then uninstall
175
+ core_plugins = {"mcp-browser", "mcp-search", "mcp-html", "skill-planner", "skill-navigator", "skill-extractor", "skill-verifier", "proc-json"}
176
+ plugin_id = None
177
+
178
+ for category, plugins in plugins_data["plugins"].items():
179
+ for plugin in plugins:
180
+ if plugin["id"] not in core_plugins and not plugin["installed"]:
181
+ plugin_id = plugin["id"]
182
+ break
183
+ if plugin_id:
184
+ break
185
+
186
+ if plugin_id:
187
+ # Install it first
188
+ install_payload = {"plugin_id": plugin_id}
189
+ install_response = client.post("/api/plugins/install", json=install_payload)
190
+ assert install_response.status_code == 200
191
+
192
+ # Now uninstall it
193
+ uninstall_payload = {"plugin_id": plugin_id}
194
+ response = client.post("/api/plugins/uninstall", json=uninstall_payload)
195
+
196
+ assert response.status_code == 200
197
+ data = response.json()
198
+
199
+ assert data["status"] == "success"
200
+ assert data["plugin"]["id"] == plugin_id
201
+ assert data["plugin"]["installed"] is False
202
+ assert "uninstalled successfully" in data["message"]
203
+
204
+ def test_uninstall_core_plugin_forbidden(self, client: TestClient) -> None:
205
+ """Test uninstalling core plugin is forbidden."""
206
+ # Try to uninstall a core plugin
207
+ core_plugin_id = "mcp-browser" # This should be a core plugin
208
+ payload = {"plugin_id": core_plugin_id}
209
+
210
+ response = client.post("/api/plugins/uninstall", json=payload)
211
+
212
+ assert response.status_code == 400
213
+ data = response.json()
214
+ assert "Cannot uninstall core plugin" in data["detail"]
215
+
216
+ def test_uninstall_not_installed_plugin(self, client: TestClient) -> None:
217
+ """Test uninstalling a plugin that's not installed."""
218
+ # Find an uninstalled non-core plugin
219
+ list_response = client.get("/api/plugins")
220
+ assert list_response.status_code == 200
221
+
222
+ plugins_data = list_response.json()
223
+ core_plugins = {"mcp-browser", "mcp-search", "mcp-html", "skill-planner", "skill-navigator", "skill-extractor", "skill-verifier", "proc-json"}
224
+
225
+ plugin_id = None
226
+ for category, plugins in plugins_data["plugins"].items():
227
+ for plugin in plugins:
228
+ if plugin["id"] not in core_plugins and not plugin["installed"]:
229
+ plugin_id = plugin["id"]
230
+ break
231
+ if plugin_id:
232
+ break
233
+
234
+ if plugin_id:
235
+ payload = {"plugin_id": plugin_id}
236
+ response = client.post("/api/plugins/uninstall", json=payload)
237
+
238
+ assert response.status_code == 200
239
+ data = response.json()
240
+ assert data["status"] == "not_installed"
241
+ assert "not installed" in data["message"]
242
+
243
+ def test_uninstall_nonexistent_plugin(self, client: TestClient) -> None:
244
+ """Test uninstalling a non-existent plugin."""
245
+ payload = {"plugin_id": "nonexistent-plugin"}
246
+ response = client.post("/api/plugins/uninstall", json=payload)
247
+
248
+ assert response.status_code == 404
249
+ data = response.json()
250
+ assert "not found" in data["detail"].lower()
251
+
252
+ def test_get_categories(self, client: TestClient) -> None:
253
+ """Test that plugins list includes categories."""
254
+ response = client.get("/api/plugins")
255
+
256
+ assert response.status_code == 200
257
+ data = response.json()
258
+
259
+ assert "categories" in data
260
+ categories = data["categories"]
261
+
262
+ assert isinstance(categories, list)
263
+ assert len(categories) > 0
264
+
265
+ # Categories are returned as strings (category IDs)
266
+ expected_categories = ["apis", "mcps", "skills", "processors"]
267
+ for expected in expected_categories:
268
+ assert expected in categories
269
+
270
+ def test_plugin_structure_validation(self, client: TestClient) -> None:
271
+ """Test that all plugins have required fields."""
272
+ response = client.get("/api/plugins")
273
+ assert response.status_code == 200
274
+
275
+ data = response.json()
276
+
277
+ required_fields = ["id", "name", "category", "description", "version", "installed"]
278
+
279
+ for category, plugins in data["plugins"].items():
280
+ for plugin in plugins:
281
+ for field in required_fields:
282
+ assert field in plugin, f"Plugin {plugin.get('id', 'unknown')} missing field {field}"
283
+
284
+ def test_install_uninstall_payload_validation(self, client: TestClient) -> None:
285
+ """Test payload validation for install/uninstall endpoints."""
286
+ # Missing plugin_id for install
287
+ response = client.post("/api/plugins/install", json={})
288
+ assert response.status_code == 422
289
+
290
+ # Missing plugin_id for uninstall
291
+ response = client.post("/api/plugins/uninstall", json={})
292
+ assert response.status_code == 422
293
+
294
+ # Invalid payload type
295
+ response = client.post("/api/plugins/install", json={"plugin_id": 123})
296
+ assert response.status_code == 422
297
+
298
+ def test_plugins_state_persistence(self, client: TestClient) -> None:
299
+ """Test that plugin installation state persists across calls."""
300
+ # Find a non-core plugin
301
+ list_response = client.get("/api/plugins")
302
+ assert list_response.status_code == 200
303
+
304
+ plugins_data = list_response.json()
305
+ core_plugins = {"mcp-browser", "mcp-search", "mcp-html", "skill-planner", "skill-navigator", "skill-extractor", "skill-verifier", "proc-json"}
306
+
307
+ plugin_id = None
308
+ for category, plugins in plugins_data["plugins"].items():
309
+ for plugin in plugins:
310
+ if plugin["id"] not in core_plugins:
311
+ plugin_id = plugin["id"]
312
+ break
313
+ if plugin_id:
314
+ break
315
+
316
+ if plugin_id:
317
+ # Check initial state
318
+ response = client.get(f"/api/plugins/{plugin_id}")
319
+ initial_state = response.json()["installed"]
320
+
321
+ # Toggle state by installing if not installed, or uninstalling if installed and not core
322
+ if not initial_state:
323
+ payload = {"plugin_id": plugin_id}
324
+ response = client.post("/api/plugins/install", json=payload)
325
+ assert response.status_code == 200
326
+
327
+ # Verify state changed
328
+ response = client.get(f"/api/plugins/{plugin_id}")
329
+ assert response.json()["installed"] is True
330
+ else:
331
+ # If already installed and not core, uninstall
332
+ if plugin_id not in core_plugins:
333
+ payload = {"plugin_id": plugin_id}
334
+ response = client.post("/api/plugins/uninstall", json=payload)
335
+ assert response.status_code == 200
336
+
337
+ # Verify state changed
338
+ response = client.get(f"/api/plugins/{plugin_id}")
339
+ assert response.json()["installed"] is False
frontend/src/App.tsx CHANGED
@@ -1,7 +1,10 @@
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
- import { BrowserRouter, Routes, Route } from 'react-router-dom';
 
3
  import Dashboard from './components/Dashboard';
4
  import Settings from './components/Settings';
 
 
5
 
6
  const queryClient = new QueryClient({
7
  defaultOptions: {
@@ -12,15 +15,58 @@ const queryClient = new QueryClient({
12
  },
13
  });
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  function App() {
16
  return (
17
  <QueryClientProvider client={queryClient}>
18
  <BrowserRouter>
19
  <div className="min-h-screen bg-gray-950 text-gray-100">
20
- <Routes>
21
- <Route path="/" element={<Dashboard />} />
22
- <Route path="/settings" element={<Settings />} />
23
- </Routes>
 
 
 
 
24
  </div>
25
  </BrowserRouter>
26
  </QueryClientProvider>
 
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
3
+ import { Home, Settings as SettingsIcon, Package } from 'lucide-react';
4
  import Dashboard from './components/Dashboard';
5
  import Settings from './components/Settings';
6
+ import PluginsPage from './components/PluginsPage';
7
+ import { classNames } from './utils/helpers';
8
 
9
  const queryClient = new QueryClient({
10
  defaultOptions: {
 
15
  },
16
  });
17
 
18
+ function NavBar() {
19
+ const location = useLocation();
20
+
21
+ const navItems = [
22
+ { path: '/', label: 'Dashboard', icon: Home },
23
+ { path: '/plugins', label: 'Plugins', icon: Package },
24
+ { path: '/settings', label: 'Settings', icon: SettingsIcon },
25
+ ];
26
+
27
+ return (
28
+ <nav className="bg-dark-900 border-b border-dark-700">
29
+ <div className="max-w-7xl mx-auto px-4">
30
+ <div className="flex items-center justify-between h-14">
31
+ <div className="flex items-center gap-2">
32
+ <span className="text-xl font-bold text-primary-400">🕷️ ScrapeRL</span>
33
+ </div>
34
+ <div className="flex items-center gap-1">
35
+ {navItems.map(({ path, label, icon: Icon }) => (
36
+ <Link
37
+ key={path}
38
+ to={path}
39
+ className={classNames(
40
+ 'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors',
41
+ location.pathname === path
42
+ ? 'bg-primary-500/20 text-primary-400'
43
+ : 'text-dark-300 hover:text-dark-100 hover:bg-dark-800'
44
+ )}
45
+ >
46
+ <Icon className="w-4 h-4" />
47
+ {label}
48
+ </Link>
49
+ ))}
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </nav>
54
+ );
55
+ }
56
+
57
  function App() {
58
  return (
59
  <QueryClientProvider client={queryClient}>
60
  <BrowserRouter>
61
  <div className="min-h-screen bg-gray-950 text-gray-100">
62
+ <NavBar />
63
+ <main className="max-w-7xl mx-auto px-4 py-6">
64
+ <Routes>
65
+ <Route path="/" element={<Dashboard />} />
66
+ <Route path="/plugins" element={<PluginsPage />} />
67
+ <Route path="/settings" element={<Settings />} />
68
+ </Routes>
69
+ </main>
70
  </div>
71
  </BrowserRouter>
72
  </QueryClientProvider>
frontend/src/components/PluginsPage.tsx ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import {
4
+ Package,
5
+ Download,
6
+ Trash2,
7
+ Search,
8
+ Filter,
9
+ CheckCircle,
10
+ AlertCircle,
11
+ Loader2,
12
+ Plug,
13
+ Cpu,
14
+ Wrench,
15
+ Database,
16
+ } from 'lucide-react';
17
+ import { Card, CardContent } from '@/components/ui/Card';
18
+ import { Button } from '@/components/ui/Button';
19
+ import { Input } from '@/components/ui/Input';
20
+ import { Badge } from '@/components/ui/Badge';
21
+ import { classNames } from '@/utils/helpers';
22
+
23
+ interface Plugin {
24
+ id: string;
25
+ name: string;
26
+ category: string;
27
+ description: string;
28
+ version: string;
29
+ size: string;
30
+ installed: boolean;
31
+ requires_key: boolean;
32
+ }
33
+
34
+ interface Category {
35
+ id: string;
36
+ name: string;
37
+ description: string;
38
+ icon: string;
39
+ }
40
+
41
+ interface PluginsResponse {
42
+ plugins: Record<string, Plugin[]>;
43
+ categories: string[];
44
+ stats: {
45
+ total: number;
46
+ installed: number;
47
+ available: number;
48
+ };
49
+ }
50
+
51
+ interface PluginsPageProps {
52
+ className?: string;
53
+ }
54
+
55
+ const getCategoryIcon = (category: string) => {
56
+ switch (category) {
57
+ case 'apis':
58
+ return <Plug className="w-5 h-5" />;
59
+ case 'mcps':
60
+ return <Wrench className="w-5 h-5" />;
61
+ case 'skills':
62
+ return <Cpu className="w-5 h-5" />;
63
+ case 'processors':
64
+ return <Database className="w-5 h-5" />;
65
+ default:
66
+ return <Package className="w-5 h-5" />;
67
+ }
68
+ };
69
+
70
+ const getCategoryLabel = (category: string) => {
71
+ const labels: Record<string, string> = {
72
+ apis: 'API Providers',
73
+ mcps: 'MCP Tools',
74
+ skills: 'Skills/Agents',
75
+ processors: 'Data Processors',
76
+ };
77
+ return labels[category] || category;
78
+ };
79
+
80
+ export const PluginsPage: React.FC<PluginsPageProps> = ({ className }) => {
81
+ const queryClient = useQueryClient();
82
+ const [searchQuery, setSearchQuery] = useState('');
83
+ const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
84
+ const [showInstalled, setShowInstalled] = useState(false);
85
+
86
+ // Fetch plugins
87
+ const { data: pluginsData, isLoading } = useQuery<PluginsResponse>({
88
+ queryKey: ['plugins'],
89
+ queryFn: async () => {
90
+ const res = await fetch('/api/plugins/');
91
+ return res.json();
92
+ },
93
+ });
94
+
95
+ // Fetch categories
96
+ const { data: categoriesData } = useQuery<{ categories: Category[] }>({
97
+ queryKey: ['plugin-categories'],
98
+ queryFn: async () => {
99
+ const res = await fetch('/api/plugins/categories');
100
+ return res.json();
101
+ },
102
+ });
103
+
104
+ // Install plugin mutation
105
+ const installMutation = useMutation({
106
+ mutationFn: async (pluginId: string) => {
107
+ const res = await fetch('/api/plugins/install', {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({ plugin_id: pluginId }),
111
+ });
112
+ return res.json();
113
+ },
114
+ onSuccess: () => {
115
+ queryClient.invalidateQueries({ queryKey: ['plugins'] });
116
+ },
117
+ });
118
+
119
+ // Uninstall plugin mutation
120
+ const uninstallMutation = useMutation({
121
+ mutationFn: async (pluginId: string) => {
122
+ const res = await fetch('/api/plugins/uninstall', {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ plugin_id: pluginId }),
126
+ });
127
+ if (!res.ok) {
128
+ const error = await res.json();
129
+ throw new Error(error.detail);
130
+ }
131
+ return res.json();
132
+ },
133
+ onSuccess: () => {
134
+ queryClient.invalidateQueries({ queryKey: ['plugins'] });
135
+ },
136
+ });
137
+
138
+ // Filter plugins
139
+ const getFilteredPlugins = () => {
140
+ if (!pluginsData?.plugins) return {};
141
+
142
+ const result: Record<string, Plugin[]> = {};
143
+
144
+ for (const [category, plugins] of Object.entries(pluginsData.plugins)) {
145
+ if (selectedCategory && category !== selectedCategory) continue;
146
+
147
+ const filtered = plugins.filter((plugin) => {
148
+ const matchesSearch =
149
+ !searchQuery ||
150
+ plugin.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
151
+ plugin.description.toLowerCase().includes(searchQuery.toLowerCase());
152
+
153
+ const matchesInstalled = !showInstalled || plugin.installed;
154
+
155
+ return matchesSearch && matchesInstalled;
156
+ });
157
+
158
+ if (filtered.length > 0) {
159
+ result[category] = filtered;
160
+ }
161
+ }
162
+
163
+ return result;
164
+ };
165
+
166
+ const filteredPlugins = getFilteredPlugins();
167
+
168
+ return (
169
+ <div className={classNames('space-y-6', className)}>
170
+ {/* Header */}
171
+ <div className="flex items-center justify-between">
172
+ <div>
173
+ <h1 className="text-2xl font-bold text-dark-100">Plugins</h1>
174
+ <p className="text-dark-400 mt-1">
175
+ Extend ScrapeRL with APIs, tools, skills, and processors
176
+ </p>
177
+ </div>
178
+ {pluginsData?.stats && (
179
+ <div className="flex gap-4 text-sm">
180
+ <div className="text-center">
181
+ <div className="text-2xl font-bold text-primary-400">
182
+ {pluginsData.stats.installed}
183
+ </div>
184
+ <div className="text-dark-400">Installed</div>
185
+ </div>
186
+ <div className="text-center">
187
+ <div className="text-2xl font-bold text-dark-300">
188
+ {pluginsData.stats.available}
189
+ </div>
190
+ <div className="text-dark-400">Available</div>
191
+ </div>
192
+ </div>
193
+ )}
194
+ </div>
195
+
196
+ {/* Filters */}
197
+ <Card>
198
+ <CardContent className="py-4">
199
+ <div className="flex flex-wrap gap-4 items-center">
200
+ {/* Search */}
201
+ <div className="flex-1 min-w-[200px]">
202
+ <Input
203
+ placeholder="Search plugins..."
204
+ value={searchQuery}
205
+ onChange={(e) => setSearchQuery(e.target.value)}
206
+ leftIcon={<Search className="w-4 h-4" />}
207
+ />
208
+ </div>
209
+
210
+ {/* Category Filter */}
211
+ <div className="flex gap-2">
212
+ <Button
213
+ size="sm"
214
+ variant={selectedCategory === null ? 'primary' : 'ghost'}
215
+ onClick={() => setSelectedCategory(null)}
216
+ >
217
+ All
218
+ </Button>
219
+ {categoriesData?.categories.map((cat) => (
220
+ <Button
221
+ key={cat.id}
222
+ size="sm"
223
+ variant={selectedCategory === cat.id ? 'primary' : 'ghost'}
224
+ onClick={() => setSelectedCategory(cat.id)}
225
+ leftIcon={<span>{cat.icon}</span>}
226
+ >
227
+ {cat.name}
228
+ </Button>
229
+ ))}
230
+ </div>
231
+
232
+ {/* Show Installed Toggle */}
233
+ <Button
234
+ size="sm"
235
+ variant={showInstalled ? 'primary' : 'ghost'}
236
+ onClick={() => setShowInstalled(!showInstalled)}
237
+ leftIcon={<Filter className="w-4 h-4" />}
238
+ >
239
+ Installed Only
240
+ </Button>
241
+ </div>
242
+ </CardContent>
243
+ </Card>
244
+
245
+ {/* Plugin List */}
246
+ {isLoading ? (
247
+ <div className="flex items-center justify-center py-12">
248
+ <Loader2 className="w-8 h-8 text-primary-400 animate-spin" />
249
+ </div>
250
+ ) : (
251
+ <div className="space-y-6">
252
+ {Object.entries(filteredPlugins).map(([category, plugins]) => (
253
+ <div key={category}>
254
+ <div className="flex items-center gap-2 mb-4">
255
+ {getCategoryIcon(category)}
256
+ <h2 className="text-lg font-semibold text-dark-100">
257
+ {getCategoryLabel(category)}
258
+ </h2>
259
+ <Badge variant="neutral" size="sm">
260
+ {plugins.length}
261
+ </Badge>
262
+ </div>
263
+
264
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
265
+ {plugins.map((plugin) => (
266
+ <Card key={plugin.id} className="relative">
267
+ <CardContent className="py-4">
268
+ <div className="flex items-start justify-between">
269
+ <div className="flex-1">
270
+ <div className="flex items-center gap-2">
271
+ <h3 className="font-medium text-dark-100">
272
+ {plugin.name}
273
+ </h3>
274
+ {plugin.installed && (
275
+ <CheckCircle className="w-4 h-4 text-green-400" />
276
+ )}
277
+ </div>
278
+ <p className="text-sm text-dark-400 mt-1">
279
+ {plugin.description}
280
+ </p>
281
+ <div className="flex items-center gap-3 mt-3 text-xs text-dark-500">
282
+ <span>v{plugin.version}</span>
283
+ <span>•</span>
284
+ <span>{plugin.size}</span>
285
+ {plugin.requires_key && (
286
+ <>
287
+ <span>•</span>
288
+ <span className="text-yellow-400">
289
+ Requires API Key
290
+ </span>
291
+ </>
292
+ )}
293
+ </div>
294
+ </div>
295
+ </div>
296
+
297
+ <div className="flex gap-2 mt-4">
298
+ {plugin.installed ? (
299
+ <Button
300
+ size="sm"
301
+ variant="ghost"
302
+ className="flex-1 text-red-400 hover:text-red-300"
303
+ onClick={() => uninstallMutation.mutate(plugin.id)}
304
+ disabled={uninstallMutation.isPending}
305
+ leftIcon={<Trash2 className="w-4 h-4" />}
306
+ >
307
+ Uninstall
308
+ </Button>
309
+ ) : (
310
+ <Button
311
+ size="sm"
312
+ variant="primary"
313
+ className="flex-1"
314
+ onClick={() => installMutation.mutate(plugin.id)}
315
+ disabled={installMutation.isPending}
316
+ leftIcon={<Download className="w-4 h-4" />}
317
+ >
318
+ Install
319
+ </Button>
320
+ )}
321
+ </div>
322
+ </CardContent>
323
+ </Card>
324
+ ))}
325
+ </div>
326
+ </div>
327
+ ))}
328
+
329
+ {Object.keys(filteredPlugins).length === 0 && (
330
+ <div className="text-center py-12">
331
+ <Package className="w-12 h-12 text-dark-500 mx-auto mb-4" />
332
+ <h3 className="text-lg font-medium text-dark-300">No plugins found</h3>
333
+ <p className="text-dark-400 mt-1">
334
+ Try adjusting your search or filter criteria
335
+ </p>
336
+ </div>
337
+ )}
338
+ </div>
339
+ )}
340
+
341
+ {/* Error Messages */}
342
+ {uninstallMutation.isError && (
343
+ <div className="fixed bottom-4 right-4 flex items-center gap-2 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
344
+ <AlertCircle className="w-5 h-5 text-red-400" />
345
+ <span className="text-sm text-red-400">
346
+ {(uninstallMutation.error as Error).message}
347
+ </span>
348
+ </div>
349
+ )}
350
+ </div>
351
+ );
352
+ };
353
+
354
+ export default PluginsPage;