pi-toggle-skills 0.1.0
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.
- package/README.md +186 -0
- package/package.json +54 -0
- package/src/index.ts +112 -0
- package/src/skill-discovery.ts +247 -0
- package/src/skill-selector.ts +424 -0
- package/toggle-skills.ts +298 -0
- package/vitest.config.ts +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 🔘 pi-toggle-skills
|
|
4
|
+
|
|
5
|
+
**Toggle skill visibility in [pi](https://github.com/earendil-works/pi-coding-agent)'s system prompt**
|
|
6
|
+
|
|
7
|
+
_Flip `disable-model-invocation` on skills so the model only sees the ones you want._
|
|
8
|
+
|
|
9
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## The Problem
|
|
17
|
+
|
|
18
|
+
Pi loads skills from multiple directories and includes their names and descriptions in the system prompt. If you have many skills installed, the prompt gets noisy — the model sees every skill and may inappropriately trigger ones you rarely use.
|
|
19
|
+
|
|
20
|
+
Pi supports `disable-model-invocation: true` in SKILL.md frontmatter to hide a skill from the system prompt, but toggling it requires manually editing each YAML file. There's no interactive way to manage skill visibility.
|
|
21
|
+
|
|
22
|
+
## The Solution
|
|
23
|
+
|
|
24
|
+
`pi-toggle-skills` gives you an interactive TUI to toggle which skills are visible to the model:
|
|
25
|
+
|
|
26
|
+
- Discover all skills from the standard directories (`~/.pi/agent/skills/`, `.pi/skills/`, etc.)
|
|
27
|
+
- Interactive `/toggle-skills` command — search, toggle, done
|
|
28
|
+
- Flips `disable-model-invocation` directly in each SKILL.md's YAML frontmatter
|
|
29
|
+
- Auto-reloads pi after saving so changes take effect immediately
|
|
30
|
+
- Subcommands for quick CLI-style enable/disable
|
|
31
|
+
|
|
32
|
+
When a skill has `disable-model-invocation: true`, pi excludes it from the system prompt entirely. The skill is still available via explicit `/skill:name` commands — it just won't be suggested to the model automatically.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### Interactive Commands
|
|
37
|
+
|
|
38
|
+
| Command | What it does |
|
|
39
|
+
| ------------------------------- | ----------------------------------------------- |
|
|
40
|
+
| `/toggle-skills` | Open interactive TUI to toggle skill visibility |
|
|
41
|
+
| `/toggle-skills status` | Show current skills and their visibility status |
|
|
42
|
+
| `/toggle-skills disable <name>` | Hide a skill from the system prompt |
|
|
43
|
+
| `/toggle-skills enable <name>` | Show a skill in the system prompt |
|
|
44
|
+
| `/toggle-skills list` | Same as `/toggle-skills status` |
|
|
45
|
+
| `/toggle-skills help` | Show usage reference |
|
|
46
|
+
|
|
47
|
+
### Interactive TUI Keybindings
|
|
48
|
+
|
|
49
|
+
| Key | Action |
|
|
50
|
+
| -------- | --------------------------------------- |
|
|
51
|
+
| `Enter` | Toggle selected skill |
|
|
52
|
+
| `Ctrl+A` | Disable all (filtered if search active) |
|
|
53
|
+
| `Ctrl+D` | Enable all (filtered if search active) |
|
|
54
|
+
| `Ctrl+S` | Save changes (run /reload to apply) |
|
|
55
|
+
| `Esc` | Cancel (discard changes) |
|
|
56
|
+
| `Ctrl+C` | Clear search, or cancel if empty |
|
|
57
|
+
| ↑/↓ | Navigate the list |
|
|
58
|
+
|
|
59
|
+
### CLI Examples
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
/toggle-skills disable brave-search
|
|
63
|
+
→ Disabled: "brave-search" — hidden from system prompt. Run /reload to apply.
|
|
64
|
+
|
|
65
|
+
/toggle-skills enable brave-search
|
|
66
|
+
→ Enabled: "brave-search" — visible in system prompt. Run /reload to apply.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pi install https://github.com/monotykamary/pi-toggle-skills
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or in `~/.pi/agent/settings.json`:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"packages": ["https://github.com/monotykamary/pi-toggle-skills"]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then `/reload` or restart pi.
|
|
84
|
+
|
|
85
|
+
For quick one-off tests:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pi -e ./toggle-skills.ts
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## How It Works
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
Session starts
|
|
95
|
+
→ Extension discovers skills from standard directories
|
|
96
|
+
→ Parses SKILL.md YAML frontmatter to check disable-model-invocation
|
|
97
|
+
→ Notifies how many visible/hidden skills
|
|
98
|
+
|
|
99
|
+
/toggle-skills (interactive):
|
|
100
|
+
→ Opens TUI selector listing all skills
|
|
101
|
+
→ Changes collected in-memory (no disk writes until Ctrl+S)
|
|
102
|
+
→ Ctrl+S: writes changed SKILL.md files, notifies user to /reload
|
|
103
|
+
→ Esc: discards changes, no files modified
|
|
104
|
+
|
|
105
|
+
/toggle-skills disable/enable:
|
|
106
|
+
→ Toggles disable-model-invocation in SKILL.md frontmatter
|
|
107
|
+
→ Adds the key when disabling, removes it when enabling (false is the default)
|
|
108
|
+
→ Uses gray-matter for safe YAML round-tripping
|
|
109
|
+
→ User must /reload for changes to take effect
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The extension does NOT monkey-patch any pi internals. It modifies SKILL.md files on disk and relies on pi's skill re-scanning on `/reload` to apply changes.
|
|
113
|
+
|
|
114
|
+
### Frontmatter Changes
|
|
115
|
+
|
|
116
|
+
When disabling a skill, the extension adds `disable-model-invocation: true` to the SKILL.md frontmatter:
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
---
|
|
120
|
+
name: my-skill
|
|
121
|
+
description: Does things
|
|
122
|
+
disable-model-invocation: true # ← added
|
|
123
|
+
---
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
When enabling, the key is removed entirely (since `false`/absent are equivalent):
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
---
|
|
130
|
+
name: my-skill
|
|
131
|
+
description: Things done
|
|
132
|
+
---
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Skill Directories Scanned
|
|
136
|
+
|
|
137
|
+
The extension scans the same directories pi uses:
|
|
138
|
+
|
|
139
|
+
| Directory | Scope |
|
|
140
|
+
| --------------------- | ------------------------ |
|
|
141
|
+
| `~/.pi/agent/skills/` | Global (pi-specific) |
|
|
142
|
+
| `~/.agents/skills/` | Global (agent-standard) |
|
|
143
|
+
| `.pi/skills/` | Project (pi-specific) |
|
|
144
|
+
| `.agents/skills/` | Project (agent-standard) |
|
|
145
|
+
|
|
146
|
+
Package skills, settings skills, and `--skill` paths are not currently scanned (they require reading `settings.json` and CLI args).
|
|
147
|
+
|
|
148
|
+
## Comparison with Alternatives
|
|
149
|
+
|
|
150
|
+
| Approach | Pros | Cons |
|
|
151
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
152
|
+
| **pi-toggle-skills** (this) | Interactive TUI; directly modifies SKILL.md; no intermediate config; auto-reload; batch changes with undo | Changes require `/reload`; doesn't scan package/settings/CLI skills|
|
|
153
|
+
| Manual SKILL.md editing | No extension needed | Tedious; error-prone YAML editing; must remember frontmatter syntax |
|
|
154
|
+
| Deleting SKILL.md files | Effective | Destructive; must restore to re-enable; can't toggle back easily |
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npm install
|
|
160
|
+
npm test # Vitest unit tests
|
|
161
|
+
npm run typecheck # TypeScript validation (pi-tui import error is expected — types resolve at runtime)
|
|
162
|
+
npm run lint:dead # Dead code detection (knip)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Structure
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
.
|
|
169
|
+
├── toggle-skills.ts # Main extension
|
|
170
|
+
├── src/
|
|
171
|
+
│ ├── index.ts # Constants, types, and utilities
|
|
172
|
+
│ ├── skill-discovery.ts # Skill directory scanning, frontmatter parsing/writing
|
|
173
|
+
│ └── skill-selector.ts # Interactive TUI component
|
|
174
|
+
├── __tests__/
|
|
175
|
+
│ └── unit/
|
|
176
|
+
│ ├── toggle-skills.test.ts
|
|
177
|
+
│ └── skill-discovery.test.ts
|
|
178
|
+
├── package.json
|
|
179
|
+
├── tsconfig.json
|
|
180
|
+
├── vitest.config.ts
|
|
181
|
+
└── knip.json
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-toggle-skills",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Toggle skill visibility in pi's system prompt — flip disable-model-invocation on skills via an interactive TUI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Tom X Nguyen",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/monotykamary/pi-toggle-skills.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/monotykamary/pi-toggle-skills#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/monotykamary/pi-toggle-skills/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-coding-agent",
|
|
20
|
+
"extension",
|
|
21
|
+
"skills",
|
|
22
|
+
"toggle",
|
|
23
|
+
"disable-model-invocation",
|
|
24
|
+
"visibility"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"*.ts",
|
|
28
|
+
"src/",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"test:coverage": "vitest run --coverage",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"lint:dead": "knip --no-gitignore"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "0.75.4",
|
|
40
|
+
"@types/node": "25.9.1",
|
|
41
|
+
"@vitest/coverage-v8": "4.1.7",
|
|
42
|
+
"knip": "6.14.1",
|
|
43
|
+
"typescript": "6.0.3",
|
|
44
|
+
"vitest": "4.1.7"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"gray-matter": "^4.0.3"
|
|
48
|
+
},
|
|
49
|
+
"pi": {
|
|
50
|
+
"extensions": [
|
|
51
|
+
"./toggle-skills.ts"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants, types, and utilities for pi-toggle-skills.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Description shown in the / commands list. */
|
|
6
|
+
export const TOGGLE_COMMAND_DESCRIPTION = "Toggle which skills are visible to the model (disable-model-invocation)";
|
|
7
|
+
|
|
8
|
+
/** Frontmatter key that controls skill visibility. */
|
|
9
|
+
export const DISABLE_MODEL_INVOCATION_KEY = "disable-model-invocation";
|
|
10
|
+
|
|
11
|
+
/** A discovered skill with its toggle state. */
|
|
12
|
+
export interface ToggleSkill {
|
|
13
|
+
/** Skill name from frontmatter (or parent directory as fallback). */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Description from frontmatter. */
|
|
16
|
+
description: string;
|
|
17
|
+
/** Absolute path to the SKILL.md file. */
|
|
18
|
+
filePath: string;
|
|
19
|
+
/** Directory containing the SKILL.md. */
|
|
20
|
+
baseDir: string;
|
|
21
|
+
/** Whether disable-model-invocation is currently true. */
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Snapshot of a skill's original state before any in-memory toggles. */
|
|
26
|
+
export interface SkillToggleChange {
|
|
27
|
+
filePath: string;
|
|
28
|
+
originalDisabled: boolean;
|
|
29
|
+
newDisabled: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check whether a frontmatter object has disable-model-invocation set to true.
|
|
34
|
+
*/
|
|
35
|
+
export function isDisabled(frontmatter: Record<string, unknown>): boolean {
|
|
36
|
+
return frontmatter[DISABLE_MODEL_INVOCATION_KEY] === true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format a skill's status for display.
|
|
41
|
+
*/
|
|
42
|
+
export function formatSkillStatus(skill: ToggleSkill): string {
|
|
43
|
+
return skill.disabled ? "✗ hidden" : "✓ visible";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Format a skill for one-line display (e.g. in status list).
|
|
48
|
+
*/
|
|
49
|
+
export function formatSkillLine(skill: ToggleSkill): string {
|
|
50
|
+
const status = skill.disabled ? "hidden" : "visible";
|
|
51
|
+
return `${skill.name} [${status}] — ${skill.description}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deduplicate skills by filePath — keep the first occurrence.
|
|
56
|
+
*/
|
|
57
|
+
export function deduplicateSkills(skills: ReadonlyArray<ToggleSkill>): ToggleSkill[] {
|
|
58
|
+
const seen = new Set<string>();
|
|
59
|
+
return skills.filter((skill) => {
|
|
60
|
+
if (seen.has(skill.filePath)) return false;
|
|
61
|
+
seen.add(skill.filePath);
|
|
62
|
+
return true;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute the set of changes between original skills and current (toggled) state.
|
|
68
|
+
*/
|
|
69
|
+
export function computeChanges(
|
|
70
|
+
originals: ReadonlyArray<ToggleSkill>,
|
|
71
|
+
current: ReadonlyArray<ToggleSkill>,
|
|
72
|
+
): SkillToggleChange[] {
|
|
73
|
+
const currentByPath = new Map<string, ToggleSkill>();
|
|
74
|
+
for (const skill of current) {
|
|
75
|
+
currentByPath.set(skill.filePath, skill);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const changes: SkillToggleChange[] = [];
|
|
79
|
+
for (const original of originals) {
|
|
80
|
+
const currentSkill = currentByPath.get(original.filePath);
|
|
81
|
+
if (currentSkill && currentSkill.disabled !== original.disabled) {
|
|
82
|
+
changes.push({
|
|
83
|
+
filePath: original.filePath,
|
|
84
|
+
originalDisabled: original.disabled,
|
|
85
|
+
newDisabled: currentSkill.disabled,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return changes;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate a skill name per the Agent Skills spec.
|
|
95
|
+
* Returns array of error messages (empty if valid).
|
|
96
|
+
*/
|
|
97
|
+
export function validateSkillName(name: string): string[] {
|
|
98
|
+
const errors: string[] = [];
|
|
99
|
+
if (name.length > 64) {
|
|
100
|
+
errors.push(`name exceeds 64 characters (${name.length})`);
|
|
101
|
+
}
|
|
102
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
103
|
+
errors.push("name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)");
|
|
104
|
+
}
|
|
105
|
+
if (name.startsWith("-") || name.endsWith("-")) {
|
|
106
|
+
errors.push("name must not start or end with a hyphen");
|
|
107
|
+
}
|
|
108
|
+
if (name.includes("--")) {
|
|
109
|
+
errors.push("name must not contain consecutive hyphens");
|
|
110
|
+
}
|
|
111
|
+
return errors;
|
|
112
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill discovery and frontmatter manipulation for pi-toggle-skills.
|
|
3
|
+
*
|
|
4
|
+
* Scans the standard pi skill directories to find SKILL.md files,
|
|
5
|
+
* parses their YAML frontmatter, and provides safe round-trip editing
|
|
6
|
+
* via gray-matter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type ToggleSkill,
|
|
11
|
+
DISABLE_MODEL_INVOCATION_KEY,
|
|
12
|
+
isDisabled,
|
|
13
|
+
deduplicateSkills,
|
|
14
|
+
} from "./index.js";
|
|
15
|
+
import matter from "gray-matter";
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
17
|
+
import { join, basename, dirname } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
|
|
20
|
+
// Skill directories to scan (in priority order, matching pi's discovery)
|
|
21
|
+
|
|
22
|
+
function getGlobalPiSkillDirs(): string[] {
|
|
23
|
+
return [join(homedir(), ".pi", "agent", "skills")];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getGlobalAgentsSkillDirs(): string[] {
|
|
27
|
+
return [join(homedir(), ".agents", "skills")];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getProjectSkillDirs(cwd: string): string[] {
|
|
31
|
+
return [join(cwd, ".pi", "skills"), join(cwd, ".agents", "skills")];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Skill source identifier for diagnostics. */
|
|
35
|
+
type SkillSource = "global-pi" | "global-agents" | "project-pi" | "project-agents";
|
|
36
|
+
|
|
37
|
+
export interface SkillDir {
|
|
38
|
+
path: string;
|
|
39
|
+
source: SkillSource;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getAllSkillDirs(cwd: string): SkillDir[] {
|
|
43
|
+
const dirs: SkillDir[] = [];
|
|
44
|
+
|
|
45
|
+
for (const path of getGlobalPiSkillDirs()) {
|
|
46
|
+
dirs.push({ path, source: "global-pi" });
|
|
47
|
+
}
|
|
48
|
+
for (const path of getGlobalAgentsSkillDirs()) {
|
|
49
|
+
dirs.push({ path, source: "global-agents" });
|
|
50
|
+
}
|
|
51
|
+
for (const path of getProjectSkillDirs(cwd)) {
|
|
52
|
+
dirs.push({ path, source: "project-pi" });
|
|
53
|
+
}
|
|
54
|
+
// Deduplicate by path
|
|
55
|
+
const seen = new Set<string>();
|
|
56
|
+
return dirs.filter((d) => {
|
|
57
|
+
if (seen.has(d.path)) return false;
|
|
58
|
+
seen.add(d.path);
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Options for discoverSkills. */
|
|
64
|
+
export interface DiscoverSkillsOptions {
|
|
65
|
+
/** If provided, override the skill directories to scan (for testing). */
|
|
66
|
+
skillDirs?: SkillDir[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Discover all skills across standard locations.
|
|
71
|
+
* Returns deduplicated skills (first found wins, matching pi's behavior).
|
|
72
|
+
*/
|
|
73
|
+
export function discoverSkills(cwd: string, options?: DiscoverSkillsOptions): ToggleSkill[] {
|
|
74
|
+
const allSkills: ToggleSkill[] = [];
|
|
75
|
+
const dirs = options?.skillDirs ?? getAllSkillDirs(cwd);
|
|
76
|
+
|
|
77
|
+
for (const dir of dirs) {
|
|
78
|
+
if (!existsSync(dir.path)) continue;
|
|
79
|
+
const skills = loadSkillsFromDir(dir.path, dir.source);
|
|
80
|
+
allSkills.push(...skills);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return deduplicateSkills(allSkills);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load skills from a directory tree.
|
|
88
|
+
*
|
|
89
|
+
* Mirrors pi's discovery rules:
|
|
90
|
+
* - If a directory contains SKILL.md, treat it as a skill (don't recurse further)
|
|
91
|
+
* - Otherwise, load direct .md children in the root
|
|
92
|
+
* - Recurse into subdirectories to find SKILL.md
|
|
93
|
+
*/
|
|
94
|
+
function loadSkillsFromDir(dir: string, source: SkillSource): ToggleSkill[] {
|
|
95
|
+
const skills: ToggleSkill[] = [];
|
|
96
|
+
|
|
97
|
+
if (!existsSync(dir)) return skills;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
101
|
+
|
|
102
|
+
// Check for SKILL.md in this directory
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.name !== "SKILL.md") continue;
|
|
105
|
+
|
|
106
|
+
const fullPath = join(dir, entry.name);
|
|
107
|
+
const skill = loadSkillFromFile(fullPath);
|
|
108
|
+
if (skill) skills.push(skill);
|
|
109
|
+
|
|
110
|
+
// SKILL.md found — don't look for other .md files or recurse
|
|
111
|
+
return skills;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// No SKILL.md — check root .md files (only in .pi/skills/ and ~/.pi/agent/skills/)
|
|
115
|
+
const isDiscoverableRoot = source === "global-pi" || source === "project-pi";
|
|
116
|
+
if (isDiscoverableRoot) {
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (entry.name.startsWith(".")) continue;
|
|
119
|
+
if (entry.name === "node_modules") continue;
|
|
120
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
121
|
+
|
|
122
|
+
const fullPath = join(dir, entry.name);
|
|
123
|
+
let isFile = entry.isFile();
|
|
124
|
+
if (entry.isSymbolicLink()) {
|
|
125
|
+
try { isFile = statSync(fullPath).isFile(); } catch { continue; }
|
|
126
|
+
}
|
|
127
|
+
if (!isFile) continue;
|
|
128
|
+
|
|
129
|
+
const skill = loadSkillFromFile(fullPath);
|
|
130
|
+
if (skill) skills.push(skill);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Recurse into subdirectories
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
if (entry.name.startsWith(".")) continue;
|
|
137
|
+
if (entry.name === "node_modules") continue;
|
|
138
|
+
|
|
139
|
+
const fullPath = join(dir, entry.name);
|
|
140
|
+
let isDirectory = entry.isDirectory();
|
|
141
|
+
|
|
142
|
+
if (entry.isSymbolicLink()) {
|
|
143
|
+
try { isDirectory = statSync(fullPath).isDirectory(); } catch { continue; }
|
|
144
|
+
}
|
|
145
|
+
if (!isDirectory) continue;
|
|
146
|
+
|
|
147
|
+
const subSkills = loadSkillsFromDir(fullPath, source);
|
|
148
|
+
skills.push(...subSkills);
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Permission errors, etc. — skip silently.
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return skills;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Load a single skill from a SKILL.md (or root .md) file.
|
|
159
|
+
* Returns null if the file can't be parsed or is missing a description.
|
|
160
|
+
*/
|
|
161
|
+
function loadSkillFromFile(filePath: string): ToggleSkill | null {
|
|
162
|
+
try {
|
|
163
|
+
const rawContent = readFileSync(filePath, "utf8");
|
|
164
|
+
const { data: frontmatter } = parseFrontmatter(rawContent);
|
|
165
|
+
const skillDir = dirname(filePath);
|
|
166
|
+
const parentDirName = basename(skillDir);
|
|
167
|
+
|
|
168
|
+
const name = (frontmatter.name as string) || parentDirName;
|
|
169
|
+
const description = frontmatter.description as string;
|
|
170
|
+
|
|
171
|
+
// Skills without description are not loaded by pi — skip them
|
|
172
|
+
if (!description || description.trim() === "") return null;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
name,
|
|
176
|
+
description,
|
|
177
|
+
filePath,
|
|
178
|
+
baseDir: skillDir,
|
|
179
|
+
disabled: isDisabled(frontmatter),
|
|
180
|
+
};
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Parse YAML frontmatter from a string using gray-matter.
|
|
188
|
+
*/
|
|
189
|
+
export function parseFrontmatter(content: string): { data: Record<string, unknown>; content: string } {
|
|
190
|
+
const parsed = matter(content);
|
|
191
|
+
return { data: parsed.data as Record<string, unknown>, content: parsed.content };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Toggle disable-model-invocation in a SKILL.md file.
|
|
196
|
+
*
|
|
197
|
+
* When disabling: sets `disable-model-invocation: true`
|
|
198
|
+
* When enabling: removes the key entirely (false is the default)
|
|
199
|
+
*
|
|
200
|
+
* Returns true if the file was written, false on error.
|
|
201
|
+
*/
|
|
202
|
+
export function toggleSkillInvocation(filePath: string, disabled: boolean): boolean {
|
|
203
|
+
try {
|
|
204
|
+
const rawContent = readFileSync(filePath, "utf8");
|
|
205
|
+
const parsed = matter(rawContent);
|
|
206
|
+
|
|
207
|
+
if (disabled) {
|
|
208
|
+
parsed.data[DISABLE_MODEL_INVOCATION_KEY] = true;
|
|
209
|
+
} else {
|
|
210
|
+
// Remove the key when enabling (false is the default / absent state)
|
|
211
|
+
delete parsed.data[DISABLE_MODEL_INVOCATION_KEY];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const output = matter.stringify(parsed.content, parsed.data);
|
|
215
|
+
writeFileSync(filePath, output, "utf8");
|
|
216
|
+
return true;
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Apply a batch of toggle changes to disk.
|
|
224
|
+
* Returns the list of filePaths that were successfully written.
|
|
225
|
+
*/
|
|
226
|
+
export function applyChanges(
|
|
227
|
+
skills: ReadonlyArray<ToggleSkill>,
|
|
228
|
+
changes: ReadonlyArray<{ filePath: string; newDisabled: boolean }>,
|
|
229
|
+
): string[] {
|
|
230
|
+
const written: string[] = [];
|
|
231
|
+
const skillsByPath = new Map<string, ToggleSkill>();
|
|
232
|
+
for (const skill of skills) {
|
|
233
|
+
skillsByPath.set(skill.filePath, skill);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const change of changes) {
|
|
237
|
+
const skill = skillsByPath.get(change.filePath);
|
|
238
|
+
// Only write if the target state differs from what we last read
|
|
239
|
+
if (skill && skill.disabled !== change.newDisabled) {
|
|
240
|
+
if (toggleSkillInvocation(change.filePath, change.newDisabled)) {
|
|
241
|
+
written.push(change.filePath);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return written;
|
|
247
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToggleSkillSelectorComponent — an interactive TUI for toggling which
|
|
3
|
+
* skills are visible in pi's system prompt.
|
|
4
|
+
*
|
|
5
|
+
* Modeled after pi-hide-providers' HideProviderSelectorComponent:
|
|
6
|
+
* - Lists all discovered skills with their enabled/disabled status
|
|
7
|
+
* - Search/filter via Input component
|
|
8
|
+
* - Enter toggles disable-model-invocation for the selected skill
|
|
9
|
+
* - Ctrl+A / Ctrl+D bulk disable/enable (respects search filter)
|
|
10
|
+
* - Ctrl+S to save changes to disk and reload pi
|
|
11
|
+
* - Esc to cancel (discard in-memory toggles)
|
|
12
|
+
*
|
|
13
|
+
* Changes are collected in-memory and only written to disk on Ctrl+S.
|
|
14
|
+
* After writing, the user runs /reload so the skill list in the system prompt updates.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
Container,
|
|
19
|
+
type Component,
|
|
20
|
+
fuzzyFilter,
|
|
21
|
+
getKeybindings,
|
|
22
|
+
Input,
|
|
23
|
+
Key,
|
|
24
|
+
matchesKey,
|
|
25
|
+
Spacer,
|
|
26
|
+
Text,
|
|
27
|
+
} from "@earendil-works/pi-tui";
|
|
28
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { DynamicBorder, keyText } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { type ToggleSkill, formatSkillStatus } from "./index.js";
|
|
31
|
+
|
|
32
|
+
interface DisplayItem {
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
filePath: string;
|
|
36
|
+
disabled: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ToggleSkillSelectorResult {
|
|
40
|
+
/** Skills with their final toggle states. */
|
|
41
|
+
skills: ToggleSkill[];
|
|
42
|
+
/** If true, the user cancelled and changes should not be written. */
|
|
43
|
+
cancelled: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ToggleSkillSelectorComponent implements Component {
|
|
47
|
+
private theme: Theme;
|
|
48
|
+
private done: (result: ToggleSkillSelectorResult) => void;
|
|
49
|
+
|
|
50
|
+
// All skill items (immutable original list)
|
|
51
|
+
private allItems: DisplayItem[] = [];
|
|
52
|
+
|
|
53
|
+
// Current in-memory toggle states (keyed by filePath)
|
|
54
|
+
private disabledMap: Map<string, boolean> = new Map();
|
|
55
|
+
|
|
56
|
+
// UI state
|
|
57
|
+
private lastWidth = 80;
|
|
58
|
+
private filteredItems: DisplayItem[] = [];
|
|
59
|
+
private selectedIndex = 0;
|
|
60
|
+
private maxVisible = 10;
|
|
61
|
+
private searchInput: Input;
|
|
62
|
+
private listContainer: Container;
|
|
63
|
+
private footerText: Text;
|
|
64
|
+
private hasChanges = false;
|
|
65
|
+
private originalSkills: ToggleSkill[] = [];
|
|
66
|
+
|
|
67
|
+
// Focusable
|
|
68
|
+
private _focused = false;
|
|
69
|
+
get focused(): boolean {
|
|
70
|
+
return this._focused;
|
|
71
|
+
}
|
|
72
|
+
set focused(value: boolean) {
|
|
73
|
+
this._focused = value;
|
|
74
|
+
this.searchInput.focused = value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
theme: Theme,
|
|
79
|
+
skills: ToggleSkill[],
|
|
80
|
+
done: (result: ToggleSkillSelectorResult) => void,
|
|
81
|
+
) {
|
|
82
|
+
this.theme = theme;
|
|
83
|
+
this.done = done;
|
|
84
|
+
this.originalSkills = skills;
|
|
85
|
+
|
|
86
|
+
// Build display items and initial state map
|
|
87
|
+
for (const skill of skills) {
|
|
88
|
+
this.allItems.push({
|
|
89
|
+
name: skill.name,
|
|
90
|
+
description: skill.description,
|
|
91
|
+
filePath: skill.filePath,
|
|
92
|
+
disabled: skill.disabled,
|
|
93
|
+
});
|
|
94
|
+
this.disabledMap.set(skill.filePath, skill.disabled);
|
|
95
|
+
}
|
|
96
|
+
this.filteredItems = [...this.allItems];
|
|
97
|
+
|
|
98
|
+
this.searchInput = new Input();
|
|
99
|
+
this.listContainer = new Container();
|
|
100
|
+
this.footerText = new Text(this.getFooterText(), 0, 0);
|
|
101
|
+
|
|
102
|
+
this.searchInput.onSubmit = () => {
|
|
103
|
+
if (this.filteredItems[this.selectedIndex]) {
|
|
104
|
+
this.toggleItem(this.filteredItems[this.selectedIndex]);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
this.updateList();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
render(width: number): string[] {
|
|
112
|
+
if (this.lastWidth !== width) {
|
|
113
|
+
this.lastWidth = width;
|
|
114
|
+
this.updateList();
|
|
115
|
+
}
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
|
|
118
|
+
lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
|
|
119
|
+
lines.push("");
|
|
120
|
+
lines.push(this.theme.fg("accent", this.theme.bold("Toggle Skill Visibility")));
|
|
121
|
+
lines.push(
|
|
122
|
+
this.theme.fg(
|
|
123
|
+
"muted",
|
|
124
|
+
`Toggle disable-model-invocation on skills. Hidden skills won't appear in the system prompt.`,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
lines.push("");
|
|
128
|
+
lines.push(...this.searchInput.render(width));
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push(...this.listContainer.render(width));
|
|
131
|
+
lines.push("");
|
|
132
|
+
lines.push(...this.footerText.render(width));
|
|
133
|
+
lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
|
|
134
|
+
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
handleInput(data: string): void {
|
|
139
|
+
const kb = getKeybindings();
|
|
140
|
+
|
|
141
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
142
|
+
if (this.filteredItems.length === 0) return;
|
|
143
|
+
this.selectedIndex =
|
|
144
|
+
this.selectedIndex === 0
|
|
145
|
+
? this.filteredItems.length - 1
|
|
146
|
+
: this.selectedIndex - 1;
|
|
147
|
+
this.updateList();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (kb.matches(data, "tui.select.down")) {
|
|
152
|
+
if (this.filteredItems.length === 0) return;
|
|
153
|
+
this.selectedIndex =
|
|
154
|
+
this.selectedIndex === this.filteredItems.length - 1
|
|
155
|
+
? 0
|
|
156
|
+
: this.selectedIndex + 1;
|
|
157
|
+
this.updateList();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Enter — toggle selected item
|
|
162
|
+
if (kb.matches(data, "tui.select.confirm")) {
|
|
163
|
+
const item = this.filteredItems[this.selectedIndex];
|
|
164
|
+
if (item) {
|
|
165
|
+
this.toggleItem(item);
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Ctrl+A — disable all (filtered if search active)
|
|
171
|
+
if (matchesKey(data, Key.ctrl("a"))) {
|
|
172
|
+
const targets = this.getFilterTargets();
|
|
173
|
+
this.disableSkills(targets);
|
|
174
|
+
this.hasChanges = true;
|
|
175
|
+
this.refresh();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Ctrl+D — enable all (filtered if search active)
|
|
180
|
+
if (matchesKey(data, Key.ctrl("d"))) {
|
|
181
|
+
const targets = this.getFilterTargets();
|
|
182
|
+
this.enableSkills(targets);
|
|
183
|
+
this.hasChanges = true;
|
|
184
|
+
this.refresh();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Ctrl+S — save and close
|
|
189
|
+
if (matchesKey(data, Key.ctrl("s"))) {
|
|
190
|
+
this.finish(false);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Escape — cancel
|
|
195
|
+
if (matchesKey(data, Key.escape)) {
|
|
196
|
+
this.finish(true);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Ctrl+C — clear search or cancel if empty
|
|
201
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
202
|
+
if (this.searchInput.getValue()) {
|
|
203
|
+
this.searchInput.setValue("");
|
|
204
|
+
this.refresh();
|
|
205
|
+
} else {
|
|
206
|
+
this.finish(true);
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Pass everything else to search input
|
|
212
|
+
this.searchInput.handleInput(data);
|
|
213
|
+
this.refresh();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
invalidate(): void {
|
|
217
|
+
this.searchInput.invalidate();
|
|
218
|
+
this.listContainer.invalidate();
|
|
219
|
+
this.footerText.invalidate();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Internal helpers
|
|
223
|
+
|
|
224
|
+
private getFilterTargets(): DisplayItem[] {
|
|
225
|
+
const query = this.searchInput.getValue();
|
|
226
|
+
return query ? this.filteredItems : this.allItems;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private getFooterText(): string {
|
|
230
|
+
const allCount = this.allItems.length;
|
|
231
|
+
const hiddenCount = this.allItems.filter((item) => this.disabledMap.get(item.filePath) ?? item.disabled).length;
|
|
232
|
+
const visibleCount = allCount - hiddenCount;
|
|
233
|
+
|
|
234
|
+
const parts: string[] = [
|
|
235
|
+
`${keyText("tui.select.confirm")} toggle`,
|
|
236
|
+
`ctrl+a hide all`,
|
|
237
|
+
`ctrl+d show all`,
|
|
238
|
+
`ctrl+s save & reload`,
|
|
239
|
+
`${visibleCount} visible · ${hiddenCount} hidden`,
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
const text = parts.join(" · ");
|
|
243
|
+
return this.hasChanges
|
|
244
|
+
? this.theme.fg("dim", ` ${text} `) + this.theme.fg("warning", "(unsaved)")
|
|
245
|
+
: this.theme.fg("dim", ` ${text}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private refresh(): void {
|
|
249
|
+
const query = this.searchInput.getValue();
|
|
250
|
+
this.filteredItems = query
|
|
251
|
+
? fuzzyFilter(
|
|
252
|
+
this.allItems,
|
|
253
|
+
query,
|
|
254
|
+
(item: DisplayItem) => `${item.name} ${item.description} ${item.filePath}`,
|
|
255
|
+
)
|
|
256
|
+
: [...this.allItems];
|
|
257
|
+
|
|
258
|
+
// Update disabled status from the map
|
|
259
|
+
for (const item of this.filteredItems) {
|
|
260
|
+
item.disabled = this.disabledMap.get(item.filePath) ?? item.disabled;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.selectedIndex = Math.min(
|
|
264
|
+
this.selectedIndex,
|
|
265
|
+
Math.max(0, this.filteredItems.length - 1),
|
|
266
|
+
);
|
|
267
|
+
this.updateList();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private updateList(): void {
|
|
271
|
+
this.listContainer.clear();
|
|
272
|
+
|
|
273
|
+
if (this.filteredItems.length === 0) {
|
|
274
|
+
this.listContainer.addChild(
|
|
275
|
+
new Text(this.theme.fg("muted", " No matching skills"), 0, 0),
|
|
276
|
+
);
|
|
277
|
+
this.footerText.setText(this.getFooterText());
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const startIndex = Math.max(
|
|
282
|
+
0,
|
|
283
|
+
Math.min(
|
|
284
|
+
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
285
|
+
this.filteredItems.length - this.maxVisible,
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
|
289
|
+
|
|
290
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
291
|
+
const item = this.filteredItems[i];
|
|
292
|
+
if (!item) continue;
|
|
293
|
+
|
|
294
|
+
const isSelected = i === this.selectedIndex;
|
|
295
|
+
const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
|
|
296
|
+
const nameText = isSelected
|
|
297
|
+
? this.theme.fg("accent", item.name)
|
|
298
|
+
: item.name;
|
|
299
|
+
const status = item.disabled
|
|
300
|
+
? this.theme.fg("warning", " ✗")
|
|
301
|
+
: this.theme.fg("success", " ✓");
|
|
302
|
+
|
|
303
|
+
this.listContainer.addChild(
|
|
304
|
+
new Text(`${prefix}${nameText}${status}`, 0, 0),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Scroll indicator
|
|
309
|
+
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
|
310
|
+
this.listContainer.addChild(
|
|
311
|
+
new Text(
|
|
312
|
+
this.theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`),
|
|
313
|
+
0,
|
|
314
|
+
0,
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Detail area for the selected item
|
|
320
|
+
if (this.filteredItems.length > 0) {
|
|
321
|
+
const selected = this.filteredItems[this.selectedIndex];
|
|
322
|
+
this.listContainer.addChild(new Spacer(1));
|
|
323
|
+
this.listContainer.addChild(
|
|
324
|
+
new Text(
|
|
325
|
+
this.theme.fg("dim", ` Source: ${selected.filePath}`),
|
|
326
|
+
0,
|
|
327
|
+
0,
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
const descLines = this.wrapDescription(selected.description);
|
|
331
|
+
for (let i = 0; i < descLines.length; i++) {
|
|
332
|
+
const prefix = i === 0 ? " Description: " : " ";
|
|
333
|
+
this.listContainer.addChild(
|
|
334
|
+
new Text(this.theme.fg("muted", `${prefix}${descLines[i]}`), 0, 0),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
this.footerText.setText(this.getFooterText());
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Toggle a single item between disabled and enabled. */
|
|
343
|
+
private toggleItem(item: DisplayItem): void {
|
|
344
|
+
const current = this.disabledMap.get(item.filePath) ?? item.disabled;
|
|
345
|
+
this.disabledMap.set(item.filePath, !current);
|
|
346
|
+
this.hasChanges = this.hasChanges || current !== this.getOriginalState(item.filePath);
|
|
347
|
+
// Recompute hasChanges against originals
|
|
348
|
+
this.recheckHasChanges();
|
|
349
|
+
this.refresh();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Disable all given items. */
|
|
353
|
+
private disableSkills(items: DisplayItem[]): void {
|
|
354
|
+
for (const item of items) {
|
|
355
|
+
this.disabledMap.set(item.filePath, true);
|
|
356
|
+
}
|
|
357
|
+
this.recheckHasChanges();
|
|
358
|
+
this.refresh();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Enable all given items. */
|
|
362
|
+
private enableSkills(items: DisplayItem[]): void {
|
|
363
|
+
for (const item of items) {
|
|
364
|
+
this.disabledMap.set(item.filePath, false);
|
|
365
|
+
}
|
|
366
|
+
this.recheckHasChanges();
|
|
367
|
+
this.refresh();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Word-wrap a description string, fitting the terminal width. */
|
|
371
|
+
private wrapDescription(text: string): string[] {
|
|
372
|
+
const label = " Description: ";
|
|
373
|
+
const indent = " ";
|
|
374
|
+
const firstLineWidth = Math.max(20, this.lastWidth - label.length);
|
|
375
|
+
const contLineWidth = Math.max(20, this.lastWidth - indent.length);
|
|
376
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
377
|
+
const lines: string[] = [];
|
|
378
|
+
let currentLine = "";
|
|
379
|
+
|
|
380
|
+
for (const word of words) {
|
|
381
|
+
const lineLen = currentLine.length === 0 ? 0 : currentLine.length + 1;
|
|
382
|
+
const limit = lines.length === 0 ? firstLineWidth : contLineWidth;
|
|
383
|
+
if (lineLen + word.length > limit && currentLine.length > 0) {
|
|
384
|
+
lines.push(currentLine);
|
|
385
|
+
currentLine = word;
|
|
386
|
+
} else {
|
|
387
|
+
currentLine = currentLine.length === 0 ? word : `${currentLine} ${word}`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (currentLine.length > 0) {
|
|
391
|
+
lines.push(currentLine);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return lines;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Check if any item's state differs from its original. */
|
|
398
|
+
private getOriginalState(filePath: string): boolean {
|
|
399
|
+
return this.originalSkills.find((s) => s.filePath === filePath)?.disabled ?? false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private recheckHasChanges(): void {
|
|
403
|
+
this.hasChanges = this.allItems.some((item) => {
|
|
404
|
+
const current = this.disabledMap.get(item.filePath) ?? item.disabled;
|
|
405
|
+
return current !== this.getOriginalState(item.filePath);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Build final skill list from current toggle states and close. */
|
|
410
|
+
private finish(cancelled: boolean): void {
|
|
411
|
+
if (cancelled) {
|
|
412
|
+
this.done({ skills: this.originalSkills, cancelled: true });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Build updated skills from original + current toggle states
|
|
417
|
+
const updatedSkills: ToggleSkill[] = this.originalSkills.map((skill) => ({
|
|
418
|
+
...skill,
|
|
419
|
+
disabled: this.disabledMap.get(skill.filePath) ?? skill.disabled,
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
this.done({ skills: updatedSkills, cancelled: false });
|
|
423
|
+
}
|
|
424
|
+
}
|
package/toggle-skills.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-toggle-skills — toggle skill visibility in pi's system prompt.
|
|
3
|
+
*
|
|
4
|
+
* Toggles `disable-model-invocation` in SKILL.md frontmatter to control
|
|
5
|
+
* whether a skill appears in the model's available skills list.
|
|
6
|
+
*
|
|
7
|
+
* When a skill has `disable-model-invocation: true`, pi excludes it from
|
|
8
|
+
* the system prompt entirely. Users can still invoke it explicitly via
|
|
9
|
+
* /skill:name commands.
|
|
10
|
+
*
|
|
11
|
+
* This extension modifies SKILL.md files on disk. Changes require a /reload
|
|
12
|
+
* to take effect.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
TOGGLE_COMMAND_DESCRIPTION,
|
|
18
|
+
type ToggleSkill,
|
|
19
|
+
computeChanges,
|
|
20
|
+
} from "./src/index.js";
|
|
21
|
+
import {
|
|
22
|
+
discoverSkills,
|
|
23
|
+
toggleSkillInvocation,
|
|
24
|
+
applyChanges,
|
|
25
|
+
} from "./src/skill-discovery.js";
|
|
26
|
+
import { ToggleSkillSelectorComponent, type ToggleSkillSelectorResult } from "./src/skill-selector.js";
|
|
27
|
+
|
|
28
|
+
export default function (pi: ExtensionAPI) {
|
|
29
|
+
let currentSkills: ToggleSkill[] = [];
|
|
30
|
+
|
|
31
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
32
|
+
currentSkills = discoverSkills(ctx.cwd);
|
|
33
|
+
|
|
34
|
+
if (currentSkills.length > 0) {
|
|
35
|
+
const hidden = currentSkills.filter((s) => s.disabled).length;
|
|
36
|
+
const visible = currentSkills.length - hidden;
|
|
37
|
+
|
|
38
|
+
if (ctx.hasUI) {
|
|
39
|
+
ctx.ui.notify(
|
|
40
|
+
`pi-toggle-skills: ${visible} visible, ${hidden} hidden skill(s) — use /toggle-skills to manage`,
|
|
41
|
+
"info",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
pi.registerCommand("toggle-skills", {
|
|
48
|
+
description: TOGGLE_COMMAND_DESCRIPTION,
|
|
49
|
+
getArgumentCompletions(prefix: string) {
|
|
50
|
+
const subcommands = ["status", "enable", "disable", "list", "help"];
|
|
51
|
+
const matches = subcommands.filter((s) => s.startsWith(prefix));
|
|
52
|
+
return matches.length > 0 ? matches.map((s) => ({ value: s, label: s })) : null;
|
|
53
|
+
},
|
|
54
|
+
handler: async (args, ctx) => {
|
|
55
|
+
await handleToggleCommand(ctx, args.trim(), currentSkills, (skills) => {
|
|
56
|
+
currentSkills = skills;
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function handleToggleCommand(
|
|
63
|
+
ctx: ExtensionCommandContext,
|
|
64
|
+
args: string,
|
|
65
|
+
currentSkills: ToggleSkill[],
|
|
66
|
+
setSkills: (skills: ToggleSkill[]) => void,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const parts = args.split(/\s+/);
|
|
69
|
+
const subcommand = parts[0]?.toLowerCase() ?? "";
|
|
70
|
+
const rest = parts.slice(1).join(" ");
|
|
71
|
+
|
|
72
|
+
// /toggle-skills — open interactive TUI selector (default)
|
|
73
|
+
if (!subcommand) {
|
|
74
|
+
await showToggleSelector(ctx, currentSkills, setSkills);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// /toggle-skills list — show skills and their status
|
|
79
|
+
if (subcommand === "list") {
|
|
80
|
+
showStatus(ctx, currentSkills);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// /toggle-skills status — same as list
|
|
85
|
+
if (subcommand === "status") {
|
|
86
|
+
showStatus(ctx, currentSkills);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// /toggle-skills disable <name> — hide a skill from the system prompt
|
|
91
|
+
if (subcommand === "disable") {
|
|
92
|
+
if (!rest) {
|
|
93
|
+
ctx.ui.notify(
|
|
94
|
+
"Usage: /toggle-skills disable <skill-name>",
|
|
95
|
+
"warning",
|
|
96
|
+
);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const skill = findSkillByName(currentSkills, rest);
|
|
101
|
+
if (!skill) {
|
|
102
|
+
ctx.ui.notify(`Skill not found: "${rest}". Use /toggle-skills list to see available skills.`, "warning");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (skill.disabled) {
|
|
107
|
+
ctx.ui.notify(`Skill "${skill.name}" is already hidden.`, "info");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (toggleSkillInvocation(skill.filePath, true)) {
|
|
112
|
+
skill.disabled = true;
|
|
113
|
+
ctx.ui.notify(
|
|
114
|
+
`Disabled: "${skill.name}" — hidden from system prompt. Run /reload to apply.`,
|
|
115
|
+
"info",
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
ctx.ui.notify(`Failed to toggle skill "${skill.name}". Check file permissions.`, "error");
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// /toggle-skills enable <name> — show a skill in the system prompt
|
|
124
|
+
if (subcommand === "enable") {
|
|
125
|
+
if (!rest) {
|
|
126
|
+
ctx.ui.notify(
|
|
127
|
+
"Usage: /toggle-skills enable <skill-name>",
|
|
128
|
+
"warning",
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const skill = findSkillByName(currentSkills, rest);
|
|
134
|
+
if (!skill) {
|
|
135
|
+
ctx.ui.notify(`Skill not found: "${rest}". Use /toggle-skills list to see available skills.`, "warning");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!skill.disabled) {
|
|
140
|
+
ctx.ui.notify(`Skill "${skill.name}" is already visible.`, "info");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (toggleSkillInvocation(skill.filePath, false)) {
|
|
145
|
+
skill.disabled = false;
|
|
146
|
+
ctx.ui.notify(
|
|
147
|
+
`Enabled: "${skill.name}" — visible in system prompt. Run /reload to apply.`,
|
|
148
|
+
"info",
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
ctx.ui.notify(`Failed to toggle skill "${skill.name}". Check file permissions.`, "error");
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// /toggle-skills help
|
|
157
|
+
if (subcommand === "help") {
|
|
158
|
+
ctx.ui.notify(
|
|
159
|
+
[
|
|
160
|
+
"pi-toggle-skills commands:",
|
|
161
|
+
" /toggle-skills Open interactive TUI to toggle skill visibility",
|
|
162
|
+
" /toggle-skills status Show current skills and their visibility",
|
|
163
|
+
" /toggle-skills list Same as /toggle-skills status",
|
|
164
|
+
" /toggle-skills disable <n> Hide a skill from the system prompt",
|
|
165
|
+
" /toggle-skills enable <n> Show a skill in the system prompt",
|
|
166
|
+
" /toggle-skills help This message",
|
|
167
|
+
"",
|
|
168
|
+
"Mechanism: toggles disable-model-invocation in SKILL.md frontmatter.",
|
|
169
|
+
" When true, the skill is excluded from the system prompt.",
|
|
170
|
+
" Users can still invoke hidden skills via /skill:name commands.",
|
|
171
|
+
" Changes require /reload to take effect.",
|
|
172
|
+
].join("\n"),
|
|
173
|
+
"info",
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
ctx.ui.notify(
|
|
179
|
+
`Unknown subcommand: "${subcommand}". Use /toggle-skills help for usage.`,
|
|
180
|
+
"warning",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Open the interactive TUI selector.
|
|
185
|
+
async function showToggleSelector(
|
|
186
|
+
ctx: ExtensionCommandContext,
|
|
187
|
+
currentSkills: ToggleSkill[],
|
|
188
|
+
setSkills: (skills: ToggleSkill[]) => void,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
if (currentSkills.length === 0) {
|
|
191
|
+
ctx.ui.notify("No skills found. Add skills to ~/.pi/agent/skills/ or .pi/skills/ first.", "warning");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const result = await ctx.ui.custom<ToggleSkillSelectorResult>(
|
|
196
|
+
(tui, theme, _kb, done) => {
|
|
197
|
+
const selector = new ToggleSkillSelectorComponent(
|
|
198
|
+
theme,
|
|
199
|
+
currentSkills,
|
|
200
|
+
(result) => done(result),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
render(width: number) {
|
|
205
|
+
return selector.render(width);
|
|
206
|
+
},
|
|
207
|
+
invalidate() {
|
|
208
|
+
selector.invalidate();
|
|
209
|
+
},
|
|
210
|
+
handleInput(data: string) {
|
|
211
|
+
selector.handleInput(data);
|
|
212
|
+
tui.requestRender();
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (!result || result.cancelled) {
|
|
219
|
+
ctx.ui.notify("Toggle selector cancelled.", "info");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Compute what changed
|
|
224
|
+
const changes = computeChanges(currentSkills, result.skills);
|
|
225
|
+
if (changes.length === 0) {
|
|
226
|
+
ctx.ui.notify("No changes to apply.", "info");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Write changes to disk
|
|
231
|
+
const written = applyChanges(currentSkills, changes);
|
|
232
|
+
if (written.length === 0) {
|
|
233
|
+
ctx.ui.notify("No files were written. Check file permissions.", "warning");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
setSkills(result.skills);
|
|
238
|
+
|
|
239
|
+
// Summarize what changed
|
|
240
|
+
const disabled = changes.filter((c) => c.newDisabled).length;
|
|
241
|
+
const enabled = changes.length - disabled;
|
|
242
|
+
const parts: string[] = [];
|
|
243
|
+
if (enabled > 0) parts.push(`${enabled} enabled`);
|
|
244
|
+
if (disabled > 0) parts.push(`${disabled} disabled`);
|
|
245
|
+
const summary = parts.join(", ");
|
|
246
|
+
|
|
247
|
+
ctx.ui.notify(
|
|
248
|
+
`Skills updated: ${summary}. Run /reload to apply.`,
|
|
249
|
+
"info",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function findSkillByName(skills: ReadonlyArray<ToggleSkill>, name: string): ToggleSkill | undefined {
|
|
254
|
+
// Exact match first
|
|
255
|
+
const exact = skills.find((s) => s.name === name);
|
|
256
|
+
if (exact) return exact;
|
|
257
|
+
|
|
258
|
+
// Prefix match
|
|
259
|
+
const prefix = skills.find((s) => s.name.startsWith(name));
|
|
260
|
+
if (prefix) return prefix;
|
|
261
|
+
|
|
262
|
+
// Fuzzy: contains
|
|
263
|
+
const fuzzy = skills.find((s) => s.name.includes(name));
|
|
264
|
+
return fuzzy;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function showStatus(
|
|
268
|
+
ctx: ExtensionCommandContext,
|
|
269
|
+
skills: ReadonlyArray<ToggleSkill>,
|
|
270
|
+
): void {
|
|
271
|
+
const lines: string[] = [];
|
|
272
|
+
|
|
273
|
+
if (skills.length === 0) {
|
|
274
|
+
lines.push("No skills found. Add skills to ~/.pi/agent/skills/ or .pi/skills/.");
|
|
275
|
+
} else {
|
|
276
|
+
const visible = skills.filter((s) => !s.disabled);
|
|
277
|
+
const hidden = skills.filter((s) => s.disabled);
|
|
278
|
+
|
|
279
|
+
if (hidden.length > 0) {
|
|
280
|
+
lines.push(`Visible skills (${visible.length}):`);
|
|
281
|
+
for (const skill of visible) {
|
|
282
|
+
lines.push(` ✓ ${skill.name}`);
|
|
283
|
+
}
|
|
284
|
+
lines.push("");
|
|
285
|
+
lines.push(`Hidden skills (${hidden.length}):`);
|
|
286
|
+
for (const skill of hidden) {
|
|
287
|
+
lines.push(` ✗ ${skill.name}`);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
lines.push(`All skills visible (${skills.length}):`);
|
|
291
|
+
for (const skill of skills) {
|
|
292
|
+
lines.push(` ✓ ${skill.name}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
298
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: "node",
|
|
7
|
+
include: ["__tests__/**/*.test.ts"],
|
|
8
|
+
exclude: ["node_modules", "dist", ".idea", ".git", ".cache"],
|
|
9
|
+
coverage: {
|
|
10
|
+
provider: "v8",
|
|
11
|
+
reporter: ["text", "json", "html"],
|
|
12
|
+
exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts"],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|