@sporesec/arcana 3.0.1 → 3.0.2
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/dist/commands/uninstall.js +4 -1
- package/dist/interactive/browse.js +2 -2
- package/dist/interactive/health.js +42 -35
- package/dist/interactive/helpers.d.ts +14 -0
- package/dist/interactive/helpers.js +33 -6
- package/dist/interactive/manage.js +3 -3
- package/dist/interactive/menu.js +4 -0
- package/dist/interactive/optimize-flow.d.ts +1 -0
- package/dist/interactive/optimize-flow.js +36 -0
- package/dist/interactive/search.js +1 -1
- package/dist/interactive/skill-detail.d.ts +4 -1
- package/dist/interactive/skill-detail.js +69 -35
- package/dist/utils/backup.d.ts +3 -0
- package/dist/utils/backup.js +32 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import chalk from "chalk";
|
|
|
5
5
|
import { getSkillDir, listSymlinks, readSkillMeta } from "../utils/fs.js";
|
|
6
6
|
import { renderBanner } from "../utils/help.js";
|
|
7
7
|
import { validateSlug } from "../utils/validate.js";
|
|
8
|
+
import { backupSkill } from "../utils/backup.js";
|
|
8
9
|
export async function uninstallCommand(skillNames, opts = {}) {
|
|
9
10
|
if (opts.json) {
|
|
10
11
|
return uninstallJson(skillNames);
|
|
@@ -49,7 +50,8 @@ async function uninstallOneInteractive(skillName, skipConfirm) {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
const spin = p.spinner();
|
|
52
|
-
spin.start(`
|
|
53
|
+
spin.start(`Backing up and removing ${skillName}...`);
|
|
54
|
+
backupSkill(skillName);
|
|
53
55
|
rmSync(skillDir, { recursive: true, force: true });
|
|
54
56
|
const symlinksRemoved = removeSymlinksFor(skillName);
|
|
55
57
|
spin.stop(`Removed ${chalk.bold(skillName)}`);
|
|
@@ -88,6 +90,7 @@ async function uninstallMultipleInteractive(skillNames, skipConfirm) {
|
|
|
88
90
|
for (let i = 0; i < toRemove.length; i++) {
|
|
89
91
|
const skillName = toRemove[i];
|
|
90
92
|
spin.start(`Removing ${chalk.bold(skillName)} (${i + 1}/${toRemove.length})...`);
|
|
93
|
+
backupSkill(skillName);
|
|
91
94
|
rmSync(getSkillDir(skillName), { recursive: true, force: true });
|
|
92
95
|
totalSymlinks += removeSymlinksFor(skillName);
|
|
93
96
|
}
|
|
@@ -48,7 +48,7 @@ export async function browseByCategory(allSkills, providerName) {
|
|
|
48
48
|
};
|
|
49
49
|
});
|
|
50
50
|
const category = await p.select({
|
|
51
|
-
message: "Browse
|
|
51
|
+
message: "Browse > Select category",
|
|
52
52
|
options: [...categoryOptions, { value: "__back", label: "Back" }],
|
|
53
53
|
});
|
|
54
54
|
handleCancel(category);
|
|
@@ -85,7 +85,7 @@ async function categorySkillList(categoryName, skillNames, allSkills, providerNa
|
|
|
85
85
|
}
|
|
86
86
|
extraOptions.push({ value: "__back", label: "Back to categories" });
|
|
87
87
|
const picked = await p.select({
|
|
88
|
-
message:
|
|
88
|
+
message: `Browse > ${categoryName}`,
|
|
89
89
|
options: [...options, ...extraOptions],
|
|
90
90
|
});
|
|
91
91
|
handleCancel(picked);
|
|
@@ -3,40 +3,47 @@ import chalk from "chalk";
|
|
|
3
3
|
import { runDoctorChecks } from "../commands/doctor.js";
|
|
4
4
|
import { handleCancel } from "./helpers.js";
|
|
5
5
|
export async function checkHealth() {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
6
|
+
while (true) {
|
|
7
|
+
const checks = runDoctorChecks();
|
|
8
|
+
p.log.step(chalk.bold("Environment Health Check"));
|
|
9
|
+
for (const check of checks) {
|
|
10
|
+
const icon = check.status === "pass" ? chalk.green("OK") : check.status === "warn" ? chalk.yellow("!!") : chalk.red("XX");
|
|
11
|
+
p.log.info(`${icon} ${chalk.bold(check.name)}: ${check.message}`);
|
|
12
|
+
if (check.fix)
|
|
13
|
+
p.log.info(chalk.dim(` Fix: ${check.fix}`));
|
|
14
|
+
}
|
|
15
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
16
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
17
|
+
if (fails > 0) {
|
|
18
|
+
p.log.error(`${fails} issue${fails > 1 ? "s" : ""} found`);
|
|
19
|
+
}
|
|
20
|
+
else if (warns > 0) {
|
|
21
|
+
p.log.warn(`${warns} warning${warns > 1 ? "s" : ""}`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
p.log.success("All checks passed");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const fixChecks = checks.filter((c) => c.fix && c.status !== "pass");
|
|
28
|
+
if (fixChecks.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
const fixOptions = fixChecks.map((c) => {
|
|
31
|
+
const cmd = c.fix.replace(/^Run:\s*/, "");
|
|
32
|
+
return { value: cmd, label: `Run: ${cmd}`, hint: c.name };
|
|
33
|
+
});
|
|
34
|
+
const fixAction = await p.select({
|
|
35
|
+
message: "Run a fix?",
|
|
36
|
+
options: [
|
|
37
|
+
...fixOptions,
|
|
38
|
+
{ value: "__recheck", label: "Re-check now" },
|
|
39
|
+
{ value: "__back", label: "Back to menu" },
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
handleCancel(fixAction);
|
|
43
|
+
if (fixAction === "__back")
|
|
44
|
+
return;
|
|
45
|
+
if (fixAction === "__recheck")
|
|
46
|
+
continue;
|
|
40
47
|
const cmd = fixAction;
|
|
41
48
|
const SAFE_PREFIXES = ["arcana ", "git config "];
|
|
42
49
|
if (!SAFE_PREFIXES.some((pre) => cmd.startsWith(pre))) {
|
|
@@ -52,6 +59,6 @@ export async function checkHealth() {
|
|
|
52
59
|
// Non-zero exit expected for some commands
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
|
-
|
|
62
|
+
// Loop continues, re-runs checks automatically
|
|
56
63
|
}
|
|
57
64
|
}
|
|
@@ -4,6 +4,20 @@ export declare function handleCancel(value: unknown): void;
|
|
|
4
4
|
export declare function countInstalled(): number;
|
|
5
5
|
export declare function truncate(str: string, max: number): string;
|
|
6
6
|
export declare function getInstalledNames(): string[];
|
|
7
|
+
export declare function getTokenEstimate(skillName: string): {
|
|
8
|
+
tokens: number;
|
|
9
|
+
kb: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function getTotalTokenBudget(): {
|
|
12
|
+
totalKB: number;
|
|
13
|
+
totalTokens: number;
|
|
14
|
+
count: number;
|
|
15
|
+
skills: {
|
|
16
|
+
name: string;
|
|
17
|
+
tokens: number;
|
|
18
|
+
kb: number;
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
7
21
|
export declare function buildMenuOptions(installedCount: number, _availableCount: number): {
|
|
8
22
|
value: string;
|
|
9
23
|
label: string;
|
|
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import * as p from "@clack/prompts";
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
import { getInstallDir } from "../utils/fs.js";
|
|
5
|
+
import { getInstallDir, getSkillDir, getDirSize } from "../utils/fs.js";
|
|
6
6
|
import { clearProviderCache } from "../registry.js";
|
|
7
7
|
export const AMBER = chalk.hex("#d4943a");
|
|
8
8
|
export function cancelAndExit() {
|
|
@@ -45,21 +45,48 @@ export function getInstalledNames() {
|
|
|
45
45
|
})
|
|
46
46
|
.sort();
|
|
47
47
|
}
|
|
48
|
+
export function getTokenEstimate(skillName) {
|
|
49
|
+
const dir = getSkillDir(skillName);
|
|
50
|
+
if (!existsSync(dir))
|
|
51
|
+
return { tokens: 0, kb: 0 };
|
|
52
|
+
const bytes = getDirSize(dir);
|
|
53
|
+
return { tokens: Math.round(bytes / 4), kb: Math.round(bytes / 1024) };
|
|
54
|
+
}
|
|
55
|
+
export function getTotalTokenBudget() {
|
|
56
|
+
const names = getInstalledNames();
|
|
57
|
+
const skills = names.map((name) => {
|
|
58
|
+
const est = getTokenEstimate(name);
|
|
59
|
+
return { name, tokens: est.tokens, kb: est.kb };
|
|
60
|
+
});
|
|
61
|
+
skills.sort((a, b) => b.tokens - a.tokens);
|
|
62
|
+
const totalKB = skills.reduce((sum, s) => sum + s.kb, 0);
|
|
63
|
+
const totalTokens = skills.reduce((sum, s) => sum + s.tokens, 0);
|
|
64
|
+
return { totalKB, totalTokens, count: names.length, skills };
|
|
65
|
+
}
|
|
48
66
|
export function buildMenuOptions(installedCount, _availableCount) {
|
|
49
67
|
const isNew = installedCount === 0;
|
|
50
68
|
const options = [];
|
|
51
69
|
if (isNew) {
|
|
52
|
-
options.push({
|
|
70
|
+
options.push({
|
|
71
|
+
value: "setup",
|
|
72
|
+
label: AMBER("Get Started"),
|
|
73
|
+
hint: "detect project, install recommended skills",
|
|
74
|
+
});
|
|
53
75
|
}
|
|
54
76
|
else {
|
|
55
|
-
options.push({
|
|
77
|
+
options.push({
|
|
78
|
+
value: "installed",
|
|
79
|
+
label: "Your skills",
|
|
80
|
+
hint: `${installedCount} installed, manage & update`,
|
|
81
|
+
});
|
|
56
82
|
}
|
|
57
|
-
options.push({ value: "browse", label: "Browse
|
|
58
|
-
options.push({ value: "search", label: "Search
|
|
83
|
+
options.push({ value: "browse", label: "Browse marketplace" });
|
|
84
|
+
options.push({ value: "search", label: "Search skills" });
|
|
59
85
|
if (!isNew) {
|
|
60
86
|
options.push({ value: "setup", label: "Get Started", hint: "detect project, add more skills" });
|
|
61
87
|
}
|
|
62
|
-
options.push({ value: "health", label: "
|
|
88
|
+
options.push({ value: "health", label: "Health check" });
|
|
89
|
+
options.push({ value: "optimize", label: "Token budget" });
|
|
63
90
|
options.push({ value: "ref", label: "CLI reference" });
|
|
64
91
|
options.push({ value: "exit", label: "Exit" });
|
|
65
92
|
return options;
|
|
@@ -35,7 +35,7 @@ export async function manageInstalled(allSkills, providerName) {
|
|
|
35
35
|
hint: `${g.skills.length} installed`,
|
|
36
36
|
}));
|
|
37
37
|
const picked = await p.select({
|
|
38
|
-
message: `
|
|
38
|
+
message: `Your skills > Select category`,
|
|
39
39
|
options: [
|
|
40
40
|
...options,
|
|
41
41
|
{ value: "__update", label: chalk.cyan("Check for updates") },
|
|
@@ -78,7 +78,7 @@ async function installedCategoryList(categoryName, installedNames, allSkills, pr
|
|
|
78
78
|
};
|
|
79
79
|
});
|
|
80
80
|
const picked = await p.select({
|
|
81
|
-
message:
|
|
81
|
+
message: `Your skills > ${categoryName}`,
|
|
82
82
|
options: [...options, { value: "__back", label: "Back" }],
|
|
83
83
|
});
|
|
84
84
|
handleCancel(picked);
|
|
@@ -108,7 +108,7 @@ async function bulkUninstall(installedNames) {
|
|
|
108
108
|
return;
|
|
109
109
|
let removed = 0;
|
|
110
110
|
for (const name of names) {
|
|
111
|
-
if (doUninstall(name))
|
|
111
|
+
if (doUninstall(name).success)
|
|
112
112
|
removed++;
|
|
113
113
|
}
|
|
114
114
|
p.log.success(`Removed ${removed} skill${removed !== 1 ? "s" : ""}`);
|
package/dist/interactive/menu.js
CHANGED
|
@@ -11,6 +11,7 @@ import { searchFlow } from "./search.js";
|
|
|
11
11
|
import { quickSetup } from "./setup.js";
|
|
12
12
|
import { manageInstalled } from "./manage.js";
|
|
13
13
|
import { checkHealth } from "./health.js";
|
|
14
|
+
import { optimizeInteractive } from "./optimize-flow.js";
|
|
14
15
|
export async function showInteractiveMenu(version) {
|
|
15
16
|
const config = loadConfig();
|
|
16
17
|
const providerName = config.defaultProvider;
|
|
@@ -93,6 +94,9 @@ export async function showInteractiveMenu(version) {
|
|
|
93
94
|
case "health":
|
|
94
95
|
await checkHealth();
|
|
95
96
|
break;
|
|
97
|
+
case "optimize":
|
|
98
|
+
await optimizeInteractive();
|
|
99
|
+
break;
|
|
96
100
|
case "ref":
|
|
97
101
|
p.note(getCliReference(), "CLI Reference");
|
|
98
102
|
break;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function optimizeInteractive(): Promise<void>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { getTotalTokenBudget, AMBER, handleCancel } from "./helpers.js";
|
|
4
|
+
export async function optimizeInteractive() {
|
|
5
|
+
const budget = getTotalTokenBudget();
|
|
6
|
+
const barWidth = 30;
|
|
7
|
+
const maxTokens = 200_000;
|
|
8
|
+
const pct = Math.min(100, Math.round((budget.totalTokens / maxTokens) * 100));
|
|
9
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
10
|
+
const bar = AMBER("█".repeat(filled)) + chalk.dim("░".repeat(barWidth - filled));
|
|
11
|
+
const lines = [];
|
|
12
|
+
lines.push(`${bar} ${pct}% of 200K context window`);
|
|
13
|
+
lines.push("");
|
|
14
|
+
lines.push(`${chalk.bold(String(budget.count))} skills installed, ${chalk.bold(String(budget.totalKB))} KB total (~${(budget.totalTokens / 1000).toFixed(0)}K tokens)`);
|
|
15
|
+
if (budget.skills.length > 0) {
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push(chalk.dim("Largest skills:"));
|
|
18
|
+
for (const s of budget.skills.slice(0, 7)) {
|
|
19
|
+
const pctOfTotal = budget.totalTokens > 0 ? Math.round((s.tokens / budget.totalTokens) * 100) : 0;
|
|
20
|
+
lines.push(` ${s.name.padEnd(32)} ${String(s.kb).padStart(4)} KB ${String(pctOfTotal).padStart(3)}%`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
p.note(lines.join("\n"), "Token Budget");
|
|
24
|
+
const action = await p.select({
|
|
25
|
+
message: "What next?",
|
|
26
|
+
options: [
|
|
27
|
+
{ value: "full", label: "Run full optimization report" },
|
|
28
|
+
{ value: "__back", label: "Back" },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
handleCancel(action);
|
|
32
|
+
if (action === "full") {
|
|
33
|
+
const { optimizeCommand } = await import("../commands/optimize.js");
|
|
34
|
+
await optimizeCommand({ json: false });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -51,7 +51,7 @@ async function searchResultsPicker(results, allSkills, providerName) {
|
|
|
51
51
|
hint: truncate(skill.description, 50),
|
|
52
52
|
}));
|
|
53
53
|
const picked = await p.select({
|
|
54
|
-
message:
|
|
54
|
+
message: `Search > Results`,
|
|
55
55
|
options: [...options, { value: "__search", label: "Search again" }, { value: "__back", label: "Back" }],
|
|
56
56
|
});
|
|
57
57
|
handleCancel(picked);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { SkillInfo } from "../types.js";
|
|
2
2
|
declare function doInstall(skillName: string, providerName: string): Promise<boolean>;
|
|
3
|
-
declare function doUninstall(skillName: string):
|
|
3
|
+
declare function doUninstall(skillName: string): {
|
|
4
|
+
success: boolean;
|
|
5
|
+
backupPath?: string;
|
|
6
|
+
};
|
|
4
7
|
export declare function skillDetailFlow(skillName: string, allSkills: SkillInfo[], providerName: string): Promise<"back" | "menu">;
|
|
5
8
|
export { doInstall, doUninstall };
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { existsSync, rmSync } from "node:fs";
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
3
|
import chalk from "chalk";
|
|
4
|
-
import { isSkillInstalled, readSkillMeta, getSkillDir } from "../utils/fs.js";
|
|
5
|
-
import { getProvider } from "../registry.js";
|
|
4
|
+
import { isSkillInstalled, readSkillMeta, getSkillDir, getDirSize } from "../utils/fs.js";
|
|
6
5
|
import { appendHistory } from "../utils/history.js";
|
|
7
6
|
import { installOneCore } from "../utils/install-core.js";
|
|
7
|
+
import { backupSkill } from "../utils/backup.js";
|
|
8
8
|
import { removeSymlinksFor } from "../commands/uninstall.js";
|
|
9
9
|
import { ui } from "../utils/ui.js";
|
|
10
|
+
import { getProvider } from "../registry.js";
|
|
10
11
|
import { handleCancel } from "./helpers.js";
|
|
11
12
|
import { getCategoryFor, getRelatedSkills } from "./categories.js";
|
|
12
13
|
async function doInstall(skillName, providerName) {
|
|
@@ -35,69 +36,93 @@ async function doInstall(skillName, providerName) {
|
|
|
35
36
|
function doUninstall(skillName) {
|
|
36
37
|
const skillDir = getSkillDir(skillName);
|
|
37
38
|
if (!existsSync(skillDir))
|
|
38
|
-
return false;
|
|
39
|
+
return { success: false };
|
|
39
40
|
try {
|
|
41
|
+
const backupPath = backupSkill(skillName);
|
|
40
42
|
rmSync(skillDir, { recursive: true, force: true });
|
|
41
43
|
removeSymlinksFor(skillName);
|
|
42
44
|
appendHistory("uninstall", skillName);
|
|
43
|
-
return true;
|
|
45
|
+
return { success: true, backupPath: backupPath ?? undefined };
|
|
44
46
|
}
|
|
45
47
|
catch {
|
|
46
|
-
return false;
|
|
48
|
+
return { success: false };
|
|
47
49
|
}
|
|
48
50
|
}
|
|
51
|
+
function getTokenEstimate(skillName) {
|
|
52
|
+
const dir = getSkillDir(skillName);
|
|
53
|
+
if (!existsSync(dir))
|
|
54
|
+
return { tokens: 0, kb: 0 };
|
|
55
|
+
const bytes = getDirSize(dir);
|
|
56
|
+
return { tokens: Math.round(bytes / 4), kb: Math.round(bytes / 1024) };
|
|
57
|
+
}
|
|
49
58
|
export async function skillDetailFlow(skillName, allSkills, providerName) {
|
|
50
59
|
const info = allSkills.find((s) => s.name === skillName);
|
|
51
60
|
const installed = isSkillInstalled(skillName);
|
|
52
61
|
const meta = installed ? readSkillMeta(skillName) : null;
|
|
53
|
-
// Build info block
|
|
62
|
+
// Build info block with visual hierarchy
|
|
54
63
|
const lines = [];
|
|
55
|
-
|
|
64
|
+
// Header
|
|
65
|
+
lines.push(`${chalk.bold(skillName)} ${info ? chalk.dim(`v${info.version}`) : ""}`);
|
|
56
66
|
if (info?.description)
|
|
57
|
-
lines.push(info.description);
|
|
67
|
+
lines.push(chalk.dim(info.description));
|
|
58
68
|
lines.push("");
|
|
69
|
+
// Status (most important, shown first)
|
|
70
|
+
if (installed && meta) {
|
|
71
|
+
const date = meta.installedAt ? new Date(meta.installedAt).toLocaleDateString() : "";
|
|
72
|
+
const est = getTokenEstimate(skillName);
|
|
73
|
+
const tokenStr = est.tokens > 0 ? ` ~${(est.tokens / 1000).toFixed(1)}K tokens` : "";
|
|
74
|
+
lines.push(`${chalk.green("Installed")} v${meta.version}${date ? ` ${date}` : ""}${tokenStr}`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
lines.push(chalk.dim("Not installed"));
|
|
78
|
+
}
|
|
79
|
+
lines.push("");
|
|
80
|
+
// Aligned metadata
|
|
81
|
+
const metadata = [];
|
|
59
82
|
if (info?.verified)
|
|
60
|
-
|
|
83
|
+
metadata.push(["Trust", chalk.green("Verified (official)")]);
|
|
61
84
|
else
|
|
62
|
-
|
|
85
|
+
metadata.push(["Trust", "Community"]);
|
|
63
86
|
if (info?.author)
|
|
64
|
-
|
|
87
|
+
metadata.push(["Author", info.author]);
|
|
65
88
|
if (info?.tags && info.tags.length > 0)
|
|
66
|
-
|
|
89
|
+
metadata.push(["Tags", info.tags.join(", ")]);
|
|
67
90
|
const category = getCategoryFor(skillName);
|
|
68
91
|
if (category)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
lines.push(`Companions: ${info.companions.join(", ")}`);
|
|
74
|
-
}
|
|
75
|
-
if (info?.conflicts && info.conflicts.length > 0) {
|
|
76
|
-
lines.push(`${chalk.red("Conflicts:")} ${info.conflicts.join(", ")}`);
|
|
77
|
-
}
|
|
78
|
-
if (installed && meta) {
|
|
79
|
-
const date = meta.installedAt ? new Date(meta.installedAt).toLocaleDateString() : "";
|
|
80
|
-
lines.push(`Status: ${chalk.green("installed")} (v${meta.version}${date ? `, ${date}` : ""})`);
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
lines.push(`Status: ${chalk.dim("not installed")}`);
|
|
92
|
+
metadata.push(["Category", category]);
|
|
93
|
+
const maxLabel = Math.max(...metadata.map(([k]) => k.length));
|
|
94
|
+
for (const [key, val] of metadata) {
|
|
95
|
+
lines.push(`${chalk.dim(key.padEnd(maxLabel + 1))} ${val}`);
|
|
84
96
|
}
|
|
97
|
+
// Relations
|
|
85
98
|
const related = getRelatedSkills(skillName);
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
const hasRelations = (info?.companions && info.companions.length > 0) ||
|
|
100
|
+
(info?.conflicts && info.conflicts.length > 0) ||
|
|
101
|
+
related.length > 0;
|
|
102
|
+
if (hasRelations) {
|
|
103
|
+
lines.push("");
|
|
104
|
+
if (info?.companions && info.companions.length > 0) {
|
|
105
|
+
lines.push(`${chalk.dim("Works with:")} ${info.companions.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
if (info?.conflicts && info.conflicts.length > 0) {
|
|
108
|
+
lines.push(`${chalk.red("Conflicts:")} ${info.conflicts.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
if (related.length > 0) {
|
|
111
|
+
lines.push(`${chalk.dim("Related:")} ${related.join(", ")}`);
|
|
112
|
+
}
|
|
88
113
|
}
|
|
89
114
|
p.note(lines.join("\n"), skillName);
|
|
90
115
|
// Action menu
|
|
91
116
|
const actions = [];
|
|
92
117
|
if (installed) {
|
|
93
|
-
actions.push({ value: "reinstall", label: "
|
|
94
|
-
actions.push({ value: "uninstall", label: "Uninstall
|
|
118
|
+
actions.push({ value: "reinstall", label: "Update to latest" });
|
|
119
|
+
actions.push({ value: "uninstall", label: "Uninstall" });
|
|
95
120
|
}
|
|
96
121
|
else {
|
|
97
122
|
actions.push({ value: "install", label: "Install this skill" });
|
|
98
123
|
}
|
|
99
|
-
actions.push({ value: "
|
|
100
|
-
const action = await p.select({ message:
|
|
124
|
+
actions.push({ value: "__back", label: "Back" });
|
|
125
|
+
const action = await p.select({ message: `${skillName} > Action`, options: actions });
|
|
101
126
|
handleCancel(action);
|
|
102
127
|
switch (action) {
|
|
103
128
|
case "install":
|
|
@@ -106,12 +131,21 @@ export async function skillDetailFlow(skillName, allSkills, providerName) {
|
|
|
106
131
|
return "back";
|
|
107
132
|
}
|
|
108
133
|
case "uninstall": {
|
|
134
|
+
// Dry-run preview
|
|
135
|
+
const skillDir = getSkillDir(skillName);
|
|
136
|
+
const size = getDirSize(skillDir);
|
|
137
|
+
p.log.info(chalk.dim(` Will remove: ${skillDir}`));
|
|
138
|
+
p.log.info(chalk.dim(` Size: ${(size / 1024).toFixed(0)} KB (${meta?.fileCount ?? "?"} files)`));
|
|
139
|
+
p.log.info(chalk.dim(` A backup will be created before removal.`));
|
|
109
140
|
const ok = await p.confirm({ message: `Uninstall ${chalk.bold(skillName)}?` });
|
|
110
141
|
handleCancel(ok);
|
|
111
142
|
if (ok) {
|
|
112
|
-
const
|
|
113
|
-
if (success) {
|
|
143
|
+
const result = doUninstall(skillName);
|
|
144
|
+
if (result.success) {
|
|
114
145
|
p.log.success(`Removed ${chalk.bold(skillName)}`);
|
|
146
|
+
if (result.backupPath) {
|
|
147
|
+
p.log.info(chalk.dim(` Backup: ${result.backupPath}`));
|
|
148
|
+
}
|
|
115
149
|
}
|
|
116
150
|
else {
|
|
117
151
|
p.log.error(`Failed to remove ${skillName}`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, cpSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { getSkillDir } from "./fs.js";
|
|
5
|
+
const BACKUP_DIR = join(homedir(), ".arcana", "backups");
|
|
6
|
+
const MAX_BACKUPS_PER_SKILL = 10;
|
|
7
|
+
export function getBackupDir() {
|
|
8
|
+
return BACKUP_DIR;
|
|
9
|
+
}
|
|
10
|
+
export function backupSkill(skillName) {
|
|
11
|
+
const skillDir = getSkillDir(skillName);
|
|
12
|
+
if (!existsSync(skillDir))
|
|
13
|
+
return null;
|
|
14
|
+
mkdirSync(BACKUP_DIR, { recursive: true });
|
|
15
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
16
|
+
const dest = join(BACKUP_DIR, `${skillName}_${timestamp}`);
|
|
17
|
+
cpSync(skillDir, dest, { recursive: true });
|
|
18
|
+
pruneOldBackups(skillName);
|
|
19
|
+
return dest;
|
|
20
|
+
}
|
|
21
|
+
export function pruneOldBackups(skillName) {
|
|
22
|
+
if (!existsSync(BACKUP_DIR))
|
|
23
|
+
return;
|
|
24
|
+
const prefix = `${skillName}_`;
|
|
25
|
+
const entries = readdirSync(BACKUP_DIR)
|
|
26
|
+
.filter((d) => d.startsWith(prefix))
|
|
27
|
+
.sort();
|
|
28
|
+
while (entries.length > MAX_BACKUPS_PER_SKILL) {
|
|
29
|
+
const oldest = entries.shift();
|
|
30
|
+
rmSync(join(BACKUP_DIR, oldest), { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|