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.
Files changed (52) hide show
  1. local_shell_mcp/__init__.py +3 -0
  2. local_shell_mcp/agent_bridge/__init__.py +1 -0
  3. local_shell_mcp/agent_bridge/models.py +29 -0
  4. local_shell_mcp/agent_bridge/skills.py +554 -0
  5. local_shell_mcp/audit.py +849 -0
  6. local_shell_mcp/auth.py +495 -0
  7. local_shell_mcp/conpty_ops.py +350 -0
  8. local_shell_mcp/deprecated_tools.py +128 -0
  9. local_shell_mcp/downloads.py +515 -0
  10. local_shell_mcp/errors.py +63 -0
  11. local_shell_mcp/fs_ops.py +674 -0
  12. local_shell_mcp/helpers/README.md +7 -0
  13. local_shell_mcp/helpers/tmux.LICENSE +18 -0
  14. local_shell_mcp/http_app.py +401 -0
  15. local_shell_mcp/human_ui.py +2049 -0
  16. local_shell_mcp/image_ops.py +136 -0
  17. local_shell_mcp/jobs.py +1570 -0
  18. local_shell_mcp/main.py +205 -0
  19. local_shell_mcp/models.py +118 -0
  20. local_shell_mcp/oauth.py +771 -0
  21. local_shell_mcp/oauth_authorize.html +261 -0
  22. local_shell_mcp/patch_ops.py +298 -0
  23. local_shell_mcp/playwright_ops.py +128 -0
  24. local_shell_mcp/remote.py +2153 -0
  25. local_shell_mcp/remote_transfer.py +607 -0
  26. local_shell_mcp/remote_worker.py +8 -0
  27. local_shell_mcp/remote_worker_cli.py +293 -0
  28. local_shell_mcp/remote_worker_installer.py +111 -0
  29. local_shell_mcp/remote_worker_routes.py +379 -0
  30. local_shell_mcp/remote_worker_service.py +816 -0
  31. local_shell_mcp/remote_worker_state.py +224 -0
  32. local_shell_mcp/search_ops.py +167 -0
  33. local_shell_mcp/settings.py +736 -0
  34. local_shell_mcp/shell_environment.py +72 -0
  35. local_shell_mcp/shell_ops.py +1028 -0
  36. local_shell_mcp/skill_ops.py +198 -0
  37. local_shell_mcp/tmux_helper.py +106 -0
  38. local_shell_mcp/todo_ops.py +75 -0
  39. local_shell_mcp/tools.py +2169 -0
  40. local_shell_mcp/transfer_ops.py +559 -0
  41. local_shell_mcp/tui_runtime.py +60 -0
  42. local_shell_mcp/ui_runtime/local-shell-mcp-tui.exe.gz +0 -0
  43. local_shell_mcp/ui_security.py +107 -0
  44. local_shell_mcp/ui_static/index.html +150 -0
  45. local_shell_mcp/ui_static/web.css +5 -0
  46. local_shell_mcp/ui_static/web.js +88 -0
  47. local_shell_mcp/version.py +32 -0
  48. local_shell_mcp-3.2.0.dist-info/METADATA +292 -0
  49. local_shell_mcp-3.2.0.dist-info/RECORD +52 -0
  50. local_shell_mcp-3.2.0.dist-info/WHEEL +4 -0
  51. local_shell_mcp-3.2.0.dist-info/entry_points.txt +2 -0
  52. local_shell_mcp-3.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """local-shell-mcp."""
2
+
3
+ __version__ = "3.2.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
+ }