Factory CLI - Demo Edit
A hybrid command-line interface that runs either:
- Interactive TUI – a full-screen React/Ink terminal app
- Headless commands – traditional
droid headless <command> sub-commands powered by Commander
The entry-point (src/index.ts) detects how it was invoked and chooses the right mode automatically.
1. Overview of the Hybrid Architecture
src/
├── index.ts # Hybrid entry – mode detection
│
├── app.tsx # React/Ink TUI (interactive mode)
│
└── commands/ # Commander commands (headless mode)
├── droid.ts
└── login.ts
• No positional args ➜ Interactive TUI
• headless subcommand ➜ Headless mode
2 · Local Development
All dev tasks are exposed as npm scripts – never run compiled .js files directly.
| Start CLI (auto mode) | npm start |
| Start with Node inspector | npm run debug |
| Set up shell alias for local dev | npm run setup:alias |
| Build SEA for current platform | npm run build |
| Build and run the binary | npm run build:run |
| Lint source | npm run lint |
| Type-check | npm run typecheck |
| Run tests | npm test |
| Produce executable bundle | npm run bundle |
| Clean build artifacts | npm run clean |
The start/debug scripts use tsx so you can edit TypeScript and restart instantly.
Shell Alias Setup
For easier local development, you can set up a droid alias that runs the CLI from your current directory:
npm run setup:alias
source ~/.zshrc
droid
Building Standalone Executables (SEA)
The CLI can be compiled into Single Executable Applications (SEA) for different platforms:
npm run build
npm run build:run
npm run build:sea:mac:arm64
npm run build:sea:mac:x64
npm run build:sea:linux:arm64
npm run build:sea:linux:x64
npm run build:sea:windows:x64
The build script automatically detects your OS and architecture, then builds the appropriate binary to dist/{platform}/{arch}/droid (or droid.exe on Windows).
3 · Testing Both Modes Locally
Interactive TUI
# Launch interactive UI
npm start
You'll see a colourful Ink interface; quit with Ctrl-C.
Running in VSCode
Factory CLI can also be run inside VSCode using the Factory extension:
- First, install the Factory VSCode extension (see VSCode Extension README for installation instructions)
- Click the Run Factory button (🤖) in the editor toolbar to launch Factory CLI in a dedicated terminal
- The extension provides full VSCode context (open files, selections, diagnostics) to Factory via MCP
Headless Commands
# Show global help
npm start -- --help
# Show headless subcommands
npm start -- headless --help
# Run login interactively (headless)
npm start -- headless login
# Send message to a droid
npm start -- headless droid "Hello, Droid!" --session-id <sessionId>
The extra -- after npm start passes subsequent flags to the CLI.
4 · Development vs Production
| Dev | npm start / npm run debug | Runs from TS sources with tsx, fast reload. |
| Bundle | npm run bundle (calls build) | Generates single executable bundle/droid.js. |
| Publish | npm publish (bundled in prepare) | Users install droid binary from npm. |
During CI the prepare script produces the bundle automatically.
5 · Examples
Headless examples
droid headless status
droid headless login
droid headless "Hello" --session-id dOLpXUI8ux6YdZrg3kCs
echo '{"role":"user","content":"hello"}' | droid exec --input-format stream-json --output-format stream-json
Streaming Input Mode (Droid Manager Integration)
The CLI supports a streaming input mode designed for Droid Manager integration, allowing a single process to handle multiple user messages while maintaining conversation state.
Usage Pattern:
const droid = spawn('droid', [
'exec',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--auto',
'low',
]);
droid.stdin.write('{"role":"user","content":"hello"}\n');
droid.stdout.on('data', (chunk) => {
const events = chunk.toString().split('\n').filter(Boolean);
events.forEach((line) => {
const event = JSON.parse(line);
});
});
droid.stdin.write('{"role":"user","content":"what can you do?"}\n');
droid.stdin.end();
Key Points:
- Input: JSONL (newline-delimited JSON) via stdin, each line is
Anthropic.MessageParam with role: "user"
- Output: JSONL events via stdout (same format as
--output-format stream-json)
- stdin stays open until explicitly closed by the caller
- Messages processed sequentially - one at a time
- Session persists across all messages in the process lifetime
- Graceful shutdown when stdin closes
Interactive example
droid
6 · Testing the Production droid Command
Sometimes you need to test the exact binary users will get from npm install -g factory-cli.
Follow this workflow:
npm run bundle
npm link
droid --help
droid headless status
droid headless "Hello" --session-id <sessionId>
npm unlink -g factory-cli
| Fast iteration / TypeScript | npm start -- <args> |
| Debug with inspector | npm run debug -- <args> |
| Validate production bundle | npm run bundle && npm link then droid headless <args> |
ℹ️ Tip: The extra -- after npm start or npm run debug passes the
remaining flags directly to the CLI.
7 · ESM & Imports
The package is "type": "module"; all runtime imports use .js extensions even though the source is TypeScript. The build pipeline rewrites them automatically.
8 · Troubleshooting
EACCES when running droid | Ensure the bundle is executable (chmod +x bundle/droid.js). npm run bundle handles this automatically. |
module not found after rename | Run npm run clean && npm run bundle to rebuild from scratch. |
| Global command still points to old code | Run npm unlink -g factory-cli && npm link to refresh the symlink. |
9 · Logging
Factory CLI has two different logging behaviors depending on the execution mode:
Interactive Mode Logging
When running in interactive TUI mode (droid with no arguments), all logging output is redirected to files to avoid interfering with the clean React/Ink interface:
- Log Directory:
~/.factory/logs/
- Log Files:
droid-log-<timestamp>.log (e.g., droid-log-2025-01-15T10-30-45-123Z.log)
- Content: All
logInfo, logException, and logWarn calls are written to the timestamped log file
- Format:
[timestamp] LEVEL: message | Context: {...}
Example log file location:
~/.factory/logs/droid-log-2025-01-15T10-30-45-123Z.log
Example log entry:
[2025-01-15T10:30:45.123Z] INFO: User started interactive session
[2025-01-15T10:30:47.456Z] ERROR: Failed to initialize MCP client: Connection refused | Context: {"retry": 1}
Headless Mode Logging
When running headless commands (droid headless <command>), logging follows standard console output patterns:
- Log Output: Directly to stdout/stderr using the standard
@factory/logging package
- Integration: Works with existing telemetry, Sentry, and monitoring systems
- Format: Standard Factory logging format with metadata support
Accessing Logs
Interactive Mode Logs:
ls -la ~/.factory/logs/
tail -f ~/.factory/logs/droid-log-*.log
cat ~/.factory/logs/droid-log-2025-01-15T10-30-45-123Z.log
Headless Mode Logs:
droid headless "test message" --session-id abc123
droid headless "test" --session-id abc123 2>&1 | tee my-session.log
Log Cleanup
Interactive mode creates a new log file for each session. To manage disk space:
find ~/.factory/logs -name "droid-log-*.log" -mtime +7 -delete
du -sh ~/.factory/logs
10 · Tool Registry & Executors Design
🔄 What Changed
After: Dynamic mapping automatically discovers tools from TUI registry
const toolMapping = buildToolMapping();
🛠 How to Add New Tools
11 · E2E Testing of the TUI
The Factory CLI includes end-to-end tests for the terminal UI using Microsoft's @microsoft/tui-test.
Testing Framework
- Framework:
@microsoft/tui-test
- Shell: bash
- Config:
apps/cli/tui-test.config.ts
Prerequisites
The E2E tests require a valid FACTORY_API_KEY environment variable to authenticate with Factory services. You can generate an API key from https://dev.app.factory.ai/settings/api-keys.
export FACTORY_API_KEY="your-api-key-here"
npm run test:e2e
The tests launch the CLI via a wrapper script that configures the environment:
- program:
./e2e-tests/test-workspace/run-droid.sh
- The wrapper exports all required env vars (e.g.,
FACTORY_HOME_OVERRIDE) and ensures correct execution context for tests.
- rows/columns: 30x80
Test Layout
apps/cli/
├── e2e-tests/
│ ├── chat-input.test.ts # Text entry, deletion, Ctrl+C, file suggestions, cursor nav
│ ├── settings.template.json # Copied to each test's .factory-dev
│ ├── templates/ # Blueprint copied to each test workspace
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/
│ └── test-workspace/ # Contains wrapper scripts only
│ ├── run-droid.sh # Creates unique workspace per invocation
│ ├── run-droid.ps1 # Windows version
│ └── run-droid.cmd # Windows CMD wrapper
└── tui-test.config.ts # TUI test config using the wrapper
Running Tests
npm run test:e2e
npm run test:e2e e2e-tests/chat-input.test.ts
Debugging
At any point of time, you can access the serialized terminal view using:
const terminalView = terminal.serialize().view;
Tracing is enabled in config (trace: true). Traces are saved under tui-traces/.
npm run show-trace tui-traces/<trace-file>
npx @microsoft/tui-test show-trace tui-traces/<trace-file>
Test Environment Isolation
Each test case runs in complete isolation with its own workspace and .factory-dev directory:
- Per-test workspaces: The wrapper scripts (
run-droid.sh, run-droid.ps1) automatically create a unique temporary workspace for each test invocation
- Unique identifiers: Workspaces are named with PID and timestamp:
/tmp/droid-test-{pid}-{timestamp}-XXXXXX
- Template copying: Files from
e2e-tests/templates/ (package.json, tsconfig.json, src/) are copied to each test workspace
- Automatic cleanup: Temporary workspaces are cleaned up when tests complete (via trap on Unix, try/finally on Windows)
- Parallel execution: Tests can run in parallel without state interference since each has its own
.factory-dev
Settings template (copied to each test's .factory-dev/settings.json):
{
"model": "claude-sonnet-4-20250514",
"cloudSessionSync": false,
"diffMode": "github"
}
What’s Covered
Current suite validates:
- Text entry visibility
- Backspace/Delete behavior (including forward delete)
- Ctrl+C warning and double Ctrl+C exit
- File suggestions via @: show, filter, navigate, select, dismiss
- Cursor navigation and in-line editing
Writing New Tests
Global config already provides the program and env. You can optionally tweak terminal size per test file:
import { test, expect } from '@microsoft/tui-test';
test.use({ rows: 24, columns: 100 });
test('example', async ({ terminal }) => {
await expect(
terminal.getByText('standing in an open terminal', {
full: false,
strict: false,
})
).toBeVisible({ timeout: 10000 });
terminal.write('Hello');
await expect(terminal.getByText('Hello')).toBeVisible();
});
12 · Contributing
pnpm install (or npm install) at repo root
cd apps/cli
- Implement feature / fix
- Ensure
npm run lint && npm run typecheck && npm test pass
- Run E2E tests with
npm run test:e2e
- Commit & open PR 🚀