@nucleoicons/skills 0.1.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/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @nucleoicons/skills
2
+
3
+ CLI to install and update **Nucleo** MCP + icon skills on your machine.
4
+
5
+ Requires **Node.js 20+**.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx @nucleoicons/skills setup --target react --tools cursor
11
+ ```
12
+
13
+ **Required for setup:** `--target` (`react` or `vanilla`) and `--tools` (e.g. `cursor`, or `cursor,claude`).
14
+
15
+ ```bash
16
+ npx @nucleoicons/skills setup --target react --tools cursor
17
+ npx @nucleoicons/skills setup --target vanilla --tools cursor,claude
18
+ ```
19
+
20
+ Setup installs the package under your user home (`~/.nucleo/skills`) and configures the editors you choose.
21
+
22
+ After setup, follow any MCP steps printed in the terminal, reload MCP in your editor, then ask:
23
+
24
+ > What Nucleo icon families do I have?
25
+
26
+ ## Update
27
+
28
+ ```bash
29
+ npx @nucleoicons/skills update
30
+ ```
31
+
32
+ ```bash
33
+ npx @nucleoicons/skills update --check # only report whether an update is available
34
+ ```
35
+
36
+ After updating, reload MCP in your editor again.
37
+
38
+ ## Other commands
39
+
40
+ ```bash
41
+ npx @nucleoicons/skills status # install path, version, sign-in
42
+ npx @nucleoicons/skills logout # remove saved credentials
43
+ ```
44
+
45
+ ## Editors
46
+
47
+ | Tool | What setup does |
48
+ | --------------- | ------------------------------------------------------------------ |
49
+ | **Cursor** | Writes `~/.cursor/mcp.json` + copies skills to `~/.cursor/skills/` |
50
+ | **Claude Code** | Copies skills; prints `claude mcp add …` command |
51
+ | **Codex** | Copies skills to `~/.agents/skills/`; prints `config.toml` snippet |
@@ -0,0 +1,17 @@
1
+ import { type AgentTool } from "../config.js";
2
+ /** Merge nucleo-icons into ~/.cursor/mcp.json and copy skills to ~/.cursor/skills */
3
+ export declare function configureCursor(root?: string): Promise<{
4
+ mcpPath: string;
5
+ skills: string[];
6
+ }>;
7
+ /** Copy skills to ~/.claude/skills and print MCP snippet (Claude uses `claude mcp add` or config file) */
8
+ export declare function configureClaude(root?: string): Promise<{
9
+ skills: string[];
10
+ hint: string;
11
+ }>;
12
+ /** Copy skills to ~/.agents/skills and print Codex config.toml snippet */
13
+ export declare function configureCodex(root?: string): Promise<{
14
+ skills: string[];
15
+ hint: string;
16
+ }>;
17
+ export declare function configureTool(tool: AgentTool, root?: string): Promise<void>;
@@ -0,0 +1,99 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { installRoot } from "../config.js";
6
+ import { mcpEntryPath, skillsSourceDir } from "../install.js";
7
+ async function readJsonFile(path, fallback) {
8
+ if (!existsSync(path)) {
9
+ return fallback;
10
+ }
11
+ try {
12
+ return JSON.parse(await readFile(path, "utf8"));
13
+ }
14
+ catch {
15
+ return fallback;
16
+ }
17
+ }
18
+ async function copySkillFolders(destSkillsRoot, root = installRoot()) {
19
+ const source = skillsSourceDir(root);
20
+ if (!existsSync(source)) {
21
+ throw new Error(`No skills/ folder in install at ${source}`);
22
+ }
23
+ const { readdir } = await import("node:fs/promises");
24
+ const entries = await readdir(source, { withFileTypes: true });
25
+ const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
26
+ await mkdir(destSkillsRoot, { recursive: true });
27
+ for (const name of skillDirs) {
28
+ const from = join(source, name);
29
+ const to = join(destSkillsRoot, name);
30
+ await cp(from, to, { recursive: true });
31
+ }
32
+ return skillDirs;
33
+ }
34
+ function nucleoMcpServerConfig(root = installRoot()) {
35
+ return {
36
+ command: "node",
37
+ args: [mcpEntryPath(root)],
38
+ env: {
39
+ NUCLEO_SKILLS_ROOT: root,
40
+ },
41
+ };
42
+ }
43
+ /** Merge nucleo-icons into ~/.cursor/mcp.json and copy skills to ~/.cursor/skills */
44
+ export async function configureCursor(root = installRoot()) {
45
+ const cursorDir = join(homedir(), ".cursor");
46
+ const mcpPath = join(cursorDir, "mcp.json");
47
+ await mkdir(cursorDir, { recursive: true });
48
+ const mcp = await readJsonFile(mcpPath, { mcpServers: {} });
49
+ mcp.mcpServers = mcp.mcpServers ?? {};
50
+ mcp.mcpServers["nucleo-icons"] = nucleoMcpServerConfig(root);
51
+ await writeFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`, "utf8");
52
+ const skills = await copySkillFolders(join(cursorDir, "skills"), root);
53
+ return { mcpPath, skills };
54
+ }
55
+ /** Copy skills to ~/.claude/skills and print MCP snippet (Claude uses `claude mcp add` or config file) */
56
+ export async function configureClaude(root = installRoot()) {
57
+ 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 };
69
+ }
70
+ /** Copy skills to ~/.agents/skills and print Codex config.toml snippet */
71
+ 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 };
82
+ }
83
+ export async function configureTool(tool, root = installRoot()) {
84
+ if (tool === "cursor") {
85
+ const result = await configureCursor(root);
86
+ console.log(` Cursor MCP → ${result.mcpPath}`);
87
+ console.log(` Cursor skills → ~/.cursor/skills/ (${result.skills.join(", ")})`);
88
+ return;
89
+ }
90
+ if (tool === "claude") {
91
+ const result = await configureClaude(root);
92
+ console.log(` Claude skills → ~/.claude/skills/ (${result.skills.join(", ")})`);
93
+ console.log(result.hint);
94
+ return;
95
+ }
96
+ const result = await configureCodex(root);
97
+ console.log(` Codex skills → ~/.agents/skills/ (${result.skills.join(", ")})`);
98
+ console.log(result.hint);
99
+ }
package/dist/api.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type PackageTarget } from "./config.js";
2
+ export declare class ApiError extends Error {
3
+ readonly status?: number | undefined;
4
+ readonly body?: string | undefined;
5
+ constructor(message: string, status?: number | undefined, body?: string | undefined);
6
+ }
7
+ /** POST /skills/login → data */
8
+ export interface SkillsLoginData {
9
+ id: number | string;
10
+ token: string;
11
+ email: string;
12
+ families: string[];
13
+ nucleo_icons_core?: number;
14
+ nucleo_icons_ui?: number;
15
+ nucleo_icons_axis?: number;
16
+ nucleo_icons_micro?: number;
17
+ nucleo_icons_sharp?: number;
18
+ nucleo_icons_pixel?: number;
19
+ }
20
+ export interface SkillsReleaseFile {
21
+ id: string;
22
+ key: string;
23
+ url: string | null;
24
+ expiresAt: string | null;
25
+ }
26
+ /** GET /skills/release → data */
27
+ export interface SkillsReleaseData {
28
+ version: string;
29
+ target: PackageTarget | string;
30
+ families: string[];
31
+ downloadReady: boolean;
32
+ files: SkillsReleaseFile[];
33
+ message?: string;
34
+ }
35
+ /**
36
+ * POST /skills/login
37
+ * Body: { email, password }
38
+ * Response: { data: { token, email, families, ... } }
39
+ */
40
+ export declare function skillsLogin(email: string, password: string): Promise<SkillsLoginData>;
41
+ /**
42
+ * GET /skills/release?token=…&target=react|vanilla
43
+ * Response: { data: { version, families, files, downloadReady } }
44
+ */
45
+ 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>;
package/dist/api.js ADDED
@@ -0,0 +1,101 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { pipeline } from "node:stream/promises";
5
+ import { Readable } from "node:stream";
6
+ import { getApiBaseUrl } from "./config.js";
7
+ export class ApiError extends Error {
8
+ status;
9
+ body;
10
+ constructor(message, status, body) {
11
+ super(message);
12
+ this.status = status;
13
+ this.body = body;
14
+ this.name = "ApiError";
15
+ }
16
+ }
17
+ async function readErrorBody(res) {
18
+ try {
19
+ return await res.text();
20
+ }
21
+ catch {
22
+ return "";
23
+ }
24
+ }
25
+ function parseApiErrorMessage(body, fallback) {
26
+ try {
27
+ const parsed = JSON.parse(body);
28
+ const details = parsed.errors?.details || parsed.errors?.title;
29
+ if (details) {
30
+ return details;
31
+ }
32
+ }
33
+ catch {
34
+ // ignore
35
+ }
36
+ return fallback;
37
+ }
38
+ /**
39
+ * POST /skills/login
40
+ * Body: { email, password }
41
+ * Response: { data: { token, email, families, ... } }
42
+ */
43
+ export async function skillsLogin(email, password) {
44
+ const url = `${getApiBaseUrl()}/skills/login`;
45
+ const res = await fetch(url, {
46
+ method: "POST",
47
+ headers: {
48
+ Accept: "application/json",
49
+ "Content-Type": "application/json",
50
+ },
51
+ body: JSON.stringify({ email, password }),
52
+ });
53
+ const body = await readErrorBody(res);
54
+ if (!res.ok) {
55
+ throw new ApiError(parseApiErrorMessage(body, `Login failed (${res.status}).`), res.status, body);
56
+ }
57
+ const json = JSON.parse(body);
58
+ if (!json.data?.token) {
59
+ throw new ApiError("Login response missing token.", res.status, body);
60
+ }
61
+ if (!Array.isArray(json.data.families) || json.data.families.length === 0) {
62
+ throw new ApiError("This account has no Nucleo icon family access.", 403, body);
63
+ }
64
+ return json.data;
65
+ }
66
+ /**
67
+ * GET /skills/release?token=…&target=react|vanilla
68
+ * Response: { data: { version, families, files, downloadReady } }
69
+ */
70
+ export async function fetchSkillsRelease(token, target) {
71
+ const url = new URL(`${getApiBaseUrl()}/skills/release`);
72
+ url.searchParams.set("token", token);
73
+ url.searchParams.set("target", target);
74
+ const res = await fetch(url, {
75
+ headers: { Accept: "application/json" },
76
+ });
77
+ const body = await readErrorBody(res);
78
+ if (!res.ok) {
79
+ throw new ApiError(parseApiErrorMessage(body, `Could not fetch release (${res.status}).`), res.status, body);
80
+ }
81
+ const json = JSON.parse(body);
82
+ if (!json.data?.version || !Array.isArray(json.data.files)) {
83
+ throw new ApiError("Release response missing version/files.", res.status, body);
84
+ }
85
+ return json.data;
86
+ }
87
+ /** Download a remote file (signed R2 URL) to disk. */
88
+ export async function downloadUrlToFile(url, destPath) {
89
+ const res = await fetch(url);
90
+ if (!res.ok) {
91
+ throw new ApiError(`Download failed (${res.status}) for ${url}`, res.status);
92
+ }
93
+ if (!res.body) {
94
+ throw new ApiError("Download failed: empty response body.");
95
+ }
96
+ await mkdir(dirname(destPath), { recursive: true });
97
+ 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);
101
+ }
@@ -0,0 +1 @@
1
+ export declare function runLogout(): Promise<void>;
@@ -0,0 +1,8 @@
1
+ import { clearCredentials } from "../credentials.js";
2
+ import { credentialsPath } from "../config.js";
3
+ export async function runLogout() {
4
+ await clearCredentials();
5
+ console.log(`✓ Signed out (removed ${credentialsPath()})`);
6
+ console.log(" Local install at ~/.nucleo/skills was kept.");
7
+ console.log(" Run nucleo-skills setup to sign in again.\n");
8
+ }
@@ -0,0 +1,10 @@
1
+ import type { AgentTool, PackageTarget } from "../config.js";
2
+ export interface SetupOptions {
3
+ /** Skip API; copy a local prepared package directory */
4
+ from?: string;
5
+ target?: PackageTarget;
6
+ tools?: AgentTool[];
7
+ email?: string;
8
+ yes?: boolean;
9
+ }
10
+ export declare function runSetup(options?: SetupOptions): Promise<void>;
@@ -0,0 +1,94 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { confirm, select, checkbox } from "@inquirer/prompts";
3
+ import { ApiError, fetchSkillsRelease } from "../api.js";
4
+ import { installRoot, nucleoHomeDir } from "../config.js";
5
+ import { buildMcp, ensureManifestTarget, installFromLocalDir, installFromRelease, readInstallManifest, } from "../install.js";
6
+ import { configureTool } from "../adapters/agents.js";
7
+ import { ensureSkillsSession, isInvalidTokenError } from "../session.js";
8
+ async function fetchReleaseWithSession(target, emailHint) {
9
+ let session = await ensureSkillsSession(emailHint);
10
+ try {
11
+ return await fetchSkillsRelease(session.token, target);
12
+ }
13
+ catch (error) {
14
+ if (isInvalidTokenError(error)) {
15
+ console.log("Saved session expired or invalid — please sign in again.");
16
+ session = await ensureSkillsSession(emailHint, { forceLogin: true });
17
+ return await fetchSkillsRelease(session.token, target);
18
+ }
19
+ throw error;
20
+ }
21
+ }
22
+ export async function runSetup(options = {}) {
23
+ console.log("\nNucleo Skills setup");
24
+ console.log("───────────────────\n");
25
+ const target = options.target ??
26
+ (await select({
27
+ message: "Which package?",
28
+ choices: [
29
+ { name: "React components", value: "react" },
30
+ { name: "Vanilla SVG", value: "vanilla" },
31
+ ],
32
+ }));
33
+ const tools = options.tools ??
34
+ (await checkbox({
35
+ message: "Configure which tools? (space to toggle, enter to confirm)",
36
+ choices: [
37
+ { name: "Cursor", value: "cursor", checked: true },
38
+ { name: "Claude Code", value: "claude" },
39
+ { name: "Codex", value: "codex" },
40
+ ],
41
+ required: true,
42
+ }));
43
+ if (tools.length === 0) {
44
+ throw new Error("Select at least one tool to configure.");
45
+ }
46
+ await mkdir(nucleoHomeDir(), { recursive: true });
47
+ if (options.from) {
48
+ console.log(`\nInstalling from local folder:\n ${options.from}\n`);
49
+ if (!options.yes) {
50
+ const ok = await confirm({
51
+ message: "Continue? (no API login — for testing only)",
52
+ default: true,
53
+ });
54
+ if (!ok) {
55
+ console.log("Cancelled.");
56
+ return;
57
+ }
58
+ }
59
+ await installFromLocalDir(options.from);
60
+ await ensureManifestTarget(target);
61
+ }
62
+ else {
63
+ console.log(`Fetching ${target} release…`);
64
+ let release;
65
+ try {
66
+ release = await fetchReleaseWithSession(target, options.email);
67
+ }
68
+ catch (error) {
69
+ if (error instanceof ApiError) {
70
+ throw new Error(error.message);
71
+ }
72
+ throw error;
73
+ }
74
+ console.log(` Version: ${release.version}`);
75
+ console.log(` Families: ${release.families.join(", ")}`);
76
+ console.log(` Files: ${release.files.map((f) => f.id).join(", ")}`);
77
+ console.log("\nDownloading…");
78
+ await installFromRelease(release, target);
79
+ }
80
+ console.log("Building MCP…");
81
+ await buildMcp();
82
+ console.log("\nConfiguring agents…");
83
+ for (const tool of tools) {
84
+ await configureTool(tool);
85
+ }
86
+ const manifest = await readInstallManifest();
87
+ console.log("\n✓ Done");
88
+ console.log(` Install path: ${installRoot()}`);
89
+ console.log(` Target: ${manifest?.target ?? target}`);
90
+ console.log(` Version: ${manifest?.version ?? "(none)"}`);
91
+ console.log(` Families: ${(manifest?.families ?? []).join(", ") || "(see manifest)"}`);
92
+ console.log("\nNext: reload MCP in your editor, then ask:");
93
+ console.log(' “What Nucleo icon families do I have?”\n');
94
+ }
@@ -0,0 +1 @@
1
+ export declare function runStatus(): Promise<void>;
@@ -0,0 +1,30 @@
1
+ import { existsSync } from "node:fs";
2
+ import { installRoot, credentialsPath } from "../config.js";
3
+ import { loadCredentials } from "../credentials.js";
4
+ import { mcpEntryPath, readInstallManifest } from "../install.js";
5
+ export async function runStatus() {
6
+ console.log("\nNucleo Skills status");
7
+ console.log("────────────────────\n");
8
+ console.log(`Credentials: ${existsSync(credentialsPath()) ? credentialsPath() : "(none)"}`);
9
+ const creds = await loadCredentials();
10
+ if (creds) {
11
+ console.log(`Signed in: ${creds.email}`);
12
+ console.log(`Token saved: ${creds.savedAt}`);
13
+ }
14
+ else {
15
+ console.log("Signed in: no");
16
+ }
17
+ const root = installRoot();
18
+ console.log(`\nInstall: ${root}`);
19
+ if (!existsSync(root)) {
20
+ console.log(" (not installed — run nucleo-skills setup)\n");
21
+ return;
22
+ }
23
+ const manifest = await readInstallManifest();
24
+ console.log(`Version: ${manifest?.version ?? "(missing)"}`);
25
+ console.log(`Target: ${manifest?.target ?? "(missing)"}`);
26
+ console.log(`Product: ${manifest?.product ?? "(missing)"}`);
27
+ console.log(`Families: ${(manifest?.families ?? []).join(", ") || "(none)"}`);
28
+ console.log(`MCP entry: ${existsSync(mcpEntryPath()) ? mcpEntryPath() : "(missing — run setup/update)"}`);
29
+ console.log("");
30
+ }
@@ -0,0 +1,8 @@
1
+ import { type AgentTool } from "../config.js";
2
+ export interface UpdateOptions {
3
+ from?: string;
4
+ check?: boolean;
5
+ tools?: AgentTool[];
6
+ yes?: boolean;
7
+ }
8
+ export declare function runUpdate(options?: UpdateOptions): Promise<void>;
@@ -0,0 +1,94 @@
1
+ import { confirm } from "@inquirer/prompts";
2
+ import { ApiError, fetchSkillsRelease } from "../api.js";
3
+ import { installRoot } from "../config.js";
4
+ import { buildMcp, ensureManifestTarget, installFromLocalDir, installFromRelease, readInstallManifest, } from "../install.js";
5
+ import { configureTool } from "../adapters/agents.js";
6
+ import { ensureSkillsSession, isInvalidTokenError } from "../session.js";
7
+ async function fetchReleaseWithSession(target) {
8
+ let session = await ensureSkillsSession();
9
+ try {
10
+ return await fetchSkillsRelease(session.token, target);
11
+ }
12
+ catch (error) {
13
+ if (isInvalidTokenError(error)) {
14
+ console.log("Saved session expired or invalid — please sign in again.");
15
+ session = await ensureSkillsSession(undefined, { forceLogin: true });
16
+ return await fetchSkillsRelease(session.token, target);
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+ export async function runUpdate(options = {}) {
22
+ console.log("\nNucleo Skills update");
23
+ console.log("────────────────────\n");
24
+ const manifest = await readInstallManifest();
25
+ if (!manifest && !options.from) {
26
+ throw new Error(`No install found at ${installRoot()}.\nRun: nucleo-skills setup`);
27
+ }
28
+ const target = manifest?.target ?? "react";
29
+ const localVersion = manifest?.version ?? "0.0.0";
30
+ if (options.from) {
31
+ console.log(`Updating from local folder:\n ${options.from}\n`);
32
+ await installFromLocalDir(options.from);
33
+ await ensureManifestTarget(target);
34
+ console.log("Building MCP…");
35
+ await buildMcp();
36
+ await reconfigure(options.tools);
37
+ const next = await readInstallManifest();
38
+ console.log(`\n✓ Updated (local)`);
39
+ console.log(` Version: ${next?.version ?? "(none)"}`);
40
+ return;
41
+ }
42
+ let release;
43
+ try {
44
+ release = await fetchReleaseWithSession(target);
45
+ }
46
+ catch (error) {
47
+ if (error instanceof ApiError) {
48
+ throw new Error(error.message);
49
+ }
50
+ throw error;
51
+ }
52
+ const remoteVersion = release.version;
53
+ console.log(` Installed: ${localVersion}`);
54
+ console.log(` Latest: ${remoteVersion}`);
55
+ console.log(` Families: ${release.families.join(", ")}`);
56
+ if (options.check) {
57
+ if (remoteVersion === localVersion) {
58
+ console.log("\nAlready up to date.");
59
+ }
60
+ else {
61
+ console.log("\nUpdate available. Run: nucleo-skills update");
62
+ }
63
+ return;
64
+ }
65
+ if (remoteVersion === localVersion) {
66
+ console.log("\nAlready up to date.");
67
+ return;
68
+ }
69
+ if (!options.yes) {
70
+ const ok = await confirm({
71
+ message: `Download and install ${remoteVersion}?`,
72
+ default: true,
73
+ });
74
+ if (!ok) {
75
+ console.log("Cancelled.");
76
+ return;
77
+ }
78
+ }
79
+ console.log("\nDownloading…");
80
+ await installFromRelease(release, target);
81
+ console.log("Building MCP…");
82
+ await buildMcp();
83
+ await reconfigure(options.tools ?? ["cursor"]);
84
+ const next = await readInstallManifest();
85
+ console.log(`\n✓ Updated to ${next?.version ?? remoteVersion}`);
86
+ console.log(" Reload MCP in your editor.\n");
87
+ }
88
+ async function reconfigure(tools) {
89
+ const list = tools && tools.length > 0 ? tools : ["cursor"];
90
+ console.log("Refreshing agent config…");
91
+ for (const tool of list) {
92
+ await configureTool(tool);
93
+ }
94
+ }
@@ -0,0 +1,22 @@
1
+ /** Default Nucleo API base. Override with NUCLEO_API_URL. */
2
+ export declare const DEFAULT_API_URL = "https://nucleoapp.com/api";
3
+ export declare function getApiBaseUrl(): string;
4
+ /** Root for credentials + installed skills package */
5
+ export declare function nucleoHomeDir(): string;
6
+ export declare function credentialsPath(): string;
7
+ /** Where the downloaded Pro package lives (MCP, skills, icons) */
8
+ export declare function installRoot(): string;
9
+ export type PackageTarget = "react" | "vanilla";
10
+ export type AgentTool = "cursor" | "claude" | "codex";
11
+ export interface NucleoCredentials {
12
+ email: string;
13
+ token: string;
14
+ savedAt: string;
15
+ }
16
+ export interface InstallManifest {
17
+ product?: string;
18
+ target?: PackageTarget | string;
19
+ version?: string;
20
+ families?: string[];
21
+ [key: string]: unknown;
22
+ }
package/dist/config.js ADDED
@@ -0,0 +1,21 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ /** Default Nucleo API base. Override with NUCLEO_API_URL. */
4
+ export const DEFAULT_API_URL = "https://nucleoapp.com/api";
5
+ export function getApiBaseUrl() {
6
+ return (process.env.NUCLEO_API_URL?.trim() || DEFAULT_API_URL).replace(/\/$/, "");
7
+ }
8
+ /** Root for credentials + installed skills package */
9
+ export function nucleoHomeDir() {
10
+ if (process.env.NUCLEO_HOME?.trim()) {
11
+ return process.env.NUCLEO_HOME.trim();
12
+ }
13
+ return join(homedir(), ".nucleo");
14
+ }
15
+ export function credentialsPath() {
16
+ return join(nucleoHomeDir(), "credentials.json");
17
+ }
18
+ /** Where the downloaded Pro package lives (MCP, skills, icons) */
19
+ export function installRoot() {
20
+ return join(nucleoHomeDir(), "skills");
21
+ }
@@ -0,0 +1,4 @@
1
+ import { type NucleoCredentials } from "./config.js";
2
+ export declare function loadCredentials(): Promise<NucleoCredentials | null>;
3
+ export declare function saveCredentials(creds: Omit<NucleoCredentials, "savedAt">): Promise<void>;
4
+ export declare function clearCredentials(): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { credentialsPath, nucleoHomeDir } from "./config.js";
4
+ export async function loadCredentials() {
5
+ const path = credentialsPath();
6
+ if (!existsSync(path)) {
7
+ return null;
8
+ }
9
+ try {
10
+ const raw = await readFile(path, "utf8");
11
+ const parsed = JSON.parse(raw);
12
+ if (!parsed.token || !parsed.email) {
13
+ return null;
14
+ }
15
+ return parsed;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export async function saveCredentials(creds) {
22
+ await mkdir(nucleoHomeDir(), { recursive: true });
23
+ const path = credentialsPath();
24
+ const payload = {
25
+ ...creds,
26
+ savedAt: new Date().toISOString(),
27
+ };
28
+ await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
29
+ try {
30
+ await chmod(path, 0o600);
31
+ }
32
+ catch {
33
+ // Windows may ignore chmod; that's ok
34
+ }
35
+ }
36
+ export async function clearCredentials() {
37
+ const path = credentialsPath();
38
+ if (existsSync(path)) {
39
+ await rm(path, { force: true });
40
+ }
41
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Command } from "commander";
6
+ import { runLogout } from "./commands/logout.js";
7
+ import { runSetup } from "./commands/setup.js";
8
+ import { runStatus } from "./commands/status.js";
9
+ import { runUpdate } from "./commands/update.js";
10
+ function packageVersion() {
11
+ try {
12
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
13
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
14
+ return pkg.version || "0.0.0";
15
+ }
16
+ catch {
17
+ return "0.0.0";
18
+ }
19
+ }
20
+ function parseTools(value) {
21
+ return value
22
+ .split(",")
23
+ .map((t) => t.trim().toLowerCase())
24
+ .filter(Boolean);
25
+ }
26
+ const program = new Command();
27
+ program
28
+ .name("nucleo-skills")
29
+ .description("Install and update Nucleo Pro MCP + icon skills")
30
+ .version(packageVersion());
31
+ program
32
+ .command("setup")
33
+ .description("Sign in, download your licensed package, configure your editor")
34
+ .option("-t, --target <target>", "react | vanilla")
35
+ .option("--tools <list>", "comma-separated: cursor,claude,codex (default: prompt, or cursor with --yes)")
36
+ .option("--from <dir>", "Install from a local prepared package (skip API download)")
37
+ .option("--email <email>", "Nucleo account email (skip prompt)")
38
+ .option("-y, --yes", "Skip confirmation prompts")
39
+ .action(async (opts) => {
40
+ try {
41
+ const tools = opts.tools
42
+ ? parseTools(opts.tools)
43
+ : opts.yes
44
+ ? ["cursor"]
45
+ : undefined;
46
+ await runSetup({
47
+ from: opts.from,
48
+ target: opts.target,
49
+ tools,
50
+ email: opts.email,
51
+ yes: Boolean(opts.yes),
52
+ });
53
+ }
54
+ catch (error) {
55
+ console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
56
+ process.exitCode = 1;
57
+ }
58
+ });
59
+ program
60
+ .command("update")
61
+ .description("Download the latest package and refresh MCP/skills")
62
+ .option("--from <dir>", "Update from a local prepared package (skip API)")
63
+ .option("--check", "Only check whether an update is available")
64
+ .option("--tools <list>", "comma-separated tools to refresh", "cursor")
65
+ .option("-y, --yes", "Skip confirmation prompts")
66
+ .action(async (opts) => {
67
+ try {
68
+ await runUpdate({
69
+ from: opts.from,
70
+ check: Boolean(opts.check),
71
+ tools: parseTools(opts.tools),
72
+ yes: Boolean(opts.yes),
73
+ });
74
+ }
75
+ catch (error) {
76
+ console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
77
+ process.exitCode = 1;
78
+ }
79
+ });
80
+ program
81
+ .command("status")
82
+ .description("Show install path, version, and sign-in state")
83
+ .action(async () => {
84
+ try {
85
+ await runStatus();
86
+ }
87
+ catch (error) {
88
+ console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
89
+ process.exitCode = 1;
90
+ }
91
+ });
92
+ program
93
+ .command("logout")
94
+ .description("Remove saved Nucleo credentials")
95
+ .action(async () => {
96
+ try {
97
+ await runLogout();
98
+ }
99
+ catch (error) {
100
+ console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
101
+ process.exitCode = 1;
102
+ }
103
+ });
104
+ program.parse();
@@ -0,0 +1,18 @@
1
+ import { type SkillsReleaseData } from "./api.js";
2
+ import { type InstallManifest, type PackageTarget } from "./config.js";
3
+ export declare function readInstallManifest(root?: string): Promise<InstallManifest | null>;
4
+ export declare function cleanInstallRoot(root?: string): Promise<void>;
5
+ /**
6
+ * Download all release files (signed URLs) and merge into the install root.
7
+ */
8
+ export declare function installFromRelease(release: SkillsReleaseData, target: PackageTarget, root?: string): Promise<void>;
9
+ /**
10
+ * Copy a local prepared package (e.g. packages/react) into the install root.
11
+ */
12
+ export declare function installFromLocalDir(sourceDir: string, root?: string): Promise<void>;
13
+ export declare function writeInstallManifest(manifest: InstallManifest, root?: string): Promise<void>;
14
+ export declare function ensureManifestTarget(target: PackageTarget, extras?: Partial<InstallManifest>, root?: string): Promise<InstallManifest>;
15
+ /** Ensure mcp deps are installed and dist is built */
16
+ export declare function buildMcp(root?: string): Promise<void>;
17
+ export declare function mcpEntryPath(root?: string): string;
18
+ export declare function skillsSourceDir(root?: string): string;
@@ -0,0 +1,145 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import extract from "extract-zip";
7
+ import { downloadUrlToFile } from "./api.js";
8
+ import { installRoot, nucleoHomeDir } from "./config.js";
9
+ const execFileAsync = promisify(execFile);
10
+ export async function readInstallManifest(root = installRoot()) {
11
+ const path = join(root, "manifest.json");
12
+ if (!existsSync(path)) {
13
+ return null;
14
+ }
15
+ try {
16
+ return JSON.parse(await readFile(path, "utf8"));
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export async function cleanInstallRoot(root = installRoot()) {
23
+ await rm(root, { recursive: true, force: true });
24
+ await mkdir(root, { recursive: true });
25
+ }
26
+ /** Extract one zip into dest, merging (does not wipe dest first). */
27
+ 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 });
31
+ try {
32
+ await extract(zipPath, { dir: tmp });
33
+ await mkdir(dest, { recursive: true });
34
+ await cp(tmp, dest, { recursive: true });
35
+ }
36
+ finally {
37
+ await rm(tmp, { recursive: true, force: true });
38
+ }
39
+ }
40
+ /**
41
+ * Download all release files (signed URLs) and merge into the install root.
42
+ */
43
+ export async function installFromRelease(release, target, root = installRoot()) {
44
+ if (!release.downloadReady) {
45
+ throw new Error(release.message ||
46
+ "Downloads are not ready on the server yet (R2 not configured or urls missing).");
47
+ }
48
+ const missingUrl = release.files.filter((f) => !f.url);
49
+ if (missingUrl.length > 0) {
50
+ throw new Error(`Release files missing download urls: ${missingUrl.map((f) => f.id).join(", ")}`);
51
+ }
52
+ await cleanInstallRoot(root);
53
+ const downloadsDir = join(nucleoHomeDir(), "downloads");
54
+ await mkdir(downloadsDir, { recursive: true });
55
+ try {
56
+ for (const file of release.files) {
57
+ 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);
62
+ }
63
+ }
64
+ finally {
65
+ await rm(downloadsDir, { recursive: true, force: true }).catch(() => undefined);
66
+ }
67
+ await writeInstallManifest({
68
+ product: "nucleo-skills",
69
+ target,
70
+ version: release.version,
71
+ families: release.families,
72
+ }, root);
73
+ }
74
+ /**
75
+ * Copy a local prepared package (e.g. packages/react) into the install root.
76
+ */
77
+ export async function installFromLocalDir(sourceDir, root = installRoot()) {
78
+ if (!existsSync(sourceDir)) {
79
+ throw new Error(`Local package path not found: ${sourceDir}`);
80
+ }
81
+ await cleanInstallRoot(root);
82
+ await cp(sourceDir, root, { recursive: true });
83
+ }
84
+ export async function writeInstallManifest(manifest, root = installRoot()) {
85
+ await mkdir(root, { recursive: true });
86
+ await writeFile(join(root, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
87
+ }
88
+ export async function ensureManifestTarget(target, extras = {}, root = installRoot()) {
89
+ const path = join(root, "manifest.json");
90
+ let manifest = {};
91
+ if (existsSync(path)) {
92
+ try {
93
+ manifest = JSON.parse(await readFile(path, "utf8"));
94
+ }
95
+ catch {
96
+ manifest = {};
97
+ }
98
+ }
99
+ manifest = {
100
+ ...manifest,
101
+ ...extras,
102
+ target: extras.target ?? manifest.target ?? target,
103
+ version: extras.version ?? manifest.version ?? "0.0.0-local",
104
+ };
105
+ if (extras.families) {
106
+ manifest.families = extras.families;
107
+ }
108
+ await writeInstallManifest(manifest, root);
109
+ return manifest;
110
+ }
111
+ /** Ensure mcp deps are installed and dist is built */
112
+ export async function buildMcp(root = installRoot()) {
113
+ const mcpDir = join(root, "mcp");
114
+ if (!existsSync(mcpDir)) {
115
+ throw new Error(`MCP folder missing at ${mcpDir}. Package may be incomplete.`);
116
+ }
117
+ const distEntry = join(mcpDir, "dist", "index.js");
118
+ const hasDist = existsSync(distEntry);
119
+ await execFileAsync("npm", ["install", "--omit=dev"], {
120
+ cwd: mcpDir,
121
+ env: process.env,
122
+ });
123
+ if (!hasDist || existsSync(join(mcpDir, "src"))) {
124
+ try {
125
+ await execFileAsync("npm", ["run", "build"], {
126
+ cwd: mcpDir,
127
+ env: process.env,
128
+ });
129
+ }
130
+ catch (error) {
131
+ if (!existsSync(distEntry)) {
132
+ throw error;
133
+ }
134
+ }
135
+ }
136
+ if (!existsSync(distEntry)) {
137
+ throw new Error(`MCP build missing: expected ${distEntry}`);
138
+ }
139
+ }
140
+ export function mcpEntryPath(root = installRoot()) {
141
+ return join(root, "mcp", "dist", "index.js");
142
+ }
143
+ export function skillsSourceDir(root = installRoot()) {
144
+ return join(root, "skills");
145
+ }
@@ -0,0 +1,12 @@
1
+ export interface SkillsSession {
2
+ email: string;
3
+ token: string;
4
+ }
5
+ /**
6
+ * Return a skills session. Reuses ~/.nucleo/credentials.json when present.
7
+ * Pass forceLogin=true after an invalid-token release error.
8
+ */
9
+ export declare function ensureSkillsSession(emailHint?: string, options?: {
10
+ forceLogin?: boolean;
11
+ }): Promise<SkillsSession>;
12
+ export declare function isInvalidTokenError(error: unknown): boolean;
@@ -0,0 +1,64 @@
1
+ import { password, input } from "@inquirer/prompts";
2
+ import { ApiError, skillsLogin } from "./api.js";
3
+ import { clearCredentials, loadCredentials, saveCredentials } from "./credentials.js";
4
+ function formatLoginError(error) {
5
+ const msg = error.message;
6
+ if (/wrong credentials|authentication/i.test(msg)) {
7
+ return "Wrong email or password. Check your Nucleo credentials and try again.";
8
+ }
9
+ if (/no .+icon|icon family|icon access/i.test(msg)) {
10
+ return "This Nucleo account has no icon family access. A Pro icon license is required.";
11
+ }
12
+ if (error.status === 404 || error.status === 501) {
13
+ return "Could not sign in right now. Please try again later.";
14
+ }
15
+ if (!error.status) {
16
+ return "Could not reach Nucleo. Check your internet connection and try again.";
17
+ }
18
+ return msg;
19
+ }
20
+ async function promptLogin(emailHint) {
21
+ const email = emailHint ??
22
+ (await input({
23
+ message: "Nucleo email",
24
+ validate: (v) => (v.includes("@") ? true : "Enter a valid email"),
25
+ }));
26
+ const pwd = await password({ message: "Password", mask: "*" });
27
+ let loginResult;
28
+ try {
29
+ loginResult = await skillsLogin(email, pwd);
30
+ }
31
+ catch (error) {
32
+ if (error instanceof ApiError) {
33
+ throw new Error(formatLoginError(error));
34
+ }
35
+ throw error;
36
+ }
37
+ const resolvedEmail = loginResult.email || email;
38
+ await saveCredentials({ email: resolvedEmail, token: loginResult.token });
39
+ console.log(`✓ Signed in as ${resolvedEmail}`);
40
+ console.log(` Families: ${loginResult.families.join(", ")}`);
41
+ return { email: resolvedEmail, token: loginResult.token };
42
+ }
43
+ /**
44
+ * Return a skills session. Reuses ~/.nucleo/credentials.json when present.
45
+ * Pass forceLogin=true after an invalid-token release error.
46
+ */
47
+ export async function ensureSkillsSession(emailHint, options = {}) {
48
+ if (!options.forceLogin) {
49
+ const existing = await loadCredentials();
50
+ if (existing?.token) {
51
+ console.log(`✓ Signed in as ${existing.email}`);
52
+ return { email: existing.email, token: existing.token };
53
+ }
54
+ }
55
+ else {
56
+ await clearCredentials();
57
+ }
58
+ return promptLogin(emailHint);
59
+ }
60
+ export function isInvalidTokenError(error) {
61
+ return (error instanceof ApiError &&
62
+ (error.status === 400 || error.status === 401) &&
63
+ /token|credentials|authentication/i.test(error.message));
64
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@nucleoicons/skills",
3
+ "version": "0.1.0",
4
+ "description": "CLI to install and update Nucleo Pro MCP + icon skills",
5
+ "type": "module",
6
+ "bin": {
7
+ "nucleo-skills": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsc --watch",
16
+ "start": "node dist/index.js",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "keywords": [
23
+ "nucleo",
24
+ "icons",
25
+ "mcp",
26
+ "cursor",
27
+ "cli",
28
+ "skills"
29
+ ],
30
+ "license": "UNLICENSED",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "homepage": "https://nucleoapp.com",
35
+ "dependencies": {
36
+ "@inquirer/prompts": "^7.5.0",
37
+ "commander": "^13.1.0",
38
+ "extract-zip": "^2.0.1"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.13.10",
42
+ "typescript": "^5.8.2"
43
+ }
44
+ }