Create your own
Lesson illustration

Extensible Command Registry with Help and Tab Completion

Welcome back. The REPL loop now has a clear responsibility: obtain one line, run one command, contain unexpected per-command failures, and then return to the prompt. What it still lacks is a scalable way to answer two user-facing questions:

  1. What can I type?
  2. Can the terminal help me type it?

In this lesson, you will replace fixed command routing with a registry of command definitions. Each definition carries immutable metadata for help and completion, while its execution logic remains a thin terminal adapter around the application handlers built earlier. This keeps commands discoverable without teaching the REPL loop about attach, list-processes, or later Frida-specific commands.


A registry is an interface-level catalog, not a second application layer

A command registry is often described as an implementation of the Command pattern: an invoker selects one of several encapsulated actions by name. That is useful here, but with one architectural caution.

Your application commands remain use cases such as “attach to process” or “list processes.” They must not depend on terminal input, help rendering, Tab, or strings such as "attach".

A terminal command definition lives at the outer interface. It translates a parsed terminal request into an application-handler call, and it declares how that request should be presented to the user.

The distinction is easiest to see in this boundary:

ConcernOwns it
Process identifier validity and attach rulesApplication handler/domain
Parsing words, quoted arguments, and optionsTerminal parser
Command spelling, aliases, usage, help, and completionTerminal command registry
Reading a line and surviving recoverable failuresREPL loop
Frida attachment detailsInfrastructure adapter

The registry is therefore not a service locator. It should not be a globally accessible object from which arbitrary code looks up arbitrary dependencies. Instead, the composition root constructs it once from explicit command definitions and injects it into the CLI and REPL adapters.

Before implementing that adapter, review Node’s precise completer contract.

Readline | Node.js v26.8.2 Documentation

Read the Node.js readline documentation to confirm the contract between your registry and the terminal. The important point is that Readline asks for candidates and the exact substring it may replace; it does not need to know anything about your application handlers.

In the section “Use of the completer function,” read the completer contract, including its small filtering example. Then inspect the completer entry in the createInterface options list immediately below the interface-options material. Notice that a completer may be asynchronous, but start with a synchronous registry because command names and option names are already local, static metadata.

A good initial rule is: Tab completion is best-effort and must be cheap. Do not enumerate processes or contact a target process merely because the user pressed Tab. Dynamic completion can be added later through carefully bounded command-specific providers.


Describe commands as immutable data plus a narrow execution seam

Begin with types for terminal-facing command metadata. Reuse the parsed-command type from the earlier parsing lesson; its exact filename and option representation may differ in your project.

// src/interfaces/terminal/commands/command-definition.ts

import type { ParsedTerminalCommand } from "../parsing/parse-terminal-command.js";

export type ReplDisposition = "continue" | "exit";

export type CommandHelp = Readonly<{
  usage: string;
  summary: string;
}>;

export type CommandCompletionContext = Readonly<{
  line: string;
  currentToken: string;
}>;

export type CommandInteraction = Readonly<{
  renderHelp(targetName: string | undefined): void;
}>;

export type TerminalCommandDefinition = Readonly<{
  /**
   * Canonical spelling used in help output and documentation.
   */
  name: string;

  /**
   * Alternative spellings accepted by the registry.
   */
  aliases: readonly string[];

  help: CommandHelp;

  /**
   * Optional, local, inexpensive suggestions for arguments or options.
   * The registry supplies top-level command-name completion itself.
   */
  complete?(
    context: CommandCompletionContext,
  ): readonly string[];

  /**
   * A terminal adapter operation. Factories create this closure by
   * explicitly receiving the application handler it needs.
   */
  execute(
    command: ParsedTerminalCommand,
    interaction: CommandInteraction,
  ): Promise<ReplDisposition>;
}>;

There are a few deliberate design choices here:

  • name, aliases, and help are data, not methods that generate ad hoc strings. This makes help deterministic and straightforward to test.
  • help.usage includes the command name, for example attach --pid <pid>. A usage line should be copyable as-is.
  • execute() receives an already parsed terminal command. It does not receive stdin, readline, process, or a Frida session.
  • complete() is optional. Most early commands need no argument completion. The registry will still complete every command name and alias.
  • A command can return "exit" without making .exit an application use case. Exiting the REPL is a terminal lifecycle decision.

For now, descriptors can be created in each terminal-command module. Here are two built-in REPL commands:

// src/interfaces/terminal/commands/help-command.ts

import type {
  ReplDisposition,
  TerminalCommandDefinition,
} from "./command-definition.js";

export const helpCommand: TerminalCommandDefinition = {
  name: ".help",
  aliases: [".h"],

  help: {
    usage: ".help [command]",
    summary: "Show available commands or help for one command.",
  },

  async execute(command, interaction): Promise<ReplDisposition> {
    const targetName = command.arguments.at(0);

    interaction.renderHelp(targetName);

    return "continue";
  },
};
// src/interfaces/terminal/commands/exit-command.ts

import type {
  ReplDisposition,
  TerminalCommandDefinition,
} from "./command-definition.js";

export const exitCommand: TerminalCommandDefinition = {
  name: ".exit",
  aliases: [".quit", ".q"],

  help: {
    usage: ".exit",
    summary: "Close the interactive REPL.",
  },

  async execute(): Promise<ReplDisposition> {
    return "exit";
  },
};

This changes one small detail from the previous lesson: .exit no longer needs a hard-coded branch in executeReplIteration(). It is now discoverable through help, accepted through aliases, and offered through completion, while the REPL loop still owns the final action of closing Readline.

An application-backed descriptor is created by a factory that receives only its required handler and renderer. For example, an eventual attach descriptor will capture an AttachToProcessHandler; it will decode the terminal arguments, call that handler, and render its typed result. The registry never has to know how Frida attachment works.


Build the registry with validation at composition time

The registry needs to perform three jobs:

  1. Resolve a canonical name or alias to a command definition.
  2. Return commands in a stable order for help output.
  3. Generate completion candidates without exposing its internal map.

It should reject duplicate names during assembly. A collision such as an alias .h being registered twice is a developer configuration mistake, not an ordinary user error. Fail early while constructing the program.

// src/interfaces/terminal/commands/command-registry.ts

import type {
  CommandCompletionContext,
  TerminalCommandDefinition,
} from "./command-definition.js";

export type CommandRegistryError = Readonly<{
  kind: "duplicate-command-name";
  name: string;
  firstCommand: string;
  secondCommand: string;
}>;

export type CreateCommandRegistryResult =
  | Readonly<{
      ok: true;
      value: CommandRegistry;
    }>
  | Readonly<{
      ok: false;
      error: CommandRegistryError;
    }>;

export type CommandRegistry = Readonly<{
  find(name: string): TerminalCommandDefinition | undefined;
  list(): readonly TerminalCommandDefinition[];
  completion(line: string): readonly string[];
}>;

export function createCommandRegistry(
  definitions: readonly TerminalCommandDefinition[],
): CreateCommandRegistryResult {
  const byName = new Map<string, TerminalCommandDefinition>();

  for (const definition of definitions) {
    const acceptedNames = [definition.name, ...definition.aliases];

    for (const name of acceptedNames) {
      const existing = byName.get(name);

      if (existing !== undefined) {
        return {
          ok: false,
          error: {
            kind: "duplicate-command-name",
            name,
            firstCommand: existing.name,
            secondCommand: definition.name,
          },
        };
      }

      byName.set(name, definition);
    }
  }

  const ordered = [...definitions].sort(compareByName);

  return {
    ok: true,
    value: {
      find(name: string): TerminalCommandDefinition | undefined {
        return byName.get(name);
      },

      list(): readonly TerminalCommandDefinition[] {
        return ordered;
      },

      completion(line: string): readonly string[] {
        return completionCandidates(byName, line);
      },
    },
  };
}

function compareByName(
  left: TerminalCommandDefinition,
  right: TerminalCommandDefinition,
): number {
  if (left.name < right.name) {
    return -1;
  }

  if (left.name > right.name) {
    return 1;
  }

  return 0;
}

function completionCandidates(
  byName: ReadonlyMap<string, TerminalCommandDefinition>,
  line: string,
): readonly string[] {
  const trimmedLeft = line.trimStart();

  if (!containsWhitespace(trimmedLeft)) {
    return matchingCommandNames(byName, trimmedLeft);
  }

  const commandName = firstToken(trimmedLeft);
  const command = byName.get(commandName);

  if (command?.complete === undefined) {
    return [];
  }

  const currentToken = finalWhitespaceDelimitedToken(line);

  const context: CommandCompletionContext = {
    line,
    currentToken,
  };

  return uniqueSorted(
    command
      .complete(context)
      .filter((candidate) => candidate.startsWith(currentToken)),
  );
}

function matchingCommandNames(
  byName: ReadonlyMap<string, TerminalCommandDefinition>,
  prefix: string,
): readonly string[] {
  return uniqueSorted(
    [...byName.keys()].filter((name) => name.startsWith(prefix)),
  );
}

function containsWhitespace(value: string): boolean {
  return /\s/.test(value);
}

function firstToken(value: string): string {
  const index = value.search(/\s/);

  return index === -1 ? value : value.slice(0, index);
}

function finalWhitespaceDelimitedToken(value: string): string {
  const match = value.match(/(?:^|\s)(\S*)$/);

  return match?.[1] ?? "";
}

function uniqueSorted(values: readonly string[]): readonly string[] {
  return [...new Set(values)].sort();
}

The internal Map is mutable during registry construction but never exposed. Consumers receive only find, list, and completion operations. The public types use readonly so callers cannot accidentally modify arrays of aliases or reorder the help listing.

Notice that the top-level candidate list includes aliases. This is useful in a REPL: typing .q should be discoverable if it works. Help output, however, should list only canonical commands, with aliases shown in the detailed view.

The initial finalWhitespaceDelimitedToken() helper is intentionally modest. It supports common option completion such as:

grasp> attach --p<Tab>

It is not a replacement for your quotation-aware REPL parser. Completion happens while a line may be incomplete, including while a quote has not yet been closed. If a future command needs sophisticated path or quoted-value completion, give that command a dedicated complete() implementation using a tolerant lexer. Do not weaken the parser just to make completion convenient.


Render help from registry data

The help formatter should be a pure projection of registry metadata. It should neither execute commands nor call application handlers.

// src/interfaces/terminal/commands/render-command-help.ts

import type { CommandRegistry } from "./command-registry.js";
import type { TerminalCommandDefinition } from "./command-definition.js";

export type HelpRenderResult =
  | Readonly<{
      kind: "rendered";
      lines: readonly string[];
    }>
  | Readonly<{
      kind: "unknown-command";
      lines: readonly string[];
    }>;

export function buildHelp(
  registry: CommandRegistry,
  targetName: string | undefined,
): HelpRenderResult {
  if (targetName !== undefined) {
    const definition = registry.find(targetName);

    if (definition === undefined) {
      return {
        kind: "unknown-command",
        lines: [`Unknown command: ${targetName}`, "Try .help for commands."],
      };
    }

    return {
      kind: "rendered",
      lines: detailLines(definition),
    };
  }

  return {
    kind: "rendered",
    lines: summaryLines(registry.list()),
  };
}

function summaryLines(
  commands: readonly TerminalCommandDefinition[],
): readonly string[] {
  const widestUsage = Math.max(
    ...commands.map((command) => command.help.usage.length),
  );

  return [
    "Commands:",
    ...commands.map((command) =>
      `  ${command.help.usage.padEnd(widestUsage)}  ${command.help.summary}`,
    ),
    "",
    "Use .help <command> for details.",
  ];
}

function detailLines(
  command: TerminalCommandDefinition,
): readonly string[] {
  const aliases =
    command.aliases.length === 0
      ? []
      : [`Aliases: ${command.aliases.join(", ")}`];

  return [
    `Usage: ${command.help.usage}`,
    command.help.summary,
    ...aliases,
  ];
}

With attach, list-processes, .help, and .exit registered, a summary might render as:

Commands:
  .exit                 Close the interactive REPL.
  .help [command]       Show available commands or help for one command.
  attach --pid <pid>    Attach to a local process by process identifier.
  list-processes        List local processes.

Use .help <command> for details.

Because ordering, spacing, and text originate in one place, both one-shot CLI help and REPL help can use the same formatter. This is an important consistency rule: users should not have to learn separate vocabularies for grasp .help and an interactive grasp> .help.


Route parsed input through the registry

Refactor the function from the earlier CLI/REPL-routing lesson so that it resolves a command rather than switching over a fixed list of names.

// src/interfaces/terminal/repl/handle-repl-line.ts

import type { TerminalWriter } from "../shared/terminal-writer.js";
import { parseReplLine } from "../parsing/parse-repl-line.js";
import type { ReplDisposition } from "../commands/command-definition.js";
import type { CommandRegistry } from "../commands/command-registry.js";
import { buildHelp } from "../commands/render-command-help.js";

export async function handleReplLine(
  line: string,
  registry: CommandRegistry,
  terminal: TerminalWriter,
): Promise<ReplDisposition> {
  const parsed = parseReplLine(line);

  if (!parsed.ok) {
    terminal.writeLine(parsed.error.message);
    return "continue";
  }

  const command = registry.find(parsed.value.name);

  if (command === undefined) {
    terminal.writeLine(`Unknown command: ${parsed.value.name}`);
    terminal.writeLine("Try .help for commands.");
    return "continue";
  }

  return command.execute(parsed.value, {
    renderHelp(targetName: string | undefined): void {
      const help = buildHelp(registry, targetName);

      for (const helpLine of help.lines) {
        terminal.writeLine(helpLine);
      }
    },
  });
}

This function still follows the earlier failure policy:

  • A parse failure is a typed, recoverable terminal outcome.
  • An unknown command is a typed, recoverable terminal outcome.
  • Expected application errors are rendered by the relevant command adapter and return "continue".
  • An unexpected rejected Promise escapes to the per-iteration try/catch in runReplLoop().

The only change required in the previous loop is to make its line handler return a disposition.

// src/interfaces/terminal/repl/execute-repl-iteration.ts

import type { ReplDisposition } from "../commands/command-definition.js";

export async function executeReplIteration(
  line: string,
  handleLine: (line: string) => Promise<ReplDisposition>,
): Promise<ReplDisposition> {
  if (line.trim().length === 0) {
    return "continue";
  }

  return handleLine(line);
}

Then runReplLoop() checks for "exit" after each completed iteration, writes its closing message, and returns. The loop no longer recognizes .exit itself; that knowledge has moved to an explicit terminal command definition.


Connect the registry to Node Readline completion

Node’s completer option receives the full current line and returns a pair:

  1. Candidate strings.
  2. The current substring that those candidates should replace.

Your registry already owns the first part. The Readline adapter only adapts it to Node’s tuple format.

// src/interfaces/terminal/repl/run-interactive-repl.ts

import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";

import type { CommandRegistry } from "../commands/command-registry.js";

function completionSubstring(line: string): string {
  const trimmedLeft = line.trimStart();

  if (!/\s/.test(trimmedLeft)) {
    return trimmedLeft;
  }

  const match = line.match(/(?:^|\s)(\S*)$/);

  return match?.[1] ?? "";
}

export async function runInteractiveRepl(
  registry: CommandRegistry,
): Promise<void> {
  const rl = createInterface({
    input: stdin,
    output: stdout,
    terminal: true,
    historySize: 100,
    removeHistoryDuplicates: true,

    completer(line: string): [readonly string[], string] {
      return [
        registry.completion(line),
        completionSubstring(line),
      ];
    },
  });

  try {
    // Call the runReplLoop implementation from the previous lesson.
    // Its handleLine closure invokes handleReplLine(line, registry, terminal).
  } finally {
    rl.close();
  }
}

Try these interactions manually once the adapter is wired:

grasp> .<Tab>

Candidates should include .exit, .h, .help, .q, and .quit.

grasp> att<Tab>

The candidate should be attach.

grasp> .help att<Tab>

This will not initially complete attach, because .help has no argument completer yet. That is acceptable. Completion should evolve command by command, rather than relying on speculative generic behavior that mishandles quotes or causes terminal-side I/O.

For a command with static options, a local provider is straightforward:

complete({ currentToken }): readonly string[] {
  const options = ["--pid"];

  return options.filter((option) =>
    option.startsWith(currentToken),
  );
},

Keep this provider static. Do not make Tab enumerate PIDs, modules, exports, or registry keys once those features arrive. Those operations can be slow, surprising, privacy-sensitive, or unsafe against an unstable target process.


Compose explicitly and test the registry as a unit

The composition root imports concrete definitions, creates the registry once, validates it, and injects it. It does not expose a global getCommandRegistry() function.

// src/main/composition-root.ts

import { createCommandRegistry } from "../interfaces/terminal/commands/command-registry.js";
import { attachCommand } from "../interfaces/terminal/commands/attach-command.js";
import { exitCommand } from "../interfaces/terminal/commands/exit-command.js";
import { helpCommand } from "../interfaces/terminal/commands/help-command.js";
import { listProcessesCommand } from "../interfaces/terminal/commands/list-processes-command.js";

const registryResult = createCommandRegistry([
  helpCommand,
  exitCommand,
  attachCommand,
  listProcessesCommand,
]);

if (!registryResult.ok) {
  throw new Error(
    [
      `Duplicate terminal command name: ${registryResult.error.name}`,
      `Used by: ${registryResult.error.firstCommand}`,
      `And: ${registryResult.error.secondCommand}`,
    ].join(" "),
  );
}

export const commandRegistry = registryResult.value;

At startup, throwing for impossible static wiring is reasonable: a user cannot correct a source-code collision from the terminal. In contrast, an unknown command entered during a REPL session remains a normal recoverable outcome.

Registry tests need no TTY, no readline, and no Frida process.

// src/interfaces/terminal/commands/command-registry.test.ts

import { describe, expect, it } from "vitest";

import type { TerminalCommandDefinition } from "./command-definition.js";
import { createCommandRegistry } from "./command-registry.js";

function command(
  name: string,
  aliases: readonly string[] = [],
): TerminalCommandDefinition {
  return {
    name,
    aliases,
    help: {
      usage: name,
      summary: `${name} summary`,
    },
    async execute() {
      return "continue";
    },
  };
}

describe("createCommandRegistry", () => {
  it("resolves canonical names and aliases", () => {
    const result = createCommandRegistry([
      command(".help", [".h"]),
      command("attach"),
    ]);

    if (!result.ok) {
      throw new Error("Expected a valid registry.");
    }

    expect(result.value.find(".help")?.name).toBe(".help");
    expect(result.value.find(".h")?.name).toBe(".help");
    expect(result.value.find("missing")).toBeUndefined();
  });

  it("returns sorted matching completion candidates", () => {
    const result = createCommandRegistry([
      command("list-processes"),
      command(".exit", [".quit", ".q"]),
      command("attach"),
    ]);

    if (!result.ok) {
      throw new Error("Expected a valid registry.");
    }

    expect(result.value.completion("a")).toEqual(["attach"]);
    expect(result.value.completion(".q")).toEqual([".q", ".quit"]);
    expect(result.value.completion("attach --")).toEqual([]);
  });

  it("rejects collisions between names and aliases", () => {
    const result = createCommandRegistry([
      command(".help", [".h"]),
      command(".history", [".h"]),
    ]);

    expect(result).toEqual({
      ok: false,
      error: {
        kind: "duplicate-command-name",
        name: ".h",
        firstCommand: ".help",
        secondCommand: ".history",
      },
    });
  });
});

These are deterministic contract tests. They protect behavior that becomes important as the registry grows: aliases keep working, help remains stably ordered, and a newly added command cannot silently shadow an established command.


Key takeaways

A command registry gives the REPL an extensible vocabulary without coupling it to application or Frida concerns:

  • A terminal command definition combines immutable name, alias, help, and completion metadata with a narrow execution adapter.
  • The registry resolves names, rejects duplicate registrations, supplies stable help data, and produces inexpensive local completion candidates.
  • .help and .exit are now explicit terminal commands, making them discoverable and completable while preserving the REPL loop’s ownership of lifecycle.
  • Node Readline’s completer only adapts registry candidates into its required tuple format.
  • Completion is best-effort: start with static command and option names, and avoid target-process or network work on Tab.
  • The composition root builds and injects the registry explicitly, rather than turning it into a global service locator.

Next, you will add cancellation semantics so Ctrl+C can interrupt a running command without terminating the entire REPL.

Can't find a good explanation? Sign up and we'll make it for you

Sign up