بناء أدوات المطورين

بناء أدوات CLI مع AI

5 دقيقة للقراءة

Project Goal: Build a production-ready CLI tool that developers will love using.

Why Build CLI Tools?

CLI tools are perfect for vibe coding because:

  • Single-purpose - Focused functionality is easier for AI to implement
  • No UI complexity - Text interface is simpler than GUI
  • Composable - Works with pipes, scripts, and automation
  • Developer-focused - Your target users understand technical tools

CLI Project Setup Prompt

Create a modern CLI tool with:

## Tech Stack
- Node.js with TypeScript
- Commander.js for argument parsing
- Inquirer.js for interactive prompts
- Chalk for colored output
- Ora for spinners
- tsx for running TypeScript directly

## Project Structure

/src /commands # Individual command implementations /lib # Shared utilities /utils # Helper functions index.ts # CLI entry point types.ts # TypeScript types /bin cli.js # Executable entry package.json # With "bin" field configured tsconfig.json # TypeScript config


## Initial Commands
1. Main command with --version and --help
2. Config management (init, get, set)
3. Core functionality commands

## Requirements
- Global installation support (npm i -g)
- Configuration file in ~/.config/[tool-name]/
- Colored output with proper exit codes
- Interactive mode and non-interactive flags

Project Initialization

// package.json
{
  "name": "my-awesome-cli",
  "version": "1.0.0",
  "description": "A CLI tool built with AI assistance",
  "type": "module",
  "bin": {
    "mycli": "./bin/cli.js"
  },
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsup src/index.ts --format esm --dts",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "chalk": "^5.3.0",
    "commander": "^12.1.0",
    "conf": "^13.0.1",
    "inquirer": "^10.2.2",
    "ora": "^8.1.1"
  },
  "devDependencies": {
    "@types/inquirer": "^9.0.7",
    "@types/node": "^22.9.0",
    "tsup": "^8.3.5",
    "tsx": "^4.19.2",
    "typescript": "^5.6.3"
  }
}
// bin/cli.js
#!/usr/bin/env node
import '../dist/index.js';
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

CLI Entry Point

// src/index.ts
import { Command } from 'commander';
import chalk from 'chalk';
import { initCommand } from './commands/init.js';
import { configCommand } from './commands/config.js';
import { runCommand } from './commands/run.js';
import { version } from '../package.json' assert { type: 'json' };

const program = new Command();

program
  .name('mycli')
  .description('A powerful CLI tool for developers')
  .version(version, '-v, --version', 'Display version number');

// Register commands
program.addCommand(initCommand);
program.addCommand(configCommand);
program.addCommand(runCommand);

// Global error handling
program.exitOverride();

try {
  await program.parseAsync(process.argv);
} catch (error) {
  if (error instanceof Error) {
    console.error(chalk.red('Error:'), error.message);
    process.exit(1);
  }
}

Configuration Management

// src/lib/config.ts
import Conf from 'conf';
import { z } from 'zod';

const configSchema = z.object({
  apiKey: z.string().optional(),
  defaultProject: z.string().optional(),
  outputFormat: z.enum(['json', 'table', 'plain']).default('table'),
  colorEnabled: z.boolean().default(true),
});

export type Config = z.infer<typeof configSchema>;

const defaults: Config = {
  outputFormat: 'table',
  colorEnabled: true,
};

export const config = new Conf<Config>({
  projectName: 'my-awesome-cli',
  defaults,
  schema: {
    apiKey: { type: 'string' },
    defaultProject: { type: 'string' },
    outputFormat: { type: 'string', enum: ['json', 'table', 'plain'] },
    colorEnabled: { type: 'boolean' },
  },
});

export function getConfig<K extends keyof Config>(key: K): Config[K] {
  return config.get(key);
}

export function setConfig<K extends keyof Config>(
  key: K,
  value: Config[K]
): void {
  config.set(key, value);
}

export function getAllConfig(): Config {
  return config.store;
}

export function resetConfig(): void {
  config.clear();
}

Interactive Commands

// src/commands/init.ts
import { Command } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import { setConfig } from '../lib/config.js';

export const initCommand = new Command('init')
  .description('Initialize configuration for the CLI')
  .option('--api-key <key>', 'API key (can also set interactively)')
  .option('--project <name>', 'Default project name')
  .action(async (options) => {
    console.log(chalk.blue('\n🚀 Welcome to MyCLI Setup!\n'));

    const answers = await inquirer.prompt([
      {
        type: 'input',
        name: 'apiKey',
        message: 'Enter your API key:',
        when: !options.apiKey,
        validate: (input) =>
          input.length > 0 || 'API key is required',
      },
      {
        type: 'input',
        name: 'defaultProject',
        message: 'Default project name (optional):',
        when: !options.project,
      },
      {
        type: 'list',
        name: 'outputFormat',
        message: 'Preferred output format:',
        choices: [
          { name: 'Table (readable)', value: 'table' },
          { name: 'JSON (machine-readable)', value: 'json' },
          { name: 'Plain text', value: 'plain' },
        ],
        default: 'table',
      },
      {
        type: 'confirm',
        name: 'colorEnabled',
        message: 'Enable colored output?',
        default: true,
      },
    ]);

    const spinner = ora('Saving configuration...').start();

    try {
      // Merge CLI options with interactive answers
      const apiKey = options.apiKey || answers.apiKey;
      const defaultProject = options.project || answers.defaultProject;

      if (apiKey) setConfig('apiKey', apiKey);
      if (defaultProject) setConfig('defaultProject', defaultProject);
      setConfig('outputFormat', answers.outputFormat);
      setConfig('colorEnabled', answers.colorEnabled);

      spinner.succeed('Configuration saved successfully!');

      console.log(chalk.green('\n✓ MyCLI is ready to use!'));
      console.log(chalk.dim('Run `mycli --help` to see available commands.\n'));
    } catch (error) {
      spinner.fail('Failed to save configuration');
      throw error;
    }
  });
// src/commands/config.ts
import { Command } from 'commander';
import chalk from 'chalk';
import { getConfig, setConfig, getAllConfig, resetConfig } from '../lib/config.js';

export const configCommand = new Command('config')
  .description('Manage CLI configuration');

configCommand
  .command('get <key>')
  .description('Get a configuration value')
  .action((key) => {
    const value = getConfig(key as any);
    if (value === undefined) {
      console.log(chalk.yellow(`Config key '${key}' is not set`));
    } else {
      console.log(value);
    }
  });

configCommand
  .command('set <key> <value>')
  .description('Set a configuration value')
  .action((key, value) => {
    // Parse boolean and number values
    let parsedValue: any = value;
    if (value === 'true') parsedValue = true;
    else if (value === 'false') parsedValue = false;
    else if (!isNaN(Number(value))) parsedValue = Number(value);

    setConfig(key as any, parsedValue);
    console.log(chalk.green(`✓ Set ${key} = ${parsedValue}`));
  });

configCommand
  .command('list')
  .description('List all configuration values')
  .action(() => {
    const all = getAllConfig();
    console.log(chalk.blue('\nCurrent Configuration:\n'));

    for (const [key, value] of Object.entries(all)) {
      const displayValue =
        key === 'apiKey' && value
          ? `${String(value).slice(0, 8)}...`
          : value ?? chalk.dim('(not set)');
      console.log(`  ${chalk.cyan(key)}: ${displayValue}`);
    }
    console.log();
  });

configCommand
  .command('reset')
  .description('Reset all configuration to defaults')
  .option('-y, --yes', 'Skip confirmation')
  .action(async (options) => {
    if (!options.yes) {
      const { confirm } = await import('inquirer').then((m) =>
        m.default.prompt([
          {
            type: 'confirm',
            name: 'confirm',
            message: 'Are you sure you want to reset all configuration?',
            default: false,
          },
        ])
      );

      if (!confirm) {
        console.log(chalk.yellow('Reset cancelled'));
        return;
      }
    }

    resetConfig();
    console.log(chalk.green('✓ Configuration reset to defaults'));
  });

Output Formatting

// src/lib/output.ts
import chalk from 'chalk';
import { getConfig } from './config.js';

interface TableColumn {
  key: string;
  header: string;
  width?: number;
  align?: 'left' | 'right' | 'center';
}

export function formatOutput<T extends Record<string, any>>(
  data: T | T[],
  columns?: TableColumn[]
): string {
  const format = getConfig('outputFormat');

  switch (format) {
    case 'json':
      return JSON.stringify(data, null, 2);
    case 'plain':
      return formatPlain(data);
    case 'table':
    default:
      return formatTable(Array.isArray(data) ? data : [data], columns);
  }
}

function formatPlain<T extends Record<string, any>>(data: T | T[]): string {
  const items = Array.isArray(data) ? data : [data];
  return items
    .map((item) =>
      Object.entries(item)
        .map(([key, value]) => `${key}: ${value}`)
        .join('\n')
    )
    .join('\n---\n');
}

function formatTable<T extends Record<string, any>>(
  data: T[],
  columns?: TableColumn[]
): string {
  if (data.length === 0) return chalk.dim('No data to display');

  const cols =
    columns ||
    Object.keys(data[0]).map((key) => ({
      key,
      header: key.charAt(0).toUpperCase() + key.slice(1),
    }));

  // Calculate column widths
  const widths = cols.map((col) => {
    const headerWidth = col.header.length;
    const dataWidth = Math.max(
      ...data.map((row) => String(row[col.key] ?? '').length)
    );
    return col.width || Math.max(headerWidth, dataWidth) + 2;
  });

  // Build header
  const header = cols
    .map((col, i) => chalk.bold(col.header.padEnd(widths[i])))
    .join(' ');

  const separator = widths.map((w) => '─'.repeat(w)).join('─');

  // Build rows
  const rows = data.map((row) =>
    cols
      .map((col, i) => {
        const value = String(row[col.key] ?? '');
        return value.padEnd(widths[i]);
      })
      .join(' ')
  );

  return [header, separator, ...rows].join('\n');
}

export function success(message: string): void {
  const colored = getConfig('colorEnabled');
  console.log(colored ? chalk.green(`${message}`) : `${message}`);
}

export function error(message: string): void {
  const colored = getConfig('colorEnabled');
  console.error(colored ? chalk.red(`${message}`) : `${message}`);
}

export function warn(message: string): void {
  const colored = getConfig('colorEnabled');
  console.warn(colored ? chalk.yellow(`${message}`) : `${message}`);
}

export function info(message: string): void {
  const colored = getConfig('colorEnabled');
  console.log(colored ? chalk.blue(`${message}`) : `${message}`);
}

Core Command Implementation

// src/commands/run.ts
import { Command } from 'commander';
import ora from 'ora';
import chalk from 'chalk';
import { getConfig } from '../lib/config.js';
import { formatOutput, error } from '../lib/output.js';

export const runCommand = new Command('run')
  .description('Execute the main functionality')
  .argument('<target>', 'Target to process')
  .option('-o, --output <file>', 'Output file path')
  .option('--dry-run', 'Show what would be done without executing')
  .option('-q, --quiet', 'Suppress non-essential output')
  .action(async (target, options) => {
    const apiKey = getConfig('apiKey');

    if (!apiKey) {
      error('API key not configured. Run `mycli init` first.');
      process.exit(1);
    }

    if (options.dryRun) {
      console.log(chalk.yellow('Dry run mode - no changes will be made\n'));
    }

    const spinner = options.quiet ? null : ora(`Processing ${target}...`).start();

    try {
      // Simulate async operation
      await new Promise((resolve) => setTimeout(resolve, 2000));

      const results = [
        { id: 1, name: 'Result 1', status: 'success' },
        { id: 2, name: 'Result 2', status: 'success' },
        { id: 3, name: 'Result 3', status: 'pending' },
      ];

      spinner?.succeed(`Processed ${target}`);

      // Format and output results
      const output = formatOutput(results, [
        { key: 'id', header: 'ID', width: 6 },
        { key: 'name', header: 'Name', width: 20 },
        { key: 'status', header: 'Status', width: 10 },
      ]);

      console.log('\n' + output + '\n');

      // Write to file if specified
      if (options.output) {
        const fs = await import('fs/promises');
        await fs.writeFile(options.output, JSON.stringify(results, null, 2));
        console.log(chalk.green(`Results written to ${options.output}`));
      }
    } catch (err) {
      spinner?.fail('Processing failed');
      error(err instanceof Error ? err.message : 'Unknown error');
      process.exit(1);
    }
  });

Testing CLI Commands

// src/__tests__/commands.test.ts
import { execSync } from 'child_process';
import { describe, it, expect, beforeEach } from 'vitest';

const cli = (args: string) =>
  execSync(`tsx src/index.ts ${args}`, { encoding: 'utf-8' });

describe('CLI Commands', () => {
  it('shows help', () => {
    const output = cli('--help');
    expect(output).toContain('A powerful CLI tool');
  });

  it('shows version', () => {
    const output = cli('--version');
    expect(output).toMatch(/\d+\.\d+\.\d+/);
  });

  it('config list works', () => {
    const output = cli('config list');
    expect(output).toContain('Configuration');
  });
});

Publishing to npm

# Build the project
npm run build

# Test locally
npm link
mycli --help

# Login to npm
npm login

# Publish
npm publish

# Or publish with access public (for scoped packages)
npm publish --access public

Key Takeaways

  1. Commander.js handles argument parsing and subcommands elegantly
  2. Inquirer.js creates professional interactive prompts
  3. Conf provides persistent configuration with validation
  4. Exit codes communicate success/failure to calling scripts
  5. Output formatting should support both human and machine consumption

بناء أدوات CLI مع AI

هدف المشروع: بناء أداة CLI جاهزة للإنتاج سيحب المطورون استخدامها.

لماذا نبني أدوات CLI؟

أدوات CLI مثالية للـ vibe coding لأن:

  • غرض واحد - الوظائف المركزة أسهل للـ AI لتنفيذها
  • لا تعقيد واجهة - واجهة النص أبسط من GUI
  • قابلة للتركيب - تعمل مع الأنابيب والسكربتات والأتمتة
  • موجهة للمطورين - المستخدمون المستهدفون يفهمون الأدوات التقنية

برومبت إعداد مشروع CLI

أنشئ أداة CLI حديثة مع:

## التقنيات
- Node.js مع TypeScript
- Commander.js لتحليل المعاملات
- Inquirer.js للمطالبات التفاعلية
- Chalk للمخرجات الملونة
- Ora للمؤشرات الدوارة
- tsx لتشغيل TypeScript مباشرة

## هيكل المشروع

/src /commands # تنفيذات الأوامر الفردية /lib # الأدوات المشتركة /utils # دوال المساعدة index.ts # نقطة دخول CLI types.ts # أنواع TypeScript /bin cli.js # الملف التنفيذي package.json # مع حقل "bin" مكوّن tsconfig.json # تكوين TypeScript


## الأوامر الأولية
1. الأمر الرئيسي مع --version و--help
2. إدارة التكوين (init، get، set)
3. أوامر الوظائف الأساسية

## المتطلبات
- دعم التثبيت العام (npm i -g)
- ملف تكوين في ~/.config/[tool-name]/
- مخرجات ملونة مع أكواد خروج صحيحة
- وضع تفاعلي وعلامات غير تفاعلية

تهيئة المشروع

// package.json
{
  "name": "my-awesome-cli",
  "version": "1.0.0",
  "description": "أداة CLI مبنية بمساعدة AI",
  "type": "module",
  "bin": {
    "mycli": "./bin/cli.js"
  },
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsup src/index.ts --format esm --dts",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "chalk": "^5.3.0",
    "commander": "^12.1.0",
    "conf": "^13.0.1",
    "inquirer": "^10.2.2",
    "ora": "^8.1.1"
  },
  "devDependencies": {
    "@types/inquirer": "^9.0.7",
    "@types/node": "^22.9.0",
    "tsup": "^8.3.5",
    "tsx": "^4.19.2",
    "typescript": "^5.6.3"
  }
}
// bin/cli.js
#!/usr/bin/env node
import '../dist/index.js';

نقطة دخول CLI

// src/index.ts
import { Command } from 'commander';
import chalk from 'chalk';
import { initCommand } from './commands/init.js';
import { configCommand } from './commands/config.js';
import { runCommand } from './commands/run.js';
import { version } from '../package.json' assert { type: 'json' };

const program = new Command();

program
  .name('mycli')
  .description('أداة CLI قوية للمطورين')
  .version(version, '-v, --version', 'عرض رقم الإصدار');

// تسجيل الأوامر
program.addCommand(initCommand);
program.addCommand(configCommand);
program.addCommand(runCommand);

// معالجة الأخطاء العامة
program.exitOverride();

try {
  await program.parseAsync(process.argv);
} catch (error) {
  if (error instanceof Error) {
    console.error(chalk.red('خطأ:'), error.message);
    process.exit(1);
  }
}

إدارة التكوين

// src/lib/config.ts
import Conf from 'conf';
import { z } from 'zod';

const configSchema = z.object({
  apiKey: z.string().optional(),
  defaultProject: z.string().optional(),
  outputFormat: z.enum(['json', 'table', 'plain']).default('table'),
  colorEnabled: z.boolean().default(true),
});

export type Config = z.infer<typeof configSchema>;

const defaults: Config = {
  outputFormat: 'table',
  colorEnabled: true,
};

export const config = new Conf<Config>({
  projectName: 'my-awesome-cli',
  defaults,
  schema: {
    apiKey: { type: 'string' },
    defaultProject: { type: 'string' },
    outputFormat: { type: 'string', enum: ['json', 'table', 'plain'] },
    colorEnabled: { type: 'boolean' },
  },
});

export function getConfig<K extends keyof Config>(key: K): Config[K] {
  return config.get(key);
}

export function setConfig<K extends keyof Config>(
  key: K,
  value: Config[K]
): void {
  config.set(key, value);
}

export function getAllConfig(): Config {
  return config.store;
}

export function resetConfig(): void {
  config.clear();
}

الأوامر التفاعلية

// src/commands/init.ts
import { Command } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import { setConfig } from '../lib/config.js';

export const initCommand = new Command('init')
  .description('تهيئة إعدادات CLI')
  .option('--api-key <key>', 'مفتاح API (يمكن تعيينه تفاعلياً أيضاً)')
  .option('--project <name>', 'اسم المشروع الافتراضي')
  .action(async (options) => {
    console.log(chalk.blue('\n🚀 مرحباً بك في إعداد MyCLI!\n'));

    const answers = await inquirer.prompt([
      {
        type: 'input',
        name: 'apiKey',
        message: 'أدخل مفتاح API:',
        when: !options.apiKey,
        validate: (input) =>
          input.length > 0 || 'مفتاح API مطلوب',
      },
      {
        type: 'input',
        name: 'defaultProject',
        message: 'اسم المشروع الافتراضي (اختياري):',
        when: !options.project,
      },
      {
        type: 'list',
        name: 'outputFormat',
        message: 'تنسيق المخرجات المفضل:',
        choices: [
          { name: 'جدول (قابل للقراءة)', value: 'table' },
          { name: 'JSON (للآلات)', value: 'json' },
          { name: 'نص عادي', value: 'plain' },
        ],
        default: 'table',
      },
      {
        type: 'confirm',
        name: 'colorEnabled',
        message: 'تفعيل المخرجات الملونة؟',
        default: true,
      },
    ]);

    const spinner = ora('جاري حفظ التكوين...').start();

    try {
      const apiKey = options.apiKey || answers.apiKey;
      const defaultProject = options.project || answers.defaultProject;

      if (apiKey) setConfig('apiKey', apiKey);
      if (defaultProject) setConfig('defaultProject', defaultProject);
      setConfig('outputFormat', answers.outputFormat);
      setConfig('colorEnabled', answers.colorEnabled);

      spinner.succeed('تم حفظ التكوين بنجاح!');

      console.log(chalk.green('\n✓ MyCLI جاهز للاستخدام!'));
      console.log(chalk.dim('شغّل `mycli --help` لرؤية الأوامر المتاحة.\n'));
    } catch (error) {
      spinner.fail('فشل في حفظ التكوين');
      throw error;
    }
  });

تنسيق المخرجات

// src/lib/output.ts
import chalk from 'chalk';
import { getConfig } from './config.js';

interface TableColumn {
  key: string;
  header: string;
  width?: number;
  align?: 'left' | 'right' | 'center';
}

export function formatOutput<T extends Record<string, any>>(
  data: T | T[],
  columns?: TableColumn[]
): string {
  const format = getConfig('outputFormat');

  switch (format) {
    case 'json':
      return JSON.stringify(data, null, 2);
    case 'plain':
      return formatPlain(data);
    case 'table':
    default:
      return formatTable(Array.isArray(data) ? data : [data], columns);
  }
}

function formatPlain<T extends Record<string, any>>(data: T | T[]): string {
  const items = Array.isArray(data) ? data : [data];
  return items
    .map((item) =>
      Object.entries(item)
        .map(([key, value]) => `${key}: ${value}`)
        .join('\n')
    )
    .join('\n---\n');
}

function formatTable<T extends Record<string, any>>(
  data: T[],
  columns?: TableColumn[]
): string {
  if (data.length === 0) return chalk.dim('لا توجد بيانات للعرض');

  const cols =
    columns ||
    Object.keys(data[0]).map((key) => ({
      key,
      header: key.charAt(0).toUpperCase() + key.slice(1),
    }));

  // حساب عرض الأعمدة
  const widths = cols.map((col) => {
    const headerWidth = col.header.length;
    const dataWidth = Math.max(
      ...data.map((row) => String(row[col.key] ?? '').length)
    );
    return col.width || Math.max(headerWidth, dataWidth) + 2;
  });

  // بناء الرأس
  const header = cols
    .map((col, i) => chalk.bold(col.header.padEnd(widths[i])))
    .join(' ');

  const separator = widths.map((w) => '─'.repeat(w)).join('─');

  // بناء الصفوف
  const rows = data.map((row) =>
    cols
      .map((col, i) => {
        const value = String(row[col.key] ?? '');
        return value.padEnd(widths[i]);
      })
      .join(' ')
  );

  return [header, separator, ...rows].join('\n');
}

export function success(message: string): void {
  const colored = getConfig('colorEnabled');
  console.log(colored ? chalk.green(`${message}`) : `${message}`);
}

export function error(message: string): void {
  const colored = getConfig('colorEnabled');
  console.error(colored ? chalk.red(`${message}`) : `${message}`);
}

النشر على npm

# بناء المشروع
npm run build

# اختبار محلياً
npm link
mycli --help

# تسجيل الدخول لـ npm
npm login

# النشر
npm publish

# أو النشر مع وصول عام (للحزم ذات النطاق)
npm publish --access public

النقاط الرئيسية

  1. Commander.js يتعامل مع تحليل المعاملات والأوامر الفرعية بأناقة
  2. Inquirer.js ينشئ مطالبات تفاعلية احترافية
  3. Conf يوفر تكويناً مستمراً مع التحقق
  4. أكواد الخروج تتواصل مع النجاح/الفشل للسكربتات المستدعية
  5. تنسيق المخرجات يجب أن يدعم الاستهلاك البشري والآلي

اختبار

الوحدة 3: بناء أدوات المطورين

خذ الاختبار