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:
| Concern | One-shot CLI | Interactive REPL |
|---|---|---|
| Raw input source | process.argv | A line read from readline |
| Who handles quotes? | PowerShell or cmd.exe before Node starts | Your REPL tokenizer |
| Invocation lifetime | One command, then the program ends | Many commands in one process |
| Application handler | AttachToProcessHandler | AttachToProcessHandler |
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.

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:
TerminalCommandrepresents a parsed terminal invocation.AttachToProcessInputrepresents 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:
- REPL-only lexical stage: raw line to tokens.
- 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:
- identifies the terminal command;
- decodes its terminal syntax into typed application input;
- invokes the application handler;
- 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:
| Component | Knows about | Must not know about |
|---|---|---|
parseReplLine() | raw REPL text and REPL quoting | sessions, Frida, terminal rendering |
parseOneShotArgv() | argument tokens from Node | sessions, Frida, output text |
dispatchTerminalCommand() | names such as attach, decoders, handler references | readline, process.argv, Frida concrete objects |
decodeAttachCommand() | --pid, positional PID syntax | rendering, active-session rules |
AttachToProcessHandler | typed ProcessId, ports, session rules | command names, options, terminal APIs |
| Renderer | user-facing terminal strings | attachment 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 code | Meaning |
|---|---|
0 | Command completed successfully |
1 | The command was understood but failed operationally |
2 | Command 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
AttachToProcessHandlerinstance.
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:
- CLI and REPL parsing produce equivalent terminal commands for equivalent input.
- 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
readlineinterface; - 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