local-shell-mcp 3.2.0__py3-none-win_amd64.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.
- local_shell_mcp/__init__.py +3 -0
- local_shell_mcp/agent_bridge/__init__.py +1 -0
- local_shell_mcp/agent_bridge/models.py +29 -0
- local_shell_mcp/agent_bridge/skills.py +554 -0
- local_shell_mcp/audit.py +849 -0
- local_shell_mcp/auth.py +495 -0
- local_shell_mcp/conpty_ops.py +350 -0
- local_shell_mcp/deprecated_tools.py +128 -0
- local_shell_mcp/downloads.py +515 -0
- local_shell_mcp/errors.py +63 -0
- local_shell_mcp/fs_ops.py +674 -0
- local_shell_mcp/helpers/README.md +7 -0
- local_shell_mcp/helpers/tmux.LICENSE +18 -0
- local_shell_mcp/http_app.py +401 -0
- local_shell_mcp/human_ui.py +2049 -0
- local_shell_mcp/image_ops.py +136 -0
- local_shell_mcp/jobs.py +1570 -0
- local_shell_mcp/main.py +205 -0
- local_shell_mcp/models.py +118 -0
- local_shell_mcp/oauth.py +771 -0
- local_shell_mcp/oauth_authorize.html +261 -0
- local_shell_mcp/patch_ops.py +298 -0
- local_shell_mcp/playwright_ops.py +128 -0
- local_shell_mcp/remote.py +2153 -0
- local_shell_mcp/remote_transfer.py +607 -0
- local_shell_mcp/remote_worker.py +8 -0
- local_shell_mcp/remote_worker_cli.py +293 -0
- local_shell_mcp/remote_worker_installer.py +111 -0
- local_shell_mcp/remote_worker_routes.py +379 -0
- local_shell_mcp/remote_worker_service.py +816 -0
- local_shell_mcp/remote_worker_state.py +224 -0
- local_shell_mcp/search_ops.py +167 -0
- local_shell_mcp/settings.py +736 -0
- local_shell_mcp/shell_environment.py +72 -0
- local_shell_mcp/shell_ops.py +1028 -0
- local_shell_mcp/skill_ops.py +198 -0
- local_shell_mcp/tmux_helper.py +106 -0
- local_shell_mcp/todo_ops.py +75 -0
- local_shell_mcp/tools.py +2169 -0
- local_shell_mcp/transfer_ops.py +559 -0
- local_shell_mcp/tui_runtime.py +60 -0
- local_shell_mcp/ui_runtime/local-shell-mcp-tui.exe.gz +0 -0
- local_shell_mcp/ui_security.py +107 -0
- local_shell_mcp/ui_static/index.html +150 -0
- local_shell_mcp/ui_static/web.css +5 -0
- local_shell_mcp/ui_static/web.js +88 -0
- local_shell_mcp/version.py +32 -0
- local_shell_mcp-3.2.0.dist-info/METADATA +292 -0
- local_shell_mcp-3.2.0.dist-info/RECORD +52 -0
- local_shell_mcp-3.2.0.dist-info/WHEEL +4 -0
- local_shell_mcp-3.2.0.dist-info/entry_points.txt +2 -0
- local_shell_mcp-3.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent bridge package."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Data models used by installed agent skills."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class SkillRecord:
|
|
8
|
+
"""Resolved skill metadata."""
|
|
9
|
+
|
|
10
|
+
name: str
|
|
11
|
+
"""Stable skill name derived from its directory."""
|
|
12
|
+
entry_path: str
|
|
13
|
+
"""Path to the Markdown skill entry file."""
|
|
14
|
+
description: str
|
|
15
|
+
"""Human-readable skill summary."""
|
|
16
|
+
related_files: list[str]
|
|
17
|
+
"""Additional files related to the skill entry."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SkillScanResult:
|
|
22
|
+
"""Skill discovery result."""
|
|
23
|
+
|
|
24
|
+
skills: dict[str, SkillRecord] = field(default_factory=dict)
|
|
25
|
+
"""Accepted skills keyed by skill name."""
|
|
26
|
+
warnings: list[str] = field(default_factory=list)
|
|
27
|
+
"""Non-fatal discovery warnings for ignored or invalid entries."""
|
|
28
|
+
scanned_entries: int = 0
|
|
29
|
+
"""Filesystem entries consumed from the registry scan budget."""
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Skill discovery and activation helpers for the agent bridge."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import stat
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .models import SkillRecord, SkillScanResult
|
|
13
|
+
|
|
14
|
+
DEFAULT_MAX_SKILLS = 256
|
|
15
|
+
DEFAULT_MAX_RELATED_FILES = 1_000
|
|
16
|
+
DEFAULT_MAX_SCAN_ENTRIES = 5_000
|
|
17
|
+
DEFAULT_MAX_PATH_BYTES = 200_000
|
|
18
|
+
DEFAULT_MAX_ENTRY_BYTES = 512_000
|
|
19
|
+
MAX_SKILL_DESCRIPTION_CHARS = 500
|
|
20
|
+
MAX_SKILL_FILE_PATH_CHARS = 4_096
|
|
21
|
+
MAX_SKILL_WARNINGS = 100
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _relative_posix(base: Path, path: Path) -> str:
|
|
25
|
+
"""Render a validated child path using POSIX separators."""
|
|
26
|
+
return path.relative_to(base).as_posix()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_relative_child_path(value: Path) -> bool:
|
|
30
|
+
"""Accept portable relative child paths on every host platform."""
|
|
31
|
+
raw = os.fspath(value)
|
|
32
|
+
posix = PurePosixPath(raw)
|
|
33
|
+
windows = PureWindowsPath(raw)
|
|
34
|
+
return (
|
|
35
|
+
not posix.anchor
|
|
36
|
+
and not windows.anchor
|
|
37
|
+
and ".." not in posix.parts
|
|
38
|
+
and ".." not in windows.parts
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_skill_name(name: str) -> str:
|
|
43
|
+
"""Validate a portable immediate-child directory name used as a Skill identifier."""
|
|
44
|
+
if not isinstance(name, str):
|
|
45
|
+
raise ValueError("Skill name must be a string")
|
|
46
|
+
if not name:
|
|
47
|
+
raise ValueError("Skill name must not be empty")
|
|
48
|
+
if name != name.strip():
|
|
49
|
+
raise ValueError("Skill name must not have leading or trailing whitespace")
|
|
50
|
+
if name in {".", ".."} or "/" in name or "\\" in name:
|
|
51
|
+
raise ValueError("Skill name must be a single directory name")
|
|
52
|
+
if len(name) > 255:
|
|
53
|
+
raise ValueError("Skill name must be at most 255 characters")
|
|
54
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in name):
|
|
55
|
+
raise ValueError("Skill name must not contain control characters")
|
|
56
|
+
try:
|
|
57
|
+
name.encode("utf-8")
|
|
58
|
+
except UnicodeEncodeError as exc:
|
|
59
|
+
raise ValueError("Skill name must be valid UTF-8") from exc
|
|
60
|
+
return name
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_skill_file_path(path: str) -> Path:
|
|
64
|
+
"""Validate a canonical portable POSIX path inside one Skill directory."""
|
|
65
|
+
if not isinstance(path, str):
|
|
66
|
+
raise ValueError("Skill file path must be a string")
|
|
67
|
+
if not path:
|
|
68
|
+
raise ValueError("Skill file path must not be empty")
|
|
69
|
+
if len(path) > MAX_SKILL_FILE_PATH_CHARS:
|
|
70
|
+
raise ValueError(f"Skill file path must be at most {MAX_SKILL_FILE_PATH_CHARS} characters")
|
|
71
|
+
if "\\" in path or ":" in path:
|
|
72
|
+
raise ValueError("Skill file path must use portable POSIX separators")
|
|
73
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in path):
|
|
74
|
+
raise ValueError("Skill file path must not contain control characters")
|
|
75
|
+
try:
|
|
76
|
+
path.encode("utf-8")
|
|
77
|
+
except UnicodeEncodeError as exc:
|
|
78
|
+
raise ValueError("Skill file path must be valid UTF-8") from exc
|
|
79
|
+
|
|
80
|
+
relative = PurePosixPath(path)
|
|
81
|
+
if relative.is_absolute() or ".." in relative.parts or relative == PurePosixPath("."):
|
|
82
|
+
raise ValueError("Skill file path must be relative to the skill directory")
|
|
83
|
+
if relative.as_posix() != path:
|
|
84
|
+
raise ValueError("Skill file path must be canonical")
|
|
85
|
+
return Path(*relative.parts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _first_sentence(value: str) -> str:
|
|
89
|
+
"""Extract the first prose sentence used as a compact skill description."""
|
|
90
|
+
match = re.match(r"(.+?[.!?])(?:\s|$)", value)
|
|
91
|
+
if match:
|
|
92
|
+
return match.group(1)
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _normalize_description(value: object) -> str | None:
|
|
97
|
+
"""Normalize and bound a front-matter or Markdown-derived description."""
|
|
98
|
+
if not isinstance(value, str):
|
|
99
|
+
return None
|
|
100
|
+
normalized = " ".join(value.split())
|
|
101
|
+
if not normalized:
|
|
102
|
+
return None
|
|
103
|
+
if len(normalized) > MAX_SKILL_DESCRIPTION_CHARS:
|
|
104
|
+
normalized = normalized[: MAX_SKILL_DESCRIPTION_CHARS - 1].rstrip() + "…"
|
|
105
|
+
return normalized
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _front_matter_description(lines: list[str], start: int) -> tuple[str | None, int]:
|
|
109
|
+
"""Parse YAML or TOML front matter and return its description and body start."""
|
|
110
|
+
delimiter = lines[start].strip()
|
|
111
|
+
end = start + 1
|
|
112
|
+
while end < len(lines):
|
|
113
|
+
stripped = lines[end].strip()
|
|
114
|
+
if stripped == delimiter or (delimiter == "---" and stripped == "..."):
|
|
115
|
+
break
|
|
116
|
+
end += 1
|
|
117
|
+
if end >= len(lines):
|
|
118
|
+
return None, start
|
|
119
|
+
|
|
120
|
+
raw = "\n".join(lines[start + 1 : end])
|
|
121
|
+
try:
|
|
122
|
+
parsed = yaml.safe_load(raw) if delimiter == "---" else tomllib.loads(raw)
|
|
123
|
+
except (yaml.YAMLError, tomllib.TOMLDecodeError, RecursionError):
|
|
124
|
+
parsed = None
|
|
125
|
+
if isinstance(parsed, dict):
|
|
126
|
+
description = _normalize_description(parsed.get("description"))
|
|
127
|
+
if description is not None:
|
|
128
|
+
return description, end + 1
|
|
129
|
+
return None, end + 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _skill_description(markdown: str) -> str:
|
|
133
|
+
"""Derive a bounded description from front matter, prose, or the first heading."""
|
|
134
|
+
lines = markdown.splitlines()
|
|
135
|
+
line_index = 0
|
|
136
|
+
while line_index < len(lines) and not lines[line_index].strip():
|
|
137
|
+
line_index += 1
|
|
138
|
+
|
|
139
|
+
if line_index < len(lines) and lines[line_index].strip() in {"---", "+++"}:
|
|
140
|
+
description, line_index = _front_matter_description(lines, line_index)
|
|
141
|
+
if description is not None:
|
|
142
|
+
return description
|
|
143
|
+
|
|
144
|
+
in_code_fence = False
|
|
145
|
+
first_heading: str | None = None
|
|
146
|
+
for line in lines[line_index:]:
|
|
147
|
+
stripped = line.strip()
|
|
148
|
+
if not stripped:
|
|
149
|
+
continue
|
|
150
|
+
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
151
|
+
in_code_fence = not in_code_fence
|
|
152
|
+
continue
|
|
153
|
+
if in_code_fence:
|
|
154
|
+
continue
|
|
155
|
+
if stripped in {"---", "...", "+++"}:
|
|
156
|
+
continue
|
|
157
|
+
if stripped.startswith("#"):
|
|
158
|
+
heading = _normalize_description(stripped.lstrip("#").strip())
|
|
159
|
+
if first_heading is None and heading is not None:
|
|
160
|
+
first_heading = heading
|
|
161
|
+
continue
|
|
162
|
+
prose = _normalize_description(_first_sentence(stripped))
|
|
163
|
+
if prose is not None:
|
|
164
|
+
return prose
|
|
165
|
+
return first_heading or "Agent skill"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _bounded(value: int | None, default: int) -> int:
|
|
169
|
+
return max(1, int(default if value is None else value))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _append_warning(warnings: list[str], message: str) -> None:
|
|
173
|
+
if len(warnings) < MAX_SKILL_WARNINGS:
|
|
174
|
+
warnings.append(message)
|
|
175
|
+
elif len(warnings) == MAX_SKILL_WARNINGS:
|
|
176
|
+
warnings.append("Additional Skill warnings were omitted")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _resolved_skills_directory(config_dir: Path, directory: str) -> tuple[Path, Path]:
|
|
180
|
+
config_root = config_dir.resolve()
|
|
181
|
+
directory_path = Path(directory)
|
|
182
|
+
if not _is_relative_child_path(directory_path):
|
|
183
|
+
raise ValueError(f"Skills directory must be inside config directory: {directory}")
|
|
184
|
+
skills_dir = config_root / directory_path
|
|
185
|
+
return config_root, skills_dir
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _open_regular_file(path: Path, max_bytes: int) -> tuple[str, int, Path]:
|
|
189
|
+
"""Open a bounded regular file, following symlinks when present."""
|
|
190
|
+
limit = _bounded(max_bytes, DEFAULT_MAX_ENTRY_BYTES)
|
|
191
|
+
flags = os.O_RDONLY
|
|
192
|
+
if hasattr(os, "O_BINARY"):
|
|
193
|
+
flags |= os.O_BINARY
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
descriptor = os.open(path, flags)
|
|
197
|
+
except OSError as exc:
|
|
198
|
+
raise ValueError(f"Skill file must be a readable regular file: {exc}") from exc
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
try:
|
|
202
|
+
opened_stat = os.fstat(descriptor)
|
|
203
|
+
if not stat.S_ISREG(opened_stat.st_mode):
|
|
204
|
+
raise ValueError("Skill file path must be a regular file")
|
|
205
|
+
if opened_stat.st_size > limit:
|
|
206
|
+
raise ValueError(f"Skill file is {opened_stat.st_size} bytes; maximum is {limit}")
|
|
207
|
+
|
|
208
|
+
resolved = path.resolve(strict=True)
|
|
209
|
+
current_stat = path.stat()
|
|
210
|
+
if (
|
|
211
|
+
opened_stat.st_ino
|
|
212
|
+
and current_stat.st_ino
|
|
213
|
+
and (opened_stat.st_dev, opened_stat.st_ino)
|
|
214
|
+
!= (current_stat.st_dev, current_stat.st_ino)
|
|
215
|
+
):
|
|
216
|
+
raise ValueError("Skill file changed while it was being opened")
|
|
217
|
+
|
|
218
|
+
chunks: list[bytes] = []
|
|
219
|
+
total = 0
|
|
220
|
+
while True:
|
|
221
|
+
chunk = os.read(descriptor, min(64 * 1024, limit + 1 - total))
|
|
222
|
+
if not chunk:
|
|
223
|
+
break
|
|
224
|
+
chunks.append(chunk)
|
|
225
|
+
total += len(chunk)
|
|
226
|
+
if total > limit:
|
|
227
|
+
raise ValueError(f"Skill file exceeds maximum size of {limit} bytes")
|
|
228
|
+
data = b"".join(chunks)
|
|
229
|
+
except OSError as exc:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"Skill file changed or became unavailable while reading: {exc}"
|
|
232
|
+
) from exc
|
|
233
|
+
finally:
|
|
234
|
+
os.close(descriptor)
|
|
235
|
+
|
|
236
|
+
content = data.decode("utf-8", errors="replace")
|
|
237
|
+
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
238
|
+
return content, len(data), resolved
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _resolve_skill_root(skills_dir: Path, name: str) -> Path:
|
|
242
|
+
validated_name = validate_skill_name(name)
|
|
243
|
+
candidate = skills_dir / validated_name
|
|
244
|
+
try:
|
|
245
|
+
resolved = candidate.resolve(strict=True)
|
|
246
|
+
except FileNotFoundError as exc:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"Unknown skill: {validated_name}. Call skills_list to see installed skills."
|
|
249
|
+
) from exc
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
raise ValueError(f"Could not inspect skill {validated_name}: {exc}") from exc
|
|
252
|
+
if not resolved.is_dir():
|
|
253
|
+
raise ValueError("Skill path must resolve to a directory")
|
|
254
|
+
return candidate
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _scan_related_files(
|
|
258
|
+
skill_root: Path,
|
|
259
|
+
entry_path: Path,
|
|
260
|
+
*,
|
|
261
|
+
max_related_files: int,
|
|
262
|
+
max_scan_entries: int,
|
|
263
|
+
max_path_bytes: int,
|
|
264
|
+
) -> tuple[list[str], list[str], int]:
|
|
265
|
+
related_limit = _bounded(max_related_files, DEFAULT_MAX_RELATED_FILES)
|
|
266
|
+
scan_limit = max(0, int(max_scan_entries))
|
|
267
|
+
path_limit = max(0, int(max_path_bytes))
|
|
268
|
+
related_files: list[str] = []
|
|
269
|
+
warnings: list[str] = []
|
|
270
|
+
scanned_entries = 0
|
|
271
|
+
if scan_limit == 0:
|
|
272
|
+
_append_warning(
|
|
273
|
+
warnings,
|
|
274
|
+
"Related file scan omitted because the scan budget is exhausted",
|
|
275
|
+
)
|
|
276
|
+
return related_files, warnings, scanned_entries
|
|
277
|
+
if path_limit == 0:
|
|
278
|
+
_append_warning(
|
|
279
|
+
warnings,
|
|
280
|
+
"Related file paths omitted because the path budget is exhausted",
|
|
281
|
+
)
|
|
282
|
+
return related_files, warnings, scanned_entries
|
|
283
|
+
path_bytes = 0
|
|
284
|
+
stack: list[tuple[Path, tuple[str, ...]]] = [(skill_root, ())]
|
|
285
|
+
visited_directories: set[Path] = set()
|
|
286
|
+
resolved_entry = entry_path.resolve(strict=True)
|
|
287
|
+
|
|
288
|
+
while stack:
|
|
289
|
+
current, relative_parts = stack.pop()
|
|
290
|
+
try:
|
|
291
|
+
resolved_current = current.resolve(strict=True)
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
_append_warning(warnings, f"Could not resolve related directory {current}: {exc}")
|
|
294
|
+
continue
|
|
295
|
+
if resolved_current in visited_directories:
|
|
296
|
+
continue
|
|
297
|
+
visited_directories.add(resolved_current)
|
|
298
|
+
try:
|
|
299
|
+
with os.scandir(current) as iterator:
|
|
300
|
+
entries = []
|
|
301
|
+
for entry in iterator:
|
|
302
|
+
if entry.name == ".git":
|
|
303
|
+
continue
|
|
304
|
+
if scanned_entries >= scan_limit:
|
|
305
|
+
_append_warning(
|
|
306
|
+
warnings,
|
|
307
|
+
f"Related file scan stopped after {scan_limit} entries",
|
|
308
|
+
)
|
|
309
|
+
return sorted(related_files), warnings, scanned_entries
|
|
310
|
+
scanned_entries += 1
|
|
311
|
+
entries.append(entry)
|
|
312
|
+
except OSError as exc:
|
|
313
|
+
_append_warning(warnings, f"Could not scan related files in {current}: {exc}")
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
for entry in sorted(entries, key=lambda item: item.name, reverse=True):
|
|
317
|
+
path = current / entry.name
|
|
318
|
+
logical_parts = (*relative_parts, entry.name)
|
|
319
|
+
relative = PurePosixPath(*logical_parts).as_posix()
|
|
320
|
+
try:
|
|
321
|
+
entry_stat = entry.stat(follow_symlinks=True)
|
|
322
|
+
if stat.S_ISDIR(entry_stat.st_mode):
|
|
323
|
+
stack.append((path, logical_parts))
|
|
324
|
+
continue
|
|
325
|
+
if not stat.S_ISREG(entry_stat.st_mode):
|
|
326
|
+
continue
|
|
327
|
+
resolved = path.resolve(strict=True)
|
|
328
|
+
if relative == "SKILL.md" or resolved == resolved_entry:
|
|
329
|
+
continue
|
|
330
|
+
validate_skill_file_path(relative)
|
|
331
|
+
encoded_bytes = len(relative.encode("utf-8"))
|
|
332
|
+
if len(related_files) >= related_limit:
|
|
333
|
+
_append_warning(
|
|
334
|
+
warnings,
|
|
335
|
+
f"Related file list truncated at {related_limit} files",
|
|
336
|
+
)
|
|
337
|
+
return sorted(related_files), warnings, scanned_entries
|
|
338
|
+
if path_bytes + encoded_bytes > path_limit:
|
|
339
|
+
_append_warning(
|
|
340
|
+
warnings,
|
|
341
|
+
f"Related file paths truncated at {path_limit} UTF-8 bytes",
|
|
342
|
+
)
|
|
343
|
+
return sorted(related_files), warnings, scanned_entries
|
|
344
|
+
related_files.append(relative)
|
|
345
|
+
path_bytes += encoded_bytes
|
|
346
|
+
except (OSError, ValueError) as exc:
|
|
347
|
+
_append_warning(
|
|
348
|
+
warnings,
|
|
349
|
+
f"Skipping related path {path.name}: {exc}",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
return sorted(related_files), warnings, scanned_entries
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _load_skill_record(
|
|
356
|
+
config_root: Path,
|
|
357
|
+
skills_dir: Path,
|
|
358
|
+
name: str,
|
|
359
|
+
*,
|
|
360
|
+
max_entry_bytes: int,
|
|
361
|
+
max_related_files: int,
|
|
362
|
+
max_scan_entries: int,
|
|
363
|
+
max_path_bytes: int,
|
|
364
|
+
) -> tuple[SkillRecord, str, int, list[str], int]:
|
|
365
|
+
skill_root = _resolve_skill_root(skills_dir, name)
|
|
366
|
+
entry_path = skill_root / "SKILL.md"
|
|
367
|
+
if not entry_path.exists():
|
|
368
|
+
raise ValueError(f"Skill {name} is missing SKILL.md")
|
|
369
|
+
content, content_bytes, resolved_entry = _open_regular_file(entry_path, max_entry_bytes)
|
|
370
|
+
related_files, warnings, scanned_entries = _scan_related_files(
|
|
371
|
+
skill_root,
|
|
372
|
+
resolved_entry,
|
|
373
|
+
max_related_files=max_related_files,
|
|
374
|
+
max_scan_entries=max_scan_entries,
|
|
375
|
+
max_path_bytes=max_path_bytes,
|
|
376
|
+
)
|
|
377
|
+
record = SkillRecord(
|
|
378
|
+
name=name,
|
|
379
|
+
entry_path=_relative_posix(config_root, entry_path),
|
|
380
|
+
description=_skill_description(content),
|
|
381
|
+
related_files=related_files,
|
|
382
|
+
)
|
|
383
|
+
return record, content, content_bytes, warnings, scanned_entries
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def scan_agent_skills(
|
|
387
|
+
config_dir: Path,
|
|
388
|
+
directory: str = "skills",
|
|
389
|
+
*,
|
|
390
|
+
max_skills: int = DEFAULT_MAX_SKILLS,
|
|
391
|
+
max_related_files: int = DEFAULT_MAX_RELATED_FILES,
|
|
392
|
+
max_scan_entries: int = DEFAULT_MAX_SCAN_ENTRIES,
|
|
393
|
+
max_path_bytes: int = DEFAULT_MAX_PATH_BYTES,
|
|
394
|
+
max_entry_bytes: int = DEFAULT_MAX_ENTRY_BYTES,
|
|
395
|
+
) -> SkillScanResult:
|
|
396
|
+
"""Discover bounded valid Markdown Skills and report malformed entries as warnings."""
|
|
397
|
+
try:
|
|
398
|
+
config_root, skills_dir = _resolved_skills_directory(config_dir, directory)
|
|
399
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
400
|
+
return SkillScanResult(warnings=[str(exc)])
|
|
401
|
+
if not skills_dir.exists():
|
|
402
|
+
return SkillScanResult()
|
|
403
|
+
if not skills_dir.is_dir():
|
|
404
|
+
return SkillScanResult(warnings=[f"Skills path is not a directory: {directory}"])
|
|
405
|
+
|
|
406
|
+
skill_limit = _bounded(max_skills, DEFAULT_MAX_SKILLS)
|
|
407
|
+
scan_limit = _bounded(max_scan_entries, DEFAULT_MAX_SCAN_ENTRIES)
|
|
408
|
+
warnings: list[str] = []
|
|
409
|
+
candidates: list[str] = []
|
|
410
|
+
scanned_entries = 0
|
|
411
|
+
try:
|
|
412
|
+
with os.scandir(skills_dir) as iterator:
|
|
413
|
+
for entry in iterator:
|
|
414
|
+
if scanned_entries >= scan_limit:
|
|
415
|
+
_append_warning(
|
|
416
|
+
warnings,
|
|
417
|
+
f"Skill directory scan stopped after {scan_limit} entries",
|
|
418
|
+
)
|
|
419
|
+
break
|
|
420
|
+
scanned_entries += 1
|
|
421
|
+
try:
|
|
422
|
+
entry_stat = entry.stat(follow_symlinks=True)
|
|
423
|
+
except OSError as exc:
|
|
424
|
+
_append_warning(warnings, f"Skipping skill {entry.name!r}: {exc}")
|
|
425
|
+
continue
|
|
426
|
+
if not stat.S_ISDIR(entry_stat.st_mode):
|
|
427
|
+
continue
|
|
428
|
+
candidates.append(entry.name)
|
|
429
|
+
except OSError as exc:
|
|
430
|
+
return SkillScanResult(
|
|
431
|
+
warnings=[f"Could not scan skills directory {directory}: {exc}"],
|
|
432
|
+
scanned_entries=scanned_entries,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
candidates.sort()
|
|
436
|
+
if len(candidates) > skill_limit:
|
|
437
|
+
_append_warning(
|
|
438
|
+
warnings,
|
|
439
|
+
f"Skill list truncated at {skill_limit} directories",
|
|
440
|
+
)
|
|
441
|
+
candidates = candidates[:skill_limit]
|
|
442
|
+
|
|
443
|
+
skills: dict[str, SkillRecord] = {}
|
|
444
|
+
total_scanned_entries = scanned_entries
|
|
445
|
+
remaining_scan_entries = max(0, scan_limit - scanned_entries)
|
|
446
|
+
remaining_path_bytes = max(0, int(max_path_bytes))
|
|
447
|
+
for name in candidates:
|
|
448
|
+
try:
|
|
449
|
+
validate_skill_name(name)
|
|
450
|
+
record, _, _, skill_warnings, related_scanned = _load_skill_record(
|
|
451
|
+
config_root,
|
|
452
|
+
skills_dir,
|
|
453
|
+
name,
|
|
454
|
+
max_entry_bytes=max_entry_bytes,
|
|
455
|
+
max_related_files=max_related_files,
|
|
456
|
+
max_scan_entries=remaining_scan_entries,
|
|
457
|
+
max_path_bytes=remaining_path_bytes,
|
|
458
|
+
)
|
|
459
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
460
|
+
_append_warning(warnings, f"Skipping skill {name!r}: {exc}")
|
|
461
|
+
continue
|
|
462
|
+
skills[name] = record
|
|
463
|
+
total_scanned_entries += related_scanned
|
|
464
|
+
remaining_scan_entries = max(0, remaining_scan_entries - related_scanned)
|
|
465
|
+
for warning in skill_warnings:
|
|
466
|
+
_append_warning(warnings, f"Skill {name}: {warning}")
|
|
467
|
+
remaining_path_bytes -= sum(len(path.encode("utf-8")) for path in record.related_files)
|
|
468
|
+
remaining_path_bytes = max(0, remaining_path_bytes)
|
|
469
|
+
|
|
470
|
+
return SkillScanResult(
|
|
471
|
+
skills=skills,
|
|
472
|
+
warnings=warnings,
|
|
473
|
+
scanned_entries=total_scanned_entries,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def load_agent_skill(
|
|
478
|
+
config_dir: Path,
|
|
479
|
+
name: str,
|
|
480
|
+
directory: str = "skills",
|
|
481
|
+
*,
|
|
482
|
+
max_related_files: int = DEFAULT_MAX_RELATED_FILES,
|
|
483
|
+
max_scan_entries: int = DEFAULT_MAX_SCAN_ENTRIES,
|
|
484
|
+
max_path_bytes: int = DEFAULT_MAX_PATH_BYTES,
|
|
485
|
+
max_entry_bytes: int = DEFAULT_MAX_ENTRY_BYTES,
|
|
486
|
+
) -> dict[str, Any]:
|
|
487
|
+
"""Load one Skill directly without scanning or reading every installed Skill."""
|
|
488
|
+
config_root, skills_dir = _resolved_skills_directory(config_dir, directory)
|
|
489
|
+
record, content, content_bytes, warnings, _ = _load_skill_record(
|
|
490
|
+
config_root,
|
|
491
|
+
skills_dir,
|
|
492
|
+
validate_skill_name(name),
|
|
493
|
+
max_entry_bytes=max_entry_bytes,
|
|
494
|
+
max_related_files=max_related_files,
|
|
495
|
+
max_scan_entries=max_scan_entries,
|
|
496
|
+
max_path_bytes=max_path_bytes,
|
|
497
|
+
)
|
|
498
|
+
return {
|
|
499
|
+
"name": record.name,
|
|
500
|
+
"entry_path": record.entry_path,
|
|
501
|
+
"description": record.description,
|
|
502
|
+
"content": content,
|
|
503
|
+
"bytes": content_bytes,
|
|
504
|
+
"related_files": list(record.related_files),
|
|
505
|
+
"warnings": warnings,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def read_agent_skill_file(
|
|
510
|
+
config_dir: Path,
|
|
511
|
+
name: str,
|
|
512
|
+
path: str,
|
|
513
|
+
directory: str = "skills",
|
|
514
|
+
*,
|
|
515
|
+
max_file_bytes: int = DEFAULT_MAX_ENTRY_BYTES,
|
|
516
|
+
) -> dict[str, Any]:
|
|
517
|
+
"""Read one bounded regular text file directly from inside an installed Skill."""
|
|
518
|
+
_, skills_dir = _resolved_skills_directory(config_dir, directory)
|
|
519
|
+
skill_name = validate_skill_name(name)
|
|
520
|
+
skill_root = _resolve_skill_root(skills_dir, skill_name)
|
|
521
|
+
relative_path = validate_skill_file_path(path)
|
|
522
|
+
if relative_path.as_posix() == "SKILL.md":
|
|
523
|
+
raise ValueError("Use skill_load to read SKILL.md")
|
|
524
|
+
file_path = skill_root / relative_path
|
|
525
|
+
content, content_bytes, _ = _open_regular_file(file_path, max_file_bytes)
|
|
526
|
+
return {
|
|
527
|
+
"name": skill_name,
|
|
528
|
+
"path": relative_path.as_posix(),
|
|
529
|
+
"content": content,
|
|
530
|
+
"bytes": content_bytes,
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def activate_skill(
|
|
535
|
+
config_dir: Path,
|
|
536
|
+
skill: SkillRecord,
|
|
537
|
+
*,
|
|
538
|
+
max_entry_bytes: int = DEFAULT_MAX_ENTRY_BYTES,
|
|
539
|
+
) -> dict[str, Any]:
|
|
540
|
+
"""Load a previously discovered Skill record for legacy agent-bridge callers."""
|
|
541
|
+
config_root = config_dir.resolve()
|
|
542
|
+
entry_relative = Path(skill.entry_path)
|
|
543
|
+
if not _is_relative_child_path(entry_relative):
|
|
544
|
+
raise ValueError("Skill entry path must be inside config directory")
|
|
545
|
+
entry_path = config_root / entry_relative
|
|
546
|
+
content, content_bytes, _ = _open_regular_file(entry_path, max_entry_bytes)
|
|
547
|
+
return {
|
|
548
|
+
"name": skill.name,
|
|
549
|
+
"entry_path": skill.entry_path,
|
|
550
|
+
"description": skill.description,
|
|
551
|
+
"content": content,
|
|
552
|
+
"bytes": content_bytes,
|
|
553
|
+
"related_files": list(skill.related_files),
|
|
554
|
+
}
|