@emmraan/ai-skills 1.0.2 → 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.
Files changed (42) hide show
  1. package/.eslintrc.cjs +21 -0
  2. package/README.md +1 -12
  3. package/dist/commands/generate-local.d.ts.map +1 -1
  4. package/dist/commands/generate-local.js +18 -3
  5. package/dist/commands/generate-local.js.map +1 -1
  6. package/dist/commands/install.d.ts.map +1 -1
  7. package/dist/commands/install.js +18 -3
  8. package/dist/commands/install.js.map +1 -1
  9. package/dist/commands/list.d.ts.map +1 -1
  10. package/dist/commands/list.js +57 -11
  11. package/dist/commands/list.js.map +1 -1
  12. package/dist/commands/remove.d.ts.map +1 -1
  13. package/dist/commands/remove.js +32 -2
  14. package/dist/commands/remove.js.map +1 -1
  15. package/dist/commands/search.d.ts +8 -0
  16. package/dist/commands/search.d.ts.map +1 -0
  17. package/dist/commands/search.js +69 -0
  18. package/dist/commands/search.js.map +1 -0
  19. package/dist/commands/update.d.ts.map +1 -1
  20. package/dist/commands/update.js +49 -8
  21. package/dist/commands/update.js.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +20 -5
  24. package/dist/index.js.map +1 -1
  25. package/dist/utils/logger.d.ts +7 -0
  26. package/dist/utils/logger.d.ts.map +1 -1
  27. package/dist/utils/logger.js +39 -3
  28. package/dist/utils/logger.js.map +1 -1
  29. package/dist/utils/ui.d.ts +13 -0
  30. package/dist/utils/ui.d.ts.map +1 -1
  31. package/dist/utils/ui.js +101 -8
  32. package/dist/utils/ui.js.map +1 -1
  33. package/package.json +6 -4
  34. package/src/commands/generate-local.ts +18 -3
  35. package/src/commands/install.ts +21 -3
  36. package/src/commands/list.ts +80 -11
  37. package/src/commands/remove.ts +36 -2
  38. package/src/commands/search.ts +94 -0
  39. package/src/commands/update.ts +54 -10
  40. package/src/index.ts +19 -5
  41. package/src/utils/logger.ts +46 -3
  42. package/src/utils/ui.ts +192 -57
@@ -5,9 +5,11 @@ import {
5
5
  updateSkillInstallPathsInLockfile,
6
6
  } from '../core/lockfile.js';
7
7
  import { AGENT_PLATFORMS, type InstallLocation, type InstallOptions } from '../core/config.js';
8
- import { error, success, info } from '../utils/logger.js';
8
+ import { error, info, section, highlight } from '../utils/logger.js';
9
9
  import { ui } from '../utils/ui.js';
10
10
  import inquirer from 'inquirer';
11
+ import chalk from 'chalk';
12
+ import pc from 'picocolors';
11
13
 
12
14
  export interface CliRemoveFlags {
13
15
  local?: boolean;
@@ -145,9 +147,15 @@ async function promptRemoveOptions(): Promise<InstallOptions> {
145
147
 
146
148
  export async function remove(skillName: string, options: InstallOptions = {}): Promise<boolean> {
147
149
  try {
150
+ section(`Removing ${chalk.bold.cyan(skillName)}`);
151
+
148
152
  const entry = await getSkillEntry(skillName);
149
153
  if (!entry) {
154
+ // eslint-disable-next-line no-console
155
+ console.log('');
150
156
  info(`${skillName} is not installed`);
157
+ // eslint-disable-next-line no-console
158
+ console.log('');
151
159
  return true;
152
160
  }
153
161
 
@@ -162,7 +170,33 @@ export async function remove(skillName: string, options: InstallOptions = {}): P
162
170
  }
163
171
 
164
172
  ui.stopSpinnerSuccess(`Removed files for ${skillName}`);
165
- success(`Removed ${skillName} from ${removedPaths.length} location(s)`);
173
+
174
+ // Print summary
175
+ // eslint-disable-next-line no-console
176
+ console.log('');
177
+ highlight(`✨ Successfully removed ${skillName}`);
178
+ // eslint-disable-next-line no-console
179
+ console.log('');
180
+ // eslint-disable-next-line no-console
181
+ console.log(pc.gray('Removed from locations:'));
182
+ removedPaths.forEach((path) => {
183
+ // eslint-disable-next-line no-console
184
+ console.log(` ${pc.red('✗')} ${pc.cyan(path)}`);
185
+ });
186
+
187
+ if (remaining.length > 0) {
188
+ // eslint-disable-next-line no-console
189
+ console.log('');
190
+ // eslint-disable-next-line no-console
191
+ console.log(pc.gray('Still installed at:'));
192
+ remaining.forEach((path) => {
193
+ // eslint-disable-next-line no-console
194
+ console.log(` ${pc.green('✓')} ${pc.cyan(path)}`);
195
+ });
196
+ }
197
+ // eslint-disable-next-line no-console
198
+ console.log('');
199
+
166
200
  return true;
167
201
  } catch (err) {
168
202
  ui.stopSpinner();
@@ -0,0 +1,94 @@
1
+ import { fetchRegistryIndex } from '../core/downloader.js';
2
+ import { log, warn } from '../utils/logger.js';
3
+ import { ui } from '../utils/ui.js';
4
+ import chalk from 'chalk';
5
+
6
+ export interface SearchOptions {
7
+ json?: boolean;
8
+ domain?: string;
9
+ }
10
+
11
+ interface SearchResult {
12
+ name: string;
13
+ version: string;
14
+ domains: string[];
15
+ lastGenerated: string;
16
+ }
17
+
18
+ function parseSearchFlags(args: string[]): SearchOptions {
19
+ const options: SearchOptions = { json: false };
20
+
21
+ for (let i = 0; i < args.length; i++) {
22
+ if (args[i] === '--json') {
23
+ options.json = true;
24
+ } else if (args[i] === '--domain' && args[i + 1]) {
25
+ options.domain = args[i + 1];
26
+ i++;
27
+ }
28
+ }
29
+
30
+ return options;
31
+ }
32
+
33
+ function matchesQuery(skillName: string, domains: string[], query: string): boolean {
34
+ const lowerQuery = query.toLowerCase();
35
+
36
+ // Match on skill name
37
+ if (skillName.toLowerCase().includes(lowerQuery)) {
38
+ return true;
39
+ }
40
+
41
+ // Match on domains
42
+ if (domains.some((domain) => domain.toLowerCase().includes(lowerQuery))) {
43
+ return true;
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ export async function search(query: string, options: SearchOptions = {}): Promise<void> {
50
+ const flags = parseSearchFlags([]);
51
+ const mergedOptions = { ...flags, ...options };
52
+
53
+ const index = await fetchRegistryIndex();
54
+ const skillNames = Object.keys(index.skills);
55
+
56
+ // Filter by query
57
+ let results: SearchResult[] = skillNames
58
+ .filter((name) => matchesQuery(name, index.skills[name].domains, query))
59
+ .map((name) => ({
60
+ name,
61
+ version: index.skills[name].version,
62
+ domains: index.skills[name].domains,
63
+ lastGenerated: index.skills[name].lastGenerated,
64
+ }));
65
+
66
+ // Filter by domain if provided
67
+ if (mergedOptions.domain) {
68
+ results = results.filter((skill) => skill.domains.includes(mergedOptions.domain!));
69
+ }
70
+
71
+ // Output
72
+ if (mergedOptions.json) {
73
+ log(JSON.stringify(results, null, 2));
74
+ } else {
75
+ if (results.length === 0) {
76
+ warn(
77
+ `No skills found matching "${query}"${mergedOptions.domain ? ` in domain "${mergedOptions.domain}"` : ''}`
78
+ );
79
+ } else {
80
+ const formattedResults = results.map((r) => {
81
+ const domainStr = r.domains.length > 0 ? chalk.gray(` [${r.domains.join(', ')}]`) : '';
82
+ return `${chalk.cyan(r.name)}${chalk.gray(` v${r.version}`)}${domainStr}`;
83
+ });
84
+
85
+ ui.printBox(formattedResults, '🔍 Search Results');
86
+
87
+ log('');
88
+ log(chalk.gray(`💡 Tip: Use ${chalk.cyan('ai-skills install <skill>')} to install a skill`));
89
+ log('');
90
+ }
91
+ }
92
+ }
93
+
94
+ export { parseSearchFlags };
@@ -2,7 +2,9 @@ import { fetchSkill, fetchRegistryIndex } from '../core/downloader.js';
2
2
  import { installSkill } from '../core/installer.js';
3
3
  import { addSkillToLockfile, getInstalledSkills, getSkillEntry } from '../core/lockfile.js';
4
4
  import { sha256 } from '../utils/hash.js';
5
- import { error, success, info, warn } from '../utils/logger.js';
5
+ import { error, success, info, warn, section, highlight, divider } from '../utils/logger.js';
6
+ import chalk from 'chalk';
7
+ import pc from 'picocolors';
6
8
 
7
9
  export async function update(options: { force?: boolean; skill?: string } = {}): Promise<boolean> {
8
10
  try {
@@ -12,10 +14,16 @@ export async function update(options: { force?: boolean; skill?: string } = {}):
12
14
  if (options.skill) {
13
15
  // Update specific skill
14
16
  const skillName = options.skill;
17
+ section(`Updating ${chalk.bold.cyan(skillName)}`);
18
+
15
19
  const entry = await getSkillEntry(skillName);
16
20
 
17
21
  if (!entry) {
22
+ // eslint-disable-next-line no-console
23
+ console.log('');
18
24
  warn(`${skillName} is not installed`);
25
+ // eslint-disable-next-line no-console
26
+ console.log('');
19
27
  return false;
20
28
  }
21
29
 
@@ -23,7 +31,11 @@ export async function update(options: { force?: boolean; skill?: string } = {}):
23
31
  const newHash = sha256(skillContent);
24
32
 
25
33
  if (newHash === entry.hash && !options.force) {
26
- info(`${skillName} is already up-to-date`);
34
+ // eslint-disable-next-line no-console
35
+ console.log('');
36
+ info(`${skillName} is already up-to-date (v${entry.version})`);
37
+ // eslint-disable-next-line no-console
38
+ console.log('');
27
39
  return true;
28
40
  }
29
41
 
@@ -31,36 +43,68 @@ export async function update(options: { force?: boolean; skill?: string } = {}):
31
43
  const installPaths = await installSkill(skillName, skillContent);
32
44
  const version = index.skills[skillName]?.version || 'unknown';
33
45
  await addSkillToLockfile(skillName, version, newHash, installPaths);
34
- success(`Updated ${skillName}`);
46
+
47
+ // eslint-disable-next-line no-console
48
+ console.log('');
49
+ highlight(`✨ Successfully updated ${skillName} to v${version}`);
50
+ // eslint-disable-next-line no-console
51
+ console.log('');
52
+ success(`Updated at ${installPaths.length} location(s)`);
53
+ // eslint-disable-next-line no-console
54
+ console.log('');
35
55
  return true;
36
56
  }
37
57
 
38
58
  // Update all installed skills
59
+ section('Updating all installed skills');
60
+ // eslint-disable-next-line no-console
61
+ console.log('');
62
+
39
63
  let updateCount = 0;
64
+ let alreadyUpToDate = 0;
65
+
40
66
  for (const entry of installed) {
41
67
  try {
68
+ // eslint-disable-next-line no-console
69
+ console.log(pc.cyan(`► ${entry.name} (v${entry.version})`));
42
70
  const skillContent = await fetchSkill(entry.name);
43
71
  const newHash = sha256(skillContent);
44
72
 
45
73
  if (newHash === entry.hash && !options.force) {
46
- info(`${entry.name} is already up-to-date`);
74
+ // eslint-disable-next-line no-console
75
+ console.log(` ${pc.gray('→')} Already up-to-date`);
76
+ alreadyUpToDate++;
47
77
  continue;
48
78
  }
49
79
 
50
- info(`Updating ${entry.name}...`);
51
80
  const installPaths = await installSkill(entry.name, skillContent);
52
81
  const version = index.skills[entry.name]?.version || 'unknown';
53
82
  await addSkillToLockfile(entry.name, version, newHash, installPaths);
54
- success(`Updated ${entry.name}`);
83
+ // eslint-disable-next-line no-console
84
+ console.log(` ${pc.green('✓')} Updated to v${version}`);
55
85
  updateCount++;
56
86
  } catch (err) {
57
- error(
58
- `Failed to update ${entry.name}: ${err instanceof Error ? err.message : String(err)}`
59
- );
87
+ // eslint-disable-next-line no-console
88
+ console.log(` ${pc.red('✗')} Error: ${err instanceof Error ? err.message : String(err)}`);
60
89
  }
61
90
  }
62
91
 
63
- success(`Updated ${updateCount} skill(s)`);
92
+ // eslint-disable-next-line no-console
93
+ console.log('');
94
+ divider();
95
+ // eslint-disable-next-line no-console
96
+ console.log('');
97
+ // eslint-disable-next-line no-console
98
+ console.log(chalk.bold('Update Summary:'));
99
+ // eslint-disable-next-line no-console
100
+ console.log(` ${pc.green('✓')} Updated: ${chalk.bold(updateCount.toString())} skill(s)`);
101
+ // eslint-disable-next-line no-console
102
+ console.log(` ${pc.gray('→')} Up-to-date: ${chalk.bold(alreadyUpToDate.toString())} skill(s)`);
103
+ // eslint-disable-next-line no-console
104
+ console.log('');
105
+ // eslint-disable-next-line no-console
106
+ console.log('');
107
+
64
108
  return true;
65
109
  } catch (err) {
66
110
  error(`Update failed: ${err instanceof Error ? err.message : String(err)}`);
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { remove, parseRemoveFlags, resolveRemoveOptionsFromFlags } from './comma
3
3
  import { list } from './commands/list.js';
4
4
  import { update } from './commands/update.js';
5
5
  import { generateLocal } from './commands/generate-local.js';
6
+ import { search, parseSearchFlags } from './commands/search.js';
6
7
  import { error, info } from './utils/logger.js';
7
8
  import { ui } from './utils/ui.js';
8
9
 
@@ -35,6 +36,11 @@ export async function main(): Promise<void> {
35
36
  const jsonFlag = args.includes('--json');
36
37
  await list(jsonFlag);
37
38
  process.exit(0);
39
+ } else if (command === 'search' && args.length >= 2) {
40
+ const query = args[1];
41
+ const flags = parseSearchFlags(args.slice(2));
42
+ await search(query, flags);
43
+ process.exit(0);
38
44
  } else if (command === 'update') {
39
45
  const forceFlag = args.includes('--force');
40
46
  let skillArg: string | undefined;
@@ -70,11 +76,12 @@ AI Skills CLI - Install framework-agnostic SKILLS.md files
70
76
  Usage:
71
77
  ai-skills <skill> [options] Install a skill
72
78
  ai-skills install <skill> [options] Install a skill
73
- ai-skills list [--json] List installed and available skills
74
- ai-skills remove <skill> [options] Remove an installed skill
75
- ai-skills update [--force] Update all installed skills
76
- [--skill <name>] Update a specific skill
77
- ai-skills generate-local [skill] Run local backend generator
79
+ ai-skills list [--json] List installed and available skills
80
+ ai-skills search <query> [options] Search for skills
81
+ ai-skills remove <skill> [options] Remove an installed skill
82
+ ai-skills update [--force] Update all installed skills
83
+ [--skill <name>] Update a specific skill
84
+ ai-skills generate-local [skill] Run local backend generator
78
85
 
79
86
  Install options:
80
87
  --local Install in current project (.ai-skills/skills)
@@ -88,12 +95,19 @@ Remove options:
88
95
  --all Remove from all platforms for selected scope
89
96
  --platform <a,b,c> Remove from selected platforms for selected scope
90
97
 
98
+ Search options:
99
+ --domain <name> Filter results by domain
100
+ --json Output results as JSON
101
+
91
102
  Examples:
92
103
  ai-skills react
93
104
  ai-skills react --local
94
105
  ai-skills react --platform claude,gemini,opencode,vscode,codex,agents
95
106
  ai-skills install typescript
96
107
  ai-skills list
108
+ ai-skills search react
109
+ ai-skills search "testing" --domain frontend
110
+ ai-skills search python --json
97
111
  ai-skills remove python
98
112
  ai-skills update --force
99
113
  ai-skills update --skill react
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable no-console */
2
2
 
3
3
  import chalk from 'chalk';
4
+ import pc from 'picocolors';
4
5
 
5
6
  export function log(message: string) {
6
7
  console.log(message);
@@ -11,13 +12,55 @@ export function info(message: string) {
11
12
  }
12
13
 
13
14
  export function success(message: string) {
14
- console.log(`${chalk.green('✓')} ${chalk.green(message)}`);
15
+ console.log(`${pc.green('✓')} ${chalk.green(message)}`);
15
16
  }
16
17
 
17
18
  export function error(message: string) {
18
- console.error(`${chalk.red('✗')} ${chalk.red(message)}`);
19
+ console.error(`${pc.red('✗')} ${chalk.red(message)}`);
19
20
  }
20
21
 
21
22
  export function warn(message: string) {
22
- console.warn(`${chalk.yellow('⚠')} ${chalk.yellow(message)}`);
23
+ console.warn(`${pc.yellow('⚠')} ${chalk.yellow(message)}`);
24
+ }
25
+
26
+ export function debug(message: string) {
27
+ if (process.env.DEBUG) {
28
+ console.log(`${chalk.gray('→')} ${chalk.gray(message)}`);
29
+ }
30
+ }
31
+
32
+ export function divider() {
33
+ console.log(pc.gray('─'.repeat(60)));
34
+ }
35
+
36
+ export function section(title: string) {
37
+ console.log('');
38
+ console.log(chalk.bold.cyan(`► ${title}`));
39
+ console.log(pc.gray('─'.repeat(title.length + 2)));
40
+ }
41
+
42
+ export function highlight(message: string) {
43
+ console.log(chalk.bold.cyan(message));
44
+ }
45
+
46
+ export function badge(label: string, content: string) {
47
+ console.log(`${chalk.bgCyan.black(` ${label} `)} ${pc.cyan(content)}`);
48
+ }
49
+
50
+ export function card(title: string, content: string[]) {
51
+ console.log('');
52
+ console.log(chalk.bold.cyan(`┌─ ${title}`));
53
+ content.forEach((line) => {
54
+ console.log(chalk.cyan('│') + ` ${line}`);
55
+ });
56
+ console.log(chalk.cyan('└' + '─'.repeat(title.length + 3)));
57
+ console.log('');
58
+ }
59
+
60
+ export function progressBar(label: string, current: number, total: number) {
61
+ const percentage = Math.round((current / total) * 100);
62
+ const filled = Math.floor((percentage / 100) * 20);
63
+ const empty = 20 - filled;
64
+ const bar = `${pc.green('█'.repeat(filled))}${pc.gray('░'.repeat(empty))}`;
65
+ console.log(`${label}: [${bar}] ${pc.cyan(`${percentage}%`)}`);
23
66
  }
package/src/utils/ui.ts CHANGED
@@ -1,57 +1,192 @@
1
- import chalk from 'chalk';
2
- import ora, { type Ora } from 'ora';
3
- import { info } from '../utils/logger.js';
4
-
5
- let activeSpinner: Ora | null = null;
6
-
7
- export const ui = {
8
- bannerShown: false,
9
-
10
- printBanner(): void {
11
- if (this.bannerShown || !process.stdout.isTTY) {
12
- return;
13
- }
14
-
15
- const title = chalk.bold.cyan('AI Skills CLI');
16
- const subtitle = chalk.gray('Install framework-agnostic SKILLS.md files');
17
- // Subtle, single-line banner to keep npx output concise
18
- info(`${title} ${chalk.gray('•')} ${subtitle}`);
19
- this.bannerShown = true;
20
- },
21
-
22
- startSpinner(text: string): void {
23
- if (!process.stdout.isTTY) {
24
- return;
25
- }
26
-
27
- if (activeSpinner) {
28
- activeSpinner.stop();
29
- }
30
- activeSpinner = ora({ text, spinner: 'dots' }).start();
31
- },
32
-
33
- stopSpinnerSuccess(text?: string): void {
34
- if (!activeSpinner) {
35
- return;
36
- }
37
- activeSpinner.succeed(text);
38
- activeSpinner = null;
39
- },
40
-
41
- stopSpinnerFail(text?: string): void {
42
- if (!activeSpinner) {
43
- return;
44
- }
45
- activeSpinner.fail(text);
46
- activeSpinner = null;
47
- },
48
-
49
- stopSpinner(): void {
50
- if (!activeSpinner) {
51
- return;
52
- }
53
- activeSpinner.stop();
54
- activeSpinner = null;
55
- },
56
- };
57
-
1
+ import chalk from 'chalk';
2
+ import pc from 'picocolors';
3
+ import ora, { type Ora } from 'ora';
4
+ import { log } from '../utils/logger.js';
5
+
6
+ let activeSpinner: Ora | null = null;
7
+
8
+ const BOX_CHARS = {
9
+ TOP_LEFT: '╭',
10
+ TOP_RIGHT: '╮',
11
+ BOTTOM_LEFT: '╰',
12
+ BOTTOM_RIGHT: '╯',
13
+ HORIZONTAL: '─',
14
+ VERTICAL: '│',
15
+ T_DOWN: '',
16
+ T_UP: '',
17
+ };
18
+
19
+ export const ui = {
20
+ bannerShown: false,
21
+
22
+ printBanner(): void {
23
+ if (this.bannerShown || !process.stdout.isTTY) {
24
+ return;
25
+ }
26
+
27
+ const title = 'AI Skills CLI';
28
+ const subtitle = 'Install framework-agnostic SKILLS.md files';
29
+ const width = Math.max(title.length, subtitle.length) + 6;
30
+
31
+ log('');
32
+ log(
33
+ pc.cyan(
34
+ `${BOX_CHARS.TOP_LEFT}${BOX_CHARS.HORIZONTAL.repeat(width - 2)}${BOX_CHARS.TOP_RIGHT}`
35
+ )
36
+ );
37
+ log(
38
+ pc.cyan(`${BOX_CHARS.VERTICAL}`) +
39
+ ` ${chalk.bold.cyan(title)}${' '.repeat(width - 3 - title.length)}` +
40
+ pc.cyan(`${BOX_CHARS.VERTICAL}`)
41
+ );
42
+ log(
43
+ pc.cyan(`${BOX_CHARS.VERTICAL}`) +
44
+ ` ${pc.gray(subtitle)}${' '.repeat(width - 3 - subtitle.length)}` +
45
+ pc.cyan(`${BOX_CHARS.VERTICAL}`)
46
+ );
47
+ log(
48
+ pc.cyan(
49
+ `${BOX_CHARS.BOTTOM_LEFT}${BOX_CHARS.HORIZONTAL.repeat(width - 2)}${BOX_CHARS.BOTTOM_RIGHT}`
50
+ )
51
+ );
52
+ log('');
53
+
54
+ this.bannerShown = true;
55
+ },
56
+
57
+ printSection(title: string): void {
58
+ log('');
59
+ log(chalk.bold.cyan(`${BOX_CHARS.VERTICAL} ${title}`));
60
+ log(chalk.cyan(`${BOX_CHARS.VERTICAL}${BOX_CHARS.HORIZONTAL.repeat(title.length + 2)}`));
61
+ },
62
+
63
+ printDivider(char: string = '─'): void {
64
+ log(chalk.gray(char.repeat(60)));
65
+ },
66
+
67
+ printBox(content: string[], title?: string): void {
68
+ // eslint-disable-next-line no-control-regex
69
+ const maxLength = Math.max(
70
+ // eslint-disable-next-line no-control-regex
71
+ ...content.map((line) => line.replace(/\u001b\[[0-9;]*m/g, '').length)
72
+ );
73
+ const width = Math.min(maxLength + 4, 70);
74
+
75
+ if (title) {
76
+ log(
77
+ chalk.cyan(
78
+ `${BOX_CHARS.TOP_LEFT}${BOX_CHARS.HORIZONTAL.repeat(width - 2)}${BOX_CHARS.TOP_RIGHT}`
79
+ )
80
+ );
81
+ }
82
+
83
+ content.forEach((line) => {
84
+ // eslint-disable-next-line no-control-regex
85
+ const padding = width - 4 - line.replace(/\u001b\[[0-9;]*m/g, '').length;
86
+ log(
87
+ chalk.cyan(`${BOX_CHARS.VERTICAL} `) +
88
+ line +
89
+ ' '.repeat(Math.max(0, padding)) +
90
+ chalk.cyan(` ${BOX_CHARS.VERTICAL}`)
91
+ );
92
+ });
93
+
94
+ log(
95
+ chalk.cyan(
96
+ `${BOX_CHARS.BOTTOM_LEFT}${BOX_CHARS.HORIZONTAL.repeat(width - 2)}${BOX_CHARS.BOTTOM_RIGHT}`
97
+ )
98
+ );
99
+ },
100
+
101
+ startSpinner(text: string): void {
102
+ if (!process.stdout.isTTY) {
103
+ return;
104
+ }
105
+
106
+ if (activeSpinner) {
107
+ activeSpinner.stop();
108
+ }
109
+ activeSpinner = ora({
110
+ text: chalk.cyan(text),
111
+ spinner: 'dots2',
112
+ }).start();
113
+ },
114
+
115
+ stopSpinnerSuccess(text?: string): void {
116
+ if (!activeSpinner) {
117
+ return;
118
+ }
119
+ activeSpinner.succeed(chalk.green(text || 'Done'));
120
+ activeSpinner = null;
121
+ },
122
+
123
+ stopSpinnerFail(text?: string): void {
124
+ if (!activeSpinner) {
125
+ return;
126
+ }
127
+ activeSpinner.fail(chalk.red(text || 'Failed'));
128
+ activeSpinner = null;
129
+ },
130
+
131
+ stopSpinner(): void {
132
+ if (!activeSpinner) {
133
+ return;
134
+ }
135
+ activeSpinner.stop();
136
+ activeSpinner = null;
137
+ },
138
+
139
+ updateSpinner(text: string): void {
140
+ if (activeSpinner) {
141
+ activeSpinner.text = chalk.cyan(text);
142
+ }
143
+ },
144
+
145
+ printTable(rows: Array<string[]>, headers?: string[]): void {
146
+ if (headers) {
147
+ const headerRow = headers.map((h) => chalk.bold.cyan(h));
148
+ const separators = headers.map((h) => chalk.gray('─'.repeat(Math.max(h.length, 15))));
149
+
150
+ const maxWidths = headers.map((h, i) =>
151
+ Math.max(h.length, ...rows.map((r) => r[i]?.length || 0))
152
+ );
153
+
154
+ log(headerRow.map((h, i) => h.padEnd(maxWidths[i])).join(' ' + chalk.gray('│') + ' '));
155
+ log(separators.join('─' + chalk.gray('┼') + '─'));
156
+
157
+ rows.forEach((row) => {
158
+ log(row.map((cell, i) => cell.padEnd(maxWidths[i])).join(' ' + chalk.gray('│') + ' '));
159
+ });
160
+ } else {
161
+ rows.forEach((row) => {
162
+ log(' ' + row.join(' '));
163
+ });
164
+ }
165
+ },
166
+
167
+ printSummary(items: Array<{ label: string; value: string | number }>): void {
168
+ log('');
169
+ const maxLabelLength = Math.max(...items.map((i) => i.label.length));
170
+ items.forEach((item) => {
171
+ const paddedLabel = item.label.padEnd(maxLabelLength);
172
+ log(chalk.gray(` ${paddedLabel} `) + chalk.bold(String(item.value)));
173
+ });
174
+ log('');
175
+ },
176
+
177
+ printSuccess(message: string): void {
178
+ log(chalk.green(`✓ ${message}`));
179
+ },
180
+
181
+ printError(message: string): void {
182
+ log(chalk.red(`✗ ${message}`));
183
+ },
184
+
185
+ printWarning(message: string): void {
186
+ log(chalk.yellow(`⚠ ${message}`));
187
+ },
188
+
189
+ printInfo(message: string): void {
190
+ log(chalk.cyan(`ℹ ${message}`));
191
+ },
192
+ };