devstuff 1.13.1__tar.gz → 1.14.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.
Files changed (54) hide show
  1. {devstuff-1.13.1 → devstuff-1.14.0}/CHANGELOG.md +6 -0
  2. {devstuff-1.13.1 → devstuff-1.14.0}/PKG-INFO +23 -1
  3. {devstuff-1.13.1 → devstuff-1.14.0}/README.md +22 -0
  4. {devstuff-1.13.1 → devstuff-1.14.0}/pyproject.toml +1 -1
  5. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/cli.py +2 -0
  6. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/help_cmd.py +1 -0
  7. devstuff-1.14.0/src/dev_setup/commands/skills_cmd.py +210 -0
  8. {devstuff-1.13.1 → devstuff-1.14.0}/uv.lock +1 -1
  9. {devstuff-1.13.1 → devstuff-1.14.0}/.claude/skills/run-stuff/SKILL.md +0 -0
  10. {devstuff-1.13.1 → devstuff-1.14.0}/.claude/skills/run-stuff/smoke.sh +0 -0
  11. {devstuff-1.13.1 → devstuff-1.14.0}/.github/workflows/bump.yml +0 -0
  12. {devstuff-1.13.1 → devstuff-1.14.0}/.github/workflows/ci.yml +0 -0
  13. {devstuff-1.13.1 → devstuff-1.14.0}/.github/workflows/publish.yml +0 -0
  14. {devstuff-1.13.1 → devstuff-1.14.0}/.github/workflows/test-installs.yml +0 -0
  15. {devstuff-1.13.1 → devstuff-1.14.0}/.gitignore +0 -0
  16. {devstuff-1.13.1 → devstuff-1.14.0}/CLAUDE.md +0 -0
  17. {devstuff-1.13.1 → devstuff-1.14.0}/dev/Dockerfile +0 -0
  18. {devstuff-1.13.1 → devstuff-1.14.0}/dev/Dockerfile.ci +0 -0
  19. {devstuff-1.13.1 → devstuff-1.14.0}/dev/Makefile +0 -0
  20. {devstuff-1.13.1 → devstuff-1.14.0}/dev/docker-compose.yml +0 -0
  21. {devstuff-1.13.1 → devstuff-1.14.0}/dev-setup +0 -0
  22. {devstuff-1.13.1 → devstuff-1.14.0}/install.sh +0 -0
  23. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/__init__.py +0 -0
  24. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/__main__.py +0 -0
  25. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/base.py +0 -0
  26. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/catalog.py +0 -0
  27. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/__init__.py +0 -0
  28. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/add_cmd.py +0 -0
  29. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/catalog_cmd.py +0 -0
  30. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/delete_cmd.py +0 -0
  31. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/docs_cmd.py +0 -0
  32. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/functions_cmd.py +0 -0
  33. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/install_cmd.py +0 -0
  34. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/list_cmd.py +0 -0
  35. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/remove_cmd.py +0 -0
  36. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/run_cmd.py +0 -0
  37. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/update_cmd.py +0 -0
  38. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/function_runner.py +0 -0
  39. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/functions.schema.json +0 -0
  40. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/functions.yaml +0 -0
  41. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/functions_catalog.py +0 -0
  42. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/functions_registry.py +0 -0
  43. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/generic.py +0 -0
  44. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/registry.py +0 -0
  45. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/tools.yaml +0 -0
  46. {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/ui.py +0 -0
  47. {devstuff-1.13.1 → devstuff-1.14.0}/tests/__init__.py +0 -0
  48. {devstuff-1.13.1 → devstuff-1.14.0}/tests/integration/__init__.py +0 -0
  49. {devstuff-1.13.1 → devstuff-1.14.0}/tests/integration/conftest.py +0 -0
  50. {devstuff-1.13.1 → devstuff-1.14.0}/tests/integration/test_tools.py +0 -0
  51. {devstuff-1.13.1 → devstuff-1.14.0}/tests/test_catalog.py +0 -0
  52. {devstuff-1.13.1 → devstuff-1.14.0}/tests/test_functions.py +0 -0
  53. {devstuff-1.13.1 → devstuff-1.14.0}/tests/test_generic.py +0 -0
  54. {devstuff-1.13.1 → devstuff-1.14.0}/tests/test_registry.py +0 -0
@@ -1,3 +1,9 @@
1
+ ## v1.14.0 (2026-07-12)
2
+
3
+ ### Feat
4
+
5
+ - add skills command to append GitHub skills to claude, codex, pi
6
+
1
7
  ## v1.13.1 (2026-07-11)
2
8
 
3
9
  ## v1.13.0 (2026-07-05)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devstuff
3
- Version: 1.13.1
3
+ Version: 1.14.0
4
4
  Summary: Development environment setup CLI for Linux
5
5
  Project-URL: Repository, https://github.com/thesawdawg/dev-setup-py
6
6
  Author-email: Sawyer <sawyerksu@gmail.com>
@@ -269,6 +269,28 @@ When a user key matches a bundled key, the user definition overrides the bundled
269
269
 
270
270
  ---
271
271
 
272
+ ### `skills`
273
+
274
+ Interactively clone a GitHub repository and copy its skills into `claude`, `codex`, and/or `pi`.
275
+
276
+ ```bash
277
+ devstuff skills add
278
+ ```
279
+
280
+ You'll be prompted for:
281
+
282
+ - **Repository** — `owner/repo` or a full URL.
283
+ - **Targets** — which of `claude` (`~/.claude/skills`), `codex` (`~/.codex/skills`), and `pi`
284
+ (`~/.pi/skills`) to install into.
285
+
286
+ The repo is cloned anonymously first. If that fails (private repo), you're prompted to
287
+ authenticate via an SSH key file or a GitHub personal access token. If the repo has a top-level
288
+ `skills/` directory, every subdirectory under it is treated as its own skill; otherwise the whole
289
+ repo is treated as a single skill. Existing skill directories are only overwritten after
290
+ confirmation.
291
+
292
+ ---
293
+
272
294
  ## Functions/Scripts
273
295
 
274
296
  Reusable shell functions/snippets, tracked in a separate catalog from installable tools
@@ -242,6 +242,28 @@ When a user key matches a bundled key, the user definition overrides the bundled
242
242
 
243
243
  ---
244
244
 
245
+ ### `skills`
246
+
247
+ Interactively clone a GitHub repository and copy its skills into `claude`, `codex`, and/or `pi`.
248
+
249
+ ```bash
250
+ devstuff skills add
251
+ ```
252
+
253
+ You'll be prompted for:
254
+
255
+ - **Repository** — `owner/repo` or a full URL.
256
+ - **Targets** — which of `claude` (`~/.claude/skills`), `codex` (`~/.codex/skills`), and `pi`
257
+ (`~/.pi/skills`) to install into.
258
+
259
+ The repo is cloned anonymously first. If that fails (private repo), you're prompted to
260
+ authenticate via an SSH key file or a GitHub personal access token. If the repo has a top-level
261
+ `skills/` directory, every subdirectory under it is treated as its own skill; otherwise the whole
262
+ repo is treated as a single skill. Existing skill directories are only overwritten after
263
+ confirmation.
264
+
265
+ ---
266
+
245
267
  ## Functions/Scripts
246
268
 
247
269
  Reusable shell functions/snippets, tracked in a separate catalog from installable tools
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devstuff"
3
- version = "1.13.1"
3
+ version = "1.14.0"
4
4
  description = "Development environment setup CLI for Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -30,6 +30,7 @@ def _register_commands() -> None:
30
30
  from dev_setup.commands.list_cmd import list_cmd
31
31
  from dev_setup.commands.remove_cmd import remove_cmd
32
32
  from dev_setup.commands.run_cmd import run_cmd
33
+ from dev_setup.commands.skills_cmd import skills_cmd
33
34
  from dev_setup.commands.update_cmd import update_cmd
34
35
 
35
36
  cli.add_command(list_cmd, "list")
@@ -44,6 +45,7 @@ def _register_commands() -> None:
44
45
  cli.add_command(catalog_cmd, "catalog")
45
46
  cli.add_command(run_cmd, "run")
46
47
  cli.add_command(functions_cmd, "functions")
48
+ cli.add_command(skills_cmd, "skills")
47
49
 
48
50
 
49
51
  _register_commands()
@@ -19,6 +19,7 @@ def print_help() -> None:
19
19
  ("docs", "<package>", "Open documentation in browser"),
20
20
  ("run", "<function> [args...]", "Run a function/script"),
21
21
  ("functions", "<list|enable|disable|path>", "Manage functions/scripts"),
22
+ ("skills", "add", "Append skills from a GitHub repo to claude/codex/pi"),
22
23
  ("version", "", "Show version"),
23
24
  ]
24
25
  for cmd, args, desc in rows:
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import questionary
10
+
11
+ from dev_setup import ui
12
+
13
+ _TARGETS: dict[str, Path] = {
14
+ "claude": Path.home() / ".claude" / "skills",
15
+ "codex": Path.home() / ".codex" / "skills",
16
+ "pi": Path.home() / ".pi" / "skills",
17
+ }
18
+
19
+
20
+ @click.group("skills", invoke_without_command=True)
21
+ @click.pass_context
22
+ def skills_cmd(ctx: click.Context) -> None:
23
+ """Append skills from a GitHub repository to claude, codex, and/or pi."""
24
+ if ctx.invoked_subcommand is None:
25
+ ctx.invoke(add_cmd)
26
+
27
+
28
+ @skills_cmd.command("add")
29
+ def add_cmd() -> None:
30
+ """Clone a GitHub repo and copy its skills into the selected tools."""
31
+ ui.section("Add Skills From GitHub")
32
+
33
+ repo = ui.text_input(
34
+ "GitHub repository (e.g. owner/repo or a full URL):", required=True
35
+ )
36
+
37
+ targets = ui.checkbox(
38
+ "Apply skills to which tools?",
39
+ [
40
+ questionary.Choice(title="claude", checked=True),
41
+ questionary.Choice(title="codex", checked=True),
42
+ questionary.Choice(title="pi", checked=True),
43
+ ],
44
+ )
45
+ if not targets:
46
+ ui.warn("No targets selected — aborted.")
47
+ return
48
+
49
+ tmp_dir = Path(tempfile.mkdtemp(prefix="devstuff-skills-"))
50
+ try:
51
+ clone_url = _clone_repo(repo, tmp_dir)
52
+ if clone_url is None:
53
+ return
54
+
55
+ skill_dirs = _discover_skills(tmp_dir, repo)
56
+ if not skill_dirs:
57
+ ui.error("No skills found in the repository.")
58
+ ui.dim(
59
+ "Expected a top-level skills/<name>/ directory, or a repo whose "
60
+ "root itself is a single skill."
61
+ )
62
+ return
63
+
64
+ ui.console.print()
65
+ ui.info(f"Found {len(skill_dirs)} skill(s): {', '.join(d.name for d in skill_dirs)}")
66
+ ui.console.print()
67
+
68
+ _install_skills(skill_dirs, targets)
69
+ finally:
70
+ shutil.rmtree(tmp_dir, ignore_errors=True)
71
+
72
+
73
+ def _clone_repo(repo: str, dest: Path) -> str | None:
74
+ """Clone `repo` into `dest`, prompting for auth if the initial attempt fails."""
75
+ https_url = _to_https_url(repo)
76
+
77
+ with ui.spinner(f"Cloning {https_url}..."):
78
+ result = _git_clone(https_url, dest)
79
+ if result.returncode == 0:
80
+ ui.success("Repository cloned.")
81
+ return https_url
82
+
83
+ ui.warn("Could not clone the repository anonymously — it may be private.")
84
+ auth_method = ui.select(
85
+ "How would you like to authenticate?",
86
+ ["SSH key file", "Personal access token", "Abort"],
87
+ )
88
+
89
+ if auth_method == "SSH key file":
90
+ ssh_key = ui.text_input(
91
+ "Path to SSH private key file:", default="~/.ssh/id_ed25519", required=True
92
+ )
93
+ ssh_key_path = Path(ssh_key).expanduser()
94
+ if not ssh_key_path.is_file():
95
+ ui.error(f"No such file: {ssh_key_path}")
96
+ return None
97
+
98
+ ssh_url = _to_ssh_url(repo)
99
+ env_overrides = {
100
+ "GIT_SSH_COMMAND": (
101
+ f"ssh -i {ssh_key_path} -o IdentitiesOnly=yes "
102
+ f"-o StrictHostKeyChecking=accept-new"
103
+ )
104
+ }
105
+ with ui.spinner(f"Cloning {ssh_url}..."):
106
+ result = _git_clone(ssh_url, dest, env_overrides=env_overrides)
107
+ if result.returncode != 0:
108
+ ui.error("Clone failed with the provided SSH key.")
109
+ ui.dim(result.stderr.strip()[-500:])
110
+ return None
111
+ ui.success("Repository cloned.")
112
+ return ssh_url
113
+
114
+ if auth_method == "Personal access token":
115
+ token = questionary.password("GitHub personal access token:").ask()
116
+ if not token:
117
+ ui.warn("No token provided — aborted.")
118
+ return None
119
+ owner_repo = _owner_repo(repo)
120
+ token_url = f"https://x-access-token:{token}@github.com/{owner_repo}.git"
121
+ with ui.spinner("Cloning with token..."):
122
+ result = _git_clone(token_url, dest)
123
+ if result.returncode != 0:
124
+ ui.error("Clone failed with the provided token.")
125
+ ui.dim(result.stderr.strip()[-500:])
126
+ return None
127
+ ui.success("Repository cloned.")
128
+ return https_url # don't retain the token in the returned URL
129
+
130
+ ui.warn("Aborted.")
131
+ return None
132
+
133
+
134
+ def _git_clone(
135
+ url: str, dest: Path, env_overrides: dict[str, str] | None = None
136
+ ) -> subprocess.CompletedProcess:
137
+ import os
138
+
139
+ env = os.environ.copy()
140
+ if env_overrides:
141
+ env.update(env_overrides)
142
+ # dest already exists (mkdtemp); clone into it directly.
143
+ return subprocess.run(
144
+ ["git", "clone", "--depth", "1", url, str(dest)],
145
+ capture_output=True,
146
+ text=True,
147
+ env=env,
148
+ )
149
+
150
+
151
+ def _owner_repo(repo: str) -> str:
152
+ """Normalize 'owner/repo', a full https URL, or an ssh URL to 'owner/repo'."""
153
+ r = repo.strip()
154
+ r = r.removeprefix("https://github.com/").removeprefix("git@github.com:")
155
+ r = r.removesuffix(".git")
156
+ return r.strip("/")
157
+
158
+
159
+ def _to_https_url(repo: str) -> str:
160
+ if repo.startswith(("http://", "https://", "git@")):
161
+ return repo
162
+ return f"https://github.com/{_owner_repo(repo)}.git"
163
+
164
+
165
+ def _to_ssh_url(repo: str) -> str:
166
+ if repo.startswith("git@"):
167
+ return repo
168
+ return f"git@github.com:{_owner_repo(repo)}.git"
169
+
170
+
171
+ def _discover_skills(repo_dir: Path, repo: str) -> list[Path]:
172
+ """Return the list of skill directories found in a cloned repo."""
173
+ skills_root = repo_dir / "skills"
174
+ if skills_root.is_dir():
175
+ return sorted(
176
+ p for p in skills_root.iterdir()
177
+ if p.is_dir() and not p.name.startswith(".")
178
+ )
179
+
180
+ # No skills/ subdir — if the repo root looks like a single skill, use it.
181
+ has_content = any(
182
+ p.name not in (".git",) for p in repo_dir.iterdir()
183
+ )
184
+ if not has_content:
185
+ return []
186
+
187
+ name = _owner_repo(repo).split("/")[-1]
188
+ single = repo_dir.parent / f"_single_skill_{name}"
189
+ if single.exists():
190
+ shutil.rmtree(single)
191
+ shutil.copytree(repo_dir, single, ignore=shutil.ignore_patterns(".git"))
192
+ return [single]
193
+
194
+
195
+ def _install_skills(skill_dirs: list[Path], targets: list[str]) -> None:
196
+ for target in targets:
197
+ base = _TARGETS[target]
198
+ base.mkdir(parents=True, exist_ok=True)
199
+ for skill_dir in skill_dirs:
200
+ dest = base / skill_dir.name
201
+ if dest.exists():
202
+ if not ui.confirm(
203
+ f"'{skill_dir.name}' already exists for {target} — overwrite?",
204
+ default=False,
205
+ ):
206
+ ui.dim(f"Skipped {skill_dir.name} for {target}.")
207
+ continue
208
+ shutil.rmtree(dest)
209
+ shutil.copytree(skill_dir, dest, ignore=shutil.ignore_patterns(".git"))
210
+ ui.success(f"Installed '{skill_dir.name}' → {dest}")
@@ -167,7 +167,7 @@ wheels = [
167
167
 
168
168
  [[package]]
169
169
  name = "devstuff"
170
- version = "1.13.0"
170
+ version = "1.14.0"
171
171
  source = { editable = "." }
172
172
  dependencies = [
173
173
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes