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/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)