@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 +15 -0
- package/package.json +1 -1
- package/packages/cli/src/bin.js +40 -54
- 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
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
package/packages/cli/src/bin.js
CHANGED
|
@@ -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.
|
|
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(
|
|
148
|
-
|
|
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
|
|
178
|
-
|
|
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
|
|
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([
|
|
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
|
|
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: "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,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
|
+
}
|