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.
@@ -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/
@@ -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,3 @@
1
+ """StrawHub CLI - discover, publish, and install agent skills and roles."""
2
+
3
+ __version__ = "0.1.1"
@@ -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)