@nucleoicons/skills 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -25,9 +25,9 @@ npx @nucleoicons/skills setup --target react --tools cursor
25
25
  npx @nucleoicons/skills setup --target vanilla --tools cursor,claude
26
26
  ```
27
27
 
28
- Setup installs the package under your user home (`~/.nucleo/skills`) and configures the editors you choose.
28
+ Setup installs the package under your user home (`~/.nucleo/skills`) and configures the editors you choose (skills + MCP, no manual config step).
29
29
 
30
- After setup, follow any MCP steps printed in the terminal, reload MCP in your editor, then ask something like:
30
+ Reload MCP in your editor, then ask something like:
31
31
 
32
32
  ```text
33
33
  What Nucleo icon families do I have?
@@ -47,8 +47,8 @@ After updating, reload MCP in your editor again.
47
47
 
48
48
  ## Editors
49
49
 
50
- | Tool | `--tools` value |
51
- | ----------- | --------------- |
52
- | Cursor | `cursor` |
53
- | Claude Code | `claude` |
54
- | Codex | `codex` |
50
+ | Tool | `--tools` value | Auto-configured |
51
+ | ----------- | --------------- | --------------- |
52
+ | Cursor | `cursor` | `~/.cursor/mcp.json` + `~/.cursor/skills/` |
53
+ | Claude Code | `claude` | `~/.claude.json` + `~/.claude/skills/` |
54
+ | Codex | `codex` | `~/.codex/config.toml` + `~/.agents/skills/` |
@@ -1,17 +1,25 @@
1
1
  import { type AgentTool } from "../config.js";
2
+ /** Remove an existing `[mcp_servers.<name>]` table (+ nested tables), then append `block`. */
3
+ export declare function upsertTomlMcpServer(toml: string, serverName: string, block: string): string;
2
4
  /** Merge nucleo-icons into ~/.cursor/mcp.json and copy skills to ~/.cursor/skills */
3
5
  export declare function configureCursor(root?: string): Promise<{
4
6
  mcpPath: string;
7
+ skillsPath: string;
5
8
  skills: string[];
6
9
  }>;
7
- /** Copy skills to ~/.claude/skills and print MCP snippet (Claude uses `claude mcp add` or config file) */
10
+ /**
11
+ * Copy skills to ~/.claude/skills and merge MCP into ~/.claude.json
12
+ * (user-scope mcpServers — same file `claude mcp add --scope user` writes).
13
+ */
8
14
  export declare function configureClaude(root?: string): Promise<{
15
+ mcpPath: string;
16
+ skillsPath: string;
9
17
  skills: string[];
10
- hint: string;
11
18
  }>;
12
- /** Copy skills to ~/.agents/skills and print Codex config.toml snippet */
19
+ /** Copy skills to ~/.agents/skills and merge MCP into ~/.codex/config.toml */
13
20
  export declare function configureCodex(root?: string): Promise<{
21
+ mcpPath: string;
22
+ skillsPath: string;
14
23
  skills: string[];
15
- hint: string;
16
24
  }>;
17
25
  export declare function configureTool(tool: AgentTool, root?: string): Promise<void>;
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { installRoot } from "../config.js";
6
6
  import { mcpEntryPath, skillsSourceDir } from "../install.js";
7
+ const MCP_SERVER_NAME = "nucleo-icons";
7
8
  async function readJsonFile(path, fallback) {
8
9
  if (!existsSync(path)) {
9
10
  return fallback;
@@ -40,60 +41,101 @@ function nucleoMcpServerConfig(root = installRoot()) {
40
41
  },
41
42
  };
42
43
  }
44
+ function escapeTomlString(value) {
45
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
46
+ }
47
+ /** Remove an existing `[mcp_servers.<name>]` table (+ nested tables), then append `block`. */
48
+ export function upsertTomlMcpServer(toml, serverName, block) {
49
+ const prefix = `mcp_servers.${serverName}`;
50
+ const lines = toml.length > 0 ? toml.split(/\r?\n/) : [];
51
+ const kept = [];
52
+ let skipping = false;
53
+ for (const line of lines) {
54
+ const header = line.match(/^\[([^\]]+)\]\s*$/);
55
+ if (header) {
56
+ const name = header[1];
57
+ skipping = name === prefix || name.startsWith(`${prefix}.`);
58
+ }
59
+ if (!skipping) {
60
+ kept.push(line);
61
+ }
62
+ }
63
+ let result = kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
64
+ if (result.length > 0) {
65
+ result += "\n\n";
66
+ }
67
+ return `${result}${block.trimEnd()}\n`;
68
+ }
69
+ function nucleoCodexTomlBlock(root = installRoot()) {
70
+ const entry = escapeTomlString(mcpEntryPath(root));
71
+ const skillsRoot = escapeTomlString(root);
72
+ return [
73
+ `[mcp_servers.${MCP_SERVER_NAME}]`,
74
+ 'command = "node"',
75
+ `args = ["${entry}"]`,
76
+ "",
77
+ `[mcp_servers.${MCP_SERVER_NAME}.env]`,
78
+ `NUCLEO_SKILLS_ROOT = "${skillsRoot}"`,
79
+ ].join("\n");
80
+ }
43
81
  /** Merge nucleo-icons into ~/.cursor/mcp.json and copy skills to ~/.cursor/skills */
44
82
  export async function configureCursor(root = installRoot()) {
45
83
  const cursorDir = join(homedir(), ".cursor");
46
84
  const mcpPath = join(cursorDir, "mcp.json");
85
+ const skillsPath = join(cursorDir, "skills");
47
86
  await mkdir(cursorDir, { recursive: true });
48
87
  const mcp = await readJsonFile(mcpPath, { mcpServers: {} });
49
88
  mcp.mcpServers = mcp.mcpServers ?? {};
50
- mcp.mcpServers["nucleo-icons"] = nucleoMcpServerConfig(root);
89
+ mcp.mcpServers[MCP_SERVER_NAME] = nucleoMcpServerConfig(root);
51
90
  await writeFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`, "utf8");
52
- const skills = await copySkillFolders(join(cursorDir, "skills"), root);
53
- return { mcpPath, skills };
91
+ const skills = await copySkillFolders(skillsPath, root);
92
+ return { mcpPath, skillsPath, skills };
54
93
  }
55
- /** Copy skills to ~/.claude/skills and print MCP snippet (Claude uses `claude mcp add` or config file) */
94
+ /**
95
+ * Copy skills to ~/.claude/skills and merge MCP into ~/.claude.json
96
+ * (user-scope mcpServers — same file `claude mcp add --scope user` writes).
97
+ */
56
98
  export async function configureClaude(root = installRoot()) {
57
99
  const claudeDir = join(homedir(), ".claude");
58
- const skills = await copySkillFolders(join(claudeDir, "skills"), root);
59
- const hint = [
60
- "Add the MCP server with:",
61
- "",
62
- ` claude mcp add --scope user --transport stdio nucleo-icons \\`,
63
- ` --env NUCLEO_SKILLS_ROOT=${root} \\`,
64
- ` -- node ${mcpEntryPath(root)}`,
65
- "",
66
- "Or merge the same command/args/env into your Claude MCP config.",
67
- ].join("\n");
68
- return { skills, hint };
100
+ const mcpPath = join(homedir(), ".claude.json");
101
+ const skillsPath = join(claudeDir, "skills");
102
+ await mkdir(claudeDir, { recursive: true });
103
+ const config = await readJsonFile(mcpPath, {});
104
+ config.mcpServers = config.mcpServers ?? {};
105
+ config.mcpServers[MCP_SERVER_NAME] = {
106
+ type: "stdio",
107
+ ...nucleoMcpServerConfig(root),
108
+ };
109
+ await writeFile(mcpPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
110
+ const skills = await copySkillFolders(skillsPath, root);
111
+ return { mcpPath, skillsPath, skills };
69
112
  }
70
- /** Copy skills to ~/.agents/skills and print Codex config.toml snippet */
113
+ /** Copy skills to ~/.agents/skills and merge MCP into ~/.codex/config.toml */
71
114
  export async function configureCodex(root = installRoot()) {
72
- const skills = await copySkillFolders(join(homedir(), ".agents", "skills"), root);
73
- const hint = [
74
- "Add to ~/.codex/config.toml:",
75
- "",
76
- "[mcp_servers.nucleo-icons]",
77
- 'command = "node"',
78
- `args = ["${mcpEntryPath(root)}"]`,
79
- `env = { NUCLEO_SKILLS_ROOT = "${root}" }`,
80
- ].join("\n");
81
- return { skills, hint };
115
+ const codexDir = join(homedir(), ".codex");
116
+ const mcpPath = join(codexDir, "config.toml");
117
+ const skillsPath = join(homedir(), ".agents", "skills");
118
+ await mkdir(codexDir, { recursive: true });
119
+ const existing = existsSync(mcpPath) ? await readFile(mcpPath, "utf8") : "";
120
+ const next = upsertTomlMcpServer(existing, MCP_SERVER_NAME, nucleoCodexTomlBlock(root));
121
+ await writeFile(mcpPath, next, "utf8");
122
+ const skills = await copySkillFolders(skillsPath, root);
123
+ return { mcpPath, skillsPath, skills };
82
124
  }
83
125
  export async function configureTool(tool, root = installRoot()) {
84
126
  if (tool === "cursor") {
85
127
  const result = await configureCursor(root);
86
128
  console.log(` Cursor MCP → ${result.mcpPath}`);
87
- console.log(` Cursor skills → ~/.cursor/skills/ (${result.skills.join(", ")})`);
129
+ console.log(` Cursor skills → ${result.skillsPath} (${result.skills.join(", ")})`);
88
130
  return;
89
131
  }
90
132
  if (tool === "claude") {
91
133
  const result = await configureClaude(root);
92
- console.log(` Claude skills → ~/.claude/skills/ (${result.skills.join(", ")})`);
93
- console.log(result.hint);
134
+ console.log(` Claude MCP → ${result.mcpPath}`);
135
+ console.log(` Claude skills → ${result.skillsPath} (${result.skills.join(", ")})`);
94
136
  return;
95
137
  }
96
138
  const result = await configureCodex(root);
97
- console.log(` Codex skills → ~/.agents/skills/ (${result.skills.join(", ")})`);
98
- console.log(result.hint);
139
+ console.log(` Codex MCP → ${result.mcpPath}`);
140
+ console.log(` Codex skills → ${result.skillsPath} (${result.skills.join(", ")})`);
99
141
  }
package/dist/api.d.ts CHANGED
@@ -32,6 +32,10 @@ export interface SkillsReleaseData {
32
32
  files: SkillsReleaseFile[];
33
33
  message?: string;
34
34
  }
35
+ export interface DownloadProgress {
36
+ transferred: number;
37
+ total: number | null;
38
+ }
35
39
  /**
36
40
  * POST /skills/login
37
41
  * Body: { email, password }
@@ -43,5 +47,10 @@ export declare function skillsLogin(email: string, password: string): Promise<Sk
43
47
  * Response: { data: { version, families, files, downloadReady } }
44
48
  */
45
49
  export declare function fetchSkillsRelease(token: string, target: PackageTarget): Promise<SkillsReleaseData>;
46
- /** Download a remote file (signed R2 URL) to disk. */
47
- export declare function downloadUrlToFile(url: string, destPath: string): Promise<void>;
50
+ declare function formatBytes(bytes: number): string;
51
+ /** Download a remote file (signed R2 URL) to disk, with optional progress. */
52
+ export declare function downloadUrlToFile(url: string, destPath: string, onProgress?: (progress: DownloadProgress) => void): Promise<{
53
+ bytes: number;
54
+ total: number | null;
55
+ }>;
56
+ export { formatBytes };
package/dist/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createWriteStream } from "node:fs";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
+ import { Transform } from "node:stream";
4
5
  import { pipeline } from "node:stream/promises";
5
6
  import { Readable } from "node:stream";
6
7
  import { getApiBaseUrl } from "./config.js";
@@ -84,18 +85,58 @@ export async function fetchSkillsRelease(token, target) {
84
85
  }
85
86
  return json.data;
86
87
  }
87
- /** Download a remote file (signed R2 URL) to disk. */
88
- export async function downloadUrlToFile(url, destPath) {
89
- const res = await fetch(url);
88
+ function formatBytes(bytes) {
89
+ if (bytes < 1024) {
90
+ return `${bytes} B`;
91
+ }
92
+ if (bytes < 1024 * 1024) {
93
+ return `${(bytes / 1024).toFixed(1)} KB`;
94
+ }
95
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
96
+ }
97
+ /** Download a remote file (signed R2 URL) to disk, with optional progress. */
98
+ export async function downloadUrlToFile(url, destPath, onProgress) {
99
+ let res;
100
+ try {
101
+ res = await fetch(url);
102
+ }
103
+ catch (error) {
104
+ const detail = error instanceof Error ? error.message : String(error);
105
+ throw new ApiError(`Download failed (network): ${detail}`);
106
+ }
90
107
  if (!res.ok) {
91
- throw new ApiError(`Download failed (${res.status}) for ${url}`, res.status);
108
+ throw new ApiError(`Download failed (${res.status}). The link may have expired — run setup again.`, res.status);
92
109
  }
93
110
  if (!res.body) {
94
111
  throw new ApiError("Download failed: empty response body.");
95
112
  }
113
+ const totalHeader = res.headers.get("content-length");
114
+ const total = totalHeader ? Number(totalHeader) : null;
115
+ let transferred = 0;
116
+ let lastLogged = 0;
96
117
  await mkdir(dirname(destPath), { recursive: true });
97
118
  const fileStream = createWriteStream(destPath);
98
- // Node fetch body → Web ReadableStream; convert for pipeline
99
- const nodeReadable = Readable.fromWeb(res.body);
100
- await pipeline(nodeReadable, fileStream);
119
+ const progressTap = new Transform({
120
+ transform(chunk, _encoding, callback) {
121
+ transferred += chunk.length;
122
+ if (onProgress) {
123
+ onProgress({ transferred, total: Number.isFinite(total) ? total : null });
124
+ }
125
+ // Throttle console-friendly updates to ~every 2MB
126
+ if (transferred - lastLogged >= 2 * 1024 * 1024 || transferred === total) {
127
+ lastLogged = transferred;
128
+ }
129
+ callback(null, chunk);
130
+ },
131
+ });
132
+ try {
133
+ const nodeReadable = Readable.fromWeb(res.body);
134
+ await pipeline(nodeReadable, progressTap, fileStream);
135
+ }
136
+ catch (error) {
137
+ const detail = error instanceof Error ? error.message : String(error);
138
+ throw new ApiError(`Download interrupted after ${formatBytes(transferred)}${total ? ` of ${formatBytes(total)}` : ""}: ${detail}`);
139
+ }
140
+ return { bytes: transferred, total: Number.isFinite(total) ? total : null };
101
141
  }
142
+ export { formatBytes };
package/dist/index.js CHANGED
@@ -23,11 +23,32 @@ function parseTools(value) {
23
23
  .map((t) => t.trim().toLowerCase())
24
24
  .filter(Boolean);
25
25
  }
26
+ let verbose = false;
27
+ function printError(error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ console.error(`\nError: ${message}`);
30
+ if (verbose && error instanceof Error && error.stack) {
31
+ console.error(error.stack);
32
+ }
33
+ console.error("");
34
+ }
35
+ process.on("unhandledRejection", (reason) => {
36
+ printError(reason);
37
+ process.exit(1);
38
+ });
39
+ process.on("uncaughtException", (error) => {
40
+ printError(error);
41
+ process.exit(1);
42
+ });
26
43
  const program = new Command();
27
44
  program
28
45
  .name("nucleo-skills")
29
46
  .description("Install and update Nucleo Pro MCP + icon skills")
30
- .version(packageVersion());
47
+ .version(packageVersion())
48
+ .option("-v, --verbose", "Show full error details")
49
+ .hook("preAction", (thisCommand) => {
50
+ verbose = Boolean(thisCommand.opts().verbose);
51
+ });
31
52
  program
32
53
  .command("setup")
33
54
  .description("Sign in, download your licensed package, configure your editor")
@@ -52,7 +73,7 @@ program
52
73
  });
53
74
  }
54
75
  catch (error) {
55
- console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
76
+ printError(error);
56
77
  process.exitCode = 1;
57
78
  }
58
79
  });
@@ -73,7 +94,7 @@ program
73
94
  });
74
95
  }
75
96
  catch (error) {
76
- console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
97
+ printError(error);
77
98
  process.exitCode = 1;
78
99
  }
79
100
  });
@@ -85,7 +106,7 @@ program
85
106
  await runStatus();
86
107
  }
87
108
  catch (error) {
88
- console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
109
+ printError(error);
89
110
  process.exitCode = 1;
90
111
  }
91
112
  });
@@ -97,7 +118,7 @@ program
97
118
  await runLogout();
98
119
  }
99
120
  catch (error) {
100
- console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
121
+ printError(error);
101
122
  process.exitCode = 1;
102
123
  }
103
124
  });
package/dist/install.d.ts CHANGED
@@ -4,6 +4,8 @@ export declare function readInstallManifest(root?: string): Promise<InstallManif
4
4
  export declare function cleanInstallRoot(root?: string): Promise<void>;
5
5
  /**
6
6
  * Download all release files (signed URLs) and merge into the install root.
7
+ * Downloads complete first, then the install folder is replaced — so a failed
8
+ * download does not wipe a previous working install.
7
9
  */
8
10
  export declare function installFromRelease(release: SkillsReleaseData, target: PackageTarget, root?: string): Promise<void>;
9
11
  /**
package/dist/install.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
- import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import extract from "extract-zip";
7
- import { downloadUrlToFile } from "./api.js";
7
+ import { downloadUrlToFile, formatBytes } from "./api.js";
8
8
  import { installRoot, nucleoHomeDir } from "./config.js";
9
9
  const execFileAsync = promisify(execFile);
10
10
  export async function readInstallManifest(root = installRoot()) {
@@ -23,43 +23,116 @@ export async function cleanInstallRoot(root = installRoot()) {
23
23
  await rm(root, { recursive: true, force: true });
24
24
  await mkdir(root, { recursive: true });
25
25
  }
26
- /** Extract one zip into dest, merging (does not wipe dest first). */
26
+ async function canRunUnzip() {
27
+ try {
28
+ await execFileAsync("unzip", ["-v"], { timeout: 5_000 });
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /**
36
+ * Extract one zip into dest (merge). Prefer system `unzip` — extract-zip + a
37
+ * full-tree cp of ~40k files is slow and has been hanging/exiting mid-install.
38
+ */
27
39
  async function extractZipMerge(zipPath, dest) {
28
- const tmp = join(nucleoHomeDir(), `.extract-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
29
- await rm(tmp, { recursive: true, force: true });
30
- await mkdir(tmp, { recursive: true });
40
+ await mkdir(dest, { recursive: true });
41
+ const heartbeat = setInterval(() => {
42
+ process.stdout.write(".");
43
+ }, 2_000);
31
44
  try {
32
- await extract(zipPath, { dir: tmp });
33
- await mkdir(dest, { recursive: true });
34
- await cp(tmp, dest, { recursive: true });
45
+ if (await canRunUnzip()) {
46
+ // -o overwrite, -q quiet (we print our own progress)
47
+ await execFileAsync("unzip", ["-o", "-q", zipPath, "-d", dest], {
48
+ maxBuffer: 16 * 1024 * 1024,
49
+ });
50
+ }
51
+ else {
52
+ // Fallback: extract directly into dest (no tmp + cp)
53
+ await extract(zipPath, { dir: dest });
54
+ }
35
55
  }
36
56
  finally {
37
- await rm(tmp, { recursive: true, force: true });
57
+ clearInterval(heartbeat);
58
+ process.stdout.write("\n");
59
+ }
60
+ }
61
+ /** Fail loudly if extract left an empty/incomplete install. */
62
+ async function assertInstallLooksComplete(root) {
63
+ const required = ["mcp", "skills"];
64
+ const missing = required.filter((name) => !existsSync(join(root, name)));
65
+ if (missing.length > 0) {
66
+ const listing = existsSync(root) ? (await readdir(root)).join(", ") || "(empty)" : "(missing)";
67
+ throw new Error(`Install looks incomplete at ${root}. Missing: ${missing.join(", ")}. ` +
68
+ `Contents: ${listing}`);
38
69
  }
39
70
  }
40
71
  /**
41
72
  * Download all release files (signed URLs) and merge into the install root.
73
+ * Downloads complete first, then the install folder is replaced — so a failed
74
+ * download does not wipe a previous working install.
42
75
  */
43
76
  export async function installFromRelease(release, target, root = installRoot()) {
44
77
  if (!release.downloadReady) {
45
78
  throw new Error(release.message ||
46
- "Downloads are not ready on the server yet (R2 not configured or urls missing).");
79
+ "Downloads are not ready on the server yet. Please try again later.");
47
80
  }
48
81
  const missingUrl = release.files.filter((f) => !f.url);
49
82
  if (missingUrl.length > 0) {
50
83
  throw new Error(`Release files missing download urls: ${missingUrl.map((f) => f.id).join(", ")}`);
51
84
  }
52
- await cleanInstallRoot(root);
53
85
  const downloadsDir = join(nucleoHomeDir(), "downloads");
54
86
  await mkdir(downloadsDir, { recursive: true });
87
+ const downloaded = [];
55
88
  try {
89
+ // 1) Download everything first (keeps existing install intact on failure)
56
90
  for (const file of release.files) {
57
91
  const zipPath = join(downloadsDir, `${file.id}-${release.version}.zip`);
58
- console.log(` ↓ ${file.id}`);
59
- await downloadUrlToFile(file.url, zipPath);
60
- await extractZipMerge(zipPath, root);
61
- await rm(zipPath, { force: true }).catch(() => undefined);
92
+ process.stdout.write(` ↓ ${file.id}`);
93
+ let lastLine = "";
94
+ try {
95
+ const result = await downloadUrlToFile(file.url, zipPath, ({ transferred, total }) => {
96
+ const label = total
97
+ ? ` ↓ ${file.id} ${formatBytes(transferred)} / ${formatBytes(total)}`
98
+ : ` ↓ ${file.id} ${formatBytes(transferred)}`;
99
+ // overwrite current line
100
+ process.stdout.write(`\r${label}${" ".repeat(Math.max(0, lastLine.length - label.length))}`);
101
+ lastLine = label;
102
+ });
103
+ const sizeLabel = result.total
104
+ ? `${formatBytes(result.bytes)} / ${formatBytes(result.total)}`
105
+ : formatBytes(result.bytes);
106
+ process.stdout.write(`\r ↓ ${file.id} ${sizeLabel} ✓\n`);
107
+ }
108
+ catch (error) {
109
+ process.stdout.write("\n");
110
+ throw error;
111
+ }
112
+ const info = await stat(zipPath);
113
+ if (info.size <= 0) {
114
+ throw new Error(`Downloaded file is empty: ${file.id}`);
115
+ }
116
+ downloaded.push({ id: file.id, zipPath });
117
+ }
118
+ // 2) Only now replace the install root
119
+ console.log(" Extracting…");
120
+ await cleanInstallRoot(root);
121
+ for (const file of downloaded) {
122
+ const zipInfo = await stat(file.zipPath);
123
+ process.stdout.write(` ▸ ${file.id} (${formatBytes(zipInfo.size)}) `);
124
+ try {
125
+ await extractZipMerge(file.zipPath, root);
126
+ console.log(` ✓ ${file.id}`);
127
+ }
128
+ catch (error) {
129
+ const detail = error instanceof Error ? error.message : String(error);
130
+ throw new Error(`Failed extracting ${file.id} (${formatBytes(zipInfo.size)}). ${detail}\n` +
131
+ `If this keeps happening, free disk space or try again with:\n` +
132
+ ` NODE_OPTIONS=--max-old-space-size=8192 npx @nucleoicons/skills setup …`);
133
+ }
62
134
  }
135
+ await assertInstallLooksComplete(root);
63
136
  }
64
137
  finally {
65
138
  await rm(downloadsDir, { recursive: true, force: true }).catch(() => undefined);
@@ -116,11 +189,19 @@ export async function buildMcp(root = installRoot()) {
116
189
  }
117
190
  const distEntry = join(mcpDir, "dist", "index.js");
118
191
  const hasDist = existsSync(distEntry);
119
- await execFileAsync("npm", ["install", "--omit=dev"], {
120
- cwd: mcpDir,
121
- env: process.env,
122
- });
192
+ console.log(" npm install (mcp)…");
193
+ try {
194
+ await execFileAsync("npm", ["install", "--omit=dev"], {
195
+ cwd: mcpDir,
196
+ env: process.env,
197
+ });
198
+ }
199
+ catch (error) {
200
+ const detail = error instanceof Error ? error.message : String(error);
201
+ throw new Error(`MCP dependency install failed: ${detail}`);
202
+ }
123
203
  if (!hasDist || existsSync(join(mcpDir, "src"))) {
204
+ console.log(" npm run build (mcp)…");
124
205
  try {
125
206
  await execFileAsync("npm", ["run", "build"], {
126
207
  cwd: mcpDir,
@@ -129,7 +210,8 @@ export async function buildMcp(root = installRoot()) {
129
210
  }
130
211
  catch (error) {
131
212
  if (!existsSync(distEntry)) {
132
- throw error;
213
+ const detail = error instanceof Error ? error.message : String(error);
214
+ throw new Error(`MCP build failed: ${detail}`);
133
215
  }
134
216
  }
135
217
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nucleoicons/skills",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "CLI to install and update Nucleo Pro MCP + icon skills",
5
5
  "type": "module",
6
6
  "bin": {