From 3b400b7be29538ef3928f0d078ad144eab33d30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roibert=20David=20Pe=C3=B1aloza=20Valencia?= <95867459+thanatosartcoder@users.noreply.github.com> Date: Sun, 10 May 2026 19:59:26 -0500 Subject: [PATCH 1/3] feat: add remove command to uninstall skills by name --- packages/autoskills/installer.ts | 86 ++++++++++++++ packages/autoskills/lib.ts | 2 +- packages/autoskills/main.ts | 154 ++++++++++++++++++++++---- packages/autoskills/tests/cli.test.ts | 90 ++++++++++++++- 4 files changed, 307 insertions(+), 25 deletions(-) diff --git a/packages/autoskills/installer.ts b/packages/autoskills/installer.ts index dd5e4b4..26c6337 100644 --- a/packages/autoskills/installer.ts +++ b/packages/autoskills/installer.ts @@ -8,6 +8,7 @@ import { statSync, symlinkSync, writeFileSync, + lstatSync, } from "node:fs"; import { createHash } from "node:crypto"; import { dirname, join, relative } from "node:path"; @@ -778,6 +779,91 @@ async function installAllSimple( return { installed, failed, errors, securityChecks }; } +// ── Remove ──────────────────────────────────────────────────── + +export interface RemoveResult { + success: boolean; + message: string; + removed: { + canonical: boolean; + symlinks: string[]; + lockEntry: boolean; + }; +} + +function readSkillsLock(projectDir: string): { version: number; skills: Record } { + const lockPath = join(projectDir, "skills-lock.json"); + try { + return JSON.parse(readFileSync(lockPath, "utf-8")); + } catch { + return { version: 1, skills: {} }; + } +} + +function writeSkillsLock(projectDir: string, lock: Record): void { + const lockPath = join(projectDir, "skills-lock.json"); + const sortedSkills: Record = {}; + const skills = lock.skills as Record; + if (skills) { + for (const k of Object.keys(skills).sort()) { + sortedSkills[k] = skills[k]; + } + } + writeFileSync(lockPath, JSON.stringify({ ...lock, skills: sortedSkills }, null, 2) + "\n"); +} + +export function removeSkill( + skillName: string, + projectDir: string, + _opts: { dryRun?: boolean } = {}, +): RemoveResult { + const canonicalDir = join(projectDir, ".agents", "skills", skillName); + const removed: RemoveResult["removed"] = { + canonical: false, + symlinks: [], + lockEntry: false, + }; + + if (existsSync(canonicalDir)) { + if (_opts.dryRun) { + // dry run: just report what would be removed + } else { + rmSync(canonicalDir, { recursive: true, force: true }); + removed.canonical = true; + } + } + + for (const folder of Object.keys(AGENT_FOLDER_MAP)) { + const linkPath = join(projectDir, folder, "skills", skillName); + try { + const st = lstatSync(linkPath); + if (st.isSymbolicLink() || st.isDirectory()) { + removed.symlinks.push(linkPath); + if (!_opts.dryRun) { + rmSync(linkPath, { recursive: true, force: true }); + } + } + } catch { + // path doesn't exist (including broken symlinks), nothing to remove + } + } + + const lock = readSkillsLock(projectDir); + if (lock.skills && skillName in lock.skills) { + removed.lockEntry = true; + if (!_opts.dryRun) { + delete lock.skills[skillName]; + writeSkillsLock(projectDir, lock); + } + } + + return { + success: true, + message: `Removed ${skillName}`, + removed, + }; +} + // ── Deprecated shim ────────────────────────────────────────── /** @deprecated retained so that UI code keeps compiling; no longer used. */ diff --git a/packages/autoskills/lib.ts b/packages/autoskills/lib.ts index beda706..c117d33 100644 --- a/packages/autoskills/lib.ts +++ b/packages/autoskills/lib.ts @@ -631,7 +631,7 @@ export function parseSkillPath(skill: string): ParsedSkillPath { export function getInstalledSkillNames(projectDir: string): Set { try { const lock = JSON.parse(readFileSync(join(projectDir, "skills-lock.json"), "utf-8")); - if (lock?.skills && typeof lock.skills === "object") { + if (lock?.skills && typeof lock.skills === "object" && Object.keys(lock.skills).length > 0) { return new Set(Object.keys(lock.skills)); } } catch {} diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index 90306b9..3483a6d 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -1,33 +1,35 @@ -import { resolve, dirname, join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; -import { detectTechnologies, collectSkills, detectAgents, getInstalledSkillNames } from "./lib.ts"; -import type { SkillEntry, Technology, ComboSkill } from "./lib.ts"; +import { cleanupClaudeMd } from "./claude.ts"; import { - log, - write, bold, + cyan, dim, + gray, green, - yellow, - cyan, + log, magenta, - red, - pink, - gray, muted, + pink, + red, SHOW_CURSOR, + write, + yellow, } from "./colors.ts"; -import { printBanner, multiSelect, formatTime } from "./ui.ts"; +import type { InstallSecurityCheck } from "./installer.ts"; import { clearAutoskillsCache, installAll, loadRegistry, + removeSkill, securityCheckForSkillPath, } from "./installer.ts"; -import type { InstallSecurityCheck } from "./installer.ts"; -import { cleanupClaudeMd } from "./claude.ts"; +import type { ComboSkill, SkillEntry, Technology } from "./lib.ts"; +import { collectSkills, detectAgents, detectTechnologies, getInstalledSkillNames } from "./lib.ts"; +import { formatTime, multiSelect, printBanner } from "./ui.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const VERSION: string = (() => { @@ -37,7 +39,7 @@ const VERSION: string = (() => { try { const pkg = JSON.parse(readFileSync(p, "utf-8")); if (pkg.name === "autoskills") return pkg.version; - } catch {} + } catch { } } return "0.0.0"; })(); @@ -57,6 +59,7 @@ interface CliArgs { help: boolean; clearCache: boolean; agents: string[]; + remove: string | undefined; } function parseArgs(): CliArgs { @@ -69,6 +72,16 @@ function parseArgs(): CliArgs { agents.push(args[i]); } } + + const removeIdx = args.findIndex((a) => a === "remove" || a === "rm"); + let remove: string | undefined = undefined; + if (removeIdx !== -1) { + remove = args[removeIdx + 1] || ""; + if (remove && remove.startsWith("-")) { + remove = ""; + } + } + return { autoYes: args.includes("-y") || args.includes("--yes"), dryRun: args.includes("--dry-run"), @@ -76,6 +89,7 @@ function parseArgs(): CliArgs { help: args.includes("--help") || args.includes("-h"), clearCache: args.includes("--clear-cache"), agents, + remove, }; } @@ -89,6 +103,8 @@ function showHelp(): void { npx autoskills ${dim("--dry-run")} Show what would be installed npx autoskills ${dim("--clear-cache")} Clear downloaded skills cache npx autoskills ${dim("-a cursor claude-code")} Install for specific IDEs only + npx autoskills ${dim("remove ")} Remove an installed skill + npx autoskills ${dim("rm ")} Alias for remove ${bold("Options:")} -y, --yes Skip confirmation prompt @@ -477,13 +493,13 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise 0 ? [ - { key: "n", label: "new", fn: (items: SkillEntry[]) => items.map((s) => !s.installed) }, - { - key: "i", - label: "installed", - fn: (items: SkillEntry[]) => items.map((s) => s.installed), - }, - ] + { key: "n", label: "new", fn: (items: SkillEntry[]) => items.map((s) => !s.installed) }, + { + key: "i", + label: "installed", + fn: (items: SkillEntry[]) => items.map((s) => s.installed), + }, + ] : [], }); @@ -500,7 +516,7 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise { - const { autoYes, dryRun, verbose, help, clearCache, agents } = parseArgs(); + const { autoYes, dryRun, verbose, help, clearCache, agents, remove } = parseArgs(); if (help) { showHelp(); @@ -518,6 +534,100 @@ async function main(): Promise { process.exit(0); } + if (remove !== undefined) { + const projectDir = resolve("."); + const installedNames = getInstalledSkillNames(projectDir); + + if (installedNames.size === 0) { + log(dim(" No skills installed.")); + log(); + process.exit(0); + } + + if (remove === "") { + const installedList = [...installedNames].sort(); + log(cyan(" ◆ ") + bold(`Select skills to remove `) + dim(`(${installedList.length} installed)`)); + log(); + + const selected = await multiSelect(installedList, { + labelFn: (name) => name, + initialSelected: [], + shortcuts: [], + }); + + if (selected.length === 0) { + log(); + log(dim(" Nothing selected.")); + log(); + process.exit(0); + } + + if (!autoYes) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question( + ` Remove ${selected.length} skill${selected.length !== 1 ? "s" : ""}? ${dim("[y/N]")} `, + (ans: string) => { + rl.close(); + resolve(ans.trim()); + }, + ); + }); + if (answer.toLowerCase() !== "y") { + log(dim(" Cancelled.")); + log(); + process.exit(0); + } + } + + for (const skillName of selected) { + const result = removeSkill(skillName, projectDir, { dryRun }); + if (result.success) { + log(green(` ✔ Removed ${skillName}`)); + } + } + log(); + process.exit(0); + } + + if (!installedNames.has(remove)) { + log(dim(` '${remove}' is not installed.`)); + log(); + process.exit(0); + } + + if (dryRun) { + log(dim(` Would remove: ${remove}`)); + log(); + process.exit(0); + } + + if (!autoYes) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question( + ` Remove '${remove}'? ${dim("[y/N]")} `, + (ans: string) => { + rl.close(); + resolve(ans.trim()); + }, + ); + }); + if (answer.toLowerCase() !== "y") { + log(dim(" Cancelled.")); + log(); + process.exit(0); + } + } + + const result = removeSkill(remove, projectDir); + if (result.success) { + log(green(` ✔ Removed ${remove}`)); + log(); + } + process.exit(0); + } + await printBanner(VERSION); const projectDir = resolve("."); diff --git a/packages/autoskills/tests/cli.test.ts b/packages/autoskills/tests/cli.test.ts index 3c787cd..7b2bc32 100644 --- a/packages/autoskills/tests/cli.test.ts +++ b/packages/autoskills/tests/cli.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; -import { ok } from "node:assert/strict"; +import { ok, strictEqual } from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync, mkdirSync, writeFileSync, symlinkSync } from "node:fs"; import { join, resolve } from "node:path"; import { useTmpDir, writePackageJson, writeFile, writeJson, addWorkspace } from "./helpers.ts"; @@ -476,4 +476,90 @@ describe("CLI", () => { ok(!output.includes("universal")); }); }); + + describe("remove", () => { + const tmp = useTmpDir(); + + it("shows informative message when no skills are installed", () => { + writePackageJson(tmp.path); + const output = run(["remove"], tmp.path); + ok(output.includes("No skills installed")); + }); + + it("shows dry-run message for remove when skill is installed", () => { + writePackageJson(tmp.path); + writeJson(tmp.path, "skills-lock.json", { + version: 1, + skills: { "react-best-practices": { source: "test", sourceType: "test", computedHash: "abc" } }, + }); + writeFile(tmp.path, ".agents/skills/react-best-practices/SKILL.md", "# React best practices"); + const output = run(["remove", "react-best-practices", "--dry-run"], tmp.path); + ok(output.includes("Would remove")); + ok(output.includes("react-best-practices")); + }); + + it("removes skill from .agents/skills directory", () => { + writePackageJson(tmp.path); + writeJson(tmp.path, "skills-lock.json", { + version: 1, + skills: { "react-best-practices": { source: "test", sourceType: "test", computedHash: "abc" } }, + }); + writeFile(tmp.path, ".agents/skills/react-best-practices/SKILL.md", "# React best practices"); + run(["remove", "react-best-practices", "-y"], tmp.path); + ok(!existsSync(join(tmp.path, ".agents/skills/react-best-practices"))); + }); + + it("removes skill entry from skills-lock.json", () => { + writePackageJson(tmp.path); + writeJson(tmp.path, "skills-lock.json", { + version: 1, + skills: { + "react-best-practices": { source: "test", sourceType: "test", computedHash: "abc" }, + "vue-best-practices": { source: "test", sourceType: "test", computedHash: "def" }, + }, + }); + run(["remove", "react-best-practices", "-y"], tmp.path); + const lock = JSON.parse(readFileSync(join(tmp.path, "skills-lock.json"), "utf-8")); + ok(!("react-best-practices" in lock.skills)); + ok("vue-best-practices" in lock.skills); + }); + + it("removes symlinks from all agent folders", () => { + writePackageJson(tmp.path); + writeJson(tmp.path, "skills-lock.json", { + version: 1, + skills: { "react-best-practices": { source: "test", sourceType: "test", computedHash: "abc" } }, + }); + writeFile(tmp.path, ".agents/skills/react-best-practices/SKILL.md", "# React best practices"); + const canonicalPath = join(tmp.path, ".agents/skills/react-best-practices"); + for (const folder of [".claude", ".cursor", ".junie"]) { + const skillsDir = join(tmp.path, folder, "skills"); + mkdirSync(skillsDir, { recursive: true }); + const linkPath = join(skillsDir, "react-best-practices"); + try { + symlinkSync(canonicalPath, linkPath, "dir"); + } catch {} + } + run(["remove", "react-best-practices", "-y"], tmp.path); + ok(!existsSync(join(tmp.path, ".claude/skills/react-best-practices"))); + ok(!existsSync(join(tmp.path, ".cursor/skills/react-best-practices"))); + ok(!existsSync(join(tmp.path, ".junie/skills/react-best-practices"))); + }); + + it("shows informative message when removing non-installed skill", () => { + writePackageJson(tmp.path); + writeJson(tmp.path, "skills-lock.json", { + version: 1, + skills: { "vue-best-practices": { source: "test", sourceType: "test", computedHash: "def" } }, + }); + const output = run(["remove", "non-existent-skill"], tmp.path); + ok(output.includes("is not installed")); + }); + + it("shows remove command in help", () => { + const output = run(["--help"]); + ok(output.includes("remove")); + ok(output.includes("rm")); + }); + }); }); From 776aebbc70c0633a0ada10e6bce758451b68aeb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roibert=20David=20Pe=C3=B1aloza=20Valencia?= <95867459+thanatosartcoder@users.noreply.github.com> Date: Sun, 10 May 2026 20:07:15 -0500 Subject: [PATCH 2/3] fix: multiSelect initialSelected array length --- packages/autoskills/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index 3483a6d..792e797 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -551,7 +551,7 @@ async function main(): Promise { const selected = await multiSelect(installedList, { labelFn: (name) => name, - initialSelected: [], + initialSelected: Array(installedList.length).fill(false), shortcuts: [], }); From 71dcb6a6f3a95848c8ed9f41c6287f53f0ca9607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roibert=20David=20Pe=C3=B1aloza=20Valencia?= <95867459+thanatosartcoder@users.noreply.github.com> Date: Sun, 10 May 2026 20:11:26 -0500 Subject: [PATCH 3/3] fix: handle non-interactive stdin in remove interactive mode --- packages/autoskills/main.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index 792e797..ef8d173 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -549,11 +549,16 @@ async function main(): Promise { log(cyan(" ◆ ") + bold(`Select skills to remove `) + dim(`(${installedList.length} installed)`)); log(); - const selected = await multiSelect(installedList, { - labelFn: (name) => name, - initialSelected: Array(installedList.length).fill(false), - shortcuts: [], - }); + let selected: string[]; + if (process.stdin.isTTY) { + selected = await multiSelect(installedList, { + labelFn: (name) => name, + initialSelected: Array(installedList.length).fill(false), + shortcuts: [], + }); + } else { + selected = []; + } if (selected.length === 0) { log();