Create your own
Lesson illustration

Unifying CLI and REPL Command Handling

Good to see you again. In the previous lesson, you separated terminal syntax from application behavior: a REPL line became a structured command, a terminal decoder produced typed use-case input, and an application handler returned typed results rather than terminal text.

Now we make that separation pay off. Your tool will support both of these forms:

grasp attach --pid=8420
grasp> attach --pid=8420

They originate from different places, but they must invoke the same AttachToProcessHandler. There should not be a “CLI attach implementation” and a separate “REPL attach implementation.”

The goal is not merely to avoid duplicate code. It is to make every application behavior consistent, testable, and extensible regardless of whether it is initiated by a one-shot command, an interactive prompt, or eventually another interface.


Two inbound adapters, one application behavior

A one-shot CLI and a REPL have different responsibilities at the boundary:

ConcernOne-shot CLIInteractive REPL
Raw input sourceprocess.argvA line read from readline
Who handles quotes?PowerShell or cmd.exe before Node startsYour REPL tokenizer
Invocation lifetimeOne command, then the program endsMany commands in one process
Application handlerAttachToProcessHandlerAttachToProcessHandler

The difference ends at the terminal boundary. Once each input is represented as a typed terminal command, both paths should use the same decoder and handler.

This diagram depicts the application core receiving requests from several inbound adapters, including a REST API, CLI, and message queue. In this tool, the one-shot CLI and the REPL are separate inbound adapters that both invoke the same application handlers through input ports.

The terminal does not own the attachment rule, session-conflict rule, or Frida operation. It only translates a user’s invocation into a request that the application understands.

This is related to the useful part of the Command pattern: different invokers can trigger one operation without embedding that operation in each invoker.

Command Pattern - Design Patterns

Watch “Command Pattern - Design Patterns” from Web Dev Simplified for a compact illustration of several UI entry points invoking one shared command object. The video uses a text-editor button and keyboard shortcut; treat those as counterparts to your CLI and REPL.

Watch shared invocation. Focus on the separation between the invoker and the command’s behavior. The video’s undo capability is not a requirement for your application handlers; the relevant idea is that a button and shortcut should not each reimplement bolding, just as the CLI and REPL should not each reimplement attachment.

For this project, avoid interpreting “Command pattern” too literally. You do not need a generic command bus, undo stack, or reflection-based dispatcher. A small, explicit routing function and typed handlers are sufficient.


Keep one terminal-command representation

In the previous lesson, the REPL parser produced this shape:

type ReplCommand = Readonly<{
  name: CommandName;
  args: readonly string[];
  options: Readonly<Record<OptionName, OptionValue>>;
}>;

The data itself is not inherently REPL-specific. Only its source is. Rename or relocate the type so it communicates its real role:

// src/interfaces/terminal/shared/terminal-command.ts

export type TerminalCommand = Readonly<{
  name: CommandName;
  args: readonly string[];
  options: Readonly<Record<OptionName, OptionValue>>;
}>;

You may retain a temporary alias while refactoring:

export type ReplCommand = TerminalCommand;

The important decision is conceptual:

  • TerminalCommand represents a parsed terminal invocation.
  • AttachToProcessInput represents application intent.
  • They are intentionally different types.

Do not make TerminalCommand an application-layer type. It still contains terminal conventions such as command names, positional arguments, and long options. The application should continue receiving:

type AttachToProcessInput = Readonly<{
  processId: ProcessId;
}>;

Share option parsing, not raw-text tokenization

The REPL receives one raw string:

attach --pid=8420

It must tokenize the string because it owns REPL quote handling. A one-shot CLI receives an array from Node:

process.argv.slice(2);
// ["attach", "--pid=8420"]

On Windows, PowerShell or cmd.exe has already interpreted shell quoting before Node sees process.argv. Therefore, do not run your REPL quote tokenizer over process.argv; that would make the shell parse quotes once and your program parse them a second time.

Instead, split the existing REPL parser into two stages:

  1. REPL-only lexical stage: raw line to tokens.
  2. Shared terminal-command stage: tokens to TerminalCommand.
// src/interfaces/terminal/shared/parse-terminal-tokens.ts

export function parseTerminalTokens(
  tokens: readonly string[],
): Result<TerminalCommand, CommandSyntaxError> {
  // Move the existing logic that:
  // - identifies the command name
  // - separates positional arguments
  // - recognizes --name and --name=value options
  // - rejects duplicate or malformed options
}

Your REPL parser becomes a small composition of the two stages:

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

export function parseReplLine(
  rawLine: string,
): Result<TerminalCommand, TerminalParseError> {
  const tokens = tokenizeReplLine(rawLine);

  if (!tokens.ok) {
    return tokens;
  }

  return parseTerminalTokens(tokens.value);
}

The one-shot CLI parser has no quote-tokenization responsibility:

// src/interfaces/terminal/cli/parse-one-shot-argv.ts

export function parseOneShotArgv(
  argv: readonly string[],
): Result<TerminalCommand, TerminalParseError> {
  return parseTerminalTokens(argv);
}

This gives both interfaces the same option grammar. For example, if the shared parser recognizes --pid=8420, both forms work consistently:

grasp attach --pid=8420
grasp> attach --pid=8420

Likewise, a duplicate option or unsupported option receives the same command-usage outcome in either mode.

Why not use a separate CLI parser library here?

Node’s util.parseArgs and libraries such as Commander are useful tools. But introducing a second parser after implementing a REPL command grammar creates an immediate maintenance hazard:

  • one parser might accept --pid 8420, while the other requires --pid=8420;
  • one might allow duplicate options and the other might reject them;
  • errors would be formatted and classified differently;
  • every new command syntax change would need two updates.

A dedicated parser may become appropriate later if you deliberately adopt a complete command-specification layer. For the current small tool, your shared token parser is the clearest way to guarantee behavioral parity.


Put routing in a thin terminal adapter

You already have an AttachToProcessHandler and a decodeAttachCommand() function. The remaining task is to create one routing function that:

  1. identifies the terminal command;
  2. decodes its terminal syntax into typed application input;
  3. invokes the application handler;
  4. returns a typed terminal-facing outcome.

This router belongs in the terminal interface layer. It is not part of the application layer because it knows command names such as "attach".

// src/interfaces/terminal/shared/dispatch-terminal-command.ts

import type {
  AttachToProcessError,
  AttachToProcessHandler,
  AttachToProcessOutput,
} from "../../../features/instrumentation-session/application/attach-to-process.js";
import type { Result } from "../../../shared/result.js";
import type { TerminalCommand } from "./terminal-command.js";
import {
  decodeAttachCommand,
  type CommandUsageError,
} from "../commands/decode-attach-command.js";

export type TerminalCommandServices = Readonly<{
  attachToProcess: AttachToProcessHandler;
}>;

export type TerminalCommandOutcome =
  | Readonly<{
      kind: "unknown-command";
      commandName: string;
    }>
  | Readonly<{
      kind: "usage-error";
      error: CommandUsageError;
    }>
  | Readonly<{
      kind: "attach-finished";
      result: Result<AttachToProcessOutput, AttachToProcessError>;
    }>;

export async function dispatchTerminalCommand(
  command: TerminalCommand,
  services: TerminalCommandServices,
): Promise<TerminalCommandOutcome> {
  switch (command.name) {
    case "attach":
      return dispatchAttach(command, services.attachToProcess);

    default:
      return {
        kind: "unknown-command",
        commandName: command.name,
      };
  }
}

async function dispatchAttach(
  command: TerminalCommand,
  handler: AttachToProcessHandler,
): Promise<TerminalCommandOutcome> {
  const input = decodeAttachCommand(command);

  if (!input.ok) {
    return {
      kind: "usage-error",
      error: input.error,
    };
  }

  return {
    kind: "attach-finished",
    result: await handler.execute(input.value),
  };
}

The deliberate switch is appropriate at this stage. It is explicit, type-friendly, and easy to inspect. The later command-registry lesson will replace this fixed routing mechanism with an extensible registration model that can provide help and completion data. Do not prematurely introduce a global service locator to solve a problem that does not yet exist.

Notice the exact boundary responsibilities:

ComponentKnows aboutMust not know about
parseReplLine()raw REPL text and REPL quotingsessions, Frida, terminal rendering
parseOneShotArgv()argument tokens from Nodesessions, Frida, output text
dispatchTerminalCommand()names such as attach, decoders, handler referencesreadline, process.argv, Frida concrete objects
decodeAttachCommand()--pid, positional PID syntaxrendering, active-session rules
AttachToProcessHandlertyped ProcessId, ports, session rulescommand names, options, terminal APIs
Rendereruser-facing terminal stringsattachment decisions

Reuse dispatch and rendering for both entry points

The next useful extraction is a function that handles a command that has already parsed successfully. Both entry points call it.

// src/interfaces/terminal/shared/execute-and-render.ts

export type TerminalWriter = Readonly<{
  writeLine(line: string): void;
}>;

export async function executeAndRenderTerminalCommand(
  command: TerminalCommand,
  services: TerminalCommandServices,
  terminal: TerminalWriter,
): Promise<number> {
  const outcome = await dispatchTerminalCommand(command, services);

  terminal.writeLine(renderTerminalCommandOutcome(outcome));

  return exitCodeFor(outcome);
}

The renderer remains a terminal concern. It can reuse the renderAttachOutcome() function from the prior lesson for the application result:

export function renderTerminalCommandOutcome(
  outcome: TerminalCommandOutcome,
): string {
  switch (outcome.kind) {
    case "unknown-command":
      return `Unknown command: ${outcome.commandName}. Type help for commands.`;

    case "usage-error":
      return renderCommandUsageError(outcome.error);

    case "attach-finished":
      return renderAttachOutcome(outcome.result);
  }
}

An exit-code policy is meaningful for a one-shot CLI but harmless for the REPL, which can simply ignore the returned value:

export function exitCodeFor(
  outcome: TerminalCommandOutcome,
): number {
  switch (outcome.kind) {
    case "usage-error":
    case "unknown-command":
      return 2;

    case "attach-finished":
      return outcome.result.ok ? 0 : 1;
  }
}

A useful convention is:

Exit codeMeaning
0Command completed successfully
1The command was understood but failed operationally
2Command syntax or usage was invalid

This distinction is valuable when the tool is later used from PowerShell scripts or automated test harnesses.


One-shot CLI entry point

The one-shot entry point has only three jobs:

  • take tokens from process.argv;
  • parse them;
  • pass the parsed command to the shared execution path.
// src/interfaces/terminal/cli/main.ts

export async function runOneShotCli(
  argv: readonly string[],
  services: TerminalCommandServices,
  terminal: TerminalWriter,
): Promise<number> {
  const parsed = parseOneShotArgv(argv);

  if (!parsed.ok) {
    terminal.writeLine(renderTerminalParseError(parsed.error));
    return 2;
  }

  return executeAndRenderTerminalCommand(
    parsed.value,
    services,
    terminal,
  );
}

At the Node process boundary:

const exitCode = await runOneShotCli(
  process.argv.slice(2),
  terminalCommandServices,
  stdoutTerminalWriter,
);

process.exitCode = exitCode;

Set process.exitCode instead of calling process.exit(). Explicit termination can cut off pending output and will become increasingly risky once the application owns session cleanup, agent unloading, and recorder shutdown.

For now, this provides the intended shape:

grasp attach 8420
grasp attach --pid=8420

Both forms become a TerminalCommand, then AttachToProcessInput, then one call to AttachToProcessHandler.execute().


REPL-facing command handling

You have not built the full asynchronous loop yet; that is the next lesson. But its command-execution function should already be straightforward:

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

export async function handleReplLine(
  line: string,
  services: TerminalCommandServices,
  terminal: TerminalWriter,
): Promise<void> {
  const parsed = parseReplLine(line);

  if (!parsed.ok) {
    terminal.writeLine(renderTerminalParseError(parsed.error));
    return;
  }

  await executeAndRenderTerminalCommand(
    parsed.value,
    services,
    terminal,
  );
}

Compare the two entry points carefully:

  • runOneShotCli() obtains pre-tokenized arguments from Node.
  • handleReplLine() obtains a raw line and tokenizes it.
  • Both invoke executeAndRenderTerminalCommand().
  • That shared function invokes dispatchTerminalCommand().
  • The dispatcher invokes the same AttachToProcessHandler instance.

The REPL must not terminate after a recoverable usage error or an expected attachment failure. It renders the outcome and yields control back to the loop. The one-shot CLI returns a meaningful exit code to the host environment.

The application handler itself should not know which mode initiated the request.


Compose one set of handlers explicitly

Both adapters should receive the same handler references from the composition root:

// src/composition-root.ts

const attachToProcess = new DefaultAttachToProcessHandler({
  activeSessionQuery,
  processAttachment,
});

export const terminalCommandServices: TerminalCommandServices = {
  attachToProcess,
};

Then the application bootstrap chooses an adapter:

await runOneShotCli(
  process.argv.slice(2),
  terminalCommandServices,
  stdoutTerminalWriter,
);

Or, once the REPL loop exists:

await runInteractiveRepl(
  terminalCommandServices,
  terminalWriter,
);

This is dependency injection in its simplest and most visible form. The routes receive their dependencies explicitly. No global object needs to locate an "attach" handler dynamically.

A concrete handler can be stateful through its ports and still be safely shared. The terminal adapters do not own the instrumentation state; they merely make requests against the application.


Verify shared behavior with a focused test

A valuable test proves two things:

  1. CLI and REPL parsing produce equivalent terminal commands for equivalent input.
  2. Both commands reach the same handler contract.
import { describe, expect, it, vi } from "vitest";
import { ok } from "../../../shared/result.js";
import { parseOneShotArgv } from "./parse-one-shot-argv.js";
import { parseReplLine } from "../repl/parse-repl-line.js";
import { dispatchTerminalCommand } from "../shared/dispatch-terminal-command.js";
import type { ProcessId } from "../../../features/instrumentation-session/domain/process-id.js";
import type { SessionId } from "../../../features/instrumentation-session/domain/session-id.js";

describe("attach routing", () => {
  it("routes equivalent CLI and REPL invocations to one handler", async () => {
    const processId = 8420 as ProcessId;
    const sessionId = "session-001" as SessionId;

    const attachToProcess = {
      execute: vi.fn(async () =>
        ok({
          sessionId,
          processId,
          state: "attached" as const,
        }),
      ),
    };

    const services = { attachToProcess };

    const cliCommand = parseOneShotArgv([
      "attach",
      "--pid=8420",
    ]);

    const replCommand = parseReplLine(
      "attach --pid=8420",
    );

    expect(cliCommand).toEqual(replCommand);
    expect(cliCommand.ok).toBe(true);

    if (!cliCommand.ok || !replCommand.ok) {
      throw new Error("Expected both command forms to parse.");
    }

    await dispatchTerminalCommand(cliCommand.value, services);
    await dispatchTerminalCommand(replCommand.value, services);

    expect(attachToProcess.execute).toHaveBeenNthCalledWith(
      1,
      { processId },
    );

    expect(attachToProcess.execute).toHaveBeenNthCalledWith(
      2,
      { processId },
    );
  });
});

The test does not require:

  • a real terminal;
  • an active Node readline interface;
  • a Windows target process;
  • Frida;
  • captured stdout.

It is testing routing only. The existing handler tests continue to test application rules, while later adapter tests will test real Frida behavior.


A lifecycle note for future Frida commands

Eventually, attaching to a process creates a real resource: a Frida session and loaded agent. A REPL can retain that session because the host process remains alive. A one-shot CLI must have a clearly bounded lifecycle, such as performing a complete attach-and-inspect operation before cleanup.

That is not a reason to fork the attach behavior now. The application handler should remain shared. The difference belongs to an outer lifecycle policy that will later ensure sessions, probes, agents, and recordings are cleaned up reliably before a one-shot process finishes.


Key takeaways

A one-shot CLI and an interactive REPL are distinct inbound adapters, not distinct implementations of application behavior.

  • Normalize both inputs into the same terminal command representation.
  • Let the REPL own raw-line tokenization; let the CLI use the already tokenized process.argv.
  • Reuse a shared token-to-command parser so option syntax behaves consistently.
  • Route parsed commands through one explicit dispatcher.
  • Decode terminal syntax outside the application handler.
  • Supply the same application-handler instances from the composition root.
  • Let one-shot mode use exit codes, while the REPL renders recoverable failures and remains active.

Next, you will build the asynchronous readline/promises loop that repeatedly calls this REPL-facing command path without collapsing when a user enters an invalid or operationally unsuccessful command.

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

Sign up