@skilly-hand/skilly-hand 0.3.0 → 0.4.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 CHANGED
@@ -16,6 +16,21 @@ All notable changes to this project are documented in this file.
16
16
  ### Removed
17
17
  - _None._
18
18
 
19
+ ## [0.4.0] - 2026-04-03
20
+ [View on npm](https://www.npmjs.com/package/@skilly-hand/skilly-hand/v/0.4.0)
21
+
22
+ ### Added
23
+
24
+ - `feat(core)`: Extract UI rendering into modular system — `ui/theme.js`, `ui/layout.js`, `ui/brand.js` — with a clean `ui/index.js` barrel export.
25
+ - `feat(core)`: Multi-level color detection (`detectColorLevel`) supporting no-color, basic (16), 256-color, and truecolor environments.
26
+ - `feat(cli)`: New renderer methods `banner()`, `detectionGrid()`, and `healthBadge()` built on the new UI modules.
27
+
28
+ ### Changed
29
+
30
+ - `refactor(core)`: `terminal.js` restructured to delegate rendering to the new `ui/` modules; backward-compatible `style` object retained for existing tests.
31
+ - `refactor(cli)`: `bin.js` simplified by extracting inline rendering into the new renderer methods.
32
+ - `chore`: Added `/sandbox` to `.gitignore`.
33
+
19
34
  ## [0.3.0] - 2026-04-03
20
35
  [View on npm](https://www.npmjs.com/package/@skilly-hand/skilly-hand/v/0.3.0)
21
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilly-hand/skilly-hand",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
+ import { createRequire } from "node:module";
3
4
  import { loadAllSkills } from "../../catalog/src/index.js";
4
5
  import { installProject, runDoctor, uninstallProject } from "../../core/src/index.js";
5
6
  import { createTerminalRenderer } from "../../core/src/terminal.js";
6
7
  import { detectProject } from "../../detectors/src/index.js";
7
8
 
9
+ const require = createRequire(import.meta.url);
10
+ const { version } = require("../../../package.json");
11
+
8
12
  const renderer = createTerminalRenderer();
9
13
 
10
14
  function parseArgs(argv) {
@@ -79,57 +83,13 @@ function buildHelpText() {
79
83
  ], { bullet: "-" }));
80
84
 
81
85
  return renderer.joinBlocks([
82
- renderer.status("info", "skilly-hand", "Portable AI skill orchestration for coding assistants."),
86
+ renderer.banner(version),
83
87
  usage,
84
88
  flags,
85
89
  examples
86
90
  ]);
87
91
  }
88
92
 
89
- function detectionRows(detections) {
90
- return detections.map((item) => ({
91
- technology: item.technology,
92
- confidence: item.confidence.toFixed(2),
93
- reasons: item.reasons.join("; "),
94
- recommended: item.recommendedSkillIds.join(", ")
95
- }));
96
- }
97
-
98
- function renderDetections(detections) {
99
- if (detections.length === 0) {
100
- return renderer.status("warn", "No technology signals were detected.", "Only core skills will be selected.");
101
- }
102
-
103
- return renderer.table(
104
- [
105
- { key: "technology", header: "Technology" },
106
- { key: "confidence", header: "Confidence" },
107
- { key: "reasons", header: "Reasons" },
108
- { key: "recommended", header: "Recommended Skills" }
109
- ],
110
- detectionRows(detections)
111
- );
112
- }
113
-
114
- function renderSkillTable(skills) {
115
- if (skills.length === 0) {
116
- return renderer.status("warn", "No skills selected.");
117
- }
118
-
119
- return renderer.table(
120
- [
121
- { key: "id", header: "Skill ID" },
122
- { key: "title", header: "Title" },
123
- { key: "tags", header: "Tags" }
124
- ],
125
- skills.map((skill) => ({
126
- id: skill.id,
127
- title: skill.title,
128
- tags: skill.tags.join(", ")
129
- }))
130
- );
131
- }
132
-
133
93
  function printInstallResult(result, flags) {
134
94
  const mode = flags.dryRun ? "dry-run" : "apply";
135
95
  const preflight = renderer.section(
@@ -144,8 +104,30 @@ function printInstallResult(result, flags) {
144
104
  ])
145
105
  );
146
106
 
147
- const detections = renderer.section("Detected Technologies", renderDetections(result.plan.detections));
148
- const skills = renderer.section("Skill Plan", renderSkillTable(result.plan.skills));
107
+ const detections = renderer.section(
108
+ "Detected Technologies",
109
+ result.plan.detections.length > 0
110
+ ? renderer.detectionGrid(result.plan.detections)
111
+ : renderer.status("warn", "No technology signals were detected.", "Only core skills will be selected.")
112
+ );
113
+
114
+ const skills = renderer.section(
115
+ "Skill Plan",
116
+ result.plan.skills.length > 0
117
+ ? renderer.table(
118
+ [
119
+ { key: "id", header: "Skill ID" },
120
+ { key: "title", header: "Title" },
121
+ { key: "tags", header: "Tags" }
122
+ ],
123
+ result.plan.skills.map((skill) => ({
124
+ id: skill.id,
125
+ title: skill.title,
126
+ tags: skill.tags.join(", ")
127
+ }))
128
+ )
129
+ : renderer.status("warn", "No skills selected.")
130
+ );
149
131
 
150
132
  const status = result.applied
151
133
  ? renderer.status("success", "Installation completed.", "Managed files and symlinks are in place.")
@@ -162,7 +144,7 @@ function printInstallResult(result, flags) {
162
144
  "Adjust `--include` and `--exclude` tags to tune skill selection."
163
145
  ]);
164
146
 
165
- renderer.write(renderer.joinBlocks([preflight, detections, skills, status, nextSteps]));
147
+ renderer.write(renderer.joinBlocks([renderer.banner(version), preflight, detections, skills, status, nextSteps]));
166
148
  }
167
149
 
168
150
  function printDetectResult(cwd, detections) {
@@ -174,8 +156,14 @@ function printDetectResult(cwd, detections) {
174
156
  ])
175
157
  );
176
158
 
177
- const details = renderer.section("Findings", renderDetections(detections));
178
- renderer.write(renderer.joinBlocks([summary, details]));
159
+ const findings = renderer.section(
160
+ "Findings",
161
+ detections.length > 0
162
+ ? renderer.detectionGrid(detections)
163
+ : renderer.status("warn", "No technology signals were detected.", "Only core skills will be selected.")
164
+ );
165
+
166
+ renderer.write(renderer.joinBlocks([summary, findings]));
179
167
  }
180
168
 
181
169
  function printListResult(skills) {
@@ -206,9 +194,7 @@ function printListResult(skills) {
206
194
  }
207
195
 
208
196
  function printDoctorResult(result) {
209
- const health = result.installed
210
- ? renderer.status("success", "Installation detected.")
211
- : renderer.status("warn", "No installation detected.");
197
+ const badge = renderer.healthBadge(result.installed);
212
198
 
213
199
  const summary = renderer.section(
214
200
  "Doctor Summary",
@@ -250,7 +236,7 @@ function printDoctorResult(result) {
250
236
  )
251
237
  );
252
238
 
253
- renderer.write(renderer.joinBlocks([health, summary, lock, issues, probes]));
239
+ renderer.write(renderer.joinBlocks([badge, summary, lock, issues, probes]));
254
240
  }
255
241
 
256
242
  function printUninstallResult(result) {
@@ -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: "autoskills",
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
+ }
@@ -0,0 +1,120 @@
1
+ // Color level detection and palette creation.
2
+ // Level 0: no color Level 1: basic ANSI Level 2: 256-color Level 3: truecolor
3
+
4
+ function normalizeBooleanEnv(value) {
5
+ if (value === undefined || value === null) return null;
6
+ const normalized = String(value).trim().toLowerCase();
7
+ if (normalized === "" || normalized === "0" || normalized === "false" || normalized === "no") return false;
8
+ return true;
9
+ }
10
+
11
+ export function detectColorLevel({ env = process.env, stream = process.stdout } = {}) {
12
+ if (normalizeBooleanEnv(env.NO_COLOR)) return 0;
13
+
14
+ const force = env.FORCE_COLOR;
15
+ if (force !== undefined) {
16
+ const level = parseInt(force, 10);
17
+ if (!isNaN(level)) return Math.min(Math.max(level, 0), 3);
18
+ if (normalizeBooleanEnv(force) === true) return 1;
19
+ if (normalizeBooleanEnv(force) === false) return 0;
20
+ }
21
+
22
+ if (!stream?.isTTY) return 0;
23
+ if (env.CI) return 0;
24
+
25
+ const colorterm = (env.COLORTERM || "").toLowerCase();
26
+ if (colorterm === "truecolor" || colorterm === "24bit") return 3;
27
+
28
+ const term = env.TERM || "";
29
+ const termProgram = env.TERM_PROGRAM || "";
30
+ if (term.includes("256color") || termProgram === "iTerm.app" || termProgram === "vscode") return 2;
31
+
32
+ return 1;
33
+ }
34
+
35
+ // ANSI wrap helpers
36
+ function wrapBasic(code) {
37
+ return (value) => `\x1b[${code}m${String(value)}\x1b[0m`;
38
+ }
39
+ function wrap256(code) {
40
+ return (value) => `\x1b[38;5;${code}m${String(value)}\x1b[0m`;
41
+ }
42
+ function wrapRgb(r, g, b) {
43
+ return (value) => `\x1b[38;2;${r};${g};${b}m${String(value)}\x1b[0m`;
44
+ }
45
+ function wrapBg256(code) {
46
+ return (value) => `\x1b[48;5;${code}m${String(value)}\x1b[0m`;
47
+ }
48
+ function wrapBgRgb(r, g, b) {
49
+ return (value) => `\x1b[48;2;${r};${g};${b}m${String(value)}\x1b[0m`;
50
+ }
51
+
52
+ const PALETTE = {
53
+ primary: { l3: [0, 184, 169], l2: 38, l1: 36 },
54
+ accent: { l3: [77, 208, 225], l2: 51, l1: 36 },
55
+ muted: { l3: [0, 150, 136], l2: 30, l1: 2 },
56
+ success: { l3: [80, 200, 120], l2: 40, l1: 32 },
57
+ warn: { l3: [255, 185, 0], l2: 214, l1: 33 },
58
+ error: { l3: [240, 80, 80], l2: 196, l1: 31 },
59
+ info: { l3: [60, 130, 220], l2: 33, l1: 34 },
60
+ magenta: { l3: [200, 100, 200], l2: 141, l1: 35 },
61
+ };
62
+
63
+ const PALETTE_BG = {
64
+ primary: { l3: [0, 184, 169], l2: 38 },
65
+ success: { l3: [40, 140, 80], l2: 28 },
66
+ warn: { l3: [160, 100, 0], l2: 136 },
67
+ error: { l3: [160, 40, 40], l2: 124 },
68
+ };
69
+
70
+ const identity = (value) => String(value);
71
+
72
+ export function createTheme(level) {
73
+ if (level === 0) {
74
+ const noop = identity;
75
+ return {
76
+ level,
77
+ primary: noop, accent: noop, muted: noop,
78
+ success: noop, warn: noop, error: noop, info: noop, magenta: noop,
79
+ bold: noop, dim: noop, reset: noop, italic: noop,
80
+ bgPrimary: noop, bgSuccess: noop, bgWarn: noop, bgError: noop,
81
+ };
82
+ }
83
+
84
+ function makeColor(slot) {
85
+ const p = PALETTE[slot];
86
+ if (level === 3) return wrapRgb(...p.l3);
87
+ if (level === 2) return wrap256(p.l2);
88
+ // level 1: muted is dim, rest are basic codes
89
+ if (slot === "muted") return wrapBasic(2);
90
+ return wrapBasic(p.l1);
91
+ }
92
+
93
+ function makeBg(slot) {
94
+ const p = PALETTE_BG[slot];
95
+ if (!p) return identity;
96
+ if (level === 3) return wrapBgRgb(...p.l3);
97
+ if (level === 2) return wrapBg256(p.l2);
98
+ return identity;
99
+ }
100
+
101
+ return {
102
+ level,
103
+ primary: makeColor("primary"),
104
+ accent: makeColor("accent"),
105
+ muted: makeColor("muted"),
106
+ success: makeColor("success"),
107
+ warn: makeColor("warn"),
108
+ error: makeColor("error"),
109
+ info: makeColor("info"),
110
+ magenta: makeColor("magenta"),
111
+ bold: wrapBasic(1),
112
+ dim: wrapBasic(2),
113
+ italic: wrapBasic(3),
114
+ reset: wrapBasic(0),
115
+ bgPrimary: makeBg("primary"),
116
+ bgSuccess: makeBg("success"),
117
+ bgWarn: makeBg("warn"),
118
+ bgError: makeBg("error"),
119
+ };
120
+ }