windcode 0.1.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.
- windcode/__init__.py +6 -0
- windcode/__main__.py +4 -0
- windcode/auth/__init__.py +11 -0
- windcode/auth/store.py +90 -0
- windcode/cli.py +181 -0
- windcode/config/__init__.py +41 -0
- windcode/config/loader.py +117 -0
- windcode/config/models.py +203 -0
- windcode/config/writer.py +74 -0
- windcode/context/__init__.py +18 -0
- windcode/context/compactor.py +72 -0
- windcode/context/estimator.py +89 -0
- windcode/context/truncation.py +87 -0
- windcode/domain/__init__.py +1 -0
- windcode/domain/errors.py +33 -0
- windcode/domain/events.py +597 -0
- windcode/domain/messages.py +226 -0
- windcode/domain/models.py +73 -0
- windcode/domain/subagents.py +263 -0
- windcode/domain/tools.py +55 -0
- windcode/extensions/__init__.py +27 -0
- windcode/extensions/commands.py +25 -0
- windcode/extensions/discovery.py +139 -0
- windcode/extensions/events.py +58 -0
- windcode/extensions/hooks/__init__.py +4 -0
- windcode/extensions/hooks/dispatcher.py +107 -0
- windcode/extensions/hooks/executor.py +49 -0
- windcode/extensions/hooks/loader.py +101 -0
- windcode/extensions/hooks/models.py +109 -0
- windcode/extensions/mcp/__init__.py +10 -0
- windcode/extensions/mcp/adapter.py +101 -0
- windcode/extensions/mcp/catalog.py +153 -0
- windcode/extensions/mcp/client.py +273 -0
- windcode/extensions/mcp/runtime.py +144 -0
- windcode/extensions/mcp/tools.py +570 -0
- windcode/extensions/models.py +168 -0
- windcode/extensions/paths.py +71 -0
- windcode/extensions/plugins/__init__.py +3 -0
- windcode/extensions/plugins/installer.py +93 -0
- windcode/extensions/plugins/manifest.py +166 -0
- windcode/extensions/runtime.py +366 -0
- windcode/extensions/service.py +437 -0
- windcode/extensions/skills/__init__.py +3 -0
- windcode/extensions/skills/loader.py +67 -0
- windcode/extensions/skills/parser.py +57 -0
- windcode/extensions/skills/tools.py +213 -0
- windcode/extensions/snapshot.py +57 -0
- windcode/extensions/state.py +158 -0
- windcode/instructions/__init__.py +8 -0
- windcode/instructions/loader.py +50 -0
- windcode/memory/__init__.py +57 -0
- windcode/memory/extraction.py +83 -0
- windcode/memory/models.py +218 -0
- windcode/memory/refiner.py +189 -0
- windcode/memory/security.py +23 -0
- windcode/memory/service.py +179 -0
- windcode/memory/store.py +362 -0
- windcode/observability/__init__.py +4 -0
- windcode/observability/redaction.py +95 -0
- windcode/observability/trace.py +115 -0
- windcode/policy/__init__.py +22 -0
- windcode/policy/engine.py +137 -0
- windcode/policy/models.py +69 -0
- windcode/providers/__init__.py +29 -0
- windcode/providers/_utils.py +19 -0
- windcode/providers/anthropic.py +215 -0
- windcode/providers/base.py +46 -0
- windcode/providers/catalog.py +119 -0
- windcode/providers/errors.py +96 -0
- windcode/providers/openai_compat.py +231 -0
- windcode/providers/openai_responses.py +189 -0
- windcode/providers/registry.py +105 -0
- windcode/runtime/__init__.py +15 -0
- windcode/runtime/control.py +89 -0
- windcode/runtime/event_bus.py +67 -0
- windcode/runtime/loop.py +510 -0
- windcode/runtime/prompts.py +132 -0
- windcode/runtime/report.py +64 -0
- windcode/runtime/retry.py +73 -0
- windcode/runtime/scheduler.py +194 -0
- windcode/runtime/subagents/__init__.py +28 -0
- windcode/runtime/subagents/approvals.py +88 -0
- windcode/runtime/subagents/budgets.py +79 -0
- windcode/runtime/subagents/coordinator.py +714 -0
- windcode/runtime/subagents/factory.py +311 -0
- windcode/runtime/subagents/roles.py +74 -0
- windcode/runtime/subagents/verification.py +53 -0
- windcode/sandbox/__init__.py +3 -0
- windcode/sandbox/bwrap.py +79 -0
- windcode/sdk.py +1259 -0
- windcode/sessions/__init__.py +23 -0
- windcode/sessions/artifacts.py +69 -0
- windcode/sessions/models.py +104 -0
- windcode/sessions/store.py +167 -0
- windcode/sessions/tree.py +35 -0
- windcode/tools/__init__.py +10 -0
- windcode/tools/apply_patch.py +191 -0
- windcode/tools/ask_user.py +58 -0
- windcode/tools/builtins.py +44 -0
- windcode/tools/edit_file.py +54 -0
- windcode/tools/filesystem.py +77 -0
- windcode/tools/glob.py +35 -0
- windcode/tools/grep.py +66 -0
- windcode/tools/memory.py +400 -0
- windcode/tools/read_file.py +54 -0
- windcode/tools/registry.py +120 -0
- windcode/tools/shell.py +159 -0
- windcode/tools/subagents/__init__.py +31 -0
- windcode/tools/subagents/cancel.py +35 -0
- windcode/tools/subagents/integrate.py +54 -0
- windcode/tools/subagents/list.py +46 -0
- windcode/tools/subagents/spawn.py +86 -0
- windcode/tools/subagents/wait.py +74 -0
- windcode/tools/write_file.py +61 -0
- windcode/tui/__init__.py +3 -0
- windcode/tui/app.py +1015 -0
- windcode/tui/commands.py +90 -0
- windcode/tui/permission_display.py +24 -0
- windcode/tui/styles.tcss +591 -0
- windcode/tui/widgets/__init__.py +31 -0
- windcode/tui/widgets/approval.py +98 -0
- windcode/tui/widgets/command_menu.py +90 -0
- windcode/tui/widgets/extensions.py +48 -0
- windcode/tui/widgets/input.py +110 -0
- windcode/tui/widgets/memory.py +143 -0
- windcode/tui/widgets/messages.py +299 -0
- windcode/tui/widgets/models.py +565 -0
- windcode/tui/widgets/question.py +46 -0
- windcode/tui/widgets/sessions.py +68 -0
- windcode/tui/widgets/status.py +46 -0
- windcode/tui/widgets/subagents.py +195 -0
- windcode/tui/widgets/tools.py +66 -0
- windcode/tui/widgets/welcome.py +149 -0
- windcode/types.py +80 -0
- windcode/worktrees/__init__.py +24 -0
- windcode/worktrees/git.py +92 -0
- windcode/worktrees/manager.py +265 -0
- windcode/worktrees/models.py +62 -0
- windcode-0.1.0.dist-info/METADATA +207 -0
- windcode-0.1.0.dist-info/RECORD +143 -0
- windcode-0.1.0.dist-info/WHEEL +4 -0
- windcode-0.1.0.dist-info/entry_points.txt +2 -0
- windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from pydantic import TypeAdapter, ValidationError
|
|
12
|
+
|
|
13
|
+
from windcode.config.models import (
|
|
14
|
+
ExtensionConfig,
|
|
15
|
+
McpHttpConfig,
|
|
16
|
+
McpServerConfig,
|
|
17
|
+
McpStdioConfig,
|
|
18
|
+
)
|
|
19
|
+
from windcode.extensions.commands import CommandRoute, build_command_catalog
|
|
20
|
+
from windcode.extensions.discovery import DiscoveryResult, DiscoveryRoot, discover_skills
|
|
21
|
+
from windcode.extensions.hooks.loader import load_hook_definition
|
|
22
|
+
from windcode.extensions.models import (
|
|
23
|
+
ActivationState,
|
|
24
|
+
CapabilityKind,
|
|
25
|
+
CapabilityRecord,
|
|
26
|
+
Diagnostic,
|
|
27
|
+
DiagnosticSeverity,
|
|
28
|
+
DiagnosticStage,
|
|
29
|
+
ExtensionScope,
|
|
30
|
+
ExtensionSnapshot,
|
|
31
|
+
ExtensionSource,
|
|
32
|
+
ManagementResult,
|
|
33
|
+
PermissionRequirement,
|
|
34
|
+
capability_id,
|
|
35
|
+
)
|
|
36
|
+
from windcode.extensions.paths import read_bounded
|
|
37
|
+
from windcode.extensions.plugins.installer import InstallResult, install_local_plugin
|
|
38
|
+
from windcode.extensions.plugins.manifest import (
|
|
39
|
+
PluginCommand,
|
|
40
|
+
PluginManifest,
|
|
41
|
+
parse_plugin_manifest,
|
|
42
|
+
)
|
|
43
|
+
from windcode.extensions.skills.parser import parse_skill_metadata
|
|
44
|
+
from windcode.extensions.snapshot import SnapshotPublisher, build_candidate
|
|
45
|
+
from windcode.extensions.state import (
|
|
46
|
+
ExtensionState,
|
|
47
|
+
ExtensionStateStore,
|
|
48
|
+
InstalledPlugin,
|
|
49
|
+
ManagementAuditRecord,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ExtensionService:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
config: ExtensionConfig,
|
|
57
|
+
workspace: Path,
|
|
58
|
+
state_store: ExtensionStateStore,
|
|
59
|
+
plugins_root: Path,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.config = config
|
|
62
|
+
self.workspace = workspace.expanduser().resolve()
|
|
63
|
+
self.state_store = state_store
|
|
64
|
+
self.plugins_root = plugins_root
|
|
65
|
+
self._user_skill_roots = tuple(Path(path).expanduser() for path in config.skill_roots)
|
|
66
|
+
self._project_skill_root = self.workspace / ".windcode" / "skills"
|
|
67
|
+
loaded = state_store.load()
|
|
68
|
+
self._state = loaded.state
|
|
69
|
+
self._state_diagnostics = loaded.diagnostics
|
|
70
|
+
self._snapshots = SnapshotPublisher()
|
|
71
|
+
self._reload_lock = asyncio.Lock()
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def snapshot(self) -> ExtensionSnapshot:
|
|
75
|
+
return self._snapshots.current
|
|
76
|
+
|
|
77
|
+
async def list_capabilities(self) -> tuple[CapabilityRecord, ...]:
|
|
78
|
+
return self.snapshot.capabilities
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def audit_records(self) -> tuple[ManagementAuditRecord, ...]:
|
|
82
|
+
return () if self._state is None else self._state.audit
|
|
83
|
+
|
|
84
|
+
def _audited(
|
|
85
|
+
self,
|
|
86
|
+
state: ExtensionState,
|
|
87
|
+
action: str,
|
|
88
|
+
source_id: str,
|
|
89
|
+
status: str,
|
|
90
|
+
*,
|
|
91
|
+
generation: int | None = None,
|
|
92
|
+
) -> ExtensionState:
|
|
93
|
+
record = ManagementAuditRecord(
|
|
94
|
+
uuid4().hex,
|
|
95
|
+
action,
|
|
96
|
+
self.snapshot.generation if generation is None else generation,
|
|
97
|
+
source_id,
|
|
98
|
+
status,
|
|
99
|
+
datetime.now(UTC).isoformat(),
|
|
100
|
+
)
|
|
101
|
+
return replace(state, audit=(*state.audit, record)[-1000:])
|
|
102
|
+
|
|
103
|
+
async def inspect(self, identifier: str) -> tuple[CapabilityRecord, ...]:
|
|
104
|
+
plugin_id = identifier.removeprefix("plugin:") if identifier.startswith("plugin:") else None
|
|
105
|
+
matches = tuple(
|
|
106
|
+
record
|
|
107
|
+
for record in self.snapshot.capabilities
|
|
108
|
+
if record.capability_id == identifier
|
|
109
|
+
or record.source.source_id == identifier
|
|
110
|
+
or (plugin_id is not None and record.source.plugin_id == plugin_id)
|
|
111
|
+
)
|
|
112
|
+
if not matches:
|
|
113
|
+
raise KeyError(f"unknown extension or capability: {identifier}")
|
|
114
|
+
return matches
|
|
115
|
+
|
|
116
|
+
def command_routes(self, *, reserved: frozenset[str] = frozenset()) -> tuple[CommandRoute, ...]:
|
|
117
|
+
commands: list[tuple[str, PluginCommand]] = []
|
|
118
|
+
for record in self.snapshot.capabilities:
|
|
119
|
+
if record.kind is not CapabilityKind.PLUGIN or not record.enabled or not record.trusted:
|
|
120
|
+
continue
|
|
121
|
+
definition = self.snapshot.definitions.get(record.capability_id)
|
|
122
|
+
if not isinstance(definition, PluginManifest):
|
|
123
|
+
continue
|
|
124
|
+
source_id = f"plugin:{definition.plugin_id}"
|
|
125
|
+
commands.extend((source_id, command) for command in definition.commands)
|
|
126
|
+
return build_command_catalog(tuple(commands), reserved=reserved)
|
|
127
|
+
|
|
128
|
+
async def set_enabled(self, extension_id: str, enabled: bool) -> ManagementResult:
|
|
129
|
+
if self._state is None:
|
|
130
|
+
return ManagementResult(False, False, self._state_diagnostics)
|
|
131
|
+
previous = self._state.enabled.get(extension_id)
|
|
132
|
+
if previous is enabled:
|
|
133
|
+
return ManagementResult(False, False)
|
|
134
|
+
values = dict(self._state.enabled)
|
|
135
|
+
values[extension_id] = enabled
|
|
136
|
+
plugins = dict(self._state.plugins)
|
|
137
|
+
plugin_id = extension_id.removeprefix("plugin:").split("/", 1)[0]
|
|
138
|
+
if plugin_id in plugins:
|
|
139
|
+
plugins[plugin_id] = replace(plugins[plugin_id], enabled=enabled)
|
|
140
|
+
self._state = self._audited(
|
|
141
|
+
replace(self._state, enabled=values, plugins=plugins),
|
|
142
|
+
"plugin_state_changed",
|
|
143
|
+
extension_id,
|
|
144
|
+
"enabled" if enabled else "disabled",
|
|
145
|
+
)
|
|
146
|
+
self.state_store.save(self._state)
|
|
147
|
+
return ManagementResult(True, True)
|
|
148
|
+
|
|
149
|
+
async def trust_workspace(self, workspace: Path, trusted: bool) -> ManagementResult:
|
|
150
|
+
if self._state is None:
|
|
151
|
+
return ManagementResult(False, False, self._state_diagnostics)
|
|
152
|
+
before = self.state_store.is_workspace_trusted(self._state, workspace)
|
|
153
|
+
if before is trusted:
|
|
154
|
+
return ManagementResult(False, False)
|
|
155
|
+
self._state = self._audited(
|
|
156
|
+
self.state_store.set_workspace_trust(self._state, workspace, trusted),
|
|
157
|
+
"workspace_trust_changed",
|
|
158
|
+
"workspace",
|
|
159
|
+
"trusted" if trusted else "untrusted",
|
|
160
|
+
)
|
|
161
|
+
self.state_store.save(self._state)
|
|
162
|
+
return ManagementResult(True, True)
|
|
163
|
+
|
|
164
|
+
async def reload(self) -> ManagementResult:
|
|
165
|
+
async with self._reload_lock:
|
|
166
|
+
if not self.config.enabled:
|
|
167
|
+
result = DiscoveryResult((), {}, self._state_diagnostics)
|
|
168
|
+
else:
|
|
169
|
+
trusted = self._state is not None and self.state_store.is_workspace_trusted(
|
|
170
|
+
self._state, self.workspace
|
|
171
|
+
)
|
|
172
|
+
roots = [
|
|
173
|
+
DiscoveryRoot(path, ExtensionScope.USER) for path in self._user_skill_roots
|
|
174
|
+
]
|
|
175
|
+
roots.append(
|
|
176
|
+
DiscoveryRoot(self._project_skill_root, ExtensionScope.PROJECT, trusted)
|
|
177
|
+
)
|
|
178
|
+
result = discover_skills(
|
|
179
|
+
tuple(roots), max_metadata_bytes=self.config.max_metadata_bytes
|
|
180
|
+
)
|
|
181
|
+
result = self._with_configured_mcp(result, project_trusted=trusted)
|
|
182
|
+
result = self._with_installed_plugins(result)
|
|
183
|
+
candidate = build_candidate(
|
|
184
|
+
result,
|
|
185
|
+
generation=self.snapshot.generation + 1,
|
|
186
|
+
config={
|
|
187
|
+
**self.config.model_dump(mode="json"),
|
|
188
|
+
"_project_mcp_servers": sorted(self.config.project_mcp_servers),
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
if self._state is not None and self.config.enabled:
|
|
192
|
+
self._state = self._audited(
|
|
193
|
+
self._state,
|
|
194
|
+
"snapshot_reloaded",
|
|
195
|
+
"extension-runtime",
|
|
196
|
+
"published" if candidate.publishable else "rejected",
|
|
197
|
+
generation=candidate.snapshot.generation,
|
|
198
|
+
)
|
|
199
|
+
self.state_store.save(self._state)
|
|
200
|
+
published = self._snapshots.publish(candidate)
|
|
201
|
+
return ManagementResult(published, False, candidate.snapshot.diagnostics)
|
|
202
|
+
|
|
203
|
+
def _with_configured_mcp(
|
|
204
|
+
self, result: DiscoveryResult, *, project_trusted: bool
|
|
205
|
+
) -> DiscoveryResult:
|
|
206
|
+
records = list(result.records)
|
|
207
|
+
definitions = dict(result.definitions)
|
|
208
|
+
for server_id, definition in sorted(self.config.mcp_servers.items()):
|
|
209
|
+
project_source = server_id in self.config.project_mcp_servers
|
|
210
|
+
source = ExtensionSource(
|
|
211
|
+
ExtensionScope.PROJECT if project_source else ExtensionScope.USER,
|
|
212
|
+
component_id=server_id,
|
|
213
|
+
)
|
|
214
|
+
stable_id = capability_id(CapabilityKind.MCP_SERVER, server_id)
|
|
215
|
+
records.append(
|
|
216
|
+
CapabilityRecord(
|
|
217
|
+
stable_id,
|
|
218
|
+
server_id,
|
|
219
|
+
CapabilityKind.MCP_SERVER,
|
|
220
|
+
source,
|
|
221
|
+
enabled=definition.enabled,
|
|
222
|
+
trusted=not project_source or project_trusted,
|
|
223
|
+
required=definition.required,
|
|
224
|
+
activation=(
|
|
225
|
+
ActivationState.INACTIVE
|
|
226
|
+
if not definition.enabled
|
|
227
|
+
else (
|
|
228
|
+
ActivationState.AVAILABLE
|
|
229
|
+
if not project_source or project_trusted
|
|
230
|
+
else ActivationState.UNTRUSTED
|
|
231
|
+
)
|
|
232
|
+
),
|
|
233
|
+
permissions=PermissionRequirement(
|
|
234
|
+
network=definition.transport == "streamable_http",
|
|
235
|
+
process=definition.transport == "stdio",
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
definitions[stable_id] = definition
|
|
240
|
+
return DiscoveryResult(tuple(records), definitions, result.diagnostics)
|
|
241
|
+
|
|
242
|
+
def _with_installed_plugins(self, result: DiscoveryResult) -> DiscoveryResult:
|
|
243
|
+
if self._state is None:
|
|
244
|
+
return DiscoveryResult(
|
|
245
|
+
result.records,
|
|
246
|
+
result.definitions,
|
|
247
|
+
(*result.diagnostics, *self._state_diagnostics),
|
|
248
|
+
)
|
|
249
|
+
records = list(result.records)
|
|
250
|
+
definitions = dict(result.definitions)
|
|
251
|
+
diagnostics = list(result.diagnostics)
|
|
252
|
+
for installed in sorted(self._state.plugins.values(), key=lambda item: item.plugin_id):
|
|
253
|
+
root = self.plugins_root / installed.plugin_id / installed.digest
|
|
254
|
+
source = ExtensionSource(
|
|
255
|
+
ExtensionScope.USER, root, installed.plugin_id, digest=installed.digest
|
|
256
|
+
)
|
|
257
|
+
try:
|
|
258
|
+
self._add_plugin(
|
|
259
|
+
installed.plugin_id,
|
|
260
|
+
installed.enabled,
|
|
261
|
+
root,
|
|
262
|
+
source,
|
|
263
|
+
records,
|
|
264
|
+
definitions,
|
|
265
|
+
)
|
|
266
|
+
except (
|
|
267
|
+
OSError,
|
|
268
|
+
UnicodeError,
|
|
269
|
+
ValueError,
|
|
270
|
+
tomllib.TOMLDecodeError,
|
|
271
|
+
ValidationError,
|
|
272
|
+
) as exc:
|
|
273
|
+
diagnostic = Diagnostic(
|
|
274
|
+
DiagnosticStage.PARSE,
|
|
275
|
+
DiagnosticSeverity.ERROR,
|
|
276
|
+
"invalid_plugin",
|
|
277
|
+
str(exc),
|
|
278
|
+
source.source_id,
|
|
279
|
+
"Repair or reinstall the plugin.",
|
|
280
|
+
)
|
|
281
|
+
diagnostics.append(diagnostic)
|
|
282
|
+
records.append(
|
|
283
|
+
CapabilityRecord(
|
|
284
|
+
capability_id(
|
|
285
|
+
CapabilityKind.PLUGIN,
|
|
286
|
+
installed.plugin_id,
|
|
287
|
+
plugin_id=installed.plugin_id,
|
|
288
|
+
),
|
|
289
|
+
installed.plugin_id,
|
|
290
|
+
CapabilityKind.PLUGIN,
|
|
291
|
+
source,
|
|
292
|
+
enabled=installed.enabled,
|
|
293
|
+
required=True,
|
|
294
|
+
activation=ActivationState.FAILED,
|
|
295
|
+
diagnostics=(diagnostic,),
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
return DiscoveryResult(
|
|
299
|
+
tuple(sorted(records, key=lambda item: item.sort_key)),
|
|
300
|
+
definitions,
|
|
301
|
+
tuple(sorted(diagnostics, key=lambda item: (item.source_id, item.category))),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _add_plugin(
|
|
305
|
+
self,
|
|
306
|
+
plugin_id: str,
|
|
307
|
+
enabled: bool,
|
|
308
|
+
root: Path,
|
|
309
|
+
source: ExtensionSource,
|
|
310
|
+
records: list[CapabilityRecord],
|
|
311
|
+
definitions: dict[str, object],
|
|
312
|
+
) -> None:
|
|
313
|
+
manifest = parse_plugin_manifest(root, max_bytes=self.config.max_metadata_bytes)
|
|
314
|
+
plugin_stable_id = capability_id(CapabilityKind.PLUGIN, plugin_id, plugin_id=plugin_id)
|
|
315
|
+
activation = ActivationState.AVAILABLE if enabled else ActivationState.INACTIVE
|
|
316
|
+
records.append(
|
|
317
|
+
CapabilityRecord(
|
|
318
|
+
plugin_stable_id,
|
|
319
|
+
plugin_id,
|
|
320
|
+
CapabilityKind.PLUGIN,
|
|
321
|
+
source,
|
|
322
|
+
enabled=enabled,
|
|
323
|
+
required=manifest.required,
|
|
324
|
+
activation=activation,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
definitions[plugin_stable_id] = manifest
|
|
328
|
+
for component in manifest.skills:
|
|
329
|
+
component_source = replace(source, component_id=component.component_id)
|
|
330
|
+
stable_id = capability_id(
|
|
331
|
+
CapabilityKind.SKILL, component.component_id, plugin_id=plugin_id
|
|
332
|
+
)
|
|
333
|
+
metadata = parse_skill_metadata(
|
|
334
|
+
root / component.path, max_bytes=self.config.max_metadata_bytes
|
|
335
|
+
)
|
|
336
|
+
records.append(
|
|
337
|
+
CapabilityRecord(
|
|
338
|
+
stable_id,
|
|
339
|
+
metadata.name,
|
|
340
|
+
CapabilityKind.SKILL,
|
|
341
|
+
component_source,
|
|
342
|
+
enabled=enabled,
|
|
343
|
+
required=manifest.required,
|
|
344
|
+
activation=activation,
|
|
345
|
+
permissions=PermissionRequirement(filesystem_read=True),
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
definitions[stable_id] = metadata
|
|
349
|
+
for component in manifest.hooks:
|
|
350
|
+
component_source = replace(source, component_id=component.component_id)
|
|
351
|
+
stable_id = capability_id(
|
|
352
|
+
CapabilityKind.HOOK, component.component_id, plugin_id=plugin_id
|
|
353
|
+
)
|
|
354
|
+
hook = load_hook_definition(
|
|
355
|
+
root,
|
|
356
|
+
component.path,
|
|
357
|
+
source_id=component_source.source_id,
|
|
358
|
+
max_bytes=self.config.max_metadata_bytes,
|
|
359
|
+
)
|
|
360
|
+
records.append(
|
|
361
|
+
CapabilityRecord(
|
|
362
|
+
stable_id,
|
|
363
|
+
component.component_id,
|
|
364
|
+
CapabilityKind.HOOK,
|
|
365
|
+
component_source,
|
|
366
|
+
enabled=enabled,
|
|
367
|
+
required=manifest.required or hook.required,
|
|
368
|
+
activation=activation,
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
definitions[stable_id] = hook
|
|
372
|
+
for component in manifest.mcp_servers:
|
|
373
|
+
component_source = replace(source, component_id=component.component_id)
|
|
374
|
+
stable_id = capability_id(
|
|
375
|
+
CapabilityKind.MCP_SERVER, component.component_id, plugin_id=plugin_id
|
|
376
|
+
)
|
|
377
|
+
raw = tomllib.loads(
|
|
378
|
+
read_bounded(root, component.path, max_bytes=self.config.max_metadata_bytes).decode(
|
|
379
|
+
"utf-8"
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
server = cast(
|
|
383
|
+
McpStdioConfig | McpHttpConfig,
|
|
384
|
+
TypeAdapter(McpServerConfig).validate_python(raw),
|
|
385
|
+
)
|
|
386
|
+
server_enabled = enabled and server.enabled
|
|
387
|
+
records.append(
|
|
388
|
+
CapabilityRecord(
|
|
389
|
+
stable_id,
|
|
390
|
+
component.component_id,
|
|
391
|
+
CapabilityKind.MCP_SERVER,
|
|
392
|
+
component_source,
|
|
393
|
+
enabled=server_enabled,
|
|
394
|
+
required=manifest.required or server.required,
|
|
395
|
+
activation=(activation if server_enabled else ActivationState.INACTIVE),
|
|
396
|
+
permissions=PermissionRequirement(
|
|
397
|
+
network=server.transport == "streamable_http",
|
|
398
|
+
process=server.transport == "stdio",
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
definitions[stable_id] = server
|
|
403
|
+
|
|
404
|
+
async def install_local(self, source: Path, *, enable: bool = False) -> InstallResult:
|
|
405
|
+
if self._state is None:
|
|
406
|
+
raise ValueError("extension state is corrupt")
|
|
407
|
+
result = install_local_plugin(source, self.plugins_root)
|
|
408
|
+
plugins = dict(self._state.plugins)
|
|
409
|
+
existing = plugins.get(result.manifest.plugin_id)
|
|
410
|
+
if existing is not None and existing.digest == result.digest:
|
|
411
|
+
if not enable or existing.enabled:
|
|
412
|
+
return result
|
|
413
|
+
plugins[result.manifest.plugin_id] = replace(existing, enabled=True)
|
|
414
|
+
self._state = self._audited(
|
|
415
|
+
replace(self._state, plugins=plugins),
|
|
416
|
+
"plugin_state_changed",
|
|
417
|
+
f"plugin:{result.manifest.plugin_id}",
|
|
418
|
+
"enabled",
|
|
419
|
+
)
|
|
420
|
+
self.state_store.save(self._state)
|
|
421
|
+
return result
|
|
422
|
+
plugins[result.manifest.plugin_id] = InstalledPlugin(
|
|
423
|
+
result.manifest.plugin_id,
|
|
424
|
+
result.manifest.version,
|
|
425
|
+
result.digest,
|
|
426
|
+
source.name,
|
|
427
|
+
datetime.now(UTC).isoformat(),
|
|
428
|
+
enable,
|
|
429
|
+
)
|
|
430
|
+
self._state = self._audited(
|
|
431
|
+
replace(self._state, plugins=plugins),
|
|
432
|
+
"plugin_installed",
|
|
433
|
+
f"plugin:{result.manifest.plugin_id}",
|
|
434
|
+
"enabled" if enable else "disabled",
|
|
435
|
+
)
|
|
436
|
+
self.state_store.save(self._state)
|
|
437
|
+
return result
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from windcode.extensions.models import CapabilityRecord
|
|
7
|
+
from windcode.extensions.paths import read_bounded
|
|
8
|
+
from windcode.extensions.skills.parser import SkillMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class SkillContent:
|
|
13
|
+
source_id: str
|
|
14
|
+
name: str
|
|
15
|
+
content: str
|
|
16
|
+
digest: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class SkillReference:
|
|
21
|
+
source_id: str
|
|
22
|
+
path: str
|
|
23
|
+
content: bytes
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SkillLoader:
|
|
27
|
+
def __init__(self, *, max_content_bytes: int) -> None:
|
|
28
|
+
self.max_content_bytes = max_content_bytes
|
|
29
|
+
self._content_cache: dict[tuple[str, str], SkillContent] = {}
|
|
30
|
+
self._source_content: dict[str, SkillContent] = {}
|
|
31
|
+
self._reference_cache: dict[tuple[str, str, str], SkillReference] = {}
|
|
32
|
+
|
|
33
|
+
def load(self, record: CapabilityRecord, metadata: SkillMetadata) -> SkillContent:
|
|
34
|
+
source_id = record.source.source_id
|
|
35
|
+
existing = self._source_content.get(source_id)
|
|
36
|
+
if existing is not None:
|
|
37
|
+
return existing
|
|
38
|
+
data = read_bounded(metadata.root, "SKILL.md", max_bytes=self.max_content_bytes)
|
|
39
|
+
digest = record.source.digest or hashlib.sha256(data).hexdigest()
|
|
40
|
+
key = (source_id, digest)
|
|
41
|
+
cached = self._content_cache.get(key)
|
|
42
|
+
if cached is not None:
|
|
43
|
+
return cached
|
|
44
|
+
try:
|
|
45
|
+
text = data.decode("utf-8")
|
|
46
|
+
except UnicodeError as exc:
|
|
47
|
+
raise ValueError(f"Skill content is not UTF-8: {source_id}") from exc
|
|
48
|
+
content = SkillContent(source_id, metadata.name, text, digest)
|
|
49
|
+
self._content_cache[key] = content
|
|
50
|
+
self._source_content[source_id] = content
|
|
51
|
+
return content
|
|
52
|
+
|
|
53
|
+
def read_reference(
|
|
54
|
+
self,
|
|
55
|
+
record: CapabilityRecord,
|
|
56
|
+
metadata: SkillMetadata,
|
|
57
|
+
content: SkillContent,
|
|
58
|
+
relative_path: str,
|
|
59
|
+
) -> SkillReference:
|
|
60
|
+
key = (content.source_id, content.digest, relative_path)
|
|
61
|
+
cached = self._reference_cache.get(key)
|
|
62
|
+
if cached is not None:
|
|
63
|
+
return cached
|
|
64
|
+
data = read_bounded(metadata.root, relative_path, max_bytes=self.max_content_bytes)
|
|
65
|
+
reference = SkillReference(record.source.source_id, relative_path, data)
|
|
66
|
+
self._reference_cache[key] = reference
|
|
67
|
+
return reference
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from windcode.extensions.models import normalize_id
|
|
10
|
+
from windcode.extensions.paths import resolve_beneath
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class SkillMetadata:
|
|
15
|
+
name: str
|
|
16
|
+
description: str
|
|
17
|
+
root: Path
|
|
18
|
+
entrypoint: Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_frontmatter(path: Path, max_bytes: int) -> bytes:
|
|
22
|
+
consumed = 0
|
|
23
|
+
lines: list[bytes] = []
|
|
24
|
+
with path.open("rb") as stream:
|
|
25
|
+
first = stream.readline(max_bytes + 1)
|
|
26
|
+
consumed += len(first)
|
|
27
|
+
if first.rstrip(b"\r\n") != b"---":
|
|
28
|
+
raise ValueError("SKILL.md must start with YAML frontmatter")
|
|
29
|
+
while consumed <= max_bytes:
|
|
30
|
+
line = stream.readline(max_bytes - consumed + 1)
|
|
31
|
+
consumed += len(line)
|
|
32
|
+
if not line:
|
|
33
|
+
raise ValueError("SKILL.md frontmatter is not terminated")
|
|
34
|
+
if line.rstrip(b"\r\n") == b"---":
|
|
35
|
+
return b"".join(lines)
|
|
36
|
+
lines.append(line)
|
|
37
|
+
raise ValueError(f"Skill frontmatter exceeds {max_bytes} bytes")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_skill_metadata(root: Path, *, max_bytes: int = 65_536) -> SkillMetadata:
|
|
41
|
+
root = root.expanduser().resolve(strict=True)
|
|
42
|
+
entrypoint = resolve_beneath(root, "SKILL.md", require_file=True)
|
|
43
|
+
try:
|
|
44
|
+
decoded = _read_frontmatter(entrypoint, max_bytes).decode("utf-8")
|
|
45
|
+
value = yaml.safe_load(decoded)
|
|
46
|
+
except (UnicodeError, yaml.YAMLError) as exc:
|
|
47
|
+
raise ValueError(f"invalid Skill frontmatter: {exc}") from exc
|
|
48
|
+
if not isinstance(value, dict):
|
|
49
|
+
raise ValueError("Skill frontmatter must be a mapping")
|
|
50
|
+
raw = cast(dict[str, Any], value)
|
|
51
|
+
if set(raw) - {"name", "description", "license", "compatibility", "metadata", "allowed-tools"}:
|
|
52
|
+
raise ValueError("Skill frontmatter contains unknown fields")
|
|
53
|
+
name = normalize_id(str(raw.get("name", "")))
|
|
54
|
+
description = raw.get("description")
|
|
55
|
+
if not isinstance(description, str) or not description.strip() or len(description) > 1024:
|
|
56
|
+
raise ValueError("Skill description must contain 1-1024 characters")
|
|
57
|
+
return SkillMetadata(name, description.strip(), root, entrypoint)
|