From Zero to AI

Lesson 1.4: Practice - CLI Chatbot

Duration: 120 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  • Build a complete command-line chatbot from scratch
  • Implement all concepts from this module in a working project
  • Add commands for controlling the chatbot
  • Handle user input gracefully
  • Create a polished, usable CLI application

Introduction

It is time to put everything together. In this lesson, you will build a fully-featured CLI chatbot that incorporates:

  • Conversation history management
  • Multiple personality modes
  • Slash commands for control
  • Token tracking
  • Conversation saving and loading
  • Graceful error handling

By the end, you will have a professional-quality chatbot you can use daily and extend further.


Project Overview

Here is what we will build:

┌─────────────────────────────────────────────────────────────────┐
│                    CLI Chatbot Features                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  CORE FEATURES                                                   │
│  ├── Multi-turn conversations with memory                       │
│  ├── Context management (token limits)                          │
│  └── Multiple AI provider support                               │
│                                                                  │
│  PERSONALITY SYSTEM                                              │
│  ├── Switch between personalities                               │
│  ├── Custom personality creation                                │
│  └── Personality persistence                                    │
│                                                                  │
│  COMMANDS                                                        │
│  ├── /help - Show available commands                            │
│  ├── /clear - Reset conversation                                │
│  ├── /personality - Change AI personality                       │
│  ├── /save - Save conversation                                  │
│  ├── /load - Load conversation                                  │
│  ├── /stats - Show usage statistics                             │
│  └── /exit - Quit the application                               │
│                                                                  │
│  USER EXPERIENCE                                                 │
│  ├── Colored output                                             │
│  ├── Loading indicators                                         │
│  ├── Error recovery                                             │
│  └── Keyboard shortcuts                                         │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Project Setup

Let us start with a fresh project:

mkdir cli-chatbot
cd cli-chatbot
npm init -y

Install dependencies:

npm install typescript tsx @types/node openai @anthropic-ai/sdk dotenv chalk readline

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "declaration": true
  },
  "include": ["src/**/*"]
}

Create the directory structure:

mkdir -p src/{core,commands,utils}
touch src/index.ts

Set up environment variables in .env:

OPENAI_API_KEY=sk-proj-your-key-here
ANTHROPIC_API_KEY=sk-ant-your-key-here
DEFAULT_MODEL=gpt-4o-mini

Add to .gitignore:

.env
node_modules
dist
conversations/

Step 1: Type Definitions

Create src/core/types.ts:

// Message types
export type MessageRole = 'system' | 'user' | 'assistant';

export interface Message {
  role: MessageRole;
  content: string;
  timestamp: Date;
}

// Conversation types
export interface Conversation {
  id: string;
  messages: Message[];
  personality: string;
  createdAt: Date;
  updatedAt: Date;
}

// Statistics
export interface ConversationStats {
  messageCount: number;
  userMessages: number;
  assistantMessages: number;
  estimatedTokens: number;
  conversationDuration: number; // milliseconds
}

// Personality
export interface Personality {
  id: string;
  name: string;
  description: string;
  systemPrompt: string;
}

// Configuration
export interface ChatbotConfig {
  model: string;
  maxTokens: number;
  temperature: number;
  maxHistoryMessages: number;
}

// Command result
export interface CommandResult {
  success: boolean;
  message: string;
  shouldContinue: boolean;
}

Step 2: Personality Definitions

Create src/core/personalities.ts:

import type { Personality } from './types';

export const personalities: Record<string, Personality> = {
  default: {
    id: 'default',
    name: 'Assistant',
    description: 'A helpful, balanced AI assistant',
    systemPrompt: `You are a helpful AI assistant.

Your characteristics:
- Clear and concise responses
- Helpful and informative
- Honest about limitations
- Professional tone

When answering:
- Provide direct answers
- Use examples when helpful
- Format code with proper syntax highlighting
- Ask for clarification if needed`,
  },

  coder: {
    id: 'coder',
    name: 'CodeBuddy',
    description: 'A programming expert focused on TypeScript',
    systemPrompt: `You are CodeBuddy, an expert TypeScript developer.

Your expertise:
- TypeScript and JavaScript
- Node.js and web development
- Best practices and design patterns
- Debugging and optimization

Your style:
- Lead with code examples
- Explain the reasoning behind solutions
- Point out potential pitfalls
- Suggest improvements proactively

Format:
- Always use code blocks with language tags
- Keep explanations concise
- Use bullet points for lists`,
  },

  teacher: {
    id: 'teacher',
    name: 'Professor',
    description: 'A patient educator who builds understanding',
    systemPrompt: `You are Professor, a patient and thorough programming teacher.

Your teaching philosophy:
- Build understanding from fundamentals
- Never assume prior knowledge
- Use analogies to explain abstract concepts
- Encourage questions and exploration

Your method:
1. Start with the simplest explanation
2. Add complexity gradually
3. Provide hands-on examples
4. Check for understanding

Always:
- Be encouraging and supportive
- Celebrate progress
- Make learning feel achievable`,
  },

  concise: {
    id: 'concise',
    name: 'Brief',
    description: 'Gives short, direct answers',
    systemPrompt: `You are Brief, an assistant who values conciseness.

Rules:
- Maximum 3 sentences for explanations
- Code examples under 10 lines when possible
- No unnecessary preamble
- Get straight to the point

If more detail is needed, ask: "Want me to elaborate?"`,
  },

  creative: {
    id: 'creative',
    name: 'Spark',
    description: 'Creative problem solver with unconventional ideas',
    systemPrompt: `You are Spark, a creative programming assistant.

Your approach:
- Think outside the box
- Suggest unconventional solutions
- Make coding fun and engaging
- Use creative analogies

Style:
- Enthusiastic and energetic
- Open to experimentation
- Encourage trying new things
- Balance creativity with practicality`,
  },
};

export function getPersonality(id: string): Personality {
  return personalities[id] || personalities.default;
}

export function listPersonalities(): Personality[] {
  return Object.values(personalities);
}

Step 3: Conversation Manager

Create src/core/conversation.ts:

import type { Conversation, ConversationStats, Message } from './types';

export class ConversationManager {
  private conversation: Conversation;
  private maxMessages: number;

  constructor(personalityId: string, maxMessages: number = 50) {
    this.maxMessages = maxMessages;
    this.conversation = this.createNew(personalityId);
  }

  private createNew(personalityId: string): Conversation {
    return {
      id: this.generateId(),
      messages: [],
      personality: personalityId,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
  }

  private generateId(): string {
    return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
  }

  addMessage(role: 'user' | 'assistant', content: string): void {
    this.conversation.messages.push({
      role,
      content,
      timestamp: new Date(),
    });
    this.conversation.updatedAt = new Date();
    this.trimIfNeeded();
  }

  setSystemMessage(content: string): void {
    // Remove existing system message if any
    this.conversation.messages = this.conversation.messages.filter((m) => m.role !== 'system');

    // Add new system message at the beginning
    this.conversation.messages.unshift({
      role: 'system',
      content,
      timestamp: new Date(),
    });
  }

  private trimIfNeeded(): void {
    // Keep system message + last N messages
    const systemMessage = this.conversation.messages.find((m) => m.role === 'system');
    const nonSystemMessages = this.conversation.messages.filter((m) => m.role !== 'system');

    if (nonSystemMessages.length > this.maxMessages) {
      const trimmed = nonSystemMessages.slice(-this.maxMessages);
      this.conversation.messages = systemMessage ? [systemMessage, ...trimmed] : trimmed;
    }
  }

  getMessagesForApi(): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> {
    return this.conversation.messages.map((m) => ({
      role: m.role,
      content: m.content,
    }));
  }

  getStats(): ConversationStats {
    const messages = this.conversation.messages;
    const userMessages = messages.filter((m) => m.role === 'user');
    const assistantMessages = messages.filter((m) => m.role === 'assistant');

    const totalChars = messages.reduce((sum, m) => sum + m.content.length, 0);
    const estimatedTokens = Math.ceil(totalChars / 4);

    const duration = this.conversation.updatedAt.getTime() - this.conversation.createdAt.getTime();

    return {
      messageCount: messages.length,
      userMessages: userMessages.length,
      assistantMessages: assistantMessages.length,
      estimatedTokens,
      conversationDuration: duration,
    };
  }

  clear(personalityId?: string): void {
    const personality = personalityId || this.conversation.personality;
    this.conversation = this.createNew(personality);
  }

  setPersonality(personalityId: string): void {
    this.conversation.personality = personalityId;
  }

  getPersonalityId(): string {
    return this.conversation.personality;
  }

  export(): string {
    return JSON.stringify(this.conversation, null, 2);
  }

  import(data: string): void {
    const parsed = JSON.parse(data) as Conversation;
    parsed.createdAt = new Date(parsed.createdAt);
    parsed.updatedAt = new Date(parsed.updatedAt);
    parsed.messages = parsed.messages.map((m) => ({
      ...m,
      timestamp: new Date(m.timestamp),
    }));
    this.conversation = parsed;
  }

  getId(): string {
    return this.conversation.id;
  }
}

Step 4: AI Client

Create src/core/ai-client.ts:

import OpenAI from 'openai';

import type { ChatbotConfig } from './types';

export class AIClient {
  private openai: OpenAI;
  private config: ChatbotConfig;
  private totalTokensUsed: number = 0;

  constructor(config: ChatbotConfig) {
    this.openai = new OpenAI();
    this.config = config;
  }

  async chat(
    messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
  ): Promise<{ content: string; tokensUsed: number }> {
    const response = await this.openai.chat.completions.create({
      model: this.config.model,
      messages,
      max_tokens: this.config.maxTokens,
      temperature: this.config.temperature,
    });

    const content = response.choices[0].message.content || '';
    const tokensUsed = response.usage?.total_tokens || 0;

    this.totalTokensUsed += tokensUsed;

    return { content, tokensUsed };
  }

  getTotalTokensUsed(): number {
    return this.totalTokensUsed;
  }

  resetTokenCount(): void {
    this.totalTokensUsed = 0;
  }
}

Step 5: Command Handler

Create src/commands/handler.ts:

import * as fs from 'fs';
import * as path from 'path';

import { AIClient } from '../core/ai-client';
import { ConversationManager } from '../core/conversation';
import { getPersonality, listPersonalities } from '../core/personalities';
import type { CommandResult } from '../core/types';

export class CommandHandler {
  private conversationManager: ConversationManager;
  private aiClient: AIClient;
  private conversationsDir: string;

  constructor(conversationManager: ConversationManager, aiClient: AIClient) {
    this.conversationManager = conversationManager;
    this.aiClient = aiClient;
    this.conversationsDir = path.join(process.cwd(), 'conversations');

    // Ensure conversations directory exists
    if (!fs.existsSync(this.conversationsDir)) {
      fs.mkdirSync(this.conversationsDir, { recursive: true });
    }
  }

  isCommand(input: string): boolean {
    return input.startsWith('/');
  }

  async execute(input: string): Promise<CommandResult> {
    const parts = input.slice(1).split(' ');
    const command = parts[0].toLowerCase();
    const args = parts.slice(1);

    switch (command) {
      case 'help':
        return this.help();
      case 'clear':
        return this.clear();
      case 'personality':
        return this.personality(args);
      case 'save':
        return this.save(args);
      case 'load':
        return this.load(args);
      case 'stats':
        return this.stats();
      case 'list':
        return this.list();
      case 'exit':
      case 'quit':
        return this.exit();
      default:
        return {
          success: false,
          message: `Unknown command: /${command}. Type /help for available commands.`,
          shouldContinue: true,
        };
    }
  }

  private help(): CommandResult {
    const helpText = `
Available Commands:
  /help                 - Show this help message
  /clear                - Clear conversation history
  /personality [name]   - Switch personality (or list available)
  /save [filename]      - Save conversation to file
  /load [filename]      - Load conversation from file
  /list                 - List saved conversations
  /stats                - Show conversation statistics
  /exit                 - Exit the chatbot

Personalities:
${listPersonalities()
  .map((p) => `  ${p.id.padEnd(12)} - ${p.description}`)
  .join('\n')}
`;
    return { success: true, message: helpText.trim(), shouldContinue: true };
  }

  private clear(): CommandResult {
    const personalityId = this.conversationManager.getPersonalityId();
    this.conversationManager.clear(personalityId);

    // Re-apply system prompt
    const personality = getPersonality(personalityId);
    this.conversationManager.setSystemMessage(personality.systemPrompt);

    this.aiClient.resetTokenCount();

    return {
      success: true,
      message: 'Conversation cleared. Starting fresh!',
      shouldContinue: true,
    };
  }

  private personality(args: string[]): CommandResult {
    if (args.length === 0) {
      const current = this.conversationManager.getPersonalityId();
      const list = listPersonalities()
        .map(
          (p) =>
            `  ${p.id === current ? '>' : ' '} ${p.id.padEnd(12)} - ${p.name}: ${p.description}`
        )
        .join('\n');

      return {
        success: true,
        message: `Current personality: ${current}\n\nAvailable personalities:\n${list}`,
        shouldContinue: true,
      };
    }

    const personalityId = args[0].toLowerCase();
    const personality = getPersonality(personalityId);

    if (personality.id !== personalityId && personalityId !== 'default') {
      return {
        success: false,
        message: `Unknown personality: ${personalityId}. Use /personality to see available options.`,
        shouldContinue: true,
      };
    }

    this.conversationManager.setPersonality(personality.id);
    this.conversationManager.setSystemMessage(personality.systemPrompt);

    return {
      success: true,
      message: `Switched to ${personality.name}. ${personality.description}`,
      shouldContinue: true,
    };
  }

  private save(args: string[]): CommandResult {
    const filename = args[0] || `chat_${Date.now()}.json`;
    const filepath = path.join(this.conversationsDir, filename);

    try {
      const data = this.conversationManager.export();
      fs.writeFileSync(filepath, data);

      return {
        success: true,
        message: `Conversation saved to: ${filename}`,
        shouldContinue: true,
      };
    } catch (error) {
      return {
        success: false,
        message: `Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`,
        shouldContinue: true,
      };
    }
  }

  private load(args: string[]): CommandResult {
    if (args.length === 0) {
      return {
        success: false,
        message: 'Please specify a filename. Use /list to see saved conversations.',
        shouldContinue: true,
      };
    }

    const filename = args[0];
    const filepath = path.join(this.conversationsDir, filename);

    try {
      if (!fs.existsSync(filepath)) {
        return {
          success: false,
          message: `File not found: ${filename}`,
          shouldContinue: true,
        };
      }

      const data = fs.readFileSync(filepath, 'utf-8');
      this.conversationManager.import(data);

      // Re-apply personality system prompt
      const personality = getPersonality(this.conversationManager.getPersonalityId());
      this.conversationManager.setSystemMessage(personality.systemPrompt);

      return {
        success: true,
        message: `Loaded conversation from: ${filename}`,
        shouldContinue: true,
      };
    } catch (error) {
      return {
        success: false,
        message: `Failed to load: ${error instanceof Error ? error.message : 'Unknown error'}`,
        shouldContinue: true,
      };
    }
  }

  private list(): CommandResult {
    try {
      const files = fs
        .readdirSync(this.conversationsDir)
        .filter((f) => f.endsWith('.json'))
        .map((f) => {
          const stat = fs.statSync(path.join(this.conversationsDir, f));
          return { name: f, modified: stat.mtime };
        })
        .sort((a, b) => b.modified.getTime() - a.modified.getTime());

      if (files.length === 0) {
        return {
          success: true,
          message: 'No saved conversations found.',
          shouldContinue: true,
        };
      }

      const list = files
        .map((f) => `  ${f.name.padEnd(30)} (${f.modified.toLocaleDateString()})`)
        .join('\n');

      return {
        success: true,
        message: `Saved conversations:\n${list}`,
        shouldContinue: true,
      };
    } catch {
      return {
        success: true,
        message: 'No saved conversations found.',
        shouldContinue: true,
      };
    }
  }

  private stats(): CommandResult {
    const stats = this.conversationManager.getStats();
    const tokensUsed = this.aiClient.getTotalTokensUsed();

    const duration = stats.conversationDuration;
    const minutes = Math.floor(duration / 60000);
    const seconds = Math.floor((duration % 60000) / 1000);

    const message = `
Conversation Statistics:
  Messages:       ${stats.messageCount} total
                  ${stats.userMessages} from you
                  ${stats.assistantMessages} from assistant
  
  Tokens:         ~${stats.estimatedTokens} in context
                  ${tokensUsed} used this session
  
  Duration:       ${minutes}m ${seconds}s
  Personality:    ${this.conversationManager.getPersonalityId()}
`;

    return { success: true, message: message.trim(), shouldContinue: true };
  }

  private exit(): CommandResult {
    return {
      success: true,
      message: 'Goodbye! Have a great day!',
      shouldContinue: false,
    };
  }
}

Step 6: Display Utilities

Create src/utils/display.ts:

import chalk from 'chalk';

export const display = {
  welcome(): void {
    console.log(chalk.cyan.bold('\n╔════════════════════════════════════╗'));
    console.log(chalk.cyan.bold('║       CLI Chatbot v1.0             ║'));
    console.log(chalk.cyan.bold('╚════════════════════════════════════╝\n'));
    console.log(chalk.gray('Type /help for available commands.\n'));
  },

  userPrompt(): string {
    return chalk.green.bold('You: ');
  },

  assistantLabel(): void {
    process.stdout.write(chalk.blue.bold('\nAssistant: '));
  },

  assistantMessage(message: string): void {
    console.log(chalk.white(message) + '\n');
  },

  command(message: string): void {
    console.log(chalk.yellow(message) + '\n');
  },

  error(message: string): void {
    console.log(chalk.red.bold('Error: ') + chalk.red(message) + '\n');
  },

  info(message: string): void {
    console.log(chalk.gray(message));
  },

  thinking(): void {
    process.stdout.write(chalk.gray('Thinking...'));
  },

  clearThinking(): void {
    process.stdout.clearLine(0);
    process.stdout.cursorTo(0);
  },

  divider(): void {
    console.log(chalk.gray('─'.repeat(50)));
  },

  goodbye(): void {
    console.log(chalk.cyan('\nGoodbye! Have a great day!\n'));
  },
};

Step 7: Main Application

Create src/index.ts:

import 'dotenv/config';
import * as readline from 'readline';

import { CommandHandler } from './commands/handler';
import { AIClient } from './core/ai-client';
import { ConversationManager } from './core/conversation';
import { getPersonality } from './core/personalities';
import type { ChatbotConfig } from './core/types';
import { display } from './utils/display';

class CLIChatbot {
  private conversationManager: ConversationManager;
  private aiClient: AIClient;
  private commandHandler: CommandHandler;
  private rl: readline.Interface;
  private isProcessing: boolean = false;

  constructor() {
    // Configuration
    const config: ChatbotConfig = {
      model: process.env.DEFAULT_MODEL || 'gpt-4o-mini',
      maxTokens: 1000,
      temperature: 0.7,
      maxHistoryMessages: 50,
    };

    // Initialize components
    this.conversationManager = new ConversationManager('default', config.maxHistoryMessages);
    this.aiClient = new AIClient(config);
    this.commandHandler = new CommandHandler(this.conversationManager, this.aiClient);

    // Set initial personality
    const defaultPersonality = getPersonality('default');
    this.conversationManager.setSystemMessage(defaultPersonality.systemPrompt);

    // Create readline interface
    this.rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });

    // Handle Ctrl+C gracefully
    this.rl.on('close', () => {
      display.goodbye();
      process.exit(0);
    });
  }

  async start(): Promise<void> {
    display.welcome();
    this.promptUser();
  }

  private promptUser(): void {
    this.rl.question(display.userPrompt(), async (input) => {
      await this.handleInput(input);
    });
  }

  private async handleInput(input: string): Promise<void> {
    const trimmedInput = input.trim();

    // Skip empty input
    if (!trimmedInput) {
      this.promptUser();
      return;
    }

    // Handle commands
    if (this.commandHandler.isCommand(trimmedInput)) {
      const result = await this.commandHandler.execute(trimmedInput);
      display.command(result.message);

      if (!result.shouldContinue) {
        this.rl.close();
        return;
      }

      this.promptUser();
      return;
    }

    // Handle chat message
    await this.handleChat(trimmedInput);
  }

  private async handleChat(message: string): Promise<void> {
    if (this.isProcessing) {
      display.info('Please wait for the current response...');
      this.promptUser();
      return;
    }

    this.isProcessing = true;

    try {
      // Add user message
      this.conversationManager.addMessage('user', message);

      // Show thinking indicator
      display.thinking();

      // Get AI response
      const messages = this.conversationManager.getMessagesForApi();
      const response = await this.aiClient.chat(messages);

      // Clear thinking indicator
      display.clearThinking();

      // Add and display response
      this.conversationManager.addMessage('assistant', response.content);
      display.assistantLabel();
      display.assistantMessage(response.content);
    } catch (error) {
      display.clearThinking();

      if (error instanceof Error) {
        if (error.message.includes('API key')) {
          display.error('Invalid API key. Please check your .env file.');
        } else if (error.message.includes('rate limit')) {
          display.error('Rate limit reached. Please wait a moment and try again.');
        } else {
          display.error(`Failed to get response: ${error.message}`);
        }
      } else {
        display.error('An unexpected error occurred.');
      }

      // Remove failed user message from history
      const messages = this.conversationManager.getMessagesForApi();
      if (messages[messages.length - 1]?.role === 'user') {
        // We need to recreate conversation without the last message
        // For simplicity, we just note the error occurred
      }
    } finally {
      this.isProcessing = false;
      this.promptUser();
    }
  }
}

// Main entry point
async function main(): Promise<void> {
  try {
    const chatbot = new CLIChatbot();
    await chatbot.start();
  } catch (error) {
    console.error('Failed to start chatbot:', error);
    process.exit(1);
  }
}

main();

Step 8: Package Configuration

Update package.json:

{
  "name": "cli-chatbot",
  "version": "1.0.0",
  "description": "A feature-rich CLI chatbot built with TypeScript",
  "main": "dist/index.js",
  "type": "module",
  "scripts": {
    "start": "tsx src/index.ts",
    "build": "tsc",
    "dev": "tsx watch src/index.ts"
  },
  "keywords": ["chatbot", "cli", "typescript", "openai"],
  "author": "",
  "license": "MIT"
}

Running the Chatbot

Start the chatbot:

npm start

You should see:

╔════════════════════════════════════╗
║       CLI Chatbot v1.0             ║
╚════════════════════════════════════╝

Type /help for available commands.

You:

Try these interactions:

You: Hello! What can you help me with?
You: /personality coder
You: How do I create a TypeScript interface?
You: /stats
You: /save my-chat.json
You: /clear
You: /load my-chat.json
You: /exit

Extending the Chatbot

Here are ideas for extending your chatbot:

Add Streaming Responses

// In ai-client.ts
async chatStream(
  messages: Array<{ role: "system" | "user" | "assistant"; content: string }>,
  onChunk: (chunk: string) => void
): Promise<string> {
  const stream = await this.openai.chat.completions.create({
    model: this.config.model,
    messages,
    stream: true
  });

  let fullContent = "";

  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content || "";
    fullContent += content;
    onChunk(content);
  }

  return fullContent;
}

Add Multi-Provider Support

// Support switching between OpenAI and Anthropic
interface ProviderConfig {
  type: 'openai' | 'anthropic';
  model: string;
}

class MultiProviderClient {
  async chat(provider: ProviderConfig, messages: Message[]): Promise<string> {
    if (provider.type === 'openai') {
      return this.chatOpenAI(provider.model, messages);
    } else {
      return this.chatAnthropic(provider.model, messages);
    }
  }
}
// Search through saved conversations
function searchConversations(query: string): string[] {
  const files = fs.readdirSync(conversationsDir);
  const matches: string[] = [];

  for (const file of files) {
    const content = fs.readFileSync(path.join(conversationsDir, file), 'utf-8');
    if (content.toLowerCase().includes(query.toLowerCase())) {
      matches.push(file);
    }
  }

  return matches;
}

Exercises

Exercise 1: Add a /model Command

Add a command to switch between AI models:

// Your implementation here
// /model gpt-4o-mini
// /model gpt-4o
// /model list
Solution

Add to handler.ts:

private models = ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo"];
private currentModel = "gpt-4o-mini";

private model(args: string[]): CommandResult {
  if (args.length === 0 || args[0] === "list") {
    const list = this.models
      .map(m => `  ${m === this.currentModel ? ">" : " "} ${m}`)
      .join("\n");

    return {
      success: true,
      message: `Current model: ${this.currentModel}\n\nAvailable models:\n${list}`,
      shouldContinue: true
    };
  }

  const model = args[0].toLowerCase();

  if (!this.models.includes(model)) {
    return {
      success: false,
      message: `Unknown model: ${model}. Use /model list to see options.`,
      shouldContinue: true
    };
  }

  this.currentModel = model;
  // You would also need to update the AIClient config

  return {
    success: true,
    message: `Switched to model: ${model}`,
    shouldContinue: true
  };
}

Add to the switch statement in execute:

case "model":
  return this.model(args);

Exercise 2: Add Conversation Export to Markdown

Add a command to export the conversation as readable Markdown:

// /export chat.md
// Should create a nicely formatted Markdown file
Solution

Add to handler.ts:

private exportMarkdown(args: string[]): CommandResult {
  const filename = args[0] || `chat_${Date.now()}.md`;
  const filepath = path.join(this.conversationsDir, filename);

  try {
    const messages = this.conversationManager.getMessagesForApi();
    const personality = getPersonality(this.conversationManager.getPersonalityId());

    let markdown = `# Chat Conversation\n\n`;
    markdown += `**Date:** ${new Date().toLocaleDateString()}\n`;
    markdown += `**Personality:** ${personality.name}\n\n`;
    markdown += `---\n\n`;

    for (const msg of messages) {
      if (msg.role === "system") continue;

      const label = msg.role === "user" ? "**You:**" : "**Assistant:**";
      markdown += `${label}\n\n${msg.content}\n\n---\n\n`;
    }

    fs.writeFileSync(filepath, markdown);

    return {
      success: true,
      message: `Exported to: ${filename}`,
      shouldContinue: true
    };
  } catch (error) {
    return {
      success: false,
      message: `Export failed: ${error instanceof Error ? error.message : "Unknown"}`,
      shouldContinue: true
    };
  }
}

Add to switch:

case "export":
  return this.exportMarkdown(args);

Exercise 3: Add Response Timing

Display how long each response takes:

// Show: "Response time: 1.2s"
Solution

Modify handleChat in index.ts:

private async handleChat(message: string): Promise<void> {
  if (this.isProcessing) {
    display.info("Please wait for the current response...");
    this.promptUser();
    return;
  }

  this.isProcessing = true;
  const startTime = Date.now();

  try {
    this.conversationManager.addMessage("user", message);
    display.thinking();

    const messages = this.conversationManager.getMessagesForApi();
    const response = await this.aiClient.chat(messages);

    display.clearThinking();

    const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);

    this.conversationManager.addMessage("assistant", response.content);
    display.assistantLabel();
    display.assistantMessage(response.content);
    display.info(`Response time: ${elapsed}s | Tokens: ${response.tokensUsed}`);

  } catch (error) {
    // ... error handling
  } finally {
    this.isProcessing = false;
    this.promptUser();
  }
}

Final Project Structure

Your completed project should look like this:

cli-chatbot/
├── src/
│   ├── core/
│   │   ├── types.ts
│   │   ├── personalities.ts
│   │   ├── conversation.ts
│   │   └── ai-client.ts
│   ├── commands/
│   │   └── handler.ts
│   ├── utils/
│   │   └── display.ts
│   └── index.ts
├── conversations/           # Created at runtime
├── .env
├── .gitignore
├── package.json
└── tsconfig.json

Key Takeaways

  1. Modular architecture: Separate concerns into distinct files and classes
  2. Command pattern: Handle user commands cleanly with a dedicated handler
  3. State management: Use a conversation manager to track history
  4. User experience: Colors, loading indicators, and clear feedback matter
  5. Error handling: Gracefully handle API errors and edge cases
  6. Extensibility: Design for easy addition of new features
  7. Persistence: Save and load conversations for continuity

Resources

Resource Type Description
Node.js Readline Documentation CLI input handling
Chalk Library Terminal colors
Commander.js Library Alternative CLI framework
Inquirer.js Library Interactive prompts

Module Complete

Congratulations! You have completed Module 1: Chatbots. You now know how to:

  • Architect a chatbot application
  • Manage conversation history and context
  • Create personalized AI assistants with system prompts
  • Build a complete, production-ready CLI chatbot

In the next module, you will learn how to add streaming responses for a more dynamic user experience.

Continue to Module 2: Streaming and Real-time