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,168 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from types import MappingProxyType
9
+ from typing import Any
10
+
11
+
12
+ class ExtensionScope(StrEnum):
13
+ BUILTIN = "builtin"
14
+ USER = "user"
15
+ PROJECT = "project"
16
+ RUN = "run"
17
+
18
+
19
+ SCOPE_RANK = {scope: rank for rank, scope in enumerate(ExtensionScope)}
20
+
21
+
22
+ class CapabilityKind(StrEnum):
23
+ SKILL = "skill"
24
+ MCP_SERVER = "mcp_server"
25
+ MCP_TOOL = "mcp_tool"
26
+ MCP_RESOURCE = "mcp_resource"
27
+ MCP_PROMPT = "mcp_prompt"
28
+ HOOK = "hook"
29
+ COMMAND = "command"
30
+ PLUGIN = "plugin"
31
+
32
+
33
+ class DiagnosticStage(StrEnum):
34
+ DISCOVER = "discover"
35
+ PARSE = "parse"
36
+ VALIDATE = "validate"
37
+ MERGE = "merge"
38
+ INSTALL = "install"
39
+ ACTIVATE = "activate"
40
+ EXECUTE = "execute"
41
+ CLOSE = "close"
42
+
43
+
44
+ class DiagnosticSeverity(StrEnum):
45
+ INFO = "info"
46
+ WARNING = "warning"
47
+ ERROR = "error"
48
+
49
+
50
+ class ActivationState(StrEnum):
51
+ INACTIVE = "inactive"
52
+ UNTRUSTED = "untrusted"
53
+ AVAILABLE = "available"
54
+ ACTIVE = "active"
55
+ FAILED = "failed"
56
+
57
+
58
+ _ID_PART = re.compile(r"^[a-z0-9][a-z0-9_.-]*$")
59
+
60
+
61
+ def normalize_id(value: str) -> str:
62
+ normalized = value.strip().lower().replace(" ", "-")
63
+ if not _ID_PART.fullmatch(normalized):
64
+ raise ValueError(f"invalid extension identifier: {value!r}")
65
+ return normalized
66
+
67
+
68
+ def capability_id(kind: CapabilityKind, public_name: str, *, plugin_id: str | None = None) -> str:
69
+ name = normalize_id(public_name)
70
+ if plugin_id is None:
71
+ return f"{kind.value}:{name}"
72
+ return f"plugin:{normalize_id(plugin_id)}/{kind.value}/{name}"
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class ExtensionSource:
77
+ scope: ExtensionScope
78
+ path: Path | None = None
79
+ plugin_id: str | None = None
80
+ component_id: str | None = None
81
+ digest: str | None = None
82
+
83
+ @property
84
+ def source_id(self) -> str:
85
+ if self.plugin_id is not None and self.component_id is not None:
86
+ return f"plugin:{normalize_id(self.plugin_id)}/{normalize_id(self.component_id)}"
87
+ label = str(self.path) if self.path is not None else "builtin"
88
+ return f"{self.scope.value}:{label}"
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class PermissionRequirement:
93
+ filesystem_read: bool = False
94
+ filesystem_write: bool = False
95
+ network: bool = False
96
+ process: bool = False
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class Diagnostic:
101
+ stage: DiagnosticStage
102
+ severity: DiagnosticSeverity
103
+ category: str
104
+ message: str
105
+ source_id: str
106
+ suggestion: str | None = None
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class CapabilityRecord:
111
+ capability_id: str
112
+ public_name: str
113
+ kind: CapabilityKind
114
+ source: ExtensionSource
115
+ enabled: bool = True
116
+ trusted: bool = True
117
+ required: bool = False
118
+ activation: ActivationState = ActivationState.AVAILABLE
119
+ permissions: PermissionRequirement = field(default_factory=PermissionRequirement)
120
+ shadowed_by: str | None = None
121
+ diagnostics: tuple[Diagnostic, ...] = ()
122
+
123
+ @property
124
+ def sort_key(self) -> tuple[int, str, str, str]:
125
+ return (
126
+ SCOPE_RANK[self.source.scope],
127
+ self.kind.value,
128
+ self.public_name,
129
+ self.source.source_id,
130
+ )
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class ExtensionSnapshot:
135
+ generation: int
136
+ config_fingerprint: str
137
+ capabilities: tuple[CapabilityRecord, ...] = ()
138
+ definitions: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
139
+ diagnostics: tuple[Diagnostic, ...] = ()
140
+
141
+ def __post_init__(self) -> None:
142
+ if self.generation < 0:
143
+ raise ValueError("snapshot generation cannot be negative")
144
+ ordered = tuple(sorted(self.capabilities, key=lambda item: item.sort_key))
145
+ object.__setattr__(self, "capabilities", ordered)
146
+ object.__setattr__(self, "definitions", MappingProxyType(dict(self.definitions)))
147
+ object.__setattr__(
148
+ self,
149
+ "diagnostics",
150
+ tuple(
151
+ sorted(
152
+ self.diagnostics,
153
+ key=lambda item: (
154
+ item.source_id,
155
+ item.stage.value,
156
+ item.severity.value,
157
+ item.category,
158
+ ),
159
+ )
160
+ ),
161
+ )
162
+
163
+
164
+ @dataclass(frozen=True, slots=True)
165
+ class ManagementResult:
166
+ changed: bool
167
+ reload_required: bool
168
+ diagnostics: tuple[Diagnostic, ...] = ()
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+
8
+
9
+ class PathBoundaryError(ValueError):
10
+ pass
11
+
12
+
13
+ def resolve_beneath(root: Path, relative: str | Path, *, require_file: bool = False) -> Path:
14
+ root = root.expanduser().resolve(strict=True)
15
+ candidate_part = Path(relative)
16
+ if candidate_part.is_absolute() or ".." in candidate_part.parts:
17
+ raise PathBoundaryError(f"path escapes extension root: {relative}")
18
+ candidate = root.joinpath(candidate_part).resolve(strict=True)
19
+ if not candidate.is_relative_to(root):
20
+ raise PathBoundaryError(f"path escapes extension root: {relative}")
21
+ mode = candidate.stat().st_mode
22
+ if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
23
+ raise PathBoundaryError(f"unsupported extension file type: {relative}")
24
+ if require_file and not stat.S_ISREG(mode):
25
+ raise PathBoundaryError(f"extension path is not a regular file: {relative}")
26
+ return candidate
27
+
28
+
29
+ def read_bounded(root: Path, relative: str | Path, *, max_bytes: int) -> bytes:
30
+ if max_bytes < 1:
31
+ raise ValueError("max_bytes must be positive")
32
+ path = resolve_beneath(root, relative, require_file=True)
33
+ with path.open("rb") as stream:
34
+ data = stream.read(max_bytes + 1)
35
+ if len(data) > max_bytes:
36
+ raise PathBoundaryError(f"extension file exceeds {max_bytes} bytes: {relative}")
37
+ return data
38
+
39
+
40
+ def scan_bounded(root: Path, *, max_depth: int, max_entries: int) -> Iterator[Path]:
41
+ if max_depth < 0 or max_entries < 1:
42
+ raise ValueError("invalid scan bounds")
43
+ resolved_root = root.expanduser().resolve(strict=True)
44
+ seen = 0
45
+ pending: list[tuple[Path, int]] = [(resolved_root, 0)]
46
+ while pending:
47
+ directory, depth = pending.pop()
48
+ with os.scandir(directory) as entries:
49
+ ordered = sorted(entries, key=lambda entry: entry.name)
50
+ child_directories: list[Path] = []
51
+ for entry in ordered:
52
+ seen += 1
53
+ if seen > max_entries:
54
+ raise PathBoundaryError(f"extension scan exceeds {max_entries} entries")
55
+ if entry.is_symlink():
56
+ continue
57
+ path = Path(entry.path)
58
+ mode = entry.stat(follow_symlinks=False).st_mode
59
+ if stat.S_ISREG(mode):
60
+ yield path
61
+ elif stat.S_ISDIR(mode) and depth < max_depth:
62
+ child_directories.append(path)
63
+ elif not stat.S_ISDIR(mode):
64
+ raise PathBoundaryError(f"unsupported extension file type: {path}")
65
+ pending.extend((path, depth + 1) for path in reversed(child_directories))
66
+
67
+
68
+ def plugin_data_directory(data_root: Path, plugin_id: str) -> Path:
69
+ from windcode.extensions.models import normalize_id
70
+
71
+ return data_root.expanduser().resolve() / "plugin-data" / normalize_id(plugin_id)
@@ -0,0 +1,3 @@
1
+ from windcode.extensions.plugins.manifest import PluginManifest, parse_plugin_manifest
2
+
3
+ __all__ = ["PluginManifest", "parse_plugin_manifest"]
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from uuid import uuid4
9
+
10
+ from windcode.extensions.paths import PathBoundaryError, scan_bounded
11
+ from windcode.extensions.plugins.manifest import PluginManifest, parse_plugin_manifest
12
+
13
+ _IGNORED_PARTS = {".git", ".hg", ".svn", "__pycache__", ".pytest_cache", ".mypy_cache"}
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class InstallResult:
18
+ manifest: PluginManifest
19
+ digest: str
20
+ destination: Path
21
+ changed: bool
22
+
23
+
24
+ def _plugin_files(root: Path, *, max_entries: int = 10_000) -> tuple[Path, ...]:
25
+ files = (
26
+ path
27
+ for path in scan_bounded(root, max_depth=32, max_entries=max_entries)
28
+ if not any(part in _IGNORED_PARTS for part in path.relative_to(root).parts)
29
+ )
30
+ return tuple(sorted(files, key=lambda path: path.relative_to(root).as_posix()))
31
+
32
+
33
+ def plugin_digest(root: Path) -> str:
34
+ root = root.expanduser().resolve(strict=True)
35
+ digest = hashlib.sha256()
36
+ for path in _plugin_files(root):
37
+ relative = path.relative_to(root).as_posix().encode()
38
+ digest.update(len(relative).to_bytes(8, "big"))
39
+ digest.update(relative)
40
+ with path.open("rb") as stream:
41
+ while block := stream.read(1024 * 1024):
42
+ digest.update(block)
43
+ return digest.hexdigest()
44
+
45
+
46
+ def install_local_plugin(source: Path, plugins_root: Path) -> InstallResult:
47
+ source = source.expanduser().resolve(strict=True)
48
+ manifest = parse_plugin_manifest(source)
49
+ digest = plugin_digest(source)
50
+ plugin_root = plugins_root.expanduser().resolve() / manifest.plugin_id
51
+ destination = plugin_root / digest
52
+ if destination.exists():
53
+ return InstallResult(manifest, digest, destination, False)
54
+ if plugin_root.exists():
55
+ for installed in plugin_root.iterdir():
56
+ if not installed.is_dir():
57
+ continue
58
+ try:
59
+ existing = parse_plugin_manifest(installed)
60
+ except ValueError:
61
+ continue
62
+ if existing.version == manifest.version and installed.name != digest:
63
+ raise ValueError(
64
+ f"plugin {manifest.plugin_id} version {manifest.version} has different content"
65
+ )
66
+ plugin_root.mkdir(parents=True, exist_ok=True, mode=0o700)
67
+ temporary = plugin_root / f".tmp-{uuid4().hex}"
68
+ temporary.mkdir(mode=0o700)
69
+ try:
70
+ for path in _plugin_files(source):
71
+ relative = path.relative_to(source)
72
+ target = temporary / relative
73
+ target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
74
+ shutil.copyfile(path, target, follow_symlinks=False)
75
+ if plugin_digest(temporary) != digest:
76
+ raise PathBoundaryError("plugin content changed during installation")
77
+ parse_plugin_manifest(temporary)
78
+ for directory, _, files in os.walk(temporary):
79
+ for name in files:
80
+ descriptor = os.open(Path(directory) / name, os.O_RDONLY)
81
+ try:
82
+ os.fsync(descriptor)
83
+ finally:
84
+ os.close(descriptor)
85
+ os.replace(temporary, destination)
86
+ directory_fd = os.open(plugin_root, os.O_RDONLY)
87
+ try:
88
+ os.fsync(directory_fd)
89
+ finally:
90
+ os.close(directory_fd)
91
+ finally:
92
+ shutil.rmtree(temporary, ignore_errors=True)
93
+ return InstallResult(manifest, digest, destination, True)
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import tomllib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ from windcode.extensions.models import normalize_id
10
+ from windcode.extensions.paths import PathBoundaryError, read_bounded, resolve_beneath
11
+
12
+ MANIFEST_PATH = Path(".windcode-plugin/plugin.toml")
13
+ _VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$")
14
+ _ALLOWED = {
15
+ "manifest_version",
16
+ "id",
17
+ "name",
18
+ "version",
19
+ "windcode",
20
+ "required",
21
+ "skills",
22
+ "hooks",
23
+ "commands",
24
+ "mcp_servers",
25
+ "permissions",
26
+ "data",
27
+ }
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class PluginComponent:
32
+ component_id: str
33
+ path: str
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class PluginCommand:
38
+ name: str
39
+ target: str
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class PluginManifest:
44
+ manifest_version: int
45
+ plugin_id: str
46
+ name: str
47
+ version: str
48
+ windcode: str
49
+ required: bool
50
+ skills: tuple[PluginComponent, ...]
51
+ hooks: tuple[PluginComponent, ...]
52
+ mcp_servers: tuple[PluginComponent, ...]
53
+ commands: tuple[PluginCommand, ...]
54
+ effects: tuple[str, ...]
55
+ network_hosts: tuple[str, ...]
56
+ persistent_data: bool
57
+ root: Path
58
+
59
+
60
+ def _components(root: Path, raw: object, label: str) -> tuple[PluginComponent, ...]:
61
+ if raw is None:
62
+ return ()
63
+ if not isinstance(raw, list):
64
+ raise ValueError(f"{label} must be an array")
65
+ result: list[PluginComponent] = []
66
+ seen: set[str] = set()
67
+ for value in cast(list[object], raw):
68
+ if not isinstance(value, dict):
69
+ raise ValueError(f"{label} entries must be tables")
70
+ item = cast(dict[str, object], value)
71
+ if set(item) != {"id", "path"}:
72
+ raise ValueError(f"{label} entries require only id and path")
73
+ component_id = normalize_id(str(item["id"]))
74
+ if component_id in seen:
75
+ raise ValueError(f"duplicate {label} id: {component_id}")
76
+ path = str(item["path"])
77
+ resolve_beneath(root, path)
78
+ seen.add(component_id)
79
+ result.append(PluginComponent(component_id, path))
80
+ return tuple(sorted(result, key=lambda item: item.component_id))
81
+
82
+
83
+ def _mcp_components(root: Path, raw: object) -> tuple[PluginComponent, ...]:
84
+ if raw is None:
85
+ return ()
86
+ if not isinstance(raw, dict):
87
+ raise ValueError("mcp_servers must be a table")
88
+ return _components(
89
+ root,
90
+ [{"id": key, "path": value["path"]} for key, value in cast(dict[str, Any], raw).items()],
91
+ "mcp_servers",
92
+ )
93
+
94
+
95
+ def _commands(raw: object) -> tuple[PluginCommand, ...]:
96
+ if raw is None:
97
+ return ()
98
+ if not isinstance(raw, list):
99
+ raise ValueError("commands must be an array")
100
+ result: list[PluginCommand] = []
101
+ seen: set[str] = set()
102
+ for value in cast(list[object], raw):
103
+ if not isinstance(value, dict):
104
+ raise ValueError("command entries must be tables")
105
+ item = cast(dict[str, object], value)
106
+ if set(item) != {"name", "target"}:
107
+ raise ValueError("command entries require only name and target")
108
+ name = normalize_id(str(item["name"]))
109
+ target = str(item["target"])
110
+ if name in seen or not re.fullmatch(
111
+ r"(?:skill|prompt|capability):[a-z0-9][a-z0-9_.-]*", target
112
+ ):
113
+ raise ValueError(f"invalid or duplicate command: {name}")
114
+ seen.add(name)
115
+ result.append(PluginCommand(name, target))
116
+ return tuple(sorted(result, key=lambda item: item.name))
117
+
118
+
119
+ def parse_plugin_manifest(root: Path, *, max_bytes: int = 65_536) -> PluginManifest:
120
+ root = root.expanduser().resolve(strict=True)
121
+ try:
122
+ raw = cast(
123
+ dict[str, object],
124
+ tomllib.loads(read_bounded(root, MANIFEST_PATH, max_bytes=max_bytes).decode("utf-8")),
125
+ )
126
+ except (UnicodeError, tomllib.TOMLDecodeError, PathBoundaryError) as exc:
127
+ raise ValueError(f"invalid plugin manifest: {exc}") from exc
128
+ unknown = set(raw) - _ALLOWED
129
+ if unknown:
130
+ raise ValueError(f"unknown manifest fields: {', '.join(sorted(unknown))}")
131
+ required_fields = {"manifest_version", "id", "name", "version", "windcode"}
132
+ missing = required_fields - set(raw)
133
+ if missing:
134
+ raise ValueError(f"missing manifest fields: {', '.join(sorted(missing))}")
135
+ if raw["manifest_version"] != 1:
136
+ raise ValueError("unsupported manifest version")
137
+ plugin_id = normalize_id(str(raw["id"]))
138
+ name, version, compatibility = str(raw["name"]), str(raw["version"]), str(raw["windcode"])
139
+ if not name.strip() or len(name) > 100 or not _VERSION.fullmatch(version):
140
+ raise ValueError("invalid plugin name or version")
141
+ if compatibility not in {">=0.1,<0.2", ">=0.1.0,<0.2.0", "*"}:
142
+ raise ValueError(f"incompatible Windcode version range: {compatibility}")
143
+ permissions = cast(dict[str, object], raw.get("permissions", {}))
144
+ if set(permissions) - {"effects", "network_hosts"}:
145
+ raise ValueError("unknown permissions fields")
146
+ data = cast(dict[str, object], raw.get("data", {}))
147
+ if set(data) - {"persistent"}:
148
+ raise ValueError("unknown data fields")
149
+ return PluginManifest(
150
+ 1,
151
+ plugin_id,
152
+ name.strip(),
153
+ version,
154
+ compatibility,
155
+ bool(raw.get("required", False)),
156
+ _components(root, raw.get("skills"), "skills"),
157
+ _components(root, raw.get("hooks"), "hooks"),
158
+ _mcp_components(root, raw.get("mcp_servers")),
159
+ _commands(raw.get("commands")),
160
+ tuple(sorted(str(value) for value in cast(list[object], permissions.get("effects", [])))),
161
+ tuple(
162
+ sorted(str(value) for value in cast(list[object], permissions.get("network_hosts", [])))
163
+ ),
164
+ bool(data.get("persistent", False)),
165
+ root,
166
+ )