librepl Internals

Overview

@forwardimpact/librepl provides a single Repl class that powers interactive and non-interactive CLI tools. It handles readline management, command dispatch, state persistence, and output formatting so that CLI entry points only need to define their application-specific behaviour.

Used by fit-guide (conversational agent) and fit-visualize (trace visualizer).


Repl Class

The Repl class follows the standard OO+DI pattern. All external dependencies are injected through the constructor with sensible defaults for production use.

import { Repl } from "@forwardimpact/librepl";

const repl = new Repl(app, formatterFn, readlineModule, processModule, osModule);
Parameter Default Purpose
app {} Application configuration object
formatterFn createTerminalFormatter Factory that returns a formatter
readlineModule Node readline Readline module
processModule global.process Process object (stdin/stdout)
osModule Node os OS module (user info for UID)

In production, only app is provided. The remaining parameters exist for testing — inject mocks to verify behaviour without real I/O.

Public API

  • repl.start() — Starts the REPL lifecycle (see below).
  • repl.state — The mutable state object, initialized from app.state.

Application Configuration

The app object passed to the constructor defines all application behaviour.

const repl = new Repl({
  prompt: "guide> ",
  usage: "**Usage:** <message>\n\nSend a message to the agent.",
  state: { resource_id: null },
  storage: createStorage("cli"),
  commands: { /* see Writing Custom Commands */ },
  setup: async (state) => { /* one-time initialization */ },
  onLine: async (line, state, output) => { /* handle user input */ },
  beforeLine: async (state) => { /* called before each line */ },
  afterLine: async (state) => { /* called after each line */ },
});
Property Type Purpose
prompt string Prompt string (default "> ")
usage string Static help text shown before command list
state object Initial state values
storage StorageInterface Optional storage for state persistence
commands object Custom command definitions
setup (state) => Promise<void> Runs once before the REPL accepts input
onLine (line, state, output) => Promise<void> Handles non-command input (line is trimmed)
beforeLine (state) => Promise<void> Hook before each non-empty line is processed
afterLine (state) => Promise<void> Hook called after each line is processed

Lifecycle

repl.start() executes the following sequence:

Load state from storage
  → Parse CLI arguments (override state, run CLI commands)
    → Run setup(state)
      → Enter interactive or non-interactive loop
        → For each line: beforeLine → dispatch → afterLine → save state

Interactive mode (TTY stdin): creates a readline interface that prompts and waits for input. Lines starting with / are dispatched as commands; all other input goes to onLine. Empty lines are silently ignored. Ctrl+C exits cleanly.

Non-interactive mode (piped stdin): reads all input, splits by newline, and processes each line sequentially. Each line is echoed with the prompt before processing. Exits when input is consumed.

Error handling: errors thrown by onLine or command handlers are caught silently — the REPL continues and afterLine still runs. Handlers are expected to log their own errors (typically via libtelemetry).


Writing Custom Commands

Commands are defined as entries in app.commands. Each command has a name (the object key), a usage string, and a handler function.

commands: {
  name: {
    usage: "Set your name",
    handler: (args, state) => {
      state.name = args[0];
    },
  },
  shout: {
    usage: "Toggle uppercase output",
    type: "boolean",
    handler: (args, state) => {
      state.shout = !state.shout;
    },
  },
},

Command Definition

Field Type Required Purpose
usage string Yes Help text shown in /help output
handler function Yes (args: string[], state: object) => Promise<result>
type string No Set to "boolean" if the command takes no arguments
cli boolean No Set to false to hide from non-interactive --help output

Handler Return Values

Return value Behaviour
undefined Normal completion, REPL continues
false In CLI arg parsing: stops processing remaining args and exits.
In interactive mode: no special effect (treated like undefined).
A Readable Stream is piped through the formatter to stdout (interactive only;
ignored during CLI arg parsing).

How Commands are Invoked

Commands work in both modes with different syntax:

Mode Syntax Example
Interactive /<command> [args...] /name Alice
CLI arguments --<command> [args...] --name Alice
Piped input /<command> [args...] echo "/name Alice" | bunx …

/-prefixed commands work in both interactive and piped input. -- flags are parsed from CLI arguments before the REPL starts. In CLI mode, dashes in flag names are converted to underscores for lookup (e.g. --resource-id maps to the resource_id command). Boolean commands consume no argument; all others consume the next CLI argument as args[0].

In interactive mode, command names are lowercased before lookup. CLI mode does not lowercase — it only converts dashes to underscores.

If an unrecognized command is entered interactively, the help output is shown.

Built-in Commands

Three commands are always registered (user commands can override them):

Command Type Behaviour
clear boolean Resets state to initial values and saves. Returns false (exits in CLI mode).
help boolean Displays usage text and all commands. Returns false (exits in CLI mode).
exit boolean Exits the process. Hidden from CLI help via cli: false.

State Persistence

When app.storage is provided (any StorageInterface implementation), the REPL automatically loads state on startup and saves it after every line.

State is keyed by the system UID (os.userInfo().uid), stored as {uid}.json. This means each OS user gets independent state.

import { createStorage } from "@forwardimpact/libstorage";

const repl = new Repl({
  storage: createStorage("cli"),
  state: { resource_id: null },
  onLine: handlePrompt,
});

The /clear command resets all state keys to their initial values defined in app.state and writes the reset state to storage.


Output Formatting

All output flows through a formatter (from @forwardimpact/libformat). The onLine handler receives a writable output stream — write to it and the REPL handles formatting and flushing to stdout.

onLine: async (line, state, output) => {
  const result = await computeResult(line);
  output.write(result);
},

Command handlers that return a Readable stream get the same treatment — the stream is consumed, formatted, and written to stdout.


Example: Minimal REPL

import { Repl } from "@forwardimpact/librepl";

const repl = new Repl({
  prompt: "echo> ",
  onLine: async (line, state, output) => {
    output.write(`You said: ${line}`);
  },
});

repl.start();

Example: REPL with Commands and State

import { Repl } from "@forwardimpact/librepl";
import { createStorage } from "@forwardimpact/libstorage";

const repl = new Repl({
  prompt: "greeter> ",
  usage: "**Usage:** <message>\n\nType a message. Use /name to set who you are.",
  storage: createStorage("greeter"),

  state: {
    name: "world",
    shout: false,
  },

  commands: {
    name: {
      usage: "Set your name",
      handler: (args, state) => {
        state.name = args[0];
      },
    },
    shout: {
      usage: "Toggle uppercase output",
      type: "boolean",
      handler: (args, state) => {
        state.shout = !state.shout;
      },
    },
  },

  onLine: async (line, state, output) => {
    let greeting = `Hello, ${state.name}! You said: ${line}`;
    if (state.shout) greeting = greeting.toUpperCase();
    output.write(greeting);
  },
});

repl.start();

Testing

Inject mocks for all dependencies to test without real I/O. Use createMockStorage from libharness for storage.

import { Repl } from "@forwardimpact/librepl";
import { createMockStorage } from "@forwardimpact/libharness";

const mockFormatter = () => ({ format: (text) => text });
const mockReadline = { createInterface: () => ({ on() {}, prompt() {} }) };
const mockProcess = {
  argv: ["node", "script.js"],
  stdin: { isTTY: true },
  stdout: { write() {} },
  exit() {},
};
const mockOs = { userInfo: () => ({ uid: 1000 }) };

const repl = new Repl(
  { state: { key: "value" }, storage: createMockStorage() },
  mockFormatter,
  mockReadline,
  mockProcess,
  mockOs,
);

Module Index

File Purpose
index.js Repl class — constructor, lifecycle, I/O
test/librepl.test.js Unit tests with fully mocked dependencies