@skilly-hand/skilly-hand 0.3.0 → 0.5.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/CHANGELOG.md +33 -0
- package/README.md +10 -0
- package/package.json +4 -1
- package/packages/cli/src/bin.js +383 -137
- package/packages/core/src/index.js +42 -8
- package/packages/core/src/terminal.js +76 -69
- package/packages/core/src/ui/brand.js +31 -0
- package/packages/core/src/ui/index.js +3 -0
- package/packages/core/src/ui/layout.js +289 -0
- package/packages/core/src/ui/theme.js +120 -0
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { copySkillTo, loadAllSkills, renderAgentsMarkdown, verifyCatalogFiles } from "../../catalog/src/index.js";
|
|
4
4
|
import { detectProject, inspectProjectFiles } from "../../detectors/src/index.js";
|
|
5
5
|
|
|
6
|
-
const DEFAULT_AGENTS = ["codex", "claude", "cursor", "gemini", "copilot"];
|
|
6
|
+
export const DEFAULT_AGENTS = ["codex", "claude", "cursor", "gemini", "copilot"];
|
|
7
7
|
const MANAGED_MARKER = "<!-- Managed by skilly-hand.";
|
|
8
8
|
|
|
9
9
|
function uniq(values) {
|
|
@@ -44,6 +44,37 @@ function parseTags(input) {
|
|
|
44
44
|
return uniq((input || []).flatMap((value) => String(value).split(",")).map((value) => value.trim()).filter(Boolean));
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function parseSkillIds(input) {
|
|
48
|
+
return uniq((input || []).flatMap((value) => String(value).split(",")).map((value) => value.trim()).filter(Boolean));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveSkillSelectionByIds({ catalog, selectedSkillIds = [] }) {
|
|
52
|
+
const ids = parseSkillIds(selectedSkillIds);
|
|
53
|
+
const portableById = new Map(
|
|
54
|
+
catalog
|
|
55
|
+
.filter((skill) => skill.portable)
|
|
56
|
+
.map((skill) => [skill.id, skill])
|
|
57
|
+
);
|
|
58
|
+
const allById = new Map(catalog.map((skill) => [skill.id, skill]));
|
|
59
|
+
|
|
60
|
+
const invalid = [];
|
|
61
|
+
for (const id of ids) {
|
|
62
|
+
if (!allById.has(id)) {
|
|
63
|
+
invalid.push(`Unknown skill id: ${id}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!portableById.has(id)) {
|
|
67
|
+
invalid.push(`Skill is not portable: ${id}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (invalid.length > 0) {
|
|
72
|
+
throw new Error(invalid.join("; "));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return ids.map((id) => portableById.get(id)).sort((a, b) => a.id.localeCompare(b.id));
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
export function resolveSkillSelection({ catalog, detections, includeTags = [], excludeTags = [] }) {
|
|
48
79
|
const coreSkills = catalog.filter((skill) => skill.tags.includes("core"));
|
|
49
80
|
const requested = new Set(coreSkills.map((skill) => skill.id));
|
|
@@ -168,17 +199,20 @@ export async function installProject({
|
|
|
168
199
|
agents,
|
|
169
200
|
dryRun = false,
|
|
170
201
|
includeTags = [],
|
|
171
|
-
excludeTags = []
|
|
202
|
+
excludeTags = [],
|
|
203
|
+
selectedSkillIds
|
|
172
204
|
}) {
|
|
173
205
|
const selectedAgents = normalizeAgentList(agents);
|
|
174
206
|
const catalog = await loadAllSkills();
|
|
175
207
|
const detections = await detectProject(cwd);
|
|
176
|
-
const skills =
|
|
177
|
-
catalog,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
208
|
+
const skills = selectedSkillIds !== undefined && selectedSkillIds !== null
|
|
209
|
+
? resolveSkillSelectionByIds({ catalog, selectedSkillIds })
|
|
210
|
+
: resolveSkillSelection({
|
|
211
|
+
catalog,
|
|
212
|
+
detections,
|
|
213
|
+
includeTags: parseTags(includeTags),
|
|
214
|
+
excludeTags: parseTags(excludeTags)
|
|
215
|
+
});
|
|
182
216
|
const plan = buildInstallPlan({ cwd, detections, skills, agents: selectedAgents });
|
|
183
217
|
|
|
184
218
|
if (dryRun) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { detectColorLevel, createTheme, createLayout } from "./ui/index.js";
|
|
2
|
+
|
|
1
3
|
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
2
4
|
|
|
3
5
|
function asString(value) {
|
|
@@ -22,6 +24,7 @@ function normalizeBooleanEnv(value) {
|
|
|
22
24
|
return true;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
// Kept as exported API (tests import these directly)
|
|
25
28
|
export function detectColorSupport({ env = process.env, stream = process.stdout } = {}) {
|
|
26
29
|
const noColor = normalizeBooleanEnv(env.NO_COLOR);
|
|
27
30
|
if (noColor) return false;
|
|
@@ -45,63 +48,19 @@ export function detectUnicodeSupport({ env = process.env, stream = process.stdou
|
|
|
45
48
|
return true;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
function
|
|
49
|
-
if (!enabled) {
|
|
50
|
-
const passthrough = (value) => asString(value);
|
|
51
|
-
return {
|
|
52
|
-
reset: passthrough,
|
|
53
|
-
bold: passthrough,
|
|
54
|
-
dim: passthrough,
|
|
55
|
-
cyan: passthrough,
|
|
56
|
-
green: passthrough,
|
|
57
|
-
yellow: passthrough,
|
|
58
|
-
red: passthrough,
|
|
59
|
-
magenta: passthrough
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const wrap = (code) => (value) => `\u001b[${code}m${asString(value)}\u001b[0m`;
|
|
64
|
-
return {
|
|
65
|
-
reset: wrap("0"),
|
|
66
|
-
bold: wrap("1"),
|
|
67
|
-
dim: wrap("2"),
|
|
68
|
-
cyan: wrap("36"),
|
|
69
|
-
green: wrap("32"),
|
|
70
|
-
yellow: wrap("33"),
|
|
71
|
-
red: wrap("31"),
|
|
72
|
-
magenta: wrap("35")
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function renderKeyValue(entries, style) {
|
|
77
|
-
if (!entries || entries.length === 0) return "";
|
|
78
|
-
const normalized = entries.map(([key, value]) => [asString(key), asString(value)]);
|
|
79
|
-
const width = normalized.reduce((max, [key]) => Math.max(max, key.length), 0);
|
|
80
|
-
return normalized
|
|
81
|
-
.map(([key, value]) => `${style.dim(padEndAnsi(key, width))} : ${value}`)
|
|
82
|
-
.join("\n");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function renderList(items, { bullet }) {
|
|
86
|
-
if (!items || items.length === 0) return "";
|
|
87
|
-
return items.map((item) => `${bullet} ${asString(item)}`).join("\n");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function renderTable(columns, rows) {
|
|
51
|
+
function renderPlainTable(columns, rows) {
|
|
91
52
|
if (!columns || columns.length === 0) return "";
|
|
92
|
-
const header = columns.map((
|
|
93
|
-
const matrix = [header, ...rows.map((row) => columns.map((
|
|
94
|
-
const widths = header.map((_,
|
|
95
|
-
matrix.reduce((max, line) => Math.max(max, stripAnsi(line[
|
|
53
|
+
const header = columns.map((c) => c.header);
|
|
54
|
+
const matrix = [header, ...rows.map((row) => columns.map((c) => asString(row[c.key] ?? "")))];
|
|
55
|
+
const widths = header.map((_, i) =>
|
|
56
|
+
matrix.reduce((max, line) => Math.max(max, stripAnsi(line[i]).length), 0)
|
|
96
57
|
);
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const separatorLine = widths.map((width) => "-".repeat(Math.max(3, width))).join(" ");
|
|
58
|
+
const headerLine = header.map((v, i) => padEndAnsi(v, widths[i])).join(" ");
|
|
59
|
+
const sepLine = widths.map((w) => "-".repeat(Math.max(3, w))).join(" ");
|
|
100
60
|
const body = rows.map((row) =>
|
|
101
|
-
columns.map((
|
|
61
|
+
columns.map((c, i) => padEndAnsi(asString(row[c.key] ?? ""), widths[i])).join(" ")
|
|
102
62
|
);
|
|
103
|
-
|
|
104
|
-
return [headerLine, separatorLine, ...body].join("\n");
|
|
63
|
+
return [headerLine, sepLine, ...body].join("\n");
|
|
105
64
|
}
|
|
106
65
|
|
|
107
66
|
function joinBlocks(blocks) {
|
|
@@ -116,66 +75,114 @@ export function createTerminalRenderer({
|
|
|
116
75
|
env = process.env,
|
|
117
76
|
platform = process.platform
|
|
118
77
|
} = {}) {
|
|
119
|
-
const
|
|
78
|
+
const colorLevel = detectColorLevel({ env, stream: stdout });
|
|
79
|
+
const colorEnabled = colorLevel > 0;
|
|
120
80
|
const unicodeEnabled = detectUnicodeSupport({ env, stream: stdout, platform });
|
|
121
|
-
|
|
81
|
+
|
|
82
|
+
const theme = createTheme(colorLevel);
|
|
83
|
+
const layout = createLayout(theme, unicodeEnabled);
|
|
84
|
+
|
|
85
|
+
// Backward-compat style object (callers in tests may use renderer.style.*)
|
|
86
|
+
const style = {
|
|
87
|
+
reset: (v) => asString(v),
|
|
88
|
+
bold: theme.bold,
|
|
89
|
+
dim: theme.dim,
|
|
90
|
+
cyan: theme.primary,
|
|
91
|
+
green: theme.success,
|
|
92
|
+
yellow: theme.warn,
|
|
93
|
+
red: theme.error,
|
|
94
|
+
magenta: theme.magenta,
|
|
95
|
+
};
|
|
96
|
+
|
|
122
97
|
const symbols = unicodeEnabled
|
|
123
98
|
? { info: "i", success: "✓", warn: "!", error: "x", bullet: "•", section: "■" }
|
|
124
99
|
: { info: "i", success: "+", warn: "!", error: "x", bullet: "-", section: "#" };
|
|
125
100
|
|
|
126
101
|
const statusStyles = {
|
|
127
|
-
info:
|
|
128
|
-
success:
|
|
129
|
-
warn:
|
|
130
|
-
error:
|
|
102
|
+
info: theme.info,
|
|
103
|
+
success: theme.success,
|
|
104
|
+
warn: theme.warn,
|
|
105
|
+
error: theme.error,
|
|
131
106
|
};
|
|
132
107
|
|
|
133
108
|
const renderer = {
|
|
134
109
|
colorEnabled,
|
|
110
|
+
colorLevel,
|
|
135
111
|
unicodeEnabled,
|
|
136
112
|
symbols,
|
|
137
113
|
style,
|
|
114
|
+
theme,
|
|
115
|
+
|
|
138
116
|
json(value) {
|
|
139
117
|
return JSON.stringify(value, null, 2);
|
|
140
118
|
},
|
|
119
|
+
|
|
141
120
|
section(title, body = "") {
|
|
142
|
-
const heading =
|
|
121
|
+
const heading = layout.sectionHeader(title);
|
|
143
122
|
const bodyText = asString(body).trimEnd();
|
|
144
123
|
return bodyText ? `${heading}\n${bodyText}` : heading;
|
|
145
124
|
},
|
|
125
|
+
|
|
146
126
|
kv(entries) {
|
|
147
|
-
return
|
|
127
|
+
return layout.kvPanel(entries);
|
|
148
128
|
},
|
|
129
|
+
|
|
149
130
|
list(items, options = {}) {
|
|
150
|
-
|
|
131
|
+
if (!items || items.length === 0) return "";
|
|
132
|
+
const bullet = options.bullet || symbols.bullet;
|
|
133
|
+
return items.map((item) => `${bullet} ${asString(item)}`).join("\n");
|
|
151
134
|
},
|
|
135
|
+
|
|
152
136
|
table(columns, rows) {
|
|
153
|
-
|
|
137
|
+
// Use bordered table when unicode is on; fall back to plain
|
|
138
|
+
if (unicodeEnabled) {
|
|
139
|
+
return layout.borderedTable(columns, rows);
|
|
140
|
+
}
|
|
141
|
+
return renderPlainTable(columns, rows);
|
|
154
142
|
},
|
|
143
|
+
|
|
155
144
|
status(level, message, detail = "") {
|
|
156
|
-
const applied = statusStyles[level] || ((
|
|
145
|
+
const applied = statusStyles[level] || ((v) => v);
|
|
157
146
|
const icon = symbols[level] || symbols.info;
|
|
158
147
|
const main = `${applied(icon)} ${asString(message)}`;
|
|
159
148
|
if (!detail) return main;
|
|
160
|
-
return `${main}\n${
|
|
149
|
+
return `${main}\n${theme.dim(" " + asString(detail))}`;
|
|
161
150
|
},
|
|
151
|
+
|
|
162
152
|
summary(title, items = []) {
|
|
163
153
|
return renderer.section(title, renderer.list(items));
|
|
164
154
|
},
|
|
155
|
+
|
|
165
156
|
nextSteps(steps = []) {
|
|
166
157
|
if (!steps.length) return "";
|
|
167
158
|
return renderer.section("Next Steps", renderer.list(steps));
|
|
168
159
|
},
|
|
160
|
+
|
|
169
161
|
error({ what = "Command failed", why = "", hint = "", exitCode = 1 } = {}) {
|
|
170
162
|
const blocks = [
|
|
171
163
|
renderer.status("error", what),
|
|
172
|
-
why
|
|
173
|
-
hint ? renderer.kv([["How to recover", hint]])
|
|
164
|
+
why ? renderer.kv([["Why", why]]) : "",
|
|
165
|
+
hint ? renderer.kv([["How to recover", hint]]) : "",
|
|
174
166
|
renderer.kv([["Exit code", asString(exitCode)]])
|
|
175
167
|
];
|
|
176
168
|
return joinBlocks(blocks);
|
|
177
169
|
},
|
|
178
|
-
|
|
170
|
+
|
|
171
|
+
// ── New visual methods ─────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
banner(version) {
|
|
174
|
+
return layout.banner(version);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
detectionGrid(detections) {
|
|
178
|
+
return layout.detectionGrid(detections);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
healthBadge(installed) {
|
|
182
|
+
return layout.healthBadge(installed);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
joinBlocks,
|
|
179
186
|
};
|
|
180
187
|
|
|
181
188
|
return {
|
|
@@ -191,6 +198,6 @@ export function createTerminalRenderer({
|
|
|
191
198
|
},
|
|
192
199
|
writeErrorJson(value) {
|
|
193
200
|
stderr.write(`${renderer.json(value)}\n`);
|
|
194
|
-
}
|
|
201
|
+
},
|
|
195
202
|
};
|
|
196
203
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Brand identity: SK logo, app name, tagline.
|
|
2
|
+
|
|
3
|
+
// Unicode block-letter "SK" — 5 rows, uses FULL BLOCK (U+2588) and LOWER HALF BLOCK (U+2584)
|
|
4
|
+
const LOGO_UNICODE = [
|
|
5
|
+
" \u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588",
|
|
6
|
+
"\u2588\u2588 \u2588\u2588 \u2588\u2588",
|
|
7
|
+
" \u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588",
|
|
8
|
+
" \u2588\u2588 \u2588\u2588 \u2588\u2588",
|
|
9
|
+
" \u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
// ASCII fallback — 5 rows, same shape using pipe/underscore
|
|
13
|
+
const LOGO_ASCII = [
|
|
14
|
+
" ____ _ _",
|
|
15
|
+
"/___ |//",
|
|
16
|
+
" ___/ |--\\",
|
|
17
|
+
" | | \\",
|
|
18
|
+
" ___| | \\",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function getBrand() {
|
|
22
|
+
return {
|
|
23
|
+
name: "skilly-hand",
|
|
24
|
+
tagline: "portable AI skill orchestration",
|
|
25
|
+
hint: "npx skilly-hand --help",
|
|
26
|
+
logo: {
|
|
27
|
+
unicode: LOGO_UNICODE,
|
|
28
|
+
ascii: LOGO_ASCII,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Layout primitives: banner, section headers, bordered tables, detection grid, health badge.
|
|
2
|
+
|
|
3
|
+
import { getBrand } from "./brand.js";
|
|
4
|
+
|
|
5
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
6
|
+
|
|
7
|
+
function stripAnsi(value) {
|
|
8
|
+
return String(value).replace(ANSI_RE, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function visLen(value) {
|
|
12
|
+
return stripAnsi(value).length;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function padEnd(value, width) {
|
|
16
|
+
const len = visLen(value);
|
|
17
|
+
if (len >= width) return value;
|
|
18
|
+
return value + " ".repeat(width - len);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function padStart(value, width) {
|
|
22
|
+
const len = visLen(value);
|
|
23
|
+
if (len >= width) return value;
|
|
24
|
+
return " ".repeat(width - len) + value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function truncate(value, maxWidth) {
|
|
28
|
+
const clean = stripAnsi(value);
|
|
29
|
+
if (clean.length <= maxWidth) return value;
|
|
30
|
+
return clean.slice(0, maxWidth - 1) + "…";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Box character sets
|
|
34
|
+
const BOX = {
|
|
35
|
+
// double-line outer (for banner)
|
|
36
|
+
d: {
|
|
37
|
+
tl: "╔", tr: "╗", bl: "╚", br: "╝",
|
|
38
|
+
h: "═", v: "║",
|
|
39
|
+
ml: "╠", mr: "╣",
|
|
40
|
+
},
|
|
41
|
+
// single-line inner (for tables and sections)
|
|
42
|
+
s: {
|
|
43
|
+
tl: "┌", tr: "┐", bl: "└", br: "┘",
|
|
44
|
+
h: "─", v: "│",
|
|
45
|
+
ml: "├", mr: "┤",
|
|
46
|
+
mt: "┬", mb: "┴", x: "┼",
|
|
47
|
+
// double-separator row (header separator in tables)
|
|
48
|
+
dh: "═",
|
|
49
|
+
dl: "╞", dr: "╡", dc: "╪",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ASCII fallback chars
|
|
54
|
+
const BOX_ASCII = {
|
|
55
|
+
d: { tl: "+", tr: "+", bl: "+", br: "+", h: "=", v: "|", ml: "+", mr: "+" },
|
|
56
|
+
s: {
|
|
57
|
+
tl: "+", tr: "+", bl: "+", br: "+",
|
|
58
|
+
h: "-", v: "|",
|
|
59
|
+
ml: "+", mr: "+", mt: "+", mb: "+", x: "+",
|
|
60
|
+
dh: "=", dl: "+", dr: "+", dc: "+",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function createLayout(theme, unicodeEnabled) {
|
|
65
|
+
const box = unicodeEnabled ? BOX : BOX_ASCII;
|
|
66
|
+
const brand = getBrand();
|
|
67
|
+
|
|
68
|
+
// ── Banner ────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function banner(version) {
|
|
71
|
+
const logo = unicodeEnabled ? brand.logo.unicode : brand.logo.ascii;
|
|
72
|
+
const innerW = 56; // inner content width (excluding border chars)
|
|
73
|
+
|
|
74
|
+
const nameStr = brand.name;
|
|
75
|
+
const versionStr = version ? ` v${version}` : "";
|
|
76
|
+
const taglineStr = brand.tagline;
|
|
77
|
+
const hintStr = brand.hint;
|
|
78
|
+
|
|
79
|
+
// right column content
|
|
80
|
+
const rightLines = [
|
|
81
|
+
theme.bold(theme.accent(nameStr)) + theme.muted(versionStr),
|
|
82
|
+
theme.muted(taglineStr),
|
|
83
|
+
"",
|
|
84
|
+
"",
|
|
85
|
+
"",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const logoW = Math.max(...logo.map((l) => stripAnsi(l).length));
|
|
89
|
+
const gap = 3;
|
|
90
|
+
const rightW = innerW - logoW - gap - 2; // 2 for leading spaces
|
|
91
|
+
|
|
92
|
+
const d = box.d;
|
|
93
|
+
const hLine = d.h.repeat(innerW + 2); // +2 for padding spaces inside border
|
|
94
|
+
|
|
95
|
+
const rows = logo.map((logoLine, i) => {
|
|
96
|
+
const logoColored = theme.primary(theme.bold(logoLine));
|
|
97
|
+
const rightLine = rightLines[i] || "";
|
|
98
|
+
const logoLen = logoW;
|
|
99
|
+
const logoPadded = padEnd(logoColored, logoLen + (visLen(logoColored) - stripAnsi(logoColored).length));
|
|
100
|
+
const rightPadded = padEnd(rightLine, rightW);
|
|
101
|
+
const content = " " + logoPadded + " ".repeat(gap) + rightPadded;
|
|
102
|
+
const contentVis = visLen(content);
|
|
103
|
+
const totalW = innerW + 2;
|
|
104
|
+
const paddingNeeded = Math.max(0, totalW - contentVis);
|
|
105
|
+
return d.v + content + " ".repeat(paddingNeeded) + d.v;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// separator row between logo area and hint
|
|
109
|
+
const sepRow = d.ml + d.h.repeat(innerW + 2) + d.mr;
|
|
110
|
+
|
|
111
|
+
const hintContent = " " + theme.muted(hintStr);
|
|
112
|
+
const hintVis = visLen(hintContent);
|
|
113
|
+
const hintPad = Math.max(0, innerW + 2 - hintVis);
|
|
114
|
+
const hintRow = d.v + hintContent + " ".repeat(hintPad) + d.v;
|
|
115
|
+
|
|
116
|
+
const lines = [
|
|
117
|
+
d.tl + hLine + d.tr,
|
|
118
|
+
...rows,
|
|
119
|
+
sepRow,
|
|
120
|
+
hintRow,
|
|
121
|
+
d.bl + hLine + d.br,
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Section header ────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function sectionHeader(title) {
|
|
130
|
+
if (!unicodeEnabled) {
|
|
131
|
+
return theme.bold(title);
|
|
132
|
+
}
|
|
133
|
+
const s = box.s;
|
|
134
|
+
const decorated = theme.primary(s.tl) + theme.primary(s.h.repeat(2)) + " " + theme.bold(title) + " " + theme.primary(s.h.repeat(Math.max(2, 40 - title.length)));
|
|
135
|
+
return decorated;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Bordered table ────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function borderedTable(columns, rows, opts = {}) {
|
|
141
|
+
if (!columns || columns.length === 0) return "";
|
|
142
|
+
const maxColW = opts.maxColWidth || 36;
|
|
143
|
+
|
|
144
|
+
const headers = columns.map((c) => String(c.header));
|
|
145
|
+
const matrix = rows.map((row) =>
|
|
146
|
+
columns.map((c) => truncate(String(row[c.key] ?? ""), maxColW))
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Compute column widths
|
|
150
|
+
const widths = headers.map((h, i) => {
|
|
151
|
+
const contentMax = matrix.reduce((m, row) => Math.max(m, visLen(row[i])), 0);
|
|
152
|
+
return Math.max(visLen(h), contentMax, 3);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!unicodeEnabled) {
|
|
156
|
+
// Plain fallback — original table style
|
|
157
|
+
const headerLine = headers.map((h, i) => padEnd(h, widths[i])).join(" ");
|
|
158
|
+
const sepLine = widths.map((w) => "-".repeat(w)).join(" ");
|
|
159
|
+
const bodyLines = matrix.map((row) =>
|
|
160
|
+
row.map((cell, i) => padEnd(cell, widths[i])).join(" ")
|
|
161
|
+
);
|
|
162
|
+
return [headerLine, sepLine, ...bodyLines].join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const s = box.s;
|
|
166
|
+
|
|
167
|
+
function makeRow(cells, widths, leftC, midC, rightC, padChar = " ") {
|
|
168
|
+
const inner = cells.map((cell, i) => padChar + padEnd(cell, widths[i]) + padChar).join(midC);
|
|
169
|
+
return leftC + inner + rightC;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function makeDivider(widths, leftC, midC, rightC, fillC) {
|
|
173
|
+
const inner = widths.map((w) => fillC.repeat(w + 2)).join(midC);
|
|
174
|
+
return leftC + inner + rightC;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const topBorder = makeDivider(widths, s.tl, s.mt, s.tr, s.h);
|
|
178
|
+
const headerRow = makeRow(headers.map((h, i) => theme.bold(h)), widths, theme.primary(s.v), theme.primary(s.v), theme.primary(s.v));
|
|
179
|
+
const headerSep = makeDivider(widths, s.dl, s.dc, s.dr, s.dh);
|
|
180
|
+
const bodyRows = matrix.map((row) =>
|
|
181
|
+
makeRow(
|
|
182
|
+
row.map((cell, i) => {
|
|
183
|
+
// color first column (usually ID) with accent
|
|
184
|
+
return i === 0 ? theme.accent(cell) : cell;
|
|
185
|
+
}),
|
|
186
|
+
widths, theme.primary(s.v), theme.primary(s.v), theme.primary(s.v)
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
const bottomBorder = makeDivider(widths, s.bl, s.mb, s.br, s.h);
|
|
190
|
+
|
|
191
|
+
// Apply primary color to border chars
|
|
192
|
+
const colorBorder = (line) => {
|
|
193
|
+
// Replace non-letter/space chars with colored versions
|
|
194
|
+
return theme.primary(line);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return [
|
|
198
|
+
colorBorder(topBorder),
|
|
199
|
+
headerRow,
|
|
200
|
+
colorBorder(headerSep),
|
|
201
|
+
...bodyRows,
|
|
202
|
+
colorBorder(bottomBorder),
|
|
203
|
+
].join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Detection grid ────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
function detectionGrid(detections) {
|
|
209
|
+
if (!detections || detections.length === 0) {
|
|
210
|
+
return theme.warn("! No technology signals detected.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const COLS = 3;
|
|
214
|
+
const COL_W = 20;
|
|
215
|
+
|
|
216
|
+
function icon(confidence) {
|
|
217
|
+
if (unicodeEnabled) {
|
|
218
|
+
if (confidence >= 0.85) return theme.success("✔");
|
|
219
|
+
if (confidence >= 0.70) return theme.primary("◆");
|
|
220
|
+
return theme.warn("⚠");
|
|
221
|
+
}
|
|
222
|
+
if (confidence >= 0.85) return theme.success("[+]");
|
|
223
|
+
if (confidence >= 0.70) return theme.primary("[-]");
|
|
224
|
+
return theme.warn("[!]");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function colorTech(tech, confidence) {
|
|
228
|
+
if (confidence >= 0.85) return theme.success(tech);
|
|
229
|
+
if (confidence >= 0.70) return theme.primary(tech);
|
|
230
|
+
return theme.warn(tech);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const cells = detections.map((d) => {
|
|
234
|
+
const pct = Math.round(d.confidence * 100) + "%";
|
|
235
|
+
const ic = icon(d.confidence);
|
|
236
|
+
const name = colorTech(d.technology, d.confidence);
|
|
237
|
+
const pctStr = theme.muted(pct);
|
|
238
|
+
// visual: "✔ React 95%"
|
|
239
|
+
return ic + " " + padEnd(name, COL_W - 5) + " " + padEnd(pctStr, 4);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const rows = [];
|
|
243
|
+
for (let i = 0; i < cells.length; i += COLS) {
|
|
244
|
+
rows.push(cells.slice(i, i + COLS).join(" "));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return rows.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Health badge ──────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
function healthBadge(installed) {
|
|
253
|
+
if (unicodeEnabled) {
|
|
254
|
+
if (installed) {
|
|
255
|
+
const badge = theme.success(theme.bold("██ HEALTHY"));
|
|
256
|
+
const line = theme.primary(" ─────────────────────────────────");
|
|
257
|
+
return badge + line;
|
|
258
|
+
}
|
|
259
|
+
const badge = theme.warn(theme.bold("██ NOT INSTALLED"));
|
|
260
|
+
const line = theme.muted(" ─────────────────────────────────");
|
|
261
|
+
return badge + line;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (installed) {
|
|
265
|
+
return theme.success(theme.bold("[OK] Installation healthy"));
|
|
266
|
+
}
|
|
267
|
+
return theme.warn(theme.bold("[!!] Not installed"));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Key-value panel ───────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
function kvPanel(entries) {
|
|
273
|
+
if (!entries || entries.length === 0) return "";
|
|
274
|
+
const normalized = entries.map(([k, v]) => [String(k), String(v)]);
|
|
275
|
+
const width = normalized.reduce((max, [k]) => Math.max(max, k.length), 0);
|
|
276
|
+
return normalized
|
|
277
|
+
.map(([k, v]) => `${theme.muted(padEnd(k, width))} : ${v}`)
|
|
278
|
+
.join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
banner,
|
|
283
|
+
sectionHeader,
|
|
284
|
+
borderedTable,
|
|
285
|
+
detectionGrid,
|
|
286
|
+
healthBadge,
|
|
287
|
+
kvPanel,
|
|
288
|
+
};
|
|
289
|
+
}
|