agent-skill-manager 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CODE_OF_CONDUCT.md +59 -0
- package/CONTRIBUTING.md +92 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/RELEASE_NOTES.md +31 -0
- package/SECURITY.md +43 -0
- package/bin/skill-manager.ts +46 -0
- package/bun.lock +204 -0
- package/docs/ARCHITECTURE.md +60 -0
- package/docs/CHANGELOG.md +22 -0
- package/docs/DEPLOYMENT.md +52 -0
- package/docs/DEVELOPMENT.md +64 -0
- package/package.json +44 -0
- package/src/config.ts +109 -0
- package/src/index.ts +324 -0
- package/src/scanner.ts +165 -0
- package/src/uninstaller.ts +225 -0
- package/src/utils/colors.ts +16 -0
- package/src/utils/frontmatter.ts +87 -0
- package/src/utils/types.ts +57 -0
- package/src/utils/version.ts +20 -0
- package/src/views/config.ts +147 -0
- package/src/views/confirm.ts +105 -0
- package/src/views/dashboard.ts +252 -0
- package/src/views/help.ts +83 -0
- package/src/views/skill-detail.ts +114 -0
- package/src/views/skill-list.ts +122 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const theme = {
|
|
2
|
+
bg: "#1a1b26",
|
|
3
|
+
bgAlt: "#24283b",
|
|
4
|
+
fg: "#c0caf5",
|
|
5
|
+
fgDim: "#565f89",
|
|
6
|
+
accent: "#7aa2f7",
|
|
7
|
+
accentAlt: "#bb9af7",
|
|
8
|
+
green: "#9ece6a",
|
|
9
|
+
red: "#f7768e",
|
|
10
|
+
yellow: "#e0af68",
|
|
11
|
+
cyan: "#7dcfff",
|
|
12
|
+
orange: "#ff9e64",
|
|
13
|
+
border: "#3b4261",
|
|
14
|
+
borderFocus: "#7aa2f7",
|
|
15
|
+
white: "#FFFFFF",
|
|
16
|
+
} as const;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export function parseFrontmatter(content: string): Record<string, string> {
|
|
2
|
+
const result: Record<string, string> = {};
|
|
3
|
+
const lines = content.split("\n");
|
|
4
|
+
|
|
5
|
+
let inFrontmatter = false;
|
|
6
|
+
let foundFirst = false;
|
|
7
|
+
let currentKey: string | null = null;
|
|
8
|
+
let currentValue: string[] = [];
|
|
9
|
+
let multilineMode: "none" | "literal" | "folded" = "none";
|
|
10
|
+
let baseIndent = -1;
|
|
11
|
+
|
|
12
|
+
function flushKey() {
|
|
13
|
+
if (currentKey) {
|
|
14
|
+
const joined = currentValue.join(" ").trim();
|
|
15
|
+
if (joined) result[currentKey] = joined;
|
|
16
|
+
currentKey = null;
|
|
17
|
+
currentValue = [];
|
|
18
|
+
multilineMode = "none";
|
|
19
|
+
baseIndent = -1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
if (line.trim() === "---") {
|
|
25
|
+
if (!foundFirst) {
|
|
26
|
+
foundFirst = true;
|
|
27
|
+
inFrontmatter = true;
|
|
28
|
+
continue;
|
|
29
|
+
} else {
|
|
30
|
+
flushKey();
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!inFrontmatter) continue;
|
|
36
|
+
|
|
37
|
+
// Check if this is a continuation line (indented) for a multiline value
|
|
38
|
+
if (multilineMode !== "none" && currentKey) {
|
|
39
|
+
const stripped = line.replace(/^\s*/, "");
|
|
40
|
+
const indent = line.length - stripped.length;
|
|
41
|
+
|
|
42
|
+
// Continuation line: must be indented more than the key
|
|
43
|
+
if (indent > 0 && stripped.length > 0) {
|
|
44
|
+
if (baseIndent === -1) baseIndent = indent;
|
|
45
|
+
currentValue.push(stripped);
|
|
46
|
+
continue;
|
|
47
|
+
} else if (stripped.length === 0) {
|
|
48
|
+
// Blank line inside multiline — skip it
|
|
49
|
+
continue;
|
|
50
|
+
} else {
|
|
51
|
+
// Not indented — end of multiline, fall through to parse as new key
|
|
52
|
+
flushKey();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Try to match a key: value line
|
|
57
|
+
const match = line.match(/^(\w[\w-]*):\s*(.*?)\s*$/);
|
|
58
|
+
if (match) {
|
|
59
|
+
flushKey();
|
|
60
|
+
const key = match[1];
|
|
61
|
+
const rawValue = match[2];
|
|
62
|
+
|
|
63
|
+
if (rawValue === "|" || rawValue === ">") {
|
|
64
|
+
// Multiline block scalar
|
|
65
|
+
currentKey = key;
|
|
66
|
+
currentValue = [];
|
|
67
|
+
multilineMode = rawValue === "|" ? "literal" : "folded";
|
|
68
|
+
} else if (
|
|
69
|
+
rawValue === "|+" ||
|
|
70
|
+
rawValue === ">+" ||
|
|
71
|
+
rawValue === "|-" ||
|
|
72
|
+
rawValue === ">-"
|
|
73
|
+
) {
|
|
74
|
+
currentKey = key;
|
|
75
|
+
currentValue = [];
|
|
76
|
+
multilineMode = rawValue.startsWith("|") ? "literal" : "folded";
|
|
77
|
+
} else {
|
|
78
|
+
// Single-line value — strip surrounding quotes
|
|
79
|
+
const cleaned = rawValue.replace(/^["']|["']$/g, "");
|
|
80
|
+
if (cleaned) result[key] = cleaned;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
flushKey();
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ─── Skill Types ────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface SkillInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
description: string;
|
|
7
|
+
dirName: string;
|
|
8
|
+
path: string;
|
|
9
|
+
originalPath: string;
|
|
10
|
+
location: string;
|
|
11
|
+
scope: "global" | "project";
|
|
12
|
+
provider: string;
|
|
13
|
+
providerLabel: string;
|
|
14
|
+
isSymlink: boolean;
|
|
15
|
+
symlinkTarget: string | null;
|
|
16
|
+
fileCount: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RemovalPlan {
|
|
20
|
+
directories: Array<{ path: string; isSymlink: boolean }>;
|
|
21
|
+
ruleFiles: string[];
|
|
22
|
+
agentsBlocks: Array<{ file: string; skillName: string }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Config Types ───────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface ProviderConfig {
|
|
28
|
+
name: string;
|
|
29
|
+
label: string;
|
|
30
|
+
global: string;
|
|
31
|
+
project: string;
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CustomPathConfig {
|
|
36
|
+
path: string;
|
|
37
|
+
label: string;
|
|
38
|
+
scope: "global" | "project";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UserPreferences {
|
|
42
|
+
defaultScope: Scope;
|
|
43
|
+
defaultSort: SortBy;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AppConfig {
|
|
47
|
+
version: number;
|
|
48
|
+
providers: ProviderConfig[];
|
|
49
|
+
customPaths: CustomPathConfig[];
|
|
50
|
+
preferences: UserPreferences;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── UI Types ───────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export type Scope = "global" | "project" | "both";
|
|
56
|
+
export type SortBy = "name" | "version" | "location";
|
|
57
|
+
export type ViewState = "dashboard" | "detail" | "confirm" | "help" | "config";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
|
|
3
|
+
const pkg = await Bun.file(
|
|
4
|
+
resolve(import.meta.dir, "../../package.json"),
|
|
5
|
+
).json();
|
|
6
|
+
|
|
7
|
+
let commitHash = "unknown";
|
|
8
|
+
try {
|
|
9
|
+
const proc = Bun.spawn(["git", "rev-parse", "--short", "HEAD"], {
|
|
10
|
+
stdout: "pipe",
|
|
11
|
+
stderr: "pipe",
|
|
12
|
+
});
|
|
13
|
+
commitHash = (await new Response(proc.stdout).text()).trim() || "unknown";
|
|
14
|
+
} catch {
|
|
15
|
+
// Not in a git repo or git not available
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const VERSION = pkg.version as string;
|
|
19
|
+
export const COMMIT_HASH = commitHash;
|
|
20
|
+
export const VERSION_STRING = `v${VERSION} (${COMMIT_HASH})`;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
SelectRenderable,
|
|
5
|
+
SelectRenderableEvents,
|
|
6
|
+
} from "@opentui/core";
|
|
7
|
+
import type { RenderContext } from "@opentui/core";
|
|
8
|
+
import { theme } from "../utils/colors";
|
|
9
|
+
import { getConfigPath } from "../config";
|
|
10
|
+
import type { AppConfig } from "../utils/types";
|
|
11
|
+
|
|
12
|
+
function providerRow(
|
|
13
|
+
label: string,
|
|
14
|
+
globalPath: string,
|
|
15
|
+
projectPath: string,
|
|
16
|
+
enabled: boolean,
|
|
17
|
+
): string {
|
|
18
|
+
const status = enabled ? "\u2714 ON " : "\u2718 OFF";
|
|
19
|
+
const statusColor = enabled ? "on" : "off";
|
|
20
|
+
const name = label.length > 14 ? label.slice(0, 14) : label;
|
|
21
|
+
return `${status} ${name.padEnd(15)} ${globalPath}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createConfigView(
|
|
25
|
+
ctx: RenderContext,
|
|
26
|
+
config: AppConfig,
|
|
27
|
+
onClose: (updatedConfig: AppConfig) => void,
|
|
28
|
+
): BoxRenderable {
|
|
29
|
+
const boxWidth = 72;
|
|
30
|
+
const providerCount = config.providers.length;
|
|
31
|
+
const boxHeight = Math.min(providerCount + 14, 30);
|
|
32
|
+
const top = Math.max(0, Math.floor((ctx.height - boxHeight) / 2));
|
|
33
|
+
const left = Math.max(0, Math.floor((ctx.width - boxWidth) / 2));
|
|
34
|
+
|
|
35
|
+
// Clone config so mutations don't affect original until close
|
|
36
|
+
const editConfig: AppConfig = JSON.parse(JSON.stringify(config));
|
|
37
|
+
|
|
38
|
+
const container = new BoxRenderable(ctx, {
|
|
39
|
+
id: "config-overlay",
|
|
40
|
+
border: true,
|
|
41
|
+
borderStyle: "rounded",
|
|
42
|
+
borderColor: theme.accent,
|
|
43
|
+
backgroundColor: theme.bgAlt,
|
|
44
|
+
title: " Configuration ",
|
|
45
|
+
titleAlignment: "center",
|
|
46
|
+
padding: 1,
|
|
47
|
+
flexDirection: "column",
|
|
48
|
+
gap: 1,
|
|
49
|
+
width: boxWidth,
|
|
50
|
+
height: boxHeight,
|
|
51
|
+
position: "absolute",
|
|
52
|
+
top,
|
|
53
|
+
left,
|
|
54
|
+
zIndex: 100,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Config file path
|
|
58
|
+
const pathText = new TextRenderable(ctx, {
|
|
59
|
+
id: "config-path",
|
|
60
|
+
content: `Config: ${getConfigPath()}`,
|
|
61
|
+
fg: theme.fgDim,
|
|
62
|
+
});
|
|
63
|
+
container.add(pathText);
|
|
64
|
+
|
|
65
|
+
// Section header
|
|
66
|
+
const headerText = new TextRenderable(ctx, {
|
|
67
|
+
id: "config-header",
|
|
68
|
+
content: "Providers (Enter to toggle, e to edit config file):",
|
|
69
|
+
fg: theme.yellow,
|
|
70
|
+
});
|
|
71
|
+
container.add(headerText);
|
|
72
|
+
|
|
73
|
+
// Build provider options
|
|
74
|
+
function buildProviderOptions() {
|
|
75
|
+
return editConfig.providers.map((p) => ({
|
|
76
|
+
name: providerRow(p.label, p.global, p.project, p.enabled),
|
|
77
|
+
description: `Project: ${p.project}`,
|
|
78
|
+
value: p.name,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const select = new SelectRenderable(ctx, {
|
|
83
|
+
id: "config-select",
|
|
84
|
+
width: "100%",
|
|
85
|
+
flexGrow: 1,
|
|
86
|
+
options: buildProviderOptions(),
|
|
87
|
+
wrapSelection: true,
|
|
88
|
+
showDescription: true,
|
|
89
|
+
showScrollIndicator: true,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
(select as any).on(SelectRenderableEvents.ITEM_SELECTED, (index: number) => {
|
|
93
|
+
// Toggle enabled state on Enter
|
|
94
|
+
if (index >= 0 && index < editConfig.providers.length) {
|
|
95
|
+
editConfig.providers[index].enabled =
|
|
96
|
+
!editConfig.providers[index].enabled;
|
|
97
|
+
select.options = buildProviderOptions();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
container.add(select);
|
|
102
|
+
|
|
103
|
+
// Custom paths section
|
|
104
|
+
if (editConfig.customPaths.length > 0) {
|
|
105
|
+
const customHeader = new TextRenderable(ctx, {
|
|
106
|
+
id: "config-custom-header",
|
|
107
|
+
content: "\nCustom Paths:",
|
|
108
|
+
fg: theme.yellow,
|
|
109
|
+
});
|
|
110
|
+
container.add(customHeader);
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < editConfig.customPaths.length; i++) {
|
|
113
|
+
const cp = editConfig.customPaths[i];
|
|
114
|
+
const cpText = new TextRenderable(ctx, {
|
|
115
|
+
id: `config-custom-${i}`,
|
|
116
|
+
content: ` ${cp.label}: ${cp.path} (${cp.scope})`,
|
|
117
|
+
fg: theme.fg,
|
|
118
|
+
});
|
|
119
|
+
container.add(cpText);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Preferences
|
|
124
|
+
const prefText = new TextRenderable(ctx, {
|
|
125
|
+
id: "config-prefs",
|
|
126
|
+
content: `\nDefaults: scope=${editConfig.preferences.defaultScope}, sort=${editConfig.preferences.defaultSort}`,
|
|
127
|
+
fg: theme.fgDim,
|
|
128
|
+
});
|
|
129
|
+
container.add(prefText);
|
|
130
|
+
|
|
131
|
+
// Footer
|
|
132
|
+
const footer = new TextRenderable(ctx, {
|
|
133
|
+
id: "config-footer",
|
|
134
|
+
content: " Enter Toggle e Edit file Esc Save & close",
|
|
135
|
+
fg: theme.fgDim,
|
|
136
|
+
});
|
|
137
|
+
container.add(footer);
|
|
138
|
+
|
|
139
|
+
// Expose select for keyboard handling and store onClose callback
|
|
140
|
+
(container as any).__configSelect = select;
|
|
141
|
+
(container as any).__editConfig = editConfig;
|
|
142
|
+
(container as any).__onClose = onClose;
|
|
143
|
+
|
|
144
|
+
select.focus();
|
|
145
|
+
|
|
146
|
+
return container;
|
|
147
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
SelectRenderable,
|
|
5
|
+
SelectRenderableEvents,
|
|
6
|
+
} from "@opentui/core";
|
|
7
|
+
import type { RenderContext } from "@opentui/core";
|
|
8
|
+
import { theme } from "../utils/colors";
|
|
9
|
+
import type { SkillInfo } from "../utils/types";
|
|
10
|
+
|
|
11
|
+
export interface ConfirmResult {
|
|
12
|
+
confirmed: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createConfirmView(
|
|
16
|
+
ctx: RenderContext,
|
|
17
|
+
skill: SkillInfo,
|
|
18
|
+
targets: string[],
|
|
19
|
+
onResult: (result: ConfirmResult) => void,
|
|
20
|
+
): BoxRenderable {
|
|
21
|
+
const boxWidth = 60;
|
|
22
|
+
const boxHeight = Math.min(targets.length + 10, 24);
|
|
23
|
+
const top = Math.max(0, Math.floor((ctx.height - boxHeight) / 2));
|
|
24
|
+
const left = Math.max(0, Math.floor((ctx.width - boxWidth) / 2));
|
|
25
|
+
|
|
26
|
+
const container = new BoxRenderable(ctx, {
|
|
27
|
+
id: "confirm-overlay",
|
|
28
|
+
border: true,
|
|
29
|
+
borderStyle: "rounded",
|
|
30
|
+
borderColor: theme.red,
|
|
31
|
+
backgroundColor: theme.bgAlt,
|
|
32
|
+
title: ` Uninstall: ${skill.name} `,
|
|
33
|
+
titleAlignment: "center",
|
|
34
|
+
padding: 1,
|
|
35
|
+
flexDirection: "column",
|
|
36
|
+
gap: 1,
|
|
37
|
+
width: boxWidth,
|
|
38
|
+
height: boxHeight,
|
|
39
|
+
position: "absolute",
|
|
40
|
+
top,
|
|
41
|
+
left,
|
|
42
|
+
zIndex: 100,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const header = new TextRenderable(ctx, {
|
|
46
|
+
content: "The following will be removed:",
|
|
47
|
+
fg: theme.yellow,
|
|
48
|
+
});
|
|
49
|
+
container.add(header);
|
|
50
|
+
|
|
51
|
+
const targetBox = new BoxRenderable(ctx, {
|
|
52
|
+
id: "confirm-targets",
|
|
53
|
+
flexDirection: "column",
|
|
54
|
+
width: "100%",
|
|
55
|
+
paddingLeft: 1,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (targets.length === 0) {
|
|
59
|
+
const noTargets = new TextRenderable(ctx, {
|
|
60
|
+
content: " (no files found to remove)",
|
|
61
|
+
fg: theme.fgDim,
|
|
62
|
+
});
|
|
63
|
+
targetBox.add(noTargets);
|
|
64
|
+
} else {
|
|
65
|
+
for (let i = 0; i < targets.length; i++) {
|
|
66
|
+
const targetText = new TextRenderable(ctx, {
|
|
67
|
+
id: `confirm-target-${i}`,
|
|
68
|
+
content: `✗ ${targets[i]}`,
|
|
69
|
+
fg: theme.red,
|
|
70
|
+
});
|
|
71
|
+
targetBox.add(targetText);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
container.add(targetBox);
|
|
75
|
+
|
|
76
|
+
const spacer = new TextRenderable(ctx, {
|
|
77
|
+
content: "",
|
|
78
|
+
height: 1,
|
|
79
|
+
});
|
|
80
|
+
container.add(spacer);
|
|
81
|
+
|
|
82
|
+
const select = new SelectRenderable(ctx, {
|
|
83
|
+
id: "confirm-select",
|
|
84
|
+
width: 30,
|
|
85
|
+
height: 4,
|
|
86
|
+
options: [
|
|
87
|
+
{ name: "Yes, uninstall", description: "", value: "yes" },
|
|
88
|
+
{ name: "Cancel", description: "", value: "cancel" },
|
|
89
|
+
],
|
|
90
|
+
wrapSelection: true,
|
|
91
|
+
selectedIndex: 1,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
(select as any).on(
|
|
95
|
+
SelectRenderableEvents.ITEM_SELECTED,
|
|
96
|
+
(_index: number, option: any) => {
|
|
97
|
+
onResult({ confirmed: option.value === "yes" });
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
container.add(select);
|
|
102
|
+
select.focus();
|
|
103
|
+
|
|
104
|
+
return container;
|
|
105
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
TabSelectRenderable,
|
|
5
|
+
TabSelectRenderableEvents,
|
|
6
|
+
TextareaRenderable,
|
|
7
|
+
ASCIIFontRenderable,
|
|
8
|
+
} from "@opentui/core";
|
|
9
|
+
import type { RenderContext } from "@opentui/core";
|
|
10
|
+
import { theme } from "../utils/colors";
|
|
11
|
+
import type { SkillInfo, Scope, SortBy, AppConfig } from "../utils/types";
|
|
12
|
+
|
|
13
|
+
const SORT_OPTIONS: SortBy[] = ["name", "version", "location"];
|
|
14
|
+
|
|
15
|
+
export interface DashboardComponents {
|
|
16
|
+
root: BoxRenderable;
|
|
17
|
+
banner: ASCIIFontRenderable;
|
|
18
|
+
scopeTabs: TabSelectRenderable;
|
|
19
|
+
searchInput: TextareaRenderable;
|
|
20
|
+
statsBar: BoxRenderable;
|
|
21
|
+
contentArea: BoxRenderable;
|
|
22
|
+
footerText: TextRenderable;
|
|
23
|
+
updateStats: (skills: SkillInfo[]) => void;
|
|
24
|
+
updateSortLabel: (by: SortBy) => void;
|
|
25
|
+
updateProviderInfo: (config: AppConfig) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildScopeDescription(
|
|
29
|
+
config: AppConfig,
|
|
30
|
+
type: "global" | "project",
|
|
31
|
+
): string {
|
|
32
|
+
const labels = config.providers.filter((p) => p.enabled).map((p) => p.label);
|
|
33
|
+
if (labels.length <= 3) return labels.join(", ");
|
|
34
|
+
return labels.slice(0, 2).join(", ") + ` +${labels.length - 2}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createDashboard(
|
|
38
|
+
ctx: RenderContext,
|
|
39
|
+
config: AppConfig,
|
|
40
|
+
onScopeChange: (scope: Scope) => void,
|
|
41
|
+
): DashboardComponents {
|
|
42
|
+
// Root layout
|
|
43
|
+
const root = new BoxRenderable(ctx, {
|
|
44
|
+
id: "dashboard-root",
|
|
45
|
+
flexDirection: "column",
|
|
46
|
+
width: "100%",
|
|
47
|
+
height: "100%",
|
|
48
|
+
padding: 1,
|
|
49
|
+
gap: 0,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ASCII banner
|
|
53
|
+
const banner = new ASCIIFontRenderable(ctx, {
|
|
54
|
+
id: "banner",
|
|
55
|
+
text: "skill-manager",
|
|
56
|
+
color: theme.accent,
|
|
57
|
+
});
|
|
58
|
+
root.add(banner);
|
|
59
|
+
|
|
60
|
+
// Stats bar (moved to top, right after banner)
|
|
61
|
+
const statsBar = new BoxRenderable(ctx, {
|
|
62
|
+
id: "stats-bar",
|
|
63
|
+
border: true,
|
|
64
|
+
borderStyle: "rounded",
|
|
65
|
+
borderColor: theme.border,
|
|
66
|
+
title: " Stats ",
|
|
67
|
+
titleAlignment: "left",
|
|
68
|
+
flexDirection: "row",
|
|
69
|
+
width: "100%",
|
|
70
|
+
height: 3,
|
|
71
|
+
paddingLeft: 1,
|
|
72
|
+
paddingRight: 1,
|
|
73
|
+
gap: 3,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const totalStat = new TextRenderable(ctx, {
|
|
77
|
+
id: "stat-total",
|
|
78
|
+
content: "Total: 0",
|
|
79
|
+
fg: theme.fg,
|
|
80
|
+
});
|
|
81
|
+
const globalStat = new TextRenderable(ctx, {
|
|
82
|
+
id: "stat-global",
|
|
83
|
+
content: "Global: 0",
|
|
84
|
+
fg: theme.cyan,
|
|
85
|
+
});
|
|
86
|
+
const projectStat = new TextRenderable(ctx, {
|
|
87
|
+
id: "stat-project",
|
|
88
|
+
content: "Project: 0",
|
|
89
|
+
fg: theme.green,
|
|
90
|
+
});
|
|
91
|
+
const symlinkStat = new TextRenderable(ctx, {
|
|
92
|
+
id: "stat-symlinks",
|
|
93
|
+
content: "Symlinks: 0",
|
|
94
|
+
fg: theme.yellow,
|
|
95
|
+
});
|
|
96
|
+
const providerStat = new TextRenderable(ctx, {
|
|
97
|
+
id: "stat-providers",
|
|
98
|
+
content: "Providers: 0",
|
|
99
|
+
fg: theme.accentAlt,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Sort info with separator
|
|
103
|
+
const sortSeparator = new TextRenderable(ctx, {
|
|
104
|
+
id: "sort-sep",
|
|
105
|
+
content: "\u2502",
|
|
106
|
+
fg: theme.border,
|
|
107
|
+
});
|
|
108
|
+
const sortLabel = new TextRenderable(ctx, {
|
|
109
|
+
id: "sort-label",
|
|
110
|
+
content: buildSortLabel("name"),
|
|
111
|
+
fg: theme.fgDim,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
statsBar.add(totalStat);
|
|
115
|
+
statsBar.add(globalStat);
|
|
116
|
+
statsBar.add(projectStat);
|
|
117
|
+
statsBar.add(symlinkStat);
|
|
118
|
+
statsBar.add(providerStat);
|
|
119
|
+
statsBar.add(sortSeparator);
|
|
120
|
+
statsBar.add(sortLabel);
|
|
121
|
+
root.add(statsBar);
|
|
122
|
+
|
|
123
|
+
// Scope tabs row
|
|
124
|
+
const tabRow = new BoxRenderable(ctx, {
|
|
125
|
+
id: "tab-row",
|
|
126
|
+
flexDirection: "row",
|
|
127
|
+
width: "100%",
|
|
128
|
+
height: 1,
|
|
129
|
+
alignItems: "center",
|
|
130
|
+
gap: 2,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const globalDesc = buildScopeDescription(config, "global");
|
|
134
|
+
const projectDesc = buildScopeDescription(config, "project");
|
|
135
|
+
|
|
136
|
+
const scopeTabs = new TabSelectRenderable(ctx, {
|
|
137
|
+
id: "scope-tabs",
|
|
138
|
+
options: [
|
|
139
|
+
{ name: "Global", description: globalDesc, value: "global" },
|
|
140
|
+
{ name: "Project", description: projectDesc, value: "project" },
|
|
141
|
+
{ name: "Both", description: "All locations", value: "both" },
|
|
142
|
+
],
|
|
143
|
+
tabWidth: 12,
|
|
144
|
+
showUnderline: false,
|
|
145
|
+
wrapSelection: true,
|
|
146
|
+
height: 1,
|
|
147
|
+
width: 42,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
(scopeTabs as any).on(
|
|
151
|
+
TabSelectRenderableEvents.ITEM_SELECTED,
|
|
152
|
+
(_index: number, option: any) => {
|
|
153
|
+
onScopeChange(option.value as Scope);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
tabRow.add(scopeTabs);
|
|
158
|
+
root.add(tabRow);
|
|
159
|
+
|
|
160
|
+
// Search box
|
|
161
|
+
const searchBox = new BoxRenderable(ctx, {
|
|
162
|
+
id: "search-box",
|
|
163
|
+
border: true,
|
|
164
|
+
borderStyle: "rounded",
|
|
165
|
+
borderColor: theme.border,
|
|
166
|
+
title: " Filter ",
|
|
167
|
+
titleAlignment: "left",
|
|
168
|
+
width: "100%",
|
|
169
|
+
height: 3,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const searchInput = new TextareaRenderable(ctx, {
|
|
173
|
+
id: "search-input",
|
|
174
|
+
width: "100%",
|
|
175
|
+
height: 1,
|
|
176
|
+
placeholder: "type to search...",
|
|
177
|
+
placeholderColor: theme.fgDim,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
searchBox.add(searchInput);
|
|
181
|
+
root.add(searchBox);
|
|
182
|
+
|
|
183
|
+
// Content area (skill list gets inserted here)
|
|
184
|
+
const contentArea = new BoxRenderable(ctx, {
|
|
185
|
+
id: "content-area",
|
|
186
|
+
flexDirection: "column",
|
|
187
|
+
width: "100%",
|
|
188
|
+
flexGrow: 1,
|
|
189
|
+
minHeight: 6,
|
|
190
|
+
});
|
|
191
|
+
root.add(contentArea);
|
|
192
|
+
|
|
193
|
+
// Footer
|
|
194
|
+
const footerText = new TextRenderable(ctx, {
|
|
195
|
+
id: "footer",
|
|
196
|
+
content:
|
|
197
|
+
" \u2191/\u2193 Navigate Enter View d Uninstall / Filter Tab Scope s Sort r Refresh c Config q Quit ? Help",
|
|
198
|
+
fg: theme.fgDim,
|
|
199
|
+
height: 1,
|
|
200
|
+
width: "100%",
|
|
201
|
+
});
|
|
202
|
+
root.add(footerText);
|
|
203
|
+
|
|
204
|
+
function buildSortLabel(by: SortBy): string {
|
|
205
|
+
return (
|
|
206
|
+
"(s) Sort: " +
|
|
207
|
+
SORT_OPTIONS.map((o) => (o === by ? `[${o}]` : o)).join(" ")
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function updateStats(skills: SkillInfo[]) {
|
|
212
|
+
const total = skills.length;
|
|
213
|
+
const unique = new Set(skills.map((s) => s.dirName)).size;
|
|
214
|
+
const globalCount = skills.filter((s) => s.scope === "global").length;
|
|
215
|
+
const projectCount = skills.filter((s) => s.scope === "project").length;
|
|
216
|
+
const symlinks = skills.filter((s) => s.isSymlink).length;
|
|
217
|
+
const providers = new Set(skills.map((s) => s.provider)).size;
|
|
218
|
+
|
|
219
|
+
totalStat.content = `Total: ${total} (${unique} unique)`;
|
|
220
|
+
globalStat.content = `Global: ${globalCount}`;
|
|
221
|
+
projectStat.content = `Project: ${projectCount}`;
|
|
222
|
+
symlinkStat.content = `Symlinks: ${symlinks}`;
|
|
223
|
+
providerStat.content = `Providers: ${providers}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function updateSortLabel(by: SortBy) {
|
|
227
|
+
sortLabel.content = buildSortLabel(by);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function updateProviderInfo(newConfig: AppConfig) {
|
|
231
|
+
const newGlobalDesc = buildScopeDescription(newConfig, "global");
|
|
232
|
+
const newProjectDesc = buildScopeDescription(newConfig, "project");
|
|
233
|
+
scopeTabs.options = [
|
|
234
|
+
{ name: "Global", description: newGlobalDesc, value: "global" },
|
|
235
|
+
{ name: "Project", description: newProjectDesc, value: "project" },
|
|
236
|
+
{ name: "Both", description: "All locations", value: "both" },
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
root,
|
|
242
|
+
banner,
|
|
243
|
+
scopeTabs,
|
|
244
|
+
searchInput,
|
|
245
|
+
statsBar,
|
|
246
|
+
contentArea,
|
|
247
|
+
footerText,
|
|
248
|
+
updateStats,
|
|
249
|
+
updateSortLabel,
|
|
250
|
+
updateProviderInfo,
|
|
251
|
+
};
|
|
252
|
+
}
|