convergent-cli 0.1.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.
Potentially problematic release.
This version of convergent-cli might be problematic. Click here for more details.
- convergent_cli/__init__.py +1 -0
- convergent_cli/auth.py +227 -0
- convergent_cli/build_context.py +225 -0
- convergent_cli/calibration_finish.py +310 -0
- convergent_cli/calibration_frontier.py +499 -0
- convergent_cli/calibration_runner.py +1413 -0
- convergent_cli/calibration_send.py +44 -0
- convergent_cli/codex_app_server.py +127 -0
- convergent_cli/command_tree.py +102 -0
- convergent_cli/commands/__init__.py +58 -0
- convergent_cli/commands/agents.py +729 -0
- convergent_cli/commands/auth.py +117 -0
- convergent_cli/commands/calibration.py +3857 -0
- convergent_cli/commands/calibration_labels_load.py +627 -0
- convergent_cli/commands/capabilities.py +85 -0
- convergent_cli/commands/dataset_authoring.py +964 -0
- convergent_cli/commands/dataset_import.py +763 -0
- convergent_cli/commands/datasets.py +678 -0
- convergent_cli/commands/docs.py +120 -0
- convergent_cli/commands/doctor.py +124 -0
- convergent_cli/commands/environments.py +63 -0
- convergent_cli/commands/findings.py +206 -0
- convergent_cli/commands/graders.py +803 -0
- convergent_cli/commands/harness.py +366 -0
- convergent_cli/commands/issues.py +388 -0
- convergent_cli/commands/production.py +173 -0
- convergent_cli/commands/profile.py +86 -0
- convergent_cli/commands/rubrics.py +14 -0
- convergent_cli/commands/runs.py +1763 -0
- convergent_cli/commands/setup.py +968 -0
- convergent_cli/commands/task_qc.py +1041 -0
- convergent_cli/commands/task_qc_reports.py +393 -0
- convergent_cli/commands/verdicts.py +156 -0
- convergent_cli/commands/workspaces.py +170 -0
- convergent_cli/commands/worlds.py +292 -0
- convergent_cli/confirm.py +59 -0
- convergent_cli/control_client.py +141 -0
- convergent_cli/customer_client.py +1272 -0
- convergent_cli/docs_client.py +165 -0
- convergent_cli/errors.py +212 -0
- convergent_cli/harness_run.py +1169 -0
- convergent_cli/harness_tree.py +854 -0
- convergent_cli/installed_agent.py +1071 -0
- convergent_cli/json_files.py +23 -0
- convergent_cli/local_files.py +64 -0
- convergent_cli/main.py +106 -0
- convergent_cli/oauth.py +431 -0
- convergent_cli/output.py +15 -0
- convergent_cli/paging.py +62 -0
- convergent_cli/run_calibration.py +361 -0
- convergent_cli/run_usage.py +99 -0
- convergent_cli/sessions.py +275 -0
- convergent_cli/setup_agents.py +190 -0
- convergent_cli/setup_auth.py +463 -0
- convergent_cli/setup_instrument.py +244 -0
- convergent_cli/setup_mcp.py +525 -0
- convergent_cli/setup_wizard.py +125 -0
- convergent_cli/skill_files/convergent/SKILL.md +25 -0
- convergent_cli/skill_files/convergent-instrument/SKILL.md +15 -0
- convergent_cli/skill_files/convergent-verify/SKILL.md +15 -0
- convergent_cli/skills.py +489 -0
- convergent_cli/state.py +297 -0
- convergent_cli/transport.py +242 -0
- convergent_cli/verdict_feedback.py +161 -0
- convergent_cli/verifier_bench_preflight.py +32 -0
- convergent_cli/world_docker.py +145 -0
- convergent_cli/world_files.py +432 -0
- convergent_cli/world_live.py +660 -0
- convergent_cli/world_mcp.py +109 -0
- convergent_cli/world_probe_requirements.txt +29 -0
- convergent_cli/world_template/Dockerfile +10 -0
- convergent_cli/world_template/README.md +38 -0
- convergent_cli/world_template/convergent.toml +11 -0
- convergent_cli/world_template/examples/default/environment/catalog.json +3 -0
- convergent_cli/world_template/examples/default/instruction.md +1 -0
- convergent_cli/world_template/requirements.txt +1 -0
- convergent_cli/world_template/schemas/catalog.json +5 -0
- convergent_cli/world_template/server.py +25 -0
- convergent_cli-0.1.0.dist-info/METADATA +679 -0
- convergent_cli-0.1.0.dist-info/RECORD +83 -0
- convergent_cli-0.1.0.dist-info/WHEEL +4 -0
- convergent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- convergent_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: convergent-verify
|
|
3
|
+
description: Inspect one Convergent recording and report evidence-backed instrumentation findings without changing code. Use when a user asks to verify, inspect, debug, or explain Convergent spans, or when convergent-instrument needs to compare a run with its expected recording.
|
|
4
|
+
version: 2
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Load the current verification workflow
|
|
8
|
+
|
|
9
|
+
If `read_docs` is available, call it for `/agent-skills/verify`.
|
|
10
|
+
If the tool is unavailable or the call fails, run `cv docs read /agent-skills/verify`.
|
|
11
|
+
Then follow that page.
|
|
12
|
+
|
|
13
|
+
If `cv` fails with `login_required`, ask the user to run `cv login`, then retry once.
|
|
14
|
+
Stop on any other `cv docs read` failure. Do not use GitHub, repository prose, or a bundled copy as
|
|
15
|
+
documentation fallback.
|
convergent_cli/skills.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import stat
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import NoReturn, cast
|
|
15
|
+
|
|
16
|
+
from convergent_cli.errors import ApiError
|
|
17
|
+
|
|
18
|
+
SKILL_FILES_DIR = Path(__file__).parent / "skill_files"
|
|
19
|
+
|
|
20
|
+
RECEIPT_NAME = ".convergent-skills.json"
|
|
21
|
+
|
|
22
|
+
# Skills an earlier cv installed that `convergent` now replaces. Their receipt
|
|
23
|
+
# entries stay readable so install can remove the files it wrote.
|
|
24
|
+
RETIRED_SKILLS = frozenset({"convergent-cv", "convergent-mcp"})
|
|
25
|
+
|
|
26
|
+
_RECEIPT_SCHEMA_VERSION = 1
|
|
27
|
+
_RECEIPT_OBJECT = "cv.skills_receipt"
|
|
28
|
+
|
|
29
|
+
_SKILL_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?")
|
|
30
|
+
_SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
31
|
+
|
|
32
|
+
CLAUDE_SKILLS = Path(".claude") / "skills"
|
|
33
|
+
AGENTS_SKILLS = Path(".agents") / "skills"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SkillError(ApiError):
|
|
37
|
+
def __init__(self, message: str, fix: str | None = None) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.fix = fix
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class PackagedSkill:
|
|
44
|
+
name: str
|
|
45
|
+
version: str
|
|
46
|
+
description: str
|
|
47
|
+
path: Path
|
|
48
|
+
|
|
49
|
+
def read(self) -> bytes:
|
|
50
|
+
return self.path.read_bytes()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class InstallReport:
|
|
55
|
+
target: Path
|
|
56
|
+
installed: list[dict[str, str]]
|
|
57
|
+
unchanged: list[dict[str, str]]
|
|
58
|
+
skipped: list[dict[str, str]]
|
|
59
|
+
removed: list[str]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def usable(self) -> bool:
|
|
63
|
+
return bool(self.installed or self.unchanged)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def catalog() -> list[PackagedSkill]:
|
|
67
|
+
found: list[PackagedSkill] = []
|
|
68
|
+
for path in sorted(SKILL_FILES_DIR.glob("*/SKILL.md")):
|
|
69
|
+
fields = _frontmatter(path.read_text(encoding="utf-8"))
|
|
70
|
+
name = fields.get("name", "")
|
|
71
|
+
if name != path.parent.name or _SKILL_NAME.fullmatch(name) is None:
|
|
72
|
+
raise ValueError(f"{path} must name its safe parent directory")
|
|
73
|
+
found.append(
|
|
74
|
+
PackagedSkill(
|
|
75
|
+
name=name,
|
|
76
|
+
version=fields.get("version", "1"),
|
|
77
|
+
description=fields.get("description", ""),
|
|
78
|
+
path=path,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def install(target: Path, *, force: bool = False) -> InstallReport:
|
|
85
|
+
selected = catalog()
|
|
86
|
+
try:
|
|
87
|
+
prepared = [
|
|
88
|
+
(skill, contents, _digest(contents))
|
|
89
|
+
for skill in selected
|
|
90
|
+
for contents in (skill.read(),)
|
|
91
|
+
]
|
|
92
|
+
except OSError as error:
|
|
93
|
+
raise SkillError(
|
|
94
|
+
f"this cv installation could not read its skill files: {error}",
|
|
95
|
+
fix="Reinstall or upgrade cv, then run the command again.",
|
|
96
|
+
) from error
|
|
97
|
+
|
|
98
|
+
installed: list[dict[str, str]] = []
|
|
99
|
+
unchanged: list[dict[str, str]] = []
|
|
100
|
+
skipped: list[dict[str, str]] = []
|
|
101
|
+
removed: list[str] = []
|
|
102
|
+
replaced: list[tuple[Path, bytes | None, int | None, str, tuple[int, int]]] = []
|
|
103
|
+
unlinked: list[tuple[Path, bytes, int]] = []
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
_make_directories(target)
|
|
107
|
+
written = read_receipt(target)
|
|
108
|
+
removable, blocked = retirements(target, written, force=force)
|
|
109
|
+
skipped.extend(blocked)
|
|
110
|
+
for skill, contents, desired in prepared:
|
|
111
|
+
relative = f"{skill.name}/SKILL.md"
|
|
112
|
+
destination = target / relative
|
|
113
|
+
problem = _destination_problem(target, destination)
|
|
114
|
+
if problem is not None:
|
|
115
|
+
skipped.append({"name": skill.name, "path": str(destination), "reason": problem})
|
|
116
|
+
continue
|
|
117
|
+
recorded = written.get(relative)
|
|
118
|
+
try:
|
|
119
|
+
previous, previous_node = _read_regular(destination)
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
previous, previous_node = None, None
|
|
122
|
+
reason = _reason_to_skip(previous, recorded, desired, force=force)
|
|
123
|
+
if reason is _UNCHANGED:
|
|
124
|
+
unchanged.append(
|
|
125
|
+
{"name": skill.name, "version": skill.version, "path": str(destination)}
|
|
126
|
+
)
|
|
127
|
+
continue
|
|
128
|
+
if reason is not None:
|
|
129
|
+
skipped.append({"name": skill.name, "path": str(destination), "reason": reason})
|
|
130
|
+
continue
|
|
131
|
+
previous_mode = (
|
|
132
|
+
stat.S_IMODE(previous_node.st_mode) if previous_node is not None else None
|
|
133
|
+
)
|
|
134
|
+
_make_directories(destination.parent)
|
|
135
|
+
identity = _atomic_write(destination, contents, mode=0o644)
|
|
136
|
+
replaced.append((destination, previous, previous_mode, desired, identity))
|
|
137
|
+
written[relative] = {
|
|
138
|
+
"path": relative,
|
|
139
|
+
"name": skill.name,
|
|
140
|
+
"version": skill.version,
|
|
141
|
+
"sha256": desired,
|
|
142
|
+
"cv_version": _cli_version(),
|
|
143
|
+
"installed_at": datetime.now(UTC).isoformat(),
|
|
144
|
+
}
|
|
145
|
+
installed.append(
|
|
146
|
+
{"name": skill.name, "version": skill.version, "path": str(destination)}
|
|
147
|
+
)
|
|
148
|
+
for name in removable:
|
|
149
|
+
destination = target / name / "SKILL.md"
|
|
150
|
+
try:
|
|
151
|
+
previous, previous_node = _read_regular(destination)
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
pass
|
|
154
|
+
else:
|
|
155
|
+
destination.unlink()
|
|
156
|
+
unlinked.append((destination, previous, stat.S_IMODE(previous_node.st_mode)))
|
|
157
|
+
with contextlib.suppress(OSError):
|
|
158
|
+
destination.parent.rmdir()
|
|
159
|
+
del written[f"{name}/SKILL.md"]
|
|
160
|
+
removed.append(name)
|
|
161
|
+
if installed or removed:
|
|
162
|
+
_write_receipt(target, written)
|
|
163
|
+
except BaseException as error:
|
|
164
|
+
failures = _restore_replaced(replaced) + _restore_unlinked(unlinked)
|
|
165
|
+
if isinstance(error, SkillError):
|
|
166
|
+
raise
|
|
167
|
+
if not isinstance(error, OSError | ValueError):
|
|
168
|
+
raise
|
|
169
|
+
rollback = (
|
|
170
|
+
f" Rollback also failed for: {'; '.join(failures)}."
|
|
171
|
+
if failures
|
|
172
|
+
else " Prior files were restored."
|
|
173
|
+
)
|
|
174
|
+
raise SkillError(
|
|
175
|
+
f"could not install into {target}: {error}. Nothing was claimed installed.{rollback}"
|
|
176
|
+
) from error
|
|
177
|
+
|
|
178
|
+
return InstallReport(target, installed, unchanged, skipped, removed)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def retirements(
|
|
182
|
+
target: Path, written: dict[str, dict[str, str]], *, force: bool
|
|
183
|
+
) -> tuple[list[str], list[dict[str, str]]]:
|
|
184
|
+
"""Retired skills whose files cv wrote and may delete, and those left in place."""
|
|
185
|
+
removable: list[str] = []
|
|
186
|
+
blocked: list[dict[str, str]] = []
|
|
187
|
+
for name in sorted(RETIRED_SKILLS):
|
|
188
|
+
relative = f"{name}/SKILL.md"
|
|
189
|
+
destination = target / relative
|
|
190
|
+
problem = _destination_problem(target, destination)
|
|
191
|
+
if problem is not None:
|
|
192
|
+
blocked.append({"name": name, "path": str(destination), "reason": problem})
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
previous, _node = _read_regular(destination)
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
if relative in written:
|
|
198
|
+
removable.append(name)
|
|
199
|
+
continue
|
|
200
|
+
recorded = written.get(relative)
|
|
201
|
+
desired = recorded.get("sha256", "") if recorded is not None else ""
|
|
202
|
+
reason = _reason_to_skip(previous, recorded, desired, force=force)
|
|
203
|
+
if reason is None or reason is _UNCHANGED:
|
|
204
|
+
removable.append(name)
|
|
205
|
+
else:
|
|
206
|
+
blocked.append({"name": name, "path": str(destination), "reason": reason})
|
|
207
|
+
return removable, blocked
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def installed_skill_problem(target: Path, names: Iterable[str]) -> str | None:
|
|
211
|
+
for name in names:
|
|
212
|
+
if _SKILL_NAME.fullmatch(name) is None:
|
|
213
|
+
return f"{name!r} is not a safe packaged skill name"
|
|
214
|
+
destination = target / name / "SKILL.md"
|
|
215
|
+
if problem := _destination_problem(target, destination):
|
|
216
|
+
return problem
|
|
217
|
+
try:
|
|
218
|
+
installed, _ = _read_regular(destination)
|
|
219
|
+
packaged, _ = _read_regular(SKILL_FILES_DIR / name / "SKILL.md")
|
|
220
|
+
except OSError as error:
|
|
221
|
+
return f"{destination} could not be read: {error}"
|
|
222
|
+
if installed != packaged:
|
|
223
|
+
return f"{destination} does not match this cv installation"
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def read_receipt(target: Path) -> dict[str, dict[str, str]]:
|
|
228
|
+
path = target / RECEIPT_NAME
|
|
229
|
+
try:
|
|
230
|
+
contents, _node = _read_regular(path)
|
|
231
|
+
except FileNotFoundError:
|
|
232
|
+
return {}
|
|
233
|
+
except OSError as error:
|
|
234
|
+
_raise_bad_receipt(path, f"it is not a readable regular file ({error})")
|
|
235
|
+
try:
|
|
236
|
+
recorded = json.loads(contents)
|
|
237
|
+
except ValueError as error:
|
|
238
|
+
_raise_bad_receipt(path, f"it is not readable as a receipt ({error})")
|
|
239
|
+
entries: dict[str, dict[str, str]] = {}
|
|
240
|
+
for entry in _receipt_files(path, recorded):
|
|
241
|
+
relative = entry["path"]
|
|
242
|
+
if relative in entries:
|
|
243
|
+
_raise_bad_receipt(path, f"it names {relative} more than once")
|
|
244
|
+
entries[relative] = entry
|
|
245
|
+
return entries
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
_UNCHANGED = "unchanged"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _reason_to_skip(
|
|
252
|
+
previous: bytes | None,
|
|
253
|
+
recorded: dict[str, str] | None,
|
|
254
|
+
desired: str,
|
|
255
|
+
*,
|
|
256
|
+
force: bool,
|
|
257
|
+
) -> str | None:
|
|
258
|
+
if previous is None:
|
|
259
|
+
return None
|
|
260
|
+
if recorded is None:
|
|
261
|
+
return "a file cv did not write is already there; move or delete it, then run again"
|
|
262
|
+
current = _digest(previous)
|
|
263
|
+
if current == recorded.get("sha256") == desired:
|
|
264
|
+
return _UNCHANGED
|
|
265
|
+
if current != recorded.get("sha256") and not force:
|
|
266
|
+
return "you changed this file after cv wrote it, so `--force` is needed to replace it"
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _cli_version() -> str:
|
|
271
|
+
from convergent_cli.main import cli_version
|
|
272
|
+
|
|
273
|
+
return cli_version()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _frontmatter(text: str) -> dict[str, str]:
|
|
277
|
+
if not text.startswith("---\n"):
|
|
278
|
+
return {}
|
|
279
|
+
block = text[4:].split("\n---\n", 1)[0]
|
|
280
|
+
fields: dict[str, str] = {}
|
|
281
|
+
for line in block.splitlines():
|
|
282
|
+
if ":" in line and not line.startswith((" ", "\t", "#")):
|
|
283
|
+
key, value = line.split(":", 1)
|
|
284
|
+
fields[key.strip()] = value.strip()
|
|
285
|
+
return fields
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _digest(contents: str | bytes) -> str:
|
|
289
|
+
data = contents.encode("utf-8") if isinstance(contents, str) else contents
|
|
290
|
+
return hashlib.sha256(data).hexdigest()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _read_regular(path: Path) -> tuple[bytes, os.stat_result]:
|
|
294
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
295
|
+
descriptor = os.open(path, flags)
|
|
296
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
297
|
+
node = os.fstat(handle.fileno())
|
|
298
|
+
if not stat.S_ISREG(node.st_mode):
|
|
299
|
+
raise OSError(f"{path} is not a regular file")
|
|
300
|
+
return handle.read(), node
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _receipt_files(path: Path, recorded: object) -> list[dict[str, str]]:
|
|
304
|
+
if not isinstance(recorded, dict):
|
|
305
|
+
_raise_bad_receipt(path, "it is not a json object")
|
|
306
|
+
version = recorded.get("schema_version")
|
|
307
|
+
if version != _RECEIPT_SCHEMA_VERSION:
|
|
308
|
+
newer = isinstance(version, int) and version > _RECEIPT_SCHEMA_VERSION
|
|
309
|
+
_raise_bad_receipt(
|
|
310
|
+
path,
|
|
311
|
+
f"its schema version is {version!r}, not {_RECEIPT_SCHEMA_VERSION}",
|
|
312
|
+
fix="Upgrade cv, then run the command again." if newer else None,
|
|
313
|
+
)
|
|
314
|
+
if recorded.get("object") != _RECEIPT_OBJECT:
|
|
315
|
+
_raise_bad_receipt(path, f"its object is not {_RECEIPT_OBJECT!r}")
|
|
316
|
+
files = recorded.get("files")
|
|
317
|
+
if not isinstance(files, list) or not all(isinstance(entry, dict) for entry in files):
|
|
318
|
+
_raise_bad_receipt(path, "its files are not a list of json objects")
|
|
319
|
+
packaged = {skill.name for skill in catalog()} | RETIRED_SKILLS
|
|
320
|
+
validated: list[dict[str, str]] = []
|
|
321
|
+
for raw in files:
|
|
322
|
+
if not all(isinstance(key, str) and isinstance(value, str) for key, value in raw.items()):
|
|
323
|
+
_raise_bad_receipt(path, "one of its entries contains a non-string field")
|
|
324
|
+
entry = cast(dict[str, str], raw)
|
|
325
|
+
name = entry.get("name", "")
|
|
326
|
+
relative = entry.get("path", "")
|
|
327
|
+
if _SKILL_NAME.fullmatch(name) is None:
|
|
328
|
+
_raise_bad_receipt(path, f"its entry has an unsafe skill name {name!r}")
|
|
329
|
+
if name not in packaged:
|
|
330
|
+
_raise_bad_receipt(path, f"its entry names unpackaged skill {name!r}")
|
|
331
|
+
if relative != f"{name}/SKILL.md":
|
|
332
|
+
_raise_bad_receipt(path, f"its entry path {relative!r} does not match {name!r}")
|
|
333
|
+
if not entry.get("version"):
|
|
334
|
+
_raise_bad_receipt(path, f"its entry {relative} has no version")
|
|
335
|
+
if _SHA256.fullmatch(entry.get("sha256", "")) is None:
|
|
336
|
+
_raise_bad_receipt(path, f"its entry {relative} has no valid sha256")
|
|
337
|
+
validated.append(entry)
|
|
338
|
+
return validated
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _raise_bad_receipt(path: Path, problem: str, fix: str | None = None) -> NoReturn:
|
|
342
|
+
raise SkillError(
|
|
343
|
+
f"{path} is not a receipt cv can use, because {problem}.",
|
|
344
|
+
fix=fix or f"Delete {path}, then run the command again.",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _write_receipt(target: Path, files: dict[str, dict[str, str]]) -> None:
|
|
349
|
+
receipt = {
|
|
350
|
+
"object": _RECEIPT_OBJECT,
|
|
351
|
+
"schema_version": _RECEIPT_SCHEMA_VERSION,
|
|
352
|
+
"files": [files[path] for path in sorted(files)],
|
|
353
|
+
}
|
|
354
|
+
contents = (json.dumps(receipt, indent=2) + "\n").encode()
|
|
355
|
+
_atomic_write(target / RECEIPT_NAME, contents, mode=0o600)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _atomic_write(path: Path, contents: bytes, *, mode: int) -> tuple[int, int]:
|
|
359
|
+
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
|
360
|
+
descriptor = os.open(
|
|
361
|
+
temporary,
|
|
362
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
|
363
|
+
mode,
|
|
364
|
+
)
|
|
365
|
+
identity: tuple[int, int] | None = None
|
|
366
|
+
try:
|
|
367
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
368
|
+
handle.write(contents)
|
|
369
|
+
handle.flush()
|
|
370
|
+
os.fsync(handle.fileno())
|
|
371
|
+
node = os.fstat(handle.fileno())
|
|
372
|
+
identity = node.st_dev, node.st_ino
|
|
373
|
+
os.replace(temporary, path)
|
|
374
|
+
return identity
|
|
375
|
+
except BaseException:
|
|
376
|
+
temporary.unlink(missing_ok=True)
|
|
377
|
+
if identity is not None:
|
|
378
|
+
try:
|
|
379
|
+
current, current_node = _read_regular(path)
|
|
380
|
+
except OSError:
|
|
381
|
+
current = None
|
|
382
|
+
else:
|
|
383
|
+
if (current_node.st_dev, current_node.st_ino) == identity and current == contents:
|
|
384
|
+
return identity
|
|
385
|
+
raise
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _atomic_create(path: Path, contents: bytes, *, mode: int) -> None:
|
|
389
|
+
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
|
390
|
+
descriptor = os.open(
|
|
391
|
+
temporary,
|
|
392
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
|
393
|
+
mode,
|
|
394
|
+
)
|
|
395
|
+
try:
|
|
396
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
397
|
+
handle.write(contents)
|
|
398
|
+
handle.flush()
|
|
399
|
+
os.fsync(handle.fileno())
|
|
400
|
+
os.link(temporary, path)
|
|
401
|
+
finally:
|
|
402
|
+
temporary.unlink(missing_ok=True)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _restore_replaced(
|
|
406
|
+
replaced: list[tuple[Path, bytes | None, int | None, str, tuple[int, int]]],
|
|
407
|
+
) -> list[str]:
|
|
408
|
+
failures: list[str] = []
|
|
409
|
+
for path, previous, mode, written_digest, written_identity in reversed(replaced):
|
|
410
|
+
try:
|
|
411
|
+
current, node = _read_regular(path)
|
|
412
|
+
same_write = (node.st_dev, node.st_ino) == written_identity
|
|
413
|
+
if not same_write or _digest(current) != written_digest:
|
|
414
|
+
failures.append(f"{path}: changed after cv wrote it, so it was left in place")
|
|
415
|
+
continue
|
|
416
|
+
if previous is None:
|
|
417
|
+
path.unlink(missing_ok=True)
|
|
418
|
+
else:
|
|
419
|
+
_atomic_write(path, previous, mode=mode if mode is not None else 0o644)
|
|
420
|
+
except OSError as error:
|
|
421
|
+
failures.append(f"{path}: {error}")
|
|
422
|
+
return failures
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _restore_unlinked(unlinked: list[tuple[Path, bytes, int]]) -> list[str]:
|
|
426
|
+
failures: list[str] = []
|
|
427
|
+
for path, previous, mode in reversed(unlinked):
|
|
428
|
+
try:
|
|
429
|
+
_make_directories(path.parent)
|
|
430
|
+
_atomic_create(path, previous, mode=mode)
|
|
431
|
+
except FileExistsError:
|
|
432
|
+
failures.append(f"{path}: created after cv removed it, so it was left in place")
|
|
433
|
+
except OSError as error:
|
|
434
|
+
failures.append(f"{path}: {error}")
|
|
435
|
+
return failures
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _make_directories(path: Path) -> None:
|
|
439
|
+
missing: list[Path] = []
|
|
440
|
+
current = path
|
|
441
|
+
while current != current.parent:
|
|
442
|
+
try:
|
|
443
|
+
node = os.lstat(current)
|
|
444
|
+
except FileNotFoundError:
|
|
445
|
+
missing.append(current)
|
|
446
|
+
current = current.parent
|
|
447
|
+
continue
|
|
448
|
+
break
|
|
449
|
+
else:
|
|
450
|
+
node = os.lstat(current)
|
|
451
|
+
if not stat.S_ISDIR(node.st_mode):
|
|
452
|
+
raise NotADirectoryError(f"{current} is not a directory")
|
|
453
|
+
for directory in reversed(missing):
|
|
454
|
+
os.mkdir(directory, 0o755)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _destination_problem(target: Path, destination: Path) -> str | None:
|
|
458
|
+
current = target
|
|
459
|
+
relative = destination.relative_to(target)
|
|
460
|
+
for index, part in enumerate(relative.parts):
|
|
461
|
+
current /= part
|
|
462
|
+
try:
|
|
463
|
+
node = os.lstat(current)
|
|
464
|
+
except FileNotFoundError:
|
|
465
|
+
continue
|
|
466
|
+
if stat.S_ISLNK(node.st_mode):
|
|
467
|
+
return f"{current} is a symlink"
|
|
468
|
+
final = index == len(relative.parts) - 1
|
|
469
|
+
if final and not stat.S_ISREG(node.st_mode):
|
|
470
|
+
return f"{current} is not a regular file"
|
|
471
|
+
if not final and not stat.S_ISDIR(node.st_mode):
|
|
472
|
+
return f"{current} is not a directory"
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
__all__ = [
|
|
477
|
+
"AGENTS_SKILLS",
|
|
478
|
+
"CLAUDE_SKILLS",
|
|
479
|
+
"RECEIPT_NAME",
|
|
480
|
+
"RETIRED_SKILLS",
|
|
481
|
+
"InstallReport",
|
|
482
|
+
"PackagedSkill",
|
|
483
|
+
"SkillError",
|
|
484
|
+
"catalog",
|
|
485
|
+
"install",
|
|
486
|
+
"installed_skill_problem",
|
|
487
|
+
"read_receipt",
|
|
488
|
+
"retirements",
|
|
489
|
+
]
|