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.
Files changed (39) hide show
  1. {skillup-0.5.0 → skillup-0.6.0}/.gitignore +2 -0
  2. {skillup-0.5.0 → skillup-0.6.0}/PKG-INFO +49 -7
  3. skillup-0.6.0/README.md +131 -0
  4. {skillup-0.5.0 → skillup-0.6.0}/docs/commands.md +76 -3
  5. {skillup-0.5.0 → skillup-0.6.0}/docs/lock-file.md +19 -2
  6. {skillup-0.5.0 → skillup-0.6.0}/pyproject.toml +4 -1
  7. skillup-0.6.0/skillup/_tree_ui.py +169 -0
  8. skillup-0.6.0/skillup/azdevops.py +166 -0
  9. {skillup-0.5.0 → skillup-0.6.0}/skillup/cli.py +139 -35
  10. {skillup-0.5.0 → skillup-0.6.0}/skillup/github.py +12 -0
  11. {skillup-0.5.0 → skillup-0.6.0}/skillup/install.py +28 -12
  12. {skillup-0.5.0 → skillup-0.6.0}/skillup/lock.py +19 -1
  13. {skillup-0.5.0 → skillup-0.6.0}/skillup/settings.py +13 -2
  14. skillup-0.6.0/tests/test_azdevops.py +230 -0
  15. {skillup-0.5.0 → skillup-0.6.0}/tests/test_e2e.py +144 -18
  16. {skillup-0.5.0 → skillup-0.6.0}/tests/test_github_auth.py +18 -0
  17. {skillup-0.5.0 → skillup-0.6.0}/uv.lock +223 -1
  18. skillup-0.5.0/README.md +0 -91
  19. {skillup-0.5.0 → skillup-0.6.0}/.github/workflows/ci.yml +0 -0
  20. {skillup-0.5.0 → skillup-0.6.0}/.github/workflows/docs.yml +0 -0
  21. {skillup-0.5.0 → skillup-0.6.0}/.github/workflows/publish.yml +0 -0
  22. {skillup-0.5.0 → skillup-0.6.0}/.python-version +0 -0
  23. {skillup-0.5.0 → skillup-0.6.0}/LICENSE +0 -0
  24. {skillup-0.5.0 → skillup-0.6.0}/assets/logo-wordmark.svg +0 -0
  25. {skillup-0.5.0 → skillup-0.6.0}/assets/logo.svg +0 -0
  26. {skillup-0.5.0 → skillup-0.6.0}/docs/assets/extra.css +0 -0
  27. {skillup-0.5.0 → skillup-0.6.0}/docs/assets/logo.svg +0 -0
  28. {skillup-0.5.0 → skillup-0.6.0}/docs/changelog.md +0 -0
  29. {skillup-0.5.0 → skillup-0.6.0}/docs/development.md +0 -0
  30. {skillup-0.5.0 → skillup-0.6.0}/docs/index.md +0 -0
  31. {skillup-0.5.0 → skillup-0.6.0}/docs/quickstart.md +0 -0
  32. {skillup-0.5.0 → skillup-0.6.0}/docs/skill-definition.md +0 -0
  33. {skillup-0.5.0 → skillup-0.6.0}/skillup/__init__.py +0 -0
  34. {skillup-0.5.0 → skillup-0.6.0}/skillup/http.py +0 -0
  35. {skillup-0.5.0 → skillup-0.6.0}/tests/test_cli.py +0 -0
  36. {skillup-0.5.0 → skillup-0.6.0}/tests/test_install.py +0 -0
  37. {skillup-0.5.0 → skillup-0.6.0}/tests/test_migrate.py +0 -0
  38. {skillup-0.5.0 → skillup-0.6.0}/tests/test_sync.py +0 -0
  39. {skillup-0.5.0 → skillup-0.6.0}/zensical.toml +0 -0
@@ -8,3 +8,5 @@ wheels/
8
8
 
9
9
  # Virtual environments
10
10
  .venv
11
+ .agents/skills
12
+ .claude/skills
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillup
3
- Version: 0.5.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-backed · Works with Claude, Gemini, and more.
23
+ Local-first · GitHub &amp; Azure DevOps · Works with Claude, Gemini, and more.
22
24
  </p>
23
25
 
24
26
  <p align="center">
25
- <code>pip install skillup</code> &nbsp;·&nbsp; <code>uv tool install skillup</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 explicitly:
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
@@ -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 &amp; 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 flag
3
+ ## Global options
4
4
 
5
- All commands accept a `--global` / `-g` flag that switches the lock file and base directory to your home directory instead of the current working directory.
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 — choose from all available skills
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
- ## Fields
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.5.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)