@nukcole-xinluo9510/pi-extension-guy 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/LICENSE +21 -0
- package/README.md +163 -0
- package/assets/switchboard.png +0 -0
- package/assets/switchboard.svg +92 -0
- package/extensions/extension-guy/index.ts +108 -0
- package/extensions/extension-guy/panel.ts +169 -0
- package/extensions/extension-guy/scan.ts +244 -0
- package/extensions/extension-guy/toggle.ts +97 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nuckcole
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# ๐งฉ Extension Guy
|
|
2
|
+
|
|
3
|
+
**A pi extension that flips other extensions.** Run `/extensions` in any pi
|
|
4
|
+
session and get a live control panel โ every local extension on one screen,
|
|
5
|
+
each on a switch you can flip on or off and **hot-reload on the spot**.
|
|
6
|
+
|
|
7
|
+
pi already hot-reloads *everything at once* with `/reload`. Extension Guy makes
|
|
8
|
+
it **per-extension**: toggle one off, hit enter, and it's gone from the running
|
|
9
|
+
session โ no restart, no `rm`, no config file, no hidden registry.
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<img src="https://raw.githubusercontent.com/luoxin9510/pi-packages/main/packages/pi-extension-guy/assets/switchboard.png" alt="Extension Guy โ the filename is the switch" width="840">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
/extensions
|
|
17
|
+
โโ Extensions (managed dirs only) โโโโโโโโโโโโโ
|
|
18
|
+
โ โโ GLOBAL (~/.pi/agent/extensions) โโ โ
|
|
19
|
+
โ โถ [x] git-checkpoint file โ
|
|
20
|
+
โ [ ] doom-overlay index-dir * โ
|
|
21
|
+
โ [x] my-tools manifest-dir โ
|
|
22
|
+
โ [-] extension-guy (self) โ
|
|
23
|
+
โ โโ PROJECT (.pi/extensions) โโ โ
|
|
24
|
+
โ [x] repo-linter file โ
|
|
25
|
+
โ โ
|
|
26
|
+
โ โ/โ move space toggle โ
|
|
27
|
+
โ enter apply+reload esc cancel โ
|
|
28
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Why it's different โ built the pi way
|
|
34
|
+
|
|
35
|
+
pi already ships the two mechanisms you need: it **discovers extensions by
|
|
36
|
+
filename**, and it **hot-reloads** them with `/reload`. Extension Guy doesn't
|
|
37
|
+
fork the core or invent a new system โ it just **composes what pi already has**.
|
|
38
|
+
|
|
39
|
+
To turn an extension off, it renames the file so the loader stops finding it
|
|
40
|
+
(`foo.ts` โ `foo.ts.disabled`), then runs pi's own reload. That's the whole
|
|
41
|
+
trick. Which means the most important design decision is what it *doesn't* add:
|
|
42
|
+
|
|
43
|
+
- **No new state.** The on-disk filename *is* the switch. `foo.ts` is on,
|
|
44
|
+
`foo.ts.disabled` is off. Nothing to keep in sync, nothing to corrupt.
|
|
45
|
+
- **No database, no config schema, no daemon.** Want to know what's enabled?
|
|
46
|
+
`ls`. Want it to survive a restart? It already does โ the files are the truth.
|
|
47
|
+
- **No core changes.** Pure public `ExtensionAPI` (`registerCommand`,
|
|
48
|
+
`ctx.ui.custom`, `ctx.reload`) plus `fs`. It runs on stock pi.
|
|
49
|
+
|
|
50
|
+
> **No new state โ the filename is the switch.**
|
|
51
|
+
> It composes pi's mechanisms instead of replacing them.
|
|
52
|
+
|
|
53
|
+
That's why the panel never lies: its `[x]` / `[ ]` is computed by replicating
|
|
54
|
+
pi's *actual* loader rules, not by trusting a side-file that could drift.
|
|
55
|
+
|
|
56
|
+
## Highlights
|
|
57
|
+
|
|
58
|
+
- ๐๏ธ **One panel** โ see every local extension; arrow to move, space to flip,
|
|
59
|
+
enter to apply.
|
|
60
|
+
- ๐ฅ **Per-extension hot reload** โ pi reloads everything at once; Extension Guy
|
|
61
|
+
flips one extension and hot-reloads it live in the current session, no restart.
|
|
62
|
+
- ๐๏ธ **Filesystem is the database** โ the filename is the state. Inspect with
|
|
63
|
+
`ls`, survives restarts, no hidden registry to desync.
|
|
64
|
+
- ๐งฉ **Zero core changes** โ built entirely on the public extension API; works on
|
|
65
|
+
unmodified pi.
|
|
66
|
+
- ๐ชถ **Zero config, zero deps** โ install and run. Nothing to set up.
|
|
67
|
+
- ๐ **Safe by construction** โ the manager locks itself, leaves symlink-escape
|
|
68
|
+
and unmanageable entries read-only, and rolls back a multi-file rename if any
|
|
69
|
+
step fails, so a directory is never left half-toggled.
|
|
70
|
+
- ๐ช **Honest state** โ `[x]`/`[ ]` mirrors pi's real loader (`isExtensionFile` +
|
|
71
|
+
`resolveExtensionEntries`), including manifest dirs that resolve to nothing.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# From npm
|
|
77
|
+
pi add npm:@nukcole-xinluo9510/pi-extension-guy
|
|
78
|
+
|
|
79
|
+
# Or from a local checkout during development
|
|
80
|
+
pi add /path/to/pi-extension-guy
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That's it. No other setup. Then run `/extensions` in any session.
|
|
84
|
+
|
|
85
|
+
## Usage
|
|
86
|
+
|
|
87
|
+
| Key | Action |
|
|
88
|
+
|-----|--------|
|
|
89
|
+
| `โ` / `โ` | move selection (skips section headers) |
|
|
90
|
+
| `space` | toggle the selected item โ pending only; `*` marks unsaved |
|
|
91
|
+
| `enter` | apply all pending toggles to disk, then reload |
|
|
92
|
+
| `esc` | cancel โ discard pending changes, touch nothing |
|
|
93
|
+
|
|
94
|
+
Toggles are batched: flip several, then apply once for a single reload.
|
|
95
|
+
|
|
96
|
+
## How it works
|
|
97
|
+
|
|
98
|
+
Extension Guy registers one command, `/extensions`. When you run it:
|
|
99
|
+
|
|
100
|
+
1. It scans the two managed dirs โ `~/.pi/agent/extensions/` and
|
|
101
|
+
`<cwd>/.pi/extensions/` โ and classifies each entry (single file, index
|
|
102
|
+
directory, or `package.json` manifest directory).
|
|
103
|
+
2. It opens a TUI overlay (`ctx.ui.custom`) listing each extension with its real
|
|
104
|
+
enabled/disabled state. Toggling only flips an in-memory flag.
|
|
105
|
+
3. On **enter**, it performs all renames first โ purely on disk:
|
|
106
|
+
|
|
107
|
+
| Shape | Disable | Enable |
|
|
108
|
+
|-------|---------|--------|
|
|
109
|
+
| single file | `foo.ts` โ `foo.ts.disabled` | reverse |
|
|
110
|
+
| index dir | `index.ts` **and** `index.js` โ `*.disabled` | reverse |
|
|
111
|
+
| manifest dir | `package.json` (+ any sibling `index.*`) โ `*.disabled` | reverse |
|
|
112
|
+
|
|
113
|
+
4. Then it calls `ctx.reload()` โ the same flow as `/reload` โ and returns. The
|
|
114
|
+
reload re-discovers extensions, so disabled ones vanish and enabled ones come
|
|
115
|
+
back, live in the current session.
|
|
116
|
+
|
|
117
|
+
Renames are prechecked and rolled back per item, so a concurrent change produces
|
|
118
|
+
a clean error instead of a half-renamed directory.
|
|
119
|
+
|
|
120
|
+
## The boundary (enforced, not asked)
|
|
121
|
+
|
|
122
|
+
Some rows are deliberately **read-only**, locked in code:
|
|
123
|
+
|
|
124
|
+
- ๐ **self** โ Extension Guy can't disable itself.
|
|
125
|
+
- ๐ **symlink** โ entries whose real target escapes the managed dirs.
|
|
126
|
+
- ๐ซ **unloadable** โ a manifest whose `pi.extensions` resolve to nothing.
|
|
127
|
+
|
|
128
|
+
And what it honestly **won't** manage (because pi exposes no API to enumerate
|
|
129
|
+
loaded extensions): npm/git packages installed via `pi add`, and extensions
|
|
130
|
+
added through `settings.json` paths. Those live outside the managed dirs, so they
|
|
131
|
+
aren't shown. The panel header says "managed dirs only" โ it never pretends the
|
|
132
|
+
list is exhaustive.
|
|
133
|
+
|
|
134
|
+
One more honest note: a toggle is **global**. The rename changes discovery for
|
|
135
|
+
every pi process and future session; other running instances pick it up on their
|
|
136
|
+
next reload.
|
|
137
|
+
|
|
138
|
+
## Requirements
|
|
139
|
+
|
|
140
|
+
- pi 0.79+
|
|
141
|
+
|
|
142
|
+
(That's the whole list.)
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npm run check # tsc --noEmit against real pi types
|
|
148
|
+
npm test # node --test (scan classification, toggle rename + rollback)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Layout:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
extensions/extension-guy/
|
|
155
|
+
index.ts # /extensions command: scan โ panel โ apply โ reload
|
|
156
|
+
scan.ts # disk discovery, mirrors pi's resolveExtensionEntries
|
|
157
|
+
toggle.ts # rename engine: precheck + per-item rollback
|
|
158
|
+
panel.ts # TUI overlay (Focusable component)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
|
Binary file
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<svg width="1200" height="720" viewBox="0 0 1200 720" xmlns="http://www.w3.org/2000/svg" font-family="'SF Mono','JetBrains Mono','Fira Code',Menlo,Consolas,monospace">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#010409"/>
|
|
5
|
+
<stop offset="1" stop-color="#05080d"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<marker id="arrowB" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
8
|
+
<path d="M0,0 L10,5 L0,10 z" fill="#58a6ff"/>
|
|
9
|
+
</marker>
|
|
10
|
+
</defs>
|
|
11
|
+
|
|
12
|
+
<rect width="1200" height="720" fill="url(#bg)"/>
|
|
13
|
+
|
|
14
|
+
<!-- terminal window -->
|
|
15
|
+
<rect x="40" y="40" width="1120" height="640" rx="16" fill="#0d1117" stroke="#30363d" stroke-width="1.5"/>
|
|
16
|
+
<circle cx="74" cy="74" r="7" fill="#ff5f56"/>
|
|
17
|
+
<circle cx="98" cy="74" r="7" fill="#ffbd2e"/>
|
|
18
|
+
<circle cx="122" cy="74" r="7" fill="#27c93f"/>
|
|
19
|
+
<text x="600" y="81" font-size="22" font-weight="700" fill="#e6edf3" text-anchor="middle">๐งฉ extension-guy</text>
|
|
20
|
+
|
|
21
|
+
<!-- supporting line -->
|
|
22
|
+
<text x="600" y="146" font-size="18" fill="#8b949e" text-anchor="middle">Every local extension on one panel โ <tspan fill="#c9d1d9" font-style="italic">flip a switch, hot-reload live.</tspan></text>
|
|
23
|
+
|
|
24
|
+
<!-- ===== switch panel band ===== -->
|
|
25
|
+
<rect x="80" y="176" width="300" height="30" rx="15" fill="#3fb950" fill-opacity="0.13"/>
|
|
26
|
+
<text x="100" y="196" font-size="16" font-weight="700" fill="#3fb950" letter-spacing="1">/extensions โ flip any switch</text>
|
|
27
|
+
|
|
28
|
+
<!-- panel -->
|
|
29
|
+
<rect x="80" y="222" width="1040" height="244" rx="12" fill="#161b22" stroke="#30363d" stroke-width="1.5"/>
|
|
30
|
+
|
|
31
|
+
<!-- row 1: ON -->
|
|
32
|
+
<text x="120" y="263" font-size="19" fill="#c9d1d9">git-checkpoint</text>
|
|
33
|
+
<text x="360" y="263" font-size="15" fill="#6e7681">file</text>
|
|
34
|
+
<rect x="952" y="247" width="64" height="30" rx="15" fill="#238636" fill-opacity="0.85"/>
|
|
35
|
+
<circle cx="999" cy="262" r="11" fill="#e6edf3"/>
|
|
36
|
+
<text x="1040" y="267" font-size="14" font-weight="700" fill="#3fb950">ON</text>
|
|
37
|
+
|
|
38
|
+
<!-- row 2: OFF -->
|
|
39
|
+
<text x="120" y="311" font-size="19" fill="#6e7681">doom-overlay</text>
|
|
40
|
+
<text x="360" y="311" font-size="15" fill="#6e7681">index-dir</text>
|
|
41
|
+
<rect x="952" y="295" width="64" height="30" rx="15" fill="#30363d"/>
|
|
42
|
+
<circle cx="969" cy="310" r="11" fill="#8b949e"/>
|
|
43
|
+
<text x="1037" y="315" font-size="14" font-weight="700" fill="#6e7681">OFF</text>
|
|
44
|
+
|
|
45
|
+
<!-- row 3: ON -->
|
|
46
|
+
<text x="120" y="359" font-size="19" fill="#c9d1d9">my-tools</text>
|
|
47
|
+
<text x="360" y="359" font-size="15" fill="#6e7681">manifest-dir</text>
|
|
48
|
+
<rect x="952" y="343" width="64" height="30" rx="15" fill="#238636" fill-opacity="0.85"/>
|
|
49
|
+
<circle cx="999" cy="358" r="11" fill="#e6edf3"/>
|
|
50
|
+
<text x="1040" y="363" font-size="14" font-weight="700" fill="#3fb950">ON</text>
|
|
51
|
+
|
|
52
|
+
<!-- row 4: LOCKED (self) -->
|
|
53
|
+
<text x="120" y="407" font-size="19" fill="#6e7681">extension-guy</text>
|
|
54
|
+
<text x="360" y="407" font-size="15" fill="#6e7681">self</text>
|
|
55
|
+
<g transform="translate(972,390)">
|
|
56
|
+
<path d="M4,9 v-3 a6,6 0 0 1 12,0 v3" fill="none" stroke="#6e7681" stroke-width="2.4"/>
|
|
57
|
+
<rect x="0" y="9" width="20" height="15" rx="2.5" fill="#6e7681"/>
|
|
58
|
+
<circle cx="10" cy="15" r="2" fill="#161b22"/>
|
|
59
|
+
</g>
|
|
60
|
+
<text x="1018" y="407" font-size="14" font-weight="700" fill="#6e7681">LOCKED</text>
|
|
61
|
+
|
|
62
|
+
<!-- footer hint inside panel -->
|
|
63
|
+
<text x="120" y="446" font-size="15" fill="#6e7681">โ/โ move ยท space toggle ยท <tspan fill="#8b949e">enter apply + reload</tspan> ยท esc cancel</text>
|
|
64
|
+
|
|
65
|
+
<!-- ===== mechanism strip ===== -->
|
|
66
|
+
<rect x="80" y="498" width="290" height="30" rx="15" fill="#58a6ff" fill-opacity="0.13"/>
|
|
67
|
+
<text x="100" y="518" font-size="16" font-weight="700" fill="#58a6ff" letter-spacing="1">the whole trick</text>
|
|
68
|
+
|
|
69
|
+
<g font-size="18">
|
|
70
|
+
<rect x="120" y="544" width="150" height="40" rx="9" fill="#11261a" stroke="#238636"/>
|
|
71
|
+
<text x="195" y="570" fill="#56d364" text-anchor="middle">foo.ts</text>
|
|
72
|
+
|
|
73
|
+
<line x1="278" y1="564" x2="338" y2="564" stroke="#58a6ff" stroke-width="2" marker-end="url(#arrowB)" marker-start="url(#arrowB)"/>
|
|
74
|
+
<text x="308" y="538" fill="#6e7681" text-anchor="middle" font-size="13">rename</text>
|
|
75
|
+
|
|
76
|
+
<rect x="346" y="544" width="226" height="40" rx="9" fill="#1c1410" stroke="#9e6a03"/>
|
|
77
|
+
<text x="459" y="570" fill="#d29922" text-anchor="middle">foo.ts.disabled</text>
|
|
78
|
+
|
|
79
|
+
<line x1="592" y1="564" x2="664" y2="564" stroke="#58a6ff" stroke-width="2" marker-end="url(#arrowB)"/>
|
|
80
|
+
|
|
81
|
+
<rect x="672" y="544" width="178" height="40" rx="9" fill="#0d1d33" stroke="#1f6feb"/>
|
|
82
|
+
<text x="761" y="570" fill="#58a6ff" text-anchor="middle">hot reload</text>
|
|
83
|
+
|
|
84
|
+
<text x="880" y="570" fill="#8b949e" font-size="16">โ live, no restart</text>
|
|
85
|
+
</g>
|
|
86
|
+
|
|
87
|
+
<!-- hero tagline -->
|
|
88
|
+
<text x="600" y="636" font-size="29" font-weight="700" fill="#58a6ff" text-anchor="middle" font-style="italic">"the filename is the switch"</text>
|
|
89
|
+
|
|
90
|
+
<!-- footnote -->
|
|
91
|
+
<text x="600" y="668" font-size="14.5" fill="#6e7681" text-anchor="middle">๐งฉ <tspan fill="#8b949e">zero core changes</tspan> ยท ๐๏ธ <tspan fill="#8b949e">the filesystem is the database</tspan> ยท ๐ชถ <tspan fill="#8b949e">no config, no deps</tspan></text>
|
|
92
|
+
</svg>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-extension-manager โ list local extensions and hot enable/disable them.
|
|
3
|
+
*
|
|
4
|
+
* Disable works by renaming a file so the loader no longer discovers it
|
|
5
|
+
* (foo.ts -> foo.ts.disabled), then ctx.reload() re-discovers everything.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: apply + reload run ONLY in the /extensions command path, because
|
|
8
|
+
* reload() exists only on ExtensionCommandContext (not the ExtensionContext a
|
|
9
|
+
* shortcut handler receives). See spec ยง4/ยง5.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { type ManagedItem, scanManagedDir } from "./scan.ts";
|
|
18
|
+
import { applyChanges } from "./toggle.ts";
|
|
19
|
+
import { ExtensionPanel, type PanelResult } from "./panel.ts";
|
|
20
|
+
|
|
21
|
+
function realOrSelf(p: string): string {
|
|
22
|
+
try {
|
|
23
|
+
return fs.realpathSync(p);
|
|
24
|
+
} catch {
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** realpaths identifying this manager (file + containing dir) for self-guard. */
|
|
30
|
+
function selfRealPaths(): string[] {
|
|
31
|
+
const here = realOrSelf(fileURLToPath(import.meta.url));
|
|
32
|
+
const set = new Set<string>([here, realOrSelf(path.dirname(here))]);
|
|
33
|
+
// Also guard the package root (one level up from src/dist).
|
|
34
|
+
set.add(realOrSelf(path.dirname(path.dirname(here))));
|
|
35
|
+
return [...set];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function managedDirs(cwd: string): Array<{ dir: string; scope: "global" | "project" }> {
|
|
39
|
+
const globalDir = path.join(os.homedir(), ".pi", "agent", "extensions");
|
|
40
|
+
const projectDir = path.join(cwd, ".pi", "extensions");
|
|
41
|
+
return [
|
|
42
|
+
{ dir: globalDir, scope: "global" },
|
|
43
|
+
{ dir: projectDir, scope: "project" },
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scanAll(cwd: string): ManagedItem[] {
|
|
48
|
+
const selves = selfRealPaths();
|
|
49
|
+
const items: ManagedItem[] = [];
|
|
50
|
+
for (const { dir, scope } of managedDirs(cwd)) {
|
|
51
|
+
items.push(...scanManagedDir(dir, scope, selves));
|
|
52
|
+
}
|
|
53
|
+
return items;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default function (pi: ExtensionAPI) {
|
|
57
|
+
pi.registerCommand("extensions", {
|
|
58
|
+
description: "List local extensions; hot enable/disable them",
|
|
59
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
60
|
+
if (ctx.mode !== "tui") {
|
|
61
|
+
ctx.ui.notify("extensions panel requires interactive mode", "error");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const items = scanAll(ctx.cwd);
|
|
66
|
+
|
|
67
|
+
// Show the panel and collect pending changes.
|
|
68
|
+
const result = await ctx.ui.custom<PanelResult | undefined>(
|
|
69
|
+
(tui, theme, _kb, done) => new ExtensionPanel(items, theme, tui, done),
|
|
70
|
+
{ overlay: true },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Cancelled or no changes.
|
|
74
|
+
if (!result || result.changes.size === 0) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 1. All disk renames first (pure fs, no ctx use after this for state).
|
|
79
|
+
const applied = applyChanges(items, result.changes);
|
|
80
|
+
const okCount = applied.filter((r) => r.ok).length;
|
|
81
|
+
const errs = applied.filter((r) => !r.ok);
|
|
82
|
+
|
|
83
|
+
// 2. Nothing applied -> report, no reload.
|
|
84
|
+
if (okCount === 0) {
|
|
85
|
+
ctx.ui.notify(
|
|
86
|
+
errs.length ? `All toggles failed: ${errs[0]!.error}` : "No changes applied",
|
|
87
|
+
"warning",
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Report BEFORE reload (last safe ctx use for messaging).
|
|
93
|
+
const errNote = errs.length ? ` (${errs.length} failed)` : "";
|
|
94
|
+
ctx.ui.notify(`Applied ${okCount} change(s)${errNote}; reloadingโฆ`, "info");
|
|
95
|
+
|
|
96
|
+
// 4. Reload is terminal for this handler. Do NOT touch ctx afterwards.
|
|
97
|
+
await ctx.reload();
|
|
98
|
+
return;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Optional shortcut: opens the panel via the command path only (its ctx has
|
|
103
|
+
// no reload()). Off by default โ uncomment to enable.
|
|
104
|
+
// pi.registerShortcut("ctrl+e", {
|
|
105
|
+
// description: "Open extension manager",
|
|
106
|
+
// handler: () => pi.sendUserMessage("/extensions"),
|
|
107
|
+
// });
|
|
108
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panel.ts โ the interactive overlay listing managed extensions.
|
|
3
|
+
*
|
|
4
|
+
* Pattern adapted from examples/extensions/overlay-test.ts. Pending toggles are
|
|
5
|
+
* in-memory only; nothing touches disk until the user presses enter (apply).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { ManagedItem, Scope } from "./scan.ts";
|
|
11
|
+
|
|
12
|
+
/** Minimal surface we need from the TUI (avoids a second pi-tui type identity). */
|
|
13
|
+
interface RenderHost {
|
|
14
|
+
requestRender(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PanelResult {
|
|
18
|
+
/** item.id -> desired enabled state, only for items the user changed. */
|
|
19
|
+
changes: Map<string, boolean>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Row {
|
|
23
|
+
type: "header" | "item";
|
|
24
|
+
label?: string; // header text
|
|
25
|
+
item?: ManagedItem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SCOPE_TITLE: Record<Scope, string> = {
|
|
29
|
+
global: "GLOBAL (~/.pi/agent/extensions)",
|
|
30
|
+
project: "PROJECT (.pi/extensions)",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class ExtensionPanel {
|
|
34
|
+
readonly width = 54;
|
|
35
|
+
focused = false;
|
|
36
|
+
|
|
37
|
+
private rows: Row[] = [];
|
|
38
|
+
private selected = 0;
|
|
39
|
+
/** pending desired-enabled overrides keyed by item.id */
|
|
40
|
+
private pending = new Map<string, boolean>();
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly items: ManagedItem[],
|
|
44
|
+
private readonly theme: Theme,
|
|
45
|
+
private readonly tui: RenderHost,
|
|
46
|
+
private readonly done: (result: PanelResult | undefined) => void,
|
|
47
|
+
) {
|
|
48
|
+
this.buildRows();
|
|
49
|
+
// Land selection on the first togglable item if any.
|
|
50
|
+
const firstItem = this.rows.findIndex((r) => r.type === "item" && r.item?.togglable);
|
|
51
|
+
if (firstItem >= 0) this.selected = firstItem;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private buildRows(): void {
|
|
55
|
+
this.rows = [];
|
|
56
|
+
for (const scope of ["global", "project"] as Scope[]) {
|
|
57
|
+
const group = this.items.filter((i) => i.scope === scope);
|
|
58
|
+
if (group.length === 0) continue;
|
|
59
|
+
this.rows.push({ type: "header", label: SCOPE_TITLE[scope] });
|
|
60
|
+
for (const item of group) this.rows.push({ type: "item", item });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private effectiveEnabled(item: ManagedItem): boolean {
|
|
65
|
+
return this.pending.has(item.id) ? this.pending.get(item.id)! : item.enabled;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private moveSelection(delta: number): void {
|
|
69
|
+
let i = this.selected;
|
|
70
|
+
for (let step = 0; step < this.rows.length; step++) {
|
|
71
|
+
i = (i + delta + this.rows.length) % this.rows.length;
|
|
72
|
+
if (this.rows[i]?.type === "item") {
|
|
73
|
+
this.selected = i;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private toggleSelected(): void {
|
|
80
|
+
const row = this.rows[this.selected];
|
|
81
|
+
if (row?.type !== "item" || !row.item) return;
|
|
82
|
+
const item = row.item;
|
|
83
|
+
if (!item.togglable) return;
|
|
84
|
+
const next = !this.effectiveEnabled(item);
|
|
85
|
+
if (next === item.enabled) {
|
|
86
|
+
this.pending.delete(item.id); // back to original -> no change
|
|
87
|
+
} else {
|
|
88
|
+
this.pending.set(item.id, next);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
handleInput(data: string): void {
|
|
93
|
+
if (matchesKey(data, "escape")) {
|
|
94
|
+
this.done(undefined);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (matchesKey(data, "return")) {
|
|
98
|
+
this.done({ changes: new Map(this.pending) });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (matchesKey(data, "up")) {
|
|
102
|
+
this.moveSelection(-1);
|
|
103
|
+
} else if (matchesKey(data, "down")) {
|
|
104
|
+
this.moveSelection(1);
|
|
105
|
+
} else if (data === " ") {
|
|
106
|
+
this.toggleSelected();
|
|
107
|
+
}
|
|
108
|
+
this.tui.requestRender();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
render(_width: number): string[] {
|
|
112
|
+
const th = this.theme;
|
|
113
|
+
const innerW = this.width - 2;
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
|
|
116
|
+
const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
|
|
117
|
+
const row = (content: string) => th.fg("border", "โ") + pad(content, innerW) + th.fg("border", "โ");
|
|
118
|
+
|
|
119
|
+
lines.push(th.fg("border", `โญ${"โ".repeat(innerW)}โฎ`));
|
|
120
|
+
lines.push(row(` ${th.fg("accent", "Extensions")} ${th.fg("dim", "(managed dirs only)")}`));
|
|
121
|
+
|
|
122
|
+
if (this.rows.length === 0) {
|
|
123
|
+
lines.push(row(` ${th.fg("dim", "No local extensions found.")}`));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < this.rows.length; i++) {
|
|
127
|
+
const r = this.rows[i]!;
|
|
128
|
+
if (r.type === "header") {
|
|
129
|
+
lines.push(row(` ${th.fg("dim", `โโ ${r.label} โโ`)}`));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const item = r.item!;
|
|
133
|
+
const isSel = i === this.selected;
|
|
134
|
+
const enabled = this.effectiveEnabled(item);
|
|
135
|
+
const dirty = this.pending.has(item.id);
|
|
136
|
+
|
|
137
|
+
let box: string;
|
|
138
|
+
if (!item.togglable) box = th.fg("dim", "[-]");
|
|
139
|
+
else if (enabled) box = th.fg("success", "[x]");
|
|
140
|
+
else box = th.fg("dim", "[ ]");
|
|
141
|
+
|
|
142
|
+
const namePlain = item.name;
|
|
143
|
+
const name = !item.togglable
|
|
144
|
+
? th.fg("dim", namePlain)
|
|
145
|
+
: isSel
|
|
146
|
+
? th.fg("accent", namePlain)
|
|
147
|
+
: th.fg("text", namePlain);
|
|
148
|
+
|
|
149
|
+
const tagText = item.reason ? `(${item.reason})` : item.shape;
|
|
150
|
+
const tag = th.fg("dim", tagText);
|
|
151
|
+
const star = dirty ? th.fg("warning", "*") : " ";
|
|
152
|
+
const prefix = isSel ? th.fg("accent", " โถ ") : " ";
|
|
153
|
+
|
|
154
|
+
// name column padded to ~22 visible chars
|
|
155
|
+
const namePad = namePlain + " ".repeat(Math.max(0, 22 - visibleWidth(namePlain)));
|
|
156
|
+
const nameCol = name + namePad.slice(namePlain.length);
|
|
157
|
+
lines.push(row(`${prefix}${box} ${nameCol} ${tag}${star}`));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
lines.push(row(""));
|
|
161
|
+
lines.push(row(` ${th.fg("dim", "โ/โ move space toggle")}`));
|
|
162
|
+
lines.push(row(` ${th.fg("dim", "enter apply+reload esc cancel")}`));
|
|
163
|
+
lines.push(th.fg("border", `โฐ${"โ".repeat(innerW)}โฏ`));
|
|
164
|
+
return lines;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
invalidate(): void {}
|
|
168
|
+
dispose(): void {}
|
|
169
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scan.ts โ discover & classify local extensions in the managed dirs.
|
|
3
|
+
*
|
|
4
|
+
* Replicates pi's loader discovery rules (isExtensionFile /
|
|
5
|
+
* resolveExtensionEntries) so the panel's enabled/disabled state mirrors what
|
|
6
|
+
* the loader would actually do. See spec ยง3 / ยง5.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
export type Scope = "global" | "project";
|
|
13
|
+
export type Shape = "file" | "index-dir" | "manifest-dir";
|
|
14
|
+
|
|
15
|
+
/** A single on-disk rename target: the path when enabled vs when disabled. */
|
|
16
|
+
export interface RenameUnit {
|
|
17
|
+
/** Absolute path that exists when the entry is ENABLED. */
|
|
18
|
+
enabledPath: string;
|
|
19
|
+
/** Absolute path that exists when the entry is DISABLED. */
|
|
20
|
+
disabledPath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ManagedItem {
|
|
24
|
+
id: string; // `${scope}:${relativeName}`
|
|
25
|
+
name: string; // display name
|
|
26
|
+
scope: Scope;
|
|
27
|
+
shape: Shape;
|
|
28
|
+
enabled: boolean; // derived to mirror the loader
|
|
29
|
+
togglable: boolean; // false for self / symlink-escape / unloadable
|
|
30
|
+
reason?: string; // tag shown when not togglable
|
|
31
|
+
/** Rename units applied on toggle (disable = enabled->disabled, enable = reverse). */
|
|
32
|
+
units: RenameUnit[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DISABLED_SUFFIX = ".disabled";
|
|
36
|
+
|
|
37
|
+
function isExtensionFile(name: string): boolean {
|
|
38
|
+
return name.endsWith(".ts") || name.endsWith(".js");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isDisabledExtensionFile(name: string): boolean {
|
|
42
|
+
return name.endsWith(`.ts${DISABLED_SUFFIX}`) || name.endsWith(`.js${DISABLED_SUFFIX}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Strip a trailing ".disabled" if present. */
|
|
46
|
+
function stripDisabled(name: string): string {
|
|
47
|
+
return name.endsWith(DISABLED_SUFFIX) ? name.slice(0, -DISABLED_SUFFIX.length) : name;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeRealpath(p: string): string | undefined {
|
|
51
|
+
try {
|
|
52
|
+
return fs.realpathSync(p);
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Does `child`'s realpath stay inside `parent`? (parent assumed already real) */
|
|
59
|
+
function isInside(parentReal: string, childReal: string): boolean {
|
|
60
|
+
const rel = path.relative(parentReal, childReal);
|
|
61
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ManifestInfo {
|
|
65
|
+
/** package.json (or .disabled) names present. */
|
|
66
|
+
pkgPath?: string;
|
|
67
|
+
pkgDisabledPath?: string;
|
|
68
|
+
/** Entry paths declared in pi.extensions that currently exist on disk. */
|
|
69
|
+
resolvedEntries: string[];
|
|
70
|
+
/** Whether a (non-disabled) package.json with a pi.extensions array exists. */
|
|
71
|
+
hasManifest: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readManifest(dir: string): ManifestInfo {
|
|
75
|
+
const pkgPath = path.join(dir, "package.json");
|
|
76
|
+
const pkgDisabledPath = path.join(dir, `package.json${DISABLED_SUFFIX}`);
|
|
77
|
+
const info: ManifestInfo = { resolvedEntries: [], hasManifest: false };
|
|
78
|
+
if (fs.existsSync(pkgDisabledPath)) info.pkgDisabledPath = pkgDisabledPath;
|
|
79
|
+
if (!fs.existsSync(pkgPath)) return info;
|
|
80
|
+
info.pkgPath = pkgPath;
|
|
81
|
+
try {
|
|
82
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
83
|
+
const exts: unknown = pkg?.pi?.extensions;
|
|
84
|
+
if (Array.isArray(exts)) {
|
|
85
|
+
info.hasManifest = true;
|
|
86
|
+
for (const e of exts) {
|
|
87
|
+
if (typeof e !== "string") continue;
|
|
88
|
+
const abs = path.resolve(dir, e);
|
|
89
|
+
if (fs.existsSync(abs)) info.resolvedEntries.push(abs);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// malformed package.json -> treat as no manifest
|
|
94
|
+
}
|
|
95
|
+
return info;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Index files (index.ts/index.js) present, enabled or disabled. */
|
|
99
|
+
function indexUnits(dir: string): { units: RenameUnit[]; anyEnabled: boolean } {
|
|
100
|
+
const units: RenameUnit[] = [];
|
|
101
|
+
let anyEnabled = false;
|
|
102
|
+
for (const base of ["index.ts", "index.js"]) {
|
|
103
|
+
const enabledPath = path.join(dir, base);
|
|
104
|
+
const disabledPath = path.join(dir, base + DISABLED_SUFFIX);
|
|
105
|
+
const hasEnabled = fs.existsSync(enabledPath);
|
|
106
|
+
const hasDisabled = fs.existsSync(disabledPath);
|
|
107
|
+
if (hasEnabled || hasDisabled) {
|
|
108
|
+
units.push({ enabledPath, disabledPath });
|
|
109
|
+
if (hasEnabled) anyEnabled = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { units, anyEnabled };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Scan one managed dir into ManagedItem[].
|
|
117
|
+
*
|
|
118
|
+
* @param dir absolute path to a managed extensions dir
|
|
119
|
+
* @param scope "global" | "project"
|
|
120
|
+
* @param selfRealPaths realpaths identifying the manager itself (file + dir)
|
|
121
|
+
*/
|
|
122
|
+
export function scanManagedDir(dir: string, scope: Scope, selfRealPaths: string[]): ManagedItem[] {
|
|
123
|
+
const items: ManagedItem[] = [];
|
|
124
|
+
if (!fs.existsSync(dir)) return items;
|
|
125
|
+
const dirReal = safeRealpath(dir) ?? dir;
|
|
126
|
+
|
|
127
|
+
let entries: fs.Dirent[];
|
|
128
|
+
try {
|
|
129
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
130
|
+
} catch {
|
|
131
|
+
return items;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const isSelf = (p: string): boolean => {
|
|
135
|
+
const real = safeRealpath(p);
|
|
136
|
+
if (!real) return false;
|
|
137
|
+
return selfRealPaths.includes(real);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const entryPath = path.join(dir, entry.name);
|
|
142
|
+
const isLink = entry.isSymbolicLink();
|
|
143
|
+
|
|
144
|
+
// Symlink whose realpath escapes the managed dir -> read-only.
|
|
145
|
+
if (isLink) {
|
|
146
|
+
const real = safeRealpath(entryPath);
|
|
147
|
+
if (!real || !isInside(dirReal, real)) {
|
|
148
|
+
const isDirLink = !!real && fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory();
|
|
149
|
+
// Only surface symlinks that look like extensions.
|
|
150
|
+
const looksExt = isExtensionFile(entry.name) || isDisabledExtensionFile(entry.name) || isDirLink;
|
|
151
|
+
if (!looksExt) continue;
|
|
152
|
+
items.push({
|
|
153
|
+
id: `${scope}:${entry.name}`,
|
|
154
|
+
name: isDirLink ? entry.name : stripDisabled(entry.name).replace(/\.(ts|js)$/, ""),
|
|
155
|
+
scope,
|
|
156
|
+
shape: isDirLink ? "index-dir" : "file",
|
|
157
|
+
// A dir symlink that resolves is loaded; a file symlink is enabled iff its name is .ts/.js.
|
|
158
|
+
enabled: isDirLink ? true : isExtensionFile(entry.name),
|
|
159
|
+
togglable: false,
|
|
160
|
+
reason: "symlink",
|
|
161
|
+
units: [],
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Single file ---
|
|
168
|
+
if (entry.isFile() && (isExtensionFile(entry.name) || isDisabledExtensionFile(entry.name))) {
|
|
169
|
+
const enabledName = stripDisabled(entry.name);
|
|
170
|
+
const enabledPath = path.join(dir, enabledName);
|
|
171
|
+
const disabledPath = path.join(dir, enabledName + DISABLED_SUFFIX);
|
|
172
|
+
const enabled = !entry.name.endsWith(DISABLED_SUFFIX);
|
|
173
|
+
items.push({
|
|
174
|
+
id: `${scope}:${enabledName}`,
|
|
175
|
+
name: enabledName.replace(/\.(ts|js)$/, ""),
|
|
176
|
+
scope,
|
|
177
|
+
shape: "file",
|
|
178
|
+
enabled,
|
|
179
|
+
togglable: !isSelf(entryPath),
|
|
180
|
+
reason: isSelf(entryPath) ? "self" : undefined,
|
|
181
|
+
units: [{ enabledPath, disabledPath }],
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Directory (or symlink-to-dir that stays inside) ---
|
|
187
|
+
const isDir = entry.isDirectory() || (isLink && fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory());
|
|
188
|
+
if (!isDir) continue;
|
|
189
|
+
|
|
190
|
+
const manifest = readManifest(entryPath);
|
|
191
|
+
const idx = indexUnits(entryPath);
|
|
192
|
+
|
|
193
|
+
// Manifest dir: package.json (or .disabled) drives loading.
|
|
194
|
+
if (manifest.hasManifest || manifest.pkgDisabledPath) {
|
|
195
|
+
const pkgEnabled = path.join(entryPath, "package.json");
|
|
196
|
+
const pkgDisabled = path.join(entryPath, `package.json${DISABLED_SUFFIX}`);
|
|
197
|
+
// units: package.json first, then any index files (to prevent fallthrough).
|
|
198
|
+
const units: RenameUnit[] = [{ enabledPath: pkgEnabled, disabledPath: pkgDisabled }, ...idx.units];
|
|
199
|
+
|
|
200
|
+
// enabled iff a live package.json resolves >=1 existing entry,
|
|
201
|
+
// OR (manifest absent/empty but index present) -> index would load.
|
|
202
|
+
const manifestLoads = manifest.hasManifest && manifest.resolvedEntries.length > 0;
|
|
203
|
+
const enabled = manifestLoads || (!manifest.pkgPath && idx.anyEnabled);
|
|
204
|
+
|
|
205
|
+
// Unloadable: package.json present but no valid entries and no index.
|
|
206
|
+
const unloadable = !!manifest.pkgPath && !manifestLoads && idx.units.length === 0;
|
|
207
|
+
|
|
208
|
+
const self = isSelf(entryPath);
|
|
209
|
+
items.push({
|
|
210
|
+
id: `${scope}:${entry.name}`,
|
|
211
|
+
name: entry.name,
|
|
212
|
+
scope,
|
|
213
|
+
shape: "manifest-dir",
|
|
214
|
+
enabled,
|
|
215
|
+
togglable: !self && !unloadable,
|
|
216
|
+
reason: self ? "self" : unloadable ? "unloadable" : undefined,
|
|
217
|
+
units,
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Index dir: index.ts and/or index.js.
|
|
223
|
+
if (idx.units.length > 0) {
|
|
224
|
+
const self = isSelf(entryPath);
|
|
225
|
+
items.push({
|
|
226
|
+
id: `${scope}:${entry.name}`,
|
|
227
|
+
name: entry.name,
|
|
228
|
+
scope,
|
|
229
|
+
shape: "index-dir",
|
|
230
|
+
enabled: idx.anyEnabled,
|
|
231
|
+
togglable: !self,
|
|
232
|
+
reason: self ? "self" : undefined,
|
|
233
|
+
units: idx.units,
|
|
234
|
+
});
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Otherwise: not an extension dir, ignore.
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Stable order: enabled first within togglable, then by name.
|
|
242
|
+
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
243
|
+
return items;
|
|
244
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toggle.ts โ apply enable/disable by renaming files on disk.
|
|
3
|
+
*
|
|
4
|
+
* - Re-reads current on-disk state right before renaming (precheck) so a
|
|
5
|
+
* concurrently-modified entry produces a clean per-item error, not a crash.
|
|
6
|
+
* - Renames within ONE item are rolled back if a later rename in that item
|
|
7
|
+
* fails, so an index-dir is never left half-renamed (spec ยง5 / Critic).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import type { ManagedItem } from "./scan.ts";
|
|
12
|
+
|
|
13
|
+
export interface ApplyResult {
|
|
14
|
+
item: ManagedItem;
|
|
15
|
+
ok: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pairs of [from, to] needed to reach `targetEnabled` for one item. */
|
|
20
|
+
function planRenames(item: ManagedItem, targetEnabled: boolean): Array<{ from: string; to: string }> {
|
|
21
|
+
const plan: Array<{ from: string; to: string }> = [];
|
|
22
|
+
for (const unit of item.units) {
|
|
23
|
+
const from = targetEnabled ? unit.disabledPath : unit.enabledPath;
|
|
24
|
+
const to = targetEnabled ? unit.enabledPath : unit.disabledPath;
|
|
25
|
+
// Only rename units whose source currently exists and whose target does not.
|
|
26
|
+
if (fs.existsSync(from)) {
|
|
27
|
+
plan.push({ from, to });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return plan;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Precheck a plan; throws on collision/missing so we abort before any rename. */
|
|
34
|
+
function precheck(plan: Array<{ from: string; to: string }>): void {
|
|
35
|
+
for (const { from, to } of plan) {
|
|
36
|
+
if (!fs.existsSync(from)) {
|
|
37
|
+
throw new Error(`source vanished: ${from}`);
|
|
38
|
+
}
|
|
39
|
+
if (fs.existsSync(to)) {
|
|
40
|
+
throw new Error(`target already exists: ${to}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Toggle one item to `targetEnabled`. Atomic-per-item with rollback.
|
|
47
|
+
* Returns nothing; throws on failure (caller collects).
|
|
48
|
+
*/
|
|
49
|
+
export function setItemEnabled(item: ManagedItem, targetEnabled: boolean): void {
|
|
50
|
+
if (!item.togglable) {
|
|
51
|
+
throw new Error(`not togglable (${item.reason ?? "locked"})`);
|
|
52
|
+
}
|
|
53
|
+
const plan = planRenames(item, targetEnabled);
|
|
54
|
+
if (plan.length === 0) {
|
|
55
|
+
// Already in desired state (or nothing to do).
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
precheck(plan);
|
|
59
|
+
|
|
60
|
+
const done: Array<{ from: string; to: string }> = [];
|
|
61
|
+
try {
|
|
62
|
+
for (const step of plan) {
|
|
63
|
+
fs.renameSync(step.from, step.to);
|
|
64
|
+
done.push(step);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Roll back already-applied renames in reverse.
|
|
68
|
+
for (let i = done.length - 1; i >= 0; i--) {
|
|
69
|
+
const step = done[i]!;
|
|
70
|
+
try {
|
|
71
|
+
fs.renameSync(step.to, step.from);
|
|
72
|
+
} catch {
|
|
73
|
+
// Best-effort rollback; surface original error below.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Apply a batch of desired states. Never throws; returns per-item results.
|
|
82
|
+
* `changes` maps item.id -> desired enabled state (only items that changed).
|
|
83
|
+
*/
|
|
84
|
+
export function applyChanges(items: ManagedItem[], changes: Map<string, boolean>): ApplyResult[] {
|
|
85
|
+
const results: ApplyResult[] = [];
|
|
86
|
+
for (const item of items) {
|
|
87
|
+
if (!changes.has(item.id)) continue;
|
|
88
|
+
const target = changes.get(item.id)!;
|
|
89
|
+
try {
|
|
90
|
+
setItemEnabled(item, target);
|
|
91
|
+
results.push({ item, ok: true });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
results.push({ item, ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
97
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nukcole-xinluo9510/pi-extension-guy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Pi extension โ a control panel to hot enable/disable your local extensions by typing /extensions",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"extension",
|
|
11
|
+
"manager",
|
|
12
|
+
"toggle",
|
|
13
|
+
"hot-reload"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/luoxin9510/pi-packages.git",
|
|
19
|
+
"directory": "packages/pi-extension-guy"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"check": "tsc --noEmit",
|
|
23
|
+
"test": "node --experimental-strip-types --test test/*.test.ts"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"extensions",
|
|
27
|
+
"assets",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./extensions/extension-guy/index.ts"
|
|
37
|
+
],
|
|
38
|
+
"image": "https://raw.githubusercontent.com/luoxin9510/pi-packages/main/packages/pi-extension-guy/assets/switchboard.png"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": ">=0.79",
|
|
42
|
+
"@earendil-works/pi-tui": ">=0.79"
|
|
43
|
+
}
|
|
44
|
+
}
|