agent-portal-2 0.1.0

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 (120) hide show
  1. package/.continue/agents/new-config.yaml +22 -0
  2. package/AGENT_STEERING.md +36 -0
  3. package/ARCHITECTURE.md +13 -0
  4. package/CHANGELOG.md +97 -0
  5. package/CLI.md +38 -0
  6. package/CONTRIBUTING.md +55 -0
  7. package/INSTALLATION.md +58 -0
  8. package/LICENSE +60 -0
  9. package/PLUGIN_SYSTEM.md +33 -0
  10. package/PYTHON_SDK.md +22 -0
  11. package/QUICKSTART.md +19 -0
  12. package/README.md +385 -0
  13. package/RELEASE_NOTES_v0.1.0.md +281 -0
  14. package/ROADMAP.md +3 -0
  15. package/RUNTIME.md +44 -0
  16. package/SAFETY_MODEL.md +24 -0
  17. package/TESTING.md +35 -0
  18. package/TROUBLESHOOTING.md +30 -0
  19. package/UPGRADE_GUIDE.md +288 -0
  20. package/VS_CODE_EXTENSION.md +47 -0
  21. package/agent-portal.config.json +20 -0
  22. package/apps/desktop/agent-portal-desktop.zip +0 -0
  23. package/apps/desktop/fixtures/local-workflow.html +151 -0
  24. package/apps/desktop/package.json +18 -0
  25. package/apps/desktop/src/main.ts +117 -0
  26. package/apps/desktop/tsconfig.json +8 -0
  27. package/apps/vscode-extension/LICENSE +60 -0
  28. package/apps/vscode-extension/README.md +20 -0
  29. package/apps/vscode-extension/media/agent-portal-logo.png +0 -0
  30. package/apps/vscode-extension/package.json +149 -0
  31. package/apps/vscode-extension/src/extension.ts +614 -0
  32. package/apps/vscode-extension/tsconfig.json +12 -0
  33. package/assets/branding/agent-portal-logo.png +0 -0
  34. package/connectors/chatgpt-tools/README.md +9 -0
  35. package/connectors/claude-mcp-server/README.md +9 -0
  36. package/connectors/gemini-connector/README.md +9 -0
  37. package/connectors/rest-websocket-api/README.md +9 -0
  38. package/docs/MCP_SERVER.md +68 -0
  39. package/docs/architecture.md +214 -0
  40. package/docs/roadmap.md +125 -0
  41. package/package.json +21 -0
  42. package/packages/agent-portal-mcp/README.md +12 -0
  43. package/packages/agent-portal-mcp/agent_portal_mcp/__init__.py +3 -0
  44. package/packages/agent-portal-mcp/agent_portal_mcp/bridge/__init__.py +1 -0
  45. package/packages/agent-portal-mcp/agent_portal_mcp/bridge/runtime_client.py +180 -0
  46. package/packages/agent-portal-mcp/agent_portal_mcp/cli.py +32 -0
  47. package/packages/agent-portal-mcp/agent_portal_mcp/doctor.py +71 -0
  48. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/__init__.py +1 -0
  49. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/actions.py +17 -0
  50. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/results.py +24 -0
  51. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/risk.py +20 -0
  52. package/packages/agent-portal-mcp/agent_portal_mcp/security/__init__.py +1 -0
  53. package/packages/agent-portal-mcp/agent_portal_mcp/security/policy.py +27 -0
  54. package/packages/agent-portal-mcp/agent_portal_mcp/server.py +148 -0
  55. package/packages/agent-portal-mcp/agent_portal_mcp/tool_registry.py +58 -0
  56. package/packages/agent-portal-mcp/agent_portal_mcp/tools/__init__.py +1 -0
  57. package/packages/agent-portal-mcp/agent_portal_mcp/tools/browser.py +89 -0
  58. package/packages/agent-portal-mcp/agent_portal_mcp/tools/common.py +98 -0
  59. package/packages/agent-portal-mcp/agent_portal_mcp/tools/inspection.py +93 -0
  60. package/packages/agent-portal-mcp/agent_portal_mcp/tools/navigation.py +93 -0
  61. package/packages/agent-portal-mcp/agent_portal_mcp/tools/reports.py +34 -0
  62. package/packages/agent-portal-mcp/agent_portal_mcp/tools/steering.py +93 -0
  63. package/packages/agent-portal-mcp/pyproject.toml +20 -0
  64. package/packages/agent-portal-mcp/tests/test_doctor.py +20 -0
  65. package/packages/agent-portal-mcp/tests/test_mcp_server.py +161 -0
  66. package/packages/core/package.json +15 -0
  67. package/packages/core/src/index.ts +1842 -0
  68. package/packages/core/tsconfig.json +8 -0
  69. package/packages/mcp-server/package.json +15 -0
  70. package/packages/mcp-server/src/index.ts +73 -0
  71. package/packages/mcp-server/tsconfig.json +8 -0
  72. package/packages/sdk/package.json +15 -0
  73. package/packages/sdk/src/index.ts +544 -0
  74. package/packages/sdk/tsconfig.json +8 -0
  75. package/plugins/README.md +16 -0
  76. package/plugins/agent-portal-browser/plugin.json +19 -0
  77. package/plugins/agent-portal-python/plugin.json +16 -0
  78. package/plugins/agent-portal-skills/plugin.json +19 -0
  79. package/plugins/agent-portal-vscode/plugin.json +27 -0
  80. package/plugins/example-runtime-plugin/README.md +3 -0
  81. package/plugins/example-runtime-plugin/plugin.json +20 -0
  82. package/plugins/plugin.schema.json +53 -0
  83. package/python/README.md +18 -0
  84. package/python/agent_portal/__init__.py +5 -0
  85. package/python/agent_portal/__main__.py +5 -0
  86. package/python/agent_portal/browser.py +393 -0
  87. package/python/agent_portal/cli.py +164 -0
  88. package/python/agent_portal/config.py +31 -0
  89. package/python/agent_portal/doctor.py +165 -0
  90. package/python/agent_portal/exceptions.py +39 -0
  91. package/python/agent_portal/logging_utils.py +33 -0
  92. package/python/agent_portal/metrics.py +309 -0
  93. package/python/agent_portal/models.py +160 -0
  94. package/python/agent_portal/plugin_system.py +42 -0
  95. package/python/agent_portal/rate_limit.py +253 -0
  96. package/python/agent_portal/runtime.py +739 -0
  97. package/python/agent_portal/server.py +351 -0
  98. package/python/agent_portal/validation.py +299 -0
  99. package/python/pyproject.toml +29 -0
  100. package/python/tests/test_config.py +24 -0
  101. package/python/tests/test_doctor.py +19 -0
  102. package/python/tests/test_metrics.py +180 -0
  103. package/python/tests/test_rate_limit.py +237 -0
  104. package/python/tests/test_runtime.py +122 -0
  105. package/python/tests/test_server.py +53 -0
  106. package/python/tests/test_validation.py +170 -0
  107. package/releases/desktop/agent-portal-desktop/README.md +378 -0
  108. package/releases/desktop/agent-portal-desktop/RELEASE_NOTES.md +14 -0
  109. package/releases/desktop/agent-portal-desktop/assets/branding/agent-portal-logo.png +0 -0
  110. package/releases/desktop/agent-portal-desktop/fixtures/local-workflow.html +151 -0
  111. package/releases/desktop/agent-portal-desktop/launch-agent-portal.bat +4 -0
  112. package/releases/desktop/agent-portal-desktop.zip +0 -0
  113. package/releases/python/agent_portal-0.0.2-py3-none-any.whl +0 -0
  114. package/releases/python/agent_portal-0.0.2.tar.gz +0 -0
  115. package/scripts/package_desktop.mjs +117 -0
  116. package/scripts/release_python.py +46 -0
  117. package/tests/plugin-manifest.test.mjs +26 -0
  118. package/tests/runtime.test.mjs +41 -0
  119. package/tests/vscode-extension.test.mjs +22 -0
  120. package/tsconfig.base.json +16 -0
@@ -0,0 +1,309 @@
1
+ """Metrics and telemetry system for Agent Portal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from collections import defaultdict
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from threading import Lock
12
+ from typing import Any
13
+
14
+
15
+ class MetricType(Enum):
16
+ """Types of metrics."""
17
+ COUNTER = "counter"
18
+ GAUGE = "gauge"
19
+ HISTOGRAM = "histogram"
20
+ TIMER = "timer"
21
+
22
+
23
+ @dataclass
24
+ class MetricValue:
25
+ """A single metric value."""
26
+ name: str
27
+ value: float
28
+ timestamp: float
29
+ tags: dict[str, str] = field(default_factory=dict)
30
+ metric_type: MetricType = MetricType.GAUGE
31
+
32
+
33
+ @dataclass
34
+ class HistogramBucket:
35
+ """A histogram bucket for tracking distributions."""
36
+ count: int = 0
37
+ sum: float = 0.0
38
+ min: float = float("inf")
39
+ max: float = float("-inf")
40
+
41
+
42
+ class MetricsCollector:
43
+ """
44
+ Thread-safe metrics collector for runtime observability.
45
+ """
46
+
47
+ def __init__(self, max_samples: int = 10000) -> None:
48
+ self.max_samples = max_samples
49
+ self._counters: dict[str, float] = defaultdict(float)
50
+ self._gauges: dict[str, float] = defaultdict(float)
51
+ self._histograms: dict[str, HistogramBucket] = defaultdict(HistogramBucket)
52
+ self._timers: dict[str, list[float]] = defaultdict(list)
53
+ self._samples: list[MetricValue] = []
54
+ self._lock = Lock()
55
+
56
+ # Initialize built-in metrics
57
+ self.init_builtin_metrics()
58
+
59
+ def init_builtin_metrics(self) -> None:
60
+ """Initialize built-in runtime metrics."""
61
+ with self._lock:
62
+ # Runtime metrics
63
+ self._gauges["runtime.uptime_seconds"] = 0.0
64
+ self._gauges["runtime.active_sessions"] = 0.0
65
+ self._gauges["runtime.browser_connected"] = 0.0
66
+
67
+ # Action metrics
68
+ self._counters["actions.total"] = 0.0
69
+ self._counters["actions.completed"] = 0.0
70
+ self._counters["actions.failed"] = 0.0
71
+ self._counters["actions.blocked"] = 0.0
72
+ self._counters["actions.approved"] = 0.0
73
+ self._counters["actions.rejected"] = 0.0
74
+
75
+ # Browser metrics
76
+ self._counters["browser.navigations"] = 0.0
77
+ self._counters["browser.screenshots"] = 0.0
78
+ self._counters["browser.errors"] = 0.0
79
+
80
+ # Network metrics
81
+ self._counters["network.requests"] = 0.0
82
+ self._counters["network.failures"] = 0.0
83
+ self._counters["network.console_errors"] = 0.0
84
+
85
+ def increment(self, name: str, value: float = 1.0, tags: dict[str, str] | None = None) -> None:
86
+ """
87
+ Increment a counter metric.
88
+
89
+ Args:
90
+ name: Metric name
91
+ value: Amount to increment by
92
+ tags: Optional tags for the metric
93
+ """
94
+ with self._lock:
95
+ self._counters[name] += value
96
+ self._add_sample(name, value, MetricType.COUNTER, tags or {})
97
+
98
+ def set_gauge(self, name: str, value: float, tags: dict[str, str] | None = None) -> None:
99
+ """
100
+ Set a gauge metric value.
101
+
102
+ Args:
103
+ name: Metric name
104
+ value: Value to set
105
+ tags: Optional tags for the metric
106
+ """
107
+ with self._lock:
108
+ self._gauges[name] = value
109
+ self._add_sample(name, value, MetricType.GAUGE, tags or {})
110
+
111
+ def record_histogram(self, name: str, value: float, tags: dict[str, str] | None = None) -> None:
112
+ """
113
+ Record a value in a histogram.
114
+
115
+ Args:
116
+ name: Metric name
117
+ value: Value to record
118
+ tags: Optional tags for the metric
119
+ """
120
+ with self._lock:
121
+ bucket = self._histograms[name]
122
+ bucket.count += 1
123
+ bucket.sum += value
124
+ bucket.min = min(bucket.min, value)
125
+ bucket.max = max(bucket.max, value)
126
+ self._add_sample(name, value, MetricType.HISTOGRAM, tags or {})
127
+
128
+ def start_timer(self, name: str) -> TimerContext:
129
+ """
130
+ Start a timer for a metric.
131
+
132
+ Args:
133
+ name: Metric name
134
+
135
+ Returns:
136
+ TimerContext that will record the elapsed time when exited
137
+ """
138
+ return TimerContext(self, name)
139
+
140
+ def record_timer(self, name: str, duration_seconds: float, tags: dict[str, str] | None = None) -> None:
141
+ """
142
+ Record a timer value.
143
+
144
+ Args:
145
+ name: Metric name
146
+ duration_seconds: Duration in seconds
147
+ tags: Optional tags for the metric
148
+ """
149
+ with self._lock:
150
+ self._timers[name].append(duration_seconds)
151
+ # Keep only last 1000 timer samples per name
152
+ if len(self._timers[name]) > 1000:
153
+ self._timers[name] = self._timers[name][-1000:]
154
+ self._add_sample(name, duration_seconds, MetricType.TIMER, tags or {})
155
+
156
+ def get_counter(self, name: str) -> float:
157
+ """Get the current value of a counter."""
158
+ with self._lock:
159
+ return self._counters.get(name, 0.0)
160
+
161
+ def get_gauge(self, name: str) -> float:
162
+ """Get the current value of a gauge."""
163
+ with self._lock:
164
+ return self._gauges.get(name, 0.0)
165
+
166
+ def get_histogram(self, name: str) -> dict[str, Any]:
167
+ """Get histogram statistics."""
168
+ with self._lock:
169
+ bucket = self._histograms.get(name)
170
+ if not bucket or bucket.count == 0:
171
+ return {"count": 0, "sum": 0.0, "min": 0.0, "max": 0.0, "avg": 0.0}
172
+
173
+ return {
174
+ "count": bucket.count,
175
+ "sum": bucket.sum,
176
+ "min": bucket.min,
177
+ "max": bucket.max,
178
+ "avg": bucket.sum / bucket.count,
179
+ }
180
+
181
+ def get_timer_stats(self, name: str) -> dict[str, Any]:
182
+ """Get timer statistics."""
183
+ with self._lock:
184
+ times = self._timers.get(name, [])
185
+ if not times:
186
+ return {"count": 0, "min": 0.0, "max": 0.0, "avg": 0.0, "p50": 0.0, "p95": 0.0, "p99": 0.0}
187
+
188
+ sorted_times = sorted(times)
189
+ count = len(sorted_times)
190
+
191
+ return {
192
+ "count": count,
193
+ "min": min(times),
194
+ "max": max(times),
195
+ "avg": sum(times) / count,
196
+ "p50": sorted_times[int(count * 0.5)] if count > 0 else 0.0,
197
+ "p95": sorted_times[int(count * 0.95)] if count > 0 else 0.0,
198
+ "p99": sorted_times[int(count * 0.99)] if count > 0 else 0.0,
199
+ }
200
+
201
+ def get_all_metrics(self) -> dict[str, Any]:
202
+ """
203
+ Get all current metrics.
204
+
205
+ Returns:
206
+ Dictionary containing all metrics
207
+ """
208
+ with self._lock:
209
+ return {
210
+ "counters": dict(self._counters),
211
+ "gauges": dict(self._gauges),
212
+ "histograms": {
213
+ name: self.get_histogram(name)
214
+ for name in self._histograms
215
+ },
216
+ "timers": {
217
+ name: self.get_timer_stats(name)
218
+ for name in self._timers
219
+ },
220
+ }
221
+
222
+ def _add_sample(self, name: str, value: float, metric_type: MetricType, tags: dict[str, str]) -> None:
223
+ """Add a sample to the samples list."""
224
+ self._samples.append(MetricValue(
225
+ name=name,
226
+ value=value,
227
+ timestamp=time.time(),
228
+ tags=tags,
229
+ metric_type=metric_type
230
+ ))
231
+
232
+ # Keep only recent samples to prevent memory issues
233
+ if len(self._samples) > self.max_samples:
234
+ self._samples = self._samples[-(self.max_samples // 2):]
235
+
236
+ def reset(self) -> None:
237
+ """Reset all metrics."""
238
+ with self._lock:
239
+ self._counters.clear()
240
+ self._gauges.clear()
241
+ self._histograms.clear()
242
+ self._timers.clear()
243
+ self._samples.clear()
244
+ self.init_builtin_metrics()
245
+
246
+ def export_to_file(self, path: Path) -> None:
247
+ """Export metrics to a JSON file."""
248
+ metrics = self.get_all_metrics()
249
+ metrics["exported_at"] = time.time()
250
+ path.write_text(json.dumps(metrics, indent=2), encoding="utf8")
251
+
252
+ def get_recent_samples(self, limit: int = 100) -> list[dict[str, Any]]:
253
+ """Get recent metric samples."""
254
+ with self._lock:
255
+ samples = self._samples[-limit:] if self._samples else []
256
+ return [
257
+ {
258
+ "name": s.name,
259
+ "value": s.value,
260
+ "timestamp": s.timestamp,
261
+ "tags": s.tags,
262
+ "type": s.metric_type.value,
263
+ }
264
+ for s in samples
265
+ ]
266
+
267
+
268
+ class TimerContext:
269
+ """Context manager for timing operations."""
270
+
271
+ def __init__(self, collector: MetricsCollector, name: str, tags: dict[str, str] | None = None) -> None:
272
+ self.collector = collector
273
+ self.name = name
274
+ self.tags = tags or {}
275
+ self.start_time: float | None = None
276
+
277
+ def __enter__(self) -> TimerContext:
278
+ self.start_time = time.time()
279
+ return self
280
+
281
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
282
+ if self.start_time is not None:
283
+ duration = time.time() - self.start_time
284
+ self.collector.record_timer(self.name, duration, self.tags)
285
+
286
+
287
+ # Global metrics instance
288
+ _global_metrics: MetricsCollector | None = None
289
+ _metrics_lock = Lock()
290
+
291
+
292
+ def get_metrics() -> MetricsCollector:
293
+ """Get or create the global metrics collector."""
294
+ global _global_metrics
295
+
296
+ if _global_metrics is None:
297
+ with _metrics_lock:
298
+ if _global_metrics is None:
299
+ _global_metrics = MetricsCollector()
300
+
301
+ return _global_metrics
302
+
303
+
304
+ def reset_metrics() -> None:
305
+ """Reset the global metrics collector."""
306
+ global _global_metrics
307
+ with _metrics_lock:
308
+ if _global_metrics is not None:
309
+ _global_metrics.reset()
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Literal
6
+
7
+
8
+ def utc_now() -> str:
9
+ return datetime.now(timezone.utc).isoformat()
10
+
11
+
12
+ RiskLevel = Literal["safe", "low", "medium", "high", "blocked"]
13
+ ActionStatus = Literal["pending", "approved", "rejected", "completed", "failed", "blocked"]
14
+ RuntimeStatus = Literal[
15
+ "idle",
16
+ "thinking",
17
+ "acting",
18
+ "waiting-approval",
19
+ "paused",
20
+ "blocked",
21
+ "finished",
22
+ "failed",
23
+ "stopped",
24
+ ]
25
+ ActionMode = Literal["read-only", "assisted", "autonomous", "manual-override"]
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class RuntimeConfigModel:
30
+ runtime_host: str = "127.0.0.1"
31
+ runtime_port: int = 8765
32
+ screenshot_directory: str = "workspaces/runtime/screenshots"
33
+ report_directory: str = "workspaces/runtime/reports"
34
+ default_browser: str = "chromium"
35
+ action_mode: ActionMode = "assisted"
36
+ approval_policy: RiskLevel = "medium"
37
+ allowed_domains: list[str] = field(default_factory=list)
38
+ blocked_domains: list[str] = field(default_factory=list)
39
+ enabled_plugins: list[str] = field(default_factory=list)
40
+ log_level: str = "INFO"
41
+ retention_days: int = 14
42
+ sensitive_screenshots_enabled: bool = False
43
+ safe_mode: bool = True
44
+ api_token: str | None = None
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class BrowserState:
49
+ browser_name: str = "chromium"
50
+ connected: bool = False
51
+ current_url: str | None = None
52
+ page_title: str | None = None
53
+ last_error: str | None = None
54
+ session_id: str | None = None
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class RiskPolicy:
59
+ mode: ActionMode = "assisted"
60
+ approval_threshold: RiskLevel = "medium"
61
+ domain_lock: str | None = None
62
+ tab_lock: str | None = None
63
+ read_only: bool = False
64
+
65
+
66
+ @dataclass(slots=True)
67
+ class PluginManifestModel:
68
+ name: str
69
+ version: str
70
+ type: str
71
+ permissions: list[str]
72
+ entryPoint: str | None = None
73
+ commands: list[str] = field(default_factory=list)
74
+ settings: dict[str, Any] = field(default_factory=dict)
75
+ panels: list[str] = field(default_factory=list)
76
+ lifecycleHooks: list[str] = field(default_factory=list)
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class ActionRequest:
81
+ action_type: str
82
+ reason: str
83
+ target: str | None = None
84
+ payload: str | None = None
85
+ risk_level: RiskLevel = "low"
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class ActionResult:
90
+ action_id: str
91
+ action_type: str
92
+ status: ActionStatus
93
+ reason: str
94
+ target: str | None = None
95
+ payload: str | None = None
96
+ result: str | None = None
97
+ error: dict[str, object] | None = None
98
+ risk_level: RiskLevel = "low"
99
+ before_screenshot: str | None = None
100
+ after_screenshot: str | None = None
101
+ created_at: str = field(default_factory=utc_now)
102
+
103
+
104
+ @dataclass(slots=True)
105
+ class SessionState:
106
+ session_id: str
107
+ started_at: str = field(default_factory=utc_now)
108
+ ended_at: str | None = None
109
+ runtime_status: RuntimeStatus = "idle"
110
+ current_goal: str | None = None
111
+ pending_actions: list[ActionResult] = field(default_factory=list)
112
+ approved_actions: list[ActionResult] = field(default_factory=list)
113
+ completed_actions: list[ActionResult] = field(default_factory=list)
114
+ rejected_actions: list[ActionResult] = field(default_factory=list)
115
+ blocked_actions: list[ActionResult] = field(default_factory=list)
116
+ failed_actions: list[ActionResult] = field(default_factory=list)
117
+ logs: list[str] = field(default_factory=list)
118
+ console_errors: list[str] = field(default_factory=list)
119
+ network_errors: list[str] = field(default_factory=list)
120
+
121
+
122
+ @dataclass(slots=True)
123
+ class HealthCheckResult:
124
+ name: str
125
+ status: Literal["passed", "warning", "failed"]
126
+ details: str
127
+ suggested_fix: str | None = None
128
+
129
+
130
+ @dataclass(slots=True)
131
+ class DoctorReport:
132
+ checks: list[HealthCheckResult] = field(default_factory=list)
133
+
134
+
135
+ @dataclass(slots=True)
136
+ class SessionReport:
137
+ project_name: str
138
+ runtime_version: str
139
+ session_id: str
140
+ start_time: str
141
+ end_time: str
142
+ browser_used: str
143
+ current_url: str | None
144
+ goals: list[str]
145
+ actions_proposed: list[ActionResult]
146
+ actions_approved: list[ActionResult]
147
+ actions_rejected: list[ActionResult]
148
+ actions_blocked: list[ActionResult]
149
+ actions_completed: list[ActionResult]
150
+ console_errors: list[str]
151
+ network_errors: list[str]
152
+ screenshots: list[str]
153
+ risk_events: list[str]
154
+ failed_steps: list[str]
155
+ suggested_fixes: list[str]
156
+ reproduction_steps: list[str]
157
+ environment_details: dict[str, str]
158
+
159
+ def to_dict(self) -> dict[str, Any]:
160
+ return asdict(self)
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .models import PluginManifestModel
7
+
8
+
9
+ PLUGIN_DIR = "plugins"
10
+
11
+
12
+ def discover_plugins(base_path: Path | None = None) -> list[Path]:
13
+ root = base_path or Path.cwd()
14
+ plugin_root = root / PLUGIN_DIR
15
+ if not plugin_root.exists():
16
+ return []
17
+ return sorted(plugin_root.glob("*/plugin.json"))
18
+
19
+
20
+ def load_plugin_manifest(plugin_path: Path) -> PluginManifestModel:
21
+ raw = json.loads(plugin_path.read_text(encoding="utf8"))
22
+ return PluginManifestModel(**raw)
23
+
24
+
25
+ def validate_plugin_manifest(plugin_path: Path) -> list[str]:
26
+ try:
27
+ manifest = load_plugin_manifest(plugin_path)
28
+ except Exception as exc:
29
+ return [f"Failed to load manifest {plugin_path}: {exc}"]
30
+
31
+ errors: list[str] = []
32
+ if not manifest.name:
33
+ errors.append("Plugin manifest is missing a name.")
34
+ if not manifest.version:
35
+ errors.append("Plugin manifest is missing a version.")
36
+ if not manifest.type:
37
+ errors.append("Plugin manifest is missing a type.")
38
+ if not isinstance(manifest.permissions, list):
39
+ errors.append("Plugin permissions must be a list.")
40
+ if not isinstance(manifest.commands, list):
41
+ errors.append("Plugin commands must be a list.")
42
+ return errors