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.
@@ -0,0 +1,6 @@
1
+ *.pyc
2
+ .DS_Store
3
+ .claude/
4
+ .local/
5
+ CLAUDE.md
6
+ PROGRESS.md
@@ -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,3 @@
1
+ """ds-skills-cli: Agent-friendly CLI for ds-skills.com."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m ds_skills_cli`."""
2
+
3
+ from ds_skills_cli.cli import main
4
+
5
+ main()
@@ -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)