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.
Files changed (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. 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,3 @@
1
+ from windcode.extensions.skills.parser import SkillMetadata, parse_skill_metadata
2
+
3
+ __all__ = ["SkillMetadata", "parse_skill_metadata"]
@@ -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)