@matchkit.io/cli 0.1.5 → 0.2.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.
@@ -1 +1,5 @@
1
- export declare function addCommand(componentName: string): Promise<void>;
1
+ interface AddOptions {
2
+ all?: boolean;
3
+ }
4
+ export declare function addCommand(componentNames: string[], options: AddOptions): Promise<void>;
5
+ export {};
@@ -4,75 +4,129 @@ import * as p from "@clack/prompts";
4
4
  import pc from "picocolors";
5
5
  import { readConfig } from "../utils/config.js";
6
6
  import { fetchComponent, fetchRegistry, isComponentInstalled, } from "../utils/registry.js";
7
- export async function addCommand(componentName) {
7
+ export async function addCommand(componentNames, options) {
8
8
  const s = p.spinner();
9
9
  try {
10
10
  const config = readConfig();
11
- // 1. Fetch the registry to find the component
12
- s.start(`Resolving ${pc.cyan(componentName)}...`);
11
+ // Fetch the registry
12
+ s.start("Resolving components...");
13
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)}`);
14
+ // Determine what to install
15
+ let targets;
16
+ if (options.all) {
17
+ targets = registry.components.map((c) => c.name);
18
+ s.stop(`Found ${pc.cyan(targets.length.toString())} components`);
19
+ }
20
+ else if (componentNames.length === 0) {
21
+ s.stop("No component specified");
22
+ p.log.error(`Specify component names: ${pc.cyan("matchkit add button card")}\nOr install everything: ${pc.cyan("matchkit add --all")}`);
19
23
  process.exit(1);
20
24
  }
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);
25
+ else {
26
+ // Validate all names exist in registry
27
+ const available = new Set(registry.components.map((c) => c.name));
28
+ const invalid = componentNames.filter((n) => !available.has(n));
29
+ if (invalid.length > 0) {
30
+ s.stop(`Not found: ${pc.red(invalid.join(", "))}`);
31
+ p.log.error(`Unknown component(s): ${invalid.join(", ")}\n\nAvailable: ${pc.dim(Array.from(available).join(", "))}`);
32
+ process.exit(1);
30
33
  }
31
- s.start(`Fetching ${pc.cyan(componentName)}...`);
34
+ targets = componentNames;
35
+ s.stop(`Resolved ${pc.cyan(targets.length.toString())} component(s)`);
32
36
  }
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
- }
37
+ // Resolve all registry dependencies
38
+ const toInstall = new Set();
39
+ const registryMap = new Map(registry.components.map((c) => [c.name, c]));
40
+ function addWithDeps(name) {
41
+ if (toInstall.has(name))
42
+ return;
43
+ const comp = registryMap.get(name);
44
+ if (!comp)
45
+ return;
46
+ // Add dependencies first
47
+ for (const dep of comp.registryDependencies) {
48
+ addWithDeps(dep);
49
+ }
50
+ toInstall.add(name);
51
+ }
52
+ for (const name of targets) {
53
+ addWithDeps(name);
54
+ }
55
+ // Check what's already installed (skip unless --all)
56
+ const alreadyInstalled = [];
57
+ const needsInstall = [];
58
+ for (const name of toInstall) {
59
+ const comp = registryMap.get(name);
60
+ if (isComponentInstalled(config.skillDir, comp.file)) {
61
+ alreadyInstalled.push(name);
62
+ }
63
+ else {
64
+ needsInstall.push(name);
40
65
  }
41
66
  }
42
- // 4. Fetch and write the component (and deps)
43
- const allComponents = [componentName, ...toInstall];
67
+ if (!options.all && alreadyInstalled.length > 0 && needsInstall.length === 0) {
68
+ p.log.info(`All ${alreadyInstalled.length} component(s) already installed. Use ${pc.cyan("matchkit add --all")} to overwrite.`);
69
+ return;
70
+ }
71
+ // For --all, overwrite everything. Otherwise only install missing.
72
+ const installList = options.all ? Array.from(toInstall) : needsInstall;
73
+ if (installList.length === 0) {
74
+ p.log.success("Everything is up to date.");
75
+ return;
76
+ }
77
+ // Fetch and write each component
78
+ s.start(`Installing ${installList.length} component(s)...`);
44
79
  const installed = [];
45
- const npmDeps = [...component.dependencies];
46
- for (const name of allComponents) {
80
+ const allNpmDeps = new Set();
81
+ for (const name of installList) {
47
82
  const data = await fetchComponent(config, name);
83
+ // Write to skill dir
48
84
  const targetPath = join(process.cwd(), config.skillDir, `components/${name}.tsx`);
49
- // Ensure directory exists
50
85
  const dir = dirname(targetPath);
51
86
  if (!existsSync(dir)) {
52
87
  mkdirSync(dir, { recursive: true });
53
88
  }
54
89
  writeFileSync(targetPath, data.source);
55
90
  installed.push(name);
56
- // Collect npm dependencies
57
- for (const d of data.dependencies) {
58
- if (!npmDeps.includes(d)) {
59
- npmDeps.push(d);
91
+ // Also write to output dir if different from skill dir
92
+ if (config.outputDir) {
93
+ const outputPath = join(process.cwd(), config.outputDir, `${name}.tsx`);
94
+ const outputDir = dirname(outputPath);
95
+ if (!existsSync(outputDir)) {
96
+ mkdirSync(outputDir, { recursive: true });
60
97
  }
98
+ writeFileSync(outputPath, data.source);
99
+ }
100
+ for (const d of data.dependencies) {
101
+ allNpmDeps.add(d);
61
102
  }
62
103
  }
63
104
  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(", ")}`);
105
+ // Summary
106
+ const lines = [];
107
+ if (installed.length <= 5) {
108
+ for (const name of installed) {
109
+ const comp = registryMap.get(name);
110
+ lines.push(` ${pc.green("+")} ${name} ${pc.dim(`(Layer ${comp?.layer ?? "?"})`)}`);
111
+ }
112
+ }
113
+ else {
114
+ lines.push(` ${pc.green("+")} ${installed.length} components installed`);
115
+ lines.push(` ${pc.dim(installed.slice(0, 5).join(", "))}${installed.length > 5 ? `, +${installed.length - 5} more` : ""}`);
116
+ }
117
+ if (alreadyInstalled.length > 0 && !options.all) {
118
+ lines.push("");
119
+ lines.push(` ${pc.dim(`${alreadyInstalled.length} already installed (skipped)`)}`);
120
+ }
121
+ lines.push("");
122
+ lines.push(`${pc.cyan("Skill dir:")} ${config.skillDir}/components/`);
123
+ if (config.outputDir) {
124
+ lines.push(`${pc.cyan("Output dir:")} ${config.outputDir}/`);
71
125
  }
72
- if (npmDeps.length > 0) {
126
+ if (allNpmDeps.size > 0) {
73
127
  lines.push("");
74
- lines.push(`${pc.yellow("npm dependencies needed:")}`);
75
- lines.push(` npm install ${npmDeps.join(" ")}`);
128
+ lines.push(`${pc.yellow("npm dependencies:")}`);
129
+ lines.push(` npm install ${Array.from(allNpmDeps).join(" ")}`);
76
130
  }
77
131
  p.note(lines.join("\n"), "Added");
78
132
  }
@@ -1,12 +1,13 @@
1
- import { existsSync } from "node:fs";
1
+ import { readFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
3
4
  import * as p from "@clack/prompts";
4
5
  import pc from "picocolors";
5
6
  import { readConfig, configExists } from "../utils/config.js";
6
- import { fetchRegistry } from "../utils/registry.js";
7
+ import { fetchRegistry, loadLocalHashes } from "../utils/registry.js";
7
8
  export async function diffCommand() {
8
9
  if (!configExists()) {
9
- p.log.error("No .matchkit/config.json found. Run `matchkit init` first.");
10
+ p.log.error("No matchkit.json found. Run `matchkit init` first.");
10
11
  process.exit(1);
11
12
  }
12
13
  const config = readConfig();
@@ -14,33 +15,75 @@ export async function diffCommand() {
14
15
  s.start("Comparing local vs registry...");
15
16
  try {
16
17
  const registry = await fetchRegistry(config);
17
- s.stop("Registry fetched");
18
- const results = {
19
- missing: [],
20
- installed: [],
21
- };
18
+ const remoteHashes = registry.componentHashes ?? {};
19
+ const localHashes = loadLocalHashes(config.skillDir);
20
+ const missing = [];
21
+ const modified = [];
22
+ const upToDate = [];
22
23
  for (const component of registry.components) {
23
24
  const localPath = join(process.cwd(), config.skillDir, component.file);
24
- if (existsSync(localPath)) {
25
- results.installed.push(component);
25
+ if (!existsSync(localPath)) {
26
+ missing.push(component);
27
+ continue;
28
+ }
29
+ // Compare via content hashes if available (fast path)
30
+ const remoteHash = remoteHashes[component.name]?.hash;
31
+ const localHash = localHashes?.[component.name]?.hash;
32
+ if (remoteHash && localHash) {
33
+ // Both have hashes — fast comparison
34
+ if (remoteHash !== localHash) {
35
+ modified.push(component);
36
+ }
37
+ else {
38
+ upToDate.push(component);
39
+ }
40
+ }
41
+ else if (remoteHash) {
42
+ // Remote has hash, compute local hash on the fly
43
+ const localContent = readFileSync(localPath, "utf-8");
44
+ const computedHash = createHash("sha256").update(localContent).digest("hex").slice(0, 16);
45
+ if (computedHash !== remoteHash) {
46
+ modified.push(component);
47
+ }
48
+ else {
49
+ upToDate.push(component);
50
+ }
26
51
  }
27
52
  else {
28
- results.missing.push(component);
53
+ // No hashes available — treat as up to date (can't compare)
54
+ upToDate.push(component);
29
55
  }
30
56
  }
57
+ s.stop("Comparison complete");
31
58
  console.log("");
32
- console.log(` ${pc.cyan(config.theme + "-ui")} · ${results.installed.length} installed, ${results.missing.length} missing`);
59
+ console.log(` ${pc.cyan(config.preset + "-ui")} · ${upToDate.length} current, ${modified.length} modified, ${missing.length} missing`);
33
60
  console.log("");
34
- if (results.missing.length > 0) {
35
- console.log(` ${pc.yellow("Missing components:")}`);
36
- for (const c of results.missing) {
61
+ if (modified.length > 0) {
62
+ console.log(` ${pc.yellow("Modified (remote has updates):")}`);
63
+ for (const c of modified) {
64
+ console.log(` ${pc.yellow("~")} ${c.name}`);
65
+ }
66
+ console.log("");
67
+ }
68
+ if (missing.length > 0) {
69
+ console.log(` ${pc.red("Missing (not installed):")}`);
70
+ for (const c of missing) {
37
71
  console.log(` ${pc.dim("○")} ${c.name}`);
38
72
  }
39
73
  console.log("");
40
- console.log(` Run ${pc.cyan("matchkit add <name>")} to install missing components.`);
74
+ }
75
+ if (modified.length === 0 && missing.length === 0) {
76
+ console.log(` ${pc.green("Everything is up to date.")}`);
41
77
  }
42
78
  else {
43
- console.log(` ${pc.green("All components are installed.")} Your skill is complete.`);
79
+ const suggestions = [];
80
+ if (modified.length > 0) {
81
+ suggestions.push(` ${pc.cyan("matchkit update")} Update modified components`);
82
+ }
83
+ if (missing.length > 0) {
84
+ suggestions.push(` ${pc.cyan("matchkit add --all")} Install everything`);
85
+ }
86
+ console.log(suggestions.join("\n"));
44
87
  }
45
88
  console.log("");
46
89
  }
@@ -1 +1,4 @@
1
- export declare function initCommand(): Promise<void>;
1
+ export declare function initCommand(options?: {
2
+ preset?: string;
3
+ accent?: string;
4
+ }): Promise<void>;
@@ -1,12 +1,14 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import pc from "picocolors";
3
- import { configExists, writeConfig, createDefaultConfig } from "../utils/config.js";
3
+ import { configExists, writeConfig, createDefaultConfig, getLegacyConfigPath } from "../utils/config.js";
4
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: "ProNeobrutalism bold, graphic" },
9
- { value: "glass", label: "Glass UI", hint: "Pro — Technical, translucent" },
5
+ import { existsSync, unlinkSync, rmSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ const PRESETS = [
8
+ { value: "clarity", label: "Clarity", hint: "FreeSharp, structured B2B SaaS" },
9
+ { value: "soft", label: "Soft", hint: "Pro — Warm-minimal for AI tools" },
10
+ { value: "brutal", label: "Brutal", hint: "Pro — Neobrutalism — bold, graphic" },
11
+ { value: "glass", label: "Glass", hint: "Pro — Technical, translucent" },
10
12
  ];
11
13
  const ACCENT_PRESETS = [
12
14
  { value: "#4F46E5", label: "Indigo" },
@@ -18,29 +20,54 @@ const ACCENT_PRESETS = [
18
20
  { value: "#EC4899", label: "Pink" },
19
21
  { value: "custom", label: "Custom hex" },
20
22
  ];
21
- export async function initCommand() {
23
+ export async function initCommand(options) {
24
+ const nonInteractive = !!options?.preset;
22
25
  p.intro(pc.bgCyan(pc.black(" matchkit init ")));
23
- if (configExists()) {
26
+ if (configExists() && !nonInteractive) {
24
27
  const shouldOverwrite = await p.confirm({
25
- message: "A .matchkit/config.json already exists. Overwrite it?",
28
+ message: "A matchkit.json already exists. Overwrite it?",
26
29
  });
27
30
  if (p.isCancel(shouldOverwrite) || !shouldOverwrite) {
28
31
  p.cancel("Init cancelled.");
29
32
  process.exit(0);
30
33
  }
31
34
  }
32
- // Step 1: Pick theme
33
- const theme = await p.select({
34
- message: "Pick a theme:",
35
- options: THEMES,
35
+ // Non-interactive: skip prompts when --preset is provided
36
+ if (nonInteractive) {
37
+ const presetId = options.preset;
38
+ const validPresets = PRESETS.map((p) => p.value);
39
+ if (!validPresets.includes(presetId)) {
40
+ p.log.error(`Unknown preset: ${pc.red(presetId)}. Valid: ${validPresets.join(", ")}`);
41
+ process.exit(1);
42
+ }
43
+ if (presetId !== "clarity" && !isAuthenticated()) {
44
+ p.log.warn(`${pc.bold(presetId)} is a premium preset. Run ${pc.bold("matchkit login")} first.`);
45
+ p.outro(`Or use ${pc.bold("--preset clarity")} (free).`);
46
+ process.exit(0);
47
+ }
48
+ const accent = options.accent ?? (presetId === "clarity" ? "#4F46E5" : "#4F46E5");
49
+ const config = createDefaultConfig(presetId, accent);
50
+ writeConfig(config);
51
+ p.log.success(`Initialized ${pc.bold(presetId + "-ui")} with accent ${pc.cyan(accent)}`);
52
+ p.log.info(`Config: ${pc.dim("matchkit.json")}`);
53
+ p.log.info(`Skill dir: ${pc.dim(config.skillDir)}`);
54
+ p.log.info("");
55
+ p.log.info(`Next: ${pc.green("matchkit add button")} or ${pc.green("matchkit add --all")}`);
56
+ p.outro(pc.green("MatchKit initialized!"));
57
+ return;
58
+ }
59
+ // Step 1: Pick preset
60
+ const preset = await p.select({
61
+ message: "Pick a preset:",
62
+ options: PRESETS,
36
63
  });
37
- if (p.isCancel(theme)) {
64
+ if (p.isCancel(preset)) {
38
65
  p.cancel("Init cancelled.");
39
66
  process.exit(0);
40
67
  }
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.`);
68
+ // Premium presets require authentication
69
+ if (preset !== "clarity" && !isAuthenticated()) {
70
+ p.log.warn(`${pc.bold(String(preset))} is a premium preset. Run ${pc.bold("matchkit login")} first.`);
44
71
  p.log.info(`Get your API key at ${pc.cyan("matchkit.io/app/keys")}`);
45
72
  p.outro(`Or use ${pc.bold("clarity")} (free) — all 27 components, no account needed.`);
46
73
  process.exit(0);
@@ -72,7 +99,7 @@ export async function initCommand() {
72
99
  }
73
100
  // Step 3: Use default axes or customize
74
101
  const useDefaults = await p.confirm({
75
- message: "Use default axis settings for this theme?",
102
+ message: "Use default axis settings for this preset?",
76
103
  initialValue: true,
77
104
  });
78
105
  if (p.isCancel(useDefaults)) {
@@ -125,22 +152,32 @@ export async function initCommand() {
125
152
  "spacing-density": density,
126
153
  };
127
154
  }
128
- // Write local config first
129
- const config = createDefaultConfig(theme, accent, overrides);
155
+ // Write config to project root (matchkit.json)
156
+ const config = createDefaultConfig(preset, accent, overrides);
130
157
  writeConfig(config);
158
+ // Clean up legacy config if it exists
159
+ const legacyPath = getLegacyConfigPath();
160
+ if (existsSync(legacyPath)) {
161
+ unlinkSync(legacyPath);
162
+ // Remove .matchkit dir if empty
163
+ try {
164
+ rmSync(dirname(legacyPath), { recursive: false });
165
+ }
166
+ catch { /* not empty, that's fine */ }
167
+ p.log.info(pc.dim("Migrated .matchkit/config.json → matchkit.json"));
168
+ }
131
169
  // Step 4: Link to a server-side project (premium + authenticated only)
132
170
  let linkedConfigId;
133
- if (theme !== "clarity" && isAuthenticated()) {
171
+ if (preset !== "clarity" && isAuthenticated()) {
134
172
  const s = p.spinner();
135
173
  s.start("Checking your projects...");
136
174
  try {
137
175
  const res = await authFetch(`${API_BASE_URL}/api/v1/configs`);
138
176
  if (res.ok) {
139
177
  const userConfigs = (await res.json());
140
- // Filter to configs matching the selected theme
141
- const matching = userConfigs.filter((c) => c.theme === theme);
178
+ const matching = userConfigs.filter((c) => c.theme === preset);
142
179
  s.stop(matching.length > 0
143
- ? `Found ${matching.length} ${theme} project${matching.length > 1 ? "s" : ""}`
180
+ ? `Found ${matching.length} ${preset} project${matching.length > 1 ? "s" : ""}`
144
181
  : "No projects found");
145
182
  if (matching.length > 0) {
146
183
  const choice = await p.select({
@@ -163,7 +200,7 @@ export async function initCommand() {
163
200
  }
164
201
  }
165
202
  else {
166
- p.log.info(`No ${pc.bold(String(theme))} projects found. Create one at ${pc.cyan("matchkit.io/app")}`);
203
+ p.log.info(`No ${pc.bold(String(preset))} projects found. Create one at ${pc.cyan("matchkit.io/app")}`);
167
204
  }
168
205
  }
169
206
  else {
@@ -173,7 +210,6 @@ export async function initCommand() {
173
210
  catch {
174
211
  s.stop("Offline — skipping project linking");
175
212
  }
176
- // Update config with configId if linked
177
213
  if (linkedConfigId) {
178
214
  const updatedConfig = { ...config, configId: linkedConfigId };
179
215
  writeConfig(updatedConfig);
@@ -182,29 +218,30 @@ export async function initCommand() {
182
218
  }
183
219
  // Summary
184
220
  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}`,
221
+ `${pc.cyan("Preset:")} ${preset}`,
222
+ `${pc.cyan("Accent:")} ${accent}`,
223
+ `${pc.cyan("Overrides:")} ${Object.keys(overrides).length === 0 ? "defaults" : Object.entries(overrides).map(([k, v]) => `${k}=${v}`).join(", ")}`,
224
+ `${pc.cyan("Config:")} matchkit.json`,
225
+ `${pc.cyan("Skill dir:")} ${config.skillDir}`,
226
+ `${pc.cyan("Output dir:")} ${config.outputDir}`,
190
227
  ];
191
228
  if (linkedConfigId) {
192
- summaryLines.push(`${pc.cyan("Project ID:")} ${linkedConfigId}`);
229
+ summaryLines.push(`${pc.cyan("Project ID:")} ${linkedConfigId}`);
193
230
  summaryLines.push("");
194
231
  summaryLines.push(`Next steps:`);
195
232
  summaryLines.push(` ${pc.green("matchkit pull")} Sync your design system`);
196
- summaryLines.push(` ${pc.green("matchkit list")} See all available components`);
233
+ summaryLines.push(` ${pc.green("matchkit add button")} Add your first component`);
197
234
  }
198
235
  else {
199
236
  summaryLines.push("");
200
237
  summaryLines.push(`Next steps:`);
201
238
  summaryLines.push(` ${pc.green("matchkit add button")} Add your first component`);
202
- summaryLines.push(` ${pc.green("matchkit list")} See all available components`);
239
+ summaryLines.push(` ${pc.green("matchkit add --all")} Add all 27 components`);
240
+ summaryLines.push(` ${pc.green("matchkit list")} See available components`);
203
241
  }
204
242
  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 → ") +
243
+ if (preset === "clarity" && !isAuthenticated()) {
244
+ p.log.info(pc.dim("Unlock 3 more presets + custom axes → ") +
208
245
  pc.cyan("matchkit.io/configure"));
209
246
  }
210
247
  p.outro(pc.green("MatchKit initialized!"));
@@ -4,7 +4,7 @@ import { readConfig, configExists } from "../utils/config.js";
4
4
  import { loadLocalRegistry, isComponentInstalled, fetchRegistry, } from "../utils/registry.js";
5
5
  export async function listCommand() {
6
6
  if (!configExists()) {
7
- p.log.error("No .matchkit/config.json found. Run `matchkit init` first.");
7
+ p.log.error("No matchkit.json found. Run `matchkit init` first.");
8
8
  process.exit(1);
9
9
  }
10
10
  const config = readConfig();
@@ -41,7 +41,7 @@ export async function listCommand() {
41
41
  };
42
42
  const installedCount = components.filter((c) => isComponentInstalled(config.skillDir, c.file)).length;
43
43
  console.log("");
44
- console.log(` ${pc.cyan(config.theme + "-ui")} · ${pc.dim(`${installedCount}/${components.length} installed`)}`);
44
+ console.log(` ${pc.cyan(config.preset + "-ui")} · ${pc.dim(`${installedCount}/${components.length} installed`)}`);
45
45
  console.log("");
46
46
  console.log(` ${pc.bold("Layer 1 — Primitives")} (${layer1.length})`);
47
47
  for (const c of layer1) {
@@ -213,7 +213,7 @@ export async function pullCommand(options) {
213
213
  cs.stop("Project found");
214
214
  const newConfig = createDefaultConfig(data.theme, data.accent ?? "#4F46E5", (data.overrides ?? {}));
215
215
  writeConfig({ ...newConfig, configId: options.configId });
216
- p.log.success(`Created .matchkit/config.json for ${pc.bold(data.theme + "-ui")}`);
216
+ p.log.success(`Created matchkit.json for ${pc.bold(data.theme + "-ui")}`);
217
217
  }
218
218
  catch {
219
219
  cs.stop("Failed");
@@ -229,7 +229,7 @@ export async function pullCommand(options) {
229
229
  }
230
230
  // Check config
231
231
  if (!configExists()) {
232
- p.log.error("No .matchkit/config.json found. Use " +
232
+ p.log.error("No matchkit.json found. Use " +
233
233
  pc.bold("--config-id <id>") +
234
234
  " or run " +
235
235
  pc.bold("matchkit init") +
@@ -239,7 +239,7 @@ export async function pullCommand(options) {
239
239
  const config = readConfig();
240
240
  const configId = config.configId;
241
241
  if (!configId) {
242
- p.log.error("No configId in .matchkit/config.json. Use " +
242
+ p.log.error("No configId in matchkit.json. Use " +
243
243
  pc.bold("matchkit pull --config-id <id>") +
244
244
  " to link a project.");
245
245
  process.exit(1);
@@ -266,7 +266,7 @@ export async function pullCommand(options) {
266
266
  zipBuffer = await pullRes.arrayBuffer();
267
267
  buildId = pullRes.headers.get("x-build-id") ?? "unknown";
268
268
  version = parseInt(pullRes.headers.get("x-version") ?? "0", 10);
269
- theme = pullRes.headers.get("x-theme") ?? config.theme;
269
+ theme = pullRes.headers.get("x-theme") ?? config.preset;
270
270
  }
271
271
  else {
272
272
  // Server returned JSON with R2 download URL
@@ -317,10 +317,12 @@ export async function pullCommand(options) {
317
317
  writeFileSync(fullPath, content);
318
318
  fileCount++;
319
319
  }
320
- // Update local config version
320
+ // Update local config with pull metadata
321
321
  const updatedConfig = {
322
322
  ...config,
323
323
  configId,
324
+ buildId,
325
+ lastPull: new Date().toISOString(),
324
326
  };
325
327
  writeConfig(updatedConfig);
326
328
  s.stop(`Extracted ${fileCount} files`);
@@ -6,16 +6,19 @@ export async function statusCommand() {
6
6
  p.intro(pc.bold("matchkit status"));
7
7
  // Check config
8
8
  if (!configExists()) {
9
- p.log.error("No .matchkit/config.json found. Run " +
9
+ p.log.error("No matchkit.json found. Run " +
10
10
  pc.bold("matchkit init") +
11
11
  " first.");
12
12
  process.exit(1);
13
13
  }
14
14
  const config = readConfig();
15
15
  const configId = config.configId;
16
- p.log.info(`Theme: ${pc.bold(config.theme)}`);
16
+ p.log.info(`Preset: ${pc.bold(config.preset)}`);
17
17
  p.log.info(`Accent: ${pc.bold(config.accent)}`);
18
18
  p.log.info(`Dir: ${pc.dim(config.skillDir)}`);
19
+ if (config.buildId) {
20
+ p.log.info(`Build: ${pc.dim(config.buildId)}`);
21
+ }
19
22
  if (!configId) {
20
23
  p.log.warn("No configId — this is a local-only config (free tier).");
21
24
  p.outro("Upgrade at " + pc.cyan("matchkit.io/configure") + " for premium themes + CLI sync.");
@@ -0,0 +1 @@
1
+ export declare function updateCommand(componentNames: string[]): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import * as p from "@clack/prompts";
5
+ import pc from "picocolors";
6
+ import { readConfig } from "../utils/config.js";
7
+ import { fetchRegistry, fetchComponent, loadLocalHashes, } from "../utils/registry.js";
8
+ export async function updateCommand(componentNames) {
9
+ const s = p.spinner();
10
+ try {
11
+ const config = readConfig();
12
+ s.start("Checking for updates...");
13
+ const registry = await fetchRegistry(config);
14
+ const registryMap = new Map(registry.components.map((c) => [c.name, c]));
15
+ const remoteHashes = registry.componentHashes ?? {};
16
+ const localHashes = loadLocalHashes(config.skillDir);
17
+ // Determine what to update
18
+ let targets;
19
+ if (componentNames.length > 0) {
20
+ // Update specific components
21
+ const invalid = componentNames.filter((n) => !registryMap.has(n));
22
+ if (invalid.length > 0) {
23
+ s.stop(`Not found: ${pc.red(invalid.join(", "))}`);
24
+ process.exit(1);
25
+ }
26
+ targets = componentNames;
27
+ }
28
+ else {
29
+ // Find all installed components that have changes (via hashes)
30
+ targets = [];
31
+ for (const component of registry.components) {
32
+ const localPath = join(process.cwd(), config.skillDir, component.file);
33
+ if (!existsSync(localPath))
34
+ continue;
35
+ const remoteHash = remoteHashes[component.name]?.hash;
36
+ if (!remoteHash)
37
+ continue; // Can't compare without remote hash
38
+ const localHash = localHashes?.[component.name]?.hash;
39
+ if (localHash && localHash === remoteHash)
40
+ continue; // Up to date
41
+ // No local hash — compute on the fly
42
+ if (!localHash) {
43
+ const localContent = readFileSync(localPath, "utf-8");
44
+ const computed = createHash("sha256").update(localContent).digest("hex").slice(0, 16);
45
+ if (computed === remoteHash)
46
+ continue; // Up to date
47
+ }
48
+ targets.push(component.name);
49
+ }
50
+ }
51
+ if (targets.length === 0) {
52
+ s.stop("Everything is up to date");
53
+ p.log.success("No updates available.");
54
+ return;
55
+ }
56
+ s.stop(`Found ${targets.length} component(s) to update`);
57
+ // Confirm update
58
+ if (componentNames.length === 0) {
59
+ const confirm = await p.confirm({
60
+ message: `Update ${targets.length} component(s)? ${pc.dim(targets.join(", "))}`,
61
+ });
62
+ if (p.isCancel(confirm) || !confirm) {
63
+ p.cancel("Update cancelled.");
64
+ process.exit(0);
65
+ }
66
+ }
67
+ // Fetch and overwrite
68
+ const updated = [];
69
+ const s2 = p.spinner();
70
+ s2.start(`Updating ${targets.length} component(s)...`);
71
+ for (const name of targets) {
72
+ const data = await fetchComponent(config, name);
73
+ const targetPath = join(process.cwd(), config.skillDir, `components/${name}.tsx`);
74
+ const dir = dirname(targetPath);
75
+ if (!existsSync(dir)) {
76
+ mkdirSync(dir, { recursive: true });
77
+ }
78
+ writeFileSync(targetPath, data.source);
79
+ // Also update output dir if configured
80
+ if (config.outputDir) {
81
+ const outputPath = join(process.cwd(), config.outputDir, `${name}.tsx`);
82
+ const outputDir = dirname(outputPath);
83
+ if (!existsSync(outputDir)) {
84
+ mkdirSync(outputDir, { recursive: true });
85
+ }
86
+ writeFileSync(outputPath, data.source);
87
+ }
88
+ updated.push(name);
89
+ }
90
+ s2.stop(`Updated ${pc.green(updated.length.toString())} component(s)`);
91
+ for (const name of updated) {
92
+ console.log(` ${pc.green("~")} ${name}`);
93
+ }
94
+ console.log("");
95
+ }
96
+ catch (err) {
97
+ p.log.error(err instanceof Error ? err.message : "An unknown error occurred");
98
+ process.exit(1);
99
+ }
100
+ }
package/dist/index.js CHANGED
@@ -1,25 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
3
6
  import { initCommand } from "./commands/init.js";
4
7
  import { addCommand } from "./commands/add.js";
5
8
  import { listCommand } from "./commands/list.js";
6
9
  import { diffCommand } from "./commands/diff.js";
10
+ import { updateCommand } from "./commands/update.js";
7
11
  import { loginCommand } from "./commands/login.js";
8
12
  import { pullCommand } from "./commands/pull.js";
9
13
  import { statusCommand } from "./commands/status.js";
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
10
16
  const program = new Command();
11
17
  program
12
18
  .name("matchkit")
13
19
  .description("MatchKit — style-agnostic design system CLI")
14
- .version("0.1.4");
20
+ .version(pkg.version);
15
21
  program
16
22
  .command("init")
17
23
  .description("Initialize a MatchKit design system in your project")
24
+ .option("-p, --preset <preset>", "Preset name (skip interactive selection)")
25
+ .option("--accent <color>", "Accent color hex (e.g. #4F46E5)")
18
26
  .action(initCommand);
19
27
  program
20
28
  .command("add")
21
- .description("Add a component to your project")
22
- .argument("<component>", "Component name (e.g. button, data-table)")
29
+ .description("Add component(s) to your project")
30
+ .argument("[components...]", "Component name(s) (e.g. button data-table)")
31
+ .option("-a, --all", "Install all components")
23
32
  .action(addCommand);
24
33
  program
25
34
  .command("list")
@@ -29,6 +38,11 @@ program
29
38
  .command("diff")
30
39
  .description("Show changes between local components and the registry")
31
40
  .action(diffCommand);
41
+ program
42
+ .command("update")
43
+ .description("Update installed component(s) to latest version")
44
+ .argument("[components...]", "Component name(s) to update (omit for all)")
45
+ .action(updateCommand);
32
46
  program
33
47
  .command("login")
34
48
  .description("Authenticate with MatchKit (opens browser)")
@@ -1,17 +1,30 @@
1
1
  export interface MatchKitConfig {
2
2
  $schema?: string;
3
- version: string;
4
- theme: string;
3
+ /** Preset/theme id (e.g. "clarity", "soft", "brutal", "glass") */
4
+ preset: string;
5
+ /** Accent color hex */
5
6
  accent: string;
7
+ /** Axis overrides from preset defaults */
6
8
  overrides: Record<string, string>;
7
- componentsDir: string;
9
+ /** Where to write component TSX files */
10
+ outputDir: string;
11
+ /** Where the skill directory lives (SKILL.md, tokens, etc.) */
8
12
  skillDir: string;
13
+ /** Registry API base URL */
9
14
  registryUrl: string;
10
- /** Server config ID for authenticated pull (premium themes) */
15
+ /** Server config ID for authenticated pull (premium presets) */
11
16
  configId?: string;
17
+ /** Last pull timestamp (ISO 8601) */
18
+ lastPull?: string;
19
+ /** Build ID from last pull/generate */
20
+ buildId?: string;
12
21
  }
13
22
  export declare function getConfigPath(cwd?: string): string;
23
+ export declare function getLegacyConfigPath(cwd?: string): string;
14
24
  export declare function configExists(cwd?: string): boolean;
25
+ /**
26
+ * Read config, auto-migrating from legacy format if needed.
27
+ */
15
28
  export declare function readConfig(cwd?: string): MatchKitConfig;
16
29
  export declare function writeConfig(config: MatchKitConfig, cwd?: string): void;
17
- export declare function createDefaultConfig(theme: string, accent: string, overrides?: Record<string, string>): MatchKitConfig;
30
+ export declare function createDefaultConfig(preset: string, accent: string, overrides?: Record<string, string>): MatchKitConfig;
@@ -1,36 +1,65 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
- import { join, dirname } from "node:path";
3
- const CONFIG_FILE = ".matchkit/config.json";
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // New config location (project root, ShadCN-style)
4
+ const CONFIG_FILE = "matchkit.json";
5
+ // Legacy config location
6
+ const LEGACY_CONFIG_FILE = ".matchkit/config.json";
4
7
  export function getConfigPath(cwd = process.cwd()) {
5
8
  return join(cwd, CONFIG_FILE);
6
9
  }
10
+ export function getLegacyConfigPath(cwd = process.cwd()) {
11
+ return join(cwd, LEGACY_CONFIG_FILE);
12
+ }
7
13
  export function configExists(cwd = process.cwd()) {
8
- return existsSync(getConfigPath(cwd));
14
+ return existsSync(getConfigPath(cwd)) || existsSync(getLegacyConfigPath(cwd));
9
15
  }
16
+ /**
17
+ * Read config, auto-migrating from legacy format if needed.
18
+ */
10
19
  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.");
20
+ const newPath = getConfigPath(cwd);
21
+ const legacyPath = getLegacyConfigPath(cwd);
22
+ let raw;
23
+ if (existsSync(newPath)) {
24
+ raw = JSON.parse(readFileSync(newPath, "utf-8"));
25
+ }
26
+ else if (existsSync(legacyPath)) {
27
+ raw = JSON.parse(readFileSync(legacyPath, "utf-8"));
28
+ }
29
+ else {
30
+ throw new Error("No matchkit.json found. Run `matchkit init` first.");
14
31
  }
15
- return JSON.parse(readFileSync(path, "utf-8"));
32
+ // Auto-migrate legacy fields
33
+ return migrateLegacyConfig(raw);
34
+ }
35
+ /** Migrate legacy config fields to new format */
36
+ function migrateLegacyConfig(raw) {
37
+ const legacy = raw;
38
+ return {
39
+ $schema: raw.$schema ?? "https://matchkit.io/schemas/config.json",
40
+ preset: legacy.theme ?? raw.preset ?? "clarity",
41
+ accent: raw.accent ?? "#4F46E5",
42
+ overrides: raw.overrides ?? {},
43
+ outputDir: legacy.componentsDir ?? raw.outputDir ?? "src/components/ui",
44
+ skillDir: raw.skillDir ?? `.claude/skills/${(legacy.theme ?? raw.preset ?? "clarity")}-ui`,
45
+ registryUrl: raw.registryUrl ?? "https://www.matchkit.io/api/registry",
46
+ configId: raw.configId,
47
+ lastPull: raw.lastPull,
48
+ buildId: raw.buildId,
49
+ };
16
50
  }
17
51
  export function writeConfig(config, cwd = process.cwd()) {
18
52
  const path = getConfigPath(cwd);
19
- const dir = dirname(path);
20
- if (!existsSync(dir)) {
21
- mkdirSync(dir, { recursive: true });
22
- }
23
53
  writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
24
54
  }
25
- export function createDefaultConfig(theme, accent, overrides = {}) {
55
+ export function createDefaultConfig(preset, accent, overrides = {}) {
26
56
  return {
27
57
  $schema: "https://matchkit.io/schemas/config.json",
28
- version: "1.0.0",
29
- theme,
58
+ preset,
30
59
  accent,
31
60
  overrides,
32
- componentsDir: "src/components/ui",
33
- skillDir: `.claude/skills/${theme}-ui`,
61
+ outputDir: "src/components/ui",
62
+ skillDir: `.claude/skills/${preset}-ui`,
34
63
  registryUrl: "https://www.matchkit.io/api/registry",
35
64
  };
36
65
  }
@@ -10,13 +10,22 @@ export interface RegistryComponent {
10
10
  dependencies: string[];
11
11
  registryDependencies: string[];
12
12
  }
13
+ /** Per-component content hash for incremental updates */
14
+ export interface ComponentHashEntry {
15
+ hash: string;
16
+ file: string;
17
+ }
13
18
  export interface RegistryData {
14
19
  $schema: string;
15
20
  basePath: string;
16
21
  components: RegistryComponent[];
22
+ /** Content hashes per component (for incremental CLI updates) */
23
+ componentHashes?: Record<string, ComponentHashEntry>;
24
+ /** Build ID associated with these hashes */
25
+ buildId?: string;
17
26
  }
18
27
  /**
19
- * Fetch the full registry for a theme from the registry API.
28
+ * Fetch the full registry for a preset from the registry API.
20
29
  */
21
30
  export declare function fetchRegistry(config: MatchKitConfig): Promise<RegistryData>;
22
31
  /**
@@ -31,6 +40,10 @@ export declare function fetchComponent(config: MatchKitConfig, name: string): Pr
31
40
  * Load a local registry.json from the skill directory.
32
41
  */
33
42
  export declare function loadLocalRegistry(skillDir: string): RegistryData | null;
43
+ /**
44
+ * Load local component hashes from component-hashes.json.
45
+ */
46
+ export declare function loadLocalHashes(skillDir: string): Record<string, ComponentHashEntry> | null;
34
47
  /**
35
48
  * Check if a component is installed locally.
36
49
  */
@@ -2,12 +2,11 @@ import { readFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAuthHeaders } from "./auth.js";
4
4
  /**
5
- * Fetch the full registry for a theme from the registry API.
5
+ * Fetch the full registry for a preset from the registry API.
6
6
  */
7
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() : {};
8
+ const url = `${config.registryUrl}?theme=${encodeURIComponent(config.preset)}`;
9
+ const headers = config.preset !== "clarity" ? getAuthHeaders() : {};
11
10
  const res = await fetch(url, { headers });
12
11
  if (!res.ok) {
13
12
  throw new Error(`Failed to fetch registry: ${res.status} ${res.statusText}`);
@@ -18,8 +17,8 @@ export async function fetchRegistry(config) {
18
17
  * Fetch a single component's source code from the registry API.
19
18
  */
20
19
  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() : {};
20
+ const url = `${config.registryUrl}/component?theme=${encodeURIComponent(config.preset)}&name=${encodeURIComponent(name)}`;
21
+ const headers = config.preset !== "clarity" ? getAuthHeaders() : {};
23
22
  const res = await fetch(url, { headers });
24
23
  if (!res.ok) {
25
24
  throw new Error(`Failed to fetch component "${name}": ${res.status} ${res.statusText}`);
@@ -41,6 +40,21 @@ export function loadLocalRegistry(skillDir) {
41
40
  return null;
42
41
  }
43
42
  }
43
+ /**
44
+ * Load local component hashes from component-hashes.json.
45
+ */
46
+ export function loadLocalHashes(skillDir) {
47
+ const hashesPath = join(process.cwd(), skillDir, "component-hashes.json");
48
+ if (!existsSync(hashesPath))
49
+ return null;
50
+ try {
51
+ const data = JSON.parse(readFileSync(hashesPath, "utf-8"));
52
+ return data.components ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
44
58
  /**
45
59
  * Check if a component is installed locally.
46
60
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matchkit.io/cli",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for MatchKit design system skills. Init projects, add components, manage your design system.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",