Skip to content

Part 1: Scaffolding ​

By the end of this part you will have a working terminal app: a Commander-based CLI that launches an Ink TUI with a message area and a text input. No AI yet — just the interactive shell that every later feature will grow inside.

What you are building ​

  • A TypeScript CLI package (package.json with ESM, build scripts)
  • A compiled dist/cli.js entrypoint
  • A Commander-based command parser
  • An Ink React app with a title, message list, and input box

Files to create ​

FilePurpose
package.jsonNode package, scripts, dependencies
tsconfig.jsonTypeScript compiler config
src/cli.tsxCLI entrypoint — parses args, renders the Ink app
src/App.tsxMain UI component — message list + input

Step 1: package.json ​

Create the file:

bash
touch package.json
json
{
  "name": "protoagent",
  "version": "0.0.1",
  "description": "A simple coding agent CLI.",
  "bin": "dist/cli.js",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/cli.tsx",
    "build:watch": "tsc --watch"
  },
  "files": [
    "dist"
  ],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@inkjs/ui": "^2.0.0",
    "commander": "^14.0.1",
    "ink": "^6.7.0",
    "ink-big-text": "^2.0.0",
    "react": "^19.1.1"
  },
  "devDependencies": {
    "@types/node": "^24.5.2",
    "@types/react": "^19.1.15",
    "tsx": "^4.20.6",
    "typescript": "^5.9.2"
  }
}

Key points:

  • "type": "module" enables ESM imports throughout the project
  • "bin": "dist/cli.js" makes the compiled CLI the executable entrypoint
  • tsx runs TypeScript directly for development (npm run dev)
  • tsc compiles to dist/ for production (npm run build)

Step 2: tsconfig.json ​

Create the file:

bash
touch tsconfig.json
json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "jsx": "react-jsx",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

The "jsx": "react-jsx" setting tells TypeScript to transform JSX without requiring explicit React imports. This is the modern approach supported in React 17+.

Step 3: src/cli.tsx ​

Create the file:

bash
mkdir -p src && touch src/cli.tsx

This file does three things: reads the package version, creates the Commander program, and renders the Ink app.

tsx
#!/usr/bin/env node
import process from 'node:process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import React from 'react';
import { render } from 'ink';
import { Command } from 'commander';
import { App } from './App.js';

// Read version from package.json relative to the compiled file location
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageJson: { version: string } = JSON.parse(
  readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
);

const program = new Command();

program
  .description('ProtoAgent — a simple, hackable coding agent CLI')
  .version(packageJson.version)
  .parse(process.argv);

const options = program.opts();

render(<App options={options} />);

Note the import path: ./App.js, not ./App.tsx. When TypeScript compiles, .tsx files become .js files in dist/, so all imports must reference the compiled extension.

Step 4: src/App.tsx ​

Create the file:

bash
touch src/App.tsx

The first version of App is just a terminal chat shell — no AI, no tools. It keeps an array of messages and an input box. When you submit text, it appears in the message area.

tsx
import React, { useState } from 'react';
import { Box, Text, useApp, useInput } from 'ink';
import { TextInput } from '@inkjs/ui';
import BigText from 'ink-big-text';

export interface AppProps {
  options?: Record<string, any>;
}

export const App: React.FC<AppProps> = () => {
  const { exit } = useApp();
  const [messages, setMessages] = useState<string[]>([]);
  const [inputText, setInputText] = useState('');
  const [inputKey, setInputKey] = useState(0);

  const handleSubmit = (value: string) => {
    const trimmed = value.trim();
    if (!trimmed) return;

    setMessages((prev) => [...prev, trimmed]);
    setInputText('');
    setInputKey((prev) => prev + 1);
  };

  useInput((_input, key) => {
    if (key.ctrl && _input === 'c') {
      exit();
    }
  });

  return (
    <Box flexDirection="column" height="100%">
      {/* Header */}
      <BigText text="ProtoAgent" font="tiny" colors={["#09A469"]} />
      <Text dimColor italic>A simple, hackable coding agent CLI.</Text>
      <Text> </Text>

      {/* Message area */}
      <Box flexDirection="column" flexGrow={1}>
        {messages.map((msg, i) => (
          <Text key={i}>
            <Text color="green" bold>{'> '}</Text>
            <Text>{msg}</Text>
          </Text>
        ))}
      </Box>

      {/* Input */}
      <Box borderStyle="round" borderColor="green" paddingX={1}>
        <Text color="green" bold>{'> '}</Text>
        <TextInput
          key={inputKey}
          defaultValue={inputText}
          onChange={setInputText}
          placeholder="Type your message..."
          onSubmit={handleSubmit}
        />
      </Box>
    </Box>
  );
};

The inputKey trick forces the TextInput to remount and clear its internal state after each submit. Without it, the input field would keep the old text.

Verification ​

Install dependencies and build:

bash
npm install
npm run build
node dist/cli.js --help

Then launch the dev version:

bash
npm run dev

You should see:

  • The ProtoAgent title rendered in large text
  • A text input at the bottom
  • Submitted messages appear in the message area
  • Ctrl-C exits the app
 █▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀
 █▀▀ █▀▄ █▄█  █  █▄█ █▀█ █▄█ ██▄ █ ▀█  █ 


A simple, hackable coding agent CLI.

> hi
> how are you
╭─────────────────────────────────────────────────────────────╮
│ > Type your message...                                      │
╰─────────────────────────────────────────────────────────────╯

Snapshot ​

Your project should match protoagent-build-your-own-checkpoints/part-1.

Pitfalls ​

  • Forgetting "type": "module" causes ESM import failures
  • Using "jsx": "react-jsx" instead of "jsx": "react" breaks Ink rendering
  • Importing ./App.tsx instead of ./App.js from compiled code fails at runtime
  • Reading package.json from the wrong relative path after compilation

What comes next ​

Part 2 adds the OpenAI SDK and streaming — the first time the app actually talks to an AI model. Everything you build after this point grows inside the shell you just created.

Built to teach how production-style coding agents actually work.