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. CLI entry points then only need to define their application-specific behaviour.

The fit-guide conversational agent and the fit-visualize trace visualizer use the library.


Repl Class

The Repl class follows the standard OO+DI pattern. You inject all external dependencies through the constructor. The defaults suit 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, you provide only app. The remaining parameters exist for tests. Inject mocks to verify behaviour without real I/O.

Public API

  • repl.start() — Starts the REPL lifecycle. The Lifecycle section describes the sequence.
  • repl.state — The mutable state object, initialized from app.state.

Application Configuration

The app object that you pass 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
documentation Array<{title, url, description?}> External doc links rendered after the command list (mirrors the matching SKILL.md ## Documentation section)
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 that runs before the REPL processes each non-empty line
afterLine (state) => Promise<void> Hook that runs after the REPL processes each line
indent string Prefix prepended to each output line (default "")

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. The REPL dispatches lines that start with / as commands. All other input goes to onLine. The REPL silently ignores empty lines. Ctrl+C exits cleanly.

Non-interactive mode (piped stdin) reads all input. It splits the input by newline. It processes each line in sequence. It echoes each line with the prompt before it processes the line. It exits when it consumes all the input.

Errors: the REPL silently catches errors that onLine or command handlers throw. The REPL continues and afterLine still runs. Handlers log their own errors, usually through libtelemetry.


Writing Custom Commands

You define commands 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. The REPL continues.
false In CLI arg parsing: stops processing remaining args and exits.
In interactive mode: no special effect, same as undefined.
A Readable The REPL pipes the stream through the formatter to stdout
(interactive only; CLI arg parsing ignores it).

How the REPL Invokes Commands

Commands work in both modes with different syntax:

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

/-prefixed commands work in both interactive and piped input. The REPL parses -- flags from CLI arguments before it starts.

Non-boolean commands accept both the --key value form (next argv entry) and the --key=value form (inline). The two forms are equivalent. In CLI mode, the REPL converts dashes in flag names to underscores for lookup. For example, --resource-id maps to the resource_id command.

Boolean commands consume no argument. All others receive the value as args[0].

In interactive mode, the REPL lowercases command names before lookup. CLI mode does not lowercase. It only converts dashes to underscores.

If you enter an unrecognized command interactively, the REPL shows the help output.

Built-in Commands

The REPL always registers three commands. 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

Provide any StorageInterface implementation as app.storage. The REPL then loads state on startup. It saves the state after every line.

The REPL keys state by the system UID from os.userInfo().uid and stores it as {uid}.json. So 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 the initial values that app.state defines. It then 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 the stream. The REPL then formats the output and flushes it 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 REPL consumes the stream, formats it, and writes it 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 libmock for storage.

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

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
src/index.js Repl class: constructor, lifecycle, I/O
test/librepl.test.js Unit tests with fully mocked dependencies

What's next