dsh-skill-picker 0.5.22 → 0.5.23

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.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Skill operations for the ⚡ panel: enable / disable, reveal in the file
3
+ * manager, and uninstall-with-undo.
4
+ *
5
+ * Three rules this module exists to enforce:
6
+ *
7
+ * 1. **Never delete anything.** "Uninstall" MOVES the skill into
8
+ * `$DSH_HOME/skill-backups/<stamp>-<name>/` next to a manifest, and the panel
9
+ * can move it back. This module makes no destructive filesystem call at all;
10
+ * a move that cannot be done with `rename()` (i.e. across volumes) fails
11
+ * loudly instead of falling back to copy-then-remove.
12
+ *
13
+ * 2. **Only inside the caller's skill roots.** The roots are passed in — this
14
+ * module must NOT re-derive them, or the two copies drift apart (which is
15
+ * exactly how the picker once ended up patching one file while DSH served
16
+ * another). Every path the browser sends is resolved and checked against the
17
+ * given roots; a path that escapes them, is the root itself, or is nested
18
+ * deeper than one level is refused. The route is reachable without
19
+ * authentication — plugin routes sit outside the web server's auth gate — so
20
+ * this is not optional.
21
+ *
22
+ * 3. **Reversible, and honest about state.** Disabling renames the skill's entry
23
+ * file, which is exactly what the official `dsh-skill-filesystem` provider
24
+ * keys on, so a disabled skill really does stop loading for agents too — it
25
+ * is not hidden only in our own UI. Re-enabling renames it back.
26
+ *
27
+ * Entry-file shape comes from the provider: a skill is a directory containing
28
+ * `SKILL.md`, or a top-level `.md` file inside a skill root.
29
+ *
30
+ * @module dsh-skill-picker/skill-ops
31
+ */
32
+
33
+ import { spawn } from 'node:child_process'
34
+ import { mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises'
35
+ import path from 'node:path'
36
+
37
+ import { dshHome } from './patch-ui-skill.js'
38
+
39
+ /** The entry file the official provider looks for. */
40
+ export const ENTRY = 'SKILL.md'
41
+
42
+ /** What a disabled entry file is called. The provider ignores it entirely. */
43
+ export const DISABLED_ENTRY = `${ENTRY}.disabled`
44
+
45
+ /** Set on every mutating request, so a cross-site form POST cannot reach these. */
46
+ export const CLIENT_HEADER = 'x-dsh-skill-picker'
47
+
48
+ /** Where uninstalled skills go, newest first. */
49
+ export function skillBackupDir() {
50
+ return path.join(dshHome(), 'skill-backups')
51
+ }
52
+
53
+ /**
54
+ * Resolve a browser-supplied skill path against the caller's roots.
55
+ *
56
+ * Requires exactly one path segment below a root, so a request for the root
57
+ * itself, for a file nested inside a skill, or for `../../` is rejected.
58
+ *
59
+ * @param candidate - the path the browser sent.
60
+ * @param roots - absolute skill roots (from the host's own helpers).
61
+ * @returns the resolved absolute path, or undefined when it is not a skill slot.
62
+ */
63
+ export function resolveSkillPath(candidate, roots) {
64
+ if (typeof candidate !== 'string' || candidate === '') return undefined
65
+ const list = Array.isArray(roots) ? roots : []
66
+ const resolved = path.resolve(candidate)
67
+ for (const root of list) {
68
+ const base = path.resolve(root)
69
+ const relative = path.relative(base, resolved)
70
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) continue
71
+ if (relative.split(path.sep).filter(Boolean).length !== 1) continue
72
+ return resolved
73
+ }
74
+ return undefined
75
+ }
76
+
77
+ /** Whether the path is a directory (a skill folder) or a file (a root-level .md). */
78
+ async function pathKind(target) {
79
+ try {
80
+ return (await stat(target)).isDirectory() ? 'dir' : 'file'
81
+ } catch {
82
+ return undefined
83
+ }
84
+ }
85
+
86
+ /** Whether anything exists at `candidate` (used to refuse overwrites). */
87
+ async function exists(candidate) {
88
+ try {
89
+ await stat(candidate)
90
+ return true
91
+ } catch {
92
+ return false
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Turn a skill on or off by renaming its entry file.
98
+ *
99
+ * @param target - an already-validated skill path.
100
+ * @param disabled - the state to move to.
101
+ * @returns `{ entry, disabled }` — the entry file name now in place.
102
+ */
103
+ export async function setSkillDisabled(target, disabled) {
104
+ const kind = await pathKind(target)
105
+ if (kind === undefined) throw new Error(`skill not found: ${target}`)
106
+ const dir = kind === 'dir' ? target : path.dirname(target)
107
+ const base = kind === 'dir' ? ENTRY : path.basename(target)
108
+ const plain = path.join(dir, base)
109
+ const off = path.join(dir, `${base}.disabled`)
110
+ const hasPlain = await exists(plain)
111
+ const hasOff = await exists(off)
112
+
113
+ if (disabled) {
114
+ if (hasOff && !hasPlain) throw new Error('already disabled')
115
+ if (!hasPlain) throw new Error(`cannot disable: ${base} is not there`)
116
+ if (hasOff) throw new Error(`cannot disable: ${base} and ${base}.disabled both exist`)
117
+ await rename(plain, off)
118
+ return { entry: `${base}.disabled`, disabled: true }
119
+ }
120
+
121
+ if (hasPlain && !hasOff) throw new Error('already enabled')
122
+ if (!hasOff) throw new Error(`cannot enable: ${base}.disabled is not there`)
123
+ if (hasPlain) throw new Error(`cannot enable: ${base} and ${base}.disabled both exist`)
124
+ await rename(off, plain)
125
+ return { entry: base, disabled: false }
126
+ }
127
+
128
+ /**
129
+ * Move a skill into the backup directory (nothing is deleted).
130
+ *
131
+ * Layout: `skill-backups/<stamp>-<name>/` holds `manifest.json` plus the moved
132
+ * skill directory (or file) under its original name, so it moves straight back.
133
+ *
134
+ * @returns `{ backupPath, restoreTarget }`.
135
+ */
136
+ export async function uninstallSkill(target) {
137
+ const kind = await pathKind(target)
138
+ if (kind === undefined) throw new Error(`skill not found: ${target}`)
139
+ const name = path.basename(target)
140
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
141
+ const backupPath = path.join(skillBackupDir(), `${stamp}-${name}`)
142
+ await mkdir(backupPath, { recursive: true })
143
+ await rename(target, path.join(backupPath, name))
144
+ await writeFile(path.join(backupPath, 'manifest.json'), `${JSON.stringify({
145
+ name,
146
+ kind,
147
+ originalPath: target,
148
+ movedAt: new Date().toISOString(),
149
+ }, null, 2)}\n`, 'utf8')
150
+ return { backupPath, restoreTarget: target }
151
+ }
152
+
153
+ /** List backups, newest first, so the panel can offer an undo. */
154
+ export async function listSkillBackups(roots) {
155
+ let entries
156
+ try {
157
+ entries = await readdir(skillBackupDir(), { withFileTypes: true })
158
+ } catch {
159
+ return []
160
+ }
161
+ const backups = []
162
+ for (const entry of entries) {
163
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
164
+ const dir = path.join(skillBackupDir(), entry.name)
165
+ try {
166
+ const manifest = JSON.parse(await readFile(path.join(dir, 'manifest.json'), 'utf8'))
167
+ backups.push({
168
+ id: entry.name,
169
+ name: typeof manifest.name === 'string' ? manifest.name : entry.name,
170
+ originalPath: typeof manifest.originalPath === 'string' ? manifest.originalPath : undefined,
171
+ movedAt: typeof manifest.movedAt === 'string' ? manifest.movedAt : undefined,
172
+ restorable: resolveSkillPath(manifest.originalPath, roots) !== undefined,
173
+ })
174
+ } catch {
175
+ /* not one of ours (or a damaged manifest) — leave it alone */
176
+ }
177
+ }
178
+ return backups.sort((a, b) => String(b.movedAt ?? '').localeCompare(String(a.movedAt ?? '')))
179
+ }
180
+
181
+ /**
182
+ * Move a backup back where it came from.
183
+ *
184
+ * Refuses when the destination is occupied, so a restore can never overwrite a
185
+ * skill installed after the uninstall. The emptied backup folder is kept under
186
+ * `skill-backups/.restored/` rather than deleted.
187
+ */
188
+ export async function restoreSkillBackup(id, roots) {
189
+ if (typeof id !== 'string' || id === '' || id.includes('/') || id.includes('\\') || id.includes('..')) {
190
+ throw new Error('invalid backup id')
191
+ }
192
+ const dir = path.join(skillBackupDir(), id)
193
+ let manifest
194
+ try {
195
+ manifest = JSON.parse(await readFile(path.join(dir, 'manifest.json'), 'utf8'))
196
+ } catch {
197
+ throw new Error('that backup is gone')
198
+ }
199
+ const original = resolveSkillPath(manifest.originalPath, roots)
200
+ if (original === undefined) throw new Error('that backup no longer points inside a skill root')
201
+ if (await exists(original)) {
202
+ throw new Error(`${path.basename(original)} is occupied again — move it away first`)
203
+ }
204
+ await rename(path.join(dir, path.basename(original)), original)
205
+ const done = path.join(skillBackupDir(), '.restored')
206
+ await mkdir(done, { recursive: true })
207
+ await rename(dir, path.join(done, id)).catch(() => {})
208
+ return { restored: original }
209
+ }
210
+
211
+ /**
212
+ * Reveal a skill in the OS file manager.
213
+ *
214
+ * Best-effort and detached: the host must not wait on (or be taken down by) a
215
+ * file-manager window.
216
+ */
217
+ export function revealSkill(target, revealPath) {
218
+ const show = typeof revealPath === 'string' && revealPath !== '' ? revealPath : target
219
+ try {
220
+ if (process.platform === 'win32') {
221
+ spawn('explorer.exe', [`/select,${show}`], { detached: true, stdio: 'ignore' }).unref()
222
+ return true
223
+ }
224
+ if (process.platform === 'darwin') {
225
+ spawn('open', ['-R', show], { detached: true, stdio: 'ignore' }).unref()
226
+ return true
227
+ }
228
+ spawn('xdg-open', [path.dirname(show)], { detached: true, stdio: 'ignore' }).unref()
229
+ return true
230
+ } catch {
231
+ return false
232
+ }
233
+ }