claudekit-cli 1.2.0 → 1.2.2

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.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "CLI tool for bootstrapping and updating ClaudeKit projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "@octokit/rest": "^22.0.0",
34
34
  "cac": "^6.7.14",
35
35
  "cli-progress": "^3.12.0",
36
+ "extract-zip": "^2.0.1",
36
37
  "fs-extra": "^11.2.0",
37
38
  "ignore": "^5.3.2",
38
39
  "keytar": "^7.9.0",
@@ -40,7 +41,6 @@
40
41
  "picocolors": "^1.1.1",
41
42
  "tar": "^7.4.3",
42
43
  "tmp": "^0.2.3",
43
- "unzipper": "^0.12.3",
44
44
  "zod": "^3.23.8"
45
45
  },
46
46
  "devDependencies": {
@@ -1,6 +1,5 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathExists, readdir } from "fs-extra";
3
- import ora from "ora";
4
3
  import { AuthManager } from "../lib/auth.js";
5
4
  import { DownloadManager } from "../lib/download.js";
6
5
  import { GitHubClient } from "../lib/github.js";
@@ -9,6 +8,7 @@ import { PromptsManager } from "../lib/prompts.js";
9
8
  import { AVAILABLE_KITS, type NewCommandOptions, NewCommandOptionsSchema } from "../types.js";
10
9
  import { ConfigManager } from "../utils/config.js";
11
10
  import { logger } from "../utils/logger.js";
11
+ import { createSpinner } from "../utils/safe-spinner.js";
12
12
 
13
13
  export async function newCommand(options: NewCommandOptions): Promise<void> {
14
14
  const prompts = new PromptsManager();
@@ -59,7 +59,7 @@ export async function newCommand(options: NewCommandOptions): Promise<void> {
59
59
  const github = new GitHubClient();
60
60
 
61
61
  // Check repository access
62
- const spinner = ora("Checking repository access...").start();
62
+ const spinner = createSpinner("Checking repository access...").start();
63
63
  const hasAccess = await github.checkAccess(kitConfig);
64
64
  if (!hasAccess) {
65
65
  spinner.fail("Access denied to repository");
@@ -132,6 +132,9 @@ export async function newCommand(options: NewCommandOptions): Promise<void> {
132
132
  const extractDir = `${tempDir}/extracted`;
133
133
  await downloadManager.extractArchive(archivePath, extractDir);
134
134
 
135
+ // Validate extraction
136
+ await downloadManager.validateExtraction(extractDir);
137
+
135
138
  // Copy files to target directory
136
139
  const merger = new FileMerger();
137
140
  await merger.merge(extractDir, resolvedDir, true); // Skip confirmation for new projects
@@ -1,6 +1,5 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathExists } from "fs-extra";
3
- import ora from "ora";
4
3
  import { AuthManager } from "../lib/auth.js";
5
4
  import { DownloadManager } from "../lib/download.js";
6
5
  import { GitHubClient } from "../lib/github.js";
@@ -10,6 +9,7 @@ import { AVAILABLE_KITS, type UpdateCommandOptions, UpdateCommandOptionsSchema }
10
9
  import { ConfigManager } from "../utils/config.js";
11
10
  import { FileScanner } from "../utils/file-scanner.js";
12
11
  import { logger } from "../utils/logger.js";
12
+ import { createSpinner } from "../utils/safe-spinner.js";
13
13
 
14
14
  export async function updateCommand(options: UpdateCommandOptions): Promise<void> {
15
15
  const prompts = new PromptsManager();
@@ -52,7 +52,7 @@ export async function updateCommand(options: UpdateCommandOptions): Promise<void
52
52
  const github = new GitHubClient();
53
53
 
54
54
  // Check repository access
55
- const spinner = ora("Checking repository access...").start();
55
+ const spinner = createSpinner("Checking repository access...").start();
56
56
  const hasAccess = await github.checkAccess(kitConfig);
57
57
  if (!hasAccess) {
58
58
  spinner.fail("Access denied to repository");
@@ -125,6 +125,9 @@ export async function updateCommand(options: UpdateCommandOptions): Promise<void
125
125
  const extractDir = `${tempDir}/extracted`;
126
126
  await downloadManager.extractArchive(archivePath, extractDir);
127
127
 
128
+ // Validate extraction
129
+ await downloadManager.validateExtraction(extractDir);
130
+
128
131
  // Identify custom .claude files to preserve
129
132
  logger.info("Scanning for custom .claude files...");
130
133
  const customClaudeFiles = await FileScanner.findCustomFiles(resolvedDir, extractDir, ".claude");
@@ -1,14 +1,11 @@
1
- import { createReadStream, createWriteStream } from "node:fs";
1
+ import { createWriteStream } from "node:fs";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { pipeline } from "node:stream";
6
- import { promisify } from "node:util";
7
5
  import cliProgress from "cli-progress";
6
+ import extractZip from "extract-zip";
8
7
  import ignore from "ignore";
9
- import ora from "ora";
10
8
  import * as tar from "tar";
11
- import unzipper from "unzipper";
12
9
  import {
13
10
  type ArchiveType,
14
11
  DownloadError,
@@ -16,8 +13,7 @@ import {
16
13
  type GitHubReleaseAsset,
17
14
  } from "../types.js";
18
15
  import { logger } from "../utils/logger.js";
19
-
20
- const streamPipeline = promisify(pipeline);
16
+ import { createSpinner } from "../utils/safe-spinner.js";
21
17
 
22
18
  export class DownloadManager {
23
19
  /**
@@ -55,11 +51,11 @@ export class DownloadManager {
55
51
 
56
52
  logger.info(`Downloading ${asset.name} (${this.formatBytes(asset.size)})...`);
57
53
 
58
- // Create progress bar
54
+ // Create progress bar with simple ASCII characters
59
55
  const progressBar = new cliProgress.SingleBar({
60
56
  format: "Progress |{bar}| {percentage}% | {value}/{total} MB",
61
- barCompleteChar: "\u2588",
62
- barIncompleteChar: "\u2591",
57
+ barCompleteChar: "=",
58
+ barIncompleteChar: "-",
63
59
  hideCursor: true,
64
60
  });
65
61
 
@@ -137,7 +133,9 @@ export class DownloadManager {
137
133
  // Add authentication for GitHub API URLs
138
134
  if (token && url.includes("api.github.com")) {
139
135
  headers.Authorization = `Bearer ${token}`;
140
- headers.Accept = "application/vnd.github+json";
136
+ // Use application/octet-stream for asset downloads (not vnd.github+json)
137
+ headers.Accept = "application/octet-stream";
138
+ headers["X-GitHub-Api-Version"] = "2022-11-28";
141
139
  } else {
142
140
  headers.Accept = "application/octet-stream";
143
141
  }
@@ -151,13 +149,13 @@ export class DownloadManager {
151
149
  const totalSize = size || Number(response.headers.get("content-length")) || 0;
152
150
  let downloadedSize = 0;
153
151
 
154
- // Create progress bar only if we know the size
152
+ // Create progress bar only if we know the size (using simple ASCII characters)
155
153
  const progressBar =
156
154
  totalSize > 0
157
155
  ? new cliProgress.SingleBar({
158
156
  format: "Progress |{bar}| {percentage}% | {value}/{total} MB",
159
- barCompleteChar: "\u2588",
160
- barIncompleteChar: "\u2591",
157
+ barCompleteChar: "=",
158
+ barIncompleteChar: "-",
161
159
  hideCursor: true,
162
160
  })
163
161
  : null;
@@ -207,7 +205,7 @@ export class DownloadManager {
207
205
  destDir: string,
208
206
  archiveType?: ArchiveType,
209
207
  ): Promise<void> {
210
- const spinner = ora("Extracting files...").start();
208
+ const spinner = createSpinner("Extracting files...").start();
211
209
 
212
210
  try {
213
211
  // Detect archive type from filename if not provided
@@ -237,19 +235,95 @@ export class DownloadManager {
237
235
  * Extract tar.gz archive
238
236
  */
239
237
  private async extractTarGz(archivePath: string, destDir: string): Promise<void> {
240
- await tar.extract({
241
- file: archivePath,
242
- cwd: destDir,
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}`);
238
+ const { readdir, stat, mkdir: mkdirPromise, copyFile, rm } = await import("node:fs/promises");
239
+ const { join: pathJoin } = await import("node:path");
240
+
241
+ // Extract to a temporary directory first
242
+ const tempExtractDir = `${destDir}-temp`;
243
+ await mkdirPromise(tempExtractDir, { recursive: true });
244
+
245
+ try {
246
+ // Extract without stripping first
247
+ await tar.extract({
248
+ file: archivePath,
249
+ cwd: tempExtractDir,
250
+ strip: 0, // Don't strip yet - we'll decide based on wrapper detection
251
+ filter: (path: string) => {
252
+ // Exclude unwanted files
253
+ const shouldInclude = !this.shouldExclude(path);
254
+ if (!shouldInclude) {
255
+ logger.debug(`Excluding: ${path}`);
256
+ }
257
+ return shouldInclude;
258
+ },
259
+ });
260
+
261
+ logger.debug(`Extracted TAR.GZ to temp: ${tempExtractDir}`);
262
+
263
+ // Apply same wrapper detection logic as zip
264
+ const entries = await readdir(tempExtractDir);
265
+ logger.debug(`Root entries: ${entries.join(", ")}`);
266
+
267
+ if (entries.length === 1) {
268
+ const rootEntry = entries[0];
269
+ const rootPath = pathJoin(tempExtractDir, rootEntry);
270
+ const rootStat = await stat(rootPath);
271
+
272
+ if (rootStat.isDirectory()) {
273
+ // Check contents of root directory
274
+ const rootContents = await readdir(rootPath);
275
+ logger.debug(`Root directory '${rootEntry}' contains: ${rootContents.join(", ")}`);
276
+
277
+ // Only strip if root is a version/release wrapper
278
+ const isWrapper = this.isWrapperDirectory(rootEntry);
279
+ logger.debug(`Is wrapper directory: ${isWrapper}`);
280
+
281
+ if (isWrapper) {
282
+ // Strip wrapper and move contents
283
+ logger.debug(`Stripping wrapper directory: ${rootEntry}`);
284
+ await this.moveDirectoryContents(rootPath, destDir);
285
+ } else {
286
+ // Keep root directory - move everything including root
287
+ logger.debug("Preserving complete directory structure");
288
+ await this.moveDirectoryContents(tempExtractDir, destDir);
289
+ }
290
+ } else {
291
+ // Single file, just move it
292
+ await mkdirPromise(destDir, { recursive: true });
293
+ await copyFile(rootPath, pathJoin(destDir, rootEntry));
249
294
  }
250
- return shouldInclude;
251
- },
252
- });
295
+ } else {
296
+ // Multiple entries at root, move them all
297
+ logger.debug("Multiple root entries - moving all");
298
+ await this.moveDirectoryContents(tempExtractDir, destDir);
299
+ }
300
+
301
+ logger.debug(`Moved contents to: ${destDir}`);
302
+
303
+ // Clean up temp directory
304
+ await rm(tempExtractDir, { recursive: true, force: true });
305
+ } catch (error) {
306
+ // Clean up temp directory on error
307
+ try {
308
+ await rm(tempExtractDir, { recursive: true, force: true });
309
+ } catch {
310
+ // Ignore cleanup errors
311
+ }
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Check if directory name is a version/release wrapper
318
+ * Examples: claudekit-engineer-v1.0.0, claudekit-engineer-1.0.0, repo-abc1234
319
+ */
320
+ private isWrapperDirectory(dirName: string): boolean {
321
+ // Match version patterns: project-v1.0.0, project-1.0.0
322
+ const versionPattern = /^[\w-]+-v?\d+\.\d+\.\d+/;
323
+ // Match commit hash patterns: project-abc1234
324
+ const hashPattern = /^[\w-]+-[a-f0-9]{7,}$/;
325
+
326
+ return versionPattern.test(dirName) || hashPattern.test(dirName);
253
327
  }
254
328
 
255
329
  /**
@@ -264,24 +338,39 @@ export class DownloadManager {
264
338
  await mkdirPromise(tempExtractDir, { recursive: true });
265
339
 
266
340
  try {
267
- // Extract zip to temp directory
268
- await streamPipeline(
269
- createReadStream(archivePath),
270
- unzipper.Extract({ path: tempExtractDir }),
271
- );
341
+ // Extract zip to temp directory using extract-zip
342
+ await extractZip(archivePath, { dir: tempExtractDir });
343
+
344
+ logger.debug(`Extracted ZIP to temp: ${tempExtractDir}`);
272
345
 
273
346
  // Find the root directory in the zip (if any)
274
347
  const entries = await readdir(tempExtractDir);
348
+ logger.debug(`Root entries: ${entries.join(", ")}`);
275
349
 
276
- // If there's a single root directory, strip it
350
+ // If there's a single root directory, check if it's a wrapper
277
351
  if (entries.length === 1) {
278
352
  const rootEntry = entries[0];
279
353
  const rootPath = pathJoin(tempExtractDir, rootEntry);
280
354
  const rootStat = await stat(rootPath);
281
355
 
282
356
  if (rootStat.isDirectory()) {
283
- // Move contents from the root directory to the destination
284
- await this.moveDirectoryContents(rootPath, destDir);
357
+ // Check contents of root directory
358
+ const rootContents = await readdir(rootPath);
359
+ logger.debug(`Root directory '${rootEntry}' contains: ${rootContents.join(", ")}`);
360
+
361
+ // Only strip if root is a version/release wrapper
362
+ const isWrapper = this.isWrapperDirectory(rootEntry);
363
+ logger.debug(`Is wrapper directory: ${isWrapper}`);
364
+
365
+ if (isWrapper) {
366
+ // Strip wrapper and move contents
367
+ logger.debug(`Stripping wrapper directory: ${rootEntry}`);
368
+ await this.moveDirectoryContents(rootPath, destDir);
369
+ } else {
370
+ // Keep root directory - move everything including root
371
+ logger.debug("Preserving complete directory structure");
372
+ await this.moveDirectoryContents(tempExtractDir, destDir);
373
+ }
285
374
  } else {
286
375
  // Single file, just move it
287
376
  await mkdirPromise(destDir, { recursive: true });
@@ -289,9 +378,12 @@ export class DownloadManager {
289
378
  }
290
379
  } else {
291
380
  // Multiple entries at root, move them all
381
+ logger.debug("Multiple root entries - moving all");
292
382
  await this.moveDirectoryContents(tempExtractDir, destDir);
293
383
  }
294
384
 
385
+ logger.debug(`Moved contents to: ${destDir}`);
386
+
295
387
  // Clean up temp directory
296
388
  await rm(tempExtractDir, { recursive: true, force: true });
297
389
  } catch (error) {
@@ -386,6 +478,44 @@ export class DownloadManager {
386
478
  throw new ExtractionError(`Cannot detect archive type from filename: ${filename}`);
387
479
  }
388
480
 
481
+ /**
482
+ * Validate extraction results
483
+ */
484
+ async validateExtraction(extractDir: string): Promise<boolean> {
485
+ const { readdir, access } = await import("node:fs/promises");
486
+ const { join: pathJoin } = await import("node:path");
487
+ const { constants } = await import("node:fs");
488
+
489
+ try {
490
+ // Check if extract directory exists and is not empty
491
+ const entries = await readdir(extractDir);
492
+ logger.debug(`Extracted files: ${entries.join(", ")}`);
493
+
494
+ if (entries.length === 0) {
495
+ logger.warning("Extraction resulted in no files");
496
+ return false;
497
+ }
498
+
499
+ // Verify critical paths exist
500
+ const criticalPaths = [".claude", "CLAUDE.md"];
501
+ for (const path of criticalPaths) {
502
+ try {
503
+ await access(pathJoin(extractDir, path), constants.F_OK);
504
+ logger.debug(`✓ Found: ${path}`);
505
+ } catch {
506
+ logger.warning(`Expected path not found: ${path}`);
507
+ }
508
+ }
509
+
510
+ return true;
511
+ } catch (error) {
512
+ logger.error(
513
+ `Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
514
+ );
515
+ return false;
516
+ }
517
+ }
518
+
389
519
  /**
390
520
  * Create temporary download directory
391
521
  */
package/src/lib/github.ts CHANGED
@@ -159,7 +159,7 @@ export class GitHubClient {
159
159
  * Get downloadable asset or source code URL from release
160
160
  * Priority:
161
161
  * 1. "ClaudeKit Engineer Package" or "ClaudeKit Marketing Package" zip file
162
- * 2. Other custom uploaded assets (.tar.gz, .tgz, .zip)
162
+ * 2. Other custom uploaded assets (.tar.gz, .tgz, .zip) excluding "Source code" archives
163
163
  * 3. GitHub's automatic tarball URL
164
164
  */
165
165
  static getDownloadableAsset(release: GitHubRelease): {
@@ -168,41 +168,58 @@ export class GitHubClient {
168
168
  name: string;
169
169
  size?: number;
170
170
  } {
171
+ // Log all available assets for debugging
172
+ logger.debug(`Available assets for ${release.tag_name}:`);
173
+ if (release.assets.length === 0) {
174
+ logger.debug(" No custom assets found");
175
+ } else {
176
+ release.assets.forEach((asset, index) => {
177
+ logger.debug(` ${index + 1}. ${asset.name} (${(asset.size / 1024 / 1024).toFixed(2)} MB)`);
178
+ });
179
+ }
180
+
171
181
  // 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
- );
182
+ const packageAsset = release.assets.find((a) => {
183
+ const nameLower = a.name.toLowerCase();
184
+ return (
185
+ nameLower.includes("claudekit") &&
186
+ nameLower.includes("package") &&
187
+ nameLower.endsWith(".zip")
188
+ );
189
+ });
178
190
 
179
191
  if (packageAsset) {
180
- logger.debug(`Using ClaudeKit package asset: ${packageAsset.name}`);
192
+ logger.debug(`✓ Selected ClaudeKit package asset: ${packageAsset.name}`);
181
193
  return {
182
194
  type: "asset",
183
- url: packageAsset.browser_download_url,
195
+ url: packageAsset.url, // Use API endpoint for authenticated downloads
184
196
  name: packageAsset.name,
185
197
  size: packageAsset.size,
186
198
  };
187
199
  }
188
200
 
189
- // Second priority: Look for any custom uploaded assets
201
+ logger.debug("⚠ No ClaudeKit package asset found, checking for other custom assets...");
202
+
203
+ // Second priority: Look for any custom uploaded assets (excluding GitHub's automatic source code archives)
190
204
  const customAsset = release.assets.find(
191
- (a) => a.name.endsWith(".tar.gz") || a.name.endsWith(".tgz") || a.name.endsWith(".zip"),
205
+ (a) =>
206
+ (a.name.endsWith(".tar.gz") || a.name.endsWith(".tgz") || a.name.endsWith(".zip")) &&
207
+ !a.name.toLowerCase().startsWith("source") &&
208
+ !a.name.toLowerCase().includes("source code"),
192
209
  );
193
210
 
194
211
  if (customAsset) {
195
- logger.debug(`Using custom asset: ${customAsset.name}`);
212
+ logger.debug(`✓ Selected custom asset: ${customAsset.name}`);
196
213
  return {
197
214
  type: "asset",
198
- url: customAsset.browser_download_url,
215
+ url: customAsset.url, // Use API endpoint for authenticated downloads
199
216
  name: customAsset.name,
200
217
  size: customAsset.size,
201
218
  };
202
219
  }
203
220
 
204
221
  // Fall back to GitHub's automatic tarball
205
- logger.debug("Using GitHub automatic tarball");
222
+ logger.debug(" No custom assets found, falling back to GitHub automatic tarball");
206
223
  return {
207
224
  type: "tarball",
208
225
  url: release.tarball_url,
package/src/types.ts CHANGED
@@ -46,7 +46,8 @@ export type Config = z.infer<typeof ConfigSchema>;
46
46
  export const GitHubReleaseAssetSchema = z.object({
47
47
  id: z.number(),
48
48
  name: z.string(),
49
- browser_download_url: z.string().url(),
49
+ url: z.string().url(), // API endpoint for authenticated downloads
50
+ browser_download_url: z.string().url(), // Direct download URL (public only)
50
51
  size: z.number(),
51
52
  content_type: z.string(),
52
53
  });
@@ -4,9 +4,9 @@ import pc from "picocolors";
4
4
  // Use ASCII-safe symbols to avoid unicode rendering issues in certain terminals
5
5
  const symbols = {
6
6
  info: "[i]",
7
- success: "[]",
7
+ success: "[+]",
8
8
  warning: "[!]",
9
- error: "[]",
9
+ error: "[x]",
10
10
  };
11
11
 
12
12
  interface LogContext {
@@ -1,53 +1,43 @@
1
- import * as clack from "@clack/prompts";
1
+ import picocolors from "picocolors";
2
2
 
3
3
  /**
4
- * Safe wrapper around clack prompts that handles unicode rendering issues.
5
- * Sets up proper environment to minimize encoding problems.
4
+ * Safe wrapper around clack prompts that uses simple ASCII characters
5
+ * instead of unicode box drawing to avoid rendering issues.
6
6
  */
7
7
 
8
- // Store original methods
9
- const originalIntro = clack.intro;
10
- const originalOutro = clack.outro;
11
- const originalNote = clack.note;
12
-
13
8
  /**
14
- * Wrapped intro that handles encoding better
9
+ * Simple intro with ASCII characters
15
10
  */
16
11
  export function intro(message: string): void {
17
- try {
18
- originalIntro(message);
19
- } catch {
20
- // Fallback to simple console log if clack fails
21
- console.log(`\n=== ${message} ===\n`);
22
- }
12
+ console.log();
13
+ console.log(picocolors.cyan(`> ${message}`));
14
+ console.log();
23
15
  }
24
16
 
25
17
  /**
26
- * Wrapped outro that handles encoding better
18
+ * Simple outro with ASCII characters
27
19
  */
28
20
  export function outro(message: string): void {
29
- try {
30
- originalOutro(message);
31
- } catch {
32
- // Fallback to simple console log if clack fails
33
- console.log(`\n=== ${message} ===\n`);
34
- }
21
+ console.log();
22
+ console.log(picocolors.green(`[OK] ${message}`));
23
+ console.log();
35
24
  }
36
25
 
37
26
  /**
38
- * Wrapped note that handles encoding better
27
+ * Simple note with ASCII box drawing
39
28
  */
40
29
  export function note(message: string, title?: string): void {
41
- try {
42
- originalNote(message, title);
43
- } catch {
44
- // Fallback to simple console log if clack fails
45
- if (title) {
46
- console.log(`\n--- ${title} ---`);
47
- }
48
- console.log(message);
30
+ console.log();
31
+ if (title) {
32
+ console.log(picocolors.cyan(` ${title}:`));
49
33
  console.log();
50
34
  }
35
+ // Split message into lines and indent each
36
+ const lines = message.split("\n");
37
+ for (const line of lines) {
38
+ console.log(` ${line}`);
39
+ }
40
+ console.log();
51
41
  }
52
42
 
53
43
  // Re-export other clack functions unchanged
@@ -0,0 +1,38 @@
1
+ import ora, { type Ora, type Options } from "ora";
2
+
3
+ /**
4
+ * Create a spinner with simple ASCII characters to avoid unicode rendering issues
5
+ */
6
+ export function createSpinner(options: string | Options): Ora {
7
+ const spinnerOptions: Options = typeof options === "string" ? { text: options } : options;
8
+
9
+ const spinner = ora({
10
+ ...spinnerOptions,
11
+ // Use simple ASCII spinner instead of unicode
12
+ spinner: "dots",
13
+ // Override symbols to use ASCII
14
+ prefixText: "",
15
+ });
16
+
17
+ // Override succeed and fail methods to use ASCII symbols
18
+ spinner.succeed = (text?: string) => {
19
+ spinner.stopAndPersist({
20
+ symbol: "[+]",
21
+ text: text || spinner.text,
22
+ });
23
+ return spinner;
24
+ };
25
+
26
+ spinner.fail = (text?: string) => {
27
+ spinner.stopAndPersist({
28
+ symbol: "[x]",
29
+ text: text || spinner.text,
30
+ });
31
+ return spinner;
32
+ };
33
+
34
+ return spinner;
35
+ }
36
+
37
+ // Re-export Ora type for convenience
38
+ export type { Ora } from "ora";