skillup 0.5.0__tar.gz → 0.6.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.
- {skillup-0.5.0 → skillup-0.6.0}/.gitignore +2 -0
- {skillup-0.5.0 → skillup-0.6.0}/PKG-INFO +49 -7
- skillup-0.6.0/README.md +131 -0
- {skillup-0.5.0 → skillup-0.6.0}/docs/commands.md +76 -3
- {skillup-0.5.0 → skillup-0.6.0}/docs/lock-file.md +19 -2
- {skillup-0.5.0 → skillup-0.6.0}/pyproject.toml +4 -1
- skillup-0.6.0/skillup/_tree_ui.py +169 -0
- skillup-0.6.0/skillup/azdevops.py +166 -0
- {skillup-0.5.0 → skillup-0.6.0}/skillup/cli.py +139 -35
- {skillup-0.5.0 → skillup-0.6.0}/skillup/github.py +12 -0
- {skillup-0.5.0 → skillup-0.6.0}/skillup/install.py +28 -12
- {skillup-0.5.0 → skillup-0.6.0}/skillup/lock.py +19 -1
- {skillup-0.5.0 → skillup-0.6.0}/skillup/settings.py +13 -2
- skillup-0.6.0/tests/test_azdevops.py +230 -0
- {skillup-0.5.0 → skillup-0.6.0}/tests/test_e2e.py +144 -18
- {skillup-0.5.0 → skillup-0.6.0}/tests/test_github_auth.py +18 -0
- {skillup-0.5.0 → skillup-0.6.0}/uv.lock +223 -1
- skillup-0.5.0/README.md +0 -91
- {skillup-0.5.0 → skillup-0.6.0}/.github/workflows/ci.yml +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/.github/workflows/docs.yml +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/.github/workflows/publish.yml +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/.python-version +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/LICENSE +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/assets/logo-wordmark.svg +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/assets/logo.svg +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/docs/assets/extra.css +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/docs/assets/logo.svg +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/docs/changelog.md +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/docs/development.md +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/docs/index.md +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/docs/quickstart.md +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/docs/skill-definition.md +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/skillup/__init__.py +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/skillup/http.py +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/tests/test_cli.py +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/tests/test_install.py +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/tests/test_migrate.py +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/tests/test_sync.py +0 -0
- {skillup-0.5.0 → skillup-0.6.0}/zensical.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: A minimal CLI to manage agent skills from GitHub releases.
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -9,6 +9,8 @@ Requires-Dist: requests
|
|
|
9
9
|
Requires-Dist: rich
|
|
10
10
|
Requires-Dist: truststore
|
|
11
11
|
Requires-Dist: typer
|
|
12
|
+
Provides-Extra: azure
|
|
13
|
+
Requires-Dist: azure-identity>=1.16; extra == 'azure'
|
|
12
14
|
Description-Content-Type: text/markdown
|
|
13
15
|
|
|
14
16
|
<p align="center">
|
|
@@ -18,11 +20,11 @@ Description-Content-Type: text/markdown
|
|
|
18
20
|
<p align="center">
|
|
19
21
|
<strong>Skill your agents up.</strong><br/>
|
|
20
22
|
A minimal CLI to install, version, and sync skills for your AI agents.<br/>
|
|
21
|
-
Local-first · GitHub
|
|
23
|
+
Local-first · GitHub & Azure DevOps · Works with Claude, Gemini, and more.
|
|
22
24
|
</p>
|
|
23
25
|
|
|
24
26
|
<p align="center">
|
|
25
|
-
<code>
|
|
27
|
+
<code>uv tool install skillup</code>
|
|
26
28
|
</p>
|
|
27
29
|
|
|
28
30
|
---
|
|
@@ -35,29 +37,69 @@ Description-Content-Type: text/markdown
|
|
|
35
37
|
- **Auto-update** — upgrade all or specific repos to their latest release or branch head
|
|
36
38
|
- **Smart cache** — skips redundant downloads; override with `SKILLUP_CACHE_DIR`
|
|
37
39
|
- **gh integration** — uses `gh` CLI when available, falls back to `requests`
|
|
40
|
+
- **Azure DevOps** — install skills from private Azure DevOps Git repos via `DefaultAzureCredential` or a PAT
|
|
38
41
|
|
|
39
42
|
## Installation
|
|
40
43
|
|
|
41
44
|
```bash
|
|
42
|
-
pip install skillup
|
|
43
|
-
# or
|
|
44
45
|
uv tool install skillup
|
|
45
46
|
```
|
|
46
47
|
|
|
48
|
+
To use Azure DevOps sources, install the `azure` extra:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv tool install 'skillup[azure]'
|
|
52
|
+
```
|
|
53
|
+
|
|
47
54
|
## Usage
|
|
48
55
|
|
|
49
|
-
### Add skills
|
|
56
|
+
### Add skills from GitHub
|
|
50
57
|
|
|
51
58
|
```bash
|
|
52
59
|
skillup add google/gemini-cli-skills
|
|
53
60
|
```
|
|
54
61
|
|
|
55
|
-
No releases? Falls back to `main` automatically. Pin a branch
|
|
62
|
+
No releases? Falls back to `main` automatically. Pin a branch or add specific skills non-interactively:
|
|
56
63
|
|
|
57
64
|
```bash
|
|
58
65
|
skillup add anthropics/skills --branch main --skill pdf
|
|
59
66
|
```
|
|
60
67
|
|
|
68
|
+
Full GitHub URLs are also accepted:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
skillup add https://github.com/anthropics/skills
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Add skills from Azure DevOps
|
|
75
|
+
|
|
76
|
+
Pass the full Azure DevOps clone URL — the provider is detected automatically from the domain:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
skillup add https://dev.azure.com/myorg/myproject/_git/myrepo
|
|
80
|
+
skillup add https://myorg.visualstudio.com/myproject/_git/myrepo # legacy URL format
|
|
81
|
+
|
|
82
|
+
# pin a branch or add specific skills non-interactively
|
|
83
|
+
skillup add https://dev.azure.com/myorg/myproject/_git/myrepo --branch develop --skill my-skill
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Azure DevOps repos are stored in the lock file under the key `azdo:org/project/repo` and participate in the shared `update` and `sync` commands just like GitHub repos.
|
|
87
|
+
|
|
88
|
+
#### Authentication
|
|
89
|
+
|
|
90
|
+
Authentication is resolved in this order:
|
|
91
|
+
|
|
92
|
+
1. **`AZURE_DEVOPS_TOKEN` env var** — a Personal Access Token (PAT) with *Code → Read* scope, or any valid bearer token.
|
|
93
|
+
2. **`DefaultAzureCredential`** (requires the `azure` extra) — tries, in order: environment variables (`AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` / `AZURE_TENANT_ID`), workload identity, managed identity, Azure CLI (`az login`), Azure Developer CLI, and interactive browser login.
|
|
94
|
+
|
|
95
|
+
The recommended approach for developer machines is `DefaultAzureCredential` via `az login`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv tool install 'skillup[azure]'
|
|
99
|
+
az login
|
|
100
|
+
skillup add https://dev.azure.com/myorg/myproject/_git/myrepo
|
|
101
|
+
```
|
|
102
|
+
|
|
61
103
|
### Remove skills
|
|
62
104
|
|
|
63
105
|
```bash
|
skillup-0.6.0/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo-wordmark.svg" alt="skillup" height="72" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<strong>Skill your agents up.</strong><br/>
|
|
7
|
+
A minimal CLI to install, version, and sync skills for your AI agents.<br/>
|
|
8
|
+
Local-first · GitHub & Azure DevOps · Works with Claude, Gemini, and more.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<code>uv tool install skillup</code>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Interactive install** — pick skills from any GitHub repo release
|
|
20
|
+
- **Multi-repo** — manage skills from multiple sources independently
|
|
21
|
+
- **Lock file** — pins commit SHAs for reproducible installs (`~/.agents/skills.lock.json`)
|
|
22
|
+
- **Auto-update** — upgrade all or specific repos to their latest release or branch head
|
|
23
|
+
- **Smart cache** — skips redundant downloads; override with `SKILLUP_CACHE_DIR`
|
|
24
|
+
- **gh integration** — uses `gh` CLI when available, falls back to `requests`
|
|
25
|
+
- **Azure DevOps** — install skills from private Azure DevOps Git repos via `DefaultAzureCredential` or a PAT
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv tool install skillup
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
To use Azure DevOps sources, install the `azure` extra:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv tool install 'skillup[azure]'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Add skills from GitHub
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
skillup add google/gemini-cli-skills
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
No releases? Falls back to `main` automatically. Pin a branch or add specific skills non-interactively:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
skillup add anthropics/skills --branch main --skill pdf
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Full GitHub URLs are also accepted:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
skillup add https://github.com/anthropics/skills
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Add skills from Azure DevOps
|
|
60
|
+
|
|
61
|
+
Pass the full Azure DevOps clone URL — the provider is detected automatically from the domain:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
skillup add https://dev.azure.com/myorg/myproject/_git/myrepo
|
|
65
|
+
skillup add https://myorg.visualstudio.com/myproject/_git/myrepo # legacy URL format
|
|
66
|
+
|
|
67
|
+
# pin a branch or add specific skills non-interactively
|
|
68
|
+
skillup add https://dev.azure.com/myorg/myproject/_git/myrepo --branch develop --skill my-skill
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Azure DevOps repos are stored in the lock file under the key `azdo:org/project/repo` and participate in the shared `update` and `sync` commands just like GitHub repos.
|
|
72
|
+
|
|
73
|
+
#### Authentication
|
|
74
|
+
|
|
75
|
+
Authentication is resolved in this order:
|
|
76
|
+
|
|
77
|
+
1. **`AZURE_DEVOPS_TOKEN` env var** — a Personal Access Token (PAT) with *Code → Read* scope, or any valid bearer token.
|
|
78
|
+
2. **`DefaultAzureCredential`** (requires the `azure` extra) — tries, in order: environment variables (`AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` / `AZURE_TENANT_ID`), workload identity, managed identity, Azure CLI (`az login`), Azure Developer CLI, and interactive browser login.
|
|
79
|
+
|
|
80
|
+
The recommended approach for developer machines is `DefaultAzureCredential` via `az login`:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv tool install 'skillup[azure]'
|
|
84
|
+
az login
|
|
85
|
+
skillup add https://dev.azure.com/myorg/myproject/_git/myrepo
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Remove skills
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
skillup remove
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Update skills
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
skillup update # all repos
|
|
98
|
+
skillup update --repo google/gemini-cli-skills # one repo
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Sync (restore from lock file)
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
skillup sync
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Installs skills at the exact pinned SHAs from the lock file — useful for new machines.
|
|
108
|
+
|
|
109
|
+
### Migrate from NPX skills CLI
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
skillup migrate # reads skills-lock.json from repo root
|
|
113
|
+
skillup migrate path/to/skills-lock.json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Skill definition
|
|
117
|
+
|
|
118
|
+
A folder is recognized as a skill when it lives inside a `skills/` directory at the repo root and contains a `SKILL.md` file.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv sync # install deps
|
|
124
|
+
uv run skillup --help
|
|
125
|
+
uv run pytest
|
|
126
|
+
uv run pyright skillup
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -1,13 +1,60 @@
|
|
|
1
1
|
# Commands
|
|
2
2
|
|
|
3
|
-
## Global
|
|
3
|
+
## Global options
|
|
4
4
|
|
|
5
|
-
All commands accept
|
|
5
|
+
All commands accept these options before the subcommand name:
|
|
6
|
+
|
|
7
|
+
| Option | Short | Description |
|
|
8
|
+
|--------|-------|-------------|
|
|
9
|
+
| `--global` | `-g` | Use home directory instead of current working directory. |
|
|
10
|
+
| `--lock-file PATH` | `-l` | Use a custom lock file path instead of the default. |
|
|
6
11
|
|
|
7
12
|
```bash
|
|
8
13
|
skillup --global add myorg/skills
|
|
14
|
+
skillup --lock-file /path/to/my.lock.json sync
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## `config`
|
|
20
|
+
|
|
21
|
+
Manage skillup configuration. Settings are persisted in the lock file.
|
|
22
|
+
|
|
23
|
+
### `config set-dirs`
|
|
24
|
+
|
|
25
|
+
Set the target directories where skills are installed.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
skillup config set-dirs <DIR> [DIR...]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Examples**
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Replace the two defaults with a single custom directory
|
|
35
|
+
skillup config set-dirs /home/user/my-skills
|
|
36
|
+
|
|
37
|
+
# Use custom paths for both agent frameworks
|
|
38
|
+
skillup config set-dirs .agents/skills .vscode/skills
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Behavior**
|
|
42
|
+
|
|
43
|
+
- Accepts one or more directory paths (space-separated).
|
|
44
|
+
- Paths can be absolute or relative (resolved from the current working directory).
|
|
45
|
+
- Saves the directories to the `config.target_dirs` key in the lock file.
|
|
46
|
+
- All subsequent commands (`add`, `remove`, `update`, `sync`) install to and remove from these directories.
|
|
47
|
+
|
|
48
|
+
### `config show`
|
|
49
|
+
|
|
50
|
+
Display the current configuration.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
skillup config show
|
|
9
54
|
```
|
|
10
55
|
|
|
56
|
+
Shows the active lock file path and target directories, including whether each was read from the lock file or is the built-in default.
|
|
57
|
+
|
|
11
58
|
---
|
|
12
59
|
|
|
13
60
|
## `add`
|
|
@@ -22,13 +69,17 @@ skillup add <owner/repo> [OPTIONS]
|
|
|
22
69
|
|--------|-------|-------------|
|
|
23
70
|
| `--skill TEXT` | `-s` | Skill name to add (repeatable). Skips interactive picker. |
|
|
24
71
|
| `--branch TEXT` | `-b` | Install from this branch instead of the latest release. |
|
|
72
|
+
| `--search TEXT` | `-f` | Filter the interactive tree to skills whose name or path contains TEXT (case-insensitive). |
|
|
25
73
|
|
|
26
74
|
**Examples**
|
|
27
75
|
|
|
28
76
|
```bash
|
|
29
|
-
# Interactive picker —
|
|
77
|
+
# Interactive picker — browse the full skill tree
|
|
30
78
|
skillup add google/gemini-cli-skills
|
|
31
79
|
|
|
80
|
+
# Filter the tree to skills related to "python"
|
|
81
|
+
skillup add myorg/skills --search python
|
|
82
|
+
|
|
32
83
|
# Add one skill non-interactively
|
|
33
84
|
skillup add anthropics/skills --skill pdf
|
|
34
85
|
|
|
@@ -39,6 +90,28 @@ skillup add anthropics/skills --skill pdf --skill code-review
|
|
|
39
90
|
skillup add myorg/skills --branch main
|
|
40
91
|
```
|
|
41
92
|
|
|
93
|
+
**Interactive tree picker**
|
|
94
|
+
|
|
95
|
+
Without `--skill`, `skillup add` opens an interactive tree that mirrors the repository's directory structure:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Select skills to add from myorg/skills:
|
|
99
|
+
> docs/ [2 skills]
|
|
100
|
+
getting-started
|
|
101
|
+
reference
|
|
102
|
+
tools/ [3 skills]
|
|
103
|
+
linter
|
|
104
|
+
formatter
|
|
105
|
+
test-runner
|
|
106
|
+
|
|
107
|
+
↑↓ move Space toggle Enter confirm Ctrl-C cancel
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- **Space** on a directory selects or deselects every skill beneath it.
|
|
111
|
+
- Directory entries show a tri-state indicator: `[ ]` none, `[-]` partial, `[x]` all selected.
|
|
112
|
+
- **Space** on an individual skill toggles just that skill and updates the parent's indicator.
|
|
113
|
+
- Use `--search` to narrow the tree before the picker opens.
|
|
114
|
+
|
|
42
115
|
**Behavior**
|
|
43
116
|
|
|
44
117
|
- Fetches the latest GitHub release. If none exists, falls back to `main`.
|
|
@@ -10,6 +10,9 @@ skillup tracks installed skills in a lock file at:
|
|
|
10
10
|
|
|
11
11
|
```json
|
|
12
12
|
{
|
|
13
|
+
"config": {
|
|
14
|
+
"target_dirs": ["/custom/agents/skills", "/custom/claude/skills"]
|
|
15
|
+
},
|
|
13
16
|
"repos": {
|
|
14
17
|
"google/gemini-cli-skills": {
|
|
15
18
|
"skills": ["code-review", "pdf"],
|
|
@@ -27,7 +30,15 @@ skillup tracks installed skills in a lock file at:
|
|
|
27
30
|
}
|
|
28
31
|
```
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
The `config` section is optional and only written when you change settings from their defaults via `skillup config set-dirs`.
|
|
34
|
+
|
|
35
|
+
## Fields — `config`
|
|
36
|
+
|
|
37
|
+
| Field | Description |
|
|
38
|
+
|-------|-------------|
|
|
39
|
+
| `config.target_dirs` | List of directories where skills are installed. Omitted when using the built-in defaults. |
|
|
40
|
+
|
|
41
|
+
## Fields — `repos`
|
|
31
42
|
|
|
32
43
|
| Field | Description |
|
|
33
44
|
|-------|-------------|
|
|
@@ -51,4 +62,10 @@ skillup sync
|
|
|
51
62
|
|
|
52
63
|
## Location override
|
|
53
64
|
|
|
54
|
-
The `--global` flag switches the base directory (and therefore the lock file location) to your home directory. Without it, skillup uses the current working directory.
|
|
65
|
+
The `--global` / `-g` flag switches the base directory (and therefore the lock file location) to your home directory. Without it, skillup uses the current working directory.
|
|
66
|
+
|
|
67
|
+
For full control over the lock file path, use `--lock-file` / `-l`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
skillup --lock-file /shared/team.lock.json sync
|
|
71
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "skillup"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "A minimal CLI to manage agent skills from GitHub releases."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -16,6 +16,9 @@ dependencies = [
|
|
|
16
16
|
"truststore",
|
|
17
17
|
]
|
|
18
18
|
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
azure = ["azure-identity>=1.16"]
|
|
21
|
+
|
|
19
22
|
[dependency-groups]
|
|
20
23
|
dev = [
|
|
21
24
|
"pyright",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from prompt_toolkit import Application
|
|
6
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
7
|
+
from prompt_toolkit.layout import Layout
|
|
8
|
+
from prompt_toolkit.layout.containers import Window
|
|
9
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
10
|
+
from prompt_toolkit.styles import Style
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Node:
|
|
15
|
+
label: str
|
|
16
|
+
value: str # "__dir__:path" or skill_name
|
|
17
|
+
depth: int
|
|
18
|
+
checked: bool = False
|
|
19
|
+
parent: int = -1 # index in flat list; -1 = top-level
|
|
20
|
+
children: list[int] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_dir(self) -> bool:
|
|
24
|
+
return self.value.startswith("__dir__:")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _count_in_subtree(t: dict) -> int:
|
|
28
|
+
return len(t.get("_skills", [])) + sum(
|
|
29
|
+
_count_in_subtree(v) for k, v in t.items() if k != "_skills"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_flat_nodes(skill_paths: dict[str, str]) -> list[Node]:
|
|
34
|
+
"""Return a DFS-ordered flat list of Nodes from {skill_name: repo-relative-path}."""
|
|
35
|
+
tree: dict = {}
|
|
36
|
+
for skill_name, path in skill_paths.items():
|
|
37
|
+
parts = path.split("/")
|
|
38
|
+
node = tree
|
|
39
|
+
for part in parts[:-1]:
|
|
40
|
+
node = node.setdefault(part, {})
|
|
41
|
+
node.setdefault("_skills", []).append(skill_name)
|
|
42
|
+
|
|
43
|
+
nodes: list[Node] = []
|
|
44
|
+
|
|
45
|
+
def flatten(subtree: dict, prefix: str, depth: int, parent_idx: int) -> None:
|
|
46
|
+
for dir_name in sorted(k for k in subtree if k != "_skills"):
|
|
47
|
+
dir_path = f"{prefix}/{dir_name}" if prefix else dir_name
|
|
48
|
+
n = _count_in_subtree(subtree[dir_name])
|
|
49
|
+
s = "s" if n != 1 else ""
|
|
50
|
+
dir_idx = len(nodes)
|
|
51
|
+
if parent_idx >= 0:
|
|
52
|
+
nodes[parent_idx].children.append(dir_idx)
|
|
53
|
+
nodes.append(Node(
|
|
54
|
+
label=f"{dir_name}/ [{n} skill{s}]",
|
|
55
|
+
value=f"__dir__:{dir_path}",
|
|
56
|
+
depth=depth,
|
|
57
|
+
parent=parent_idx,
|
|
58
|
+
))
|
|
59
|
+
flatten(subtree[dir_name], dir_path, depth + 1, dir_idx)
|
|
60
|
+
|
|
61
|
+
for skill in sorted(subtree.get("_skills", [])):
|
|
62
|
+
skill_idx = len(nodes)
|
|
63
|
+
if parent_idx >= 0:
|
|
64
|
+
nodes[parent_idx].children.append(skill_idx)
|
|
65
|
+
nodes.append(Node(
|
|
66
|
+
label=skill,
|
|
67
|
+
value=skill,
|
|
68
|
+
depth=depth,
|
|
69
|
+
parent=parent_idx,
|
|
70
|
+
))
|
|
71
|
+
|
|
72
|
+
flatten(tree, "", 0, -1)
|
|
73
|
+
return nodes
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def dir_state(idx: int, nodes: list[Node]) -> str:
|
|
77
|
+
"""Return 'all', 'some', or 'none' for a dir node based on its descendants."""
|
|
78
|
+
node = nodes[idx]
|
|
79
|
+
if not node.children:
|
|
80
|
+
return "all" if node.checked else "none"
|
|
81
|
+
checked = sum(
|
|
82
|
+
1 for c in node.children
|
|
83
|
+
if (nodes[c].is_dir and dir_state(c, nodes) == "all")
|
|
84
|
+
or (not nodes[c].is_dir and nodes[c].checked)
|
|
85
|
+
)
|
|
86
|
+
if checked == 0:
|
|
87
|
+
return "none"
|
|
88
|
+
if checked == len(node.children):
|
|
89
|
+
return "all"
|
|
90
|
+
return "some"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _set_subtree(idx: int, nodes: list[Node], checked: bool) -> None:
|
|
94
|
+
nodes[idx].checked = checked
|
|
95
|
+
for c in nodes[idx].children:
|
|
96
|
+
_set_subtree(c, nodes, checked)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def toggle(idx: int, nodes: list[Node]) -> None:
|
|
100
|
+
"""Toggle a node. Dirs toggle their entire subtree; check-all if not all checked."""
|
|
101
|
+
if nodes[idx].is_dir:
|
|
102
|
+
_set_subtree(idx, nodes, dir_state(idx, nodes) != "all")
|
|
103
|
+
else:
|
|
104
|
+
nodes[idx].checked = not nodes[idx].checked
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def tree_checkbox(prompt: str, skill_paths: dict[str, str]) -> list[str] | None:
|
|
108
|
+
"""Interactive hierarchical checkbox.
|
|
109
|
+
|
|
110
|
+
Space on a directory selects/deselects every skill beneath it.
|
|
111
|
+
Directories show tri-state: [ ] none, [-] partial, [x] all.
|
|
112
|
+
|
|
113
|
+
Returns sorted skill names, or None if the user cancelled.
|
|
114
|
+
"""
|
|
115
|
+
nodes = build_flat_nodes(skill_paths)
|
|
116
|
+
if not nodes:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
state = {"cursor": 0, "cancelled": False}
|
|
120
|
+
|
|
121
|
+
def get_tokens() -> list[tuple[str, str]]:
|
|
122
|
+
lines: list[tuple[str, str]] = [("class:prompt", f"{prompt}\n")]
|
|
123
|
+
for i, node in enumerate(nodes):
|
|
124
|
+
indent = " " * node.depth
|
|
125
|
+
if node.is_dir:
|
|
126
|
+
s = dir_state(i, nodes)
|
|
127
|
+
cb = "[x]" if s == "all" else "[-]" if s == "some" else "[ ]"
|
|
128
|
+
else:
|
|
129
|
+
cb = "[x]" if node.checked else "[ ]"
|
|
130
|
+
marker = "> " if i == state["cursor"] else " "
|
|
131
|
+
style = "class:cursor" if i == state["cursor"] else ""
|
|
132
|
+
lines.append((style, f"{marker}{indent}{cb} {node.label}\n"))
|
|
133
|
+
lines.append(("class:hint", "\n↑↓ move Space toggle Enter confirm Ctrl-C cancel\n"))
|
|
134
|
+
return lines
|
|
135
|
+
|
|
136
|
+
kb = KeyBindings()
|
|
137
|
+
|
|
138
|
+
@kb.add("up")
|
|
139
|
+
def _(e): state["cursor"] = max(0, state["cursor"] - 1)
|
|
140
|
+
|
|
141
|
+
@kb.add("down")
|
|
142
|
+
def _(e): state["cursor"] = min(len(nodes) - 1, state["cursor"] + 1)
|
|
143
|
+
|
|
144
|
+
@kb.add("space")
|
|
145
|
+
def _(e): toggle(state["cursor"], nodes)
|
|
146
|
+
|
|
147
|
+
@kb.add("enter")
|
|
148
|
+
def _(e): e.app.exit()
|
|
149
|
+
|
|
150
|
+
@kb.add("c-c")
|
|
151
|
+
def _(e):
|
|
152
|
+
state["cancelled"] = True
|
|
153
|
+
e.app.exit()
|
|
154
|
+
|
|
155
|
+
app = Application(
|
|
156
|
+
layout=Layout(Window(FormattedTextControl(get_tokens, focusable=True))),
|
|
157
|
+
key_bindings=kb,
|
|
158
|
+
style=Style.from_dict({
|
|
159
|
+
"cursor": "reverse",
|
|
160
|
+
"prompt": "bold",
|
|
161
|
+
"hint": "fg:ansigray italic",
|
|
162
|
+
}),
|
|
163
|
+
mouse_support=False,
|
|
164
|
+
)
|
|
165
|
+
app.run()
|
|
166
|
+
|
|
167
|
+
if state["cancelled"]:
|
|
168
|
+
return None
|
|
169
|
+
return sorted(n.value for n in nodes if not n.is_dir and n.checked)
|