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,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,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
|
+
)
|