@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.
@@ -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 = resolveSkillSelection({
177
- catalog,
178
- detections,
179
- includeTags: parseTags(includeTags),
180
- excludeTags: parseTags(excludeTags)
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 createStyler(enabled) {
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((column) => column.header);
93
- const matrix = [header, ...rows.map((row) => columns.map((column) => asString(row[column.key] ?? "")))];
94
- const widths = header.map((_, index) =>
95
- matrix.reduce((max, line) => Math.max(max, stripAnsi(line[index]).length), 0)
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 headerLine = header.map((value, index) => padEndAnsi(value, widths[index])).join(" ");
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((column, index) => padEndAnsi(asString(row[column.key] ?? ""), widths[index])).join(" ")
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 colorEnabled = detectColorSupport({ env, stream: stdout });
78
+ const colorLevel = detectColorLevel({ env, stream: stdout });
79
+ const colorEnabled = colorLevel > 0;
120
80
  const unicodeEnabled = detectUnicodeSupport({ env, stream: stdout, platform });
121
- const style = createStyler(colorEnabled);
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: style.cyan,
128
- success: style.green,
129
- warn: style.yellow,
130
- error: style.red
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 = `${style.cyan(symbols.section)} ${style.bold(asString(title))}`;
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 renderKeyValue(entries, style);
127
+ return layout.kvPanel(entries);
148
128
  },
129
+
149
130
  list(items, options = {}) {
150
- return renderList(items, { bullet: options.bullet || symbols.bullet });
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
- return renderTable(columns, rows);
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] || ((value) => value);
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${style.dim(" " + asString(detail))}`;
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 ? renderer.kv([["Why", 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
- joinBlocks
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,3 @@
1
+ export { detectColorLevel, createTheme } from "./theme.js";
2
+ export { getBrand } from "./brand.js";
3
+ export { createLayout } from "./layout.js";
@@ -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
+ }