strawhub 0.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.
- strawhub-0.1.1/.gitignore +26 -0
- strawhub-0.1.1/PKG-INFO +11 -0
- strawhub-0.1.1/pyproject.toml +33 -0
- strawhub-0.1.1/src/strawhub/__init__.py +3 -0
- strawhub-0.1.1/src/strawhub/cli.py +27 -0
- strawhub-0.1.1/src/strawhub/client.py +145 -0
- strawhub-0.1.1/src/strawhub/commands/__init__.py +0 -0
- strawhub-0.1.1/src/strawhub/commands/info.py +27 -0
- strawhub-0.1.1/src/strawhub/commands/install.py +207 -0
- strawhub-0.1.1/src/strawhub/commands/list.py +41 -0
- strawhub-0.1.1/src/strawhub/commands/login.py +28 -0
- strawhub-0.1.1/src/strawhub/commands/resolve_cmd.py +56 -0
- strawhub-0.1.1/src/strawhub/commands/search.py +25 -0
- strawhub-0.1.1/src/strawhub/commands/uninstall.py +87 -0
- strawhub-0.1.1/src/strawhub/commands/whoami.py +23 -0
- strawhub-0.1.1/src/strawhub/config.py +35 -0
- strawhub-0.1.1/src/strawhub/display.py +95 -0
- strawhub-0.1.1/src/strawhub/errors.py +34 -0
- strawhub-0.1.1/src/strawhub/frontmatter.py +173 -0
- strawhub-0.1.1/src/strawhub/lockfile.py +167 -0
- strawhub-0.1.1/src/strawhub/paths.py +74 -0
- strawhub-0.1.1/src/strawhub/resolver.py +244 -0
- strawhub-0.1.1/src/strawhub/version_spec.py +118 -0
- strawhub-0.1.1/tests/conftest.py +81 -0
- strawhub-0.1.1/tests/test_frontmatter.py +128 -0
- strawhub-0.1.1/tests/test_lockfile.py +227 -0
- strawhub-0.1.1/tests/test_paths.py +76 -0
- strawhub-0.1.1/tests/test_resolver.py +253 -0
- strawhub-0.1.1/tests/test_version_spec.py +160 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.output/
|
|
4
|
+
.vinxi/
|
|
5
|
+
.convex/
|
|
6
|
+
*.local
|
|
7
|
+
.env
|
|
8
|
+
.env.*
|
|
9
|
+
!.env.example
|
|
10
|
+
app.config.*
|
|
11
|
+
coverage/
|
|
12
|
+
.claude/
|
|
13
|
+
.tanstack/
|
|
14
|
+
.DS_Store
|
|
15
|
+
|
|
16
|
+
# Playwright
|
|
17
|
+
test-results/
|
|
18
|
+
playwright-report/
|
|
19
|
+
.vercel
|
|
20
|
+
|
|
21
|
+
# Python CLI
|
|
22
|
+
__pycache__/
|
|
23
|
+
*.pyc
|
|
24
|
+
*.egg-info/
|
|
25
|
+
cli/dist/
|
|
26
|
+
cli/.venv/
|
strawhub-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: strawhub
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: CLI for the StrawHub agent skill and role registry
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click>=8.1
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: rich>=13.0
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'test'
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "strawhub"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "CLI for the StrawHub agent skill and role registry"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"click>=8.1",
|
|
8
|
+
"httpx>=0.27",
|
|
9
|
+
"rich>=13.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
strawhub = "strawhub.cli:cli"
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = [
|
|
17
|
+
"pytest>=7.0",
|
|
18
|
+
"pytest-cov>=4.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.version]
|
|
26
|
+
path = "src/strawhub/__init__.py"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/strawhub"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from strawhub import __version__
|
|
4
|
+
from strawhub.commands.login import login
|
|
5
|
+
from strawhub.commands.whoami import whoami
|
|
6
|
+
from strawhub.commands.search import search
|
|
7
|
+
from strawhub.commands.info import info
|
|
8
|
+
from strawhub.commands.list import list_cmd
|
|
9
|
+
from strawhub.commands.install import install
|
|
10
|
+
from strawhub.commands.uninstall import uninstall
|
|
11
|
+
from strawhub.commands.resolve_cmd import resolve_cmd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
@click.version_option(version=__version__, prog_name="strawhub")
|
|
16
|
+
def cli():
|
|
17
|
+
"""StrawHub CLI - discover and install agent skills and roles."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
cli.add_command(login)
|
|
21
|
+
cli.add_command(whoami)
|
|
22
|
+
cli.add_command(search)
|
|
23
|
+
cli.add_command(info)
|
|
24
|
+
cli.add_command(list_cmd, name="list")
|
|
25
|
+
cli.add_command(install)
|
|
26
|
+
cli.add_command(uninstall)
|
|
27
|
+
cli.add_command(resolve_cmd, name="resolve")
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from strawhub.config import get_api_url, get_token
|
|
4
|
+
from strawhub.errors import AuthError, NotFoundError, RateLimitError, APIError, StrawHubError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StrawHubClient:
|
|
8
|
+
"""HTTP client for the StrawHub API."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, token: str | None = None, api_url: str | None = None):
|
|
11
|
+
self.api_url = api_url or get_api_url()
|
|
12
|
+
self.token = token or get_token()
|
|
13
|
+
self._client = httpx.Client(
|
|
14
|
+
base_url=self.api_url,
|
|
15
|
+
timeout=30.0,
|
|
16
|
+
headers=self._build_headers(),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def __enter__(self):
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def __exit__(self, *args):
|
|
23
|
+
self.close()
|
|
24
|
+
|
|
25
|
+
def _build_headers(self) -> dict:
|
|
26
|
+
headers = {"Accept": "application/json"}
|
|
27
|
+
if self.token:
|
|
28
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
29
|
+
return headers
|
|
30
|
+
|
|
31
|
+
def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
32
|
+
try:
|
|
33
|
+
return self._client.request(method, url, **kwargs)
|
|
34
|
+
except httpx.ConnectError:
|
|
35
|
+
raise StrawHubError(
|
|
36
|
+
f"Could not connect to {self.api_url}. "
|
|
37
|
+
"Check your network or set STRAWHUB_API_URL."
|
|
38
|
+
)
|
|
39
|
+
except httpx.TimeoutException:
|
|
40
|
+
raise StrawHubError(f"Request to {self.api_url} timed out.")
|
|
41
|
+
|
|
42
|
+
def _parse_json(self, resp: httpx.Response) -> dict:
|
|
43
|
+
content_type = resp.headers.get("content-type", "")
|
|
44
|
+
if "application/json" not in content_type and "text/html" in content_type:
|
|
45
|
+
raise StrawHubError(
|
|
46
|
+
f"Server returned HTML instead of JSON. "
|
|
47
|
+
f"Is STRAWHUB_API_URL set to the Convex site URL?"
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
return resp.json()
|
|
51
|
+
except Exception:
|
|
52
|
+
raise StrawHubError(f"Unexpected response from server: {resp.text[:200]}")
|
|
53
|
+
|
|
54
|
+
def _handle_response(self, resp: httpx.Response) -> dict:
|
|
55
|
+
if resp.status_code == 401:
|
|
56
|
+
msg = "Unauthorized"
|
|
57
|
+
try:
|
|
58
|
+
msg = resp.json().get("error", msg)
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
raise AuthError(msg)
|
|
62
|
+
if resp.status_code == 404:
|
|
63
|
+
msg = "Not found"
|
|
64
|
+
try:
|
|
65
|
+
msg = resp.json().get("error", msg)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
raise NotFoundError(msg)
|
|
69
|
+
if resp.status_code == 429:
|
|
70
|
+
retry = int(resp.headers.get("Retry-After", "60"))
|
|
71
|
+
raise RateLimitError(retry)
|
|
72
|
+
if resp.status_code >= 400:
|
|
73
|
+
msg = resp.text
|
|
74
|
+
try:
|
|
75
|
+
msg = resp.json().get("error", msg)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
raise APIError(resp.status_code, msg)
|
|
79
|
+
return self._parse_json(resp)
|
|
80
|
+
|
|
81
|
+
def whoami(self) -> dict:
|
|
82
|
+
resp = self._request("GET","/api/v1/whoami")
|
|
83
|
+
return self._handle_response(resp)
|
|
84
|
+
|
|
85
|
+
def search(self, query: str, kind: str = "all", limit: int = 20) -> dict:
|
|
86
|
+
resp = self._request("GET",
|
|
87
|
+
"/api/v1/search", params={"q": query, "kind": kind, "limit": limit}
|
|
88
|
+
)
|
|
89
|
+
return self._handle_response(resp)
|
|
90
|
+
|
|
91
|
+
def list_skills(self, limit: int = 50, sort: str = "updated") -> dict:
|
|
92
|
+
resp = self._request("GET",
|
|
93
|
+
"/api/v1/skills", params={"limit": limit, "sort": sort}
|
|
94
|
+
)
|
|
95
|
+
return self._handle_response(resp)
|
|
96
|
+
|
|
97
|
+
def list_roles(self, limit: int = 50, sort: str = "updated") -> dict:
|
|
98
|
+
resp = self._request("GET",
|
|
99
|
+
"/api/v1/roles", params={"limit": limit, "sort": sort}
|
|
100
|
+
)
|
|
101
|
+
return self._handle_response(resp)
|
|
102
|
+
|
|
103
|
+
def get_skill(self, slug: str) -> dict:
|
|
104
|
+
resp = self._request("GET",f"/api/v1/skills/{slug}")
|
|
105
|
+
return self._handle_response(resp)
|
|
106
|
+
|
|
107
|
+
def get_role(self, slug: str) -> dict:
|
|
108
|
+
resp = self._request("GET",f"/api/v1/roles/{slug}")
|
|
109
|
+
return self._handle_response(resp)
|
|
110
|
+
|
|
111
|
+
def get_skill_file(self, slug: str, path: str = "SKILL.md") -> str:
|
|
112
|
+
resp = self._request("GET",
|
|
113
|
+
f"/api/v1/skills/{slug}/file", params={"path": path}
|
|
114
|
+
)
|
|
115
|
+
if resp.status_code >= 400:
|
|
116
|
+
self._handle_response(resp)
|
|
117
|
+
return resp.text
|
|
118
|
+
|
|
119
|
+
def get_role_file(self, slug: str, path: str = "ROLE.md") -> str:
|
|
120
|
+
resp = self._request("GET",
|
|
121
|
+
f"/api/v1/roles/{slug}/file", params={"path": path}
|
|
122
|
+
)
|
|
123
|
+
if resp.status_code >= 400:
|
|
124
|
+
self._handle_response(resp)
|
|
125
|
+
return resp.text
|
|
126
|
+
|
|
127
|
+
def resolve_role_deps(self, slug: str) -> dict:
|
|
128
|
+
resp = self._request("GET",f"/api/v1/roles/{slug}/resolve")
|
|
129
|
+
return self._handle_response(resp)
|
|
130
|
+
|
|
131
|
+
def get_info(self, slug: str, kind: str | None = None) -> tuple[str, dict]:
|
|
132
|
+
"""Get info for a slug, auto-detecting kind if not specified.
|
|
133
|
+
Returns (kind, detail_dict)."""
|
|
134
|
+
if kind == "skill":
|
|
135
|
+
return ("skill", self.get_skill(slug))
|
|
136
|
+
if kind == "role":
|
|
137
|
+
return ("role", self.get_role(slug))
|
|
138
|
+
# Auto-detect: try skill first, then role
|
|
139
|
+
try:
|
|
140
|
+
return ("skill", self.get_skill(slug))
|
|
141
|
+
except NotFoundError:
|
|
142
|
+
return ("role", self.get_role(slug))
|
|
143
|
+
|
|
144
|
+
def close(self):
|
|
145
|
+
self._client.close()
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from strawhub.client import StrawHubClient
|
|
4
|
+
from strawhub.display import print_detail, print_error
|
|
5
|
+
from strawhub.errors import NotFoundError, StrawHubError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command()
|
|
9
|
+
@click.argument("slug")
|
|
10
|
+
@click.option(
|
|
11
|
+
"--kind",
|
|
12
|
+
type=click.Choice(["skill", "role"]),
|
|
13
|
+
default=None,
|
|
14
|
+
help="Specify kind (auto-detects if omitted)",
|
|
15
|
+
)
|
|
16
|
+
def info(slug, kind):
|
|
17
|
+
"""Show detailed information about a skill or role."""
|
|
18
|
+
with StrawHubClient() as client:
|
|
19
|
+
try:
|
|
20
|
+
detected_kind, detail = client.get_info(slug, kind=kind)
|
|
21
|
+
print_detail(detected_kind, detail)
|
|
22
|
+
except NotFoundError:
|
|
23
|
+
print_error(f"'{slug}' not found as a skill or role.")
|
|
24
|
+
raise SystemExit(1)
|
|
25
|
+
except StrawHubError as e:
|
|
26
|
+
print_error(str(e))
|
|
27
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from strawhub.client import StrawHubClient
|
|
6
|
+
from strawhub.display import print_success, print_error, console
|
|
7
|
+
from strawhub.errors import NotFoundError, StrawHubError, DependencyError
|
|
8
|
+
from strawhub.frontmatter import parse_frontmatter
|
|
9
|
+
from strawhub.lockfile import Lockfile, PackageRef
|
|
10
|
+
from strawhub.paths import (
|
|
11
|
+
get_root,
|
|
12
|
+
get_local_root,
|
|
13
|
+
get_global_root,
|
|
14
|
+
get_lockfile_path,
|
|
15
|
+
get_package_dir,
|
|
16
|
+
package_exists,
|
|
17
|
+
)
|
|
18
|
+
from strawhub.version_spec import parse_dependency_spec
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.command()
|
|
22
|
+
@click.argument("slug")
|
|
23
|
+
@click.option(
|
|
24
|
+
"--kind",
|
|
25
|
+
type=click.Choice(["skill", "role"]),
|
|
26
|
+
default=None,
|
|
27
|
+
help="Specify kind (auto-detects if omitted)",
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--global",
|
|
31
|
+
"is_global",
|
|
32
|
+
is_flag=True,
|
|
33
|
+
default=False,
|
|
34
|
+
help="Install to global directory (~/.strawpot or STRAWPOT_HOME)",
|
|
35
|
+
)
|
|
36
|
+
def install(slug, kind, is_global):
|
|
37
|
+
"""Install a skill or role with all dependencies."""
|
|
38
|
+
root = get_root(is_global)
|
|
39
|
+
lockfile = Lockfile.load(get_lockfile_path(root))
|
|
40
|
+
|
|
41
|
+
with StrawHubClient() as client:
|
|
42
|
+
try:
|
|
43
|
+
detected_kind, detail = client.get_info(slug, kind=kind)
|
|
44
|
+
lv = detail.get("latestVersion")
|
|
45
|
+
if not lv:
|
|
46
|
+
print_error(f"'{slug}' has no published versions.")
|
|
47
|
+
raise SystemExit(1)
|
|
48
|
+
|
|
49
|
+
version = lv["version"]
|
|
50
|
+
root_ref = PackageRef(kind=detected_kind, slug=slug, version=version)
|
|
51
|
+
|
|
52
|
+
# Check if already installed in either scope
|
|
53
|
+
if _already_installed(root_ref):
|
|
54
|
+
scope = _installed_scope(root_ref)
|
|
55
|
+
console.print(
|
|
56
|
+
f"'{slug}' v{version} is already installed ({scope})."
|
|
57
|
+
)
|
|
58
|
+
# Still register as direct install if not already
|
|
59
|
+
if not lockfile.is_direct_install(root_ref.key):
|
|
60
|
+
lockfile.add_package(root_ref)
|
|
61
|
+
lockfile.add_direct_install(root_ref)
|
|
62
|
+
lockfile.save()
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Resolve dependencies
|
|
66
|
+
dep_list = _resolve_deps(client, detected_kind, slug, detail)
|
|
67
|
+
|
|
68
|
+
# Install dependencies first (leaves before root)
|
|
69
|
+
for dep in dep_list:
|
|
70
|
+
dep_ref = PackageRef(
|
|
71
|
+
kind=dep["kind"], slug=dep["slug"], version=dep["version"]
|
|
72
|
+
)
|
|
73
|
+
if _already_installed(dep_ref):
|
|
74
|
+
# Register in lockfile if in our scope but not tracked
|
|
75
|
+
if package_exists(root, dep_ref.kind, dep_ref.slug, dep_ref.version):
|
|
76
|
+
lockfile.add_package(dep_ref, dependent=root_ref)
|
|
77
|
+
console.print(
|
|
78
|
+
f" Skipped {dep['kind']} '{dep['slug']}' v{dep['version']}"
|
|
79
|
+
" (already installed)"
|
|
80
|
+
)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
_download_package(client, dep["kind"], dep["slug"], dep["version"], root)
|
|
84
|
+
lockfile.add_package(dep_ref, dependent=root_ref)
|
|
85
|
+
console.print(
|
|
86
|
+
f" Installed {dep['kind']} '{dep['slug']}' v{dep['version']}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Install the root package
|
|
90
|
+
_download_package(client, detected_kind, slug, version, root)
|
|
91
|
+
lockfile.add_package(root_ref)
|
|
92
|
+
lockfile.add_direct_install(root_ref)
|
|
93
|
+
lockfile.save()
|
|
94
|
+
print_success(f"Installed {detected_kind} '{slug}' v{version}")
|
|
95
|
+
|
|
96
|
+
except NotFoundError:
|
|
97
|
+
print_error(f"'{slug}' not found.")
|
|
98
|
+
raise SystemExit(1)
|
|
99
|
+
except (StrawHubError, DependencyError) as e:
|
|
100
|
+
print_error(str(e))
|
|
101
|
+
raise SystemExit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_deps(
|
|
105
|
+
client: StrawHubClient, kind: str, slug: str, detail: dict
|
|
106
|
+
) -> list[dict]:
|
|
107
|
+
"""Resolve all transitive dependencies for a package.
|
|
108
|
+
|
|
109
|
+
For roles, uses the server-side /resolve endpoint.
|
|
110
|
+
For skills, resolves client-side by parsing SKILL.md frontmatter.
|
|
111
|
+
"""
|
|
112
|
+
if kind == "role":
|
|
113
|
+
console.print("Resolving dependencies...")
|
|
114
|
+
resolved = client.resolve_role_deps(slug)
|
|
115
|
+
return resolved.get("dependencies", [])
|
|
116
|
+
else:
|
|
117
|
+
# Skills: resolve client-side via frontmatter parsing
|
|
118
|
+
dep_list: list[dict] = []
|
|
119
|
+
visited: set[str] = set()
|
|
120
|
+
_resolve_skill_deps(client, slug, visited, dep_list)
|
|
121
|
+
# Remove the root skill itself from the dep list (it was added by recursion)
|
|
122
|
+
dep_list = [d for d in dep_list if d["slug"] != slug]
|
|
123
|
+
if dep_list:
|
|
124
|
+
console.print("Resolving dependencies...")
|
|
125
|
+
return dep_list
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_skill_deps(
|
|
129
|
+
client: StrawHubClient,
|
|
130
|
+
slug: str,
|
|
131
|
+
visited: set[str],
|
|
132
|
+
dep_list: list[dict],
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Recursively resolve skill dependencies by fetching SKILL.md and parsing frontmatter."""
|
|
135
|
+
if slug in visited:
|
|
136
|
+
return
|
|
137
|
+
visited.add(slug)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
_, detail = client.get_info(slug, kind="skill")
|
|
141
|
+
except NotFoundError:
|
|
142
|
+
raise DependencyError(f"Dependency skill '{slug}' not found in registry")
|
|
143
|
+
|
|
144
|
+
lv = detail.get("latestVersion")
|
|
145
|
+
if not lv:
|
|
146
|
+
raise DependencyError(f"Dependency skill '{slug}' has no published versions")
|
|
147
|
+
|
|
148
|
+
# Get the SKILL.md content and parse frontmatter for dependencies
|
|
149
|
+
deps = detail.get("dependencies", {})
|
|
150
|
+
skill_deps = deps.get("skills", [])
|
|
151
|
+
|
|
152
|
+
# Also check latestVersion.dependencies if top-level is empty
|
|
153
|
+
if not skill_deps and lv.get("dependencies"):
|
|
154
|
+
skill_deps = lv["dependencies"].get("skills", [])
|
|
155
|
+
|
|
156
|
+
# Recurse into transitive deps first (DFS, leaves first)
|
|
157
|
+
for dep_spec_str in skill_deps:
|
|
158
|
+
spec = parse_dependency_spec(dep_spec_str)
|
|
159
|
+
_resolve_skill_deps(client, spec.slug, visited, dep_list)
|
|
160
|
+
|
|
161
|
+
dep_list.append({
|
|
162
|
+
"kind": "skill",
|
|
163
|
+
"slug": slug,
|
|
164
|
+
"version": lv["version"],
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _already_installed(ref: PackageRef) -> bool:
|
|
169
|
+
"""Check if a package exists in either local or global scope."""
|
|
170
|
+
return (
|
|
171
|
+
package_exists(get_local_root(), ref.kind, ref.slug, ref.version)
|
|
172
|
+
or package_exists(get_global_root(), ref.kind, ref.slug, ref.version)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _installed_scope(ref: PackageRef) -> str:
|
|
177
|
+
"""Return 'local' or 'global' indicating where the package is installed."""
|
|
178
|
+
if package_exists(get_local_root(), ref.kind, ref.slug, ref.version):
|
|
179
|
+
return "local"
|
|
180
|
+
return "global"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _download_package(
|
|
184
|
+
client: StrawHubClient,
|
|
185
|
+
kind: str,
|
|
186
|
+
slug: str,
|
|
187
|
+
version: str,
|
|
188
|
+
root: Path,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Download a package's files into root/{kind}s/{slug}-{version}/."""
|
|
191
|
+
target_dir = get_package_dir(root, kind, slug, version)
|
|
192
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
|
|
194
|
+
# Fetch detail to get file list
|
|
195
|
+
_, detail = client.get_info(slug, kind=kind)
|
|
196
|
+
lv = detail.get("latestVersion", {})
|
|
197
|
+
|
|
198
|
+
for f in lv.get("files", []):
|
|
199
|
+
file_path = f["path"]
|
|
200
|
+
if kind == "skill":
|
|
201
|
+
content = client.get_skill_file(slug, path=file_path)
|
|
202
|
+
else:
|
|
203
|
+
content = client.get_role_file(slug, path=file_path)
|
|
204
|
+
|
|
205
|
+
out_file = target_dir / file_path
|
|
206
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
out_file.write_text(content)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from strawhub.client import StrawHubClient
|
|
4
|
+
from strawhub.display import print_list_table, print_error, console
|
|
5
|
+
from strawhub.errors import StrawHubError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command("list")
|
|
9
|
+
@click.option(
|
|
10
|
+
"--kind",
|
|
11
|
+
type=click.Choice(["skills", "roles", "all"]),
|
|
12
|
+
default="all",
|
|
13
|
+
)
|
|
14
|
+
@click.option("--limit", type=int, default=50, help="Max results (1-200)")
|
|
15
|
+
@click.option(
|
|
16
|
+
"--sort",
|
|
17
|
+
type=click.Choice(["updated", "downloads", "stars"]),
|
|
18
|
+
default="updated",
|
|
19
|
+
)
|
|
20
|
+
def list_cmd(kind, limit, sort):
|
|
21
|
+
"""List skills and/or roles."""
|
|
22
|
+
with StrawHubClient() as client:
|
|
23
|
+
try:
|
|
24
|
+
if kind in ("skills", "all"):
|
|
25
|
+
data = client.list_skills(limit=limit, sort=sort)
|
|
26
|
+
items = data.get("items", [])
|
|
27
|
+
if items:
|
|
28
|
+
print_list_table(items, "skill")
|
|
29
|
+
elif kind == "skills":
|
|
30
|
+
console.print("No skills found.")
|
|
31
|
+
|
|
32
|
+
if kind in ("roles", "all"):
|
|
33
|
+
data = client.list_roles(limit=limit, sort=sort)
|
|
34
|
+
items = data.get("items", [])
|
|
35
|
+
if items:
|
|
36
|
+
print_list_table(items, "role")
|
|
37
|
+
elif kind == "roles":
|
|
38
|
+
console.print("No roles found.")
|
|
39
|
+
except StrawHubError as e:
|
|
40
|
+
print_error(str(e))
|
|
41
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from strawhub.client import StrawHubClient
|
|
4
|
+
from strawhub.config import set_token
|
|
5
|
+
from strawhub.display import print_user_info, print_success, print_error
|
|
6
|
+
from strawhub.errors import StrawHubError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command()
|
|
10
|
+
def login():
|
|
11
|
+
"""Authenticate with your StrawHub API token."""
|
|
12
|
+
click.echo("Get your API token from: https://strawhub.dev/settings")
|
|
13
|
+
token = click.prompt("API token", hide_input=True)
|
|
14
|
+
|
|
15
|
+
if not token.startswith("sh_"):
|
|
16
|
+
print_error("Invalid token format. Tokens start with 'sh_'.")
|
|
17
|
+
raise SystemExit(1)
|
|
18
|
+
|
|
19
|
+
with StrawHubClient(token=token) as client:
|
|
20
|
+
try:
|
|
21
|
+
user = client.whoami()
|
|
22
|
+
except StrawHubError as e:
|
|
23
|
+
print_error(str(e))
|
|
24
|
+
raise SystemExit(1)
|
|
25
|
+
|
|
26
|
+
set_token(token)
|
|
27
|
+
print_success("Logged in successfully!")
|
|
28
|
+
print_user_info(user)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from strawhub.display import print_error, console
|
|
6
|
+
from strawhub.errors import DependencyError
|
|
7
|
+
from strawhub.resolver import resolve
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command("resolve")
|
|
11
|
+
@click.argument("slug")
|
|
12
|
+
@click.option(
|
|
13
|
+
"--kind",
|
|
14
|
+
type=click.Choice(["skill", "role"]),
|
|
15
|
+
default=None,
|
|
16
|
+
help="Specify kind (auto-detects if omitted)",
|
|
17
|
+
)
|
|
18
|
+
@click.option(
|
|
19
|
+
"--version",
|
|
20
|
+
"ver",
|
|
21
|
+
default=None,
|
|
22
|
+
help="Resolve a specific version",
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--global",
|
|
26
|
+
"is_global",
|
|
27
|
+
is_flag=True,
|
|
28
|
+
default=False,
|
|
29
|
+
help="Only search global directory",
|
|
30
|
+
)
|
|
31
|
+
def resolve_cmd(slug, kind, ver, is_global):
|
|
32
|
+
"""Resolve a slug to its installed path and dependency paths.
|
|
33
|
+
|
|
34
|
+
Outputs JSON with the resolved package and all transitive
|
|
35
|
+
dependencies, including absolute file paths.
|
|
36
|
+
"""
|
|
37
|
+
from strawhub.paths import get_global_root, get_local_root
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
if is_global:
|
|
41
|
+
# Only search global scope
|
|
42
|
+
gr = get_global_root()
|
|
43
|
+
result = resolve(
|
|
44
|
+
slug,
|
|
45
|
+
kind=kind,
|
|
46
|
+
version=ver,
|
|
47
|
+
local_root=gr, # Use global as both to skip local
|
|
48
|
+
global_root=gr,
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
result = resolve(slug, kind=kind, version=ver)
|
|
52
|
+
|
|
53
|
+
console.print_json(json.dumps(result))
|
|
54
|
+
except DependencyError as e:
|
|
55
|
+
print_error(str(e))
|
|
56
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from strawhub.client import StrawHubClient
|
|
4
|
+
from strawhub.display import print_search_results, print_error, console
|
|
5
|
+
from strawhub.errors import StrawHubError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command()
|
|
9
|
+
@click.argument("query")
|
|
10
|
+
@click.option("--kind", type=click.Choice(["skill", "role", "all"]), default="all")
|
|
11
|
+
@click.option("--limit", type=int, default=20, help="Max results (1-100)")
|
|
12
|
+
def search(query, kind, limit):
|
|
13
|
+
"""Search for skills and roles."""
|
|
14
|
+
with StrawHubClient() as client:
|
|
15
|
+
try:
|
|
16
|
+
data = client.search(query, kind=kind, limit=limit)
|
|
17
|
+
results = data.get("results", [])
|
|
18
|
+
if not results:
|
|
19
|
+
console.print("No results found.")
|
|
20
|
+
return
|
|
21
|
+
print_search_results(results)
|
|
22
|
+
console.print(f"\n{data['count']} result(s) found.")
|
|
23
|
+
except StrawHubError as e:
|
|
24
|
+
print_error(str(e))
|
|
25
|
+
raise SystemExit(1)
|