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.

Files changed (83) hide show
  1. convergent_cli/__init__.py +1 -0
  2. convergent_cli/auth.py +227 -0
  3. convergent_cli/build_context.py +225 -0
  4. convergent_cli/calibration_finish.py +310 -0
  5. convergent_cli/calibration_frontier.py +499 -0
  6. convergent_cli/calibration_runner.py +1413 -0
  7. convergent_cli/calibration_send.py +44 -0
  8. convergent_cli/codex_app_server.py +127 -0
  9. convergent_cli/command_tree.py +102 -0
  10. convergent_cli/commands/__init__.py +58 -0
  11. convergent_cli/commands/agents.py +729 -0
  12. convergent_cli/commands/auth.py +117 -0
  13. convergent_cli/commands/calibration.py +3857 -0
  14. convergent_cli/commands/calibration_labels_load.py +627 -0
  15. convergent_cli/commands/capabilities.py +85 -0
  16. convergent_cli/commands/dataset_authoring.py +964 -0
  17. convergent_cli/commands/dataset_import.py +763 -0
  18. convergent_cli/commands/datasets.py +678 -0
  19. convergent_cli/commands/docs.py +120 -0
  20. convergent_cli/commands/doctor.py +124 -0
  21. convergent_cli/commands/environments.py +63 -0
  22. convergent_cli/commands/findings.py +206 -0
  23. convergent_cli/commands/graders.py +803 -0
  24. convergent_cli/commands/harness.py +366 -0
  25. convergent_cli/commands/issues.py +388 -0
  26. convergent_cli/commands/production.py +173 -0
  27. convergent_cli/commands/profile.py +86 -0
  28. convergent_cli/commands/rubrics.py +14 -0
  29. convergent_cli/commands/runs.py +1763 -0
  30. convergent_cli/commands/setup.py +968 -0
  31. convergent_cli/commands/task_qc.py +1041 -0
  32. convergent_cli/commands/task_qc_reports.py +393 -0
  33. convergent_cli/commands/verdicts.py +156 -0
  34. convergent_cli/commands/workspaces.py +170 -0
  35. convergent_cli/commands/worlds.py +292 -0
  36. convergent_cli/confirm.py +59 -0
  37. convergent_cli/control_client.py +141 -0
  38. convergent_cli/customer_client.py +1272 -0
  39. convergent_cli/docs_client.py +165 -0
  40. convergent_cli/errors.py +212 -0
  41. convergent_cli/harness_run.py +1169 -0
  42. convergent_cli/harness_tree.py +854 -0
  43. convergent_cli/installed_agent.py +1071 -0
  44. convergent_cli/json_files.py +23 -0
  45. convergent_cli/local_files.py +64 -0
  46. convergent_cli/main.py +106 -0
  47. convergent_cli/oauth.py +431 -0
  48. convergent_cli/output.py +15 -0
  49. convergent_cli/paging.py +62 -0
  50. convergent_cli/run_calibration.py +361 -0
  51. convergent_cli/run_usage.py +99 -0
  52. convergent_cli/sessions.py +275 -0
  53. convergent_cli/setup_agents.py +190 -0
  54. convergent_cli/setup_auth.py +463 -0
  55. convergent_cli/setup_instrument.py +244 -0
  56. convergent_cli/setup_mcp.py +525 -0
  57. convergent_cli/setup_wizard.py +125 -0
  58. convergent_cli/skill_files/convergent/SKILL.md +25 -0
  59. convergent_cli/skill_files/convergent-instrument/SKILL.md +15 -0
  60. convergent_cli/skill_files/convergent-verify/SKILL.md +15 -0
  61. convergent_cli/skills.py +489 -0
  62. convergent_cli/state.py +297 -0
  63. convergent_cli/transport.py +242 -0
  64. convergent_cli/verdict_feedback.py +161 -0
  65. convergent_cli/verifier_bench_preflight.py +32 -0
  66. convergent_cli/world_docker.py +145 -0
  67. convergent_cli/world_files.py +432 -0
  68. convergent_cli/world_live.py +660 -0
  69. convergent_cli/world_mcp.py +109 -0
  70. convergent_cli/world_probe_requirements.txt +29 -0
  71. convergent_cli/world_template/Dockerfile +10 -0
  72. convergent_cli/world_template/README.md +38 -0
  73. convergent_cli/world_template/convergent.toml +11 -0
  74. convergent_cli/world_template/examples/default/environment/catalog.json +3 -0
  75. convergent_cli/world_template/examples/default/instruction.md +1 -0
  76. convergent_cli/world_template/requirements.txt +1 -0
  77. convergent_cli/world_template/schemas/catalog.json +5 -0
  78. convergent_cli/world_template/server.py +25 -0
  79. convergent_cli-0.1.0.dist-info/METADATA +679 -0
  80. convergent_cli-0.1.0.dist-info/RECORD +83 -0
  81. convergent_cli-0.1.0.dist-info/WHEEL +4 -0
  82. convergent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  83. 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.
@@ -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
+ ]