From c636556b7303252a73766a9dd2fc6eb5cb8c5ca4 Mon Sep 17 00:00:00 2001 From: Santiago Date: Wed, 13 May 2026 17:02:46 +0000 Subject: [PATCH 1/3] feat(cli): add interactive --remove flag to uninstall skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `-r` / `--remove` mode that lists currently installed skills, highlights them in red, and lets users select which to remove with the existing multi-select UI ([space] to toggle, [enter] to remove). Removal is symmetrical with install: cleans up the canonical .agents/skills/ directory, every per-agent symlink/copy (.claude/skills/, .cline/skills/, etc.), the matching skills-lock.json entry, and prunes empty parent directories. When the last skill is removed, the autoskills section of CLAUDE.md is swept too. Implementation: - New `uninstaller.ts` module with `listInstalledSkills`, `uninstallSkill`, and `uninstallAll` (mirroring installer.ts's TTY / verbose / simple modes). - `multiSelect` in ui.ts accepts an optional `confirmLabel` so the bottom hint reads '[enter] remove' instead of 'confirm'. Default is unchanged. - `main.ts` wires the flag, adds the destructive-by-default pre-unchecked selection screen, and reuses `cleanupClaudeMd`. Safety: - Skill names are validated against path separators and traversal. - Pre-unchecks every row — removal is opt-in. - Broken symlinks left by prior failures are still cleaned up. Tests: 14 new tests in tests/uninstaller.test.ts covering listing, removal, lockfile preservation, missing-lockfile fallback, parent- dir pruning, trace messages, broken symlinks, and unsafe-name rejection. Full suite: 369/369 passing. --- README.md | 1 + packages/autoskills/README.md | 9 + packages/autoskills/main.ts | 139 +++++++- packages/autoskills/tests/uninstaller.test.ts | 203 +++++++++++ packages/autoskills/ui.ts | 13 +- packages/autoskills/uninstaller.ts | 320 ++++++++++++++++++ 6 files changed, 682 insertions(+), 3 deletions(-) create mode 100644 packages/autoskills/tests/uninstaller.test.ts create mode 100644 packages/autoskills/uninstaller.ts diff --git a/README.md b/README.md index a2b1085e..fdb586ae 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ This keeps the package small while avoiding live downloads from third-party skil ``` -y, --yes Skip confirmation prompt --dry-run Show what would be installed without installing +-r, --remove Remove installed skills (interactive) -h, --help Show help message ``` diff --git a/packages/autoskills/README.md b/packages/autoskills/README.md index 0cff108d..201448f7 100644 --- a/packages/autoskills/README.md +++ b/packages/autoskills/README.md @@ -36,6 +36,14 @@ npx autoskills -y npx autoskills --dry-run ``` +### Remove installed skills + +```bash +npx autoskills -r +``` + +Lists every skill currently installed in your project, lets you pick which ones to remove, and cleans up the canonical `.agents/skills/` directory, the per-agent symlinks (`.claude/skills/`, `.cline/skills/`, etc.), and the matching `skills-lock.json` entry. Pass `-y` to remove all installed skills without confirmation. + ### Claude Code summary If `claude-code` is auto-detected or passed with `-a`, `autoskills` writes a `CLAUDE.md` file in your project root summarizing the markdown files installed under `.claude/skills`. @@ -46,6 +54,7 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` writes a `CL | ----------------- | ----------------------------------------------------- | | `-y`, `--yes` | Skip confirmation prompt, install all detected skills | | `--dry-run` | Show detected skills without installing anything | +| `-r`, `--remove` | Remove installed skills (interactive) | | `-v`, `--verbose` | Show install trace and error details | | `-h`, `--help` | Show help message | diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index 90306b9e..9cd15b83 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -28,6 +28,7 @@ import { } from "./installer.ts"; import type { InstallSecurityCheck } from "./installer.ts"; import { cleanupClaudeMd } from "./claude.ts"; +import { listInstalledSkills, uninstallAll } from "./uninstaller.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const VERSION: string = (() => { @@ -56,6 +57,7 @@ interface CliArgs { verbose: boolean; help: boolean; clearCache: boolean; + remove: boolean; agents: string[]; } @@ -75,6 +77,7 @@ function parseArgs(): CliArgs { verbose: args.includes("--verbose") || args.includes("-v"), help: args.includes("--help") || args.includes("-h"), clearCache: args.includes("--clear-cache"), + remove: args.includes("--remove") || args.includes("-r"), agents, }; } @@ -88,12 +91,14 @@ function showHelp(): void { npx autoskills ${dim("-y")} Skip confirmation npx autoskills ${dim("--dry-run")} Show what would be installed npx autoskills ${dim("--clear-cache")} Clear downloaded skills cache + npx autoskills ${dim("-r")} Remove installed skills (interactive) npx autoskills ${dim("-a cursor claude-code")} Install for specific IDEs only ${bold("Options:")} -y, --yes Skip confirmation prompt --dry-run Show skills without installing --clear-cache Clear downloaded skills cache + -r, --remove Remove installed skills (interactive) -v, --verbose Show install trace and error details -a, --agent Install for specific IDEs only (e.g. cursor, claude-code) -h, --help Show this help message @@ -497,10 +502,137 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise { + if (autoYes) { + log( + red(" ◆ ") + + bold( + `Removing all ${installed.length} installed skill${installed.length === 1 ? "" : "s"}`, + ), + ); + log(); + return installed; + } + + const maxLabel = Math.max(...installed.map((s) => s.length)); + log(red(" ◆ ") + bold(`Select skills to remove `) + dim(`(${installed.length} installed)`)); + log(); + + const selected = await multiSelect(installed, { + labelFn: (name) => red(name) + " ".repeat(Math.max(0, maxLabel - name.length)), + // Start with everything unchecked — removal is destructive, opt-in only. + initialSelected: installed.map(() => false), + confirmLabel: "remove", + }); + + return selected; +} + +function printRemoveSummary({ + uninstalled, + failed, + errors, + elapsed, +}: { + uninstalled: number; + failed: number; + errors: { name: string; output: string; stderr: string }[]; + elapsed: number; +}): void { + log(); + + if (failed === 0) { + log( + green( + bold( + ` ✔ Done! ${uninstalled} skill${uninstalled !== 1 ? "s" : ""} removed in ${formatTime(elapsed)}.`, + ), + ), + ); + } else { + log( + yellow( + ` Done: ${green(`${uninstalled} removed`)}, ${red(`${failed} failed`)} in ${formatTime(elapsed)}.`, + ), + ); + + if (errors.length > 0) { + log(); + log(bold(red(" Errors:"))); + for (const { name, output } of errors) { + log(red(` ✘ ${name}`)); + log(dim(` ${output}`)); + } + log(); + log(dim(` If it looks like an autoskills bug, please create an issue: ${ISSUES_URL}`)); + } + } + log(); +} + +async function runRemoveFlow( + projectDir: string, + { autoYes, verbose }: { autoYes: boolean; verbose: boolean }, +): Promise { + const installed = listInstalledSkills(projectDir); + + if (installed.length === 0) { + log(yellow(" ⚠ No installed skills found in this project.")); + log(dim(" Nothing to remove.")); + log(); + process.exit(0); + } + + const toRemove = await selectSkillsToRemove(installed, autoYes); + + if (toRemove.length === 0) { + log(); + log(dim(" Nothing selected.")); + log(); + process.exit(0); + } + + log(); + log(red(" ◆ ") + bold("Removing skills...")); + log(); + + const startTime = Date.now(); + const { uninstalled, failed, errors } = await uninstallAll(toRemove, { + projectDir, + verbose, + }); + const elapsed = Date.now() - startTime; + + // If every autoskills-installed skill is gone, clean up the CLAUDE.md + // section too so it doesn't reference skills that no longer exist. + const remaining = listInstalledSkills(projectDir); + if (remaining.length === 0) { + const claudeCleanup = cleanupClaudeMd(projectDir); + if (claudeCleanup.cleaned) { + if (claudeCleanup.deleted) { + log(dim(" Removed autoskills section from CLAUDE.md (file was empty, deleted).")); + } else { + log(dim(" Removed autoskills section from CLAUDE.md.")); + } + } + } else if (existsSync(join(projectDir, "CLAUDE.md"))) { + log( + dim( + " Note: CLAUDE.md may still reference removed skills. " + + "Re-run `npx autoskills` to refresh it.", + ), + ); + } + + printRemoveSummary({ uninstalled, failed, errors, elapsed }); +} + // ── Main ───────────────────────────────────────────────────── async function main(): Promise { - const { autoYes, dryRun, verbose, help, clearCache, agents } = parseArgs(); + const { autoYes, dryRun, verbose, help, clearCache, remove, agents } = parseArgs(); if (help) { showHelp(); @@ -522,6 +654,11 @@ async function main(): Promise { const projectDir = resolve("."); + if (remove) { + await runRemoveFlow(projectDir, { autoYes, verbose }); + return; + } + write(dim(" Scanning project...\r")); const { detected, isFrontend, combos } = detectTechnologies(projectDir); write("\x1b[K"); diff --git a/packages/autoskills/tests/uninstaller.test.ts b/packages/autoskills/tests/uninstaller.test.ts new file mode 100644 index 00000000..fc5ec969 --- /dev/null +++ b/packages/autoskills/tests/uninstaller.test.ts @@ -0,0 +1,203 @@ +import { describe, it } from "node:test"; +import { ok, strictEqual, deepStrictEqual } from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; + +import { useTmpDir, writeFile, writeJson } from "./helpers.ts"; +import { listInstalledSkills, uninstallSkill } from "../uninstaller.ts"; + +function setupInstalledSkill( + projectDir: string, + name: string, + { + withLockEntry = true, + withAgentLink = true, + }: { withLockEntry?: boolean; withAgentLink?: boolean } = {}, +): void { + const canonical = join(projectDir, ".agents", "skills", name); + mkdirSync(canonical, { recursive: true }); + writeFile(canonical, "SKILL.md", `# ${name}\n`); + + if (withAgentLink) { + const claudeSkills = join(projectDir, ".claude", "skills"); + mkdirSync(claudeSkills, { recursive: true }); + try { + symlinkSync(canonical, join(claudeSkills, name), "dir"); + } catch { + // Symlink may fail on filesystems that don't support it; fall back to a real dir. + mkdirSync(join(claudeSkills, name), { recursive: true }); + writeFile(join(claudeSkills, name), "SKILL.md", `# ${name}\n`); + } + } + + if (withLockEntry) { + writeJson(projectDir, "skills-lock.json", { + version: 1, + skills: { + [name]: { + source: `example/repo/${name}`, + sourceType: "autoskills-registry", + computedHash: "deadbeef", + }, + }, + }); + } +} + +describe("listInstalledSkills", () => { + const tmp = useTmpDir(); + + it("returns an empty array when nothing is installed", () => { + deepStrictEqual(listInstalledSkills(tmp.path), []); + }); + + it("returns names from skills-lock.json sorted alphabetically", () => { + writeJson(tmp.path, "skills-lock.json", { + version: 1, + skills: { + zod: { source: "x", sourceType: "autoskills-registry", computedHash: "h" }, + astro: { source: "x", sourceType: "autoskills-registry", computedHash: "h" }, + react: { source: "x", sourceType: "autoskills-registry", computedHash: "h" }, + }, + }); + + deepStrictEqual(listInstalledSkills(tmp.path), ["astro", "react", "zod"]); + }); + + it("falls back to .agents/skills/ when skills-lock.json is missing", () => { + mkdirSync(join(tmp.path, ".agents", "skills", "vite"), { recursive: true }); + mkdirSync(join(tmp.path, ".agents", "skills", "shadcn"), { recursive: true }); + + deepStrictEqual(listInstalledSkills(tmp.path), ["shadcn", "vite"]); + }); + + it("merges and de-duplicates lockfile and filesystem entries", () => { + writeJson(tmp.path, "skills-lock.json", { + version: 1, + skills: { + astro: { source: "x", sourceType: "autoskills-registry", computedHash: "h" }, + }, + }); + mkdirSync(join(tmp.path, ".agents", "skills", "astro"), { recursive: true }); + mkdirSync(join(tmp.path, ".agents", "skills", "vite"), { recursive: true }); + + deepStrictEqual(listInstalledSkills(tmp.path), ["astro", "vite"]); + }); +}); + +describe("uninstallSkill", () => { + const tmp = useTmpDir(); + + it("removes the canonical dir, agent symlinks, and lockfile entry", () => { + setupInstalledSkill(tmp.path, "vite"); + + const result = uninstallSkill("vite", { projectDir: tmp.path }); + + strictEqual(result.success, true); + strictEqual(result.skillName, "vite"); + ok(result.removedPaths.length >= 2); + + ok(!existsSync(join(tmp.path, ".agents", "skills", "vite"))); + ok(!existsSync(join(tmp.path, ".claude", "skills", "vite"))); + ok(!existsSync(join(tmp.path, "skills-lock.json"))); // empty -> deleted + }); + + it("preserves other skills' lockfile entries when removing one", () => { + setupInstalledSkill(tmp.path, "astro", { withAgentLink: false }); + writeJson(tmp.path, "skills-lock.json", { + version: 1, + skills: { + astro: { source: "x", sourceType: "autoskills-registry", computedHash: "h1" }, + react: { source: "y", sourceType: "autoskills-registry", computedHash: "h2" }, + }, + }); + + uninstallSkill("astro", { projectDir: tmp.path }); + + const lock = JSON.parse(readFileSync(join(tmp.path, "skills-lock.json"), "utf-8")); + deepStrictEqual(Object.keys(lock.skills), ["react"]); + strictEqual(lock.skills.react.computedHash, "h2"); + }); + + it("reports failure when the skill is not installed", () => { + const result = uninstallSkill("not-installed", { projectDir: tmp.path }); + strictEqual(result.success, false); + ok(result.output.includes("not installed")); + }); + + it("rejects unsafe skill names with path separators", () => { + const result = uninstallSkill("../etc/passwd", { projectDir: tmp.path }); + strictEqual(result.success, false); + ok(result.output.includes("invalid skill name")); + }); + + it("rejects empty skill names", () => { + const result = uninstallSkill("", { projectDir: tmp.path }); + strictEqual(result.success, false); + }); + + it("works even when the lockfile is absent (fallback path)", () => { + // Only the .agents/skills/ dir exists, no lockfile. + const canonical = join(tmp.path, ".agents", "skills", "shadcn"); + mkdirSync(canonical, { recursive: true }); + writeFile(canonical, "SKILL.md", "# shadcn\n"); + + const result = uninstallSkill("shadcn", { projectDir: tmp.path }); + + strictEqual(result.success, true); + ok(!existsSync(canonical)); + }); + + it("prunes empty parent directories after the last skill is removed", () => { + setupInstalledSkill(tmp.path, "vite"); + uninstallSkill("vite", { projectDir: tmp.path }); + + ok(!existsSync(join(tmp.path, ".agents"))); + ok(!existsSync(join(tmp.path, ".claude"))); + }); + + it("keeps the .agents directory when other skills remain", () => { + setupInstalledSkill(tmp.path, "vite", { withLockEntry: false }); + setupInstalledSkill(tmp.path, "astro", { withLockEntry: false }); + + uninstallSkill("vite", { projectDir: tmp.path }); + + ok(!existsSync(join(tmp.path, ".agents", "skills", "vite"))); + ok(existsSync(join(tmp.path, ".agents", "skills", "astro"))); + }); + + it("collects trace messages when onTrace is provided", () => { + setupInstalledSkill(tmp.path, "vite"); + const traces: string[] = []; + const result = uninstallSkill("vite", { + projectDir: tmp.path, + onTrace: (msg) => traces.push(msg), + }); + + strictEqual(result.success, true); + ok(traces.length > 0); + ok(traces.some((t) => t.includes("removed"))); + }); + + it("removes a broken symlink left behind by a previous failure", () => { + const claudeSkills = join(tmp.path, ".claude", "skills"); + mkdirSync(claudeSkills, { recursive: true }); + try { + symlinkSync(join(tmp.path, "does-not-exist"), join(claudeSkills, "ghost"), "dir"); + } catch { + // skip the test silently on platforms that don't support symlinks. + return; + } + writeJson(tmp.path, "skills-lock.json", { + version: 1, + skills: { + ghost: { source: "x", sourceType: "autoskills-registry", computedHash: "h" }, + }, + }); + + const result = uninstallSkill("ghost", { projectDir: tmp.path }); + + strictEqual(result.success, true); + ok(!existsSync(join(claudeSkills, "ghost"))); + }); +}); diff --git a/packages/autoskills/ui.ts b/packages/autoskills/ui.ts index 9c423e74..80d8f7be 100644 --- a/packages/autoskills/ui.ts +++ b/packages/autoskills/ui.ts @@ -92,11 +92,20 @@ interface MultiSelectOptions { groupFn?: (item: T) => string; initialSelected?: boolean[]; shortcuts?: { key: string; label: string; fn: (items: T[]) => boolean[] }[]; + /** Verb shown in the bottom help line. Defaults to "confirm". */ + confirmLabel?: string; } export function multiSelect( items: T[], - { labelFn, hintFn, groupFn, initialSelected, shortcuts = [] }: MultiSelectOptions, + { + labelFn, + hintFn, + groupFn, + initialSelected, + shortcuts = [], + confirmLabel = "confirm", + }: MultiSelectOptions, ): Promise { if (initialSelected && initialSelected.length !== items.length) { throw new Error( @@ -181,7 +190,7 @@ export function multiSelect( dim(" all · ") + shortcutPart + white(bold("[enter]")) + - dim(` confirm (${count}/${items.length})`), + dim(` ${confirmLabel} (${count}/${items.length})`), ); } diff --git a/packages/autoskills/uninstaller.ts b/packages/autoskills/uninstaller.ts new file mode 100644 index 00000000..4584d147 --- /dev/null +++ b/packages/autoskills/uninstaller.ts @@ -0,0 +1,320 @@ +import { + existsSync, + lstatSync, + readFileSync, + readdirSync, + rmSync, + rmdirSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +import { AGENT_FOLDER_MAP } from "./skills-map.ts"; +import { log, write, dim, green, cyan, red, HIDE_CURSOR, SHOW_CURSOR, SPINNER } from "./colors.ts"; + +// ── Types ──────────────────────────────────────────────────── + +export interface UninstallResult { + success: boolean; + skillName: string; + removedPaths: string[]; + output: string; + stderr: string; +} + +interface UninstallOptions { + projectDir?: string; + verbose?: boolean; + onTrace?: (message: string) => void; +} + +export interface UninstallAllResult { + uninstalled: number; + failed: number; + errors: { name: string; output: string; stderr: string }[]; +} + +// ── Filesystem helpers ─────────────────────────────────────── + +function safeRemove(path: string, trace?: (msg: string) => void): boolean { + try { + if (!existsSync(path)) { + // existsSync follows symlinks, so it returns false for broken symlinks. + // Use lstat to detect them and still remove them. + try { + lstatSync(path); + } catch { + return false; + } + } + rmSync(path, { recursive: true, force: true }); + trace?.(`removed ${path}`); + return true; + } catch (err) { + trace?.(`could not remove ${path}: ${(err as Error).message}`); + return false; + } +} + +function removeFromSkillsLock(projectDir: string, skillName: string): boolean { + const lockPath = join(projectDir, "skills-lock.json"); + if (!existsSync(lockPath)) return false; + + let lock: { version?: number; skills?: Record }; + try { + lock = JSON.parse(readFileSync(lockPath, "utf-8")); + } catch { + return false; + } + + if (!lock?.skills || typeof lock.skills !== "object" || !(skillName in lock.skills)) { + return false; + } + + delete lock.skills[skillName]; + + const remaining = Object.keys(lock.skills); + if (remaining.length === 0) { + rmSync(lockPath, { force: true }); + return true; + } + + const sortedSkills: Record = {}; + for (const k of remaining.sort()) { + sortedSkills[k] = lock.skills[k]; + } + lock.skills = sortedSkills; + writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n"); + return true; +} + +function removeEmptyDir(path: string, trace?: (msg: string) => void): void { + try { + if (!existsSync(path)) return; + const entries = readdirSync(path); + if (entries.length === 0) { + rmdirSync(path); + trace?.(`removed empty dir ${path}`); + } + } catch { + // ignore — best-effort cleanup + } +} + +// ── Installed skills discovery ─────────────────────────────── + +/** + * Return the list of skills currently installed in the project, drawn from + * skills-lock.json (preferred) and falling back to the contents of + * .agents/skills/. Names are sorted alphabetically and de-duplicated. + */ +export function listInstalledSkills(projectDir: string): string[] { + const names = new Set(); + + try { + const lock = JSON.parse(readFileSync(join(projectDir, "skills-lock.json"), "utf-8")); + if (lock?.skills && typeof lock.skills === "object") { + for (const name of Object.keys(lock.skills)) names.add(name); + } + } catch {} + + try { + const entries = readdirSync(join(projectDir, ".agents", "skills"), { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) names.add(entry.name); + } + } catch {} + + return [...names].sort((a, b) => a.localeCompare(b)); +} + +// ── Single skill uninstall ─────────────────────────────────── + +export function uninstallSkill(skillName: string, opts: UninstallOptions = {}): UninstallResult { + const projectDir = opts.projectDir || process.cwd(); + const trace = opts.onTrace; + const removedPaths: string[] = []; + + if (!skillName || skillName.includes("/") || skillName.includes("\\") || skillName === "..") { + return { + success: false, + skillName, + removedPaths: [], + output: `invalid skill name: ${skillName}`, + stderr: `invalid skill name: ${skillName}`, + }; + } + + // 1) Remove the per-agent symlinks/copies under each known agent folder. + for (const folder of Object.keys(AGENT_FOLDER_MAP)) { + const linkPath = join(projectDir, folder, "skills", skillName); + if (safeRemove(linkPath, trace)) removedPaths.push(linkPath); + // If the agent's skills/ directory is now empty, prune it (best-effort). + removeEmptyDir(join(projectDir, folder, "skills"), trace); + removeEmptyDir(join(projectDir, folder), trace); + } + + // 2) Remove the canonical install dir under .agents/skills/. + const canonicalDir = join(projectDir, ".agents", "skills", skillName); + if (safeRemove(canonicalDir, trace)) removedPaths.push(canonicalDir); + + // 3) Prune .agents/skills and .agents if they ended up empty. + removeEmptyDir(join(projectDir, ".agents", "skills"), trace); + removeEmptyDir(join(projectDir, ".agents"), trace); + + // 4) Remove the entry from skills-lock.json. + const lockUpdated = removeFromSkillsLock(projectDir, skillName); + if (lockUpdated) { + removedPaths.push(`skills-lock.json:${skillName}`); + trace?.(`updated skills-lock.json (removed ${skillName})`); + } + + if (removedPaths.length === 0) { + return { + success: false, + skillName, + removedPaths, + output: `skill '${skillName}' is not installed`, + stderr: `skill '${skillName}' is not installed`, + }; + } + + return { + success: true, + skillName, + removedPaths, + output: `uninstalled ${skillName}`, + stderr: "", + }; +} + +// ── Batch uninstall ────────────────────────────────────────── + +export async function uninstallAll( + skillNames: string[], + opts: UninstallOptions = {}, +): Promise { + if (opts.verbose) return uninstallAllVerbose(skillNames, opts); + if (!process.stdout.isTTY) return uninstallAllSimple(skillNames, opts); + + const sorted = [...skillNames].sort((a, b) => a.localeCompare(b)); + const total = sorted.length; + const states = sorted.map((name) => ({ + name, + status: "pending" as "pending" | "removing" | "success" | "failed", + })); + + let frame = 0; + let rendered = false; + let activeCount = 0; + + function render(): void { + if (rendered) { + write(`\x1b[${total}A\r`); + } + rendered = true; + write("\x1b[J"); + + for (const state of states) { + switch (state.status) { + case "pending": + write(dim(` ◌ ${state.name}`) + "\n"); + break; + case "removing": + write(cyan(` ${SPINNER[frame]}`) + ` ${state.name}...\n`); + break; + case "success": + write(green(` ✔ ${state.name}`) + dim(" — removed") + "\n"); + break; + case "failed": + write(red(` ✘ ${state.name}`) + dim(" — failed") + "\n"); + break; + } + } + } + + write(HIDE_CURSOR); + + const timer = setInterval(() => { + frame = (frame + 1) % SPINNER.length; + if (activeCount > 0) render(); + }, 80); + + let uninstalled = 0; + let failed = 0; + const errors: UninstallAllResult["errors"] = []; + + for (let i = 0; i < sorted.length; i++) { + const state = states[i]; + state.status = "removing"; + activeCount++; + render(); + + const result = uninstallSkill(sorted[i], opts); + + activeCount--; + if (result.success) { + state.status = "success"; + uninstalled++; + } else { + state.status = "failed"; + errors.push({ name: result.skillName, output: result.output, stderr: result.stderr }); + failed++; + } + render(); + } + + clearInterval(timer); + render(); + write(SHOW_CURSOR); + + return { uninstalled, failed, errors }; +} + +function uninstallAllVerbose(skillNames: string[], opts: UninstallOptions): UninstallAllResult { + const sorted = [...skillNames].sort((a, b) => a.localeCompare(b)); + let uninstalled = 0; + let failed = 0; + const errors: UninstallAllResult["errors"] = []; + + for (const name of sorted) { + log(cyan(` ◆ ${name}`)); + const result = uninstallSkill(name, { + ...opts, + onTrace: (message) => log(dim(` ${message}`)), + }); + + if (result.success) { + log(green(` ✔ removed`)); + uninstalled++; + } else { + log(red(` ✘ failed`) + dim(` — ${result.output}`)); + errors.push({ name: result.skillName, output: result.output, stderr: result.stderr }); + failed++; + } + log(); + } + + return { uninstalled, failed, errors }; +} + +function uninstallAllSimple(skillNames: string[], opts: UninstallOptions): UninstallAllResult { + const sorted = [...skillNames].sort((a, b) => a.localeCompare(b)); + let uninstalled = 0; + let failed = 0; + const errors: UninstallAllResult["errors"] = []; + + for (const name of sorted) { + const result = uninstallSkill(name, opts); + if (result.success) { + log(green(` ✔ ${name}`) + dim(" — removed")); + uninstalled++; + } else { + log(red(` ✘ ${name}`) + dim(` — ${result.output}`)); + errors.push({ name: result.skillName, output: result.output, stderr: result.stderr }); + failed++; + } + } + + return { uninstalled, failed, errors }; +} From 600150d401529420b964a285e936e5a696972b31 Mon Sep 17 00:00:00 2001 From: persano Date: Mon, 18 May 2026 14:23:38 -0300 Subject: [PATCH 2/3] feat(cli): allow removal from the default install picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds symmetric checkbox semantics to the default picker: installed skills now start checked, new skills stay checked, and unchecking an installed skill marks it for removal. Confirming with [enter] runs both the install and the remove pass in one go. When the resolved selection involves any removal, the CLI prints "About to install N and remove M skill(s). Continue? [y/N]" and aborts cleanly on anything other than y/Y — destructive moves stay opt-in. Implementation: - New `changes.ts` module with `computeChanges` (pure bucketing of installs / removes / keeps / skips) and `confirmDestructive` (y/N prompt; takes optional input/output streams for tests). Kept in a separate file so the test suite can import the helpers without executing main.ts's top-level CLI bootstrap. - `multiSelect` gains `confirmHintFn`, a per-render callback that swaps the trailing `(checked/total)` counter for a richer label. The default picker uses it to show `— install N, remove M` whenever any installed row is unchecked, and falls back to the existing counter otherwise. - `selectSkills` now returns the per-skill checked state instead of the filtered list; `main()` calls `computeChanges` + `confirmDestructive` and dispatches to `installAll` and/or `uninstallAll` as needed, then routes through `printSummary` / `printRemoveSummary` / a new `printCombinedSummary` depending on what ran. - `-y` keeps its existing behaviour: it installs everything detected (installs ∪ keeps) and skips the prompt; no removals happen in -y mode — use `-r -y` for unattended removal. `-r` / `--remove` is unchanged and remains the way to skip detection. Tests: 16 new tests in tests/changes.test.ts covering the bucketing (pure install / pure remove / mixed / keeps-not-installs / empty / length mismatch) and the prompt (y, Y, n, bare newline, non-y input, autoYes shortcut, no-removals shortcut, summary line format, singular/plural). Full suite: 385/385 passing. Docs: help text and both READMEs note that removal now also works from the default picker. --- README.md | 6 +- packages/autoskills/README.md | 17 +- packages/autoskills/changes.ts | 72 ++++++++ packages/autoskills/main.ts | 213 ++++++++++++++++++---- packages/autoskills/tests/changes.test.ts | 192 +++++++++++++++++++ packages/autoskills/ui.ts | 10 +- 6 files changed, 469 insertions(+), 41 deletions(-) create mode 100644 packages/autoskills/changes.ts create mode 100644 packages/autoskills/tests/changes.test.ts diff --git a/README.md b/README.md index fdb586ae..d96ffc22 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,14 @@ This keeps the package small while avoiding live downloads from third-party skil ``` -y, --yes Skip confirmation prompt --dry-run Show what would be installed without installing --r, --remove Remove installed skills (interactive) +-r, --remove Remove-only mode — skip detection, list installed skills -h, --help Show help message ``` +The default `npx autoskills` picker also supports removal: installed skills start +checked, and unchecking any of them queues a removal that runs after a y/N +confirmation. Use `-r` when you want to skip detection entirely. + ## Supported Technologies Built to work across modern frontend, backend, mobile, cloud, and media stacks. diff --git a/packages/autoskills/README.md b/packages/autoskills/README.md index 201448f7..e6080970 100644 --- a/packages/autoskills/README.md +++ b/packages/autoskills/README.md @@ -38,11 +38,18 @@ npx autoskills --dry-run ### Remove installed skills -```bash -npx autoskills -r -``` +Removal works two ways: + +- **From the default picker.** Running `npx autoskills` now pre-checks every already-installed skill. Uncheck any of them and confirm to remove them in the same pass as installing new ones. Whenever the selection involves removals, you get a `About to install N and remove M skill(s). Continue? [y/N]` prompt before anything destructive happens. +- **Remove-only mode.** When you just want to clean up, use: + + ```bash + npx autoskills -r + ``` + + This skips detection entirely, lists every skill currently installed in your project, and lets you pick which ones to remove. Pass `-y` to remove all installed skills without confirmation. -Lists every skill currently installed in your project, lets you pick which ones to remove, and cleans up the canonical `.agents/skills/` directory, the per-agent symlinks (`.claude/skills/`, `.cline/skills/`, etc.), and the matching `skills-lock.json` entry. Pass `-y` to remove all installed skills without confirmation. +Either way, the canonical `.agents/skills/` directory, the per-agent symlinks (`.claude/skills/`, `.cline/skills/`, etc.), and the matching `skills-lock.json` entry are all cleaned up. ### Claude Code summary @@ -54,7 +61,7 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` writes a `CL | ----------------- | ----------------------------------------------------- | | `-y`, `--yes` | Skip confirmation prompt, install all detected skills | | `--dry-run` | Show detected skills without installing anything | -| `-r`, `--remove` | Remove installed skills (interactive) | +| `-r`, `--remove` | Remove-only mode — skip detection, list installed | | `-v`, `--verbose` | Show install trace and error details | | `-h`, `--help` | Show help message | diff --git a/packages/autoskills/changes.ts b/packages/autoskills/changes.ts new file mode 100644 index 00000000..b2232492 --- /dev/null +++ b/packages/autoskills/changes.ts @@ -0,0 +1,72 @@ +import { createInterface } from "node:readline"; + +import { bold, dim, yellow } from "./colors.ts"; +import type { SkillEntry } from "./lib.ts"; + +// ── Symmetric Selection Helpers ────────────────────────────── +// +// These power the default picker's symmetric checkbox semantics: +// checked = "I want this installed", unchecked installed = "remove it". +// Kept in their own module so tests can import them without executing +// main.ts's top-level CLI bootstrap. + +export interface SkillChanges { + /** Not previously installed and currently checked. */ + installs: SkillEntry[]; + /** Previously installed and currently unchecked — to be removed. */ + removes: string[]; + /** Previously installed and still checked — left alone. */ + keeps: SkillEntry[]; + /** Not installed and unchecked — ignored. */ + skips: SkillEntry[]; +} + +export function computeChanges(skills: SkillEntry[], selected: boolean[]): SkillChanges { + if (skills.length !== selected.length) { + throw new Error( + `selected length (${selected.length}) must match skills length (${skills.length})`, + ); + } + const installs: SkillEntry[] = []; + const removes: string[] = []; + const keeps: SkillEntry[] = []; + const skips: SkillEntry[] = []; + for (let i = 0; i < skills.length; i++) { + const s = skills[i]; + const checked = selected[i]; + if (s.installed && checked) keeps.push(s); + else if (s.installed && !checked) removes.push(s.skill); + else if (!s.installed && checked) installs.push(s); + else skips.push(s); + } + return { installs, removes, keeps, skips }; +} + +export async function confirmDestructive( + installs: number, + removes: number, + opts: { + autoYes?: boolean; + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; + } = {}, +): Promise { + if (removes === 0) return true; + if (opts.autoYes) return true; + + const out = opts.output ?? process.stdout; + const noun = `skill${removes === 1 ? "" : "s"}`; + out.write("\n"); + out.write(yellow(` ⚠ About to install ${installs} and remove ${removes} ${noun}.`) + "\n"); + out.write(` ${bold("Continue?")} ${dim("[y/N]")} `); + + const input = opts.input ?? process.stdin; + const rl = createInterface({ input, output: out, terminal: false }); + return new Promise((resolve) => { + rl.once("line", (answer) => { + rl.close(); + const trimmed = answer.trim(); + resolve(trimmed === "y" || trimmed === "Y"); + }); + }); +} diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index 9cd15b83..7e972134 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -29,6 +29,7 @@ import { import type { InstallSecurityCheck } from "./installer.ts"; import { cleanupClaudeMd } from "./claude.ts"; import { listInstalledSkills, uninstallAll } from "./uninstaller.ts"; +import { computeChanges, confirmDestructive } from "./changes.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const VERSION: string = (() => { @@ -87,21 +88,24 @@ function showHelp(): void { ${bold("autoskills")} — Auto-install the best AI skills for your project ${bold("Usage:")} - npx autoskills Detect & install skills + npx autoskills Detect & install skills (uncheck installed to remove) npx autoskills ${dim("-y")} Skip confirmation npx autoskills ${dim("--dry-run")} Show what would be installed npx autoskills ${dim("--clear-cache")} Clear downloaded skills cache - npx autoskills ${dim("-r")} Remove installed skills (interactive) + npx autoskills ${dim("-r")} Remove-only mode (skip detection) npx autoskills ${dim("-a cursor claude-code")} Install for specific IDEs only ${bold("Options:")} -y, --yes Skip confirmation prompt --dry-run Show skills without installing --clear-cache Clear downloaded skills cache - -r, --remove Remove installed skills (interactive) + -r, --remove Remove-only mode — list installed skills, no detection -v, --verbose Show install trace and error details -a, --agent Install for specific IDEs only (e.g. cursor, claude-code) -h, --help Show this help message + + ${dim("In the default picker, installed skills start checked. Unchecking marks")} + ${dim("them for removal — you'll be prompted before any removal happens.")} `); } @@ -423,10 +427,12 @@ function printSummary({ installed, failed, errors, elapsed, verbose }: SummaryOp // ── Skill Selection ────────────────────────────────────────── -async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise { +async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise { if (autoYes) { printSkillsList(skills); - return skills; + // Preserve legacy -y semantics: install everything detected (refresh). + // No removals happen in -y mode — use `-r -y` for that. + return skills.map(() => true); } const INSTALLED_TAG = " (installed)"; @@ -478,7 +484,8 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise 1 ? `← ${techSources.join(", ")}` : ""; }, groupFn: (s) => s.sources[0], - initialSelected: skills.map((s) => !s.installed), + // Symmetric: installed skills start checked too. Unchecking marks for removal. + initialSelected: skills.map(() => true), shortcuts: installedCount > 0 ? [ @@ -490,16 +497,24 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise { + let installsN = 0; + let removesN = 0; + for (let i = 0; i < skills.length; i++) { + const checked = sel[i]; + if (!skills[i].installed && checked) installsN++; + else if (skills[i].installed && !checked) removesN++; + } + if (removesN === 0) return null; + if (installsN === 0) return `— remove ${removesN}`; + return `— install ${installsN}, remove ${removesN}`; + }, }); - if (selected.length === 0) { - log(); - log(dim(" Nothing selected.")); - log(); - process.exit(0); - } - - return selected; + // Build the boolean array from the resolved selection. multiSelect returns + // the items that ended up checked; reverse-map back to the per-index state. + const checkedSet = new Set(selected); + return skills.map((s) => checkedSet.has(s)); } // ── Remove Flow ────────────────────────────────────────────── @@ -572,6 +587,66 @@ function printRemoveSummary({ log(); } +function printCombinedSummary({ + installed, + installFailed, + installErrors, + uninstalled, + removeFailed, + removeErrors, + elapsed, + verbose, +}: { + installed: number; + installFailed: number; + installErrors: SummaryOptions["errors"]; + uninstalled: number; + removeFailed: number; + removeErrors: { name: string; output: string; stderr: string }[]; + elapsed: number; + verbose: boolean; +}): void { + log(); + + const totalFailed = installFailed + removeFailed; + if (totalFailed === 0) { + log( + green( + bold( + ` ✔ Done! ${installed} installed, ${uninstalled} removed in ${formatTime(elapsed)}.`, + ), + ), + ); + } else { + log( + yellow( + ` Done: ${green(`${installed} installed`)}, ${green(`${uninstalled} removed`)}, ${red(`${totalFailed} failed`)} in ${formatTime(elapsed)}.`, + ), + ); + if (installErrors.length > 0 || removeErrors.length > 0) { + log(); + log(bold(red(" Errors:"))); + for (const { name, output } of installErrors) { + log(red(` ✘ install ${name}`)); + if (!verbose) log(dim(` ${briefErrorReason(output, "")}`)); + } + for (const { name, output } of removeErrors) { + log(red(` ✘ remove ${name}`)); + log(dim(` ${output}`)); + } + log(); + if (!verbose) { + log(dim(" Run again with --verbose to see the full error details.")); + } + log(dim(` If it looks like an autoskills bug, please create an issue: ${ISSUES_URL}`)); + } + } + + log(); + log(pink(" Enjoyed autoskills? Consider sponsoring → https://github.com/sponsors/midudev")); + log(); +} + async function runRemoveFlow( projectDir: string, { autoYes, verbose }: { autoYes: boolean; verbose: boolean }, @@ -695,32 +770,75 @@ async function main(): Promise { process.exit(0); } - const selectedSkills = await selectSkills(skills, autoYes); + const selectedState = await selectSkills(skills, autoYes); + const { installs, removes, keeps } = computeChanges(skills, selectedState); - log(); + // In autoYes mode we preserve today's "refresh everything detected" + // behaviour by sending installs ∪ keeps to installAll. In interactive mode + // keeps are left alone — the user didn't toggle them off. + const toInstall = autoYes ? [...installs, ...keeps] : installs; + + if (toInstall.length === 0 && removes.length === 0) { + log(); + log(dim(" Nothing selected.")); + log(); + process.exit(0); + } + + const confirmed = await confirmDestructive(toInstall.length, removes.length, { autoYes }); + if (!confirmed) { + log(); + log(dim(" Cancelled, no changes made.")); + log(); + process.exit(0); + } - log(cyan(" ◆ ") + bold("Installing skills...")); - log(dim(` Agents: ${resolvedAgents.join(", ")}`)); log(); const startTime = Date.now(); - const { installed, failed, errors, securityChecks } = await installAll( - selectedSkills, - resolvedAgents, - { - verbose, - }, - ); - const elapsed = Date.now() - startTime; - const claudeCleanup = cleanupClaudeMd(projectDir); + let installResult = { + installed: 0, + failed: 0, + errors: [] as { + name: string; + output: string; + stderr: string; + exitCode: number | null; + command: string; + }[], + securityChecks: [] as InstallSecurityCheck[], + }; + let removeResult = { + uninstalled: 0, + failed: 0, + errors: [] as { name: string; output: string; stderr: string }[], + }; + + if (toInstall.length > 0) { + log(cyan(" ◆ ") + bold("Installing skills...")); + log(dim(` Agents: ${resolvedAgents.join(", ")}`)); + log(); + + installResult = await installAll(toInstall, resolvedAgents, { verbose }); - if (process.stdout.isTTY && !verbose) { - const up = selectedSkills.length + 2; - write(`\x1b[${up}A\r\x1b[K`); - log(green(" ◆ ") + bold("Done!")); - write(`\x1b[${selectedSkills.length + 1}B`); + if (process.stdout.isTTY && !verbose) { + const up = toInstall.length + 2; + write(`\x1b[${up}A\r\x1b[K`); + log(green(" ◆ ") + bold("Done!")); + write(`\x1b[${toInstall.length + 1}B`); + } } + if (removes.length > 0) { + if (toInstall.length > 0) log(); + log(red(" ◆ ") + bold("Removing skills...")); + log(); + removeResult = await uninstallAll(removes, { projectDir, verbose }); + } + + const elapsed = Date.now() - startTime; + const claudeCleanup = cleanupClaudeMd(projectDir); + if (claudeCleanup.cleaned) { if (claudeCleanup.deleted) { log(dim(" Removed autoskills section from CLAUDE.md (file was empty, deleted).")); @@ -730,8 +848,35 @@ async function main(): Promise { log(); } - printSecurityChecks(securityChecks); - printSummary({ installed, failed, errors, elapsed, verbose }); + printSecurityChecks(installResult.securityChecks); + + if (removes.length === 0) { + printSummary({ + installed: installResult.installed, + failed: installResult.failed, + errors: installResult.errors, + elapsed, + verbose, + }); + } else if (toInstall.length === 0) { + printRemoveSummary({ + uninstalled: removeResult.uninstalled, + failed: removeResult.failed, + errors: removeResult.errors, + elapsed, + }); + } else { + printCombinedSummary({ + installed: installResult.installed, + installFailed: installResult.failed, + installErrors: installResult.errors, + uninstalled: removeResult.uninstalled, + removeFailed: removeResult.failed, + removeErrors: removeResult.errors, + elapsed, + verbose, + }); + } } main().catch((err: Error) => { diff --git a/packages/autoskills/tests/changes.test.ts b/packages/autoskills/tests/changes.test.ts new file mode 100644 index 00000000..a1e0d632 --- /dev/null +++ b/packages/autoskills/tests/changes.test.ts @@ -0,0 +1,192 @@ +import { describe, it } from "node:test"; +import { ok, strictEqual, deepStrictEqual, throws } from "node:assert/strict"; +import { Readable, Writable } from "node:stream"; + +import { computeChanges, confirmDestructive } from "../changes.ts"; +import type { SkillEntry } from "../lib.ts"; + +function skill(name: string, installed: boolean): SkillEntry { + return { skill: name, sources: ["React"], installed }; +} + +// Suppress stdout noise from the prompt when running confirmDestructive tests. +function silentOutput(): Writable { + return new Writable({ + write(_chunk, _enc, cb) { + cb(); + }, + }); +} + +describe("computeChanges", () => { + it("buckets a pure install scenario", () => { + const skills = [skill("a", false), skill("b", false)]; + const { installs, removes, keeps, skips } = computeChanges(skills, [true, true]); + + deepStrictEqual( + installs.map((s) => s.skill), + ["a", "b"], + ); + deepStrictEqual(removes, []); + deepStrictEqual(keeps, []); + deepStrictEqual(skips, []); + }); + + it("buckets a pure remove scenario", () => { + const skills = [skill("a", true), skill("b", true)]; + const { installs, removes, keeps, skips } = computeChanges(skills, [false, false]); + + deepStrictEqual(installs, []); + deepStrictEqual(removes, ["a", "b"]); + deepStrictEqual(keeps, []); + deepStrictEqual(skips, []); + }); + + it("buckets a mixed install + remove + keep + skip scenario", () => { + const skills = [ + skill("new-keep", false), // installs + skill("new-skip", false), // skips + skill("old-remove", true), // removes + skill("old-keep", true), // keeps + ]; + const { installs, removes, keeps, skips } = computeChanges(skills, [true, false, false, true]); + + deepStrictEqual( + installs.map((s) => s.skill), + ["new-keep"], + ); + deepStrictEqual(removes, ["old-remove"]); + deepStrictEqual( + keeps.map((s) => s.skill), + ["old-keep"], + ); + deepStrictEqual( + skips.map((s) => s.skill), + ["new-skip"], + ); + }); + + it("treats already-installed checked items as keeps, not installs", () => { + const skills = [skill("a", true)]; + const { installs, keeps, removes } = computeChanges(skills, [true]); + + deepStrictEqual(installs, []); + deepStrictEqual( + keeps.map((s) => s.skill), + ["a"], + ); + deepStrictEqual(removes, []); + }); + + it("handles an empty list", () => { + const out = computeChanges([], []); + deepStrictEqual(out, { installs: [], removes: [], keeps: [], skips: [] }); + }); + + it("throws when selected length does not match skills length", () => { + throws( + () => computeChanges([skill("a", false)], [true, false]), + /selected length \(2\) must match skills length \(1\)/, + ); + }); +}); + +describe("confirmDestructive", () => { + it("returns true immediately when there are no removals", async () => { + const result = await confirmDestructive(3, 0, { output: silentOutput() }); + strictEqual(result, true); + }); + + it("returns true immediately when autoYes is set, even with removals", async () => { + const result = await confirmDestructive(0, 2, { autoYes: true, output: silentOutput() }); + strictEqual(result, true); + }); + + it("returns true when the user types y", async () => { + const result = await confirmDestructive(2, 1, { + input: Readable.from(["y\n"]), + output: silentOutput(), + }); + strictEqual(result, true); + }); + + it("returns true when the user types Y", async () => { + const result = await confirmDestructive(0, 1, { + input: Readable.from(["Y\n"]), + output: silentOutput(), + }); + strictEqual(result, true); + }); + + it("returns false when the user types n", async () => { + const result = await confirmDestructive(0, 1, { + input: Readable.from(["n\n"]), + output: silentOutput(), + }); + strictEqual(result, false); + }); + + it("returns false on a bare newline (default N)", async () => { + const result = await confirmDestructive(0, 1, { + input: Readable.from(["\n"]), + output: silentOutput(), + }); + strictEqual(result, false); + }); + + it("returns false on anything that is not y/Y", async () => { + const result = await confirmDestructive(0, 1, { + input: Readable.from(["yes\n"]), + output: silentOutput(), + }); + strictEqual(result, false); + }); + + it("writes the summary line with both counts when both apply", async () => { + const chunks: string[] = []; + const output = new Writable({ + write(chunk, _enc, cb) { + chunks.push(chunk.toString()); + cb(); + }, + }); + await confirmDestructive(4, 1, { + input: Readable.from(["y\n"]), + output, + }); + const text = chunks.join(""); + ok(/About to install 4 and remove 1 skill\b/.test(text), `unexpected prompt: ${text}`); + ok(/Continue\?/.test(text), `prompt missing Continue?: ${text}`); + ok(/\[y\/N\]/.test(text), `prompt missing [y/N]: ${text}`); + }); + + it("uses the singular noun when removing exactly one skill", async () => { + const chunks: string[] = []; + const output = new Writable({ + write(chunk, _enc, cb) { + chunks.push(chunk.toString()); + cb(); + }, + }); + await confirmDestructive(0, 1, { + input: Readable.from(["n\n"]), + output, + }); + ok(/remove 1 skill\./.test(chunks.join(""))); + }); + + it("uses the plural noun when removing multiple skills", async () => { + const chunks: string[] = []; + const output = new Writable({ + write(chunk, _enc, cb) { + chunks.push(chunk.toString()); + cb(); + }, + }); + await confirmDestructive(0, 3, { + input: Readable.from(["n\n"]), + output, + }); + ok(/remove 3 skills\./.test(chunks.join(""))); + }); +}); diff --git a/packages/autoskills/ui.ts b/packages/autoskills/ui.ts index 80d8f7be..e522294b 100644 --- a/packages/autoskills/ui.ts +++ b/packages/autoskills/ui.ts @@ -94,6 +94,12 @@ interface MultiSelectOptions { shortcuts?: { key: string; label: string; fn: (items: T[]) => boolean[] }[]; /** Verb shown in the bottom help line. Defaults to "confirm". */ confirmLabel?: string; + /** + * Optional dynamic suffix after `[enter] ${confirmLabel}`. Recomputed on + * every render with the current selection. Return `null` to fall back to + * the default `(checked/total)` counter. + */ + confirmHintFn?: (selected: boolean[]) => string | null; } export function multiSelect( @@ -105,6 +111,7 @@ export function multiSelect( initialSelected, shortcuts = [], confirmLabel = "confirm", + confirmHintFn, }: MultiSelectOptions, ): Promise { if (initialSelected && initialSelected.length !== items.length) { @@ -180,6 +187,7 @@ export function multiSelect( .map((s) => white(bold(`[${s.key}]`)) + dim(` ${s.label}`)) .join(dim(" · ")); const shortcutPart = shortcuts.length > 0 ? shortcutHints + dim(" · ") : ""; + const confirmSuffix = confirmHintFn?.(selected) ?? `(${count}/${items.length})`; write( dim(" ") + white(bold("[↑↓]")) + @@ -190,7 +198,7 @@ export function multiSelect( dim(" all · ") + shortcutPart + white(bold("[enter]")) + - dim(` ${confirmLabel} (${count}/${items.length})`), + dim(` ${confirmLabel} ${confirmSuffix}`), ); } From 6ad62df0b62459f0ca4222b7ef05e6da6e13c53c Mon Sep 17 00:00:00 2001 From: persano Date: Mon, 18 May 2026 14:31:26 -0300 Subject: [PATCH 3/3] fix(cli): pass bare skill name to uninstaller from the default picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default picker built its remove list from `SkillEntry.skill`, which is the full `author/repo/skillName` registry path. `uninstallSkill` rejects any name with a path separator and only operates on the bare last segment (the same key used in `skills-lock.json`). Effect before this fix: any removal triggered from the default picker failed with `invalid skill name: //`. The dedicated `-r` flow was unaffected because `listInstalledSkills` reads keys straight from the lockfile, which are already bare names. `computeChanges` now extracts the bare name with `parseSkillPath().skillName` before pushing into `removes`, matching the contract used by `installer.ts` when it writes the lockfile and by `-r`'s flow. Raw URL skills (no parseable name) are dropped from `removes` rather than poisoning the call with an empty string. Tests: 2 new regression cases — one for the registry-shape path (`sickn33/antigravity-awesome-skills/nodejs-best-practices` → `nodejs-best-practices`) and one for URL-style skills (dropped). Existing `installs`/`keeps` assertions updated to reflect that those buckets still carry the full path (installAll needs it). 387/387 passing. --- packages/autoskills/changes.ts | 14 +++++-- packages/autoskills/tests/changes.test.ts | 45 ++++++++++++++++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/autoskills/changes.ts b/packages/autoskills/changes.ts index b2232492..7c096bf0 100644 --- a/packages/autoskills/changes.ts +++ b/packages/autoskills/changes.ts @@ -1,6 +1,7 @@ import { createInterface } from "node:readline"; import { bold, dim, yellow } from "./colors.ts"; +import { parseSkillPath } from "./lib.ts"; import type { SkillEntry } from "./lib.ts"; // ── Symmetric Selection Helpers ────────────────────────────── @@ -13,7 +14,12 @@ import type { SkillEntry } from "./lib.ts"; export interface SkillChanges { /** Not previously installed and currently checked. */ installs: SkillEntry[]; - /** Previously installed and currently unchecked — to be removed. */ + /** + * Bare last-segment names of skills to remove — the same form used as + * keys in `skills-lock.json` and accepted by `uninstallSkill`. Derived + * from `SkillEntry.skill` via `parseSkillPath().skillName`. Empty-string + * names (e.g. raw URL skills with no parseable name) are dropped. + */ removes: string[]; /** Previously installed and still checked — left alone. */ keeps: SkillEntry[]; @@ -35,8 +41,10 @@ export function computeChanges(skills: SkillEntry[], selected: boolean[]): Skill const s = skills[i]; const checked = selected[i]; if (s.installed && checked) keeps.push(s); - else if (s.installed && !checked) removes.push(s.skill); - else if (!s.installed && checked) installs.push(s); + else if (s.installed && !checked) { + const { skillName } = parseSkillPath(s.skill); + if (skillName) removes.push(skillName); + } else if (!s.installed && checked) installs.push(s); else skips.push(s); } return { installs, removes, keeps, skips }; diff --git a/packages/autoskills/tests/changes.test.ts b/packages/autoskills/tests/changes.test.ts index a1e0d632..8aeb9d59 100644 --- a/packages/autoskills/tests/changes.test.ts +++ b/packages/autoskills/tests/changes.test.ts @@ -6,7 +6,10 @@ import { computeChanges, confirmDestructive } from "../changes.ts"; import type { SkillEntry } from "../lib.ts"; function skill(name: string, installed: boolean): SkillEntry { - return { skill: name, sources: ["React"], installed }; + // Mirror the registry shape (`author/repo/skillName`) so tests exercise + // the same path parseSkillPath would see in production. + const fullPath = name.includes("/") ? name : `acme/skills/${name}`; + return { skill: fullPath, sources: ["React"], installed }; } // Suppress stdout noise from the prompt when running confirmDestructive tests. @@ -25,7 +28,7 @@ describe("computeChanges", () => { deepStrictEqual( installs.map((s) => s.skill), - ["a", "b"], + ["acme/skills/a", "acme/skills/b"], ); deepStrictEqual(removes, []); deepStrictEqual(keeps, []); @@ -37,6 +40,7 @@ describe("computeChanges", () => { const { installs, removes, keeps, skips } = computeChanges(skills, [false, false]); deepStrictEqual(installs, []); + // `removes` is the bare last-segment name (what uninstallSkill accepts). deepStrictEqual(removes, ["a", "b"]); deepStrictEqual(keeps, []); deepStrictEqual(skips, []); @@ -51,18 +55,19 @@ describe("computeChanges", () => { ]; const { installs, removes, keeps, skips } = computeChanges(skills, [true, false, false, true]); + // installs / keeps carry the full registry path — installAll needs it. deepStrictEqual( installs.map((s) => s.skill), - ["new-keep"], + ["acme/skills/new-keep"], ); deepStrictEqual(removes, ["old-remove"]); deepStrictEqual( keeps.map((s) => s.skill), - ["old-keep"], + ["acme/skills/old-keep"], ); deepStrictEqual( skips.map((s) => s.skill), - ["new-skip"], + ["acme/skills/new-skip"], ); }); @@ -73,7 +78,7 @@ describe("computeChanges", () => { deepStrictEqual(installs, []); deepStrictEqual( keeps.map((s) => s.skill), - ["a"], + ["acme/skills/a"], ); deepStrictEqual(removes, []); }); @@ -83,6 +88,34 @@ describe("computeChanges", () => { deepStrictEqual(out, { installs: [], removes: [], keeps: [], skips: [] }); }); + it("removes contains the bare last-segment name, not the full author/repo/name", () => { + // Registry uses `author/repo/skillName`, but skills-lock.json keys on + // skillName and uninstallSkill rejects path separators. computeChanges + // bridges the two by extracting the last segment. + const skills: SkillEntry[] = [ + { + skill: "sickn33/antigravity-awesome-skills/nodejs-best-practices", + sources: ["Node.js"], + installed: true, + }, + ]; + const { removes } = computeChanges(skills, [false]); + + deepStrictEqual(removes, ["nodejs-best-practices"]); + }); + + it("drops removes for URL-style skills with no parseable name", () => { + // parseSkillPath returns skillName: "" for raw http(s) URLs. Those + // can't be passed to uninstallSkill safely; we drop them rather than + // letting the empty string poison the remove call. + const skills: SkillEntry[] = [ + { skill: "https://example.com/skill.md", sources: ["Custom"], installed: true }, + ]; + const { removes } = computeChanges(skills, [false]); + + deepStrictEqual(removes, []); + }); + it("throws when selected length does not match skills length", () => { throws( () => computeChanges([skill("a", false)], [true, false]),