agent-skill-manager 1.0.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/CODE_OF_CONDUCT.md +59 -0
- package/CONTRIBUTING.md +92 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/RELEASE_NOTES.md +31 -0
- package/SECURITY.md +43 -0
- package/bin/skill-manager.ts +46 -0
- package/bun.lock +204 -0
- package/docs/ARCHITECTURE.md +60 -0
- package/docs/CHANGELOG.md +22 -0
- package/docs/DEPLOYMENT.md +52 -0
- package/docs/DEVELOPMENT.md +64 -0
- package/package.json +44 -0
- package/src/config.ts +109 -0
- package/src/index.ts +324 -0
- package/src/scanner.ts +165 -0
- package/src/uninstaller.ts +225 -0
- package/src/utils/colors.ts +16 -0
- package/src/utils/frontmatter.ts +87 -0
- package/src/utils/types.ts +57 -0
- package/src/utils/version.ts +20 -0
- package/src/views/config.ts +147 -0
- package/src/views/confirm.ts +105 -0
- package/src/views/dashboard.ts +252 -0
- package/src/views/help.ts +83 -0
- package/src/views/skill-detail.ts +114 -0
- package/src/views/skill-list.ts +122 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { createCliRenderer } from "@opentui/core";
|
|
2
|
+
import type {
|
|
3
|
+
SkillInfo,
|
|
4
|
+
Scope,
|
|
5
|
+
SortBy,
|
|
6
|
+
ViewState,
|
|
7
|
+
AppConfig,
|
|
8
|
+
} from "./utils/types";
|
|
9
|
+
import { loadConfig, saveConfig, getConfigPath } from "./config";
|
|
10
|
+
import { scanAllSkills, searchSkills, sortSkills } from "./scanner";
|
|
11
|
+
import {
|
|
12
|
+
buildFullRemovalPlan,
|
|
13
|
+
executeRemoval,
|
|
14
|
+
getExistingTargets,
|
|
15
|
+
} from "./uninstaller";
|
|
16
|
+
import { createDashboard } from "./views/dashboard";
|
|
17
|
+
import { createSkillList } from "./views/skill-list";
|
|
18
|
+
import { createDetailView } from "./views/skill-detail";
|
|
19
|
+
import { createConfirmView } from "./views/confirm";
|
|
20
|
+
import { createHelpView } from "./views/help";
|
|
21
|
+
import { createConfigView } from "./views/config";
|
|
22
|
+
|
|
23
|
+
// ─── State ──────────────────────────────────────────────────────────────────
|
|
24
|
+
let currentConfig: AppConfig;
|
|
25
|
+
let allSkills: SkillInfo[] = [];
|
|
26
|
+
let filteredSkills: SkillInfo[] = [];
|
|
27
|
+
let currentScope: Scope = "both";
|
|
28
|
+
let currentSort: SortBy = "name";
|
|
29
|
+
let searchQuery = "";
|
|
30
|
+
let viewState: ViewState = "dashboard";
|
|
31
|
+
let selectedSkill: SkillInfo | null = null;
|
|
32
|
+
let searchMode = false;
|
|
33
|
+
|
|
34
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
35
|
+
async function main() {
|
|
36
|
+
// Load config before anything else
|
|
37
|
+
currentConfig = await loadConfig();
|
|
38
|
+
currentScope = currentConfig.preferences.defaultScope;
|
|
39
|
+
currentSort = currentConfig.preferences.defaultSort;
|
|
40
|
+
|
|
41
|
+
const renderer = await createCliRenderer({
|
|
42
|
+
exitOnCtrlC: true,
|
|
43
|
+
useAlternateScreen: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── Build Dashboard ─────────────────────────────────────────────────────
|
|
47
|
+
const dashboard = createDashboard(
|
|
48
|
+
renderer,
|
|
49
|
+
currentConfig,
|
|
50
|
+
async (scope: Scope) => {
|
|
51
|
+
currentScope = scope;
|
|
52
|
+
await refreshSkills();
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// ── Skill List ──────────────────────────────────────────────────────────
|
|
57
|
+
const skillList = createSkillList(
|
|
58
|
+
renderer,
|
|
59
|
+
[],
|
|
60
|
+
(skill: SkillInfo) => {
|
|
61
|
+
showDetail(skill);
|
|
62
|
+
},
|
|
63
|
+
renderer.width,
|
|
64
|
+
);
|
|
65
|
+
dashboard.contentArea.add(skillList.container);
|
|
66
|
+
|
|
67
|
+
// ── Overlay containers ────────────────────────────────────────────────
|
|
68
|
+
let overlayContainer: any = null;
|
|
69
|
+
|
|
70
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
71
|
+
async function refreshSkills() {
|
|
72
|
+
allSkills = await scanAllSkills(currentConfig, currentScope);
|
|
73
|
+
applyFilters();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyFilters() {
|
|
77
|
+
let skills = searchSkills(allSkills, searchQuery);
|
|
78
|
+
skills = sortSkills(skills, currentSort);
|
|
79
|
+
filteredSkills = skills;
|
|
80
|
+
skillList.update(filteredSkills);
|
|
81
|
+
dashboard.updateStats(allSkills);
|
|
82
|
+
dashboard.updateSortLabel(currentSort);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function removeOverlay() {
|
|
86
|
+
if (overlayContainer) {
|
|
87
|
+
renderer.root.remove(overlayContainer.id);
|
|
88
|
+
overlayContainer = null;
|
|
89
|
+
}
|
|
90
|
+
viewState = "dashboard";
|
|
91
|
+
skillList.select.focus();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function showDetail(skill: SkillInfo) {
|
|
95
|
+
removeOverlay();
|
|
96
|
+
selectedSkill = skill;
|
|
97
|
+
viewState = "detail";
|
|
98
|
+
overlayContainer = createDetailView(renderer, skill);
|
|
99
|
+
renderer.root.add(overlayContainer);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function showConfirm(skill: SkillInfo) {
|
|
103
|
+
removeOverlay();
|
|
104
|
+
selectedSkill = skill;
|
|
105
|
+
viewState = "confirm";
|
|
106
|
+
|
|
107
|
+
const plan = buildFullRemovalPlan(skill.dirName, allSkills, currentConfig);
|
|
108
|
+
const targets = await getExistingTargets(plan);
|
|
109
|
+
|
|
110
|
+
overlayContainer = createConfirmView(
|
|
111
|
+
renderer,
|
|
112
|
+
skill,
|
|
113
|
+
targets,
|
|
114
|
+
async (result) => {
|
|
115
|
+
if (result.confirmed) {
|
|
116
|
+
await executeRemoval(plan);
|
|
117
|
+
removeOverlay();
|
|
118
|
+
await refreshSkills();
|
|
119
|
+
} else {
|
|
120
|
+
removeOverlay();
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
renderer.root.add(overlayContainer);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function showHelp() {
|
|
128
|
+
removeOverlay();
|
|
129
|
+
viewState = "help";
|
|
130
|
+
overlayContainer = createHelpView(renderer);
|
|
131
|
+
renderer.root.add(overlayContainer);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function showConfig() {
|
|
135
|
+
removeOverlay();
|
|
136
|
+
viewState = "config";
|
|
137
|
+
overlayContainer = createConfigView(
|
|
138
|
+
renderer,
|
|
139
|
+
currentConfig,
|
|
140
|
+
async (updatedConfig) => {
|
|
141
|
+
currentConfig = updatedConfig;
|
|
142
|
+
await saveConfig(updatedConfig);
|
|
143
|
+
dashboard.updateProviderInfo(updatedConfig);
|
|
144
|
+
removeOverlay();
|
|
145
|
+
await refreshSkills();
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
renderer.root.add(overlayContainer);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function cycleSortOrder() {
|
|
152
|
+
const orders: SortBy[] = ["name", "version", "location"];
|
|
153
|
+
const idx = orders.indexOf(currentSort);
|
|
154
|
+
currentSort = orders[(idx + 1) % orders.length];
|
|
155
|
+
applyFilters();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function enterSearchMode() {
|
|
159
|
+
searchMode = true;
|
|
160
|
+
dashboard.searchInput.focus();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function exitSearchMode() {
|
|
164
|
+
searchMode = false;
|
|
165
|
+
searchQuery = "";
|
|
166
|
+
dashboard.searchInput.initialValue = "";
|
|
167
|
+
applyFilters();
|
|
168
|
+
skillList.select.focus();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Keyboard Handling ───────────────────────────────────────────────────
|
|
172
|
+
(renderer.keyInput as any).on("keypress", async (key: any) => {
|
|
173
|
+
// In search mode, handle Esc to exit and Enter to confirm
|
|
174
|
+
if (searchMode) {
|
|
175
|
+
if (key.name === "escape") {
|
|
176
|
+
exitSearchMode();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (key.name === "return") {
|
|
180
|
+
searchQuery = dashboard.searchInput.plainText || "";
|
|
181
|
+
searchMode = false;
|
|
182
|
+
applyFilters();
|
|
183
|
+
skillList.select.focus();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Live filtering as user types
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
searchQuery = dashboard.searchInput.plainText || "";
|
|
189
|
+
applyFilters();
|
|
190
|
+
}, 10);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Global keys
|
|
195
|
+
if (key.name === "q" && viewState === "dashboard") {
|
|
196
|
+
renderer.destroy();
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (key.name === "escape") {
|
|
201
|
+
if (viewState === "config" && overlayContainer) {
|
|
202
|
+
// Save config and close
|
|
203
|
+
const editConfig = (overlayContainer as any).__editConfig;
|
|
204
|
+
const onClose = (overlayContainer as any).__onClose;
|
|
205
|
+
if (onClose && editConfig) {
|
|
206
|
+
onClose(editConfig);
|
|
207
|
+
} else {
|
|
208
|
+
removeOverlay();
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (viewState !== "dashboard") {
|
|
213
|
+
removeOverlay();
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Help overlay
|
|
219
|
+
if (key.sequence === "?") {
|
|
220
|
+
if (viewState === "help") {
|
|
221
|
+
removeOverlay();
|
|
222
|
+
} else if (viewState === "dashboard") {
|
|
223
|
+
showHelp();
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Config view keys
|
|
229
|
+
if (viewState === "config") {
|
|
230
|
+
if (key.name === "e") {
|
|
231
|
+
// Open config in $EDITOR
|
|
232
|
+
const editorCmd = process.env.EDITOR || process.env.VISUAL || "vi";
|
|
233
|
+
const parts = editorCmd.split(/\s+/);
|
|
234
|
+
const configPath = getConfigPath();
|
|
235
|
+
renderer.destroy();
|
|
236
|
+
const proc = Bun.spawn([...parts, configPath], {
|
|
237
|
+
stdin: "inherit",
|
|
238
|
+
stdout: "inherit",
|
|
239
|
+
stderr: "inherit",
|
|
240
|
+
});
|
|
241
|
+
await proc.exited;
|
|
242
|
+
// Reload config and restart
|
|
243
|
+
currentConfig = await loadConfig();
|
|
244
|
+
process.exit(0); // User needs to restart after external edit
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Dashboard-specific keys
|
|
250
|
+
if (viewState === "dashboard") {
|
|
251
|
+
if (key.name === "/" || key.sequence === "/") {
|
|
252
|
+
enterSearchMode();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (key.name === "s" && !key.ctrl) {
|
|
257
|
+
cycleSortOrder();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (key.name === "r" && !key.ctrl) {
|
|
262
|
+
await refreshSkills();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (key.name === "c" && !key.ctrl) {
|
|
267
|
+
await showConfig();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (key.name === "tab") {
|
|
272
|
+
const scopes: Scope[] = ["global", "project", "both"];
|
|
273
|
+
const idx = scopes.indexOf(currentScope);
|
|
274
|
+
currentScope = scopes[(idx + 1) % scopes.length];
|
|
275
|
+
const tabMap: Record<Scope, number> = {
|
|
276
|
+
global: 0,
|
|
277
|
+
project: 1,
|
|
278
|
+
both: 2,
|
|
279
|
+
};
|
|
280
|
+
dashboard.scopeTabs.setSelectedIndex(tabMap[currentScope]);
|
|
281
|
+
refreshSkills();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (key.name === "d") {
|
|
286
|
+
const skill = getSelectedSkill();
|
|
287
|
+
if (skill) {
|
|
288
|
+
showConfirm(skill);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Detail view keys
|
|
295
|
+
if (viewState === "detail") {
|
|
296
|
+
if (key.name === "d") {
|
|
297
|
+
if (selectedSkill) {
|
|
298
|
+
showConfirm(selectedSkill);
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
function getSelectedSkill(): SkillInfo | null {
|
|
306
|
+
const idx = skillList.select.getSelectedIndex();
|
|
307
|
+
if (idx >= 0 && idx < filteredSkills.length) {
|
|
308
|
+
return filteredSkills[idx];
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Mount & Start ─────────────────────────────────────────────────────
|
|
314
|
+
renderer.root.add(dashboard.root);
|
|
315
|
+
|
|
316
|
+
// Initial load
|
|
317
|
+
await refreshSkills();
|
|
318
|
+
skillList.select.focus();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
main().catch((err) => {
|
|
322
|
+
console.error("Fatal error:", err);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
});
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readdir, stat, lstat, readlink, readFile } from "fs/promises";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { parseFrontmatter } from "./utils/frontmatter";
|
|
4
|
+
import { resolveProviderPath } from "./config";
|
|
5
|
+
import type { SkillInfo, Scope, SortBy, AppConfig } from "./utils/types";
|
|
6
|
+
|
|
7
|
+
interface ScanLocation {
|
|
8
|
+
dir: string;
|
|
9
|
+
location: string;
|
|
10
|
+
scope: "global" | "project";
|
|
11
|
+
providerName: string;
|
|
12
|
+
providerLabel: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildScanLocations(config: AppConfig, scope: Scope): ScanLocation[] {
|
|
16
|
+
const locations: ScanLocation[] = [];
|
|
17
|
+
|
|
18
|
+
for (const provider of config.providers) {
|
|
19
|
+
if (!provider.enabled) continue;
|
|
20
|
+
|
|
21
|
+
if (scope === "global" || scope === "both") {
|
|
22
|
+
locations.push({
|
|
23
|
+
dir: resolveProviderPath(provider.global),
|
|
24
|
+
location: `global-${provider.name}`,
|
|
25
|
+
scope: "global",
|
|
26
|
+
providerName: provider.name,
|
|
27
|
+
providerLabel: provider.label,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (scope === "project" || scope === "both") {
|
|
32
|
+
locations.push({
|
|
33
|
+
dir: resolveProviderPath(provider.project),
|
|
34
|
+
location: `project-${provider.name}`,
|
|
35
|
+
scope: "project",
|
|
36
|
+
providerName: provider.name,
|
|
37
|
+
providerLabel: provider.label,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const custom of config.customPaths) {
|
|
43
|
+
if (scope === custom.scope || scope === "both") {
|
|
44
|
+
locations.push({
|
|
45
|
+
dir: resolveProviderPath(custom.path),
|
|
46
|
+
location: `${custom.scope}-custom`,
|
|
47
|
+
scope: custom.scope,
|
|
48
|
+
providerName: "custom",
|
|
49
|
+
providerLabel: custom.label,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return locations;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function countFiles(dir: string): Promise<number> {
|
|
58
|
+
try {
|
|
59
|
+
const entries = await readdir(dir, { recursive: true } as any);
|
|
60
|
+
return entries.length;
|
|
61
|
+
} catch {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function scanDirectory(loc: ScanLocation): Promise<SkillInfo[]> {
|
|
67
|
+
const skills: SkillInfo[] = [];
|
|
68
|
+
|
|
69
|
+
let entries: string[];
|
|
70
|
+
try {
|
|
71
|
+
entries = await readdir(loc.dir);
|
|
72
|
+
} catch {
|
|
73
|
+
return skills;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const entryPath = join(loc.dir, entry);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const entryStat = await stat(entryPath);
|
|
81
|
+
if (!entryStat.isDirectory()) continue;
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const skillMdPath = join(entryPath, "SKILL.md");
|
|
87
|
+
let content: string;
|
|
88
|
+
try {
|
|
89
|
+
content = await readFile(skillMdPath, "utf-8");
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const fm = parseFrontmatter(content);
|
|
95
|
+
|
|
96
|
+
let isSymlink = false;
|
|
97
|
+
let symlinkTarget: string | null = null;
|
|
98
|
+
try {
|
|
99
|
+
const lstats = await lstat(entryPath);
|
|
100
|
+
if (lstats.isSymbolicLink()) {
|
|
101
|
+
isSymlink = true;
|
|
102
|
+
symlinkTarget = await readlink(entryPath);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// not a symlink
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const fileCount = await countFiles(entryPath);
|
|
109
|
+
|
|
110
|
+
skills.push({
|
|
111
|
+
name: fm.name || entry,
|
|
112
|
+
version: fm.version || "0.0.0",
|
|
113
|
+
description: (fm.description || "").replace(/\s*\n\s*/g, " ").trim(),
|
|
114
|
+
dirName: entry,
|
|
115
|
+
path: resolve(entryPath),
|
|
116
|
+
originalPath: entryPath,
|
|
117
|
+
location: loc.location,
|
|
118
|
+
scope: loc.scope,
|
|
119
|
+
provider: loc.providerName,
|
|
120
|
+
providerLabel: loc.providerLabel,
|
|
121
|
+
isSymlink,
|
|
122
|
+
symlinkTarget,
|
|
123
|
+
fileCount,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return skills;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function scanAllSkills(
|
|
131
|
+
config: AppConfig,
|
|
132
|
+
scope: Scope,
|
|
133
|
+
): Promise<SkillInfo[]> {
|
|
134
|
+
const locations = buildScanLocations(config, scope);
|
|
135
|
+
const results = await Promise.all(locations.map(scanDirectory));
|
|
136
|
+
return results.flat();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function searchSkills(skills: SkillInfo[], query: string): SkillInfo[] {
|
|
140
|
+
if (!query.trim()) return skills;
|
|
141
|
+
const q = query.toLowerCase();
|
|
142
|
+
return skills.filter(
|
|
143
|
+
(s) =>
|
|
144
|
+
s.name.toLowerCase().includes(q) ||
|
|
145
|
+
s.description.toLowerCase().includes(q) ||
|
|
146
|
+
s.location.toLowerCase().includes(q) ||
|
|
147
|
+
s.providerLabel.toLowerCase().includes(q),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function sortSkills(skills: SkillInfo[], by: SortBy): SkillInfo[] {
|
|
152
|
+
const sorted = [...skills];
|
|
153
|
+
switch (by) {
|
|
154
|
+
case "name":
|
|
155
|
+
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
|
156
|
+
break;
|
|
157
|
+
case "version":
|
|
158
|
+
sorted.sort((a, b) => a.version.localeCompare(b.version));
|
|
159
|
+
break;
|
|
160
|
+
case "location":
|
|
161
|
+
sorted.sort((a, b) => a.location.localeCompare(b.location));
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
return sorted;
|
|
165
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { rm, readFile, writeFile, access, lstat } from "fs/promises";
|
|
2
|
+
import { join, resolve, dirname } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { resolveProviderPath } from "./config";
|
|
5
|
+
import type { SkillInfo, RemovalPlan, AppConfig } from "./utils/types";
|
|
6
|
+
|
|
7
|
+
const HOME = homedir();
|
|
8
|
+
|
|
9
|
+
export function buildRemovalPlan(
|
|
10
|
+
skill: SkillInfo,
|
|
11
|
+
config: AppConfig,
|
|
12
|
+
): RemovalPlan {
|
|
13
|
+
const plan: RemovalPlan = {
|
|
14
|
+
directories: [],
|
|
15
|
+
ruleFiles: [],
|
|
16
|
+
agentsBlocks: [],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// The skill directory itself
|
|
20
|
+
plan.directories.push({
|
|
21
|
+
path: skill.originalPath,
|
|
22
|
+
isSymlink: skill.isSymlink,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const name = skill.dirName;
|
|
26
|
+
|
|
27
|
+
// Check for tool-specific rule files (project scope only)
|
|
28
|
+
if (skill.scope === "project") {
|
|
29
|
+
plan.ruleFiles.push(
|
|
30
|
+
resolve(".cursor", "rules", `${name}.mdc`),
|
|
31
|
+
resolve(".windsurf", "rules", `${name}.md`),
|
|
32
|
+
resolve(".github", "instructions", `${name}.instructions.md`),
|
|
33
|
+
);
|
|
34
|
+
plan.agentsBlocks.push({ file: resolve("AGENTS.md"), skillName: name });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (skill.scope === "global") {
|
|
38
|
+
// Check AGENTS.md for all enabled providers with global paths
|
|
39
|
+
for (const provider of config.providers) {
|
|
40
|
+
if (!provider.enabled) continue;
|
|
41
|
+
const globalDir = resolveProviderPath(provider.global);
|
|
42
|
+
const agentsMdPath = join(dirname(globalDir), "AGENTS.md");
|
|
43
|
+
plan.agentsBlocks.push({ file: agentsMdPath, skillName: name });
|
|
44
|
+
}
|
|
45
|
+
// Also check ~/.codex/AGENTS.md explicitly (common location)
|
|
46
|
+
const codexAgentsMd = join(HOME, ".codex", "AGENTS.md");
|
|
47
|
+
const alreadyIncluded = plan.agentsBlocks.some(
|
|
48
|
+
(b) => b.file === codexAgentsMd,
|
|
49
|
+
);
|
|
50
|
+
if (!alreadyIncluded) {
|
|
51
|
+
plan.agentsBlocks.push({ file: codexAgentsMd, skillName: name });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return plan;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildFullRemovalPlan(
|
|
59
|
+
dirName: string,
|
|
60
|
+
allSkills: SkillInfo[],
|
|
61
|
+
config: AppConfig,
|
|
62
|
+
): RemovalPlan {
|
|
63
|
+
const matching = allSkills.filter((s) => s.dirName === dirName);
|
|
64
|
+
if (matching.length === 0) {
|
|
65
|
+
return { directories: [], ruleFiles: [], agentsBlocks: [] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const combined: RemovalPlan = {
|
|
69
|
+
directories: [],
|
|
70
|
+
ruleFiles: [],
|
|
71
|
+
agentsBlocks: [],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const seenDirs = new Set<string>();
|
|
75
|
+
const seenRules = new Set<string>();
|
|
76
|
+
const seenBlocks = new Set<string>();
|
|
77
|
+
|
|
78
|
+
for (const skill of matching) {
|
|
79
|
+
const plan = buildRemovalPlan(skill, config);
|
|
80
|
+
|
|
81
|
+
for (const dir of plan.directories) {
|
|
82
|
+
if (!seenDirs.has(dir.path)) {
|
|
83
|
+
seenDirs.add(dir.path);
|
|
84
|
+
combined.directories.push(dir);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const rule of plan.ruleFiles) {
|
|
89
|
+
if (!seenRules.has(rule)) {
|
|
90
|
+
seenRules.add(rule);
|
|
91
|
+
combined.ruleFiles.push(rule);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const block of plan.agentsBlocks) {
|
|
96
|
+
const key = `${block.file}::${block.skillName}`;
|
|
97
|
+
if (!seenBlocks.has(key)) {
|
|
98
|
+
seenBlocks.add(key);
|
|
99
|
+
combined.agentsBlocks.push(block);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return combined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
108
|
+
try {
|
|
109
|
+
await access(path);
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function removeAgentsMdBlock(
|
|
117
|
+
filePath: string,
|
|
118
|
+
skillName: string,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
if (!(await fileExists(filePath))) return;
|
|
121
|
+
|
|
122
|
+
let content = await readFile(filePath, "utf-8");
|
|
123
|
+
|
|
124
|
+
// Try both new and old marker formats for backward compatibility
|
|
125
|
+
for (const prefix of ["skill-manager", "pskills"]) {
|
|
126
|
+
const startMarker = `<!-- ${prefix}: ${skillName} -->`;
|
|
127
|
+
const endMarker = `<!-- /${prefix}: ${skillName} -->`;
|
|
128
|
+
|
|
129
|
+
const startIdx = content.indexOf(startMarker);
|
|
130
|
+
const endIdx = content.indexOf(endMarker);
|
|
131
|
+
|
|
132
|
+
if (startIdx === -1 || endIdx === -1) continue;
|
|
133
|
+
|
|
134
|
+
let removeStart = startIdx;
|
|
135
|
+
if (removeStart > 0 && content[removeStart - 1] === "\n") {
|
|
136
|
+
removeStart--;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const removeEnd = endIdx + endMarker.length;
|
|
140
|
+
let actualEnd = removeEnd;
|
|
141
|
+
if (actualEnd < content.length && content[actualEnd] === "\n") {
|
|
142
|
+
actualEnd++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
content = content.slice(0, removeStart) + content.slice(actualEnd);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await writeFile(filePath, content, "utf-8");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function executeRemoval(plan: RemovalPlan): Promise<string[]> {
|
|
152
|
+
const log: string[] = [];
|
|
153
|
+
|
|
154
|
+
// Remove directories/symlinks
|
|
155
|
+
for (const dir of plan.directories) {
|
|
156
|
+
try {
|
|
157
|
+
if (dir.isSymlink) {
|
|
158
|
+
await rm(dir.path);
|
|
159
|
+
log.push(`Removed symlink: ${dir.path}`);
|
|
160
|
+
} else {
|
|
161
|
+
await rm(dir.path, { recursive: true, force: true });
|
|
162
|
+
log.push(`Removed directory: ${dir.path}`);
|
|
163
|
+
}
|
|
164
|
+
} catch (err: any) {
|
|
165
|
+
log.push(`Failed to remove ${dir.path}: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Remove rule files
|
|
170
|
+
for (const ruleFile of plan.ruleFiles) {
|
|
171
|
+
if (await fileExists(ruleFile)) {
|
|
172
|
+
try {
|
|
173
|
+
await rm(ruleFile);
|
|
174
|
+
log.push(`Removed rule file: ${ruleFile}`);
|
|
175
|
+
} catch (err: any) {
|
|
176
|
+
log.push(`Failed to remove ${ruleFile}: ${err.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Remove AGENTS.md blocks
|
|
182
|
+
for (const block of plan.agentsBlocks) {
|
|
183
|
+
try {
|
|
184
|
+
await removeAgentsMdBlock(block.file, block.skillName);
|
|
185
|
+
log.push(`Cleaned AGENTS.md block in: ${block.file}`);
|
|
186
|
+
} catch (err: any) {
|
|
187
|
+
log.push(`Failed to clean AGENTS.md block: ${err.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return log;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function getExistingTargets(plan: RemovalPlan): Promise<string[]> {
|
|
195
|
+
const existing: string[] = [];
|
|
196
|
+
|
|
197
|
+
for (const dir of plan.directories) {
|
|
198
|
+
if (await fileExists(dir.path)) {
|
|
199
|
+
const lstats = await lstat(dir.path);
|
|
200
|
+
const type = lstats.isSymbolicLink() ? "symlink" : "directory";
|
|
201
|
+
existing.push(`${dir.path} (${type})`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const ruleFile of plan.ruleFiles) {
|
|
206
|
+
if (await fileExists(ruleFile)) {
|
|
207
|
+
existing.push(ruleFile);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const block of plan.agentsBlocks) {
|
|
212
|
+
if (await fileExists(block.file)) {
|
|
213
|
+
const content = await readFile(block.file, "utf-8");
|
|
214
|
+
// Check both new and old marker formats
|
|
215
|
+
if (
|
|
216
|
+
content.includes(`<!-- skill-manager: ${block.skillName} -->`) ||
|
|
217
|
+
content.includes(`<!-- pskills: ${block.skillName} -->`)
|
|
218
|
+
) {
|
|
219
|
+
existing.push(`${block.file} (AGENTS.md block)`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return existing;
|
|
225
|
+
}
|