ds-skills-cli 0.1.0__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.
- ds_skills_cli-0.1.0/.gitignore +6 -0
- ds_skills_cli-0.1.0/PKG-INFO +67 -0
- ds_skills_cli-0.1.0/README.md +48 -0
- ds_skills_cli-0.1.0/pyproject.toml +32 -0
- ds_skills_cli-0.1.0/src/ds_skills_cli/__init__.py +3 -0
- ds_skills_cli-0.1.0/src/ds_skills_cli/__main__.py +5 -0
- ds_skills_cli-0.1.0/src/ds_skills_cli/cli.py +235 -0
- ds_skills_cli-0.1.0/src/ds_skills_cli/client.py +136 -0
- ds_skills_cli-0.1.0/src/ds_skills_cli/output.py +26 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ds-skills-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-friendly CLI to browse, search, and pull data science skills from ds-skills.com
|
|
5
|
+
Project-URL: Homepage, https://ds-skills.com
|
|
6
|
+
Project-URL: Repository, https://github.com/wenmin-wu/ds-skills
|
|
7
|
+
Project-URL: Issues, https://github.com/wenmin-wu/ds-skills/issues
|
|
8
|
+
Author-email: wenmin-wu <wuwenmin1991@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agent,cli,data-science,kaggle,skills
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# ds-skills-cli
|
|
21
|
+
|
|
22
|
+
Agent-friendly CLI to browse, search, and pull data science skills from [ds-skills.com](https://ds-skills.com).
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install ds-skills-cli
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# List all skills
|
|
34
|
+
ds-skills list
|
|
35
|
+
ds-skills list --domain nlp
|
|
36
|
+
|
|
37
|
+
# Search
|
|
38
|
+
ds-skills search "deberta"
|
|
39
|
+
|
|
40
|
+
# Show full skill content
|
|
41
|
+
ds-skills show nlp/deberta-classification
|
|
42
|
+
|
|
43
|
+
# Pull a skill to current directory
|
|
44
|
+
ds-skills pull nlp/deberta-classification
|
|
45
|
+
ds-skills pull nlp/deberta-classification --dest ./my-skills
|
|
46
|
+
|
|
47
|
+
# Pull all skills in a domain
|
|
48
|
+
ds-skills pull nlp --dest ./skills
|
|
49
|
+
|
|
50
|
+
# Install to an AI agent
|
|
51
|
+
ds-skills install --agent claude-code
|
|
52
|
+
ds-skills install --agent cursor --domain nlp
|
|
53
|
+
|
|
54
|
+
# Stats
|
|
55
|
+
ds-skills stats
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
All commands support `--json` for structured output (JSON to stdout, human messages to stderr).
|
|
59
|
+
|
|
60
|
+
## Exit Codes
|
|
61
|
+
|
|
62
|
+
| Code | Meaning |
|
|
63
|
+
|------|---------|
|
|
64
|
+
| 0 | Success |
|
|
65
|
+
| 1 | General error |
|
|
66
|
+
| 2 | Not found |
|
|
67
|
+
| 3 | Invalid input |
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ds-skills-cli
|
|
2
|
+
|
|
3
|
+
Agent-friendly CLI to browse, search, and pull data science skills from [ds-skills.com](https://ds-skills.com).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ds-skills-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# List all skills
|
|
15
|
+
ds-skills list
|
|
16
|
+
ds-skills list --domain nlp
|
|
17
|
+
|
|
18
|
+
# Search
|
|
19
|
+
ds-skills search "deberta"
|
|
20
|
+
|
|
21
|
+
# Show full skill content
|
|
22
|
+
ds-skills show nlp/deberta-classification
|
|
23
|
+
|
|
24
|
+
# Pull a skill to current directory
|
|
25
|
+
ds-skills pull nlp/deberta-classification
|
|
26
|
+
ds-skills pull nlp/deberta-classification --dest ./my-skills
|
|
27
|
+
|
|
28
|
+
# Pull all skills in a domain
|
|
29
|
+
ds-skills pull nlp --dest ./skills
|
|
30
|
+
|
|
31
|
+
# Install to an AI agent
|
|
32
|
+
ds-skills install --agent claude-code
|
|
33
|
+
ds-skills install --agent cursor --domain nlp
|
|
34
|
+
|
|
35
|
+
# Stats
|
|
36
|
+
ds-skills stats
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
All commands support `--json` for structured output (JSON to stdout, human messages to stderr).
|
|
40
|
+
|
|
41
|
+
## Exit Codes
|
|
42
|
+
|
|
43
|
+
| Code | Meaning |
|
|
44
|
+
|------|---------|
|
|
45
|
+
| 0 | Success |
|
|
46
|
+
| 1 | General error |
|
|
47
|
+
| 2 | Not found |
|
|
48
|
+
| 3 | Invalid input |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ds-skills-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Agent-friendly CLI to browse, search, and pull data science skills from ds-skills.com"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "wenmin-wu", email = "wuwenmin1991@gmail.com" }]
|
|
13
|
+
keywords = ["kaggle", "data-science", "skills", "cli", "agent"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://ds-skills.com"
|
|
25
|
+
Repository = "https://github.com/wenmin-wu/ds-skills"
|
|
26
|
+
Issues = "https://github.com/wenmin-wu/ds-skills/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
ds-skills = "ds_skills_cli.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/ds_skills_cli"]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""ds-skills CLI — agent-friendly interface to ds-skills.com.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0=success, 1=error, 2=not found, 3=invalid input.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ds_skills_cli.client import ApiError, Client
|
|
13
|
+
from ds_skills_cli.output import emit_json, emit_table, log
|
|
14
|
+
|
|
15
|
+
# Agent install directories (default per platform)
|
|
16
|
+
AGENT_DIRS = {
|
|
17
|
+
"claude-code": Path.home() / ".claude" / "skills",
|
|
18
|
+
"cursor": Path.home() / ".cursor" / "rules",
|
|
19
|
+
"codex": Path.home() / ".codex" / "skills",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
# Shared flags available on every subcommand (before or after subcommand name)
|
|
25
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
26
|
+
common.add_argument(
|
|
27
|
+
"--json", action="store_true", help="Output JSON to stdout (agent mode)"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
p = argparse.ArgumentParser(
|
|
31
|
+
prog="ds-skills",
|
|
32
|
+
description="Browse, search, and pull data science skills from ds-skills.com",
|
|
33
|
+
parents=[common],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
sub = p.add_subparsers(dest="command")
|
|
37
|
+
|
|
38
|
+
# --- list ---
|
|
39
|
+
ls = sub.add_parser("list", help="List skills", parents=[common])
|
|
40
|
+
ls.add_argument("--domain", "-d", help="Filter by domain")
|
|
41
|
+
ls.add_argument("--page", type=int, default=1)
|
|
42
|
+
ls.add_argument("--limit", type=int, default=200)
|
|
43
|
+
|
|
44
|
+
# --- search ---
|
|
45
|
+
sr = sub.add_parser("search", help="Search skills by keyword", parents=[common])
|
|
46
|
+
sr.add_argument("query", help="Search query")
|
|
47
|
+
sr.add_argument("--domain", "-d", help="Filter by domain")
|
|
48
|
+
sr.add_argument("--page", type=int, default=1)
|
|
49
|
+
sr.add_argument("--limit", type=int, default=50)
|
|
50
|
+
|
|
51
|
+
# --- show ---
|
|
52
|
+
sh = sub.add_parser("show", help="Show full skill detail (e.g. nlp/deberta-classification)", parents=[common])
|
|
53
|
+
sh.add_argument("skill", help="domain/slug (e.g. nlp/deberta-classification)")
|
|
54
|
+
|
|
55
|
+
# --- pull ---
|
|
56
|
+
pl = sub.add_parser("pull", help="Download and extract skills", parents=[common])
|
|
57
|
+
pl.add_argument(
|
|
58
|
+
"target",
|
|
59
|
+
help="domain/slug for one skill, or domain name for all skills in a domain",
|
|
60
|
+
)
|
|
61
|
+
pl.add_argument(
|
|
62
|
+
"--dest", "-o", type=Path, default=Path("."), help="Destination directory"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# --- install ---
|
|
66
|
+
ins = sub.add_parser("install", help="Install skills to an AI agent's skill directory", parents=[common])
|
|
67
|
+
ins.add_argument(
|
|
68
|
+
"--agent",
|
|
69
|
+
"-a",
|
|
70
|
+
required=True,
|
|
71
|
+
choices=list(AGENT_DIRS.keys()),
|
|
72
|
+
help="Target agent",
|
|
73
|
+
)
|
|
74
|
+
ins.add_argument("--domain", "-d", help="Only install skills from this domain")
|
|
75
|
+
ins.add_argument(
|
|
76
|
+
"--dest",
|
|
77
|
+
type=Path,
|
|
78
|
+
default=None,
|
|
79
|
+
help="Override default agent directory",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# --- stats ---
|
|
83
|
+
sub.add_parser("stats", help="Show aggregate statistics", parents=[common])
|
|
84
|
+
|
|
85
|
+
return p
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_skill_ref(ref: str) -> tuple[str, str | None]:
|
|
89
|
+
"""Parse 'domain/slug' or 'domain'. Returns (domain, slug_or_None)."""
|
|
90
|
+
parts = ref.strip("/").split("/", 1)
|
|
91
|
+
domain = parts[0]
|
|
92
|
+
slug = parts[1] if len(parts) > 1 else None
|
|
93
|
+
return domain, slug
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_list(client: Client, args: argparse.Namespace) -> int:
|
|
97
|
+
data = client.list_skills(domain=args.domain, page=args.page, limit=args.limit)
|
|
98
|
+
if args.json:
|
|
99
|
+
emit_json(data)
|
|
100
|
+
else:
|
|
101
|
+
skills = data.get("skills", [])
|
|
102
|
+
log(f"{data.get('total', len(skills))} skills")
|
|
103
|
+
emit_table(
|
|
104
|
+
skills,
|
|
105
|
+
["domain", "slug", "description"],
|
|
106
|
+
[12, 40, 60],
|
|
107
|
+
)
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_search(client: Client, args: argparse.Namespace) -> int:
|
|
112
|
+
data = client.search(
|
|
113
|
+
query=args.query, domain=args.domain, page=args.page, limit=args.limit
|
|
114
|
+
)
|
|
115
|
+
if args.json:
|
|
116
|
+
emit_json(data)
|
|
117
|
+
else:
|
|
118
|
+
results = data.get("results", [])
|
|
119
|
+
facets = data.get("facets", {})
|
|
120
|
+
log(f'{data.get("total", len(results))} results for "{args.query}"')
|
|
121
|
+
if facets:
|
|
122
|
+
log(" " + " ".join(f"{d}:{n}" for d, n in facets.items()))
|
|
123
|
+
emit_table(results, ["domain", "slug", "description"], [12, 40, 60])
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_show(client: Client, args: argparse.Namespace) -> int:
|
|
128
|
+
domain, slug = _parse_skill_ref(args.skill)
|
|
129
|
+
if not slug:
|
|
130
|
+
log(f"ERROR: Invalid skill reference '{args.skill}'. Expected domain/slug.")
|
|
131
|
+
return 3
|
|
132
|
+
data = client.show_skill(domain, slug)
|
|
133
|
+
if args.json:
|
|
134
|
+
emit_json(data)
|
|
135
|
+
else:
|
|
136
|
+
print(data.get("content", ""))
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_pull(client: Client, args: argparse.Namespace) -> int:
|
|
141
|
+
domain, slug = _parse_skill_ref(args.target)
|
|
142
|
+
dest = args.dest
|
|
143
|
+
|
|
144
|
+
if slug:
|
|
145
|
+
log(f"Pulling {domain}/{slug} → {dest}")
|
|
146
|
+
files = client.pull_skill(domain, slug, dest)
|
|
147
|
+
else:
|
|
148
|
+
log(f"Pulling all {domain} skills → {dest}")
|
|
149
|
+
files = client.pull_domain(domain, dest)
|
|
150
|
+
|
|
151
|
+
if args.json:
|
|
152
|
+
emit_json({"files": files, "count": len(files)})
|
|
153
|
+
else:
|
|
154
|
+
log(f"{len(files)} files extracted")
|
|
155
|
+
for f in files:
|
|
156
|
+
print(f)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_install(client: Client, args: argparse.Namespace) -> int:
|
|
161
|
+
dest = args.dest or AGENT_DIRS[args.agent]
|
|
162
|
+
log(f"Installing skills to {dest} (agent: {args.agent})")
|
|
163
|
+
|
|
164
|
+
if args.domain:
|
|
165
|
+
domains = [args.domain]
|
|
166
|
+
else:
|
|
167
|
+
stats = client.stats()
|
|
168
|
+
domains = list(stats.get("domains", {}).keys())
|
|
169
|
+
|
|
170
|
+
all_files: list[str] = []
|
|
171
|
+
for domain in domains:
|
|
172
|
+
log(f" pulling {domain}...")
|
|
173
|
+
files = client.pull_domain(domain, dest)
|
|
174
|
+
all_files.extend(files)
|
|
175
|
+
|
|
176
|
+
if args.json:
|
|
177
|
+
emit_json({"agent": args.agent, "dest": str(dest), "files": all_files, "count": len(all_files)})
|
|
178
|
+
else:
|
|
179
|
+
log(f"Done. {len(all_files)} files installed to {dest}")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_stats(client: Client, args: argparse.Namespace) -> int:
|
|
184
|
+
data = client.stats()
|
|
185
|
+
if args.json:
|
|
186
|
+
emit_json(data)
|
|
187
|
+
else:
|
|
188
|
+
print(f"Total skills: {data.get('total_skills', '?')}")
|
|
189
|
+
print(f"Competitions: {data.get('competitions_processed', '?')}")
|
|
190
|
+
for d, n in data.get("domains", {}).items():
|
|
191
|
+
print(f" {d}: {n}")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_DISPATCH = {
|
|
196
|
+
"list": cmd_list,
|
|
197
|
+
"search": cmd_search,
|
|
198
|
+
"show": cmd_show,
|
|
199
|
+
"pull": cmd_pull,
|
|
200
|
+
"install": cmd_install,
|
|
201
|
+
"stats": cmd_stats,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main(argv: list[str] | None = None) -> None:
|
|
206
|
+
parser = _build_parser()
|
|
207
|
+
args = parser.parse_args(argv)
|
|
208
|
+
|
|
209
|
+
if not args.command:
|
|
210
|
+
parser.print_help(sys.stderr)
|
|
211
|
+
sys.exit(3)
|
|
212
|
+
|
|
213
|
+
client = Client()
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
code = _DISPATCH[args.command](client, args)
|
|
217
|
+
sys.exit(code)
|
|
218
|
+
except ApiError as exc:
|
|
219
|
+
if exc.status == 404:
|
|
220
|
+
log(f"ERROR: Not found. {exc.message}")
|
|
221
|
+
if args.json:
|
|
222
|
+
emit_json({"error": exc.message, "status": 404})
|
|
223
|
+
sys.exit(2)
|
|
224
|
+
else:
|
|
225
|
+
log(f"ERROR: {exc}")
|
|
226
|
+
if args.json:
|
|
227
|
+
emit_json({"error": str(exc), "status": exc.status})
|
|
228
|
+
sys.exit(1)
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
sys.exit(130)
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
log(f"ERROR: {exc}")
|
|
233
|
+
if args.json:
|
|
234
|
+
emit_json({"error": str(exc)})
|
|
235
|
+
sys.exit(1)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""HTTP client for the ds-skills.com API. Zero external dependencies (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
BASE_URL = "https://ds-skills.com"
|
|
14
|
+
_TIMEOUT = 30
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiError(Exception):
|
|
18
|
+
"""Raised when the API returns a non-2xx status."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, status: int, message: str):
|
|
21
|
+
self.status = status
|
|
22
|
+
self.message = message
|
|
23
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Client:
|
|
27
|
+
"""Thin wrapper around the ds-skills REST API."""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self.base_url = BASE_URL
|
|
31
|
+
|
|
32
|
+
# -- low-level ----------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _get_json(self, path: str, params: dict | None = None) -> dict:
|
|
35
|
+
url = f"{self.base_url}/api{path}"
|
|
36
|
+
if params:
|
|
37
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
38
|
+
if params:
|
|
39
|
+
url += "?" + urlencode(params)
|
|
40
|
+
req = Request(url, headers={"Accept": "application/json"})
|
|
41
|
+
try:
|
|
42
|
+
with urlopen(req, timeout=_TIMEOUT) as resp:
|
|
43
|
+
return json.loads(resp.read())
|
|
44
|
+
except HTTPError as exc:
|
|
45
|
+
body = exc.read().decode(errors="replace")
|
|
46
|
+
try:
|
|
47
|
+
detail = json.loads(body).get("detail", body)
|
|
48
|
+
except (json.JSONDecodeError, AttributeError):
|
|
49
|
+
detail = body
|
|
50
|
+
raise ApiError(exc.code, detail) from None
|
|
51
|
+
except URLError as exc:
|
|
52
|
+
raise ApiError(0, f"Connection failed: {exc.reason}") from None
|
|
53
|
+
|
|
54
|
+
def _get_bytes(self, path: str) -> bytes:
|
|
55
|
+
url = f"{self.base_url}/api{path}"
|
|
56
|
+
req = Request(url, headers={"Accept": "application/zip"})
|
|
57
|
+
try:
|
|
58
|
+
with urlopen(req, timeout=60) as resp:
|
|
59
|
+
return resp.read()
|
|
60
|
+
except HTTPError as exc:
|
|
61
|
+
body = exc.read().decode(errors="replace")
|
|
62
|
+
try:
|
|
63
|
+
detail = json.loads(body).get("detail", body)
|
|
64
|
+
except (json.JSONDecodeError, AttributeError):
|
|
65
|
+
detail = body
|
|
66
|
+
raise ApiError(exc.code, detail) from None
|
|
67
|
+
except URLError as exc:
|
|
68
|
+
raise ApiError(0, f"Connection failed: {exc.reason}") from None
|
|
69
|
+
|
|
70
|
+
# -- public API ---------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def list_skills(
|
|
73
|
+
self,
|
|
74
|
+
domain: str | None = None,
|
|
75
|
+
query: str | None = None,
|
|
76
|
+
page: int = 1,
|
|
77
|
+
limit: int = 200,
|
|
78
|
+
) -> dict:
|
|
79
|
+
"""GET /api/skills — list with optional domain/query filter."""
|
|
80
|
+
return self._get_json(
|
|
81
|
+
"/skills", {"domain": domain, "q": query, "page": page, "limit": limit}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def search(
|
|
85
|
+
self,
|
|
86
|
+
query: str,
|
|
87
|
+
domain: str | None = None,
|
|
88
|
+
page: int = 1,
|
|
89
|
+
limit: int = 50,
|
|
90
|
+
) -> dict:
|
|
91
|
+
"""GET /api/search — full-text search with facets."""
|
|
92
|
+
return self._get_json(
|
|
93
|
+
"/search", {"q": query, "domain": domain, "page": page, "limit": limit}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def show_skill(self, domain: str, slug: str) -> dict:
|
|
97
|
+
"""GET /api/skills/{domain}/{slug} — full detail with markdown."""
|
|
98
|
+
return self._get_json(f"/skills/{domain}/{slug}")
|
|
99
|
+
|
|
100
|
+
def stats(self) -> dict:
|
|
101
|
+
"""GET /api/stats — aggregate statistics."""
|
|
102
|
+
return self._get_json("/stats")
|
|
103
|
+
|
|
104
|
+
def download_skill(self, domain: str, slug: str) -> bytes:
|
|
105
|
+
"""GET /api/download/{domain}/{slug} — single skill ZIP."""
|
|
106
|
+
return self._get_bytes(f"/download/{domain}/{slug}")
|
|
107
|
+
|
|
108
|
+
def download_domain(self, domain: str) -> bytes:
|
|
109
|
+
"""GET /api/download/{domain} — all skills in a domain ZIP."""
|
|
110
|
+
return self._get_bytes(f"/download/{domain}")
|
|
111
|
+
|
|
112
|
+
# -- convenience --------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def pull_skill(self, domain: str, slug: str, dest: Path) -> list[str]:
|
|
115
|
+
"""Download and extract a single skill. Returns list of written files."""
|
|
116
|
+
data = self.download_skill(domain, slug)
|
|
117
|
+
return self._extract_zip(data, dest)
|
|
118
|
+
|
|
119
|
+
def pull_domain(self, domain: str, dest: Path) -> list[str]:
|
|
120
|
+
"""Download and extract all skills in a domain. Returns list of written files."""
|
|
121
|
+
data = self.download_domain(domain)
|
|
122
|
+
return self._extract_zip(data, dest)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _extract_zip(data: bytes, dest: Path) -> list[str]:
|
|
126
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
written: list[str] = []
|
|
128
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
129
|
+
for info in zf.infolist():
|
|
130
|
+
if info.is_dir():
|
|
131
|
+
continue
|
|
132
|
+
target = dest / info.filename
|
|
133
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
target.write_bytes(zf.read(info))
|
|
135
|
+
written.append(str(target))
|
|
136
|
+
return written
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Structured output helpers. JSON → stdout, human → stderr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def log(msg: str) -> None:
|
|
10
|
+
"""Human-readable message to stderr (visible in both modes)."""
|
|
11
|
+
print(msg, file=sys.stderr)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def emit_json(data: object) -> None:
|
|
15
|
+
"""Machine-readable JSON to stdout."""
|
|
16
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def emit_table(rows: list[dict], columns: list[str], widths: list[int]) -> None:
|
|
20
|
+
"""Print a simple aligned table to stdout."""
|
|
21
|
+
header = " ".join(c.upper().ljust(w) for c, w in zip(columns, widths))
|
|
22
|
+
print(header)
|
|
23
|
+
print("-" * len(header))
|
|
24
|
+
for row in rows:
|
|
25
|
+
line = " ".join(str(row.get(c, "")).ljust(w) for c, w in zip(columns, widths))
|
|
26
|
+
print(line)
|