@matchkit.io/cli 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.
@@ -0,0 +1 @@
1
+ export declare function addCommand(componentName: string): Promise<void>;
@@ -0,0 +1,84 @@
1
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import * as p from "@clack/prompts";
4
+ import pc from "picocolors";
5
+ import { readConfig } from "../utils/config.js";
6
+ import { fetchComponent, fetchRegistry, isComponentInstalled, } from "../utils/registry.js";
7
+ export async function addCommand(componentName) {
8
+ const s = p.spinner();
9
+ try {
10
+ const config = readConfig();
11
+ // 1. Fetch the registry to find the component
12
+ s.start(`Resolving ${pc.cyan(componentName)}...`);
13
+ const registry = await fetchRegistry(config);
14
+ const component = registry.components.find((c) => c.name === componentName);
15
+ if (!component) {
16
+ s.stop(`Component ${pc.red(componentName)} not found`);
17
+ const available = registry.components.map((c) => c.name).join(", ");
18
+ p.log.error(`Component "${componentName}" not found in the ${config.theme} registry.\n\nAvailable: ${pc.dim(available)}`);
19
+ process.exit(1);
20
+ }
21
+ // 2. Check if already installed
22
+ if (isComponentInstalled(config.skillDir, component.file)) {
23
+ s.stop(`${pc.yellow(componentName)} already installed`);
24
+ const overwrite = await p.confirm({
25
+ message: `${componentName} is already installed. Overwrite?`,
26
+ });
27
+ if (p.isCancel(overwrite) || !overwrite) {
28
+ p.cancel("Cancelled.");
29
+ process.exit(0);
30
+ }
31
+ s.start(`Fetching ${pc.cyan(componentName)}...`);
32
+ }
33
+ // 3. Resolve registry dependencies (install those first)
34
+ const toInstall = [];
35
+ if (component.registryDependencies.length > 0) {
36
+ for (const dep of component.registryDependencies) {
37
+ if (!isComponentInstalled(config.skillDir, `components/${dep}.tsx`)) {
38
+ toInstall.push(dep);
39
+ }
40
+ }
41
+ }
42
+ // 4. Fetch and write the component (and deps)
43
+ const allComponents = [componentName, ...toInstall];
44
+ const installed = [];
45
+ const npmDeps = [...component.dependencies];
46
+ for (const name of allComponents) {
47
+ const data = await fetchComponent(config, name);
48
+ const targetPath = join(process.cwd(), config.skillDir, `components/${name}.tsx`);
49
+ // Ensure directory exists
50
+ const dir = dirname(targetPath);
51
+ if (!existsSync(dir)) {
52
+ mkdirSync(dir, { recursive: true });
53
+ }
54
+ writeFileSync(targetPath, data.source);
55
+ installed.push(name);
56
+ // Collect npm dependencies
57
+ for (const d of data.dependencies) {
58
+ if (!npmDeps.includes(d)) {
59
+ npmDeps.push(d);
60
+ }
61
+ }
62
+ }
63
+ s.stop(`Installed ${pc.green(installed.length.toString())} component(s)`);
64
+ // 5. Summary
65
+ const lines = [
66
+ `${pc.cyan("Component:")} ${componentName} (Layer ${component.layer})`,
67
+ `${pc.cyan("Path:")} ${config.skillDir}/components/${componentName}.tsx`,
68
+ ];
69
+ if (toInstall.length > 0) {
70
+ lines.push(`${pc.cyan("Dependencies:")} ${toInstall.map((d) => pc.dim(d)).join(", ")}`);
71
+ }
72
+ if (npmDeps.length > 0) {
73
+ lines.push("");
74
+ lines.push(`${pc.yellow("npm dependencies needed:")}`);
75
+ lines.push(` npm install ${npmDeps.join(" ")}`);
76
+ }
77
+ p.note(lines.join("\n"), "Added");
78
+ }
79
+ catch (err) {
80
+ s.stop("Failed");
81
+ p.log.error(err instanceof Error ? err.message : "An unknown error occurred");
82
+ process.exit(1);
83
+ }
84
+ }
@@ -0,0 +1 @@
1
+ export declare function diffCommand(): Promise<void>;
@@ -0,0 +1,52 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import * as p from "@clack/prompts";
4
+ import pc from "picocolors";
5
+ import { readConfig, configExists } from "../utils/config.js";
6
+ import { fetchRegistry } from "../utils/registry.js";
7
+ export async function diffCommand() {
8
+ if (!configExists()) {
9
+ p.log.error("No .matchkit/config.json found. Run `matchkit init` first.");
10
+ process.exit(1);
11
+ }
12
+ const config = readConfig();
13
+ const s = p.spinner();
14
+ s.start("Comparing local vs registry...");
15
+ try {
16
+ const registry = await fetchRegistry(config);
17
+ s.stop("Registry fetched");
18
+ const results = {
19
+ missing: [],
20
+ installed: [],
21
+ };
22
+ for (const component of registry.components) {
23
+ const localPath = join(process.cwd(), config.skillDir, component.file);
24
+ if (existsSync(localPath)) {
25
+ results.installed.push(component);
26
+ }
27
+ else {
28
+ results.missing.push(component);
29
+ }
30
+ }
31
+ console.log("");
32
+ console.log(` ${pc.cyan(config.theme + "-ui")} · ${results.installed.length} installed, ${results.missing.length} missing`);
33
+ console.log("");
34
+ if (results.missing.length > 0) {
35
+ console.log(` ${pc.yellow("Missing components:")}`);
36
+ for (const c of results.missing) {
37
+ console.log(` ${pc.dim("○")} ${c.name}`);
38
+ }
39
+ console.log("");
40
+ console.log(` Run ${pc.cyan("matchkit add <name>")} to install missing components.`);
41
+ }
42
+ else {
43
+ console.log(` ${pc.green("All components are installed.")} Your skill is complete.`);
44
+ }
45
+ console.log("");
46
+ }
47
+ catch (err) {
48
+ s.stop("Failed");
49
+ p.log.error(err instanceof Error ? err.message : "Unknown error");
50
+ process.exit(1);
51
+ }
52
+ }
@@ -0,0 +1 @@
1
+ export declare function initCommand(): Promise<void>;
@@ -0,0 +1,211 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { configExists, writeConfig, createDefaultConfig } from "../utils/config.js";
4
+ import { isAuthenticated, authFetch, API_BASE_URL } from "../utils/auth.js";
5
+ const THEMES = [
6
+ { value: "clarity", label: "Clarity UI", hint: "Free — Sharp, structured B2B SaaS" },
7
+ { value: "soft", label: "Soft UI", hint: "Pro — Warm-minimal for AI tools" },
8
+ { value: "brutal", label: "Brutal UI", hint: "Pro — Neobrutalism — bold, graphic" },
9
+ { value: "glass", label: "Glass UI", hint: "Pro — Technical, translucent" },
10
+ ];
11
+ const ACCENT_PRESETS = [
12
+ { value: "#4F46E5", label: "Indigo" },
13
+ { value: "#0EA5E9", label: "Sky Blue" },
14
+ { value: "#10B981", label: "Emerald" },
15
+ { value: "#F59E0B", label: "Amber" },
16
+ { value: "#EF4444", label: "Red" },
17
+ { value: "#8B5CF6", label: "Violet" },
18
+ { value: "#EC4899", label: "Pink" },
19
+ { value: "custom", label: "Custom hex" },
20
+ ];
21
+ export async function initCommand() {
22
+ p.intro(pc.bgCyan(pc.black(" matchkit init ")));
23
+ if (configExists()) {
24
+ const shouldOverwrite = await p.confirm({
25
+ message: "A .matchkit/config.json already exists. Overwrite it?",
26
+ });
27
+ if (p.isCancel(shouldOverwrite) || !shouldOverwrite) {
28
+ p.cancel("Init cancelled.");
29
+ process.exit(0);
30
+ }
31
+ }
32
+ // Step 1: Pick theme
33
+ const theme = await p.select({
34
+ message: "Pick a theme:",
35
+ options: THEMES,
36
+ });
37
+ if (p.isCancel(theme)) {
38
+ p.cancel("Init cancelled.");
39
+ process.exit(0);
40
+ }
41
+ // Premium themes require authentication
42
+ if (theme !== "clarity" && !isAuthenticated()) {
43
+ p.log.warn(`${pc.bold(String(theme))} is a premium theme. Run ${pc.bold("matchkit login")} first.`);
44
+ p.log.info(`Get your API key at ${pc.cyan("matchkit.io/app/keys")}`);
45
+ p.outro(`Or use ${pc.bold("clarity")} (free) — all 27 components, no account needed.`);
46
+ process.exit(0);
47
+ }
48
+ // Step 2: Pick accent color
49
+ const accentChoice = await p.select({
50
+ message: "Pick an accent color:",
51
+ options: ACCENT_PRESETS,
52
+ });
53
+ if (p.isCancel(accentChoice)) {
54
+ p.cancel("Init cancelled.");
55
+ process.exit(0);
56
+ }
57
+ let accent = accentChoice;
58
+ if (accent === "custom") {
59
+ const customHex = await p.text({
60
+ message: "Enter a hex color (e.g. #FF6B35):",
61
+ validate: (val) => {
62
+ if (!/^#[0-9A-Fa-f]{6}$/.test(val)) {
63
+ return "Please enter a valid hex color (e.g. #FF6B35)";
64
+ }
65
+ },
66
+ });
67
+ if (p.isCancel(customHex)) {
68
+ p.cancel("Init cancelled.");
69
+ process.exit(0);
70
+ }
71
+ accent = customHex;
72
+ }
73
+ // Step 3: Use default axes or customize
74
+ const useDefaults = await p.confirm({
75
+ message: "Use default axis settings for this theme?",
76
+ initialValue: true,
77
+ });
78
+ if (p.isCancel(useDefaults)) {
79
+ p.cancel("Init cancelled.");
80
+ process.exit(0);
81
+ }
82
+ let overrides = {};
83
+ if (!useDefaults) {
84
+ const radius = await p.select({
85
+ message: "Corner style (radius):",
86
+ options: [
87
+ { value: "sm", label: "Sharp", hint: "2-4px" },
88
+ { value: "md", label: "Moderate", hint: "4-8px" },
89
+ { value: "lg", label: "Rounded", hint: "8-12px" },
90
+ { value: "xl", label: "Pill", hint: "12-16px" },
91
+ ],
92
+ });
93
+ if (p.isCancel(radius)) {
94
+ p.cancel("Init cancelled.");
95
+ process.exit(0);
96
+ }
97
+ const buttonStyle = await p.select({
98
+ message: "Button style:",
99
+ options: [
100
+ { value: "filled", label: "Filled", hint: "Solid background" },
101
+ { value: "outline", label: "Outline", hint: "Border only" },
102
+ { value: "ghost", label: "Ghost", hint: "No background or border" },
103
+ { value: "soft-fill", label: "Soft fill", hint: "Muted background" },
104
+ ],
105
+ });
106
+ if (p.isCancel(buttonStyle)) {
107
+ p.cancel("Init cancelled.");
108
+ process.exit(0);
109
+ }
110
+ const density = await p.select({
111
+ message: "Spacing density:",
112
+ options: [
113
+ { value: "compact", label: "Compact", hint: "Tight spacing" },
114
+ { value: "default", label: "Default", hint: "Standard spacing" },
115
+ { value: "spacious", label: "Spacious", hint: "Generous spacing" },
116
+ ],
117
+ });
118
+ if (p.isCancel(density)) {
119
+ p.cancel("Init cancelled.");
120
+ process.exit(0);
121
+ }
122
+ overrides = {
123
+ radius: radius,
124
+ "button-style": buttonStyle,
125
+ "spacing-density": density,
126
+ };
127
+ }
128
+ // Write local config first
129
+ const config = createDefaultConfig(theme, accent, overrides);
130
+ writeConfig(config);
131
+ // Step 4: Link to a server-side project (premium + authenticated only)
132
+ let linkedConfigId;
133
+ if (theme !== "clarity" && isAuthenticated()) {
134
+ const s = p.spinner();
135
+ s.start("Checking your projects...");
136
+ try {
137
+ const res = await authFetch(`${API_BASE_URL}/api/v1/configs`);
138
+ if (res.ok) {
139
+ const userConfigs = (await res.json());
140
+ // Filter to configs matching the selected theme
141
+ const matching = userConfigs.filter((c) => c.theme === theme);
142
+ s.stop(matching.length > 0
143
+ ? `Found ${matching.length} ${theme} project${matching.length > 1 ? "s" : ""}`
144
+ : "No projects found");
145
+ if (matching.length > 0) {
146
+ const choice = await p.select({
147
+ message: "Link to an existing project?",
148
+ options: [
149
+ ...matching.map((c) => ({
150
+ value: c.id,
151
+ label: c.name,
152
+ hint: `${c.id} · v${c.version}`,
153
+ })),
154
+ {
155
+ value: "__skip__",
156
+ label: "Skip — just use local config",
157
+ hint: "You can link later",
158
+ },
159
+ ],
160
+ });
161
+ if (!p.isCancel(choice) && choice !== "__skip__") {
162
+ linkedConfigId = choice;
163
+ }
164
+ }
165
+ else {
166
+ p.log.info(`No ${pc.bold(String(theme))} projects found. Create one at ${pc.cyan("matchkit.io/app")}`);
167
+ }
168
+ }
169
+ else {
170
+ s.stop("Could not fetch projects");
171
+ }
172
+ }
173
+ catch {
174
+ s.stop("Offline — skipping project linking");
175
+ }
176
+ // Update config with configId if linked
177
+ if (linkedConfigId) {
178
+ const updatedConfig = { ...config, configId: linkedConfigId };
179
+ writeConfig(updatedConfig);
180
+ p.log.success(`Linked to ${pc.dim(linkedConfigId)}`);
181
+ }
182
+ }
183
+ // Summary
184
+ const summaryLines = [
185
+ `${pc.cyan("Theme:")} ${theme}`,
186
+ `${pc.cyan("Accent:")} ${accent}`,
187
+ `${pc.cyan("Overrides:")} ${Object.keys(overrides).length === 0 ? "defaults" : Object.entries(overrides).map(([k, v]) => `${k}=${v}`).join(", ")}`,
188
+ `${pc.cyan("Config:")} .matchkit/config.json`,
189
+ `${pc.cyan("Skill dir:")} ${config.skillDir}`,
190
+ ];
191
+ if (linkedConfigId) {
192
+ summaryLines.push(`${pc.cyan("Project ID:")} ${linkedConfigId}`);
193
+ summaryLines.push("");
194
+ summaryLines.push(`Next steps:`);
195
+ summaryLines.push(` ${pc.green("matchkit pull")} Sync your design system`);
196
+ summaryLines.push(` ${pc.green("matchkit list")} See all available components`);
197
+ }
198
+ else {
199
+ summaryLines.push("");
200
+ summaryLines.push(`Next steps:`);
201
+ summaryLines.push(` ${pc.green("matchkit add button")} Add your first component`);
202
+ summaryLines.push(` ${pc.green("matchkit list")} See all available components`);
203
+ }
204
+ p.note(summaryLines.join("\n"), "Configuration saved");
205
+ // Gentle upsell for free users
206
+ if (theme === "clarity" && !isAuthenticated()) {
207
+ p.log.info(pc.dim("Unlock 3 more themes + custom axes → ") +
208
+ pc.cyan("matchkit.io/configure"));
209
+ }
210
+ p.outro(pc.green("MatchKit initialized!"));
211
+ }
@@ -0,0 +1 @@
1
+ export declare function listCommand(): Promise<void>;
@@ -0,0 +1,58 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { readConfig, configExists } from "../utils/config.js";
4
+ import { loadLocalRegistry, isComponentInstalled, fetchRegistry, } from "../utils/registry.js";
5
+ export async function listCommand() {
6
+ if (!configExists()) {
7
+ p.log.error("No .matchkit/config.json found. Run `matchkit init` first.");
8
+ process.exit(1);
9
+ }
10
+ const config = readConfig();
11
+ const s = p.spinner();
12
+ // Try local registry first, fall back to remote
13
+ let components;
14
+ const localRegistry = loadLocalRegistry(config.skillDir);
15
+ if (localRegistry) {
16
+ components = localRegistry.components;
17
+ }
18
+ else {
19
+ s.start("Fetching component registry...");
20
+ try {
21
+ const registry = await fetchRegistry(config);
22
+ components = registry.components;
23
+ s.stop("Registry loaded");
24
+ }
25
+ catch (err) {
26
+ s.stop("Failed to fetch registry");
27
+ p.log.error(err instanceof Error ? err.message : "Unknown error");
28
+ process.exit(1);
29
+ }
30
+ }
31
+ // Group by layer
32
+ const layer1 = components.filter((c) => c.layer === 1);
33
+ const layer2 = components.filter((c) => c.layer === 2);
34
+ const formatComponent = (c) => {
35
+ const installed = isComponentInstalled(config.skillDir, c.file);
36
+ const status = installed
37
+ ? pc.green("●")
38
+ : pc.dim("○");
39
+ const name = installed ? pc.white(c.name) : pc.dim(c.name);
40
+ return ` ${status} ${name.padEnd(24)} ${pc.dim(c.description)}`;
41
+ };
42
+ const installedCount = components.filter((c) => isComponentInstalled(config.skillDir, c.file)).length;
43
+ console.log("");
44
+ console.log(` ${pc.cyan(config.theme + "-ui")} · ${pc.dim(`${installedCount}/${components.length} installed`)}`);
45
+ console.log("");
46
+ console.log(` ${pc.bold("Layer 1 — Primitives")} (${layer1.length})`);
47
+ for (const c of layer1) {
48
+ console.log(formatComponent(c));
49
+ }
50
+ console.log("");
51
+ console.log(` ${pc.bold("Layer 2 — Composites")} (${layer2.length})`);
52
+ for (const c of layer2) {
53
+ console.log(formatComponent(c));
54
+ }
55
+ console.log("");
56
+ console.log(` ${pc.green("●")} installed ${pc.dim("○")} available — use ${pc.cyan("matchkit add <name>")} to install`);
57
+ console.log("");
58
+ }
@@ -0,0 +1,3 @@
1
+ export declare function loginCommand(options?: {
2
+ key?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,140 @@
1
+ import * as http from "node:http";
2
+ import { exec } from "node:child_process";
3
+ import { platform } from "node:os";
4
+ import * as p from "@clack/prompts";
5
+ import pc from "picocolors";
6
+ import { saveApiKey, getApiKey, API_BASE_URL } from "../utils/auth.js";
7
+ /**
8
+ * Open a URL in the user's default browser.
9
+ */
10
+ function openBrowser(url) {
11
+ const cmd = platform() === "darwin"
12
+ ? "open"
13
+ : platform() === "win32"
14
+ ? "start"
15
+ : "xdg-open";
16
+ exec(`${cmd} "${url}"`);
17
+ }
18
+ /**
19
+ * Verify an API key by hitting the user endpoint.
20
+ */
21
+ async function verifyKey(apiKey) {
22
+ try {
23
+ const res = await fetch(`${API_BASE_URL}/api/v1/user`, {
24
+ headers: { Authorization: `Bearer ${apiKey}` },
25
+ });
26
+ if (!res.ok)
27
+ return null;
28
+ return await res.json();
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ export async function loginCommand(options) {
35
+ p.intro(pc.bold("matchkit login"));
36
+ const existing = getApiKey();
37
+ if (existing) {
38
+ const overwrite = await p.confirm({
39
+ message: "You already have an API key stored. Replace it?",
40
+ });
41
+ if (p.isCancel(overwrite) || !overwrite) {
42
+ p.outro("Keeping existing key.");
43
+ return;
44
+ }
45
+ }
46
+ // --key flag or non-interactive: fall back to manual key entry
47
+ if (options?.key || !process.stdin.isTTY) {
48
+ const apiKey = options?.key ?? process.env.MATCHKIT_API_KEY;
49
+ if (!apiKey) {
50
+ p.log.error("No API key provided. Use --key <key> or set MATCHKIT_API_KEY.");
51
+ process.exit(1);
52
+ }
53
+ const s = p.spinner();
54
+ s.start("Verifying key...");
55
+ const user = await verifyKey(apiKey);
56
+ if (!user) {
57
+ s.stop("Invalid API key");
58
+ p.log.error("This API key is not valid. Check it and try again.");
59
+ process.exit(1);
60
+ }
61
+ s.stop("Key verified");
62
+ saveApiKey(apiKey);
63
+ p.log.success(`Logged in as ${pc.bold(user.email ?? "unknown")}`);
64
+ p.outro("Key stored in " + pc.dim("~/.matchkit/credentials"));
65
+ return;
66
+ }
67
+ // Browser-based auth flow
68
+ const s = p.spinner();
69
+ const callbackPromise = new Promise((resolve, reject) => {
70
+ const server = http.createServer((req, res) => {
71
+ const url = new URL(req.url ?? "/", `http://127.0.0.1`);
72
+ if (url.pathname === "/callback") {
73
+ const key = url.searchParams.get("key");
74
+ res.writeHead(200, {
75
+ "Content-Type": "text/html",
76
+ "Connection": "close",
77
+ });
78
+ res.end(`
79
+ <html>
80
+ <body style="font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #FAF7F2;">
81
+ <div style="text-align: center;">
82
+ <div style="font-size: 48px; margin-bottom: 12px;">&#10003;</div>
83
+ <h2 style="color: #1A1916; margin-bottom: 8px;">Authenticated</h2>
84
+ <p style="color: #8A8579;">You can close this tab and return to your terminal.</p>
85
+ </div>
86
+ </body>
87
+ </html>
88
+ `);
89
+ server.close();
90
+ clearTimeout(timer);
91
+ if (key) {
92
+ resolve(key);
93
+ }
94
+ else {
95
+ reject(new Error("No API key received in callback"));
96
+ }
97
+ }
98
+ else {
99
+ res.writeHead(404);
100
+ res.end("Not found");
101
+ }
102
+ });
103
+ const timer = setTimeout(() => {
104
+ server.close();
105
+ reject(new Error("Login timed out after 2 minutes. Try again."));
106
+ }, 120_000);
107
+ server.listen(0, "127.0.0.1", () => {
108
+ const addr = server.address();
109
+ const loginUrl = `${API_BASE_URL}/cli-auth?port=${addr.port}`;
110
+ p.log.info(`Opening browser to ${pc.cyan(loginUrl)}`);
111
+ openBrowser(loginUrl);
112
+ s.start("Waiting for browser authentication...");
113
+ });
114
+ });
115
+ try {
116
+ const key = await callbackPromise;
117
+ s.stop("Key received");
118
+ const vs = p.spinner();
119
+ vs.start("Verifying key...");
120
+ const user = await verifyKey(key);
121
+ if (!user) {
122
+ vs.stop("Verification failed");
123
+ p.log.error("The received key could not be verified. Try again.");
124
+ process.exit(1);
125
+ }
126
+ vs.stop("Key verified");
127
+ saveApiKey(key);
128
+ p.log.success(`Logged in as ${pc.bold(user.email ?? "unknown")}`);
129
+ p.log.info(`Tier: ${pc.cyan(user.tier ?? "free")}`);
130
+ p.log.info(`Key stored in ${pc.dim("~/.matchkit/credentials")}`);
131
+ p.outro("Run " +
132
+ pc.bold("matchkit init") +
133
+ " to link a project.");
134
+ }
135
+ catch (err) {
136
+ s.stop("Login failed");
137
+ p.log.error(err instanceof Error ? err.message : "Could not complete login.");
138
+ process.exit(1);
139
+ }
140
+ }
@@ -0,0 +1 @@
1
+ export declare function pullCommand(): Promise<void>;
@@ -0,0 +1,112 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { readConfig, configExists, writeConfig } from "../utils/config.js";
6
+ import { getApiKey, authFetch, API_BASE_URL } from "../utils/auth.js";
7
+ export async function pullCommand() {
8
+ p.intro(pc.bold("matchkit pull"));
9
+ // Check auth
10
+ const apiKey = getApiKey();
11
+ if (!apiKey) {
12
+ p.log.error("Not logged in. Run " + pc.bold("matchkit login") + " first.");
13
+ process.exit(1);
14
+ }
15
+ // Check config
16
+ if (!configExists()) {
17
+ p.log.error("No .matchkit/config.json found. Run " +
18
+ pc.bold("matchkit init") +
19
+ " first.");
20
+ process.exit(1);
21
+ }
22
+ const config = readConfig();
23
+ const configId = config.configId;
24
+ if (!configId) {
25
+ p.log.error("No configId in .matchkit/config.json. This project was set up before the hosted resolution model.");
26
+ p.log.info("Create a config at " +
27
+ pc.cyan("matchkit.io/app") +
28
+ " and run " +
29
+ pc.bold("matchkit init") +
30
+ " again.");
31
+ process.exit(1);
32
+ }
33
+ const s = p.spinner();
34
+ s.start("Pulling latest design system...");
35
+ try {
36
+ // Get the download from the server — may return JSON with downloadUrl
37
+ // or stream the ZIP directly (when R2 is unavailable)
38
+ const pullRes = await authFetch(`${API_BASE_URL}/api/v1/configs/${configId}/pull`);
39
+ if (!pullRes.ok) {
40
+ const err = await pullRes.json().catch(() => ({}));
41
+ s.stop("Pull failed");
42
+ p.log.error(err.error ?? `Server returned ${pullRes.status}`);
43
+ process.exit(1);
44
+ }
45
+ let zipBuffer;
46
+ let buildId;
47
+ let version;
48
+ let theme;
49
+ const contentType = pullRes.headers.get("content-type") ?? "";
50
+ if (contentType.includes("application/zip")) {
51
+ // Server streamed the ZIP directly (R2 unavailable)
52
+ zipBuffer = await pullRes.arrayBuffer();
53
+ buildId = pullRes.headers.get("x-build-id") ?? "unknown";
54
+ version = parseInt(pullRes.headers.get("x-version") ?? "0", 10);
55
+ theme = pullRes.headers.get("x-theme") ?? config.theme;
56
+ }
57
+ else {
58
+ // Server returned JSON with R2 download URL
59
+ const pullData = await pullRes.json();
60
+ buildId = pullData.buildId;
61
+ version = pullData.version;
62
+ theme = pullData.theme;
63
+ s.message("Downloading package...");
64
+ // Download the ZIP from R2
65
+ const zipRes = await fetch(pullData.downloadUrl);
66
+ if (!zipRes.ok) {
67
+ s.stop("Download failed");
68
+ p.log.error(`Failed to download package: ${zipRes.status}`);
69
+ process.exit(1);
70
+ }
71
+ zipBuffer = await zipRes.arrayBuffer();
72
+ }
73
+ s.message("Extracting components...");
74
+ // Extract ZIP using JSZip (dynamic import to keep CLI startup fast)
75
+ const JSZip = (await import("jszip")).default;
76
+ const zip = await JSZip.loadAsync(zipBuffer);
77
+ const skillDir = config.skillDir;
78
+ const fullSkillDir = join(process.cwd(), skillDir);
79
+ // Ensure skill directory exists
80
+ if (!existsSync(fullSkillDir)) {
81
+ mkdirSync(fullSkillDir, { recursive: true });
82
+ }
83
+ let fileCount = 0;
84
+ for (const [path, entry] of Object.entries(zip.files)) {
85
+ if (entry.dir)
86
+ continue;
87
+ const content = await entry.async("string");
88
+ const fullPath = join(fullSkillDir, path);
89
+ const dir = dirname(fullPath);
90
+ if (!existsSync(dir)) {
91
+ mkdirSync(dir, { recursive: true });
92
+ }
93
+ writeFileSync(fullPath, content);
94
+ fileCount++;
95
+ }
96
+ // Update local config version
97
+ const updatedConfig = {
98
+ ...config,
99
+ configId,
100
+ };
101
+ writeConfig(updatedConfig);
102
+ s.stop(`Extracted ${fileCount} files`);
103
+ p.log.success(`${pc.bold(theme + "-ui")} v${version} → ${pc.dim(skillDir)}`);
104
+ p.log.info(`Build: ${pc.dim(buildId)}`);
105
+ p.outro("Design system is up to date.");
106
+ }
107
+ catch (err) {
108
+ s.stop("Pull failed");
109
+ p.log.error(err instanceof Error ? err.message : "An unexpected error occurred");
110
+ process.exit(1);
111
+ }
112
+ }
@@ -0,0 +1 @@
1
+ export declare function statusCommand(): Promise<void>;
@@ -0,0 +1,53 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { readConfig, configExists } from "../utils/config.js";
4
+ import { getApiKey, authFetch, API_BASE_URL } from "../utils/auth.js";
5
+ export async function statusCommand() {
6
+ p.intro(pc.bold("matchkit status"));
7
+ // Check config
8
+ if (!configExists()) {
9
+ p.log.error("No .matchkit/config.json found. Run " +
10
+ pc.bold("matchkit init") +
11
+ " first.");
12
+ process.exit(1);
13
+ }
14
+ const config = readConfig();
15
+ const configId = config.configId;
16
+ p.log.info(`Theme: ${pc.bold(config.theme)}`);
17
+ p.log.info(`Accent: ${pc.bold(config.accent)}`);
18
+ p.log.info(`Dir: ${pc.dim(config.skillDir)}`);
19
+ if (!configId) {
20
+ p.log.warn("No configId — this is a local-only config (free tier).");
21
+ p.outro("Upgrade at " + pc.cyan("matchkit.io/configure") + " for premium themes + CLI sync.");
22
+ return;
23
+ }
24
+ p.log.info(`Config: ${pc.dim(configId)}`);
25
+ const apiKey = getApiKey();
26
+ if (!apiKey) {
27
+ p.log.warn("Not logged in — run " + pc.bold("matchkit login") + " to sync.");
28
+ p.outro("");
29
+ return;
30
+ }
31
+ const s = p.spinner();
32
+ s.start("Checking server...");
33
+ try {
34
+ const res = await authFetch(`${API_BASE_URL}/api/v1/configs/${configId}`);
35
+ if (!res.ok) {
36
+ s.stop("Could not reach server");
37
+ p.log.warn("Config not found on server or access denied.");
38
+ p.outro("");
39
+ return;
40
+ }
41
+ const serverConfig = await res.json();
42
+ s.stop("Synced");
43
+ p.log.info(`Version: ${pc.bold(String(serverConfig.version))}`);
44
+ p.log.info(`Build: ${pc.dim(serverConfig.buildId ?? "not built")}`);
45
+ p.log.info(`Updated: ${pc.dim(new Date(serverConfig.updatedAt).toLocaleDateString())}`);
46
+ p.outro("Use " + pc.bold("matchkit pull") + " to update your local files.");
47
+ }
48
+ catch {
49
+ s.stop("Connection failed");
50
+ p.log.warn("Could not connect to matchkit.io.");
51
+ p.outro("");
52
+ }
53
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { initCommand } from "./commands/init.js";
4
+ import { addCommand } from "./commands/add.js";
5
+ import { listCommand } from "./commands/list.js";
6
+ import { diffCommand } from "./commands/diff.js";
7
+ import { loginCommand } from "./commands/login.js";
8
+ import { pullCommand } from "./commands/pull.js";
9
+ import { statusCommand } from "./commands/status.js";
10
+ const program = new Command();
11
+ program
12
+ .name("matchkit")
13
+ .description("MatchKit — style-agnostic design system CLI")
14
+ .version("0.1.0");
15
+ program
16
+ .command("init")
17
+ .description("Initialize a MatchKit design system in your project")
18
+ .action(initCommand);
19
+ program
20
+ .command("add")
21
+ .description("Add a component to your project")
22
+ .argument("<component>", "Component name (e.g. button, data-table)")
23
+ .action(addCommand);
24
+ program
25
+ .command("list")
26
+ .description("List all available components and their install status")
27
+ .action(listCommand);
28
+ program
29
+ .command("diff")
30
+ .description("Show changes between local components and the registry")
31
+ .action(diffCommand);
32
+ program
33
+ .command("login")
34
+ .description("Authenticate with MatchKit (opens browser)")
35
+ .option("--key <key>", "API key for non-interactive login (CI/CD)")
36
+ .action(loginCommand);
37
+ program
38
+ .command("pull")
39
+ .description("Pull your latest resolved design system from the server")
40
+ .action(pullCommand);
41
+ program
42
+ .command("status")
43
+ .description("Show current config, version, and sync status")
44
+ .action(statusCommand);
45
+ program.parse();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * CLI authentication utilities.
3
+ *
4
+ * API key stored globally in ~/.matchkit/credentials (not per-project).
5
+ * Also supports MATCHKIT_API_KEY env var for CI/CD.
6
+ */
7
+ /** Base URL for the MatchKit API. Override via MATCHKIT_API_URL for dev. */
8
+ export declare const API_BASE_URL: string;
9
+ export interface Credentials {
10
+ apiKey: string;
11
+ }
12
+ /**
13
+ * Read the stored API key.
14
+ * Priority: MATCHKIT_API_KEY env var > ~/.matchkit/credentials file.
15
+ */
16
+ export declare function getApiKey(): string | null;
17
+ /**
18
+ * Store an API key in ~/.matchkit/credentials.
19
+ */
20
+ export declare function saveApiKey(apiKey: string): void;
21
+ /**
22
+ * Check if the user is authenticated (has an API key).
23
+ */
24
+ export declare function isAuthenticated(): boolean;
25
+ /**
26
+ * Get auth headers for API requests.
27
+ */
28
+ export declare function getAuthHeaders(): Record<string, string>;
29
+ /**
30
+ * Make an authenticated fetch request to the MatchKit API.
31
+ */
32
+ export declare function authFetch(url: string, options?: RequestInit): Promise<Response>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * CLI authentication utilities.
3
+ *
4
+ * API key stored globally in ~/.matchkit/credentials (not per-project).
5
+ * Also supports MATCHKIT_API_KEY env var for CI/CD.
6
+ */
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ /** Base URL for the MatchKit API. Override via MATCHKIT_API_URL for dev. */
11
+ export const API_BASE_URL = process.env.MATCHKIT_API_URL ?? "https://www.matchkit.io";
12
+ const CREDENTIALS_DIR = join(homedir(), ".matchkit");
13
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials");
14
+ /**
15
+ * Read the stored API key.
16
+ * Priority: MATCHKIT_API_KEY env var > ~/.matchkit/credentials file.
17
+ */
18
+ export function getApiKey() {
19
+ // 1. Check environment variable (for CI/CD)
20
+ const envKey = process.env.MATCHKIT_API_KEY;
21
+ if (envKey)
22
+ return envKey;
23
+ // 2. Check credentials file
24
+ if (!existsSync(CREDENTIALS_FILE))
25
+ return null;
26
+ try {
27
+ const content = readFileSync(CREDENTIALS_FILE, "utf-8");
28
+ const creds = JSON.parse(content);
29
+ return creds.apiKey || null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /**
36
+ * Store an API key in ~/.matchkit/credentials.
37
+ */
38
+ export function saveApiKey(apiKey) {
39
+ if (!existsSync(CREDENTIALS_DIR)) {
40
+ mkdirSync(CREDENTIALS_DIR, { recursive: true });
41
+ }
42
+ const creds = { apiKey };
43
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2) + "\n", {
44
+ mode: 0o600, // read/write only for owner
45
+ });
46
+ }
47
+ /**
48
+ * Check if the user is authenticated (has an API key).
49
+ */
50
+ export function isAuthenticated() {
51
+ return getApiKey() !== null;
52
+ }
53
+ /**
54
+ * Get auth headers for API requests.
55
+ */
56
+ export function getAuthHeaders() {
57
+ const apiKey = getApiKey();
58
+ if (!apiKey)
59
+ return {};
60
+ return { Authorization: `Bearer ${apiKey}` };
61
+ }
62
+ /**
63
+ * Make an authenticated fetch request to the MatchKit API.
64
+ */
65
+ export async function authFetch(url, options = {}) {
66
+ const headers = {
67
+ ...getAuthHeaders(),
68
+ ...(options.headers ?? {}),
69
+ };
70
+ return fetch(url, { ...options, headers });
71
+ }
@@ -0,0 +1,17 @@
1
+ export interface MatchKitConfig {
2
+ $schema?: string;
3
+ version: string;
4
+ theme: string;
5
+ accent: string;
6
+ overrides: Record<string, string>;
7
+ componentsDir: string;
8
+ skillDir: string;
9
+ registryUrl: string;
10
+ /** Server config ID for authenticated pull (premium themes) */
11
+ configId?: string;
12
+ }
13
+ export declare function getConfigPath(cwd?: string): string;
14
+ export declare function configExists(cwd?: string): boolean;
15
+ export declare function readConfig(cwd?: string): MatchKitConfig;
16
+ export declare function writeConfig(config: MatchKitConfig, cwd?: string): void;
17
+ export declare function createDefaultConfig(theme: string, accent: string, overrides?: Record<string, string>): MatchKitConfig;
@@ -0,0 +1,36 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ const CONFIG_FILE = ".matchkit/config.json";
4
+ export function getConfigPath(cwd = process.cwd()) {
5
+ return join(cwd, CONFIG_FILE);
6
+ }
7
+ export function configExists(cwd = process.cwd()) {
8
+ return existsSync(getConfigPath(cwd));
9
+ }
10
+ export function readConfig(cwd = process.cwd()) {
11
+ const path = getConfigPath(cwd);
12
+ if (!existsSync(path)) {
13
+ throw new Error("No .matchkit/config.json found. Run `matchkit init` first.");
14
+ }
15
+ return JSON.parse(readFileSync(path, "utf-8"));
16
+ }
17
+ export function writeConfig(config, cwd = process.cwd()) {
18
+ const path = getConfigPath(cwd);
19
+ const dir = dirname(path);
20
+ if (!existsSync(dir)) {
21
+ mkdirSync(dir, { recursive: true });
22
+ }
23
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
24
+ }
25
+ export function createDefaultConfig(theme, accent, overrides = {}) {
26
+ return {
27
+ $schema: "https://matchkit.io/schemas/config.json",
28
+ version: "1.0.0",
29
+ theme,
30
+ accent,
31
+ overrides,
32
+ componentsDir: "src/components/ui",
33
+ skillDir: `.claude/skills/${theme}-ui`,
34
+ registryUrl: "https://www.matchkit.io/api/registry",
35
+ };
36
+ }
@@ -0,0 +1,37 @@
1
+ import type { MatchKitConfig } from "./config.js";
2
+ export interface RegistryComponent {
3
+ name: string;
4
+ type: string;
5
+ layer: number;
6
+ description: string;
7
+ tags: string[];
8
+ category: string;
9
+ file: string;
10
+ dependencies: string[];
11
+ registryDependencies: string[];
12
+ }
13
+ export interface RegistryData {
14
+ $schema: string;
15
+ basePath: string;
16
+ components: RegistryComponent[];
17
+ }
18
+ /**
19
+ * Fetch the full registry for a theme from the registry API.
20
+ */
21
+ export declare function fetchRegistry(config: MatchKitConfig): Promise<RegistryData>;
22
+ /**
23
+ * Fetch a single component's source code from the registry API.
24
+ */
25
+ export declare function fetchComponent(config: MatchKitConfig, name: string): Promise<{
26
+ source: string;
27
+ dependencies: string[];
28
+ registryDependencies: string[];
29
+ }>;
30
+ /**
31
+ * Load a local registry.json from the skill directory.
32
+ */
33
+ export declare function loadLocalRegistry(skillDir: string): RegistryData | null;
34
+ /**
35
+ * Check if a component is installed locally.
36
+ */
37
+ export declare function isComponentInstalled(skillDir: string, componentFile: string): boolean;
@@ -0,0 +1,50 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAuthHeaders } from "./auth.js";
4
+ /**
5
+ * Fetch the full registry for a theme from the registry API.
6
+ */
7
+ export async function fetchRegistry(config) {
8
+ const url = `${config.registryUrl}?theme=${encodeURIComponent(config.theme)}`;
9
+ // Include auth headers for premium themes (clarity is free, no auth needed)
10
+ const headers = config.theme !== "clarity" ? getAuthHeaders() : {};
11
+ const res = await fetch(url, { headers });
12
+ if (!res.ok) {
13
+ throw new Error(`Failed to fetch registry: ${res.status} ${res.statusText}`);
14
+ }
15
+ return res.json();
16
+ }
17
+ /**
18
+ * Fetch a single component's source code from the registry API.
19
+ */
20
+ export async function fetchComponent(config, name) {
21
+ const url = `${config.registryUrl}/component?theme=${encodeURIComponent(config.theme)}&name=${encodeURIComponent(name)}`;
22
+ const headers = config.theme !== "clarity" ? getAuthHeaders() : {};
23
+ const res = await fetch(url, { headers });
24
+ if (!res.ok) {
25
+ throw new Error(`Failed to fetch component "${name}": ${res.status} ${res.statusText}`);
26
+ }
27
+ return res.json();
28
+ }
29
+ /**
30
+ * Load a local registry.json from the skill directory.
31
+ */
32
+ export function loadLocalRegistry(skillDir) {
33
+ const registryPath = join(process.cwd(), skillDir, "registry.json");
34
+ if (!existsSync(registryPath))
35
+ return null;
36
+ try {
37
+ const content = readFileSync(registryPath, "utf-8");
38
+ return JSON.parse(content);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Check if a component is installed locally.
46
+ */
47
+ export function isComponentInstalled(skillDir, componentFile) {
48
+ const filePath = join(process.cwd(), skillDir, componentFile);
49
+ return existsSync(filePath);
50
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@matchkit.io/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for MatchKit design system skills. Init projects, add components, manage your design system.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "matchkit": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsx src/index.ts",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "@clack/prompts": "^0.9.0",
17
+ "commander": "^13.0.0",
18
+ "jszip": "^3.10.1",
19
+ "picocolors": "^1.1.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.15.0",
23
+ "tsx": "^4.19.0",
24
+ "typescript": "^5.4.0"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/Maes9/matchkit"
35
+ },
36
+ "homepage": "https://matchkit.io",
37
+ "license": "MIT"
38
+ }