arraylake 1.0.3a1__tar.gz → 1.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arraylake-1.0.3a1 → arraylake-1.1.1}/PKG-INFO +2 -2
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/_credential_cache.py +55 -14
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/_version.py +2 -2
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/cli/main.py +2 -0
- arraylake-1.1.1/arraylake/cli/skills.py +208 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/client.py +198 -72
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/compute/types.py +28 -2
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/repos/icechunk/storage.py +39 -4
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/repos/icechunk/types.py +2 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/repos/icechunk/virtual.py +3 -3
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/types.py +29 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/pyproject.toml +4 -4
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/conftest.py +36 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/icechunk/conftest.py +13 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/icechunk/test_storage.py +16 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/icechunk/test_virtual.py +15 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_client.py +16 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_credential_cache.py +121 -8
- arraylake-1.1.1/tests/test_skills.py +111 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_types.py +12 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_virtual_chunks.py +96 -0
- arraylake-1.1.1/uv.lock +2802 -0
- arraylake-1.0.3a1/uv.lock +0 -2795
- {arraylake-1.0.3a1 → arraylake-1.1.1}/.gitignore +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/README.md +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/__init__.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/__main__.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/api_utils.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/asyn.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/cli/__init__.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/cli/auth.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/cli/compute.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/cli/config.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/cli/repo.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/cli/utils.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/compute/doctor.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/compute/http_client.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/compute/services.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/config.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/config.yaml +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/credentials.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/diagnostics.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/display/__init__.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/display/repo.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/display/repolist.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/exceptions.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/log_util.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/metastore/__init__.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/metastore/abc.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/metastore/http_metastore.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/py.typed +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/repos/__init__.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/repos/icechunk/__init__.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/token.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/arraylake/tuning.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/__init__.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/compute/test_client.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/compute/test_doctor.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/config.yaml +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/icechunk/__init__.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_api_utils.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_asyn.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_cli.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_config.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_credentials.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_diagnostics.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_performance.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_subscriptions.py +0 -0
- {arraylake-1.0.3a1 → arraylake-1.1.1}/tests/test_token_handler.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arraylake
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: Python client for ArrayLake
|
|
5
5
|
Project-URL: Documentation, https://docs.earthmover.io/
|
|
6
6
|
Project-URL: Changelog, https://docs.earthmover.io/changelog
|
|
@@ -14,7 +14,7 @@ Requires-Python: <3.15,>=3.12
|
|
|
14
14
|
Requires-Dist: click<9.0,>=8.1.3
|
|
15
15
|
Requires-Dist: donfig<1.0,>=0.7
|
|
16
16
|
Requires-Dist: httpx<0.29,>=0.23
|
|
17
|
-
Requires-Dist: icechunk<3.0.0,>=2.0.
|
|
17
|
+
Requires-Dist: icechunk<3.0.0,>=2.0.6
|
|
18
18
|
Requires-Dist: numpy<3.0,>=1.23
|
|
19
19
|
Requires-Dist: packaging<27.0,>=23.0
|
|
20
20
|
Requires-Dist: pydantic[email]<3.0,>=2.9
|
|
@@ -10,17 +10,24 @@ Validity follows ``Cache-Control: max-age`` semantics: cached creds are
|
|
|
10
10
|
served while ``expires_at - CLOCK_SKEW_SAFETY > now``. The 30s safety only
|
|
11
11
|
protects against worker/server NTP-level clock drift; it is not a
|
|
12
12
|
pre-refresh window.
|
|
13
|
+
|
|
14
|
+
The cache is loop-agnostic: callers may live on any asyncio event loop in
|
|
15
|
+
the process. In-flight refreshes are coalesced via the singleflight
|
|
16
|
+
pattern using a single ``concurrent.futures.Future`` per key as the shared
|
|
17
|
+
result channel; each waiter bridges into its own loop via
|
|
18
|
+
``asyncio.wrap_future``. The race for who runs the fetcher is resolved by
|
|
19
|
+
``dict.setdefault``, which is atomic under the CPython GIL.
|
|
13
20
|
"""
|
|
14
21
|
|
|
15
22
|
from __future__ import annotations
|
|
16
23
|
|
|
17
24
|
import asyncio
|
|
25
|
+
import concurrent.futures
|
|
18
26
|
from collections.abc import Awaitable, Callable
|
|
19
|
-
from dataclasses import dataclass
|
|
27
|
+
from dataclasses import dataclass
|
|
20
28
|
from datetime import UTC, datetime, timedelta
|
|
21
29
|
from typing import Literal
|
|
22
30
|
|
|
23
|
-
from arraylake.asyn import get_background_loop
|
|
24
31
|
from arraylake.log_util import get_logger
|
|
25
32
|
from arraylake.types import OrgName, Platform, TempCredentials
|
|
26
33
|
|
|
@@ -34,7 +41,7 @@ CLOCK_SKEW_SAFETY = timedelta(seconds=30)
|
|
|
34
41
|
class CredentialCacheKey:
|
|
35
42
|
api_url: str
|
|
36
43
|
auth_key: int
|
|
37
|
-
scope: Literal["repo", "bucket"]
|
|
44
|
+
scope: Literal["repo", "bucket", "vcc"]
|
|
38
45
|
org: OrgName
|
|
39
46
|
identifier: str
|
|
40
47
|
platform: Platform
|
|
@@ -43,12 +50,20 @@ class CredentialCacheKey:
|
|
|
43
50
|
|
|
44
51
|
@dataclass
|
|
45
52
|
class _Entry:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
expires_at: datetime | None = None
|
|
53
|
+
creds: TempCredentials
|
|
54
|
+
expires_at: datetime | None
|
|
49
55
|
|
|
50
56
|
|
|
51
57
|
_CACHE: dict[CredentialCacheKey, _Entry] = {}
|
|
58
|
+
_INFLIGHT: dict[CredentialCacheKey, concurrent.futures.Future[TempCredentials]] = {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fresh(entry: _Entry | None, now: datetime) -> TempCredentials | None:
|
|
62
|
+
if entry is None or entry.expires_at is None:
|
|
63
|
+
return None
|
|
64
|
+
if entry.expires_at - CLOCK_SKEW_SAFETY > now:
|
|
65
|
+
return entry.creds
|
|
66
|
+
return None
|
|
52
67
|
|
|
53
68
|
|
|
54
69
|
async def get_or_refresh(
|
|
@@ -57,18 +72,29 @@ async def get_or_refresh(
|
|
|
57
72
|
*,
|
|
58
73
|
use_cache: bool = True,
|
|
59
74
|
) -> TempCredentials:
|
|
60
|
-
assert asyncio.get_running_loop() is get_background_loop(), "credential cache must be used from the arraylake background loop"
|
|
61
75
|
if not use_cache:
|
|
62
76
|
return await fetcher()
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
|
|
78
|
+
cached = _fresh(_CACHE.get(key), datetime.now(UTC))
|
|
79
|
+
if cached is not None:
|
|
80
|
+
return cached
|
|
81
|
+
|
|
82
|
+
my_future: concurrent.futures.Future[TempCredentials] = concurrent.futures.Future()
|
|
83
|
+
existing = _INFLIGHT.setdefault(key, my_future)
|
|
84
|
+
if existing is not my_future:
|
|
85
|
+
return await asyncio.wrap_future(existing)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
cached = _fresh(_CACHE.get(key), datetime.now(UTC))
|
|
89
|
+
if cached is not None:
|
|
90
|
+
my_future.set_result(cached)
|
|
91
|
+
return cached
|
|
92
|
+
|
|
68
93
|
creds = await fetcher()
|
|
69
94
|
exp = creds.expiration
|
|
70
95
|
if exp is not None and exp.tzinfo is None:
|
|
71
96
|
exp = exp.replace(tzinfo=UTC)
|
|
97
|
+
now = datetime.now(UTC)
|
|
72
98
|
if exp is not None and exp - CLOCK_SKEW_SAFETY <= now:
|
|
73
99
|
logger.warning(
|
|
74
100
|
"delegated credentials returned with lifetime <= clock-skew safety; server contract violation",
|
|
@@ -77,10 +103,25 @@ async def get_or_refresh(
|
|
|
77
103
|
org=key.org,
|
|
78
104
|
identifier=key.identifier,
|
|
79
105
|
)
|
|
80
|
-
|
|
81
|
-
|
|
106
|
+
_CACHE[key] = _Entry(creds=creds, expires_at=exp)
|
|
107
|
+
my_future.set_result(creds)
|
|
82
108
|
return creds
|
|
109
|
+
except BaseException as e:
|
|
110
|
+
my_future.set_exception(e)
|
|
111
|
+
raise
|
|
112
|
+
finally:
|
|
113
|
+
_INFLIGHT.pop(key, None)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def populate(key: CredentialCacheKey, creds: TempCredentials) -> None:
|
|
117
|
+
exp = creds.expiration
|
|
118
|
+
if exp is not None and exp.tzinfo is None:
|
|
119
|
+
exp = exp.replace(tzinfo=UTC)
|
|
120
|
+
if _fresh(_CACHE.get(key), datetime.now(UTC)) is not None:
|
|
121
|
+
return
|
|
122
|
+
_CACHE[key] = _Entry(creds=creds, expires_at=exp)
|
|
83
123
|
|
|
84
124
|
|
|
85
125
|
def clear() -> None:
|
|
86
126
|
_CACHE.clear()
|
|
127
|
+
_INFLIGHT.clear()
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.
|
|
22
|
-
__version_tuple__ = version_tuple = (1,
|
|
21
|
+
__version__ = version = '1.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 1, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -6,6 +6,7 @@ from arraylake.cli.auth import auth
|
|
|
6
6
|
from arraylake.cli.compute import app as compute_app
|
|
7
7
|
from arraylake.cli.config import app as config_app
|
|
8
8
|
from arraylake.cli.repo import app as repo_app
|
|
9
|
+
from arraylake.cli.skills import app as skills_app
|
|
9
10
|
from arraylake.cli.utils import rich_console
|
|
10
11
|
from arraylake.diagnostics import get_diagnostics
|
|
11
12
|
|
|
@@ -44,3 +45,4 @@ app.add_typer(auth, name="auth")
|
|
|
44
45
|
app.add_typer(repo_app, name="repo")
|
|
45
46
|
app.add_typer(config_app, name="config")
|
|
46
47
|
app.add_typer(compute_app, name="compute")
|
|
48
|
+
app.add_typer(skills_app, name="skills")
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Install Arraylake Agent Skills into agent-specific skill directories.
|
|
2
|
+
|
|
3
|
+
Skills follow the open Agent Skills spec (https://agentskills.io) and are
|
|
4
|
+
hosted as static content on the Earthmover docs site. This command fetches
|
|
5
|
+
the manifest, downloads each skill's files, and writes them into the
|
|
6
|
+
directories agent skill loaders read from:
|
|
7
|
+
|
|
8
|
+
- `~/.claude/skills/` (Claude Code, opencode via compat shim)
|
|
9
|
+
- `~/.agents/skills/` (Codex, Cursor, Gemini CLI, and others)
|
|
10
|
+
|
|
11
|
+
With `--project`, installs into `.claude/skills/` and `.agents/skills/`
|
|
12
|
+
under the current working directory instead.
|
|
13
|
+
|
|
14
|
+
Skill content ships independently of the `arraylake` client release. Re-run
|
|
15
|
+
`arraylake skills install` to pick up new content; no `pip install -U`
|
|
16
|
+
required.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import shutil
|
|
22
|
+
import tempfile
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
import typer
|
|
28
|
+
from packaging.version import InvalidVersion, Version
|
|
29
|
+
|
|
30
|
+
import arraylake
|
|
31
|
+
from arraylake.cli.utils import rich_console
|
|
32
|
+
from arraylake.config import config
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(help="Install Arraylake Agent Skills for AI coding agents")
|
|
35
|
+
|
|
36
|
+
DEFAULT_SKILLS_URI = "https://docs.earthmover.io/skills"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SkillEntry:
|
|
41
|
+
name: str
|
|
42
|
+
version: str
|
|
43
|
+
min_client_version: str | None
|
|
44
|
+
description: str
|
|
45
|
+
files: tuple[str, ...]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _target_dirs(project: bool) -> list[Path]:
|
|
49
|
+
base = Path.cwd() if project else Path.home()
|
|
50
|
+
return [base / ".claude" / "skills", base / ".agents" / "skills"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _skills_uri() -> str:
|
|
54
|
+
uri: str = config.get("skills.uri", DEFAULT_SKILLS_URI)
|
|
55
|
+
return uri.rstrip("/")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _client_version_ok(min_required: str | None) -> bool:
|
|
59
|
+
if not min_required:
|
|
60
|
+
return True
|
|
61
|
+
try:
|
|
62
|
+
return Version(arraylake.__version__) >= Version(min_required)
|
|
63
|
+
except InvalidVersion:
|
|
64
|
+
# Treat unparseable client versions (e.g. local dev builds without
|
|
65
|
+
# a tag) as "good enough" — don't block users on tooling weirdness.
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_manifest(payload: dict) -> list[SkillEntry]:
|
|
70
|
+
entries: list[SkillEntry] = []
|
|
71
|
+
for raw in payload.get("skills", []):
|
|
72
|
+
entries.append(
|
|
73
|
+
SkillEntry(
|
|
74
|
+
name=raw["name"],
|
|
75
|
+
version=str(raw.get("version", "0.0")),
|
|
76
|
+
min_client_version=raw.get("min_client_version"),
|
|
77
|
+
description=str(raw.get("description", "")),
|
|
78
|
+
files=tuple(raw.get("files", [])),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return entries
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fetch_manifest(uri: str) -> list[SkillEntry]:
|
|
85
|
+
url = f"{uri}/manifest.json"
|
|
86
|
+
with httpx.Client(timeout=20.0, follow_redirects=True) as client:
|
|
87
|
+
resp = client.get(url)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
return _parse_manifest(resp.json())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _empty_temporary_sibling(dest: Path, prefix: str) -> Path:
|
|
93
|
+
path = Path(tempfile.mkdtemp(prefix=prefix, dir=dest))
|
|
94
|
+
path.rmdir()
|
|
95
|
+
return path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _remove_path(path: Path) -> None:
|
|
99
|
+
if path.is_symlink() or path.is_file():
|
|
100
|
+
path.unlink()
|
|
101
|
+
elif path.exists():
|
|
102
|
+
shutil.rmtree(path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _install_skill(uri: str, entry: SkillEntry, dest: Path) -> str:
|
|
106
|
+
"""Download all of `entry.files` into `dest/<name>/...`.
|
|
107
|
+
|
|
108
|
+
Returns one-word status: written | conflict.
|
|
109
|
+
"""
|
|
110
|
+
skill_root = dest / entry.name
|
|
111
|
+
if skill_root.exists() and not skill_root.is_dir():
|
|
112
|
+
return "conflict"
|
|
113
|
+
|
|
114
|
+
tmp_root = Path(tempfile.mkdtemp(prefix=".arraylake-skill-", dir=dest))
|
|
115
|
+
backup_root: Path | None = None
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
|
119
|
+
for rel in entry.files:
|
|
120
|
+
if ".." in rel.split("/") or rel.startswith("/"):
|
|
121
|
+
raise RuntimeError(f"Refusing to write file outside skill root: {rel}")
|
|
122
|
+
file_url = f"{uri}/{entry.name}/{rel}"
|
|
123
|
+
resp = client.get(file_url)
|
|
124
|
+
resp.raise_for_status()
|
|
125
|
+
target = tmp_root / rel
|
|
126
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
target.write_bytes(resp.content)
|
|
128
|
+
|
|
129
|
+
if skill_root.exists() or skill_root.is_symlink():
|
|
130
|
+
# Pre-existing install, including legacy wheel-bundled symlinks.
|
|
131
|
+
# Move it aside only after every replacement file has downloaded.
|
|
132
|
+
backup_root = _empty_temporary_sibling(dest, ".arraylake-skill-backup-")
|
|
133
|
+
skill_root.replace(backup_root)
|
|
134
|
+
|
|
135
|
+
tmp_root.replace(skill_root)
|
|
136
|
+
if backup_root is not None:
|
|
137
|
+
_remove_path(backup_root)
|
|
138
|
+
backup_root = None
|
|
139
|
+
return "written"
|
|
140
|
+
except Exception:
|
|
141
|
+
if backup_root is not None and (backup_root.exists() or backup_root.is_symlink()):
|
|
142
|
+
if skill_root.exists() or skill_root.is_symlink():
|
|
143
|
+
_remove_path(skill_root)
|
|
144
|
+
backup_root.replace(skill_root)
|
|
145
|
+
backup_root = None
|
|
146
|
+
raise
|
|
147
|
+
finally:
|
|
148
|
+
if tmp_root.exists() or tmp_root.is_symlink():
|
|
149
|
+
_remove_path(tmp_root)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command()
|
|
153
|
+
def install(
|
|
154
|
+
project: bool = typer.Option(False, "--project", help="Install into the current directory instead of $HOME."),
|
|
155
|
+
url: str | None = typer.Option(
|
|
156
|
+
None,
|
|
157
|
+
"--url",
|
|
158
|
+
help=f"Override the skills server URL. Default: skills.uri config or {DEFAULT_SKILLS_URI}.",
|
|
159
|
+
),
|
|
160
|
+
) -> None:
|
|
161
|
+
"""**Install** Arraylake skills into agent skill directories.
|
|
162
|
+
|
|
163
|
+
Default: user scope (`~/.claude/skills/` and `~/.agents/skills/`).
|
|
164
|
+
With `--project`: project scope under the current working directory.
|
|
165
|
+
|
|
166
|
+
Fetches the latest published skills from the Earthmover docs site
|
|
167
|
+
(configurable via `--url` or the `skills.uri` config key). Re-run this
|
|
168
|
+
to pick up content updates — they ship independently of the `arraylake`
|
|
169
|
+
package release cycle.
|
|
170
|
+
"""
|
|
171
|
+
uri = (url or _skills_uri()).rstrip("/")
|
|
172
|
+
rich_console.print(f"Fetching skill manifest from [cyan]{uri}/manifest.json[/cyan]")
|
|
173
|
+
try:
|
|
174
|
+
entries = _fetch_manifest(uri)
|
|
175
|
+
except httpx.HTTPError as err:
|
|
176
|
+
rich_console.print(f"[red]Failed to fetch skills from {uri}: {err}[/red]")
|
|
177
|
+
raise typer.Exit(code=1) from err
|
|
178
|
+
|
|
179
|
+
if not entries:
|
|
180
|
+
rich_console.print("[yellow]Manifest contained no skills; nothing to install.[/yellow]")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
ok = True
|
|
184
|
+
for target in _target_dirs(project):
|
|
185
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
for entry in entries:
|
|
187
|
+
if not _client_version_ok(entry.min_client_version):
|
|
188
|
+
rich_console.print(
|
|
189
|
+
f"[yellow]skip[/yellow] {target / entry.name} "
|
|
190
|
+
f"(skill requires arraylake>={entry.min_client_version}, "
|
|
191
|
+
f"installed {arraylake.__version__})"
|
|
192
|
+
)
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
status = _install_skill(uri, entry, target)
|
|
196
|
+
except httpx.HTTPError as err:
|
|
197
|
+
rich_console.print(f"[red]error[/red] {target / entry.name}: {err}")
|
|
198
|
+
ok = False
|
|
199
|
+
continue
|
|
200
|
+
if status == "conflict":
|
|
201
|
+
ok = False
|
|
202
|
+
rich_console.print(f"[yellow]skip[/yellow] {target / entry.name} (path exists and is not a directory)")
|
|
203
|
+
else:
|
|
204
|
+
rich_console.print(f"[green]{status:8}[/green]{target / entry.name}")
|
|
205
|
+
|
|
206
|
+
if not ok:
|
|
207
|
+
rich_console.print("\n[yellow]One or more skills were skipped. Resolve the conflicts above and re-run.[/yellow]")
|
|
208
|
+
raise typer.Exit(code=1)
|