deepctl-core 0.2.18__tar.gz → 0.2.19__tar.gz
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.
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/PKG-INFO +1 -1
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/pyproject.toml +1 -1
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/output.py +7 -5
- deepctl_core-0.2.19/src/deepctl_core/skill_bundle.py +405 -0
- deepctl_core-0.2.19/src/deepctl_core/skill_generator.py +1293 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core.egg-info/PKG-INFO +1 -1
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core.egg-info/SOURCES.txt +1 -0
- deepctl_core-0.2.18/src/deepctl_core/skill_generator.py +0 -1129
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/README.md +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/setup.cfg +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/__init__.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/auth.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/base_command.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/base_group_command.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/client.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/config.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/models.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/plugin_env.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/plugin_manager.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/py.typed +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core/timing.py +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core.egg-info/dependency_links.txt +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core.egg-info/requires.txt +0 -0
- {deepctl_core-0.2.18 → deepctl_core-0.2.19}/src/deepctl_core.egg-info/top_level.txt +0 -0
|
@@ -369,22 +369,24 @@ def print_error(message: str) -> None:
|
|
|
369
369
|
stderr_console.print(f"[red]✗[/red] {message}")
|
|
370
370
|
|
|
371
371
|
|
|
372
|
-
def print_warning(message: str) -> None:
|
|
373
|
-
"""Print warning
|
|
372
|
+
def print_warning(message: str, *, stderr: bool = False) -> None:
|
|
373
|
+
"""Print a warning; ``stderr=True`` keeps it off stdout outside agentic mode too."""
|
|
374
374
|
if not _output_config["quiet"]:
|
|
375
375
|
if _output_config["agentic"]:
|
|
376
376
|
stderr_console.print(f"WARN: {message}")
|
|
377
377
|
else:
|
|
378
|
-
console.print(
|
|
378
|
+
(stderr_console if stderr else console).print(
|
|
379
|
+
f"[yellow]⚠[/yellow] {message}"
|
|
380
|
+
)
|
|
379
381
|
|
|
380
382
|
|
|
381
|
-
def print_info(message: str) -> None:
|
|
383
|
+
def print_info(message: str, *, stderr: bool = False) -> None:
|
|
382
384
|
"""Print info message."""
|
|
383
385
|
if not _output_config["quiet"]:
|
|
384
386
|
if _output_config["agentic"]:
|
|
385
387
|
stderr_console.print(f"INFO: {message}")
|
|
386
388
|
else:
|
|
387
|
-
console.print(f"[blue]ℹ[/blue] {message}")
|
|
389
|
+
(stderr_console if stderr else console).print(f"[blue]ℹ[/blue] {message}")
|
|
388
390
|
|
|
389
391
|
|
|
390
392
|
def print_debug(message: str) -> None:
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Fetch the deepgram/skills bundle from an immutable pin and list its skills.
|
|
2
|
+
|
|
3
|
+
The repository is downloaded as one codeload tarball, unpacked with explicit
|
|
4
|
+
safety checks and published atomically into deepctl's cache. The skills come
|
|
5
|
+
from the upstream ``.claude-plugin/marketplace.json``, and each name is checked
|
|
6
|
+
to be one plain directory name before any path is built from it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import tarfile
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
|
|
28
|
+
SKILLS_REPO = "deepgram/skills"
|
|
29
|
+
REF_ENV_VAR = "DEEPCTL_SKILLS_REF"
|
|
30
|
+
|
|
31
|
+
# The default bundle is pinned by full commit SHA plus the sha256 of its
|
|
32
|
+
# codeload tarball, because the release tag is a lightweight, movable tag.
|
|
33
|
+
DEFAULT_SKILLS_COMMIT = "0fc13fad726fb78e17fb1f05ba5942f0d022990f"
|
|
34
|
+
DEFAULT_SKILLS_SHA256 = (
|
|
35
|
+
"5b7f975378110372c8ae3a3c712b72ba2fa43b06f2d1d93497abf87262a23980"
|
|
36
|
+
)
|
|
37
|
+
#: The release that DEFAULT_SKILLS_COMMIT is. A label for people; never fetched.
|
|
38
|
+
DEFAULT_SKILLS_RELEASE = "deepgram-skills-v1.7.0"
|
|
39
|
+
|
|
40
|
+
_MANIFEST_PATH = ".claude-plugin/marketplace.json"
|
|
41
|
+
_PLUGIN_NAME = "deepgram"
|
|
42
|
+
_SKILL_ENTRY_FILE = "SKILL.md"
|
|
43
|
+
|
|
44
|
+
_DOWNLOAD_TIMEOUT = 30.0
|
|
45
|
+
# Caps on the download and what it may unpack to. The real bundle is about
|
|
46
|
+
# 160 KB with 65 members, so anything near these is not the bundle we expect.
|
|
47
|
+
# They bound member count, names and file data, but tarfile reads pax/GNU header
|
|
48
|
+
# metadata before they run. That is accepted: the pin is hash-checked first, and
|
|
49
|
+
# a user ref's tarball comes from codeload's git archive, not crafted pax data.
|
|
50
|
+
_MAX_BUNDLE_BYTES = 64 * 1024 * 1024
|
|
51
|
+
_MAX_EXTRACTED_BYTES = 256 * 1024 * 1024
|
|
52
|
+
_MAX_MEMBERS = 10_000
|
|
53
|
+
_MAX_MEMBER_NAME_BYTES = 512
|
|
54
|
+
|
|
55
|
+
_MAX_REF_LENGTH = 100
|
|
56
|
+
# The longest valid ref, "a/" * 49 + "aa", spells to 4 + 49 * 4 + 2 = 202 bytes.
|
|
57
|
+
# 120 leaves a typical Windows home, the cache root and the bundle's deepest
|
|
58
|
+
# file (46 characters) under MAX_PATH (260). "pinned-<sha>" is 47 bytes.
|
|
59
|
+
_MAX_CACHE_NAME_BYTES = 120
|
|
60
|
+
_REF_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]*")
|
|
61
|
+
# A manifest entry must be exactly ``./skills/<name>`` or ``skills/<name>``,
|
|
62
|
+
# and ``<name>`` must be one plain directory name.
|
|
63
|
+
_ENTRY_PATTERN = re.compile(r"(?:\./)?skills/([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
64
|
+
# A path component Windows cannot store as written: a reserved device name,
|
|
65
|
+
# with or without an extension, or a name it would strip a trailing ' '/'.' from.
|
|
66
|
+
_WINDOWS_UNSAFE = re.compile(
|
|
67
|
+
r"(?i)(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|(?:COM|LPT)[0-9\u00b9\u00b2\u00b3])"
|
|
68
|
+
r"(?:\..*)?|.*[ .]"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Written into every cache directory this module publishes. A non-empty directory
|
|
72
|
+
# at a cache path without it is never moved or deleted. POSIX rename replaces an
|
|
73
|
+
# empty one, so one appearing in the rename window can go (no file data at risk).
|
|
74
|
+
_CACHE_MARKER = ".deepctl-skills-cache"
|
|
75
|
+
_STAGING_PREFIX = ".tmp-"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SkillFetchError(Exception):
|
|
79
|
+
"""The skills bundle could not be fetched, unpacked or trusted."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SkillRefNotFoundError(SkillFetchError):
|
|
83
|
+
"""The skills repository has no such ref (HTTP 404)."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SkillRefInvalidError(SkillFetchError):
|
|
87
|
+
"""The ref is not one this module will put in a URL or a directory name."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class RepoSkill:
|
|
92
|
+
"""One validated skill: a plain directory name and its folder in the cache."""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
path: Path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cache_name(ref: str) -> str:
|
|
99
|
+
"""Return the cache directory name for a validated ``ref``."""
|
|
100
|
+
# The prefixes keep every user ref out of the hash-checked pin's directory,
|
|
101
|
+
# even on a case-insensitive filesystem. '%' is not a ref character, so
|
|
102
|
+
# spelling '/' as '%2F' cannot make two refs collide.
|
|
103
|
+
if ref == DEFAULT_SKILLS_COMMIT:
|
|
104
|
+
return f"pinned-{ref}"
|
|
105
|
+
return "ref-" + ref.replace("/", "%2F")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def portable_name(name: str) -> bool:
|
|
109
|
+
"""True if ``name`` is one plain folder name safe on every OS (as entries)."""
|
|
110
|
+
match = _ENTRY_PATTERN.fullmatch(f"skills/{name}")
|
|
111
|
+
return bool(match) and not _WINDOWS_UNSAFE.fullmatch(name)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_ref(ref: str) -> str:
|
|
115
|
+
"""Return ``ref`` if it is safe in a URL path and a cache name, else raise."""
|
|
116
|
+
if len(ref) > _MAX_REF_LENGTH:
|
|
117
|
+
raise SkillRefInvalidError(
|
|
118
|
+
f"The skills ref is {len(ref)} characters, more than the "
|
|
119
|
+
f"{_MAX_REF_LENGTH} allowed."
|
|
120
|
+
)
|
|
121
|
+
if (
|
|
122
|
+
not _REF_PATTERN.fullmatch(ref)
|
|
123
|
+
or ".." in ref
|
|
124
|
+
or "//" in ref
|
|
125
|
+
or "/." in ref
|
|
126
|
+
or ref.endswith(("/", "."))
|
|
127
|
+
):
|
|
128
|
+
raise SkillRefInvalidError(
|
|
129
|
+
f"The skills ref {ref!r} must start with a letter or digit, use only "
|
|
130
|
+
"letters, digits and '._/-', and not contain '..', empty segments or "
|
|
131
|
+
"segments starting with '.', or end in '.' or '/'."
|
|
132
|
+
)
|
|
133
|
+
if len(_cache_name(ref).encode("utf-8")) > _MAX_CACHE_NAME_BYTES:
|
|
134
|
+
raise SkillRefInvalidError(
|
|
135
|
+
f"The skills ref {ref!r} would need a cache directory name longer "
|
|
136
|
+
f"than {_MAX_CACHE_NAME_BYTES} bytes."
|
|
137
|
+
)
|
|
138
|
+
return ref
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def resolve_skills_ref(ref: str | None = None) -> str:
|
|
142
|
+
"""Pick the ref: ``ref``, then a non-blank ``DEEPCTL_SKILLS_REF``, then the pin.
|
|
143
|
+
|
|
144
|
+
Raises :class:`SkillRefInvalidError` if the chosen ref fails validation.
|
|
145
|
+
"""
|
|
146
|
+
if ref is not None:
|
|
147
|
+
return validate_ref(ref)
|
|
148
|
+
from_env = os.environ.get(REF_ENV_VAR, "").strip()
|
|
149
|
+
if from_env:
|
|
150
|
+
return validate_ref(from_env)
|
|
151
|
+
return DEFAULT_SKILLS_COMMIT
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def bundle_url(ref: str) -> str:
|
|
155
|
+
"""Return the codeload tarball URL for a validated ``ref``."""
|
|
156
|
+
return f"https://codeload.github.com/{SKILLS_REPO}/tar.gz/{ref}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fetch_skill_bundle(
|
|
160
|
+
ref: str | None = None,
|
|
161
|
+
*,
|
|
162
|
+
cache_dir: Path | None = None,
|
|
163
|
+
force: bool = False,
|
|
164
|
+
download: Callable[[str], bytes] | None = None,
|
|
165
|
+
) -> list[RepoSkill]:
|
|
166
|
+
"""Fetch the skills bundle into the cache and return its skills.
|
|
167
|
+
|
|
168
|
+
A user ref (argument or ``DEEPCTL_SKILLS_REF``) has no known hash, so only
|
|
169
|
+
the pinned commit is checked against :data:`DEFAULT_SKILLS_SHA256`.
|
|
170
|
+
|
|
171
|
+
Only the pinned commit, whose content cannot change, is served from the
|
|
172
|
+
cache. Its directory is published only after the hash check and full
|
|
173
|
+
validation pass, and is validated again on every hit. Other refs may move,
|
|
174
|
+
so they are always downloaded. ``force`` skips the cache hit. ``cache_dir``
|
|
175
|
+
must be a directory deepctl owns. ``download`` returns a URL's bytes. The
|
|
176
|
+
returned paths are valid until the same ref is fetched again.
|
|
177
|
+
|
|
178
|
+
Raises :class:`SkillRefInvalidError`, :class:`SkillRefNotFoundError` or,
|
|
179
|
+
for any other failure, :class:`SkillFetchError`.
|
|
180
|
+
"""
|
|
181
|
+
resolved = resolve_skills_ref(ref)
|
|
182
|
+
pinned = resolved == DEFAULT_SKILLS_COMMIT
|
|
183
|
+
root = cache_dir or Path.home() / ".deepctl" / "skills" / "repo_cache"
|
|
184
|
+
target = root / _cache_name(resolved)
|
|
185
|
+
|
|
186
|
+
if pinned and not force and _is_our_cache(target):
|
|
187
|
+
try:
|
|
188
|
+
return read_manifest_skills(target)
|
|
189
|
+
except SkillFetchError:
|
|
190
|
+
pass # Incomplete or damaged: download and replace it below.
|
|
191
|
+
|
|
192
|
+
data = (download or _download)(bundle_url(resolved))
|
|
193
|
+
if pinned and hashlib.sha256(data).hexdigest() != DEFAULT_SKILLS_SHA256:
|
|
194
|
+
raise SkillFetchError(
|
|
195
|
+
f"The downloaded {DEFAULT_SKILLS_RELEASE} bundle does not match its "
|
|
196
|
+
"pinned sha256, so it was not used."
|
|
197
|
+
)
|
|
198
|
+
return _publish(data, target)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def read_manifest_skills(root: Path) -> list[RepoSkill]:
|
|
202
|
+
"""Return the skills ``root``'s manifest lists for the deepgram plugin.
|
|
203
|
+
|
|
204
|
+
Every entry is validated before any path is built from it. Other plugins
|
|
205
|
+
point at other repositories and are ignored. Raises
|
|
206
|
+
:class:`SkillFetchError` for a bad manifest, entry or skill folder.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
raw = json.loads((root / _MANIFEST_PATH).read_text(encoding="utf-8"))
|
|
210
|
+
except (OSError, ValueError) as exc:
|
|
211
|
+
raise SkillFetchError(f"Could not read the skills manifest: {exc}.")
|
|
212
|
+
|
|
213
|
+
plugins = raw.get("plugins") if isinstance(raw, dict) else None
|
|
214
|
+
if not isinstance(plugins, list):
|
|
215
|
+
plugins = []
|
|
216
|
+
matches = [
|
|
217
|
+
p for p in plugins if isinstance(p, dict) and p.get("name") == _PLUGIN_NAME
|
|
218
|
+
]
|
|
219
|
+
entries = matches[0].get("skills") if len(matches) == 1 else None
|
|
220
|
+
if not isinstance(entries, list) or not entries:
|
|
221
|
+
raise SkillFetchError(
|
|
222
|
+
f"The skills manifest has no single {_PLUGIN_NAME!r} plugin listing skills."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
names: list[str] = []
|
|
226
|
+
seen: set[str] = set()
|
|
227
|
+
for entry in entries:
|
|
228
|
+
match = _ENTRY_PATTERN.fullmatch(entry) if isinstance(entry, str) else None
|
|
229
|
+
# The pattern already refuses '/', '\\', ':', '.' and '..'.
|
|
230
|
+
if match is None or _WINDOWS_UNSAFE.fullmatch(match.group(1)):
|
|
231
|
+
raise SkillFetchError(
|
|
232
|
+
f"The skills manifest entry {entry!r} is not ./skills/<portable name>."
|
|
233
|
+
)
|
|
234
|
+
name = match.group(1)
|
|
235
|
+
if name.casefold() in seen:
|
|
236
|
+
raise SkillFetchError(f"The skills manifest lists {name!r} twice.")
|
|
237
|
+
seen.add(name.casefold())
|
|
238
|
+
names.append(name)
|
|
239
|
+
|
|
240
|
+
skills = [RepoSkill(name, root / "skills" / name) for name in names]
|
|
241
|
+
for skill in skills:
|
|
242
|
+
try:
|
|
243
|
+
found = (skill.path / _SKILL_ENTRY_FILE).is_file()
|
|
244
|
+
except OSError: # For example, a name too long for the filesystem.
|
|
245
|
+
found = False
|
|
246
|
+
if not found:
|
|
247
|
+
raise SkillFetchError(
|
|
248
|
+
f"The skill {skill.name!r} has no {_SKILL_ENTRY_FILE} in the bundle."
|
|
249
|
+
)
|
|
250
|
+
return skills
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _download(url: str, *, transport: httpx.BaseTransport | None = None) -> bytes:
|
|
254
|
+
"""Return the bytes at ``url``, refusing more than the bundle size cap."""
|
|
255
|
+
try:
|
|
256
|
+
with (
|
|
257
|
+
httpx.Client(
|
|
258
|
+
transport=transport, timeout=_DOWNLOAD_TIMEOUT, follow_redirects=True
|
|
259
|
+
) as client,
|
|
260
|
+
client.stream("GET", url) as resp,
|
|
261
|
+
):
|
|
262
|
+
if resp.status_code == 404:
|
|
263
|
+
raise SkillRefNotFoundError(f"No skills bundle was found at {url}.")
|
|
264
|
+
if resp.status_code != 200:
|
|
265
|
+
raise SkillFetchError(
|
|
266
|
+
f"Downloading {url} failed with HTTP {resp.status_code}."
|
|
267
|
+
)
|
|
268
|
+
data = bytearray()
|
|
269
|
+
for chunk in resp.iter_bytes():
|
|
270
|
+
data += chunk
|
|
271
|
+
if len(data) > _MAX_BUNDLE_BYTES:
|
|
272
|
+
raise SkillFetchError(
|
|
273
|
+
f"The skills bundle is larger than {_MAX_BUNDLE_BYTES} bytes."
|
|
274
|
+
)
|
|
275
|
+
return bytes(data)
|
|
276
|
+
except httpx.HTTPError as exc:
|
|
277
|
+
raise SkillFetchError(f"Could not download {url}: {str(exc).rstrip('.')}.")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _is_our_cache(path: Path) -> bool:
|
|
281
|
+
"""True if ``path`` is a real directory that this module published."""
|
|
282
|
+
try:
|
|
283
|
+
return not path.is_symlink() and (path / _CACHE_MARKER).is_file()
|
|
284
|
+
except OSError:
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _safe_members(tar: tarfile.TarFile) -> list[tuple[tarfile.TarInfo, str]]:
|
|
289
|
+
"""Check every member, then return each with its top-level dir stripped.
|
|
290
|
+
|
|
291
|
+
Nothing is written until every member has passed.
|
|
292
|
+
"""
|
|
293
|
+
checked: list[tuple[tarfile.TarInfo, str]] = []
|
|
294
|
+
tops: set[str] = set()
|
|
295
|
+
total = 0
|
|
296
|
+
for member in tar:
|
|
297
|
+
name = member.name
|
|
298
|
+
if len(checked) >= _MAX_MEMBERS:
|
|
299
|
+
raise SkillFetchError(f"The bundle has more than {_MAX_MEMBERS} members.")
|
|
300
|
+
if len(name.encode("utf-8", "surrogateescape")) > _MAX_MEMBER_NAME_BYTES:
|
|
301
|
+
raise SkillFetchError("The bundle has a member name that is too long.")
|
|
302
|
+
if not (member.isreg() or member.isdir()):
|
|
303
|
+
# Symlinks, hardlinks, devices and fifos never belong in a bundle.
|
|
304
|
+
raise SkillFetchError(
|
|
305
|
+
f"The bundle member {name!r} is not a regular file or directory."
|
|
306
|
+
)
|
|
307
|
+
parts = [p for p in name.split("/") if p not in ("", ".")]
|
|
308
|
+
# '\\' and ':' cover Windows separators, drives (C:\x, C:x) and streams.
|
|
309
|
+
windows = "\\" in name or ":" in name
|
|
310
|
+
windows = windows or any(_WINDOWS_UNSAFE.fullmatch(p) for p in parts)
|
|
311
|
+
if not parts or ".." in parts or name.startswith("/") or windows:
|
|
312
|
+
raise SkillFetchError(f"The bundle member {name!r} is not a safe path.")
|
|
313
|
+
total += member.size if member.isreg() else 0
|
|
314
|
+
if total > _MAX_EXTRACTED_BYTES:
|
|
315
|
+
raise SkillFetchError(
|
|
316
|
+
f"The bundle unpacks to more than {_MAX_EXTRACTED_BYTES} bytes."
|
|
317
|
+
)
|
|
318
|
+
tops.add(parts[0])
|
|
319
|
+
checked.append((member, "/".join(parts[1:])))
|
|
320
|
+
if len(tops) != 1:
|
|
321
|
+
raise SkillFetchError("The bundle does not have a single top-level directory.")
|
|
322
|
+
return checked
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _extract(data: bytes, dest: Path) -> None:
|
|
326
|
+
"""Unpack the tarball ``data`` into the new directory ``dest``."""
|
|
327
|
+
try:
|
|
328
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
|
|
329
|
+
members = _safe_members(tar)
|
|
330
|
+
dest.mkdir()
|
|
331
|
+
for member, rel in members:
|
|
332
|
+
path = dest.joinpath(*rel.split("/")) if rel else dest
|
|
333
|
+
if member.isdir():
|
|
334
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
continue
|
|
336
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
src = tar.extractfile(member)
|
|
338
|
+
if src is None:
|
|
339
|
+
raise SkillFetchError(f"Could not read {member.name!r}.")
|
|
340
|
+
# 'xb' refuses to overwrite, so a duplicate member (or a
|
|
341
|
+
# case-insensitive clash) fails instead of replacing a file.
|
|
342
|
+
with src, path.open("xb") as out:
|
|
343
|
+
shutil.copyfileobj(src, out)
|
|
344
|
+
except (tarfile.TarError, OSError, EOFError) as exc:
|
|
345
|
+
raise SkillFetchError(f"Could not unpack the skills bundle: {exc}.")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _publish(data: bytes, target: Path) -> list[RepoSkill]:
|
|
349
|
+
"""Unpack, validate and atomically put the bundle at ``target``.
|
|
350
|
+
|
|
351
|
+
Work happens in a fresh ``mkdtemp`` directory beside ``target``, so every
|
|
352
|
+
rename stays on one filesystem. A previous cache is renamed into staging,
|
|
353
|
+
the new tree is renamed into place, and if that fails a directory is put
|
|
354
|
+
back; anything else is kept in staging, so nothing is lost or overwritten.
|
|
355
|
+
If another process publishes the same ref first, its copy is used.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
staging = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX, dir=target.parent))
|
|
360
|
+
except OSError as exc:
|
|
361
|
+
raise SkillFetchError(f"Could not prepare the skills cache: {exc}.")
|
|
362
|
+
new, previous, swapping = staging / "new", staging / "previous", False
|
|
363
|
+
try:
|
|
364
|
+
_extract(data, new)
|
|
365
|
+
skills = read_manifest_skills(new)
|
|
366
|
+
try:
|
|
367
|
+
(new / _CACHE_MARKER).write_text(target.name, encoding="utf-8")
|
|
368
|
+
if target.is_symlink() or target.exists():
|
|
369
|
+
if not _is_our_cache(target):
|
|
370
|
+
raise SkillFetchError(
|
|
371
|
+
f"{target} was not created by deepctl, so it was left alone"
|
|
372
|
+
)
|
|
373
|
+
os.replace(target, previous)
|
|
374
|
+
if not _is_our_cache(previous): # Swapped in after the check.
|
|
375
|
+
raise SkillFetchError(
|
|
376
|
+
f"{target} changed while publishing, so it was not replaced"
|
|
377
|
+
)
|
|
378
|
+
swapping = True
|
|
379
|
+
os.replace(new, target) # Can replace an empty dir; see _CACHE_MARKER.
|
|
380
|
+
except BaseException as exc: # Ctrl-C too: never rmtree the only copy.
|
|
381
|
+
if swapping and isinstance(exc, OSError) and _is_our_cache(target):
|
|
382
|
+
# Another process published this ref after ours moved aside.
|
|
383
|
+
return read_manifest_skills(target)
|
|
384
|
+
try: # Put back only a real dir: renaming a file or link clobbers.
|
|
385
|
+
if os.path.isdir(previous) and not os.path.islink(previous):
|
|
386
|
+
os.replace(previous, target) # Same empty-dir caveat.
|
|
387
|
+
except OSError:
|
|
388
|
+
pass # The finally sees that staging holds the only copy.
|
|
389
|
+
mine = _is_our_cache(target) and _is_our_cache(previous)
|
|
390
|
+
kept = os.path.lexists(previous) and not mine
|
|
391
|
+
where = f"; what was there is kept in {previous}" if kept else ""
|
|
392
|
+
if not isinstance(exc, (OSError, SkillFetchError)):
|
|
393
|
+
raise
|
|
394
|
+
raise SkillFetchError(
|
|
395
|
+
f"Could not publish the skills bundle to {target}: {exc}{where}."
|
|
396
|
+
)
|
|
397
|
+
return [RepoSkill(s.name, target / "skills" / s.name) for s in skills]
|
|
398
|
+
finally:
|
|
399
|
+
# Ownership: mkdtemp made ``staging`` in this call. Delete it only if
|
|
400
|
+
# ``previous`` is absent, or both it and ``target`` are proven ours, so
|
|
401
|
+
# a sole or foreign copy is kept. State, not a flag, survives a Ctrl-C.
|
|
402
|
+
if not os.path.lexists(previous) or (
|
|
403
|
+
_is_our_cache(target) and _is_our_cache(previous)
|
|
404
|
+
):
|
|
405
|
+
shutil.rmtree(staging, ignore_errors=True)
|