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/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
+ }