Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/autoskills/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
statSync,
symlinkSync,
writeFileSync,
lstatSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { dirname, join, relative } from "node:path";
Expand Down Expand Up @@ -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<string, unknown> } {
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<string, unknown>): void {
const lockPath = join(projectDir, "skills-lock.json");
const sortedSkills: Record<string, unknown> = {};
const skills = lock.skills as Record<string, unknown>;
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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/autoskills/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ export function parseSkillPath(skill: string): ParsedSkillPath {
export function getInstalledSkillNames(projectDir: string): Set<string> {
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 {}
Expand Down
159 changes: 137 additions & 22 deletions packages/autoskills/main.ts
Original file line number Diff line number Diff line change
@@ -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 = (() => {
Expand All @@ -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";
})();
Expand All @@ -57,6 +59,7 @@ interface CliArgs {
help: boolean;
clearCache: boolean;
agents: string[];
remove: string | undefined;
}

function parseArgs(): CliArgs {
Expand All @@ -69,13 +72,24 @@ 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"),
verbose: args.includes("--verbose") || args.includes("-v"),
help: args.includes("--help") || args.includes("-h"),
clearCache: args.includes("--clear-cache"),
agents,
remove,
};
}

Expand All @@ -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 <skill>")} Remove an installed skill
npx autoskills ${dim("rm <skill>")} Alias for remove

${bold("Options:")}
-y, --yes Skip confirmation prompt
Expand Down Expand Up @@ -477,13 +493,13 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise<Ski
shortcuts:
installedCount > 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),
},
]
: [],
});

Expand All @@ -500,7 +516,7 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise<Ski
// ── Main ─────────────────────────────────────────────────────

async function main(): Promise<void> {
const { autoYes, dryRun, verbose, help, clearCache, agents } = parseArgs();
const { autoYes, dryRun, verbose, help, clearCache, agents, remove } = parseArgs();

if (help) {
showHelp();
Expand All @@ -518,6 +534,105 @@ async function main(): Promise<void> {
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();

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();
log(dim(" Nothing selected."));
log();
process.exit(0);
}

if (!autoYes) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((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<string>((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(".");
Expand Down
Loading