How-to › Ship a CLI or REPL

How to turn an exploratory REPL session into a script#

Convert a REPL history into a script that runs, by dropping the lines that threw, the expressions typed to look at a value, and the REPL's own commands.

Audience
API consumer
Level
beginner
Topic
Explore an API interactively
Languages
TypeScript and JavaScript
Verified
Tags

You worked out how to call an API in the REPL, it took twenty minutes, and the useful part is fourteen lines somewhere in a history of forty. Tomorrow you will do it again from memory, get a field name wrong the same way you did the first time, and spend another twenty minutes.

What you get

You will end up with a script built from the session, with the failed attempts, and the lines you typed only to look at something, removed. This is for you if you explore APIs interactively and lose the result.

Short answer

Keep the declarations and the calls. Drop three things: any line that threw when you typed it, any bare expression typed only to see a value, and the REPL’s own dot commands. Where a name was declared twice, keep the later version in the position the first one held. Then read the result, because a working session is not the same as a script you would commit.

You will need

Node 22 or later, and a REPL session whose history you kept. Node writes one by default to the path NODE_REPL_HISTORY names,1 and .save writes the current session to a file if you remember to run it before you exit. Python’s readline history works the same way, and so does the conversion.2

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
.save in the Node REPLYou remember to run it, and the session was mostly cleanEverything you typed, failures included, so the file rarely runs as writtenThe session had false starts, which is most sessions
Converting the history fileAny session, including the ones you did not plan to keepA conversion that guesses, and output you have to read before trustingYou want the script to be what you would have written
Copying from the terminalThree or four lines that you can see on one screenPrompts and output mixed into the code, removed by handThe session is longer than a screen
Writing the script from scratchThe exploration taught you something and the code is worth rewritingThe twenty minutes again, from a memory that is already fadingYou want the thing that worked, verbatim

The conversion is a first draft and it is worth being clear about that. What it gives you is every line that ran, with the noise gone, which is a much better starting point than a blank file and a memory. What it does not give you is a script you should commit unread.

.save and a history conversion differ in when you have to have decided.3 .save needs the thought before you exit; a history file is there whether or not you planned anything. Given that the whole situation starts with an unplanned session, the second is the one that works in practice.

Drop the three kinds of noise

Three rules, each aimed at a different thing a REPL collects.

if (line.startsWith('.')) {
  dropped.push({ line, why: 'REPL command' })
  continue
}
if (ran(line) === false) {
  dropped.push({ line, why: 'threw when it was typed' })
  continue
}
if (isBareExpression(line)) {
  dropped.push({ line, why: 'typed to look at a value' })
  continue
}

Report what was dropped and why. A conversion that silently removes a line you needed is worse than one that keeps too much, and the list is how you check its work in five seconds.

Bare expressions are the interesting case. In a REPL, typing page.data.length prints a number and is the point of typing it. In a script it computes a value and discards it, which is dead code that looks like an intention.

Keep the version that worked

A name declared twice keeps its later definition.

function dedupe(lines) {
  const byName = new Map()
  const order = []
  for (const line of lines) {
    const name = /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line)?.[1]
    if (!name) { order.push({ line }); continue }
    if (byName.has(name)) byName.get(name).line = line
    else {
      const entry = { line }
      byName.set(name, entry)
      order.push(entry)
    }
  }
  return order.map((e) => e.line)
}

Keeping the first position and the last definition is what makes the result run. A redeclaration moved to the end would leave every reference between the two reading an older value. In a module a second const with the same name is a syntax error rather than a shadow.

Check it worked

Convert a session that had three false starts in it.

node demo.mjs
dropped
  threw when it was typed    res.staus
  typed to look at a value   res.status
  threw when it was typed    page.data.lenght
  typed to look at a value   page.data.length
  typed to look at a value   ids
  threw when it was typed    const serials = page.data.map(m => m.serail)
  REPL command               .exit

script
  const base = 'http://127.0.0.1:0'
  const key = 'sk_live_1'
  await fetch(base + '/meters')
  const res = await fetch(base + '/meters', { headers: { authorization: 'Bearer ' + key } })
  const page = await res.json()
  const ids = page.data.map(m => m.id)
  const serials2 = page.data.map(m => m.serial)
  console.log(ids, serials2)

Eight lines from a history of fifteen, and the three typos are gone. Read the script before you keep it, because it still carries the evidence of how it was made. Line three is an unauthenticated probe from when you were checking the endpoint existed. Line two holds a live key that belongs in the environment. The last variable is called serials2 because the first attempt failed.

The dropped list is the other half of the evidence. It gives the reason for each of the seven lines that went. A conversion you cannot audit is a conversion you will stop using the first time it removes something you wanted.

node --test convert.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 110.048599

When it goes wrong

The script fails on a name that does not exist. A declaration was typed as a bare expression, or the REPL had it from an earlier session. Run the script in a clean process before you trust it. A REPL carries everything you ever typed, and a script carries only what you kept.

Multiline input becomes several broken lines. History files store what you typed per line, and a function body spans several. Join continuation lines before converting, or keep multiline work in a file from the start. A function you are still shaping belongs in an editor, and the REPL is for calling it.

A credential ends up in the script. You typed it in the REPL, so it is in the history file too. Move it to an environment variable, and consider whether the history file itself needs deleting.

Nothing survives the conversion. The session was all inspection and no declarations. That is a finding about the session rather than the tool. It usually means the work was reading rather than building, and there was nothing to save.

When not to do this

Do not commit a converted session without reading it. The output is a transcript of how you learned, and a transcript is not a design. Rename the variables, remove the exploratory calls, and it becomes a script somebody else can read. Ten minutes of that is the difference between a note to yourself and something the team can use.

Do not convert a session that touched production. The lines that ran are exactly the lines you would rather not re-run by accident, and a file makes that one keystroke away. Convert it, read it, then decide whether it should exist at all.

Do not treat the history file as private. It is a plain file, it is often backed up, and it holds every key you pasted while exploring.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The default file is .node_repl_history in the home directory, and NODE_REPL_HISTORY_SIZE keeps the last 1000 lines of it. Setting NODE_REPL_HISTORY to an empty string switches the history off, except on Windows, where an environment variable with an empty value is invalid. The documentation’s remedy is to set it to one or more spaces, and it offers this without further comment. ↩︎ Back to text

  2. Python has saved its history to ~/.python_history since 3.4, when the site module made the behavior automatic. The readline module adds a note for macOS, where the library underneath may be libedit rather than GNU readline. The two may use different history file formats, and switching between them may leave the existing file unusable. The same way, then, up to the point where it is not. ↩︎ Back to text

  3. The Node REPL has seven special commands: .break, .clear, .exit, .help, .save, .load and .editor, and one of the seven exists to show the list of the other six. .save and .load are documented as a pair, each with a one-line example. .editor gets the longest entry, in which a function greets a Node.js user and Ctrl+D finishes the job. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.