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.
- {devstuff-1.13.1 → devstuff-1.14.0}/CHANGELOG.md +6 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/PKG-INFO +23 -1
- {devstuff-1.13.1 → devstuff-1.14.0}/README.md +22 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/pyproject.toml +1 -1
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/cli.py +2 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/help_cmd.py +1 -0
- devstuff-1.14.0/src/dev_setup/commands/skills_cmd.py +210 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/uv.lock +1 -1
- {devstuff-1.13.1 → devstuff-1.14.0}/.claude/skills/run-stuff/SKILL.md +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/.claude/skills/run-stuff/smoke.sh +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/.github/workflows/bump.yml +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/.github/workflows/ci.yml +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/.github/workflows/publish.yml +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/.github/workflows/test-installs.yml +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/.gitignore +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/CLAUDE.md +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/dev/Dockerfile +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/dev/Dockerfile.ci +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/dev/Makefile +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/dev/docker-compose.yml +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/dev-setup +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/install.sh +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/__init__.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/__main__.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/base.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/catalog.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/__init__.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/add_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/catalog_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/delete_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/docs_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/functions_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/install_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/list_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/remove_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/run_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/commands/update_cmd.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/function_runner.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/functions.schema.json +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/functions.yaml +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/functions_catalog.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/functions_registry.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/generic.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/registry.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/tools.yaml +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/src/dev_setup/ui.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/tests/__init__.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/tests/integration/__init__.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/tests/integration/conftest.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/tests/integration/test_tools.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/tests/test_catalog.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/tests/test_functions.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/tests/test_generic.py +0 -0
- {devstuff-1.13.1 → devstuff-1.14.0}/tests/test_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devstuff
|
|
3
|
-
Version: 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
|
|
@@ -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}")
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|