claudekit-cli 1.1.0 → 1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudekit-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI tool for bootstrapping and updating ClaudeKit projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathExists, readdir } from "fs-extra";
3
3
  import ora from "ora";
4
+ import { AuthManager } from "../lib/auth.js";
4
5
  import { DownloadManager } from "../lib/download.js";
5
6
  import { GitHubClient } from "../lib/github.js";
6
7
  import { FileMerger } from "../lib/merge.js";
@@ -81,20 +82,51 @@ export async function newCommand(options: NewCommandOptions): Promise<void> {
81
82
 
82
83
  logger.success(`Found release: ${release.tag_name} - ${release.name}`);
83
84
 
84
- // Find downloadable asset
85
- const asset = release.assets.find(
86
- (a) => a.name.endsWith(".tar.gz") || a.name.endsWith(".tgz") || a.name.endsWith(".zip"),
87
- );
85
+ // Get downloadable asset (custom asset or GitHub tarball)
86
+ const downloadInfo = GitHubClient.getDownloadableAsset(release);
88
87
 
89
- if (!asset) {
90
- logger.error("No downloadable archive found in release");
91
- return;
92
- }
88
+ logger.info(`Download source: ${downloadInfo.type}`);
89
+ logger.debug(`Download URL: ${downloadInfo.url}`);
93
90
 
94
91
  // Download asset
95
92
  const downloadManager = new DownloadManager();
96
93
  const tempDir = await downloadManager.createTempDir();
97
- const archivePath = await downloadManager.downloadAsset(asset, tempDir);
94
+
95
+ // Get authentication token for API requests
96
+ const { token } = await AuthManager.getToken();
97
+
98
+ let archivePath: string;
99
+ try {
100
+ // Try downloading the asset/tarball with authentication
101
+ archivePath = await downloadManager.downloadFile({
102
+ url: downloadInfo.url,
103
+ name: downloadInfo.name,
104
+ size: downloadInfo.size,
105
+ destDir: tempDir,
106
+ token, // Always pass token for private repository access
107
+ });
108
+ } catch (error) {
109
+ // If asset download fails, fallback to GitHub tarball
110
+ if (downloadInfo.type === "asset") {
111
+ logger.warning("Asset download failed, falling back to GitHub tarball...");
112
+ const tarballInfo = {
113
+ type: "github-tarball" as const,
114
+ url: release.tarball_url,
115
+ name: `${kitConfig.repo}-${release.tag_name}.tar.gz`,
116
+ size: 0, // Size unknown for tarball
117
+ };
118
+
119
+ archivePath = await downloadManager.downloadFile({
120
+ url: tarballInfo.url,
121
+ name: tarballInfo.name,
122
+ size: tarballInfo.size,
123
+ destDir: tempDir,
124
+ token,
125
+ });
126
+ } else {
127
+ throw error;
128
+ }
129
+ }
98
130
 
99
131
  // Extract archive
100
132
  const extractDir = `${tempDir}/extracted`;
@@ -1,12 +1,14 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathExists } from "fs-extra";
3
3
  import ora from "ora";
4
+ import { AuthManager } from "../lib/auth.js";
4
5
  import { DownloadManager } from "../lib/download.js";
5
6
  import { GitHubClient } from "../lib/github.js";
6
7
  import { FileMerger } from "../lib/merge.js";
7
8
  import { PromptsManager } from "../lib/prompts.js";
8
9
  import { AVAILABLE_KITS, type UpdateCommandOptions, UpdateCommandOptionsSchema } from "../types.js";
9
10
  import { ConfigManager } from "../utils/config.js";
11
+ import { FileScanner } from "../utils/file-scanner.js";
10
12
  import { logger } from "../utils/logger.js";
11
13
 
12
14
  export async function updateCommand(options: UpdateCommandOptions): Promise<void> {
@@ -73,36 +75,80 @@ export async function updateCommand(options: UpdateCommandOptions): Promise<void
73
75
 
74
76
  logger.success(`Found release: ${release.tag_name} - ${release.name}`);
75
77
 
76
- // Find downloadable asset
77
- const asset = release.assets.find(
78
- (a) => a.name.endsWith(".tar.gz") || a.name.endsWith(".tgz") || a.name.endsWith(".zip"),
79
- );
78
+ // Get downloadable asset (custom asset or GitHub tarball)
79
+ const downloadInfo = GitHubClient.getDownloadableAsset(release);
80
80
 
81
- if (!asset) {
82
- logger.error("No downloadable archive found in release");
83
- return;
84
- }
81
+ logger.info(`Download source: ${downloadInfo.type}`);
82
+ logger.debug(`Download URL: ${downloadInfo.url}`);
85
83
 
86
84
  // Download asset
87
85
  const downloadManager = new DownloadManager();
88
86
  const tempDir = await downloadManager.createTempDir();
89
- const archivePath = await downloadManager.downloadAsset(asset, tempDir);
87
+
88
+ // Get authentication token for API requests
89
+ const { token } = await AuthManager.getToken();
90
+
91
+ let archivePath: string;
92
+ try {
93
+ // Try downloading the asset/tarball with authentication
94
+ archivePath = await downloadManager.downloadFile({
95
+ url: downloadInfo.url,
96
+ name: downloadInfo.name,
97
+ size: downloadInfo.size,
98
+ destDir: tempDir,
99
+ token, // Always pass token for private repository access
100
+ });
101
+ } catch (error) {
102
+ // If asset download fails, fallback to GitHub tarball
103
+ if (downloadInfo.type === "asset") {
104
+ logger.warning("Asset download failed, falling back to GitHub tarball...");
105
+ const tarballInfo = {
106
+ type: "github-tarball" as const,
107
+ url: release.tarball_url,
108
+ name: `${kitConfig.repo}-${release.tag_name}.tar.gz`,
109
+ size: 0, // Size unknown for tarball
110
+ };
111
+
112
+ archivePath = await downloadManager.downloadFile({
113
+ url: tarballInfo.url,
114
+ name: tarballInfo.name,
115
+ size: tarballInfo.size,
116
+ destDir: tempDir,
117
+ token,
118
+ });
119
+ } else {
120
+ throw error;
121
+ }
122
+ }
90
123
 
91
124
  // Extract archive
92
125
  const extractDir = `${tempDir}/extracted`;
93
126
  await downloadManager.extractArchive(archivePath, extractDir);
94
127
 
128
+ // Identify custom .claude files to preserve
129
+ logger.info("Scanning for custom .claude files...");
130
+ const customClaudeFiles = await FileScanner.findCustomFiles(resolvedDir, extractDir, ".claude");
131
+
95
132
  // Merge files with confirmation
96
133
  const merger = new FileMerger();
134
+
135
+ // Add custom .claude files to ignore patterns
136
+ if (customClaudeFiles.length > 0) {
137
+ merger.addIgnorePatterns(customClaudeFiles);
138
+ logger.success(`Protected ${customClaudeFiles.length} custom .claude file(s)`);
139
+ }
140
+
97
141
  await merger.merge(extractDir, resolvedDir, false); // Show confirmation for updates
98
142
 
99
143
  prompts.outro(`✨ Project updated successfully at ${resolvedDir}`);
100
144
 
101
145
  // Show next steps
102
- prompts.note(
103
- "Your project has been updated with the latest version.\nProtected files (.env, etc.) were not modified.",
104
- "Update complete",
105
- );
146
+ const protectedNote =
147
+ customClaudeFiles.length > 0
148
+ ? "Your project has been updated with the latest version.\nProtected files (.env, .claude custom files, etc.) were not modified."
149
+ : "Your project has been updated with the latest version.\nProtected files (.env, etc.) were not modified.";
150
+
151
+ prompts.note(protectedNote, "Update complete");
106
152
  } catch (error) {
107
153
  if (error instanceof Error && error.message === "Merge cancelled by user") {
108
154
  logger.warning("Update cancelled");
package/src/index.ts CHANGED
@@ -7,6 +7,15 @@ import { cac } from "cac";
7
7
  import { newCommand } from "./commands/new.js";
8
8
  import { updateCommand } from "./commands/update.js";
9
9
  import { versionCommand } from "./commands/version.js";
10
+ import { logger } from "./utils/logger.js";
11
+
12
+ // Set proper output encoding to prevent unicode rendering issues
13
+ if (process.stdout.setEncoding) {
14
+ process.stdout.setEncoding("utf8");
15
+ }
16
+ if (process.stderr.setEncoding) {
17
+ process.stderr.setEncoding("utf8");
18
+ }
10
19
 
11
20
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
12
21
 
@@ -15,6 +24,10 @@ const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"),
15
24
 
16
25
  const cli = cac("ck");
17
26
 
27
+ // Global options
28
+ cli.option("--verbose, -v", "Enable verbose logging for debugging");
29
+ cli.option("--log-file <path>", "Write logs to file");
30
+
18
31
  // New command
19
32
  cli
20
33
  .command("new", "Bootstrap a new ClaudeKit project")
@@ -51,5 +64,33 @@ cli.version(packageJson.version);
51
64
  // Help
52
65
  cli.help();
53
66
 
54
- // Parse CLI arguments
67
+ // Parse to get global options first
68
+ const parsed = cli.parse(process.argv, { run: false });
69
+
70
+ // Check environment variable
71
+ const envVerbose =
72
+ process.env.CLAUDEKIT_VERBOSE === "1" || process.env.CLAUDEKIT_VERBOSE === "true";
73
+
74
+ // Enable verbose if flag or env var is set
75
+ const isVerbose = parsed.options.verbose || envVerbose;
76
+
77
+ if (isVerbose) {
78
+ logger.setVerbose(true);
79
+ }
80
+
81
+ // Set log file if specified
82
+ if (parsed.options.logFile) {
83
+ logger.setLogFile(parsed.options.logFile);
84
+ }
85
+
86
+ // Log startup info in verbose mode
87
+ logger.verbose("ClaudeKit CLI starting", {
88
+ version: packageJson.version,
89
+ command: parsed.args[0] || "none",
90
+ options: parsed.options,
91
+ cwd: process.cwd(),
92
+ node: process.version,
93
+ });
94
+
95
+ // Parse again to run the command
55
96
  cli.parse();
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { pipeline } from "node:stream";
6
6
  import { promisify } from "node:util";
7
7
  import cliProgress from "cli-progress";
8
+ import ignore from "ignore";
8
9
  import ora from "ora";
9
10
  import * as tar from "tar";
10
11
  import unzipper from "unzipper";
@@ -19,6 +20,29 @@ import { logger } from "../utils/logger.js";
19
20
  const streamPipeline = promisify(pipeline);
20
21
 
21
22
  export class DownloadManager {
23
+ /**
24
+ * Patterns to exclude from extraction
25
+ */
26
+ private static EXCLUDE_PATTERNS = [
27
+ ".git",
28
+ ".git/**",
29
+ ".github",
30
+ ".github/**",
31
+ "node_modules",
32
+ "node_modules/**",
33
+ ".DS_Store",
34
+ "Thumbs.db",
35
+ "*.log",
36
+ ];
37
+
38
+ /**
39
+ * Check if file path should be excluded
40
+ */
41
+ private shouldExclude(filePath: string): boolean {
42
+ const ig = ignore().add(DownloadManager.EXCLUDE_PATTERNS);
43
+ return ig.ignores(filePath);
44
+ }
45
+
22
46
  /**
23
47
  * Download asset from URL with progress tracking
24
48
  */
@@ -91,6 +115,90 @@ export class DownloadManager {
91
115
  }
92
116
  }
93
117
 
118
+ /**
119
+ * Download file from URL with progress tracking (supports both assets and API URLs)
120
+ */
121
+ async downloadFile(params: {
122
+ url: string;
123
+ name: string;
124
+ size?: number;
125
+ destDir: string;
126
+ token?: string;
127
+ }): Promise<string> {
128
+ const { url, name, size, destDir, token } = params;
129
+ const destPath = join(destDir, name);
130
+
131
+ await mkdir(destDir, { recursive: true });
132
+
133
+ logger.info(`Downloading ${name}${size ? ` (${this.formatBytes(size)})` : ""}...`);
134
+
135
+ const headers: Record<string, string> = {};
136
+
137
+ // Add authentication for GitHub API URLs
138
+ if (token && url.includes("api.github.com")) {
139
+ headers.Authorization = `Bearer ${token}`;
140
+ headers.Accept = "application/vnd.github+json";
141
+ } else {
142
+ headers.Accept = "application/octet-stream";
143
+ }
144
+
145
+ const response = await fetch(url, { headers });
146
+
147
+ if (!response.ok) {
148
+ throw new DownloadError(`Failed to download: ${response.statusText}`);
149
+ }
150
+
151
+ const totalSize = size || Number(response.headers.get("content-length")) || 0;
152
+ let downloadedSize = 0;
153
+
154
+ // Create progress bar only if we know the size
155
+ const progressBar =
156
+ totalSize > 0
157
+ ? new cliProgress.SingleBar({
158
+ format: "Progress |{bar}| {percentage}% | {value}/{total} MB",
159
+ barCompleteChar: "\u2588",
160
+ barIncompleteChar: "\u2591",
161
+ hideCursor: true,
162
+ })
163
+ : null;
164
+
165
+ if (progressBar) {
166
+ progressBar.start(Math.round(totalSize / 1024 / 1024), 0);
167
+ }
168
+
169
+ const fileStream = createWriteStream(destPath);
170
+ const reader = response.body?.getReader();
171
+
172
+ if (!reader) {
173
+ throw new DownloadError("Failed to get response reader");
174
+ }
175
+
176
+ try {
177
+ while (true) {
178
+ const { done, value } = await reader.read();
179
+
180
+ if (done) break;
181
+
182
+ fileStream.write(value);
183
+ downloadedSize += value.length;
184
+
185
+ if (progressBar) {
186
+ progressBar.update(Math.round(downloadedSize / 1024 / 1024));
187
+ }
188
+ }
189
+
190
+ fileStream.end();
191
+ if (progressBar) progressBar.stop();
192
+
193
+ logger.success(`Downloaded ${name}`);
194
+ return destPath;
195
+ } catch (error) {
196
+ fileStream.close();
197
+ if (progressBar) progressBar.stop();
198
+ throw error;
199
+ }
200
+ }
201
+
94
202
  /**
95
203
  * Extract archive to destination
96
204
  */
@@ -133,6 +241,14 @@ export class DownloadManager {
133
241
  file: archivePath,
134
242
  cwd: destDir,
135
243
  strip: 1, // Strip the root directory from the archive
244
+ filter: (path: string) => {
245
+ // Exclude unwanted files
246
+ const shouldInclude = !this.shouldExclude(path);
247
+ if (!shouldInclude) {
248
+ logger.debug(`Excluding: ${path}`);
249
+ }
250
+ return shouldInclude;
251
+ },
136
252
  });
137
253
  }
138
254
 
@@ -140,7 +256,121 @@ export class DownloadManager {
140
256
  * Extract zip archive
141
257
  */
142
258
  private async extractZip(archivePath: string, destDir: string): Promise<void> {
143
- await streamPipeline(createReadStream(archivePath), unzipper.Extract({ path: destDir }));
259
+ const { readdir, stat, mkdir: mkdirPromise, copyFile, rm } = await import("node:fs/promises");
260
+ const { join: pathJoin } = await import("node:path");
261
+
262
+ // Extract to a temporary directory first
263
+ const tempExtractDir = `${destDir}-temp`;
264
+ await mkdirPromise(tempExtractDir, { recursive: true });
265
+
266
+ try {
267
+ // Extract zip to temp directory
268
+ await streamPipeline(
269
+ createReadStream(archivePath),
270
+ unzipper.Extract({ path: tempExtractDir }),
271
+ );
272
+
273
+ // Find the root directory in the zip (if any)
274
+ const entries = await readdir(tempExtractDir);
275
+
276
+ // If there's a single root directory, strip it
277
+ if (entries.length === 1) {
278
+ const rootEntry = entries[0];
279
+ const rootPath = pathJoin(tempExtractDir, rootEntry);
280
+ const rootStat = await stat(rootPath);
281
+
282
+ if (rootStat.isDirectory()) {
283
+ // Move contents from the root directory to the destination
284
+ await this.moveDirectoryContents(rootPath, destDir);
285
+ } else {
286
+ // Single file, just move it
287
+ await mkdirPromise(destDir, { recursive: true });
288
+ await copyFile(rootPath, pathJoin(destDir, rootEntry));
289
+ }
290
+ } else {
291
+ // Multiple entries at root, move them all
292
+ await this.moveDirectoryContents(tempExtractDir, destDir);
293
+ }
294
+
295
+ // Clean up temp directory
296
+ await rm(tempExtractDir, { recursive: true, force: true });
297
+ } catch (error) {
298
+ // Clean up temp directory on error
299
+ try {
300
+ await rm(tempExtractDir, { recursive: true, force: true });
301
+ } catch {
302
+ // Ignore cleanup errors
303
+ }
304
+ throw error;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Move directory contents from source to destination, applying exclusion filters
310
+ */
311
+ private async moveDirectoryContents(sourceDir: string, destDir: string): Promise<void> {
312
+ const { readdir, stat, mkdir: mkdirPromise, copyFile } = await import("node:fs/promises");
313
+ const { join: pathJoin, relative } = await import("node:path");
314
+
315
+ await mkdirPromise(destDir, { recursive: true });
316
+
317
+ const entries = await readdir(sourceDir);
318
+
319
+ for (const entry of entries) {
320
+ const sourcePath = pathJoin(sourceDir, entry);
321
+ const destPath = pathJoin(destDir, entry);
322
+ const relativePath = relative(sourceDir, sourcePath);
323
+
324
+ // Skip excluded files
325
+ if (this.shouldExclude(relativePath)) {
326
+ logger.debug(`Excluding: ${relativePath}`);
327
+ continue;
328
+ }
329
+
330
+ const entryStat = await stat(sourcePath);
331
+
332
+ if (entryStat.isDirectory()) {
333
+ // Recursively copy directory
334
+ await this.copyDirectory(sourcePath, destPath);
335
+ } else {
336
+ // Copy file
337
+ await copyFile(sourcePath, destPath);
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Recursively copy directory
344
+ */
345
+ private async copyDirectory(sourceDir: string, destDir: string): Promise<void> {
346
+ const { readdir, stat, mkdir: mkdirPromise, copyFile } = await import("node:fs/promises");
347
+ const { join: pathJoin, relative } = await import("node:path");
348
+
349
+ await mkdirPromise(destDir, { recursive: true });
350
+
351
+ const entries = await readdir(sourceDir);
352
+
353
+ for (const entry of entries) {
354
+ const sourcePath = pathJoin(sourceDir, entry);
355
+ const destPath = pathJoin(destDir, entry);
356
+ const relativePath = relative(sourceDir, sourcePath);
357
+
358
+ // Skip excluded files
359
+ if (this.shouldExclude(relativePath)) {
360
+ logger.debug(`Excluding: ${relativePath}`);
361
+ continue;
362
+ }
363
+
364
+ const entryStat = await stat(sourcePath);
365
+
366
+ if (entryStat.isDirectory()) {
367
+ // Recursively copy directory
368
+ await this.copyDirectory(sourcePath, destPath);
369
+ } else {
370
+ // Copy file
371
+ await copyFile(sourcePath, destPath);
372
+ }
373
+ }
144
374
  }
145
375
 
146
376
  /**
package/src/lib/github.ts CHANGED
@@ -154,4 +154,60 @@ export class GitHubClient {
154
154
  );
155
155
  }
156
156
  }
157
+
158
+ /**
159
+ * Get downloadable asset or source code URL from release
160
+ * Priority:
161
+ * 1. "ClaudeKit Engineer Package" or "ClaudeKit Marketing Package" zip file
162
+ * 2. Other custom uploaded assets (.tar.gz, .tgz, .zip)
163
+ * 3. GitHub's automatic tarball URL
164
+ */
165
+ static getDownloadableAsset(release: GitHubRelease): {
166
+ type: "asset" | "tarball" | "zipball";
167
+ url: string;
168
+ name: string;
169
+ size?: number;
170
+ } {
171
+ // First priority: Look for official ClaudeKit package assets
172
+ const packageAsset = release.assets.find(
173
+ (a) =>
174
+ a.name.toLowerCase().includes("claudekit") &&
175
+ a.name.toLowerCase().includes("package") &&
176
+ a.name.endsWith(".zip"),
177
+ );
178
+
179
+ if (packageAsset) {
180
+ logger.debug(`Using ClaudeKit package asset: ${packageAsset.name}`);
181
+ return {
182
+ type: "asset",
183
+ url: packageAsset.browser_download_url,
184
+ name: packageAsset.name,
185
+ size: packageAsset.size,
186
+ };
187
+ }
188
+
189
+ // Second priority: Look for any custom uploaded assets
190
+ const customAsset = release.assets.find(
191
+ (a) => a.name.endsWith(".tar.gz") || a.name.endsWith(".tgz") || a.name.endsWith(".zip"),
192
+ );
193
+
194
+ if (customAsset) {
195
+ logger.debug(`Using custom asset: ${customAsset.name}`);
196
+ return {
197
+ type: "asset",
198
+ url: customAsset.browser_download_url,
199
+ name: customAsset.name,
200
+ size: customAsset.size,
201
+ };
202
+ }
203
+
204
+ // Fall back to GitHub's automatic tarball
205
+ logger.debug("Using GitHub automatic tarball");
206
+ return {
207
+ type: "tarball",
208
+ url: release.tarball_url,
209
+ name: `${release.tag_name}.tar.gz`,
210
+ size: undefined, // Size unknown for automatic tarballs
211
+ };
212
+ }
157
213
  }
@@ -1,5 +1,6 @@
1
1
  import * as clack from "@clack/prompts";
2
2
  import { AVAILABLE_KITS, type KitType } from "../types.js";
3
+ import { intro, note, outro } from "../utils/safe-prompts.js";
3
4
 
4
5
  export class PromptsManager {
5
6
  /**
@@ -94,20 +95,20 @@ export class PromptsManager {
94
95
  * Show intro message
95
96
  */
96
97
  intro(message: string): void {
97
- clack.intro(message);
98
+ intro(message);
98
99
  }
99
100
 
100
101
  /**
101
102
  * Show outro message
102
103
  */
103
104
  outro(message: string): void {
104
- clack.outro(message);
105
+ outro(message);
105
106
  }
106
107
 
107
108
  /**
108
109
  * Show note
109
110
  */
110
111
  note(message: string, title?: string): void {
111
- clack.note(message, title);
112
+ note(message, title);
112
113
  }
113
114
  }
package/src/types.ts CHANGED
@@ -60,6 +60,8 @@ export const GitHubReleaseSchema = z.object({
60
60
  prerelease: z.boolean(),
61
61
  assets: z.array(GitHubReleaseAssetSchema),
62
62
  published_at: z.string().optional(),
63
+ tarball_url: z.string().url(),
64
+ zipball_url: z.string().url(),
63
65
  });
64
66
  export type GitHubRelease = z.infer<typeof GitHubReleaseSchema>;
65
67
 
@@ -77,13 +79,13 @@ export const AVAILABLE_KITS: Record<KitType, KitConfig> = {
77
79
  engineer: {
78
80
  name: "ClaudeKit Engineer",
79
81
  repo: "claudekit-engineer",
80
- owner: "mrgoonie",
82
+ owner: "claudekit",
81
83
  description: "Engineering toolkit for building with Claude",
82
84
  },
83
85
  marketing: {
84
86
  name: "ClaudeKit Marketing",
85
87
  repo: "claudekit-marketing",
86
- owner: "mrgoonie",
88
+ owner: "claudekit",
87
89
  description: "[Coming Soon] Marketing toolkit",
88
90
  },
89
91
  };