dataspring-cli 0.3.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.
- cli/__init__.py +15 -0
- cli/_skills/dataspring-author/SKILL.md +401 -0
- cli/_skills/dataspring-consume/SKILL.md +712 -0
- cli/_skills/dataspring-correct/SKILL.md +124 -0
- cli/auth.py +375 -0
- cli/bundled_manifest.py +36 -0
- cli/contract.py +1138 -0
- cli/generated.py +1297 -0
- cli/main.py +3232 -0
- cli/output.py +266 -0
- cli/runtime.py +201 -0
- cli/skills_commands.py +247 -0
- cli/skilltree.py +350 -0
- cli/upgrade.py +66 -0
- cli/version.py +123 -0
- dataspring_cli-0.3.0.dist-info/METADATA +202 -0
- dataspring_cli-0.3.0.dist-info/RECORD +20 -0
- dataspring_cli-0.3.0.dist-info/WHEEL +4 -0
- dataspring_cli-0.3.0.dist-info/entry_points.txt +2 -0
- settings.py +78 -0
cli/skills_commands.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""``dataspring skills``: list, get, install and diff the published skills.
|
|
2
|
+
|
|
3
|
+
Mirrors ``tk skills`` (the ticks CLI). The skills are fetched from the server
|
|
4
|
+
the CLI is configured for (``GET /api/skills``, ``GET /api/skills/{name}``),
|
|
5
|
+
so what an agent installs is what that deployment serves: the parameter
|
|
6
|
+
tables in a skill are generated from the server's own dispatch registry.
|
|
7
|
+
``--offline`` reads the copy bundled in the wheel instead (the same trees,
|
|
8
|
+
as of the CLI's build).
|
|
9
|
+
|
|
10
|
+
``install`` writes a skill tree into the agent's skills directory
|
|
11
|
+
(``~/.claude/skills/<name>/`` by default) with a ``.dataspring-skill`` stamp
|
|
12
|
+
recording the version and where it came from. It refuses a non-empty
|
|
13
|
+
directory without a stamp unless ``--force`` (it may be hand-edited or
|
|
14
|
+
belong to something else) and replaces a stamped one in place, which is the
|
|
15
|
+
upgrade path. ``diff`` compares an installed directory with the served tree.
|
|
16
|
+
|
|
17
|
+
Exit codes follow tk's: 0 ok, 1 drift or a failed target, 2 usage (an
|
|
18
|
+
unmanaged directory without --force), 4 no such skill.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Annotated, Optional
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
import typer
|
|
29
|
+
|
|
30
|
+
from cli import skilltree
|
|
31
|
+
from cli.output import console, print_error, print_warning
|
|
32
|
+
|
|
33
|
+
EXIT_GENERIC = 1
|
|
34
|
+
EXIT_USAGE = 2
|
|
35
|
+
EXIT_NOT_FOUND = 4
|
|
36
|
+
|
|
37
|
+
#: Tests set ``transport`` here to answer with a fake server.
|
|
38
|
+
CLIENT_OPTIONS: dict = {}
|
|
39
|
+
|
|
40
|
+
skills_app = typer.Typer(
|
|
41
|
+
help="Published skills: list, get, install into the agent's skills directory, diff",
|
|
42
|
+
no_args_is_help=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_skills_dir() -> Path:
|
|
47
|
+
return Path.home() / ".claude" / "skills"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Where the trees come from
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _api_base() -> str:
|
|
56
|
+
from settings import get_settings
|
|
57
|
+
|
|
58
|
+
return get_settings().mcp_server_base_url.removesuffix("/api")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_json(path: str) -> dict:
|
|
62
|
+
url = f"{_api_base()}{path}"
|
|
63
|
+
with httpx.Client(timeout=30.0, **CLIENT_OPTIONS) as client:
|
|
64
|
+
response = client.get(url)
|
|
65
|
+
if response.status_code == 404:
|
|
66
|
+
detail = response.json().get("detail", "not found") if response.headers.get("content-type", "").startswith(
|
|
67
|
+
"application/json"
|
|
68
|
+
) else response.text
|
|
69
|
+
raise skilltree.SkillNotFound(detail)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
return response.json()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SkillSource:
|
|
75
|
+
"""The server or the bundle: the same two questions either way."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, offline: bool):
|
|
78
|
+
self.offline = offline
|
|
79
|
+
self.label = "bundled" if offline else _api_base()
|
|
80
|
+
|
|
81
|
+
def index(self) -> list[dict]:
|
|
82
|
+
if self.offline:
|
|
83
|
+
return [t.summary() for t in skilltree.bundled_library()]
|
|
84
|
+
return _get_json("/api/skills")["skills"]
|
|
85
|
+
|
|
86
|
+
def tree(self, name: str) -> skilltree.SkillTree:
|
|
87
|
+
if self.offline:
|
|
88
|
+
return skilltree.find_tree(name, skilltree.bundled_library())
|
|
89
|
+
return skilltree.SkillTree.from_dict(_get_json(f"/api/skills/{name}"))
|
|
90
|
+
|
|
91
|
+
def names(self) -> list[str]:
|
|
92
|
+
return [s["name"] for s in self.index()]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _source(offline: bool) -> SkillSource:
|
|
96
|
+
src = SkillSource(offline)
|
|
97
|
+
if offline and skilltree.bundled_root() is None:
|
|
98
|
+
print_error("This install carries no bundled skills; drop --offline to fetch them from the server")
|
|
99
|
+
raise typer.Exit(EXIT_GENERIC)
|
|
100
|
+
return src
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _fail_network(exc: Exception) -> None:
|
|
104
|
+
print_error(f"Could not reach the DataSpring server for skills: {exc}", hint="Try --offline for the bundled copy")
|
|
105
|
+
raise typer.Exit(EXIT_GENERIC)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Commands
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
OfflineOpt = Annotated[bool, typer.Option("--offline", help="Use the skills bundled in this CLI, not the server's")]
|
|
114
|
+
DirOpt = Annotated[
|
|
115
|
+
Optional[Path],
|
|
116
|
+
typer.Option("--dir", help="The skills directory (each skill goes in <dir>/<name>/); default ~/.claude/skills"),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@skills_app.command("list")
|
|
121
|
+
def skills_list(offline: OfflineOpt = False, as_json: Annotated[bool, typer.Option("--json")] = False):
|
|
122
|
+
"""List the skills the server (or the bundle) serves, with their versions."""
|
|
123
|
+
src = _source(offline)
|
|
124
|
+
try:
|
|
125
|
+
rows = src.index()
|
|
126
|
+
except httpx.HTTPError as e:
|
|
127
|
+
_fail_network(e)
|
|
128
|
+
if as_json:
|
|
129
|
+
typer.echo(json.dumps({"source": src.label, "skills": rows}, indent=2))
|
|
130
|
+
return
|
|
131
|
+
for row in rows:
|
|
132
|
+
typer.echo(f"{row['name']}\t{row['version']}\t{row['description']}")
|
|
133
|
+
if not rows:
|
|
134
|
+
print_warning("No skills served")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@skills_app.command("get")
|
|
138
|
+
def skills_get(
|
|
139
|
+
name: Annotated[str, typer.Argument(help="Skill name (dataspring-consume, or just consume)")],
|
|
140
|
+
offline: OfflineOpt = False,
|
|
141
|
+
full: Annotated[bool, typer.Option("--full", help="Print every file in the tree, not only SKILL.md")] = False,
|
|
142
|
+
):
|
|
143
|
+
"""Print a skill's SKILL.md (or the whole tree with --full)."""
|
|
144
|
+
src = _source(offline)
|
|
145
|
+
tree = _tree_or_exit(src, name)
|
|
146
|
+
files = list(tree.files) if full else [f for f in tree.files if f.path == "SKILL.md"]
|
|
147
|
+
for i, f in enumerate(files):
|
|
148
|
+
if i:
|
|
149
|
+
typer.echo(f"\n--- {f.path} ---\n")
|
|
150
|
+
typer.echo(f.content, nl=not f.content.endswith("\n"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _tree_or_exit(src: SkillSource, name: str) -> skilltree.SkillTree:
|
|
154
|
+
try:
|
|
155
|
+
return src.tree(name)
|
|
156
|
+
except skilltree.SkillNotFound as e:
|
|
157
|
+
print_error(str(e))
|
|
158
|
+
raise typer.Exit(EXIT_NOT_FOUND)
|
|
159
|
+
except httpx.HTTPError as e:
|
|
160
|
+
_fail_network(e)
|
|
161
|
+
raise AssertionError("unreachable")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _selected(src: SkillSource, name: str | None, all_: bool) -> list[str]:
|
|
165
|
+
if bool(name) == all_:
|
|
166
|
+
print_error("Name one skill, or pass --all")
|
|
167
|
+
raise typer.Exit(EXIT_USAGE)
|
|
168
|
+
if all_:
|
|
169
|
+
try:
|
|
170
|
+
return src.names()
|
|
171
|
+
except httpx.HTTPError as e:
|
|
172
|
+
_fail_network(e)
|
|
173
|
+
return [name] # type: ignore[list-item]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@skills_app.command("install")
|
|
177
|
+
def skills_install(
|
|
178
|
+
name: Annotated[Optional[str], typer.Argument(help="Skill name; or --all")] = None,
|
|
179
|
+
all_: Annotated[bool, typer.Option("--all", help="Install every served skill")] = False,
|
|
180
|
+
dir: DirOpt = None,
|
|
181
|
+
force: Annotated[bool, typer.Option("--force", help="Replace a directory that has no DataSpring stamp")] = False,
|
|
182
|
+
offline: OfflineOpt = False,
|
|
183
|
+
):
|
|
184
|
+
"""Install a skill tree into the agent's skills directory, stamped.
|
|
185
|
+
|
|
186
|
+
Re-running over a stamped directory upgrades it in place.
|
|
187
|
+
"""
|
|
188
|
+
src = _source(offline)
|
|
189
|
+
root = dir or default_skills_dir()
|
|
190
|
+
failed = 0
|
|
191
|
+
code = 0
|
|
192
|
+
for skill_name in _selected(src, name, all_):
|
|
193
|
+
tree = _tree_or_exit(src, skill_name)
|
|
194
|
+
target = root / tree.name
|
|
195
|
+
try:
|
|
196
|
+
stamp = skilltree.install_tree(tree, target, source=src.label, force=force)
|
|
197
|
+
except skilltree.UnmanagedDirectory as e:
|
|
198
|
+
failed += 1
|
|
199
|
+
code = code or EXIT_USAGE
|
|
200
|
+
typer.echo(f"refused {target}: {e} (pass --force to replace it)")
|
|
201
|
+
continue
|
|
202
|
+
except OSError as e:
|
|
203
|
+
failed += 1
|
|
204
|
+
code = EXIT_GENERIC
|
|
205
|
+
typer.echo(f"failed {target}: {e}")
|
|
206
|
+
continue
|
|
207
|
+
typer.echo(f"installed {tree.name} {stamp.version} to {target}")
|
|
208
|
+
if failed:
|
|
209
|
+
print_error(f"{failed} skill install target(s) failed")
|
|
210
|
+
raise typer.Exit(code)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@skills_app.command("diff")
|
|
214
|
+
def skills_diff(
|
|
215
|
+
name: Annotated[Optional[str], typer.Argument(help="Skill name; default: every installed skill")] = None,
|
|
216
|
+
dir: DirOpt = None,
|
|
217
|
+
offline: OfflineOpt = False,
|
|
218
|
+
):
|
|
219
|
+
"""Show drift between the installed skills and the served ones.
|
|
220
|
+
|
|
221
|
+
Exit 1 on any drift: a changed, added or removed file, or a stamp whose
|
|
222
|
+
version is not the served one.
|
|
223
|
+
"""
|
|
224
|
+
src = _source(offline)
|
|
225
|
+
root = dir or default_skills_dir()
|
|
226
|
+
if name:
|
|
227
|
+
trees = [_tree_or_exit(src, name)]
|
|
228
|
+
else:
|
|
229
|
+
installed = sorted(p.name for p in root.iterdir() if skilltree.read_stamp(p)) if root.is_dir() else []
|
|
230
|
+
if not installed:
|
|
231
|
+
print_warning(f"No DataSpring-stamped skills under {root}")
|
|
232
|
+
raise typer.Exit(EXIT_GENERIC)
|
|
233
|
+
trees = [_tree_or_exit(src, n) for n in installed]
|
|
234
|
+
drift = False
|
|
235
|
+
for tree in trees:
|
|
236
|
+
target = root / tree.name
|
|
237
|
+
try:
|
|
238
|
+
diff = skilltree.diff_tree(tree, target)
|
|
239
|
+
except FileNotFoundError:
|
|
240
|
+
typer.echo(f"{tree.name}: not installed at {target}")
|
|
241
|
+
drift = True
|
|
242
|
+
continue
|
|
243
|
+
typer.echo(diff.render())
|
|
244
|
+
drift = drift or not diff.ok()
|
|
245
|
+
if drift:
|
|
246
|
+
console.print("[yellow]drift detected; `dataspring skills install --all` brings the copies up to date[/]")
|
|
247
|
+
raise typer.Exit(EXIT_GENERIC)
|
cli/skilltree.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""A skill as a tree of files, the same on every surface.
|
|
2
|
+
|
|
3
|
+
The one description of what a skill *is* once it leaves ``backend/skills/``:
|
|
4
|
+
its name and description (the SKILL.md frontmatter), every file under its
|
|
5
|
+
directory, and a ``version`` that is a hash of that content. The server's
|
|
6
|
+
``GET /api/skills[/{name}]`` and the ``dataspring://skills/{name}`` resource
|
|
7
|
+
serialize this shape; ``dataspring skills install`` writes it to disk with a
|
|
8
|
+
stamp, ``dataspring skills diff`` compares the two. Because both ends hash
|
|
9
|
+
the same bytes the same way, an installed tree and a served one agree on
|
|
10
|
+
their version without either trusting the other's clock.
|
|
11
|
+
|
|
12
|
+
This module ships in the CLI wheel, so it imports nothing from the server
|
|
13
|
+
(tests/test_cli_thin_client.py); the server imports it, the way the D19
|
|
14
|
+
note describes the shared contract package: code that is only data.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import tempfile
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
#: The marker ``skills install`` writes at the root of every tree it installs.
|
|
31
|
+
#: Its presence is what makes a directory DataSpring-managed: install refuses
|
|
32
|
+
#: to replace a non-empty directory without it unless forced.
|
|
33
|
+
STAMP_FILE = ".dataspring-skill"
|
|
34
|
+
|
|
35
|
+
#: Vendor prefix of every published skill (``dataspring-consume``).
|
|
36
|
+
NAME_PREFIX = "dataspring-"
|
|
37
|
+
|
|
38
|
+
#: The bundled copy the wheel ships (written by ``backend/hatch_build.py`` at
|
|
39
|
+
#: build time from ``backend/skills/``), relative to this package.
|
|
40
|
+
_BUNDLED_SUBDIR = "_skills"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class SkillFile:
|
|
45
|
+
path: str # slash-separated, relative to the skill root: "SKILL.md"
|
|
46
|
+
content: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class SkillTree:
|
|
51
|
+
name: str
|
|
52
|
+
description: str
|
|
53
|
+
version: str
|
|
54
|
+
files: tuple[SkillFile, ...]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def skill_md(self) -> str:
|
|
58
|
+
return next(f.content for f in self.files if f.path == "SKILL.md")
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"name": self.name,
|
|
63
|
+
"description": self.description,
|
|
64
|
+
"version": self.version,
|
|
65
|
+
"files": [{"path": f.path, "content": f.content} for f in self.files],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def summary(self) -> dict:
|
|
69
|
+
"""The index row: everything but the file contents."""
|
|
70
|
+
return {
|
|
71
|
+
"name": self.name,
|
|
72
|
+
"description": self.description,
|
|
73
|
+
"version": self.version,
|
|
74
|
+
"files": [f.path for f in self.files],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict) -> "SkillTree":
|
|
79
|
+
files = tuple(
|
|
80
|
+
sorted(
|
|
81
|
+
(SkillFile(path=f["path"], content=f["content"]) for f in data["files"]),
|
|
82
|
+
key=lambda f: f.path,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return cls(
|
|
86
|
+
name=data["name"],
|
|
87
|
+
description=data.get("description", ""),
|
|
88
|
+
version=data.get("version") or tree_version(files),
|
|
89
|
+
files=files,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SkillNotFound(LookupError):
|
|
94
|
+
"""No skill of that name in the library at hand."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class UnmanagedDirectory(Exception):
|
|
98
|
+
"""The install target exists, is not empty, and carries no stamp."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Reading a library from disk
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_frontmatter(text: str) -> tuple[str, str]:
|
|
107
|
+
"""``(name, description)`` from a SKILL.md's ``---`` block."""
|
|
108
|
+
lines = text.split("\n")
|
|
109
|
+
if not lines or lines[0].strip() != "---":
|
|
110
|
+
raise ValueError("SKILL.md is missing its leading frontmatter delimiter")
|
|
111
|
+
closing = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
|
|
112
|
+
if closing is None:
|
|
113
|
+
raise ValueError("SKILL.md is missing its closing frontmatter delimiter")
|
|
114
|
+
fm = yaml.safe_load("\n".join(lines[1:closing])) or {}
|
|
115
|
+
name = fm.get("name")
|
|
116
|
+
if not name:
|
|
117
|
+
raise ValueError("SKILL.md frontmatter has no 'name'")
|
|
118
|
+
return str(name), str(fm.get("description") or "")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def tree_version(files: tuple[SkillFile, ...] | list[SkillFile]) -> str:
|
|
122
|
+
"""SHA-256 over every file's path and content, in path order; 16 hex chars.
|
|
123
|
+
|
|
124
|
+
The stamp file is never part of it (it records the version, so it
|
|
125
|
+
cannot be an input to it).
|
|
126
|
+
"""
|
|
127
|
+
digest = hashlib.sha256()
|
|
128
|
+
for f in sorted(files, key=lambda f: f.path):
|
|
129
|
+
digest.update(f.path.encode("utf-8"))
|
|
130
|
+
digest.update(b"\0")
|
|
131
|
+
digest.update(f.content.encode("utf-8"))
|
|
132
|
+
digest.update(b"\0")
|
|
133
|
+
return digest.hexdigest()[:16]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _tree_files(directory: Path) -> tuple[SkillFile, ...]:
|
|
137
|
+
files: list[SkillFile] = []
|
|
138
|
+
for path in sorted(directory.rglob("*")):
|
|
139
|
+
if not path.is_file():
|
|
140
|
+
continue
|
|
141
|
+
rel = path.relative_to(directory).as_posix()
|
|
142
|
+
if rel == STAMP_FILE or any(part.startswith(".") for part in rel.split("/")):
|
|
143
|
+
continue
|
|
144
|
+
files.append(SkillFile(path=rel, content=path.read_text(encoding="utf-8")))
|
|
145
|
+
return tuple(files)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_tree(directory: Path) -> SkillTree:
|
|
149
|
+
"""The skill at ``directory`` (which must hold a SKILL.md)."""
|
|
150
|
+
directory = Path(directory)
|
|
151
|
+
skill_md = directory / "SKILL.md"
|
|
152
|
+
if not skill_md.is_file():
|
|
153
|
+
raise SkillNotFound(f"{directory} has no SKILL.md")
|
|
154
|
+
files = _tree_files(directory)
|
|
155
|
+
name, description = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
|
|
156
|
+
return SkillTree(name=name, description=description, version=tree_version(files), files=files)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_library(root: Path) -> list[SkillTree]:
|
|
160
|
+
"""Every ``<root>/<name>/SKILL.md`` skill, sorted by name."""
|
|
161
|
+
root = Path(root)
|
|
162
|
+
if not root.is_dir():
|
|
163
|
+
return []
|
|
164
|
+
trees = [read_tree(child) for child in sorted(root.iterdir()) if (child / "SKILL.md").is_file()]
|
|
165
|
+
return sorted(trees, key=lambda t: t.name)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def resolve_name(name: str, available: list[str]) -> str:
|
|
169
|
+
"""``consume`` and ``dataspring-consume`` both name the same skill."""
|
|
170
|
+
if name in available:
|
|
171
|
+
return name
|
|
172
|
+
prefixed = NAME_PREFIX + name
|
|
173
|
+
if prefixed in available:
|
|
174
|
+
return prefixed
|
|
175
|
+
raise SkillNotFound(f"No skill named '{name}'. Available: {', '.join(available) or '(none)'}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def find_tree(name: str, trees: list[SkillTree]) -> SkillTree:
|
|
179
|
+
resolved = resolve_name(name, [t.name for t in trees])
|
|
180
|
+
return next(t for t in trees if t.name == resolved)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# The copy the wheel ships
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def bundled_root() -> Path | None:
|
|
189
|
+
"""Where the bundled skills are, or ``None`` when this install has none.
|
|
190
|
+
|
|
191
|
+
A built wheel carries ``cli/_skills/`` (the build hook copies
|
|
192
|
+
``backend/skills/`` there); a checkout runs against ``backend/skills/``
|
|
193
|
+
itself, so there is never a second checked-in copy.
|
|
194
|
+
"""
|
|
195
|
+
here = Path(__file__).resolve().parent
|
|
196
|
+
for candidate in (here / _BUNDLED_SUBDIR, here.parent / "skills"):
|
|
197
|
+
if candidate.is_dir():
|
|
198
|
+
return candidate
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def bundled_library() -> list[SkillTree]:
|
|
203
|
+
root = bundled_root()
|
|
204
|
+
return read_library(root) if root else []
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Installing, stamping, diffing
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class Stamp:
|
|
214
|
+
skill: str
|
|
215
|
+
version: str
|
|
216
|
+
source: str # the server URL the tree came from, or "bundled"
|
|
217
|
+
installed_at: str
|
|
218
|
+
|
|
219
|
+
def to_dict(self) -> dict:
|
|
220
|
+
return {
|
|
221
|
+
"skill": self.skill,
|
|
222
|
+
"version": self.version,
|
|
223
|
+
"source": self.source,
|
|
224
|
+
"installed_at": self.installed_at,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def read_stamp(directory: Path) -> Stamp | None:
|
|
229
|
+
"""The stamp at the root of ``directory``, or ``None`` when there is none
|
|
230
|
+
(never installed by DataSpring) or it is unreadable."""
|
|
231
|
+
path = Path(directory) / STAMP_FILE
|
|
232
|
+
try:
|
|
233
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
234
|
+
return Stamp(
|
|
235
|
+
skill=str(data["skill"]),
|
|
236
|
+
version=str(data["version"]),
|
|
237
|
+
source=str(data.get("source", "")),
|
|
238
|
+
installed_at=str(data.get("installed_at", "")),
|
|
239
|
+
)
|
|
240
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _is_managed(directory: Path) -> bool:
|
|
245
|
+
"""Safe to replace without --force: empty, or carrying a stamp."""
|
|
246
|
+
try:
|
|
247
|
+
entries = list(directory.iterdir())
|
|
248
|
+
except OSError:
|
|
249
|
+
return False
|
|
250
|
+
return not entries or (directory / STAMP_FILE).is_file()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def install_tree(tree: SkillTree, directory: Path, *, source: str, force: bool = False) -> Stamp:
|
|
254
|
+
"""Write ``tree`` to ``directory``, replacing whatever is there.
|
|
255
|
+
|
|
256
|
+
Refuses a directory that exists, is non-empty and has no stamp
|
|
257
|
+
(``UnmanagedDirectory``) unless ``force``. The new tree is written to a
|
|
258
|
+
temporary sibling first and renamed into place, so a crash leaves either
|
|
259
|
+
the old tree or the new one, never half of each. Nothing from the old
|
|
260
|
+
tree survives: an upgrade of a stamped directory drops hand-added extras.
|
|
261
|
+
"""
|
|
262
|
+
directory = Path(directory)
|
|
263
|
+
if directory.exists() and not directory.is_dir():
|
|
264
|
+
raise NotADirectoryError(f"{directory} exists and is not a directory")
|
|
265
|
+
exists = directory.is_dir()
|
|
266
|
+
if exists and not force and not _is_managed(directory):
|
|
267
|
+
raise UnmanagedDirectory(
|
|
268
|
+
f"{directory} exists and is not DataSpring-managed (no {STAMP_FILE} stamp)"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
parent = directory.parent
|
|
272
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
# Sweep a temp tree a crashed install left behind: it holds a complete
|
|
274
|
+
# SKILL.md and a harness may load it as a second copy of the skill.
|
|
275
|
+
for stale in parent.glob(directory.name + ".dataspring-tmp-*"):
|
|
276
|
+
shutil.rmtree(stale, ignore_errors=True)
|
|
277
|
+
|
|
278
|
+
tmp = Path(tempfile.mkdtemp(prefix=directory.name + ".dataspring-tmp-", dir=parent))
|
|
279
|
+
try:
|
|
280
|
+
for f in tree.files:
|
|
281
|
+
dest = tmp / Path(*f.path.split("/"))
|
|
282
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
dest.write_text(f.content, encoding="utf-8")
|
|
284
|
+
stamp = Stamp(
|
|
285
|
+
skill=tree.name,
|
|
286
|
+
version=tree.version,
|
|
287
|
+
source=source,
|
|
288
|
+
installed_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
289
|
+
)
|
|
290
|
+
(tmp / STAMP_FILE).write_text(json.dumps(stamp.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
291
|
+
if exists:
|
|
292
|
+
shutil.rmtree(directory)
|
|
293
|
+
os.rename(tmp, directory)
|
|
294
|
+
except BaseException:
|
|
295
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
296
|
+
raise
|
|
297
|
+
return stamp
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass(frozen=True)
|
|
301
|
+
class SkillDiff:
|
|
302
|
+
"""An installed directory against a served (or bundled) tree."""
|
|
303
|
+
|
|
304
|
+
name: str
|
|
305
|
+
added: tuple[str, ...] # on disk, not in the tree
|
|
306
|
+
removed: tuple[str, ...] # in the tree, not on disk
|
|
307
|
+
changed: tuple[str, ...] # in both, different bytes
|
|
308
|
+
stamp_version: str | None # None: unstamped
|
|
309
|
+
served_version: str
|
|
310
|
+
|
|
311
|
+
def ok(self) -> bool:
|
|
312
|
+
return (
|
|
313
|
+
not self.added
|
|
314
|
+
and not self.removed
|
|
315
|
+
and not self.changed
|
|
316
|
+
and self.stamp_version is not None
|
|
317
|
+
and self.stamp_version == self.served_version
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def render(self) -> str:
|
|
321
|
+
if self.ok():
|
|
322
|
+
return f"{self.name}: no drift (version {self.served_version})"
|
|
323
|
+
installed = self.stamp_version or "(unstamped)"
|
|
324
|
+
lines = [f"{self.name}: drift, installed={installed} served={self.served_version}"]
|
|
325
|
+
for label, paths in (("added", self.added), ("removed", self.removed), ("changed", self.changed)):
|
|
326
|
+
if paths:
|
|
327
|
+
lines.append(f" {label} ({len(paths)}):")
|
|
328
|
+
lines.extend(f" {p}" for p in paths)
|
|
329
|
+
return "\n".join(lines)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def diff_tree(tree: SkillTree, directory: Path) -> SkillDiff:
|
|
333
|
+
"""Compare ``directory`` with ``tree``; a missing directory is an error."""
|
|
334
|
+
directory = Path(directory)
|
|
335
|
+
if not directory.is_dir():
|
|
336
|
+
raise FileNotFoundError(f"{directory} is not an installed skill directory")
|
|
337
|
+
on_disk = {f.path: f.content for f in _tree_files(directory)}
|
|
338
|
+
served = {f.path: f.content for f in tree.files}
|
|
339
|
+
removed = sorted(p for p in served if p not in on_disk)
|
|
340
|
+
changed = sorted(p for p in served if p in on_disk and on_disk[p] != served[p])
|
|
341
|
+
added = sorted(p for p in on_disk if p not in served)
|
|
342
|
+
stamp = read_stamp(directory)
|
|
343
|
+
return SkillDiff(
|
|
344
|
+
name=tree.name,
|
|
345
|
+
added=tuple(added),
|
|
346
|
+
removed=tuple(removed),
|
|
347
|
+
changed=tuple(changed),
|
|
348
|
+
stamp_version=stamp.version if stamp else None,
|
|
349
|
+
served_version=tree.version,
|
|
350
|
+
)
|
cli/upgrade.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""``dataspring upgrade``: upgrade the CLI in place, however it was installed.
|
|
2
|
+
|
|
3
|
+
Three installers put ``dataspring`` on a machine and each upgrades its own
|
|
4
|
+
way; the install script uses ``uv tool``. The method is read off
|
|
5
|
+
``sys.executable``: a uv tool's interpreter lives under ``.../uv/tools/
|
|
6
|
+
dataspring-cli/``, pipx's under ``.../pipx/venvs/dataspring-cli/``, and
|
|
7
|
+
anything else is a plain pip install into whatever environment this is.
|
|
8
|
+
``--check`` only compares the installed version with PyPI's latest.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from cli.version import DIST, cli_version
|
|
21
|
+
|
|
22
|
+
PYPI_JSON = f"https://pypi.org/pypi/{DIST}/json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_install_method(executable: str | None = None) -> str:
|
|
26
|
+
"""``"uv"``, ``"pipx"`` or ``"pip"``."""
|
|
27
|
+
parts = Path(executable or sys.executable).resolve().parts
|
|
28
|
+
if "tools" in parts and DIST in parts:
|
|
29
|
+
return "uv"
|
|
30
|
+
if "pipx" in parts and DIST in parts:
|
|
31
|
+
return "pipx"
|
|
32
|
+
return "pip"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def upgrade_command(method: str) -> list[str]:
|
|
36
|
+
if method == "uv":
|
|
37
|
+
return ["uv", "tool", "upgrade", DIST]
|
|
38
|
+
if method == "pipx":
|
|
39
|
+
return ["pipx", "upgrade", DIST]
|
|
40
|
+
return [sys.executable, "-m", "pip", "install", "--upgrade", DIST]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def installed_version_after_upgrade() -> str:
|
|
44
|
+
"""Ask the (possibly replaced) interpreter, not this process's cached metadata."""
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
[sys.executable, "-c", f"from importlib.metadata import version; print(version({DIST!r}))"],
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
check=False,
|
|
50
|
+
)
|
|
51
|
+
return result.stdout.strip() or cli_version()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def latest_pypi_version() -> str:
|
|
55
|
+
with httpx.Client(timeout=15.0) as client:
|
|
56
|
+
response = client.get(PYPI_JSON)
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
return response.json()["info"]["version"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _key(version: str) -> tuple[int, ...]:
|
|
62
|
+
return tuple(int(n) for n in re.findall(r"\d+", version)) or (0,)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_newer(candidate: str, current: str) -> bool:
|
|
66
|
+
return _key(candidate) > _key(current)
|